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

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

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

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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-24 13:05:52 +00:00
parent 8a4e46d872
commit 5d2965beae
19 changed files with 3249 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
using RealCV.Application.Models;
namespace RealCV.Application.Interfaces;
/// <summary>
/// Service for verifying academic researchers via ORCID
/// </summary>
public interface IAcademicVerifierService
{
/// <summary>
/// Verify an academic researcher by ORCID ID
/// </summary>
Task<AcademicVerificationResult> VerifyByOrcidAsync(string orcidId);
/// <summary>
/// Search for researchers and verify by name
/// </summary>
Task<AcademicVerificationResult> VerifyByNameAsync(
string name,
string? affiliation = null);
/// <summary>
/// Search ORCID for researchers
/// </summary>
Task<List<OrcidSearchResult>> SearchResearchersAsync(
string name,
string? affiliation = null);
/// <summary>
/// Verify claimed publications
/// </summary>
Task<List<PublicationVerificationResult>> VerifyPublicationsAsync(
string orcidId,
List<string> claimedPublications);
}
public sealed record OrcidSearchResult
{
public required string OrcidId { get; init; }
public required string Name { get; init; }
public string? OrcidUrl { get; init; }
public List<string>? 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; }
}

View File

@@ -0,0 +1,36 @@
using RealCV.Application.Models;
namespace RealCV.Application.Interfaces;
/// <summary>
/// Service for verifying developer profiles and skills via GitHub
/// </summary>
public interface IGitHubVerifierService
{
/// <summary>
/// Verify a GitHub profile and analyze activity
/// </summary>
Task<GitHubVerificationResult> VerifyProfileAsync(string username);
/// <summary>
/// Verify claimed programming skills against GitHub activity
/// </summary>
Task<GitHubVerificationResult> VerifySkillsAsync(
string username,
List<string> claimedSkills);
/// <summary>
/// Search for GitHub profiles matching a name
/// </summary>
Task<List<GitHubProfileSearchResult>> 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; }
}

View File

@@ -0,0 +1,49 @@
using RealCV.Application.Models;
namespace RealCV.Application.Interfaces;
/// <summary>
/// Service for verifying international companies via OpenCorporates
/// </summary>
public interface IInternationalCompanyVerifierService
{
/// <summary>
/// Verify an international company
/// </summary>
Task<InternationalCompanyResult> VerifyCompanyAsync(
string companyName,
string? jurisdiction = null,
DateOnly? claimedStartDate = null,
DateOnly? claimedEndDate = null);
/// <summary>
/// Search for companies across all jurisdictions
/// </summary>
Task<List<OpenCorporatesSearchResult>> SearchCompaniesAsync(
string query,
string? jurisdiction = null);
/// <summary>
/// Get list of supported jurisdictions
/// </summary>
Task<List<JurisdictionInfo>> 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; }
}

View File

@@ -0,0 +1,52 @@
using RealCV.Application.Models;
namespace RealCV.Application.Interfaces;
/// <summary>
/// Service for verifying professional qualifications (FCA, SRA, etc.)
/// </summary>
public interface IProfessionalVerifierService
{
/// <summary>
/// Verify if a person is registered with the FCA
/// </summary>
Task<ProfessionalVerificationResult> VerifyFcaRegistrationAsync(
string name,
string? firmName = null,
string? referenceNumber = null);
/// <summary>
/// Verify if a person is a registered solicitor with the SRA
/// </summary>
Task<ProfessionalVerificationResult> VerifySolicitorAsync(
string name,
string? sraNumber = null,
string? firmName = null);
/// <summary>
/// Search FCA register for individuals
/// </summary>
Task<List<FcaIndividualSearchResult>> SearchFcaIndividualsAsync(string name);
/// <summary>
/// Search SRA register for solicitors
/// </summary>
Task<List<SraSolicitorSearchResult>> 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<string>? 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; }
}

View File

@@ -0,0 +1,62 @@
namespace RealCV.Application.Models;
/// <summary>
/// Result of verifying an academic researcher via ORCID
/// </summary>
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<AcademicAffiliation> Affiliations { get; init; } = [];
// Publications
public int TotalPublications { get; init; }
public List<Publication> RecentPublications { get; init; } = [];
// Education from ORCID
public List<AcademicEducation> Education { get; init; } = [];
public string? VerificationNotes { get; init; }
public List<AcademicVerificationFlag> 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; }
}

View File

@@ -0,0 +1,52 @@
namespace RealCV.Application.Models;
/// <summary>
/// Result of verifying a developer's GitHub profile
/// </summary>
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<string, int> LanguageStats { get; init; } = new();
// Claimed skills verification
public List<SkillVerification> SkillVerifications { get; init; } = [];
public string? VerificationNotes { get; init; }
public List<GitHubVerificationFlag> 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; }
}

View File

@@ -0,0 +1,30 @@
namespace RealCV.Application.Models;
/// <summary>
/// Result of verifying an international company via OpenCorporates
/// </summary>
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<CompanyVerificationFlag> Flags { get; init; } = [];
}

View File

