From 5d2965beae19ff37a08e77f3091edde57d850991 Mon Sep 17 00:00:00 2001 From: Peter Foster Date: Sat, 24 Jan 2026 13:05:52 +0000 Subject: [PATCH] feat: Add additional verification APIs (FCA, SRA, GitHub, OpenCorporates, ORCID) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Interfaces/IAcademicVerifierService.cs | 54 ++ .../Interfaces/IGitHubVerifierService.cs | 36 ++ .../IInternationalCompanyVerifierService.cs | 49 ++ .../IProfessionalVerifierService.cs | 52 ++ .../Models/AcademicVerificationResult.cs | 62 +++ .../Models/GitHubVerificationResult.cs | 52 ++ .../Models/InternationalCompanyResult.cs | 30 ++ .../Models/ProfessionalVerificationResult.cs | 38 ++ .../Clients/FcaRegisterClient.cs | 216 ++++++++ .../Clients/GitHubClient.cs | 265 +++++++++ .../Clients/OpenCorporatesClient.cs | 270 ++++++++++ .../Clients/OrcidClient.cs | 342 ++++++++++++ .../Clients/SraRegisterClient.cs | 181 +++++++ .../DependencyInjection.cs | 37 ++ .../Services/AcademicVerifierService.cs | 509 ++++++++++++++++++ .../Services/GitHubVerifierService.cs | 275 ++++++++++ .../InternationalCompanyVerifierService.cs | 376 +++++++++++++ .../Services/ProfessionalVerifierService.cs | 395 ++++++++++++++ src/RealCV.Web/appsettings.json | 10 + 19 files changed, 3249 insertions(+) create mode 100644 src/RealCV.Application/Interfaces/IAcademicVerifierService.cs create mode 100644 src/RealCV.Application/Interfaces/IGitHubVerifierService.cs create mode 100644 src/RealCV.Application/Interfaces/IInternationalCompanyVerifierService.cs create mode 100644 src/RealCV.Application/Interfaces/IProfessionalVerifierService.cs create mode 100644 src/RealCV.Application/Models/AcademicVerificationResult.cs create mode 100644 src/RealCV.Application/Models/GitHubVerificationResult.cs create mode 100644 src/RealCV.Application/Models/InternationalCompanyResult.cs create mode 100644 src/RealCV.Application/Models/ProfessionalVerificationResult.cs create mode 100644 src/RealCV.Infrastructure/Clients/FcaRegisterClient.cs create mode 100644 src/RealCV.Infrastructure/Clients/GitHubClient.cs create mode 100644 src/RealCV.Infrastructure/Clients/OpenCorporatesClient.cs create mode 100644 src/RealCV.Infrastructure/Clients/OrcidClient.cs create mode 100644 src/RealCV.Infrastructure/Clients/SraRegisterClient.cs create mode 100644 src/RealCV.Infrastructure/Services/AcademicVerifierService.cs create mode 100644 src/RealCV.Infrastructure/Services/GitHubVerifierService.cs create mode 100644 src/RealCV.Infrastructure/Services/InternationalCompanyVerifierService.cs create mode 100644 src/RealCV.Infrastructure/Services/ProfessionalVerifierService.cs diff --git a/src/RealCV.Application/Interfaces/IAcademicVerifierService.cs b/src/RealCV.Application/Interfaces/IAcademicVerifierService.cs new file mode 100644 index 0000000..cc30b53 --- /dev/null +++ b/src/RealCV.Application/Interfaces/IAcademicVerifierService.cs @@ -0,0 +1,54 @@ +using RealCV.Application.Models; + +namespace RealCV.Application.Interfaces; + +/// +/// Service for verifying academic researchers via ORCID +/// +public interface IAcademicVerifierService +{ + /// + /// Verify an academic researcher by ORCID ID + /// + Task VerifyByOrcidAsync(string orcidId); + + /// + /// Search for researchers and verify by name + /// + Task VerifyByNameAsync( + string name, + string? affiliation = null); + + /// + /// Search ORCID for researchers + /// + Task> SearchResearchersAsync( + string name, + string? affiliation = null); + + /// + /// Verify claimed publications + /// + Task> VerifyPublicationsAsync( + string orcidId, + List claimedPublications); +} + +public sealed record OrcidSearchResult +{ + public required string OrcidId { get; init; } + public required string Name { get; init; } + public string? OrcidUrl { get; init; } + public List? Affiliations { get; init; } + public int? PublicationCount { get; init; } +} + +public sealed record PublicationVerificationResult +{ + public required string ClaimedTitle { get; init; } + public required bool IsVerified { get; init; } + public string? MatchedTitle { get; init; } + public string? Doi { get; init; } + public int? Year { get; init; } + public string? Notes { get; init; } +} diff --git a/src/RealCV.Application/Interfaces/IGitHubVerifierService.cs b/src/RealCV.Application/Interfaces/IGitHubVerifierService.cs new file mode 100644 index 0000000..75e268e --- /dev/null +++ b/src/RealCV.Application/Interfaces/IGitHubVerifierService.cs @@ -0,0 +1,36 @@ +using RealCV.Application.Models; + +namespace RealCV.Application.Interfaces; + +/// +/// Service for verifying developer profiles and skills via GitHub +/// +public interface IGitHubVerifierService +{ + /// + /// Verify a GitHub profile and analyze activity + /// + Task VerifyProfileAsync(string username); + + /// + /// Verify claimed programming skills against GitHub activity + /// + Task VerifySkillsAsync( + string username, + List claimedSkills); + + /// + /// Search for GitHub profiles matching a name + /// + Task> SearchProfilesAsync(string name); +} + +public sealed record GitHubProfileSearchResult +{ + public required string Username { get; init; } + public string? Name { get; init; } + public string? AvatarUrl { get; init; } + public string? Bio { get; init; } + public int PublicRepos { get; init; } + public int Followers { get; init; } +} diff --git a/src/RealCV.Application/Interfaces/IInternationalCompanyVerifierService.cs b/src/RealCV.Application/Interfaces/IInternationalCompanyVerifierService.cs new file mode 100644 index 0000000..b2e14f4 --- /dev/null +++ b/src/RealCV.Application/Interfaces/IInternationalCompanyVerifierService.cs @@ -0,0 +1,49 @@ +using RealCV.Application.Models; + +namespace RealCV.Application.Interfaces; + +/// +/// Service for verifying international companies via OpenCorporates +/// +public interface IInternationalCompanyVerifierService +{ + /// + /// Verify an international company + /// + Task VerifyCompanyAsync( + string companyName, + string? jurisdiction = null, + DateOnly? claimedStartDate = null, + DateOnly? claimedEndDate = null); + + /// + /// Search for companies across all jurisdictions + /// + Task> SearchCompaniesAsync( + string query, + string? jurisdiction = null); + + /// + /// Get list of supported jurisdictions + /// + Task> GetJurisdictionsAsync(); +} + +public sealed record OpenCorporatesSearchResult +{ + public required string CompanyName { get; init; } + public required string CompanyNumber { get; init; } + public required string Jurisdiction { get; init; } + public string? JurisdictionCode { get; init; } + public string? Status { get; init; } + public DateOnly? IncorporationDate { get; init; } + public string? OpenCorporatesUrl { get; init; } + public double? MatchScore { get; init; } +} + +public sealed record JurisdictionInfo +{ + public required string Code { get; init; } + public required string Name { get; init; } + public string? Country { get; init; } +} diff --git a/src/RealCV.Application/Interfaces/IProfessionalVerifierService.cs b/src/RealCV.Application/Interfaces/IProfessionalVerifierService.cs new file mode 100644 index 0000000..7cb2c04 --- /dev/null +++ b/src/RealCV.Application/Interfaces/IProfessionalVerifierService.cs @@ -0,0 +1,52 @@ +using RealCV.Application.Models; + +namespace RealCV.Application.Interfaces; + +/// +/// Service for verifying professional qualifications (FCA, SRA, etc.) +/// +public interface IProfessionalVerifierService +{ + /// + /// Verify if a person is registered with the FCA + /// + Task VerifyFcaRegistrationAsync( + string name, + string? firmName = null, + string? referenceNumber = null); + + /// + /// Verify if a person is a registered solicitor with the SRA + /// + Task VerifySolicitorAsync( + string name, + string? sraNumber = null, + string? firmName = null); + + /// + /// Search FCA register for individuals + /// + Task> SearchFcaIndividualsAsync(string name); + + /// + /// Search SRA register for solicitors + /// + Task> SearchSolicitorsAsync(string name); +} + +public sealed record FcaIndividualSearchResult +{ + public required string Name { get; init; } + public required string IndividualReferenceNumber { get; init; } + public string? Status { get; init; } + public List? CurrentFirms { get; init; } +} + +public sealed record SraSolicitorSearchResult +{ + public required string Name { get; init; } + public required string SraNumber { get; init; } + public string? Status { get; init; } + public string? CurrentOrganisation { get; init; } + public string? AdmissionDate { get; init; } +} diff --git a/src/RealCV.Application/Models/AcademicVerificationResult.cs b/src/RealCV.Application/Models/AcademicVerificationResult.cs new file mode 100644 index 0000000..b2df17d --- /dev/null +++ b/src/RealCV.Application/Models/AcademicVerificationResult.cs @@ -0,0 +1,62 @@ +namespace RealCV.Application.Models; + +/// +/// Result of verifying an academic researcher via ORCID +/// +public sealed record AcademicVerificationResult +{ + public required string ClaimedName { get; init; } + public required bool IsVerified { get; init; } + + // ORCID profile + public string? OrcidId { get; init; } + public string? MatchedName { get; init; } + public string? OrcidUrl { get; init; } + + // Academic affiliations + public List Affiliations { get; init; } = []; + + // Publications + public int TotalPublications { get; init; } + public List RecentPublications { get; init; } = []; + + // Education from ORCID + public List Education { get; init; } = []; + + public string? VerificationNotes { get; init; } + public List Flags { get; init; } = []; +} + +public sealed record AcademicAffiliation +{ + public required string Organization { get; init; } + public string? Department { get; init; } + public string? Role { get; init; } + public DateOnly? StartDate { get; init; } + public DateOnly? EndDate { get; init; } +} + +public sealed record Publication +{ + public required string Title { get; init; } + public string? Journal { get; init; } + public int? Year { get; init; } + public string? Doi { get; init; } + public string? Type { get; init; } +} + +public sealed record AcademicEducation +{ + public required string Institution { get; init; } + public string? Degree { get; init; } + public string? Subject { get; init; } + public int? Year { get; init; } +} + +public sealed record AcademicVerificationFlag +{ + 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/RealCV.Application/Models/GitHubVerificationResult.cs b/src/RealCV.Application/Models/GitHubVerificationResult.cs new file mode 100644 index 0000000..69856de --- /dev/null +++ b/src/RealCV.Application/Models/GitHubVerificationResult.cs @@ -0,0 +1,52 @@ +namespace RealCV.Application.Models; + +/// +/// Result of verifying a developer's GitHub profile +/// +public sealed record GitHubVerificationResult +{ + public required string ClaimedUsername { get; init; } + public required bool IsVerified { get; init; } + + // Profile details + public string? ProfileName { get; init; } + public string? ProfileUrl { get; init; } + public string? Bio { get; init; } + public string? Company { get; init; } + public string? Location { get; init; } + public DateOnly? AccountCreated { get; init; } + + // Activity metrics + public int PublicRepos { get; init; } + public int Followers { get; init; } + public int Following { get; init; } + public int TotalContributions { get; init; } + + // Language breakdown + public Dictionary LanguageStats { get; init; } = new(); + + // Claimed skills verification + public List SkillVerifications { get; init; } = []; + + public string? VerificationNotes { get; init; } + public List Flags { get; init; } = []; +} + +public sealed record SkillVerification +{ + public required string ClaimedSkill { get; init; } + public required bool IsVerified { get; init; } + public int RepoCount { get; init; } + public int TotalLinesOfCode { get; init; } + public DateOnly? FirstUsed { get; init; } + public DateOnly? LastUsed { get; init; } + public string? Notes { get; init; } +} + +public sealed record GitHubVerificationFlag +{ + 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/RealCV.Application/Models/InternationalCompanyResult.cs b/src/RealCV.Application/Models/InternationalCompanyResult.cs new file mode 100644 index 0000000..33d232c --- /dev/null +++ b/src/RealCV.Application/Models/InternationalCompanyResult.cs @@ -0,0 +1,30 @@ +namespace RealCV.Application.Models; + +/// +/// Result of verifying an international company via OpenCorporates +/// +public sealed record InternationalCompanyResult +{ + public required string ClaimedCompany { get; init; } + public required string ClaimedJurisdiction { get; init; } + public required bool IsVerified { get; init; } + + // Matched company details + public string? MatchedCompanyName { get; init; } + public string? CompanyNumber { get; init; } + public string? Jurisdiction { get; init; } + public string? JurisdictionCode { get; init; } + public string? CompanyType { get; init; } + public string? Status { get; init; } + public DateOnly? IncorporationDate { get; init; } + public DateOnly? DissolutionDate { get; init; } + public string? RegisteredAddress { get; init; } + + // OpenCorporates specific + public string? OpenCorporatesUrl { get; init; } + public DateTime? DataLastUpdated { get; init; } + + public int MatchScore { get; init; } + public string? VerificationNotes { get; init; } + public List Flags { get; init; } = []; +} diff --git a/src/RealCV.Application/Models/ProfessionalVerificationResult.cs b/src/RealCV.Application/Models/ProfessionalVerificationResult.cs new file mode 100644 index 0000000..252ace1 --- /dev/null +++ b/src/RealCV.Application/Models/ProfessionalVerificationResult.cs @@ -0,0 +1,38 @@ +namespace RealCV.Application.Models; + +/// +/// Result of verifying a professional qualification (FCA, SRA, etc.) +/// +public sealed record ProfessionalVerificationResult +{ + public required string ClaimedName { get; init; } + public required string ProfessionalBody { get; init; } + public required bool IsVerified { get; init; } + + // Matched professional details + public string? MatchedName { get; init; } + public string? RegistrationNumber { get; init; } + public string? Status { get; init; } + public string? CurrentEmployer { get; init; } + public DateOnly? RegistrationDate { get; init; } + + // For FCA + public List? ApprovedFunctions { get; init; } + public List? ControlledFunctions { get; init; } + + // For SRA + public string? SolicitorType { get; init; } + public string? AdmissionDate { get; init; } + public string? PractisingCertificateStatus { get; init; } + + public string? VerificationNotes { get; init; } + public List Flags { get; init; } = []; +} + +public sealed record ProfessionalVerificationFlag +{ + 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/RealCV.Infrastructure/Clients/FcaRegisterClient.cs b/src/RealCV.Infrastructure/Clients/FcaRegisterClient.cs new file mode 100644 index 0000000..dca9f33 --- /dev/null +++ b/src/RealCV.Infrastructure/Clients/FcaRegisterClient.cs @@ -0,0 +1,216 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace RealCV.Infrastructure.Clients; + +public sealed class FcaRegisterClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly string _apiKey; + + public FcaRegisterClient( + HttpClient httpClient, + IOptions options, + ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + _apiKey = options.Value.ApiKey; + + _httpClient.BaseAddress = new Uri("https://register.fca.org.uk/services/V0.1/"); + _httpClient.DefaultRequestHeaders.Add("X-Auth-Email", options.Value.Email); + _httpClient.DefaultRequestHeaders.Add("X-Auth-Key", _apiKey); + } + + public async Task SearchIndividualsAsync(string name, int page = 1) + { + try + { + var encodedName = Uri.EscapeDataString(name); + var url = $"Individuals?q={encodedName}&page={page}"; + + _logger.LogDebug("Searching FCA for individual: {Name}", name); + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("FCA API returned {StatusCode} for search: {Name}", + response.StatusCode, name); + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching FCA for individual: {Name}", name); + return null; + } + } + + public async Task GetIndividualAsync(string individualReferenceNumber) + { + try + { + var url = $"Individuals/{individualReferenceNumber}"; + + _logger.LogDebug("Getting FCA individual: {IRN}", individualReferenceNumber); + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("FCA API returned {StatusCode} for IRN: {IRN}", + response.StatusCode, individualReferenceNumber); + return null; + } + + var wrapper = await response.Content.ReadFromJsonAsync(JsonOptions); + return wrapper?.Data?.FirstOrDefault(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting FCA individual: {IRN}", individualReferenceNumber); + return null; + } + } + + public async Task SearchFirmsAsync(string name, int page = 1) + { + try + { + var encodedName = Uri.EscapeDataString(name); + var url = $"Firms?q={encodedName}&page={page}"; + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching FCA for firm: {Name}", name); + return null; + } + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +} + +public class FcaOptions +{ + public string ApiKey { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; +} + +// Response models +public class FcaIndividualResponse +{ + public List? Data { get; set; } + public FcaPagination? Pagination { get; set; } +} + +public class FcaIndividualSearchItem +{ + [JsonPropertyName("Individual Reference Number")] + public string? IndividualReferenceNumber { get; set; } + + public string? Name { get; set; } + public string? Status { get; set; } + + [JsonPropertyName("Current Employer(s)")] + public string? CurrentEmployers { get; set; } +} + +public class FcaIndividualDetailsWrapper +{ + public List? Data { get; set; } +} + +public class FcaIndividualDetails +{ + [JsonPropertyName("Individual Reference Number")] + public string? IndividualReferenceNumber { get; set; } + + public string? Name { get; set; } + public string? Status { get; set; } + + [JsonPropertyName("Effective Date")] + public string? EffectiveDate { get; set; } + + [JsonPropertyName("Controlled Functions")] + public List? ControlledFunctions { get; set; } + + [JsonPropertyName("Previous Employments")] + public List? PreviousEmployments { get; set; } +} + +public class FcaControlledFunction +{ + [JsonPropertyName("Controlled Function")] + public string? ControlledFunction { get; set; } + + [JsonPropertyName("Firm Name")] + public string? FirmName { get; set; } + + [JsonPropertyName("Firm Reference Number")] + public string? FirmReferenceNumber { get; set; } + + [JsonPropertyName("Status")] + public string? Status { get; set; } + + [JsonPropertyName("Effective From")] + public string? EffectiveFrom { get; set; } +} + +public class FcaPreviousEmployment +{ + [JsonPropertyName("Firm Name")] + public string? FirmName { get; set; } + + [JsonPropertyName("Firm Reference Number")] + public string? FirmReferenceNumber { get; set; } + + [JsonPropertyName("Start Date")] + public string? StartDate { get; set; } + + [JsonPropertyName("End Date")] + public string? EndDate { get; set; } +} + +public class FcaFirmResponse +{ + public List? Data { get; set; } + public FcaPagination? Pagination { get; set; } +} + +public class FcaFirmSearchItem +{ + [JsonPropertyName("Firm Reference Number")] + public string? FirmReferenceNumber { get; set; } + + [JsonPropertyName("Firm Name")] + public string? FirmName { get; set; } + + public string? Status { get; set; } +} + +public class FcaPagination +{ + public int Page { get; set; } + public int TotalPages { get; set; } + public int TotalItems { get; set; } +} diff --git a/src/RealCV.Infrastructure/Clients/GitHubClient.cs b/src/RealCV.Infrastructure/Clients/GitHubClient.cs new file mode 100644 index 0000000..c3945de --- /dev/null +++ b/src/RealCV.Infrastructure/Clients/GitHubClient.cs @@ -0,0 +1,265 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace RealCV.Infrastructure.Clients; + +public sealed class GitHubApiClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public GitHubApiClient( + HttpClient httpClient, + IOptions options, + ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + + _httpClient.BaseAddress = new Uri("https://api.github.com/"); + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + _httpClient.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28"); + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("RealCV/1.0"); + + if (!string.IsNullOrEmpty(options.Value.PersonalAccessToken)) + { + _httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", options.Value.PersonalAccessToken); + } + } + + public async Task GetUserAsync(string username) + { + try + { + var url = $"users/{Uri.EscapeDataString(username)}"; + + _logger.LogDebug("Getting GitHub user: {Username}", username); + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("GitHub API returned {StatusCode} for user: {Username}", + response.StatusCode, username); + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting GitHub user: {Username}", username); + return null; + } + } + + public async Task> GetUserReposAsync(string username, int perPage = 100) + { + var repos = new List(); + var page = 1; + + try + { + while (true) + { + var url = $"users/{Uri.EscapeDataString(username)}/repos?per_page={perPage}&page={page}&sort=updated"; + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + break; + } + + var pageRepos = await response.Content.ReadFromJsonAsync>(JsonOptions); + + if (pageRepos == null || pageRepos.Count == 0) + { + break; + } + + repos.AddRange(pageRepos); + + if (pageRepos.Count < perPage) + { + break; + } + + page++; + + // Limit to avoid rate limiting + if (page > 5) + { + break; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting repos for user: {Username}", username); + } + + return repos; + } + + public async Task?> GetRepoLanguagesAsync(string owner, string repo) + { + try + { + var url = $"repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(repo)}/languages"; + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadFromJsonAsync>(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting languages for repo: {Owner}/{Repo}", owner, repo); + return null; + } + } + + public async Task SearchUsersAsync(string query, int perPage = 30) + { + try + { + var encodedQuery = Uri.EscapeDataString(query); + var url = $"search/users?q={encodedQuery}&per_page={perPage}"; + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching GitHub users: {Query}", query); + return null; + } + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +} + +public class GitHubOptions +{ + public string PersonalAccessToken { get; set; } = string.Empty; +} + +// Response models +public class GitHubUser +{ + public string? Login { get; set; } + public int Id { get; set; } + public string? Name { get; set; } + public string? Company { get; set; } + public string? Blog { get; set; } + public string? Location { get; set; } + public string? Email { get; set; } + public string? Bio { get; set; } + + [JsonPropertyName("twitter_username")] + public string? TwitterUsername { get; set; } + + [JsonPropertyName("public_repos")] + public int PublicRepos { get; set; } + + [JsonPropertyName("public_gists")] + public int PublicGists { get; set; } + + public int Followers { get; set; } + public int Following { get; set; } + + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } + + [JsonPropertyName("updated_at")] + public DateTime UpdatedAt { get; set; } + + [JsonPropertyName("html_url")] + public string? HtmlUrl { get; set; } + + [JsonPropertyName("avatar_url")] + public string? AvatarUrl { get; set; } +} + +public class GitHubRepo +{ + public int Id { get; set; } + public string? Name { get; set; } + + [JsonPropertyName("full_name")] + public string? FullName { get; set; } + + public string? Description { get; set; } + public string? Language { get; set; } + + [JsonPropertyName("html_url")] + public string? HtmlUrl { get; set; } + + public bool Fork { get; set; } + public bool Private { get; set; } + + [JsonPropertyName("stargazers_count")] + public int StargazersCount { get; set; } + + [JsonPropertyName("watchers_count")] + public int WatchersCount { get; set; } + + [JsonPropertyName("forks_count")] + public int ForksCount { get; set; } + + public int Size { get; set; } + + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } + + [JsonPropertyName("updated_at")] + public DateTime UpdatedAt { get; set; } + + [JsonPropertyName("pushed_at")] + public DateTime? PushedAt { get; set; } +} + +public class GitHubUserSearchResponse +{ + [JsonPropertyName("total_count")] + public int TotalCount { get; set; } + + [JsonPropertyName("incomplete_results")] + public bool IncompleteResults { get; set; } + + public List? Items { get; set; } +} + +public class GitHubUserSearchItem +{ + public string? Login { get; set; } + public int Id { get; set; } + + [JsonPropertyName("avatar_url")] + public string? AvatarUrl { get; set; } + + [JsonPropertyName("html_url")] + public string? HtmlUrl { get; set; } + + public double Score { get; set; } +} diff --git a/src/RealCV.Infrastructure/Clients/OpenCorporatesClient.cs b/src/RealCV.Infrastructure/Clients/OpenCorporatesClient.cs new file mode 100644 index 0000000..61a5504 --- /dev/null +++ b/src/RealCV.Infrastructure/Clients/OpenCorporatesClient.cs @@ -0,0 +1,270 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace RealCV.Infrastructure.Clients; + +public sealed class OpenCorporatesClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly string _apiToken; + + public OpenCorporatesClient( + HttpClient httpClient, + IOptions options, + ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + _apiToken = options.Value.ApiToken; + + _httpClient.BaseAddress = new Uri("https://api.opencorporates.com/v0.4/"); + } + + public async Task SearchCompaniesAsync( + string query, + string? jurisdiction = null, + int perPage = 30, + int page = 1) + { + try + { + var encodedQuery = Uri.EscapeDataString(query); + var url = $"companies/search?q={encodedQuery}&per_page={perPage}&page={page}"; + + if (!string.IsNullOrEmpty(jurisdiction)) + { + url += $"&jurisdiction_code={Uri.EscapeDataString(jurisdiction)}"; + } + + if (!string.IsNullOrEmpty(_apiToken)) + { + url += $"&api_token={_apiToken}"; + } + + _logger.LogDebug("Searching OpenCorporates: {Query}", query); + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("OpenCorporates API returned {StatusCode} for search: {Query}", + response.StatusCode, query); + return null; + } + + var wrapper = await response.Content.ReadFromJsonAsync>(JsonOptions); + return wrapper?.Results; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching OpenCorporates: {Query}", query); + return null; + } + } + + public async Task GetCompanyAsync(string jurisdictionCode, string companyNumber) + { + try + { + var url = $"companies/{Uri.EscapeDataString(jurisdictionCode)}/{Uri.EscapeDataString(companyNumber)}"; + + if (!string.IsNullOrEmpty(_apiToken)) + { + url += $"?api_token={_apiToken}"; + } + + _logger.LogDebug("Getting OpenCorporates company: {Jurisdiction}/{CompanyNumber}", + jurisdictionCode, companyNumber); + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + var wrapper = await response.Content.ReadFromJsonAsync(JsonOptions); + return wrapper?.Results?.Company; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting OpenCorporates company: {Jurisdiction}/{CompanyNumber}", + jurisdictionCode, companyNumber); + return null; + } + } + + public async Task?> GetJurisdictionsAsync() + { + try + { + var url = "jurisdictions"; + + if (!string.IsNullOrEmpty(_apiToken)) + { + url += $"?api_token={_apiToken}"; + } + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + var wrapper = await response.Content.ReadFromJsonAsync(JsonOptions); + return wrapper?.Results?.Jurisdictions?.Select(j => j.Jurisdiction).Where(j => j != null).ToList()!; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting OpenCorporates jurisdictions"); + return null; + } + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +} + +public class OpenCorporatesOptions +{ + public string ApiToken { get; set; } = string.Empty; +} + +// Response wrapper +public class OpenCorporatesResponseWrapper +{ + [JsonPropertyName("api_version")] + public string? ApiVersion { get; set; } + + public T? Results { get; set; } +} + +// Search response +public class OpenCorporatesSearchResponse +{ + public List? Companies { get; set; } + public int Page { get; set; } + + [JsonPropertyName("per_page")] + public int PerPage { get; set; } + + [JsonPropertyName("total_pages")] + public int TotalPages { get; set; } + + [JsonPropertyName("total_count")] + public int TotalCount { get; set; } +} + +public class OpenCorporatesCompanyWrapper +{ + public OpenCorporatesCompany? Company { get; set; } + public OpenCorporatesCompanyResults? Results { get; set; } +} + +public class OpenCorporatesCompanyResults +{ + public OpenCorporatesCompany? Company { get; set; } +} + +public class OpenCorporatesCompany +{ + public string? Name { get; set; } + + [JsonPropertyName("company_number")] + public string? CompanyNumber { get; set; } + + [JsonPropertyName("jurisdiction_code")] + public string? JurisdictionCode { get; set; } + + [JsonPropertyName("incorporation_date")] + public string? IncorporationDate { get; set; } + + [JsonPropertyName("dissolution_date")] + public string? DissolutionDate { get; set; } + + [JsonPropertyName("company_type")] + public string? CompanyType { get; set; } + + [JsonPropertyName("registry_url")] + public string? RegistryUrl { get; set; } + + [JsonPropertyName("branch_status")] + public string? BranchStatus { get; set; } + + [JsonPropertyName("current_status")] + public string? CurrentStatus { get; set; } + + [JsonPropertyName("opencorporates_url")] + public string? OpencorporatesUrl { get; set; } + + [JsonPropertyName("registered_address_in_full")] + public string? RegisteredAddressInFull { get; set; } + + [JsonPropertyName("retrieved_at")] + public DateTime? RetrievedAt { get; set; } + + [JsonPropertyName("updated_at")] + public DateTime? UpdatedAt { get; set; } + + public OpenCorporatesAddress? RegisteredAddress { get; set; } + + [JsonPropertyName("industry_codes")] + public List? IndustryCodes { get; set; } +} + +public class OpenCorporatesAddress +{ + [JsonPropertyName("street_address")] + public string? StreetAddress { get; set; } + + public string? Locality { get; set; } + public string? Region { get; set; } + + [JsonPropertyName("postal_code")] + public string? PostalCode { get; set; } + + public string? Country { get; set; } +} + +public class OpenCorporatesIndustryCode +{ + public string? Code { get; set; } + public string? Description { get; set; } + + [JsonPropertyName("code_scheme_id")] + public string? CodeSchemeId { get; set; } +} + +// Jurisdictions +public class OpenCorporatesJurisdictionsWrapper +{ + public OpenCorporatesJurisdictionsList? Results { get; set; } +} + +public class OpenCorporatesJurisdictionsList +{ + public List? Jurisdictions { get; set; } +} + +public class OpenCorporatesJurisdictionWrapper +{ + public OpenCorporatesJurisdiction? Jurisdiction { get; set; } +} + +public class OpenCorporatesJurisdiction +{ + public string? Code { get; set; } + public string? Name { get; set; } + public string? Country { get; set; } + + [JsonPropertyName("full_name")] + public string? FullName { get; set; } +} diff --git a/src/RealCV.Infrastructure/Clients/OrcidClient.cs b/src/RealCV.Infrastructure/Clients/OrcidClient.cs new file mode 100644 index 0000000..37e3c94 --- /dev/null +++ b/src/RealCV.Infrastructure/Clients/OrcidClient.cs @@ -0,0 +1,342 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace RealCV.Infrastructure.Clients; + +public sealed class OrcidClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public OrcidClient( + HttpClient httpClient, + ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + + _httpClient.BaseAddress = new Uri("https://pub.orcid.org/v3.0/"); + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + } + + public async Task SearchResearchersAsync(string query, int start = 0, int rows = 20) + { + try + { + var encodedQuery = Uri.EscapeDataString(query); + var url = $"search?q={encodedQuery}&start={start}&rows={rows}"; + + _logger.LogDebug("Searching ORCID: {Query}", query); + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("ORCID API returned {StatusCode} for search: {Query}", + response.StatusCode, query); + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching ORCID: {Query}", query); + return null; + } + } + + public async Task GetRecordAsync(string orcidId) + { + try + { + // Normalize ORCID ID format (remove URL prefix if present) + orcidId = NormalizeOrcidId(orcidId); + + var url = $"{orcidId}/record"; + + _logger.LogDebug("Getting ORCID record: {OrcidId}", orcidId); + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("ORCID API returned {StatusCode} for ID: {OrcidId}", + response.StatusCode, orcidId); + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting ORCID record: {OrcidId}", orcidId); + return null; + } + } + + public async Task GetWorksAsync(string orcidId) + { + try + { + orcidId = NormalizeOrcidId(orcidId); + var url = $"{orcidId}/works"; + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting ORCID works: {OrcidId}", orcidId); + return null; + } + } + + public async Task GetEmploymentsAsync(string orcidId) + { + try + { + orcidId = NormalizeOrcidId(orcidId); + var url = $"{orcidId}/employments"; + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting ORCID employments: {OrcidId}", orcidId); + return null; + } + } + + public async Task GetEducationsAsync(string orcidId) + { + try + { + orcidId = NormalizeOrcidId(orcidId); + var url = $"{orcidId}/educations"; + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting ORCID educations: {OrcidId}", orcidId); + return null; + } + } + + private static string NormalizeOrcidId(string orcidId) + { + // Remove URL prefixes + orcidId = orcidId.Replace("https://orcid.org/", "") + .Replace("http://orcid.org/", "") + .Trim(); + return orcidId; + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +} + +// Response models +public class OrcidSearchResponse +{ + [JsonPropertyName("num-found")] + public int NumFound { get; set; } + + public List? Result { get; set; } +} + +public class OrcidSearchResult +{ + [JsonPropertyName("orcid-identifier")] + public OrcidIdentifier? OrcidIdentifier { get; set; } +} + +public class OrcidIdentifier +{ + public string? Uri { get; set; } + public string? Path { get; set; } + public string? Host { get; set; } +} + +public class OrcidRecord +{ + [JsonPropertyName("orcid-identifier")] + public OrcidIdentifier? OrcidIdentifier { get; set; } + + public OrcidPerson? Person { get; set; } + + [JsonPropertyName("activities-summary")] + public OrcidActivitiesSummary? ActivitiesSummary { get; set; } +} + +public class OrcidPerson +{ + public OrcidName? Name { get; set; } + public OrcidBiography? Biography { get; set; } +} + +public class OrcidName +{ + [JsonPropertyName("given-names")] + public OrcidValue? GivenNames { get; set; } + + [JsonPropertyName("family-name")] + public OrcidValue? FamilyName { get; set; } + + [JsonPropertyName("credit-name")] + public OrcidValue? CreditName { get; set; } +} + +public class OrcidValue +{ + public string? Value { get; set; } +} + +public class OrcidBiography +{ + public string? Content { get; set; } +} + +public class OrcidActivitiesSummary +{ + public OrcidEmployments? Employments { get; set; } + public OrcidEducations? Educations { get; set; } + public OrcidWorks? Works { get; set; } +} + +public class OrcidEmployments +{ + [JsonPropertyName("affiliation-group")] + public List? AffiliationGroup { get; set; } +} + +public class OrcidEducations +{ + [JsonPropertyName("affiliation-group")] + public List? AffiliationGroup { get; set; } +} + +public class OrcidAffiliationGroup +{ + public List? Summaries { get; set; } +} + +public class OrcidAffiliationSummaryWrapper +{ + [JsonPropertyName("employment-summary")] + public OrcidAffiliationSummary? EmploymentSummary { get; set; } + + [JsonPropertyName("education-summary")] + public OrcidAffiliationSummary? EducationSummary { get; set; } +} + +public class OrcidAffiliationSummary +{ + [JsonPropertyName("department-name")] + public string? DepartmentName { get; set; } + + [JsonPropertyName("role-title")] + public string? RoleTitle { get; set; } + + [JsonPropertyName("start-date")] + public OrcidDate? StartDate { get; set; } + + [JsonPropertyName("end-date")] + public OrcidDate? EndDate { get; set; } + + public OrcidOrganization? Organization { get; set; } +} + +public class OrcidDate +{ + public OrcidValue? Year { get; set; } + public OrcidValue? Month { get; set; } + public OrcidValue? Day { get; set; } +} + +public class OrcidOrganization +{ + public string? Name { get; set; } + public OrcidAddress? Address { get; set; } +} + +public class OrcidAddress +{ + public string? City { get; set; } + public string? Region { get; set; } + public string? Country { get; set; } +} + +public class OrcidWorks +{ + public List? Group { get; set; } +} + +public class OrcidWorkGroup +{ + [JsonPropertyName("work-summary")] + public List? WorkSummary { get; set; } +} + +public class OrcidWorkSummary +{ + public string? Title { get; set; } + public OrcidTitle? TitleObj { get; set; } + public string? Type { get; set; } + + [JsonPropertyName("publication-date")] + public OrcidDate? PublicationDate { get; set; } + + [JsonPropertyName("journal-title")] + public OrcidValue? JournalTitle { get; set; } + + [JsonPropertyName("external-ids")] + public OrcidExternalIds? ExternalIds { get; set; } +} + +public class OrcidTitle +{ + public OrcidValue? Title { get; set; } +} + +public class OrcidExternalIds +{ + [JsonPropertyName("external-id")] + public List? ExternalId { get; set; } +} + +public class OrcidExternalId +{ + [JsonPropertyName("external-id-type")] + public string? ExternalIdType { get; set; } + + [JsonPropertyName("external-id-value")] + public string? ExternalIdValue { get; set; } +} diff --git a/src/RealCV.Infrastructure/Clients/SraRegisterClient.cs b/src/RealCV.Infrastructure/Clients/SraRegisterClient.cs new file mode 100644 index 0000000..0e17d43 --- /dev/null +++ b/src/RealCV.Infrastructure/Clients/SraRegisterClient.cs @@ -0,0 +1,181 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace RealCV.Infrastructure.Clients; + +public sealed class SraRegisterClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public SraRegisterClient( + HttpClient httpClient, + ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + + _httpClient.BaseAddress = new Uri("https://sra-prod-apim.azure-api.net/"); + } + + public async Task SearchSolicitorsAsync(string name, int page = 1, int pageSize = 20) + { + try + { + var encodedName = Uri.EscapeDataString(name); + var url = $"solicitors/search?name={encodedName}&page={page}&pageSize={pageSize}"; + + _logger.LogDebug("Searching SRA for solicitor: {Name}", name); + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("SRA API returned {StatusCode} for search: {Name}", + response.StatusCode, name); + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching SRA for solicitor: {Name}", name); + return null; + } + } + + public async Task GetSolicitorAsync(string sraNumber) + { + try + { + var url = $"solicitors/{sraNumber}"; + + _logger.LogDebug("Getting SRA solicitor: {SraNumber}", sraNumber); + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("SRA API returned {StatusCode} for SRA number: {SraNumber}", + response.StatusCode, sraNumber); + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting SRA solicitor: {SraNumber}", sraNumber); + return null; + } + } + + public async Task SearchOrganisationsAsync(string name, int page = 1, int pageSize = 20) + { + try + { + var encodedName = Uri.EscapeDataString(name); + var url = $"organisations/search?name={encodedName}&page={page}&pageSize={pageSize}"; + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching SRA for organisation: {Name}", name); + return null; + } + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +} + +// Response models +public class SraSolicitorSearchResponse +{ + public List? Results { get; set; } + public int TotalCount { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } +} + +public class SraSolicitorSearchItem +{ + public string? SraId { get; set; } + public string? Name { get; set; } + public string? Status { get; set; } + public string? Town { get; set; } + public string? AdmissionDate { get; set; } + public string? CurrentOrganisation { get; set; } +} + +public class SraSolicitorDetails +{ + public string? SraId { get; set; } + public string? Forename { get; set; } + public string? MiddleNames { get; set; } + public string? Surname { get; set; } + public string? Status { get; set; } + public string? AdmissionDate { get; set; } + public string? SolicitorType { get; set; } + public string? PractisingCertificateStatus { get; set; } + + public SraCurrentPosition? CurrentPosition { get; set; } + public List? PreviousPositions { get; set; } + public List? DisciplinaryHistory { get; set; } + + public string FullName => string.Join(" ", + new[] { Forename, MiddleNames, Surname }.Where(s => !string.IsNullOrWhiteSpace(s))); +} + +public class SraCurrentPosition +{ + public string? OrganisationName { get; set; } + public string? OrganisationSraId { get; set; } + public string? Role { get; set; } + public string? StartDate { get; set; } +} + +public class SraPreviousPosition +{ + public string? OrganisationName { get; set; } + public string? OrganisationSraId { get; set; } + public string? Role { get; set; } + public string? StartDate { get; set; } + public string? EndDate { get; set; } +} + +public class SraDisciplinaryRecord +{ + public string? DecisionDate { get; set; } + public string? DecisionType { get; set; } + public string? Summary { get; set; } +} + +public class SraOrganisationSearchResponse +{ + public List? Results { get; set; } + public int TotalCount { get; set; } +} + +public class SraOrganisationSearchItem +{ + public string? SraId { get; set; } + public string? Name { get; set; } + public string? Status { get; set; } + public string? Type { get; set; } + public string? Town { get; set; } +} diff --git a/src/RealCV.Infrastructure/DependencyInjection.cs b/src/RealCV.Infrastructure/DependencyInjection.cs index e20020a..9fab7d1 100644 --- a/src/RealCV.Infrastructure/DependencyInjection.cs +++ b/src/RealCV.Infrastructure/DependencyInjection.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Polly; using Polly.Extensions.Http; using RealCV.Application.Interfaces; +using RealCV.Infrastructure.Clients; using RealCV.Infrastructure.Configuration; using RealCV.Infrastructure.Data; using RealCV.Infrastructure.ExternalApis; @@ -74,6 +75,16 @@ public static class DependencyInjection services.Configure( configuration.GetSection(LocalStorageSettings.SectionName)); + // Configure options for additional verification APIs + services.Configure( + configuration.GetSection("FcaRegister")); + + services.Configure( + configuration.GetSection("GitHub")); + + services.Configure( + configuration.GetSection("OpenCorporates")); + // Configure HttpClient for CompaniesHouseClient with retry policy services.AddHttpClient((serviceProvider, client) => { @@ -88,6 +99,26 @@ public static class DependencyInjection }) .AddPolicyHandler(GetRetryPolicy()); + // Configure HttpClient for FCA Register API + services.AddHttpClient() + .AddPolicyHandler(GetRetryPolicy()); + + // Configure HttpClient for SRA Register API + services.AddHttpClient() + .AddPolicyHandler(GetRetryPolicy()); + + // Configure HttpClient for GitHub API + services.AddHttpClient() + .AddPolicyHandler(GetRetryPolicy()); + + // Configure HttpClient for OpenCorporates API + services.AddHttpClient() + .AddPolicyHandler(GetRetryPolicy()); + + // Configure HttpClient for ORCID API + services.AddHttpClient() + .AddPolicyHandler(GetRetryPolicy()); + // Register services services.AddScoped(); services.AddScoped(); @@ -98,6 +129,12 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); + // Register additional verification services + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // Register file storage - use local storage if configured, otherwise Azure var useLocalStorage = configuration.GetValue("UseLocalStorage"); if (useLocalStorage) diff --git a/src/RealCV.Infrastructure/Services/AcademicVerifierService.cs b/src/RealCV.Infrastructure/Services/AcademicVerifierService.cs new file mode 100644 index 0000000..747cc07 --- /dev/null +++ b/src/RealCV.Infrastructure/Services/AcademicVerifierService.cs @@ -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 _logger; + + public AcademicVerifierService( + OrcidClient orcidClient, + ILogger logger) + { + _orcidClient = orcidClient; + _logger = logger; + } + + public async Task 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(); + 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(); + var employments = await _orcidClient.GetEmploymentsAsync(orcidId); + if (employments?.AffiliationGroup != null) + { + affiliations.AddRange(ExtractAffiliations(employments.AffiliationGroup, "employment")); + } + + // Get detailed education information + var educationList = new List(); + var educations = await _orcidClient.GetEducationsAsync(orcidId); + if (educations?.AffiliationGroup != null) + { + educationList.AddRange(ExtractEducations(educations.AffiliationGroup)); + } + + // Get works/publications + var publications = new List(); + 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 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> 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(); + + 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(); + 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(); + 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> VerifyPublicationsAsync( + string orcidId, + List claimedPublications) + { + var results = new List(); + + 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 ExtractAffiliations( + List groups, + string type) + { + var affiliations = new List(); + + 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 ExtractEducations(List groups) + { + var educations = new List(); + + 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 ExtractPublications(List groups) + { + var publications = new List(); + + 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 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 affiliations, int publicationCount, int educationCount) + { + var parts = new List(); + + 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"; + } +} diff --git a/src/RealCV.Infrastructure/Services/GitHubVerifierService.cs b/src/RealCV.Infrastructure/Services/GitHubVerifierService.cs new file mode 100644 index 0000000..df47941 --- /dev/null +++ b/src/RealCV.Infrastructure/Services/GitHubVerifierService.cs @@ -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 _logger; + + // Map common skill names to GitHub languages + private static readonly Dictionary 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 logger) + { + _gitHubClient = gitHubClient; + _logger = logger; + } + + public async Task 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(); + 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(); + + // 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 VerifySkillsAsync( + string username, + List claimedSkills) + { + var result = await VerifyProfileAsync(username); + + if (!result.IsVerified) + return result; + + var skillVerifications = new List(); + + 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> SearchProfilesAsync(string name) + { + try + { + var searchResponse = await _gitHubClient.SearchUsersAsync(name); + + if (searchResponse?.Items == null) + { + return []; + } + + var results = new List(); + + 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 repos) + { + var parts = new List + { + $"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); + } +} diff --git a/src/RealCV.Infrastructure/Services/InternationalCompanyVerifierService.cs b/src/RealCV.Infrastructure/Services/InternationalCompanyVerifierService.cs new file mode 100644 index 0000000..954c4b1 --- /dev/null +++ b/src/RealCV.Infrastructure/Services/InternationalCompanyVerifierService.cs @@ -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 _logger; + + // Common jurisdiction codes + private static readonly Dictionary 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 logger) + { + _openCorporatesClient = openCorporatesClient; + _logger = logger; + } + + public async Task 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(); + + 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> 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> 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 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(); + + 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); + } +} diff --git a/src/RealCV.Infrastructure/Services/ProfessionalVerifierService.cs b/src/RealCV.Infrastructure/Services/ProfessionalVerifierService.cs new file mode 100644 index 0000000..4d16928 --- /dev/null +++ b/src/RealCV.Infrastructure/Services/ProfessionalVerifierService.cs @@ -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 _logger; + + public ProfessionalVerifierService( + FcaRegisterClient fcaClient, + SraRegisterClient sraClient, + ILogger logger) + { + _fcaClient = fcaClient; + _sraClient = sraClient; + _logger = logger; + } + + public async Task 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 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> 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> 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; + } +} diff --git a/src/RealCV.Web/appsettings.json b/src/RealCV.Web/appsettings.json index fb12798..f01272f 100644 --- a/src/RealCV.Web/appsettings.json +++ b/src/RealCV.Web/appsettings.json @@ -18,6 +18,16 @@ "ConnectionString": "", "ContainerName": "cv-uploads" }, + "FcaRegister": { + "ApiKey": "", + "Email": "" + }, + "GitHub": { + "PersonalAccessToken": "" + }, + "OpenCorporates": { + "ApiToken": "" + }, "Serilog": { "MinimumLevel": { "Default": "Information",