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>
This commit is contained in:
2026-01-21 15:07:20 +00:00
parent 28d7d41b25
commit 998e9a8ab8
134 changed files with 1182 additions and 702 deletions

View File

@@ -0,0 +1,167 @@
using System.Text.Json;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RealCV.Application.Helpers;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
using RealCV.Infrastructure.Configuration;
using RealCV.Infrastructure.Helpers;
namespace RealCV.Infrastructure.Services;
public sealed class AICompanyNameMatcherService : ICompanyNameMatcherService
{
private readonly AnthropicClient _anthropicClient;
private readonly ILogger<AICompanyNameMatcherService> _logger;
private const string SystemPrompt = """
You are a UK company name matching expert. Your task is to determine if a company name
from a CV matches any of the official company names from Companies House records.
You understand:
- Trading names vs registered names (e.g., "Tesco" = "TESCO PLC")
- Subsidiaries vs parent companies (e.g., "ASDA" might work for "ASDA STORES LIMITED")
- Common abbreviations (Ltd = Limited, PLC = Public Limited Company, CiC = Community Interest Company)
- That completely different words mean different companies (e.g., "Families First" "Families Against Conformity")
You must respond ONLY with valid JSON, no other text or markdown.
""";
private const string MatchingPrompt = """
Compare the company name from a CV against official Companies House records.
CV Company Name: "{CV_COMPANY}"
Companies House Candidates:
{CANDIDATES}
Determine which candidate (if any) is the SAME company as the CV entry.
Rules:
1. A match requires the companies to be the SAME organisation, not just similar names
2. "Families First CiC" is NOT the same as "FAMILIES AGAINST CONFORMITY LTD" - different words = different companies
3. Trading names should match their registered entity (e.g., "Tesco" matches "TESCO PLC")
4. Subsidiaries can match if clearly the same organisation (e.g., "ASDA" could match "ASDA STORES LIMITED")
5. Acronyms in parentheses are abbreviations of the full name (e.g., "North Halifax Partnership (NHP)" = "NORTH HALIFAX PARTNERSHIP")
6. CiC/CIC = Community Interest Company, LLP = Limited Liability Partnership - these are legal suffixes
7. If the CV name contains all the key words of a candidate (ignoring Ltd/Limited/CIC/etc.), it's likely a match
8. If NO candidate is clearly the same company, return "NONE" as the best match
Respond with this exact JSON structure:
{
"bestMatchCompanyNumber": "string (company number of best match, or 'NONE' if no valid match)",
"confidenceScore": number (0-100, where 100 = certain match, 0 = no match),
"matchType": "string (Exact, TradingName, Subsidiary, Parent, NoMatch)",
"reasoning": "string (brief explanation of why this is or isn't a match)"
}
""";
public AICompanyNameMatcherService(
IOptions<AnthropicSettings> settings,
ILogger<AICompanyNameMatcherService> logger)
{
_logger = logger;
_anthropicClient = new AnthropicClient(settings.Value.ApiKey);
}
public async Task<SemanticMatchResult?> FindBestMatchAsync(
string cvCompanyName,
List<CompanyCandidate> candidates,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(cvCompanyName) || candidates.Count == 0)
{
return null;
}
_logger.LogDebug("Using AI to match '{CVCompany}' against {Count} candidates",
cvCompanyName, candidates.Count);
try
{
var candidatesText = string.Join("\n", candidates.Select((c, i) =>
$"{i + 1}. {c.CompanyName} (Number: {c.CompanyNumber}, Status: {c.CompanyStatus ?? "Unknown"})"));
var prompt = MatchingPrompt
.Replace("{CV_COMPANY}", cvCompanyName)
.Replace("{CANDIDATES}", candidatesText);
var messages = new List<Message>
{
new(RoleType.User, prompt)
};
var parameters = new MessageParameters
{
Model = "claude-sonnet-4-20250514",
MaxTokens = 1024,
Messages = messages,
System = [new SystemMessage(SystemPrompt)]
};
var response = await _anthropicClient.Messages.GetClaudeMessageAsync(parameters, cancellationToken);
var responseText = response.Content
.OfType<TextContent>()
.FirstOrDefault()?.Text;
if (string.IsNullOrWhiteSpace(responseText))
{
_logger.LogWarning("AI returned empty response for company matching");
return null;
}
responseText = JsonResponseHelper.CleanJsonResponse(responseText);
var aiResponse = JsonSerializer.Deserialize<AIMatchResponse>(responseText, JsonDefaults.CamelCase);
if (aiResponse is null)
{
_logger.LogWarning("Failed to deserialize AI response: {Response}", responseText);
return null;
}
_logger.LogDebug("AI match result: {CompanyNumber} with {Score}% confidence - {Reasoning}",
aiResponse.BestMatchCompanyNumber, aiResponse.ConfidenceScore, aiResponse.Reasoning);
// Find the matched candidate
if (aiResponse.BestMatchCompanyNumber == "NONE" || aiResponse.ConfidenceScore < 50)
{
return new SemanticMatchResult
{
CandidateCompanyName = "No match",
CandidateCompanyNumber = "NONE",
ConfidenceScore = 0,
MatchType = "NoMatch",
Reasoning = aiResponse.Reasoning
};
}
var matchedCandidate = candidates.FirstOrDefault(c =>
c.CompanyNumber.Equals(aiResponse.BestMatchCompanyNumber, StringComparison.OrdinalIgnoreCase));
if (matchedCandidate is null)
{
_logger.LogWarning("AI returned company number {Number} not in candidates list",
aiResponse.BestMatchCompanyNumber);
return null;
}
return new SemanticMatchResult
{
CandidateCompanyName = matchedCandidate.CompanyName,
CandidateCompanyNumber = matchedCandidate.CompanyNumber,
ConfidenceScore = aiResponse.ConfidenceScore,
MatchType = aiResponse.MatchType,
Reasoning = aiResponse.Reasoning
};
}
catch (Exception ex)
{
_logger.LogError(ex, "AI company matching failed for '{CVCompany}'", cvCompanyName);
return null; // Fall back to fuzzy matching
}
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.Extensions.Logging;
using RealCV.Application.Interfaces;
using RealCV.Domain.Entities;
using RealCV.Infrastructure.Data;
namespace RealCV.Infrastructure.Services;
public sealed class AuditService : IAuditService
{
private readonly ApplicationDbContext _dbContext;
private readonly ILogger<AuditService> _logger;
public AuditService(ApplicationDbContext dbContext, ILogger<AuditService> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task LogAsync(
Guid userId,
string action,
string? entityType = null,
Guid? entityId = null,
string? details = null,
string? ipAddress = null)
{
var auditLog = new AuditLog
{
Id = Guid.NewGuid(),
UserId = userId,
Action = action,
EntityType = entityType,
EntityId = entityId,
Details = details,
IpAddress = ipAddress,
CreatedAt = DateTime.UtcNow
};
_dbContext.AuditLogs.Add(auditLog);
try
{
await _dbContext.SaveChangesAsync();
_logger.LogDebug("Audit log created: {Action} by user {UserId}", action, userId);
}
catch (Exception ex)
{
// Don't let audit failures break the main flow
_logger.LogError(ex, "Failed to create audit log: {Action} by user {UserId}", action, userId);
}
}
}

View File

@@ -0,0 +1,212 @@
using System.Text.Json;
using Hangfire;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
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 RealCV.Infrastructure.Services;
public sealed class CVCheckService : ICVCheckService
{
private readonly ApplicationDbContext _dbContext;
private readonly IFileStorageService _fileStorageService;
private readonly IBackgroundJobClient _backgroundJobClient;
private readonly IAuditService _auditService;
private readonly ISubscriptionService _subscriptionService;
private readonly ILogger<CVCheckService> _logger;
public CVCheckService(
ApplicationDbContext dbContext,
IFileStorageService fileStorageService,
IBackgroundJobClient backgroundJobClient,
IAuditService auditService,
ISubscriptionService subscriptionService,
ILogger<CVCheckService> logger)
{
_dbContext = dbContext;
_fileStorageService = fileStorageService;
_backgroundJobClient = backgroundJobClient;
_auditService = auditService;
_subscriptionService = subscriptionService;
_logger = logger;
}
public async Task<Guid> CreateCheckAsync(Guid userId, Stream file, string fileName)
{
ArgumentNullException.ThrowIfNull(file);
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
_logger.LogDebug("Creating CV check for user {UserId}, file: {FileName}", userId, fileName);
// Check quota before proceeding
if (!await _subscriptionService.CanPerformCheckAsync(userId))
{
_logger.LogWarning("User {UserId} quota exceeded - CV check denied", userId);
throw new QuotaExceededException();
}
// Upload file to blob storage
var blobUrl = await _fileStorageService.UploadAsync(file, fileName);
_logger.LogDebug("File uploaded to: {BlobUrl}", blobUrl);
// Create CV check record
var cvCheck = new CVCheck
{
Id = Guid.NewGuid(),
UserId = userId,
OriginalFileName = fileName,
BlobUrl = blobUrl,
Status = CheckStatus.Pending
};
_dbContext.CVChecks.Add(cvCheck);
await _dbContext.SaveChangesAsync();
_logger.LogDebug("CV check record created with ID: {CheckId}", cvCheck.Id);
// Queue background job for processing
_backgroundJobClient.Enqueue<ProcessCVCheckJob>(job => job.ExecuteAsync(cvCheck.Id, CancellationToken.None));
_logger.LogInformation(
"CV check {CheckId} created for user {UserId}, processing queued",
cvCheck.Id, userId);
await _auditService.LogAsync(userId, AuditActions.CVUploaded, "CVCheck", cvCheck.Id, $"File: {fileName}");
// Increment usage after successful creation
await _subscriptionService.IncrementUsageAsync(userId);
return cvCheck.Id;
}
public async Task<CVCheckDto?> GetCheckAsync(Guid id)
{
_logger.LogDebug("Retrieving CV check: {CheckId}", id);
var cvCheck = await _dbContext.CVChecks
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == id);
if (cvCheck is null)
{
_logger.LogDebug("CV check not found: {CheckId}", id);
return null;
}
return MapToDto(cvCheck);
}
public async Task<List<CVCheckDto>> GetUserChecksAsync(Guid userId)
{
_logger.LogDebug("Retrieving CV checks for user: {UserId}", userId);
var checks = await _dbContext.CVChecks
.AsNoTracking()
.Where(c => c.UserId == userId)
.OrderByDescending(c => c.CreatedAt)
.ToListAsync();
_logger.LogDebug("Found {Count} CV checks for user {UserId}", checks.Count, userId);
return checks.Select(MapToDto).ToList();
}
public async Task<CVCheckDto?> GetCheckForUserAsync(Guid id, Guid userId)
{
_logger.LogDebug("Retrieving CV check {CheckId} for user {UserId}", id, userId);
var cvCheck = await _dbContext.CVChecks
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId);
if (cvCheck is null)
{
_logger.LogDebug("CV check not found: {CheckId} for user {UserId}", id, userId);
return null;
}
return MapToDto(cvCheck);
}
public async Task<VeracityReport?> GetReportAsync(Guid checkId, Guid userId)
{
_logger.LogDebug("Retrieving report for CV check {CheckId}, user {UserId}", checkId, userId);
var cvCheck = await _dbContext.CVChecks
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == checkId && c.UserId == userId);
if (cvCheck is null)
{
_logger.LogWarning("CV check not found: {CheckId} for user {UserId}", checkId, userId);
return null;
}
if (cvCheck.Status != CheckStatus.Completed || string.IsNullOrEmpty(cvCheck.ReportJson))
{
_logger.LogDebug("CV check {CheckId} not completed or has no report", checkId);
return null;
}
try
{
var report = JsonSerializer.Deserialize<VeracityReport>(cvCheck.ReportJson, JsonDefaults.CamelCase);
return report;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to deserialize report JSON for check {CheckId}", checkId);
return null;
}
}
public async Task<bool> DeleteCheckAsync(Guid checkId, Guid userId)
{
_logger.LogDebug("Deleting CV check {CheckId} for user {UserId}", checkId, userId);
var cvCheck = await _dbContext.CVChecks
.Include(c => c.Flags)
.FirstOrDefaultAsync(c => c.Id == checkId && c.UserId == userId);
if (cvCheck is null)
{
_logger.LogWarning("CV check {CheckId} not found for user {UserId}", checkId, userId);
return false;
}
var fileName = cvCheck.OriginalFileName;
_dbContext.CVFlags.RemoveRange(cvCheck.Flags);
_dbContext.CVChecks.Remove(cvCheck);
await _dbContext.SaveChangesAsync();
_logger.LogInformation("Deleted CV check {CheckId} for user {UserId}", checkId, userId);
await _auditService.LogAsync(userId, AuditActions.CVDeleted, "CVCheck", checkId, $"File: {fileName}");
return true;
}
private static CVCheckDto MapToDto(CVCheck cvCheck)
{
return new CVCheckDto
{
Id = cvCheck.Id,
OriginalFileName = cvCheck.OriginalFileName,
Status = cvCheck.Status.ToString(),
VeracityScore = cvCheck.VeracityScore,
ProcessingStage = cvCheck.ProcessingStage,
CreatedAt = cvCheck.CreatedAt,
CompletedAt = cvCheck.CompletedAt
};
}
}

