Clean architecture solution with: - Domain: Entities (User, CVCheck, CVFlag, CompanyCache) and Enums - Application: Service interfaces, DTOs, and models - Infrastructure: EF Core, Identity, Hangfire, external API clients, services - Web: Blazor Server UI with pages and components Features: - CV upload and parsing (PDF/DOCX) using Claude API - Employment verification against Companies House API - Timeline analysis for gaps and overlaps - Veracity scoring algorithm - Background job processing with Hangfire - Azure Blob Storage for file storage Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
170 lines
6.0 KiB
C#
170 lines
6.0 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using TrueCV.Application.DTOs;
|
|
using TrueCV.Infrastructure.Configuration;
|
|
|
|
namespace TrueCV.Infrastructure.ExternalApis;
|
|
|
|
public sealed class CompaniesHouseClient
|
|
{
|
|
private readonly HttpClient _httpClient;
|
|
private readonly ILogger<CompaniesHouseClient> _logger;
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
public CompaniesHouseClient(
|
|
HttpClient httpClient,
|
|
IOptions<CompaniesHouseSettings> settings,
|
|
ILogger<CompaniesHouseClient> logger)
|
|
{
|
|
_httpClient = httpClient;
|
|
_logger = logger;
|
|
|
|
var apiKey = settings.Value.ApiKey;
|
|
var authValue = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{apiKey}:"));
|
|
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authValue);
|
|
_httpClient.BaseAddress = new Uri(settings.Value.BaseUrl);
|
|
}
|
|
|
|
public async Task<CompaniesHouseSearchResponse?> SearchCompaniesAsync(
|
|
string query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(query);
|
|
|
|
var encodedQuery = Uri.EscapeDataString(query);
|
|
var requestUrl = $"/search/companies?q={encodedQuery}";
|
|
|
|
_logger.LogDebug("Searching Companies House for: {Query}", query);
|
|
|
|
try
|
|
{
|
|
var response = await _httpClient.GetAsync(requestUrl, cancellationToken);
|
|
|
|
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
|
{
|
|
_logger.LogWarning("Rate limit exceeded for Companies House API");
|
|
throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.");
|
|
}
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var result = await response.Content.ReadFromJsonAsync<CompaniesHouseSearchResponse>(
|
|
JsonOptions,
|
|
cancellationToken);
|
|
|
|
_logger.LogDebug("Found {Count} companies matching query: {Query}",
|
|
result?.Items?.Count ?? 0, query);
|
|
|
|
return result;
|
|
}
|
|
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
|
|
{
|
|
_logger.LogWarning("Rate limit exceeded for Companies House API");
|
|
throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.", ex);
|
|
}
|
|
}
|
|
|
|
public async Task<CompaniesHouseCompany?> GetCompanyAsync(
|
|
string companyNumber,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(companyNumber);
|
|
|
|
var requestUrl = $"/company/{companyNumber}";
|
|
|
|
_logger.LogDebug("Fetching company details for: {CompanyNumber}", companyNumber);
|
|
|
|
try
|
|
{
|
|
var response = await _httpClient.GetAsync(requestUrl, cancellationToken);
|
|
|
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
{
|
|
_logger.LogDebug("Company not found: {CompanyNumber}", companyNumber);
|
|
return null;
|
|
}
|
|
|
|
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
|
{
|
|
_logger.LogWarning("Rate limit exceeded for Companies House API");
|
|
throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.");
|
|
}
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var result = await response.Content.ReadFromJsonAsync<CompaniesHouseCompany>(
|
|
JsonOptions,
|
|
cancellationToken);
|
|
|
|
_logger.LogDebug("Retrieved company: {CompanyName} ({CompanyNumber})",
|
|
result?.CompanyName, companyNumber);
|
|
|
|
return result;
|
|
}
|
|
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
|
|
{
|
|
_logger.LogWarning("Rate limit exceeded for Companies House API");
|
|
throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.", ex);
|
|
}
|
|
}
|
|
}
|
|
|
|
// DTOs for Companies House API responses
|
|
public sealed record CompaniesHouseSearchResponse
|
|
{
|
|
public int TotalResults { get; init; }
|
|
public int ItemsPerPage { get; init; }
|
|
public int StartIndex { get; init; }
|
|
public List<CompaniesHouseSearchItem> Items { get; init; } = [];
|
|
}
|
|
|
|
public sealed record CompaniesHouseSearchItem
|
|
{
|
|
public required string CompanyNumber { get; init; }
|
|
public required string Title { get; init; }
|
|
public string? CompanyStatus { get; init; }
|
|
public string? CompanyType { get; init; }
|
|
public string? DateOfCreation { get; init; }
|
|
public string? DateOfCessation { get; init; }
|
|
public CompaniesHouseAddress? Address { get; init; }
|
|
public string? AddressSnippet { get; init; }
|
|
}
|
|
|
|
public sealed record CompaniesHouseCompany
|
|
{
|
|
public required string CompanyNumber { get; init; }
|
|
public required string CompanyName { get; init; }
|
|
public string? CompanyStatus { get; init; }
|
|
public string? Type { get; init; }
|
|
public string? DateOfCreation { get; init; }
|
|
public string? DateOfCessation { get; init; }
|
|
public CompaniesHouseAddress? RegisteredOfficeAddress { get; init; }
|
|
}
|
|
|
|
public sealed record CompaniesHouseAddress
|
|
{
|
|
public string? Premises { get; init; }
|
|
public string? AddressLine1 { get; init; }
|
|
public string? AddressLine2 { get; init; }
|
|
public string? Locality { get; init; }
|
|
public string? Region { get; init; }
|
|
public string? PostalCode { get; init; }
|
|
public string? Country { get; init; }
|
|
}
|
|
|
|
public class CompaniesHouseRateLimitException : Exception
|
|
{
|
|
public CompaniesHouseRateLimitException(string message) : base(message) { }
|
|
public CompaniesHouseRateLimitException(string message, Exception innerException) : base(message, innerException) { }
|
|
}
|