feat: Add additional verification APIs (FCA, SRA, GitHub, OpenCorporates, ORCID)

This adds five new free API integrations for enhanced CV verification:

- FCA Register API: Verify financial services professionals
- SRA Register API: Verify solicitors and legal professionals
- GitHub API: Verify developer profiles and technical skills
- OpenCorporates API: Verify international companies across jurisdictions
- ORCID API: Verify academic researchers and publications

Includes:
- API clients for all five services with retry policies
- Service implementations with name matching and validation
- Models for verification results with detailed flags
- Configuration options in appsettings.json
- DI registration for all services

🤖 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-24 13:05:52 +00:00
parent 8a4e46d872
commit 5d2965beae
19 changed files with 3249 additions and 0 deletions

View File

@@ -0,0 +1,509 @@
using Microsoft.Extensions.Logging;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
using RealCV.Infrastructure.Clients;
using InterfaceOrcidSearchResult = RealCV.Application.Interfaces.OrcidSearchResult;
namespace RealCV.Infrastructure.Services;
public sealed class AcademicVerifierService : IAcademicVerifierService
{
private readonly OrcidClient _orcidClient;
private readonly ILogger<AcademicVerifierService> _logger;
public AcademicVerifierService(
OrcidClient orcidClient,
ILogger<AcademicVerifierService> logger)
{
_orcidClient = orcidClient;
_logger = logger;
}
public async Task<AcademicVerificationResult> VerifyByOrcidAsync(string orcidId)
{
try
{
_logger.LogInformation("Verifying ORCID: {OrcidId}", orcidId);
var record = await _orcidClient.GetRecordAsync(orcidId);
if (record == null)
{
return new AcademicVerificationResult
{
ClaimedName = orcidId,
IsVerified = false,
VerificationNotes = "ORCID record not found"
};
}
// Extract name
string? name = null;
if (record.Person?.Name != null)
{
var nameParts = new List<string>();
if (!string.IsNullOrEmpty(record.Person.Name.GivenNames?.Value))
nameParts.Add(record.Person.Name.GivenNames.Value);
if (!string.IsNullOrEmpty(record.Person.Name.FamilyName?.Value))
nameParts.Add(record.Person.Name.FamilyName.Value);
name = string.Join(" ", nameParts);
}
// Get detailed employment information
var affiliations = new List<AcademicAffiliation>();
var employments = await _orcidClient.GetEmploymentsAsync(orcidId);
if (employments?.AffiliationGroup != null)
{
affiliations.AddRange(ExtractAffiliations(employments.AffiliationGroup, "employment"));
}
// Get detailed education information
var educationList = new List<AcademicEducation>();
var educations = await _orcidClient.GetEducationsAsync(orcidId);
if (educations?.AffiliationGroup != null)
{
educationList.AddRange(ExtractEducations(educations.AffiliationGroup));
}
// Get works/publications
var publications = new List<Publication>();
var works = await _orcidClient.GetWorksAsync(orcidId);
if (works?.Group != null)
{
publications.AddRange(ExtractPublications(works.Group));
}
return new AcademicVerificationResult
{
ClaimedName = name ?? orcidId,
IsVerified = true,
OrcidId = orcidId,
MatchedName = name,
OrcidUrl = record.OrcidIdentifier?.Uri,
Affiliations = affiliations,
TotalPublications = publications.Count,
RecentPublications = publications.Take(10).ToList(),
Education = educationList,
VerificationNotes = BuildVerificationSummary(affiliations, publications.Count, educationList.Count)
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error verifying ORCID: {OrcidId}", orcidId);
return new AcademicVerificationResult
{
ClaimedName = orcidId,
IsVerified = false,
VerificationNotes = $"Error during verification: {ex.Message}"
};
}
}
public async Task<AcademicVerificationResult> VerifyByNameAsync(
string name,
string? affiliation = null)
{
try
{
_logger.LogInformation("Searching ORCID for: {Name} at {Affiliation}",
name, affiliation ?? "any affiliation");
var query = name;
if (!string.IsNullOrEmpty(affiliation))
{
query = $"{name} {affiliation}";
}
var searchResponse = await _orcidClient.SearchResearchersAsync(query);
if (searchResponse?.Result == null || searchResponse.Result.Count == 0)
{
return new AcademicVerificationResult
{
ClaimedName = name,
IsVerified = false,
VerificationNotes = "No matching ORCID records found"
};
}
// Get the first match's ORCID ID
var firstMatch = searchResponse.Result.First();
var orcidId = firstMatch.OrcidIdentifier?.Path;
if (string.IsNullOrEmpty(orcidId))
{
return new AcademicVerificationResult
{
ClaimedName = name,
IsVerified = false,
VerificationNotes = "Search returned results but no ORCID ID found"
};
}
// Get full details
var result = await VerifyByOrcidAsync(orcidId);
// Update claimed name to the search name
return result with { ClaimedName = name };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching ORCID for: {Name}", name);
return new AcademicVerificationResult
{
ClaimedName = name,
IsVerified = false,
VerificationNotes = $"Error during search: {ex.Message}"
};
}
}
public async Task<List<InterfaceOrcidSearchResult>> SearchResearchersAsync(
string name,
string? affiliation = null)
{
try
{
var query = name;
if (!string.IsNullOrEmpty(affiliation))
{
query = $"{name} {affiliation}";
}
var searchResponse = await _orcidClient.SearchResearchersAsync(query, 0, 20);
if (searchResponse?.Result == null)
{
return [];
}
var results = new List<InterfaceOrcidSearchResult>();
foreach (var searchResult in searchResponse.Result.Take(10))
{
var orcidId = searchResult.OrcidIdentifier?.Path;
if (string.IsNullOrEmpty(orcidId))
continue;
var record = await _orcidClient.GetRecordAsync(orcidId);
if (record == null)
continue;
// Extract name
string? researcherName = null;
if (record.Person?.Name != null)
{
var nameParts = new List<string>();
if (!string.IsNullOrEmpty(record.Person.Name.GivenNames?.Value))
nameParts.Add(record.Person.Name.GivenNames.Value);
if (!string.IsNullOrEmpty(record.Person.Name.FamilyName?.Value))
nameParts.Add(record.Person.Name.FamilyName.Value);
researcherName = string.Join(" ", nameParts);
}
// Get affiliations
var affiliations = new List<string>();
if (record.ActivitiesSummary?.Employments?.AffiliationGroup != null)
{
affiliations = record.ActivitiesSummary.Employments.AffiliationGroup
.SelectMany(g => g.Summaries ?? [])
.Select(s => s.EmploymentSummary?.Organization?.Name)
.Where(n => !string.IsNullOrEmpty(n))
.Distinct()
.Take(5)
.ToList()!;
}
// Get publication count
var publicationCount = record.ActivitiesSummary?.Works?.Group?.Count ?? 0;
results.Add(new InterfaceOrcidSearchResult
{
OrcidId = orcidId,
Name = researcherName ?? "Unknown",
OrcidUrl = searchResult.OrcidIdentifier?.Uri,
Affiliations = affiliations,
PublicationCount = publicationCount
});
}
return results;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching ORCID for: {Name}", name);
return [];
}
}
public async Task<List<PublicationVerificationResult>> VerifyPublicationsAsync(
string orcidId,
List<string> claimedPublications)
{
var results = new List<PublicationVerificationResult>();
try
{
var works = await _orcidClient.GetWorksAsync(orcidId);
if (works?.Group == null)
{
return claimedPublications.Select(title => new PublicationVerificationResult
{
ClaimedTitle = title,
IsVerified = false,
Notes = "Could not retrieve ORCID publications"
}).ToList();
}
var orcidPublications = ExtractPublications(works.Group);
foreach (var claimedTitle in claimedPublications)
{
var match = FindBestPublicationMatch(claimedTitle, orcidPublications);
if (match != null)
{
results.Add(new PublicationVerificationResult
{
ClaimedTitle = claimedTitle,
IsVerified = true,
MatchedTitle = match.Title,
Doi = match.Doi,
Year = match.Year,
Notes = "Publication found in ORCID record"
});
}
else
{
results.Add(new PublicationVerificationResult
{
ClaimedTitle = claimedTitle,
IsVerified = false,
Notes = "Publication not found in ORCID record"
});
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error verifying publications for ORCID: {OrcidId}", orcidId);
return claimedPublications.Select(title => new PublicationVerificationResult
{
ClaimedTitle = title,
IsVerified = false,
Notes = $"Error during verification: {ex.Message}"
}).ToList();
}
return results;
}
private static List<AcademicAffiliation> ExtractAffiliations(
List<OrcidAffiliationGroup> groups,
string type)
{
var affiliations = new List<AcademicAffiliation>();
foreach (var group in groups)
{
if (group.Summaries == null)
continue;
foreach (var wrapper in group.Summaries)
{
var summary = type == "employment"
? wrapper.EmploymentSummary
: wrapper.EducationSummary;
if (summary?.Organization?.Name == null)
continue;
affiliations.Add(new AcademicAffiliation
{
Organization = summary.Organization.Name,
Department = summary.DepartmentName,
Role = summary.RoleTitle,
StartDate = ParseOrcidDate(summary.StartDate),
EndDate = ParseOrcidDate(summary.EndDate)
});
}
}
// Sort by start date descending (most recent first)
return affiliations
.OrderByDescending(a => a.StartDate)
.ToList();
}
private static List<AcademicEducation> ExtractEducations(List<OrcidAffiliationGroup> groups)
{
var educations = new List<AcademicEducation>();
foreach (var group in groups)
{
if (group.Summaries == null)
continue;
foreach (var wrapper in group.Summaries)
{
var summary = wrapper.EducationSummary;
if (summary?.Organization?.Name == null)
continue;
var startYear = summary.StartDate?.Year?.Value;
var endYear = summary.EndDate?.Year?.Value;
educations.Add(new AcademicEducation
{
Institution = summary.Organization.Name,
Degree = summary.RoleTitle,
Subject = summary.DepartmentName,
Year = !string.IsNullOrEmpty(endYear)
? int.TryParse(endYear, out var y) ? y : null
: !string.IsNullOrEmpty(startYear)
? int.TryParse(startYear, out var sy) ? sy : null
: null
});
}
}
return educations
.OrderByDescending(e => e.Year)
.ToList();
}
private static List<Publication> ExtractPublications(List<OrcidWorkGroup> groups)
{
var publications = new List<Publication>();
foreach (var group in groups)
{
if (group.WorkSummary == null || group.WorkSummary.Count == 0)
continue;
// Take the first work summary from each group (they're typically duplicates)
var work = group.WorkSummary.First();
var title = work.TitleObj?.Title?.Value ?? work.Title;
if (string.IsNullOrEmpty(title))
continue;
// Extract DOI if available
string? doi = null;
if (work.ExternalIds?.ExternalId != null)
{
var doiEntry = work.ExternalIds.ExternalId
.FirstOrDefault(e => e.ExternalIdType?.Equals("doi", StringComparison.OrdinalIgnoreCase) == true);
doi = doiEntry?.ExternalIdValue;
}
// Parse year
int? year = null;
if (!string.IsNullOrEmpty(work.PublicationDate?.Year?.Value) &&
int.TryParse(work.PublicationDate.Year.Value, out var y))
{
year = y;
}
publications.Add(new Publication
{
Title = title,
Journal = work.JournalTitle?.Value,
Year = year,
Doi = doi,
Type = work.Type
});
}
// Sort by publication year descending
return publications
.OrderByDescending(p => p.Year)
.ToList();
}
private static DateOnly? ParseOrcidDate(OrcidDate? date)
{
if (date?.Year?.Value == null)
return null;
if (!int.TryParse(date.Year.Value, out var year))
return null;
var month = 1;
if (!string.IsNullOrEmpty(date.Month?.Value) && int.TryParse(date.Month.Value, out var m))
month = m;
var day = 1;
if (!string.IsNullOrEmpty(date.Day?.Value) && int.TryParse(date.Day.Value, out var d))
day = d;
try
{
return new DateOnly(year, month, day);
}
catch
{
return new DateOnly(year, 1, 1);
}
}
private static Publication? FindBestPublicationMatch(string claimedTitle, List<Publication> publications)
{
var normalizedClaimed = NormalizeTitle(claimedTitle);
// First try exact match
var exactMatch = publications.FirstOrDefault(p =>
NormalizeTitle(p.Title).Equals(normalizedClaimed, StringComparison.OrdinalIgnoreCase));
if (exactMatch != null)
return exactMatch;
// Then try contains match
var containsMatch = publications.FirstOrDefault(p =>
NormalizeTitle(p.Title).Contains(normalizedClaimed, StringComparison.OrdinalIgnoreCase) ||
normalizedClaimed.Contains(NormalizeTitle(p.Title), StringComparison.OrdinalIgnoreCase));
return containsMatch;
}
private static string NormalizeTitle(string title)
{
return title
.ToLowerInvariant()
.Replace(":", " ")
.Replace("-", " ")
.Replace(" ", " ")
.Trim();
}
private static string BuildVerificationSummary(List<AcademicAffiliation> affiliations, int publicationCount, int educationCount)
{
var parts = new List<string>();
var currentAffiliation = affiliations.FirstOrDefault(a => !a.EndDate.HasValue);
if (currentAffiliation != null)
{
parts.Add($"Current: {currentAffiliation.Organization}");
}
if (publicationCount > 0)
{
parts.Add($"Publications: {publicationCount}");
}
if (affiliations.Count > 0)
{
parts.Add($"Positions: {affiliations.Count}");
}
if (educationCount > 0)
{
parts.Add($"Education: {educationCount}");
}
return parts.Count > 0 ? string.Join(" | ", parts) : "ORCID record verified";
}
}

View File

@@ -0,0 +1,275 @@
using Microsoft.Extensions.Logging;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
using RealCV.Infrastructure.Clients;
namespace RealCV.Infrastructure.Services;
public sealed class GitHubVerifierService : IGitHubVerifierService
{
private readonly GitHubApiClient _gitHubClient;
private readonly ILogger<GitHubVerifierService> _logger;
// Map common skill names to GitHub languages
private static readonly Dictionary<string, string[]> SkillToLanguageMap = new(StringComparer.OrdinalIgnoreCase)
{
["JavaScript"] = ["JavaScript", "TypeScript"],
["TypeScript"] = ["TypeScript"],
["Python"] = ["Python"],
["Java"] = ["Java", "Kotlin"],
["C#"] = ["C#"],
[".NET"] = ["C#", "F#"],
["React"] = ["JavaScript", "TypeScript"],
["Angular"] = ["TypeScript", "JavaScript"],
["Vue"] = ["JavaScript", "TypeScript", "Vue"],
["Node.js"] = ["JavaScript", "TypeScript"],
["Go"] = ["Go"],
["Golang"] = ["Go"],
["Rust"] = ["Rust"],
["Ruby"] = ["Ruby"],
["PHP"] = ["PHP"],
["Swift"] = ["Swift"],
["Kotlin"] = ["Kotlin"],
["C++"] = ["C++", "C"],
["C"] = ["C"],
["Scala"] = ["Scala"],
["R"] = ["R"],
["SQL"] = ["PLSQL", "TSQL"],
["Shell"] = ["Shell", "Bash", "PowerShell"],
["DevOps"] = ["Shell", "Dockerfile", "HCL"],
["Docker"] = ["Dockerfile"],
["Terraform"] = ["HCL"],
["Mobile"] = ["Swift", "Kotlin", "Dart", "Java"],
["iOS"] = ["Swift", "Objective-C"],
["Android"] = ["Kotlin", "Java"],
["Flutter"] = ["Dart"],
["Machine Learning"] = ["Python", "Jupyter Notebook", "R"],
["Data Science"] = ["Python", "Jupyter Notebook", "R"],
};
public GitHubVerifierService(
GitHubApiClient gitHubClient,
ILogger<GitHubVerifierService> logger)
{
_gitHubClient = gitHubClient;
_logger = logger;
}
public async Task<GitHubVerificationResult> VerifyProfileAsync(string username)
{
try
{
_logger.LogInformation("Verifying GitHub profile: {Username}", username);
var user = await _gitHubClient.GetUserAsync(username);
if (user == null)
{
return new GitHubVerificationResult
{
ClaimedUsername = username,
IsVerified = false,
VerificationNotes = "GitHub profile not found"
};
}
// Get repositories for language analysis
var repos = await _gitHubClient.GetUserReposAsync(username);
// Analyze languages
var languageStats = new Dictionary<string, int>();
foreach (var repo in repos.Where(r => !r.Fork && !string.IsNullOrEmpty(r.Language)))
{
if (!languageStats.ContainsKey(repo.Language!))
languageStats[repo.Language!] = 0;
languageStats[repo.Language!]++;
}
// Calculate flags
var flags = new List<GitHubVerificationFlag>();
// Check account age
var accountAge = DateTime.UtcNow - user.CreatedAt;
if (accountAge.TotalDays < 90)
{
flags.Add(new GitHubVerificationFlag
{
Type = "NewAccount",
Severity = "Info",
Message = "Account created less than 90 days ago",
ScoreImpact = -5
});
}
// Check for empty profile
if (user.PublicRepos == 0)
{
flags.Add(new GitHubVerificationFlag
{
Type = "NoRepos",
Severity = "Warning",
Message = "No public repositories",
ScoreImpact = -10
});
}
return new GitHubVerificationResult
{
ClaimedUsername = username,
IsVerified = true,
ProfileName = user.Name,
ProfileUrl = user.HtmlUrl,
Bio = user.Bio,
Company = user.Company,
Location = user.Location,
AccountCreated = user.CreatedAt != default
? DateOnly.FromDateTime(user.CreatedAt)
: null,
PublicRepos = user.PublicRepos,
Followers = user.Followers,
Following = user.Following,
LanguageStats = languageStats,
VerificationNotes = BuildVerificationSummary(user, repos),
Flags = flags
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error verifying GitHub profile: {Username}", username);
return new GitHubVerificationResult
{
ClaimedUsername = username,
IsVerified = false,
VerificationNotes = $"Error during verification: {ex.Message}"
};
}
}
public async Task<GitHubVerificationResult> VerifySkillsAsync(
string username,
List<string> claimedSkills)
{
var result = await VerifyProfileAsync(username);
if (!result.IsVerified)
return result;
var skillVerifications = new List<SkillVerification>();
foreach (var skill in claimedSkills)
{
var verified = false;
var repoCount = 0;
// Check if the skill matches a known language directly
if (result.LanguageStats.TryGetValue(skill, out var count))
{
verified = true;
repoCount = count;
}
else if (SkillToLanguageMap.TryGetValue(skill, out var mappedLanguages))
{
// Check if any mapped language exists in the user's repos
foreach (var lang in mappedLanguages)
{
if (result.LanguageStats.TryGetValue(lang, out var langCount))
{
verified = true;
repoCount += langCount;
}
}
}
skillVerifications.Add(new SkillVerification
{
ClaimedSkill = skill,
IsVerified = verified,
RepoCount = repoCount,
Notes = verified
? $"Found in {repoCount} repositories"
: "No repositories found using this skill"
});
}
var verifiedCount = skillVerifications.Count(sv => sv.IsVerified);
var totalCount = skillVerifications.Count;
var percentage = totalCount > 0 ? (verifiedCount * 100) / totalCount : 0;
return result with
{
SkillVerifications = skillVerifications,
VerificationNotes = $"Skills verified: {verifiedCount}/{totalCount} ({percentage}%)"
};
}
public async Task<List<GitHubProfileSearchResult>> SearchProfilesAsync(string name)
{
try
{
var searchResponse = await _gitHubClient.SearchUsersAsync(name);
if (searchResponse?.Items == null)
{
return [];
}
var results = new List<GitHubProfileSearchResult>();
foreach (var item in searchResponse.Items.Take(10))
{
if (string.IsNullOrEmpty(item.Login))
continue;
// Get full profile details
var user = await _gitHubClient.GetUserAsync(item.Login);
results.Add(new GitHubProfileSearchResult
{
Username = item.Login,
Name = user?.Name,
AvatarUrl = item.AvatarUrl,
Bio = user?.Bio,
PublicRepos = user?.PublicRepos ?? 0,
Followers = user?.Followers ?? 0
});
}
return results;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching GitHub profiles: {Name}", name);
return [];
}
}
private static string BuildVerificationSummary(GitHubUser user, List<GitHubRepo> repos)
{
var parts = new List<string>
{
$"Account created: {user.CreatedAt:yyyy-MM-dd}",
$"Public repos: {user.PublicRepos}",
$"Followers: {user.Followers}"
};
var totalStars = repos.Sum(r => r.StargazersCount);
if (totalStars > 0)
{
parts.Add($"Total stars: {totalStars}");
}
var topLanguages = repos
.Where(r => !r.Fork && !string.IsNullOrEmpty(r.Language))
.GroupBy(r => r.Language)
.OrderByDescending(g => g.Count())
.Take(3)
.Select(g => g.Key);
if (topLanguages.Any())
{
parts.Add($"Top languages: {string.Join(", ", topLanguages)}");
}
return string.Join(" | ", parts);
}
}

View File

@@ -0,0 +1,376 @@
using Microsoft.Extensions.Logging;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
using RealCV.Infrastructure.Clients;
namespace RealCV.Infrastructure.Services;
public sealed class InternationalCompanyVerifierService : IInternationalCompanyVerifierService
{
private readonly OpenCorporatesClient _openCorporatesClient;
private readonly ILogger<InternationalCompanyVerifierService> _logger;
// Common jurisdiction codes
private static readonly Dictionary<string, string> CountryToJurisdiction = new(StringComparer.OrdinalIgnoreCase)
{
["United Kingdom"] = "gb",
["UK"] = "gb",
["England"] = "gb",
["Scotland"] = "gb",
["Wales"] = "gb",
["United States"] = "us",
["USA"] = "us",
["US"] = "us",
["Germany"] = "de",
["France"] = "fr",
["Netherlands"] = "nl",
["Ireland"] = "ie",
["Spain"] = "es",
["Italy"] = "it",
["Canada"] = "ca",
["Australia"] = "au",
["New Zealand"] = "nz",
["Singapore"] = "sg",
["Hong Kong"] = "hk",
["Japan"] = "jp",
["India"] = "in",
["China"] = "cn",
["Brazil"] = "br",
["Mexico"] = "mx",
["Switzerland"] = "ch",
["Sweden"] = "se",
["Norway"] = "no",
["Denmark"] = "dk",
["Finland"] = "fi",
["Belgium"] = "be",
["Austria"] = "at",
["Poland"] = "pl",
["Portugal"] = "pt",
["UAE"] = "ae",
["South Africa"] = "za",
};
public InternationalCompanyVerifierService(
OpenCorporatesClient openCorporatesClient,
ILogger<InternationalCompanyVerifierService> logger)
{
_openCorporatesClient = openCorporatesClient;
_logger = logger;
}
public async Task<InternationalCompanyResult> VerifyCompanyAsync(
string companyName,
string? jurisdiction = null,
DateOnly? claimedStartDate = null,
DateOnly? claimedEndDate = null)
{
try
{
_logger.LogInformation("Searching OpenCorporates for: {Company} in {Jurisdiction}",
companyName, jurisdiction ?? "all jurisdictions");
string? jurisdictionCode = null;
if (!string.IsNullOrEmpty(jurisdiction) && CountryToJurisdiction.TryGetValue(jurisdiction, out var code))
{
jurisdictionCode = code;
}
else if (!string.IsNullOrEmpty(jurisdiction) && jurisdiction.Length == 2)
{
jurisdictionCode = jurisdiction.ToLowerInvariant();
}
var searchResponse = await _openCorporatesClient.SearchCompaniesAsync(
companyName,
jurisdictionCode);
if (searchResponse?.Companies == null || searchResponse.Companies.Count == 0)
{
return new InternationalCompanyResult
{
ClaimedCompany = companyName,
ClaimedJurisdiction = jurisdiction ?? "Unknown",
IsVerified = false,
VerificationNotes = "No matching companies found in OpenCorporates"
};
}
// Find the best match
var bestMatch = FindBestMatch(companyName, searchResponse.Companies);
if (bestMatch == null)
{
return new InternationalCompanyResult
{
ClaimedCompany = companyName,
ClaimedJurisdiction = jurisdiction ?? "Unknown",
IsVerified = false,
VerificationNotes = $"Found {searchResponse.Companies.Count} results but no close name matches"
};
}
// Parse dates
var incorporationDate = ParseDate(bestMatch.IncorporationDate);
var dissolutionDate = ParseDate(bestMatch.DissolutionDate);
// Calculate match score
var matchScore = CalculateMatchScore(companyName, bestMatch.Name ?? "");
// Check for timeline issues
var flags = new List<CompanyVerificationFlag>();
if (claimedStartDate.HasValue && incorporationDate.HasValue &&
claimedStartDate.Value < incorporationDate.Value)
{
flags.Add(new CompanyVerificationFlag
{
Type = "EmploymentBeforeIncorporation",
Severity = "Critical",
Message = $"Claimed start date ({claimedStartDate:yyyy-MM-dd}) is before company incorporation ({incorporationDate:yyyy-MM-dd})",
ScoreImpact = -30
});
}
if (claimedEndDate.HasValue && dissolutionDate.HasValue &&
claimedEndDate.Value > dissolutionDate.Value)
{
flags.Add(new CompanyVerificationFlag
{
Type = "EmploymentAfterDissolution",
Severity = "Warning",
Message = $"Claimed end date ({claimedEndDate:yyyy-MM-dd}) is after company dissolution ({dissolutionDate:yyyy-MM-dd})",
ScoreImpact = -20
});
}
return new InternationalCompanyResult
{
ClaimedCompany = companyName,
ClaimedJurisdiction = jurisdiction ?? "Unknown",
IsVerified = true,
MatchedCompanyName = bestMatch.Name,
CompanyNumber = bestMatch.CompanyNumber,
Jurisdiction = GetJurisdictionName(bestMatch.JurisdictionCode),
JurisdictionCode = bestMatch.JurisdictionCode,
CompanyType = bestMatch.CompanyType,
Status = bestMatch.CurrentStatus,
IncorporationDate = incorporationDate,
DissolutionDate = dissolutionDate,
RegisteredAddress = bestMatch.RegisteredAddressInFull,
OpenCorporatesUrl = bestMatch.OpencorporatesUrl,
DataLastUpdated = bestMatch.UpdatedAt,
MatchScore = matchScore,
VerificationNotes = BuildVerificationSummary(bestMatch, searchResponse.TotalCount),
Flags = flags
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching OpenCorporates for: {Company}", companyName);
return new InternationalCompanyResult
{
ClaimedCompany = companyName,
ClaimedJurisdiction = jurisdiction ?? "Unknown",
IsVerified = false,
VerificationNotes = $"Error during search: {ex.Message}"
};
}
}
public async Task<List<OpenCorporatesSearchResult>> SearchCompaniesAsync(
string query,
string? jurisdiction = null)
{
try
{
string? jurisdictionCode = null;
if (!string.IsNullOrEmpty(jurisdiction) && CountryToJurisdiction.TryGetValue(jurisdiction, out var code))
{
jurisdictionCode = code;
}
var searchResponse = await _openCorporatesClient.SearchCompaniesAsync(
query,
jurisdictionCode);
if (searchResponse?.Companies == null)
{
return [];
}
return searchResponse.Companies
.Where(c => c.Company != null)
.Select(c => new OpenCorporatesSearchResult
{
CompanyName = c.Company!.Name ?? "Unknown",
CompanyNumber = c.Company.CompanyNumber ?? "Unknown",
Jurisdiction = GetJurisdictionName(c.Company.JurisdictionCode),
JurisdictionCode = c.Company.JurisdictionCode,
Status = c.Company.CurrentStatus,
IncorporationDate = ParseDate(c.Company.IncorporationDate),
OpenCorporatesUrl = c.Company.OpencorporatesUrl,
MatchScore = CalculateMatchScore(query, c.Company.Name ?? "")
})
.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching OpenCorporates for: {Query}", query);
return [];
}
}
public async Task<List<JurisdictionInfo>> GetJurisdictionsAsync()
{
try
{
var jurisdictions = await _openCorporatesClient.GetJurisdictionsAsync();
if (jurisdictions == null)
{
return [];
}
return jurisdictions
.Select(j => new JurisdictionInfo
{
Code = j.Code ?? "Unknown",
Name = j.FullName ?? j.Name ?? "Unknown",
Country = j.Country
})
.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting OpenCorporates jurisdictions");
return [];
}
}
private static OpenCorporatesCompany? FindBestMatch(
string searchName,
List<OpenCorporatesCompanyWrapper> companies)
{
var normalizedSearch = NormalizeName(searchName);
// First try exact match
var exactMatch = companies
.Select(c => c.Company)
.Where(c => c != null)
.FirstOrDefault(c => NormalizeName(c!.Name ?? "").Equals(normalizedSearch, StringComparison.OrdinalIgnoreCase));
if (exactMatch != null)
return exactMatch;
// Then try contains match, preferring active companies
var containsMatches = companies
.Select(c => c.Company)
.Where(c => c != null && !string.IsNullOrEmpty(c.Name))
.Where(c => NormalizeName(c!.Name!).Contains(normalizedSearch, StringComparison.OrdinalIgnoreCase) ||
normalizedSearch.Contains(NormalizeName(c!.Name!), StringComparison.OrdinalIgnoreCase))
.OrderBy(c => c!.CurrentStatus?.ToLowerInvariant() == "active" ? 0 : 1)
.ThenBy(c => c!.Name!.Length)
.ToList();
return containsMatches.FirstOrDefault();
}
private static string NormalizeName(string name)
{
return name
.Replace("LIMITED", "", StringComparison.OrdinalIgnoreCase)
.Replace("LTD", "", StringComparison.OrdinalIgnoreCase)
.Replace("PLC", "", StringComparison.OrdinalIgnoreCase)
.Replace("INC", "", StringComparison.OrdinalIgnoreCase)
.Replace("CORP", "", StringComparison.OrdinalIgnoreCase)
.Replace("LLC", "", StringComparison.OrdinalIgnoreCase)
.Replace("GMBH", "", StringComparison.OrdinalIgnoreCase)
.Replace("AG", "", StringComparison.OrdinalIgnoreCase)
.Replace(".", "")
.Replace(",", "")
.Trim();
}
private static string GetJurisdictionName(string? code)
{
if (string.IsNullOrEmpty(code))
return "Unknown";
return code.ToUpperInvariant();
}
private static DateOnly? ParseDate(string? dateString)
{
if (string.IsNullOrEmpty(dateString))
return null;
if (DateOnly.TryParse(dateString, out var date))
return date;
return null;
}
private static int CalculateMatchScore(string searchName, string foundName)
{
var normalizedSearch = NormalizeName(searchName).ToLowerInvariant();
var normalizedFound = NormalizeName(foundName).ToLowerInvariant();
if (normalizedSearch == normalizedFound)
return 100;
if (normalizedFound.Contains(normalizedSearch) || normalizedSearch.Contains(normalizedFound))
return 80;
// Calculate Levenshtein similarity
var distance = LevenshteinDistance(normalizedSearch, normalizedFound);
var maxLength = Math.Max(normalizedSearch.Length, normalizedFound.Length);
var similarity = (int)((1 - (double)distance / maxLength) * 100);
return Math.Max(0, similarity);
}
private static int LevenshteinDistance(string s1, string s2)
{
var n = s1.Length;
var m = s2.Length;
var d = new int[n + 1, m + 1];
for (var i = 0; i <= n; i++)
d[i, 0] = i;
for (var j = 0; j <= m; j++)
d[0, j] = j;
for (var i = 1; i <= n; i++)
{
for (var j = 1; j <= m; j++)
{
var cost = s1[i - 1] == s2[j - 1] ? 0 : 1;
d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost);
}
}
return d[n, m];
}
private static string BuildVerificationSummary(OpenCorporatesCompany company, int totalResults)
{
var parts = new List<string>();
if (!string.IsNullOrEmpty(company.CurrentStatus))
parts.Add($"Status: {company.CurrentStatus}");
if (!string.IsNullOrEmpty(company.JurisdictionCode))
parts.Add($"Jurisdiction: {company.JurisdictionCode.ToUpperInvariant()}");
if (!string.IsNullOrEmpty(company.IncorporationDate))
parts.Add($"Incorporated: {company.IncorporationDate}");
if (!string.IsNullOrEmpty(company.CompanyType))
parts.Add($"Type: {company.CompanyType}");
if (totalResults > 1)
parts.Add($"({totalResults} total matches found)");
return string.Join(" | ", parts);
}
}

View File

@@ -0,0 +1,395 @@
using Microsoft.Extensions.Logging;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
using RealCV.Infrastructure.Clients;
namespace RealCV.Infrastructure.Services;
public sealed class ProfessionalVerifierService : IProfessionalVerifierService
{
private readonly FcaRegisterClient _fcaClient;
private readonly SraRegisterClient _sraClient;
private readonly ILogger<ProfessionalVerifierService> _logger;
public ProfessionalVerifierService(
FcaRegisterClient fcaClient,
SraRegisterClient sraClient,
ILogger<ProfessionalVerifierService> logger)
{
_fcaClient = fcaClient;
_sraClient = sraClient;
_logger = logger;
}
public async Task<ProfessionalVerificationResult> VerifyFcaRegistrationAsync(
string name,
string? firmName = null,
string? referenceNumber = null)
{
try
{
_logger.LogInformation("Verifying FCA registration for: {Name}", name);
// If we have a reference number, try to get directly
if (!string.IsNullOrEmpty(referenceNumber))
{
var details = await _fcaClient.GetIndividualAsync(referenceNumber);
if (details != null)
{
var isNameMatch = IsNameMatch(name, details.Name);
return new ProfessionalVerificationResult
{
ClaimedName = name,
ProfessionalBody = "FCA",
IsVerified = isNameMatch,
RegistrationNumber = details.IndividualReferenceNumber,
MatchedName = details.Name,
Status = details.Status,
RegistrationDate = ParseDate(details.EffectiveDate),
ControlledFunctions = details.ControlledFunctions?
.Where(cf => cf.Status?.Equals("Active", StringComparison.OrdinalIgnoreCase) == true)
.Select(cf => cf.ControlledFunction ?? "Unknown")
.ToList(),
VerificationNotes = isNameMatch
? $"FCA Individual Reference Number: {details.IndividualReferenceNumber}"
: "Reference number found but name does not match"
};
}
}
// Search by name
var searchResponse = await _fcaClient.SearchIndividualsAsync(name);
if (searchResponse?.Data == null || searchResponse.Data.Count == 0)
{
return new ProfessionalVerificationResult
{
ClaimedName = name,
ProfessionalBody = "FCA",
IsVerified = false,
VerificationNotes = "No matching FCA registered individuals found"
};
}
// Find best match
var matches = searchResponse.Data
.Where(i => IsNameMatch(name, i.Name))
.ToList();
if (matches.Count == 0)
{
return new ProfessionalVerificationResult
{
ClaimedName = name,
ProfessionalBody = "FCA",
IsVerified = false,
VerificationNotes = $"Found {searchResponse.Data.Count} results but no close name matches"
};
}
// If firm specified, try to match on that too
FcaIndividualSearchItem? bestMatch = null;
if (!string.IsNullOrEmpty(firmName))
{
bestMatch = matches.FirstOrDefault(m =>
m.CurrentEmployers?.Contains(firmName, StringComparison.OrdinalIgnoreCase) == true);
}
bestMatch ??= matches.First();
// Get detailed information
if (!string.IsNullOrEmpty(bestMatch.IndividualReferenceNumber))
{
var details = await _fcaClient.GetIndividualAsync(bestMatch.IndividualReferenceNumber);
if (details != null)
{
return new ProfessionalVerificationResult
{
ClaimedName = name,
ProfessionalBody = "FCA",
IsVerified = true,
RegistrationNumber = details.IndividualReferenceNumber,
MatchedName = details.Name,
Status = details.Status,
RegistrationDate = ParseDate(details.EffectiveDate),
CurrentEmployer = bestMatch.CurrentEmployers,
ControlledFunctions = details.ControlledFunctions?
.Where(cf => cf.Status?.Equals("Active", StringComparison.OrdinalIgnoreCase) == true)
.Select(cf => cf.ControlledFunction ?? "Unknown")
.ToList(),
VerificationNotes = $"FCA Individual Reference Number: {details.IndividualReferenceNumber}"
};
}
}
// Basic verification without details
return new ProfessionalVerificationResult
{
ClaimedName = name,
ProfessionalBody = "FCA",
IsVerified = true,
RegistrationNumber = bestMatch.IndividualReferenceNumber,
MatchedName = bestMatch.Name,
Status = bestMatch.Status,
CurrentEmployer = bestMatch.CurrentEmployers,
VerificationNotes = "Verified via FCA Register search"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error verifying FCA registration for: {Name}", name);
return new ProfessionalVerificationResult
{
ClaimedName = name,
ProfessionalBody = "FCA",
IsVerified = false,
VerificationNotes = $"Error during verification: {ex.Message}"
};
}
}
public async Task<ProfessionalVerificationResult> VerifySolicitorAsync(
string name,
string? sraNumber = null,
string? firmName = null)
{
try
{
_logger.LogInformation("Verifying SRA registration for: {Name}", name);
// If we have an SRA number, try to get directly
if (!string.IsNullOrEmpty(sraNumber))
{
var details = await _sraClient.GetSolicitorAsync(sraNumber);
if (details != null)
{
var isNameMatch = IsNameMatch(name, details.FullName);
return new ProfessionalVerificationResult
{
ClaimedName = name,
ProfessionalBody = "SRA",
IsVerified = isNameMatch,
RegistrationNumber = details.SraId,
MatchedName = details.FullName,
Status = details.Status,
AdmissionDate = details.AdmissionDate,
SolicitorType = details.SolicitorType,
PractisingCertificateStatus = details.PractisingCertificateStatus,
CurrentEmployer = details.CurrentPosition?.OrganisationName,
VerificationNotes = isNameMatch
? $"SRA ID: {details.SraId}"
: "SRA number found but name does not match",
Flags = details.DisciplinaryHistory?.Count > 0
? [new ProfessionalVerificationFlag
{
Type = "DisciplinaryRecord",
Severity = "Warning",
Message = $"Has {details.DisciplinaryHistory.Count} disciplinary record(s)",
ScoreImpact = -20
}]
: []
};
}
}
// Search by name
var searchResponse = await _sraClient.SearchSolicitorsAsync(name);
if (searchResponse?.Results == null || searchResponse.Results.Count == 0)
{
return new ProfessionalVerificationResult
{
ClaimedName = name,
ProfessionalBody = "SRA",
IsVerified = false,
VerificationNotes = "No matching SRA registered solicitors found"
};
}
// Find best match
var matches = searchResponse.Results
.Where(s => IsNameMatch(name, s.Name))
.ToList();
if (matches.Count == 0)
{
return new ProfessionalVerificationResult
{
ClaimedName = name,
ProfessionalBody = "SRA",
IsVerified = false,
VerificationNotes = $"Found {searchResponse.Results.Count} results but no close name matches"
};
}
// If firm specified, try to match on that too
SraSolicitorSearchItem? bestMatch = null;
if (!string.IsNullOrEmpty(firmName))
{
bestMatch = matches.FirstOrDefault(m =>
m.CurrentOrganisation?.Contains(firmName, StringComparison.OrdinalIgnoreCase) == true);
}
bestMatch ??= matches.First();
// Get detailed information
if (!string.IsNullOrEmpty(bestMatch.SraId))
{
var details = await _sraClient.GetSolicitorAsync(bestMatch.SraId);
if (details != null)
{
return new ProfessionalVerificationResult
{
ClaimedName = name,
ProfessionalBody = "SRA",
IsVerified = true,
RegistrationNumber = details.SraId,
MatchedName = details.FullName,
Status = details.Status,
AdmissionDate = details.AdmissionDate,
SolicitorType = details.SolicitorType,
PractisingCertificateStatus = details.PractisingCertificateStatus,
CurrentEmployer = details.CurrentPosition?.OrganisationName,
VerificationNotes = $"SRA ID: {details.SraId}",
Flags = details.DisciplinaryHistory?.Count > 0
? [new ProfessionalVerificationFlag
{
Type = "DisciplinaryRecord",
Severity = "Warning",
Message = $"Has {details.DisciplinaryHistory.Count} disciplinary record(s)",
ScoreImpact = -20
}]
: []
};
}
}
// Basic verification without details
return new ProfessionalVerificationResult
{
ClaimedName = name,
ProfessionalBody = "SRA",
IsVerified = true,
RegistrationNumber = bestMatch.SraId,
MatchedName = bestMatch.Name,
Status = bestMatch.Status,
AdmissionDate = bestMatch.AdmissionDate,
CurrentEmployer = bestMatch.CurrentOrganisation,
VerificationNotes = "Verified via SRA Register search"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error verifying SRA registration for: {Name}", name);
return new ProfessionalVerificationResult
{
ClaimedName = name,
ProfessionalBody = "SRA",
IsVerified = false,
VerificationNotes = $"Error during verification: {ex.Message}"
};
}
}
public async Task<List<FcaIndividualSearchResult>> SearchFcaIndividualsAsync(string name)
{
try
{
var searchResponse = await _fcaClient.SearchIndividualsAsync(name);
if (searchResponse?.Data == null)
{
return [];
}
return searchResponse.Data
.Select(i => new FcaIndividualSearchResult
{
Name = i.Name ?? "Unknown",
IndividualReferenceNumber = i.IndividualReferenceNumber ?? "Unknown",
Status = i.Status,
CurrentFirms = i.CurrentEmployers?.Split(',')
.Select(f => f.Trim())
.Where(f => !string.IsNullOrEmpty(f))
.ToList()
})
.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching FCA for: {Name}", name);
return [];
}
}
public async Task<List<SraSolicitorSearchResult>> SearchSolicitorsAsync(string name)
{
try
{
var searchResponse = await _sraClient.SearchSolicitorsAsync(name);
if (searchResponse?.Results == null)
{
return [];
}
return searchResponse.Results
.Select(s => new SraSolicitorSearchResult
{
Name = s.Name ?? "Unknown",
SraNumber = s.SraId ?? "Unknown",
Status = s.Status,
CurrentOrganisation = s.CurrentOrganisation,
AdmissionDate = s.AdmissionDate
})
.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching SRA for: {Name}", name);
return [];
}
}
private static bool IsNameMatch(string searchName, string? foundName)
{
if (string.IsNullOrEmpty(foundName))
return false;
var searchNormalized = NormalizeName(searchName);
var foundNormalized = NormalizeName(foundName);
// Exact match
if (searchNormalized.Equals(foundNormalized, StringComparison.OrdinalIgnoreCase))
return true;
// Check if all parts of search name are in found name
var searchParts = searchNormalized.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var foundParts = foundNormalized.Split(' ', StringSplitOptions.RemoveEmptyEntries);
// All search parts must be found
return searchParts.All(sp =>
foundParts.Any(fp => fp.Equals(sp, StringComparison.OrdinalIgnoreCase)));
}
private static string NormalizeName(string name)
{
return name
.Replace(",", " ")
.Replace(".", " ")
.Replace("-", " ")
.Trim();
}
private static DateOnly? ParseDate(string? dateString)
{
if (string.IsNullOrEmpty(dateString))
return null;
if (DateOnly.TryParse(dateString, out var date))
return date;
return null;
}
}