@@ -0,0 +1,38 @@
namespace RealCV.Application.Models;
/// <summary>
/// Result of verifying a professional qualification (FCA, SRA, etc.)
/// </summary>
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<string>? ApprovedFunctions { get; init; }
public List<string>? 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<ProfessionalVerificationFlag> 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; }
}

View File

@@ -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<FcaRegisterClient> _logger;
private readonly string _apiKey;
public FcaRegisterClient(
HttpClient httpClient,
IOptions<FcaOptions> options,
ILogger<FcaRegisterClient> 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<FcaIndividualResponse?> 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<FcaIndividualResponse>(JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching FCA for individual: {Name}", name);
return null;
}
}
public async Task<FcaIndividualDetails?> 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<FcaIndividualDetailsWrapper>(JsonOptions);
return wrapper?.Data?.FirstOrDefault();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting FCA individual: {IRN}", individualReferenceNumber);
return null;
}
}
public async Task<FcaFirmResponse?> 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<FcaFirmResponse>(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<FcaIndividualSearchItem>? 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<FcaIndividualDetails>? 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<FcaControlledFunction>? ControlledFunctions { get; set; }
[JsonPropertyName("Previous Employments")]
public List<FcaPreviousEmployment>? 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<FcaFirmSearchItem>? 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; }
}

View File

@@ -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<GitHubApiClient> _logger;
public GitHubApiClient(
HttpClient httpClient,
IOptions<GitHubOptions> options,
ILogger<GitHubApiClient> 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<GitHubUser?> 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<GitHubUser>(JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting GitHub user: {Username}", username);
return null;
}
}
public async Task<List<GitHubRepo>> GetUserReposAsync(string username, int perPage = 100)
{
var repos = new List<GitHubRepo>();
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<List<GitHubRepo>>(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<Dictionary<string, int>?> 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<Dictionary<string, int>>(JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting languages for repo: {Owner}/{Repo}", owner, repo);
return null;
}
}
public async Task<GitHubUserSearchResponse?> 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<GitHubUserSearchResponse>(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<GitHubUserSearchItem>? 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; }
}

View File

@@ -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<OpenCorporatesClient> _logger;
private readonly string _apiToken;
public OpenCorporatesClient(
HttpClient httpClient,
IOptions<OpenCorporatesOptions> options,
ILogger<OpenCorporatesClient> logger)
{
_httpClient = httpClient;
_logger = logger;
_apiToken = options.Value.ApiToken;
_httpClient.BaseAddress = new Uri("https://api.opencorporates.com/v0.4/");
}
public async Task<OpenCorporatesSearchResponse?> 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<OpenCorporatesResponseWrapper<OpenCorporatesSearchResponse>>(JsonOptions);
return wrapper?.Results;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching OpenCorporates: {Query}", query);
return null;
}
}
public async Task<OpenCorporatesCompany?> 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<OpenCorporatesCompanyWrapper>(JsonOptions);
return wrapper?.Results?.Company;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting OpenCorporates company: {Jurisdiction}/{CompanyNumber}",
jurisdictionCode, companyNumber);
return null;
}
}
public async Task<List<OpenCorporatesJurisdiction>?> 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<OpenCorporatesJurisdictionsWrapper>(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<T>
{
[JsonPropertyName("api_version")]
public string? ApiVersion { get; set; }
public T? Results { get; set; }
}
// Search response
public class OpenCorporatesSearchResponse
{
public List<OpenCorporatesCompanyWrapper>? 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<OpenCorporatesIndustryCode>? 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<OpenCorporatesJurisdictionWrapper>? 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; }
}

View File

@@ -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<OrcidClient> _logger;
public OrcidClient(
HttpClient httpClient,
ILogger<OrcidClient> 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<OrcidSearchResponse?> 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<OrcidSearchResponse>(JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching ORCID: {Query}", query);
return null;
}
}
public async Task<OrcidRecord?> 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<OrcidRecord>(JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting ORCID record: {OrcidId}", orcidId);
return null;
}
}
public async Task<OrcidWorks?> 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<OrcidWorks>(JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting ORCID works: {OrcidId}", orcidId);
return null;
}
}
public async Task<OrcidEmployments?> 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<OrcidEmployments>(JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting ORCID employments: {OrcidId}", orcidId);
return null;
}
}
public async Task<OrcidEducations?> 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<OrcidEducations>(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<OrcidSearchResult>? 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<OrcidAffiliationGroup>? AffiliationGroup { get; set; }
}
public class OrcidEducations
{
[JsonPropertyName("affiliation-group")]
public List<OrcidAffiliationGroup>? AffiliationGroup { get; set; }
}
public class OrcidAffiliationGroup
{
public List<OrcidAffiliationSummaryWrapper>? 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<OrcidWorkGroup>? Group { get; set; }
}
public class OrcidWorkGroup
{
[JsonPropertyName("work-summary")]
public List<OrcidWorkSummary>? 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<OrcidExternalId>? ExternalId { get; set; }
}
public class OrcidExternalId
{
[JsonPropertyName("external-id-type")]
public string? ExternalIdType { get; set; }
[JsonPropertyName("external-id-value")]
public string? ExternalIdValue { get; set; }
}

