5 Commits

Author SHA1 Message Date
0457271b57 feat: Expand UK institution recognition with professional bodies and variations
- Add CIPD, CIMA, ACCA, ICAEW, ICAS, CII, CIPS, CMI as recognized professional bodies
- Add 40+ university name variations (e.g., "Hull University" → "University of Hull")
- Add automatic "X University" ↔ "University of X" pattern transformation
- Improves education verification accuracy for common CV name formats

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:42:35 +00:00
4337f7a381 feat: Add FE colleges and improve education institution matching
- Add notable Further Education colleges to recognised institutions
  (Loughborough College, Hartpury College, etc.)
- Improve compound name matching to handle separators (/, &, -, ,)
  so "Loughborough College/Motorsport UK Academy" now matches

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:53:56 +00:00
f49d107061 feat: Add Education Verification section and use neutral language
- Add Education Verification section to report UI showing institution
  verification status, qualifications, and dates
- Add differentiated icons for Information flags (career, timeline,
  management, education types)
- Change potentially defamatory language to neutral terms:
  - "Diploma Mill" → "Not Accredited"
  - "Suspicious" → "Unrecognised"
  - Flag descriptions now recommend manual verification rather than
    making definitive claims about institution legitimacy

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:22:14 +00:00
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 4037 additions and 653 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 WORKDIR /src
# Copy solution and project files first for better layer caching # Copy solution and project files first for better layer caching
COPY TrueCV.sln ./ COPY RealCV.sln ./
COPY src/TrueCV.Domain/TrueCV.Domain.csproj src/TrueCV.Domain/ COPY src/RealCV.Domain/RealCV.Domain.csproj src/RealCV.Domain/
COPY src/TrueCV.Application/TrueCV.Application.csproj src/TrueCV.Application/ COPY src/RealCV.Application/RealCV.Application.csproj src/RealCV.Application/
COPY src/TrueCV.Infrastructure/TrueCV.Infrastructure.csproj src/TrueCV.Infrastructure/ COPY src/RealCV.Infrastructure/RealCV.Infrastructure.csproj src/RealCV.Infrastructure/
COPY src/TrueCV.Web/TrueCV.Web.csproj src/TrueCV.Web/ COPY src/RealCV.Web/RealCV.Web.csproj src/RealCV.Web/
# Restore dependencies # Restore dependencies
RUN dotnet restore RUN dotnet restore
@@ -16,7 +16,7 @@ RUN dotnet restore
COPY src/ src/ COPY src/ src/
# Build and publish # Build and publish
WORKDIR /src/src/TrueCV.Web WORKDIR /src/src/RealCV.Web
RUN dotnet publish -c Release -o /app/publish --no-restore RUN dotnet publish -c Release -o /app/publish --no-restore
# Runtime stage # 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 CMD curl -f http://localhost:8080/health || exit 1
# Start the app # 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" ENV PATH="$PATH:/root/.dotnet/tools"
# Copy solution and project files # Copy solution and project files
COPY TrueCV.sln ./ COPY RealCV.sln ./
COPY src/TrueCV.Domain/TrueCV.Domain.csproj src/TrueCV.Domain/ COPY src/RealCV.Domain/RealCV.Domain.csproj src/RealCV.Domain/
COPY src/TrueCV.Application/TrueCV.Application.csproj src/TrueCV.Application/ COPY src/RealCV.Application/RealCV.Application.csproj src/RealCV.Application/
COPY src/TrueCV.Infrastructure/TrueCV.Infrastructure.csproj src/TrueCV.Infrastructure/ COPY src/RealCV.Infrastructure/RealCV.Infrastructure.csproj src/RealCV.Infrastructure/
COPY src/TrueCV.Web/TrueCV.Web.csproj src/TrueCV.Web/ COPY src/RealCV.Web/RealCV.Web.csproj src/RealCV.Web/
# Restore dependencies # Restore dependencies
RUN dotnet restore RUN dotnet restore
@@ -20,7 +20,7 @@ RUN dotnet restore
COPY src/ src/ COPY src/ src/
# Build the project # 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 # 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 MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{F25C3740-9240-46DF-BC34-985BC577216B}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{F25C3740-9240-46DF-BC34-985BC577216B}"
EndProject 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 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 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 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 EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{80890010-EDA6-418B-AD6C-5A9D875594C4}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{80890010-EDA6-418B-AD6C-5A9D875594C4}"
EndProject 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 EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution 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 #!/bin/bash
# TrueCV Deployment Script # RealCV Deployment Script
# Run this from your development machine to deploy to a Linux server # Run this from your development machine to deploy to a Linux server
set -e set -e
@@ -7,8 +7,8 @@ set -e
# Configuration - UPDATE THESE VALUES # Configuration - UPDATE THESE VALUES
SERVER_USER="deploy" SERVER_USER="deploy"
SERVER_HOST="your-server.com" SERVER_HOST="your-server.com"
SERVER_PATH="/var/www/truecv" SERVER_PATH="/var/www/realcv"
DOMAIN="truecv.yourdomain.com" DOMAIN="realcv.yourdomain.com"
# Colors for output # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
@@ -16,7 +16,7 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
echo -e "${GREEN}=== TrueCV Deployment Script ===${NC}" echo -e "${GREEN}=== RealCV Deployment Script ===${NC}"
# Check if configuration is set # Check if configuration is set
if [[ "$SERVER_HOST" == "your-server.com" ]]; then if [[ "$SERVER_HOST" == "your-server.com" ]]; then
@@ -27,15 +27,15 @@ fi
# Step 1: Build and publish # Step 1: Build and publish
echo -e "${YELLOW}Step 1: Publishing application...${NC}" echo -e "${YELLOW}Step 1: Publishing application...${NC}"
cd "$(dirname "$0")/.." 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 # Step 2: Create deployment package
echo -e "${YELLOW}Step 2: Creating deployment package...${NC}" 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 # Step 3: Transfer to server
echo -e "${YELLOW}Step 3: Transferring to server...${NC}" 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 # Step 4: Deploy on server
echo -e "${YELLOW}Step 4: Deploying on server...${NC}" echo -e "${YELLOW}Step 4: Deploying on server...${NC}"
@@ -43,23 +43,23 @@ ssh ${SERVER_USER}@${SERVER_HOST} << 'ENDSSH'
set -e set -e
# Stop the service if running # Stop the service if running
sudo systemctl stop truecv 2>/dev/null || true sudo systemctl stop realcv 2>/dev/null || true
# Backup current deployment # Backup current deployment
if [ -d "/var/www/truecv" ]; then if [ -d "/var/www/realcv" ]; then
sudo mv /var/www/truecv /var/www/truecv.backup.$(date +%Y%m%d_%H%M%S) sudo mv /var/www/realcv /var/www/realcv.backup.$(date +%Y%m%d_%H%M%S)
fi fi
# Create directory and extract # Create directory and extract
sudo mkdir -p /var/www/truecv sudo mkdir -p /var/www/realcv
sudo tar -xzf /tmp/truecv-release.tar.gz -C /var/www/truecv sudo tar -xzf /tmp/realcv-release.tar.gz -C /var/www/realcv
sudo chown -R www-data:www-data /var/www/truecv sudo chown -R www-data:www-data /var/www/realcv
# Start the service # Start the service
sudo systemctl start truecv sudo systemctl start realcv
# Clean up # Clean up
rm /tmp/truecv-release.tar.gz rm /tmp/realcv-release.tar.gz
echo "Deployment complete on server" echo "Deployment complete on server"
ENDSSH ENDSSH
@@ -67,14 +67,14 @@ ENDSSH
# Step 5: Verify deployment # Step 5: Verify deployment
echo -e "${YELLOW}Step 5: Verifying deployment...${NC}" echo -e "${YELLOW}Step 5: Verifying deployment...${NC}"
sleep 3 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 "${GREEN}=== Deployment successful! ===${NC}"
echo -e "Site should be available at: https://${DOMAIN}" echo -e "Site should be available at: https://${DOMAIN}"
else 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 fi
# Cleanup local files # Cleanup local files
rm -f deploy/truecv-release.tar.gz rm -f deploy/realcv-release.tar.gz
echo -e "${GREEN}Done!${NC}" echo -e "${GREEN}Done!${NC}"

