Initial commit: TrueCV CV verification platform
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>
This commit is contained in:
220
.gitignore
vendored
Normal file
220
.gitignore
vendored
Normal file
@@ -0,0 +1,220 @@
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio files
|
||||
.vs/
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
*.rsuser
|
||||
*.userprefs
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# Rider
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# MSBuild
|
||||
*.log
|
||||
msbuild.*.binlog
|
||||
|
||||
# NuGet
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
.nuget/
|
||||
nuget.exe
|
||||
packages/
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
|
||||
# ASP.NET scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
*.trx
|
||||
|
||||
# Coverage Results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
lcov.info
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# DocProject
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# ReSharper
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity
|
||||
_TeamCity*
|
||||
|
||||
# DotCover
|
||||
*.dotCover
|
||||
|
||||
# AxoCover
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet
|
||||
coverage*.json
|
||||
|
||||
# NCrunch
|
||||
*.ncrunch*
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
|
||||
# MiniProfiler
|
||||
App_Data/*.mdf
|
||||
App_Data/*.ldf
|
||||
|
||||
# ServiceStack
|
||||
App_Data/*.db
|
||||
|
||||
# Entity Framework
|
||||
*.mdf
|
||||
*.ldf
|
||||
|
||||
# Publishing
|
||||
PublishProfiles/
|
||||
publish/
|
||||
*.Publish.xml
|
||||
*.azurePubxml
|
||||
*.pubxml
|
||||
*.pubxml.user
|
||||
|
||||
# NuGet packages config
|
||||
packages.config
|
||||
|
||||
# Backup & report files
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Linux
|
||||
*~
|
||||
|
||||
# Environment and secrets
|
||||
.env
|
||||
.env.*
|
||||
*.env
|
||||
appsettings.Development.json
|
||||
appsettings.Local.json
|
||||
appsettings.*.local.json
|
||||
secrets.json
|
||||
*.pfx
|
||||
*.p12
|
||||
|
||||
# Azure
|
||||
.azure/
|
||||
|
||||
# Terraform
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
.terraform/
|
||||
|
||||
# JetBrains Rider
|
||||
.idea/
|
||||
|
||||
# Local development
|
||||
local/
|
||||
.local/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
*.swp
|
||||
48
TrueCV.sln
Normal file
48
TrueCV.sln
Normal file
@@ -0,0 +1,48 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{F25C3740-9240-46DF-BC34-985BC577216B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrueCV.Domain", "src\TrueCV.Domain\TrueCV.Domain.csproj", "{41AC48AF-09BC-48D1-9CA4-1B05D3B693F0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrueCV.Application", "src\TrueCV.Application\TrueCV.Application.csproj", "{A8A1BA81-3B2F-4F95-BB15-ACA40DF2A70E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrueCV.Infrastructure", "src\TrueCV.Infrastructure\TrueCV.Infrastructure.csproj", "{03DB607C-9592-4930-8C89-3E257A319278}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrueCV.Web", "src\TrueCV.Web\TrueCV.Web.csproj", "{D69F57DB-3092-48AF-81BB-868E3749C638}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{41AC48AF-09BC-48D1-9CA4-1B05D3B693F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{41AC48AF-09BC-48D1-9CA4-1B05D3B693F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{41AC48AF-09BC-48D1-9CA4-1B05D3B693F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{41AC48AF-09BC-48D1-9CA4-1B05D3B693F0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A8A1BA81-3B2F-4F95-BB15-ACA40DF2A70E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A8A1BA81-3B2F-4F95-BB15-ACA40DF2A70E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A8A1BA81-3B2F-4F95-BB15-ACA40DF2A70E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A8A1BA81-3B2F-4F95-BB15-ACA40DF2A70E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{03DB607C-9592-4930-8C89-3E257A319278}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{03DB607C-9592-4930-8C89-3E257A319278}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{03DB607C-9592-4930-8C89-3E257A319278}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{03DB607C-9592-4930-8C89-3E257A319278}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D69F57DB-3092-48AF-81BB-868E3749C638}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D69F57DB-3092-48AF-81BB-868E3749C638}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D69F57DB-3092-48AF-81BB-868E3749C638}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D69F57DB-3092-48AF-81BB-868E3749C638}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{41AC48AF-09BC-48D1-9CA4-1B05D3B693F0} = {F25C3740-9240-46DF-BC34-985BC577216B}
|
||||
{A8A1BA81-3B2F-4F95-BB15-ACA40DF2A70E} = {F25C3740-9240-46DF-BC34-985BC577216B}
|
||||
{03DB607C-9592-4930-8C89-3E257A319278} = {F25C3740-9240-46DF-BC34-985BC577216B}
|
||||
{D69F57DB-3092-48AF-81BB-868E3749C638} = {F25C3740-9240-46DF-BC34-985BC577216B}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
11
src/TrueCV.Application/DTOs/CVCheckDto.cs
Normal file
11
src/TrueCV.Application/DTOs/CVCheckDto.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace TrueCV.Application.DTOs;
|
||||
|
||||
public sealed record CVCheckDto
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required string OriginalFileName { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public int? VeracityScore { get; init; }
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
public DateTime? CompletedAt { get; init; }
|
||||
}
|
||||
10
src/TrueCV.Application/DTOs/CompanySearchResult.cs
Normal file
10
src/TrueCV.Application/DTOs/CompanySearchResult.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace TrueCV.Application.DTOs;
|
||||
|
||||
public sealed record CompanySearchResult
|
||||
{
|
||||
public required string CompanyNumber { get; init; }
|
||||
public required string CompanyName { get; init; }
|
||||
public required string CompanyStatus { get; init; }
|
||||
public DateOnly? IncorporationDate { get; init; }
|
||||
public string? AddressSnippet { get; init; }
|
||||
}
|
||||
13
src/TrueCV.Application/Interfaces/ICVCheckService.cs
Normal file
13
src/TrueCV.Application/Interfaces/ICVCheckService.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using TrueCV.Application.DTOs;
|
||||
using TrueCV.Application.Models;
|
||||
|
||||
namespace TrueCV.Application.Interfaces;
|
||||
|
||||
public interface ICVCheckService
|
||||
{
|
||||
Task<Guid> CreateCheckAsync(Guid userId, Stream file, string fileName);
|
||||
Task<CVCheckDto?> GetCheckAsync(Guid id);
|
||||
Task<CVCheckDto?> GetCheckForUserAsync(Guid id, Guid userId);
|
||||
Task<List<CVCheckDto>> GetUserChecksAsync(Guid userId);
|
||||
Task<VeracityReport?> GetReportAsync(Guid checkId, Guid userId);
|
||||
}
|
||||
8
src/TrueCV.Application/Interfaces/ICVParserService.cs
Normal file
8
src/TrueCV.Application/Interfaces/ICVParserService.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using TrueCV.Application.Models;
|
||||
|
||||
namespace TrueCV.Application.Interfaces;
|
||||
|
||||
public interface ICVParserService
|
||||
{
|
||||
Task<CVData> ParseAsync(Stream fileStream, string fileName);
|
||||
}
|
||||
10
src/TrueCV.Application/Interfaces/ICompanyVerifierService.cs
Normal file
10
src/TrueCV.Application/Interfaces/ICompanyVerifierService.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using TrueCV.Application.DTOs;
|
||||
using TrueCV.Application.Models;
|
||||
|
||||
namespace TrueCV.Application.Interfaces;
|
||||
|
||||
public interface ICompanyVerifierService
|
||||
{
|
||||
Task<CompanyVerificationResult> VerifyCompanyAsync(string companyName, DateOnly? startDate, DateOnly? endDate);
|
||||
Task<List<CompanySearchResult>> SearchCompaniesAsync(string query);
|
||||
}
|
||||
8
src/TrueCV.Application/Interfaces/IFileStorageService.cs
Normal file
8
src/TrueCV.Application/Interfaces/IFileStorageService.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace TrueCV.Application.Interfaces;
|
||||
|
||||
public interface IFileStorageService
|
||||
{
|
||||
Task<string> UploadAsync(Stream fileStream, string fileName);
|
||||
Task<Stream> DownloadAsync(string blobUrl);
|
||||
Task DeleteAsync(string blobUrl);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using TrueCV.Application.Models;
|
||||
|
||||
namespace TrueCV.Application.Interfaces;
|
||||
|
||||
public interface ITimelineAnalyserService
|
||||
{
|
||||
TimelineAnalysisResult Analyse(List<EmploymentEntry> employmentHistory);
|
||||
}
|
||||
11
src/TrueCV.Application/Models/CVData.cs
Normal file
11
src/TrueCV.Application/Models/CVData.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace TrueCV.Application.Models;
|
||||
|
||||
public sealed record CVData
|
||||
{
|
||||
public required string FullName { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public string? Phone { get; init; }
|
||||
public List<EmploymentEntry> Employment { get; init; } = [];
|
||||
public List<EducationEntry> Education { get; init; } = [];
|
||||
public List<string> Skills { get; init; } = [];
|
||||
}
|
||||
13
src/TrueCV.Application/Models/CompanyVerificationResult.cs
Normal file
13
src/TrueCV.Application/Models/CompanyVerificationResult.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace TrueCV.Application.Models;
|
||||
|
||||
public sealed record CompanyVerificationResult
|
||||
{
|
||||
public required string ClaimedCompany { get; init; }
|
||||
public string? MatchedCompanyName { get; init; }
|
||||
public string? MatchedCompanyNumber { get; init; }
|
||||
public required int MatchScore { get; init; }
|
||||
public required bool IsVerified { get; init; }
|
||||
public string? VerificationNotes { get; init; }
|
||||
public DateOnly? ClaimedStartDate { get; init; }
|
||||
public DateOnly? ClaimedEndDate { get; init; }
|
||||
}
|
||||
11
src/TrueCV.Application/Models/EducationEntry.cs
Normal file
11
src/TrueCV.Application/Models/EducationEntry.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace TrueCV.Application.Models;
|
||||
|
||||
public sealed record EducationEntry
|
||||
{
|
||||
public required string Institution { get; init; }
|
||||
public string? Qualification { get; init; }
|
||||
public string? Subject { get; init; }
|
||||
public string? Grade { get; init; }
|
||||
public DateOnly? StartDate { get; init; }
|
||||
public DateOnly? EndDate { get; init; }
|
||||
}
|
||||
12
src/TrueCV.Application/Models/EmploymentEntry.cs
Normal file
12
src/TrueCV.Application/Models/EmploymentEntry.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace TrueCV.Application.Models;
|
||||
|
||||
public sealed record EmploymentEntry
|
||||
{
|
||||
public required string CompanyName { get; init; }
|
||||
public required string JobTitle { get; init; }
|
||||
public string? Location { get; init; }
|
||||
public DateOnly? StartDate { get; init; }
|
||||
public DateOnly? EndDate { get; init; }
|
||||
public bool IsCurrent { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
10
src/TrueCV.Application/Models/FlagResult.cs
Normal file
10
src/TrueCV.Application/Models/FlagResult.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace TrueCV.Application.Models;
|
||||
|
||||
public sealed record FlagResult
|
||||
{
|
||||
public required string Category { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public required int ScoreImpact { get; init; }
|
||||
}
|
||||
9
src/TrueCV.Application/Models/TimelineAnalysisResult.cs
Normal file
9
src/TrueCV.Application/Models/TimelineAnalysisResult.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace TrueCV.Application.Models;
|
||||
|
||||
public sealed record TimelineAnalysisResult
|
||||
{
|
||||
public required int TotalGapMonths { get; init; }
|
||||
public required int TotalOverlapMonths { get; init; }
|
||||
public List<TimelineGap> Gaps { get; init; } = [];
|
||||
public List<TimelineOverlap> Overlaps { get; init; } = [];
|
||||
}
|
||||
8
src/TrueCV.Application/Models/TimelineGap.cs
Normal file
8
src/TrueCV.Application/Models/TimelineGap.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace TrueCV.Application.Models;
|
||||
|
||||
public sealed record TimelineGap
|
||||
{
|
||||
public required DateOnly StartDate { get; init; }
|
||||
public required DateOnly EndDate { get; init; }
|
||||
public required int Months { get; init; }
|
||||
}
|
||||
10
src/TrueCV.Application/Models/TimelineOverlap.cs
Normal file
10
src/TrueCV.Application/Models/TimelineOverlap.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace TrueCV.Application.Models;
|
||||
|
||||
public sealed record TimelineOverlap
|
||||
{
|
||||
public required string Company1 { get; init; }
|
||||
public required string Company2 { get; init; }
|
||||
public required DateOnly OverlapStart { get; init; }
|
||||
public required DateOnly OverlapEnd { get; init; }
|
||||
public required int Months { get; init; }
|
||||
}
|
||||
11
src/TrueCV.Application/Models/VeracityReport.cs
Normal file
11
src/TrueCV.Application/Models/VeracityReport.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace TrueCV.Application.Models;
|
||||
|
||||
public sealed record VeracityReport
|
||||
{
|
||||
public required int OverallScore { get; init; }
|
||||
public required string ScoreLabel { get; init; }
|
||||
public List<CompanyVerificationResult> EmploymentVerifications { get; init; } = [];
|
||||
public required TimelineAnalysisResult TimelineAnalysis { get; init; }
|
||||
public List<FlagResult> Flags { get; init; } = [];
|
||||
public required DateTime GeneratedAt { get; init; }
|
||||
}
|
||||
13
src/TrueCV.Application/TrueCV.Application.csproj
Normal file
13
src/TrueCV.Application/TrueCV.Application.csproj
Normal file
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TrueCV.Domain\TrueCV.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
39
src/TrueCV.Domain/Entities/CVCheck.cs
Normal file
39
src/TrueCV.Domain/Entities/CVCheck.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using TrueCV.Domain.Enums;
|
||||
|
||||
namespace TrueCV.Domain.Entities;
|
||||
|
||||
public class CVCheck
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(512)]
|
||||
public string OriginalFileName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(2048)]
|
||||
public string BlobUrl { get; set; } = string.Empty;
|
||||
|
||||
public CheckStatus Status { get; set; }
|
||||
|
||||
public string? ExtractedDataJson { get; set; }
|
||||
|
||||
public int? VeracityScore { get; set; }
|
||||
|
||||
public string? ReportJson { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
[ForeignKey(nameof(UserId))]
|
||||
public User User { get; set; } = null!;
|
||||
|
||||
public ICollection<CVFlag> Flags { get; set; } = new List<CVFlag>();
|
||||
}
|
||||
31
src/TrueCV.Domain/Entities/CVFlag.cs
Normal file
31
src/TrueCV.Domain/Entities/CVFlag.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using TrueCV.Domain.Enums;
|
||||
|
||||
namespace TrueCV.Domain.Entities;
|
||||
|
||||
public class CVFlag
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid CVCheckId { get; set; }
|
||||
|
||||
public FlagCategory Category { get; set; }
|
||||
|
||||
public FlagSeverity Severity { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(256)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(2048)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public int ScoreImpact { get; set; }
|
||||
|
||||
// Navigation property
|
||||
[ForeignKey(nameof(CVCheckId))]
|
||||
public CVCheck CVCheck { get; set; } = null!;
|
||||
}
|
||||
24
src/TrueCV.Domain/Entities/CompanyCache.cs
Normal file
24
src/TrueCV.Domain/Entities/CompanyCache.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TrueCV.Domain.Entities;
|
||||
|
||||
public class CompanyCache
|
||||
{
|
||||
[Key]
|
||||
[MaxLength(32)]
|
||||
public string CompanyNumber { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(512)]
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(64)]
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
public DateOnly? IncorporationDate { get; set; }
|
||||
|
||||
public DateOnly? DissolutionDate { get; set; }
|
||||
|
||||
public DateTime CachedAt { get; set; }
|
||||
}
|
||||
24
src/TrueCV.Domain/Entities/User.cs
Normal file
24
src/TrueCV.Domain/Entities/User.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TrueCV.Domain.Enums;
|
||||
|
||||
namespace TrueCV.Domain.Entities;
|
||||
|
||||
public class User
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(256)]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
public UserPlan Plan { get; set; }
|
||||
|
||||
[MaxLength(256)]
|
||||
public string? StripeCustomerId { get; set; }
|
||||
|
||||
public int ChecksUsedThisMonth { get; set; }
|
||||
|
||||
// Navigation property
|
||||
public ICollection<CVCheck> CVChecks { get; set; } = new List<CVCheck>();
|
||||
}
|
||||
9
src/TrueCV.Domain/Enums/CheckStatus.cs
Normal file
9
src/TrueCV.Domain/Enums/CheckStatus.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace TrueCV.Domain.Enums;
|
||||
|
||||
public enum CheckStatus
|
||||
{
|
||||
Pending,
|
||||
Processing,
|
||||
Completed,
|
||||
Failed
|
||||
}
|
||||
9
src/TrueCV.Domain/Enums/FlagCategory.cs
Normal file
9
src/TrueCV.Domain/Enums/FlagCategory.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace TrueCV.Domain.Enums;
|
||||
|
||||
public enum FlagCategory
|
||||
{
|
||||
Employment,
|
||||
Education,
|
||||
Timeline,
|
||||
Plausibility
|
||||
}
|
||||
8
src/TrueCV.Domain/Enums/FlagSeverity.cs
Normal file
8
src/TrueCV.Domain/Enums/FlagSeverity.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace TrueCV.Domain.Enums;
|
||||
|
||||
public enum FlagSeverity
|
||||
{
|
||||
Info,
|
||||
Warning,
|
||||
Critical
|
||||
}
|
||||
8
src/TrueCV.Domain/Enums/UserPlan.cs
Normal file
8
src/TrueCV.Domain/Enums/UserPlan.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace TrueCV.Domain.Enums;
|
||||
|
||||
public enum UserPlan
|
||||
{
|
||||
Free,
|
||||
Professional,
|
||||
Enterprise
|
||||
}
|
||||
13
src/TrueCV.Domain/TrueCV.Domain.csproj
Normal file
13
src/TrueCV.Domain/TrueCV.Domain.csproj
Normal file
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace TrueCV.Infrastructure.Configuration;
|
||||
|
||||
public sealed class AnthropicSettings
|
||||
{
|
||||
public const string SectionName = "Anthropic";
|
||||
|
||||
public required string ApiKey { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace TrueCV.Infrastructure.Configuration;
|
||||
|
||||
public sealed class AzureBlobSettings
|
||||
{
|
||||
public const string SectionName = "AzureBlob";
|
||||
|
||||
public required string ConnectionString { get; init; }
|
||||
public required string ContainerName { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace TrueCV.Infrastructure.Configuration;
|
||||
|
||||
public sealed class CompaniesHouseSettings
|
||||
{
|
||||
public const string SectionName = "CompaniesHouse";
|
||||
|
||||
public required string BaseUrl { get; init; }
|
||||
public required string ApiKey { get; init; }
|
||||
}
|
||||
114
src/TrueCV.Infrastructure/Data/ApplicationDbContext.cs
Normal file
114
src/TrueCV.Infrastructure/Data/ApplicationDbContext.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrueCV.Domain.Entities;
|
||||
using TrueCV.Domain.Enums;
|
||||
using TrueCV.Infrastructure.Identity;
|
||||
|
||||
namespace TrueCV.Infrastructure.Data;
|
||||
|
||||
public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid>, Guid>
|
||||
{
|
||||
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<CVCheck> CVChecks => Set<CVCheck>();
|
||||
public DbSet<CVFlag> CVFlags => Set<CVFlag>();
|
||||
public DbSet<CompanyCache> CompanyCache => Set<CompanyCache>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
ConfigureApplicationUser(builder);
|
||||
ConfigureCVCheck(builder);
|
||||
ConfigureCVFlag(builder);
|
||||
ConfigureCompanyCache(builder);
|
||||
}
|
||||
|
||||
private static void ConfigureApplicationUser(ModelBuilder builder)
|
||||
{
|
||||
builder.Entity<ApplicationUser>(entity =>
|
||||
{
|
||||
entity.Property(u => u.Plan)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32);
|
||||
|
||||
entity.Property(u => u.StripeCustomerId)
|
||||
.HasMaxLength(256);
|
||||
|
||||
entity.HasMany(u => u.CVChecks)
|
||||
.WithOne()
|
||||
.HasForeignKey(c => c.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureCVCheck(ModelBuilder builder)
|
||||
{
|
||||
builder.Entity<CVCheck>(entity =>
|
||||
{
|
||||
entity.Property(c => c.Status)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32);
|
||||
|
||||
entity.HasIndex(c => c.UserId)
|
||||
.HasDatabaseName("IX_CVChecks_UserId");
|
||||
|
||||
entity.HasIndex(c => c.Status)
|
||||
.HasDatabaseName("IX_CVChecks_Status");
|
||||
|
||||
entity.HasMany(c => c.Flags)
|
||||
.WithOne(f => f.CVCheck)
|
||||
.HasForeignKey(f => f.CVCheckId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Ignore the User navigation property since we're using ApplicationUser
|
||||
entity.Ignore(c => c.User);
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureCVFlag(ModelBuilder builder)
|
||||
{
|
||||
builder.Entity<CVFlag>(entity =>
|
||||
{
|
||||
entity.Property(f => f.Category)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32);
|
||||
|
||||
entity.Property(f => f.Severity)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32);
|
||||
|
||||
entity.HasIndex(f => f.CVCheckId)
|
||||
.HasDatabaseName("IX_CVFlags_CVCheckId");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureCompanyCache(ModelBuilder builder)
|
||||
{
|
||||
builder.Entity<CompanyCache>(entity =>
|
||||
{
|
||||
entity.HasKey(c => c.CompanyNumber);
|
||||
|
||||
entity.Property(c => c.CompanyNumber)
|
||||
.HasMaxLength(32);
|
||||
});
|
||||
}
|
||||
|
||||
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var newCVChecks = ChangeTracker.Entries<CVCheck>()
|
||||
.Where(e => e.State == EntityState.Added)
|
||||
.Select(e => e.Entity);
|
||||
|
||||
foreach (var cvCheck in newCVChecks)
|
||||
{
|
||||
cvCheck.CreatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
return await base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
113
src/TrueCV.Infrastructure/DependencyInjection.cs
Normal file
113
src/TrueCV.Infrastructure/DependencyInjection.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using Azure.Storage.Blobs;
|
||||
using Hangfire;
|
||||
using Hangfire.SqlServer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Polly;
|
||||
using Polly.Extensions.Http;
|
||||
using TrueCV.Application.Interfaces;
|
||||
using TrueCV.Infrastructure.Configuration;
|
||||
using TrueCV.Infrastructure.Data;
|
||||
using TrueCV.Infrastructure.ExternalApis;
|
||||
using TrueCV.Infrastructure.Jobs;
|
||||
using TrueCV.Infrastructure.Services;
|
||||
|
||||
namespace TrueCV.Infrastructure;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
// Configure DbContext with SQL Server
|
||||
services.AddDbContext<ApplicationDbContext>(options =>
|
||||
options.UseSqlServer(
|
||||
configuration.GetConnectionString("DefaultConnection"),
|
||||
sqlOptions =>
|
||||
{
|
||||
sqlOptions.EnableRetryOnFailure(
|
||||
maxRetryCount: 3,
|
||||
maxRetryDelay: TimeSpan.FromSeconds(30),
|
||||
errorNumbersToAdd: null);
|
||||
}));
|
||||
|
||||
// Configure Hangfire with SQL Server storage
|
||||
services.AddHangfire(config => config
|
||||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||
.UseSimpleAssemblyNameTypeSerializer()
|
||||
.UseRecommendedSerializerSettings()
|
||||
.UseSqlServerStorage(
|
||||
configuration.GetConnectionString("HangfireConnection"),
|
||||
new SqlServerStorageOptions
|
||||
{
|
||||
CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
|
||||
SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),
|
||||
QueuePollInterval = TimeSpan.Zero,
|
||||
UseRecommendedIsolationLevel = true,
|
||||
DisableGlobalLocks = true
|
||||
}));
|
||||
|
||||
services.AddHangfireServer();
|
||||
|
||||
// Configure options
|
||||
services.Configure<CompaniesHouseSettings>(
|
||||
configuration.GetSection(CompaniesHouseSettings.SectionName));
|
||||
|
||||
services.Configure<AnthropicSettings>(
|
||||
configuration.GetSection(AnthropicSettings.SectionName));
|
||||
|
||||
services.Configure<AzureBlobSettings>(
|
||||
configuration.GetSection(AzureBlobSettings.SectionName));
|
||||
|
||||
// Configure HttpClient for CompaniesHouseClient with retry policy
|
||||
services.AddHttpClient<CompaniesHouseClient>((serviceProvider, client) =>
|
||||
{
|
||||
var settings = configuration
|
||||
.GetSection(CompaniesHouseSettings.SectionName)
|
||||
.Get<CompaniesHouseSettings>();
|
||||
|
||||
if (settings is not null)
|
||||
{
|
||||
client.BaseAddress = new Uri(settings.BaseUrl);
|
||||
}
|
||||
})
|
||||
.AddPolicyHandler(GetRetryPolicy());
|
||||
|
||||
// Configure BlobServiceClient
|
||||
var azureBlobConnectionString = configuration
|
||||
.GetSection(AzureBlobSettings.SectionName)
|
||||
.GetValue<string>("ConnectionString");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(azureBlobConnectionString))
|
||||
{
|
||||
services.AddSingleton(_ => new BlobServiceClient(azureBlobConnectionString));
|
||||
}
|
||||
|
||||
// Register services
|
||||
services.AddScoped<ICVParserService, CVParserService>();
|
||||
services.AddScoped<ICompanyVerifierService, CompanyVerifierService>();
|
||||
services.AddScoped<ITimelineAnalyserService, TimelineAnalyserService>();
|
||||
services.AddScoped<IFileStorageService, FileStorageService>();
|
||||
services.AddScoped<ICVCheckService, CVCheckService>();
|
||||
|
||||
// Register Hangfire jobs
|
||||
services.AddTransient<ProcessCVCheckJob>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
|
||||
{
|
||||
return HttpPolicyExtensions
|
||||
.HandleTransientHttpError()
|
||||
.OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||
.WaitAndRetryAsync(
|
||||
retryCount: 3,
|
||||
sleepDurationProvider: retryAttempt =>
|
||||
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
|
||||
onRetry: (outcome, timespan, retryAttempt, context) =>
|
||||
{
|
||||
// Logging could be added here via ILogger if injected
|
||||
});
|
||||
}
|
||||
}
|
||||
169
src/TrueCV.Infrastructure/ExternalApis/CompaniesHouseClient.cs
Normal file
169
src/TrueCV.Infrastructure/ExternalApis/CompaniesHouseClient.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
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) { }
|
||||
}
|
||||
16
src/TrueCV.Infrastructure/Identity/ApplicationUser.cs
Normal file
16
src/TrueCV.Infrastructure/Identity/ApplicationUser.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using TrueCV.Domain.Entities;
|
||||
using TrueCV.Domain.Enums;
|
||||
|
||||
namespace TrueCV.Infrastructure.Identity;
|
||||
|
||||
public class ApplicationUser : IdentityUser<Guid>
|
||||
{
|
||||
public UserPlan Plan { get; set; }
|
||||
|
||||
public string? StripeCustomerId { get; set; }
|
||||
|
||||
public int ChecksUsedThisMonth { get; set; }
|
||||
|
||||
public ICollection<CVCheck> CVChecks { get; set; } = new List<CVCheck>();
|
||||
}
|
||||
241
src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs
Normal file
241
src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs
Normal file
@@ -0,0 +1,241 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TrueCV.Application.Interfaces;
|
||||
using TrueCV.Application.Models;
|
||||
using TrueCV.Domain.Entities;
|
||||
using TrueCV.Domain.Enums;
|
||||
using TrueCV.Infrastructure.Data;
|
||||
|
||||
namespace TrueCV.Infrastructure.Jobs;
|
||||
|
||||
public sealed class ProcessCVCheckJob
|
||||
{
|
||||
private readonly ApplicationDbContext _dbContext;
|
||||
private readonly IFileStorageService _fileStorageService;
|
||||
private readonly ICVParserService _cvParserService;
|
||||
private readonly ICompanyVerifierService _companyVerifierService;
|
||||
private readonly ITimelineAnalyserService _timelineAnalyserService;
|
||||
private readonly ILogger<ProcessCVCheckJob> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private const int BaseScore = 100;
|
||||
private const int UnverifiedCompanyPenalty = 10;
|
||||
private const int GapMonthPenalty = 1;
|
||||
private const int MaxGapPenalty = 10;
|
||||
private const int OverlapMonthPenalty = 2;
|
||||
|
||||
public ProcessCVCheckJob(
|
||||
ApplicationDbContext dbContext,
|
||||
IFileStorageService fileStorageService,
|
||||
ICVParserService cvParserService,
|
||||
ICompanyVerifierService companyVerifierService,
|
||||
ITimelineAnalyserService timelineAnalyserService,
|
||||
ILogger<ProcessCVCheckJob> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_fileStorageService = fileStorageService;
|
||||
_cvParserService = cvParserService;
|
||||
_companyVerifierService = companyVerifierService;
|
||||
_timelineAnalyserService = timelineAnalyserService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(Guid cvCheckId, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting CV check processing for: {CheckId}", cvCheckId);
|
||||
|
||||
var cvCheck = await _dbContext.CVChecks
|
||||
.FirstOrDefaultAsync(c => c.Id == cvCheckId, cancellationToken);
|
||||
|
||||
if (cvCheck is null)
|
||||
{
|
||||
_logger.LogError("CV check not found: {CheckId}", cvCheckId);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Update status to Processing
|
||||
cvCheck.Status = CheckStatus.Processing;
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogDebug("CV check {CheckId} status updated to Processing", cvCheckId);
|
||||
|
||||
// Step 2: Download file from blob
|
||||
await using var fileStream = await _fileStorageService.DownloadAsync(cvCheck.BlobUrl);
|
||||
|
||||
_logger.LogDebug("Downloaded CV file for check {CheckId}", cvCheckId);
|
||||
|
||||
// Step 3: Parse CV
|
||||
var cvData = await _cvParserService.ParseAsync(fileStream, cvCheck.OriginalFileName);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Parsed CV for check {CheckId}: {EmploymentCount} employment entries",
|
||||
cvCheckId, cvData.Employment.Count);
|
||||
|
||||
// Step 4: Save extracted data
|
||||
cvCheck.ExtractedDataJson = JsonSerializer.Serialize(cvData, JsonOptions);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Step 5: Verify each employment entry
|
||||
var verificationResults = new List<CompanyVerificationResult>();
|
||||
foreach (var employment in cvData.Employment)
|
||||
{
|
||||
var result = await _companyVerifierService.VerifyCompanyAsync(
|
||||
employment.CompanyName,
|
||||
employment.StartDate,
|
||||
employment.EndDate);
|
||||
|
||||
verificationResults.Add(result);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Verified {Company}: {IsVerified} (Score: {Score}%)",
|
||||
employment.CompanyName, result.IsVerified, result.MatchScore);
|
||||
}
|
||||
|
||||
// Step 6: Analyse timeline
|
||||
var timelineAnalysis = _timelineAnalyserService.Analyse(cvData.Employment);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Timeline analysis for check {CheckId}: {GapCount} gaps, {OverlapCount} overlaps",
|
||||
cvCheckId, timelineAnalysis.Gaps.Count, timelineAnalysis.Overlaps.Count);
|
||||
|
||||
// Step 7: Calculate veracity score
|
||||
var (score, flags) = CalculateVeracityScore(verificationResults, timelineAnalysis);
|
||||
|
||||
_logger.LogDebug("Calculated veracity score for check {CheckId}: {Score}", cvCheckId, score);
|
||||
|
||||
// Step 8: Create CVFlag records
|
||||
foreach (var flag in flags)
|
||||
{
|
||||
var cvFlag = new CVFlag
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CVCheckId = cvCheckId,
|
||||
Category = Enum.Parse<FlagCategory>(flag.Category),
|
||||
Severity = Enum.Parse<FlagSeverity>(flag.Severity),
|
||||
Title = flag.Title,
|
||||
Description = flag.Description,
|
||||
ScoreImpact = flag.ScoreImpact
|
||||
};
|
||||
|
||||
_dbContext.CVFlags.Add(cvFlag);
|
||||
}
|
||||
|
||||
// Step 9: Generate veracity report
|
||||
var report = new VeracityReport
|
||||
{
|
||||
OverallScore = score,
|
||||
ScoreLabel = GetScoreLabel(score),
|
||||
EmploymentVerifications = verificationResults,
|
||||
TimelineAnalysis = timelineAnalysis,
|
||||
Flags = flags,
|
||||
GeneratedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
cvCheck.ReportJson = JsonSerializer.Serialize(report, JsonOptions);
|
||||
cvCheck.VeracityScore = score;
|
||||
|
||||
// Step 10: Update status to Completed
|
||||
cvCheck.Status = CheckStatus.Completed;
|
||||
cvCheck.CompletedAt = DateTime.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"CV check {CheckId} completed successfully with score {Score}",
|
||||
cvCheckId, score);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing CV check {CheckId}", cvCheckId);
|
||||
|
||||
cvCheck.Status = CheckStatus.Failed;
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static (int Score, List<FlagResult> Flags) CalculateVeracityScore(
|
||||
List<CompanyVerificationResult> verifications,
|
||||
TimelineAnalysisResult timeline)
|
||||
{
|
||||
var score = BaseScore;
|
||||
var flags = new List<FlagResult>();
|
||||
|
||||
// Penalty for unverified companies
|
||||
foreach (var verification in verifications.Where(v => !v.IsVerified))
|
||||
{
|
||||
score -= UnverifiedCompanyPenalty;
|
||||
|
||||
flags.Add(new FlagResult
|
||||
{
|
||||
Category = FlagCategory.Employment.ToString(),
|
||||
Severity = FlagSeverity.Warning.ToString(),
|
||||
Title = "Unverified Company",
|
||||
Description = $"Could not verify employment at '{verification.ClaimedCompany}'. {verification.VerificationNotes}",
|
||||
ScoreImpact = -UnverifiedCompanyPenalty
|
||||
});
|
||||
}
|
||||
|
||||
// Penalty for gaps (max -10 per gap)
|
||||
foreach (var gap in timeline.Gaps)
|
||||
{
|
||||
var gapPenalty = Math.Min(gap.Months * GapMonthPenalty, MaxGapPenalty);
|
||||
score -= gapPenalty;
|
||||
|
||||
var severity = gap.Months >= 6 ? FlagSeverity.Warning : FlagSeverity.Info;
|
||||
|
||||
flags.Add(new FlagResult
|
||||
{
|
||||
Category = FlagCategory.Timeline.ToString(),
|
||||
Severity = severity.ToString(),
|
||||
Title = "Employment Gap",
|
||||
Description = $"{gap.Months} month gap in employment from {gap.StartDate:MMM yyyy} to {gap.EndDate:MMM yyyy}",
|
||||
ScoreImpact = -gapPenalty
|
||||
});
|
||||
}
|
||||
|
||||
// Penalty for overlaps (only if > 2 months)
|
||||
foreach (var overlap in timeline.Overlaps)
|
||||
{
|
||||
var excessMonths = overlap.Months - 2; // Allow 2 month transition
|
||||
var overlapPenalty = excessMonths * OverlapMonthPenalty;
|
||||
score -= overlapPenalty;
|
||||
|
||||
var severity = overlap.Months >= 6 ? FlagSeverity.Critical : FlagSeverity.Warning;
|
||||
|
||||
flags.Add(new FlagResult
|
||||
{
|
||||
Category = FlagCategory.Timeline.ToString(),
|
||||
Severity = severity.ToString(),
|
||||
Title = "Employment Overlap",
|
||||
Description = $"{overlap.Months} month overlap between '{overlap.Company1}' and '{overlap.Company2}' ({overlap.OverlapStart:MMM yyyy} to {overlap.OverlapEnd:MMM yyyy})",
|
||||
ScoreImpact = -overlapPenalty
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure score doesn't go below 0
|
||||
score = Math.Max(0, score);
|
||||
|
||||
return (score, flags);
|
||||
}
|
||||
|
||||
private static string GetScoreLabel(int score)
|
||||
{
|
||||
return score switch
|
||||
{
|
||||
>= 90 => "Excellent",
|
||||
>= 75 => "Good",
|
||||
>= 60 => "Fair",
|
||||
>= 40 => "Poor",
|
||||
_ => "Very Poor"
|
||||
};
|
||||
}
|
||||
}
|
||||
164
src/TrueCV.Infrastructure/Services/CVCheckService.cs
Normal file
164
src/TrueCV.Infrastructure/Services/CVCheckService.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using System.Text.Json;
|
||||
using Hangfire;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TrueCV.Application.DTOs;
|
||||
using TrueCV.Application.Interfaces;
|
||||
using TrueCV.Application.Models;
|
||||
using TrueCV.Domain.Entities;
|
||||
using TrueCV.Domain.Enums;
|
||||
using TrueCV.Infrastructure.Data;
|
||||
using TrueCV.Infrastructure.Jobs;
|
||||
|
||||
namespace TrueCV.Infrastructure.Services;
|
||||
|
||||
public sealed class CVCheckService : ICVCheckService
|
||||
{
|
||||
private readonly ApplicationDbContext _dbContext;
|
||||
private readonly IFileStorageService _fileStorageService;
|
||||
private readonly IBackgroundJobClient _backgroundJobClient;
|
||||
private readonly ILogger<CVCheckService> _logger;
|
||||
|
||||
public CVCheckService(
|
||||
ApplicationDbContext dbContext,
|
||||
IFileStorageService fileStorageService,
|
||||
IBackgroundJobClient backgroundJobClient,
|
||||
ILogger<CVCheckService> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_fileStorageService = fileStorageService;
|
||||
_backgroundJobClient = backgroundJobClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateCheckAsync(Guid userId, Stream file, string fileName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(file);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
||||
|
||||
_logger.LogDebug("Creating CV check for user {UserId}, file: {FileName}", userId, fileName);
|
||||
|
||||
// Upload file to blob storage
|
||||
var blobUrl = await _fileStorageService.UploadAsync(file, fileName);
|
||||
|
||||
_logger.LogDebug("File uploaded to: {BlobUrl}", blobUrl);
|
||||
|
||||
// Create CV check record
|
||||
var cvCheck = new CVCheck
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
OriginalFileName = fileName,
|
||||
BlobUrl = blobUrl,
|
||||
Status = CheckStatus.Pending
|
||||
};
|
||||
|
||||
_dbContext.CVChecks.Add(cvCheck);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogDebug("CV check record created with ID: {CheckId}", cvCheck.Id);
|
||||
|
||||
// Queue background job for processing
|
||||
_backgroundJobClient.Enqueue<ProcessCVCheckJob>(job => job.ExecuteAsync(cvCheck.Id, CancellationToken.None));
|
||||
|
||||
_logger.LogInformation(
|
||||
"CV check {CheckId} created for user {UserId}, processing queued",
|
||||
cvCheck.Id, userId);
|
||||
|
||||
return cvCheck.Id;
|
||||
}
|
||||
|
||||
public async Task<CVCheckDto?> GetCheckAsync(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Retrieving CV check: {CheckId}", id);
|
||||
|
||||
var cvCheck = await _dbContext.CVChecks
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
if (cvCheck is null)
|
||||
{
|
||||
_logger.LogDebug("CV check not found: {CheckId}", id);
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapToDto(cvCheck);
|
||||
}
|
||||
|
||||
public async Task<List<CVCheckDto>> GetUserChecksAsync(Guid userId)
|
||||
{
|
||||
_logger.LogDebug("Retrieving CV checks for user: {UserId}", userId);
|
||||
|
||||
var checks = await _dbContext.CVChecks
|
||||
.AsNoTracking()
|
||||
.Where(c => c.UserId == userId)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
_logger.LogDebug("Found {Count} CV checks for user {UserId}", checks.Count, userId);
|
||||
|
||||
return checks.Select(MapToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<CVCheckDto?> GetCheckForUserAsync(Guid id, Guid userId)
|
||||
{
|
||||
_logger.LogDebug("Retrieving CV check {CheckId} for user {UserId}", id, userId);
|
||||
|
||||
var cvCheck = await _dbContext.CVChecks
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId);
|
||||
|
||||
if (cvCheck is null)
|
||||
{
|
||||
_logger.LogDebug("CV check not found: {CheckId} for user {UserId}", id, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapToDto(cvCheck);
|
||||
}
|
||||
|
||||
public async Task<VeracityReport?> GetReportAsync(Guid checkId, Guid userId)
|
||||
{
|
||||
_logger.LogDebug("Retrieving report for CV check {CheckId}, user {UserId}", checkId, userId);
|
||||
|
||||
var cvCheck = await _dbContext.CVChecks
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == checkId && c.UserId == userId);
|
||||
|
||||
if (cvCheck is null)
|
||||
{
|
||||
_logger.LogWarning("CV check not found: {CheckId} for user {UserId}", checkId, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cvCheck.Status != CheckStatus.Completed || string.IsNullOrEmpty(cvCheck.ReportJson))
|
||||
{
|
||||
_logger.LogDebug("CV check {CheckId} not completed or has no report", checkId);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var report = JsonSerializer.Deserialize<VeracityReport>(cvCheck.ReportJson);
|
||||
return report;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize report JSON for check {CheckId}", checkId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static CVCheckDto MapToDto(CVCheck cvCheck)
|
||||
{
|
||||
return new CVCheckDto
|
||||
{
|
||||
Id = cvCheck.Id,
|
||||
OriginalFileName = cvCheck.OriginalFileName,
|
||||
Status = cvCheck.Status.ToString(),
|
||||
VeracityScore = cvCheck.VeracityScore,
|
||||
CreatedAt = cvCheck.CreatedAt,
|
||||
CompletedAt = cvCheck.CompletedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
318
src/TrueCV.Infrastructure/Services/CVParserService.cs
Normal file
318
src/TrueCV.Infrastructure/Services/CVParserService.cs
Normal file
@@ -0,0 +1,318 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Anthropic.SDK;
|
||||
using Anthropic.SDK.Messaging;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TrueCV.Application.Interfaces;
|
||||
using TrueCV.Application.Models;
|
||||
using TrueCV.Infrastructure.Configuration;
|
||||
using UglyToad.PdfPig;
|
||||
|
||||
namespace TrueCV.Infrastructure.Services;
|
||||
|
||||
public sealed class CVParserService : ICVParserService
|
||||
{
|
||||
private readonly AnthropicClient _anthropicClient;
|
||||
private readonly ILogger<CVParserService> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private const string SystemPrompt = """
|
||||
You are a CV/Resume parser. Your task is to extract structured information from CV text.
|
||||
You must respond ONLY with valid JSON, no other text or markdown.
|
||||
""";
|
||||
|
||||
private const string ExtractionPrompt = """
|
||||
Parse the following CV text and extract the information into this exact JSON structure:
|
||||
|
||||
{
|
||||
"fullName": "string (required)",
|
||||
"email": "string or null",
|
||||
"phone": "string or null",
|
||||
"employment": [
|
||||
{
|
||||
"companyName": "string (required)",
|
||||
"jobTitle": "string (required)",
|
||||
"location": "string or null",
|
||||
"startDate": "YYYY-MM-DD or null",
|
||||
"endDate": "YYYY-MM-DD or null (null if current)",
|
||||
"isCurrent": "boolean",
|
||||
"description": "string or null"
|
||||
}
|
||||
],
|
||||
"education": [
|
||||
{
|
||||
"institution": "string (required)",
|
||||
"qualification": "string or null (e.g., BSc, MSc, PhD)",
|
||||
"subject": "string or null",
|
||||
"grade": "string or null",
|
||||
"startDate": "YYYY-MM-DD or null",
|
||||
"endDate": "YYYY-MM-DD or null"
|
||||
}
|
||||
],
|
||||
"skills": ["array of skill strings"]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- For dates, use the first day of the month if only month/year is given (e.g., "Jan 2020" becomes "2020-01-01")
|
||||
- For dates with only year, use January 1st (e.g., "2020" becomes "2020-01-01")
|
||||
- Set isCurrent to true if the job appears to be ongoing (e.g., "Present", "Current", no end date mentioned with recent start)
|
||||
- Extract all employment history in chronological order
|
||||
- If information is not available, use null
|
||||
- Do not invent or assume information not present in the text
|
||||
|
||||
CV TEXT:
|
||||
{CV_TEXT}
|
||||
""";
|
||||
|
||||
public CVParserService(
|
||||
IOptions<AnthropicSettings> settings,
|
||||
ILogger<CVParserService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_anthropicClient = new AnthropicClient(settings.Value.ApiKey);
|
||||
}
|
||||
|
||||
public async Task<CVData> ParseAsync(Stream fileStream, string fileName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fileStream);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
||||
|
||||
_logger.LogDebug("Parsing CV file: {FileName}", fileName);
|
||||
|
||||
var text = await ExtractTextAsync(fileStream, fileName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
_logger.LogWarning("No text content extracted from file: {FileName}", fileName);
|
||||
throw new InvalidOperationException($"Could not extract text content from file: {fileName}");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Extracted {CharCount} characters from {FileName}", text.Length, fileName);
|
||||
|
||||
var cvData = await ParseWithClaudeAsync(text);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Successfully parsed CV for {FullName} with {EmploymentCount} employment entries and {EducationCount} education entries",
|
||||
cvData.FullName,
|
||||
cvData.Employment.Count,
|
||||
cvData.Education.Count);
|
||||
|
||||
return cvData;
|
||||
}
|
||||
|
||||
private async Task<string> ExtractTextAsync(Stream fileStream, string fileName)
|
||||
{
|
||||
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
|
||||
return extension switch
|
||||
{
|
||||
".pdf" => await ExtractTextFromPdfAsync(fileStream),
|
||||
".docx" => ExtractTextFromDocx(fileStream),
|
||||
_ => throw new NotSupportedException($"File type '{extension}' is not supported. Only PDF and DOCX files are accepted.")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string> ExtractTextFromPdfAsync(Stream fileStream)
|
||||
{
|
||||
// Copy stream to memory for PdfPig (requires seekable stream)
|
||||
using var memoryStream = new MemoryStream();
|
||||
await fileStream.CopyToAsync(memoryStream);
|
||||
memoryStream.Position = 0;
|
||||
|
||||
using var document = PdfDocument.Open(memoryStream);
|
||||
var textBuilder = new StringBuilder();
|
||||
|
||||
foreach (var page in document.GetPages())
|
||||
{
|
||||
var pageText = page.Text;
|
||||
textBuilder.AppendLine(pageText);
|
||||
}
|
||||
|
||||
return textBuilder.ToString();
|
||||
}
|
||||
|
||||
private static string ExtractTextFromDocx(Stream fileStream)
|
||||
{
|
||||
using var document = WordprocessingDocument.Open(fileStream, false);
|
||||
var body = document.MainDocumentPart?.Document?.Body;
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var textBuilder = new StringBuilder();
|
||||
|
||||
foreach (var paragraph in body.Elements<Paragraph>())
|
||||
{
|
||||
var paragraphText = paragraph.InnerText;
|
||||
if (!string.IsNullOrWhiteSpace(paragraphText))
|
||||
{
|
||||
textBuilder.AppendLine(paragraphText);
|
||||
}
|
||||
}
|
||||
|
||||
return textBuilder.ToString();
|
||||
}
|
||||
|
||||
private async Task<CVData> ParseWithClaudeAsync(string cvText)
|
||||
{
|
||||
var prompt = ExtractionPrompt.Replace("{CV_TEXT}", cvText);
|
||||
|
||||
var messages = new List<Message>
|
||||
{
|
||||
new(RoleType.User, prompt)
|
||||
};
|
||||
|
||||
var parameters = new MessageParameters
|
||||
{
|
||||
Model = "claude-sonnet-4-20250514",
|
||||
MaxTokens = 4096,
|
||||
Messages = messages,
|
||||
System = [new SystemMessage(SystemPrompt)]
|
||||
};
|
||||
|
||||
_logger.LogDebug("Sending CV text to Claude API for parsing");
|
||||
|
||||
var response = await _anthropicClient.Messages.GetClaudeMessageAsync(parameters);
|
||||
|
||||
var responseText = response.Content
|
||||
.OfType<TextContent>()
|
||||
.FirstOrDefault()?.Text;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(responseText))
|
||||
{
|
||||
_logger.LogError("Claude API returned empty response");
|
||||
throw new InvalidOperationException("Failed to parse CV: AI returned empty response");
|
||||
}
|
||||
|
||||
// Clean up response - remove markdown code blocks if present
|
||||
responseText = CleanJsonResponse(responseText);
|
||||
|
||||
_logger.LogDebug("Received response from Claude API, parsing JSON");
|
||||
|
||||
try
|
||||
{
|
||||
var parsedResponse = JsonSerializer.Deserialize<ClaudeCVResponse>(responseText, JsonOptions);
|
||||
|
||||
if (parsedResponse is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to deserialize CV data from AI response");
|
||||
}
|
||||
|
||||
return MapToCVData(parsedResponse);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse Claude response as JSON: {Response}", responseText);
|
||||
throw new InvalidOperationException("Failed to parse CV: AI returned invalid JSON", ex);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
FullName = response.FullName ?? "Unknown",
|
||||
Email = response.Email,
|
||||
Phone = response.Phone,
|
||||
Employment = response.Employment?.Select(e => new EmploymentEntry
|
||||
{
|
||||
CompanyName = e.CompanyName ?? "Unknown Company",
|
||||
JobTitle = e.JobTitle ?? "Unknown Position",
|
||||
Location = e.Location,
|
||||
StartDate = ParseDate(e.StartDate),
|
||||
EndDate = ParseDate(e.EndDate),
|
||||
IsCurrent = e.IsCurrent ?? false,
|
||||
Description = e.Description
|
||||
}).ToList() ?? [],
|
||||
Education = response.Education?.Select(e => new EducationEntry
|
||||
{
|
||||
Institution = e.Institution ?? "Unknown Institution",
|
||||
Qualification = e.Qualification,
|
||||
Subject = e.Subject,
|
||||
Grade = e.Grade,
|
||||
StartDate = ParseDate(e.StartDate),
|
||||
EndDate = ParseDate(e.EndDate)
|
||||
}).ToList() ?? [],
|
||||
Skills = response.Skills ?? []
|
||||
};
|
||||
}
|
||||
|
||||
private static DateOnly? ParseDate(string? dateString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dateString))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateOnly.TryParse(dateString, out var date))
|
||||
{
|
||||
return date;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Internal DTOs for Claude response parsing
|
||||
private sealed record ClaudeCVResponse
|
||||
{
|
||||
public string? FullName { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public string? Phone { get; init; }
|
||||
public List<ClaudeEmploymentEntry>? Employment { get; init; }
|
||||
public List<ClaudeEducationEntry>? Education { get; init; }
|
||||
public List<string>? Skills { get; init; }
|
||||
}
|
||||
|
||||
private sealed record ClaudeEmploymentEntry
|
||||
{
|
||||
public string? CompanyName { get; init; }
|
||||
public string? JobTitle { get; init; }
|
||||
public string? Location { get; init; }
|
||||
public string? StartDate { get; init; }
|
||||
public string? EndDate { get; init; }
|
||||
public bool? IsCurrent { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
private sealed record ClaudeEducationEntry
|
||||
{
|
||||
public string? Institution { get; init; }
|
||||
public string? Qualification { get; init; }
|
||||
public string? Subject { get; init; }
|
||||
public string? Grade { get; init; }
|
||||
public string? StartDate { get; init; }
|
||||
public string? EndDate { get; init; }
|
||||
}
|
||||
}
|
||||
247
src/TrueCV.Infrastructure/Services/CompanyVerifierService.cs
Normal file
247
src/TrueCV.Infrastructure/Services/CompanyVerifierService.cs
Normal file
@@ -0,0 +1,247 @@
|
||||
using FuzzySharp;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TrueCV.Application.DTOs;
|
||||
using TrueCV.Application.Interfaces;
|
||||
using TrueCV.Application.Models;
|
||||
using TrueCV.Domain.Entities;
|
||||
using TrueCV.Infrastructure.Data;
|
||||
using TrueCV.Infrastructure.ExternalApis;
|
||||
|
||||
namespace TrueCV.Infrastructure.Services;
|
||||
|
||||
public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
{
|
||||
private readonly CompaniesHouseClient _companiesHouseClient;
|
||||
private readonly ApplicationDbContext _dbContext;
|
||||
private readonly ILogger<CompanyVerifierService> _logger;
|
||||
|
||||
private const int FuzzyMatchThreshold = 70;
|
||||
private const int CacheExpirationDays = 30;
|
||||
|
||||
public CompanyVerifierService(
|
||||
CompaniesHouseClient companiesHouseClient,
|
||||
ApplicationDbContext dbContext,
|
||||
ILogger<CompanyVerifierService> logger)
|
||||
{
|
||||
_companiesHouseClient = companiesHouseClient;
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<CompanyVerificationResult> VerifyCompanyAsync(
|
||||
string companyName,
|
||||
DateOnly? startDate,
|
||||
DateOnly? endDate)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(companyName);
|
||||
|
||||
_logger.LogDebug("Verifying company: {CompanyName}", companyName);
|
||||
|
||||
// Try to find a cached match first
|
||||
var cachedMatch = await FindCachedMatchAsync(companyName);
|
||||
if (cachedMatch is not null)
|
||||
{
|
||||
_logger.LogDebug("Found cached company match for: {CompanyName}", companyName);
|
||||
return CreateVerificationResult(companyName, cachedMatch, startDate, endDate);
|
||||
}
|
||||
|
||||
// Search Companies House
|
||||
try
|
||||
{
|
||||
var searchResponse = await _companiesHouseClient.SearchCompaniesAsync(companyName);
|
||||
|
||||
if (searchResponse?.Items is null || searchResponse.Items.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No companies found for: {CompanyName}", companyName);
|
||||
return CreateUnverifiedResult(companyName, startDate, endDate, "No matching company found in Companies House");
|
||||
}
|
||||
|
||||
// Find best fuzzy match
|
||||
var bestMatch = FindBestMatch(companyName, searchResponse.Items);
|
||||
|
||||
if (bestMatch is null)
|
||||
{
|
||||
_logger.LogDebug("No fuzzy match above threshold for: {CompanyName}", companyName);
|
||||
return CreateUnverifiedResult(companyName, startDate, endDate,
|
||||
$"No company name matched above {FuzzyMatchThreshold}% threshold");
|
||||
}
|
||||
|
||||
// Cache the matched company
|
||||
var match = bestMatch.Value;
|
||||
await CacheCompanyAsync(match.Item);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Verified company {ClaimedName} matched to {MatchedName} with score {Score}%",
|
||||
companyName, match.Item.Title, match.Score);
|
||||
|
||||
return new CompanyVerificationResult
|
||||
{
|
||||
ClaimedCompany = companyName,
|
||||
MatchedCompanyName = match.Item.Title,
|
||||
MatchedCompanyNumber = match.Item.CompanyNumber,
|
||||
MatchScore = match.Score,
|
||||
IsVerified = true,
|
||||
VerificationNotes = $"Matched with {match.Score}% confidence",
|
||||
ClaimedStartDate = startDate,
|
||||
ClaimedEndDate = endDate
|
||||
};
|
||||
}
|
||||
catch (CompaniesHouseRateLimitException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Rate limit hit while verifying company: {CompanyName}", companyName);
|
||||
return CreateUnverifiedResult(companyName, startDate, endDate,
|
||||
"Verification temporarily unavailable due to rate limiting");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<CompanySearchResult>> SearchCompaniesAsync(string query)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query);
|
||||
|
||||
_logger.LogDebug("Searching companies for query: {Query}", query);
|
||||
|
||||
var response = await _companiesHouseClient.SearchCompaniesAsync(query);
|
||||
|
||||
if (response?.Items is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.Items.Select(item => new CompanySearchResult
|
||||
{
|
||||
CompanyNumber = item.CompanyNumber,
|
||||
CompanyName = item.Title,
|
||||
CompanyStatus = item.CompanyStatus ?? "Unknown",
|
||||
IncorporationDate = ParseDate(item.DateOfCreation),
|
||||
AddressSnippet = item.AddressSnippet
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<CompanyCache?> FindCachedMatchAsync(string companyName)
|
||||
{
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-CacheExpirationDays);
|
||||
|
||||
// Get recent cached companies
|
||||
var cachedCompanies = await _dbContext.CompanyCache
|
||||
.Where(c => c.CachedAt >= cutoffDate)
|
||||
.ToListAsync();
|
||||
|
||||
if (cachedCompanies.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find best fuzzy match in cache
|
||||
var matches = cachedCompanies
|
||||
.Select(c => new { Company = c, Score = Fuzz.Ratio(companyName.ToUpperInvariant(), c.CompanyName.ToUpperInvariant()) })
|
||||
.Where(m => m.Score >= FuzzyMatchThreshold)
|
||||
.OrderByDescending(m => m.Score)
|
||||
.FirstOrDefault();
|
||||
|
||||
return matches?.Company;
|
||||
}
|
||||
|
||||
private static (CompaniesHouseSearchItem Item, int Score)? FindBestMatch(
|
||||
string companyName,
|
||||
List<CompaniesHouseSearchItem> items)
|
||||
{
|
||||
var normalizedSearch = companyName.ToUpperInvariant();
|
||||
|
||||
var matches = items
|
||||
.Select(item => (Item: item, Score: Fuzz.Ratio(normalizedSearch, item.Title.ToUpperInvariant())))
|
||||
.Where(m => m.Score >= FuzzyMatchThreshold)
|
||||
.OrderByDescending(m => m.Score)
|
||||
.ToList();
|
||||
|
||||
return matches.Count > 0 ? matches[0] : null;
|
||||
}
|
||||
|
||||
private async Task CacheCompanyAsync(CompaniesHouseSearchItem item)
|
||||
{
|
||||
var existingCache = await _dbContext.CompanyCache
|
||||
.FirstOrDefaultAsync(c => c.CompanyNumber == item.CompanyNumber);
|
||||
|
||||
if (existingCache is not null)
|
||||
{
|
||||
existingCache.CompanyName = item.Title;
|
||||
existingCache.Status = item.CompanyStatus ?? "Unknown";
|
||||
existingCache.IncorporationDate = ParseDate(item.DateOfCreation);
|
||||
existingCache.DissolutionDate = ParseDate(item.DateOfCessation);
|
||||
existingCache.CachedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
var cacheEntry = new CompanyCache
|
||||
{
|
||||
CompanyNumber = item.CompanyNumber,
|
||||
CompanyName = item.Title,
|
||||
Status = item.CompanyStatus ?? "Unknown",
|
||||
IncorporationDate = ParseDate(item.DateOfCreation),
|
||||
DissolutionDate = ParseDate(item.DateOfCessation),
|
||||
CachedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_dbContext.CompanyCache.Add(cacheEntry);
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static CompanyVerificationResult CreateVerificationResult(
|
||||
string claimedCompany,
|
||||
CompanyCache cached,
|
||||
DateOnly? startDate,
|
||||
DateOnly? endDate)
|
||||
{
|
||||
var matchScore = Fuzz.Ratio(
|
||||
claimedCompany.ToUpperInvariant(),
|
||||
cached.CompanyName.ToUpperInvariant());
|
||||
|
||||
return new CompanyVerificationResult
|
||||
{
|
||||
ClaimedCompany = claimedCompany,
|
||||
MatchedCompanyName = cached.CompanyName,
|
||||
MatchedCompanyNumber = cached.CompanyNumber,
|
||||
MatchScore = matchScore,
|
||||
IsVerified = true,
|
||||
VerificationNotes = $"Matched from cache with {matchScore}% confidence",
|
||||
ClaimedStartDate = startDate,
|
||||
ClaimedEndDate = endDate
|
||||
};
|
||||
}
|
||||
|
||||
private static CompanyVerificationResult CreateUnverifiedResult(
|
||||
string companyName,
|
||||
DateOnly? startDate,
|
||||
DateOnly? endDate,
|
||||
string reason)
|
||||
{
|
||||
return new CompanyVerificationResult
|
||||
{
|
||||
ClaimedCompany = companyName,
|
||||
MatchedCompanyName = null,
|
||||
MatchedCompanyNumber = null,
|
||||
MatchScore = 0,
|
||||
IsVerified = false,
|
||||
VerificationNotes = reason,
|
||||
ClaimedStartDate = startDate,
|
||||
ClaimedEndDate = endDate
|
||||
};
|
||||
}
|
||||
|
||||
private static DateOnly? ParseDate(string? dateString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dateString))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateOnly.TryParse(dateString, out var date))
|
||||
{
|
||||
return date;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
120
src/TrueCV.Infrastructure/Services/FileStorageService.cs
Normal file
120
src/TrueCV.Infrastructure/Services/FileStorageService.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TrueCV.Application.Interfaces;
|
||||
using TrueCV.Infrastructure.Configuration;
|
||||
|
||||
namespace TrueCV.Infrastructure.Services;
|
||||
|
||||
public sealed class FileStorageService : IFileStorageService
|
||||
{
|
||||
private readonly BlobContainerClient _containerClient;
|
||||
private readonly ILogger<FileStorageService> _logger;
|
||||
|
||||
public FileStorageService(
|
||||
IOptions<AzureBlobSettings> settings,
|
||||
ILogger<FileStorageService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
var blobServiceClient = new BlobServiceClient(settings.Value.ConnectionString);
|
||||
_containerClient = blobServiceClient.GetBlobContainerClient(settings.Value.ContainerName);
|
||||
}
|
||||
|
||||
public async Task<string> UploadAsync(Stream fileStream, string fileName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fileStream);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
||||
|
||||
var extension = Path.GetExtension(fileName);
|
||||
var uniqueBlobName = $"{Guid.NewGuid()}{extension}";
|
||||
|
||||
_logger.LogDebug("Uploading file {FileName} as blob {BlobName}", fileName, uniqueBlobName);
|
||||
|
||||
var blobClient = _containerClient.GetBlobClient(uniqueBlobName);
|
||||
|
||||
await _containerClient.CreateIfNotExistsAsync();
|
||||
|
||||
var httpHeaders = new BlobHttpHeaders
|
||||
{
|
||||
ContentType = GetContentType(extension)
|
||||
};
|
||||
|
||||
await blobClient.UploadAsync(fileStream, new BlobUploadOptions
|
||||
{
|
||||
HttpHeaders = httpHeaders,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["originalFileName"] = fileName,
|
||||
["uploadedAt"] = DateTime.UtcNow.ToString("O")
|
||||
}
|
||||
});
|
||||
|
||||
var blobUrl = blobClient.Uri.ToString();
|
||||
|
||||
_logger.LogInformation("Successfully uploaded file {FileName} to {BlobUrl}", fileName, blobUrl);
|
||||
|
||||
return blobUrl;
|
||||
}
|
||||
|
||||
public async Task<Stream> DownloadAsync(string blobUrl)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(blobUrl);
|
||||
|
||||
var blobName = ExtractBlobNameFromUrl(blobUrl);
|
||||
|
||||
_logger.LogDebug("Downloading blob {BlobName} from {BlobUrl}", blobName, blobUrl);
|
||||
|
||||
var blobClient = _containerClient.GetBlobClient(blobName);
|
||||
|
||||
var response = await blobClient.DownloadStreamingAsync();
|
||||
|
||||
_logger.LogDebug("Successfully downloaded blob {BlobName}", blobName);
|
||||
|
||||
return response.Value.Content;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string blobUrl)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(blobUrl);
|
||||
|
||||
var blobName = ExtractBlobNameFromUrl(blobUrl);
|
||||
|
||||
_logger.LogDebug("Deleting blob {BlobName}", blobName);
|
||||
|
||||
var blobClient = _containerClient.GetBlobClient(blobName);
|
||||
|
||||
var deleted = await blobClient.DeleteIfExistsAsync();
|
||||
|
||||
if (deleted)
|
||||
{
|
||||
_logger.LogInformation("Successfully deleted blob {BlobName}", blobName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Blob {BlobName} did not exist when attempting to delete", blobName);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractBlobNameFromUrl(string blobUrl)
|
||||
{
|
||||
var uri = new Uri(blobUrl);
|
||||
var segments = uri.Segments;
|
||||
|
||||
// The blob name is the last segment after the container name
|
||||
// URL format: https://account.blob.core.windows.net/container/blobname
|
||||
return segments.Length > 2 ? segments[^1] : throw new ArgumentException("Invalid blob URL", nameof(blobUrl));
|
||||
}
|
||||
|
||||
private static string GetContentType(string extension)
|
||||
{
|
||||
return extension.ToLowerInvariant() switch
|
||||
{
|
||||
".pdf" => "application/pdf",
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".doc" => "application/msword",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
}
|
||||
205
src/TrueCV.Infrastructure/Services/TimelineAnalyserService.cs
Normal file
205
src/TrueCV.Infrastructure/Services/TimelineAnalyserService.cs
Normal file
@@ -0,0 +1,205 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TrueCV.Application.Interfaces;
|
||||
using TrueCV.Application.Models;
|
||||
|
||||
namespace TrueCV.Infrastructure.Services;
|
||||
|
||||
public sealed class TimelineAnalyserService : ITimelineAnalyserService
|
||||
{
|
||||
private readonly ILogger<TimelineAnalyserService> _logger;
|
||||
|
||||
private const int MinimumGapMonths = 3;
|
||||
private const int AllowedOverlapMonths = 2;
|
||||
|
||||
public TimelineAnalyserService(ILogger<TimelineAnalyserService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public TimelineAnalysisResult Analyse(List<EmploymentEntry> employmentHistory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(employmentHistory);
|
||||
|
||||
if (employmentHistory.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No employment history to analyse");
|
||||
return new TimelineAnalysisResult
|
||||
{
|
||||
TotalGapMonths = 0,
|
||||
TotalOverlapMonths = 0,
|
||||
Gaps = [],
|
||||
Overlaps = []
|
||||
};
|
||||
}
|
||||
|
||||
// Filter entries with valid dates and sort by start date
|
||||
var sortedEmployment = employmentHistory
|
||||
.Where(e => e.StartDate.HasValue)
|
||||
.OrderBy(e => e.StartDate!.Value)
|
||||
.ToList();
|
||||
|
||||
if (sortedEmployment.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No employment entries with valid dates to analyse");
|
||||
return new TimelineAnalysisResult
|
||||
{
|
||||
TotalGapMonths = 0,
|
||||
TotalOverlapMonths = 0,
|
||||
Gaps = [],
|
||||
Overlaps = []
|
||||
};
|
||||
}
|
||||
|
||||
var gaps = DetectGaps(sortedEmployment);
|
||||
var overlaps = DetectOverlaps(sortedEmployment);
|
||||
|
||||
var totalGapMonths = gaps.Sum(g => g.Months);
|
||||
var totalOverlapMonths = overlaps.Sum(o => o.Months);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Timeline analysis complete: {GapCount} gaps ({TotalGapMonths} months), {OverlapCount} overlaps ({TotalOverlapMonths} months)",
|
||||
gaps.Count, totalGapMonths, overlaps.Count, totalOverlapMonths);
|
||||
|
||||
return new TimelineAnalysisResult
|
||||
{
|
||||
TotalGapMonths = totalGapMonths,
|
||||
TotalOverlapMonths = totalOverlapMonths,
|
||||
Gaps = gaps,
|
||||
Overlaps = overlaps
|
||||
};
|
||||
}
|
||||
|
||||
private List<TimelineGap> DetectGaps(List<EmploymentEntry> sortedEmployment)
|
||||
{
|
||||
var gaps = new List<TimelineGap>();
|
||||
|
||||
for (var i = 0; i < sortedEmployment.Count - 1; i++)
|
||||
{
|
||||
var current = sortedEmployment[i];
|
||||
var next = sortedEmployment[i + 1];
|
||||
|
||||
// Get the effective end date for the current position
|
||||
var currentEndDate = GetEffectiveEndDate(current);
|
||||
var nextStartDate = next.StartDate!.Value;
|
||||
|
||||
// Skip if there's no gap or overlap
|
||||
if (currentEndDate >= nextStartDate)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var gapMonths = CalculateMonthsDifference(currentEndDate, nextStartDate);
|
||||
|
||||
// Only report gaps of 3+ months
|
||||
if (gapMonths >= MinimumGapMonths)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Detected {Months} month gap between {EndDate} and {StartDate}",
|
||||
gapMonths, currentEndDate, nextStartDate);
|
||||
|
||||
gaps.Add(new TimelineGap
|
||||
{
|
||||
StartDate = currentEndDate,
|
||||
EndDate = nextStartDate,
|
||||
Months = gapMonths
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return gaps;
|
||||
}
|
||||
|
||||
private List<TimelineOverlap> DetectOverlaps(List<EmploymentEntry> sortedEmployment)
|
||||
{
|
||||
var overlaps = new List<TimelineOverlap>();
|
||||
|
||||
for (var i = 0; i < sortedEmployment.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < sortedEmployment.Count; j++)
|
||||
{
|
||||
var earlier = sortedEmployment[i];
|
||||
var later = sortedEmployment[j];
|
||||
|
||||
var overlap = CalculateOverlap(earlier, later);
|
||||
|
||||
if (overlap is not null && overlap.Value.Months > AllowedOverlapMonths)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Detected {Months} month overlap between {Company1} and {Company2}",
|
||||
overlap.Value.Months, earlier.CompanyName, later.CompanyName);
|
||||
|
||||
overlaps.Add(new TimelineOverlap
|
||||
{
|
||||
Company1 = earlier.CompanyName,
|
||||
Company2 = later.CompanyName,
|
||||
OverlapStart = overlap.Value.Start,
|
||||
OverlapEnd = overlap.Value.End,
|
||||
Months = overlap.Value.Months
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return overlaps;
|
||||
}
|
||||
|
||||
private static (DateOnly Start, DateOnly End, int Months)? CalculateOverlap(
|
||||
EmploymentEntry earlier,
|
||||
EmploymentEntry later)
|
||||
{
|
||||
if (!earlier.StartDate.HasValue || !later.StartDate.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var earlierEnd = GetEffectiveEndDate(earlier);
|
||||
var laterStart = later.StartDate.Value;
|
||||
|
||||
// No overlap if earlier job ended before later job started
|
||||
if (earlierEnd <= laterStart)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var laterEnd = GetEffectiveEndDate(later);
|
||||
|
||||
// The overlap period
|
||||
var overlapStart = laterStart;
|
||||
var overlapEnd = earlierEnd < laterEnd ? earlierEnd : laterEnd;
|
||||
|
||||
if (overlapStart >= overlapEnd)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var months = CalculateMonthsDifference(overlapStart, overlapEnd);
|
||||
|
||||
return (overlapStart, overlapEnd, months);
|
||||
}
|
||||
|
||||
private static DateOnly GetEffectiveEndDate(EmploymentEntry entry)
|
||||
{
|
||||
if (entry.EndDate.HasValue)
|
||||
{
|
||||
return entry.EndDate.Value;
|
||||
}
|
||||
|
||||
// If marked as current or no end date, use today
|
||||
return DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
private static int CalculateMonthsDifference(DateOnly startDate, DateOnly endDate)
|
||||
{
|
||||
var yearDiff = endDate.Year - startDate.Year;
|
||||
var monthDiff = endDate.Month - startDate.Month;
|
||||
var totalMonths = (yearDiff * 12) + monthDiff;
|
||||
|
||||
// Add a month if we've passed the day in the month
|
||||
if (endDate.Day >= startDate.Day)
|
||||
{
|
||||
totalMonths++;
|
||||
}
|
||||
|
||||
return Math.Max(0, totalMonths);
|
||||
}
|
||||
}
|
||||
27
src/TrueCV.Infrastructure/TrueCV.Infrastructure.csproj
Normal file
27
src/TrueCV.Infrastructure/TrueCV.Infrastructure.csproj
Normal file
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TrueCV.Application\TrueCV.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Anthropic.SDK" Version="5.8.0" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.27.0" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.4.1" />
|
||||
<PackageReference Include="FuzzySharp" Version="2.0.2" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.*" />
|
||||
<PackageReference Include="Hangfire.SqlServer" Version="1.8.*" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.*" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.*" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="UglyToad.PdfPig" Version="1.7.0-custom-5" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
20
src/TrueCV.Web/Components/App.razor
Normal file
20
src/TrueCV.Web/Components/App.razor
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
<link rel="stylesheet" href="TrueCV.Web.styles.css" />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet @rendermode="InteractiveServer" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
94
src/TrueCV.Web/Components/Layout/MainLayout.razor
Normal file
94
src/TrueCV.Web/Components/Layout/MainLayout.razor
Normal file
@@ -0,0 +1,94 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="d-flex flex-column min-vh-100">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary shadow-sm">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-bold" href="/">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-patch-check-fill me-2" viewBox="0 0 16 16">
|
||||
<path d="M10.067.87a2.89 2.89 0 0 0-4.134 0l-.622.638-.89-.011a2.89 2.89 0 0 0-2.924 2.924l.01.89-.636.622a2.89 2.89 0 0 0 0 4.134l.637.622-.011.89a2.89 2.89 0 0 0 2.924 2.924l.89-.01.622.636a2.89 2.89 0 0 0 4.134 0l.622-.637.89.011a2.89 2.89 0 0 0 2.924-2.924l-.01-.89.636-.622a2.89 2.89 0 0 0 0-4.134l-.637-.622.011-.89a2.89 2.89 0 0 0-2.924-2.924l-.89.01-.622-.636zm.287 5.984-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7 8.793l2.646-2.647a.5.5 0 0 1 .708.708z"/>
|
||||
</svg>
|
||||
TrueCV
|
||||
</a>
|
||||
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">
|
||||
Home
|
||||
</NavLink>
|
||||
</li>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/dashboard">
|
||||
Dashboard
|
||||
</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/check">
|
||||
New Check
|
||||
</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-person-circle me-1" viewBox="0 0 16 16">
|
||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||
</svg>
|
||||
@context.User.Identity?.Name
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<form action="/account/logout" method="post">
|
||||
<AntiforgeryToken />
|
||||
<button type="submit" class="dropdown-item">Logout</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/account/login">
|
||||
Login
|
||||
</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link btn btn-outline-light ms-2 px-3" href="/account/register">
|
||||
Register
|
||||
</NavLink>
|
||||
</li>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="flex-grow-1">
|
||||
@Body
|
||||
</main>
|
||||
|
||||
<footer class="bg-dark text-light py-4 mt-auto">
|
||||
<div class="container text-center">
|
||||
<p class="mb-0">© @DateTime.Now.Year TrueCV. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" class="alert alert-danger fixed-bottom m-3" style="display: none;">
|
||||
An unhandled error has occurred.
|
||||
<a href="" class="alert-link reload">Reload</a>
|
||||
<button type="button" class="btn-close float-end dismiss" aria-label="Close"></button>
|
||||
</div>
|
||||
96
src/TrueCV.Web/Components/Layout/MainLayout.razor.css
Normal file
96
src/TrueCV.Web/Components/Layout/MainLayout.razor.css
Normal file
@@ -0,0 +1,96 @@
|
||||
.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;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
148
src/TrueCV.Web/Components/Pages/Account/Login.razor
Normal file
148
src/TrueCV.Web/Components/Pages/Account/Login.razor
Normal file
@@ -0,0 +1,148 @@
|
||||
@page "/account/login"
|
||||
@using TrueCV.Web.Components.Layout
|
||||
@layout MainLayout
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using TrueCV.Infrastructure.Identity
|
||||
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<PageTitle>Login - TrueCV</PageTitle>
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5">
|
||||
<div class="card border-0 shadow">
|
||||
<div class="card-body p-5">
|
||||
<div class="text-center mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-patch-check-fill text-primary mb-3" viewBox="0 0 16 16">
|
||||
<path d="M10.067.87a2.89 2.89 0 0 0-4.134 0l-.622.638-.89-.011a2.89 2.89 0 0 0-2.924 2.924l.01.89-.636.622a2.89 2.89 0 0 0 0 4.134l.637.622-.011.89a2.89 2.89 0 0 0 2.924 2.924l.89-.01.622.636a2.89 2.89 0 0 0 4.134 0l.622-.637.89.011a2.89 2.89 0 0 0 2.924-2.924l-.01-.89.636-.622a2.89 2.89 0 0 0 0-4.134l-.637-.622.011-.89a2.89 2.89 0 0 0-2.924-2.924l-.89.01-.622-.636zm.287 5.984-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7 8.793l2.646-2.647a.5.5 0 0 1 .708.708z"/>
|
||||
</svg>
|
||||
<h3 class="fw-bold">Welcome Back</h3>
|
||||
<p class="text-muted">Sign in to your TrueCV account</p>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
@_errorMessage
|
||||
<button type="button" class="btn-close" @onclick="() => _errorMessage = null" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<EditForm Model="_model" OnValidSubmit="HandleLogin" FormName="login">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email address</label>
|
||||
<InputText id="email" class="form-control form-control-lg" @bind-Value="_model.Email"
|
||||
placeholder="name@example.com" />
|
||||
<ValidationMessage For="() => _model.Email" class="text-danger" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<InputText id="password" type="password" class="form-control form-control-lg"
|
||||
@bind-Value="_model.Password" placeholder="Enter your password" />
|
||||
<ValidationMessage For="() => _model.Password" class="text-danger" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<InputCheckbox id="rememberMe" class="form-check-input" @bind-Value="_model.RememberMe" />
|
||||
<label class="form-check-label" for="rememberMe">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg" disabled="@_isLoading">
|
||||
@if (_isLoading)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||
<span>Signing in...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Sign In</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
<div class="text-center">
|
||||
<p class="mb-0">
|
||||
Don't have an account?
|
||||
<a href="/account/register" class="text-decoration-none fw-medium">Create one</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private LoginModel _model = new();
|
||||
private bool _isLoading;
|
||||
private string? _errorMessage;
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
public string? ReturnUrl { get; set; }
|
||||
|
||||
private async Task HandleLogin()
|
||||
{
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await SignInManager.PasswordSignInAsync(
|
||||
_model.Email,
|
||||
_model.Password,
|
||||
_model.RememberMe,
|
||||
lockoutOnFailure: false);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
var returnUrl = string.IsNullOrEmpty(ReturnUrl) ? "/dashboard" : ReturnUrl;
|
||||
NavigationManager.NavigateTo(returnUrl, forceLoad: true);
|
||||
}
|
||||
else if (result.IsLockedOut)
|
||||
{
|
||||
_errorMessage = "This account has been locked out. Please try again later.";
|
||||
}
|
||||
else if (result.IsNotAllowed)
|
||||
{
|
||||
_errorMessage = "This account is not allowed to sign in.";
|
||||
}
|
||||
else
|
||||
{
|
||||
_errorMessage = "Invalid email or password.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"An error occurred: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class LoginModel
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required")]
|
||||
[System.ComponentModel.DataAnnotations.EmailAddress(ErrorMessage = "Invalid email format")]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
public bool RememberMe { get; set; }
|
||||
}
|
||||
}
|
||||
163
src/TrueCV.Web/Components/Pages/Account/Register.razor
Normal file
163
src/TrueCV.Web/Components/Pages/Account/Register.razor
Normal file
@@ -0,0 +1,163 @@
|
||||
@page "/account/register"
|
||||
@using TrueCV.Web.Components.Layout
|
||||
@layout MainLayout
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using TrueCV.Infrastructure.Identity
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<PageTitle>Register - TrueCV</PageTitle>
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5">
|
||||
<div class="card border-0 shadow">
|
||||
<div class="card-body p-5">
|
||||
<div class="text-center mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-patch-check-fill text-primary mb-3" viewBox="0 0 16 16">
|
||||
<path d="M10.067.87a2.89 2.89 0 0 0-4.134 0l-.622.638-.89-.011a2.89 2.89 0 0 0-2.924 2.924l.01.89-.636.622a2.89 2.89 0 0 0 0 4.134l.637.622-.011.89a2.89 2.89 0 0 0 2.924 2.924l.89-.01.622.636a2.89 2.89 0 0 0 4.134 0l.622-.637.89.011a2.89 2.89 0 0 0 2.924-2.924l-.01-.89.636-.622a2.89 2.89 0 0 0 0-4.134l-.637-.622.011-.89a2.89 2.89 0 0 0-2.924-2.924l-.89.01-.622-.636zm.287 5.984-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7 8.793l2.646-2.647a.5.5 0 0 1 .708.708z"/>
|
||||
</svg>
|
||||
<h3 class="fw-bold">Create Account</h3>
|
||||
<p class="text-muted">Start verifying CVs with confidence</p>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
@_errorMessage
|
||||
<button type="button" class="btn-close" @onclick="() => _errorMessage = null" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<EditForm Model="_model" OnValidSubmit="HandleRegister" FormName="register">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email address</label>
|
||||
<InputText id="email" class="form-control form-control-lg" @bind-Value="_model.Email"
|
||||
placeholder="name@example.com" />
|
||||
<ValidationMessage For="() => _model.Email" class="text-danger" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<InputText id="password" type="password" class="form-control form-control-lg"
|
||||
@bind-Value="_model.Password" placeholder="Create a password" />
|
||||
<ValidationMessage For="() => _model.Password" class="text-danger" />
|
||||
<div class="form-text">Password must be at least 6 characters.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="confirmPassword" class="form-label">Confirm Password</label>
|
||||
<InputText id="confirmPassword" type="password" class="form-control form-control-lg"
|
||||
@bind-Value="_model.ConfirmPassword" placeholder="Confirm your password" />
|
||||
<ValidationMessage For="() => _model.ConfirmPassword" class="text-danger" />
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg" disabled="@_isLoading">
|
||||
@if (_isLoading)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||
<span>Creating account...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Create Account</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
<div class="text-center">
|
||||
<p class="mb-0">
|
||||
Already have an account?
|
||||
<a href="/account/login" class="text-decoration-none fw-medium">Sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<small class="text-muted">
|
||||
By creating an account, you agree to our
|
||||
<a href="#" class="text-decoration-none">Terms of Service</a>
|
||||
and
|
||||
<a href="#" class="text-decoration-none">Privacy Policy</a>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private RegisterModel _model = new();
|
||||
private bool _isLoading;
|
||||
private string? _errorMessage;
|
||||
|
||||
private async Task HandleRegister()
|
||||
{
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (_model.Password != _model.ConfirmPassword)
|
||||
{
|
||||
_errorMessage = "Passwords do not match.";
|
||||
_isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = _model.Email,
|
||||
Email = _model.Email,
|
||||
Plan = Domain.Enums.UserPlan.Free,
|
||||
ChecksUsedThisMonth = 0
|
||||
};
|
||||
|
||||
var result = await UserManager.CreateAsync(user, _model.Password);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await SignInManager.SignInAsync(user, isPersistent: false);
|
||||
NavigationManager.NavigateTo("/dashboard", forceLoad: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
var errors = result.Errors.Select(e => e.Description);
|
||||
_errorMessage = string.Join(" ", errors);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"An error occurred: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RegisterModel
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required")]
|
||||
[System.ComponentModel.DataAnnotations.EmailAddress(ErrorMessage = "Invalid email format")]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
|
||||
[System.ComponentModel.DataAnnotations.MinLength(6, ErrorMessage = "Password must be at least 6 characters")]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your password")]
|
||||
[System.ComponentModel.DataAnnotations.Compare(nameof(Password), ErrorMessage = "Passwords do not match")]
|
||||
public string ConfirmPassword { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
261
src/TrueCV.Web/Components/Pages/Check.razor
Normal file
261
src/TrueCV.Web/Components/Pages/Check.razor
Normal file
@@ -0,0 +1,261 @@
|
||||
@page "/check"
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@inject ICVCheckService CVCheckService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
|
||||
<PageTitle>Upload CV - TrueCV</PageTitle>
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="text-center mb-4">
|
||||
<h1 class="fw-bold">Upload CV for Verification</h1>
|
||||
<p class="text-muted lead">Upload a CV in PDF or DOCX format to begin the verification process</p>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
@_errorMessage
|
||||
<button type="button" class="btn-close" @onclick="() => _errorMessage = null" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card border-0 shadow">
|
||||
<div class="card-body p-5">
|
||||
@if (_isUploading)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary mb-3" role="status" style="width: 3rem; height: 3rem;">
|
||||
<span class="visually-hidden">Uploading...</span>
|
||||
</div>
|
||||
<h5 class="mb-2">Uploading your CV...</h5>
|
||||
<p class="text-muted">Please wait while we process your file</p>
|
||||
<div class="progress" style="height: 8px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar"
|
||||
style="width: @(_uploadProgress)%"
|
||||
aria-valuenow="@_uploadProgress"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="upload-area @(_isDragging ? "dragging" : "")"
|
||||
@ondragenter="HandleDragEnter"
|
||||
@ondragleave="HandleDragLeave"
|
||||
@ondragover:preventDefault
|
||||
@ondrop="HandleDrop"
|
||||
@ondrop:preventDefault>
|
||||
|
||||
<InputFile OnChange="HandleFileSelected"
|
||||
accept=".pdf,.docx"
|
||||
class="d-none"
|
||||
id="fileInput" />
|
||||
|
||||
<label for="fileInput" class="d-block text-center py-5 cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" class="bi bi-cloud-arrow-up text-primary mb-3" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708l2-2z"/>
|
||||
<path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z"/>
|
||||
</svg>
|
||||
<h5 class="mb-2">Drag and drop your CV here</h5>
|
||||
<p class="text-muted mb-3">or click to browse files</p>
|
||||
<span class="badge bg-light text-dark">Accepted formats: PDF, DOCX</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (_selectedFile is not null)
|
||||
{
|
||||
<div class="mt-4 p-3 bg-light rounded d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-file-earmark-text text-primary me-3" viewBox="0 0 16 16">
|
||||
<path d="M5.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1h-5zM5 9.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5zm0 2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5z"/>
|
||||
<path d="M9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5L9.5 0zm0 1v2A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="mb-0 fw-medium">@_selectedFile.Name</p>
|
||||
<small class="text-muted">@FormatFileSize(_selectedFile.Size)</small>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="ClearFile">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-x-lg" viewBox="0 0 16 16">
|
||||
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<button class="btn btn-primary btn-lg px-5" @onclick="UploadFile">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-upload me-2" viewBox="0 0 16 16">
|
||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
||||
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
|
||||
</svg>
|
||||
Start Verification
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<small class="text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-shield-check me-1" viewBox="0 0 16 16">
|
||||
<path d="M5.338 1.59a61.44 61.44 0 0 0-2.837.856.481.481 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.725 10.725 0 0 0 2.287 2.233c.346.244.652.42.893.533.12.057.218.095.293.118a.55.55 0 0 0 .101.025.615.615 0 0 0 .1-.025c.076-.023.174-.061.294-.118.24-.113.547-.29.893-.533a10.726 10.726 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.775 11.775 0 0 1-2.517 2.453 7.159 7.159 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7.158 7.158 0 0 1-1.048-.625 11.777 11.777 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 62.456 62.456 0 0 1 5.072.56z"/>
|
||||
<path d="M10.854 5.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 7.793l2.646-2.647a.5.5 0 0 1 .708 0z"/>
|
||||
</svg>
|
||||
Your files are processed securely and stored encrypted
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.upload-area {
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-area:hover,
|
||||
.upload-area.dragging {
|
||||
border-color: var(--bs-primary);
|
||||
background-color: rgba(var(--bs-primary-rgb), 0.05);
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private IBrowserFile? _selectedFile;
|
||||
private bool _isUploading;
|
||||
private bool _isDragging;
|
||||
private int _uploadProgress;
|
||||
private string? _errorMessage;
|
||||
|
||||
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
private void HandleDragEnter()
|
||||
{
|
||||
_isDragging = true;
|
||||
}
|
||||
|
||||
private void HandleDragLeave()
|
||||
{
|
||||
_isDragging = false;
|
||||
}
|
||||
|
||||
private void HandleDrop()
|
||||
{
|
||||
_isDragging = false;
|
||||
}
|
||||
|
||||
private void HandleFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
_errorMessage = null;
|
||||
var file = e.File;
|
||||
|
||||
if (!IsValidFileType(file.Name))
|
||||
{
|
||||
_errorMessage = "Invalid file type. Please upload a PDF or DOCX file.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.Size > MaxFileSize)
|
||||
{
|
||||
_errorMessage = "File size exceeds 10MB limit.";
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedFile = file;
|
||||
}
|
||||
|
||||
private void ClearFile()
|
||||
{
|
||||
_selectedFile = null;
|
||||
_errorMessage = null;
|
||||
}
|
||||
|
||||
private async Task UploadFile()
|
||||
{
|
||||
if (_selectedFile is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
_isUploading = true;
|
||||
_uploadProgress = 0;
|
||||
_errorMessage = null;
|
||||
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
_errorMessage = "Unable to identify user. Please log in again.";
|
||||
_isUploading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate progress for better UX
|
||||
var progressTask = SimulateProgress();
|
||||
|
||||
await using var stream = _selectedFile.OpenReadStream(MaxFileSize);
|
||||
using var memoryStream = new MemoryStream();
|
||||
await stream.CopyToAsync(memoryStream);
|
||||
memoryStream.Position = 0;
|
||||
|
||||
var checkId = await CVCheckService.CreateCheckAsync(userId, memoryStream, _selectedFile.Name);
|
||||
|
||||
_uploadProgress = 100;
|
||||
await Task.Delay(500); // Brief pause to show completion
|
||||
|
||||
NavigationManager.NavigateTo($"/report/{checkId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"An error occurred while uploading: {ex.Message}";
|
||||
_isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SimulateProgress()
|
||||
{
|
||||
while (_uploadProgress < 90 && _isUploading)
|
||||
{
|
||||
await Task.Delay(200);
|
||||
_uploadProgress += 10;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsValidFileType(string fileName)
|
||||
{
|
||||
var extension = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
return extension is ".pdf" or ".docx";
|
||||
}
|
||||
|
||||
private static string FormatFileSize(long bytes)
|
||||
{
|
||||
string[] sizes = ["B", "KB", "MB", "GB"];
|
||||
int order = 0;
|
||||
double size = bytes;
|
||||
|
||||
while (size >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
size /= 1024;
|
||||
}
|
||||
|
||||
return $"{size:0.##} {sizes[order]}";
|
||||
}
|
||||
}
|
||||
283
src/TrueCV.Web/Components/Pages/Dashboard.razor
Normal file
283
src/TrueCV.Web/Components/Pages/Dashboard.razor
Normal file
@@ -0,0 +1,283 @@
|
||||
@page "/dashboard"
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@inject ICVCheckService CVCheckService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
|
||||
<PageTitle>Dashboard - TrueCV</PageTitle>
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="fw-bold mb-1">Dashboard</h1>
|
||||
<p class="text-muted mb-0">View and manage your CV verification checks</p>
|
||||
</div>
|
||||
<a href="/check" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg me-1" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||
</svg>
|
||||
New Check
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p class="mt-3 text-muted">Loading your checks...</p>
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">
|
||||
@_errorMessage
|
||||
</div>
|
||||
}
|
||||
else if (_checks.Count == 0)
|
||||
{
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body text-center py-5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" class="bi bi-file-earmark-text text-muted mb-3" viewBox="0 0 16 16">
|
||||
<path d="M5.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1h-5zM5 9.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5zm0 2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5z"/>
|
||||
<path d="M9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5L9.5 0zm0 1v2A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
|
||||
</svg>
|
||||
<h4>No CV Checks Yet</h4>
|
||||
<p class="text-muted mb-4">Start by uploading your first CV for verification</p>
|
||||
<a href="/check" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-upload me-1" viewBox="0 0 16 16">
|
||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
||||
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
|
||||
</svg>
|
||||
Upload CV
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<!-- Stats Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle p-3 me-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-file-earmark-check text-primary" viewBox="0 0 16 16">
|
||||
<path d="M10.854 7.854a.5.5 0 0 0-.708-.708L7.5 9.793 6.354 8.646a.5.5 0 1 0-.708.708l1.5 1.5a.5.5 0 0 0 .708 0l3-3z"/>
|
||||
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-0">@_checks.Count</h3>
|
||||
<small class="text-muted">Total Checks</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-success bg-opacity-10 rounded-circle p-3 me-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-check-circle text-success" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-0">@_checks.Count(c => c.Status == "Completed")</h3>
|
||||
<small class="text-muted">Completed</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle p-3 me-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-hourglass-split text-warning" viewBox="0 0 16 16">
|
||||
<path d="M2.5 15a.5.5 0 1 1 0-1h1v-1a4.5 4.5 0 0 1 2.557-4.06c.29-.139.443-.377.443-.59v-.7c0-.213-.154-.451-.443-.59A4.5 4.5 0 0 1 3.5 3V2h-1a.5.5 0 0 1 0-1h11a.5.5 0 0 1 0 1h-1v1a4.5 4.5 0 0 1-2.557 4.06c-.29.139-.443.377-.443.59v.7c0 .213.154.451.443.59A4.5 4.5 0 0 1 12.5 13v1h1a.5.5 0 0 1 0 1h-11zm2-13v1c0 .537.12 1.045.337 1.5h6.326c.216-.455.337-.963.337-1.5V2h-7zm3 6.35c0 .701-.478 1.236-1.011 1.492A3.5 3.5 0 0 0 4.5 13s.866-1.299 3-1.48V8.35zm1 0v3.17c2.134.181 3 1.48 3 1.48a3.5 3.5 0 0 0-1.989-3.158C8.978 9.586 8.5 9.052 8.5 8.351z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-0">@_checks.Count(c => c.Status is "Pending" or "Processing")</h3>
|
||||
<small class="text-muted">In Progress</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checks List -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="mb-0 fw-bold">Recent CV Checks</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>File Name</th>
|
||||
<th>Date</th>
|
||||
<th class="text-center">Status</th>
|
||||
<th class="text-center">Score</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var check in _checks)
|
||||
{
|
||||
<tr class="@(check.Status == "Completed" ? "cursor-pointer" : "")"
|
||||
@onclick="() => ViewReport(check)">
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-file-earmark-text text-primary me-2" viewBox="0 0 16 16">
|
||||
<path d="M5.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1h-5zM5 9.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5zm0 2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5z"/>
|
||||
<path d="M9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5L9.5 0zm0 1v2A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
|
||||
</svg>
|
||||
<span class="fw-medium">@check.OriginalFileName</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted">@check.CreatedAt.ToString("dd MMM yyyy HH:mm")</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@switch (check.Status)
|
||||
{
|
||||
case "Completed":
|
||||
<span class="badge bg-success">Completed</span>
|
||||
break;
|
||||
case "Processing":
|
||||
<span class="badge bg-primary">
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true" style="width: 0.7rem; height: 0.7rem;"></span>
|
||||
Processing
|
||||
</span>
|
||||
break;
|
||||
case "Pending":
|
||||
<span class="badge bg-secondary">Pending</span>
|
||||
break;
|
||||
case "Failed":
|
||||
<span class="badge bg-danger">Failed</span>
|
||||
break;
|
||||
default:
|
||||
<span class="badge bg-secondary">@check.Status</span>
|
||||
break;
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@if (check.VeracityScore.HasValue)
|
||||
{
|
||||
<span class="badge @GetScoreBadgeClass(check.VeracityScore.Value) fs-6">
|
||||
@check.VeracityScore
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">-</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (check.Status == "Completed")
|
||||
{
|
||||
<a href="/report/@check.Id" class="btn btn-sm btn-outline-primary" @onclick:stopPropagation="true">
|
||||
View Report
|
||||
</a>
|
||||
}
|
||||
else if (check.Status is "Pending" or "Processing")
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled>
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="/check" class="btn btn-sm btn-outline-warning">
|
||||
Retry
|
||||
</a>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cursor-pointer:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private List<CVCheckDto> _checks = [];
|
||||
private bool _isLoading = true;
|
||||
private string? _errorMessage;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadChecks();
|
||||
}
|
||||
|
||||
private async Task LoadChecks()
|
||||
{
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
_errorMessage = "Unable to identify user. Please log in again.";
|
||||
return;
|
||||
}
|
||||
|
||||
_checks = await CVCheckService.GetUserChecksAsync(userId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"An error occurred while loading checks: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ViewReport(CVCheckDto check)
|
||||
{
|
||||
if (check.Status == "Completed")
|
||||
{
|
||||
NavigationManager.NavigateTo($"/report/{check.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetScoreBadgeClass(int score)
|
||||
{
|
||||
return score switch
|
||||
{
|
||||
> 70 => "bg-success",
|
||||
>= 50 => "bg-warning text-dark",
|
||||
_ => "bg-danger"
|
||||
};
|
||||
}
|
||||
}
|
||||
36
src/TrueCV.Web/Components/Pages/Error.razor
Normal file
36
src/TrueCV.Web/Components/Pages/Error.razor
Normal file
@@ -0,0 +1,36 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
}
|
||||
146
src/TrueCV.Web/Components/Pages/Home.razor
Normal file
146
src/TrueCV.Web/Components/Pages/Home.razor
Normal file
@@ -0,0 +1,146 @@
|
||||
@page "/"
|
||||
|
||||
<PageTitle>TrueCV - Verify CVs with Confidence</PageTitle>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="bg-primary text-white py-5">
|
||||
<div class="container">
|
||||
<div class="row align-items-center py-5">
|
||||
<div class="col-lg-6">
|
||||
<h1 class="display-4 fw-bold mb-4">Verify CVs with Confidence</h1>
|
||||
<p class="lead mb-4">
|
||||
TrueCV uses AI-powered analysis and official company records to verify employment history,
|
||||
detect timeline inconsistencies, and flag potential issues in candidate CVs.
|
||||
</p>
|
||||
<div class="d-flex gap-3">
|
||||
<a href="/check" class="btn btn-light btn-lg px-4">
|
||||
Start Verification
|
||||
</a>
|
||||
<a href="#features" class="btn btn-outline-light btn-lg px-4">
|
||||
Learn More
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 text-center mt-4 mt-lg-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300" fill="currentColor" class="opacity-25" viewBox="0 0 16 16">
|
||||
<path d="M10.067.87a2.89 2.89 0 0 0-4.134 0l-.622.638-.89-.011a2.89 2.89 0 0 0-2.924 2.924l.01.89-.636.622a2.89 2.89 0 0 0 0 4.134l.637.622-.011.89a2.89 2.89 0 0 0 2.924 2.924l.89-.01.622.636a2.89 2.89 0 0 0 4.134 0l.622-.637.89.011a2.89 2.89 0 0 0 2.924-2.924l-.01-.89.636-.622a2.89 2.89 0 0 0 0-4.134l-.637-.622.011-.89a2.89 2.89 0 0 0-2.924-2.924l-.89.01-.622-.636zm.287 5.984-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7 8.793l2.646-2.647a.5.5 0 0 1 .708.708z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section id="features" class="py-5 bg-light">
|
||||
<div class="container">
|
||||
<div class="text-center mb-5">
|
||||
<h2 class="fw-bold">How TrueCV Works</h2>
|
||||
<p class="text-muted lead">Comprehensive CV verification in three key areas</p>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Employment Verification -->
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center p-4">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-inline-flex p-3 mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="bi bi-building-check text-primary" viewBox="0 0 16 16">
|
||||
<path d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm1.679-4.493-1.335 2.226a.75.75 0 0 1-1.174.144l-.774-.773a.5.5 0 0 1 .708-.708l.547.548 1.17-1.951a.5.5 0 1 1 .858.514Z"/>
|
||||
<path d="M2 1a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6.5a.5.5 0 0 1-1 0V1H3v14h3v-2.5a.5.5 0 0 1 .5-.5H8v4H3a1 1 0 0 1-1-1V1Z"/>
|
||||
<path d="M4.5 2a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm3 0a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm3 0a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm-6 3a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm3 0a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm3 0a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm-6 3a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm3 0a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="card-title fw-bold">Employment Verification</h4>
|
||||
<p class="card-text text-muted">
|
||||
Cross-reference claimed employers with official Companies House records to verify
|
||||
company existence and match accuracy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Analysis -->
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center p-4">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-inline-flex p-3 mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="bi bi-calendar-range text-primary" viewBox="0 0 16 16">
|
||||
<path d="M9 7a1 1 0 0 1 1-1h5v2h-5a1 1 0 0 1-1-1zM1 9h4a1 1 0 0 1 0 2H1V9z"/>
|
||||
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="card-title fw-bold">Timeline Analysis</h4>
|
||||
<p class="card-text text-muted">
|
||||
Detect unexplained employment gaps and overlapping job periods that may indicate
|
||||
inconsistencies in the candidate's work history.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI-Powered Parsing -->
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center p-4">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-inline-flex p-3 mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="bi bi-cpu text-primary" viewBox="0 0 16 16">
|
||||
<path d="M5 0a.5.5 0 0 1 .5.5V2h1V.5a.5.5 0 0 1 1 0V2h1V.5a.5.5 0 0 1 1 0V2h1V.5a.5.5 0 0 1 1 0V2A2.5 2.5 0 0 1 14 4.5h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14a2.5 2.5 0 0 1-2.5 2.5v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14A2.5 2.5 0 0 1 2 11.5H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2A2.5 2.5 0 0 1 4.5 2V.5A.5.5 0 0 1 5 0zm-.5 3A1.5 1.5 0 0 0 3 4.5v7A1.5 1.5 0 0 0 4.5 13h7a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 11.5 3h-7zM5 6.5A1.5 1.5 0 0 1 6.5 5h3A1.5 1.5 0 0 1 11 6.5v3A1.5 1.5 0 0 1 9.5 11h-3A1.5 1.5 0 0 1 5 9.5v-3zM6.5 6a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="card-title fw-bold">AI-Powered Parsing</h4>
|
||||
<p class="card-text text-muted">
|
||||
Advanced AI extracts and structures CV data from PDF and DOCX files, ensuring
|
||||
accurate information capture for analysis.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How It Works Section -->
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<div class="text-center mb-5">
|
||||
<h2 class="fw-bold">Get Started in Minutes</h2>
|
||||
<p class="text-muted lead">Simple three-step verification process</p>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 justify-content-center">
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<div class="bg-primary text-white rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 60px; height: 60px;">
|
||||
<span class="fw-bold fs-4">1</span>
|
||||
</div>
|
||||
<h5 class="fw-bold">Upload CV</h5>
|
||||
<p class="text-muted">Upload the candidate's CV in PDF or DOCX format</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<div class="bg-primary text-white rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 60px; height: 60px;">
|
||||
<span class="fw-bold fs-4">2</span>
|
||||
</div>
|
||||
<h5 class="fw-bold">AI Analysis</h5>
|
||||
<p class="text-muted">Our AI parses the CV and verifies against official records</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<div class="bg-primary text-white rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 60px; height: 60px;">
|
||||
<span class="fw-bold fs-4">3</span>
|
||||
</div>
|
||||
<h5 class="fw-bold">Get Report</h5>
|
||||
<p class="text-muted">Receive a detailed veracity report with actionable insights</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-5">
|
||||
<a href="/check" class="btn btn-primary btn-lg px-5">
|
||||
Start Your First Check
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
549
src/TrueCV.Web/Components/Pages/Report.razor
Normal file
549
src/TrueCV.Web/Components/Pages/Report.razor
Normal file
@@ -0,0 +1,549 @@
|
||||
@page "/report/{Id:guid}"
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@inject ICVCheckService CVCheckService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
|
||||
<PageTitle>Verification Report - TrueCV</PageTitle>
|
||||
|
||||
<div class="container py-5">
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status" style="width: 3rem; height: 3rem;">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p class="mt-3 text-muted">Loading report...</p>
|
||||
</div>
|
||||
}
|
||||
else if (_errorMessage is not null)
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">
|
||||
@_errorMessage
|
||||
</div>
|
||||
<a href="/dashboard" class="btn btn-primary">Back to Dashboard</a>
|
||||
}
|
||||
else if (_check is not null && _check.Status != "Completed")
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="card border-0 shadow-sm mx-auto" style="max-width: 500px;">
|
||||
<div class="card-body p-5">
|
||||
@if (_check.Status == "Processing")
|
||||
{
|
||||
<div class="spinner-border text-primary mb-3" role="status" style="width: 3rem; height: 3rem;">
|
||||
<span class="visually-hidden">Processing...</span>
|
||||
</div>
|
||||
<h4 class="mb-2">Processing Your CV</h4>
|
||||
<p class="text-muted mb-4">Our AI is analyzing the document. This usually takes 1-2 minutes.</p>
|
||||
<div class="progress" style="height: 8px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar"
|
||||
style="width: 60%">
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_check.Status == "Pending")
|
||||
{
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-hourglass-split text-warning mb-3" viewBox="0 0 16 16">
|
||||
<path d="M2.5 15a.5.5 0 1 1 0-1h1v-1a4.5 4.5 0 0 1 2.557-4.06c.29-.139.443-.377.443-.59v-.7c0-.213-.154-.451-.443-.59A4.5 4.5 0 0 1 3.5 3V2h-1a.5.5 0 0 1 0-1h11a.5.5 0 0 1 0 1h-1v1a4.5 4.5 0 0 1-2.557 4.06c-.29.139-.443.377-.443.59v.7c0 .213.154.451.443.59A4.5 4.5 0 0 1 12.5 13v1h1a.5.5 0 0 1 0 1h-11zm2-13v1c0 .537.12 1.045.337 1.5h6.326c.216-.455.337-.963.337-1.5V2h-7zm3 6.35c0 .701-.478 1.236-1.011 1.492A3.5 3.5 0 0 0 4.5 13s.866-1.299 3-1.48V8.35zm1 0v3.17c2.134.181 3 1.48 3 1.48a3.5 3.5 0 0 0-1.989-3.158C8.978 9.586 8.5 9.052 8.5 8.351z"/>
|
||||
</svg>
|
||||
<h4 class="mb-2">Queued for Processing</h4>
|
||||
<p class="text-muted">Your CV is in the queue and will be processed shortly.</p>
|
||||
}
|
||||
else if (_check.Status == "Failed")
|
||||
{
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-exclamation-triangle text-danger mb-3" viewBox="0 0 16 16">
|
||||
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.146.146 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.163.163 0 0 1-.054.06.116.116 0 0 1-.066.017H1.146a.115.115 0 0 1-.066-.017.163.163 0 0 1-.054-.06.176.176 0 0 1 .002-.183L7.884 2.073a.147.147 0 0 1 .054-.057zm1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566z"/>
|
||||
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
|
||||
</svg>
|
||||
<h4 class="mb-2">Processing Failed</h4>
|
||||
<p class="text-muted">We encountered an error processing your CV. Please try uploading again.</p>
|
||||
}
|
||||
|
||||
<p class="text-muted small mt-4">
|
||||
File: @_check.OriginalFileName<br />
|
||||
Uploaded: @_check.CreatedAt.ToString("dd MMM yyyy HH:mm")
|
||||
</p>
|
||||
|
||||
<button class="btn btn-outline-primary mt-3" @onclick="RefreshStatus">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-clockwise me-1" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
||||
</svg>
|
||||
Refresh Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_report is not null && _check is not null)
|
||||
{
|
||||
<!-- Report Header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/dashboard">Dashboard</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Report</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="fw-bold">Verification Report</h1>
|
||||
<p class="text-muted">
|
||||
@_check.OriginalFileName | Generated @_report.GeneratedAt.ToString("dd MMM yyyy HH:mm")
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-outline-primary" @onclick="DownloadReport">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-download me-1" viewBox="0 0 16 16">
|
||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
||||
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
|
||||
</svg>
|
||||
Download Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score Card -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-4 text-center border-end">
|
||||
<div class="score-circle @GetScoreColorClass(_report.OverallScore) mx-auto mb-2">
|
||||
<span class="score-value">@_report.OverallScore</span>
|
||||
</div>
|
||||
<h5 class="mb-0">@_report.ScoreLabel</h5>
|
||||
<small class="text-muted">Veracity Score</small>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="row text-center">
|
||||
<div class="col-4">
|
||||
<h3 class="mb-0 text-primary">@_report.EmploymentVerifications.Count</h3>
|
||||
<small class="text-muted">Employers Checked</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h3 class="mb-0 text-warning">@_report.TimelineAnalysis.TotalGapMonths</h3>
|
||||
<small class="text-muted">Months of Gaps</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h3 class="mb-0 text-danger">@_report.Flags.Count</h3>
|
||||
<small class="text-muted">Flags Raised</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Employment Verification -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="mb-0 fw-bold">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-building me-2" viewBox="0 0 16 16">
|
||||
<path d="M4 2.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1ZM4 5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1ZM7.5 5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1ZM4.5 8a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1ZM10.5 8a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Z"/>
|
||||
<path d="M2 1a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V1Zm11 0H3v14h3v-2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5V15h3V1Z"/>
|
||||
</svg>
|
||||
Employment Verification
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Claimed Employer</th>
|
||||
<th>Period</th>
|
||||
<th>Matched Company</th>
|
||||
<th class="text-center">Match Score</th>
|
||||
<th class="text-center">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var verification in _report.EmploymentVerifications)
|
||||
{
|
||||
<tr>
|
||||
<td class="fw-medium">@verification.ClaimedCompany</td>
|
||||
<td>
|
||||
@if (verification.ClaimedStartDate.HasValue)
|
||||
{
|
||||
<span>@verification.ClaimedStartDate.Value.ToString("MMM yyyy")</span>
|
||||
<span> - </span>
|
||||
@if (verification.ClaimedEndDate.HasValue)
|
||||
{
|
||||
<span>@verification.ClaimedEndDate.Value.ToString("MMM yyyy")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Present</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">Not specified</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(verification.MatchedCompanyName))
|
||||
{
|
||||
<span>@verification.MatchedCompanyName</span>
|
||||
@if (!string.IsNullOrEmpty(verification.MatchedCompanyNumber))
|
||||
{
|
||||
<br /><small class="text-muted">@verification.MatchedCompanyNumber</small>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">No match found</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge @GetMatchScoreBadgeClass(verification.MatchScore)">
|
||||
@verification.MatchScore%
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@if (verification.IsVerified)
|
||||
{
|
||||
<span class="badge bg-success">Verified</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-warning text-dark">Unverified</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@if (!string.IsNullOrEmpty(verification.VerificationNotes))
|
||||
{
|
||||
<tr class="table-light">
|
||||
<td colspan="5" class="small text-muted py-1 ps-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-info-circle me-1" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
|
||||
</svg>
|
||||
@verification.VerificationNotes
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Analysis -->
|
||||
<div class="row mb-4">
|
||||
<!-- Gaps -->
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="mb-0 fw-bold">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-clock-history me-2 text-warning" viewBox="0 0 16 16">
|
||||
<path d="M8.515 1.019A7 7 0 0 0 8 1V0a8 8 0 0 1 .589.022l-.074.997zm2.004.45a7.003 7.003 0 0 0-.985-.299l.219-.976c.383.086.76.2 1.126.342l-.36.933zm1.37.71a7.01 7.01 0 0 0-.439-.27l.493-.87a8.025 8.025 0 0 1 .979.654l-.615.789a6.996 6.996 0 0 0-.418-.302zm1.834 1.79a6.99 6.99 0 0 0-.653-.796l.724-.69c.27.285.52.59.747.91l-.818.576zm.744 1.352a7.08 7.08 0 0 0-.214-.468l.893-.45a7.976 7.976 0 0 1 .45 1.088l-.95.313a7.023 7.023 0 0 0-.179-.483zm.53 2.507a6.991 6.991 0 0 0-.1-1.025l.985-.17c.067.386.106.778.116 1.17l-1 .025zm-.131 1.538c.033-.17.06-.339.081-.51l.993.123a7.957 7.957 0 0 1-.23 1.155l-.964-.267c.046-.165.086-.332.12-.501zm-.952 2.379c.184-.29.346-.594.486-.908l.914.405c-.16.36-.345.706-.555 1.038l-.845-.535zm-.964 1.205c.122-.122.239-.248.35-.378l.758.653a8.073 8.073 0 0 1-.401.432l-.707-.707z"/>
|
||||
<path d="M8 1a7 7 0 1 0 4.95 11.95l.707.707A8.001 8.001 0 1 1 8 0v1z"/>
|
||||
<path d="M7.5 3a.5.5 0 0 1 .5.5v5.21l3.248 1.856a.5.5 0 0 1-.496.868l-3.5-2A.5.5 0 0 1 7 9V3.5a.5.5 0 0 1 .5-.5z"/>
|
||||
</svg>
|
||||
Employment Gaps
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (_report.TimelineAnalysis.Gaps.Count > 0)
|
||||
{
|
||||
<ul class="list-group list-group-flush">
|
||||
@foreach (var gap in _report.TimelineAnalysis.Gaps)
|
||||
{
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||
<span>
|
||||
@gap.StartDate.ToString("MMM yyyy") - @gap.EndDate.ToString("MMM yyyy")
|
||||
</span>
|
||||
<span class="badge bg-warning text-dark rounded-pill">@gap.Months months</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<div class="mt-3 p-2 bg-light rounded">
|
||||
<small class="text-muted">Total gap time: <strong>@_report.TimelineAnalysis.TotalGapMonths months</strong></small>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="bi bi-check-circle text-success mb-2" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/>
|
||||
</svg>
|
||||
<p class="mb-0 text-muted">No significant gaps detected</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlaps -->
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="mb-0 fw-bold">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-intersect me-2 text-danger" viewBox="0 0 16 16">
|
||||
<path d="M0 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2H2a2 2 0 0 1-2-2V2zm5 10v2a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2v5a2 2 0 0 1-2 2H5zm6-8V2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2V6a2 2 0 0 1 2-2h5z"/>
|
||||
</svg>
|
||||
Timeline Overlaps
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (_report.TimelineAnalysis.Overlaps.Count > 0)
|
||||
{
|
||||
<ul class="list-group list-group-flush">
|
||||
@foreach (var overlap in _report.TimelineAnalysis.Overlaps)
|
||||
{
|
||||
<li class="list-group-item px-0">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<small class="text-muted">@overlap.Company1</small> &
|
||||
<small class="text-muted">@overlap.Company2</small>
|
||||
</div>
|
||||
<span class="badge bg-danger rounded-pill">@overlap.Months months</span>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
@overlap.OverlapStart.ToString("MMM yyyy") - @overlap.OverlapEnd.ToString("MMM yyyy")
|
||||
</small>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<div class="mt-3 p-2 bg-light rounded">
|
||||
<small class="text-muted">Total overlap time: <strong>@_report.TimelineAnalysis.TotalOverlapMonths months</strong></small>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="bi bi-check-circle text-success mb-2" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/>
|
||||
</svg>
|
||||
<p class="mb-0 text-muted">No overlapping positions detected</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flags -->
|
||||
@if (_report.Flags.Count > 0)
|
||||
{
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="mb-0 fw-bold">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-flag me-2 text-danger" viewBox="0 0 16 16">
|
||||
<path d="M14.778.085A.5.5 0 0 1 15 .5V8a.5.5 0 0 1-.314.464L14.5 8l.186.464-.003.001-.006.003-.023.009a12.435 12.435 0 0 1-.397.15c-.264.095-.631.223-1.047.35-.816.252-1.879.523-2.71.523-.847 0-1.548-.28-2.158-.525l-.028-.01C7.68 8.71 7.14 8.5 6.5 8.5c-.7 0-1.638.23-2.437.477A19.626 19.626 0 0 0 3 9.342V15.5a.5.5 0 0 1-1 0V.5a.5.5 0 0 1 1 0v.282c.226-.079.496-.17.79-.26C4.606.272 5.67 0 6.5 0c.84 0 1.524.277 2.121.519l.043.018C9.286.788 9.828 1 10.5 1c.7 0 1.638-.23 2.437-.477a19.587 19.587 0 0 0 1.349-.476l.019-.007.004-.002h.001"/>
|
||||
</svg>
|
||||
Flags Raised
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@{
|
||||
var criticalFlags = _report.Flags.Where(f => f.Severity == "Critical").ToList();
|
||||
var warningFlags = _report.Flags.Where(f => f.Severity == "Warning").ToList();
|
||||
var infoFlags = _report.Flags.Where(f => f.Severity == "Info").ToList();
|
||||
}
|
||||
|
||||
@if (criticalFlags.Count > 0)
|
||||
{
|
||||
<h6 class="text-danger fw-bold mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-octagon me-1" viewBox="0 0 16 16">
|
||||
<path d="M4.54.146A.5.5 0 0 1 4.893 0h6.214a.5.5 0 0 1 .353.146l4.394 4.394a.5.5 0 0 1 .146.353v6.214a.5.5 0 0 1-.146.353l-4.394 4.394a.5.5 0 0 1-.353.146H4.893a.5.5 0 0 1-.353-.146L.146 11.46A.5.5 0 0 1 0 11.107V4.893a.5.5 0 0 1 .146-.353L4.54.146zM5.1 1 1 5.1v5.8L5.1 15h5.8l4.1-4.1V5.1L10.9 1H5.1z"/>
|
||||
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
|
||||
</svg>
|
||||
Critical Issues
|
||||
</h6>
|
||||
@foreach (var flag in criticalFlags)
|
||||
{
|
||||
<div class="alert alert-danger mb-2">
|
||||
<strong>@flag.Title</strong>
|
||||
<span class="badge bg-danger ms-2">-@flag.ScoreImpact pts</span>
|
||||
<p class="mb-0 mt-1 small">@flag.Description</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (warningFlags.Count > 0)
|
||||
{
|
||||
<h6 class="text-warning fw-bold mb-3 @(criticalFlags.Count > 0 ? "mt-4" : "")">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-triangle me-1" viewBox="0 0 16 16">
|
||||
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.146.146 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.163.163 0 0 1-.054.06.116.116 0 0 1-.066.017H1.146a.115.115 0 0 1-.066-.017.163.163 0 0 1-.054-.06.176.176 0 0 1 .002-.183L7.884 2.073a.147.147 0 0 1 .054-.057zm1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566z"/>
|
||||
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
|
||||
</svg>
|
||||
Warnings
|
||||
</h6>
|
||||
@foreach (var flag in warningFlags)
|
||||
{
|
||||
<div class="alert alert-warning mb-2">
|
||||
<strong>@flag.Title</strong>
|
||||
<span class="badge bg-warning text-dark ms-2">-@flag.ScoreImpact pts</span>
|
||||
<p class="mb-0 mt-1 small">@flag.Description</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (infoFlags.Count > 0)
|
||||
{
|
||||
<h6 class="text-info fw-bold mb-3 @(criticalFlags.Count > 0 || warningFlags.Count > 0 ? "mt-4" : "")">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-info-circle me-1" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
|
||||
</svg>
|
||||
Information
|
||||
</h6>
|
||||
@foreach (var flag in infoFlags)
|
||||
{
|
||||
<div class="alert alert-info mb-2">
|
||||
<strong>@flag.Title</strong>
|
||||
<span class="badge bg-info text-dark ms-2">-@flag.ScoreImpact pts</span>
|
||||
<p class="mb-0 mt-1 small">@flag.Description</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.score-circle {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 8px solid;
|
||||
}
|
||||
|
||||
.score-circle.score-high {
|
||||
border-color: #198754;
|
||||
background-color: rgba(25, 135, 84, 0.1);
|
||||
}
|
||||
|
||||
.score-circle.score-medium {
|
||||
border-color: #ffc107;
|
||||
background-color: rgba(255, 193, 7, 0.1);
|
||||
}
|
||||
|
||||
.score-circle.score-low {
|
||||
border-color: #dc3545;
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.score-high .score-value {
|
||||
color: #198754;
|
||||
}
|
||||
|
||||
.score-medium .score-value {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.score-low .score-value {
|
||||
color: #dc3545;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
private CVCheckDto? _check;
|
||||
private VeracityReport? _report;
|
||||
private bool _isLoading = true;
|
||||
private string? _errorMessage;
|
||||
private Guid _userId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out _userId))
|
||||
{
|
||||
_errorMessage = "Unable to identify user. Please log in again.";
|
||||
return;
|
||||
}
|
||||
|
||||
_check = await CVCheckService.GetCheckForUserAsync(Id, _userId);
|
||||
|
||||
if (_check is null)
|
||||
{
|
||||
_errorMessage = "Report not found or you don't have access to view it.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (_check.Status == "Completed")
|
||||
{
|
||||
_report = await CVCheckService.GetReportAsync(Id, _userId);
|
||||
|
||||
if (_report is null)
|
||||
{
|
||||
_errorMessage = "Unable to load the report data.";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"An error occurred: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshStatus()
|
||||
{
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private void DownloadReport()
|
||||
{
|
||||
// TODO: Implement report download functionality
|
||||
}
|
||||
|
||||
private static string GetScoreColorClass(int score)
|
||||
{
|
||||
return score switch
|
||||
{
|
||||
> 70 => "score-high",
|
||||
>= 50 => "score-medium",
|
||||
_ => "score-low"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetMatchScoreBadgeClass(int score)
|
||||
{
|
||||
return score switch
|
||||
{
|
||||
>= 80 => "bg-success",
|
||||
>= 50 => "bg-warning text-dark",
|
||||
_ => "bg-danger"
|
||||
};
|
||||
}
|
||||
}
|
||||
24
src/TrueCV.Web/Components/Routes.razor
Normal file
24
src/TrueCV.Web/Components/Routes.razor
Normal file
@@ -0,0 +1,24 @@
|
||||
<CascadingAuthenticationState>
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
||||
<NotAuthorized>
|
||||
@if (context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
<RedirectToLogin />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="container py-5">
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<h4 class="alert-heading">Access Denied</h4>
|
||||
<p>You are not authorized to access this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
</CascadingAuthenticationState>
|
||||
190
src/TrueCV.Web/Components/Shared/CVUploader.razor
Normal file
190
src/TrueCV.Web/Components/Shared/CVUploader.razor
Normal file
@@ -0,0 +1,190 @@
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
|
||||
<div class="cv-uploader @(_isDragOver ? "drag-over" : "")"
|
||||
@ondragenter="HandleDragEnter"
|
||||
@ondragenter:preventDefault
|
||||
@ondragleave="HandleDragLeave"
|
||||
@ondragleave:preventDefault
|
||||
@ondragover:preventDefault
|
||||
@ondrop="HandleDrop"
|
||||
@ondrop:preventDefault>
|
||||
|
||||
<InputFile OnChange="HandleFileSelected"
|
||||
accept=".pdf,.docx"
|
||||
class="cv-uploader-input"
|
||||
id="cv-file-input" />
|
||||
|
||||
<label for="cv-file-input" class="cv-uploader-label">
|
||||
@if (string.IsNullOrEmpty(_selectedFileName))
|
||||
{
|
||||
<div class="cv-uploader-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708z"/>
|
||||
<path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383m.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="cv-uploader-text">
|
||||
<span class="cv-uploader-title">Drag and drop your CV here</span>
|
||||
<span class="cv-uploader-subtitle">or click to browse</span>
|
||||
<span class="cv-uploader-hint">Accepts .pdf and .docx files (max 10MB)</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cv-uploader-icon text-success">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-file-earmark-check" viewBox="0 0 16 16">
|
||||
<path d="M10.854 7.854a.5.5 0 0 0-.708-.708L7.5 9.793 6.354 8.646a.5.5 0 1 0-.708.708l1.5 1.5a.5.5 0 0 0 .708 0z"/>
|
||||
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="cv-uploader-text">
|
||||
<span class="cv-uploader-title text-success">File selected</span>
|
||||
<span class="cv-uploader-filename">@_selectedFileName</span>
|
||||
<span class="cv-uploader-hint">Click or drag to replace</span>
|
||||
</div>
|
||||
}
|
||||
</label>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mt-3 mb-0" role="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-triangle-fill me-2" viewBox="0 0 16 16">
|
||||
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5m.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/>
|
||||
</svg>
|
||||
@_errorMessage
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cv-uploader {
|
||||
position: relative;
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background-color: #f8f9fa;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.cv-uploader:hover,
|
||||
.cv-uploader.drag-over {
|
||||
border-color: #0d6efd;
|
||||
background-color: #e7f1ff;
|
||||
}
|
||||
|
||||
.cv-uploader.drag-over {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.cv-uploader-input {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cv-uploader-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cv-uploader-icon {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.cv-uploader.drag-over .cv-uploader-icon {
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.cv-uploader-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.cv-uploader-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.cv-uploader-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.cv-uploader-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.cv-uploader-filename {
|
||||
font-size: 0.875rem;
|
||||
color: #495057;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10MB
|
||||
private static readonly string[] AllowedExtensions = [".pdf", ".docx"];
|
||||
|
||||
private bool _isDragOver;
|
||||
private string? _selectedFileName;
|
||||
private string? _errorMessage;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<IBrowserFile> OnFileSelected { get; set; }
|
||||
|
||||
private void HandleDragEnter()
|
||||
{
|
||||
_isDragOver = true;
|
||||
}
|
||||
|
||||
private void HandleDragLeave()
|
||||
{
|
||||
_isDragOver = false;
|
||||
}
|
||||
|
||||
private void HandleDrop()
|
||||
{
|
||||
_isDragOver = false;
|
||||
}
|
||||
|
||||
private async Task HandleFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
_errorMessage = null;
|
||||
_selectedFileName = null;
|
||||
|
||||
var file = e.File;
|
||||
if (file is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(file.Name).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(extension))
|
||||
{
|
||||
_errorMessage = "Invalid file type. Please upload a .pdf or .docx file.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.Size > MaxFileSizeBytes)
|
||||
{
|
||||
_errorMessage = "File size exceeds 10MB limit. Please upload a smaller file.";
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedFileName = file.Name;
|
||||
await OnFileSelected.InvokeAsync(file);
|
||||
}
|
||||
}
|
||||
248
src/TrueCV.Web/Components/Shared/EmploymentTable.razor
Normal file
248
src/TrueCV.Web/Components/Shared/EmploymentTable.razor
Normal file
@@ -0,0 +1,248 @@
|
||||
@using TrueCV.Application.Models
|
||||
|
||||
<div class="employment-table-wrapper">
|
||||
@if (Verifications is null || Verifications.Count == 0)
|
||||
{
|
||||
<div class="employment-empty">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-briefcase text-muted mb-3" viewBox="0 0 16 16">
|
||||
<path d="M6.5 1A1.5 1.5 0 0 0 5 2.5V3H1.5A1.5 1.5 0 0 0 0 4.5v8A1.5 1.5 0 0 0 1.5 14h13a1.5 1.5 0 0 0 1.5-1.5v-8A1.5 1.5 0 0 0 14.5 3H11v-.5A1.5 1.5 0 0 0 9.5 1zM6 2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5V3H6zM1 4.5a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 .5.5V7H1zM1 8h14v4.5a.5.5 0 0 1-.5.5h-13a.5.5 0 0 1-.5-.5z"/>
|
||||
</svg>
|
||||
<span class="text-muted">No employment verification data available</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="employment-summary mb-3">
|
||||
@{
|
||||
var verifiedCount = Verifications.Count(v => v.IsVerified);
|
||||
var unverifiedCount = Verifications.Count - verifiedCount;
|
||||
}
|
||||
<span class="badge bg-success me-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-check-lg me-1" viewBox="0 0 16 16">
|
||||
<path d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425z"/>
|
||||
</svg>
|
||||
@verifiedCount Verified
|
||||
</span>
|
||||
@if (unverifiedCount > 0)
|
||||
{
|
||||
<span class="badge bg-danger">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-x-lg me-1" viewBox="0 0 16 16">
|
||||
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z"/>
|
||||
</svg>
|
||||
@unverifiedCount Unverified
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover employment-table mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col">Company Claimed</th>
|
||||
<th scope="col">Matched Company</th>
|
||||
<th scope="col">Match Score</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Dates</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var verification in Verifications)
|
||||
{
|
||||
<tr class="@(verification.IsVerified ? "" : "table-warning")">
|
||||
<td>
|
||||
<span class="company-name">@verification.ClaimedCompany</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(verification.MatchedCompanyName))
|
||||
{
|
||||
<span class="matched-company">
|
||||
@verification.MatchedCompanyName
|
||||
@if (!string.IsNullOrEmpty(verification.MatchedCompanyNumber))
|
||||
{
|
||||
<small class="text-muted d-block">@verification.MatchedCompanyNumber</small>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">No match found</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="match-score">
|
||||
<div class="progress" style="width: 80px; height: 6px;">
|
||||
<div class="progress-bar @GetMatchScoreClass(verification.MatchScore)"
|
||||
role="progressbar"
|
||||
style="width: @verification.MatchScore%"
|
||||
aria-valuenow="@verification.MatchScore"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100">
|
||||
</div>
|
||||
</div>
|
||||
<span class="match-score-value @GetMatchScoreTextClass(verification.MatchScore)">
|
||||
@verification.MatchScore%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if (verification.IsVerified)
|
||||
{
|
||||
<span class="badge bg-success">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-check-circle-fill me-1" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
||||
</svg>
|
||||
Verified
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-x-circle-fill me-1" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293z"/>
|
||||
</svg>
|
||||
Unverified
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="employment-dates">
|
||||
@FormatDateRange(verification.ClaimedStartDate, verification.ClaimedEndDate)
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@if (!string.IsNullOrEmpty(verification.VerificationNotes))
|
||||
{
|
||||
<tr class="verification-notes-row @(verification.IsVerified ? "" : "table-warning")">
|
||||
<td colspan="5" class="verification-notes">
|
||||
<small>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-info-circle me-1" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
|
||||
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
|
||||
</svg>
|
||||
@verification.VerificationNotes
|
||||
</small>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.employment-table-wrapper {
|
||||
background-color: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.employment-summary {
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
}
|
||||
|
||||
.employment-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 3rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.employment-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.employment-table th {
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
color: #495057;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
.company-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.matched-company {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.match-score {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.match-score-value {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
min-width: 35px;
|
||||
}
|
||||
|
||||
.employment-dates {
|
||||
font-size: 0.8125rem;
|
||||
color: #6c757d;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.verification-notes-row td {
|
||||
padding-top: 0 !important;
|
||||
border-top: none !important;
|
||||
}
|
||||
|
||||
.verification-notes {
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.table-warning {
|
||||
--bs-table-bg: #fff3cd;
|
||||
--bs-table-striped-bg: #f2e6be;
|
||||
--bs-table-hover-bg: #ecdeb1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public List<CompanyVerificationResult>? 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}";
|
||||
}
|
||||
}
|
||||
222
src/TrueCV.Web/Components/Shared/FlagsList.razor
Normal file
222
src/TrueCV.Web/Components/Shared/FlagsList.razor
Normal file
@@ -0,0 +1,222 @@
|
||||
@using TrueCV.Application.Models
|
||||
|
||||
<div class="flags-list">
|
||||
@if (Flags is null || Flags.Count == 0)
|
||||
{
|
||||
<div class="flags-empty">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-check-circle text-success mb-3" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
|
||||
<path d="m10.97 4.97-.02.022-3.473 4.425-2.093-2.094a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05"/>
|
||||
</svg>
|
||||
<span class="text-muted">No flags found</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="flags-summary mb-3">
|
||||
@{
|
||||
var criticalCount = GetFlagsBySeverity("Critical").Count;
|
||||
var warningCount = GetFlagsBySeverity("Warning").Count;
|
||||
var infoCount = GetFlagsBySeverity("Info").Count;
|
||||
}
|
||||
@if (criticalCount > 0)
|
||||
{
|
||||
<span class="badge bg-danger me-2">@criticalCount Critical</span>
|
||||
}
|
||||
@if (warningCount > 0)
|
||||
{
|
||||
<span class="badge bg-warning text-dark me-2">@warningCount Warning</span>
|
||||
}
|
||||
@if (infoCount > 0)
|
||||
{
|
||||
<span class="badge bg-info text-dark">@infoCount Info</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flags-items">
|
||||
@foreach (var flag in GetSortedFlags())
|
||||
{
|
||||
<div class="flag-item @GetFlagSeverityClass(flag.Severity)">
|
||||
<div class="flag-icon">
|
||||
@GetFlagIcon(flag.Severity)
|
||||
</div>
|
||||
<div class="flag-content">
|
||||
<div class="flag-header">
|
||||
<span class="flag-title">@flag.Title</span>
|
||||
<span class="flag-category badge bg-secondary">@flag.Category</span>
|
||||
</div>
|
||||
<p class="flag-description">@flag.Description</p>
|
||||
<div class="flag-impact">
|
||||
<span class="flag-impact-label">Score Impact:</span>
|
||||
<span class="flag-impact-value @(flag.ScoreImpact < 0 ? "text-danger" : "text-success")">
|
||||
@(flag.ScoreImpact > 0 ? "+" : "")@flag.ScoreImpact
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.flags-list {
|
||||
background-color: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.flags-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.flags-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.flag-item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
.flag-item.critical {
|
||||
background-color: #f8d7da;
|
||||
border-left-color: #dc3545;
|
||||
}
|
||||
|
||||
.flag-item.warning {
|
||||
background-color: #fff3cd;
|
||||
border-left-color: #ffc107;
|
||||
}
|
||||
|
||||
.flag-item.info {
|
||||
background-color: #cff4fc;
|
||||
border-left-color: #0dcaf0;
|
||||
}
|
||||
|
||||
.flag-icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.flag-item.critical .flag-icon { color: #dc3545; }
|
||||
.flag-item.warning .flag-icon { color: #856404; }
|
||||
.flag-item.info .flag-icon { color: #055160; }
|
||||
|
||||
.flag-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flag-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.flag-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.flag-item.critical .flag-title { color: #842029; }
|
||||
.flag-item.warning .flag-title { color: #664d03; }
|
||||
.flag-item.info .flag-title { color: #055160; }
|
||||
|
||||
.flag-category {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.flag-description {
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.flag-impact {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.flag-impact-label {
|
||||
color: #6c757d;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.flag-impact-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public List<FlagResult>? Flags { get; set; }
|
||||
|
||||
private List<FlagResult> GetSortedFlags()
|
||||
{
|
||||
if (Flags is null || Flags.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return Flags
|
||||
.OrderBy(f => GetSeverityOrder(f.Severity))
|
||||
.ThenBy(f => f.ScoreImpact)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private List<FlagResult> 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" => """<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-exclamation-octagon-fill" viewBox="0 0 16 16"><path d="M11.46.146A.5.5 0 0 0 11.107 0H4.893a.5.5 0 0 0-.353.146L.146 4.54A.5.5 0 0 0 0 4.893v6.214a.5.5 0 0 0 .146.353l4.394 4.394a.5.5 0 0 0 .353.146h6.214a.5.5 0 0 0 .353-.146l4.394-4.394a.5.5 0 0 0 .146-.353V4.893a.5.5 0 0 0-.146-.353zM8 4c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995A.905.905 0 0 1 8 4m.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/></svg>""",
|
||||
"warning" => """<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-exclamation-triangle-fill" viewBox="0 0 16 16"><path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5m.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/></svg>""",
|
||||
_ => """<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-info-circle-fill" viewBox="0 0 16 16"><path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2"/></svg>"""
|
||||
};
|
||||
return new MarkupString(icon);
|
||||
}
|
||||
}
|
||||
9
src/TrueCV.Web/Components/Shared/RedirectToLogin.razor
Normal file
9
src/TrueCV.Web/Components/Shared/RedirectToLogin.razor
Normal file
@@ -0,0 +1,9 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
var returnUrl = Uri.EscapeDataString(NavigationManager.Uri);
|
||||
NavigationManager.NavigateTo($"/account/login?returnUrl={returnUrl}", forceLoad: true);
|
||||
}
|
||||
}
|
||||
426
src/TrueCV.Web/Components/Shared/TimelineVisualization.razor
Normal file
426
src/TrueCV.Web/Components/Shared/TimelineVisualization.razor
Normal file
@@ -0,0 +1,426 @@
|
||||
@using TrueCV.Application.Models
|
||||
|
||||
<div class="timeline-visualization">
|
||||
@if (Employment is null || Employment.Count == 0)
|
||||
{
|
||||
<div class="timeline-empty">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-calendar-range text-muted mb-3" viewBox="0 0 16 16">
|
||||
<path d="M9 7a1 1 0 0 1 1-1h5v2h-5a1 1 0 0 1-1-1M1 9h4a1 1 0 0 1 0 2H1z"/>
|
||||
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/>
|
||||
</svg>
|
||||
<span class="text-muted">No employment timeline data available</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="timeline-header mb-3">
|
||||
<div class="timeline-legend">
|
||||
<span class="legend-item">
|
||||
<span class="legend-color employment"></span>
|
||||
Employment
|
||||
</span>
|
||||
@if (Analysis?.Gaps?.Count > 0)
|
||||
{
|
||||
<span class="legend-item">
|
||||
<span class="legend-color gap"></span>
|
||||
Gap (@Analysis.TotalGapMonths months total)
|
||||
</span>
|
||||
}
|
||||
@if (Analysis?.Overlaps?.Count > 0)
|
||||
{
|
||||
<span class="legend-item">
|
||||
<span class="legend-color overlap"></span>
|
||||
Overlap (@Analysis.TotalOverlapMonths months total)
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-container">
|
||||
@{
|
||||
var timelineData = GetTimelineData();
|
||||
var minDate = timelineData.MinDate;
|
||||
var maxDate = timelineData.MaxDate;
|
||||
var totalMonths = GetMonthsDifference(minDate, maxDate);
|
||||
}
|
||||
|
||||
<div class="timeline-axis">
|
||||
@foreach (var year in GetYearMarkers(minDate, maxDate))
|
||||
{
|
||||
var position = GetPositionPercentage(year, minDate, totalMonths);
|
||||
<div class="timeline-year-marker" style="left: @position%;">
|
||||
<span class="year-label">@year.Year</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="timeline-bars">
|
||||
@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);
|
||||
|
||||
<div class="timeline-bar-row">
|
||||
<div class="timeline-bar employment-bar"
|
||||
style="left: @left%; width: @width%;"
|
||||
title="@entry.CompanyName: @FormatDateRange(entry.StartDate, entry.EndDate, entry.IsCurrent)">
|
||||
<span class="bar-label">@entry.CompanyName</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Analysis?.Gaps?.Count > 0)
|
||||
{
|
||||
<div class="timeline-gaps">
|
||||
@foreach (var gap in Analysis.Gaps)
|
||||
{
|
||||
var left = GetPositionPercentage(gap.StartDate, minDate, totalMonths);
|
||||
var width = GetWidthPercentage(gap.StartDate, gap.EndDate, totalMonths);
|
||||
|
||||
<div class="timeline-gap-marker"
|
||||
style="left: @left%; width: @width%;"
|
||||
title="Gap: @gap.Months months (@gap.StartDate.ToString("MMM yyyy") - @gap.EndDate.ToString("MMM yyyy"))">
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Analysis?.Overlaps?.Count > 0)
|
||||
{
|
||||
<div class="timeline-overlaps">
|
||||
@foreach (var overlap in Analysis.Overlaps)
|
||||
{
|
||||
var left = GetPositionPercentage(overlap.OverlapStart, minDate, totalMonths);
|
||||
var width = GetWidthPercentage(overlap.OverlapStart, overlap.OverlapEnd, totalMonths);
|
||||
|
||||
<div class="timeline-overlap-marker"
|
||||
style="left: @left%; width: @width%;"
|
||||
title="Overlap: @overlap.Company1 & @overlap.Company2 (@overlap.Months months)">
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Analysis is not null && (Analysis.Gaps.Count > 0 || Analysis.Overlaps.Count > 0))
|
||||
{
|
||||
<div class="timeline-details mt-4">
|
||||
@if (Analysis.Gaps.Count > 0)
|
||||
{
|
||||
<div class="timeline-detail-section">
|
||||
<h6 class="detail-title text-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-triangle me-2" viewBox="0 0 16 16">
|
||||
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.15.15 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.2.2 0 0 1-.054.06.1.1 0 0 1-.066.017H1.146a.1.1 0 0 1-.066-.017.2.2 0 0 1-.054-.06.18.18 0 0 1 .002-.183L7.884 2.073a.15.15 0 0 1 .054-.057m1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767z"/>
|
||||
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0M7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0z"/>
|
||||
</svg>
|
||||
Employment Gaps
|
||||
</h6>
|
||||
<ul class="detail-list">
|
||||
@foreach (var gap in Analysis.Gaps)
|
||||
{
|
||||
<li>
|
||||
<strong>@gap.Months months</strong> gap from @gap.StartDate.ToString("MMM yyyy") to @gap.EndDate.ToString("MMM yyyy")
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Analysis.Overlaps.Count > 0)
|
||||
{
|
||||
<div class="timeline-detail-section">
|
||||
<h6 class="detail-title text-danger">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-layers me-2" viewBox="0 0 16 16">
|
||||
<path d="M8.235 1.559a.5.5 0 0 0-.47 0l-7.5 4a.5.5 0 0 0 0 .882L3.188 8 .264 9.559a.5.5 0 0 0 0 .882l7.5 4a.5.5 0 0 0 .47 0l7.5-4a.5.5 0 0 0 0-.882L12.813 8l2.922-1.559a.5.5 0 0 0 0-.882zm3.515 7.008L14.438 10 8 13.433 1.562 10 4.25 8.567l3.515 1.874a.5.5 0 0 0 .47 0zM8 9.433 1.562 6 8 2.567 14.438 6z"/>
|
||||
</svg>
|
||||
Employment Overlaps
|
||||
</h6>
|
||||
<ul class="detail-list">
|
||||
@foreach (var overlap in Analysis.Overlaps)
|
||||
{
|
||||
<li>
|
||||
<strong>@overlap.Months months</strong> overlap between <em>@overlap.Company1</em> and <em>@overlap.Company2</em>
|
||||
(@overlap.OverlapStart.ToString("MMM yyyy") - @overlap.OverlapEnd.ToString("MMM yyyy"))
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.timeline-visualization {
|
||||
background-color: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.timeline-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.timeline-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.legend-color.employment { background-color: #0d6efd; }
|
||||
.legend-color.gap { background-color: #fd7e14; }
|
||||
.legend-color.overlap { background-color: #dc3545; }
|
||||
|
||||
.timeline-container {
|
||||
position: relative;
|
||||
padding: 2rem 0;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.timeline-axis {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 30px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.timeline-year-marker {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.timeline-year-marker::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 1px;
|
||||
height: 10px;
|
||||
background-color: #dee2e6;
|
||||
}
|
||||
|
||||
.year-label {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.timeline-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.timeline-bar-row {
|
||||
position: relative;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.timeline-bar {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.5rem;
|
||||
overflow: hidden;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.employment-bar {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.timeline-gaps {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 30px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-gap-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(253, 126, 20, 0.2);
|
||||
border-left: 2px dashed #fd7e14;
|
||||
border-right: 2px dashed #fd7e14;
|
||||
pointer-events: auto;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.timeline-overlaps {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 30px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-overlap-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(220, 53, 69, 0.15);
|
||||
border-left: 2px solid #dc3545;
|
||||
border-right: 2px solid #dc3545;
|
||||
pointer-events: auto;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.timeline-details {
|
||||
border-top: 1px solid #dee2e6;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.timeline-detail-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.timeline-detail-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.detail-list li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public TimelineAnalysisResult? Analysis { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public List<EmploymentEntry>? 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<DateOnly>();
|
||||
|
||||
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<DateOnly> 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}";
|
||||
}
|
||||
}
|
||||
191
src/TrueCV.Web/Components/Shared/VeracityScoreCard.razor
Normal file
191
src/TrueCV.Web/Components/Shared/VeracityScoreCard.razor
Normal file
@@ -0,0 +1,191 @@
|
||||
@implements IDisposable
|
||||
|
||||
<div class="veracity-score-card">
|
||||
<div class="score-circle @GetScoreColorClass()">
|
||||
<svg viewBox="0 0 36 36" class="score-chart">
|
||||
<path class="score-background"
|
||||
d="M18 2.0845
|
||||
a 15.9155 15.9155 0 0 1 0 31.831
|
||||
a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
<path class="score-progress"
|
||||
stroke-dasharray="@_displayScore, 100"
|
||||
d="M18 2.0845
|
||||
a 15.9155 15.9155 0 0 1 0 31.831
|
||||
a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
</svg>
|
||||
<div class="score-value">
|
||||
<span class="score-number">@_displayScore</span>
|
||||
<span class="score-max">/100</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-label @GetScoreColorClass()">
|
||||
@(string.IsNullOrEmpty(Label) ? GetDefaultLabel() : Label)
|
||||
</div>
|
||||
<div class="score-description">
|
||||
@GetScoreDescription()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.veracity-score-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
background-color: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.score-circle {
|
||||
position: relative;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.score-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.score-background {
|
||||
fill: none;
|
||||
stroke: #e9ecef;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.score-progress {
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dasharray 0.5s ease-out;
|
||||
}
|
||||
|
||||
.score-circle.excellent .score-progress { stroke: #198754; }
|
||||
.score-circle.good .score-progress { stroke: #20c997; }
|
||||
.score-circle.moderate .score-progress { stroke: #ffc107; }
|
||||
.score-circle.poor .score-progress { stroke: #dc3545; }
|
||||
|
||||
.score-value {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.score-number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.score-circle.excellent .score-number { color: #198754; }
|
||||
.score-circle.good .score-number { color: #20c997; }
|
||||
.score-circle.moderate .score-number { color: #ffc107; }
|
||||
.score-circle.poor .score-number { color: #dc3545; }
|
||||
|
||||
.score-max {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.score-label.excellent { color: #198754; }
|
||||
.score-label.good { color: #20c997; }
|
||||
.score-label.moderate { color: #ffc107; }
|
||||
.score-label.poor { color: #dc3545; }
|
||||
|
||||
.score-description {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
||||
198
src/TrueCV.Web/Components/Shared/VerificationProgress.razor
Normal file
198
src/TrueCV.Web/Components/Shared/VerificationProgress.razor
Normal file
@@ -0,0 +1,198 @@
|
||||
@using TrueCV.Domain.Enums
|
||||
|
||||
<div class="verification-progress">
|
||||
<div class="verification-progress-header">
|
||||
@if (!string.IsNullOrEmpty(FileName))
|
||||
{
|
||||
<span class="verification-progress-filename" title="@FileName">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-earmark-text me-2" viewBox="0 0 16 16">
|
||||
<path d="M5.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zM5 9.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5m0 2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5"/>
|
||||
<path d="M9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5zm0 1v2A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/>
|
||||
</svg>
|
||||
@FileName
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="verification-progress-status">
|
||||
@switch (Status)
|
||||
{
|
||||
case CheckStatus.Pending:
|
||||
<div class="status-icon status-pending">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-hourglass" viewBox="0 0 16 16">
|
||||
<path d="M2 1.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-1v1a4.5 4.5 0 0 1-2.557 4.06c-.29.139-.443.377-.443.59v.7c0 .213.154.451.443.59A4.5 4.5 0 0 1 12.5 13v1h1a.5.5 0 0 1 0 1h-11a.5.5 0 1 1 0-1h1v-1a4.5 4.5 0 0 1 2.557-4.06c.29-.139.443-.377.443-.59v-.7c0-.213-.154-.451-.443-.59A4.5 4.5 0 0 1 3.5 3V2h-1a.5.5 0 0 1-.5-.5m2.5.5v1a3.5 3.5 0 0 0 1.989 3.158c.533.256 1.011.791 1.011 1.491v.702c0 .7-.478 1.235-1.011 1.491A3.5 3.5 0 0 0 4.5 13v1h7v-1a3.5 3.5 0 0 0-1.989-3.158C8.978 9.586 8.5 9.052 8.5 8.351v-.702c0-.7.478-1.235 1.011-1.491A3.5 3.5 0 0 0 11.5 3V2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="status-text">
|
||||
<span class="status-title">Pending</span>
|
||||
<span class="status-description">Your CV is queued for verification</span>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case CheckStatus.Processing:
|
||||
<div class="status-icon status-processing">
|
||||
<div class="spinner-border text-primary" role="status" style="width: 3rem; height: 3rem;">
|
||||
<span class="visually-hidden">Processing...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-text">
|
||||
<span class="status-title">Processing</span>
|
||||
<span class="status-description">Verifying your CV data...</span>
|
||||
</div>
|
||||
<div class="processing-steps mt-3">
|
||||
<div class="processing-step active">
|
||||
<div class="step-indicator"></div>
|
||||
<span>Extracting CV data</span>
|
||||
</div>
|
||||
<div class="processing-step">
|
||||
<div class="step-indicator"></div>
|
||||
<span>Verifying companies</span>
|
||||
</div>
|
||||
<div class="processing-step">
|
||||
<div class="step-indicator"></div>
|
||||
<span>Analyzing timeline</span>
|
||||
</div>
|
||||
<div class="processing-step">
|
||||
<div class="step-indicator"></div>
|
||||
<span>Generating report</span>
|
||||
</div>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case CheckStatus.Completed:
|
||||
<div class="status-icon status-completed">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-check-circle-fill text-success" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="status-text">
|
||||
<span class="status-title text-success">Completed</span>
|
||||
<span class="status-description">Verification complete! View your results below.</span>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case CheckStatus.Failed:
|
||||
<div class="status-icon status-failed">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-x-circle-fill text-danger" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="status-text">
|
||||
<span class="status-title text-danger">Failed</span>
|
||||
<span class="status-description">Something went wrong. Please try again.</span>
|
||||
</div>
|
||||
break;
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.verification-progress {
|
||||
background-color: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.verification-progress-header {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.verification-progress-filename {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
color: #495057;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.verification-progress-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.status-pending svg {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.status-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.status-description {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.processing-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.processing-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.processing-step.active {
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.processing-step.active .step-indicator {
|
||||
background-color: #0d6efd;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: #dee2e6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public CheckStatus Status { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? FileName { get; set; }
|
||||
}
|
||||
18
src/TrueCV.Web/Components/_Imports.razor
Normal file
18
src/TrueCV.Web/Components/_Imports.razor
Normal file
@@ -0,0 +1,18 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using TrueCV.Web
|
||||
@using TrueCV.Web.Components
|
||||
@using TrueCV.Web.Components.Shared
|
||||
@using TrueCV.Application.Interfaces
|
||||
@using TrueCV.Application.DTOs
|
||||
@using TrueCV.Application.Models
|
||||
@using TrueCV.Domain.Enums
|
||||
108
src/TrueCV.Web/Program.cs
Normal file
108
src/TrueCV.Web/Program.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using Hangfire;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Serilog;
|
||||
using TrueCV.Infrastructure;
|
||||
using TrueCV.Infrastructure.Data;
|
||||
using TrueCV.Infrastructure.Identity;
|
||||
using TrueCV.Web;
|
||||
using TrueCV.Web.Components;
|
||||
|
||||
// Configure Serilog
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.WriteTo.Console()
|
||||
.CreateBootstrapLogger();
|
||||
|
||||
try
|
||||
{
|
||||
Log.Information("Starting TrueCV web application");
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure Serilog from appsettings
|
||||
builder.Host.UseSerilog((context, services, configuration) => configuration
|
||||
.ReadFrom.Configuration(context.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext());
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
// Add Infrastructure services (DbContext, Hangfire, HttpClients, Services)
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
// Add Identity
|
||||
builder.Services.AddIdentity<ApplicationUser, IdentityRole<Guid>>(options =>
|
||||
{
|
||||
options.Password.RequireDigit = false;
|
||||
options.Password.RequireLowercase = false;
|
||||
options.Password.RequireUppercase = false;
|
||||
options.Password.RequireNonAlphanumeric = false;
|
||||
options.Password.RequiredLength = 6;
|
||||
options.SignIn.RequireConfirmedAccount = false;
|
||||
})
|
||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
builder.Services.ConfigureApplicationCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/account/login";
|
||||
options.LogoutPath = "/account/logout";
|
||||
options.AccessDeniedPath = "/account/login";
|
||||
});
|
||||
|
||||
// Add Cascading Authentication State
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider, RevalidatingIdentityAuthenticationStateProvider<ApplicationUser>>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseStaticFiles();
|
||||
app.UseAntiforgery();
|
||||
|
||||
// Add Serilog request logging
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Add Hangfire Dashboard (only in development)
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||||
{
|
||||
Authorization = [] // Allow anonymous access in development
|
||||
});
|
||||
}
|
||||
|
||||
// Logout endpoint
|
||||
app.MapPost("/account/logout", async (SignInManager<ApplicationUser> signInManager) =>
|
||||
{
|
||||
await signInManager.SignOutAsync();
|
||||
return Results.Redirect("/");
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Application terminated unexpectedly");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
38
src/TrueCV.Web/Properties/launchSettings.json
Normal file
38
src/TrueCV.Web/Properties/launchSettings.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:9979",
|
||||
"sslPort": 44360
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5114",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7176;http://localhost:5114",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Server;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace TrueCV.Web;
|
||||
|
||||
public class RevalidatingIdentityAuthenticationStateProvider<TUser>
|
||||
: RevalidatingServerAuthenticationStateProvider where TUser : class
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IdentityOptions _options;
|
||||
|
||||
public RevalidatingIdentityAuthenticationStateProvider(
|
||||
ILoggerFactory loggerFactory,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<IdentityOptions> optionsAccessor)
|
||||
: base(loggerFactory)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_options = optionsAccessor.Value;
|
||||
}
|
||||
|
||||
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
|
||||
|
||||
protected override async Task<bool> ValidateAuthenticationStateAsync(
|
||||
AuthenticationState authenticationState, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<TUser>>();
|
||||
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateSecurityStampAsync(UserManager<TUser> userManager, ClaimsPrincipal principal)
|
||||
{
|
||||
var user = await userManager.GetUserAsync(principal);
|
||||
if (user is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!userManager.SupportsUserSecurityStamp)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var principalStamp = principal.FindFirstValue(_options.ClaimsIdentity.SecurityStampClaimType);
|
||||
var userStamp = await userManager.GetSecurityStampAsync(user);
|
||||
return principalStamp == userStamp;
|
||||
}
|
||||
}
|
||||
18
src/TrueCV.Web/TrueCV.Web.csproj
Normal file
18
src/TrueCV.Web/TrueCV.Web.csproj
Normal file
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TrueCV.Infrastructure\TrueCV.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="8.0.*" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
30
src/TrueCV.Web/appsettings.json
Normal file
30
src/TrueCV.Web/appsettings.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=.;Database=TrueCV;Trusted_Connection=True;TrustServerCertificate=True;",
|
||||
"HangfireConnection": "Server=.;Database=TrueCV_Hangfire;Trusted_Connection=True;TrustServerCertificate=True;"
|
||||
},
|
||||
"CompaniesHouse": {
|
||||
"BaseUrl": "https://api.company-information.service.gov.uk",
|
||||
"ApiKey": ""
|
||||
},
|
||||
"Anthropic": {
|
||||
"ApiKey": ""
|
||||
},
|
||||
"AzureBlob": {
|
||||
"ConnectionString": "",
|
||||
"ContainerName": "cv-uploads"
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{ "Name": "Console" }
|
||||
]
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
51
src/TrueCV.Web/wwwroot/app.css
Normal file
51
src/TrueCV.Web/wwwroot/app.css
Normal file
@@ -0,0 +1,51 @@
|
||||
html, body {
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
a, .btn-link {
|
||||
color: #006bb7;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #fff;
|
||||
background-color: #1b6ec2;
|
||||
border-color: #1861ac;
|
||||
}
|
||||
|
||||
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
||||
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-top: 1.1rem;
|
||||
}
|
||||
|
||||
h1:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.valid.modified:not([type=checkbox]) {
|
||||
outline: 1px solid #26b050;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
outline: 1px solid #e50000;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
color: #e50000;
|
||||
}
|
||||
|
||||
.blazor-error-boundary {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
||||
padding: 1rem 1rem 1rem 3.7rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.blazor-error-boundary::after {
|
||||
content: "An error has occurred."
|
||||
}
|
||||
|
||||
.darker-border-checkbox.form-check-input {
|
||||
border-color: #929292;
|
||||
}
|
||||
7
src/TrueCV.Web/wwwroot/bootstrap/bootstrap.min.css
vendored
Normal file
7
src/TrueCV.Web/wwwroot/bootstrap/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/TrueCV.Web/wwwroot/bootstrap/bootstrap.min.css.map
Normal file
1
src/TrueCV.Web/wwwroot/bootstrap/bootstrap.min.css.map
Normal file
File diff suppressed because one or more lines are too long
BIN
src/TrueCV.Web/wwwroot/favicon.png
Normal file
BIN
src/TrueCV.Web/wwwroot/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Reference in New Issue
Block a user