diff --git a/src/RealCV.Application/Interfaces/IAcademicVerifierService.cs b/src/RealCV.Application/Interfaces/IAcademicVerifierService.cs new file mode 100644 index 0000000..cc30b53 --- /dev/null +++ b/src/RealCV.Application/Interfaces/IAcademicVerifierService.cs @@ -0,0 +1,54 @@ +using RealCV.Application.Models; + +namespace RealCV.Application.Interfaces; + +/// +/// Service for verifying academic researchers via ORCID +/// +public interface IAcademicVerifierService +{ + /// + /// Verify an academic researcher by ORCID ID + /// + Task VerifyByOrcidAsync(string orcidId); + + /// + /// Search for researchers and verify by name + /// + Task VerifyByNameAsync( + string name, + string? affiliation = null); + + /// + /// Search ORCID for researchers + /// + Task> SearchResearchersAsync( + string name, + string? affiliation = null); + + /// + /// Verify claimed publications + /// + Task> VerifyPublicationsAsync( + string orcidId, + List claimedPublications); +} + +public sealed record OrcidSearchResult +{ + public required string OrcidId { get; init; } + public required string Name { get; init; } + public string? OrcidUrl { get; init; } + public List? Affiliations { get; init; } + public int? PublicationCount { get; init; } +} + +public sealed record PublicationVerificationResult +{ + public required string ClaimedTitle { get; init; } + public required bool IsVerified { get; init; } + public string? MatchedTitle { get; init; } + public string? Doi { get; init; } + public int? Year { get; init; } + public string? Notes { get; init; } +} diff --git a/src/RealCV.Application/Interfaces/IGitHubVerifierService.cs b/src/RealCV.Application/Interfaces/IGitHubVerifierService.cs new file mode 100644 index 0000000..75e268e --- /dev/null +++ b/src/RealCV.Application/Interfaces/IGitHubVerifierService.cs @@ -0,0 +1,36 @@ +using RealCV.Application.Models; + +namespace RealCV.Application.Interfaces; + +/// +/// Service for verifying developer profiles and skills via GitHub +/// +public interface IGitHubVerifierService +{ + /// + /// Verify a GitHub profile and analyze activity + /// + Task VerifyProfileAsync(string username); + + /// + /// Verify claimed programming skills against GitHub activity + /// + Task VerifySkillsAsync( + string username, + List claimedSkills); + + /// + /// Search for GitHub profiles matching a name + /// + Task> SearchProfilesAsync(string name); +} + +public sealed record GitHubProfileSearchResult +{ + public required string Username { get; init; } + public string? Name { get; init; } + public string? AvatarUrl { get; init; } + public string? Bio { get; init; } + public int PublicRepos { get; init; } + public int Followers { get; init; } +} diff --git a/src/RealCV.Application/Interfaces/IProfessionalVerifierService.cs b/src/RealCV.Application/Interfaces/IProfessionalVerifierService.cs new file mode 100644 index 0000000..7cb2c04 --- /dev/null +++ b/src/RealCV.Application/Interfaces/IProfessionalVerifierService.cs @@ -0,0 +1,52 @@ +using RealCV.Application.Models; + +namespace RealCV.Application.Interfaces; + +/// +/// Service for verifying professional qualifications (FCA, SRA, etc.) +/// +public interface IProfessionalVerifierService +{ + /// + /// Verify if a person is registered with the FCA + /// + Task VerifyFcaRegistrationAsync( + string name, + string? firmName = null, + string? referenceNumber = null); + + /// + /// Verify if a person is a registered solicitor with the SRA + /// + Task VerifySolicitorAsync( + string name, + string? sraNumber = null, + string? firmName = null); + + /// + /// Search FCA register for individuals + /// + Task> SearchFcaIndividualsAsync(string name); + + /// + /// Search SRA register for solicitors + /// + Task> SearchSolicitorsAsync(string name); +} + +public sealed record FcaIndividualSearchResult +{ + public required string Name { get; init; } + public required string IndividualReferenceNumber { get; init; } + public string? Status { get; init; } + public List? CurrentFirms { get; init; } +} + +public sealed record SraSolicitorSearchResult +{ + public required string Name { get; init; } + public required string SraNumber { get; init; } + public string? Status { get; init; } + public string? CurrentOrganisation { get; init; } + public string? AdmissionDate { get; init; } +} diff --git a/src/RealCV.Application/Models/AcademicVerificationResult.cs b/src/RealCV.Application/Models/AcademicVerificationResult.cs new file mode 100644 index 0000000..b2df17d --- /dev/null +++ b/src/RealCV.Application/Models/AcademicVerificationResult.cs @@ -0,0 +1,62 @@ +namespace RealCV.Application.Models; + +/// +/// Result of verifying an academic researcher via ORCID +/// +public sealed record AcademicVerificationResult +{ + public required string ClaimedName { get; init; } + public required bool IsVerified { get; init; } + + // ORCID profile + public string? OrcidId { get; init; } + public string? MatchedName { get; init; } + public string? OrcidUrl { get; init; } + + // Academic affiliations + public List Affiliations { get; init; } = []; + + // Publications + public int TotalPublications { get; init; } + public List RecentPublications { get; init; } = []; + + // Education from ORCID + public List Education { get; init; } = []; + + public string? VerificationNotes { get; init; } + public List Flags { get; init; } = []; +} + +public sealed record AcademicAffiliation +{ + public required string Organization { get; init; } + public string? Department { get; init; } + public string? Role { get; init; } + public DateOnly? StartDate { get; init; } + public DateOnly? EndDate { get; init; } +} + +public sealed record Publication +{ + public required string Title { get; init; } + public string? Journal { get; init; } + public int? Year { get; init; } + public string? Doi { get; init; } + public string? Type { get; init; } +} + +public sealed record AcademicEducation +{ + public required string Institution { get; init; } + public string? Degree { get; init; } + public string? Subject { get; init; } + public int? Year { get; init; } +} + +public sealed record AcademicVerificationFlag +{ + public required string Type { get; init; } + public required string Severity { get; init; } + public required string Message { get; init; } + public int ScoreImpact { get; init; } +} diff --git a/src/RealCV.Application/Models/GitHubVerificationResult.cs b/src/RealCV.Application/Models/GitHubVerificationResult.cs new file mode 100644 index 0000000..69856de --- /dev/null +++ b/src/RealCV.Application/Models/GitHubVerificationResult.cs @@ -0,0 +1,52 @@ +namespace RealCV.Application.Models; + +/// +/// Result of verifying a developer's GitHub profile +/// +public sealed record GitHubVerificationResult +{ + public required string ClaimedUsername { get; init; } + public required bool IsVerified { get; init; } + + // Profile details + public string? ProfileName { get; init; } + public string? ProfileUrl { get; init; } + public string? Bio { get; init; } + public string? Company { get; init; } + public string? Location { get; init; } + public DateOnly? AccountCreated { get; init; } + + // Activity metrics + public int PublicRepos { get; init; } + public int Followers { get; init; } + public int Following { get; init; } + public int TotalContributions { get; init; } + + // Language breakdown + public Dictionary LanguageStats { get; init; } = new(); + + // Claimed skills verification + public List SkillVerifications { get; init; } = []; + + public string? VerificationNotes { get; init; } + public List Flags { get; init; } = []; +} + +public sealed record SkillVerification +{ + public required string ClaimedSkill { get; init; } + public required bool IsVerified { get; init; } + public int RepoCount { get; init; } + public int TotalLinesOfCode { get; init; } + public DateOnly? FirstUsed { get; init; } + public DateOnly? LastUsed { get; init; } + public string? Notes { get; init; } +} + +public sealed record GitHubVerificationFlag +{ + public required string Type { get; init; } + public required string Severity { get; init; } + public required string Message { get; init; } + public int ScoreImpact { get; init; } +} diff --git a/src/RealCV.Application/Models/ProfessionalVerificationResult.cs b/src/RealCV.Application/Models/ProfessionalVerificationResult.cs new file mode 100644 index 0000000..252ace1 --- /dev/null +++ b/src/RealCV.Application/Models/ProfessionalVerificationResult.cs @@ -0,0 +1,38 @@ +namespace RealCV.Application.Models; + +/// +/// Result of verifying a professional qualification (FCA, SRA, etc.) +/// +public sealed record ProfessionalVerificationResult +{ + public required string ClaimedName { get; init; } + public required string ProfessionalBody { get; init; } + public required bool IsVerified { get; init; } + + // Matched professional details + public string? MatchedName { get; init; } + public string? RegistrationNumber { get; init; } + public string? Status { get; init; } + public string? CurrentEmployer { get; init; } + public DateOnly? RegistrationDate { get; init; } + + // For FCA + public List? ApprovedFunctions { get; init; } + public List? ControlledFunctions { get; init; } + + // For SRA + public string? SolicitorType { get; init; } + public string? AdmissionDate { get; init; } + public string? PractisingCertificateStatus { get; init; } + + public string? VerificationNotes { get; init; } + public List Flags { get; init; } = []; +} + +public sealed record ProfessionalVerificationFlag +{ + public required string Type { get; init; } + public required string Severity { get; init; } + public required string Message { get; init; } + public int ScoreImpact { get; init; } +} diff --git a/src/RealCV.Infrastructure/Clients/FcaRegisterClient.cs b/src/RealCV.Infrastructure/Clients/FcaRegisterClient.cs new file mode 100644 index 0000000..dca9f33 --- /dev/null +++ b/src/RealCV.Infrastructure/Clients/FcaRegisterClient.cs @@ -0,0 +1,216 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace RealCV.Infrastructure.Clients; + +public sealed class FcaRegisterClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly string _apiKey; + + public FcaRegisterClient( + HttpClient httpClient, + IOptions options, + ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + _apiKey = options.Value.ApiKey; + + _httpClient.BaseAddress = new Uri("https://register.fca.org.uk/services/V0.1/"); + _httpClient.DefaultRequestHeaders.Add("X-Auth-Email", options.Value.Email); + _httpClient.DefaultRequestHeaders.Add("X-Auth-Key", _apiKey); + } + + public async Task SearchIndividualsAsync(string name, int page = 1) + { + try + { + var encodedName = Uri.EscapeDataString(name); + var url = $"Individuals?q={encodedName}&page={page}"; + + _logger.LogDebug("Searching FCA for individual: {Name}", name); + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("FCA API returned {StatusCode} for search: {Name}", + response.StatusCode, name); + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching FCA for individual: {Name}", name); + return null; + } + } + + public async Task GetIndividualAsync(string individualReferenceNumber) + { + try + { + var url = $"Individuals/{individualReferenceNumber}"; + + _logger.LogDebug("Getting FCA individual: {IRN}", individualReferenceNumber); + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("FCA API returned {StatusCode} for IRN: {IRN}", + response.StatusCode, individualReferenceNumber); + return null; + } + + var wrapper = await response.Content.ReadFromJsonAsync(JsonOptions); + return wrapper?.Data?.FirstOrDefault(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting FCA individual: {IRN}", individualReferenceNumber); + return null; + } + } + + public async Task SearchFirmsAsync(string name, int page = 1) + { + try + { + var encodedName = Uri.EscapeDataString(name); + var url = $"Firms?q={encodedName}&page={page}"; + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching FCA for firm: {Name}", name); + return null; + } + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +} + +public class FcaOptions +{ + public string ApiKey { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; +} + +// Response models +public class FcaIndividualResponse +{ + public List? Data { get; set; } + public FcaPagination? Pagination { get; set; } +} + +public class FcaIndividualSearchItem +{ + [JsonPropertyName("Individual Reference Number")] + public string? IndividualReferenceNumber { get; set; } + + public string? Name { get; set; } + public string? Status { get; set; } + + [JsonPropertyName("Current Employer(s)")] + public string? CurrentEmployers { get; set; } +} + +public class FcaIndividualDetailsWrapper +{ + public List? Data { get; set; } +} + +public class FcaIndividualDetails +{ + [JsonPropertyName("Individual Reference Number")] + public string? IndividualReferenceNumber { get; set; } + + public string? Name { get; set; } + public string? Status { get; set; } + + [JsonPropertyName("Effective Date")] + public string? EffectiveDate { get; set; } + + [JsonPropertyName("Controlled Functions")] + public List? ControlledFunctions { get; set; } + + [JsonPropertyName("Previous Employments")] + public List? PreviousEmployments { get; set; } +} + +public class FcaControlledFunction +{ + [JsonPropertyName("Controlled Function")] + public string? ControlledFunction { get; set; } + + [JsonPropertyName("Firm Name")] + public string? FirmName { get; set; } + + [JsonPropertyName("Firm Reference Number")] + public string? FirmReferenceNumber { get; set; } + + [JsonPropertyName("Status")] + public string? Status { get; set; } + + [JsonPropertyName("Effective From")] + public string? EffectiveFrom { get; set; } +} + +public class FcaPreviousEmployment +{ + [JsonPropertyName("Firm Name")] + public string? FirmName { get; set; } + + [JsonPropertyName("Firm Reference Number")] + public string? FirmReferenceNumber { get; set; } + + [JsonPropertyName("Start Date")] + public string? StartDate { get; set; } + + [JsonPropertyName("End Date")] + public string? EndDate { get; set; } +} + +public class FcaFirmResponse +{ + public List? Data { get; set; } + public FcaPagination? Pagination { get; set; } +} + +public class FcaFirmSearchItem +{ + [JsonPropertyName("Firm Reference Number")] + public string? FirmReferenceNumber { get; set; } + + [JsonPropertyName("Firm Name")] + public string? FirmName { get; set; } + + public string? Status { get; set; } +} + +public class FcaPagination +{ + public int Page { get; set; } + public int TotalPages { get; set; } + public int TotalItems { get; set; } +} diff --git a/src/RealCV.Infrastructure/Clients/GitHubClient.cs b/src/RealCV.Infrastructure/Clients/GitHubClient.cs new file mode 100644 index 0000000..c3945de --- /dev/null +++ b/src/RealCV.Infrastructure/Clients/GitHubClient.cs @@ -0,0 +1,265 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace RealCV.Infrastructure.Clients; + +public sealed class GitHubApiClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public GitHubApiClient( + HttpClient httpClient, + IOptions options, + ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + + _httpClient.BaseAddress = new Uri("https://api.github.com/"); + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + _httpClient.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28"); + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("RealCV/1.0"); + + if (!string.IsNullOrEmpty(options.Value.PersonalAccessToken)) + { + _httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", options.Value.PersonalAccessToken); + } + } + + public async Task GetUserAsync(string username) + { + try + { + var url = $"users/{Uri.EscapeDataString(username)}"; + + _logger.LogDebug("Getting GitHub user: {Username}", username); + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("GitHub API returned {StatusCode} for user: {Username}", + response.StatusCode, username); + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting GitHub user: {Username}", username); + return null; + } + } + + public async Task> GetUserReposAsync(string username, int perPage = 100) + { + var repos = new List(); + var page = 1; + + try + { + while (true) + { + var url = $"users/{Uri.EscapeDataString(username)}/repos?per_page={perPage}&page={page}&sort=updated"; + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + break; + } + + var pageRepos = await response.Content.ReadFromJsonAsync>(JsonOptions); + + if (pageRepos == null || pageRepos.Count == 0) + { + break; + } + + repos.AddRange(pageRepos); + + if (pageRepos.Count < perPage) + { + break; + } + + page++; + + // Limit to avoid rate limiting + if (page > 5) + { + break; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting repos for user: {Username}", username); + } + + return repos; + } + + public async Task?> GetRepoLanguagesAsync(string owner, string repo) + { + try + { + var url = $"repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(repo)}/languages"; + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadFromJsonAsync>(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting languages for repo: {Owner}/{Repo}", owner, repo); + return null; + } + } + + public async Task SearchUsersAsync(string query, int perPage = 30) + { + try + { + var encodedQuery = Uri.EscapeDataString(query); + var url = $"search/users?q={encodedQuery}&per_page={perPage}"; + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching GitHub users: {Query}", query); + return null; + } + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +} + +public class GitHubOptions +{ + public string PersonalAccessToken { get; set; } = string.Empty; +} + +// Response models +public class GitHubUser +{ + public string? Login { get; set; } + public int Id { get; set; } + public string? Name { get; set; } + public string? Company { get; set; } + public string? Blog { get; set; } + public string? Location { get; set; } + public string? Email { get; set; } + public string? Bio { get; set; } + + [JsonPropertyName("twitter_username")] + public string? TwitterUsername { get; set; } + + [JsonPropertyName("public_repos")] + public int PublicRepos { get; set; } + + [JsonPropertyName("public_gists")] + public int PublicGists { get; set; } + + public int Followers { get; set; } + public int Following { get; set; } + + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } + + [JsonPropertyName("updated_at")] + public DateTime UpdatedAt { get; set; } + + [JsonPropertyName("html_url")] + public string? HtmlUrl { get; set; } + + [JsonPropertyName("avatar_url")] + public string? AvatarUrl { get; set; } +} + +public class GitHubRepo +{ + public int Id { get; set; } + public string? Name { get; set; } + + [JsonPropertyName("full_name")] + public string? FullName { get; set; } + + public string? Description { get; set; } + public string? Language { get; set; } + + [JsonPropertyName("html_url")] + public string? HtmlUrl { get; set; } + + public bool Fork { get; set; } + public bool Private { get; set; } + + [JsonPropertyName("stargazers_count")] + public int StargazersCount { get; set; } + + [JsonPropertyName("watchers_count")] + public int WatchersCount { get; set; } + + [JsonPropertyName("forks_count")] + public int ForksCount { get; set; } + + public int Size { get; set; } + + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } + + [JsonPropertyName("updated_at")] + public DateTime UpdatedAt { get; set; } + + [JsonPropertyName("pushed_at")] + public DateTime? PushedAt { get; set; } +} + +public class GitHubUserSearchResponse +{ + [JsonPropertyName("total_count")] + public int TotalCount { get; set; } + + [JsonPropertyName("incomplete_results")] + public bool IncompleteResults { get; set; } + + public List? Items { get; set; } +} + +public class GitHubUserSearchItem +{ + public string? Login { get; set; } + public int Id { get; set; } + + [JsonPropertyName("avatar_url")] + public string? AvatarUrl { get; set; } + + [JsonPropertyName("html_url")] + public string? HtmlUrl { get; set; } + + public double Score { get; set; } +} diff --git a/src/RealCV.Infrastructure/Clients/OrcidClient.cs b/src/RealCV.Infrastructure/Clients/OrcidClient.cs new file mode 100644 index 0000000..37e3c94 --- /dev/null +++ b/src/RealCV.Infrastructure/Clients/OrcidClient.cs @@ -0,0 +1,342 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace RealCV.Infrastructure.Clients; + +public sealed class OrcidClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public OrcidClient( + HttpClient httpClient, + ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + + _httpClient.BaseAddress = new Uri("https://pub.orcid.org/v3.0/"); + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + } + + public async Task SearchResearchersAsync(string query, int start = 0, int rows = 20) + { + try + { + var encodedQuery = Uri.EscapeDataString(query); + var url = $"search?q={encodedQuery}&start={start}&rows={rows}"; + + _logger.LogDebug("Searching ORCID: {Query}", query); + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("ORCID API returned {StatusCode} for search: {Query}", + response.StatusCode, query); + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching ORCID: {Query}", query); + return null; + } + } + + public async Task GetRecordAsync(string orcidId) + { + try + { + // Normalize ORCID ID format (remove URL prefix if present) + orcidId = NormalizeOrcidId(orcidId); + + var url = $"{orcidId}/record"; + + _logger.LogDebug("Getting ORCID record: {OrcidId}", orcidId); + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("ORCID API returned {StatusCode} for ID: {OrcidId}", + response.StatusCode, orcidId); + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting ORCID record: {OrcidId}", orcidId); + return null; + } + } + + public async Task GetWorksAsync(string orcidId) + { + try + { + orcidId = NormalizeOrcidId(orcidId); + var url = $"{orcidId}/works"; + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting ORCID works: {OrcidId}", orcidId); + return null; + } + } + + public async Task GetEmploymentsAsync(string orcidId) + { + try + { + orcidId = NormalizeOrcidId(orcidId); + var url = $"{orcidId}/employments"; + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting ORCID employments: {OrcidId}", orcidId); + return null; + } + } + + public async Task GetEducationsAsync(string orcidId) + { + try + { + orcidId = NormalizeOrcidId(orcidId); + var url = $"{orcidId}/educations"; + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting ORCID educations: {OrcidId}", orcidId); + return null; + } + } + + private static string NormalizeOrcidId(string orcidId) + { + // Remove URL prefixes + orcidId = orcidId.Replace("https://orcid.org/", "") + .Replace("http://orcid.org/", "") + .Trim(); + return orcidId; + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +} + +// Response models +public class OrcidSearchResponse +{ + [JsonPropertyName("num-found")] + public int NumFound { get; set; } + + public List? Result { get; set; } +} + +public class OrcidSearchResult +{ + [JsonPropertyName("orcid-identifier")] + public OrcidIdentifier? OrcidIdentifier { get; set; } +} + +public class OrcidIdentifier +{ + public string? Uri { get; set; } + public string? Path { get; set; } + public string? Host { get; set; } +} + +public class OrcidRecord +{ + [JsonPropertyName("orcid-identifier")] + public OrcidIdentifier? OrcidIdentifier { get; set; } + + public OrcidPerson? Person { get; set; } + + [JsonPropertyName("activities-summary")] + public OrcidActivitiesSummary? ActivitiesSummary { get; set; } +} + +public class OrcidPerson +{ + public OrcidName? Name { get; set; } + public OrcidBiography? Biography { get; set; } +} + +public class OrcidName +{ + [JsonPropertyName("given-names")] + public OrcidValue? GivenNames { get; set; } + + [JsonPropertyName("family-name")] + public OrcidValue? FamilyName { get; set; } + + [JsonPropertyName("credit-name")] + public OrcidValue? CreditName { get; set; } +} + +public class OrcidValue +{ + public string? Value { get; set; } +} + +public class OrcidBiography +{ + public string? Content { get; set; } +} + +public class OrcidActivitiesSummary +{ + public OrcidEmployments? Employments { get; set; } + public OrcidEducations? Educations { get; set; } + public OrcidWorks? Works { get; set; } +} + +public class OrcidEmployments +{ + [JsonPropertyName("affiliation-group")] + public List? AffiliationGroup { get; set; } +} + +public class OrcidEducations +{ + [JsonPropertyName("affiliation-group")] + public List? AffiliationGroup { get; set; } +} + +public class OrcidAffiliationGroup +{ + public List? Summaries { get; set; } +} + +public class OrcidAffiliationSummaryWrapper +{ + [JsonPropertyName("employment-summary")] + public OrcidAffiliationSummary? EmploymentSummary { get; set; } + + [JsonPropertyName("education-summary")] + public OrcidAffiliationSummary? EducationSummary { get; set; } +} + +public class OrcidAffiliationSummary +{ + [JsonPropertyName("department-name")] + public string? DepartmentName { get; set; } + + [JsonPropertyName("role-title")] + public string? RoleTitle { get; set; } + + [JsonPropertyName("start-date")] + public OrcidDate? StartDate { get; set; } + + [JsonPropertyName("end-date")] + public OrcidDate? EndDate { get; set; } + + public OrcidOrganization? Organization { get; set; } +} + +public class OrcidDate +{ + public OrcidValue? Year { get; set; } + public OrcidValue? Month { get; set; } + public OrcidValue? Day { get; set; } +} + +public class OrcidOrganization +{ + public string? Name { get; set; } + public OrcidAddress? Address { get; set; } +} + +public class OrcidAddress +{ + public string? City { get; set; } + public string? Region { get; set; } + public string? Country { get; set; } +} + +public class OrcidWorks +{ + public List? Group { get; set; } +} + +public class OrcidWorkGroup +{ + [JsonPropertyName("work-summary")] + public List? WorkSummary { get; set; } +} + +public class OrcidWorkSummary +{ + public string? Title { get; set; } + public OrcidTitle? TitleObj { get; set; } + public string? Type { get; set; } + + [JsonPropertyName("publication-date")] + public OrcidDate? PublicationDate { get; set; } + + [JsonPropertyName("journal-title")] + public OrcidValue? JournalTitle { get; set; } + + [JsonPropertyName("external-ids")] + public OrcidExternalIds? ExternalIds { get; set; } +} + +public class OrcidTitle +{ + public OrcidValue? Title { get; set; } +} + +public class OrcidExternalIds +{ + [JsonPropertyName("external-id")] + public List? ExternalId { get; set; } +} + +public class OrcidExternalId +{ + [JsonPropertyName("external-id-type")] + public string? ExternalIdType { get; set; } + + [JsonPropertyName("external-id-value")] + public string? ExternalIdValue { get; set; } +} diff --git a/src/RealCV.Infrastructure/Clients/SraRegisterClient.cs b/src/RealCV.Infrastructure/Clients/SraRegisterClient.cs new file mode 100644 index 0000000..0e17d43 --- /dev/null +++ b/src/RealCV.Infrastructure/Clients/SraRegisterClient.cs @@ -0,0 +1,181 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace RealCV.Infrastructure.Clients; + +public sealed class SraRegisterClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public SraRegisterClient( + HttpClient httpClient, + ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + + _httpClient.BaseAddress = new Uri("https://sra-prod-apim.azure-api.net/"); + } + + public async Task SearchSolicitorsAsync(string name, int page = 1, int pageSize = 20) + { + try + { + var encodedName = Uri.EscapeDataString(name); + var url = $"solicitors/search?name={encodedName}&page={page}&pageSize={pageSize}"; + + _logger.LogDebug("Searching SRA for solicitor: {Name}", name); + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("SRA API returned {StatusCode} for search: {Name}", + response.StatusCode, name); + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching SRA for solicitor: {Name}", name); + return null; + } + } + + public async Task GetSolicitorAsync(string sraNumber) + { + try + { + var url = $"solicitors/{sraNumber}"; + + _logger.LogDebug("Getting SRA solicitor: {SraNumber}", sraNumber); + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("SRA API returned {StatusCode} for SRA number: {SraNumber}", + response.StatusCode, sraNumber); + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting SRA solicitor: {SraNumber}", sraNumber); + return null; + } + } + + public async Task SearchOrganisationsAsync(string name, int page = 1, int pageSize = 20) + { + try + { + var encodedName = Uri.EscapeDataString(name); + var url = $"organisations/search?name={encodedName}&page={page}&pageSize={pageSize}"; + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadFromJsonAsync(JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching SRA for organisation: {Name}", name); + return null; + } + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +} + +// Response models +public class SraSolicitorSearchResponse +{ + public List? Results { get; set; } + public int TotalCount { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } +} + +public class SraSolicitorSearchItem +{ + public string? SraId { get; set; } + public string? Name { get; set; } + public string? Status { get; set; } + public string? Town { get; set; } + public string? AdmissionDate { get; set; } + public string? CurrentOrganisation { get; set; } +} + +public class SraSolicitorDetails +{ + public string? SraId { get; set; } + public string? Forename { get; set; } + public string? MiddleNames { get; set; } + public string? Surname { get; set; } + public string? Status { get; set; } + public string? AdmissionDate { get; set; } + public string? SolicitorType { get; set; } + public string? PractisingCertificateStatus { get; set; } + + public SraCurrentPosition? CurrentPosition { get; set; } + public List? PreviousPositions { get; set; } + public List? DisciplinaryHistory { get; set; } + + public string FullName => string.Join(" ", + new[] { Forename, MiddleNames, Surname }.Where(s => !string.IsNullOrWhiteSpace(s))); +} + +public class SraCurrentPosition +{ + public string? OrganisationName { get; set; } + public string? OrganisationSraId { get; set; } + public string? Role { get; set; } + public string? StartDate { get; set; } +} + +public class SraPreviousPosition +{ + public string? OrganisationName { get; set; } + public string? OrganisationSraId { get; set; } + public string? Role { get; set; } + public string? StartDate { get; set; } + public string? EndDate { get; set; } +} + +public class SraDisciplinaryRecord +{ + public string? DecisionDate { get; set; } + public string? DecisionType { get; set; } + public string? Summary { get; set; } +} + +public class SraOrganisationSearchResponse +{ + public List? Results { get; set; } + public int TotalCount { get; set; } +} + +public class SraOrganisationSearchItem +{ + public string? SraId { get; set; } + public string? Name { get; set; } + public string? Status { get; set; } + public string? Type { get; set; } + public string? Town { get; set; } +} diff --git a/src/RealCV.Infrastructure/DependencyInjection.cs b/src/RealCV.Infrastructure/DependencyInjection.cs index e20020a..1e109f0 100644 --- a/src/RealCV.Infrastructure/DependencyInjection.cs +++ b/src/RealCV.Infrastructure/DependencyInjection.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Polly; using Polly.Extensions.Http; using RealCV.Application.Interfaces; +using RealCV.Infrastructure.Clients; using RealCV.Infrastructure.Configuration; using RealCV.Infrastructure.Data; using RealCV.Infrastructure.ExternalApis; @@ -74,6 +75,13 @@ public static class DependencyInjection services.Configure( configuration.GetSection(LocalStorageSettings.SectionName)); + // Configure options for additional verification APIs + services.Configure( + configuration.GetSection("FcaRegister")); + + services.Configure( + configuration.GetSection("GitHub")); + // Configure HttpClient for CompaniesHouseClient with retry policy services.AddHttpClient((serviceProvider, client) => { @@ -88,6 +96,22 @@ public static class DependencyInjection }) .AddPolicyHandler(GetRetryPolicy()); + // Configure HttpClient for FCA Register API + services.AddHttpClient() + .AddPolicyHandler(GetRetryPolicy()); + + // Configure HttpClient for SRA Register API + services.AddHttpClient() + .AddPolicyHandler(GetRetryPolicy()); + + // Configure HttpClient for GitHub API + services.AddHttpClient() + .AddPolicyHandler(GetRetryPolicy()); + + // Configure HttpClient for ORCID API + services.AddHttpClient() + .AddPolicyHandler(GetRetryPolicy()); + // Register services services.AddScoped(); services.AddScoped(); @@ -98,6 +122,11 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); + // Register additional verification services + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // Register file storage - use local storage if configured, otherwise Azure var useLocalStorage = configuration.GetValue("UseLocalStorage"); if (useLocalStorage) diff --git a/src/RealCV.Infrastructure/Services/AcademicVerifierService.cs b/src/RealCV.Infrastructure/Services/AcademicVerifierService.cs new file mode 100644 index 0000000..747cc07 --- /dev/null +++ b/src/RealCV.Infrastructure/Services/AcademicVerifierService.cs @@ -0,0 +1,509 @@ +using Microsoft.Extensions.Logging; +using RealCV.Application.Interfaces; +using RealCV.Application.Models; +using RealCV.Infrastructure.Clients; + +using InterfaceOrcidSearchResult = RealCV.Application.Interfaces.OrcidSearchResult; + +namespace RealCV.Infrastructure.Services; + +public sealed class AcademicVerifierService : IAcademicVerifierService +{ + private readonly OrcidClient _orcidClient; + private readonly ILogger _logger; + + public AcademicVerifierService( + OrcidClient orcidClient, + ILogger logger) + { + _orcidClient = orcidClient; + _logger = logger; + } + + public async Task VerifyByOrcidAsync(string orcidId) + { + try + { + _logger.LogInformation("Verifying ORCID: {OrcidId}", orcidId); + + var record = await _orcidClient.GetRecordAsync(orcidId); + + if (record == null) + { + return new AcademicVerificationResult + { + ClaimedName = orcidId, + IsVerified = false, + VerificationNotes = "ORCID record not found" + }; + } + + // Extract name + string? name = null; + if (record.Person?.Name != null) + { + var nameParts = new List(); + if (!string.IsNullOrEmpty(record.Person.Name.GivenNames?.Value)) + nameParts.Add(record.Person.Name.GivenNames.Value); + if (!string.IsNullOrEmpty(record.Person.Name.FamilyName?.Value)) + nameParts.Add(record.Person.Name.FamilyName.Value); + name = string.Join(" ", nameParts); + } + + // Get detailed employment information + var affiliations = new List(); + var employments = await _orcidClient.GetEmploymentsAsync(orcidId); + if (employments?.AffiliationGroup != null) + { + affiliations.AddRange(ExtractAffiliations(employments.AffiliationGroup, "employment")); + } + + // Get detailed education information + var educationList = new List(); + var educations = await _orcidClient.GetEducationsAsync(orcidId); + if (educations?.AffiliationGroup != null) + { + educationList.AddRange(ExtractEducations(educations.AffiliationGroup)); + } + + // Get works/publications + var publications = new List(); + var works = await _orcidClient.GetWorksAsync(orcidId); + if (works?.Group != null) + { + publications.AddRange(ExtractPublications(works.Group)); + } + + return new AcademicVerificationResult + { + ClaimedName = name ?? orcidId, + IsVerified = true, + OrcidId = orcidId, + MatchedName = name, + OrcidUrl = record.OrcidIdentifier?.Uri, + Affiliations = affiliations, + TotalPublications = publications.Count, + RecentPublications = publications.Take(10).ToList(), + Education = educationList, + VerificationNotes = BuildVerificationSummary(affiliations, publications.Count, educationList.Count) + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error verifying ORCID: {OrcidId}", orcidId); + return new AcademicVerificationResult + { + ClaimedName = orcidId, + IsVerified = false, + VerificationNotes = $"Error during verification: {ex.Message}" + }; + } + } + + public async Task VerifyByNameAsync( + string name, + string? affiliation = null) + { + try + { + _logger.LogInformation("Searching ORCID for: {Name} at {Affiliation}", + name, affiliation ?? "any affiliation"); + + var query = name; + if (!string.IsNullOrEmpty(affiliation)) + { + query = $"{name} {affiliation}"; + } + + var searchResponse = await _orcidClient.SearchResearchersAsync(query); + + if (searchResponse?.Result == null || searchResponse.Result.Count == 0) + { + return new AcademicVerificationResult + { + ClaimedName = name, + IsVerified = false, + VerificationNotes = "No matching ORCID records found" + }; + } + + // Get the first match's ORCID ID + var firstMatch = searchResponse.Result.First(); + var orcidId = firstMatch.OrcidIdentifier?.Path; + + if (string.IsNullOrEmpty(orcidId)) + { + return new AcademicVerificationResult + { + ClaimedName = name, + IsVerified = false, + VerificationNotes = "Search returned results but no ORCID ID found" + }; + } + + // Get full details + var result = await VerifyByOrcidAsync(orcidId); + + // Update claimed name to the search name + return result with { ClaimedName = name }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching ORCID for: {Name}", name); + return new AcademicVerificationResult + { + ClaimedName = name, + IsVerified = false, + VerificationNotes = $"Error during search: {ex.Message}" + }; + } + } + + public async Task> SearchResearchersAsync( + string name, + string? affiliation = null) + { + try + { + var query = name; + if (!string.IsNullOrEmpty(affiliation)) + { + query = $"{name} {affiliation}"; + } + + var searchResponse = await _orcidClient.SearchResearchersAsync(query, 0, 20); + + if (searchResponse?.Result == null) + { + return []; + } + + var results = new List(); + + foreach (var searchResult in searchResponse.Result.Take(10)) + { + var orcidId = searchResult.OrcidIdentifier?.Path; + if (string.IsNullOrEmpty(orcidId)) + continue; + + var record = await _orcidClient.GetRecordAsync(orcidId); + if (record == null) + continue; + + // Extract name + string? researcherName = null; + if (record.Person?.Name != null) + { + var nameParts = new List(); + if (!string.IsNullOrEmpty(record.Person.Name.GivenNames?.Value)) + nameParts.Add(record.Person.Name.GivenNames.Value); + if (!string.IsNullOrEmpty(record.Person.Name.FamilyName?.Value)) + nameParts.Add(record.Person.Name.FamilyName.Value); + researcherName = string.Join(" ", nameParts); + } + + // Get affiliations + var affiliations = new List(); + if (record.ActivitiesSummary?.Employments?.AffiliationGroup != null) + { + affiliations = record.ActivitiesSummary.Employments.AffiliationGroup + .SelectMany(g => g.Summaries ?? []) + .Select(s => s.EmploymentSummary?.Organization?.Name) + .Where(n => !string.IsNullOrEmpty(n)) + .Distinct() + .Take(5) + .ToList()!; + } + + // Get publication count + var publicationCount = record.ActivitiesSummary?.Works?.Group?.Count ?? 0; + + results.Add(new InterfaceOrcidSearchResult + { + OrcidId = orcidId, + Name = researcherName ?? "Unknown", + OrcidUrl = searchResult.OrcidIdentifier?.Uri, + Affiliations = affiliations, + PublicationCount = publicationCount + }); + } + + return results; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching ORCID for: {Name}", name); + return []; + } + } + + public async Task> VerifyPublicationsAsync( + string orcidId, + List claimedPublications) + { + var results = new List(); + + try + { + var works = await _orcidClient.GetWorksAsync(orcidId); + + if (works?.Group == null) + { + return claimedPublications.Select(title => new PublicationVerificationResult + { + ClaimedTitle = title, + IsVerified = false, + Notes = "Could not retrieve ORCID publications" + }).ToList(); + } + + var orcidPublications = ExtractPublications(works.Group); + + foreach (var claimedTitle in claimedPublications) + { + var match = FindBestPublicationMatch(claimedTitle, orcidPublications); + + if (match != null) + { + results.Add(new PublicationVerificationResult + { + ClaimedTitle = claimedTitle, + IsVerified = true, + MatchedTitle = match.Title, + Doi = match.Doi, + Year = match.Year, + Notes = "Publication found in ORCID record" + }); + } + else + { + results.Add(new PublicationVerificationResult + { + ClaimedTitle = claimedTitle, + IsVerified = false, + Notes = "Publication not found in ORCID record" + }); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error verifying publications for ORCID: {OrcidId}", orcidId); + + return claimedPublications.Select(title => new PublicationVerificationResult + { + ClaimedTitle = title, + IsVerified = false, + Notes = $"Error during verification: {ex.Message}" + }).ToList(); + } + + return results; + } + + private static List ExtractAffiliations( + List groups, + string type) + { + var affiliations = new List(); + + foreach (var group in groups) + { + if (group.Summaries == null) + continue; + + foreach (var wrapper in group.Summaries) + { + var summary = type == "employment" + ? wrapper.EmploymentSummary + : wrapper.EducationSummary; + + if (summary?.Organization?.Name == null) + continue; + + affiliations.Add(new AcademicAffiliation + { + Organization = summary.Organization.Name, + Department = summary.DepartmentName, + Role = summary.RoleTitle, + StartDate = ParseOrcidDate(summary.StartDate), + EndDate = ParseOrcidDate(summary.EndDate) + }); + } + } + + // Sort by start date descending (most recent first) + return affiliations + .OrderByDescending(a => a.StartDate) + .ToList(); + } + + private static List ExtractEducations(List groups) + { + var educations = new List(); + + foreach (var group in groups) + { + if (group.Summaries == null) + continue; + + foreach (var wrapper in group.Summaries) + { + var summary = wrapper.EducationSummary; + if (summary?.Organization?.Name == null) + continue; + + var startYear = summary.StartDate?.Year?.Value; + var endYear = summary.EndDate?.Year?.Value; + + educations.Add(new AcademicEducation + { + Institution = summary.Organization.Name, + Degree = summary.RoleTitle, + Subject = summary.DepartmentName, + Year = !string.IsNullOrEmpty(endYear) + ? int.TryParse(endYear, out var y) ? y : null + : !string.IsNullOrEmpty(startYear) + ? int.TryParse(startYear, out var sy) ? sy : null + : null + }); + } + } + + return educations + .OrderByDescending(e => e.Year) + .ToList(); + } + + private static List ExtractPublications(List groups) + { + var publications = new List(); + + foreach (var group in groups) + { + if (group.WorkSummary == null || group.WorkSummary.Count == 0) + continue; + + // Take the first work summary from each group (they're typically duplicates) + var work = group.WorkSummary.First(); + + var title = work.TitleObj?.Title?.Value ?? work.Title; + if (string.IsNullOrEmpty(title)) + continue; + + // Extract DOI if available + string? doi = null; + if (work.ExternalIds?.ExternalId != null) + { + var doiEntry = work.ExternalIds.ExternalId + .FirstOrDefault(e => e.ExternalIdType?.Equals("doi", StringComparison.OrdinalIgnoreCase) == true); + doi = doiEntry?.ExternalIdValue; + } + + // Parse year + int? year = null; + if (!string.IsNullOrEmpty(work.PublicationDate?.Year?.Value) && + int.TryParse(work.PublicationDate.Year.Value, out var y)) + { + year = y; + } + + publications.Add(new Publication + { + Title = title, + Journal = work.JournalTitle?.Value, + Year = year, + Doi = doi, + Type = work.Type + }); + } + + // Sort by publication year descending + return publications + .OrderByDescending(p => p.Year) + .ToList(); + } + + private static DateOnly? ParseOrcidDate(OrcidDate? date) + { + if (date?.Year?.Value == null) + return null; + + if (!int.TryParse(date.Year.Value, out var year)) + return null; + + var month = 1; + if (!string.IsNullOrEmpty(date.Month?.Value) && int.TryParse(date.Month.Value, out var m)) + month = m; + + var day = 1; + if (!string.IsNullOrEmpty(date.Day?.Value) && int.TryParse(date.Day.Value, out var d)) + day = d; + + try + { + return new DateOnly(year, month, day); + } + catch + { + return new DateOnly(year, 1, 1); + } + } + + private static Publication? FindBestPublicationMatch(string claimedTitle, List publications) + { + var normalizedClaimed = NormalizeTitle(claimedTitle); + + // First try exact match + var exactMatch = publications.FirstOrDefault(p => + NormalizeTitle(p.Title).Equals(normalizedClaimed, StringComparison.OrdinalIgnoreCase)); + + if (exactMatch != null) + return exactMatch; + + // Then try contains match + var containsMatch = publications.FirstOrDefault(p => + NormalizeTitle(p.Title).Contains(normalizedClaimed, StringComparison.OrdinalIgnoreCase) || + normalizedClaimed.Contains(NormalizeTitle(p.Title), StringComparison.OrdinalIgnoreCase)); + + return containsMatch; + } + + private static string NormalizeTitle(string title) + { + return title + .ToLowerInvariant() + .Replace(":", " ") + .Replace("-", " ") + .Replace(" ", " ") + .Trim(); + } + + private static string BuildVerificationSummary(List affiliations, int publicationCount, int educationCount) + { + var parts = new List(); + + var currentAffiliation = affiliations.FirstOrDefault(a => !a.EndDate.HasValue); + if (currentAffiliation != null) + { + parts.Add($"Current: {currentAffiliation.Organization}"); + } + + if (publicationCount > 0) + { + parts.Add($"Publications: {publicationCount}"); + } + + if (affiliations.Count > 0) + { + parts.Add($"Positions: {affiliations.Count}"); + } + + if (educationCount > 0) + { + parts.Add($"Education: {educationCount}"); + } + + return parts.Count > 0 ? string.Join(" | ", parts) : "ORCID record verified"; + } +} diff --git a/src/RealCV.Infrastructure/Services/GitHubVerifierService.cs b/src/RealCV.Infrastructure/Services/GitHubVerifierService.cs new file mode 100644 index 0000000..df47941 --- /dev/null +++ b/src/RealCV.Infrastructure/Services/GitHubVerifierService.cs @@ -0,0 +1,275 @@ +using Microsoft.Extensions.Logging; +using RealCV.Application.Interfaces; +using RealCV.Application.Models; +using RealCV.Infrastructure.Clients; + +namespace RealCV.Infrastructure.Services; + +public sealed class GitHubVerifierService : IGitHubVerifierService +{ + private readonly GitHubApiClient _gitHubClient; + private readonly ILogger _logger; + + // Map common skill names to GitHub languages + private static readonly Dictionary SkillToLanguageMap = new(StringComparer.OrdinalIgnoreCase) + { + ["JavaScript"] = ["JavaScript", "TypeScript"], + ["TypeScript"] = ["TypeScript"], + ["Python"] = ["Python"], + ["Java"] = ["Java", "Kotlin"], + ["C#"] = ["C#"], + [".NET"] = ["C#", "F#"], + ["React"] = ["JavaScript", "TypeScript"], + ["Angular"] = ["TypeScript", "JavaScript"], + ["Vue"] = ["JavaScript", "TypeScript", "Vue"], + ["Node.js"] = ["JavaScript", "TypeScript"], + ["Go"] = ["Go"], + ["Golang"] = ["Go"], + ["Rust"] = ["Rust"], + ["Ruby"] = ["Ruby"], + ["PHP"] = ["PHP"], + ["Swift"] = ["Swift"], + ["Kotlin"] = ["Kotlin"], + ["C++"] = ["C++", "C"], + ["C"] = ["C"], + ["Scala"] = ["Scala"], + ["R"] = ["R"], + ["SQL"] = ["PLSQL", "TSQL"], + ["Shell"] = ["Shell", "Bash", "PowerShell"], + ["DevOps"] = ["Shell", "Dockerfile", "HCL"], + ["Docker"] = ["Dockerfile"], + ["Terraform"] = ["HCL"], + ["Mobile"] = ["Swift", "Kotlin", "Dart", "Java"], + ["iOS"] = ["Swift", "Objective-C"], + ["Android"] = ["Kotlin", "Java"], + ["Flutter"] = ["Dart"], + ["Machine Learning"] = ["Python", "Jupyter Notebook", "R"], + ["Data Science"] = ["Python", "Jupyter Notebook", "R"], + }; + + public GitHubVerifierService( + GitHubApiClient gitHubClient, + ILogger logger) + { + _gitHubClient = gitHubClient; + _logger = logger; + } + + public async Task VerifyProfileAsync(string username) + { + try + { + _logger.LogInformation("Verifying GitHub profile: {Username}", username); + + var user = await _gitHubClient.GetUserAsync(username); + + if (user == null) + { + return new GitHubVerificationResult + { + ClaimedUsername = username, + IsVerified = false, + VerificationNotes = "GitHub profile not found" + }; + } + + // Get repositories for language analysis + var repos = await _gitHubClient.GetUserReposAsync(username); + + // Analyze languages + var languageStats = new Dictionary(); + foreach (var repo in repos.Where(r => !r.Fork && !string.IsNullOrEmpty(r.Language))) + { + if (!languageStats.ContainsKey(repo.Language!)) + languageStats[repo.Language!] = 0; + languageStats[repo.Language!]++; + } + + // Calculate flags + var flags = new List(); + + // Check account age + var accountAge = DateTime.UtcNow - user.CreatedAt; + if (accountAge.TotalDays < 90) + { + flags.Add(new GitHubVerificationFlag + { + Type = "NewAccount", + Severity = "Info", + Message = "Account created less than 90 days ago", + ScoreImpact = -5 + }); + } + + // Check for empty profile + if (user.PublicRepos == 0) + { + flags.Add(new GitHubVerificationFlag + { + Type = "NoRepos", + Severity = "Warning", + Message = "No public repositories", + ScoreImpact = -10 + }); + } + + return new GitHubVerificationResult + { + ClaimedUsername = username, + IsVerified = true, + ProfileName = user.Name, + ProfileUrl = user.HtmlUrl, + Bio = user.Bio, + Company = user.Company, + Location = user.Location, + AccountCreated = user.CreatedAt != default + ? DateOnly.FromDateTime(user.CreatedAt) + : null, + PublicRepos = user.PublicRepos, + Followers = user.Followers, + Following = user.Following, + LanguageStats = languageStats, + VerificationNotes = BuildVerificationSummary(user, repos), + Flags = flags + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error verifying GitHub profile: {Username}", username); + return new GitHubVerificationResult + { + ClaimedUsername = username, + IsVerified = false, + VerificationNotes = $"Error during verification: {ex.Message}" + }; + } + } + + public async Task VerifySkillsAsync( + string username, + List claimedSkills) + { + var result = await VerifyProfileAsync(username); + + if (!result.IsVerified) + return result; + + var skillVerifications = new List(); + + foreach (var skill in claimedSkills) + { + var verified = false; + var repoCount = 0; + + // Check if the skill matches a known language directly + if (result.LanguageStats.TryGetValue(skill, out var count)) + { + verified = true; + repoCount = count; + } + else if (SkillToLanguageMap.TryGetValue(skill, out var mappedLanguages)) + { + // Check if any mapped language exists in the user's repos + foreach (var lang in mappedLanguages) + { + if (result.LanguageStats.TryGetValue(lang, out var langCount)) + { + verified = true; + repoCount += langCount; + } + } + } + + skillVerifications.Add(new SkillVerification + { + ClaimedSkill = skill, + IsVerified = verified, + RepoCount = repoCount, + Notes = verified + ? $"Found in {repoCount} repositories" + : "No repositories found using this skill" + }); + } + + var verifiedCount = skillVerifications.Count(sv => sv.IsVerified); + var totalCount = skillVerifications.Count; + var percentage = totalCount > 0 ? (verifiedCount * 100) / totalCount : 0; + + return result with + { + SkillVerifications = skillVerifications, + VerificationNotes = $"Skills verified: {verifiedCount}/{totalCount} ({percentage}%)" + }; + } + + public async Task> SearchProfilesAsync(string name) + { + try + { + var searchResponse = await _gitHubClient.SearchUsersAsync(name); + + if (searchResponse?.Items == null) + { + return []; + } + + var results = new List(); + + foreach (var item in searchResponse.Items.Take(10)) + { + if (string.IsNullOrEmpty(item.Login)) + continue; + + // Get full profile details + var user = await _gitHubClient.GetUserAsync(item.Login); + + results.Add(new GitHubProfileSearchResult + { + Username = item.Login, + Name = user?.Name, + AvatarUrl = item.AvatarUrl, + Bio = user?.Bio, + PublicRepos = user?.PublicRepos ?? 0, + Followers = user?.Followers ?? 0 + }); + } + + return results; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching GitHub profiles: {Name}", name); + return []; + } + } + + private static string BuildVerificationSummary(GitHubUser user, List repos) + { + var parts = new List + { + $"Account created: {user.CreatedAt:yyyy-MM-dd}", + $"Public repos: {user.PublicRepos}", + $"Followers: {user.Followers}" + }; + + var totalStars = repos.Sum(r => r.StargazersCount); + if (totalStars > 0) + { + parts.Add($"Total stars: {totalStars}"); + } + + var topLanguages = repos + .Where(r => !r.Fork && !string.IsNullOrEmpty(r.Language)) + .GroupBy(r => r.Language) + .OrderByDescending(g => g.Count()) + .Take(3) + .Select(g => g.Key); + + if (topLanguages.Any()) + { + parts.Add($"Top languages: {string.Join(", ", topLanguages)}"); + } + + return string.Join(" | ", parts); + } +} diff --git a/src/RealCV.Infrastructure/Services/ProfessionalVerifierService.cs b/src/RealCV.Infrastructure/Services/ProfessionalVerifierService.cs new file mode 100644 index 0000000..4d16928 --- /dev/null +++ b/src/RealCV.Infrastructure/Services/ProfessionalVerifierService.cs @@ -0,0 +1,395 @@ +using Microsoft.Extensions.Logging; +using RealCV.Application.Interfaces; +using RealCV.Application.Models; +using RealCV.Infrastructure.Clients; + +namespace RealCV.Infrastructure.Services; + +public sealed class ProfessionalVerifierService : IProfessionalVerifierService +{ + private readonly FcaRegisterClient _fcaClient; + private readonly SraRegisterClient _sraClient; + private readonly ILogger _logger; + + public ProfessionalVerifierService( + FcaRegisterClient fcaClient, + SraRegisterClient sraClient, + ILogger logger) + { + _fcaClient = fcaClient; + _sraClient = sraClient; + _logger = logger; + } + + public async Task VerifyFcaRegistrationAsync( + string name, + string? firmName = null, + string? referenceNumber = null) + { + try + { + _logger.LogInformation("Verifying FCA registration for: {Name}", name); + + // If we have a reference number, try to get directly + if (!string.IsNullOrEmpty(referenceNumber)) + { + var details = await _fcaClient.GetIndividualAsync(referenceNumber); + if (details != null) + { + var isNameMatch = IsNameMatch(name, details.Name); + return new ProfessionalVerificationResult + { + ClaimedName = name, + ProfessionalBody = "FCA", + IsVerified = isNameMatch, + RegistrationNumber = details.IndividualReferenceNumber, + MatchedName = details.Name, + Status = details.Status, + RegistrationDate = ParseDate(details.EffectiveDate), + ControlledFunctions = details.ControlledFunctions? + .Where(cf => cf.Status?.Equals("Active", StringComparison.OrdinalIgnoreCase) == true) + .Select(cf => cf.ControlledFunction ?? "Unknown") + .ToList(), + VerificationNotes = isNameMatch + ? $"FCA Individual Reference Number: {details.IndividualReferenceNumber}" + : "Reference number found but name does not match" + }; + } + } + + // Search by name + var searchResponse = await _fcaClient.SearchIndividualsAsync(name); + + if (searchResponse?.Data == null || searchResponse.Data.Count == 0) + { + return new ProfessionalVerificationResult + { + ClaimedName = name, + ProfessionalBody = "FCA", + IsVerified = false, + VerificationNotes = "No matching FCA registered individuals found" + }; + } + + // Find best match + var matches = searchResponse.Data + .Where(i => IsNameMatch(name, i.Name)) + .ToList(); + + if (matches.Count == 0) + { + return new ProfessionalVerificationResult + { + ClaimedName = name, + ProfessionalBody = "FCA", + IsVerified = false, + VerificationNotes = $"Found {searchResponse.Data.Count} results but no close name matches" + }; + } + + // If firm specified, try to match on that too + FcaIndividualSearchItem? bestMatch = null; + if (!string.IsNullOrEmpty(firmName)) + { + bestMatch = matches.FirstOrDefault(m => + m.CurrentEmployers?.Contains(firmName, StringComparison.OrdinalIgnoreCase) == true); + } + + bestMatch ??= matches.First(); + + // Get detailed information + if (!string.IsNullOrEmpty(bestMatch.IndividualReferenceNumber)) + { + var details = await _fcaClient.GetIndividualAsync(bestMatch.IndividualReferenceNumber); + if (details != null) + { + return new ProfessionalVerificationResult + { + ClaimedName = name, + ProfessionalBody = "FCA", + IsVerified = true, + RegistrationNumber = details.IndividualReferenceNumber, + MatchedName = details.Name, + Status = details.Status, + RegistrationDate = ParseDate(details.EffectiveDate), + CurrentEmployer = bestMatch.CurrentEmployers, + ControlledFunctions = details.ControlledFunctions? + .Where(cf => cf.Status?.Equals("Active", StringComparison.OrdinalIgnoreCase) == true) + .Select(cf => cf.ControlledFunction ?? "Unknown") + .ToList(), + VerificationNotes = $"FCA Individual Reference Number: {details.IndividualReferenceNumber}" + }; + } + } + + // Basic verification without details + return new ProfessionalVerificationResult + { + ClaimedName = name, + ProfessionalBody = "FCA", + IsVerified = true, + RegistrationNumber = bestMatch.IndividualReferenceNumber, + MatchedName = bestMatch.Name, + Status = bestMatch.Status, + CurrentEmployer = bestMatch.CurrentEmployers, + VerificationNotes = "Verified via FCA Register search" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error verifying FCA registration for: {Name}", name); + return new ProfessionalVerificationResult + { + ClaimedName = name, + ProfessionalBody = "FCA", + IsVerified = false, + VerificationNotes = $"Error during verification: {ex.Message}" + }; + } + } + + public async Task VerifySolicitorAsync( + string name, + string? sraNumber = null, + string? firmName = null) + { + try + { + _logger.LogInformation("Verifying SRA registration for: {Name}", name); + + // If we have an SRA number, try to get directly + if (!string.IsNullOrEmpty(sraNumber)) + { + var details = await _sraClient.GetSolicitorAsync(sraNumber); + if (details != null) + { + var isNameMatch = IsNameMatch(name, details.FullName); + + return new ProfessionalVerificationResult + { + ClaimedName = name, + ProfessionalBody = "SRA", + IsVerified = isNameMatch, + RegistrationNumber = details.SraId, + MatchedName = details.FullName, + Status = details.Status, + AdmissionDate = details.AdmissionDate, + SolicitorType = details.SolicitorType, + PractisingCertificateStatus = details.PractisingCertificateStatus, + CurrentEmployer = details.CurrentPosition?.OrganisationName, + VerificationNotes = isNameMatch + ? $"SRA ID: {details.SraId}" + : "SRA number found but name does not match", + Flags = details.DisciplinaryHistory?.Count > 0 + ? [new ProfessionalVerificationFlag + { + Type = "DisciplinaryRecord", + Severity = "Warning", + Message = $"Has {details.DisciplinaryHistory.Count} disciplinary record(s)", + ScoreImpact = -20 + }] + : [] + }; + } + } + + // Search by name + var searchResponse = await _sraClient.SearchSolicitorsAsync(name); + + if (searchResponse?.Results == null || searchResponse.Results.Count == 0) + { + return new ProfessionalVerificationResult + { + ClaimedName = name, + ProfessionalBody = "SRA", + IsVerified = false, + VerificationNotes = "No matching SRA registered solicitors found" + }; + } + + // Find best match + var matches = searchResponse.Results + .Where(s => IsNameMatch(name, s.Name)) + .ToList(); + + if (matches.Count == 0) + { + return new ProfessionalVerificationResult + { + ClaimedName = name, + ProfessionalBody = "SRA", + IsVerified = false, + VerificationNotes = $"Found {searchResponse.Results.Count} results but no close name matches" + }; + } + + // If firm specified, try to match on that too + SraSolicitorSearchItem? bestMatch = null; + if (!string.IsNullOrEmpty(firmName)) + { + bestMatch = matches.FirstOrDefault(m => + m.CurrentOrganisation?.Contains(firmName, StringComparison.OrdinalIgnoreCase) == true); + } + + bestMatch ??= matches.First(); + + // Get detailed information + if (!string.IsNullOrEmpty(bestMatch.SraId)) + { + var details = await _sraClient.GetSolicitorAsync(bestMatch.SraId); + if (details != null) + { + return new ProfessionalVerificationResult + { + ClaimedName = name, + ProfessionalBody = "SRA", + IsVerified = true, + RegistrationNumber = details.SraId, + MatchedName = details.FullName, + Status = details.Status, + AdmissionDate = details.AdmissionDate, + SolicitorType = details.SolicitorType, + PractisingCertificateStatus = details.PractisingCertificateStatus, + CurrentEmployer = details.CurrentPosition?.OrganisationName, + VerificationNotes = $"SRA ID: {details.SraId}", + Flags = details.DisciplinaryHistory?.Count > 0 + ? [new ProfessionalVerificationFlag + { + Type = "DisciplinaryRecord", + Severity = "Warning", + Message = $"Has {details.DisciplinaryHistory.Count} disciplinary record(s)", + ScoreImpact = -20 + }] + : [] + }; + } + } + + // Basic verification without details + return new ProfessionalVerificationResult + { + ClaimedName = name, + ProfessionalBody = "SRA", + IsVerified = true, + RegistrationNumber = bestMatch.SraId, + MatchedName = bestMatch.Name, + Status = bestMatch.Status, + AdmissionDate = bestMatch.AdmissionDate, + CurrentEmployer = bestMatch.CurrentOrganisation, + VerificationNotes = "Verified via SRA Register search" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error verifying SRA registration for: {Name}", name); + return new ProfessionalVerificationResult + { + ClaimedName = name, + ProfessionalBody = "SRA", + IsVerified = false, + VerificationNotes = $"Error during verification: {ex.Message}" + }; + } + } + + public async Task> SearchFcaIndividualsAsync(string name) + { + try + { + var searchResponse = await _fcaClient.SearchIndividualsAsync(name); + + if (searchResponse?.Data == null) + { + return []; + } + + return searchResponse.Data + .Select(i => new FcaIndividualSearchResult + { + Name = i.Name ?? "Unknown", + IndividualReferenceNumber = i.IndividualReferenceNumber ?? "Unknown", + Status = i.Status, + CurrentFirms = i.CurrentEmployers?.Split(',') + .Select(f => f.Trim()) + .Where(f => !string.IsNullOrEmpty(f)) + .ToList() + }) + .ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching FCA for: {Name}", name); + return []; + } + } + + public async Task> SearchSolicitorsAsync(string name) + { + try + { + var searchResponse = await _sraClient.SearchSolicitorsAsync(name); + + if (searchResponse?.Results == null) + { + return []; + } + + return searchResponse.Results + .Select(s => new SraSolicitorSearchResult + { + Name = s.Name ?? "Unknown", + SraNumber = s.SraId ?? "Unknown", + Status = s.Status, + CurrentOrganisation = s.CurrentOrganisation, + AdmissionDate = s.AdmissionDate + }) + .ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching SRA for: {Name}", name); + return []; + } + } + + private static bool IsNameMatch(string searchName, string? foundName) + { + if (string.IsNullOrEmpty(foundName)) + return false; + + var searchNormalized = NormalizeName(searchName); + var foundNormalized = NormalizeName(foundName); + + // Exact match + if (searchNormalized.Equals(foundNormalized, StringComparison.OrdinalIgnoreCase)) + return true; + + // Check if all parts of search name are in found name + var searchParts = searchNormalized.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var foundParts = foundNormalized.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + // All search parts must be found + return searchParts.All(sp => + foundParts.Any(fp => fp.Equals(sp, StringComparison.OrdinalIgnoreCase))); + } + + private static string NormalizeName(string name) + { + return name + .Replace(",", " ") + .Replace(".", " ") + .Replace("-", " ") + .Trim(); + } + + private static DateOnly? ParseDate(string? dateString) + { + if (string.IsNullOrEmpty(dateString)) + return null; + + if (DateOnly.TryParse(dateString, out var date)) + return date; + + return null; + } +} diff --git a/src/RealCV.Web/appsettings.json b/src/RealCV.Web/appsettings.json index fb12798..272bc79 100644 --- a/src/RealCV.Web/appsettings.json +++ b/src/RealCV.Web/appsettings.json @@ -18,6 +18,13 @@ "ConnectionString": "", "ContainerName": "cv-uploads" }, + "FcaRegister": { + "ApiKey": "", + "Email": "" + }, + "GitHub": { + "PersonalAccessToken": "" + }, "Serilog": { "MinimumLevel": { "Default": "Information",