View File

@@ -1,11 +1,11 @@
#!/bin/bash #!/bin/bash
# TrueCV Server Setup Script # RealCV Server Setup Script
# Run this ONCE on a fresh Linux server (Ubuntu 22.04/24.04) # Run this ONCE on a fresh Linux server (Ubuntu 22.04/24.04)
set -e set -e
# Configuration - UPDATE THESE VALUES # Configuration - UPDATE THESE VALUES
DOMAIN="truecv.yourdomain.com" DOMAIN="realcv.yourdomain.com"
DB_PASSWORD="YourStrong!Password123" DB_PASSWORD="YourStrong!Password123"
ADMIN_EMAIL="admin@yourdomain.com" ADMIN_EMAIL="admin@yourdomain.com"
@@ -15,7 +15,7 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
NC='\033[0m' NC='\033[0m'
echo -e "${GREEN}=== TrueCV Server Setup ===${NC}" echo -e "${GREEN}=== RealCV Server Setup ===${NC}"
# Check if running as root # Check if running as root
if [[ $EUID -ne 0 ]]; then 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' \ docker run -e 'ACCEPT_EULA=Y' \
-e "SA_PASSWORD=${DB_PASSWORD}" \ -e "SA_PASSWORD=${DB_PASSWORD}" \
-p 127.0.0.1:1433:1433 \ -p 127.0.0.1:1433:1433 \
--name truecv-sql \ --name realcv-sql \
--restart unless-stopped \ --restart unless-stopped \
-v truecv-sqldata:/var/opt/mssql \ -v realcv-sqldata:/var/opt/mssql \
-d mcr.microsoft.com/mssql/server:2022-latest -d mcr.microsoft.com/mssql/server:2022-latest
echo "Waiting for SQL Server to start..." echo "Waiting for SQL Server to start..."
sleep 30 sleep 30
# Create the database # 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 \ -S localhost -U SA -P "${DB_PASSWORD}" -C \
-Q "CREATE DATABASE TrueCV" -Q "CREATE DATABASE RealCV"
# Step 6: Create application directory # Step 6: Create application directory
echo -e "${YELLOW}Step 6: Creating application directory...${NC}" echo -e "${YELLOW}Step 6: Creating application directory...${NC}"
mkdir -p /var/www/truecv mkdir -p /var/www/realcv
chown -R www-data:www-data /var/www/truecv chown -R www-data:www-data /var/www/realcv
# Step 7: Create systemd service # Step 7: Create systemd service
echo -e "${YELLOW}Step 7: Creating systemd service...${NC}" echo -e "${YELLOW}Step 7: Creating systemd service...${NC}"
cat > /etc/systemd/system/truecv.service << EOF cat > /etc/systemd/system/realcv.service << EOF
[Unit] [Unit]
Description=TrueCV Web Application Description=RealCV Web Application
After=network.target docker.service After=network.target docker.service
Requires=docker.service Requires=docker.service
[Service] [Service]
WorkingDirectory=/var/www/truecv WorkingDirectory=/var/www/realcv
ExecStart=/usr/bin/dotnet /var/www/truecv/TrueCV.Web.dll ExecStart=/usr/bin/dotnet /var/www/realcv/RealCV.Web.dll
Restart=always Restart=always
RestartSec=10 RestartSec=10
KillSignal=SIGINT KillSignal=SIGINT
SyslogIdentifier=truecv SyslogIdentifier=realcv
User=www-data User=www-data
Environment=ASPNETCORE_ENVIRONMENT=Production Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=ASPNETCORE_URLS=http://localhost:5000 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] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
systemctl daemon-reload systemctl daemon-reload
systemctl enable truecv systemctl enable realcv
# Step 8: Configure Nginx # Step 8: Configure Nginx
echo -e "${YELLOW}Step 8: Configuring Nginx...${NC}" echo -e "${YELLOW}Step 8: Configuring Nginx...${NC}"
cat > /etc/nginx/sites-available/truecv << EOF cat > /etc/nginx/sites-available/realcv << EOF
server { server {
listen 80; listen 80;
server_name ${DOMAIN}; server_name ${DOMAIN};
@@ -122,7 +122,7 @@ server {
} }
EOF 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 rm -f /etc/nginx/sites-enabled/default
nginx -t nginx -t
systemctl reload nginx 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 "3. Run SSL setup: certbot --nginx -d ${DOMAIN}"
echo "" echo ""
echo "Useful commands:" echo "Useful commands:"
echo " sudo systemctl status truecv - Check app status" echo " sudo systemctl status realcv - Check app status"
echo " sudo journalctl -u truecv -f - View app logs" echo " sudo journalctl -u realcv -f - View app logs"
echo " docker logs truecv-sql - View SQL Server logs" echo " docker logs realcv-sql - View SQL Server logs"
echo "" echo ""
echo -e "${YELLOW}Database connection string:${NC}" 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' version: '3.8'
services: services:
# TrueCV Web Application # RealCV Web Application
truecv-web: realcv-web:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: truecv-web container_name: realcv-web
ports: ports:
- "5000:8080" - "5000:8080"
environment: environment:
- ASPNETCORE_ENVIRONMENT=Development - ASPNETCORE_ENVIRONMENT=Development
- 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;
- ConnectionStrings__HangfireConnection=Server=sqlserver;Database=TrueCV_Hangfire;User Id=sa;Password=TrueCV_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__ConnectionString=DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1;
- AzureBlob__ContainerName=cv-uploads - AzureBlob__ContainerName=cv-uploads
- CompaniesHouse__BaseUrl=https://api.company-information.service.gov.uk - CompaniesHouse__BaseUrl=https://api.company-information.service.gov.uk
@@ -24,25 +24,25 @@ services:
azurite: azurite:
condition: service_started condition: service_started
networks: networks:
- truecv-network - realcv-network
restart: unless-stopped restart: unless-stopped
# SQL Server Database # SQL Server Database
sqlserver: sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest image: mcr.microsoft.com/mssql/server:2022-latest
container_name: truecv-sqlserver container_name: realcv-sqlserver
ports: ports:
- "1433:1433" - "1433:1433"
environment: environment:
- ACCEPT_EULA=Y - ACCEPT_EULA=Y
- MSSQL_SA_PASSWORD=TrueCV_P@ssw0rd! - MSSQL_SA_PASSWORD=RealCV_P@ssw0rd!
- MSSQL_PID=Developer - MSSQL_PID=Developer
volumes: volumes:
- sqlserver-data:/var/opt/mssql - sqlserver-data:/var/opt/mssql
networks: networks:
- truecv-network - realcv-network
healthcheck: 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 interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -52,7 +52,7 @@ services:
# Azure Storage Emulator (Azurite) # Azure Storage Emulator (Azurite)
azurite: azurite:
image: mcr.microsoft.com/azure-storage/azurite:latest image: mcr.microsoft.com/azure-storage/azurite:latest
container_name: truecv-azurite container_name: realcv-azurite
ports: ports:
- "10000:10000" # Blob service - "10000:10000" # Blob service
- "10001:10001" # Queue service - "10001:10001" # Queue service
@@ -61,7 +61,7 @@ services:
- azurite-data:/data - 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" command: "azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0 --location /data --debug /data/debug.log"
networks: networks:
- truecv-network - realcv-network
restart: unless-stopped restart: unless-stopped
# Database initialization (runs migrations) # Database initialization (runs migrations)
@@ -69,18 +69,18 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.migrations dockerfile: Dockerfile.migrations
container_name: truecv-db-init container_name: realcv-db-init
environment: 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: depends_on:
sqlserver: sqlserver:
condition: service_healthy condition: service_healthy
networks: networks:
- truecv-network - realcv-network
restart: "no" restart: "no"
networks: networks:
truecv-network: realcv-network:
driver: bridge driver: bridge
volumes: 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 public sealed record CVCheckDto
{ {

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.DTOs; namespace RealCV.Application.DTOs;
public sealed record CompanySearchResult 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> /// <summary>
/// Known diploma mills and fake educational institutions. /// Known diploma mills and fake educational institutions.

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Data; namespace RealCV.Application.Data;
/// <summary> /// <summary>
/// List of recognised UK higher education institutions. /// List of recognised UK higher education institutions.
@@ -156,6 +156,24 @@ public static class UKInstitutions
"University for the Creative Arts", "University for the Creative Arts",
"Ravensbourne University London", "Ravensbourne University London",
// Professional Bodies (accredited qualification-awarding)
"CIPD",
"Chartered Institute of Personnel and Development",
"CIMA",
"Chartered Institute of Management Accountants",
"ACCA",
"Association of Chartered Certified Accountants",
"ICAEW",
"Institute of Chartered Accountants in England and Wales",
"ICAS",
"Institute of Chartered Accountants of Scotland",
"CII",
"Chartered Insurance Institute",
"CIPS",
"Chartered Institute of Procurement and Supply",
"CMI",
"Chartered Management Institute",
// Business Schools (accredited) // Business Schools (accredited)
"Henley Business School", "Henley Business School",
"Warwick Business School", "Warwick Business School",
@@ -168,6 +186,19 @@ public static class UKInstitutions
"Cranfield School of Management", "Cranfield School of Management",
"Ashridge Business School", "Ashridge Business School",
"Alliance Manchester Business School", "Alliance Manchester Business School",
// Notable Further Education Colleges
"Loughborough College",
"City of Bristol College",
"Newcastle College",
"Leeds City College",
"City College Norwich",
"Weston College",
"Chichester College",
"Hartpury College",
"Myerscough College",
"Plumpton College",
"Writtle University College",
}; };
/// <summary> /// <summary>
@@ -218,6 +249,77 @@ public static class UKInstitutions
["Queen Mary"] = "Queen Mary University of London", ["Queen Mary"] = "Queen Mary University of London",
["Royal Holloway University"] = "Royal Holloway, University of London", ["Royal Holloway University"] = "Royal Holloway, University of London",
["RHUL"] = "Royal Holloway, University of London", ["RHUL"] = "Royal Holloway, University of London",
["Hull University"] = "University of Hull",
["Hull"] = "University of Hull",
// Additional "X University" variations for "University of X" institutions
["Birmingham University"] = "University of Birmingham",
["Bristol University"] = "University of Bristol",
["Edinburgh University"] = "University of Edinburgh",
["Exeter University"] = "University of Exeter",
["Glasgow University"] = "University of Glasgow",
["Leeds University"] = "University of Leeds",
["Leicester University"] = "University of Leicester",
["Liverpool University"] = "University of Liverpool",
["Manchester University"] = "University of Manchester",
["Nottingham University"] = "University of Nottingham",
["Sheffield University"] = "University of Sheffield",
["Southampton University"] = "University of Southampton",
["Warwick University"] = "University of Warwick",
["York University"] = "University of York",
["Bath University"] = "University of Bath",
["Bradford University"] = "University of Bradford",
["Brighton University"] = "University of Brighton",
["Derby University"] = "University of Derby",
["Dundee University"] = "University of Dundee",
["Essex University"] = "University of Essex",
["Greenwich University"] = "University of Greenwich",
["Hertfordshire University"] = "University of Hertfordshire",
["Huddersfield University"] = "University of Huddersfield",
["Kent University"] = "University of Kent",
["Lincoln University"] = "University of Lincoln",
["Plymouth University"] = "University of Plymouth",
["Portsmouth University"] = "University of Portsmouth",
["Reading University"] = "University of Reading",
["Salford University"] = "University of Salford",
["Surrey University"] = "University of Surrey",
["Sussex University"] = "University of Sussex",
["Westminster University"] = "University of Westminster",
["Winchester University"] = "University of Winchester",
["Wolverhampton University"] = "University of Wolverhampton",
["Worcester University"] = "University of Worcester",
["Aberdeen University"] = "University of Aberdeen",
["Stirling University"] = "University of Stirling",
["Strathclyde University"] = "University of Strathclyde",
["Aberystwyth University"] = "Aberystwyth University",
["Bangor University"] = "Bangor University",
["Swansea University"] = "Swansea University",
// London university variations
["UCL"] = "University College London",
["University College, London"] = "University College London",
["East London University"] = "University of East London",
["London Metropolitan"] = "London Metropolitan University",
["London Met"] = "London Metropolitan University",
["South Bank University"] = "London South Bank University",
["LSBU"] = "London South Bank University",
// Other common variations
["Open University"] = "The Open University",
["OU"] = "The Open University",
["Northumbria"] = "Northumbria University",
["De Montfort"] = "De Montfort University",
["DMU"] = "De Montfort University",
["Sheffield Hallam"] = "Sheffield Hallam University",
["Nottingham Trent"] = "Nottingham Trent University",
["NTU"] = "Nottingham Trent University",
["Oxford Brookes"] = "Oxford Brookes University",
["MMU"] = "Manchester Metropolitan University",
["Manchester Met"] = "Manchester Metropolitan University",
["Liverpool John Moores"] = "Liverpool John Moores University",
["LJMU"] = "Liverpool John Moores University",
["UWE"] = "University of the West of England",
["West of England"] = "University of the West of England",
}; };
/// <summary> /// <summary>
@@ -270,7 +372,40 @@ public static class UKInstitutions
if (NameVariations.TryGetValue(normalised, out var officialName)) if (NameVariations.TryGetValue(normalised, out var officialName))
return officialName; return officialName;
// Fuzzy match // Try automatic "X University" ↔ "University of X" transformation
var transformed = TryTransformUniversityName(normalised);
if (transformed != null && RecognisedInstitutions.Contains(transformed))
return transformed;
// Handle compound names (e.g., "Loughborough College/Motorsport UK Academy")
// Split by common separators and check each part
var separators = new[] { '/', '&', '-', '', '—', ',' };
if (normalised.IndexOfAny(separators) >= 0)
{
var parts = normalised.Split(separators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
// Try direct match on part
if (RecognisedInstitutions.Contains(part))
return part;
// Try variation match on part
if (NameVariations.TryGetValue(part, out var partOfficialName))
return partOfficialName;
// Try fuzzy match on part
foreach (var institution in RecognisedInstitutions)
{
if (institution.Contains(part, StringComparison.OrdinalIgnoreCase) ||
part.Contains(institution, StringComparison.OrdinalIgnoreCase))
{
return institution;
}
}
}
}
// Fuzzy match on full name
foreach (var institution in RecognisedInstitutions) foreach (var institution in RecognisedInstitutions)
{ {
if (institution.Contains(normalised, StringComparison.OrdinalIgnoreCase) || if (institution.Contains(normalised, StringComparison.OrdinalIgnoreCase) ||
@@ -282,4 +417,27 @@ public static class UKInstitutions
return null; return null;
} }
/// <summary>
/// Attempts to transform university name between common formats:
/// "X University" ↔ "University of X"
/// </summary>
private static string? TryTransformUniversityName(string name)
{
// Try "X University" → "University of X"
if (name.EndsWith(" University", StringComparison.OrdinalIgnoreCase))
{
var place = name[..^11].Trim(); // Remove " University"
return $"University of {place}";
}
// Try "University of X" → "X University"
if (name.StartsWith("University of ", StringComparison.OrdinalIgnoreCase))
{
var place = name[14..].Trim(); // Remove "University of "
return $"{place} University";
}
return null;
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
using TrueCV.Application.DTOs; using RealCV.Application.DTOs;
using TrueCV.Application.Models; using RealCV.Application.Models;
namespace TrueCV.Application.Interfaces; namespace RealCV.Application.Interfaces;
public interface ICVCheckService 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 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 public interface ICompanyNameMatcherService
{ {

View File

@@ -1,7 +1,7 @@
using TrueCV.Application.DTOs; using RealCV.Application.DTOs;
using TrueCV.Application.Models; using RealCV.Application.Models;
namespace TrueCV.Application.Interfaces; namespace RealCV.Application.Interfaces;
public interface ICompanyVerifierService 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 public interface IEducationVerifierService
{ {

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Interfaces; namespace RealCV.Application.Interfaces;
public interface IFileStorageService 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 public interface ITimelineAnalyserService
{ {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\TrueCV.Domain\TrueCV.Domain.csproj" /> <ProjectReference Include="..\RealCV.Domain\RealCV.Domain.csproj" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <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; using System.ComponentModel.DataAnnotations;
namespace TrueCV.Domain.Entities; namespace RealCV.Domain.Entities;
public class AuditLog public class AuditLog
{ {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Domain.Enums; namespace RealCV.Domain.Enums;
public enum UserPlan 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 public sealed class AnthropicSettings
{ {

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Infrastructure.Configuration; namespace RealCV.Infrastructure.Configuration;
public sealed class LocalStorageSettings 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;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TrueCV.Domain.Entities; using RealCV.Domain.Entities;
using TrueCV.Infrastructure.Identity; using RealCV.Infrastructure.Identity;
namespace TrueCV.Infrastructure.Data; namespace RealCV.Infrastructure.Data;
public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid>, Guid> public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid>, Guid>
{ {
@@ -40,6 +40,18 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityR
entity.Property(u => u.StripeCustomerId) entity.Property(u => u.StripeCustomerId)
.HasMaxLength(256); .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) entity.HasMany(u => u.CVChecks)
.WithOne() .WithOne()
.HasForeignKey(c => c.UserId) .HasForeignKey(c => c.UserId)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TrueCV.Application.Helpers; using RealCV.Application.Helpers;
using TrueCV.Application.Interfaces; using RealCV.Application.Interfaces;
using TrueCV.Application.Models; using RealCV.Application.Models;
using TrueCV.Domain.Entities; using RealCV.Domain.Entities;
using TrueCV.Domain.Enums; using RealCV.Domain.Enums;
using TrueCV.Infrastructure.Data; using RealCV.Infrastructure.Data;
namespace TrueCV.Infrastructure.Jobs; namespace RealCV.Infrastructure.Jobs;
public sealed class ProcessCVCheckJob public sealed class ProcessCVCheckJob
{ {
@@ -398,8 +398,8 @@ public sealed class ProcessCVCheckJob
{ {
Category = FlagCategory.Education.ToString(), Category = FlagCategory.Education.ToString(),
Severity = FlagSeverity.Critical.ToString(), Severity = FlagSeverity.Critical.ToString(),
Title = "Diploma Mill Detected", Title = "Unaccredited Institution",
Description = $"'{edu.ClaimedInstitution}' is a known diploma mill. {edu.VerificationNotes}", Description = $"'{edu.ClaimedInstitution}' was not found in accredited institutions databases. Manual verification recommended.",
ScoreImpact = -DiplomaMillPenalty ScoreImpact = -DiplomaMillPenalty
}); });
} }
@@ -413,8 +413,8 @@ public sealed class ProcessCVCheckJob
{ {
Category = FlagCategory.Education.ToString(), Category = FlagCategory.Education.ToString(),
Severity = FlagSeverity.Warning.ToString(), Severity = FlagSeverity.Warning.ToString(),
Title = "Suspicious Institution", Title = "Unrecognised Institution",
Description = $"'{edu.ClaimedInstitution}' has suspicious characteristics. {edu.VerificationNotes}", Description = $"'{edu.ClaimedInstitution}' was not found in recognised institutions databases. Manual verification recommended.",
ScoreImpact = -SuspiciousInstitutionPenalty ScoreImpact = -SuspiciousInstitutionPenalty
}); });
} }

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
using TrueCV.Application.Data; using RealCV.Application.Data;
using TrueCV.Application.Interfaces; using RealCV.Application.Interfaces;
using TrueCV.Application.Models; using RealCV.Application.Models;
namespace TrueCV.Infrastructure.Services; namespace RealCV.Infrastructure.Services;
public sealed class EducationVerifierService : IEducationVerifierService public sealed class EducationVerifierService : IEducationVerifierService
{ {
@@ -24,7 +24,7 @@ public sealed class EducationVerifierService : IEducationVerifierService
IsVerified = false, IsVerified = false,
IsDiplomaMill = true, IsDiplomaMill = true,
IsSuspicious = true, IsSuspicious = true,
VerificationNotes = "Institution is on the diploma mill blacklist", VerificationNotes = "Institution not found in accredited institutions database",
ClaimedStartDate = education.StartDate, ClaimedStartDate = education.StartDate,
ClaimedEndDate = education.EndDate, ClaimedEndDate = education.EndDate,
DatesArePlausible = true, DatesArePlausible = true,
@@ -43,7 +43,7 @@ public sealed class EducationVerifierService : IEducationVerifierService
IsVerified = false, IsVerified = false,
IsDiplomaMill = false, IsDiplomaMill = false,
IsSuspicious = true, IsSuspicious = true,
VerificationNotes = "Institution name contains suspicious patterns common in diploma mills", VerificationNotes = "Institution not found in recognised institutions database",
ClaimedStartDate = education.StartDate, ClaimedStartDate = education.StartDate,
ClaimedEndDate = education.EndDate, ClaimedEndDate = education.EndDate,
DatesArePlausible = true, DatesArePlausible = true,

View File

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

View File

@@ -1,9 +1,9 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TrueCV.Application.Interfaces; using RealCV.Application.Interfaces;
using TrueCV.Infrastructure.Configuration; using RealCV.Infrastructure.Configuration;
namespace TrueCV.Infrastructure.Services; namespace RealCV.Infrastructure.Services;
public sealed class LocalFileStorageService : IFileStorageService 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 Microsoft.Extensions.Logging;
using TrueCV.Application.Interfaces; using RealCV.Application.Interfaces;
using TrueCV.Application.Models; using RealCV.Application.Models;
namespace TrueCV.Infrastructure.Services; namespace RealCV.Infrastructure.Services;
public sealed class TimelineAnalyserService : ITimelineAnalyserService public sealed class TimelineAnalyserService : ITimelineAnalyserService
{ {

View File

@@ -1,8 +1,8 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization; 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 public sealed class UserContextService : IUserContextService
{ {

View File

@@ -7,7 +7,7 @@
<base href="/" /> <base href="/" />
<link rel="stylesheet" href="bootstrap/bootstrap.min.css" /> <link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="app.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" /> <link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="InteractiveServer" /> <HeadOutlet @rendermode="InteractiveServer" />
</head> </head>

View File

@@ -1,10 +1,10 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
<div class="d-flex flex-column min-vh-100"> <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"> <div class="container">
<a class="navbar-brand fw-bold" href="/"> <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> </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"> <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 @Body
</main> </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"> <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> </div>
</footer> </footer>
</div> </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" @page "/account/login"
@using TrueCV.Web.Components.Layout @using RealCV.Web.Components.Layout
@layout MainLayout @layout MainLayout
@using Microsoft.AspNetCore.Identity @using Microsoft.AspNetCore.Identity
@using TrueCV.Infrastructure.Identity @using RealCV.Infrastructure.Identity
@inject SignInManager<ApplicationUser> SignInManager @inject SignInManager<ApplicationUser> SignInManager
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
<PageTitle>Login - TrueCV</PageTitle> <PageTitle>Login - RealCV</PageTitle>
<div class="auth-container"> <div class="auth-container">
<!-- Left side - Form --> <!-- Left side - Form -->
@@ -16,7 +16,7 @@
<div class="auth-form-wrapper"> <div class="auth-form-wrapper">
<div class="text-center mb-4"> <div class="text-center mb-4">
<a href="/"> <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> </a>
</div> </div>
@@ -80,7 +80,7 @@
</form> </form>
<div class="auth-divider"> <div class="auth-divider">
<span>New to TrueCV?</span> <span>New to RealCV?</span>
</div> </div>
<div class="text-center"> <div class="text-center">
@@ -123,7 +123,7 @@
<div class="auth-testimonial"> <div class="auth-testimonial">
<blockquote> <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> </blockquote>
<cite>- HR Director, Tech Company</cite> <cite>- HR Director, Tech Company</cite>
</div> </div>

View File

@@ -1,16 +1,16 @@
@page "/account/register" @page "/account/register"
@using TrueCV.Web.Components.Layout @using RealCV.Web.Components.Layout
@layout MainLayout @layout MainLayout
@rendermode InteractiveServer @rendermode InteractiveServer
@using Microsoft.AspNetCore.Identity @using Microsoft.AspNetCore.Identity
@using TrueCV.Infrastructure.Identity @using RealCV.Infrastructure.Identity
@inject UserManager<ApplicationUser> UserManager @inject UserManager<ApplicationUser> UserManager
@inject SignInManager<ApplicationUser> SignInManager @inject SignInManager<ApplicationUser> SignInManager
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
<PageTitle>Register - TrueCV</PageTitle> <PageTitle>Register - RealCV</PageTitle>
<div class="auth-container"> <div class="auth-container">
<!-- Left side - Form --> <!-- Left side - Form -->
@@ -18,7 +18,7 @@
<div class="auth-form-wrapper"> <div class="auth-form-wrapper">
<div class="text-center mb-4"> <div class="text-center mb-4">
<a href="/"> <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> </a>
</div> </div>
@@ -157,7 +157,7 @@
<div class="auth-testimonial"> <div class="auth-testimonial">
<blockquote> <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> </blockquote>
<cite>- Recruitment Manager, Financial Services</cite> <cite>- Recruitment Manager, Financial Services</cite>
</div> </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 @rendermode InteractiveServer
@inject ICVCheckService CVCheckService @inject ICVCheckService CVCheckService
@inject ISubscriptionService SubscriptionService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider @inject AuthenticationStateProvider AuthenticationStateProvider
@inject ILogger<Check> Logger @inject ILogger<Check> Logger
<PageTitle>Upload CVs - TrueCV</PageTitle> <PageTitle>Upload CVs - RealCV</PageTitle>
<div class="container py-5"> <div class="container py-5">
<div class="row justify-content-center"> <div class="row justify-content-center">
@@ -21,9 +22,47 @@
</svg> </svg>
<span>For UK employment history</span> <span>For UK employment history</span>
</div> </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> </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"> <div class="alert alert-danger alert-dismissible fade show" role="alert">
@_errorMessage @_errorMessage
@@ -186,21 +225,21 @@
<style> <style>
.upload-area { .upload-area {
border: 2px dashed var(--truecv-gray-300); border: 2px dashed var(--realcv-gray-300);
border-radius: 16px; border-radius: 16px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); 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 { .upload-area:hover {
border-color: var(--truecv-primary); border-color: var(--realcv-primary);
background: linear-gradient(180deg, #e8f1fa 0%, #d4e4f4 100%); background: linear-gradient(180deg, #e8f1fa 0%, #d4e4f4 100%);
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(59, 111, 212, 0.1); box-shadow: 0 10px 25px -5px rgba(59, 111, 212, 0.1);
} }
.upload-area.dragging { .upload-area.dragging {
border-color: var(--truecv-primary); border-color: var(--realcv-primary);
background: linear-gradient(180deg, #d4e4f4 0%, #c5d9ef 100%); background: linear-gradient(180deg, #d4e4f4 0%, #c5d9ef 100%);
border-style: solid; border-style: solid;
transform: scale(1.02); transform: scale(1.02);
@@ -209,7 +248,7 @@
.upload-icon { .upload-icon {
width: 80px; width: 80px;
height: 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; border-radius: 20px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -232,16 +271,16 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
border: 1px solid var(--truecv-gray-200); border: 1px solid var(--realcv-gray-200);
border-radius: 12px; border-radius: 12px;
padding: 1rem; padding: 1rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
background: var(--truecv-bg-surface); background: var(--realcv-bg-surface);
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.file-list-item:hover { .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); box-shadow: 0 4px 12px rgba(59, 111, 212, 0.08);
} }
@@ -261,7 +300,7 @@
.file-type-icon.docx { .file-type-icon.docx {
background: linear-gradient(135deg, #e3ecf7 0%, #d4e4f4 100%); background: linear-gradient(135deg, #e3ecf7 0%, #d4e4f4 100%);
color: var(--truecv-primary); color: var(--realcv-primary);
} }
.security-info { .security-info {
@@ -273,14 +312,14 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.625rem 1rem; padding: 0.625rem 1rem;
background: var(--truecv-bg-muted); background: var(--realcv-bg-muted);
border-radius: var(--truecv-radius); border-radius: var(--realcv-radius);
font-size: 0.875rem; font-size: 0.875rem;
color: var(--truecv-gray-600); color: var(--realcv-gray-600);
} }
.security-badge svg { .security-badge svg {
color: var(--truecv-verified); color: var(--realcv-verified);
} }
@@media (max-width: 576px) { @@media (max-width: 576px) {
@@ -320,6 +359,8 @@
private int _currentFileIndex; private int _currentFileIndex;
private int _totalFiles; private int _totalFiles;
private string? _currentFileName; private string? _currentFileName;
private bool _quotaExceeded;
private SubscriptionInfoDto? _subscription;
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
private const int MaxFileCount = 50; // Maximum files per batch private const int MaxFileCount = 50; // Maximum files per batch
@@ -331,6 +372,25 @@
// Buffered file to prevent stale IBrowserFile references // Buffered file to prevent stale IBrowserFile references
private sealed record BufferedFile(string Name, long Size, byte[] Data); 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() private void HandleDragEnter()
{ {
_isDragging = true; _isDragging = true;
@@ -466,6 +526,13 @@
await CVCheckService.CreateCheckAsync(userId, memoryStream, file.Name); 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) catch (Exception ex)
{ {
Logger.LogError(ex, "Error uploading CV: {FileName}", file.Name); 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