Compare commits

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

View File

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

31
deploy-local.sh Executable file
View File

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

View File

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

View File

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

View File

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

BIN
realcv.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

BIN
screenshots/check.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 MiB

BIN
screenshots/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 MiB

BIN
screenshots/homepage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 MiB

BIN
screenshots/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 MiB

BIN
screenshots/pricing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 MiB

BIN
screenshots/register.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 MiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
namespace TrueCV.Application.Data;
namespace RealCV.Application.Data;
/// <summary>
/// List of recognised UK higher education institutions.
@@ -156,6 +156,24 @@ public static class UKInstitutions
"University for the Creative Arts",
"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)
"Henley Business School",
"Warwick Business School",
@@ -168,6 +186,19 @@ public static class UKInstitutions
"Cranfield School of Management",
"Ashridge 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>
@@ -218,6 +249,77 @@ public static class UKInstitutions
["Queen Mary"] = "Queen Mary University of London",
["Royal Holloway University"] = "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>
@@ -270,7 +372,40 @@ public static class UKInstitutions
if (NameVariations.TryGetValue(normalised, out var 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)
{
if (institution.Contains(normalised, StringComparison.OrdinalIgnoreCase) ||
@@ -282,4 +417,27 @@ public static class UKInstitutions
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
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using TrueCV.Application.Helpers;
using TrueCV.Application.Interfaces;
using TrueCV.Application.Models;
using TrueCV.Domain.Entities;
using TrueCV.Domain.Enums;
using TrueCV.Infrastructure.Data;
using RealCV.Application.Helpers;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
using RealCV.Domain.Entities;
using RealCV.Domain.Enums;
using RealCV.Infrastructure.Data;
namespace TrueCV.Infrastructure.Jobs;
namespace RealCV.Infrastructure.Jobs;
public sealed class ProcessCVCheckJob
{
@@ -398,8 +398,8 @@ public sealed class ProcessCVCheckJob
{
Category = FlagCategory.Education.ToString(),
Severity = FlagSeverity.Critical.ToString(),
Title = "Diploma Mill Detected",
Description = $"'{edu.ClaimedInstitution}' is a known diploma mill. {edu.VerificationNotes}",
Title = "Unaccredited Institution",
Description = $"'{edu.ClaimedInstitution}' was not found in accredited institutions databases. Manual verification recommended.",
ScoreImpact = -DiplomaMillPenalty
});
}
@@ -413,8 +413,8 @@ public sealed class ProcessCVCheckJob
{
Category = FlagCategory.Education.ToString(),
Severity = FlagSeverity.Warning.ToString(),
Title = "Suspicious Institution",
Description = $"'{edu.ClaimedInstitution}' has suspicious characteristics. {edu.VerificationNotes}",
Title = "Unrecognised Institution",
Description = $"'{edu.ClaimedInstitution}' was not found in recognised institutions databases. Manual verification recommended.",
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">
<ItemGroup>
<ProjectReference Include="..\TrueCV.Application\TrueCV.Application.csproj" />
<ProjectReference Include="..\RealCV.Application\RealCV.Application.csproj" />
</ItemGroup>
<ItemGroup>
@@ -19,6 +19,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.*" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.*" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Stripe.net" Version="50.2.0" />
<PackageReference Include="UglyToad.PdfPig" Version="1.7.0-custom-5" />
</ItemGroup>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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