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",