merge: reconcile perf/wave1-perf with main (Wave 0 features)
CI and Deploy / test (pull_request) Successful in 2m13s
CI and Deploy / deploy (pull_request) Has been skipped

Resolve conflicts from main's Wave 0 (PR #1) landing after this branch was cut:

- useViewResource.ts: main's e352aae already fixes the render loop the same way
  (load in a ref, dropped from deps) — took main's canonical version. My
  independent fix is superseded (my branch predated e352aae, which is why the
  loop reproduced live).
- JobApplicationsController.cs: keep BOTH main's IJobCvMatchService and my
  AnalyticsService (ctor gets both optional params). GetAnalyticsOverview stays
  delegated to AnalyticsService.
- Fold main's H3 additions into the extracted AnalyticsService: pipeline-driven
  funnel (JobPipeline.Normalize/Stages) + time-in-stage (StageAnalytics) and add
  StageDurationDto + TimeInStage to Models/AnalyticsDtos.cs, preserving the API
  contract the frontend expects.

Build clean; backend suite 135/135 green.
This commit is contained in:
cesnimda
2026-07-05 20:16:40 +02:00
67 changed files with 3254 additions and 498 deletions
@@ -58,6 +58,10 @@ namespace JobTrackerApi.Controllers
"DateApplied",
"Location",
"Salary",
"SalaryMin",
"SalaryMax",
"SalaryCurrency",
"SalaryPeriod",
"NextAction",
"FollowUpAt",
"JobUrl",
@@ -76,6 +80,10 @@ namespace JobTrackerApi.Controllers
Esc(j.DateApplied.ToString("o")),
Esc(j.Location),
Esc(j.Salary),
Esc(j.SalaryMin?.ToString(System.Globalization.CultureInfo.InvariantCulture)),
Esc(j.SalaryMax?.ToString(System.Globalization.CultureInfo.InvariantCulture)),
Esc(j.SalaryCurrency),
Esc(j.SalaryPeriod),
Esc(j.NextAction),
Esc(j.FollowUpAt?.ToString("o")),
Esc(j.JobUrl),
@@ -24,8 +24,9 @@ namespace JobTrackerApi.Controllers
private readonly ICvTemplateRenderer _cvTemplateRenderer;
private readonly ICvPdfExporter _cvPdfExporter;
private readonly AnalyticsService _analytics;
private readonly IJobCvMatchService _matchService;
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null)
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null, IJobCvMatchService? matchService = null)
{
_db = db;
_summarizer = summarizer;
@@ -35,6 +36,7 @@ namespace JobTrackerApi.Controllers
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
_analytics = analytics ?? new AnalyticsService(db);
_matchService = matchService ?? new JobCvMatchService();
}
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
@@ -751,6 +753,10 @@ Canonical profile:
Deadline: job.Deadline,
Location: job.Location,
Salary: job.Salary,
SalaryMin: job.SalaryMin,
SalaryMax: job.SalaryMax,
SalaryCurrency: job.SalaryCurrency,
SalaryPeriod: job.SalaryPeriod,
NextAction: job.NextAction,
FollowUpAt: job.FollowUpAt,
FeedbackRequestedAt: job.FeedbackRequestedAt,
@@ -1083,6 +1089,10 @@ Canonical profile:
DateTime? Deadline,
string? Location,
string? Salary,
decimal? SalaryMin,
decimal? SalaryMax,
string? SalaryCurrency,
string? SalaryPeriod,
string? NextAction,
DateTime? FollowUpAt,
DateTime? FeedbackRequestedAt,
@@ -1351,6 +1361,10 @@ Canonical profile:
string? Status,
string? Location,
string? Salary,
decimal? SalaryMin,
decimal? SalaryMax,
string? SalaryCurrency,
string? SalaryPeriod,
string? NextAction,
DateTime? FollowUpAt,
string? Notes,
@@ -1369,6 +1383,22 @@ Canonical profile:
bool? HasOtherAttachment
);
private static (decimal? Min, decimal? Max, string? Currency, string? Period) NormalizeSalary(
decimal? min, decimal? max, string? currency, string? period)
{
if (min is < 0) min = null;
if (max is < 0) max = null;
if (min.HasValue && max.HasValue && min > max) (min, max) = (max, min);
var cur = (currency ?? "").Trim().ToUpperInvariant();
if (cur.Length > 8) cur = cur[..8];
var per = (period ?? "").Trim().ToLowerInvariant();
if (per is not ("year" or "month" or "hour")) per = "";
return (min, max, cur.Length == 0 ? null : cur, per.Length == 0 ? null : per);
}
[HttpPost]
public async Task<ActionResult<JobApplication>> Create([FromBody] CreateJobApplicationRequest request, CancellationToken cancellationToken)
{
@@ -1377,9 +1407,7 @@ Canonical profile:
if (title.Length == 0) return BadRequest("Job title is required.");
if (request.CompanyId <= 0) return BadRequest("Valid companyId is required.");
var companyOk = await _db.Companies.AnyAsync(c => c.Id == request.CompanyId, cancellationToken);
if (!companyOk) return BadRequest("companyId does not exist.");
// Scoped by the Company query filter, so this also rejects another user's companyId.
var companyExists = await _db.Companies.AnyAsync(c => c.Id == request.CompanyId, cancellationToken);
if (!companyExists) return BadRequest("companyId does not exist.");
@@ -1388,7 +1416,7 @@ Canonical profile:
OwnerUserId = string.IsNullOrWhiteSpace(userId) ? null : userId,
JobTitle = title,
CompanyId = request.CompanyId,
Status = string.IsNullOrWhiteSpace(request.Status) ? "Applied" : request.Status.Trim(),
Status = JobPipeline.Normalize(request.Status),
Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim(),
Salary = string.IsNullOrWhiteSpace(request.Salary) ? null : request.Salary.Trim(),
NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim(),
@@ -1411,6 +1439,9 @@ Canonical profile:
ResponseDate = null,
};
(job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) =
NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod);
// Generate and persist a short summary at creation time to avoid repeated model calls.
try
{
@@ -1449,6 +1480,10 @@ Canonical profile:
DateTime? ResponseDate,
string? Location,
string? Salary,
decimal? SalaryMin,
decimal? SalaryMax,
string? SalaryCurrency,
string? SalaryPeriod,
string? NextAction,
DateTime? FollowUpAt,
bool? HasResume,
@@ -1484,11 +1519,13 @@ Canonical profile:
job.JobTitle = title;
job.CompanyId = request.CompanyId;
job.Status = string.IsNullOrWhiteSpace(request.Status) ? job.Status : request.Status.Trim();
job.Status = string.IsNullOrWhiteSpace(request.Status) ? job.Status : JobPipeline.Normalize(request.Status);
job.ResponseReceived = request.ResponseReceived;
job.ResponseDate = request.ResponseDate;
job.Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim();
job.Salary = string.IsNullOrWhiteSpace(request.Salary) ? null : request.Salary.Trim();
(job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) =
NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod);
job.NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim();
job.FollowUpAt = request.FollowUpAt;
job.FeedbackRequestedAt = request.FeedbackRequestedAt;
@@ -1535,6 +1572,13 @@ Canonical profile:
public sealed record UpdateStatusRequest(string Status);
public sealed record PipelineStageDto(string Key, int Order, string Category);
/// <summary>Canonical ordered pipeline stages so the UI renders one source of truth.</summary>
[HttpGet("pipeline")]
public ActionResult<IEnumerable<PipelineStageDto>> GetPipeline()
=> Ok(JobPipeline.Stages.Select(s => new PipelineStageDto(s.Key, s.Order, s.Category.ToString())));
[HttpPatch("{id:int}/status")]
public async Task<IActionResult> UpdateStatus([FromRoute] int id, [FromBody] UpdateStatusRequest request, CancellationToken cancellationToken)
{
@@ -1543,7 +1587,7 @@ Canonical profile:
if (string.IsNullOrWhiteSpace(request.Status)) return BadRequest("Status is required.");
var old = job.Status;
job.Status = request.Status.Trim();
job.Status = JobPipeline.Normalize(request.Status);
if (!string.Equals(old, job.Status, StringComparison.OrdinalIgnoreCase))
{
_db.JobEvents.Add(new JobEvent
@@ -1560,6 +1604,57 @@ Canonical profile:
return NoContent();
}
public sealed record StatusSuggestionDto(
bool HasSuggestion,
string? SuggestedStatus,
string? CurrentStatus,
string? Signal,
string? Confidence,
DateTime? MessageDate,
string? MessageSubject);
/// <summary>
/// Suggests a pipeline status from the job's most recent inbound message (e.g. an interview
/// invite or rejection). Deterministic and always human-confirmed via PATCH .../status.
/// </summary>
[HttpGet("{id:int}/status-suggestion")]
public async Task<ActionResult<StatusSuggestionDto>> GetStatusSuggestion([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var none = new StatusSuggestionDto(false, null, job.Status, null, null, null, null);
var latestInbound = await _db.Correspondences
.AsNoTracking()
.Where(c => c.JobApplicationId == id
&& c.Direction != "outbound"
&& c.From != "Me")
.OrderByDescending(c => c.Date)
.FirstOrDefaultAsync(cancellationToken);
if (latestInbound is null) return Ok(none);
var suggestion = EmailStatusClassifier.Classify(latestInbound.Subject, latestInbound.Content);
if (suggestion is null) return Ok(none);
// Don't nag when the job is already in (or past) the suggested stage.
var currentOrder = JobPipeline.OrderOf(job.Status);
var suggestedOrder = JobPipeline.OrderOf(suggestion.SuggestedStatus);
if (JobPipeline.Normalize(job.Status) == suggestion.SuggestedStatus || currentOrder >= suggestedOrder)
{
return Ok(none);
}
return Ok(new StatusSuggestionDto(
HasSuggestion: true,
SuggestedStatus: suggestion.SuggestedStatus,
CurrentStatus: job.Status,
Signal: suggestion.Signal,
Confidence: suggestion.Confidence,
MessageDate: latestInbound.Date,
MessageSubject: latestInbound.Subject));
}
[HttpPost("{id:int}/refresh-ai")]
public async Task<ActionResult<JobApplicationDto>> RefreshAi([FromRoute] int id, CancellationToken cancellationToken)
@@ -2024,6 +2119,89 @@ Canonical profile:
};
}
public sealed record MatchScoreDto(
int Score,
string Band,
int MatchedCount,
int TotalKeywords,
List<string> MatchedKeywords,
List<string> MissingKeywords,
List<MatchSectionCoverageDto> SectionCoverage,
bool HasEnoughSignal);
public sealed record MatchSectionCoverageDto(string Section, int Matched, int Total);
// Builds CV text grouped by section so match coverage can show *where* the evidence sits.
private static Dictionary<string, string> BuildCvSections(ApplicationUser? user)
{
var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson);
var sections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
void Add(string name, IEnumerable<string?> values)
{
var text = string.Join("\n", values.Where(v => !string.IsNullOrWhiteSpace(v)));
if (!string.IsNullOrWhiteSpace(text)) sections[name] = text;
}
Add("Summary", new[] { structured.Contact.Headline }.Concat(structured.Summary));
Add("Skills", structured.Skills);
Add("Experience", structured.Jobs.SelectMany(job =>
new[] { job.Title, job.Company }.Concat(job.Bullets).Concat(job.Skills)));
Add("Education", structured.Education.SelectMany(ed =>
new[] { ed.Qualification, ed.Institution }.Concat(ed.Details)));
// Always include raw profile text (covers users who only pasted plain CV text, and
// catches keywords the structured sections missed).
if (!string.IsNullOrWhiteSpace(user?.ProfileCvText))
{
sections["Profile"] = user!.ProfileCvText!;
}
return sections;
}
/// <summary>
/// Fast, deterministic CV↔job keyword coverage score. Unlike candidate-fit (AI narrative),
/// this makes no model calls, so it returns instantly and reproducibly.
/// </summary>
[HttpGet("{id:int}/match-score")]
public async Task<ActionResult<MatchScoreDto>> GetMatchScore([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var userId = CurrentUserId;
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
var cvSections = BuildCvSections(user);
if (cvSections.Count == 0)
{
return BadRequest("Add your profile CV on the Profile page before running the match score.");
}
var jobText = string.Join("\n\n", new[] { job.Description, job.TranslatedDescription, job.Notes }
.Where(x => !string.IsNullOrWhiteSpace(x)));
if (string.IsNullOrWhiteSpace(jobText))
{
return BadRequest("This job does not have enough description or notes to compare against your CV.");
}
var result = _matchService.Evaluate(job.JobTitle, jobText, cvSections);
return Ok(new MatchScoreDto(
Score: result.Score,
Band: result.Band,
MatchedCount: result.MatchedCount,
TotalKeywords: result.TotalKeywords,
MatchedKeywords: result.MatchedKeywords.ToList(),
MissingKeywords: result.MissingKeywords.ToList(),
SectionCoverage: result.SectionCoverage.Select(s => new MatchSectionCoverageDto(s.Section, s.Matched, s.Total)).ToList(),
HasEnoughSignal: result.HasEnoughSignal));
}
[HttpGet("{id:int}/candidate-fit")]
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
{
@@ -883,6 +883,10 @@ public sealed class ProfileCvController : ControllerBase
return run;
}
// Invoked by CvProcessingHostedService (this controller is also registered as a
// transient service). NonAction keeps it off the HTTP surface: without it the
// controller-level [Route] exposes it as an any-verb endpoint.
[NonAction]
public async Task ProcessQueuedRunAsync(int runId, CancellationToken cancellationToken)
{
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == runId, cancellationToken);
+1
View File
@@ -11,6 +11,7 @@
<Compile Remove="Controllers\**\*.cs" />
<Compile Remove="Services\**\*.cs" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
</ItemGroup>
+10
View File
@@ -112,6 +112,7 @@ builder.Services.AddCors(options =>
// Add controllers
builder.Services.AddControllers();
builder.Services.AddOpenApi();
var dataRoot = (builder.Configuration["Data:Root"] ?? "").Trim();
if (string.IsNullOrWhiteSpace(dataRoot))
{
@@ -128,6 +129,8 @@ Directory.CreateDirectory(dataProtectionKeysPath);
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath))
.SetApplicationName("JobTracker");
builder.Services.AddSingleton<IDatabaseBackupRunner, SqliteDatabaseBackupRunner>();
builder.Services.AddHostedService<DatabaseBackupHostedService>();
builder.Services.AddHostedService<RulesHostedService>();
builder.Services.AddHostedService<FollowUpReminderHostedService>();
builder.Services.AddHostedService<DailyExportHostedService>();
@@ -155,6 +158,7 @@ builder.Services.AddHttpClient("ai-service", client =>
builder.Services.AddMemoryCache();
builder.Services.AddScoped<AnalyticsService>();
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
builder.Services.AddSingleton<IJobCvMatchService, JobCvMatchService>();
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
@@ -440,4 +444,10 @@ app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
// API schema for tooling/docs. Dev-only: not exposed in production deployments.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi().AllowAnonymous();
}
app.Run();
+43 -11
View File
@@ -64,6 +64,7 @@ namespace JobTrackerApi.Services
.Where(j => !j.IsDeleted)
.Select(j => new
{
j.Id,
j.Status,
j.ResponseReceived,
j.ResponseDate,
@@ -74,16 +75,14 @@ namespace JobTrackerApi.Services
})
.ToListAsync(cancellationToken);
var funnelMap = new Dictionary<string, int>
{
["Applied"] = activeJobs.Count(j => string.Equals(j.Status, "Applied", StringComparison.OrdinalIgnoreCase)),
["Interview"] = activeJobs.Count(j => string.Equals(j.Status, "Interview", StringComparison.OrdinalIgnoreCase) || string.Equals(j.Status, "Interviewing", StringComparison.OrdinalIgnoreCase)),
["Offer"] = activeJobs.Count(j => string.Equals(j.Status, "Offer", StringComparison.OrdinalIgnoreCase)),
["Rejected"] = activeJobs.Count(j => string.Equals(j.Status, "Rejected", StringComparison.OrdinalIgnoreCase)),
["Ghosted"] = activeJobs.Count(j => string.Equals(j.Status, "Ghosted", StringComparison.OrdinalIgnoreCase)),
};
var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).ToList();
// Funnel = distribution across canonical stages, driven by the pipeline (one source
// of truth, so it includes every stage and normalizes legacy spellings).
var normalizedByStage = activeJobs
.GroupBy(j => JobPipeline.Normalize(j.Status))
.ToDictionary(g => g.Key, g => g.Count());
var funnel = JobPipeline.Stages
.Select(stage => new FunnelStagePoint(stage.Key, normalizedByStage.TryGetValue(stage.Key, out var c) ? c : 0))
.ToList();
var responseRateBySource = activeJobs
.GroupBy(j => string.IsNullOrWhiteSpace(j.CompanySource) ? "Unknown source" : j.CompanySource!.Trim())
@@ -127,13 +126,46 @@ namespace JobTrackerApi.Services
: Math.Round(responseDays[mid], 1);
}
// Time-in-stage: for each active job, when did it enter its current stage? Use the most
// recent StatusChanged event into that stage, else its applied date.
var activeIds = activeJobs.Select(j => j.Id).ToList();
var statusChanges = await _db.JobEvents
.AsNoTracking()
.Where(e => e.Type == "StatusChanged" && activeIds.Contains(e.JobApplicationId))
.Select(e => new { e.JobApplicationId, e.NewValue, e.At })
.ToListAsync(cancellationToken);
var lastEntryByJob = statusChanges
.GroupBy(e => e.JobApplicationId)
.ToDictionary(g => g.Key, g => g.ToList());
var occupancy = activeJobs.Select(job =>
{
var current = JobPipeline.Normalize(job.Status);
DateTime enteredAt = job.DateApplied;
if (lastEntryByJob.TryGetValue(job.Id, out var changes))
{
var lastIntoCurrent = changes
.Where(e => JobPipeline.Normalize(e.NewValue) == current)
.OrderByDescending(e => e.At)
.FirstOrDefault();
if (lastIntoCurrent is not null) enteredAt = lastIntoCurrent.At;
}
return new StageOccupancy(current, enteredAt.ToUniversalTime());
});
var timeInStage = StageAnalytics.TimeInStage(occupancy, DateTime.UtcNow)
.Select(p => new StageDurationDto(p.Stage, p.MedianDays, p.Count))
.ToList();
return new AnalyticsOverviewDto(
Funnel: funnel,
ResponseRateBySource: responseRateBySource,
TopCompanies: topCompanies,
MedianDaysToFirstResponse: medianDays,
TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null),
TotalActive: activeJobs.Count
TotalActive: activeJobs.Count,
TimeInStage: timeInStage
);
}
}
@@ -0,0 +1,84 @@
namespace JobTrackerApi.Services
{
public sealed class DatabaseBackupHostedService : BackgroundService
{
private readonly IDatabaseBackupRunner _runner;
private readonly ILogger<DatabaseBackupHostedService> _logger;
private readonly IConfiguration _cfg;
private readonly IStartupReadiness _startupReadiness;
public DatabaseBackupHostedService(
IDatabaseBackupRunner runner,
ILogger<DatabaseBackupHostedService> logger,
IConfiguration cfg,
IStartupReadiness startupReadiness)
{
_runner = runner;
_logger = logger;
_cfg = cfg;
_startupReadiness = startupReadiness;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _startupReadiness.WaitUntilReadyAsync(stoppingToken);
if (!_cfg.GetValue("Backups:Enabled", true))
{
_logger.LogInformation("Automated database backups disabled (Backups:Enabled=false).");
return;
}
if (!_runner.IsSupported)
{
_logger.LogWarning("Automated database backups are unavailable for the configured provider. Configure external backups for MySQL/MariaDB.");
return;
}
var hour = _cfg.GetValue("Backups:HourLocal", 3);
if (hour < 0 || hour > 23) hour = 3;
// Catch-up: guarantee at least one recent backup exists even if the
// process never stays up long enough to reach the scheduled hour.
var latest = _runner.GetLatestBackupUtc();
if (latest is null || latest < DateTime.UtcNow.AddHours(-24))
{
await TryBackupAsync(stoppingToken);
}
while (!stoppingToken.IsCancellationRequested)
{
var now = DateTime.Now;
var next = new DateTime(now.Year, now.Month, now.Day, hour, 0, 0);
if (next <= now) next = next.AddDays(1);
_logger.LogInformation("Next database backup scheduled at {Next}.", next);
try
{
await Task.Delay(next - now, stoppingToken);
}
catch (TaskCanceledException)
{
break;
}
await TryBackupAsync(stoppingToken);
}
}
private async Task TryBackupAsync(CancellationToken ct)
{
try
{
await _runner.RunOnceAsync(ct);
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
_logger.LogError(ex, "Database backup failed.");
}
}
}
}
@@ -0,0 +1,110 @@
using Microsoft.Data.Sqlite;
namespace JobTrackerApi.Services
{
public interface IDatabaseBackupRunner
{
string BackupsRoot { get; }
bool IsSupported { get; }
/// <summary>Creates one backup file and prunes old ones. Returns the backup path, or null when unsupported.</summary>
Task<string?> RunOnceAsync(CancellationToken ct);
DateTime? GetLatestBackupUtc();
}
public sealed class SqliteDatabaseBackupRunner : IDatabaseBackupRunner
{
public const string BackupFilePrefix = "jobtracker_backup_";
private readonly ILogger<SqliteDatabaseBackupRunner> _logger;
private readonly string _connectionString;
private readonly int _retainCount;
public string BackupsRoot { get; }
public bool IsSupported { get; }
public SqliteDatabaseBackupRunner(IConfiguration cfg, AppPaths paths, ILogger<SqliteDatabaseBackupRunner> logger)
{
_logger = logger;
var provider = (cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
var cs = cfg.GetConnectionString("JobTracker");
if (string.IsNullOrWhiteSpace(cs))
{
cs = $"Data Source={paths.GetDbPath()}";
provider = "sqlite";
}
_connectionString = cs;
IsSupported = provider == "sqlite";
BackupsRoot = Path.Combine(paths.DataRoot, "backups");
_retainCount = Math.Clamp(cfg.GetValue("Backups:RetainCount", 14), 1, 365);
}
// Test-friendly constructor.
public SqliteDatabaseBackupRunner(string connectionString, string backupsRoot, int retainCount, ILogger<SqliteDatabaseBackupRunner> logger)
{
_logger = logger;
_connectionString = connectionString;
IsSupported = true;
BackupsRoot = backupsRoot;
_retainCount = Math.Clamp(retainCount, 1, 365);
}
public async Task<string?> RunOnceAsync(CancellationToken ct)
{
if (!IsSupported)
{
_logger.LogWarning("Automated backups only support the SQLite provider. Configure external backups for MySQL/MariaDB.");
return null;
}
Directory.CreateDirectory(BackupsRoot);
var target = Path.Combine(BackupsRoot, $"{BackupFilePrefix}{DateTime.UtcNow:yyyyMMdd_HHmmss}.db");
if (File.Exists(target)) File.Delete(target);
await using (var connection = new SqliteConnection(_connectionString))
{
await connection.OpenAsync(ct);
await using var command = connection.CreateCommand();
// VACUUM INTO produces a consistent, compacted snapshot without blocking writers (WAL).
command.CommandText = $"VACUUM INTO '{target.Replace("'", "''")}'";
await command.ExecuteNonQueryAsync(ct);
}
_logger.LogInformation("Database backup written: {File}.", target);
PruneOldBackups();
return target;
}
public DateTime? GetLatestBackupUtc()
{
if (!Directory.Exists(BackupsRoot)) return null;
var latest = ListBackups().FirstOrDefault();
return latest?.LastWriteTimeUtc;
}
private void PruneOldBackups()
{
foreach (var stale in ListBackups().Skip(_retainCount))
{
try
{
stale.Delete();
_logger.LogInformation("Pruned old database backup: {File}.", stale.Name);
}
catch (IOException ex)
{
_logger.LogWarning(ex, "Could not prune old database backup {File}.", stale.Name);
}
}
}
private IOrderedEnumerable<FileInfo> ListBackups()
=> new DirectoryInfo(BackupsRoot)
.EnumerateFiles($"{BackupFilePrefix}*.db")
.OrderByDescending(f => f.LastWriteTimeUtc);
}
}
@@ -0,0 +1,61 @@
namespace JobTrackerApi.Services
{
public sealed record EmailStatusSuggestion(string SuggestedStatus, string Signal, string Confidence);
/// <summary>
/// Deterministic email → pipeline-status classifier. Scans subject/body for outcome signals and
/// suggests a canonical status. No AI: instant, reproducible, and safe (the user always confirms).
/// Priority matters — a rejection email often still mentions "interview", so rejection wins.
/// </summary>
public static class EmailStatusClassifier
{
// Ordered highest-priority first. Each stage lists lowercase phrases to look for.
private static readonly (string Status, string Confidence, string[] Phrases)[] Rules =
{
("Rejected", "high", new[]
{
"regret to inform", "we regret", "unfortunately, we", "not moving forward",
"not be moving forward", "decided not to proceed", "will not be proceeding",
"not to proceed", "not been selected", "will not be progressing",
"unable to offer", "position has been filled", "no longer being considered",
"decided to move forward with other", "pursue other candidates",
"not to move forward", "were not successful", "was not successful",
}),
("Offer", "high", new[]
{
"pleased to offer", "delighted to offer", "happy to offer", "offer of employment",
"job offer", "we would like to offer", "formal offer", "extend an offer",
"offer letter", "excited to offer",
}),
("Interview", "medium", new[]
{
"invite you to interview", "invite you to an interview", "schedule an interview",
"would like to invite you", "phone screen", "phone interview", "video interview",
"technical interview", "next steps in the", "your availability for a call",
"availability for an interview", "set up a call", "set up an interview",
"meet the team", "book a time", "invitation to interview", "interview invitation",
"like to speak with you", "move to the interview",
}),
};
// Weaker single-word cues only fire when no strong phrase matched (kept low-confidence).
private static readonly string[] InterviewWeakCues = { "interview", "assessment", "coding challenge", "take-home" };
public static EmailStatusSuggestion? Classify(string? subject, string? body)
{
var text = $"{subject}\n{body}".ToLowerInvariant();
if (string.IsNullOrWhiteSpace(text)) return null;
foreach (var (status, confidence, phrases) in Rules)
{
var hit = phrases.FirstOrDefault(p => text.Contains(p, StringComparison.Ordinal));
if (hit is not null) return new EmailStatusSuggestion(status, hit, confidence);
}
var weak = InterviewWeakCues.FirstOrDefault(c => text.Contains(c, StringComparison.Ordinal));
if (weak is not null) return new EmailStatusSuggestion("Interview", weak, "low");
return null;
}
}
}
+208
View File
@@ -0,0 +1,208 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using JobTrackerApi.Services.JobImport;
namespace JobTrackerApi.Services
{
/// <summary>One keyword drawn from the job posting and whether the CV covers it.</summary>
public sealed record MatchKeyword(string Keyword, int Weight, bool InTitle, bool Matched);
/// <summary>How many of the matched keywords appear in a given CV section.</summary>
public sealed record MatchSectionCoverage(string Section, int Matched, int Total);
public sealed record JobCvMatchResult(
int Score,
string Band,
int MatchedCount,
int TotalKeywords,
IReadOnlyList<string> MatchedKeywords,
IReadOnlyList<string> MissingKeywords,
IReadOnlyList<MatchSectionCoverage> SectionCoverage,
bool HasEnoughSignal);
public interface IJobCvMatchService
{
JobCvMatchResult Evaluate(string jobTitle, string jobText, IReadOnlyDictionary<string, string> cvSections);
}
/// <summary>
/// Deterministic CV↔job keyword coverage score. No AI: the same inputs always produce the
/// same number so users get a stable, reproducible signal (the Jobscan-style differentiator).
/// The AI narrative lives separately in the candidate-fit endpoint.
/// </summary>
public sealed class JobCvMatchService : IJobCvMatchService
{
// Weights: curated skill tags are high-signal; salient posting terms are the long tail.
private const int CuratedTagWeight = 3;
private const int TermWeight = 1;
private const int TitleBonus = 2;
private const int MaxKeywords = 28;
private static readonly Regex TokenPattern = new(@"[a-z0-9][a-z0-9+.#-]*", RegexOptions.Compiled);
private static readonly HashSet<string> StopWords = new(StringComparer.OrdinalIgnoreCase)
{
"the", "and", "for", "with", "you", "your", "our", "are", "will", "have", "has", "that",
"this", "from", "not", "but", "all", "can", "who", "how", "why", "what", "when", "who",
"job", "role", "work", "working", "team", "teams", "company", "years", "year", "experience",
"experienced", "skills", "skill", "ability", "able", "strong", "good", "great", "excellent",
"including", "include", "includes", "well", "using", "use", "used", "within", "across",
"into", "onto", "their", "them", "they", "were", "was", "would", "should", "could", "must",
"new", "also", "per", "via", "etc", "such", "any", "one", "two", "three", "day", "days",
"week", "weeks", "month", "months", "time", "full", "part", "based", "join", "looking",
"seeking", "candidate", "candidates", "applicant", "position", "positions", "opportunity",
"responsibilities", "requirements", "required", "preferred", "plus", "nice", "want", "need",
"needs", "help", "make", "made", "get", "got", "more", "most", "many", "much", "each",
"other", "others", "some", "than", "then", "there", "here", "about", "over", "under", "out",
"off", "its", "his", "her", "she", "him", "may", "might", "high", "low", "level", "levels",
"environment", "environments", "world", "people", "person", "customer", "customers", "client",
"clients", "product", "products", "service", "services", "business", "solution", "solutions",
"project", "projects", "process", "processes", "development", "develop", "developer",
// Seniority / role-title words: noise for CV keyword matching (the hard skills are what count).
"senior", "junior", "lead", "principal", "mid", "staff", "engineer", "engineers",
"engineering", "manager", "specialist", "analyst", "consultant", "administrator",
"coordinator", "associate", "intern", "officer", "director", "professional",
};
public JobCvMatchResult Evaluate(string jobTitle, string jobText, IReadOnlyDictionary<string, string> cvSections)
{
jobTitle ??= string.Empty;
jobText ??= string.Empty;
cvSections ??= new Dictionary<string, string>();
var titleTokens = Tokenize(jobTitle).ToHashSet(StringComparer.OrdinalIgnoreCase);
var keywords = BuildKeywords(jobTitle, jobText, titleTokens);
// Combine all CV sections into one searchable corpus, plus keep per-section text for coverage.
var sectionCorpora = cvSections
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value))
.ToDictionary(kvp => kvp.Key, kvp => Normalize(kvp.Value), StringComparer.OrdinalIgnoreCase);
var fullCorpus = string.Join(" \n ", sectionCorpora.Values);
var evaluated = keywords
.Select(k => k with { Matched = CorpusContains(fullCorpus, k.Keyword) })
.ToList();
var totalWeight = evaluated.Sum(k => k.Weight);
var matchedWeight = evaluated.Where(k => k.Matched).Sum(k => k.Weight);
var hasEnoughSignal = evaluated.Count >= 3 && sectionCorpora.Count > 0;
var score = totalWeight == 0 ? 0 : (int)Math.Round(100.0 * matchedWeight / totalWeight, MidpointRounding.AwayFromZero);
score = Math.Clamp(score, 0, 100);
var band = !hasEnoughSignal ? "Unknown" : score >= 75 ? "Strong" : score >= 50 ? "Partial" : "Low";
var matchedKeywords = evaluated.Where(k => k.Matched)
.OrderByDescending(k => k.Weight).ThenBy(k => k.Keyword, StringComparer.OrdinalIgnoreCase)
.Select(k => k.Keyword).ToList();
var missingKeywords = evaluated.Where(k => !k.Matched)
.OrderByDescending(k => k.Weight).ThenBy(k => k.Keyword, StringComparer.OrdinalIgnoreCase)
.Select(k => k.Keyword).ToList();
var sectionCoverage = sectionCorpora
.Select(section => new MatchSectionCoverage(
section.Key,
evaluated.Count(k => CorpusContains(section.Value, k.Keyword)),
evaluated.Count))
.Where(sc => sc.Total > 0)
.OrderByDescending(sc => sc.Matched)
.ToList();
return new JobCvMatchResult(
Score: score,
Band: band,
MatchedCount: matchedKeywords.Count,
TotalKeywords: evaluated.Count,
MatchedKeywords: matchedKeywords,
MissingKeywords: missingKeywords,
SectionCoverage: sectionCoverage,
HasEnoughSignal: hasEnoughSignal);
}
private static List<MatchKeyword> BuildKeywords(string jobTitle, string jobText, HashSet<string> titleTokens)
{
var combined = $"{jobTitle}\n{jobText}";
var byKey = new Dictionary<string, MatchKeyword>(StringComparer.OrdinalIgnoreCase);
// 1) Curated skill tags: high-signal, canonical spelling.
foreach (var tag in SkillTagger.Detect(combined))
{
var inTitle = TitleContains(jobTitle, tag);
byKey[tag] = new MatchKeyword(tag, CuratedTagWeight + (inTitle ? TitleBonus : 0), inTitle, false);
}
// 2) Salient posting terms: frequency-ranked content words from the description.
var frequencies = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var token in Tokenize(jobText))
{
if (token.Length < 3 || StopWords.Contains(token) || IsNumeric(token)) continue;
frequencies[token] = frequencies.TryGetValue(token, out var c) ? c + 1 : 1;
}
var rankedTerms = frequencies
.Where(kvp => kvp.Value >= 1)
.OrderByDescending(kvp => titleTokens.Contains(kvp.Key) ? 1 : 0)
.ThenByDescending(kvp => kvp.Value)
.ThenBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase)
.Select(kvp => kvp.Key);
foreach (var term in rankedTerms)
{
if (byKey.Count >= MaxKeywords) break;
if (byKey.ContainsKey(term)) continue;
var inTitle = titleTokens.Contains(term);
byKey[term] = new MatchKeyword(term, TermWeight + (inTitle ? TitleBonus : 0), inTitle, false);
}
return byKey.Values
.OrderByDescending(k => k.Weight)
.ThenBy(k => k.Keyword, StringComparer.OrdinalIgnoreCase)
.Take(MaxKeywords)
.ToList();
}
private static bool TitleContains(string title, string phrase)
=> Normalize(title).Contains(Normalize(phrase), StringComparison.Ordinal);
private static bool CorpusContains(string normalizedCorpus, string keyword)
{
var needle = Normalize(keyword);
if (needle.Length == 0) return false;
// Word-boundary-ish match to avoid "go" matching "goal".
var idx = normalizedCorpus.IndexOf(needle, StringComparison.Ordinal);
while (idx >= 0)
{
var beforeOk = idx == 0 || !char.IsLetterOrDigit(normalizedCorpus[idx - 1]);
var afterPos = idx + needle.Length;
var afterOk = afterPos >= normalizedCorpus.Length || !char.IsLetterOrDigit(normalizedCorpus[afterPos]);
if (beforeOk && afterOk) return true;
idx = normalizedCorpus.IndexOf(needle, idx + 1, StringComparison.Ordinal);
}
return false;
}
private static IEnumerable<string> Tokenize(string text)
{
if (string.IsNullOrWhiteSpace(text)) yield break;
foreach (Match m in TokenPattern.Matches(text.ToLowerInvariant()))
{
yield return m.Value.Trim('-', '.', '+', '#');
}
}
private static bool IsNumeric(string token)
=> token.All(c => char.IsDigit(c) || c is '.' or '-' or '+');
private static string Normalize(string text)
{
if (string.IsNullOrWhiteSpace(text)) return string.Empty;
var sb = new StringBuilder(text.Length);
foreach (var ch in text.ToLowerInvariant())
{
sb.Append(char.IsWhiteSpace(ch) ? ' ' : ch);
}
return sb.ToString();
}
}
}
@@ -9,8 +9,10 @@ public static class SkillTagger
{
private static readonly (string Tag, Regex Pattern, int Weight)[] Patterns =
{
("C#", new Regex(@"\bC#\b|\bcsharp\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
(".NET", new Regex(@"\b\.NET\b|\bASP\.NET\b|\bDOTNET\b|\bEntity Framework\b|\bEF Core\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
// Symbol skills need punctuation-tolerant boundaries: \b fails next to '#'/'.'
// (both non-word chars), which previously left "C#," and ".NET," undetected.
("C#", new Regex(@"(?<![A-Za-z0-9#])C#|\bc[-\s]?sharp\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
(".NET", new Regex(@"(?<![A-Za-z0-9.])\.NET\b|\bASP\.NET\b|\bDOTNET\b|\bEntity Framework\b|\bEF Core\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
("Python", new Regex(@"\bPython\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
("Java", new Regex(@"\bJava\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("JavaScript", new Regex(@"\bJavaScript\b|\bJS\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
+75
View File
@@ -0,0 +1,75 @@
namespace JobTrackerApi.Services
{
public enum PipelineCategory
{
Active,
Success,
Closed,
}
public sealed record PipelineStage(string Key, int Order, PipelineCategory Category);
/// <summary>
/// Canonical job-application pipeline: the single source of truth for the ordered set of
/// statuses, their grouping, and how free-text/legacy values normalize onto them.
/// Status remains a free-text column so custom values are never destroyed; this only
/// canonicalizes casing and known synonyms.
/// </summary>
public static class JobPipeline
{
public const string DefaultStatus = "Applied";
public static readonly IReadOnlyList<PipelineStage> Stages = new List<PipelineStage>
{
new("Applied", 1, PipelineCategory.Active),
new("Waiting", 2, PipelineCategory.Active),
new("Interview", 3, PipelineCategory.Active),
new("Offer", 4, PipelineCategory.Success),
new("Rejected", 5, PipelineCategory.Closed),
new("Ghosted", 6, PipelineCategory.Closed),
};
private static readonly Dictionary<string, string> Canonical =
Stages.ToDictionary(s => s.Key, s => s.Key, StringComparer.OrdinalIgnoreCase);
// Legacy/synonym spellings that should collapse onto a canonical stage.
private static readonly Dictionary<string, string> Aliases = new(StringComparer.OrdinalIgnoreCase)
{
["interviewing"] = "Interview",
["interviews"] = "Interview",
["interviewed"] = "Interview",
["in interview"] = "Interview",
["awaiting response"] = "Waiting",
["awaiting"] = "Waiting",
["in progress"] = "Waiting",
["pending"] = "Waiting",
["no response"] = "Ghosted",
["no reply"] = "Ghosted",
["declined"] = "Rejected",
};
/// <summary>
/// Returns the canonical status for a raw value: trims, matches a stage case-insensitively,
/// or maps a known synonym. Unknown non-empty values are preserved (trimmed) so custom
/// statuses survive. Empty/whitespace becomes the default stage.
/// </summary>
public static string Normalize(string? status)
{
var trimmed = (status ?? string.Empty).Trim();
if (trimmed.Length == 0) return DefaultStatus;
if (Canonical.TryGetValue(trimmed, out var canonical)) return canonical;
if (Aliases.TryGetValue(trimmed, out var alias)) return alias;
return trimmed;
}
public static bool IsCanonical(string? status)
=> !string.IsNullOrWhiteSpace(status) && Canonical.ContainsKey(status.Trim());
public static int OrderOf(string? status)
{
var normalized = Normalize(status);
var stage = Stages.FirstOrDefault(s => string.Equals(s.Key, normalized, StringComparison.OrdinalIgnoreCase));
return stage?.Order ?? int.MaxValue; // custom statuses sort last
}
}
}
+45
View File
@@ -0,0 +1,45 @@
namespace JobTrackerApi.Services
{
public sealed record StageDurationPoint(string Stage, int Order, double MedianDays, int Count);
/// <summary>One job's position: its canonical stage and when it entered that stage.</summary>
public sealed record StageOccupancy(string Status, DateTime EnteredStageAtUtc);
/// <summary>
/// Pure time-in-stage analytics: for each active pipeline stage, the median number of days
/// the jobs currently sitting there have been waiting. Closed stages (Rejected/Ghosted) and
/// the terminal success stage (Offer) are excluded — "how long has this been stuck" only
/// makes sense for stages you still act on.
/// </summary>
public static class StageAnalytics
{
public static List<StageDurationPoint> TimeInStage(IEnumerable<StageOccupancy> jobs, DateTime nowUtc)
{
var byStage = jobs
.Select(j => (Stage: JobPipeline.Normalize(j.Status), Days: Math.Max(0, (nowUtc - j.EnteredStageAtUtc).TotalDays)))
.Where(x => JobPipeline.Stages.Any(s => s.Key == x.Stage && s.Category == PipelineCategory.Active))
.GroupBy(x => x.Stage);
var points = new List<StageDurationPoint>();
foreach (var group in byStage)
{
var days = group.Select(x => x.Days).OrderBy(x => x).ToList();
points.Add(new StageDurationPoint(
Stage: group.Key,
Order: JobPipeline.OrderOf(group.Key),
MedianDays: Median(days),
Count: days.Count));
}
return points.OrderBy(p => p.Order).ToList();
}
private static double Median(IReadOnlyList<double> sorted)
{
if (sorted.Count == 0) return 0;
var mid = sorted.Count / 2;
var median = sorted.Count % 2 == 0 ? (sorted[mid - 1] + sorted[mid]) / 2d : sorted[mid];
return Math.Round(median, 1);
}
}
}
@@ -484,6 +484,12 @@ public static class StartupInitializationExtensions
EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;");
// Structured salary fields (EF maps decimal to TEXT on SQLite).
EnsureColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE JobApplications ADD COLUMN SalaryMin TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE JobApplications ADD COLUMN SalaryMax TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE JobApplications ADD COLUMN SalaryCurrency TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE JobApplications ADD COLUMN SalaryPeriod TEXT NULL;");
// Ensure ownership columns exist even on non-legacy DBs.
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
@@ -617,6 +623,10 @@ public static class StartupInitializationExtensions
EnsureMySqlColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE `JobApplications` ADD COLUMN `DeletedAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Location", "ALTER TABLE `JobApplications` ADD COLUMN `Location` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Salary", "ALTER TABLE `JobApplications` ADD COLUMN `Salary` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMin` decimal(18,2) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMax` decimal(18,2) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryCurrency` varchar(8) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryPeriod` varchar(16) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "NextAction", "ALTER TABLE `JobApplications` ADD COLUMN `NextAction` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE `JobApplications` ADD COLUMN `FollowUpAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "FeedbackRequestedAt", "ALTER TABLE `JobApplications` ADD COLUMN `FeedbackRequestedAt` datetime NULL;");
@@ -1,99 +0,0 @@
{
"Version": "dailyexport.v1",
"CreatedAt": "2026-03-25T02:00:00.0368687+01:00",
"Companies": [
{
"Id": 1,
"OwnerUserId": "23dc196b-f227-4499-93fe-403d8801e21c",
"Name": "Acme Browser QA",
"Location": null,
"Source": null,
"RecruiterName": "Maria Recruiter",
"RecruiterEmail": "maria@acme.test",
"RecruiterLinkedIn": null,
"LastContactedAt": "2026-03-24T11:15:21.4772436",
"NextContactAt": "2026-03-24T00:00:00",
"PipelineStage": null
}
],
"JobApplications": [
{
"Id": 1,
"OwnerUserId": "23dc196b-f227-4499-93fe-403d8801e21c",
"JobTitle": "Backend Developer",
"CompanyId": 1,
"Company": null,
"Status": "Waiting",
"DateApplied": "2026-03-01T13:00:00+01:00",
"Location": null,
"Salary": null,
"NextAction": null,
"FollowUpAt": "2026-03-24T00:00:00",
"FeedbackRequestedAt": null,
"RecruiterMessageDraft": "Saved browser recruiter message",
"HasResume": true,
"HasCoverLetter": true,
"HasPortfolio": false,
"HasOtherAttachment": false,
"IsDeleted": false,
"DeletedAt": null,
"ResponseReceived": true,
"ResponseDate": null,
"Notes": "Browser-seeded notes\n\n\u003C\u003C\u003CAPPLICATION_ANSWER_DRAFT\u003E\u003E\u003E\nSaved browser application answer\n\u003C\u003C\u003CEND_APPLICATION_ANSWER_DRAFT\u003E\u003E\u003E",
"CoverLetterText": "Saved browser cover letter",
"JobUrl": "https://example.test/backend-developer",
"Description": "Need .NET APIs and strong stakeholder communication.",
"TranslatedDescription": null,
"DescriptionLanguage": null,
"Tags": "[\u0022.NET\u0022, \u0022APIs\u0022, \u0022Communication\u0022]",
"Deadline": null,
"ShortSummary": "Strong overlap in backend API delivery.",
"TailoredCvText": "Saved browser tailored CV",
"TailoredCvUpdatedAt": "2026-03-24T10:58:13.226164+01:00",
"LastReminderEmailSentAt": null,
"Messages": [],
"Attachments": [],
"Events": [],
"DaysSince": 23
}
],
"Correspondence": [
{
"Id": 1,
"JobApplicationId": 1,
"From": "Company",
"Subject": "Backend Developer application update",
"Channel": "Email",
"ExternalMessageId": "browser-msg-1",
"ExternalThreadId": "browser-thread-1",
"ExternalFrom": "Maria Recruiter \u003Cmaria@acme.test\u003E",
"ExternalTo": "admin@example.com",
"Content": "We are aligning interview slots and need someone who can own the API layer.",
"Date": "2026-03-10T10:00:00+01:00"
},
{
"Id": 2,
"JobApplicationId": 1,
"From": "Me",
"Subject": "Re: Backend Developer application update",
"Channel": "Email",
"ExternalMessageId": null,
"ExternalThreadId": null,
"ExternalFrom": null,
"ExternalTo": null,
"Content": "Hi Maria,\n\nEdited browser follow-up.\n\nThanks,\nadmin@example.com",
"Date": "2026-03-24T11:15:21.4521755"
}
],
"Attachments": [],
"Events": [],
"Rules": {
"Id": 1,
"AppliedFollowUpDays": 14,
"AppliedGhostDays": 30,
"OfferFollowUpDays": 7,
"OfferGhostDays": 14,
"FeedbackFollowUpDays": 7,
"FeedbackGhostDays": 14
}
}
@@ -1,99 +0,0 @@
{
"Version": "dailyexport.v1",
"CreatedAt": "2026-03-26T02:00:00.005823+01:00",
"Companies": [
{
"Id": 1,
"OwnerUserId": "23dc196b-f227-4499-93fe-403d8801e21c",
"Name": "Acme Browser QA",
"Location": null,
"Source": null,
"RecruiterName": "Maria Recruiter",
"RecruiterEmail": "maria@acme.test",
"RecruiterLinkedIn": null,
"LastContactedAt": "2026-03-24T11:15:21.4772436",
"NextContactAt": "2026-03-24T00:00:00",
"PipelineStage": null
}
],
"JobApplications": [
{
"Id": 1,
"OwnerUserId": "23dc196b-f227-4499-93fe-403d8801e21c",
"JobTitle": "Backend Developer",
"CompanyId": 1,
"Company": null,
"Status": "Waiting",
"DateApplied": "2026-03-01T13:00:00+01:00",
"Location": null,
"Salary": null,
"NextAction": null,
"FollowUpAt": "2026-03-24T00:00:00",
"FeedbackRequestedAt": null,
"RecruiterMessageDraft": "Saved browser recruiter message",
"HasResume": true,
"HasCoverLetter": true,
"HasPortfolio": false,
"HasOtherAttachment": false,
"IsDeleted": false,
"DeletedAt": null,
"ResponseReceived": true,
"ResponseDate": null,
"Notes": "Browser-seeded notes\n\n\u003C\u003C\u003CAPPLICATION_ANSWER_DRAFT\u003E\u003E\u003E\nSaved browser application answer\n\u003C\u003C\u003CEND_APPLICATION_ANSWER_DRAFT\u003E\u003E\u003E",
"CoverLetterText": "Saved browser cover letter",
"JobUrl": "https://example.test/backend-developer",
"Description": "Need .NET APIs and strong stakeholder communication.",
"TranslatedDescription": null,
"DescriptionLanguage": null,
"Tags": "[\u0022.NET\u0022, \u0022APIs\u0022, \u0022Communication\u0022]",
"Deadline": null,
"ShortSummary": "Strong overlap in backend API delivery.",
"TailoredCvText": "Saved browser tailored CV",
"TailoredCvUpdatedAt": "2026-03-24T10:58:13.226164+01:00",
"LastReminderEmailSentAt": null,
"Messages": [],
"Attachments": [],
"Events": [],
"DaysSince": 24
}
],
"Correspondence": [
{
"Id": 1,
"JobApplicationId": 1,
"From": "Company",
"Subject": "Backend Developer application update",
"Channel": "Email",
"ExternalMessageId": "browser-msg-1",
"ExternalThreadId": "browser-thread-1",
"ExternalFrom": "Maria Recruiter \u003Cmaria@acme.test\u003E",
"ExternalTo": "admin@example.com",
"Content": "We are aligning interview slots and need someone who can own the API layer.",
"Date": "2026-03-10T10:00:00+01:00"
},
{
"Id": 2,
"JobApplicationId": 1,
"From": "Me",
"Subject": "Re: Backend Developer application update",
"Channel": "Email",
"ExternalMessageId": null,
"ExternalThreadId": null,
"ExternalFrom": null,
"ExternalTo": null,
"Content": "Hi Maria,\n\nEdited browser follow-up.\n\nThanks,\nadmin@example.com",
"Date": "2026-03-24T11:15:21.4521755"
}
],
"Attachments": [],
"Events": [],
"Rules": {
"Id": 1,
"AppliedFollowUpDays": 14,
"AppliedGhostDays": 30,
"OfferFollowUpDays": 7,
"OfferGhostDays": 14,
"FeedbackFollowUpDays": 7,
"FeedbackGhostDays": 14
}
}
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<key id="9a89a42c-d2bd-4770-83fb-5930685432db" version="1">
<creationDate>2026-03-24T09:54:28.8487759Z</creationDate>
<activationDate>2026-03-24T09:54:28.8487759Z</activationDate>
<expirationDate>2026-06-22T09:54:28.8487759Z</expirationDate>
<descriptor deserializerType="Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel.AuthenticatedEncryptorDescriptorDeserializer, Microsoft.AspNetCore.DataProtection, Version=9.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60">
<descriptor>
<encryption algorithm="AES_256_CBC" />
<validation algorithm="HMACSHA256" />
<masterKey p4:requiresEncryption="true" xmlns:p4="http://schemas.asp.net/2015/03/dataProtection">
<!-- Warning: the key below is in an unencrypted form. -->
<value>LXbXqbpiEXn0OM6fr/TuXDBcZd83DvOInTI09PGZRr1Z20LQCD/PUKF1oo9UwC4O1VgK3wA//yxH9PPCIPzEaw==</value>
</masterKey>
</descriptor>
</descriptor>
</key>