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:
@@ -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; }
|
||||||
|
}
|
||||||
36
src/RealCV.Application/Interfaces/IGitHubVerifierService.cs
Normal file
36
src/RealCV.Application/Interfaces/IGitHubVerifierService.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
62
src/RealCV.Application/Models/AcademicVerificationResult.cs
Normal file
62
src/RealCV.Application/Models/AcademicVerificationResult.cs
Normal 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; }
|
||||||
|
}
|
||||||
52
src/RealCV.Application/Models/GitHubVerificationResult.cs
Normal file
52
src/RealCV.Application/Models/GitHubVerificationResult.cs
Normal 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; }
|
||||||
|
}
|
||||||
30
src/RealCV.Application/Models/InternationalCompanyResult.cs
Normal file
30
src/RealCV.Application/Models/InternationalCompanyResult.cs
Normal 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; } = [];
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
216
src/RealCV.Infrastructure/Clients/FcaRegisterClient.cs
Normal file
216
src/RealCV.Infrastructure/Clients/FcaRegisterClient.cs
Normal 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; }
|
||||||
|
}
|
||||||
265
src/RealCV.Infrastructure/Clients/GitHubClient.cs
Normal file
265
src/RealCV.Infrastructure/Clients/GitHubClient.cs
Normal 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; }
|
||||||
|
}
|
||||||
270
src/RealCV.Infrastructure/Clients/OpenCorporatesClient.cs
Normal file
270
src/RealCV.Infrastructure/Clients/OpenCorporatesClient.cs
Normal 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; }
|
||||||
|
}
|
||||||
342
src/RealCV.Infrastructure/Clients/OrcidClient.cs
Normal file
342
src/RealCV.Infrastructure/Clients/OrcidClient.cs
Normal 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; }
|
||||||
|
}
|
||||||
181
src/RealCV.Infrastructure/Clients/SraRegisterClient.cs
Normal file
181
src/RealCV.Infrastructure/Clients/SraRegisterClient.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
509
src/RealCV.Infrastructure/Services/AcademicVerifierService.cs
Normal file
509
src/RealCV.Infrastructure/Services/AcademicVerifierService.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
275
src/RealCV.Infrastructure/Services/GitHubVerifierService.cs
Normal file
275
src/RealCV.Infrastructure/Services/GitHubVerifierService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user