diff --git a/src/TrueCV.Application/Helpers/DateHelpers.cs b/src/TrueCV.Application/Helpers/DateHelpers.cs index 78bb122..22aa49c 100644 --- a/src/TrueCV.Application/Helpers/DateHelpers.cs +++ b/src/TrueCV.Application/Helpers/DateHelpers.cs @@ -2,6 +2,22 @@ namespace TrueCV.Application.Helpers; public static class DateHelpers { + /// + /// Calculates the number of months between two dates. + /// + public static int MonthsBetween(DateOnly start, DateOnly end) + { + return ((end.Year - start.Year) * 12) + (end.Month - start.Month); + } + + /// + /// Calculates the number of months between two dates. + /// + public static int MonthsBetween(DateTime start, DateTime end) + { + return ((end.Year - start.Year) * 12) + (end.Month - start.Month); + } + public static DateOnly? ParseDate(string? dateString) { if (string.IsNullOrWhiteSpace(dateString)) diff --git a/src/TrueCV.Infrastructure/Helpers/JsonResponseHelper.cs b/src/TrueCV.Infrastructure/Helpers/JsonResponseHelper.cs new file mode 100644 index 0000000..8329068 --- /dev/null +++ b/src/TrueCV.Infrastructure/Helpers/JsonResponseHelper.cs @@ -0,0 +1,32 @@ +namespace TrueCV.Infrastructure.Helpers; + +/// +/// Helper methods for processing AI/LLM JSON responses. +/// +public static class JsonResponseHelper +{ + /// + /// Cleans a JSON response by removing markdown code block formatting. + /// + public static string CleanJsonResponse(string response) + { + var trimmed = response.Trim(); + + // Remove markdown code blocks + if (trimmed.StartsWith("```json", StringComparison.OrdinalIgnoreCase)) + { + trimmed = trimmed[7..]; + } + else if (trimmed.StartsWith("```")) + { + trimmed = trimmed[3..]; + } + + if (trimmed.EndsWith("```")) + { + trimmed = trimmed[..^3]; + } + + return trimmed.Trim(); + } +} diff --git a/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs b/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs index a77986e..3b49ef3 100644 --- a/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs +++ b/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs @@ -745,8 +745,7 @@ public sealed class ProcessCVCheckJob if (seniorityJump >= 3) { // Calculate time between roles - var monthsBetween = ((currRole.StartDate!.Value.Year - prevRole.StartDate!.Value.Year) * 12) + - (currRole.StartDate!.Value.Month - prevRole.StartDate!.Value.Month); + var monthsBetween = DateHelpers.MonthsBetween(prevRole.StartDate!.Value, currRole.StartDate!.Value); // If jumped 3+ levels in less than 2 years, flag it if (monthsBetween < 24) @@ -788,8 +787,7 @@ public sealed class ProcessCVCheckJob foreach (var emp in employment.Where(e => e.StartDate.HasValue)) { - var monthsAfterEducation = ((emp.StartDate!.Value.Year - latestEducationEnd.Year) * 12) + - (emp.StartDate!.Value.Month - latestEducationEnd.Month); + var monthsAfterEducation = DateHelpers.MonthsBetween(latestEducationEnd, emp.StartDate!.Value); // Check if this is a senior role started within 2 years of finishing education if (monthsAfterEducation < 24 && monthsAfterEducation >= 0) @@ -835,8 +833,7 @@ public sealed class ProcessCVCheckJob if (role.StartDate.HasValue) { var endDate = role.EndDate ?? DateOnly.FromDateTime(DateTime.Today); - var months = ((endDate.Year - role.StartDate.Value.Year) * 12) + - (endDate.Month - role.StartDate.Value.Month); + var months = DateHelpers.MonthsBetween(role.StartDate.Value, endDate); totalMonths += Math.Max(0, months); } } @@ -953,7 +950,7 @@ public sealed class ProcessCVCheckJob .Select(e => e.EndDate ?? DateOnly.FromDateTime(DateTime.Today)) .Max(); - var totalMonths = ((latestEnd.Year - earliestStart.Year) * 12) + (latestEnd.Month - earliestStart.Month); + var totalMonths = DateHelpers.MonthsBetween(earliestStart, latestEnd); var years = totalMonths / 12; var months = totalMonths % 12; @@ -1011,8 +1008,7 @@ public sealed class ProcessCVCheckJob if (lastRole?.EndDate != null) { - var monthsSince = ((DateTime.Today.Year - lastRole.EndDate.Value.Year) * 12) + - (DateTime.Today.Month - lastRole.EndDate.Value.Month); + var monthsSince = DateHelpers.MonthsBetween(lastRole.EndDate.Value, DateOnly.FromDateTime(DateTime.Today)); if (monthsSince > 0) { @@ -1041,7 +1037,7 @@ public sealed class ProcessCVCheckJob .Select(e => { var endDate = e.EndDate ?? DateOnly.FromDateTime(DateTime.Today); - var months = ((endDate.Year - e.StartDate!.Value.Year) * 12) + (endDate.Month - e.StartDate.Value.Month); + var months = DateHelpers.MonthsBetween(e.StartDate!.Value, endDate); return new { Entry = e, Months = months }; }) .Where(x => x.Months >= longTenureMonths) diff --git a/src/TrueCV.Infrastructure/Services/AICompanyNameMatcherService.cs b/src/TrueCV.Infrastructure/Services/AICompanyNameMatcherService.cs index 5282eb6..60d56cf 100644 --- a/src/TrueCV.Infrastructure/Services/AICompanyNameMatcherService.cs +++ b/src/TrueCV.Infrastructure/Services/AICompanyNameMatcherService.cs @@ -7,6 +7,7 @@ using TrueCV.Application.Helpers; using TrueCV.Application.Interfaces; using TrueCV.Application.Models; using TrueCV.Infrastructure.Configuration; +using TrueCV.Infrastructure.Helpers; namespace TrueCV.Infrastructure.Services; @@ -112,7 +113,7 @@ public sealed class AICompanyNameMatcherService : ICompanyNameMatcherService return null; } - responseText = CleanJsonResponse(responseText); + responseText = JsonResponseHelper.CleanJsonResponse(responseText); var aiResponse = JsonSerializer.Deserialize(responseText, JsonDefaults.CamelCase); @@ -163,25 +164,4 @@ public sealed class AICompanyNameMatcherService : ICompanyNameMatcherService return null; // Fall back to fuzzy matching } } - - private static string CleanJsonResponse(string response) - { - var trimmed = response.Trim(); - - if (trimmed.StartsWith("```json", StringComparison.OrdinalIgnoreCase)) - { - trimmed = trimmed[7..]; - } - else if (trimmed.StartsWith("```")) - { - trimmed = trimmed[3..]; - } - - if (trimmed.EndsWith("```")) - { - trimmed = trimmed[..^3]; - } - - return trimmed.Trim(); - } } diff --git a/src/TrueCV.Infrastructure/Services/CVParserService.cs b/src/TrueCV.Infrastructure/Services/CVParserService.cs index 0f37427..7c2634a 100644 --- a/src/TrueCV.Infrastructure/Services/CVParserService.cs +++ b/src/TrueCV.Infrastructure/Services/CVParserService.cs @@ -10,6 +10,7 @@ using TrueCV.Application.Helpers; using TrueCV.Application.Interfaces; using TrueCV.Application.Models; using TrueCV.Infrastructure.Configuration; +using TrueCV.Infrastructure.Helpers; using UglyToad.PdfPig; namespace TrueCV.Infrastructure.Services; @@ -191,7 +192,7 @@ public sealed class CVParserService : ICVParserService } // Clean up response - remove markdown code blocks if present - responseText = CleanJsonResponse(responseText); + responseText = JsonResponseHelper.CleanJsonResponse(responseText); _logger.LogDebug("Received response from Claude API, parsing JSON"); @@ -213,28 +214,6 @@ public sealed class CVParserService : ICVParserService } } - private static string CleanJsonResponse(string response) - { - var trimmed = response.Trim(); - - // Remove markdown code blocks - if (trimmed.StartsWith("```json", StringComparison.OrdinalIgnoreCase)) - { - trimmed = trimmed[7..]; - } - else if (trimmed.StartsWith("```")) - { - trimmed = trimmed[3..]; - } - - if (trimmed.EndsWith("```")) - { - trimmed = trimmed[..^3]; - } - - return trimmed.Trim(); - } - private static CVData MapToCVData(ClaudeCVResponse response) { return new CVData diff --git a/src/TrueCV.Infrastructure/Services/EducationVerifierService.cs b/src/TrueCV.Infrastructure/Services/EducationVerifierService.cs index 2e81458..ec3c6da 100644 --- a/src/TrueCV.Infrastructure/Services/EducationVerifierService.cs +++ b/src/TrueCV.Infrastructure/Services/EducationVerifierService.cs @@ -4,7 +4,7 @@ using TrueCV.Application.Models; namespace TrueCV.Infrastructure.Services; -public class EducationVerifierService : IEducationVerifierService +public sealed class EducationVerifierService : IEducationVerifierService { private const int MinimumDegreeYears = 1; private const int MaximumDegreeYears = 8; diff --git a/src/TrueCV.Web/Components/Layout/MainLayout.razor.css b/src/TrueCV.Web/Components/Layout/MainLayout.razor.css index 038baf1..0d26cfc 100644 --- a/src/TrueCV.Web/Components/Layout/MainLayout.razor.css +++ b/src/TrueCV.Web/Components/Layout/MainLayout.razor.css @@ -1,81 +1,3 @@ -.page { - position: relative; - display: flex; - flex-direction: column; -} - -main { - flex: 1; -} - -.sidebar { - background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); -} - -.top-row { - background-color: #f7f7f7; - border-bottom: 1px solid #d6d5d5; - justify-content: flex-end; - height: 3.5rem; - display: flex; - align-items: center; -} - - .top-row ::deep a, .top-row ::deep .btn-link { - white-space: nowrap; - margin-left: 1.5rem; - text-decoration: none; - } - - .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { - text-decoration: underline; - } - - .top-row ::deep a:first-child { - overflow: hidden; - text-overflow: ellipsis; - } - -@media (max-width: 640.98px) { - .top-row { - justify-content: space-between; - } - - .top-row ::deep a, .top-row ::deep .btn-link { - margin-left: 0; - } -} - -@media (min-width: 641px) { - .page { - flex-direction: row; - } - - .sidebar { - width: 250px; - height: 100vh; - position: sticky; - top: 0; - } - - .top-row { - position: sticky; - top: 0; - z-index: 1; - } - - .top-row.auth ::deep a:first-child { - flex: 1; - text-align: right; - width: 0; - } - - .top-row, article { - padding-left: 2rem !important; - padding-right: 1.5rem !important; - } -} - #blazor-error-ui { background: lightyellow; bottom: 0; @@ -88,9 +10,9 @@ main { z-index: 1000; } - #blazor-error-ui .dismiss { - cursor: pointer; - position: absolute; - right: 0.75rem; - top: 0.5rem; - } +#blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; +} diff --git a/src/TrueCV.Web/Components/Pages/Dashboard.razor b/src/TrueCV.Web/Components/Pages/Dashboard.razor index 3fb4127..117a39d 100644 --- a/src/TrueCV.Web/Components/Pages/Dashboard.razor +++ b/src/TrueCV.Web/Components/Pages/Dashboard.razor @@ -8,7 +8,7 @@ @inject AuthenticationStateProvider AuthenticationStateProvider @inject ILogger Logger @inject IJSRuntime JSRuntime -@inject TrueCV.Web.Services.PdfReportService PdfReportService +@inject TrueCV.Web.Services.IPdfReportService PdfReportService @inject IAuditService AuditService Dashboard - TrueCV diff --git a/src/TrueCV.Web/Components/Pages/Report.razor b/src/TrueCV.Web/Components/Pages/Report.razor index daae012..03a8508 100644 --- a/src/TrueCV.Web/Components/Pages/Report.razor +++ b/src/TrueCV.Web/Components/Pages/Report.razor @@ -9,7 +9,7 @@ @inject ILogger Logger @inject IJSRuntime JSRuntime @inject IAuditService AuditService -@inject PdfReportService PdfReportService +@inject IPdfReportService PdfReportService Verification Report - TrueCV diff --git a/src/TrueCV.Web/Components/Shared/EmploymentTable.razor b/src/TrueCV.Web/Components/Shared/EmploymentTable.razor deleted file mode 100644 index bf10f58..0000000 --- a/src/TrueCV.Web/Components/Shared/EmploymentTable.razor +++ /dev/null @@ -1,248 +0,0 @@ -@using TrueCV.Application.Models - -
- @if (Verifications is null || Verifications.Count == 0) - { -
- - - - No employment verification data available -
- } - else - { -
- @{ - var verifiedCount = Verifications.Count(v => v.IsVerified); - var unverifiedCount = Verifications.Count - verifiedCount; - } - - - - - @verifiedCount Verified - - @if (unverifiedCount > 0) - { - - - - - @unverifiedCount Unverified - - } -
- -
- - - - - - - - - - - - @foreach (var verification in Verifications) - { - - - - - - - - @if (!string.IsNullOrEmpty(verification.VerificationNotes)) - { - - - - } - } - -
Company ClaimedMatched CompanyMatch ScoreStatusDates
- @verification.ClaimedCompany - - @if (!string.IsNullOrEmpty(verification.MatchedCompanyName)) - { - - @verification.MatchedCompanyName - @if (!string.IsNullOrEmpty(verification.MatchedCompanyNumber)) - { - @verification.MatchedCompanyNumber - } - - } - else - { - No match found - } - -
-
-
-
-
- - @verification.MatchScore% - -
-
- @if (verification.IsVerified) - { - - - - - Verified - - } - else - { - - - - - Unverified - - } - - - @FormatDateRange(verification.ClaimedStartDate, verification.ClaimedEndDate) - -
- - - - - - @verification.VerificationNotes - -
-
- } -
- - - -@code { - [Parameter] - public List? Verifications { get; set; } - - private static string GetMatchScoreClass(int score) - { - return score switch - { - >= 80 => "bg-success", - >= 60 => "bg-info", - >= 40 => "bg-warning", - _ => "bg-danger" - }; - } - - private static string GetMatchScoreTextClass(int score) - { - return score switch - { - >= 80 => "text-success", - >= 60 => "text-info", - >= 40 => "text-warning", - _ => "text-danger" - }; - } - - private static string FormatDateRange(DateOnly? startDate, DateOnly? endDate) - { - if (!startDate.HasValue && !endDate.HasValue) - { - return "Dates not specified"; - } - - var start = startDate?.ToString("MMM yyyy") ?? "Unknown"; - var end = endDate?.ToString("MMM yyyy") ?? "Present"; - - return $"{start} - {end}"; - } -} diff --git a/src/TrueCV.Web/Components/Shared/FlagsList.razor b/src/TrueCV.Web/Components/Shared/FlagsList.razor deleted file mode 100644 index 50afc56..0000000 --- a/src/TrueCV.Web/Components/Shared/FlagsList.razor +++ /dev/null @@ -1,222 +0,0 @@ -@using TrueCV.Application.Models - -
- @if (Flags is null || Flags.Count == 0) - { -
- - - - - No flags found -
- } - else - { -
- @{ - var criticalCount = GetFlagsBySeverity("Critical").Count; - var warningCount = GetFlagsBySeverity("Warning").Count; - var infoCount = GetFlagsBySeverity("Info").Count; - } - @if (criticalCount > 0) - { - @criticalCount Critical - } - @if (warningCount > 0) - { - @warningCount Warning - } - @if (infoCount > 0) - { - @infoCount Info - } -
- -
- @foreach (var flag in GetSortedFlags()) - { -
-
- @GetFlagIcon(flag.Severity) -
-
-
- @flag.Title - @flag.Category -
-

@flag.Description

-
- Score Impact: - - @(flag.ScoreImpact > 0 ? "+" : "")@flag.ScoreImpact - -
-
-
- } -
- } -
- - - -@code { - [Parameter] - public List? Flags { get; set; } - - private List GetSortedFlags() - { - if (Flags is null || Flags.Count == 0) - { - return []; - } - - return Flags - .OrderBy(f => GetSeverityOrder(f.Severity)) - .ThenBy(f => f.ScoreImpact) - .ToList(); - } - - private List GetFlagsBySeverity(string severity) - { - if (Flags is null) - { - return []; - } - - return Flags.Where(f => f.Severity.Equals(severity, StringComparison.OrdinalIgnoreCase)).ToList(); - } - - private static int GetSeverityOrder(string severity) - { - return severity.ToLowerInvariant() switch - { - "critical" => 0, - "warning" => 1, - "info" => 2, - _ => 3 - }; - } - - private static string GetFlagSeverityClass(string severity) - { - return severity.ToLowerInvariant() switch - { - "critical" => "critical", - "warning" => "warning", - "info" => "info", - _ => "info" - }; - } - - private static MarkupString GetFlagIcon(string severity) - { - var icon = severity.ToLowerInvariant() switch - { - "critical" => """""", - "warning" => """""", - _ => """""" - }; - return new MarkupString(icon); - } -} diff --git a/src/TrueCV.Web/Components/Shared/TimelineVisualization.razor b/src/TrueCV.Web/Components/Shared/TimelineVisualization.razor deleted file mode 100644 index 871b7e5..0000000 --- a/src/TrueCV.Web/Components/Shared/TimelineVisualization.razor +++ /dev/null @@ -1,426 +0,0 @@ -@using TrueCV.Application.Models - -
- @if (Employment is null || Employment.Count == 0) - { -
- - - - - No employment timeline data available -
- } - else - { -
-
- - - Employment - - @if (Analysis?.Gaps?.Count > 0) - { - - - Gap (@Analysis.TotalGapMonths months total) - - } - @if (Analysis?.Overlaps?.Count > 0) - { - - - Overlap (@Analysis.TotalOverlapMonths months total) - - } -
-
- -
- @{ - var timelineData = GetTimelineData(); - var minDate = timelineData.MinDate; - var maxDate = timelineData.MaxDate; - var totalMonths = GetMonthsDifference(minDate, maxDate); - } - -
- @foreach (var year in GetYearMarkers(minDate, maxDate)) - { - var position = GetPositionPercentage(year, minDate, totalMonths); -
- @year.Year -
- } -
- -
- @foreach (var entry in Employment.OrderBy(e => e.StartDate ?? DateOnly.MaxValue)) - { - var startDate = entry.StartDate ?? minDate; - var endDate = entry.EndDate ?? (entry.IsCurrent ? DateOnly.FromDateTime(DateTime.Today) : maxDate); - var left = GetPositionPercentage(startDate, minDate, totalMonths); - var width = GetWidthPercentage(startDate, endDate, totalMonths); - -
-
- @entry.CompanyName -
-
- } -
- - @if (Analysis?.Gaps?.Count > 0) - { -
- @foreach (var gap in Analysis.Gaps) - { - var left = GetPositionPercentage(gap.StartDate, minDate, totalMonths); - var width = GetWidthPercentage(gap.StartDate, gap.EndDate, totalMonths); - -
-
- } -
- } - - @if (Analysis?.Overlaps?.Count > 0) - { -
- @foreach (var overlap in Analysis.Overlaps) - { - var left = GetPositionPercentage(overlap.OverlapStart, minDate, totalMonths); - var width = GetWidthPercentage(overlap.OverlapStart, overlap.OverlapEnd, totalMonths); - -
-
- } -
- } -
- - @if (Analysis is not null && (Analysis.Gaps.Count > 0 || Analysis.Overlaps.Count > 0)) - { -
- @if (Analysis.Gaps.Count > 0) - { -
-
- - - - - Employment Gaps -
-
    - @foreach (var gap in Analysis.Gaps) - { -
  • - @gap.Months months gap from @gap.StartDate.ToString("MMM yyyy") to @gap.EndDate.ToString("MMM yyyy") -
  • - } -
-
- } - - @if (Analysis.Overlaps.Count > 0) - { -
-
- - - - Employment Overlaps -
-
    - @foreach (var overlap in Analysis.Overlaps) - { -
  • - @overlap.Months months overlap between @overlap.Company1 and @overlap.Company2 - (@overlap.OverlapStart.ToString("MMM yyyy") - @overlap.OverlapEnd.ToString("MMM yyyy")) -
  • - } -
-
- } -
- } - } -
- - - -@code { - [Parameter] - public TimelineAnalysisResult? Analysis { get; set; } - - [Parameter] - public List? Employment { get; set; } - - private (DateOnly MinDate, DateOnly MaxDate) GetTimelineData() - { - if (Employment is null || Employment.Count == 0) - { - var today = DateOnly.FromDateTime(DateTime.Today); - return (today.AddYears(-5), today); - } - - var dates = new List(); - - foreach (var entry in Employment) - { - if (entry.StartDate.HasValue) - { - dates.Add(entry.StartDate.Value); - } - if (entry.EndDate.HasValue) - { - dates.Add(entry.EndDate.Value); - } - else if (entry.IsCurrent) - { - dates.Add(DateOnly.FromDateTime(DateTime.Today)); - } - } - - if (dates.Count == 0) - { - var today = DateOnly.FromDateTime(DateTime.Today); - return (today.AddYears(-5), today); - } - - var minDate = dates.Min().AddMonths(-3); - var maxDate = dates.Max().AddMonths(3); - - return (minDate, maxDate); - } - - private static int GetMonthsDifference(DateOnly start, DateOnly end) - { - return ((end.Year - start.Year) * 12) + end.Month - start.Month; - } - - private static double GetPositionPercentage(DateOnly date, DateOnly minDate, int totalMonths) - { - if (totalMonths == 0) return 0; - var months = GetMonthsDifference(minDate, date); - return Math.Max(0, Math.Min(100, (double)months / totalMonths * 100)); - } - - private static double GetWidthPercentage(DateOnly start, DateOnly end, int totalMonths) - { - if (totalMonths == 0) return 0; - var months = GetMonthsDifference(start, end); - return Math.Max(1, Math.Min(100, (double)months / totalMonths * 100)); - } - - private static IEnumerable GetYearMarkers(DateOnly minDate, DateOnly maxDate) - { - var startYear = minDate.Year; - var endYear = maxDate.Year; - - for (var year = startYear; year <= endYear; year++) - { - yield return new DateOnly(year, 1, 1); - } - } - - private static string FormatDateRange(DateOnly? startDate, DateOnly? endDate, bool isCurrent) - { - var start = startDate?.ToString("MMM yyyy") ?? "Unknown"; - var end = isCurrent ? "Present" : (endDate?.ToString("MMM yyyy") ?? "Unknown"); - return $"{start} - {end}"; - } -} diff --git a/src/TrueCV.Web/Components/Shared/VeracityScoreCard.razor b/src/TrueCV.Web/Components/Shared/VeracityScoreCard.razor deleted file mode 100644 index e8b097b..0000000 --- a/src/TrueCV.Web/Components/Shared/VeracityScoreCard.razor +++ /dev/null @@ -1,191 +0,0 @@ -@implements IDisposable - -
-
- - - - -
- @_displayScore - /100 -
-
-
- @(string.IsNullOrEmpty(Label) ? GetDefaultLabel() : Label) -
-
- @GetScoreDescription() -
-
- - - -@code { - private int _displayScore; - private System.Threading.Timer? _animationTimer; - private int _targetScore; - - [Parameter] - public int Score { get; set; } - - [Parameter] - public string? Label { get; set; } - - protected override void OnParametersSet() - { - _targetScore = Math.Clamp(Score, 0, 100); - - if (_displayScore != _targetScore) - { - StartAnimation(); - } - } - - private void StartAnimation() - { - _animationTimer?.Dispose(); - - var increment = _targetScore > _displayScore ? 1 : -1; - var intervalMs = Math.Max(10, 500 / Math.Max(1, Math.Abs(_targetScore - _displayScore))); - - _animationTimer = new System.Threading.Timer(_ => - { - if (_displayScore == _targetScore) - { - _animationTimer?.Dispose(); - return; - } - - _displayScore += increment; - InvokeAsync(StateHasChanged); - }, null, 0, intervalMs); - } - - private string GetScoreColorClass() - { - return _targetScore switch - { - >= 90 => "excellent", - >= 70 => "good", - >= 50 => "moderate", - _ => "poor" - }; - } - - private string GetDefaultLabel() - { - return _targetScore switch - { - >= 90 => "Excellent", - >= 70 => "Good", - >= 50 => "Moderate", - _ => "Poor" - }; - } - - private string GetScoreDescription() - { - return _targetScore switch - { - >= 90 => "This CV demonstrates high veracity with minimal concerns.", - >= 70 => "This CV is generally trustworthy with some minor concerns.", - >= 50 => "This CV has some inconsistencies that may need clarification.", - _ => "This CV has significant concerns that require attention." - }; - } - - public void Dispose() - { - _animationTimer?.Dispose(); - } -} diff --git a/src/TrueCV.Web/Components/Shared/VerificationProgress.razor b/src/TrueCV.Web/Components/Shared/VerificationProgress.razor deleted file mode 100644 index 59b6e29..0000000 --- a/src/TrueCV.Web/Components/Shared/VerificationProgress.razor +++ /dev/null @@ -1,198 +0,0 @@ -@using TrueCV.Domain.Enums - -
-
- @if (!string.IsNullOrEmpty(FileName)) - { - - - - - - @FileName - - } -
- -
- @switch (Status) - { - case CheckStatus.Pending: -
- - - -
-
- Pending - Your CV is queued for verification -
- break; - - case CheckStatus.Processing: -
-
- Processing... -
-
-
- Processing - Verifying your CV data... -
-
-
-
- Extracting CV data -
-
-
- Verifying companies -
-
-
- Analysing timeline -
-
-
- Generating report -
-
- break; - - case CheckStatus.Completed: -
- - - -
-
- Completed - Verification complete! View your results below. -
- break; - - case CheckStatus.Failed: -
- - - -
-
- Failed - Something went wrong. Please try again. -
- break; - } -
-
- - - -@code { - [Parameter] - public CheckStatus Status { get; set; } - - [Parameter] - public string? FileName { get; set; } -} diff --git a/src/TrueCV.Web/HangfireAuthorizationFilter.cs b/src/TrueCV.Web/HangfireAuthorizationFilter.cs new file mode 100644 index 0000000..bd34a8a --- /dev/null +++ b/src/TrueCV.Web/HangfireAuthorizationFilter.cs @@ -0,0 +1,12 @@ +using Hangfire.Dashboard; + +namespace TrueCV.Web; + +public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter +{ + public bool Authorize(DashboardContext context) + { + var httpContext = context.GetHttpContext(); + return httpContext.User.Identity?.IsAuthenticated ?? false; + } +} diff --git a/src/TrueCV.Web/Program.cs b/src/TrueCV.Web/Program.cs index 9d1e006..a55b967 100644 --- a/src/TrueCV.Web/Program.cs +++ b/src/TrueCV.Web/Program.cs @@ -1,6 +1,8 @@ +using System.Threading.RateLimiting; using Hangfire; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Serilog; using TrueCV.Infrastructure; @@ -35,7 +37,7 @@ try builder.Services.AddInfrastructure(builder.Configuration); // Add Web services - builder.Services.AddScoped(); + builder.Services.AddScoped(); // Add Identity with secure password requirements builder.Services.AddIdentity>(options => @@ -69,25 +71,52 @@ try builder.Services.AddHealthChecks() .AddDbContextCheck("database"); + // Add rate limiting for login endpoint + builder.Services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddFixedWindowLimiter("login", limiterOptions => + { + limiterOptions.PermitLimit = 5; + limiterOptions.Window = TimeSpan.FromMinutes(1); + limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + limiterOptions.QueueLimit = 0; + }); + }); + var app = builder.Build(); // Seed default admin user and clear company cache using (var scope = app.Services.CreateScope()) { var userManager = scope.ServiceProvider.GetRequiredService>(); - var defaultEmail = "admin@truecv.local"; - var defaultPassword = "TrueCV_Admin123!"; + var defaultEmail = builder.Configuration["DefaultAdmin:Email"]; + var defaultPassword = builder.Configuration["DefaultAdmin:Password"]; - if (await userManager.FindByEmailAsync(defaultEmail) == null) + if (!string.IsNullOrEmpty(defaultEmail) && !string.IsNullOrEmpty(defaultPassword)) { - var adminUser = new ApplicationUser + if (await userManager.FindByEmailAsync(defaultEmail) == null) { - UserName = defaultEmail, - Email = defaultEmail, - EmailConfirmed = true - }; - await userManager.CreateAsync(adminUser, defaultPassword); - Log.Information("Created default admin user: {Email}", defaultEmail); + var adminUser = new ApplicationUser + { + UserName = defaultEmail, + Email = defaultEmail, + EmailConfirmed = true + }; + var result = await userManager.CreateAsync(adminUser, defaultPassword); + if (result.Succeeded) + { + Log.Information("Created default admin user: {Email}", defaultEmail); + } + else + { + Log.Warning("Failed to create admin user: {Errors}", string.Join(", ", result.Errors.Select(e => e.Description))); + } + } + } + else + { + Log.Information("No default admin credentials configured - skipping admin user seeding"); } // Clear company cache on startup to ensure fresh API lookups @@ -127,13 +156,14 @@ try app.UseAuthentication(); app.UseAuthorization(); + app.UseRateLimiter(); - // Add Hangfire Dashboard (only in development) + // Add Hangfire Dashboard (only in development, requires authentication) if (app.Environment.IsDevelopment()) { app.UseHangfireDashboard("/hangfire", new DashboardOptions { - Authorization = [] // Allow anonymous access in development + Authorization = [new HangfireAuthorizationFilter()] }); } @@ -173,7 +203,7 @@ try Log.Warning("Failed login attempt for {Email}", email); return Results.Redirect("/account/login?error=Invalid+email+or+password."); } - }); + }).RequireRateLimiting("login"); // Logout endpoint app.MapPost("/account/logout", async (SignInManager signInManager) => diff --git a/src/TrueCV.Web/Services/IPdfReportService.cs b/src/TrueCV.Web/Services/IPdfReportService.cs new file mode 100644 index 0000000..224fae1 --- /dev/null +++ b/src/TrueCV.Web/Services/IPdfReportService.cs @@ -0,0 +1,9 @@ +using TrueCV.Application.Models; + +namespace TrueCV.Web.Services; + +public interface IPdfReportService +{ + byte[] GenerateSingleReport(string candidateName, VeracityReport report); + byte[] GenerateReport(List data); +} diff --git a/src/TrueCV.Web/Services/PdfReportService.cs b/src/TrueCV.Web/Services/PdfReportService.cs index 0bee050..5cb434b 100644 --- a/src/TrueCV.Web/Services/PdfReportService.cs +++ b/src/TrueCV.Web/Services/PdfReportService.cs @@ -1,11 +1,12 @@ using QuestPDF.Fluent; using QuestPDF.Helpers; using QuestPDF.Infrastructure; +using TrueCV.Application.Helpers; using TrueCV.Application.Models; namespace TrueCV.Web.Services; -public class PdfReportService +public class PdfReportService : IPdfReportService { /// /// Generates a detailed PDF report for a single CV verification. @@ -33,8 +34,8 @@ public class PdfReportService private void ComposeSingleReportHeader(IContainer container, string candidateName, VeracityReport report) { - var scoreColor = report.OverallScore > 70 ? Colors.Green.Darken1 : - (report.OverallScore >= 50 ? Colors.Orange.Darken1 : Colors.Red.Darken1); + var scoreColor = report.OverallScore > ScoreThresholds.High ? Colors.Green.Darken1 : + (report.OverallScore >= ScoreThresholds.Medium ? Colors.Orange.Darken1 : Colors.Red.Darken1); container.Column(column => { @@ -282,7 +283,7 @@ public class PdfReportService foreach (var item in data) { var bgColor = alternate ? Colors.Grey.Lighten4 : Colors.White; - var scoreColor = item.Score > 70 ? Colors.Green.Darken1 : (item.Score >= 50 ? Colors.Orange.Darken1 : Colors.Red.Darken1); + var scoreColor = item.Score > ScoreThresholds.High ? Colors.Green.Darken1 : (item.Score >= ScoreThresholds.Medium ? Colors.Orange.Darken1 : Colors.Red.Darken1); table.Cell().Background(bgColor).Padding(5).Text(item.CandidateName); table.Cell().Background(bgColor).Padding(5).Text(item.UploadDate.ToString("dd MMM yy")); diff --git a/src/TrueCV.Web/appsettings.json b/src/TrueCV.Web/appsettings.json index 980fecd..731490e 100644 --- a/src/TrueCV.Web/appsettings.json +++ b/src/TrueCV.Web/appsettings.json @@ -10,6 +10,10 @@ "Anthropic": { "ApiKey": "" }, + "DefaultAdmin": { + "Email": "", + "Password": "" + }, "AzureBlob": { "ConnectionString": "", "ContainerName": "cv-uploads"