Compare commits

2 Commits

Author SHA1 Message Date
998e9a8ab8 Rename project to RealCV with new logo and font updates
- Rename all TrueCV references to RealCV across the codebase
- Add new transparent RealCV logo
- Switch from JetBrains Mono to Inter font for better number clarity
- Update solution, project files, and namespaces

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:07:20 +00:00
28d7d41b25 feat: Add Stripe payment integration and subscription management
- Add Stripe.net SDK for payment processing
- Implement StripeService with checkout sessions, customer portal, webhooks
- Implement SubscriptionService for quota management
- Add quota enforcement to CVCheckService
- Create Pricing, Billing, Settings pages
- Add checkout success/cancel pages
- Update Check and Dashboard with usage indicators
- Add ResetMonthlyUsageJob for billing cycle resets
- Add database migration for subscription fields

Plan tiers: Free (3 checks), Professional £49/mo (30), Enterprise £199/mo (unlimited)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:03:24 +00:00
134 changed files with 3550 additions and 644 deletions

447
DEPLOYMENT-GUIDE.md Normal file
View File

@@ -0,0 +1,447 @@
# TrueCV Production Deployment Guide
A low-budget guide to launching TrueCV as a professional, secure offering.
---
## Budget Summary
| Component | Monthly Cost | Notes |
|-----------|-------------|-------|
| Azure App Service (B1) | ~£10 | 1.75GB RAM, custom domain, SSL |
| Azure SQL (Basic) | ~£4 | 2GB, 5 DTUs - upgrade as needed |
| Azure Blob Storage | ~£1 | Pay per GB stored |
| Domain name | ~£10/year | .com or .co.uk |
| **Total** | **~£15-20/month** | Scales with usage |
Alternative: A single £5-10/month VPS (Hetzner, DigitalOcean) can run everything if you're comfortable with Linux administration.
---
## Phase 1: Pre-Launch Checklist
### 1.1 Stripe Setup (Required for Payments)
1. Create a Stripe account at [stripe.com](https://stripe.com)
2. Complete business verification (required for live payments)
3. Create two Products in Stripe Dashboard:
- **Professional**: £49/month recurring
- **Enterprise**: £199/month recurring
4. Copy the Price IDs (start with `price_`) to your config
5. Configure the Customer Portal:
- Dashboard → Settings → Billing → Customer Portal
- Enable: Update payment methods, Cancel subscriptions, View invoices
6. Set up webhook endpoint (after deployment):
- Dashboard → Developers → Webhooks → Add endpoint
- URL: `https://yourdomain.com/api/stripe/webhook`
- Events: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_failed`
7. Copy the webhook signing secret to your config
### 1.2 External API Keys
| Service | Purpose | How to Get |
|---------|---------|-----------|
| Companies House | Company verification | [developer.company-information.service.gov.uk](https://developer.company-information.service.gov.uk) - Free |
| Anthropic (Claude) | CV parsing & analysis | [console.anthropic.com](https://console.anthropic.com) - Pay per use |
### 1.3 Domain & Email
1. Register a domain (Cloudflare, Namecheap, or similar)
2. Set up a professional email:
- Budget option: Zoho Mail free tier (5 users)
- Better option: Google Workspace (£5/user/month)
3. Configure SPF, DKIM, DMARC records for email deliverability
---
## Phase 2: Infrastructure Setup
### Option A: Azure (Recommended for .NET)
#### App Service + Azure SQL
```bash
# Install Azure CLI, then:
az login
# Create resource group
az group create --name truecv-prod --location uksouth
# Create App Service plan (B1 = ~£10/month)
az appservice plan create \
--name truecv-plan \
--resource-group truecv-prod \
--sku B1 \
--is-linux
# Create web app
az webapp create \
--name truecv-app \
--resource-group truecv-prod \
--plan truecv-plan \
--runtime "DOTNETCORE:8.0"
# Create SQL Server
az sql server create \
--name truecv-sql \
--resource-group truecv-prod \
--location uksouth \
--admin-user truecvadmin \
--admin-password <STRONG_PASSWORD>
# Create database (Basic = ~£4/month)
az sql db create \
--name truecv-db \
--server truecv-sql \
--resource-group truecv-prod \
--service-objective Basic
# Create storage account for CV files
az storage account create \
--name truecvstorage \
--resource-group truecv-prod \
--location uksouth \
--sku Standard_LRS
```
#### Environment Variables (App Service Configuration)
Set these in Azure Portal → App Service → Configuration → Application settings:
```
ConnectionStrings__DefaultConnection=Server=truecv-sql.database.windows.net;Database=truecv-db;User Id=truecvadmin;Password=<PASSWORD>;Encrypt=True;
ConnectionStrings__HangfireConnection=<SAME_AS_ABOVE>
Stripe__SecretKey=sk_live_xxx
Stripe__PublishableKey=pk_live_xxx
Stripe__WebhookSecret=whsec_xxx
Stripe__PriceIds__Professional=price_xxx
Stripe__PriceIds__Enterprise=price_xxx
Anthropic__ApiKey=sk-ant-xxx
CompaniesHouse__ApiKey=xxx
AzureBlob__ConnectionString=<FROM_STORAGE_ACCOUNT>
AzureBlob__ContainerName=cvfiles
```
### Option B: VPS (Budget Alternative)
A £5-10/month VPS from Hetzner, DigitalOcean, or Vultr can run everything:
1. Ubuntu 22.04 LTS
2. Install Docker and Docker Compose
3. Use the existing `docker-compose.yml` with modifications:
```yaml
# docker-compose.prod.yml
version: '3.8'
services:
web:
build: .
ports:
- "5000:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__DefaultConnection=Server=db;Database=TrueCV;User Id=sa;Password=${DB_PASSWORD};TrustServerCertificate=True
depends_on:
- db
restart: always
db:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
- ACCEPT_EULA=Y
- SA_PASSWORD=${DB_PASSWORD}
volumes:
- sqldata:/var/opt/mssql
restart: always
caddy: # Reverse proxy with automatic HTTPS
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
restart: always
volumes:
sqldata:
caddy_data:
```
```
# Caddyfile
yourdomain.com {
reverse_proxy web:5000
}
```
---
## Phase 3: Security Hardening
### 3.1 Application Security (Critical)
#### Secrets Management
- **Never** commit secrets to git
- Use Azure Key Vault or environment variables
- Rotate API keys quarterly
#### HTTPS Enforcement
Already configured in `Program.cs`:
```csharp
app.UseHsts();
app.UseHttpsRedirection();
```
#### Content Security Policy
Add to `Program.cs`:
```csharp
app.Use(async (context, next) =>
{
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
context.Response.Headers.Append("X-Frame-Options", "DENY");
context.Response.Headers.Append("X-XSS-Protection", "1; mode=block");
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
context.Response.Headers.Append("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
await next();
});
```
#### Rate Limiting
Add to `Program.cs`:
```csharp
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: context.User.Identity?.Name ?? context.Request.Headers.Host.ToString(),
factory: _ => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1)
}));
});
// In middleware pipeline
app.UseRateLimiter();
```
### 3.2 Database Security
1. **Use strong passwords** (20+ characters, random)
2. **Enable Azure SQL firewall** - allow only App Service IP
3. **Enable Transparent Data Encryption** (on by default in Azure)
4. **Regular backups** - Azure does this automatically (7-day retention on Basic tier)
### 3.3 File Storage Security
CV files contain sensitive data:
1. **Private container** - never allow public blob access
2. **SAS tokens** - generate time-limited URLs for downloads
3. **Encryption at rest** - enabled by default in Azure Storage
4. **Consider encryption at application level** for extra protection
### 3.4 Authentication Security
Already using ASP.NET Identity with good defaults. Verify these settings:
```csharp
// In Program.cs - identity configuration
builder.Services.Configure<IdentityOptions>(options =>
{
options.Password.RequiredLength = 12;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = true;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.Lockout.MaxFailedAccessAttempts = 5;
});
```
### 3.5 Stripe Webhook Security
Already implemented - verify signature on every webhook:
```csharp
// In StripeService.cs - this is critical
stripeEvent = EventUtility.ConstructEvent(json, signature, _settings.WebhookSecret);
```
---
## Phase 4: Compliance & Legal
### 4.1 GDPR Compliance (Required for UK/EU)
1. **Privacy Policy** - create and link in footer
- What data you collect (CVs, email, payment info)
- How long you retain it
- User rights (access, deletion, portability)
- Third parties (Stripe, Anthropic, Companies House)
2. **Cookie Consent** - add banner for analytics cookies
3. **Data Retention Policy**:
- CVs: Delete after 30 days or on user request
- Accounts: Retain while active, delete 90 days after closure
- Payment data: Stripe handles this (PCI compliant)
4. **Right to Deletion** - implement account deletion feature
5. **Data Processing Agreement** - required if you have business customers
### 4.2 Terms of Service
Cover:
- Service description and limitations
- Acceptable use policy
- Payment terms and refund policy
- Liability limitations
- Dispute resolution
### 4.3 PCI Compliance
Stripe Checkout handles card data - you never touch it. This puts you in **PCI SAQ-A** (simplest level):
- Use only Stripe Checkout or Elements
- Serve pages over HTTPS
- Don't store card numbers
---
## Phase 5: Monitoring & Operations
### 5.1 Application Monitoring
#### Free Option: Application Insights (Azure)
```csharp
// In Program.cs
builder.Services.AddApplicationInsightsTelemetry();
```
#### Budget Option: Seq + Serilog
```csharp
// Already using Serilog - add Seq sink
Log.Logger = new LoggerConfiguration()
.WriteTo.Seq("http://localhost:5341") // Self-hosted Seq
.CreateLogger();
```
### 5.2 Uptime Monitoring
Free options:
- [UptimeRobot](https://uptimerobot.com) - 50 free monitors
- [Freshping](https://freshping.io) - 50 free monitors
Set up alerts for:
- Homepage availability
- API health endpoint
- Webhook endpoint
### 5.3 Error Tracking
Add a health check endpoint:
```csharp
// In Program.cs
app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow }));
```
### 5.4 Backup Strategy
| Component | Backup Method | Frequency |
|-----------|--------------|-----------|
| Database | Azure automated backups | Continuous (7-day retention) |
| CV files | Azure Blob redundancy (LRS) | Automatic |
| Config | Git repository | On change |
For VPS: Set up daily database dumps to offsite storage.
---
## Phase 6: Launch Checklist
### Pre-Launch (1 week before)
- [ ] All environment variables configured
- [ ] Database migrations applied
- [ ] Stripe products created and tested
- [ ] Webhook endpoint configured and tested
- [ ] SSL certificate active
- [ ] Privacy Policy and Terms published
- [ ] Test complete user journey (signup → payment → CV check)
- [ ] Test subscription cancellation flow
- [ ] Error pages customised (404, 500)
### Launch Day
- [ ] Switch Stripe to live mode (change API keys)
- [ ] Verify webhook is receiving live events
- [ ] Monitor error logs closely
- [ ] Test a real payment (refund yourself)
### Post-Launch (First Week)
- [ ] Monitor for errors daily
- [ ] Check Stripe dashboard for failed payments
- [ ] Respond to support queries within 24 hours
- [ ] Gather user feedback
---
## Phase 7: Scaling (When Needed)
Start small and scale based on actual usage:
| Trigger | Action | Cost Impact |
|---------|--------|-------------|
| Response time > 2s | Upgrade App Service to B2/B3 | +£10-30/month |
| Database DTU > 80% | Upgrade to Standard S0 | +£15/month |
| Storage > 5GB | Already pay-per-use | Minimal |
| > 1000 users | Add Redis for caching | +£15/month |
---
## Quick Reference: Configuration Files
### appsettings.Production.json
```json
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "yourdomain.com;www.yourdomain.com"
}
```
Sensitive values should be in environment variables, not this file.
---
## Support & Resources
- [Azure App Service Docs](https://learn.microsoft.com/en-us/azure/app-service/)
- [Stripe Documentation](https://stripe.com/docs)
- [ASP.NET Core Security](https://learn.microsoft.com/en-us/aspnet/core/security/)
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
---
## Estimated Time to Launch
| Phase | Effort |
|-------|--------|
| Stripe setup | 1-2 hours |
| Infrastructure | 2-4 hours |
| Security hardening | 2-3 hours |
| Legal pages | 2-4 hours (use templates) |
| Testing | 2-4 hours |
| **Total** | **1-2 days** |
---
*Document version: 1.0 | Last updated: January 2026*

View File

@@ -3,11 +3,11 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy solution and project files first for better layer caching
COPY TrueCV.sln ./
COPY src/TrueCV.Domain/TrueCV.Domain.csproj src/TrueCV.Domain/
COPY src/TrueCV.Application/TrueCV.Application.csproj src/TrueCV.Application/
COPY src/TrueCV.Infrastructure/TrueCV.Infrastructure.csproj src/TrueCV.Infrastructure/
COPY src/TrueCV.Web/TrueCV.Web.csproj src/TrueCV.Web/
COPY RealCV.sln ./
COPY src/RealCV.Domain/RealCV.Domain.csproj src/RealCV.Domain/
COPY src/RealCV.Application/RealCV.Application.csproj src/RealCV.Application/
COPY src/RealCV.Infrastructure/RealCV.Infrastructure.csproj src/RealCV.Infrastructure/
COPY src/RealCV.Web/RealCV.Web.csproj src/RealCV.Web/
# Restore dependencies
RUN dotnet restore
@@ -16,7 +16,7 @@ RUN dotnet restore
COPY src/ src/
# Build and publish
WORKDIR /src/src/TrueCV.Web
WORKDIR /src/src/RealCV.Web
RUN dotnet publish -c Release -o /app/publish --no-restore
# Runtime stage
@@ -51,4 +51,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
# Start the app
ENTRYPOINT ["dotnet", "TrueCV.Web.dll"]
ENTRYPOINT ["dotnet", "RealCV.Web.dll"]

View File

@@ -7,11 +7,11 @@ RUN dotnet tool install --global dotnet-ef
ENV PATH="$PATH:/root/.dotnet/tools"
# Copy solution and project files
COPY TrueCV.sln ./
COPY src/TrueCV.Domain/TrueCV.Domain.csproj src/TrueCV.Domain/
COPY src/TrueCV.Application/TrueCV.Application.csproj src/TrueCV.Application/
COPY src/TrueCV.Infrastructure/TrueCV.Infrastructure.csproj src/TrueCV.Infrastructure/
COPY src/TrueCV.Web/TrueCV.Web.csproj src/TrueCV.Web/
COPY RealCV.sln ./
COPY src/RealCV.Domain/RealCV.Domain.csproj src/RealCV.Domain/
COPY src/RealCV.Application/RealCV.Application.csproj src/RealCV.Application/
COPY src/RealCV.Infrastructure/RealCV.Infrastructure.csproj src/RealCV.Infrastructure/
COPY src/RealCV.Web/RealCV.Web.csproj src/RealCV.Web/
# Restore dependencies
RUN dotnet restore
@@ -20,7 +20,7 @@ RUN dotnet restore
COPY src/ src/
# Build the project
RUN dotnet build src/TrueCV.Web/TrueCV.Web.csproj -c Release
RUN dotnet build src/RealCV.Web/RealCV.Web.csproj -c Release
# Run migrations on startup
ENTRYPOINT ["dotnet", "ef", "database", "update", "--project", "src/TrueCV.Infrastructure", "--startup-project", "src/TrueCV.Web", "--no-build", "-c", "Release"]
ENTRYPOINT ["dotnet", "ef", "database", "update", "--project", "src/RealCV.Infrastructure", "--startup-project", "src/RealCV.Web", "--no-build", "-c", "Release"]

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

View File

@@ -5,17 +5,17 @@ VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{F25C3740-9240-46DF-BC34-985BC577216B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrueCV.Domain", "src\TrueCV.Domain\TrueCV.Domain.csproj", "{41AC48AF-09BC-48D1-9CA4-1B05D3B693F0}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealCV.Domain", "src\RealCV.Domain\RealCV.Domain.csproj", "{41AC48AF-09BC-48D1-9CA4-1B05D3B693F0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrueCV.Application", "src\TrueCV.Application\TrueCV.Application.csproj", "{A8A1BA81-3B2F-4F95-BB15-ACA40DF2A70E}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealCV.Application", "src\RealCV.Application\RealCV.Application.csproj", "{A8A1BA81-3B2F-4F95-BB15-ACA40DF2A70E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrueCV.Infrastructure", "src\TrueCV.Infrastructure\TrueCV.Infrastructure.csproj", "{03DB607C-9592-4930-8C89-3E257A319278}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealCV.Infrastructure", "src\RealCV.Infrastructure\RealCV.Infrastructure.csproj", "{03DB607C-9592-4930-8C89-3E257A319278}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrueCV.Web", "src\TrueCV.Web\TrueCV.Web.csproj", "{D69F57DB-3092-48AF-81BB-868E3749C638}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealCV.Web", "src\RealCV.Web\RealCV.Web.csproj", "{D69F57DB-3092-48AF-81BB-868E3749C638}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{80890010-EDA6-418B-AD6C-5A9D875594C4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrueCV.Tests", "tests\TrueCV.Tests\TrueCV.Tests.csproj", "{4450D4F1-4EB9-445E-904B-1C57701493D8}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealCV.Tests", "tests\RealCV.Tests\RealCV.Tests.csproj", "{4450D4F1-4EB9-445E-904B-1C57701493D8}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution

31
deploy-local.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Deploy RealCV from local git repo to website
set -e
cd /git/RealCV
echo "Building application..."
dotnet publish src/RealCV.Web -c Release -o ./publish --nologo
echo "Stopping service..."
sudo systemctl stop realcv
echo "Backing up config..."
cp /var/www/realcv/appsettings.Production.json /tmp/appsettings.Production.json 2>/dev/null || true
echo "Deploying files..."
sudo rm -rf /var/www/realcv/*
sudo cp -r ./publish/* /var/www/realcv/
echo "Restoring config..."
sudo cp /tmp/appsettings.Production.json /var/www/realcv/ 2>/dev/null || true
echo "Setting permissions..."
sudo chown -R www-data:www-data /var/www/realcv
echo "Starting service..."
sudo systemctl start realcv
echo "Done! Checking status..."
sleep 2
sudo systemctl is-active realcv && echo "Service is running."

View File

@@ -1,5 +1,5 @@
#!/bin/bash
# TrueCV Deployment Script
# RealCV Deployment Script
# Run this from your development machine to deploy to a Linux server
set -e
@@ -7,8 +7,8 @@ set -e
# Configuration - UPDATE THESE VALUES
SERVER_USER="deploy"
SERVER_HOST="your-server.com"
SERVER_PATH="/var/www/truecv"
DOMAIN="truecv.yourdomain.com"
SERVER_PATH="/var/www/realcv"
DOMAIN="realcv.yourdomain.com"
# Colors for output
RED='\033[0;31m'
@@ -16,7 +16,7 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}=== TrueCV Deployment Script ===${NC}"
echo -e "${GREEN}=== RealCV Deployment Script ===${NC}"
# Check if configuration is set
if [[ "$SERVER_HOST" == "your-server.com" ]]; then
@@ -27,15 +27,15 @@ fi
# Step 1: Build and publish
echo -e "${YELLOW}Step 1: Publishing application...${NC}"
cd "$(dirname "$0")/.."
dotnet publish src/TrueCV.Web -c Release -o ./publish --nologo
dotnet publish src/RealCV.Web -c Release -o ./publish --nologo
# Step 2: Create deployment package
echo -e "${YELLOW}Step 2: Creating deployment package...${NC}"
tar -czf deploy/truecv-release.tar.gz -C publish .
tar -czf deploy/realcv-release.tar.gz -C publish .
# Step 3: Transfer to server
echo -e "${YELLOW}Step 3: Transferring to server...${NC}"
scp deploy/truecv-release.tar.gz ${SERVER_USER}@${SERVER_HOST}:/tmp/
scp deploy/realcv-release.tar.gz ${SERVER_USER}@${SERVER_HOST}:/tmp/
# Step 4: Deploy on server
echo -e "${YELLOW}Step 4: Deploying on server...${NC}"
@@ -43,23 +43,23 @@ ssh ${SERVER_USER}@${SERVER_HOST} << 'ENDSSH'
set -e
# Stop the service if running
sudo systemctl stop truecv 2>/dev/null || true
sudo systemctl stop realcv 2>/dev/null || true
# Backup current deployment
if [ -d "/var/www/truecv" ]; then
sudo mv /var/www/truecv /var/www/truecv.backup.$(date +%Y%m%d_%H%M%S)
if [ -d "/var/www/realcv" ]; then
sudo mv /var/www/realcv /var/www/realcv.backup.$(date +%Y%m%d_%H%M%S)
fi
# Create directory and extract
sudo mkdir -p /var/www/truecv
sudo tar -xzf /tmp/truecv-release.tar.gz -C /var/www/truecv
sudo chown -R www-data:www-data /var/www/truecv
sudo mkdir -p /var/www/realcv
sudo tar -xzf /tmp/realcv-release.tar.gz -C /var/www/realcv
sudo chown -R www-data:www-data /var/www/realcv
# Start the service
sudo systemctl start truecv
sudo systemctl start realcv
# Clean up
rm /tmp/truecv-release.tar.gz
rm /tmp/realcv-release.tar.gz
echo "Deployment complete on server"
ENDSSH
@@ -67,14 +67,14 @@ ENDSSH
# Step 5: Verify deployment
echo -e "${YELLOW}Step 5: Verifying deployment...${NC}"
sleep 3
if ssh ${SERVER_USER}@${SERVER_HOST} "sudo systemctl is-active truecv" | grep -q "active"; then
if ssh ${SERVER_USER}@${SERVER_HOST} "sudo systemctl is-active realcv" | grep -q "active"; then
echo -e "${GREEN}=== Deployment successful! ===${NC}"
echo -e "Site should be available at: https://${DOMAIN}"
else
echo -e "${RED}Warning: Service may not be running. Check with: sudo systemctl status truecv${NC}"
echo -e "${RED}Warning: Service may not be running. Check with: sudo systemctl status realcv${NC}"
fi
# Cleanup local files
rm -f deploy/truecv-release.tar.gz
rm -f deploy/realcv-release.tar.gz
echo -e "${GREEN}Done!${NC}"

View File

@@ -1,11 +1,11 @@
#!/bin/bash
# TrueCV Server Setup Script
# RealCV Server Setup Script
# Run this ONCE on a fresh Linux server (Ubuntu 22.04/24.04)
set -e
# Configuration - UPDATE THESE VALUES
DOMAIN="truecv.yourdomain.com"
DOMAIN="realcv.yourdomain.com"
DB_PASSWORD="YourStrong!Password123"
ADMIN_EMAIL="admin@yourdomain.com"
@@ -15,7 +15,7 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${GREEN}=== TrueCV Server Setup ===${NC}"
echo -e "${GREEN}=== RealCV Server Setup ===${NC}"
# Check if running as root
if [[ $EUID -ne 0 ]]; then
@@ -52,54 +52,54 @@ echo -e "${YELLOW}Step 5: Setting up SQL Server...${NC}"
docker run -e 'ACCEPT_EULA=Y' \
-e "SA_PASSWORD=${DB_PASSWORD}" \
-p 127.0.0.1:1433:1433 \
--name truecv-sql \
--name realcv-sql \
--restart unless-stopped \
-v truecv-sqldata:/var/opt/mssql \
-v realcv-sqldata:/var/opt/mssql \
-d mcr.microsoft.com/mssql/server:2022-latest
echo "Waiting for SQL Server to start..."
sleep 30
# Create the database
docker exec truecv-sql /opt/mssql-tools18/bin/sqlcmd \
docker exec realcv-sql /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U SA -P "${DB_PASSWORD}" -C \
-Q "CREATE DATABASE TrueCV"
-Q "CREATE DATABASE RealCV"
# Step 6: Create application directory
echo -e "${YELLOW}Step 6: Creating application directory...${NC}"
mkdir -p /var/www/truecv
chown -R www-data:www-data /var/www/truecv
mkdir -p /var/www/realcv
chown -R www-data:www-data /var/www/realcv
# Step 7: Create systemd service
echo -e "${YELLOW}Step 7: Creating systemd service...${NC}"
cat > /etc/systemd/system/truecv.service << EOF
cat > /etc/systemd/system/realcv.service << EOF
[Unit]
Description=TrueCV Web Application
Description=RealCV Web Application
After=network.target docker.service
Requires=docker.service
[Service]
WorkingDirectory=/var/www/truecv
ExecStart=/usr/bin/dotnet /var/www/truecv/TrueCV.Web.dll
WorkingDirectory=/var/www/realcv
ExecStart=/usr/bin/dotnet /var/www/realcv/RealCV.Web.dll
Restart=always
RestartSec=10
KillSignal=SIGINT
SyslogIdentifier=truecv
SyslogIdentifier=realcv
User=www-data
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=ASPNETCORE_URLS=http://localhost:5000
Environment=ConnectionStrings__DefaultConnection=Server=127.0.0.1;Database=TrueCV;User Id=SA;Password=${DB_PASSWORD};TrustServerCertificate=True
Environment=ConnectionStrings__DefaultConnection=Server=127.0.0.1;Database=RealCV;User Id=SA;Password=${DB_PASSWORD};TrustServerCertificate=True
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable truecv
systemctl enable realcv
# Step 8: Configure Nginx
echo -e "${YELLOW}Step 8: Configuring Nginx...${NC}"
cat > /etc/nginx/sites-available/truecv << EOF
cat > /etc/nginx/sites-available/realcv << EOF
server {
listen 80;
server_name ${DOMAIN};
@@ -122,7 +122,7 @@ server {
}
EOF
ln -sf /etc/nginx/sites-available/truecv /etc/nginx/sites-enabled/
ln -sf /etc/nginx/sites-available/realcv /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginx
@@ -151,9 +151,9 @@ echo "2. Deploy the application using deploy.sh from your dev machine"
echo "3. Run SSL setup: certbot --nginx -d ${DOMAIN}"
echo ""
echo "Useful commands:"
echo " sudo systemctl status truecv - Check app status"
echo " sudo journalctl -u truecv -f - View app logs"
echo " docker logs truecv-sql - View SQL Server logs"
echo " sudo systemctl status realcv - Check app status"
echo " sudo journalctl -u realcv -f - View app logs"
echo " docker logs realcv-sql - View SQL Server logs"
echo ""
echo -e "${YELLOW}Database connection string:${NC}"
echo " Server=127.0.0.1;Database=TrueCV;User Id=SA;Password=${DB_PASSWORD};TrustServerCertificate=True"
echo " Server=127.0.0.1;Database=RealCV;User Id=SA;Password=${DB_PASSWORD};TrustServerCertificate=True"

View File

@@ -1,18 +1,18 @@
version: '3.8'
services:
# TrueCV Web Application
truecv-web:
# RealCV Web Application
realcv-web:
build:
context: .
dockerfile: Dockerfile
container_name: truecv-web
container_name: realcv-web
ports:
- "5000:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__DefaultConnection=Server=sqlserver;Database=TrueCV;User Id=sa;Password=TrueCV_P@ssw0rd!;TrustServerCertificate=True;
- ConnectionStrings__HangfireConnection=Server=sqlserver;Database=TrueCV_Hangfire;User Id=sa;Password=TrueCV_P@ssw0rd!;TrustServerCertificate=True;
- ConnectionStrings__DefaultConnection=Server=sqlserver;Database=RealCV;User Id=sa;Password=RealCV_P@ssw0rd!;TrustServerCertificate=True;
- ConnectionStrings__HangfireConnection=Server=sqlserver;Database=RealCV_Hangfire;User Id=sa;Password=RealCV_P@ssw0rd!;TrustServerCertificate=True;
- AzureBlob__ConnectionString=DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1;
- AzureBlob__ContainerName=cv-uploads
- CompaniesHouse__BaseUrl=https://api.company-information.service.gov.uk
@@ -24,25 +24,25 @@ services:
azurite:
condition: service_started
networks:
- truecv-network
- realcv-network
restart: unless-stopped
# SQL Server Database
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
container_name: truecv-sqlserver
container_name: realcv-sqlserver
ports:
- "1433:1433"
environment:
- ACCEPT_EULA=Y
- MSSQL_SA_PASSWORD=TrueCV_P@ssw0rd!
- MSSQL_SA_PASSWORD=RealCV_P@ssw0rd!
- MSSQL_PID=Developer
volumes:
- sqlserver-data:/var/opt/mssql
networks:
- truecv-network
- realcv-network
healthcheck:
test: /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "TrueCV_P@ssw0rd!" -C -Q "SELECT 1" || exit 1
test: /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "RealCV_P@ssw0rd!" -C -Q "SELECT 1" || exit 1
interval: 10s
timeout: 5s
retries: 10
@@ -52,7 +52,7 @@ services:
# Azure Storage Emulator (Azurite)
azurite:
image: mcr.microsoft.com/azure-storage/azurite:latest
container_name: truecv-azurite
container_name: realcv-azurite
ports:
- "10000:10000" # Blob service
- "10001:10001" # Queue service
@@ -61,7 +61,7 @@ services:
- azurite-data:/data
command: "azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0 --location /data --debug /data/debug.log"
networks:
- truecv-network
- realcv-network
restart: unless-stopped
# Database initialization (runs migrations)
@@ -69,18 +69,18 @@ services:
build:
context: .
dockerfile: Dockerfile.migrations
container_name: truecv-db-init
container_name: realcv-db-init
environment:
- ConnectionStrings__DefaultConnection=Server=sqlserver;Database=TrueCV;User Id=sa;Password=TrueCV_P@ssw0rd!;TrustServerCertificate=True;
- ConnectionStrings__DefaultConnection=Server=sqlserver;Database=RealCV;User Id=sa;Password=RealCV_P@ssw0rd!;TrustServerCertificate=True;
depends_on:
sqlserver:
condition: service_healthy
networks:
- truecv-network
- realcv-network
restart: "no"
networks:
truecv-network:
realcv-network:
driver: bridge
volumes:

BIN
realcv.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

BIN
screenshots/check.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 MiB

BIN
screenshots/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 MiB

BIN
screenshots/homepage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 MiB

BIN
screenshots/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 MiB

BIN
screenshots/pricing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 MiB

BIN
screenshots/register.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 MiB

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.DTOs;
namespace RealCV.Application.DTOs;
public sealed record CVCheckDto
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.DTOs;
namespace RealCV.Application.DTOs;
public sealed record CompanySearchResult
{

View File

@@ -0,0 +1,16 @@
using RealCV.Domain.Enums;
namespace RealCV.Application.DTOs;
public class SubscriptionInfoDto
{
public UserPlan Plan { get; set; }
public int ChecksUsedThisMonth { get; set; }
public int MonthlyLimit { get; set; }
public int ChecksRemaining { get; set; }
public bool IsUnlimited { get; set; }
public string? SubscriptionStatus { get; set; }
public DateTime? CurrentPeriodEnd { get; set; }
public bool HasActiveSubscription { get; set; }
public string DisplayPrice { get; set; } = string.Empty;
}

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Data;
namespace RealCV.Application.Data;
/// <summary>
/// Known diploma mills and fake educational institutions.

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Data;
namespace RealCV.Application.Data;
/// <summary>
/// List of recognised UK higher education institutions.

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Helpers;
namespace RealCV.Application.Helpers;
public static class DateHelpers
{

View File

@@ -1,6 +1,6 @@
using System.Text.Json;
namespace TrueCV.Application.Helpers;
namespace RealCV.Application.Helpers;
public static class JsonDefaults
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Helpers;
namespace RealCV.Application.Helpers;
public static class ScoreThresholds
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Interfaces;
namespace RealCV.Application.Interfaces;
public interface IAuditService
{

View File

@@ -1,7 +1,7 @@
using TrueCV.Application.DTOs;
using TrueCV.Application.Models;
using RealCV.Application.DTOs;
using RealCV.Application.Models;
namespace TrueCV.Application.Interfaces;
namespace RealCV.Application.Interfaces;
public interface ICVCheckService
{

View File

@@ -1,6 +1,6 @@
using TrueCV.Application.Models;
using RealCV.Application.Models;
namespace TrueCV.Application.Interfaces;
namespace RealCV.Application.Interfaces;
public interface ICVParserService
{

View File

@@ -1,6 +1,6 @@
using TrueCV.Application.Models;
using RealCV.Application.Models;
namespace TrueCV.Application.Interfaces;
namespace RealCV.Application.Interfaces;
public interface ICompanyNameMatcherService
{

View File

@@ -1,7 +1,7 @@
using TrueCV.Application.DTOs;
using TrueCV.Application.Models;
using RealCV.Application.DTOs;
using RealCV.Application.Models;
namespace TrueCV.Application.Interfaces;
namespace RealCV.Application.Interfaces;
public interface ICompanyVerifierService
{

View File

@@ -1,6 +1,6 @@
using TrueCV.Application.Models;
using RealCV.Application.Models;
namespace TrueCV.Application.Interfaces;
namespace RealCV.Application.Interfaces;
public interface IEducationVerifierService
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Interfaces;
namespace RealCV.Application.Interfaces;
public interface IFileStorageService
{

View File

@@ -0,0 +1,10 @@
using RealCV.Domain.Enums;
namespace RealCV.Application.Interfaces;
public interface IStripeService
{
Task<string> CreateCheckoutSessionAsync(Guid userId, string email, UserPlan targetPlan, string successUrl, string cancelUrl);
Task<string> CreateCustomerPortalSessionAsync(string stripeCustomerId, string returnUrl);
Task HandleWebhookAsync(string json, string signature);
}

View File

@@ -0,0 +1,11 @@
using RealCV.Application.DTOs;
namespace RealCV.Application.Interfaces;
public interface ISubscriptionService
{
Task<bool> CanPerformCheckAsync(Guid userId);
Task IncrementUsageAsync(Guid userId);
Task ResetUsageAsync(Guid userId);
Task<SubscriptionInfoDto> GetSubscriptionInfoAsync(Guid userId);
}

View File

@@ -1,6 +1,6 @@
using TrueCV.Application.Models;
using RealCV.Application.Models;
namespace TrueCV.Application.Interfaces;
namespace RealCV.Application.Interfaces;
public interface ITimelineAnalyserService
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Interfaces;
namespace RealCV.Application.Interfaces;
public interface IUserContextService
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Models;
namespace RealCV.Application.Models;
public sealed record CVData
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Models;
namespace RealCV.Application.Models;
public sealed record CompanyVerificationResult
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Models;
namespace RealCV.Application.Models;
public sealed record EducationEntry
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Models;
namespace RealCV.Application.Models;
public sealed record EducationVerificationResult
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Models;
namespace RealCV.Application.Models;
public sealed record EmploymentEntry
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Models;
namespace RealCV.Application.Models;
public sealed record FlagResult
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Models;
namespace RealCV.Application.Models;
public record SemanticMatchResult
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Models;
namespace RealCV.Application.Models;
public sealed record TimelineAnalysisResult
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Models;
namespace RealCV.Application.Models;
public sealed record TimelineGap
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Models;
namespace RealCV.Application.Models;
public sealed record TimelineOverlap
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Models;
namespace RealCV.Application.Models;
public sealed record VeracityReport
{

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\TrueCV.Domain\TrueCV.Domain.csproj" />
<ProjectReference Include="..\RealCV.Domain\RealCV.Domain.csproj" />
</ItemGroup>
<PropertyGroup>

View File

@@ -0,0 +1,31 @@
using RealCV.Domain.Enums;
namespace RealCV.Domain.Constants;
public static class PlanLimits
{
public static int GetMonthlyLimit(UserPlan plan) => plan switch
{
UserPlan.Free => 3,
UserPlan.Professional => 30,
UserPlan.Enterprise => int.MaxValue,
_ => 0
};
public static int GetPricePence(UserPlan plan) => plan switch
{
UserPlan.Professional => 4900,
UserPlan.Enterprise => 19900,
_ => 0
};
public static string GetDisplayPrice(UserPlan plan) => plan switch
{
UserPlan.Free => "Free",
UserPlan.Professional => "£49/month",
UserPlan.Enterprise => "£199/month",
_ => "Unknown"
};
public static bool IsUnlimited(UserPlan plan) => plan == UserPlan.Enterprise;
}

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace TrueCV.Domain.Entities;
namespace RealCV.Domain.Entities;
public class AuditLog
{

View File

@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
using TrueCV.Domain.Enums;
using RealCV.Domain.Enums;
namespace TrueCV.Domain.Entities;
namespace RealCV.Domain.Entities;
public class CVCheck
{

View File

@@ -1,8 +1,8 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using TrueCV.Domain.Enums;
using RealCV.Domain.Enums;
namespace TrueCV.Domain.Entities;
namespace RealCV.Domain.Entities;
public class CVFlag
{

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace TrueCV.Domain.Entities;
namespace RealCV.Domain.Entities;
public class CompanyCache
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Domain.Enums;
namespace RealCV.Domain.Enums;
public enum CheckStatus
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Domain.Enums;
namespace RealCV.Domain.Enums;
public enum FlagCategory
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Domain.Enums;
namespace RealCV.Domain.Enums;
public enum FlagSeverity
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Domain.Enums;
namespace RealCV.Domain.Enums;
public enum UserPlan
{

View File

@@ -0,0 +1,19 @@
namespace RealCV.Domain.Exceptions;
public class QuotaExceededException : Exception
{
public QuotaExceededException()
: base("Monthly CV check quota exceeded. Please upgrade your plan.")
{
}
public QuotaExceededException(string message)
: base(message)
{
}
public QuotaExceededException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Infrastructure.Configuration;
namespace RealCV.Infrastructure.Configuration;
public sealed class AnthropicSettings
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Infrastructure.Configuration;
namespace RealCV.Infrastructure.Configuration;
public sealed class AzureBlobSettings
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Infrastructure.Configuration;
namespace RealCV.Infrastructure.Configuration;
public sealed class CompaniesHouseSettings
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Infrastructure.Configuration;
namespace RealCV.Infrastructure.Configuration;
public sealed class LocalStorageSettings
{

View File

@@ -0,0 +1,17 @@
namespace RealCV.Infrastructure.Configuration;
public class StripeSettings
{
public const string SectionName = "Stripe";
public string SecretKey { get; set; } = string.Empty;
public string PublishableKey { get; set; } = string.Empty;
public string WebhookSecret { get; set; } = string.Empty;
public StripePriceIds PriceIds { get; set; } = new();
}
public class StripePriceIds
{
public string Professional { get; set; } = string.Empty;
public string Enterprise { get; set; } = string.Empty;
}

View File

@@ -1,10 +1,10 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using TrueCV.Domain.Entities;
using TrueCV.Infrastructure.Identity;
using RealCV.Domain.Entities;
using RealCV.Infrastructure.Identity;
namespace TrueCV.Infrastructure.Data;
namespace RealCV.Infrastructure.Data;
public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid>, Guid>
{
@@ -40,6 +40,18 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityR
entity.Property(u => u.StripeCustomerId)
.HasMaxLength(256);
entity.Property(u => u.StripeSubscriptionId)
.HasMaxLength(256);
entity.Property(u => u.SubscriptionStatus)
.HasMaxLength(32);
entity.HasIndex(u => u.StripeCustomerId)
.HasDatabaseName("IX_Users_StripeCustomerId");
entity.HasIndex(u => u.StripeSubscriptionId)
.HasDatabaseName("IX_Users_StripeSubscriptionId");
entity.HasMany(u => u.CVChecks)
.WithOne()
.HasForeignKey(c => c.UserId)

View File

@@ -5,11 +5,11 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using TrueCV.Infrastructure.Data;
using RealCV.Infrastructure.Data;
#nullable disable
namespace TrueCV.Infrastructure.Data.Migrations
namespace RealCV.Infrastructure.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260118182916_InitialCreate")]
@@ -156,7 +156,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -211,7 +211,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("CVChecks");
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVFlag", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVFlag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -251,7 +251,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("CVFlags");
});
modelBuilder.Entity("TrueCV.Domain.Entities.CompanyCache", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CompanyCache", b =>
{
b.Property<string>("CompanyNumber")
.HasMaxLength(32)
@@ -281,7 +281,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("CompanyCache");
});
modelBuilder.Entity("TrueCV.Domain.Entities.User", b =>
modelBuilder.Entity("RealCV.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -307,7 +307,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("User");
});
modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b =>
modelBuilder.Entity("RealCV.Infrastructure.Identity.ApplicationUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -396,7 +396,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -405,7 +405,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -420,7 +420,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -429,29 +429,29 @@ namespace TrueCV.Infrastructure.Data.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany("CVChecks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TrueCV.Domain.Entities.User", null)
b.HasOne("RealCV.Domain.Entities.User", null)
.WithMany("CVChecks")
.HasForeignKey("UserId1");
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVFlag", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVFlag", b =>
{
b.HasOne("TrueCV.Domain.Entities.CVCheck", "CVCheck")
b.HasOne("RealCV.Domain.Entities.CVCheck", "CVCheck")
.WithMany("Flags")
.HasForeignKey("CVCheckId")
.OnDelete(DeleteBehavior.Cascade)
@@ -460,17 +460,17 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.Navigation("CVCheck");
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.Navigation("Flags");
});
modelBuilder.Entity("TrueCV.Domain.Entities.User", b =>
modelBuilder.Entity("RealCV.Domain.Entities.User", b =>
{
b.Navigation("CVChecks");
});
modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b =>
modelBuilder.Entity("RealCV.Infrastructure.Identity.ApplicationUser", b =>
{
b.Navigation("CVChecks");
});

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TrueCV.Infrastructure.Data.Migrations
namespace RealCV.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration

View File

@@ -5,11 +5,11 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using TrueCV.Infrastructure.Data;
using RealCV.Infrastructure.Data;
#nullable disable
namespace TrueCV.Infrastructure.Data.Migrations
namespace RealCV.Infrastructure.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260120191035_AddProcessingStageToCV")]
@@ -156,7 +156,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -210,7 +210,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("CVChecks");
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVFlag", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVFlag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -250,7 +250,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("CVFlags");
});
modelBuilder.Entity("TrueCV.Domain.Entities.CompanyCache", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CompanyCache", b =>
{
b.Property<string>("CompanyNumber")
.HasMaxLength(32)
@@ -292,7 +292,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("CompanyCache");
});
modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b =>
modelBuilder.Entity("RealCV.Infrastructure.Identity.ApplicationUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -381,7 +381,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -390,7 +390,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -405,7 +405,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -414,25 +414,25 @@ namespace TrueCV.Infrastructure.Data.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany("CVChecks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVFlag", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVFlag", b =>
{
b.HasOne("TrueCV.Domain.Entities.CVCheck", "CVCheck")
b.HasOne("RealCV.Domain.Entities.CVCheck", "CVCheck")
.WithMany("Flags")
.HasForeignKey("CVCheckId")
.OnDelete(DeleteBehavior.Cascade)
@@ -441,12 +441,12 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.Navigation("CVCheck");
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.Navigation("Flags");
});
modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b =>
modelBuilder.Entity("RealCV.Infrastructure.Identity.ApplicationUser", b =>
{
b.Navigation("CVChecks");
});

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TrueCV.Infrastructure.Data.Migrations
namespace RealCV.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddProcessingStageToCV : Migration

View File

@@ -5,11 +5,11 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using TrueCV.Infrastructure.Data;
using RealCV.Infrastructure.Data;
#nullable disable
namespace TrueCV.Infrastructure.Data.Migrations
namespace RealCV.Infrastructure.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260120194532_AddAuditLogTable")]
@@ -156,7 +156,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TrueCV.Domain.Entities.AuditLog", b =>
modelBuilder.Entity("RealCV.Domain.Entities.AuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -202,7 +202,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("AuditLogs");
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -256,7 +256,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("CVChecks");
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVFlag", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVFlag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -296,7 +296,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("CVFlags");
});
modelBuilder.Entity("TrueCV.Domain.Entities.CompanyCache", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CompanyCache", b =>
{
b.Property<string>("CompanyNumber")
.HasMaxLength(32)
@@ -338,7 +338,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("CompanyCache");
});
modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b =>
modelBuilder.Entity("RealCV.Infrastructure.Identity.ApplicationUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -427,7 +427,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -436,7 +436,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -451,7 +451,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -460,25 +460,25 @@ namespace TrueCV.Infrastructure.Data.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany("CVChecks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVFlag", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVFlag", b =>
{
b.HasOne("TrueCV.Domain.Entities.CVCheck", "CVCheck")
b.HasOne("RealCV.Domain.Entities.CVCheck", "CVCheck")
.WithMany("Flags")
.HasForeignKey("CVCheckId")
.OnDelete(DeleteBehavior.Cascade)
@@ -487,12 +487,12 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.Navigation("CVCheck");
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.Navigation("Flags");
});
modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b =>
modelBuilder.Entity("RealCV.Infrastructure.Identity.ApplicationUser", b =>
{
b.Navigation("CVChecks");
});

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TrueCV.Infrastructure.Data.Migrations
namespace RealCV.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddAuditLogTable : Migration

View File

@@ -0,0 +1,519 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RealCV.Infrastructure.Data;
#nullable disable
namespace RealCV.Infrastructure.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260121115517_AddStripeSubscriptionFields")]
partial class AddStripeSubscriptionFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.23")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex")
.HasFilter("[NormalizedName] IS NOT NULL");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("RealCV.Domain.Entities.AuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Details")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<Guid?>("EntityId")
.HasColumnType("uniqueidentifier");
b.Property<string>("EntityType")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("Action")
.HasDatabaseName("IX_AuditLogs_Action");
b.HasIndex("CreatedAt")
.HasDatabaseName("IX_AuditLogs_CreatedAt");
b.HasIndex("UserId")
.HasDatabaseName("IX_AuditLogs_UserId");
b.ToTable("AuditLogs");
});
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("BlobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("ExtractedDataJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("ProcessingStage")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ReportJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<int?>("VeracityScore")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Status")
.HasDatabaseName("IX_CVChecks_Status");
b.HasIndex("UserId")
.HasDatabaseName("IX_CVChecks_UserId");
b.ToTable("CVChecks");
});
modelBuilder.Entity("RealCV.Domain.Entities.CVFlag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("CVCheckId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<int>("ScoreImpact")
.HasColumnType("int");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("CVCheckId")
.HasDatabaseName("IX_CVFlags_CVCheckId");
b.ToTable("CVFlags");
});
modelBuilder.Entity("RealCV.Domain.Entities.CompanyCache", b =>
{
b.Property<string>("CompanyNumber")
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("AccountsCategory")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CachedAt")
.HasColumnType("datetime2");
b.Property<string>("CompanyName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("CompanyType")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateOnly?>("DissolutionDate")
.HasColumnType("date");
b.Property<DateOnly?>("IncorporationDate")
.HasColumnType("date");
b.Property<string>("SicCodesJson")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("CompanyNumber");
b.ToTable("CompanyCache");
});
modelBuilder.Entity("RealCV.Infrastructure.Identity.ApplicationUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<int>("ChecksUsedThisMonth")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("CurrentPeriodEnd")
.HasColumnType("datetime2");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("Plan")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<string>("StripeCustomerId")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("StripeSubscriptionId")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("SubscriptionStatus")
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex")
.HasFilter("[NormalizedUserName] IS NOT NULL");
b.HasIndex("StripeCustomerId")
.HasDatabaseName("IX_Users_StripeCustomerId");
b.HasIndex("StripeSubscriptionId")
.HasDatabaseName("IX_Users_StripeSubscriptionId");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany("CVChecks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RealCV.Domain.Entities.CVFlag", b =>
{
b.HasOne("RealCV.Domain.Entities.CVCheck", "CVCheck")
.WithMany("Flags")
.HasForeignKey("CVCheckId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CVCheck");
});
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.Navigation("Flags");
});
modelBuilder.Entity("RealCV.Infrastructure.Identity.ApplicationUser", b =>
{
b.Navigation("CVChecks");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,69 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RealCV.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddStripeSubscriptionFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "CurrentPeriodEnd",
table: "AspNetUsers",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "StripeSubscriptionId",
table: "AspNetUsers",
type: "nvarchar(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SubscriptionStatus",
table: "AspNetUsers",
type: "nvarchar(32)",
maxLength: 32,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Users_StripeCustomerId",
table: "AspNetUsers",
column: "StripeCustomerId");
migrationBuilder.CreateIndex(
name: "IX_Users_StripeSubscriptionId",
table: "AspNetUsers",
column: "StripeSubscriptionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Users_StripeCustomerId",
table: "AspNetUsers");
migrationBuilder.DropIndex(
name: "IX_Users_StripeSubscriptionId",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "CurrentPeriodEnd",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "StripeSubscriptionId",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "SubscriptionStatus",
table: "AspNetUsers");
}
}
}

View File

@@ -4,11 +4,11 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using TrueCV.Infrastructure.Data;
using RealCV.Infrastructure.Data;
#nullable disable
namespace TrueCV.Infrastructure.Data.Migrations
namespace RealCV.Infrastructure.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
@@ -153,7 +153,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TrueCV.Domain.Entities.AuditLog", b =>
modelBuilder.Entity("RealCV.Domain.Entities.AuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -199,7 +199,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("AuditLogs");
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -253,7 +253,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("CVChecks");
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVFlag", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVFlag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -293,7 +293,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("CVFlags");
});
modelBuilder.Entity("TrueCV.Domain.Entities.CompanyCache", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CompanyCache", b =>
{
b.Property<string>("CompanyNumber")
.HasMaxLength(32)
@@ -335,7 +335,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("CompanyCache");
});
modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b =>
modelBuilder.Entity("RealCV.Infrastructure.Identity.ApplicationUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -351,6 +351,9 @@ namespace TrueCV.Infrastructure.Data.Migrations
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("CurrentPeriodEnd")
.HasColumnType("datetime2");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
@@ -393,6 +396,14 @@ namespace TrueCV.Infrastructure.Data.Migrations
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("StripeSubscriptionId")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("SubscriptionStatus")
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
@@ -410,6 +421,12 @@ namespace TrueCV.Infrastructure.Data.Migrations
.HasDatabaseName("UserNameIndex")
.HasFilter("[NormalizedUserName] IS NOT NULL");
b.HasIndex("StripeCustomerId")
.HasDatabaseName("IX_Users_StripeCustomerId");
b.HasIndex("StripeSubscriptionId")
.HasDatabaseName("IX_Users_StripeSubscriptionId");
b.ToTable("AspNetUsers", (string)null);
});
@@ -424,7 +441,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -433,7 +450,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -448,7 +465,7 @@ namespace TrueCV.Infrastructure.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -457,25 +474,25 @@ namespace TrueCV.Infrastructure.Data.Migrations
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany("CVChecks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVFlag", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVFlag", b =>
{
b.HasOne("TrueCV.Domain.Entities.CVCheck", "CVCheck")
b.HasOne("RealCV.Domain.Entities.CVCheck", "CVCheck")
.WithMany("Flags")
.HasForeignKey("CVCheckId")
.OnDelete(DeleteBehavior.Cascade)
@@ -484,12 +501,12 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.Navigation("CVCheck");
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b =>
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.Navigation("Flags");
});
modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b =>
modelBuilder.Entity("RealCV.Infrastructure.Identity.ApplicationUser", b =>
{
b.Navigation("CVChecks");
});

View File

@@ -5,14 +5,15 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Polly;
using Polly.Extensions.Http;
using TrueCV.Application.Interfaces;
using TrueCV.Infrastructure.Configuration;
using TrueCV.Infrastructure.Data;
using TrueCV.Infrastructure.ExternalApis;
using TrueCV.Infrastructure.Jobs;
using TrueCV.Infrastructure.Services;
using Stripe;
using RealCV.Application.Interfaces;
using RealCV.Infrastructure.Configuration;
using RealCV.Infrastructure.Data;
using RealCV.Infrastructure.ExternalApis;
using RealCV.Infrastructure.Jobs;
using RealCV.Infrastructure.Services;
namespace TrueCV.Infrastructure;
namespace RealCV.Infrastructure;
public static class DependencyInjection
{
@@ -74,6 +75,16 @@ public static class DependencyInjection
services.Configure<LocalStorageSettings>(
configuration.GetSection(LocalStorageSettings.SectionName));
services.Configure<StripeSettings>(
configuration.GetSection(StripeSettings.SectionName));
// Configure Stripe API key
var stripeSettings = configuration.GetSection(StripeSettings.SectionName).Get<StripeSettings>();
if (!string.IsNullOrEmpty(stripeSettings?.SecretKey))
{
StripeConfiguration.ApiKey = stripeSettings.SecretKey;
}
// Configure HttpClient for CompaniesHouseClient with retry policy
services.AddHttpClient<CompaniesHouseClient>((serviceProvider, client) =>
{
@@ -97,6 +108,8 @@ public static class DependencyInjection
services.AddScoped<ICVCheckService, CVCheckService>();
services.AddScoped<IUserContextService, UserContextService>();
services.AddScoped<IAuditService, AuditService>();
services.AddScoped<IStripeService, StripeService>();
services.AddScoped<ISubscriptionService, Services.SubscriptionService>();
// Register file storage - use local storage if configured, otherwise Azure
var useLocalStorage = configuration.GetValue<bool>("UseLocalStorage");
@@ -111,6 +124,7 @@ public static class DependencyInjection
// Register Hangfire jobs
services.AddTransient<ProcessCVCheckJob>();
services.AddTransient<ResetMonthlyUsageJob>();
return services;
}

View File

@@ -6,10 +6,10 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TrueCV.Application.DTOs;
using TrueCV.Infrastructure.Configuration;
using RealCV.Application.DTOs;
using RealCV.Infrastructure.Configuration;
namespace TrueCV.Infrastructure.ExternalApis;
namespace RealCV.Infrastructure.ExternalApis;
public sealed class CompaniesHouseClient
{

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Infrastructure.Helpers;
namespace RealCV.Infrastructure.Helpers;
/// <summary>
/// Helper methods for processing AI/LLM JSON responses.

View File

@@ -1,8 +1,8 @@
using Microsoft.AspNetCore.Identity;
using TrueCV.Domain.Entities;
using TrueCV.Domain.Enums;
using RealCV.Domain.Entities;
using RealCV.Domain.Enums;
namespace TrueCV.Infrastructure.Identity;
namespace RealCV.Infrastructure.Identity;
public class ApplicationUser : IdentityUser<Guid>
{
@@ -10,6 +10,12 @@ public class ApplicationUser : IdentityUser<Guid>
public string? StripeCustomerId { get; set; }
public string? StripeSubscriptionId { get; set; }
public string? SubscriptionStatus { get; set; }
public DateTime? CurrentPeriodEnd { get; set; }
public int ChecksUsedThisMonth { get; set; }
public ICollection<CVCheck> CVChecks { get; set; } = new List<CVCheck>();

View File

@@ -1,14 +1,14 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using TrueCV.Application.Helpers;
using TrueCV.Application.Interfaces;
using TrueCV.Application.Models;
using TrueCV.Domain.Entities;
using TrueCV.Domain.Enums;
using TrueCV.Infrastructure.Data;
using RealCV.Application.Helpers;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
using RealCV.Domain.Entities;
using RealCV.Domain.Enums;
using RealCV.Infrastructure.Data;
namespace TrueCV.Infrastructure.Jobs;
namespace RealCV.Infrastructure.Jobs;
public sealed class ProcessCVCheckJob
{

View File

@@ -0,0 +1,80 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using RealCV.Domain.Enums;
using RealCV.Infrastructure.Data;
namespace RealCV.Infrastructure.Jobs;
/// <summary>
/// Hangfire job that resets monthly CV check usage for users whose billing period has ended.
/// This job should run daily to catch users whose subscriptions renewed.
/// </summary>
public sealed class ResetMonthlyUsageJob
{
private readonly ApplicationDbContext _dbContext;
private readonly ILogger<ResetMonthlyUsageJob> _logger;
public ResetMonthlyUsageJob(
ApplicationDbContext dbContext,
ILogger<ResetMonthlyUsageJob> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation("Starting monthly usage reset job");
try
{
var now = DateTime.UtcNow;
// Reset usage for paid users whose billing period has ended
// The webhook handler already resets usage when subscription renews,
// but this catches any edge cases or delays in webhook delivery
var paidUsersToReset = await _dbContext.Users
.Where(u => u.Plan != UserPlan.Free)
.Where(u => u.CurrentPeriodEnd != null && u.CurrentPeriodEnd <= now)
.Where(u => u.ChecksUsedThisMonth > 0)
.Where(u => u.SubscriptionStatus == "active")
.ToListAsync(cancellationToken);
foreach (var user in paidUsersToReset)
{
user.ChecksUsedThisMonth = 0;
_logger.LogInformation(
"Reset usage for paid user {UserId} - billing period ended {PeriodEnd}",
user.Id, user.CurrentPeriodEnd);
}
// Reset usage for free users on the 1st of each month
if (now.Day == 1)
{
var freeUsersToReset = await _dbContext.Users
.Where(u => u.Plan == UserPlan.Free)
.Where(u => u.ChecksUsedThisMonth > 0)
.ToListAsync(cancellationToken);
foreach (var user in freeUsersToReset)
{
user.ChecksUsedThisMonth = 0;
_logger.LogInformation("Reset usage for free user {UserId}", user.Id);
}
_logger.LogInformation("Reset usage for {Count} free users", freeUsersToReset.Count);
}
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Monthly usage reset job completed. Reset {PaidCount} paid users",
paidUsersToReset.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in monthly usage reset job");
throw;
}
}
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\TrueCV.Application\TrueCV.Application.csproj" />
<ProjectReference Include="..\RealCV.Application\RealCV.Application.csproj" />
</ItemGroup>
<ItemGroup>
@@ -19,6 +19,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.*" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.*" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Stripe.net" Version="50.2.0" />
<PackageReference Include="UglyToad.PdfPig" Version="1.7.0-custom-5" />
</ItemGroup>

View File

@@ -3,13 +3,13 @@ using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TrueCV.Application.Helpers;
using TrueCV.Application.Interfaces;
using TrueCV.Application.Models;
using TrueCV.Infrastructure.Configuration;
using TrueCV.Infrastructure.Helpers;
using RealCV.Application.Helpers;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
using RealCV.Infrastructure.Configuration;
using RealCV.Infrastructure.Helpers;
namespace TrueCV.Infrastructure.Services;
namespace RealCV.Infrastructure.Services;
public sealed class AICompanyNameMatcherService : ICompanyNameMatcherService
{

View File

@@ -1,9 +1,9 @@
using Microsoft.Extensions.Logging;
using TrueCV.Application.Interfaces;
using TrueCV.Domain.Entities;
using TrueCV.Infrastructure.Data;
using RealCV.Application.Interfaces;
using RealCV.Domain.Entities;
using RealCV.Infrastructure.Data;
namespace TrueCV.Infrastructure.Services;
namespace RealCV.Infrastructure.Services;
public sealed class AuditService : IAuditService
{

View File

@@ -2,16 +2,17 @@ using System.Text.Json;
using Hangfire;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using TrueCV.Application.DTOs;
using TrueCV.Application.Helpers;
using TrueCV.Application.Interfaces;
using TrueCV.Application.Models;
using TrueCV.Domain.Entities;
using TrueCV.Domain.Enums;
using TrueCV.Infrastructure.Data;
using TrueCV.Infrastructure.Jobs;
using RealCV.Application.DTOs;
using RealCV.Application.Helpers;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
using RealCV.Domain.Entities;
using RealCV.Domain.Enums;
using RealCV.Domain.Exceptions;
using RealCV.Infrastructure.Data;
using RealCV.Infrastructure.Jobs;
namespace TrueCV.Infrastructure.Services;
namespace RealCV.Infrastructure.Services;
public sealed class CVCheckService : ICVCheckService
{
@@ -19,6 +20,7 @@ public sealed class CVCheckService : ICVCheckService
private readonly IFileStorageService _fileStorageService;
private readonly IBackgroundJobClient _backgroundJobClient;
private readonly IAuditService _auditService;
private readonly ISubscriptionService _subscriptionService;
private readonly ILogger<CVCheckService> _logger;
public CVCheckService(
@@ -26,12 +28,14 @@ public sealed class CVCheckService : ICVCheckService
IFileStorageService fileStorageService,
IBackgroundJobClient backgroundJobClient,
IAuditService auditService,
ISubscriptionService subscriptionService,
ILogger<CVCheckService> logger)
{
_dbContext = dbContext;
_fileStorageService = fileStorageService;
_backgroundJobClient = backgroundJobClient;
_auditService = auditService;
_subscriptionService = subscriptionService;
_logger = logger;
}
@@ -42,6 +46,13 @@ public sealed class CVCheckService : ICVCheckService
_logger.LogDebug("Creating CV check for user {UserId}, file: {FileName}", userId, fileName);
// Check quota before proceeding
if (!await _subscriptionService.CanPerformCheckAsync(userId))
{
_logger.LogWarning("User {UserId} quota exceeded - CV check denied", userId);
throw new QuotaExceededException();
}
// Upload file to blob storage
var blobUrl = await _fileStorageService.UploadAsync(file, fileName);
@@ -71,6 +82,9 @@ public sealed class CVCheckService : ICVCheckService
await _auditService.LogAsync(userId, AuditActions.CVUploaded, "CVCheck", cvCheck.Id, $"File: {fileName}");
// Increment usage after successful creation
await _subscriptionService.IncrementUsageAsync(userId);
return cvCheck.Id;
}

View File

@@ -6,14 +6,14 @@ using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TrueCV.Application.Helpers;
using TrueCV.Application.Interfaces;
using TrueCV.Application.Models;
using TrueCV.Infrastructure.Configuration;
using TrueCV.Infrastructure.Helpers;
using RealCV.Application.Helpers;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
using RealCV.Infrastructure.Configuration;
using RealCV.Infrastructure.Helpers;
using UglyToad.PdfPig;
namespace TrueCV.Infrastructure.Services;
namespace RealCV.Infrastructure.Services;
public sealed class CVParserService : ICVParserService
{

View File

@@ -2,15 +2,15 @@ using System.Text.Json;
using FuzzySharp;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using TrueCV.Application.DTOs;
using TrueCV.Application.Helpers;
using TrueCV.Application.Interfaces;
using TrueCV.Application.Models;
using TrueCV.Domain.Entities;
using TrueCV.Infrastructure.Data;
using TrueCV.Infrastructure.ExternalApis;
using RealCV.Application.DTOs;
using RealCV.Application.Helpers;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
using RealCV.Domain.Entities;
using RealCV.Infrastructure.Data;
using RealCV.Infrastructure.ExternalApis;
namespace TrueCV.Infrastructure.Services;
namespace RealCV.Infrastructure.Services;
public sealed class CompanyVerifierService : ICompanyVerifierService
{

View File

@@ -1,8 +1,8 @@
using TrueCV.Application.Data;
using TrueCV.Application.Interfaces;
using TrueCV.Application.Models;
using RealCV.Application.Data;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
namespace TrueCV.Infrastructure.Services;
namespace RealCV.Infrastructure.Services;
public sealed class EducationVerifierService : IEducationVerifierService
{

View File

@@ -2,10 +2,10 @@ using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TrueCV.Application.Interfaces;
using TrueCV.Infrastructure.Configuration;
using RealCV.Application.Interfaces;
using RealCV.Infrastructure.Configuration;
namespace TrueCV.Infrastructure.Services;
namespace RealCV.Infrastructure.Services;
public sealed class FileStorageService : IFileStorageService
{

View File

@@ -1,9 +1,9 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TrueCV.Application.Interfaces;
using TrueCV.Infrastructure.Configuration;
using RealCV.Application.Interfaces;
using RealCV.Infrastructure.Configuration;
namespace TrueCV.Infrastructure.Services;
namespace RealCV.Infrastructure.Services;
public sealed class LocalFileStorageService : IFileStorageService
{

View File

@@ -0,0 +1,316 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Stripe;
using Stripe.Checkout;
using RealCV.Application.Interfaces;
using RealCV.Domain.Enums;
using RealCV.Infrastructure.Configuration;
using RealCV.Infrastructure.Data;
namespace RealCV.Infrastructure.Services;
public sealed class StripeService : IStripeService
{
private readonly ApplicationDbContext _dbContext;
private readonly StripeSettings _settings;
private readonly ILogger<StripeService> _logger;
public StripeService(
ApplicationDbContext dbContext,
IOptions<StripeSettings> settings,
ILogger<StripeService> logger)
{
_dbContext = dbContext;
_settings = settings.Value;
_logger = logger;
StripeConfiguration.ApiKey = _settings.SecretKey;
}
public async Task<string> CreateCheckoutSessionAsync(
Guid userId,
string email,
UserPlan targetPlan,
string successUrl,
string cancelUrl)
{
_logger.LogInformation("Creating checkout session for user {UserId}, plan {Plan}", userId, targetPlan);
var priceId = targetPlan switch
{
UserPlan.Professional => _settings.PriceIds.Professional,
UserPlan.Enterprise => _settings.PriceIds.Enterprise,
_ => throw new ArgumentException($"Invalid plan for checkout: {targetPlan}")
};
if (string.IsNullOrEmpty(priceId))
{
throw new InvalidOperationException($"Price ID not configured for plan: {targetPlan}");
}
var user = await _dbContext.Users.FindAsync(userId);
if (user == null)
{
throw new InvalidOperationException($"User not found: {userId}");
}
var sessionOptions = new SessionCreateOptions
{
Mode = "subscription",
CustomerEmail = string.IsNullOrEmpty(user.StripeCustomerId) ? email : null,
Customer = !string.IsNullOrEmpty(user.StripeCustomerId) ? user.StripeCustomerId : null,
LineItems = new List<SessionLineItemOptions>
{
new()
{
Price = priceId,
Quantity = 1
}
},
SuccessUrl = successUrl + "?session_id={CHECKOUT_SESSION_ID}",
CancelUrl = cancelUrl,
Metadata = new Dictionary<string, string>
{
{ "user_id", userId.ToString() },
{ "target_plan", targetPlan.ToString() }
},
SubscriptionData = new SessionSubscriptionDataOptions
{
Metadata = new Dictionary<string, string>
{
{ "user_id", userId.ToString() },
{ "plan", targetPlan.ToString() }
}
}
};
var sessionService = new SessionService();
var session = await sessionService.CreateAsync(sessionOptions);
_logger.LogInformation("Checkout session created: {SessionId}", session.Id);
return session.Url;
}
public async Task<string> CreateCustomerPortalSessionAsync(string stripeCustomerId, string returnUrl)
{
_logger.LogInformation("Creating customer portal session for customer {CustomerId}", stripeCustomerId);
var options = new Stripe.BillingPortal.SessionCreateOptions
{
Customer = stripeCustomerId,
ReturnUrl = returnUrl
};
var service = new Stripe.BillingPortal.SessionService();
var session = await service.CreateAsync(options);
return session.Url;
}
public async Task HandleWebhookAsync(string json, string signature)
{
Event stripeEvent;
try
{
stripeEvent = EventUtility.ConstructEvent(json, signature, _settings.WebhookSecret);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Webhook signature verification failed");
throw;
}
_logger.LogInformation("Processing webhook event: {EventType} ({EventId})", stripeEvent.Type, stripeEvent.Id);
switch (stripeEvent.Type)
{
case EventTypes.CheckoutSessionCompleted:
await HandleCheckoutSessionCompleted(stripeEvent);
break;
case EventTypes.CustomerSubscriptionUpdated:
await HandleSubscriptionUpdated(stripeEvent);
break;
case EventTypes.CustomerSubscriptionDeleted:
await HandleSubscriptionDeleted(stripeEvent);
break;
case EventTypes.InvoicePaymentFailed:
await HandlePaymentFailed(stripeEvent);
break;
default:
_logger.LogDebug("Unhandled webhook event type: {EventType}", stripeEvent.Type);
break;
}
}
private async Task HandleCheckoutSessionCompleted(Event stripeEvent)
{
var session = stripeEvent.Data.Object as Session;
if (session == null)
{
_logger.LogWarning("Could not parse checkout session from event");
return;
}
var userIdString = session.Metadata.GetValueOrDefault("user_id");
var targetPlanString = session.Metadata.GetValueOrDefault("target_plan");
if (string.IsNullOrEmpty(userIdString) || !Guid.TryParse(userIdString, out var userId))
{
_logger.LogWarning("Missing or invalid user_id in checkout session metadata");
return;
}
if (string.IsNullOrEmpty(targetPlanString) || !Enum.TryParse<UserPlan>(targetPlanString, out var targetPlan))
{
_logger.LogWarning("Missing or invalid target_plan in checkout session metadata");
return;
}
var user = await _dbContext.Users.FindAsync(userId);
if (user == null)
{
_logger.LogWarning("User not found for checkout session: {UserId}", userId);
return;
}
user.StripeCustomerId = session.CustomerId;
user.StripeSubscriptionId = session.SubscriptionId;
user.Plan = targetPlan;
user.SubscriptionStatus = "active";
user.ChecksUsedThisMonth = 0;
// Fetch subscription to get period end (from the first item)
if (!string.IsNullOrEmpty(session.SubscriptionId))
{
var stripeSubscriptionService = new Stripe.SubscriptionService();
var stripeSubscription = await stripeSubscriptionService.GetAsync(session.SubscriptionId);
var firstItem = stripeSubscription.Items?.Data?.FirstOrDefault();
if (firstItem != null)
{
user.CurrentPeriodEnd = firstItem.CurrentPeriodEnd;
}
}
await _dbContext.SaveChangesAsync();
_logger.LogInformation(
"User {UserId} upgraded to {Plan} via checkout session {SessionId}",
userId, targetPlan, session.Id);
}
private async Task HandleSubscriptionUpdated(Event stripeEvent)
{
var stripeSubscription = stripeEvent.Data.Object as Stripe.Subscription;
if (stripeSubscription == null)
{
_logger.LogWarning("Could not parse subscription from event");
return;
}
var user = await _dbContext.Users
.FirstOrDefaultAsync(u => u.StripeSubscriptionId == stripeSubscription.Id);
if (user == null)
{
_logger.LogDebug("No user found for subscription: {SubscriptionId}", stripeSubscription.Id);
return;
}
var previousStatus = user.SubscriptionStatus;
var previousPeriodEnd = user.CurrentPeriodEnd;
user.SubscriptionStatus = stripeSubscription.Status;
// Get period end from first subscription item
var firstItem = stripeSubscription.Items?.Data?.FirstOrDefault();
var newPeriodEnd = firstItem?.CurrentPeriodEnd;
user.CurrentPeriodEnd = newPeriodEnd;
// Reset usage if billing period renewed
if (previousPeriodEnd.HasValue &&
newPeriodEnd.HasValue &&
newPeriodEnd.Value > previousPeriodEnd.Value &&
stripeSubscription.Status == "active")
{
user.ChecksUsedThisMonth = 0;
_logger.LogInformation("Reset monthly usage for user {UserId} - new billing period", user.Id);
}
// Handle plan changes from Stripe portal
var planString = stripeSubscription.Metadata.GetValueOrDefault("plan");
if (!string.IsNullOrEmpty(planString) && Enum.TryParse<UserPlan>(planString, out var plan))
{
user.Plan = plan;
}
await _dbContext.SaveChangesAsync();
_logger.LogInformation(
"Subscription updated for user {UserId}: status {Status}, period end {PeriodEnd}",
user.Id, stripeSubscription.Status, newPeriodEnd);
}
private async Task HandleSubscriptionDeleted(Event stripeEvent)
{
var stripeSubscription = stripeEvent.Data.Object as Stripe.Subscription;
if (stripeSubscription == null)
{
_logger.LogWarning("Could not parse subscription from event");
return;
}
var user = await _dbContext.Users
.FirstOrDefaultAsync(u => u.StripeSubscriptionId == stripeSubscription.Id);
if (user == null)
{
_logger.LogDebug("No user found for deleted subscription: {SubscriptionId}", stripeSubscription.Id);
return;
}
user.Plan = UserPlan.Free;
user.StripeSubscriptionId = null;
user.SubscriptionStatus = null;
user.CurrentPeriodEnd = null;
user.ChecksUsedThisMonth = 0;
await _dbContext.SaveChangesAsync();
_logger.LogInformation(
"User {UserId} downgraded to Free plan - subscription {SubscriptionId} deleted",
user.Id, stripeSubscription.Id);
}
private async Task HandlePaymentFailed(Event stripeEvent)
{
var invoice = stripeEvent.Data.Object as Invoice;
if (invoice == null)
{
_logger.LogWarning("Could not parse invoice from event");
return;
}
var user = await _dbContext.Users
.FirstOrDefaultAsync(u => u.StripeCustomerId == invoice.CustomerId);
if (user == null)
{
_logger.LogDebug("No user found for customer: {CustomerId}", invoice.CustomerId);
return;
}
user.SubscriptionStatus = "past_due";
await _dbContext.SaveChangesAsync();
_logger.LogWarning(
"Payment failed for user {UserId}, invoice {InvoiceId}. Subscription marked as past_due.",
user.Id, invoice.Id);
}
}

View File

@@ -0,0 +1,133 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using RealCV.Application.DTOs;
using RealCV.Application.Interfaces;
using RealCV.Domain.Constants;
using RealCV.Domain.Enums;
using RealCV.Infrastructure.Data;
namespace RealCV.Infrastructure.Services;
public sealed class SubscriptionService : ISubscriptionService
{
private readonly ApplicationDbContext _dbContext;
private readonly ILogger<SubscriptionService> _logger;
public SubscriptionService(
ApplicationDbContext dbContext,
ILogger<SubscriptionService> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task<bool> CanPerformCheckAsync(Guid userId)
{
var user = await _dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == userId);
if (user == null)
{
_logger.LogWarning("User not found for quota check: {UserId}", userId);
return false;
}
// Enterprise users have unlimited checks
if (PlanLimits.IsUnlimited(user.Plan))
{
return true;
}
// Check if subscription is in good standing for paid plans
if (user.Plan != UserPlan.Free)
{
if (user.SubscriptionStatus == "canceled" || user.SubscriptionStatus == "unpaid")
{
_logger.LogWarning(
"User {UserId} subscription status is {Status} - denying check",
userId, user.SubscriptionStatus);
return false;
}
}
var limit = PlanLimits.GetMonthlyLimit(user.Plan);
var canPerform = user.ChecksUsedThisMonth < limit;
if (!canPerform)
{
_logger.LogInformation(
"User {UserId} has reached quota: {Used}/{Limit} checks",
userId, user.ChecksUsedThisMonth, limit);
}
return canPerform;
}
public async Task IncrementUsageAsync(Guid userId)
{
var user = await _dbContext.Users.FindAsync(userId);
if (user == null)
{
_logger.LogWarning("User not found for usage increment: {UserId}", userId);
return;
}
user.ChecksUsedThisMonth++;
await _dbContext.SaveChangesAsync();
_logger.LogDebug(
"Incremented usage for user {UserId}: {Count} checks this month",
userId, user.ChecksUsedThisMonth);
}
public async Task ResetUsageAsync(Guid userId)
{
var user = await _dbContext.Users.FindAsync(userId);
if (user == null)
{
_logger.LogWarning("User not found for usage reset: {UserId}", userId);
return;
}
user.ChecksUsedThisMonth = 0;
await _dbContext.SaveChangesAsync();
_logger.LogInformation("Reset monthly usage for user {UserId}", userId);
}
public async Task<SubscriptionInfoDto> GetSubscriptionInfoAsync(Guid userId)
{
var user = await _dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == userId);
if (user == null)
{
_logger.LogWarning("User not found for subscription info: {UserId}", userId);
return new SubscriptionInfoDto
{
Plan = UserPlan.Free,
MonthlyLimit = PlanLimits.GetMonthlyLimit(UserPlan.Free),
DisplayPrice = PlanLimits.GetDisplayPrice(UserPlan.Free)
};
}
var limit = PlanLimits.GetMonthlyLimit(user.Plan);
var isUnlimited = PlanLimits.IsUnlimited(user.Plan);
return new SubscriptionInfoDto
{
Plan = user.Plan,
ChecksUsedThisMonth = user.ChecksUsedThisMonth,
MonthlyLimit = limit,
ChecksRemaining = isUnlimited ? int.MaxValue : Math.Max(0, limit - user.ChecksUsedThisMonth),
IsUnlimited = isUnlimited,
SubscriptionStatus = user.SubscriptionStatus,
CurrentPeriodEnd = user.CurrentPeriodEnd,
HasActiveSubscription = !string.IsNullOrEmpty(user.StripeSubscriptionId) &&
(user.SubscriptionStatus == "active" || user.SubscriptionStatus == "past_due"),
DisplayPrice = PlanLimits.GetDisplayPrice(user.Plan)
};
}
}

View File

@@ -1,8 +1,8 @@
using Microsoft.Extensions.Logging;
using TrueCV.Application.Interfaces;
using TrueCV.Application.Models;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
namespace TrueCV.Infrastructure.Services;
namespace RealCV.Infrastructure.Services;
public sealed class TimelineAnalyserService : ITimelineAnalyserService
{

View File

@@ -1,8 +1,8 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using TrueCV.Application.Interfaces;
using RealCV.Application.Interfaces;
namespace TrueCV.Infrastructure.Services;
namespace RealCV.Infrastructure.Services;
public sealed class UserContextService : IUserContextService
{

View File

@@ -7,7 +7,7 @@
<base href="/" />
<link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="TrueCV.Web.styles.css" />
<link rel="stylesheet" href="RealCV.Web.styles.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="InteractiveServer" />
</head>

View File

@@ -1,10 +1,10 @@
@inherits LayoutComponentBase
<div class="d-flex flex-column min-vh-100">
<nav class="navbar navbar-expand-lg navbar-light shadow-sm" style="background-color: var(--truecv-bg-surface);">
<nav class="navbar navbar-expand-lg navbar-light shadow-sm" style="background-color: var(--realcv-bg-surface);">
<div class="container">
<a class="navbar-brand fw-bold" href="/">
<img src="images/TrueCV_Logo.png" alt="TrueCV" style="height: 95px;" />
<img src="images/RealCV_Logo_Transparent.png" alt="RealCV" style="height: 95px;" />
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
@@ -77,9 +77,9 @@
@Body
</main>
<footer class="text-light py-4 mt-auto" style="background-color: var(--truecv-footer-bg);">
<footer class="text-light py-4 mt-auto" style="background-color: var(--realcv-footer-bg);">
<div class="container text-center">
<p class="mb-0">&copy; @DateTime.Now.Year TrueCV. All rights reserved.</p>
<p class="mb-0">&copy; @DateTime.Now.Year RealCV. All rights reserved.</p>
</div>
</footer>
</div>

View File

@@ -0,0 +1,220 @@
@page "/account/billing"
@attribute [Authorize]
@rendermode InteractiveServer
@inject ISubscriptionService SubscriptionService
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject NavigationManager NavigationManager
<PageTitle>Billing - RealCV</PageTitle>
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="mb-4">
<h1 class="fw-bold mb-1">Billing & Subscription</h1>
<p class="text-muted">Manage your subscription and view usage</p>
</div>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@_errorMessage
<button type="button" class="btn-close" @onclick="() => _errorMessage = null"></button>
</div>
}
@if (_isLoading)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
}
else if (_subscription != null)
{
<!-- Current Plan Card -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<h5 class="fw-bold mb-1">Current Plan</h5>
<p class="text-muted small mb-0">Your active subscription details</p>
</div>
<span class="badge bg-primary-subtle text-primary px-3 py-2 fs-6">
@_subscription.Plan
</span>
</div>
<div class="row g-4">
<div class="col-md-6">
<div class="p-3 rounded" style="background: var(--realcv-bg-muted);">
<div class="small text-muted mb-1">Price</div>
<div class="fw-bold fs-4">@_subscription.DisplayPrice</div>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded" style="background: var(--realcv-bg-muted);">
<div class="small text-muted mb-1">Status</div>
<div class="fw-bold fs-4">
@if (_subscription.HasActiveSubscription)
{
<span class="text-success">Active</span>
}
else if (_subscription.Plan == RealCV.Domain.Enums.UserPlan.Free)
{
<span class="text-muted">Free Tier</span>
}
else
{
<span class="text-warning">@(_subscription.SubscriptionStatus ?? "Inactive")</span>
}
</div>
</div>
</div>
</div>
@if (_subscription.CurrentPeriodEnd.HasValue)
{
<div class="mt-3 small text-muted">
Next billing date: <strong>@_subscription.CurrentPeriodEnd.Value.ToString("dd MMMM yyyy")</strong>
</div>
}
<div class="d-flex gap-2 mt-4">
<a href="/pricing" class="btn btn-primary">
@if (_subscription.Plan == RealCV.Domain.Enums.UserPlan.Free)
{
<span>Upgrade Plan</span>
}
else
{
<span>Change Plan</span>
}
</a>
@if (_subscription.HasActiveSubscription)
{
<form action="/api/billing/portal" method="post">
<button type="submit" class="btn btn-outline-secondary">
Manage Subscription
</button>
</form>
}
</div>
</div>
</div>
<!-- Usage Card -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<h5 class="fw-bold mb-4">Usage This Month</h5>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="text-muted">CV Checks</span>
<span class="fw-semibold">
@if (_subscription.IsUnlimited)
{
<span>@_subscription.ChecksUsedThisMonth used (Unlimited)</span>
}
else
{
<span>@_subscription.ChecksUsedThisMonth / @_subscription.MonthlyLimit</span>
}
</span>
</div>
@if (!_subscription.IsUnlimited)
{
var percentage = _subscription.MonthlyLimit > 0
? Math.Min(100, (_subscription.ChecksUsedThisMonth * 100) / _subscription.MonthlyLimit)
: 0;
var progressClass = percentage >= 90 ? "bg-danger" : percentage >= 75 ? "bg-warning" : "bg-primary";
<div class="progress" style="height: 10px;">
<div class="progress-bar @progressClass" role="progressbar" style="width: @percentage%"></div>
</div>
@if (_subscription.ChecksRemaining <= 0)
{
<div class="alert alert-warning mt-3 mb-0 py-2">
<small>
You've used all your checks this month.
<a href="/pricing" class="alert-link">Upgrade your plan</a> for more.
</small>
</div>
}
else if (_subscription.ChecksRemaining <= 3 && _subscription.Plan != RealCV.Domain.Enums.UserPlan.Free)
{
<div class="alert alert-info mt-3 mb-0 py-2">
<small>
You have @_subscription.ChecksRemaining checks remaining this month.
</small>
</div>
}
}
</div>
</div>
</div>
<!-- Manage Billing Card (for paid users) -->
@if (_subscription.HasActiveSubscription)
{
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<h5 class="fw-bold mb-3">Billing Management</h5>
<p class="text-muted mb-4">
Use the Stripe Customer Portal to update your payment method, view invoices, or cancel your subscription.
</p>
<form action="/api/billing/portal" method="post">
<button type="submit" class="btn btn-outline-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16">
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4zm2-1a1 1 0 0 0-1 1v1h14V4a1 1 0 0 0-1-1H2zm13 4H1v5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V7z"/>
<path d="M2 10a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-1z"/>
</svg>
Open Billing Portal
</button>
</form>
</div>
</div>
}
}
</div>
</div>
</div>
@code {
private SubscriptionInfoDto? _subscription;
private bool _isLoading = true;
private string? _errorMessage;
protected override async Task OnInitializedAsync()
{
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("error", out var error))
{
_errorMessage = error == "portal_failed"
? "Unable to open billing portal. Please try again."
: error.ToString();
}
try
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userIdClaim) && Guid.TryParse(userIdClaim, out var userId))
{
_subscription = await SubscriptionService.GetSubscriptionInfoAsync(userId);
}
}
catch (Exception)
{
_errorMessage = "Unable to load subscription information.";
}
finally
{
_isLoading = false;
}
}
}

View File

@@ -1,14 +1,14 @@
@page "/account/login"
@using TrueCV.Web.Components.Layout
@using RealCV.Web.Components.Layout
@layout MainLayout
@using Microsoft.AspNetCore.Identity
@using TrueCV.Infrastructure.Identity
@using RealCV.Infrastructure.Identity
@inject SignInManager<ApplicationUser> SignInManager
@inject NavigationManager NavigationManager
<PageTitle>Login - TrueCV</PageTitle>
<PageTitle>Login - RealCV</PageTitle>
<div class="auth-container">
<!-- Left side - Form -->
@@ -16,7 +16,7 @@
<div class="auth-form-wrapper">
<div class="text-center mb-4">
<a href="/">
<img src="images/TrueCV_Logo.png" alt="TrueCV" class="auth-logo" />
<img src="images/RealCV_Logo_Transparent.png" alt="RealCV" class="auth-logo" />
</a>
</div>
@@ -80,7 +80,7 @@
</form>
<div class="auth-divider">
<span>New to TrueCV?</span>
<span>New to RealCV?</span>
</div>
<div class="text-center">
@@ -123,7 +123,7 @@
<div class="auth-testimonial">
<blockquote>
"TrueCV has transformed our hiring process. We catch discrepancies we would have missed before."
"RealCV has transformed our hiring process. We catch discrepancies we would have missed before."
</blockquote>
<cite>- HR Director, Tech Company</cite>
</div>

View File

@@ -1,16 +1,16 @@
@page "/account/register"
@using TrueCV.Web.Components.Layout
@using RealCV.Web.Components.Layout
@layout MainLayout
@rendermode InteractiveServer
@using Microsoft.AspNetCore.Identity
@using TrueCV.Infrastructure.Identity
@using RealCV.Infrastructure.Identity
@inject UserManager<ApplicationUser> UserManager
@inject SignInManager<ApplicationUser> SignInManager
@inject NavigationManager NavigationManager
<PageTitle>Register - TrueCV</PageTitle>
<PageTitle>Register - RealCV</PageTitle>
<div class="auth-container">
<!-- Left side - Form -->
@@ -18,7 +18,7 @@
<div class="auth-form-wrapper">
<div class="text-center mb-4">
<a href="/">
<img src="images/TrueCV_Logo.png" alt="TrueCV" class="auth-logo" />
<img src="images/RealCV_Logo_Transparent.png" alt="RealCV" class="auth-logo" />
</a>
</div>
@@ -157,7 +157,7 @@
<div class="auth-testimonial">
<blockquote>
"We reduced bad hires by 40% in the first quarter using TrueCV."
"We reduced bad hires by 40% in the first quarter using RealCV."
</blockquote>
<cite>- Recruitment Manager, Financial Services</cite>
</div>

View File

@@ -0,0 +1,235 @@
@page "/account/settings"
@attribute [Authorize]
@rendermode InteractiveServer
@using Microsoft.AspNetCore.Identity
@using RealCV.Infrastructure.Identity
@inject UserManager<ApplicationUser> UserManager
@inject SignInManager<ApplicationUser> SignInManager
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject NavigationManager NavigationManager
@inject ILogger<Settings> Logger
<PageTitle>Account Settings - RealCV</PageTitle>
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="mb-4">
<h1 class="fw-bold mb-1">Account Settings</h1>
<p class="text-muted">Manage your account details and security</p>
</div>
@if (!string.IsNullOrEmpty(_successMessage))
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@_successMessage
<button type="button" class="btn-close" @onclick="() => _successMessage = null"></button>
</div>
}
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@_errorMessage
<button type="button" class="btn-close" @onclick="() => _errorMessage = null"></button>
</div>
}
<!-- Profile Section -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<h5 class="fw-bold mb-4">Profile Information</h5>
<div class="mb-3">
<label class="form-label small text-muted">Email Address</label>
<input type="email" class="form-control" value="@_userEmail" disabled />
<small class="text-muted">Email cannot be changed</small>
</div>
<div class="mb-3">
<label class="form-label small text-muted">Current Plan</label>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-primary-subtle text-primary px-3 py-2">@_userPlan</span>
<a href="/account/billing" class="btn btn-sm btn-link">Manage</a>
</div>
</div>
<div class="mb-0">
<label class="form-label small text-muted">Member Since</label>
<p class="mb-0">@_memberSince?.ToString("dd MMMM yyyy")</p>
</div>
</div>
</div>
<!-- Change Password Section -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<h5 class="fw-bold mb-4">Change Password</h5>
<EditForm Model="_passwordModel" OnValidSubmit="ChangePassword" FormName="change-password">
<DataAnnotationsValidator />
<div class="mb-3">
<label for="currentPassword" class="form-label small text-muted">Current Password</label>
<InputText type="password" id="currentPassword" class="form-control" @bind-Value="_passwordModel.CurrentPassword" />
<ValidationMessage For="() => _passwordModel.CurrentPassword" class="text-danger small" />
</div>
<div class="mb-3">
<label for="newPassword" class="form-label small text-muted">New Password</label>
<InputText type="password" id="newPassword" class="form-control" @bind-Value="_passwordModel.NewPassword" />
<ValidationMessage For="() => _passwordModel.NewPassword" class="text-danger small" />
<small class="text-muted">Minimum 12 characters with uppercase, lowercase, number, and special character</small>
</div>
<div class="mb-4">
<label for="confirmPassword" class="form-label small text-muted">Confirm New Password</label>
<InputText type="password" id="confirmPassword" class="form-control" @bind-Value="_passwordModel.ConfirmPassword" />
<ValidationMessage For="() => _passwordModel.ConfirmPassword" class="text-danger small" />
</div>
<button type="submit" class="btn btn-primary" disabled="@_isChangingPassword">
@if (_isChangingPassword)
{
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
}
Update Password
</button>
</EditForm>
</div>
</div>
<!-- Quick Links -->
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<h5 class="fw-bold mb-4">Quick Links</h5>
<div class="list-group list-group-flush">
<a href="/account/billing" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center px-0 border-0">
<div>
<strong>Billing & Subscription</strong>
<p class="mb-0 small text-muted">Manage your plan and payment method</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
</svg>
</a>
<a href="/dashboard" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center px-0 border-0">
<div>
<strong>Dashboard</strong>
<p class="mb-0 small text-muted">View your CV verification history</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
</svg>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
@code {
private string? _userEmail;
private string _userPlan = "Free";
private DateTime? _memberSince;
private string? _successMessage;
private string? _errorMessage;
private bool _isChangingPassword;
private PasswordChangeModel _passwordModel = new();
protected override async Task OnInitializedAsync()
{
try
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userIdClaim) && Guid.TryParse(userIdClaim, out var userId))
{
var user = await UserManager.FindByIdAsync(userId.ToString());
if (user != null)
{
_userEmail = user.Email;
_userPlan = user.Plan.ToString();
// Lockout end date is used as a proxy; in a real app you might have a CreatedAt field
_memberSince = DateTime.UtcNow.AddMonths(-1); // Placeholder
}
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error loading user settings");
_errorMessage = "Unable to load account information.";
}
}
private async Task ChangePassword()
{
if (_isChangingPassword) return;
_isChangingPassword = true;
_errorMessage = null;
_successMessage = null;
try
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
{
_errorMessage = "Unable to identify user.";
return;
}
var user = await UserManager.FindByIdAsync(userId.ToString());
if (user == null)
{
_errorMessage = "User not found.";
return;
}
var result = await UserManager.ChangePasswordAsync(
user,
_passwordModel.CurrentPassword,
_passwordModel.NewPassword);
if (result.Succeeded)
{
_successMessage = "Password updated successfully.";
_passwordModel = new PasswordChangeModel();
await SignInManager.RefreshSignInAsync(user);
}
else
{
_errorMessage = string.Join(" ", result.Errors.Select(e => e.Description));
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error changing password");
_errorMessage = "An error occurred. Please try again.";
}
finally
{
_isChangingPassword = false;
}
}
private class PasswordChangeModel
{
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Current password is required")]
public string CurrentPassword { get; set; } = "";
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "New password is required")]
[System.ComponentModel.DataAnnotations.MinLength(12, ErrorMessage = "Password must be at least 12 characters")]
public string NewPassword { get; set; } = "";
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your new password")]
[System.ComponentModel.DataAnnotations.Compare("NewPassword", ErrorMessage = "Passwords do not match")]
public string ConfirmPassword { get; set; } = "";
}
}

View File

@@ -3,11 +3,12 @@
@rendermode InteractiveServer
@inject ICVCheckService CVCheckService
@inject ISubscriptionService SubscriptionService
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject ILogger<Check> Logger
<PageTitle>Upload CVs - TrueCV</PageTitle>
<PageTitle>Upload CVs - RealCV</PageTitle>
<div class="container py-5">
<div class="row justify-content-center">
@@ -21,9 +22,47 @@
</svg>
<span>For UK employment history</span>
</div>
@if (_subscription != null)
{
<div class="mt-3">
@if (_subscription.IsUnlimited)
{
<span class="badge bg-success-subtle text-success px-3 py-2">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M5.798 9.137a.5.5 0 0 1-.03.706l-.01.009a.5.5 0 1 1-.676-.737l.01-.009a.5.5 0 0 1 .706.03zm3.911-3.911a.5.5 0 0 1-.03.706l-.01.009a.5.5 0 1 1-.676-.737l.01-.009a.5.5 0 0 1 .706.03z"/>
<path fill-rule="evenodd" d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zM2 8a6 6 0 1 1 12 0A6 6 0 0 1 2 8z"/>
<path d="M2.472 3.528a.5.5 0 0 1 .707 0l9.9 9.9a.5.5 0 0 1-.707.707l-9.9-9.9a.5.5 0 0 1 0-.707z"/>
</svg>
Unlimited checks
</span>
}
else
{
<span class="badge @(_subscription.ChecksRemaining <= 0 ? "bg-danger-subtle text-danger" : _subscription.ChecksRemaining <= 3 ? "bg-warning-subtle text-warning" : "bg-primary-subtle text-primary") px-3 py-2">
@_subscription.ChecksRemaining of @_subscription.MonthlyLimit checks remaining
</span>
}
</div>
}
</div>
@if (!string.IsNullOrEmpty(_errorMessage))
@if (_quotaExceeded)
{
<div class="alert alert-warning" role="alert">
<h5 class="alert-heading fw-bold mb-2">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</svg>
Monthly Limit Reached
</h5>
<p class="mb-3">You've used all your CV checks for this month. Upgrade your plan to continue verifying CVs.</p>
<a href="/pricing" class="btn btn-warning">
Upgrade Plan
</a>
</div>
}
else if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@_errorMessage
@@ -186,21 +225,21 @@
<style>
.upload-area {
border: 2px dashed var(--truecv-gray-300);
border: 2px dashed var(--realcv-gray-300);
border-radius: 16px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: linear-gradient(180deg, var(--truecv-bg-surface) 0%, var(--truecv-bg-muted) 100%);
background: linear-gradient(180deg, var(--realcv-bg-surface) 0%, var(--realcv-bg-muted) 100%);
}
.upload-area:hover {
border-color: var(--truecv-primary);
border-color: var(--realcv-primary);
background: linear-gradient(180deg, #e8f1fa 0%, #d4e4f4 100%);
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(59, 111, 212, 0.1);
}
.upload-area.dragging {
border-color: var(--truecv-primary);
border-color: var(--realcv-primary);
background: linear-gradient(180deg, #d4e4f4 0%, #c5d9ef 100%);
border-style: solid;
transform: scale(1.02);
@@ -209,7 +248,7 @@
.upload-icon {
width: 80px;
height: 80px;
background: linear-gradient(135deg, var(--truecv-primary) 0%, var(--truecv-primary-dark) 100%);
background: linear-gradient(135deg, var(--realcv-primary) 0%, var(--realcv-primary-dark) 100%);
border-radius: 20px;
display: flex;
align-items: center;
@@ -232,16 +271,16 @@
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid var(--truecv-gray-200);
border: 1px solid var(--realcv-gray-200);
border-radius: 12px;
padding: 1rem;
margin-bottom: 0.75rem;
background: var(--truecv-bg-surface);
background: var(--realcv-bg-surface);
transition: all 0.2s ease;
}
.file-list-item:hover {
border-color: var(--truecv-primary);
border-color: var(--realcv-primary);
box-shadow: 0 4px 12px rgba(59, 111, 212, 0.08);
}
@@ -261,7 +300,7 @@
.file-type-icon.docx {
background: linear-gradient(135deg, #e3ecf7 0%, #d4e4f4 100%);
color: var(--truecv-primary);
color: var(--realcv-primary);
}
.security-info {
@@ -273,14 +312,14 @@
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--truecv-bg-muted);
border-radius: var(--truecv-radius);
background: var(--realcv-bg-muted);
border-radius: var(--realcv-radius);
font-size: 0.875rem;
color: var(--truecv-gray-600);
color: var(--realcv-gray-600);
}
.security-badge svg {
color: var(--truecv-verified);
color: var(--realcv-verified);
}
@@media (max-width: 576px) {
@@ -320,6 +359,8 @@
private int _currentFileIndex;
private int _totalFiles;
private string? _currentFileName;
private bool _quotaExceeded;
private SubscriptionInfoDto? _subscription;
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
private const int MaxFileCount = 50; // Maximum files per batch
@@ -331,6 +372,25 @@
// Buffered file to prevent stale IBrowserFile references
private sealed record BufferedFile(string Name, long Size, byte[] Data);
protected override async Task OnInitializedAsync()
{
try
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userIdClaim) && Guid.TryParse(userIdClaim, out var userId))
{
_subscription = await SubscriptionService.GetSubscriptionInfoAsync(userId);
_quotaExceeded = !_subscription.IsUnlimited && _subscription.ChecksRemaining <= 0;
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error loading subscription info");
}
}
private void HandleDragEnter()
{
_isDragging = true;
@@ -466,6 +526,13 @@
await CVCheckService.CreateCheckAsync(userId, memoryStream, file.Name);
}
catch (RealCV.Domain.Exceptions.QuotaExceededException)
{
_quotaExceeded = true;
_errorMessage = null;
failedFiles.Add($"{file.Name} (quota exceeded)");
break; // Stop processing further files
}
catch (Exception ex)
{
Logger.LogError(ex, "Error uploading CV: {FileName}", file.Name);

Some files were not shown because too many files have changed in this diff Show More