From 652aa2e612b7390a7c7d7637b918f104749cb46b Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 20 Jan 2026 20:00:24 +0100 Subject: [PATCH] Add comprehensive CV verification checks and dashboard auto-refresh - Add dashboard auto-refresh polling to update when processing completes - Skip verification for freelance employers (but not contractors) - Add incorporation date check (flags employment before company existed) - Add dissolution date check (flags employment at dissolved companies) - Add dormant company check (flags non-director roles at dormant companies) - Add company size vs role check (flags senior roles at micro-entities) - Add SIC code mismatch check (flags tech roles at non-tech companies) - Add director verification against Companies House officers - Add rapid career progression detection (3+ seniority jumps in <2 years) - Add early career senior role detection (<2 years after education) - Extend CompanyVerificationResult with flags and company data - Add officers endpoint to Companies House client - Fix null reference warning in Report.razor Co-Authored-By: Claude Opus 4.5 --- .../Interfaces/ICompanyVerifierService.cs | 3 +- .../Models/CompanyVerificationResult.cs | 22 + src/TrueCV.Domain/Entities/CompanyCache.cs | 9 + .../ExternalApis/CompaniesHouseClient.cs | 73 +++ .../Jobs/ProcessCVCheckJob.cs | 316 ++++++++++- .../Services/CompanyVerifierService.cs | 494 +++++++++++++++++- .../Components/Pages/Dashboard.razor | 45 ++ src/TrueCV.Web/Components/Pages/Report.razor | 2 +- .../Jobs/ProcessCVCheckJobTests.cs | 9 +- 9 files changed, 937 insertions(+), 36 deletions(-) diff --git a/src/TrueCV.Application/Interfaces/ICompanyVerifierService.cs b/src/TrueCV.Application/Interfaces/ICompanyVerifierService.cs index 96fb495..138189c 100644 --- a/src/TrueCV.Application/Interfaces/ICompanyVerifierService.cs +++ b/src/TrueCV.Application/Interfaces/ICompanyVerifierService.cs @@ -5,6 +5,7 @@ namespace TrueCV.Application.Interfaces; public interface ICompanyVerifierService { - Task VerifyCompanyAsync(string companyName, DateOnly? startDate, DateOnly? endDate); + Task VerifyCompanyAsync(string companyName, DateOnly? startDate, DateOnly? endDate, string? jobTitle = null); Task> SearchCompaniesAsync(string query); + Task VerifyDirectorAsync(string companyNumber, string candidateName, DateOnly? startDate, DateOnly? endDate); } diff --git a/src/TrueCV.Application/Models/CompanyVerificationResult.cs b/src/TrueCV.Application/Models/CompanyVerificationResult.cs index 32a0898..129a30f 100644 --- a/src/TrueCV.Application/Models/CompanyVerificationResult.cs +++ b/src/TrueCV.Application/Models/CompanyVerificationResult.cs @@ -10,4 +10,26 @@ public sealed record CompanyVerificationResult public string? VerificationNotes { get; init; } public DateOnly? ClaimedStartDate { get; init; } public DateOnly? ClaimedEndDate { get; init; } + public string? CompanyType { get; init; } + public string? ClaimedJobTitle { get; init; } + public bool? JobTitlePlausible { get; init; } + public string? JobTitleNotes { get; init; } + + // Additional company data for verification checks + public string? CompanyStatus { get; init; } + public DateOnly? IncorporationDate { get; init; } + public DateOnly? DissolutionDate { get; init; } + public string? AccountsCategory { get; init; } + public List? SicCodes { get; init; } + + // Additional verification flags + public List Flags { get; init; } = []; +} + +public sealed record CompanyVerificationFlag +{ + public required string Type { get; init; } + public required string Severity { get; init; } + public required string Message { get; init; } + public int ScoreImpact { get; init; } } diff --git a/src/TrueCV.Domain/Entities/CompanyCache.cs b/src/TrueCV.Domain/Entities/CompanyCache.cs index 7d83f83..8db3c2d 100644 --- a/src/TrueCV.Domain/Entities/CompanyCache.cs +++ b/src/TrueCV.Domain/Entities/CompanyCache.cs @@ -16,9 +16,18 @@ public class CompanyCache [MaxLength(64)] public string Status { get; set; } = string.Empty; + [MaxLength(64)] + public string? CompanyType { get; set; } + public DateOnly? IncorporationDate { get; set; } public DateOnly? DissolutionDate { get; set; } + [MaxLength(64)] + public string? AccountsCategory { get; set; } + + [MaxLength(256)] + public string? SicCodesJson { get; set; } + public DateTime CachedAt { get; set; } } diff --git a/src/TrueCV.Infrastructure/ExternalApis/CompaniesHouseClient.cs b/src/TrueCV.Infrastructure/ExternalApis/CompaniesHouseClient.cs index 376c741..5b7cd28 100644 --- a/src/TrueCV.Infrastructure/ExternalApis/CompaniesHouseClient.cs +++ b/src/TrueCV.Infrastructure/ExternalApis/CompaniesHouseClient.cs @@ -117,6 +117,50 @@ public sealed class CompaniesHouseClient throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.", ex); } } + + public async Task GetOfficersAsync( + string companyNumber, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(companyNumber); + + var requestUrl = $"/company/{companyNumber}/officers"; + + _logger.LogDebug("Fetching officers for company: {CompanyNumber}", companyNumber); + + try + { + var response = await _httpClient.GetAsync(requestUrl, cancellationToken); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + _logger.LogDebug("No officers found for company: {CompanyNumber}", companyNumber); + return null; + } + + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + _logger.LogWarning("Rate limit exceeded for Companies House API"); + throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later."); + } + + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync( + JsonOptions, + cancellationToken); + + _logger.LogDebug("Retrieved {Count} officers for company {CompanyNumber}", + result?.Items?.Count ?? 0, companyNumber); + + return result; + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests) + { + _logger.LogWarning("Rate limit exceeded for Companies House API"); + throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.", ex); + } + } } // DTOs for Companies House API responses @@ -138,6 +182,7 @@ public sealed record CompaniesHouseSearchItem public string? DateOfCessation { get; init; } public CompaniesHouseAddress? Address { get; init; } public string? AddressSnippet { get; init; } + public List? SicCodes { get; init; } } public sealed record CompaniesHouseCompany @@ -149,6 +194,34 @@ public sealed record CompaniesHouseCompany public string? DateOfCreation { get; init; } public string? DateOfCessation { get; init; } public CompaniesHouseAddress? RegisteredOfficeAddress { get; init; } + public List? SicCodes { get; init; } + public CompaniesHouseAccounts? Accounts { get; init; } +} + +public sealed record CompaniesHouseAccounts +{ + public string? AccountingReferenceDate { get; init; } + public CompaniesHouseLastAccounts? LastAccounts { get; init; } +} + +public sealed record CompaniesHouseLastAccounts +{ + public string? MadeUpTo { get; init; } + public string? Type { get; init; } // e.g., "micro-entity", "small", "medium", "dormant", etc. +} + +public sealed record CompaniesHouseOfficersResponse +{ + public int TotalResults { get; init; } + public List Items { get; init; } = []; +} + +public sealed record CompaniesHouseOfficer +{ + public required string Name { get; init; } + public string? OfficerRole { get; init; } + public string? AppointedOn { get; init; } + public string? ResignedOn { get; init; } } public sealed record CompaniesHouseAddress diff --git a/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs b/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs index 7b208c1..2b4052d 100644 --- a/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs +++ b/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs @@ -22,6 +22,10 @@ public sealed class ProcessCVCheckJob private const int BaseScore = 100; private const int UnverifiedCompanyPenalty = 10; + private const int ImplausibleJobTitlePenalty = 15; + private const int CompanyVerificationFlagPenalty = 5; // Base penalty for company flags, actual from flag.ScoreImpact + private const int RapidProgressionPenalty = 10; + private const int EarlyCareerSeniorRolePenalty = 10; private const int GapMonthPenalty = 1; private const int MaxGapPenalty = 10; private const int OverlapMonthPenalty = 2; @@ -86,22 +90,46 @@ public sealed class ProcessCVCheckJob await _dbContext.SaveChangesAsync(cancellationToken); // Step 5: Verify each employment entry (parallelized with rate limiting) - var verificationTasks = cvData.Employment.Select(async employment => - { - var result = await _companyVerifierService.VerifyCompanyAsync( - employment.CompanyName, - employment.StartDate, - employment.EndDate); + // Skip freelance entries as they cannot be verified against company registries + var verificationTasks = cvData.Employment + .Where(e => !IsFreelance(e.CompanyName)) + .Select(async employment => + { + var result = await _companyVerifierService.VerifyCompanyAsync( + employment.CompanyName, + employment.StartDate, + employment.EndDate, + employment.JobTitle); - _logger.LogDebug( - "Verified {Company}: {IsVerified} (Score: {Score}%)", - employment.CompanyName, result.IsVerified, result.MatchScore); + _logger.LogDebug( + "Verified {Company}: {IsVerified} (Score: {Score}%), JobTitle: {JobTitle}, Plausible: {Plausible}", + employment.CompanyName, result.IsVerified, result.MatchScore, + employment.JobTitle, result.JobTitlePlausible); - return result; - }); + return result; + }); var verificationResults = (await Task.WhenAll(verificationTasks)).ToList(); + // Add freelance entries as auto-verified (skipped) + foreach (var employment in cvData.Employment.Where(e => IsFreelance(e.CompanyName))) + { + verificationResults.Add(new CompanyVerificationResult + { + ClaimedCompany = employment.CompanyName, + IsVerified = true, + MatchScore = 100, + VerificationNotes = "Freelance/self-employed - verification skipped", + ClaimedJobTitle = employment.JobTitle, + JobTitlePlausible = true + }); + + _logger.LogDebug("Skipped verification for freelance entry: {Company}", employment.CompanyName); + } + + // Step 5b: Verify director claims against Companies House officers + await VerifyDirectorClaims(cvData.FullName, verificationResults, cancellationToken); + // Step 6: Verify education entries var educationResults = _educationVerifierService.VerifyAll( cvData.Education, @@ -122,7 +150,7 @@ public sealed class ProcessCVCheckJob cvCheckId, timelineAnalysis.Gaps.Count, timelineAnalysis.Overlaps.Count); // Step 8: Calculate veracity score - var (score, flags) = CalculateVeracityScore(verificationResults, educationResults, timelineAnalysis); + var (score, flags) = CalculateVeracityScore(verificationResults, educationResults, timelineAnalysis, cvData); _logger.LogDebug("Calculated veracity score for check {CheckId}: {Score}", cvCheckId, score); @@ -194,7 +222,8 @@ public sealed class ProcessCVCheckJob private static (int Score, List Flags) CalculateVeracityScore( List verifications, List educationResults, - TimelineAnalysisResult timeline) + TimelineAnalysisResult timeline, + CVData cvData) { var score = BaseScore; var flags = new List(); @@ -214,6 +243,48 @@ public sealed class ProcessCVCheckJob }); } + // Process company verification flags (incorporation date, dissolution, dormant, etc.) + foreach (var verification in verifications.Where(v => v.Flags.Count > 0)) + { + foreach (var companyFlag in verification.Flags) + { + var penalty = Math.Abs(companyFlag.ScoreImpact); + score -= penalty; + + var severity = companyFlag.Severity switch + { + "Critical" => FlagSeverity.Critical, + "Warning" => FlagSeverity.Warning, + _ => FlagSeverity.Info + }; + + flags.Add(new FlagResult + { + Category = FlagCategory.Employment.ToString(), + Severity = severity.ToString(), + Title = companyFlag.Type switch + { + "EmploymentBeforeIncorporation" => "Employment Before Company Existed", + "EmploymentAtDissolvedCompany" => "Employment at Dissolved Company", + "CurrentEmploymentAtDissolvedCompany" => "Current Employment at Dissolved Company", + "EmploymentAtDormantCompany" => "Employment at Dormant Company", + "SeniorRoleAtMicroCompany" => "Senior Role at Micro Company", + "SicCodeMismatch" => "Role/Industry Mismatch", + "ImplausibleJobTitle" => "Implausible Job Title", + _ => companyFlag.Type + }, + Description = companyFlag.Message, + ScoreImpact = -penalty + }); + } + } + + // Check for rapid career progression + CheckRapidCareerProgression(cvData.Employment, flags, ref score); + + // Check for early career senior roles (relative to education end date) + CheckEarlyCareerSeniorRoles(cvData.Employment, cvData.Education, flags, ref score); + // Penalty for diploma mills (critical) foreach (var edu in educationResults.Where(e => e.IsDiplomaMill)) { @@ -328,4 +399,223 @@ public sealed class ProcessCVCheckJob _ => "Very Poor" }; } + + private static bool IsFreelance(string companyName) + { + if (string.IsNullOrWhiteSpace(companyName)) return false; + + var name = companyName.Trim().ToLowerInvariant(); + return name == "freelance" || + name == "freelancer" || + name == "self-employed" || + name == "self employed" || + name.StartsWith("freelance ") || + name.StartsWith("self-employed ") || + name.Contains("(freelance)") || + name.Contains("(self-employed)"); + } + + private async Task VerifyDirectorClaims( + string candidateName, + List verificationResults, + CancellationToken cancellationToken) + { + // Find all director claims at verified companies + foreach (var result in verificationResults.Where(v => v.IsVerified && !string.IsNullOrEmpty(v.MatchedCompanyNumber))) + { + var jobTitle = result.ClaimedJobTitle?.ToLowerInvariant() ?? ""; + + // Check if this is a director claim + var isDirectorClaim = jobTitle.Contains("director") || + jobTitle.Contains("company secretary") || + jobTitle == "md" || + jobTitle.Contains("managing director"); + + if (!isDirectorClaim) continue; + + _logger.LogDebug( + "Verifying director claim for {Candidate} at {Company}", + candidateName, result.MatchedCompanyName); + + var isVerifiedDirector = await _companyVerifierService.VerifyDirectorAsync( + result.MatchedCompanyNumber!, + candidateName, + result.ClaimedStartDate, + result.ClaimedEndDate); + + if (isVerifiedDirector == false) + { + // Add a flag for unverified director claim + var flags = result.Flags.ToList(); + flags.Add(new CompanyVerificationFlag + { + Type = "UnverifiedDirectorClaim", + Severity = "Critical", + Message = $"Claimed director role at '{result.MatchedCompanyName}' but candidate name not found in Companies House officers list", + ScoreImpact = -20 + }); + + // Update the result with the new flag + var index = verificationResults.IndexOf(result); + verificationResults[index] = result with { Flags = flags }; + + _logger.LogWarning( + "Director claim not verified for {Candidate} at {Company}", + candidateName, result.MatchedCompanyName); + } + else if (isVerifiedDirector == true) + { + _logger.LogInformation( + "Director claim verified for {Candidate} at {Company}", + candidateName, result.MatchedCompanyName); + } + } + } + + private static void CheckRapidCareerProgression( + List employment, + List flags, + ref int score) + { + // Group employment by company and check for rapid promotions + var byCompany = employment + .Where(e => !string.IsNullOrWhiteSpace(e.CompanyName) && e.StartDate.HasValue) + .GroupBy(e => e.CompanyName.ToLowerInvariant()) + .Where(g => g.Count() > 1); + + foreach (var companyGroup in byCompany) + { + var orderedRoles = companyGroup.OrderBy(e => e.StartDate).ToList(); + + for (int i = 1; i < orderedRoles.Count; i++) + { + var prevRole = orderedRoles[i - 1]; + var currRole = orderedRoles[i]; + + var prevSeniority = GetSeniorityLevel(prevRole.JobTitle); + var currSeniority = GetSeniorityLevel(currRole.JobTitle); + + // Check for jump of 3+ seniority levels + var seniorityJump = currSeniority - prevSeniority; + if (seniorityJump >= 3) + { + // Calculate time between roles + var monthsBetween = ((currRole.StartDate!.Value.Year - prevRole.StartDate!.Value.Year) * 12) + + (currRole.StartDate!.Value.Month - prevRole.StartDate!.Value.Month); + + // If jumped 3+ levels in less than 2 years, flag it + if (monthsBetween < 24) + { + score -= RapidProgressionPenalty; + + flags.Add(new FlagResult + { + Category = FlagCategory.Employment.ToString(), + Severity = FlagSeverity.Warning.ToString(), + Title = "Rapid Career Progression", + Description = $"Promoted from '{prevRole.JobTitle}' to '{currRole.JobTitle}' at '{companyGroup.First().CompanyName}' in {monthsBetween} months - unusually fast progression", + ScoreImpact = -RapidProgressionPenalty + }); + } + } + } + } + } + + private static void CheckEarlyCareerSeniorRoles( + List employment, + List education, + List flags, + ref int score) + { + // Find the latest education end date to estimate career start + var latestEducationEnd = education + .Where(e => e.EndDate.HasValue) + .Select(e => e.EndDate!.Value) + .DefaultIfEmpty(DateOnly.MinValue) + .Max(); + + if (latestEducationEnd == DateOnly.MinValue) + { + // No education dates available, skip check + return; + } + + foreach (var emp in employment.Where(e => e.StartDate.HasValue)) + { + var monthsAfterEducation = ((emp.StartDate!.Value.Year - latestEducationEnd.Year) * 12) + + (emp.StartDate!.Value.Month - latestEducationEnd.Month); + + // Check if this is a senior role started within 2 years of finishing education + if (monthsAfterEducation < 24 && monthsAfterEducation >= 0) + { + var seniority = GetSeniorityLevel(emp.JobTitle); + + // Flag if they're claiming a senior role (level 4+) very early in career + if (seniority >= 4) + { + score -= EarlyCareerSeniorRolePenalty; + + flags.Add(new FlagResult + { + Category = FlagCategory.Employment.ToString(), + Severity = FlagSeverity.Warning.ToString(), + Title = "Early Career Senior Role", + Description = $"Claimed senior role '{emp.JobTitle}' at '{emp.CompanyName}' only {monthsAfterEducation} months after completing education", + ScoreImpact = -EarlyCareerSeniorRolePenalty + }); + } + } + } + } + + private static int GetSeniorityLevel(string? jobTitle) + { + if (string.IsNullOrWhiteSpace(jobTitle)) return 0; + + var title = jobTitle.ToLowerInvariant(); + + // Level 6: C-suite + if (title.Contains("ceo") || title.Contains("cto") || title.Contains("cfo") || + title.Contains("coo") || title.Contains("cio") || title.Contains("chief") || + title.Contains("managing director") || title == "md" || + title.Contains("president") || title.Contains("chairman") || + title.Contains("chairwoman") || title.Contains("chairperson")) + { + return 6; + } + + // Level 5: VP / Executive + if (title.Contains("vice president") || title.Contains("vp ") || + title.StartsWith("vp") || title.Contains("svp") || + title.Contains("executive director") || title.Contains("executive vice")) + { + return 5; + } + + // Level 4: Director / Head + if (title.Contains("director") || title.Contains("head of")) + { + return 4; + } + + // Level 3: Senior / Lead / Principal / Manager + if (title.Contains("senior") || title.Contains("lead") || + title.Contains("principal") || title.Contains("manager") || + title.Contains("team lead") || title.Contains("staff")) + { + return 3; + } + + // Level 2: Mid-level (no junior, no senior) + if (!title.Contains("junior") && !title.Contains("trainee") && + !title.Contains("intern") && !title.Contains("graduate") && + !title.Contains("entry") && !title.Contains("assistant")) + { + return 2; + } + + // Level 1: Junior / Entry-level + return 1; + } } diff --git a/src/TrueCV.Infrastructure/Services/CompanyVerifierService.cs b/src/TrueCV.Infrastructure/Services/CompanyVerifierService.cs index e26dab0..c0ab1c7 100644 --- a/src/TrueCV.Infrastructure/Services/CompanyVerifierService.cs +++ b/src/TrueCV.Infrastructure/Services/CompanyVerifierService.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using FuzzySharp; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -20,6 +21,15 @@ public sealed class CompanyVerifierService : ICompanyVerifierService private const int FuzzyMatchThreshold = 70; private const int CacheExpirationDays = 30; + // SIC codes for tech/software companies + private static readonly HashSet TechSicCodes = new() + { + "62011", "62012", "62020", "62030", "62090", // Computer programming and consultancy + "63110", "63120", // Data processing, hosting + "58210", "58290", // Publishing of computer games, other software + "61100", "61200", "61300", "61900" // Telecommunications + }; + public CompanyVerifierService( CompaniesHouseClient companiesHouseClient, IDbContextFactory dbContextFactory, @@ -33,18 +43,20 @@ public sealed class CompanyVerifierService : ICompanyVerifierService public async Task VerifyCompanyAsync( string companyName, DateOnly? startDate, - DateOnly? endDate) + DateOnly? endDate, + string? jobTitle = null) { ArgumentException.ThrowIfNullOrWhiteSpace(companyName); _logger.LogDebug("Verifying company: {CompanyName}", companyName); + var flags = new List(); // Try to find a cached match first var cachedMatch = await FindCachedMatchAsync(companyName); if (cachedMatch is not null) { _logger.LogDebug("Found cached company match for: {CompanyName}", companyName); - return CreateVerificationResult(companyName, cachedMatch, startDate, endDate); + return CreateResultFromCache(cachedMatch, companyName, startDate, endDate, jobTitle, flags); } // Search Companies House @@ -55,7 +67,7 @@ public sealed class CompanyVerifierService : ICompanyVerifierService if (searchResponse?.Items is null || searchResponse.Items.Count == 0) { _logger.LogDebug("No companies found for: {CompanyName}", companyName); - return CreateUnverifiedResult(companyName, startDate, endDate, "No matching company found in Companies House"); + return CreateUnverifiedResult(companyName, startDate, endDate, jobTitle, "No matching company found in Companies House"); } // Find best fuzzy match @@ -64,18 +76,58 @@ public sealed class CompanyVerifierService : ICompanyVerifierService if (bestMatch is null) { _logger.LogDebug("No fuzzy match above threshold for: {CompanyName}", companyName); - return CreateUnverifiedResult(companyName, startDate, endDate, + return CreateUnverifiedResult(companyName, startDate, endDate, jobTitle, $"No company name matched above {FuzzyMatchThreshold}% threshold"); } - // Cache the matched company var match = bestMatch.Value; - await CacheCompanyAsync(match.Item); + + // Fetch full company details for additional data + var companyDetails = await _companiesHouseClient.GetCompanyAsync(match.Item.CompanyNumber); + + // Cache the matched company with full details + await CacheCompanyAsync(match.Item, companyDetails); _logger.LogInformation( "Verified company {ClaimedName} matched to {MatchedName} with score {Score}%", companyName, match.Item.Title, match.Score); + // Run all verification checks + var incorporationDate = DateHelpers.ParseDate(match.Item.DateOfCreation); + var dissolutionDate = DateHelpers.ParseDate(match.Item.DateOfCessation); + var companyStatus = match.Item.CompanyStatus; + var companyType = match.Item.CompanyType; + var sicCodes = companyDetails?.SicCodes ?? match.Item.SicCodes; + var accountsCategory = companyDetails?.Accounts?.LastAccounts?.Type; + + // Check 1: Employment before company incorporation + CheckIncorporationDate(flags, startDate, incorporationDate, match.Item.Title); + + // Check 2: Employment at dissolved company + CheckDissolutionDate(flags, endDate, dissolutionDate, companyStatus, match.Item.Title); + + // Check 3: Dormant company check + CheckDormantCompany(flags, accountsCategory, jobTitle, match.Item.Title); + + // Check 4: Company size vs job title + CheckCompanySizeVsRole(flags, accountsCategory, jobTitle, match.Item.Title); + + // Check 5: SIC code vs job title mismatch + CheckSicCodeMismatch(flags, sicCodes, jobTitle, match.Item.Title); + + // Check 6: Job title plausibility for PLCs + var (jobPlausible, jobNotes) = CheckJobTitlePlausibility(jobTitle, companyType); + if (jobPlausible == false) + { + flags.Add(new CompanyVerificationFlag + { + Type = "ImplausibleJobTitle", + Severity = "Critical", + Message = jobNotes ?? "Job title requires verification", + ScoreImpact = -15 + }); + } + return new CompanyVerificationResult { ClaimedCompany = companyName, @@ -85,13 +137,23 @@ public sealed class CompanyVerifierService : ICompanyVerifierService IsVerified = true, VerificationNotes = $"Matched with {match.Score}% confidence", ClaimedStartDate = startDate, - ClaimedEndDate = endDate + ClaimedEndDate = endDate, + CompanyType = companyType, + CompanyStatus = companyStatus, + IncorporationDate = incorporationDate, + DissolutionDate = dissolutionDate, + AccountsCategory = accountsCategory, + SicCodes = sicCodes, + ClaimedJobTitle = jobTitle, + JobTitlePlausible = jobPlausible, + JobTitleNotes = jobNotes, + Flags = flags }; } catch (CompaniesHouseRateLimitException ex) { _logger.LogWarning(ex, "Rate limit hit while verifying company: {CompanyName}", companyName); - return CreateUnverifiedResult(companyName, startDate, endDate, + return CreateUnverifiedResult(companyName, startDate, endDate, jobTitle, "Verification temporarily unavailable due to rate limiting"); } } @@ -119,14 +181,364 @@ public sealed class CompanyVerifierService : ICompanyVerifierService }).ToList(); } + public async Task VerifyDirectorAsync( + string companyNumber, + string candidateName, + DateOnly? startDate, + DateOnly? endDate) + { + if (string.IsNullOrWhiteSpace(companyNumber) || string.IsNullOrWhiteSpace(candidateName)) + { + return null; + } + + try + { + var officers = await _companiesHouseClient.GetOfficersAsync(companyNumber); + + if (officers?.Items is null || officers.Items.Count == 0) + { + _logger.LogDebug("No officers found for company {CompanyNumber}", companyNumber); + return null; + } + + // Normalize candidate name for comparison + var normalizedCandidate = NormalizeName(candidateName); + + foreach (var officer in officers.Items) + { + // Check if officer role is director-like + var role = officer.OfficerRole?.ToLowerInvariant() ?? ""; + if (!role.Contains("director") && !role.Contains("secretary")) + { + continue; + } + + // Fuzzy match the name + var normalizedOfficer = NormalizeName(officer.Name); + var matchScore = Fuzz.Ratio(normalizedCandidate, normalizedOfficer); + + if (matchScore >= 80) // High threshold for name matching + { + // Check date overlap + var appointedOn = DateHelpers.ParseDate(officer.AppointedOn); + var resignedOn = DateHelpers.ParseDate(officer.ResignedOn); + + // If no claimed dates, just check if names match + if (!startDate.HasValue && !endDate.HasValue) + { + _logger.LogDebug( + "Found matching director {OfficerName} for candidate {CandidateName} at company {CompanyNumber}", + officer.Name, candidateName, companyNumber); + return true; + } + + // Check if employment period overlaps with directorship + var datesOverlap = DatesOverlap( + startDate, endDate, + appointedOn, resignedOn); + + if (datesOverlap) + { + _logger.LogDebug( + "Verified director {OfficerName} matches candidate {CandidateName} with overlapping dates", + officer.Name, candidateName); + return true; + } + } + } + + _logger.LogDebug( + "No matching director found for candidate {CandidateName} at company {CompanyNumber}", + candidateName, companyNumber); + return false; + } + catch (CompaniesHouseRateLimitException) + { + _logger.LogWarning("Rate limit hit while verifying director for company {CompanyNumber}", companyNumber); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error verifying director for company {CompanyNumber}", companyNumber); + return null; + } + } + + private static string NormalizeName(string name) + { + if (string.IsNullOrWhiteSpace(name)) return ""; + + // Companies House often stores names as "SURNAME, Firstname" + // Convert to "Firstname Surname" format for comparison + var normalized = name.ToUpperInvariant().Trim(); + + if (normalized.Contains(',')) + { + var parts = normalized.Split(',', 2); + if (parts.Length == 2) + { + normalized = $"{parts[1].Trim()} {parts[0].Trim()}"; + } + } + + return normalized; + } + + private static bool DatesOverlap(DateOnly? start1, DateOnly? end1, DateOnly? start2, DateOnly? end2) + { + // If no dates, assume overlap + if (!start1.HasValue && !end1.HasValue) return true; + if (!start2.HasValue && !end2.HasValue) return true; + + // Use default dates for missing values + var s1 = start1 ?? DateOnly.MinValue; + var e1 = end1 ?? DateOnly.MaxValue; + var s2 = start2 ?? DateOnly.MinValue; + var e2 = end2 ?? DateOnly.MaxValue; + + // Check overlap: periods overlap if one starts before the other ends + return s1 <= e2 && s2 <= e1; + } + + #region Verification Checks + + private static void CheckIncorporationDate( + List flags, + DateOnly? claimedStartDate, + DateOnly? incorporationDate, + string companyName) + { + if (claimedStartDate.HasValue && incorporationDate.HasValue) + { + if (claimedStartDate.Value < incorporationDate.Value) + { + flags.Add(new CompanyVerificationFlag + { + Type = "EmploymentBeforeIncorporation", + Severity = "Critical", + Message = $"Claimed employment at '{companyName}' starting {claimedStartDate:MMM yyyy} is before company incorporation date {incorporationDate:MMM yyyy}", + ScoreImpact = -20 + }); + } + } + } + + private static void CheckDissolutionDate( + List flags, + DateOnly? claimedEndDate, + DateOnly? dissolutionDate, + string? companyStatus, + string companyName) + { + var isDissolvedStatus = companyStatus?.ToLowerInvariant() is "dissolved" or "liquidation" or "administration"; + + if (dissolutionDate.HasValue && isDissolvedStatus) + { + // Allow 3 month buffer for wind-down + var bufferDate = dissolutionDate.Value.AddMonths(3); + + if (claimedEndDate.HasValue && claimedEndDate.Value > bufferDate) + { + flags.Add(new CompanyVerificationFlag + { + Type = "EmploymentAtDissolvedCompany", + Severity = "Critical", + Message = $"Claimed employment at '{companyName}' until {claimedEndDate:MMM yyyy} but company was dissolved on {dissolutionDate:MMM yyyy}", + ScoreImpact = -20 + }); + } + else if (!claimedEndDate.HasValue) // Current employment at dissolved company + { + flags.Add(new CompanyVerificationFlag + { + Type = "CurrentEmploymentAtDissolvedCompany", + Severity = "Critical", + Message = $"Claims current employment at '{companyName}' but company was dissolved on {dissolutionDate:MMM yyyy}", + ScoreImpact = -25 + }); + } + } + } + + private static void CheckDormantCompany( + List flags, + string? accountsCategory, + string? jobTitle, + string companyName) + { + if (string.IsNullOrWhiteSpace(accountsCategory)) return; + + var isDormant = accountsCategory.ToLowerInvariant().Contains("dormant"); + if (!isDormant) return; + + // Directors can maintain dormant companies, but other roles are suspicious + var title = jobTitle?.ToLowerInvariant() ?? ""; + var isDirectorRole = title.Contains("director") || title.Contains("company secretary"); + + if (!isDirectorRole) + { + flags.Add(new CompanyVerificationFlag + { + Type = "EmploymentAtDormantCompany", + Severity = "Warning", + Message = $"Claimed active employment as '{jobTitle}' at '{companyName}' which files dormant accounts", + ScoreImpact = -10 + }); + } + } + + private static void CheckCompanySizeVsRole( + List flags, + string? accountsCategory, + string? jobTitle, + string companyName) + { + if (string.IsNullOrWhiteSpace(accountsCategory) || string.IsNullOrWhiteSpace(jobTitle)) return; + + var category = accountsCategory.ToLowerInvariant(); + var title = jobTitle.ToLowerInvariant(); + + // Micro-entity: < 10 employees, < £632k turnover + var isMicroEntity = category.Contains("micro"); + + // Check for senior management roles at micro companies + var isSeniorRole = title.Contains("vp") || + title.Contains("vice president") || + title.Contains("head of") || + title.Contains("chief") || + title.Contains("director of") || + title.Contains("senior director"); + + // At micro companies, having many senior roles is suspicious + if (isMicroEntity && isSeniorRole) + { + flags.Add(new CompanyVerificationFlag + { + Type = "SeniorRoleAtMicroCompany", + Severity = "Warning", + Message = $"Claimed senior role '{jobTitle}' at '{companyName}' which files micro-entity accounts (typically <10 employees)", + ScoreImpact = -10 + }); + } + } + + private static void CheckSicCodeMismatch( + List flags, + List? sicCodes, + string? jobTitle, + string companyName) + { + if (sicCodes is null || sicCodes.Count == 0 || string.IsNullOrWhiteSpace(jobTitle)) return; + + var title = jobTitle.ToLowerInvariant(); + + // Check if this is a tech role + var isTechRole = title.Contains("software") || + title.Contains("developer") || + title.Contains("engineer") || + title.Contains("programmer") || + title.Contains("data scientist") || + title.Contains("data analyst") || + title.Contains("devops") || + title.Contains("cloud") || + title.Contains("machine learning") || + title.Contains("ai ") || + title.Contains("frontend") || + title.Contains("backend") || + title.Contains("full stack") || + title.Contains("fullstack"); + + if (isTechRole) + { + // Check if company has any tech SIC codes + var hasTechSic = sicCodes.Any(s => TechSicCodes.Contains(s)); + + if (!hasTechSic) + { + // Get the primary SIC code description (simplified - just show code) + var primarySic = sicCodes.FirstOrDefault() ?? "Unknown"; + + flags.Add(new CompanyVerificationFlag + { + Type = "SicCodeMismatch", + Severity = "Info", + Message = $"Tech role '{jobTitle}' at '{companyName}' (SIC: {primarySic}) - company is not registered as a technology business", + ScoreImpact = -5 + }); + } + } + } + + private static (bool? IsPlausible, string? Notes) CheckJobTitlePlausibility(string? jobTitle, string? companyType) + { + if (string.IsNullOrWhiteSpace(jobTitle) || string.IsNullOrWhiteSpace(companyType)) + { + return (null, null); + } + + var title = jobTitle.Trim().ToLowerInvariant(); + var type = companyType.Trim().ToLowerInvariant(); + + // Check if this is a PLC (Public Limited Company) - these are large companies + var isPlc = type.Contains("plc") || type.Contains("public limited"); + + // Check for C-suite / very senior roles + var isCsuiteRole = title.Contains("ceo") || + title.Contains("chief executive") || + title.Contains("cto") || + title.Contains("chief technology") || + title.Contains("cfo") || + title.Contains("chief financial") || + title.Contains("coo") || + title.Contains("chief operating") || + title.Contains("cio") || + title.Contains("chief information") || + title.Contains("managing director") || + title == "md" || + title.Contains("chairman") || + title.Contains("chairwoman") || + title.Contains("chairperson") || + title.Contains("president"); + + // Check for board-level roles + var isBoardRole = title.Contains("board member") || + title.Contains("non-executive director") || + title.Contains("executive director") || + (title == "director" && !title.Contains("of")); + + if (isPlc && (isCsuiteRole || isBoardRole)) + { + return (false, $"Claimed senior role '{jobTitle}' at a PLC requires verification - C-suite positions at public companies are publicly disclosed"); + } + + // Check for VP/SVP at PLCs (also usually disclosed) + var isVpRole = title.Contains("vice president") || + title.Contains("vp ") || + title.StartsWith("vp") || + title.Contains("svp") || + title.Contains("senior vice president") || + title.Contains("evp") || + title.Contains("executive vice president"); + + if (isPlc && isVpRole) + { + return (false, $"Claimed VP-level role '{jobTitle}' at a PLC - senior positions at public companies should be verifiable"); + } + + return (true, null); + } + + #endregion + + #region Helper Methods + private async Task FindCachedMatchAsync(string companyName) { var cutoffDate = DateTime.UtcNow.AddDays(-CacheExpirationDays); - // Use factory to create a new DbContext for thread-safe parallel access await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); - // Get recent cached companies var cachedCompanies = await dbContext.CompanyCache .Where(c => c.CachedAt >= cutoffDate) .ToListAsync(); @@ -136,7 +548,6 @@ public sealed class CompanyVerifierService : ICompanyVerifierService return null; } - // Find best fuzzy match in cache var matches = cachedCompanies .Select(c => new { Company = c, Score = Fuzz.Ratio(companyName.ToUpperInvariant(), c.CompanyName.ToUpperInvariant()) }) .Where(m => m.Score >= FuzzyMatchThreshold) @@ -161,20 +572,26 @@ public sealed class CompanyVerifierService : ICompanyVerifierService return matches.Count > 0 ? matches[0] : null; } - private async Task CacheCompanyAsync(CompaniesHouseSearchItem item) + private async Task CacheCompanyAsync(CompaniesHouseSearchItem item, CompaniesHouseCompany? details) { - // Use factory to create a new DbContext for thread-safe parallel access await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); var existingCache = await dbContext.CompanyCache .FirstOrDefaultAsync(c => c.CompanyNumber == item.CompanyNumber); + var sicCodes = details?.SicCodes ?? item.SicCodes; + var sicCodesJson = sicCodes != null ? JsonSerializer.Serialize(sicCodes) : null; + var accountsCategory = details?.Accounts?.LastAccounts?.Type; + if (existingCache is not null) { existingCache.CompanyName = item.Title; existingCache.Status = item.CompanyStatus ?? "Unknown"; + existingCache.CompanyType = item.CompanyType; existingCache.IncorporationDate = DateHelpers.ParseDate(item.DateOfCreation); existingCache.DissolutionDate = DateHelpers.ParseDate(item.DateOfCessation); + existingCache.AccountsCategory = accountsCategory; + existingCache.SicCodesJson = sicCodesJson; existingCache.CachedAt = DateTime.UtcNow; } else @@ -184,8 +601,11 @@ public sealed class CompanyVerifierService : ICompanyVerifierService CompanyNumber = item.CompanyNumber, CompanyName = item.Title, Status = item.CompanyStatus ?? "Unknown", + CompanyType = item.CompanyType, IncorporationDate = DateHelpers.ParseDate(item.DateOfCreation), DissolutionDate = DateHelpers.ParseDate(item.DateOfCessation), + AccountsCategory = accountsCategory, + SicCodesJson = sicCodesJson, CachedAt = DateTime.UtcNow }; @@ -195,16 +615,41 @@ public sealed class CompanyVerifierService : ICompanyVerifierService await dbContext.SaveChangesAsync(); } - private static CompanyVerificationResult CreateVerificationResult( - string claimedCompany, + private CompanyVerificationResult CreateResultFromCache( CompanyCache cached, + string claimedCompany, DateOnly? startDate, - DateOnly? endDate) + DateOnly? endDate, + string? jobTitle, + List flags) { var matchScore = Fuzz.Ratio( claimedCompany.ToUpperInvariant(), cached.CompanyName.ToUpperInvariant()); + var sicCodes = !string.IsNullOrEmpty(cached.SicCodesJson) + ? JsonSerializer.Deserialize>(cached.SicCodesJson) + : null; + + // Run all verification checks + CheckIncorporationDate(flags, startDate, cached.IncorporationDate, cached.CompanyName); + CheckDissolutionDate(flags, endDate, cached.DissolutionDate, cached.Status, cached.CompanyName); + CheckDormantCompany(flags, cached.AccountsCategory, jobTitle, cached.CompanyName); + CheckCompanySizeVsRole(flags, cached.AccountsCategory, jobTitle, cached.CompanyName); + CheckSicCodeMismatch(flags, sicCodes, jobTitle, cached.CompanyName); + + var (jobPlausible, jobNotes) = CheckJobTitlePlausibility(jobTitle, cached.CompanyType); + if (jobPlausible == false) + { + flags.Add(new CompanyVerificationFlag + { + Type = "ImplausibleJobTitle", + Severity = "Critical", + Message = jobNotes ?? "Job title requires verification", + ScoreImpact = -15 + }); + } + return new CompanyVerificationResult { ClaimedCompany = claimedCompany, @@ -214,7 +659,17 @@ public sealed class CompanyVerifierService : ICompanyVerifierService IsVerified = true, VerificationNotes = $"Matched from cache with {matchScore}% confidence", ClaimedStartDate = startDate, - ClaimedEndDate = endDate + ClaimedEndDate = endDate, + CompanyType = cached.CompanyType, + CompanyStatus = cached.Status, + IncorporationDate = cached.IncorporationDate, + DissolutionDate = cached.DissolutionDate, + AccountsCategory = cached.AccountsCategory, + SicCodes = sicCodes, + ClaimedJobTitle = jobTitle, + JobTitlePlausible = jobPlausible, + JobTitleNotes = jobNotes, + Flags = flags }; } @@ -222,6 +677,7 @@ public sealed class CompanyVerifierService : ICompanyVerifierService string companyName, DateOnly? startDate, DateOnly? endDate, + string? jobTitle, string reason) { return new CompanyVerificationResult @@ -233,8 +689,10 @@ public sealed class CompanyVerifierService : ICompanyVerifierService IsVerified = false, VerificationNotes = reason, ClaimedStartDate = startDate, - ClaimedEndDate = endDate + ClaimedEndDate = endDate, + ClaimedJobTitle = jobTitle }; } + #endregion } diff --git a/src/TrueCV.Web/Components/Pages/Dashboard.razor b/src/TrueCV.Web/Components/Pages/Dashboard.razor index f100ec0..df6cc09 100644 --- a/src/TrueCV.Web/Components/Pages/Dashboard.razor +++ b/src/TrueCV.Web/Components/Pages/Dashboard.razor @@ -1,6 +1,7 @@ @page "/dashboard" @attribute [Authorize] @rendermode InteractiveServer +@implements IDisposable @inject ICVCheckService CVCheckService @inject NavigationManager NavigationManager @@ -250,10 +251,54 @@ private bool _isExporting; private string? _errorMessage; private Guid _userId; + private System.Threading.Timer? _pollingTimer; + private bool _isPolling; protected override async Task OnInitializedAsync() { await LoadChecks(); + StartPollingIfNeeded(); + } + + private void StartPollingIfNeeded() + { + if (HasProcessingChecks() && !_isPolling) + { + _isPolling = true; + _pollingTimer = new System.Threading.Timer(async _ => + { + await InvokeAsync(async () => + { + await LoadChecks(); + if (!HasProcessingChecks()) + { + StopPolling(); + } + StateHasChanged(); + }); + }, null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3)); + } + } + + private bool HasProcessingChecks() + { + foreach (var c in _checks) + { + if (c.Status == "Processing" || c.Status == "Pending") return true; + } + return false; + } + + private void StopPolling() + { + _isPolling = false; + _pollingTimer?.Dispose(); + _pollingTimer = null; + } + + public void Dispose() + { + StopPolling(); } private async Task LoadChecks() diff --git a/src/TrueCV.Web/Components/Pages/Report.razor b/src/TrueCV.Web/Components/Pages/Report.razor index 604007d..47a1266 100644 --- a/src/TrueCV.Web/Components/Pages/Report.razor +++ b/src/TrueCV.Web/Components/Pages/Report.razor @@ -115,7 +115,7 @@
-
+
@_report.OverallScore
@_report.ScoreLabel
diff --git a/tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs b/tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs index a1f53cc..cdfa41e 100644 --- a/tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs +++ b/tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs @@ -194,7 +194,8 @@ public sealed class ProcessCVCheckJobTests : IDisposable x => x.VerifyCompanyAsync( It.IsAny(), It.IsAny(), - It.IsAny()), + It.IsAny(), + It.IsAny()), Times.Exactly(3)); } @@ -1031,7 +1032,8 @@ public sealed class ProcessCVCheckJobTests : IDisposable .Setup(x => x.VerifyCompanyAsync( It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny())) .ReturnsAsync(() => queue.Count > 0 ? queue.Dequeue() : CreateDefaultVerificationResult()); } else @@ -1040,7 +1042,8 @@ public sealed class ProcessCVCheckJobTests : IDisposable .Setup(x => x.VerifyCompanyAsync( It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny())) .ReturnsAsync(CreateDefaultVerificationResult()); }