View File

@@ -0,0 +1,278 @@
using System.Text;
using System.Text.Json;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RealCV.Application.Helpers;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
using RealCV.Infrastructure.Configuration;
using RealCV.Infrastructure.Helpers;
using UglyToad.PdfPig;
namespace RealCV.Infrastructure.Services;
public sealed class CVParserService : ICVParserService
{
private readonly AnthropicClient _anthropicClient;
private readonly ILogger<CVParserService> _logger;
private const string SystemPrompt = """
You are a CV/Resume parser. Your task is to extract structured information from CV text.
You must respond ONLY with valid JSON, no other text or markdown.
""";
private const string ExtractionPrompt = """
Parse the following CV text and extract the information into this exact JSON structure:
{
"fullName": "string (required)",
"email": "string or null",
"phone": "string or null",
"employment": [
{
"companyName": "string (required)",
"jobTitle": "string (required)",
"location": "string or null",
"startDate": "YYYY-MM-DD or null",
"endDate": "YYYY-MM-DD or null (null if current)",
"isCurrent": "boolean",
"description": "string or null"
}
],
"education": [
{
"institution": "string (required)",
"qualification": "string or null (e.g., BSc, MSc, PhD)",
"subject": "string or null",
"grade": "string or null",
"startDate": "YYYY-MM-DD or null",
"endDate": "YYYY-MM-DD or null"
}
],
"skills": ["array of skill strings"]
}
Rules:
- For dates, use the first day of the month if only month/year is given (e.g., "Jan 2020" becomes "2020-01-01")
- For dates with only year, use January 1st (e.g., "2020" becomes "2020-01-01")
- Set isCurrent to true if the job appears to be ongoing (e.g., "Present", "Current", no end date mentioned with recent start)
- Extract all employment history in chronological order
- If information is not available, use null
- Do not invent or assume information not present in the text
CV TEXT:
{CV_TEXT}
""";
public CVParserService(
IOptions<AnthropicSettings> settings,
ILogger<CVParserService> logger)
{
_logger = logger;
_anthropicClient = new AnthropicClient(settings.Value.ApiKey);
}
public async Task<CVData> ParseAsync(Stream fileStream, string fileName, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(fileStream);
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
_logger.LogDebug("Parsing CV file: {FileName}", fileName);
var text = await ExtractTextAsync(fileStream, fileName, cancellationToken);
if (string.IsNullOrWhiteSpace(text))
{
_logger.LogWarning("No text content extracted from file: {FileName}", fileName);
throw new InvalidOperationException($"Could not extract text content from file: {fileName}");
}
_logger.LogDebug("Extracted {CharCount} characters from {FileName}", text.Length, fileName);
var cvData = await ParseWithClaudeAsync(text, cancellationToken);
_logger.LogInformation(
"Successfully parsed CV for {FullName} with {EmploymentCount} employment entries and {EducationCount} education entries",
cvData.FullName,
cvData.Employment.Count,
cvData.Education.Count);
return cvData;
}
private async Task<string> ExtractTextAsync(Stream fileStream, string fileName, CancellationToken cancellationToken)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
return extension switch
{
".pdf" => await ExtractTextFromPdfAsync(fileStream, cancellationToken),
".docx" => ExtractTextFromDocx(fileStream),
_ => throw new NotSupportedException($"File type '{extension}' is not supported. Only PDF and DOCX files are accepted.")
};
}
private async Task<string> ExtractTextFromPdfAsync(Stream fileStream, CancellationToken cancellationToken)
{
// Copy stream to memory for PdfPig (requires seekable stream)
using var memoryStream = new MemoryStream();
await fileStream.CopyToAsync(memoryStream, cancellationToken);
memoryStream.Position = 0;
using var document = PdfDocument.Open(memoryStream);
var textBuilder = new StringBuilder();
foreach (var page in document.GetPages())
{
cancellationToken.ThrowIfCancellationRequested();
var pageText = page.Text;
textBuilder.AppendLine(pageText);
}
return textBuilder.ToString();
}
private static string ExtractTextFromDocx(Stream fileStream)
{
using var document = WordprocessingDocument.Open(fileStream, false);
var body = document.MainDocumentPart?.Document?.Body;
if (body is null)
{
return string.Empty;
}
var textBuilder = new StringBuilder();
foreach (var paragraph in body.Elements<Paragraph>())
{
var paragraphText = paragraph.InnerText;
if (!string.IsNullOrWhiteSpace(paragraphText))
{
textBuilder.AppendLine(paragraphText);
}
}
return textBuilder.ToString();
}
private async Task<CVData> ParseWithClaudeAsync(string cvText, CancellationToken cancellationToken)
{
var prompt = ExtractionPrompt.Replace("{CV_TEXT}", cvText);
var messages = new List<Message>
{
new(RoleType.User, prompt)
};
var parameters = new MessageParameters
{
Model = "claude-sonnet-4-20250514",
MaxTokens = 4096,
Messages = messages,
System = [new SystemMessage(SystemPrompt)]
};
_logger.LogDebug("Sending CV text to Claude API for parsing");
var response = await _anthropicClient.Messages.GetClaudeMessageAsync(parameters, cancellationToken);
var responseText = response.Content
.OfType<TextContent>()
.FirstOrDefault()?.Text;
if (string.IsNullOrWhiteSpace(responseText))
{
_logger.LogError("Claude API returned empty response");
throw new InvalidOperationException("Failed to parse CV: AI returned empty response");
}
// Clean up response - remove markdown code blocks if present
responseText = JsonResponseHelper.CleanJsonResponse(responseText);
_logger.LogDebug("Received response from Claude API, parsing JSON");
try
{
var parsedResponse = JsonSerializer.Deserialize<ClaudeCVResponse>(responseText, JsonDefaults.CamelCase);
if (parsedResponse is null)
{
throw new InvalidOperationException("Failed to deserialize CV data from AI response");
}
return MapToCVData(parsedResponse);
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse Claude response as JSON: {Response}", responseText);
throw new InvalidOperationException("Failed to parse CV: AI returned invalid JSON", ex);
}
}
private static CVData MapToCVData(ClaudeCVResponse response)
{
return new CVData
{
FullName = response.FullName ?? "Unknown",
Email = response.Email,
Phone = response.Phone,
Employment = response.Employment?.Select(e => new EmploymentEntry
{
CompanyName = e.CompanyName ?? "Unknown Company",
JobTitle = e.JobTitle ?? "Unknown Position",
Location = e.Location,
StartDate = DateHelpers.ParseDate(e.StartDate),
EndDate = DateHelpers.ParseDate(e.EndDate),
IsCurrent = e.IsCurrent ?? false,
Description = e.Description
}).ToList() ?? [],
Education = response.Education?.Select(e => new EducationEntry
{
Institution = e.Institution ?? "Unknown Institution",
Qualification = e.Qualification,
Subject = e.Subject,
Grade = e.Grade,
StartDate = DateHelpers.ParseDate(e.StartDate),
EndDate = DateHelpers.ParseDate(e.EndDate)
}).ToList() ?? [],
Skills = response.Skills ?? []
};
}
// Internal DTOs for Claude response parsing
private sealed record ClaudeCVResponse
{
public string? FullName { get; init; }
public string? Email { get; init; }
public string? Phone { get; init; }
public List<ClaudeEmploymentEntry>? Employment { get; init; }
public List<ClaudeEducationEntry>? Education { get; init; }
public List<string>? Skills { get; init; }
}
private sealed record ClaudeEmploymentEntry
{
public string? CompanyName { get; init; }
public string? JobTitle { get; init; }
public string? Location { get; init; }
public string? StartDate { get; init; }
public string? EndDate { get; init; }
public bool? IsCurrent { get; init; }
public string? Description { get; init; }
}
private sealed record ClaudeEducationEntry
{
public string? Institution { get; init; }
public string? Qualification { get; init; }
public string? Subject { get; init; }
public string? Grade { get; init; }
public string? StartDate { get; init; }
public string? EndDate { get; init; }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,271 @@
using RealCV.Application.Data;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
namespace RealCV.Infrastructure.Services;
public sealed class EducationVerifierService : IEducationVerifierService
{
private const int MinimumDegreeYears = 1;
private const int MaximumDegreeYears = 8;
private const int MinimumGraduationAge = 18;
public EducationVerificationResult Verify(EducationEntry education)
{
var institution = education.Institution;
// Check for diploma mill first (highest priority flag)
if (DiplomaMills.IsDiplomaMill(institution))
{
return new EducationVerificationResult
{
ClaimedInstitution = institution,
Status = "DiplomaMill",
IsVerified = false,
IsDiplomaMill = true,
IsSuspicious = true,
VerificationNotes = "Institution is on the diploma mill blacklist",
ClaimedStartDate = education.StartDate,
ClaimedEndDate = education.EndDate,
DatesArePlausible = true,
ClaimedQualification = education.Qualification,
ClaimedSubject = education.Subject
};
}
// Check for suspicious patterns
if (DiplomaMills.HasSuspiciousPattern(institution))
{
return new EducationVerificationResult
{
ClaimedInstitution = institution,
Status = "Suspicious",
IsVerified = false,
IsDiplomaMill = false,
IsSuspicious = true,
VerificationNotes = "Institution name contains suspicious patterns common in diploma mills",
ClaimedStartDate = education.StartDate,
ClaimedEndDate = education.EndDate,
DatesArePlausible = true,
ClaimedQualification = education.Qualification,
ClaimedSubject = education.Subject
};
}
// Check if it's a recognised UK institution
var officialName = UKInstitutions.GetOfficialName(institution);
if (officialName != null)
{
var (datesPlausible, dateNotes) = CheckDatePlausibility(education.StartDate, education.EndDate);
return new EducationVerificationResult
{
ClaimedInstitution = institution,
MatchedInstitution = officialName,
Status = "Recognised",
IsVerified = true,
IsDiplomaMill = false,
IsSuspicious = false,
VerificationNotes = institution.Equals(officialName, StringComparison.OrdinalIgnoreCase)
? "Verified UK higher education institution"
: $"Matched to official name: {officialName}",
ClaimedStartDate = education.StartDate,
ClaimedEndDate = education.EndDate,
DatesArePlausible = datesPlausible,
DatePlausibilityNotes = dateNotes,
ClaimedQualification = education.Qualification,
ClaimedSubject = education.Subject
};
}
// Not in our database - could be international or unrecognised
var isUnknownInstitution = string.IsNullOrWhiteSpace(institution) ||
institution.Equals("Unknown Institution", StringComparison.OrdinalIgnoreCase) ||
institution.Equals("Unknown", StringComparison.OrdinalIgnoreCase);
return new EducationVerificationResult
{
ClaimedInstitution = institution,
Status = "Unknown",
IsVerified = false,
IsDiplomaMill = false,
IsSuspicious = false,
VerificationNotes = isUnknownInstitution ? null : "Institution not found in UK recognised institutions database. May be an international institution.",
ClaimedStartDate = education.StartDate,
ClaimedEndDate = education.EndDate,
DatesArePlausible = true,
ClaimedQualification = education.Qualification,
ClaimedSubject = education.Subject
};
}
public List<EducationVerificationResult> VerifyAll(
List<EducationEntry> education,
List<EmploymentEntry>? employment = null)
{
var results = new List<EducationVerificationResult>();
foreach (var edu in education)
{
var result = Verify(edu);
// If we have employment data, check for timeline issues
if (employment?.Count > 0 && result.ClaimedEndDate.HasValue)
{
var (timelinePlausible, timelineNotes) = CheckEducationEmploymentTimeline(
result.ClaimedEndDate.Value,
employment);
if (!timelinePlausible)
{
result = result with
{
DatesArePlausible = false,
DatePlausibilityNotes = CombineNotes(result.DatePlausibilityNotes, timelineNotes)
};
}
}
results.Add(result);
}
// Check for overlapping education periods
CheckOverlappingEducation(results);
return results;
}
private static (bool isPlausible, string? notes) CheckDatePlausibility(DateOnly? startDate, DateOnly? endDate)
{
if (!startDate.HasValue || !endDate.HasValue)
{
return (true, null);
}
var start = startDate.Value;
var end = endDate.Value;
// End date should be after start date
if (end <= start)
{
return (false, "End date is before or equal to start date");
}
// Check course duration is reasonable
var years = (end.ToDateTime(TimeOnly.MinValue) - start.ToDateTime(TimeOnly.MinValue)).TotalDays / 365.25;
if (years < MinimumDegreeYears)
{
return (false, $"Course duration ({years:F1} years) is unusually short for a degree");
}
if (years > MaximumDegreeYears)
{
return (false, $"Course duration ({years:F1} years) is unusually long");
}
// Check if graduation date is in the future
if (end > DateOnly.FromDateTime(DateTime.UtcNow))
{
return (true, "Graduation date is in the future - possibly currently studying");
}
return (true, null);
}
private static (bool isPlausible, string? notes) CheckEducationEmploymentTimeline(
DateOnly graduationDate,
List<EmploymentEntry> employment)
{
// Find the earliest employment start date
var earliestEmployment = employment
.Where(e => e.StartDate.HasValue)
.OrderBy(e => e.StartDate)
.FirstOrDefault();
if (earliestEmployment?.StartDate == null)
{
return (true, null);
}
var employmentStart = earliestEmployment.StartDate.Value;
// If someone claims to have started full-time work significantly before graduating,
// that's suspicious (unless it's clearly an internship/part-time role)
var monthsBeforeGraduation = (graduationDate.ToDateTime(TimeOnly.MinValue) -
employmentStart.ToDateTime(TimeOnly.MinValue)).TotalDays / 30;
if (monthsBeforeGraduation > 24) // More than 2 years before graduation
{
var isLikelyInternship = earliestEmployment.JobTitle.Contains("intern", StringComparison.OrdinalIgnoreCase) ||
earliestEmployment.JobTitle.Contains("placement", StringComparison.OrdinalIgnoreCase) ||
earliestEmployment.JobTitle.Contains("trainee", StringComparison.OrdinalIgnoreCase);
if (!isLikelyInternship)
{
return (false, $"Employment at {earliestEmployment.CompanyName} started {monthsBeforeGraduation:F0} months before claimed graduation");
}
}
return (true, null);
}
private static void CheckOverlappingEducation(List<EducationVerificationResult> results)
{
var datedResults = results
.Where(r => r.ClaimedStartDate.HasValue && r.ClaimedEndDate.HasValue)
.ToList();
for (var i = 0; i < datedResults.Count; i++)
{
for (var j = i + 1; j < datedResults.Count; j++)
{
var edu1 = datedResults[i];
var edu2 = datedResults[j];
if (PeriodsOverlap(
edu1.ClaimedStartDate!.Value, edu1.ClaimedEndDate!.Value,
edu2.ClaimedStartDate!.Value, edu2.ClaimedEndDate!.Value))
{
// Find the actual index in the original results list
var idx1 = results.IndexOf(edu1);
var idx2 = results.IndexOf(edu2);
if (idx1 >= 0)
{
results[idx1] = edu1 with
{
DatePlausibilityNotes = CombineNotes(
edu1.DatePlausibilityNotes,
$"Overlaps with education at {edu2.ClaimedInstitution}")
};
}
if (idx2 >= 0)
{
results[idx2] = edu2 with
{
DatePlausibilityNotes = CombineNotes(
edu2.DatePlausibilityNotes,
$"Overlaps with education at {edu1.ClaimedInstitution}")
};
}
}
}
}
}
private static bool PeriodsOverlap(DateOnly start1, DateOnly end1, DateOnly start2, DateOnly end2)
{
return start1 < end2 && start2 < end1;
}
private static string? CombineNotes(string? existing, string? additional)
{
if (string.IsNullOrEmpty(additional))
return existing;
if (string.IsNullOrEmpty(existing))
return additional;
return $"{existing}; {additional}";
}
}

View File

@@ -0,0 +1,133 @@
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RealCV.Application.Interfaces;
using RealCV.Infrastructure.Configuration;
namespace RealCV.Infrastructure.Services;
public sealed class FileStorageService : IFileStorageService
{
private readonly BlobContainerClient _containerClient;
private readonly ILogger<FileStorageService> _logger;
public FileStorageService(
IOptions<AzureBlobSettings> settings,
ILogger<FileStorageService> logger)
{
_logger = logger;
var blobServiceClient = new BlobServiceClient(settings.Value.ConnectionString);
_containerClient = blobServiceClient.GetBlobContainerClient(settings.Value.ContainerName);
}
public async Task<string> UploadAsync(Stream fileStream, string fileName)
{
ArgumentNullException.ThrowIfNull(fileStream);
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
var extension = Path.GetExtension(fileName);
var uniqueBlobName = $"{Guid.NewGuid()}{extension}";
_logger.LogDebug("Uploading file {FileName} as blob {BlobName}", fileName, uniqueBlobName);
var blobClient = _containerClient.GetBlobClient(uniqueBlobName);
await _containerClient.CreateIfNotExistsAsync();
var httpHeaders = new BlobHttpHeaders
{
ContentType = GetContentType(extension)
};
await blobClient.UploadAsync(fileStream, new BlobUploadOptions
{
HttpHeaders = httpHeaders,
Metadata = new Dictionary<string, string>
{
["originalFileName"] = fileName,
["uploadedAt"] = DateTime.UtcNow.ToString("O")
}
});
var blobUrl = blobClient.Uri.ToString();
_logger.LogInformation("Successfully uploaded file {FileName} to {BlobUrl}", fileName, blobUrl);
return blobUrl;
}
public async Task<Stream> DownloadAsync(string blobUrl)
{
ArgumentException.ThrowIfNullOrWhiteSpace(blobUrl);
var blobName = ExtractBlobNameFromUrl(blobUrl);
_logger.LogDebug("Downloading blob {BlobName} from {BlobUrl}", blobName, blobUrl);
var blobClient = _containerClient.GetBlobClient(blobName);
// Download to memory stream to ensure proper resource management
// The caller will own and dispose this stream
var memoryStream = new MemoryStream();
await blobClient.DownloadToAsync(memoryStream);
memoryStream.Position = 0;
_logger.LogDebug("Successfully downloaded blob {BlobName}", blobName);
return memoryStream;
}
public async Task DeleteAsync(string blobUrl)
{
ArgumentException.ThrowIfNullOrWhiteSpace(blobUrl);
var blobName = ExtractBlobNameFromUrl(blobUrl);
_logger.LogDebug("Deleting blob {BlobName}", blobName);
var blobClient = _containerClient.GetBlobClient(blobName);
var deleted = await blobClient.DeleteIfExistsAsync();
if (deleted)
{
_logger.LogInformation("Successfully deleted blob {BlobName}", blobName);
}
else
{
_logger.LogWarning("Blob {BlobName} did not exist when attempting to delete", blobName);
}
}
private static string ExtractBlobNameFromUrl(string blobUrl)
{
if (!Uri.TryCreate(blobUrl, UriKind.Absolute, out var uri))
{
throw new ArgumentException($"Invalid blob URL format: '{blobUrl}'", nameof(blobUrl));
}
var segments = uri.Segments;
// The blob name is the last segment after the container name
// URL format: https://account.blob.core.windows.net/container/blobname
if (segments.Length <= 2)
{
throw new ArgumentException($"Blob URL does not contain a valid blob name: '{blobUrl}'", nameof(blobUrl));
}
return segments[^1];
}
private static string GetContentType(string extension)
{
return extension.ToLowerInvariant() switch
{
".pdf" => "application/pdf",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".doc" => "application/msword",
_ => "application/octet-stream"
};
}
}

View File

@@ -0,0 +1,117 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RealCV.Application.Interfaces;
using RealCV.Infrastructure.Configuration;
namespace RealCV.Infrastructure.Services;
public sealed class LocalFileStorageService : IFileStorageService
{
private readonly string _storagePath;
private readonly ILogger<LocalFileStorageService> _logger;
public LocalFileStorageService(
IOptions<LocalStorageSettings> settings,
ILogger<LocalFileStorageService> logger)
{
_logger = logger;
_storagePath = settings.Value.StoragePath;
if (!Directory.Exists(_storagePath))
{
Directory.CreateDirectory(_storagePath);
_logger.LogInformation("Created local storage directory: {Path}", _storagePath);
}
}
public async Task<string> UploadAsync(Stream fileStream, string fileName)
{
ArgumentNullException.ThrowIfNull(fileStream);
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
var extension = Path.GetExtension(fileName);
var uniqueFileName = $"{Guid.NewGuid()}{extension}";
var filePath = Path.Combine(_storagePath, uniqueFileName);
_logger.LogDebug("Uploading file {FileName} to {FilePath}", fileName, filePath);
await using var fileStreamOut = new FileStream(filePath, FileMode.Create, FileAccess.Write);
await fileStream.CopyToAsync(fileStreamOut);
// Return a file:// URL for local storage
var fileUrl = $"file://{filePath}";
_logger.LogInformation("Successfully uploaded file {FileName} to {FileUrl}", fileName, fileUrl);
return fileUrl;
}
public async Task<Stream> DownloadAsync(string blobUrl)
{
ArgumentException.ThrowIfNullOrWhiteSpace(blobUrl);
var filePath = ExtractFilePathFromUrl(blobUrl);
_logger.LogDebug("Downloading file from {FilePath}", filePath);
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"File not found: {filePath}");
}
var memoryStream = new MemoryStream();
await using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
await fileStream.CopyToAsync(memoryStream);
memoryStream.Position = 0;
_logger.LogDebug("Successfully downloaded file from {FilePath}", filePath);
return memoryStream;
}
public Task DeleteAsync(string blobUrl)
{
ArgumentException.ThrowIfNullOrWhiteSpace(blobUrl);
var filePath = ExtractFilePathFromUrl(blobUrl);
_logger.LogDebug("Deleting file {FilePath}", filePath);
if (File.Exists(filePath))
{
File.Delete(filePath);
_logger.LogInformation("Successfully deleted file {FilePath}", filePath);
}
else
{
_logger.LogWarning("File {FilePath} did not exist when attempting to delete", filePath);
}
return Task.CompletedTask;
}
private string ExtractFilePathFromUrl(string fileUrl)
{
string filePath;
if (fileUrl.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
{
filePath = fileUrl[7..];
}
else
{
filePath = fileUrl;
}
// Resolve to absolute path and validate it's within storage directory
var fullPath = Path.GetFullPath(filePath);
var storagePath = Path.GetFullPath(_storagePath);
if (!fullPath.StartsWith(storagePath, StringComparison.OrdinalIgnoreCase))
{
throw new UnauthorizedAccessException($"Access denied: path is outside storage directory");
}
return fullPath;
}
}

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