View File

@@ -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<SraRegisterClient> _logger;
public SraRegisterClient(
HttpClient httpClient,
ILogger<SraRegisterClient> logger)
{
_httpClient = httpClient;
_logger = logger;
_httpClient.BaseAddress = new Uri("https://sra-prod-apim.azure-api.net/");
}
public async Task<SraSolicitorSearchResponse?> 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<SraSolicitorSearchResponse>(JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching SRA for solicitor: {Name}", name);
return null;
}
}
public async Task<SraSolicitorDetails?> 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<SraSolicitorDetails>(JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting SRA solicitor: {SraNumber}", sraNumber);
return null;
}
}
public async Task<SraOrganisationSearchResponse?> 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<SraOrganisationSearchResponse>(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<SraSolicitorSearchItem>? 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<SraPreviousPosition>? PreviousPositions { get; set; }
public List<SraDisciplinaryRecord>? 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<SraOrganisationSearchItem>? 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; }
}

View File

@@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Polly; using Polly;
using Polly.Extensions.Http; using Polly.Extensions.Http;
using RealCV.Application.Interfaces; using RealCV.Application.Interfaces;
using RealCV.Infrastructure.Clients;
using RealCV.Infrastructure.Configuration; using RealCV.Infrastructure.Configuration;
using RealCV.Infrastructure.Data; using RealCV.Infrastructure.Data;
using RealCV.Infrastructure.ExternalApis; using RealCV.Infrastructure.ExternalApis;
@@ -74,6 +75,16 @@ public static class DependencyInjection
services.Configure<LocalStorageSettings>( services.Configure<LocalStorageSettings>(
configuration.GetSection(LocalStorageSettings.SectionName)); configuration.GetSection(LocalStorageSettings.SectionName));
// Configure options for additional verification APIs
services.Configure<FcaOptions>(
configuration.GetSection("FcaRegister"));
services.Configure<GitHubOptions>(
configuration.GetSection("GitHub"));
services.Configure<OpenCorporatesOptions>(
configuration.GetSection("OpenCorporates"));
// Configure HttpClient for CompaniesHouseClient with retry policy // Configure HttpClient for CompaniesHouseClient with retry policy
services.AddHttpClient<CompaniesHouseClient>((serviceProvider, client) => services.AddHttpClient<CompaniesHouseClient>((serviceProvider, client) =>
{ {
@@ -88,6 +99,26 @@ public static class DependencyInjection
}) })
.AddPolicyHandler(GetRetryPolicy()); .AddPolicyHandler(GetRetryPolicy());
// Configure HttpClient for FCA Register API
services.AddHttpClient<FcaRegisterClient>()
.AddPolicyHandler(GetRetryPolicy());
// Configure HttpClient for SRA Register API
services.AddHttpClient<SraRegisterClient>()
.AddPolicyHandler(GetRetryPolicy());
// Configure HttpClient for GitHub API
services.AddHttpClient<GitHubApiClient>()
.AddPolicyHandler(GetRetryPolicy());
// Configure HttpClient for OpenCorporates API
services.AddHttpClient<OpenCorporatesClient>()
.AddPolicyHandler(GetRetryPolicy());
// Configure HttpClient for ORCID API
services.AddHttpClient<OrcidClient>()
.AddPolicyHandler(GetRetryPolicy());
// Register services // Register services
services.AddScoped<ICVParserService, CVParserService>(); services.AddScoped<ICVParserService, CVParserService>();
services.AddScoped<ICompanyNameMatcherService, AICompanyNameMatcherService>(); services.AddScoped<ICompanyNameMatcherService, AICompanyNameMatcherService>();
@@ -98,6 +129,12 @@ public static class DependencyInjection
services.AddScoped<IUserContextService, UserContextService>(); services.AddScoped<IUserContextService, UserContextService>();
services.AddScoped<IAuditService, AuditService>(); services.AddScoped<IAuditService, AuditService>();
// Register additional verification services
services.AddScoped<IProfessionalVerifierService, ProfessionalVerifierService>();
services.AddScoped<IGitHubVerifierService, GitHubVerifierService>();
services.AddScoped<IInternationalCompanyVerifierService, InternationalCompanyVerifierService>();
services.AddScoped<IAcademicVerifierService, AcademicVerifierService>();
// Register file storage - use local storage if configured, otherwise Azure // Register file storage - use local storage if configured, otherwise Azure
var useLocalStorage = configuration.GetValue<bool>("UseLocalStorage"); var useLocalStorage = configuration.GetValue<bool>("UseLocalStorage");
if (useLocalStorage) if (useLocalStorage)

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,16 @@
"ConnectionString": "", "ConnectionString": "",
"ContainerName": "cv-uploads" "ContainerName": "cv-uploads"
}, },
"FcaRegister": {
"ApiKey": "",
"Email": ""
},
"GitHub": {
"PersonalAccessToken": ""
},
"OpenCorporates": {
"ApiToken": ""
},
"Serilog": { "Serilog": {
"MinimumLevel": { "MinimumLevel": {
"Default": "Information", "Default": "Information",