@@ -0,0 +1,205 @@
using Microsoft.Extensions.Logging;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
namespace RealCV.Infrastructure.Services;
public sealed class TimelineAnalyserService : ITimelineAnalyserService
{
private readonly ILogger<TimelineAnalyserService> _logger;
private const int MinimumGapMonths = 3;
private const int AllowedOverlapMonths = 2;
public TimelineAnalyserService(ILogger<TimelineAnalyserService> logger)
{
_logger = logger;
}
public TimelineAnalysisResult Analyse(List<EmploymentEntry> employmentHistory)
{
ArgumentNullException.ThrowIfNull(employmentHistory);
if (employmentHistory.Count == 0)
{
_logger.LogDebug("No employment history to analyse");
return new TimelineAnalysisResult
{
TotalGapMonths = 0,
TotalOverlapMonths = 0,
Gaps = [],
Overlaps = []
};
}
// Filter entries with valid dates and sort by start date
var sortedEmployment = employmentHistory
.Where(e => e.StartDate.HasValue)
.OrderBy(e => e.StartDate!.Value)
.ToList();
if (sortedEmployment.Count == 0)
{
_logger.LogDebug("No employment entries with valid dates to analyse");
return new TimelineAnalysisResult
{
TotalGapMonths = 0,
TotalOverlapMonths = 0,
Gaps = [],
Overlaps = []
};
}
var gaps = DetectGaps(sortedEmployment);
var overlaps = DetectOverlaps(sortedEmployment);
var totalGapMonths = gaps.Sum(g => g.Months);
var totalOverlapMonths = overlaps.Sum(o => o.Months);
_logger.LogInformation(
"Timeline analysis complete: {GapCount} gaps ({TotalGapMonths} months), {OverlapCount} overlaps ({TotalOverlapMonths} months)",
gaps.Count, totalGapMonths, overlaps.Count, totalOverlapMonths);
return new TimelineAnalysisResult
{
TotalGapMonths = totalGapMonths,
TotalOverlapMonths = totalOverlapMonths,
Gaps = gaps,
Overlaps = overlaps
};
}
private List<TimelineGap> DetectGaps(List<EmploymentEntry> sortedEmployment)
{
var gaps = new List<TimelineGap>();
for (var i = 0; i < sortedEmployment.Count - 1; i++)
{
var current = sortedEmployment[i];
var next = sortedEmployment[i + 1];
// Get the effective end date for the current position
var currentEndDate = GetEffectiveEndDate(current);
var nextStartDate = next.StartDate!.Value;
// Skip if there's no gap or overlap
if (currentEndDate >= nextStartDate)
{
continue;
}
var gapMonths = CalculateMonthsDifference(currentEndDate, nextStartDate);
// Only report gaps of 3+ months
if (gapMonths >= MinimumGapMonths)
{
_logger.LogDebug(
"Detected {Months} month gap between {EndDate} and {StartDate}",
gapMonths, currentEndDate, nextStartDate);
gaps.Add(new TimelineGap
{
StartDate = currentEndDate,
EndDate = nextStartDate,
Months = gapMonths
});
}
}
return gaps;
}
private List<TimelineOverlap> DetectOverlaps(List<EmploymentEntry> sortedEmployment)
{
var overlaps = new List<TimelineOverlap>();
for (var i = 0; i < sortedEmployment.Count; i++)
{
for (var j = i + 1; j < sortedEmployment.Count; j++)
{
var earlier = sortedEmployment[i];
var later = sortedEmployment[j];
var overlap = CalculateOverlap(earlier, later);
if (overlap is not null && overlap.Value.Months > AllowedOverlapMonths)
{
_logger.LogDebug(
"Detected {Months} month overlap between {Company1} and {Company2}",
overlap.Value.Months, earlier.CompanyName, later.CompanyName);
overlaps.Add(new TimelineOverlap
{
Company1 = earlier.CompanyName,
Company2 = later.CompanyName,
OverlapStart = overlap.Value.Start,
OverlapEnd = overlap.Value.End,
Months = overlap.Value.Months
});
}
}
}
return overlaps;
}
private static (DateOnly Start, DateOnly End, int Months)? CalculateOverlap(
EmploymentEntry earlier,
EmploymentEntry later)
{
if (!earlier.StartDate.HasValue || !later.StartDate.HasValue)
{
return null;
}
var earlierEnd = GetEffectiveEndDate(earlier);
var laterStart = later.StartDate.Value;
// No overlap if earlier job ended before later job started
if (earlierEnd <= laterStart)
{
return null;
}
var laterEnd = GetEffectiveEndDate(later);
// The overlap period
var overlapStart = laterStart;
var overlapEnd = earlierEnd < laterEnd ? earlierEnd : laterEnd;
if (overlapStart >= overlapEnd)
{
return null;
}
var months = CalculateMonthsDifference(overlapStart, overlapEnd);
return (overlapStart, overlapEnd, months);
}
private static DateOnly GetEffectiveEndDate(EmploymentEntry entry)
{
if (entry.EndDate.HasValue)
{
return entry.EndDate.Value;
}
// If marked as current or no end date, use today
return DateOnly.FromDateTime(DateTime.UtcNow);
}
private static int CalculateMonthsDifference(DateOnly startDate, DateOnly endDate)
{
var yearDiff = endDate.Year - startDate.Year;
var monthDiff = endDate.Month - startDate.Month;
var totalMonths = (yearDiff * 12) + monthDiff;
// Add a month if we've passed the day in the month
if (endDate.Day >= startDate.Day)
{
totalMonths++;
}
return Math.Max(0, totalMonths);
}
}

View File

@@ -0,0 +1,28 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using RealCV.Application.Interfaces;
namespace RealCV.Infrastructure.Services;
public sealed class UserContextService : IUserContextService
{
private readonly AuthenticationStateProvider _authenticationStateProvider;
public UserContextService(AuthenticationStateProvider authenticationStateProvider)
{
_authenticationStateProvider = authenticationStateProvider;
}
public async Task<Guid?> GetCurrentUserIdAsync()
{
var authState = await _authenticationStateProvider.GetAuthenticationStateAsync();
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
{
return null;
}
return userId;
}
}