Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1e508988a | |||
| d61dd6310b | |||
| 3bd7b4b7e4 | |||
| 30bb6a942d | |||
| fb11469a48 | |||
| 5a9245cf74 | |||
| 2996441f52 | |||
| bd51c245d3 | |||
| a1a3736cc4 | |||
| ae3505b877 | |||
| 695fbd6d21 | |||
| 45cbc8b1ab | |||
| 5a306f51a1 | |||
| bb736d1183 | |||
| 209528c8b5 | |||
| 3fad43a9e2 | |||
| 83e6430a24 | |||
| 8f174cb767 | |||
| c41d1e8d0f | |||
| 6150e7f19b | |||
| 999d6e05e7 | |||
| e352aaeaac | |||
| c38295d869 | |||
| 519c32efd7 | |||
| aa43ada16a | |||
| 29325a2048 | |||
| 3ef3192e6c | |||
| 657cb95a48 | |||
| eea327e1f6 | |||
| 54abc9f546 | |||
| 591c9b8a64 | |||
| 534534b333 | |||
| fcccecefa3 | |||
| 48cd83b442 | |||
| b52371ea79 | |||
| cc97a6b6c5 | |||
| 5f2f0a881a |
@@ -43,7 +43,9 @@ jobs:
|
||||
|
||||
- name: Test frontend
|
||||
working-directory: job-tracker-ui
|
||||
run: npm test -- --watchAll=false --runInBand App.test.tsx confirm.test.tsx prompt.test.tsx dialog-flow.test.tsx confirm-flow.test.tsx attachments.test.tsx job-details-generated-drafts.test.tsx admin-system-page.test.tsx profile-page.test.tsx login-page.test.tsx
|
||||
# Run the WHOLE suite. Never whitelist test files here again: the previous
|
||||
# whitelist silently skipped new suites and let two regressions reach main.
|
||||
run: npm test -- --watchAll=false --runInBand
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: job-tracker-ui
|
||||
|
||||
@@ -46,6 +46,14 @@ todo jobtracker.txt
|
||||
tmp/
|
||||
/tmp/
|
||||
|
||||
# Runtime data that must never be committed (DataProtection keys, exports, CV artifacts)
|
||||
keys/
|
||||
backups/
|
||||
JobTrackerApi/exports/
|
||||
JobTrackerApi/CvArtifacts/
|
||||
JobTrackerApi/CvExports/
|
||||
JobTrackerApi/CvBenchmarks/
|
||||
|
||||
# Local app data
|
||||
*.db
|
||||
*.db-*
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AttachmentsController : ControllerBase
|
||||
{
|
||||
private readonly IWebHostEnvironment _env;
|
||||
public AttachmentsController(IWebHostEnvironment env) => _env = env;
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Upload([FromForm] IFormFileCollection files, [FromForm] int jobId)
|
||||
{
|
||||
var folder = Path.Combine(_env.ContentRootPath, "Attachments", jobId.ToString());
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var path = Path.Combine(folder, file.FileName);
|
||||
using var stream = new FileStream(path, FileMode.Create);
|
||||
await file.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class CompaniesController : ControllerBase
|
||||
{
|
||||
private readonly JobTrackerContext _context;
|
||||
public CompaniesController(JobTrackerContext context) => _context = context;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IEnumerable<Company>> Get() =>
|
||||
await _context.Companies.Include(c => c.Jobs).ToListAsync();
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<Company>> Post(Company company)
|
||||
{
|
||||
_context.Companies.Add(company);
|
||||
await _context.SaveChangesAsync();
|
||||
return CreatedAtAction(nameof(Get), new { id = company.Id }, company);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class CorrespondenceController : ControllerBase
|
||||
{
|
||||
private readonly JobTrackerContext _context;
|
||||
public CorrespondenceController(JobTrackerContext context) => _context = context;
|
||||
|
||||
// GET all messages for a job
|
||||
[HttpGet("{jobId}")]
|
||||
public async Task<IEnumerable<Correspondence>> GetForJob(int jobId)
|
||||
{
|
||||
return await _context.Correspondences
|
||||
.Where(c => c.JobApplicationId == jobId)
|
||||
.OrderBy(c => c.Date)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
// POST new message
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<Correspondence>> Post(Correspondence message)
|
||||
{
|
||||
_context.Correspondences.Add(message);
|
||||
await _context.SaveChangesAsync();
|
||||
return CreatedAtAction(nameof(GetForJob), new { jobId = message.JobApplicationId }, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class JobApplicationsController : ControllerBase
|
||||
{
|
||||
private readonly JobTrackerContext _context;
|
||||
public JobApplicationsController(JobTrackerContext context) => _context = context;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IEnumerable<JobApplication>> Get() =>
|
||||
await _context.JobApplications.Include(j => j.Company).ToListAsync();
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<JobApplication>> Post(JobApplication job)
|
||||
{
|
||||
_context.JobApplications.Add(job);
|
||||
await _context.SaveChangesAsync();
|
||||
return CreatedAtAction(nameof(Get), new { id = job.Id }, job);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Put(int id, JobApplication updatedJob)
|
||||
{
|
||||
var job = await _context.JobApplications.FindAsync(id);
|
||||
if (job == null) return NotFound();
|
||||
|
||||
job.JobTitle = updatedJob.JobTitle;
|
||||
job.Status = updatedJob.Status;
|
||||
job.ResponseReceived = updatedJob.ResponseReceived;
|
||||
job.ResponseDate = updatedJob.ResponseDate;
|
||||
job.Notes = updatedJob.Notes;
|
||||
job.CoverLetterText = updatedJob.CoverLetterText;
|
||||
job.JobUrl = updatedJob.JobUrl;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ namespace JobTrackerApi.Data
|
||||
.HasIndex(c => c.OwnerUserId);
|
||||
|
||||
modelBuilder.Entity<Correspondence>()
|
||||
.HasQueryFilter(c => CurrentUserId != null && c.JobApplication.OwnerUserId == CurrentUserId)
|
||||
.HasOne(c => c.JobApplication)
|
||||
.WithMany(j => j.Messages)
|
||||
.HasForeignKey(c => c.JobApplicationId)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class DatabaseBackupRunnerTests : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public DatabaseBackupRunnerTests()
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), $"jt-backup-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
private string CreateSourceDb(out string connectionString)
|
||||
{
|
||||
var dbPath = Path.Combine(_root, "source.db");
|
||||
connectionString = $"Data Source={dbPath}";
|
||||
using var connection = new SqliteConnection(connectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "CREATE TABLE Sample (Id INTEGER PRIMARY KEY, Name TEXT); INSERT INTO Sample (Name) VALUES ('alpha'), ('beta');";
|
||||
command.ExecuteNonQuery();
|
||||
return dbPath;
|
||||
}
|
||||
|
||||
private SqliteDatabaseBackupRunner CreateRunner(string connectionString, int retainCount = 14)
|
||||
=> new(connectionString, Path.Combine(_root, "backups"), retainCount, NullLogger<SqliteDatabaseBackupRunner>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task RunOnce_creates_a_restorable_backup_file()
|
||||
{
|
||||
CreateSourceDb(out var connectionString);
|
||||
var runner = CreateRunner(connectionString);
|
||||
|
||||
var backupPath = await runner.RunOnceAsync(CancellationToken.None);
|
||||
|
||||
Assert.NotNull(backupPath);
|
||||
Assert.True(File.Exists(backupPath));
|
||||
|
||||
await using var verify = new SqliteConnection($"Data Source={backupPath}");
|
||||
await verify.OpenAsync();
|
||||
await using var count = verify.CreateCommand();
|
||||
count.CommandText = "SELECT COUNT(*) FROM Sample";
|
||||
Assert.Equal(2L, (long)(await count.ExecuteScalarAsync())!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOnce_prunes_backups_beyond_retention()
|
||||
{
|
||||
CreateSourceDb(out var connectionString);
|
||||
var runner = CreateRunner(connectionString, retainCount: 2);
|
||||
var backupsRoot = runner.BackupsRoot;
|
||||
Directory.CreateDirectory(backupsRoot);
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var stale = Path.Combine(backupsRoot, $"{SqliteDatabaseBackupRunner.BackupFilePrefix}stale{i}.db");
|
||||
File.WriteAllText(stale, "stale");
|
||||
File.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddDays(-10 - i));
|
||||
}
|
||||
|
||||
await runner.RunOnceAsync(CancellationToken.None);
|
||||
|
||||
var remaining = Directory.GetFiles(backupsRoot, $"{SqliteDatabaseBackupRunner.BackupFilePrefix}*.db");
|
||||
Assert.Equal(2, remaining.Length);
|
||||
Assert.Contains(remaining, f => Path.GetFileName(f).Contains("stale0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Latest_backup_timestamp_reflects_newest_file()
|
||||
{
|
||||
CreateSourceDb(out var connectionString);
|
||||
var runner = CreateRunner(connectionString);
|
||||
|
||||
Assert.Null(runner.GetLatestBackupUtc());
|
||||
|
||||
Directory.CreateDirectory(runner.BackupsRoot);
|
||||
var file = Path.Combine(runner.BackupsRoot, $"{SqliteDatabaseBackupRunner.BackupFilePrefix}x.db");
|
||||
File.WriteAllText(file, "x");
|
||||
var stamp = DateTime.UtcNow.AddHours(-3);
|
||||
File.SetLastWriteTimeUtc(file, stamp);
|
||||
|
||||
var latest = runner.GetLatestBackupUtc();
|
||||
Assert.NotNull(latest);
|
||||
Assert.True(Math.Abs((latest!.Value - stamp).TotalSeconds) < 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using JobTrackerApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class EmailStatusClassifierTests
|
||||
{
|
||||
[Fact]
|
||||
public void Detects_rejection()
|
||||
{
|
||||
var s = EmailStatusClassifier.Classify("Your application", "Thank you for your time. Unfortunately, we have decided not to proceed with your application.");
|
||||
Assert.NotNull(s);
|
||||
Assert.Equal("Rejected", s!.SuggestedStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detects_offer()
|
||||
{
|
||||
var s = EmailStatusClassifier.Classify("Great news", "We are pleased to offer you the position of Backend Engineer.");
|
||||
Assert.NotNull(s);
|
||||
Assert.Equal("Offer", s!.SuggestedStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detects_interview_invite()
|
||||
{
|
||||
var s = EmailStatusClassifier.Classify("Next steps", "We would like to invite you to interview next week. What is your availability for a call?");
|
||||
Assert.NotNull(s);
|
||||
Assert.Equal("Interview", s!.SuggestedStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejection_wins_over_interview_mention()
|
||||
{
|
||||
// A rejection email that references the interview the candidate had must classify as Rejected.
|
||||
var s = EmailStatusClassifier.Classify(
|
||||
"Update on your application",
|
||||
"Thank you for taking the time to interview with us. Unfortunately, we will not be moving forward.");
|
||||
Assert.NotNull(s);
|
||||
Assert.Equal("Rejected", s!.SuggestedStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Weak_interview_cue_is_low_confidence()
|
||||
{
|
||||
var s = EmailStatusClassifier.Classify("Coding challenge", "Please complete this take-home assessment.");
|
||||
Assert.NotNull(s);
|
||||
Assert.Equal("Interview", s!.SuggestedStatus);
|
||||
Assert.Equal("low", s.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_for_neutral_email()
|
||||
{
|
||||
Assert.Null(EmailStatusClassifier.Classify("Re: question", "Thanks for the info, that answers my question about the parking."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handles_empty_input()
|
||||
=> Assert.Null(EmailStatusClassifier.Classify(null, null));
|
||||
}
|
||||
@@ -38,6 +38,42 @@ public sealed class JobApplicationsAuthorizationTests
|
||||
Assert.IsType<NotFoundResult>(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMatchScore_returns_not_found_for_other_users_job()
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString();
|
||||
await using var ownerDb = CreateDb(dbName, "owner-1");
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "owner-1" };
|
||||
ownerDb.Companies.Add(company);
|
||||
await ownerDb.SaveChangesAsync();
|
||||
ownerDb.JobApplications.Add(new JobApplication { JobTitle = "Secret", CompanyId = company.Id, OwnerUserId = "owner-1", Description = "C# .NET" });
|
||||
await ownerDb.SaveChangesAsync();
|
||||
var jobId = await ownerDb.JobApplications.Select(j => j.Id).FirstAsync();
|
||||
|
||||
await using var attackerDb = CreateDb(dbName, "other-user");
|
||||
var result = await CreateController(attackerDb).GetMatchScore(jobId, CancellationToken.None);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatusSuggestion_returns_not_found_for_other_users_job()
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString();
|
||||
await using var ownerDb = CreateDb(dbName, "owner-1");
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "owner-1" };
|
||||
ownerDb.Companies.Add(company);
|
||||
await ownerDb.SaveChangesAsync();
|
||||
ownerDb.JobApplications.Add(new JobApplication { JobTitle = "Secret", CompanyId = company.Id, OwnerUserId = "owner-1" });
|
||||
await ownerDb.SaveChangesAsync();
|
||||
var jobId = await ownerDb.JobApplications.Select(j => j.Id).FirstAsync();
|
||||
|
||||
await using var attackerDb = CreateDb(dbName, "other-user");
|
||||
var result = await CreateController(attackerDb).GetStatusSuggestion(jobId, CancellationToken.None);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result.Result);
|
||||
}
|
||||
|
||||
private static JobTrackerContext CreateDb(string dbName, string? userId)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
|
||||
@@ -56,6 +56,231 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
Assert.Contains("Profile page", badRequest.Value?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_suggestion_from_latest_inbound_rejection()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Applied" };
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.Correspondences.Add(new Correspondence
|
||||
{
|
||||
JobApplicationId = job.Id,
|
||||
From = "Company",
|
||||
Direction = "inbound",
|
||||
Subject = "Update",
|
||||
Content = "Unfortunately, we have decided not to proceed.",
|
||||
Date = DateTime.Now,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, "user-1");
|
||||
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(ok.Value);
|
||||
Assert.True(dto.HasSuggestion);
|
||||
Assert.Equal("Rejected", dto.SuggestedStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_suggestion_suppressed_when_already_in_stage()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Rejected" };
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.Correspondences.Add(new Correspondence
|
||||
{
|
||||
JobApplicationId = job.Id,
|
||||
From = "Company",
|
||||
Direction = "inbound",
|
||||
Content = "Unfortunately, we will not be moving forward.",
|
||||
Date = DateTime.Now,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, "user-1");
|
||||
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(ok.Value);
|
||||
Assert.False(dto.HasSuggestion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Match_score_scores_job_against_profile_cv()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||
db.Companies.Add(company);
|
||||
db.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = "user-1",
|
||||
UserName = "u",
|
||||
Email = "u@example.com",
|
||||
ProfileCvText = "Backend engineer skilled in C#, .NET, SQL and Docker. Built REST APIs.",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var job = new JobApplication
|
||||
{
|
||||
JobTitle = "Senior C# Backend Developer",
|
||||
CompanyId = company.Id,
|
||||
OwnerUserId = "user-1",
|
||||
Description = "We need strong C#, .NET, SQL, Docker and REST API experience.",
|
||||
};
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, "user-1");
|
||||
var result = await controller.GetMatchScore(job.Id, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var dto = Assert.IsType<JobApplicationsController.MatchScoreDto>(ok.Value);
|
||||
Assert.True(dto.HasEnoughSignal);
|
||||
Assert.True(dto.Score >= 75, $"expected strong score, got {dto.Score}");
|
||||
Assert.Contains("C#", dto.MatchedKeywords);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Match_score_requires_profile_cv()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||
db.Companies.Add(company);
|
||||
db.Users.Add(new ApplicationUser { Id = "user-1", UserName = "u", Email = "u@example.com" });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Description = "C# .NET" };
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, "user-1");
|
||||
var result = await controller.GetMatchScore(job.Id, CancellationToken.None);
|
||||
|
||||
Assert.IsType<BadRequestObjectResult>(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_normalizes_structured_salary()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, "user-1");
|
||||
var request = new JobApplicationsController.CreateJobApplicationRequest(
|
||||
JobTitle: "Backend Dev",
|
||||
CompanyId: company.Id,
|
||||
Status: null,
|
||||
Location: null,
|
||||
Salary: "60-70k",
|
||||
SalaryMin: 70000m, // min > max on purpose: normalization swaps them
|
||||
SalaryMax: 60000m,
|
||||
SalaryCurrency: " nok ",
|
||||
SalaryPeriod: "YEAR",
|
||||
NextAction: null,
|
||||
FollowUpAt: null,
|
||||
Notes: null,
|
||||
Description: null,
|
||||
TranslatedDescription: null,
|
||||
DescriptionLanguage: null,
|
||||
Tags: null,
|
||||
Deadline: null,
|
||||
CoverLetterText: null,
|
||||
JobUrl: null,
|
||||
DateApplied: null,
|
||||
FeedbackRequestedAt: null,
|
||||
HasResume: null,
|
||||
HasCoverLetter: null,
|
||||
HasPortfolio: null,
|
||||
HasOtherAttachment: null);
|
||||
|
||||
var result = await controller.Create(request, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
var saved = await db.JobApplications.FirstAsync();
|
||||
Assert.Equal(60000m, saved.SalaryMin);
|
||||
Assert.Equal(70000m, saved.SalaryMax);
|
||||
Assert.Equal("NOK", saved.SalaryCurrency);
|
||||
Assert.Equal("year", saved.SalaryPeriod);
|
||||
Assert.Equal("60-70k", saved.Salary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_drops_invalid_salary_period_and_negative_values()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var job = new JobApplication
|
||||
{
|
||||
JobTitle = "Backend Dev",
|
||||
CompanyId = company.Id,
|
||||
OwnerUserId = "user-1",
|
||||
SalaryMin = 50000m,
|
||||
SalaryMax = 60000m,
|
||||
SalaryCurrency = "NOK",
|
||||
SalaryPeriod = "year",
|
||||
};
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, "user-1");
|
||||
var request = new JobApplicationsController.UpdateJobApplicationRequest(
|
||||
JobTitle: "Backend Dev",
|
||||
CompanyId: company.Id,
|
||||
Status: "Applied",
|
||||
ResponseReceived: false,
|
||||
ResponseDate: null,
|
||||
Location: null,
|
||||
Salary: null,
|
||||
SalaryMin: -5m,
|
||||
SalaryMax: null,
|
||||
SalaryCurrency: "",
|
||||
SalaryPeriod: "fortnight",
|
||||
NextAction: null,
|
||||
FollowUpAt: null,
|
||||
HasResume: null,
|
||||
HasCoverLetter: null,
|
||||
HasPortfolio: null,
|
||||
HasOtherAttachment: null,
|
||||
Notes: null,
|
||||
Description: null,
|
||||
TranslatedDescription: null,
|
||||
DescriptionLanguage: null,
|
||||
Tags: null,
|
||||
Deadline: null,
|
||||
CoverLetterText: null,
|
||||
JobUrl: null,
|
||||
DateApplied: null,
|
||||
FeedbackRequestedAt: null,
|
||||
StatusChangedAt: null);
|
||||
|
||||
var result = await controller.Update(job.Id, request, CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
var saved = await db.JobApplications.FirstAsync();
|
||||
Assert.Null(saved.SalaryMin);
|
||||
Assert.Null(saved.SalaryMax);
|
||||
Assert.Null(saved.SalaryCurrency);
|
||||
Assert.Null(saved.SalaryPeriod);
|
||||
}
|
||||
|
||||
private static JobApplicationsController CreateController(JobTrackerContext db, string userId)
|
||||
{
|
||||
var summarizer = new Mock<ISummarizerService>();
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using JobTrackerApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class JobCvMatchServiceTests
|
||||
{
|
||||
private readonly JobCvMatchService _service = new();
|
||||
|
||||
private static Dictionary<string, string> Sections(params (string Name, string Text)[] items)
|
||||
=> items.ToDictionary(i => i.Name, i => i.Text, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[Fact]
|
||||
public void Strong_overlap_scores_high_and_lists_matched_keywords()
|
||||
{
|
||||
var result = _service.Evaluate(
|
||||
jobTitle: "Senior C# Backend Developer",
|
||||
jobText: "We need a backend engineer with strong C#, .NET, SQL and Docker experience building REST APIs.",
|
||||
cvSections: Sections(
|
||||
("Skills", "C# .NET SQL Docker Kubernetes"),
|
||||
("Experience", "Built REST APIs in C# and .NET with SQL Server and Docker.")));
|
||||
|
||||
Assert.True(result.Score >= 75, $"expected strong score, got {result.Score}");
|
||||
Assert.Equal("Strong", result.Band);
|
||||
Assert.Contains("C#", result.MatchedKeywords);
|
||||
Assert.Contains(".NET", result.MatchedKeywords);
|
||||
Assert.True(result.HasEnoughSignal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_overlap_scores_low_and_surfaces_missing_keywords()
|
||||
{
|
||||
var result = _service.Evaluate(
|
||||
jobTitle: "Kubernetes Platform Engineer",
|
||||
jobText: "Deep Kubernetes, AWS, and Docker platform experience required. Terraform and CI/CD pipelines.",
|
||||
cvSections: Sections(
|
||||
("Skills", "Graphic design, Adobe Photoshop, Illustrator, copywriting"),
|
||||
("Experience", "Ran marketing campaigns and brand design work.")));
|
||||
|
||||
Assert.True(result.Score < 50, $"expected low score, got {result.Score}");
|
||||
Assert.Equal("Low", result.Band);
|
||||
Assert.Contains("Kubernetes", result.MissingKeywords);
|
||||
Assert.Contains("AWS", result.MissingKeywords);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Is_deterministic_for_identical_inputs()
|
||||
{
|
||||
var a = _service.Evaluate("Data Engineer", "Python, SQL, Spark, ETL pipelines and AWS.", Sections(("Skills", "Python SQL AWS")));
|
||||
var b = _service.Evaluate("Data Engineer", "Python, SQL, Spark, ETL pipelines and AWS.", Sections(("Skills", "Python SQL AWS")));
|
||||
|
||||
Assert.Equal(a.Score, b.Score);
|
||||
Assert.Equal(a.MatchedKeywords, b.MatchedKeywords);
|
||||
Assert.Equal(a.MissingKeywords, b.MissingKeywords);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Word_boundary_prevents_false_substring_matches()
|
||||
{
|
||||
// "go" (the language) must not match inside "goals"/"ago".
|
||||
var result = _service.Evaluate(
|
||||
jobTitle: "Go Developer",
|
||||
jobText: "Go programming language, goroutines, concurrency.",
|
||||
cvSections: Sections(("Experience", "Achieved company goals two years ago in a great environment.")));
|
||||
|
||||
Assert.DoesNotContain("go", result.MatchedKeywords, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Section_coverage_reports_where_matches_are_concentrated()
|
||||
{
|
||||
var result = _service.Evaluate(
|
||||
jobTitle: "React Frontend Engineer",
|
||||
jobText: "Build UIs with React, TypeScript and JavaScript. Strong testing culture.",
|
||||
cvSections: Sections(
|
||||
("Skills", "React TypeScript JavaScript"),
|
||||
("Experience", "Wrote documentation and managed budgets.")));
|
||||
|
||||
var skills = Assert.Single(result.SectionCoverage, s => s.Section == "Skills");
|
||||
var experience = Assert.Single(result.SectionCoverage, s => s.Section == "Experience");
|
||||
Assert.True(skills.Matched > experience.Matched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_cv_reports_no_signal()
|
||||
{
|
||||
var result = _service.Evaluate("Anything", "Some role text with several words here.", Sections());
|
||||
Assert.False(result.HasEnoughSignal);
|
||||
Assert.Equal("Unknown", result.Band);
|
||||
Assert.Equal(0, result.MatchedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Title_keywords_are_weighted_and_missing_ones_rank_first()
|
||||
{
|
||||
// The title term "kubernetes" is absent from the CV; it should lead the missing list
|
||||
// because title terms carry the title bonus weight.
|
||||
var result = _service.Evaluate(
|
||||
jobTitle: "Kubernetes Specialist",
|
||||
jobText: "Kubernetes orchestration. Some familiarity with logging and monitoring dashboards.",
|
||||
cvSections: Sections(("Skills", "logging monitoring dashboards")));
|
||||
|
||||
Assert.Equal("Kubernetes", result.MissingKeywords.First());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using JobTrackerApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class JobPipelineTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("applied", "Applied")]
|
||||
[InlineData("APPLIED", "Applied")]
|
||||
[InlineData(" Offer ", "Offer")]
|
||||
[InlineData("Interviewing", "Interview")]
|
||||
[InlineData("interviews", "Interview")]
|
||||
[InlineData("declined", "Rejected")]
|
||||
[InlineData("no response", "Ghosted")]
|
||||
public void Normalize_canonicalizes_casing_and_synonyms(string input, string expected)
|
||||
=> Assert.Equal(expected, JobPipeline.Normalize(input));
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void Normalize_empty_becomes_default(string? input)
|
||||
=> Assert.Equal("Applied", JobPipeline.Normalize(input));
|
||||
|
||||
[Fact]
|
||||
public void Normalize_preserves_unknown_custom_status()
|
||||
=> Assert.Equal("Take-home assignment", JobPipeline.Normalize(" Take-home assignment "));
|
||||
|
||||
[Fact]
|
||||
public void Stages_are_ordered_and_unique()
|
||||
{
|
||||
var orders = JobPipeline.Stages.Select(s => s.Order).ToList();
|
||||
Assert.Equal(orders.OrderBy(x => x), orders);
|
||||
Assert.Equal(orders.Count, orders.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderOf_sorts_canonical_before_custom()
|
||||
{
|
||||
Assert.True(JobPipeline.OrderOf("Applied") < JobPipeline.OrderOf("Offer"));
|
||||
Assert.True(JobPipeline.OrderOf("Offer") < JobPipeline.OrderOf("Custom stage"));
|
||||
Assert.Equal(JobPipeline.OrderOf("Interview"), JobPipeline.OrderOf("Interviewing"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCanonical_only_true_for_known_stages()
|
||||
{
|
||||
Assert.True(JobPipeline.IsCanonical("Offer"));
|
||||
Assert.True(JobPipeline.IsCanonical("offer"));
|
||||
Assert.False(JobPipeline.IsCanonical("Interviewing")); // synonym, not canonical
|
||||
Assert.False(JobPipeline.IsCanonical("Whatever"));
|
||||
}
|
||||
}
|
||||
@@ -556,6 +556,129 @@ public sealed class ProfileCvControllerTests
|
||||
Assert.Equal("Warwickshire College, UK", structured.Education[0].Location);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rewrite_section_returns_ai_service_unavailable_detail_when_ai_health_is_unhealthy()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1", ProfileCvText = "Professional Summary\nBuilt backend systems." };
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
var aiService = new Mock<ISummarizerService>();
|
||||
aiService
|
||||
.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), 1800, 400))
|
||||
.ReturnsAsync(string.Empty);
|
||||
aiService
|
||||
.Setup(x => x.GetMetricsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new AiServiceMetrics(
|
||||
Healthy: false,
|
||||
Model: "distilbart",
|
||||
Device: "cpu",
|
||||
GpuAvailable: false,
|
||||
GpuName: null,
|
||||
OcrAvailable: true,
|
||||
OcrLanguages: "eng",
|
||||
OllamaConfigured: true,
|
||||
OllamaReachable: true,
|
||||
OllamaModel: "qwen2.5:7b",
|
||||
OllamaModelAvailable: true,
|
||||
OllamaVersion: "0.6.0",
|
||||
OllamaInstalledModels: new List<string> { "qwen2.5:7b" },
|
||||
OllamaLoadedModels: new List<string>(),
|
||||
OllamaLoadedCount: 0,
|
||||
HealthLatencyMs: 21,
|
||||
ProbeLatencyMs: null,
|
||||
LastProbeAt: null,
|
||||
LastProbeSuccessAt: null,
|
||||
LastProbeFailureAt: null,
|
||||
ProbeFailures: 1,
|
||||
Requests: 1,
|
||||
CacheHits: 0,
|
||||
CacheMisses: 1,
|
||||
Failures: 1,
|
||||
AverageLatencyMs: 21,
|
||||
OcrRequests: 0,
|
||||
OcrFailures: 0,
|
||||
AverageOcrLatencyMs: null,
|
||||
LastOcrSuccessAt: null,
|
||||
LastOcrFailureAt: null,
|
||||
LastSuccessAt: null,
|
||||
LastFailureAt: DateTimeOffset.UtcNow,
|
||||
LastError: "Model loading is disabled by AI_SERVICE_SKIP_MODEL_LOAD."));
|
||||
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||
|
||||
var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest());
|
||||
|
||||
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode);
|
||||
var payload = Assert.IsType<ProfileCvController.CvRewriteFailureDto>(objectResult.Value);
|
||||
Assert.Equal("ai-service-unavailable", payload.Code);
|
||||
Assert.Contains("could not rewrite", payload.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("unavailable", payload.Detail ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("AI_SERVICE_SKIP_MODEL_LOAD", payload.LastAiError ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rewrite_section_returns_rewrite_empty_detail_when_ai_health_is_healthy()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1", ProfileCvText = "Professional Summary\nBuilt backend systems." };
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
var aiService = new Mock<ISummarizerService>();
|
||||
aiService
|
||||
.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), 1800, 400))
|
||||
.ReturnsAsync(string.Empty);
|
||||
aiService
|
||||
.Setup(x => x.GetMetricsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new AiServiceMetrics(
|
||||
Healthy: true,
|
||||
Model: "distilbart",
|
||||
Device: "cpu",
|
||||
GpuAvailable: false,
|
||||
GpuName: null,
|
||||
OcrAvailable: true,
|
||||
OcrLanguages: "eng",
|
||||
OllamaConfigured: true,
|
||||
OllamaReachable: true,
|
||||
OllamaModel: "qwen2.5:7b",
|
||||
OllamaModelAvailable: true,
|
||||
OllamaVersion: "0.6.0",
|
||||
OllamaInstalledModels: new List<string> { "qwen2.5:7b" },
|
||||
OllamaLoadedModels: new List<string>(),
|
||||
OllamaLoadedCount: 0,
|
||||
HealthLatencyMs: 21,
|
||||
ProbeLatencyMs: null,
|
||||
LastProbeAt: null,
|
||||
LastProbeSuccessAt: null,
|
||||
LastProbeFailureAt: null,
|
||||
ProbeFailures: 0,
|
||||
Requests: 1,
|
||||
CacheHits: 0,
|
||||
CacheMisses: 1,
|
||||
Failures: 0,
|
||||
AverageLatencyMs: 21,
|
||||
OcrRequests: 0,
|
||||
OcrFailures: 0,
|
||||
AverageOcrLatencyMs: null,
|
||||
LastOcrSuccessAt: null,
|
||||
LastOcrFailureAt: null,
|
||||
LastSuccessAt: DateTimeOffset.UtcNow,
|
||||
LastFailureAt: null,
|
||||
LastError: null));
|
||||
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||
|
||||
var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest());
|
||||
|
||||
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode);
|
||||
var payload = Assert.IsType<ProfileCvController.CvRewriteFailureDto>(objectResult.Value);
|
||||
Assert.Equal("rewrite-empty", payload.Code);
|
||||
Assert.Contains("empty", payload.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("no usable text", payload.Detail ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rewrite_section_can_target_saved_job_context_and_whole_cv()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using JobTrackerApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class StageAnalyticsTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void Computes_median_days_per_active_stage()
|
||||
{
|
||||
var jobs = new[]
|
||||
{
|
||||
new StageOccupancy("Applied", Now.AddDays(-10)),
|
||||
new StageOccupancy("Applied", Now.AddDays(-20)),
|
||||
new StageOccupancy("Applied", Now.AddDays(-30)),
|
||||
new StageOccupancy("Interview", Now.AddDays(-4)),
|
||||
};
|
||||
|
||||
var result = StageAnalytics.TimeInStage(jobs, Now);
|
||||
|
||||
var applied = Assert.Single(result, p => p.Stage == "Applied");
|
||||
Assert.Equal(20, applied.MedianDays);
|
||||
Assert.Equal(3, applied.Count);
|
||||
|
||||
var interview = Assert.Single(result, p => p.Stage == "Interview");
|
||||
Assert.Equal(4, interview.MedianDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Excludes_closed_and_success_stages()
|
||||
{
|
||||
var jobs = new[]
|
||||
{
|
||||
new StageOccupancy("Offer", Now.AddDays(-5)),
|
||||
new StageOccupancy("Rejected", Now.AddDays(-5)),
|
||||
new StageOccupancy("Ghosted", Now.AddDays(-5)),
|
||||
};
|
||||
|
||||
Assert.Empty(StageAnalytics.TimeInStage(jobs, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalizes_legacy_status_and_orders_by_pipeline()
|
||||
{
|
||||
var jobs = new[]
|
||||
{
|
||||
new StageOccupancy("Interviewing", Now.AddDays(-3)),
|
||||
new StageOccupancy("Applied", Now.AddDays(-1)),
|
||||
new StageOccupancy("Waiting", Now.AddDays(-2)),
|
||||
};
|
||||
|
||||
var result = StageAnalytics.TimeInStage(jobs, Now);
|
||||
|
||||
Assert.Equal(new[] { "Applied", "Waiting", "Interview" }, result.Select(p => p.Stage).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_input_returns_empty()
|
||||
=> Assert.Empty(StageAnalytics.TimeInStage(Array.Empty<StageOccupancy>(), Now));
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using JobTrackerApi.Services;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class SummarizerServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Summarize_section_uses_cv_rewrite_endpoint()
|
||||
{
|
||||
var handler = new CapturingHandler();
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost:8001")
|
||||
};
|
||||
|
||||
var httpFactory = new Mock<IHttpClientFactory>();
|
||||
httpFactory.Setup(x => x.CreateClient("ai-service")).Returns(httpClient);
|
||||
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new SummarizerService(httpFactory.Object, memoryCache);
|
||||
|
||||
var result = await service.SummarizeSectionAsync("Rewrite this CV", "Professional Summary\nBuilt backend systems.", 1800, 400);
|
||||
|
||||
Assert.Equal("rewritten cv", result);
|
||||
Assert.Equal("/cv/rewrite", handler.LastPath);
|
||||
Assert.NotNull(handler.LastBody);
|
||||
Assert.Contains("\"instruction\":\"Rewrite this CV\"", handler.LastBody);
|
||||
Assert.Contains("\"max_length\":256", handler.LastBody);
|
||||
Assert.Contains("\"min_length\":180", handler.LastBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Summarize_section_clamps_lengths_to_ai_service_limits()
|
||||
{
|
||||
var handler = new CapturingHandler();
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost:8001")
|
||||
};
|
||||
|
||||
var httpFactory = new Mock<IHttpClientFactory>();
|
||||
httpFactory.Setup(x => x.CreateClient("ai-service")).Returns(httpClient);
|
||||
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new SummarizerService(httpFactory.Object, memoryCache);
|
||||
|
||||
await service.SummarizeSectionAsync("Rewrite this CV", "Professional Summary\nBuilt backend systems.", 1800, 400);
|
||||
|
||||
Assert.NotNull(handler.LastBody);
|
||||
Assert.Contains("\"max_length\":256", handler.LastBody);
|
||||
Assert.Contains("\"min_length\":180", handler.LastBody);
|
||||
}
|
||||
|
||||
private sealed class CapturingHandler : HttpMessageHandler
|
||||
{
|
||||
public string? LastBody { get; private set; }
|
||||
public string? LastPath { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastPath = request.RequestUri?.AbsolutePath;
|
||||
LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
var responseBody = LastPath == "/cv/rewrite"
|
||||
? "{\"rewritten_text\":\"rewritten cv\"}"
|
||||
: "{\"summary\":\"ok\"}";
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -23,8 +23,9 @@ namespace JobTrackerApi.Controllers
|
||||
private readonly ILogger<JobApplicationsController> _logger;
|
||||
private readonly ICvTemplateRenderer _cvTemplateRenderer;
|
||||
private readonly ICvPdfExporter _cvPdfExporter;
|
||||
private readonly IJobCvMatchService _matchService;
|
||||
|
||||
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null)
|
||||
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, IJobCvMatchService? matchService = null)
|
||||
{
|
||||
_db = db;
|
||||
_summarizer = summarizer;
|
||||
@@ -33,6 +34,7 @@ namespace JobTrackerApi.Controllers
|
||||
_logger = logger;
|
||||
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
|
||||
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
|
||||
_matchService = matchService ?? new JobCvMatchService();
|
||||
}
|
||||
|
||||
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
|
||||
@@ -749,6 +751,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,
|
||||
@@ -1081,6 +1087,10 @@ Canonical profile:
|
||||
DateTime? Deadline,
|
||||
string? Location,
|
||||
string? Salary,
|
||||
decimal? SalaryMin,
|
||||
decimal? SalaryMax,
|
||||
string? SalaryCurrency,
|
||||
string? SalaryPeriod,
|
||||
string? NextAction,
|
||||
DateTime? FollowUpAt,
|
||||
DateTime? FeedbackRequestedAt,
|
||||
@@ -1349,6 +1359,10 @@ Canonical profile:
|
||||
string? Status,
|
||||
string? Location,
|
||||
string? Salary,
|
||||
decimal? SalaryMin,
|
||||
decimal? SalaryMax,
|
||||
string? SalaryCurrency,
|
||||
string? SalaryPeriod,
|
||||
string? NextAction,
|
||||
DateTime? FollowUpAt,
|
||||
string? Notes,
|
||||
@@ -1367,6 +1381,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)
|
||||
{
|
||||
@@ -1375,9 +1405,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.");
|
||||
|
||||
@@ -1386,7 +1414,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(),
|
||||
@@ -1409,6 +1437,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
|
||||
{
|
||||
@@ -1447,6 +1478,10 @@ Canonical profile:
|
||||
DateTime? ResponseDate,
|
||||
string? Location,
|
||||
string? Salary,
|
||||
decimal? SalaryMin,
|
||||
decimal? SalaryMax,
|
||||
string? SalaryCurrency,
|
||||
string? SalaryPeriod,
|
||||
string? NextAction,
|
||||
DateTime? FollowUpAt,
|
||||
bool? HasResume,
|
||||
@@ -1482,11 +1517,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;
|
||||
@@ -1533,6 +1570,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)
|
||||
{
|
||||
@@ -1541,7 +1585,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
|
||||
@@ -1558,6 +1602,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)
|
||||
@@ -1977,13 +2072,15 @@ Canonical profile:
|
||||
public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate);
|
||||
public sealed record TagTrendSeries(string Tag, List<int> Counts);
|
||||
public sealed record TagTrendPoint(string Month, List<int> Counts);
|
||||
public sealed record StageDurationDto(string Stage, double MedianDays, int Count);
|
||||
public sealed record AnalyticsOverviewDto(
|
||||
List<FunnelStagePoint> Funnel,
|
||||
List<ResponseRatePoint> ResponseRateBySource,
|
||||
List<CompanyActivityPoint> TopCompanies,
|
||||
double? MedianDaysToFirstResponse,
|
||||
int TotalResponses,
|
||||
int TotalActive
|
||||
int TotalActive,
|
||||
List<StageDurationDto> TimeInStage
|
||||
);
|
||||
public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason);
|
||||
public sealed record DuplicateCheckResult(bool HasDuplicates, List<DuplicateCandidateDto> Matches);
|
||||
@@ -2070,6 +2167,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)
|
||||
{
|
||||
@@ -2674,16 +2854,14 @@ Candidate master CV:
|
||||
.Where(j => !j.IsDeleted)
|
||||
.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.Company?.Source) ? "Unknown source" : j.Company!.Source!.Trim())
|
||||
@@ -2727,13 +2905,46 @@ Candidate master CV:
|
||||
: 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 Ok(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
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -109,10 +109,14 @@ public sealed class ProfileCvController : ControllerBase
|
||||
public JsonElement? JobApplicationId { get; set; }
|
||||
public string? TemplateId { get; set; }
|
||||
public string? SourceText { get; set; }
|
||||
public string? PromptBackground { get; set; }
|
||||
public string? Tone { get; set; }
|
||||
public string? Language { get; set; }
|
||||
}
|
||||
public sealed record ParseCvRequest(string? Text);
|
||||
public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List<string> PreviewBullets);
|
||||
public sealed record ProfileCvPreviewDto(string TemplateId, string Html, string SuggestedFileName, string FullText, string RewrittenText, string? SectionName, StructuredCvProfile StructuredCv, TailoredCvDocument Document, string? TargetRole, int? JobApplicationId);
|
||||
public sealed record CvRewriteFailureDto(string Code, string Message, string? Detail = null, string? LastAiError = null);
|
||||
|
||||
private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
|
||||
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
|
||||
@@ -295,6 +299,9 @@ public sealed class ProfileCvController : ControllerBase
|
||||
var style = string.IsNullOrWhiteSpace(request.Style) ? "ats-minimal" : request.Style.Trim();
|
||||
var templateId = NormalizeTemplateId(request.TemplateId ?? style);
|
||||
var targetRole = string.IsNullOrWhiteSpace(request.TargetRole) ? null : request.TargetRole.Trim();
|
||||
var tone = string.IsNullOrWhiteSpace(request.Tone) ? null : request.Tone.Trim();
|
||||
var language = string.IsNullOrWhiteSpace(request.Language) ? null : request.Language.Trim();
|
||||
var promptBackground = string.IsNullOrWhiteSpace(request.PromptBackground) ? null : request.PromptBackground.Trim();
|
||||
var jobApplicationId = ParseFlexibleNullableInt(request.JobApplicationId);
|
||||
var jobContext = jobApplicationId.HasValue
|
||||
? await _db.JobApplications
|
||||
@@ -326,9 +333,12 @@ public sealed class ProfileCvController : ControllerBase
|
||||
: effectiveTargetRole is not null
|
||||
? $"Target role: {effectiveTargetRole}. Keep it broadly reusable but clearly aligned to that role family."
|
||||
: "Keep it broadly reusable for future tailoring.";
|
||||
var toneGuidance = tone is not null ? $"Tone guidance: {tone}." : "Tone guidance: confident, professional, concise, and factual.";
|
||||
var languageGuidance = language is not null ? $"Write the CV in {language}." : "Write the CV in English unless the source clearly requires another language.";
|
||||
var backgroundGuidance = promptBackground is not null ? $"Candidate background and emphasis: {promptBackground}" : string.Empty;
|
||||
|
||||
var subject = sectionName is null ? "this CV" : $"the '{sectionName}' section of this CV";
|
||||
var instruction = $"Rewrite only {subject}. Preserve facts, avoid inventing employers, titles, qualifications, dates, locations, or metrics. Style guidance: {style}. Template direction: {templateGuidance}. {roleGuidance} Return only the rewritten text with clean headings and bullets when useful.";
|
||||
var instruction = $"Rewrite only {subject}. Preserve facts, avoid inventing employers, titles, qualifications, dates, locations, salaries, or metrics. Style guidance: {style}. Template direction: {templateGuidance}. {roleGuidance} {toneGuidance} {languageGuidance} {backgroundGuidance} Return only the rewritten CV text with clean headings and strong bullet phrasing when useful.";
|
||||
var rewritten = await _aiService.SummarizeSectionAsync(
|
||||
instruction,
|
||||
rewriteSource,
|
||||
@@ -337,9 +347,23 @@ public sealed class ProfileCvController : ControllerBase
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rewritten))
|
||||
{
|
||||
_logger.LogWarning("CV rewrite returned empty output. Section={SectionName} Template={TemplateId} TargetRole={TargetRole} JobApplicationId={JobApplicationId} HasSourceText={HasSourceText} StructuredSections={StructuredSectionCount}",
|
||||
sectionName ?? "<whole-cv>", templateId, effectiveTargetRole ?? "<none>", jobApplicationId, !string.IsNullOrWhiteSpace(sourceText), structuredCv.Sections.Count);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, "The AI service could not rewrite your CV right now.");
|
||||
var metrics = await _aiService.GetMetricsAsync(HttpContext.RequestAborted);
|
||||
var detail = metrics.Healthy
|
||||
? "The rewrite request reached the AI service, but it returned no usable text."
|
||||
: "The AI rewrite service is unavailable or not ready.";
|
||||
var failureCode = metrics.Healthy ? "rewrite-empty" : "ai-service-unavailable";
|
||||
var message = metrics.Healthy
|
||||
? "The AI service returned an empty CV rewrite."
|
||||
: "The AI service could not rewrite your CV right now.";
|
||||
|
||||
_logger.LogWarning("CV rewrite returned empty output. Section={SectionName} Template={TemplateId} TargetRole={TargetRole} JobApplicationId={JobApplicationId} HasSourceText={HasSourceText} StructuredSections={StructuredSectionCount} AiHealthy={AiHealthy} AiLastError={AiLastError}",
|
||||
sectionName ?? "<whole-cv>", templateId, effectiveTargetRole ?? "<none>", jobApplicationId, !string.IsNullOrWhiteSpace(sourceText), structuredCv.Sections.Count, metrics.Healthy, metrics.LastError ?? "<none>");
|
||||
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new CvRewriteFailureDto(
|
||||
failureCode,
|
||||
message,
|
||||
detail,
|
||||
metrics.LastError));
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
@@ -859,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);
|
||||
@@ -2123,7 +2151,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
}
|
||||
|
||||
var contactSection = sections.FirstOrDefault(section => section.Name == "Contact");
|
||||
profile.Contact.Location = PreferDetectedLocation(contactSection.Content ?? text, profile.Contact.Location, profile.Contact.FullName);
|
||||
profile.Contact.Location = PreferDetectedLocation(contactSection?.Content ?? text, profile.Contact.Location, profile.Contact.FullName);
|
||||
profile.Summary = CondenseSummary(profile.Summary);
|
||||
profile.Skills = OrderSkills(profile.Skills);
|
||||
profile.Interests = CleanInterestItems(profile.Interests);
|
||||
|
||||
@@ -16,6 +16,11 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
ENV CV_PDF_BROWSER_PATH=/usr/bin/chromium
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends chromium \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p /data
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -60,16 +60,26 @@ builder.Services.AddDbContext<JobTrackerContext>((sp, options) =>
|
||||
// Avoid ServerVersion.AutoDetect here because it forces an immediate DB connection
|
||||
// during service registration, which can crash the API if MariaDB is temporarily
|
||||
// unavailable or on a different network during deploy startup.
|
||||
options.UseMySql(cs, new MariaDbServerVersion(new Version(11, 0, 0)));
|
||||
options.UseMySql(cs, new MariaDbServerVersion(new Version(11, 0, 0)), mysql =>
|
||||
{
|
||||
mysql.MigrationsAssembly("JobTrackerApi");
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
options.UseSqlite(cs);
|
||||
options.UseSqlite(cs, sqlite =>
|
||||
{
|
||||
sqlite.MigrationsAssembly("JobTrackerApi");
|
||||
});
|
||||
}
|
||||
|
||||
// We create Identity tables on startup in environments where `dotnet ef` isn't available.
|
||||
// That can cause EF to detect "pending model changes" and throw on Migrate(). Ignore it.
|
||||
options.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
|
||||
options.ConfigureWarnings(w =>
|
||||
{
|
||||
w.Ignore(RelationalEventId.PendingModelChangesWarning);
|
||||
w.Ignore(CoreEventId.PossibleIncorrectRequiredNavigationWithQueryFilterInteractionWarning);
|
||||
});
|
||||
});
|
||||
|
||||
// Enable CORS (allowlist by default)
|
||||
@@ -102,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))
|
||||
{
|
||||
@@ -118,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>();
|
||||
@@ -144,6 +157,7 @@ builder.Services.AddHttpClient("ai-service", client =>
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
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>();
|
||||
@@ -429,4 +443,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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Playwright;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
@@ -11,6 +12,18 @@ public interface ICvPdfExporter
|
||||
|
||||
public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
|
||||
{
|
||||
private static readonly string[] BrowserCandidates =
|
||||
{
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable"
|
||||
};
|
||||
|
||||
private readonly AppPaths _paths;
|
||||
private readonly ILogger<PlaywrightCvPdfExporter> _logger;
|
||||
|
||||
@@ -25,42 +38,141 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var folder = Path.Combine(_paths.CvExportsRoot, now.ToString("yyyyMMdd"));
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
var fileName = string.IsNullOrWhiteSpace(renderResult.SuggestedFileName)
|
||||
? $"tailored-cv-{now:yyyyMMddHHmmss}.pdf"
|
||||
: renderResult.SuggestedFileName;
|
||||
var storagePath = Path.Combine(folder, fileName);
|
||||
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "jobtracker-cv-pdf", Guid.NewGuid().ToString("n"));
|
||||
var htmlPath = Path.Combine(tempRoot, "document.html");
|
||||
var userDataDir = Path.Combine(tempRoot, "profile");
|
||||
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
Directory.CreateDirectory(userDataDir);
|
||||
|
||||
try
|
||||
{
|
||||
using var playwright = await Playwright.CreateAsync();
|
||||
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||
await File.WriteAllTextAsync(htmlPath, renderResult.Html ?? string.Empty, Encoding.UTF8, cancellationToken);
|
||||
|
||||
var browserPath = ResolveBrowserPath();
|
||||
if (string.IsNullOrWhiteSpace(browserPath))
|
||||
{
|
||||
Headless = true,
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
await page.SetContentAsync(renderResult.Html, new PageSetContentOptions
|
||||
throw new InvalidOperationException("CV PDF export is unavailable. Install Chromium/Google Chrome or set CV_PDF_BROWSER_PATH.");
|
||||
}
|
||||
|
||||
var arguments = BuildArguments(userDataDir, storagePath, htmlPath);
|
||||
var startInfo = new ProcessStartInfo();
|
||||
startInfo.FileName = browserPath;
|
||||
startInfo.Arguments = arguments;
|
||||
startInfo.RedirectStandardOutput = true;
|
||||
startInfo.RedirectStandardError = true;
|
||||
startInfo.UseShellExecute = false;
|
||||
startInfo.CreateNoWindow = true;
|
||||
|
||||
using var process = new Process();
|
||||
process.StartInfo = startInfo;
|
||||
process.Start();
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync();
|
||||
var stderr = await process.StandardError.ReadToEndAsync();
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
WaitUntil = WaitUntilState.Load,
|
||||
});
|
||||
var bytes = await page.PdfAsync(new PagePdfOptions
|
||||
throw new InvalidOperationException($"CV PDF export failed via browser CLI. ExitCode={process.ExitCode}. Stdout={stdout}. Stderr={stderr}");
|
||||
}
|
||||
|
||||
if (!File.Exists(storagePath))
|
||||
{
|
||||
Format = "A4",
|
||||
PrintBackground = true,
|
||||
Margin = new()
|
||||
{
|
||||
Top = "0",
|
||||
Right = "0",
|
||||
Bottom = "0",
|
||||
Left = "0",
|
||||
}
|
||||
});
|
||||
await File.WriteAllBytesAsync(storagePath, bytes, cancellationToken);
|
||||
throw new InvalidOperationException($"CV PDF export did not create the expected file at {storagePath}.");
|
||||
}
|
||||
|
||||
var bytes = await File.ReadAllBytesAsync(storagePath, cancellationToken);
|
||||
return new CvPdfArtifact(fileName, storagePath, bytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to export CV PDF to {Path}", storagePath);
|
||||
throw new InvalidOperationException("CV PDF export is unavailable. Ensure Chromium is installed for Playwright on this machine.", ex);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempRoot);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildArguments(string userDataDir, string storagePath, string htmlPath)
|
||||
{
|
||||
var parts = new List<string>
|
||||
{
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--allow-file-access-from-files",
|
||||
"--enable-local-file-accesses",
|
||||
"--user-data-dir=" + Quote(userDataDir),
|
||||
"--print-to-pdf=" + Quote(storagePath),
|
||||
Quote(htmlPath)
|
||||
};
|
||||
|
||||
return string.Join(' ', parts);
|
||||
}
|
||||
|
||||
private static string? ResolveBrowserPath()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("CV_PDF_BROWSER_PATH");
|
||||
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
|
||||
{
|
||||
return configured;
|
||||
}
|
||||
|
||||
foreach (var candidate in BrowserCandidates)
|
||||
{
|
||||
if (Path.IsPathRooted(candidate))
|
||||
{
|
||||
if (File.Exists(candidate)) return candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
var resolved = FindOnPath(candidate);
|
||||
if (!string.IsNullOrWhiteSpace(resolved)) return resolved;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FindOnPath(string fileName)
|
||||
{
|
||||
var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
||||
var parts = path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var dir in parts)
|
||||
{
|
||||
var fullPath = Path.Combine(dir, fileName);
|
||||
if (File.Exists(fullPath)) return fullPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best effort temp cleanup
|
||||
}
|
||||
}
|
||||
|
||||
private static string Quote(string value)
|
||||
{
|
||||
return '"' + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + '"';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;");
|
||||
@@ -607,6 +613,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;");
|
||||
@@ -870,7 +880,17 @@ public static class StartupInitializationExtensions
|
||||
}
|
||||
}
|
||||
|
||||
db.Database.Migrate();
|
||||
try
|
||||
{
|
||||
using var migrationScope = app.Services.CreateScope();
|
||||
var migrationDb = migrationScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
migrationDb.Database.Migrate();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
app.Logger.LogError(ex, "Database migration failed during startup initialization.");
|
||||
throw;
|
||||
}
|
||||
|
||||
// Optional: seed an initial admin user for local username/password login.
|
||||
// Set Auth:AdminEmail and Auth:AdminPassword to enable.
|
||||
@@ -878,21 +898,25 @@ public static class StartupInitializationExtensions
|
||||
var adminPassword = (app.Configuration["Auth:AdminPassword"] ?? "").Trim();
|
||||
if (!string.IsNullOrWhiteSpace(adminEmail) && !string.IsNullOrWhiteSpace(adminPassword))
|
||||
{
|
||||
using var adminScope = app.Services.CreateScope();
|
||||
var adminDb = adminScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
var adminUsers = adminScope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var adminRoles = adminScope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
|
||||
const string adminRole = "Admin";
|
||||
|
||||
if (!roles.RoleExistsAsync(adminRole).GetAwaiter().GetResult())
|
||||
if (!adminRoles.RoleExistsAsync(adminRole).GetAwaiter().GetResult())
|
||||
{
|
||||
roles.CreateAsync(new IdentityRole(adminRole)).GetAwaiter().GetResult();
|
||||
adminRoles.CreateAsync(new IdentityRole(adminRole)).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
var existing = users.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
|
||||
var existing = adminUsers.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
|
||||
if (existing is null)
|
||||
{
|
||||
var u = new ApplicationUser { UserName = adminEmail, Email = adminEmail, EmailConfirmed = true };
|
||||
var created = users.CreateAsync(u, adminPassword).GetAwaiter().GetResult();
|
||||
var created = adminUsers.CreateAsync(u, adminPassword).GetAwaiter().GetResult();
|
||||
if (created.Succeeded)
|
||||
{
|
||||
users.AddToRoleAsync(u, adminRole).GetAwaiter().GetResult();
|
||||
adminUsers.AddToRoleAsync(u, adminRole).GetAwaiter().GetResult();
|
||||
app.Logger.LogInformation("Seeded admin user: {Email}", adminEmail);
|
||||
}
|
||||
else
|
||||
@@ -902,17 +926,17 @@ public static class StartupInitializationExtensions
|
||||
}
|
||||
else
|
||||
{
|
||||
var inRole = users.IsInRoleAsync(existing, adminRole).GetAwaiter().GetResult();
|
||||
if (!inRole) users.AddToRoleAsync(existing, adminRole).GetAwaiter().GetResult();
|
||||
var inRole = adminUsers.IsInRoleAsync(existing, adminRole).GetAwaiter().GetResult();
|
||||
if (!inRole) adminUsers.AddToRoleAsync(existing, adminRole).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
// One-time claim of legacy data for the admin user so enabling auth doesn't "hide" existing records.
|
||||
var admin = users.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
|
||||
var admin = adminUsers.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
|
||||
if (admin is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var conn = db.Database.GetDbConnection();
|
||||
using var conn = adminDb.Database.GetDbConnection();
|
||||
conn.Open();
|
||||
|
||||
static bool ColumnExists(DbConnection c, string providerName, string table, string column)
|
||||
@@ -953,12 +977,12 @@ public static class StartupInitializationExtensions
|
||||
{
|
||||
if (companyOwnershipExists)
|
||||
{
|
||||
db.Database.ExecuteSqlRaw("UPDATE Companies SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id);
|
||||
adminDb.Database.ExecuteSqlRaw("UPDATE Companies SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id);
|
||||
}
|
||||
|
||||
if (jobOwnershipExists)
|
||||
{
|
||||
db.Database.ExecuteSqlRaw("UPDATE JobApplications SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id);
|
||||
adminDb.Database.ExecuteSqlRaw("UPDATE JobApplications SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ namespace JobTrackerApi.Services
|
||||
public class SummarizerService : ISummarizerService
|
||||
{
|
||||
private const int AiSummarizeMaxInputChars = 20000;
|
||||
private const int AiServiceMaxSummaryLength = 256;
|
||||
private const int AiServiceMaxMinLength = 180;
|
||||
private const int AiServiceMinSummaryLength = 24;
|
||||
private const int AiServiceMinMinLength = 8;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly object _metricsLock = new();
|
||||
@@ -149,8 +153,7 @@ namespace JobTrackerApi.Services
|
||||
public Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text)) return Task.FromResult<string?>(null);
|
||||
var composed = ComposeBoundedPrompt(instruction.Trim(), text.Trim());
|
||||
return SummarizeCoreAsync(composed, maxLength, minLength);
|
||||
return RewriteCoreAsync(instruction.Trim(), text.Trim(), maxLength, minLength);
|
||||
}
|
||||
|
||||
private static string ComposeBoundedPrompt(string instruction, string text)
|
||||
@@ -170,9 +173,17 @@ namespace JobTrackerApi.Services
|
||||
return prefix + text[..remaining];
|
||||
}
|
||||
|
||||
private async Task<string?> SummarizeCoreAsync(string text, int maxLength, int minLength)
|
||||
private async Task<string?> RewriteCoreAsync(string instruction, string text, int maxLength, int minLength)
|
||||
{
|
||||
var key = BuildCacheKey(text, maxLength, minLength);
|
||||
var normalizedMaxLength = Math.Clamp(maxLength, AiServiceMinSummaryLength, AiServiceMaxSummaryLength);
|
||||
var normalizedMinLength = Math.Clamp(minLength, AiServiceMinMinLength, AiServiceMaxMinLength);
|
||||
if (normalizedMinLength >= normalizedMaxLength)
|
||||
{
|
||||
normalizedMinLength = Math.Max(AiServiceMinMinLength, normalizedMaxLength - 1);
|
||||
}
|
||||
|
||||
var composed = ComposeBoundedPrompt(instruction, text);
|
||||
var key = BuildCacheKey($"rewrite::{composed}", normalizedMaxLength, normalizedMinLength);
|
||||
Interlocked.Increment(ref _requests);
|
||||
|
||||
if (_cache.TryGetValue<string>(key, out var cached))
|
||||
@@ -189,7 +200,95 @@ namespace JobTrackerApi.Services
|
||||
Interlocked.Increment(ref _cacheMisses);
|
||||
|
||||
var client = _httpFactory.CreateClient("ai-service");
|
||||
var payload = JsonSerializer.Serialize(new { text, max_length = maxLength, min_length = minLength });
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
instruction,
|
||||
text,
|
||||
max_length = normalizedMaxLength,
|
||||
min_length = normalizedMinLength,
|
||||
});
|
||||
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var res = await client.PostAsync("/cv/rewrite", content);
|
||||
sw.Stop();
|
||||
Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks);
|
||||
if (!res.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await ReadErrorBodyAsync(res);
|
||||
Interlocked.Increment(ref _failures);
|
||||
lock (_metricsLock)
|
||||
{
|
||||
_lastFailureAt = DateTimeOffset.UtcNow;
|
||||
_lastError = $"AI rewrite failed: {errorBody}";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
using var stream = await res.Content.ReadAsStreamAsync();
|
||||
using var doc = await JsonDocument.ParseAsync(stream);
|
||||
if (doc.RootElement.TryGetProperty("rewritten_text", out var el))
|
||||
{
|
||||
var s = el.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(s)) _cache.Set(key, s, TimeSpan.FromHours(6));
|
||||
lock (_metricsLock)
|
||||
{
|
||||
_lastSuccessAt = DateTimeOffset.UtcNow;
|
||||
_lastError = null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
lock (_metricsLock)
|
||||
{
|
||||
_lastFailureAt = DateTimeOffset.UtcNow;
|
||||
_lastError = "AI rewrite failed: response did not contain rewritten_text.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks);
|
||||
Interlocked.Increment(ref _failures);
|
||||
lock (_metricsLock)
|
||||
{
|
||||
_lastFailureAt = DateTimeOffset.UtcNow;
|
||||
_lastError = ex.Message;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> SummarizeCoreAsync(string text, int maxLength, int minLength)
|
||||
{
|
||||
var normalizedMaxLength = Math.Clamp(maxLength, AiServiceMinSummaryLength, AiServiceMaxSummaryLength);
|
||||
var normalizedMinLength = Math.Clamp(minLength, AiServiceMinMinLength, AiServiceMaxMinLength);
|
||||
if (normalizedMinLength >= normalizedMaxLength)
|
||||
{
|
||||
normalizedMinLength = Math.Max(AiServiceMinMinLength, normalizedMaxLength - 1);
|
||||
}
|
||||
|
||||
var key = BuildCacheKey(text, normalizedMaxLength, normalizedMinLength);
|
||||
Interlocked.Increment(ref _requests);
|
||||
|
||||
if (_cache.TryGetValue<string>(key, out var cached))
|
||||
{
|
||||
Interlocked.Increment(ref _cacheHits);
|
||||
lock (_metricsLock)
|
||||
{
|
||||
_lastSuccessAt = DateTimeOffset.UtcNow;
|
||||
_lastError = null;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _cacheMisses);
|
||||
|
||||
var client = _httpFactory.CreateClient("ai-service");
|
||||
var payload = JsonSerializer.Serialize(new { text, max_length = normalizedMaxLength, min_length = normalizedMinLength });
|
||||
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -27,7 +27,6 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" />
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.55.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -13,6 +13,12 @@ public class JobApplication
|
||||
public DateTime DateApplied { get; set; } = DateTime.UtcNow;
|
||||
public string? Location { get; set; }
|
||||
public string? Salary { get; set; }
|
||||
|
||||
// Structured salary; the free-text Salary field is kept for display/back-compat.
|
||||
public decimal? SalaryMin { get; set; }
|
||||
public decimal? SalaryMax { get; set; }
|
||||
public string? SalaryCurrency { get; set; } // e.g. "NOK", "GBP", "EUR"
|
||||
public string? SalaryPeriod { get; set; } // "year" | "month" | "hour"
|
||||
public string? NextAction { get; set; }
|
||||
public DateTime? FollowUpAt { get; set; }
|
||||
public DateTime? FeedbackRequestedAt { get; set; }
|
||||
|
||||
@@ -12,6 +12,8 @@ Job Tracker is a simple, self-hosted app for tracking job applications with a Re
|
||||
- History/event trail per application (created, status changes, follow-up set, delete/restore)
|
||||
- Export jobs to JSON/CSV + daily scheduled JSON export
|
||||
- Optional “job import” preview from supported job sites (plugins) + optional translation to English
|
||||
- Quick-capture bookmarklet (Settings) + installable PWA with a mobile share-target: both open `/?add=<page url>` to pre-fill Add Job from any posting
|
||||
- Note: no offline service-worker cache is bundled by design (the app is deployed frequently; an aggressive cache would risk serving stale builds). The manifest provides installability and share-to-capture without it.
|
||||
- Optional local AI service for short/full descriptions
|
||||
- Optional Google sign-in (Google ID tokens) to protect the API
|
||||
|
||||
@@ -133,6 +135,10 @@ Common keys:
|
||||
- `Exports:DailyEnabled`: enable/disable daily export background job
|
||||
- `Exports:DailyFolder`: export destination (relative to `Data:Root` if not absolute)
|
||||
- `Exports:DailyHourLocal`: local hour (0–23) when the daily export runs
|
||||
- `Backups:Enabled`: enable/disable the automated SQLite backup job (default `true`)
|
||||
- `Backups:HourLocal`: local hour (0–23) when the daily database backup runs (default `3`)
|
||||
- `Backups:RetainCount`: how many backup files to keep in `<Data:Root>/backups` (default `14`)
|
||||
- Backups use SQLite `VACUUM INTO` (consistent snapshot, safe with WAL). A catch-up backup runs at startup when none exists from the last 24 h. For MySQL/MariaDB configure external backups instead (see `deploy/MARIADB.md`).
|
||||
- `Auth:GoogleClientId`: if set, enables JWT bearer validation for Google ID tokens
|
||||
- `Auth:JwtKey`: secret used to sign local JWTs for username/password login (set via env var `Auth__JwtKey`)
|
||||
- `Auth:JwtIssuer`: JWT issuer (default `JobTrackerApi`)
|
||||
@@ -187,7 +193,9 @@ Authentication:
|
||||
- Updates an application; records a `StatusChanged` event if the status changed.
|
||||
- `PATCH /api/jobapplications/{id}/status`
|
||||
- Body: `{ "status": "..." }`
|
||||
- Updates only status; records `StatusChanged` if it changed.
|
||||
- Updates only status; records `StatusChanged` if it changed. The status is normalized against the canonical pipeline (casing + known synonyms like `Interviewing`→`Interview`); unrecognized values are preserved as custom statuses.
|
||||
- `GET /api/jobapplications/pipeline`
|
||||
- Returns the canonical ordered pipeline stages (`Applied, Waiting, Interview, Offer, Rejected, Ghosted`) with display order and category (`Active`/`Success`/`Closed`). The UI renders board columns and status dropdowns from this single source of truth.
|
||||
- `PATCH /api/jobapplications/{id}/followup`
|
||||
- Body: `{ "followUpAt": "2026-03-13T12:00:00Z" }` (or `null`)
|
||||
- Sets/clears follow-up date; records a `FollowUpSet` event.
|
||||
@@ -197,6 +205,10 @@ Authentication:
|
||||
- Returns a unified timeline combining job events, correspondence, and attachments.
|
||||
- `GET /api/jobapplications/stats`
|
||||
- Returns totals, counts by status, applied-last-30-days, and average days since applied.
|
||||
- `GET /api/jobapplications/{id}/match-score`
|
||||
- Deterministic CV↔job keyword-coverage score (0–100) with matched/missing keywords and per-CV-section coverage. No AI calls: results are instant and reproducible. Requires profile CV text/structure and a job description. (The AI narrative equivalent is `GET /api/jobapplications/{id}/candidate-fit`.)
|
||||
- `GET /api/jobapplications/{id}/status-suggestion`
|
||||
- Deterministic status suggestion derived from the job's most recent inbound message (interview invite / offer / rejection). Returns a forward-only suggestion (`hasSuggestion`, `suggestedStatus`, `signal`, …) or `hasSuggestion: false`. Applying it is a normal `PATCH .../status` — always user-confirmed.
|
||||
- `DELETE /api/jobapplications/{id}`
|
||||
- Soft-deletes an application (`IsDeleted=true`); records a `Deleted` event.
|
||||
- `POST /api/jobapplications/{id}/restore`
|
||||
|
||||
+3
-1
@@ -61,7 +61,9 @@ fi
|
||||
# Force recreation so updated port mappings, env vars, and container config always apply on deploy.
|
||||
compose up -d --force-recreate --remove-orphans backend frontend
|
||||
if [ "$DEPLOY_BUILD_AI_SERVICE" = "true" ]; then
|
||||
compose up -d --force-recreate ai-service ollama
|
||||
# Ollama is opt-in (compose "bundled-ollama" profile). Deploys reuse an
|
||||
# existing/shared Ollama via OLLAMA_BASE_URL instead of starting a duplicate.
|
||||
compose up -d --force-recreate ai-service
|
||||
fi
|
||||
|
||||
if [ -n "${OLLAMA_MODEL:-}" ]; then
|
||||
|
||||
+11
-2
@@ -54,6 +54,9 @@ services:
|
||||
frontend:
|
||||
build:
|
||||
context: ./job-tracker-ui
|
||||
# fork-ts-checker (CRA's build type-checker) needs more than Docker's default
|
||||
# 64MB /dev/shm; too little causes a SIGSEGV during `npm run build`.
|
||||
shm_size: '1gb'
|
||||
args:
|
||||
- REACT_APP_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
|
||||
# Optional override; default in production is `/api`
|
||||
@@ -72,12 +75,14 @@ services:
|
||||
context: ./tools/summarizer
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
# Point at an existing/shared Ollama by setting OLLAMA_BASE_URL in .env
|
||||
# (e.g. http://<host-ip>:11435). The in-compose ollama service below is
|
||||
# opt-in via the "bundled-ollama" profile, so it is NOT started by default
|
||||
# and no duplicate Ollama container is created.
|
||||
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434}
|
||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:7b}
|
||||
ports:
|
||||
- "8001:8001"
|
||||
depends_on:
|
||||
- ollama
|
||||
networks:
|
||||
- default
|
||||
- shared_services
|
||||
@@ -88,7 +93,11 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Opt-in only: start with `docker compose --profile bundled-ollama up`.
|
||||
# Left out of the default set so deploys reuse an existing/shared Ollama
|
||||
# (configured via OLLAMA_BASE_URL) instead of spinning up a duplicate.
|
||||
ollama:
|
||||
profiles: ["bundled-ollama"]
|
||||
image: ollama/ollama:latest
|
||||
ports:
|
||||
- "11434:11434"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Merge Request — Wave 0 quick wins + Tier-1/Tier-2 features
|
||||
|
||||
**Branch:** `chore/wave0-quick-wins` → `main`
|
||||
**Scope:** 24 commits · 62 files · +3,165 / −489
|
||||
**Status:** all tests green (backend 135, frontend 23 suites / 54 tests), production build compiles.
|
||||
|
||||
> Prepared for human review. Do **not** auto-merge. One operator action is required after merge
|
||||
> (DataProtection key rotation — see *Known limitations*).
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Delivers the first two roadmap tiers plus the engineering-health groundwork, developed as small
|
||||
conventional commits. Two design principles run through it:
|
||||
|
||||
1. **Deterministic over "AI-guessy."** Match scoring, status suggestions, and pipeline logic are
|
||||
pure/deterministic — instant, reproducible, and safe (the user confirms every state change). This
|
||||
directly answers the market's most common complaint (hallucinated/generic AI output).
|
||||
2. **One pathway, not two.** The bookmarklet and the PWA share-target feed a single `/?add=` capture
|
||||
flow rather than parallel implementations.
|
||||
|
||||
## What's included
|
||||
|
||||
**Engineering health (Wave 0)**
|
||||
- `security:` untracked committed DataProtection keys + daily exports; removed dead legacy controllers.
|
||||
- `feat:` automated daily SQLite backups (`VACUUM INTO`, retention, startup catch-up) — prod previously
|
||||
had **no** automated backup on Linux.
|
||||
- `ci:` run the **entire** frontend suite (the old whitelist was hiding 3 broken suites, now fixed).
|
||||
- `feat:` dev-only OpenAPI at `/openapi/v1.json`; `feat:` structured salary fields.
|
||||
|
||||
**Tier-1 features**
|
||||
- **Match score** (`GET /jobapplications/{id}/match-score`) — deterministic CV↔job keyword coverage
|
||||
(0–100) + matched/missing keywords + section coverage. Instant panel on the Candidate Fit tab.
|
||||
- **Canonical pipeline** — `JobPipeline` single source of truth; status normalized on write (custom
|
||||
values preserved); UI deduped across 5 files; `GET .../pipeline`.
|
||||
- **Analytics v2** — time-in-stage medians (from `StatusChanged` history) + funnel driven by the
|
||||
pipeline (fixes a bug that omitted the Waiting stage).
|
||||
- **Status suggestions** — deterministic email→status classifier surfaced as a human-confirmed banner.
|
||||
|
||||
**Tier-2 features**
|
||||
- **Bookmarklet** quick-capture (Settings) reusing `jobimport/preview`.
|
||||
- **Installable PWA** with a mobile share-target into the same capture flow.
|
||||
|
||||
**Quality**
|
||||
- Phase-6 security review (`docs/SECURITY_REPORT.md`): tenant isolation on new endpoints verified +
|
||||
regression-tested; no injection/ReDoS; dev-only OpenAPI.
|
||||
- Bug fixes: `SkillTagger` C#/.NET regex (silently missed those skills everywhere), a React
|
||||
stale-closure, a duplicated DB query, and 3 pre-existing hidden test failures.
|
||||
|
||||
## Test coverage added
|
||||
|
||||
New pure/unit-tested services: `JobCvMatchService` (7), `JobPipeline` (14), `StageAnalytics` (4),
|
||||
`EmailStatusClassifier` (7). New endpoint integration + authorization tests (match-score,
|
||||
status-suggestion). New frontend tests: match-score panel, status-suggestion banner, pipeline,
|
||||
quick-capture, capture-url resolution.
|
||||
|
||||
## Docs
|
||||
|
||||
New: `docs/SYSTEM_OVERVIEW.md`, `docs/PRODUCT_RESEARCH.md`, `docs/ROADMAP.md`,
|
||||
`docs/SECURITY_REPORT.md`. README updated with the new endpoints, backup/pipeline config, and
|
||||
quick-capture/PWA notes.
|
||||
|
||||
## Known limitations / follow-ups
|
||||
|
||||
- **ACTION REQUIRED (security):** the removed DataProtection key XMLs remain in git **history**.
|
||||
Rotate them on the production host after merge (see `SECURITY_REPORT.md` §6).
|
||||
- **Per-user custom pipeline stages** were deliberately deferred (unproven demand; large surface).
|
||||
- **No offline service worker** by design — the app deploys frequently and an aggressive cache would
|
||||
risk serving stale builds. The PWA is installable and share-capable without it.
|
||||
- Not yet done (future branches): interview hub (M3), contacts CRM (M4), god-controller decomposition,
|
||||
performance pass, Vite migration.
|
||||
|
||||
## Reviewer notes
|
||||
|
||||
- Repo quirk: controllers/services compile via the `JobTrackerBackend` library, **not** the
|
||||
`JobTrackerApi` host project (see `docs/SYSTEM_OVERVIEW.md` §2).
|
||||
- All AI-adjacent features are deterministic and make no model calls.
|
||||
@@ -0,0 +1,121 @@
|
||||
# PRODUCT_RESEARCH.md — Job Application Tracking Market (2026)
|
||||
|
||||
> Phase 2 deliverable. Research conducted 2026-07-02 via web sources (linked throughout).
|
||||
> Purpose: position Jobbjakt against the market and rank the features worth building next.
|
||||
|
||||
---
|
||||
|
||||
## 1. Market landscape
|
||||
|
||||
The market splits into five clusters:
|
||||
|
||||
| Cluster | Representatives | Model |
|
||||
|---|---|---|
|
||||
| **Tracker-first + AI resume** | [Teal](https://www.tealhq.com/), [Huntr](https://huntr.co/pricing), JibberJobber | Freemium SaaS; premium $29–40/mo |
|
||||
| **Autofill / volume** | [Simplify](https://simplify.jobs/job-application-tracker) (autofill), [LazyApply](https://lazyapply.com/) ($99–999/yr), LoopCV (auto-apply) | Extension-centric |
|
||||
| **Matching + copilot** | [Jobright](https://jobright.ai/blog/teal-review-2026-walkthrough-alternatives-and-faqs/) | AI job matching, resume tailoring, autofill |
|
||||
| **Resume/ATS optimization** | [Jobscan](https://www.jobscan.co/) ($49.95/mo!), Resume Worded, Rezi | Match-score per job description |
|
||||
| **Self-hosted / privacy** | [JobSync](https://github.com/Gsync/jobsync), [CareerSync](https://github.com/Tomiwajin/CareerSync), [career-ops](https://career-ops.org/), various [GitHub projects](https://github.com/topics/job-application-tracker) | OSS, local-first, often Ollama-based |
|
||||
| **Email auto-tracking** | [Trackr](https://www.trackrjobs.com/), [G-Track](https://jobtrack-ai.com/gmail-job-tracker), Gmail [Chrome extensions](https://chromewebstore.google.com/detail/gmail-job-application-tra/lkpjngmdfncejiomkofogfdoppgifmkh) | Inbox scanning → status updates |
|
||||
|
||||
### Competitor snapshots
|
||||
|
||||
**Teal** — market leader for tracker+resume. Free: unlimited tracking, Chrome extension (50+ job boards), kanban (Saved/Applied/Interview/Offer/Rejected), 10 ATS templates, contact manager, ATS score (15 checks). Premium ($9/wk, $29/mo, [$79/qtr](https://www.tealhq.com/pricing)): keyword match scoring, AI bullets/cover letters, analytics. Cons reported: [billing-after-cancellation complaints, generic/hallucinating AI content, ATS failures on two-column templates](https://resumehog.com/blog/posts/teal-hq-review-april-2026-is-the-job-tracker-worth-your-time.html), [high-maintenance workflow, overwhelming UI, poor support](https://resumejudge.com/blog/tealhq-review/), no automation.
|
||||
|
||||
**Huntr** — best visual kanban + CRM layer. Free: 100 tracked jobs cap, unlimited base resumes, basic scoring. [Pro $40/mo](https://huntr.co/pricing): AI tailored resumes, unlimited cover letters, advanced matching/insights. 4.9★ extension (clip from any site + autofill). Cons: [must rebuild resume inside their builder, plain templates, free plan stops being useful fast](https://resumejudge.com/blog/huntr-review/), online-only.
|
||||
|
||||
**Simplify** — free autofill extension for 100+ ATS portals (Workday, Greenhouse, iCIMS), real-time keyword flagging, pipeline tracking. Execution-focused, light on CRM depth.
|
||||
|
||||
**Jobscan** — per-job resume match score (1–100, 30+ checks, "aim ≥75%"), cover-letter optimization report. Expensive ($49.95/mo). This single feature is the most-cited reason people pay for job-search tools.
|
||||
|
||||
**Email auto-trackers** (Trackr, G-Track, extensions) — scan Gmail, AI-classify (Applied/Next step/Rejected/Offer), auto-update statuses, apply labels. This is rapidly becoming table stakes; users love "zero manual data entry".
|
||||
|
||||
**Self-hosted OSS** (JobSync, CareerSync, career-ops) — privacy pitch ("no cloud, no telemetry, no account"), Ollama/local-LLM parsing, but all are far less complete than Jobbjakt: mostly CRUD + basic AI, no CV pipeline, no correspondence CRM, no rules engine.
|
||||
|
||||
### Standard vs premium features across the market
|
||||
|
||||
- **Table stakes (free everywhere):** kanban board, status stages, notes, basic contact tracking, browser clipper, export.
|
||||
- **Premium (what people pay for):** per-job resume↔JD **match scoring with keyword gaps**, AI tailored resumes/cover letters, analytics (response rate, funnel conversion, time-in-stage), email/interview follow-up automation, autofill at scale.
|
||||
- **Emerging differentiators:** inbox auto-tracking, interview prep hubs (question banks, scheduling, calendar sync — cf. [interview scheduling tools](https://www.selectsoftwarereviews.com/buyer-guide/interview-scheduling-software)), job-match scoring against a profile, salary/offer comparison.
|
||||
|
||||
### Recurring user frustrations (opportunities)
|
||||
|
||||
1. **Privacy/data anxiety** — sensitive career data on VC-funded SaaS; [breach/misuse concerns](https://www.saashub.com/compare-job-tracker-by-teal-vs-huntr). Jobbjakt's core moat.
|
||||
2. **Paywall fatigue** — free tiers cap exactly at the point of seriousness (Huntr's 100 jobs, Teal's AI credits, Jobscan's 5 scans/mo).
|
||||
3. **AI slop** — hallucinated skills, misspelled names, generic bullets; users want AI grounded in *their* real CV (Jobbjakt's structured-CV grounding is the right architecture).
|
||||
4. **Manual data entry** — retyping jobs and statuses; solved by clippers + inbox scanning.
|
||||
5. **Vendor lock-in** — resumes trapped in proprietary builders (Huntr), hard exports.
|
||||
6. **Tool sprawl** — tracker + Jobscan + resume builder + calendar = 4 subscriptions; users want one hub.
|
||||
|
||||
---
|
||||
|
||||
## 2. Feature matrix — Jobbjakt vs market
|
||||
|
||||
✅ has it · 🟡 partial · ❌ missing
|
||||
|
||||
| Feature | Teal | Huntr | Simplify | OSS self-hosted | **Jobbjakt today** |
|
||||
|---|---|---|---|---|---|
|
||||
| Kanban pipeline | ✅ | ✅ | ✅ | 🟡 | 🟡 board view exists; status is free-text, no drag-drop canonical pipeline |
|
||||
| Job capture from URL | ✅ ext | ✅ ext | ✅ ext | 🟡 | 🟡 server-side parse (Finn/NAV/LinkedIn/Jobbnorge + JSON-LD); no extension/bookmarklet |
|
||||
| Inbox auto-tracking | ❌ | ❌ | 🟡 | 🟡 | ✅ **Gmail OAuth import + human review queue** (ahead of paid SaaS) |
|
||||
| Contacts/recruiter CRM | ✅ | ✅ | ❌ | ❌ | 🟡 company-level only, no people entities |
|
||||
| Resume/CV builder | ✅ | ✅ | 🟡 | ❌ | ✅ structured CV parse + templates + PDF export |
|
||||
| Per-job tailored resume (AI) | 💰 | 💰 | 💰 | ❌ | ✅ **local-AI tailored drafts** (privacy-unique) |
|
||||
| Resume↔JD match score + keyword gaps | 💰 | 💰 | 🟡 | ❌ | ❌ (handoff doc lists "missing-keyword analysis" as planned) |
|
||||
| AI cover letters / messages | 💰 | 💰 | 💰 | ❌ | ✅ free, local |
|
||||
| Follow-up reminders | ✅ | ✅ | 🟡 | ❌ | ✅ + rules engine (auto-ghost) — richer than most |
|
||||
| Analytics dashboard (funnel, response rate, time-in-stage) | 💰 | 💰 | 🟡 | 🟡 | 🟡 basic stats endpoint only |
|
||||
| Interview management (schedule, prep notes, calendar) | 🟡 | 🟡 | ❌ | ❌ | ❌ (only generic follow-up dates) |
|
||||
| Calendar integration (ICS/Google) | 🟡 | 🟡 | ❌ | ❌ | ❌ |
|
||||
| Salary/offer tracking & comparison | 🟡 | 🟡 | ❌ | ❌ | 🟡 salary text field only |
|
||||
| Autofill applications | ❌ | ✅ | ✅ | ❌ | ❌ (out of scope — needs extension) |
|
||||
| Multi-language (EN/NB) + translation | ❌ | ❌ | ❌ | ❌ | ✅ unique for Nordic market |
|
||||
| Self-hosted / data ownership | ❌ | ❌ | ❌ | ✅ | ✅ |
|
||||
| Mobile experience | ✅ apps | ✅ | ✅ | ❌ | 🟡 responsive-ish desktop web; no PWA |
|
||||
| Export/portability | 🟡 | 🟡 | 🟡 | ✅ | ✅ JSON/CSV + daily export |
|
||||
|
||||
**Position:** Jobbjakt is already **ahead of every OSS competitor** and matches or beats paid SaaS on AI drafting, Gmail import, and data ownership. Its gaps versus paid SaaS are: match scoring, canonical pipeline/kanban UX, interview & calendar layer, analytics depth, capture friction (no extension), and contact-level CRM.
|
||||
|
||||
---
|
||||
|
||||
## 3. Market gap — what would make Jobbjakt significantly better than existing solutions
|
||||
|
||||
> **"The private, self-hosted career hub: everything Teal+Huntr+Jobscan charge $70–90/mo for, powered by your own local AI, with your data never leaving your server."**
|
||||
|
||||
No product today combines: serious tracker UX + inbox auto-tracking + local-LLM tailoring + match scoring + interview hub, self-hosted. Jobbjakt is uniquely ~60% of the way there.
|
||||
|
||||
---
|
||||
|
||||
## 4. Ranked feature ideas (value × effort)
|
||||
|
||||
Effort: S (<1 day) · M (1–3 days) · L (1–2 wk) · XL (>2 wk). Grounded in the Phase 1 codebase map.
|
||||
|
||||
| # | Feature | User impact | Effort | Notes |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **CV↔job match score + keyword gap analysis** (per job: score, missing keywords, section coverage; reuse structured CV JSON + existing Ollama path) | ★★★★★ — the #1 paid feature in the market, free & local here | M–L | Backend has all inputs already; add endpoint + UI panel in job workspace |
|
||||
| 2 | **Canonical pipeline + drag-drop kanban** (status enum/ordering, custom stages per user, drive board/badges from it) | ★★★★★ — core daily UX; free-text status blocks analytics too | M–L | Already on README wish list; needs migration for status normalization |
|
||||
| 3 | **Analytics dashboard v2** (funnel conversion, response rate, time-in-stage, weekly activity, source effectiveness) | ★★★★ — retention feature; needs #2 for clean stages | M | Data all exists in `JobEvent` history |
|
||||
| 4 | **Interview hub** (interview entity: rounds, type, scheduled time, prep notes, outcome; ICS feed/export + reminders) | ★★★★ — biggest functional gap vs SaaS | L | New entity + timeline integration; ICS is cheap, Google Calendar sync later |
|
||||
| 5 | **Bookmarklet / minimal browser capture** (one-click "save to Jobbjakt" using existing `jobimport/preview`) | ★★★★ — kills the biggest friction (manual entry); full extension can wait | S–M | Server parsing already exists; a bookmarklet or share-target PWA is days not weeks |
|
||||
| 6 | **Contacts (people) CRM** (recruiter/hiring-manager entities linked to companies/jobs/correspondence) | ★★★ | M | Natural extension of company recruiter fields |
|
||||
| 7 | **PWA pass** (installable, mobile nav polish, share-target for job URLs) | ★★★ — mobile is where users check status | M | CRA supports PWA manifest; pairs with #5 |
|
||||
| 8 | **Salary/offer tracker** (structured salary min/max/currency, offer comparison view) | ★★ | S–M | Currently a free-text field |
|
||||
| 9 | **Smarter inbox** (extend existing Gmail review with AI status suggestions: "this looks like a rejection → move to Rejected?") | ★★★★ — compounds an existing unique strength | M | Classification via existing Ollama service |
|
||||
| 10 | **Web push / digest notifications** (beyond SMTP) | ★★ | M | Needs service worker (pairs with #7) |
|
||||
|
||||
Deliberately **not** recommended: auto-apply bots (ToS/ethics/quality problems, LazyApply-style tools are poorly reviewed), building a full Chrome-store extension now (high maintenance; bookmarklet first), multi-provider cloud AI (undermines the privacy moat — keep local-first with optional cloud later).
|
||||
|
||||
## 5. Recommended implementation order (input to Phase 3 roadmap)
|
||||
|
||||
1. **Match score + keyword gaps** (#1) — flagship differentiator, builds on freshest code (structured CV).
|
||||
2. **Canonical pipeline + kanban** (#2) — unblocks analytics, fixes daily UX.
|
||||
3. **Analytics v2** (#3) — quick follow-on.
|
||||
4. **Bookmarklet capture** (#5) + **PWA** (#7) — friction killers.
|
||||
5. **Interview hub** (#4) — biggest new surface, schedule after the above land.
|
||||
6. Then #9, #6, #8, #10 by appetite.
|
||||
|
||||
Engineering-health work (CI test whitelist, prod DB backups, god-controller decomposition) is tracked separately in `docs/SYSTEM_OVERVIEW.md` §15–17 and should interleave with feature work in Phase 3.
|
||||
|
||||
---
|
||||
|
||||
Sources: [Prentus tracker roundup](https://prentus.com/blog/we-found-the-5-best-job-tracker-tools-on-the-market) · [ApplyArc comparison](https://applyarc.com/compare/best-job-application-trackers) · [Teal pricing](https://www.tealhq.com/pricing) · [Teal reviews (ResumeHog)](https://resumehog.com/blog/posts/teal-hq-review-april-2026-is-the-job-tracker-worth-your-time.html) · [Teal cons (ResumeJudge)](https://resumejudge.com/blog/tealhq-review/) · [Huntr pricing](https://huntr.co/pricing) · [Huntr cons (ResumeJudge)](https://resumejudge.com/blog/huntr-review/) · [Huntr vs Teal](https://huntr.co/blog/huntr-vs-teal) · [Simplify tracker](https://simplify.jobs/job-application-tracker) · [Jobright review of Teal](https://jobright.ai/blog/teal-review-2026-walkthrough-alternatives-and-faqs/) · [LazyApply](https://lazyapply.com/) · [Auto-apply tools compared](https://blog.fastapply.co/auto-apply-jobs-tools-compared-2026) · [Jobscan](https://www.jobscan.co/) · [Jobscan pricing](https://onlineatschecker.com/blog/jobscan-pricing-2026-free-plan-worth-it) · [JobSync (OSS)](https://github.com/Gsync/jobsync) · [CareerSync (OSS)](https://github.com/Tomiwajin/CareerSync) · [career-ops](https://career-ops.org/) · [Trackr](https://www.trackrjobs.com/) · [G-Track](https://jobtrack-ai.com/gmail-job-tracker) · [Gmail tracker extension](https://chromewebstore.google.com/detail/gmail-job-application-tra/lkpjngmdfncejiomkofogfdoppgifmkh) · [Interview scheduling software guide](https://www.selectsoftwarereviews.com/buyer-guide/interview-scheduling-software) · [SaaSHub Teal vs Huntr](https://www.saashub.com/compare-job-tracker-by-teal-vs-huntr)
|
||||
@@ -0,0 +1,75 @@
|
||||
# ROADMAP.md — Jobbjakt Product & Engineering Roadmap
|
||||
|
||||
> Phase 3 deliverable (2026-07-02). Sources: `docs/SYSTEM_OVERVIEW.md` (Phase 1) and `docs/PRODUCT_RESEARCH.md` (Phase 2).
|
||||
> Scoring: Value/Complexity/Risk on ▲ high / ● medium / ▽ low. Effort: S <1 day · M 1–3 days · L 1–2 wk · XL >2 wk.
|
||||
|
||||
**North star:** the private, self-hosted career hub — the tracker UX of Huntr, the tailoring/scoring of Teal+Jobscan, powered by local AI, with data that never leaves your server.
|
||||
|
||||
---
|
||||
|
||||
## Tier 0 — Quick Wins (do first; days, low risk, compounding payoff)
|
||||
|
||||
| # | Item | Type | Value | Effort | Risk | Rationale |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Q1 | **CI: run the full frontend test suite** (replace the hand-maintained 10-file whitelist with the whole suite; fix/quarantine any flaky test explicitly) | eng | ▲ | S | ▽ | New tests currently silently skipped in CI; already caused a gap once |
|
||||
| Q2 | **Automated production DB backup** (scheduled SQLite `VACUUM INTO`/copy to `exports/` with retention; document restore) | eng | ▲ | S–M | ▽ | Prod currently has *no working automated backup* (backup endpoint is Windows-DPAPI-only, prod is Linux) |
|
||||
| Q3 | **Repo hygiene** (delete dead root `Controller/`; remove `temp_job.json`, `temp_post_job.py`; gitignore `JobTrackerApi/CvArtifacts/`, `bin_build/`, stray artifacts; commit pending WIP fixes on a branch) | eng | ● | S | ▽ | Removes footguns before refactors; working tree currently dirty |
|
||||
| Q4 | **Swagger/OpenAPI** (Swashbuckle or built-in OpenAPI, dev-only exposure) | eng | ● | S | ▽ | README endpoint list already drifts; prerequisite for a generated TS client later |
|
||||
| Q5 | **Structured salary fields** (min/max/currency/period alongside the free-text field, backfill-friendly) | product | ● | S–M | ▽ | Cheap now, prerequisite for offer comparison + analytics later |
|
||||
|
||||
## Tier 1 — High Value (the differentiators; next 2–4 weeks of feature work)
|
||||
|
||||
| # | Item | Value | Effort | Risk | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| H1 | **CV↔Job match score + keyword gap analysis** — per-job score, missing keywords, section coverage; reuse `ProfileCvStructureJson` + existing Ollama path; panel in job workspace | ▲▲ | M–L | ● | The market's #1 paid feature (Jobscan $50/mo), free & local here. Flagship differentiator |
|
||||
| H2 | **Canonical pipeline + drag-drop kanban** — status enum + ordering + per-user custom stages; migration normalizing existing free-text statuses; board becomes drag-drop | ▲▲ | M–L | ● | Fixes daily UX; unblocks H3; the riskiest part is the status migration (needs careful mapping + tests) |
|
||||
| H3 | **Analytics dashboard v2** — funnel conversion, response rate, time-in-stage, weekly activity, source effectiveness (data already in `JobEvent`) | ▲ | M | ▽ | Depends on H2 for clean stages |
|
||||
| H4 | **Gmail AI status suggestions** — extend the existing review queue: classify incoming mail (rejection/interview/offer) via local AI and suggest status moves, human-confirmed | ▲ | M | ● | Compounds an existing unique strength; keep human-in-the-loop |
|
||||
|
||||
## Tier 2 — Medium Value (after Tier 1)
|
||||
|
||||
| # | Item | Value | Effort | Risk |
|
||||
|---|---|---|---|---|
|
||||
| M1 | **Bookmarklet / PWA share-target capture** — one-click save-to-Jobbjakt reusing `jobimport/preview` | ▲ | S–M | ▽ |
|
||||
| M2 | **PWA pass** — manifest, installability, mobile nav polish | ● | M | ▽ |
|
||||
| M3 | **Interview hub** — interview entity (round, type, time, prep notes, outcome), timeline integration, ICS export + reminders | ▲ | L | ● |
|
||||
| M4 | **Contacts (people) CRM** — recruiter/hiring-manager entities linked to companies/jobs/correspondence | ● | M | ▽ |
|
||||
| M5 | **Durable CV processing queue** — DB-backed queue replacing in-memory (jobs survive restart) | ● | M | ● |
|
||||
| M6 | **ProblemDetails + validation consistency** across API | ● | M | ▽ |
|
||||
|
||||
## Tier 3 — Long-Term Improvements (structural; interleave carefully)
|
||||
|
||||
| # | Item | Value | Effort | Risk |
|
||||
|---|---|---|---|---|
|
||||
| L1 | **Decompose god controllers** (`JobApplicationsController` 151 KB, `ProfileCvController` 117 KB, `GmailController` 60 KB) into feature services; extract AI prompt construction behind interfaces. Strictly behavior-preserving, test-first, one slice per PR | ▲ (maintainability) | XL | ▲ |
|
||||
| L2 | **Finish the project-layout migration** — physically move linked `Models/`/`Data/`/controller/service files into real projects, retire glob-include `JobTrackerBackend` | ● | L | ● |
|
||||
| L3 | **Vite migration** (CRA/react-scripts is EOL; 4 GB-heap builds) | ● | L | ● |
|
||||
| L4 | **OpenAPI-generated TypeScript client** replacing hand-written `api.ts` surface | ● | M–L | ● |
|
||||
| L5 | **Staging environment / deploy gate** (compose profile or second host; smoke test before prod) | ▲ (ops) | L | ● |
|
||||
|
||||
## Tier 4 — Future Ideas (not scheduled)
|
||||
|
||||
- Full browser extension (Chrome/Firefox store) with autofill.
|
||||
- Web push notifications + weekly digest.
|
||||
- Company research assistant (local AI summarizing company info).
|
||||
- Offer comparison & salary analytics dashboards.
|
||||
- Job feed matching from saved searches (Finn/NAV polling).
|
||||
- Native mobile wrappers; CalDAV/Google Calendar two-way sync.
|
||||
- Multi-instance/scale-out readiness (distributed cache/queue).
|
||||
|
||||
---
|
||||
|
||||
## Recommended execution sequence (Phase 4+)
|
||||
|
||||
Interleaving product and engineering so debt never blocks features:
|
||||
|
||||
1. **Wave 0 (hygiene):** Q3 → Q1 → Q2 → Q4 → Q5 (each a small conventional commit on a feature branch; Q1/Q2 are the two items with real operational risk today)
|
||||
2. **Wave 1 (flagship):** H1 match scoring (design doc → backend endpoint → UI panel → tests)
|
||||
3. **Wave 2 (core UX):** H2 canonical pipeline/kanban, then H3 analytics
|
||||
4. **Wave 3:** H4 Gmail suggestions, M1 bookmarklet, M2 PWA
|
||||
5. **Wave 4:** M3 interview hub, M4 contacts, M5 durable queue
|
||||
6. **Continuous:** L1 controller decomposition proceeds opportunistically — whenever a wave touches a god-controller area, extract that slice first (M6 rides along); L2–L5 scheduled after Wave 3 checkpoint.
|
||||
|
||||
Phases 5–10 of the mission (bug hunt, security audit, performance, refactoring, testing, docs) run after or between waves as checkpoints; Phase 11 rules apply throughout (feature branches, conventional commits, full test suite before commit, no auto-merge to main).
|
||||
|
||||
**Explicitly deprioritized:** auto-apply automation (quality/ToS problems), cloud AI providers (undermines privacy moat), Chrome-store extension before the bookmarklet proves demand.
|
||||
@@ -0,0 +1,122 @@
|
||||
# SECURITY_REPORT.md — Session Change Review
|
||||
|
||||
> Phase 6 deliverable. Scope: security review of the changes made in this work session
|
||||
> (Wave 0 + roadmap H1–H4), plus confirmation that the tenant-isolation model still holds.
|
||||
> Date: 2026-07-03. Complements the prior standalone assessments in
|
||||
> `docs/security-assessments/` (M013 adversarial, M014 remediation, M015 authorization replay).
|
||||
|
||||
This is **not** a full re-audit of the whole application — those live in `docs/security-assessments/`.
|
||||
It is a focused review of the new/changed surface so nothing shipped this session introduces a regression.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
No new vulnerabilities were introduced. Tenant isolation on the new endpoints is carried by the
|
||||
existing `JobTrackerContext` global query filters and is now covered by regression tests. One latent
|
||||
correctness issue (a routable background-service method) was closed, and leaked runtime secrets were
|
||||
removed from version control (rotation recommended — see §6).
|
||||
|
||||
| Severity | Count | Items |
|
||||
|---|---|---|
|
||||
| Critical | 0 | — |
|
||||
| High | 0 | — |
|
||||
| Medium | 1 (mitigated) | DataProtection keys present in git history (untracked this session; rotation recommended) |
|
||||
| Low / hardening | 3 | see §5 |
|
||||
|
||||
---
|
||||
|
||||
## 2. New/changed attack surface reviewed
|
||||
|
||||
| Change | Surface | Verdict |
|
||||
|---|---|---|
|
||||
| `GET /jobapplications/{id}/match-score` | route int id; reads own CV + job | Safe — tenant-scoped |
|
||||
| `GET /jobapplications/{id}/status-suggestion` | route int id; reads own correspondence | Safe — tenant-scoped |
|
||||
| `GET /jobapplications/pipeline` | none (static metadata) | Safe |
|
||||
| `PATCH .../status`, Create/Update (status normalization) | user string → `JobPipeline.Normalize` | Safe — no injection, values stored parameterized |
|
||||
| Structured salary fields | numeric + short strings, `NormalizeSalary` | Safe — clamps negatives, whitelists period |
|
||||
| Automated DB backup (`VACUUM INTO`) | server-controlled path | Safe — see §4 |
|
||||
| Dev OpenAPI (`/openapi/v1.json`) | schema | Safe — `Development` environment only |
|
||||
| `EmailStatusClassifier` | reads stored correspondence text | Safe — deterministic, no eval/injection |
|
||||
|
||||
---
|
||||
|
||||
## 3. OWASP-oriented checklist for the new code
|
||||
|
||||
- **A01 Broken Access Control** — The two new data endpoints load the job via
|
||||
`_db.JobApplications.FirstOrDefaultAsync(j => j.Id == id)`, which is filtered by the global
|
||||
query filter `CurrentUserId != null && OwnerUserId == CurrentUserId` (deny-on-null, hardened in
|
||||
M013-2). A cross-user id returns `NotFound`, not another tenant's data. The correspondence lookup
|
||||
in `status-suggestion` and the `JobEvent` lookup in analytics are likewise filtered through their
|
||||
parent's owner. **Verified by `JobApplicationsAuthorizationTests` (match-score + status-suggestion).**
|
||||
- **A03 Injection** — All new persistence goes through EF Core parameterized queries. The only raw
|
||||
SQL added is `VACUUM INTO '<path>'` with a fully server-derived path (see §4). No string
|
||||
concatenation of user input into queries.
|
||||
- **A03 ReDoS** — New regexes (`JobCvMatchService.TokenPattern`, the revised `SkillTagger` C#/.NET
|
||||
patterns with fixed-width look-behinds) are linear with no catastrophic backtracking.
|
||||
- **A04 Insecure Design** — Status suggestions and match scoring are deterministic and
|
||||
**human-confirmed** (a status only changes when the user clicks). No automated outbound actions.
|
||||
- **A05 Security Misconfiguration** — OpenAPI is exposed only under `IsDevelopment()`; production
|
||||
deployments (`ASPNETCORE_ENVIRONMENT=Production`) do not serve it.
|
||||
- **A08 Data Integrity** — `JobPipeline.Normalize` canonicalizes status on write but preserves
|
||||
unknown custom values (no silent data loss).
|
||||
- **A09 Logging** — No secrets or PII added to logs by the new code.
|
||||
|
||||
---
|
||||
|
||||
## 4. Database backup — path handling
|
||||
|
||||
`SqliteDatabaseBackupRunner` runs `VACUUM INTO '<target>'`. The target is
|
||||
`<Data:Root>/backups/jobtracker_backup_<UTC-timestamp>.db` — no user input reaches it — and single
|
||||
quotes are escaped defensively. Backups contain the full database (sensitive) and are written to the
|
||||
same data volume as the live DB, i.e. the same trust boundary; they are git-ignored. For defense in
|
||||
depth, operators should ship backups off-host with transport encryption and restrict volume
|
||||
permissions. **Recommendation (low):** document an off-host, encrypted backup rotation in the
|
||||
deployment guide.
|
||||
|
||||
---
|
||||
|
||||
## 5. Low / hardening findings
|
||||
|
||||
1. **match-score input size (low).** `GetMatchScore` does not cap job-description length before
|
||||
tokenizing. Descriptions are bounded in practice (imported/typed), and the algorithm is linear, so
|
||||
this is not a DoS, but a defensive cap (e.g. 50 KB) would be prudent.
|
||||
2. **New read endpoints are not rate-limited (low).** `match-score`/`status-suggestion` are cheap and
|
||||
deterministic (no AI, one indexed query), and auth-gated in production, so abuse potential is low.
|
||||
Consider a general authenticated-read limiter if the API is exposed publicly.
|
||||
3. **status-suggestion is conservative for custom statuses (informational).** A job in a non-canonical
|
||||
custom status (pipeline order = max) never receives a suggestion. This is safe (fails closed) but
|
||||
slightly under-surfaces; acceptable given custom statuses are rare.
|
||||
|
||||
---
|
||||
|
||||
## 6. Secrets hygiene (actioned this session)
|
||||
|
||||
- Committed ASP.NET **DataProtection key XML files** (`keys/`, `JobTrackerApi/keys/`) and daily export
|
||||
JSON were removed from tracking and added to `.gitignore`
|
||||
(commit `security: untrack DataProtection keys and runtime exports…`).
|
||||
- **These key files remain in git history.** DataProtection keys sign auth/session artifacts, so
|
||||
**rotating them on the production host is recommended** (generate fresh keys; the app regenerates the
|
||||
key ring in the persisted `keys/` directory on next start). Until rotated, anyone with history access
|
||||
could read the old key material.
|
||||
- Local `.env` remains git-ignored; `appsettings.Development.json` contains only `CHANGE_ME_*`
|
||||
placeholders. No live secrets are tracked.
|
||||
|
||||
---
|
||||
|
||||
## 7. Confirmed intact from prior assessments
|
||||
|
||||
Spot-checked that the M013–M015 remediations are still in force after this session's changes:
|
||||
|
||||
- Owner query filters still deny on null `CurrentUserId` (`Data/JobTrackerContext.cs`).
|
||||
- Local JWT still requires a concrete subject claim (`LocalAuthIdentity`, `Program.cs`).
|
||||
- Job-import SSRF guard (DNS resolution + private-range rejection, redirects disabled) untouched.
|
||||
- CSRF double-submit middleware and CORS allowlist untouched.
|
||||
|
||||
---
|
||||
|
||||
## 8. Retest
|
||||
|
||||
All backend tests pass (135), including the two new tenant-isolation tests for the new endpoints.
|
||||
No fix in this report required code changes beyond what already landed; the residual **action for the
|
||||
operator is DataProtection key rotation** (§6).
|
||||
@@ -0,0 +1,293 @@
|
||||
# Jobbjakt (Job Tracker) — System Overview
|
||||
|
||||
> Phase 1 deliverable: full-system map produced before any code changes.
|
||||
> Last updated: 2026-07-02. Verified against commit `eea327e1` plus local working-tree changes.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the product is
|
||||
|
||||
Jobbjakt is a self-hosted, multi-user job application tracking platform with heavy AI assistance:
|
||||
|
||||
- Track job applications end-to-end (status pipeline, follow-ups, deadlines, salary, tags, notes).
|
||||
- Company/recruiter CRM (pipeline stage, contact dates, recruiter details).
|
||||
- Correspondence log per application, including **Gmail OAuth import with review workflow**.
|
||||
- Attachments per application with purpose metadata and AI-inclusion toggles.
|
||||
- **CV platform**: upload → OCR/text extraction → structured CV parsing (Ollama-assisted block classification) → per-job tailored CV drafts → templated PDF export via Playwright.
|
||||
- AI drafts: cover letters, recruiter messages, follow-up drafts, job description summaries, translation (LibreTranslate optional).
|
||||
- Rules engine (auto-ghosting, follow-up "needs attention"), reminder emails, daily JSON export, history/event trail, encrypted backup (Windows/DPAPI).
|
||||
- Admin surface: user management, audit log, system readiness page.
|
||||
- Deployed to production at `https://jobs.cesnimda.uk` via Gitea Actions → SSH → Docker Compose.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture overview
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Client
|
||||
UI[React 19 SPA<br/>MUI 7, react-router 6<br/>CRA/react-scripts]
|
||||
end
|
||||
|
||||
subgraph Frontend container
|
||||
NGINX[nginx 1.29-alpine<br/>serves build + proxies /api]
|
||||
end
|
||||
|
||||
subgraph Backend container
|
||||
API[ASP.NET Core net9.0 API<br/>JobTrackerApi host]
|
||||
BG[Hosted services:<br/>Rules, FollowUpReminder,<br/>DailyExport, JobEnrichment,<br/>SummarizerProbe, CvProcessing]
|
||||
DB[(SQLite default<br/>or MariaDB/MySQL)]
|
||||
FS[/Data root:<br/>Attachments, CvArtifacts,<br/>exports, DP keys/]
|
||||
end
|
||||
|
||||
subgraph AI stack
|
||||
AISVC[FastAPI ai-service :8001<br/>distilbart summarizer,<br/>OCR pytesseract/PyMuPDF,<br/>docx/pdf extraction]
|
||||
OLLAMA[Ollama :11434<br/>qwen2.5:7b<br/>CV classification + rewrite]
|
||||
end
|
||||
|
||||
EXT1[Google OAuth / Gmail API]
|
||||
EXT2[Job sites: Finn, NAV,<br/>LinkedIn, Jobbnorge]
|
||||
EXT3[SMTP - Gmail app password]
|
||||
EXT4[LibreTranslate optional]
|
||||
|
||||
UI --> NGINX --> API
|
||||
API --> DB
|
||||
API --> FS
|
||||
API --> AISVC --> OLLAMA
|
||||
API --> EXT1
|
||||
API --> EXT2
|
||||
API --> EXT3
|
||||
API --> EXT4
|
||||
BG --> DB
|
||||
```
|
||||
|
||||
### Solution layout (unusual — read this first)
|
||||
|
||||
| Project | Role |
|
||||
|---|---|
|
||||
| `JobTrackerApi/` | Web **host** only: `Program.cs`, appsettings, migrations, Dockerfile. Its csproj **excludes** `Controllers/**` and `Services/**` from its own compilation. |
|
||||
| `JobTrackerBackend/` | "Transitional shared-backend" **library** that compiles, via `<Compile Include>` links, the files physically located in `../Data`, `../Models`, `../JobTrackerApi/Controllers`, `../JobTrackerApi/Services`. Exists so tests can reference controllers/services without the web-entry project. |
|
||||
| `JobTrackerApi.Tests/` | xUnit test project (~20 test classes incl. authorization/hostile-fixture tests). |
|
||||
| `Models/`, `Data/` (repo root) | The *real* EF models and `JobTrackerContext`, compiled into JobTrackerBackend. |
|
||||
| `Controller/` (repo root) | **Legacy stub controllers (~1 KB each) — dead code**, not referenced by any csproj. |
|
||||
| `job-tracker-ui/` | React SPA. |
|
||||
| `tools/summarizer/` | FastAPI AI service (own Dockerfile, pytest tests). |
|
||||
| `deploy/`, `.gitea/workflows/` | Prod deploy script + CI/CD pipeline. |
|
||||
| `docs/` | Session handoffs, security assessments (M013–M015), UAT notes. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Technology stack
|
||||
|
||||
**Backend**: ASP.NET Core net9.0, EF Core 9 (SQLite default; Pomelo MySQL/MariaDB switchable via `Database:Provider`), ASP.NET Identity Core (users/roles), JWT bearer auth (local + Google policy scheme), built-in RateLimiter, DataProtection (file-system keys), Playwright (CV PDF export).
|
||||
|
||||
**Frontend**: React 19, TypeScript 4.9, MUI 7 (+ x-data-grid, x-date-pickers, lab), axios, react-router-dom 6, @tanstack/react-table, CRA `react-scripts` 5 (build needs `--max-old-space-size=4096`), i18n EN + NB (custom provider), Jest/RTL tests.
|
||||
|
||||
**AI**: FastAPI + transformers (`sshleifer/distilbart-cnn-12-6`) for summaries; pytesseract/PyMuPDF/pypdf/python-docx for extraction/OCR; Ollama (`qwen2.5:7b`) for CV block classification and rewrite paths; TTL cache.
|
||||
|
||||
**Infra**: Docker Compose (4 services: backend, frontend/nginx, ai-service, ollama w/ GPU), Gitea Actions CI (build + backend tests + selected frontend tests + frontend build) → SSH deploy → `deploy/deploy.sh` on the prod host, external `jobtracker_shared` network.
|
||||
|
||||
---
|
||||
|
||||
## 4. Authentication & authorization
|
||||
|
||||
- **Smart policy scheme**: inspects the bearer token issuer — Google-issued ID tokens (`accounts.google.com`) route to the `google` JWT handler (validated against `Auth:GoogleClientId`); everything else routes to `local` JWT (symmetric key `Auth:JwtKey`, issuer/audience validated, 2-min clock skew).
|
||||
- **Cookie session support**: local handler also reads the session cookie (`AuthSessionOptions.SessionCookieName`); **CSRF double-submit** middleware enforces cookie+header match for all mutating requests when a session cookie is present (login/register/reset/csrf endpoints exempt).
|
||||
- `Auth:Require=true` sets a fallback authorize-all policy (prod compose sets it). Dev without a JWT key generates an ephemeral key + warning; **fails closed** if auth required but no key.
|
||||
- Local tokens **must** carry a subject claim (`LocalAuthIdentity`), enforced in `OnTokenValidated` — hardened after finding M013-2.
|
||||
- **Multi-tenancy**: every tenant entity carries `OwnerUserId`; `JobTrackerContext` applies global query filters `CurrentUserId != null && OwnerUserId == CurrentUserId` (deny-on-null). Correspondence/JobEvents/CV entities filter through their parent's owner.
|
||||
- Roles via ASP.NET Identity: admin-only controllers (`UsersController`, `AdminAuditController`, `AdminSystemController`).
|
||||
- Password policy: min 8, digit + lowercase required. Password reset via emailed token (SMTP required). Registration disabled by default.
|
||||
- Rate limiting: `auth-login` (10/5 min/IP) and `auth-email` (5/15 min/IP) fixed-window policies.
|
||||
|
||||
---
|
||||
|
||||
## 5. Database schema (EF Core, 8 migrations)
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
ApplicationUser ||--o{ Company : owns
|
||||
ApplicationUser ||--o{ JobApplication : owns
|
||||
ApplicationUser ||--o| UserRuleSettings : has
|
||||
ApplicationUser ||--o{ GmailConnection : has
|
||||
ApplicationUser ||--o{ CvUploadArtifact : owns
|
||||
ApplicationUser ||--o{ CvExtractionRun : owns
|
||||
Company ||--o{ JobApplication : "has jobs"
|
||||
JobApplication ||--o{ Correspondence : messages
|
||||
JobApplication ||--o{ Attachment : attachments
|
||||
JobApplication ||--o{ JobEvent : events
|
||||
JobApplication ||--o| TailoredCvDraft : "1:1 draft"
|
||||
CvUploadArtifact ||--o{ CvExtractionRun : "source of"
|
||||
ApplicationUser ||--o{ GmailReviewDecision : decides
|
||||
```
|
||||
|
||||
Key notes:
|
||||
|
||||
- `ApplicationUser` (IdentityUser) also stores profile CV text, **structured CV JSON** (`ProfileCvStructureJson`), avatar data-URL, Google link info, current CV artifact/run pointers.
|
||||
- `JobApplication`: status string (default "Applied"), soft delete (`IsDeleted`/`DeletedAt`), tags as JSON string, imported description + translation, persisted `ShortSummary`, tailored CV text, reminder bookkeeping. Cascade deletes to messages/attachments/events/draft.
|
||||
- `RuleSettings` (global, seeded Id=1) + per-user `UserRuleSettings`.
|
||||
- `SystemEmailSettings`: DB-stored SMTP override (resolved by `EmailSettingsResolver`).
|
||||
- Indexes: `OwnerUserId` on Company/JobApplication/GmailConnection; composite `(OwnerUserId, UploadedAtUtc)`, `(OwnerUserId, StartedAtUtc)`, unique `(OwnerUserId, JobApplicationId)` on draft, unique `(OwnerUserId, GmailAddress)`.
|
||||
- SQLite file lives at `DataRoot/jobtracker.db` (WAL mode); migrations applied automatically at startup (`StartupInitializationExtensions`, 62 KB — also seeds admin, creates Identity tables where `dotnet ef` unavailable, ignores `PendingModelChangesWarning`).
|
||||
|
||||
---
|
||||
|
||||
## 6. API surface (all under `/api`, ~15 controllers)
|
||||
|
||||
| Controller | Highlights |
|
||||
|---|---|
|
||||
| `JobApplicationsController` (**151 KB!**) | CRUD, paging/filtering/sorting, board, reminders, stats, history, unified timeline, status/follow-up PATCH, soft delete/restore, **plus** AI surface: application package material, follow-up drafts, cover-letter/recruiter drafts ("Maria" drafts), workflow signals. |
|
||||
| `ProfileCvController` (**117 KB**) | CV upload artifacts, extraction runs, structure parsing, rebuild/improve, tailored CV generation via Ollama rewrite, template rendering + Playwright PDF preview/export, benchmark corpus harness. |
|
||||
| `GmailController` (**60 KB**) | OAuth connect/callback, sync, message review queue, import decisions, job matching. |
|
||||
| `AuthController` (22 KB) | login/register/me/config, Google exchange, password reset request/reset, session cookie + CSRF endpoints. |
|
||||
| `CompaniesController` | CRUD, idempotent create by name, recruiter/pipeline fields. |
|
||||
| `CorrespondenceController` | per-job messages CRUD. |
|
||||
| `AttachmentsController` | multipart upload to disk, download, rename, delete, purpose/AI-inclusion metadata. |
|
||||
| `RulesController` | global + per-user rule settings, clamped. |
|
||||
| `ExportController` | JSON/CSV export. |
|
||||
| `BackupController` | DPAPI-encrypted backup (Windows only). |
|
||||
| `JobImportController` | URL preview via plugin parsers (SSRF-hardened). |
|
||||
| `UsersController`, `AdminAuditController`, `AdminSystemController` | admin: user/role management, audit trail, system readiness (DB/Gmail/AI). |
|
||||
| `ClientErrorsController` | frontend error intake → logs. |
|
||||
|
||||
No OpenAPI/Swagger is wired up; the README is the de-facto API doc (already drifting).
|
||||
|
||||
---
|
||||
|
||||
## 7. Background services (6 hosted services)
|
||||
|
||||
| Service | Function |
|
||||
|---|---|
|
||||
| `RulesHostedService` → `RulesEngine` | periodic auto-transitions (e.g., → Ghosted) from rule settings |
|
||||
| `FollowUpReminderHostedService` | reminder emails for due/upcoming follow-ups (dedup via `LastReminderEmailSentAt`) |
|
||||
| `DailyExportHostedService` | daily JSON export at configured local hour |
|
||||
| `JobEnrichmentHostedService` | backfills summaries/enrichment for jobs |
|
||||
| `SummarizerProbeHostedService` | probes AI service readiness |
|
||||
| `CvProcessingHostedService` + `CvProcessingQueue` | in-memory queue for CV extraction/processing jobs |
|
||||
|
||||
All state is in-process (`IMemoryCache`, in-memory queue) — single-instance assumption; no distributed locks; queue contents lost on restart.
|
||||
|
||||
---
|
||||
|
||||
## 8. AI pipeline (data flow)
|
||||
|
||||
1. **Job import**: URL → plugin parse (Finn/NAV/LinkedIn/Jobbnorge or universal JSON-LD parser) → optional LibreTranslate → language detect + skill tagging → preview → user accepts → stored on `JobApplication`.
|
||||
2. **Summaries**: API → `SummarizerService` (31 KB) → FastAPI `/summarize` (distilbart, TTL-cached, GPU-if-available) → persisted `ShortSummary`.
|
||||
3. **CV ingest**: upload (PDF/DOCX/image ≤ 8 MB) → FastAPI extract/OCR → block classification (Ollama-assisted, `CvAiClassifier`/`CvAiNormalizer`) → `ProfileCvStructureJson` on user.
|
||||
4. **Tailoring**: job description + structured CV sections → Ollama rewrite path (recent commits: clamped lengths, hardened diagnostics) → `TailoredCvDraft` (JSON blocks) → `CvTemplateRenderer` (25 KB, template carousel) → Playwright → PDF.
|
||||
5. **Drafts**: cover letter / recruiter message / follow-up drafts generated per job with attachment-aware context selection.
|
||||
|
||||
Degradation: if AI service or Ollama is down, core tracking still works (probe service + "AI is not a deploy gate" in CI).
|
||||
|
||||
---
|
||||
|
||||
## 9. Email
|
||||
|
||||
- `SmtpEmailSender` with `EmailSettingsResolver`: config from env/appsettings **or** DB-stored `SystemEmailSettings` (admin-editable).
|
||||
- Uses Gmail SMTP + app password in prod. Flows: password reset, follow-up reminders. `App:PublicBaseUrl` builds links.
|
||||
|
||||
---
|
||||
|
||||
## 10. Configuration & secrets
|
||||
|
||||
- `.env` (git-ignored) → docker-compose env → ASP.NET config. `.env.example` documents the shape. Real secrets currently present in local `.env` (JWT key, admin password, SMTP app password, Google client secret).
|
||||
- `appsettings.Development.json` contains only `CHANGE_ME_*` placeholders (good).
|
||||
- Key knobs: `Database:Provider`, `ConnectionStrings:JobTracker`, `Data:Root`, `Cors:Origins`, `Ai:BaseUrl`, `Auth:*`, `Email:*`, `Exports:*`, `Translation:*`, `App:PublicBaseUrl`, `HttpsRedirection:*` (TLS terminated at reverse proxy; HSTS/redirect off in-container).
|
||||
- `ProductionConfigTests.cs` exists to guard prod config shape.
|
||||
|
||||
---
|
||||
|
||||
## 11. Build, CI/CD, deployment
|
||||
|
||||
- **CI** (`.gitea/workflows/ci-deploy.yml`): on PR + push-to-main → build backend (Release), run backend tests, `npm ci`, run an **explicit whitelist of 10 frontend test files** (not the whole suite), build frontend.
|
||||
- **Deploy** (push to main only): SSH to prod host → `git reset --hard <sha>` in `/opt/job-tracker/app` → `deploy/deploy.sh` (docker compose build/up with retry/cache-prune fallbacks) → verify containers; AI service health is non-blocking.
|
||||
- Frontend Dockerfile: node build stage → nginx 1.29-alpine (working-tree bump from 1.27 pending commit); nginx proxies `/api` to backend.
|
||||
- No staging environment; deploys go straight to prod after CI.
|
||||
|
||||
---
|
||||
|
||||
## 12. Testing strategy
|
||||
|
||||
- **Backend**: xUnit integration-style tests via `TestHostFactory`; notable coverage: authorization (`JobApplicationsAuthorizationTests`, `OwnershipGuardTests`, hostile fixture DB project), auth/system, Gmail, CV corpus harness, summarizer, SQLite migration helper, production config.
|
||||
- **Frontend**: ~20 Jest/RTL test files (workspace flows, Gmail review, login, admin, attachments, drafts, trust-loop e2e-ish component tests). CI runs only the whitelisted subset.
|
||||
- **AI service**: pytest (`tools/summarizer/tests/test_app.py`).
|
||||
- No true end-to-end browser tests; no load/perf tests.
|
||||
|
||||
---
|
||||
|
||||
## 13. Logging & error handling
|
||||
|
||||
- Console/debug logging; custom middleware logs every request (method, path, status, ms, traceId, sub claim). Unhandled exceptions logged then rethrown (500).
|
||||
- Client errors POSTed to `/api/client-errors` and logged server-side; React `ErrorBoundary` + route error page in UI.
|
||||
- No structured sink (Seq/OTLP), no log rotation policy in-app (container stdout), no correlation to frontend errorIds beyond log text, no ProblemDetails standardization.
|
||||
|
||||
---
|
||||
|
||||
## 14. Security posture (current)
|
||||
|
||||
Strong points (much already hardened via M013–M015 adversarial assessments in `docs/security-assessments/`):
|
||||
|
||||
- SSRF on job import **fixed & retested** (DNS resolution check, private/loopback/link-local rejection, redirects disabled).
|
||||
- Subjectless-JWT / owner-filter bypass **fixed & retested** (fail-closed identity, deny-on-null query filters).
|
||||
- Cross-user job history leak fixed (`81196374`); authorization replay findings recorded (M015).
|
||||
- CSRF double-submit for cookie sessions; CORS allowlist; rate-limited login/email endpoints; ephemeral JWT key refused when auth required; Identity password hashing (PBKDF2); DataProtection keys persisted outside repo runtime path.
|
||||
|
||||
Open questions / watch areas (to verify in Phase 6):
|
||||
|
||||
- `AllowCredentials()` combined with configurable `Cors:Origins="*"` wildcard mode (SetIsOriginAllowed(true) + credentials) — dangerous if ever enabled.
|
||||
- Attachment upload: file-type/size limits, path handling, content-type on download need re-audit.
|
||||
- Avatar stored as data-URL on user record (size/XSS considerations).
|
||||
- Gmail OAuth token storage encryption at rest; scopes; audit of `GmailController` (60 KB).
|
||||
- Global rate limiting only on 2 auth policies — AI/expensive endpoints unthrottled.
|
||||
- Backup endpoint Windows-only DPAPI — silently unavailable on Linux prod.
|
||||
- Dependency freshness (axios, react-scripts 5/CRA is deprecated upstream; transformers/torch pinning).
|
||||
- Secrets present in local `.env` (expected, git-ignored) — confirm no history leaks.
|
||||
|
||||
---
|
||||
|
||||
## 15. Technical debt report
|
||||
|
||||
1. **God controllers**: `JobApplicationsController` (151 KB), `ProfileCvController` (117 KB), `GmailController` (60 KB), `StartupInitializationExtensions` (62 KB). Massive single files mixing HTTP, business logic, AI prompt construction, and persistence. Highest-leverage refactor target — but high risk, needs test cover first.
|
||||
2. **Transitional project layout**: `JobTrackerBackend` compiles files it doesn't own via glob includes; root `Models/`/`Data/` folders; **dead** root `Controller/` folder; `JobTrackerBackend/bin`+`obj` artifacts and `JobTrackerApi/jobtracker.db` + `bin_build/`, `CvArtifacts/`, `exports/`, `keys/` polluting the repo/working tree. `.gitignore` needs review.
|
||||
3. **CI runs a hand-maintained subset** of frontend tests — new test files silently not run (already bit them once; `profile-page.test.tsx` had to be added manually).
|
||||
4. **CRA/react-scripts 5** is EOL-ish, slow builds (needs 4 GB heap), TS 4.9. Vite migration is the obvious path (medium effort).
|
||||
5. **Naming drift**: `Summarizer*` vs `AiService*`; "Jobbjakt" vs "Job Tracker" branding split; EN/NB translation consistency flagged in handoff doc.
|
||||
6. No OpenAPI; README endpoint list already drifts from code (e.g., Gmail/profile/admin endpoints missing there).
|
||||
7. In-memory queue/cache single-instance coupling undocumented.
|
||||
8. Root-level clutter: `temp_job.json`, `temp_post_job.py`, `todo jobtracker.txt`, `test/`, `tmp/`, `vendor/`, `.venv/`.
|
||||
9. `DaysSince` compares `DateTime.UtcNow` with `.Days` truncation — timezone/UX edge cases; status is a free string, no canonical pipeline enum (README itself lists this as a wanted improvement).
|
||||
10. Windows-only backup path.
|
||||
|
||||
---
|
||||
|
||||
## 16. Areas of concern
|
||||
|
||||
- **Single point of data**: SQLite in a Docker volume; backups are manual/Windows-only; no automated off-host backup.
|
||||
- **Deploy risk**: `git reset --hard` + straight-to-prod with no staging and non-exhaustive CI test coverage.
|
||||
- **AI coupling**: prompt logic buried in controllers makes model/provider changes and testing hard.
|
||||
- **Restart data loss**: queued CV processing jobs are lost on restart (in-memory queue).
|
||||
- **Uncommitted working tree**: 3 modified files (Dockerfile nginx bump, `useViewResource` stale-closure fix, handoff doc) + untracked `scripts/start-ollama-cv.ps1` and a stray `JobTrackerApi/CvArtifacts/` data folder.
|
||||
|
||||
---
|
||||
|
||||
## 17. Opportunities for improvement (input to Phase 2/3)
|
||||
|
||||
Product (initial hypotheses, to be validated by market research):
|
||||
|
||||
- Canonical pipeline model + customizable Kanban stages (already on README wish list).
|
||||
- Interview scheduling/prep hub (calendar integration, prep notes, question banks).
|
||||
- Salary/offer comparison and analytics dashboards (funnel conversion, response rates, time-in-stage).
|
||||
- Browser extension / bookmarklet for one-click job capture (plugins already exist server-side).
|
||||
- Saved searches/views, full-text search, date-range and tag filters.
|
||||
- Notifications beyond email (web push, digest).
|
||||
- Contact-level recruiter CRM (people, not just companies).
|
||||
- Mobile-friendly PWA pass.
|
||||
|
||||
Engineering:
|
||||
|
||||
- Swagger/OpenAPI + generated TS client; ProblemDetails everywhere.
|
||||
- Split god controllers into feature services; move AI prompting behind interfaces.
|
||||
- Run full frontend test suite in CI (`npm test -- --watchAll=false` without whitelist) once flaky tests are addressed; add `dotnet format`/eslint gates.
|
||||
- Vite migration; dependency refresh.
|
||||
- Durable job queue (DB-backed) for CV processing; automated DB backup job.
|
||||
- Repo hygiene: delete dead `Controller/`, ignore build artifacts, remove committed DB files.
|
||||
@@ -79,6 +79,14 @@ Mitigation has been added in deploy script, but if it happens again check:
|
||||
3. Final UX polish pass on profile/job details/attachments
|
||||
4. Dashboard + system polish
|
||||
|
||||
## Useful skills to apply next time
|
||||
- `accessibility`
|
||||
- use for the final UI polish/a11y pass across dialogs, forms, focus states, contrast, keyboard support, and screen-reader naming
|
||||
- `agent-browser`
|
||||
- use for live verification of local or deployed Jobbjakt flows, screenshots, route checks, admin/system checks, and browser-based a11y smoke testing
|
||||
- `code-optimizer`
|
||||
- use for a targeted performance/code-quality audit after the current feature/polish work stabilizes
|
||||
|
||||
## Files most relevant next time
|
||||
- `JobTrackerApi/Controllers/JobApplicationsController.cs`
|
||||
- `JobTrackerApi/Controllers/ProfileCvController.cs`
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# M015 Cross-User Authorization Replay Report
|
||||
|
||||
This report covers the follow-up tenant-boundary work after `M013` and `M014`.
|
||||
|
||||
Related artifacts:
|
||||
|
||||
- `docs/security-assessments/M013-adversarial-security-assessment.md`
|
||||
- `docs/security-assessments/M014-security-remediation-verification.md`
|
||||
- `docs/security-assessments/M015-hostile-fixture-setup.md`
|
||||
- `docs/security-assessments/M015-hostile-fixture-setup.json`
|
||||
- `docs/security-assessments/M015-s02-probe-results.json`
|
||||
|
||||
## Test Setup
|
||||
|
||||
A dedicated hostile-test SQLite database was created from the current EF model because the default development DB was missing core domain tables needed for real authorization probes.
|
||||
|
||||
Fixture runtime:
|
||||
|
||||
- clean SQLite DB under `.tmp/m015-fixture`
|
||||
- API started with `Data__Root=/home/pi/development/JobTracker/.tmp/m015-fixture`
|
||||
- registration temporarily enabled for the fixture runtime
|
||||
- two real local users created through the API:
|
||||
- `alice.m015@example.com`
|
||||
- `bob.m015@example.com`
|
||||
|
||||
Alice-owned fixture resources created through the real API:
|
||||
|
||||
- `company_id = 1`
|
||||
- `job_id = 1`
|
||||
- `correspondence_id = 1`
|
||||
- `attachment_id = 1`
|
||||
|
||||
All mutating requests used the real cookie + CSRF contract.
|
||||
|
||||
## Cross-User Probe Summary
|
||||
|
||||
Bob targeted Alice’s fixture ids with a real authenticated session.
|
||||
|
||||
### Defended in this pass
|
||||
|
||||
The following probes failed closed with `404` when Bob targeted Alice’s resources:
|
||||
|
||||
- `GET /api/attachments/1`
|
||||
- `GET /api/attachments/download/1`
|
||||
- `PATCH /api/attachments/1`
|
||||
- `DELETE /api/attachments/1`
|
||||
- `GET /api/correspondence/1`
|
||||
- `DELETE /api/correspondence/1`
|
||||
- `GET /api/jobapplications/1`
|
||||
- `PUT /api/jobapplications/1`
|
||||
- `PATCH /api/jobapplications/1/followup`
|
||||
- `GET /api/jobapplications/1/timeline`
|
||||
- `GET /api/jobapplications/1/tailored-cv-draft`
|
||||
- `GET /api/jobapplications/1/followup-draft`
|
||||
|
||||
These routes did not expose or mutate Alice-owned data in this hostile fixture pass.
|
||||
|
||||
## Confirmed Finding
|
||||
|
||||
### Cross-user read leak on job history
|
||||
|
||||
- **Category:** Authorization / data exposure
|
||||
- **Endpoint:** `GET /api/jobapplications/{id}/history`
|
||||
- **Risk:** **Medium**
|
||||
|
||||
#### Vulnerability
|
||||
|
||||
Before the fix, Bob could request Alice’s job history by raw job id and receive Alice’s `JobEvent` rows.
|
||||
|
||||
Observed pre-fix response:
|
||||
|
||||
- `GET /api/jobapplications/1/history` as Bob
|
||||
- `200 OK`
|
||||
- payload included Alice-owned event data, including the `Created` event for Alice’s job
|
||||
|
||||
#### Example exploit input
|
||||
|
||||
```http
|
||||
GET /api/jobapplications/1/history
|
||||
Cookie: jobtracker_auth=<bob session cookie>
|
||||
```
|
||||
|
||||
#### Root cause
|
||||
|
||||
Two issues combined:
|
||||
|
||||
1. `GetHistory(...)` queried `JobEvents` directly by `JobApplicationId` without verifying that the parent job belonged to the current user.
|
||||
2. `JobEvent` had no owner-scoped query filter in `Data/JobTrackerContext.cs`.
|
||||
|
||||
#### Fix
|
||||
|
||||
- `GetHistory(...)` now checks whether the requested job exists in the current user’s scoped `JobApplications` query and returns `404` if it does not.
|
||||
- `JobEvent` now has an owner-scoped query filter tied to `JobApplication.OwnerUserId`.
|
||||
- Added focused regression test:
|
||||
- `JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs`
|
||||
|
||||
#### Replay after fix
|
||||
|
||||
Observed post-fix response:
|
||||
|
||||
- `GET /api/jobapplications/1/history` as Bob
|
||||
- `404 Not Found`
|
||||
|
||||
#### Verdict
|
||||
|
||||
**Fixed.**
|
||||
|
||||
## Automated Evidence
|
||||
|
||||
### Focused regression test
|
||||
|
||||
```bash
|
||||
dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter JobApplicationsAuthorizationTests
|
||||
```
|
||||
|
||||
Observed:
|
||||
|
||||
- passed
|
||||
- verifies `GetHistory` returns `NotFound` for another user’s job
|
||||
|
||||
## Final Assessment
|
||||
|
||||
For the prioritized raw-id authorization seams exercised in this milestone:
|
||||
|
||||
- **confirmed and fixed:** `GET /api/jobapplications/{id}/history`
|
||||
- **no finding in this fixture pass:** attachments, correspondence, primary job read/update, follow-up patch, timeline, tailored draft, follow-up draft
|
||||
|
||||
## Remaining Boundary
|
||||
|
||||
This report covers the endpoints actually exercised in the hostile fixture pass. It does **not** claim that every authorization-sensitive route in the application has been exhaustively proven safe; it closes the high-risk raw-id seams prioritized from the earlier assessment with a real two-user runtime and replay evidence.
|
||||
@@ -14,7 +14,7 @@ RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
FROM nginx:1.29.8-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
{
|
||||
"short_name": "JobTrack",
|
||||
"name": "JobTrack — Job Application Tracker",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#0b1224",
|
||||
"background_color": "#0b1224"
|
||||
"short_name": "Jobbjakt",
|
||||
"name": "Jobbjakt — Job Application Tracker",
|
||||
"description": "Track and manage your job applications, tailor CVs, and stay on top of follow-ups.",
|
||||
"id": "/",
|
||||
"scope": "/",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"categories": ["productivity", "business"],
|
||||
"theme_color": "#15803d",
|
||||
"background_color": "#0b1224",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/",
|
||||
"method": "GET",
|
||||
"params": {
|
||||
"url": "add",
|
||||
"text": "addtext"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import ForgotPasswordPage from "./pages/ForgotPasswordPage";
|
||||
import ResetPasswordPage from "./pages/ResetPasswordPage";
|
||||
import RouteErrorPage from "./pages/RouteErrorPage";
|
||||
import { api } from "./api";
|
||||
import { resolveCaptureUrl } from "./captureUrl";
|
||||
import { clearAuthClientState, setAuthUserKey } from "./auth";
|
||||
import AppShell, { NavItem } from "./layout/AppShell";
|
||||
import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs";
|
||||
@@ -109,6 +110,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
const compactHeaderActions = useMediaQuery("(max-width:767.95px)");
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [captureUrl, setCaptureUrl] = useState<string | undefined>(undefined);
|
||||
const [quickOpen, setQuickOpen] = useState(false);
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
const [requireAuth, setRequireAuth] = useState<boolean | null>(null);
|
||||
@@ -124,6 +126,19 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
useEffect(() => {
|
||||
api.get<AuthConfig>("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false));
|
||||
}, []);
|
||||
|
||||
// Quick-capture target: bookmarklet (/?add=<url>) or PWA share (url in `add`, or a link
|
||||
// embedded in shared `addtext`). Opens Add Job pre-filled and strips the params.
|
||||
useEffect(() => {
|
||||
const url = resolveCaptureUrl(location.search);
|
||||
if (!url) return;
|
||||
setCaptureUrl(url);
|
||||
setAddOpen(true);
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("add");
|
||||
params.delete("addtext");
|
||||
navigate({ pathname: location.pathname, search: params.toString() }, { replace: true });
|
||||
}, [location.search, location.pathname, navigate]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api.get<MeResponse>("/auth/me")
|
||||
@@ -288,7 +303,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
</AppShell>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<AddJobModal open={addOpen} onClose={() => setAddOpen(false)} onCreated={() => { setRefreshToken((t) => t + 1); }} />
|
||||
<AddJobModal open={addOpen} initialUrl={captureUrl} onClose={() => { setAddOpen(false); setCaptureUrl(undefined); }} onCreated={() => { setRefreshToken((t) => t + 1); }} />
|
||||
<QuickCommandDialog open={quickOpen} onClose={() => setQuickOpen(false)} onNavigate={(to) => navigate(to)} onOpenAddJob={() => setAddOpen(true)} />
|
||||
</Suspense>
|
||||
</>
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
import axios from "axios";
|
||||
import { clearAuthClientState, getCsrfToken } from "./auth";
|
||||
|
||||
function looksLikeHtml(value: string) {
|
||||
return /<\s*html\b|<\s*body\b|<\s*head\b|<\s*title\b|<\s*!doctype\b/i.test(value);
|
||||
}
|
||||
|
||||
function sanitizeServerMessage(value: string, fallback: string) {
|
||||
const text = value.trim();
|
||||
if (!text) return fallback;
|
||||
if (looksLikeHtml(text)) return fallback;
|
||||
return text.length > 300 ? `${text.slice(0, 297).trimEnd()}...` : text;
|
||||
}
|
||||
|
||||
export function getApiErrorMessage(error: any, fallback = "Request failed.") {
|
||||
const data = error?.response?.data;
|
||||
if (typeof data === "string" && data.trim()) return data.trim();
|
||||
if (typeof data?.message === "string" && data.message.trim()) return data.message.trim();
|
||||
if (typeof data?.detail === "string" && data.detail.trim()) return data.detail.trim();
|
||||
if (typeof data?.title === "string" && data.title.trim()) return data.title.trim();
|
||||
if (typeof data === "string" && data.trim()) return sanitizeServerMessage(data, fallback);
|
||||
if (typeof data?.message === "string" && data.message.trim()) return sanitizeServerMessage(data.message, fallback);
|
||||
if (typeof data?.detail === "string" && data.detail.trim()) return sanitizeServerMessage(data.detail, fallback);
|
||||
if (typeof data?.title === "string" && data.title.trim()) return sanitizeServerMessage(data.title, fallback);
|
||||
if (Array.isArray(data?.errors)) {
|
||||
const first = data.errors.find((value: unknown) => typeof value === "string" && value.trim());
|
||||
if (first) return first;
|
||||
if (first) return sanitizeServerMessage(first, fallback);
|
||||
}
|
||||
if (data?.errors && typeof data.errors === "object") {
|
||||
for (const value of Object.values(data.errors)) {
|
||||
if (Array.isArray(value)) {
|
||||
const first = value.find((item: unknown) => typeof item === "string" && item.trim());
|
||||
if (first) return first;
|
||||
if (first) return sanitizeServerMessage(first, fallback);
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
if (typeof value === "string" && value.trim()) return sanitizeServerMessage(value, fallback);
|
||||
}
|
||||
}
|
||||
if (typeof error?.message === "string" && error.message.trim()) return error.message.trim();
|
||||
if (typeof error?.message === "string" && error.message.trim()) return sanitizeServerMessage(error.message, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { resolveCaptureUrl } from './captureUrl';
|
||||
|
||||
describe('resolveCaptureUrl', () => {
|
||||
test('reads the bookmarklet add param', () => {
|
||||
expect(resolveCaptureUrl('?add=https%3A%2F%2Fexample.com%2Fjob')).toBe('https://example.com/job');
|
||||
});
|
||||
|
||||
test('extracts a url embedded in shared text', () => {
|
||||
expect(resolveCaptureUrl('?addtext=' + encodeURIComponent('Cool role here https://example.com/job/42 apply now')))
|
||||
.toBe('https://example.com/job/42');
|
||||
});
|
||||
|
||||
test('prefers add over addtext', () => {
|
||||
expect(resolveCaptureUrl('?add=https%3A%2F%2Fa.com&addtext=' + encodeURIComponent('https://b.com')))
|
||||
.toBe('https://a.com');
|
||||
});
|
||||
|
||||
test('returns null when there is no url', () => {
|
||||
expect(resolveCaptureUrl('')).toBeNull();
|
||||
expect(resolveCaptureUrl('?addtext=' + encodeURIComponent('just some text, no link'))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
// Resolves the quick-capture URL from query params produced by the bookmarklet (`add`)
|
||||
// or the PWA share-target (a link in `add`, or embedded in shared `addtext`).
|
||||
export function resolveCaptureUrl(search: string): string | null {
|
||||
const params = new URLSearchParams(search);
|
||||
const add = params.get("add");
|
||||
if (add) return add;
|
||||
const addText = params.get("addtext");
|
||||
if (addText) return addText.match(/https?:\/\/\S+/)?.[0] ?? null;
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||
|
||||
@@ -30,12 +30,14 @@ import { Company, JobImportResult } from "../types";
|
||||
import { invalidateCompaniesCache, useCompanies } from "../hooks/useCompanies";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { PIPELINE_STATUSES, statusLabel as pipelineStatusLabel } from "../pipeline";
|
||||
import TagsInput from "./TagsInput";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
initialUrl?: string;
|
||||
}
|
||||
|
||||
type DuplicateCandidate = {
|
||||
@@ -60,7 +62,6 @@ type CreatedJobResponse = {
|
||||
type AttachmentBucketKey = "resume" | "coverLetter" | "portfolio" | "other";
|
||||
type AttachmentBuckets = Record<AttachmentBucketKey, File[]>;
|
||||
|
||||
const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||
const ACCEPTED_DOCUMENT_TYPES = ".pdf,.doc,.docx,.txt,.md,image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
|
||||
const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
|
||||
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX };
|
||||
@@ -97,7 +98,7 @@ function normalizeLanguage(value?: string | null) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { t, language } = useI18n();
|
||||
|
||||
@@ -115,9 +116,13 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
|
||||
const [dateApplied, setDateApplied] = useState(() => getTodayIso());
|
||||
const [jobTitle, setJobTitle] = useState("");
|
||||
const [status, setStatus] = useState<(typeof STATUS_OPTIONS)[number]>("Applied");
|
||||
const [status, setStatus] = useState<(typeof PIPELINE_STATUSES)[number]>("Applied");
|
||||
const [location, setLocation] = useState("");
|
||||
const [salary, setSalary] = useState("");
|
||||
const [salaryMin, setSalaryMin] = useState("");
|
||||
const [salaryMax, setSalaryMax] = useState("");
|
||||
const [salaryCurrency, setSalaryCurrency] = useState("");
|
||||
const [salaryPeriod, setSalaryPeriod] = useState("");
|
||||
const [jobUrl, setJobUrl] = useState("");
|
||||
const [deadline, setDeadline] = useState("");
|
||||
|
||||
@@ -133,6 +138,21 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
setCompanies(cachedCompanies);
|
||||
}, [cachedCompanies]);
|
||||
|
||||
// Quick-capture: when opened with a URL (from the bookmarklet), prefill and auto-import once.
|
||||
const autoImportedUrlRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
autoImportedUrlRef.current = null;
|
||||
return;
|
||||
}
|
||||
const url = initialUrl?.trim();
|
||||
if (!url || autoImportedUrlRef.current === url) return;
|
||||
autoImportedUrlRef.current = url;
|
||||
setJobUrl(url);
|
||||
void importFromUrl(url);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, initialUrl]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCompany(null);
|
||||
setCompanyInput("");
|
||||
@@ -219,16 +239,17 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const importFromUrl = async () => {
|
||||
const importFromUrl = async (urlArg?: string) => {
|
||||
if (importing) return;
|
||||
if (!jobUrl.trim()) {
|
||||
const url = (urlArg ?? jobUrl).trim();
|
||||
if (!url) {
|
||||
toast(t("addJobModalPasteUrlFirst"), "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const res = await api.post<JobImportResult>("/jobimport/preview", { url: jobUrl.trim() });
|
||||
const res = await api.post<JobImportResult>("/jobimport/preview", { url });
|
||||
const r = res.data;
|
||||
if (!r?.success) throw new Error(r?.error || t("addJobModalImportFailed"));
|
||||
|
||||
@@ -291,6 +312,10 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
status,
|
||||
location,
|
||||
salary,
|
||||
salaryMin: salaryMin.trim() ? Number(salaryMin) : null,
|
||||
salaryMax: salaryMax.trim() ? Number(salaryMax) : null,
|
||||
salaryCurrency: salaryCurrency.trim() || null,
|
||||
salaryPeriod: salaryPeriod || null,
|
||||
nextAction: null,
|
||||
followUpAt: null,
|
||||
jobUrl,
|
||||
@@ -342,18 +367,6 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
}));
|
||||
};
|
||||
|
||||
const statusLabel = (value: typeof STATUS_OPTIONS[number]) => {
|
||||
const map = {
|
||||
Applied: t("statusApplied"),
|
||||
Waiting: t("statusWaiting"),
|
||||
Interview: t("statusInterview"),
|
||||
Offer: t("statusOffer"),
|
||||
Rejected: t("statusRejected"),
|
||||
Ghosted: t("statusGhosted"),
|
||||
} as const;
|
||||
return map[value];
|
||||
};
|
||||
|
||||
const filesLabel = (files: File[]) => {
|
||||
if (files.length === 0) return t("addJobModalNoFilesSelected");
|
||||
if (files.length === 1) return files[0].name;
|
||||
@@ -471,9 +484,9 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
/>
|
||||
|
||||
<TextField select label={t("addJobModalStatus")} value={status} onChange={(e) => setStatus(e.target.value as any)} sx={FIELD_SX}>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
{PIPELINE_STATUSES.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{statusLabel(s)}
|
||||
{pipelineStatusLabel(t, s)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
@@ -482,6 +495,15 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
|
||||
|
||||
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
|
||||
<TextField label={t("salaryMinLabel")} type="number" value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} sx={FIELD_SX} />
|
||||
<TextField label={t("salaryMaxLabel")} type="number" value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} sx={FIELD_SX} />
|
||||
<TextField label={t("salaryCurrencyLabel")} value={salaryCurrency} onChange={(e) => setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} />
|
||||
<TextField select SelectProps={{ native: true }} label={t("salaryPeriodLabel")} value={salaryPeriod} onChange={(e) => setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}>
|
||||
<option value=""></option>
|
||||
<option value="year">{t("salaryPeriodYear")}</option>
|
||||
<option value="month">{t("salaryPeriodMonth")}</option>
|
||||
<option value="hour">{t("salaryPeriodHour")}</option>
|
||||
</TextField>
|
||||
<DatePicker
|
||||
label={t("addJobModalDeadline")}
|
||||
value={parsePickerDate(deadline)}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { api } from "../api";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
import { getUserKeyFromToken } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { statusLabel } from "../pipeline";
|
||||
import { buildWorkflowPath, getWorkflowAction } from "../jobWorkflowSignals";
|
||||
import { JobApplication } from "../types";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
@@ -49,6 +50,7 @@ type OverviewAnalytics = {
|
||||
medianDaysToFirstResponse?: number | null;
|
||||
totalResponses: number;
|
||||
totalActive: number;
|
||||
timeInStage?: { stage: string; medianDays: number; count: number }[];
|
||||
};
|
||||
type TagTrendResponse = { months: string[]; series: { tag: string; counts: number[] }[] };
|
||||
|
||||
@@ -453,7 +455,7 @@ export default function DashboardView() {
|
||||
return (
|
||||
<Box key={item.label}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{item.label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.label)}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
@@ -474,6 +476,22 @@ export default function DashboardView() {
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{overview?.timeInStage?.length ? (
|
||||
<Box sx={{ mt: 2.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, mb: 1 }}>{t("dashboardTimeInStageTitle")}</Typography>
|
||||
<Stack spacing={0.75}>
|
||||
{overview.timeInStage.map((item) => (
|
||||
<Box key={item.stage} sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.stage)}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{summaryView.topSource?.label ?? t("dashboardResponseSources")}</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950, mt: 0.5 }}>{summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"}</Typography>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useToast } from "../toast";
|
||||
import { useCompanies } from "../hooks/useCompanies";
|
||||
import TagsInput from "./TagsInput";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { PIPELINE_STATUSES, statusLabel } from "../pipeline";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -32,7 +33,6 @@ interface Props {
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||
const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
|
||||
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX };
|
||||
|
||||
@@ -80,6 +80,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
const [dateApplied, setDateApplied] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [location, setLocation] = useState("");
|
||||
const [salary, setSalary] = useState("");
|
||||
const [salaryMin, setSalaryMin] = useState("");
|
||||
const [salaryMax, setSalaryMax] = useState("");
|
||||
const [salaryCurrency, setSalaryCurrency] = useState("");
|
||||
const [salaryPeriod, setSalaryPeriod] = useState("");
|
||||
const [nextAction, setNextAction] = useState("");
|
||||
const [followUpAt, setFollowUpAt] = useState<string>("");
|
||||
const [jobUrl, setJobUrl] = useState("");
|
||||
@@ -110,6 +114,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
setDateApplied(toDateInputValue(j.dateApplied));
|
||||
setLocation(j.location ?? "");
|
||||
setSalary(j.salary ?? "");
|
||||
setSalaryMin(j.salaryMin != null ? String(j.salaryMin) : "");
|
||||
setSalaryMax(j.salaryMax != null ? String(j.salaryMax) : "");
|
||||
setSalaryCurrency(j.salaryCurrency ?? "");
|
||||
setSalaryPeriod(j.salaryPeriod ?? "");
|
||||
setNextAction((j as any).nextAction ?? "");
|
||||
setFollowUpAt((j as any).followUpAt ? toDateInputValue((j as any).followUpAt) : "");
|
||||
setJobUrl(j.jobUrl ?? "");
|
||||
@@ -144,6 +152,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
responseDate: responseReceived && responseDate ? responseDate : null,
|
||||
location: location.trim() || null,
|
||||
salary: salary.trim() || null,
|
||||
salaryMin: salaryMin.trim() ? Number(salaryMin) : null,
|
||||
salaryMax: salaryMax.trim() ? Number(salaryMax) : null,
|
||||
salaryCurrency: salaryCurrency.trim() || null,
|
||||
salaryPeriod: salaryPeriod || null,
|
||||
nextAction: nextAction.trim() || null,
|
||||
followUpAt: followUpAt || null,
|
||||
hasResume,
|
||||
@@ -195,7 +207,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobStatusUpdate")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2, mt: 1 }}>
|
||||
<TextField select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)} sx={FIELD_SX}>
|
||||
{STATUS_OPTIONS.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||
{PIPELINE_STATUSES.map((s) => <MenuItem key={s} value={s}>{statusLabel(t, s)}</MenuItem>)}
|
||||
</TextField>
|
||||
<DatePicker label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { ...PICKER_TEXT_FIELD_PROPS, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} />
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box>
|
||||
@@ -210,6 +222,15 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
||||
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
|
||||
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
|
||||
<TextField label={t("salaryMinLabel")} type="number" value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} sx={FIELD_SX} />
|
||||
<TextField label={t("salaryMaxLabel")} type="number" value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} sx={FIELD_SX} />
|
||||
<TextField label={t("salaryCurrencyLabel")} value={salaryCurrency} onChange={(e) => setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} />
|
||||
<TextField select SelectProps={{ native: true }} label={t("salaryPeriodLabel")} value={salaryPeriod} onChange={(e) => setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}>
|
||||
<option value=""></option>
|
||||
<option value="year">{t("salaryPeriodYear")}</option>
|
||||
<option value="month">{t("salaryPeriodMonth")}</option>
|
||||
<option value="hour">{t("salaryPeriodHour")}</option>
|
||||
</TextField>
|
||||
<DatePicker label={t("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
|
||||
<TextField label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} sx={FIELD_SX} />
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput value={tags} onChange={setTags} /></Box>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
LinearProgress,
|
||||
MenuItem,
|
||||
Select,
|
||||
Tab,
|
||||
@@ -17,9 +18,11 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { alpha } from "@mui/material/styles";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse, TailoredCvDraft } from "../types";
|
||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, StatusSuggestion, TailoredCvDraft } from "../types";
|
||||
import { statusLabel } from "../pipeline";
|
||||
import { useToast } from "../toast";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft";
|
||||
@@ -130,6 +133,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const { confirmAction } = useDialogActions();
|
||||
const followUpCache = useWorkspaceTabCache<FollowUpDraft | null>();
|
||||
const candidateFitCache = useWorkspaceTabCache<CandidateFit | null>();
|
||||
const matchScoreCache = useWorkspaceTabCache<MatchScore | null>();
|
||||
const focusPlanCache = useWorkspaceTabCache<FocusPlanResponse | null>();
|
||||
const interviewPrepCache = useWorkspaceTabCache<InterviewPrepResponse | null>();
|
||||
const readinessCache = useWorkspaceTabCache<ReadinessResponse | null>();
|
||||
@@ -168,6 +172,10 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const [sendingDraft, setSendingDraft] = useState(false);
|
||||
const [refreshingAi, setRefreshingAi] = useState(false);
|
||||
const [candidateFit, setCandidateFit] = useState<CandidateFit | null>(null);
|
||||
const [matchScore, setMatchScore] = useState<MatchScore | null>(null);
|
||||
const [loadingMatchScore, setLoadingMatchScore] = useState(false);
|
||||
const [statusSuggestion, setStatusSuggestion] = useState<StatusSuggestion | null>(null);
|
||||
const [applyingStatusSuggestion, setApplyingStatusSuggestion] = useState(false);
|
||||
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(null);
|
||||
const [loadingCandidateFit, setLoadingCandidateFit] = useState(false);
|
||||
const [loadingFocusPlan, setLoadingFocusPlan] = useState(false);
|
||||
@@ -200,6 +208,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
if (!open || !jobId) return;
|
||||
setFollowUpDraft(null);
|
||||
setCandidateFit(null);
|
||||
setMatchScore(null);
|
||||
setStatusSuggestion(null);
|
||||
setFocusPlan(null);
|
||||
setInterviewPrep(null);
|
||||
setReadiness(null);
|
||||
@@ -280,6 +290,49 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
}).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
|
||||
}, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]);
|
||||
|
||||
// Match score is deterministic and cheap: load it on the Candidate Fit tab
|
||||
// independently of the slow AI narrative so users see the number instantly.
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 5 || matchScore) return;
|
||||
const cacheKey = `${jobId}:match-score`;
|
||||
const cached = matchScoreCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
setMatchScore(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingMatchScore(true);
|
||||
api.get<MatchScore>(`/jobapplications/${jobId}/match-score`).then((r) => {
|
||||
matchScoreCache.setCached(cacheKey, r.data);
|
||||
setMatchScore(r.data);
|
||||
}).catch(() => setMatchScore(null)).finally(() => setLoadingMatchScore(false));
|
||||
}, [open, jobId, tab, matchScore, matchScoreCache]);
|
||||
|
||||
// Suggest a status move from the latest inbound email when the workspace opens.
|
||||
useEffect(() => {
|
||||
if (!open || !jobId) return;
|
||||
let cancelled = false;
|
||||
api.get<StatusSuggestion>(`/jobapplications/${jobId}/status-suggestion`)
|
||||
.then((r) => { if (!cancelled) setStatusSuggestion(r.data?.hasSuggestion ? r.data : null); })
|
||||
.catch(() => { if (!cancelled) setStatusSuggestion(null); });
|
||||
return () => { cancelled = true; };
|
||||
}, [open, jobId]);
|
||||
|
||||
const applyStatusSuggestion = async () => {
|
||||
if (!jobId || !statusSuggestion?.suggestedStatus) return;
|
||||
setApplyingStatusSuggestion(true);
|
||||
try {
|
||||
await api.patch(`/jobapplications/${jobId}/status`, { status: statusSuggestion.suggestedStatus });
|
||||
setJob((prev) => prev ? { ...prev, status: statusSuggestion.suggestedStatus! } : prev);
|
||||
setStatusSuggestion(null);
|
||||
toast(t("statusSuggestionApplied"), "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, t("statusSuggestionFailed")), "error");
|
||||
} finally {
|
||||
setApplyingStatusSuggestion(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 6 || focusPlan) return;
|
||||
const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`;
|
||||
@@ -598,6 +651,25 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
|
||||
{attachmentPicker}
|
||||
|
||||
{statusSuggestion?.hasSuggestion ? (
|
||||
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "warning.main", backgroundColor: (theme) => alpha(theme.palette.warning.main, 0.08), display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
||||
{t("statusSuggestionTitle", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{t("statusSuggestionReason", { signal: statusSuggestion.signal ?? "", current: statusLabel(t, statusSuggestion.currentStatus ?? "") })}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button size="small" variant="contained" color="warning" disabled={applyingStatusSuggestion} onClick={() => void applyStatusSuggestion()}>
|
||||
{t("statusSuggestionApply", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })}
|
||||
</Button>
|
||||
<Button size="small" variant="text" onClick={() => setStatusSuggestion(null)}>{t("statusSuggestionDismiss")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{tab === 0 && (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
@@ -1058,6 +1130,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
|
||||
{tab === 5 && (
|
||||
<Box>
|
||||
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
|
||||
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
@@ -1136,6 +1209,73 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
);
|
||||
}
|
||||
|
||||
function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading: boolean }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (loading && !score) {
|
||||
return (
|
||||
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<CircularProgress size={18} />
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreLoading")}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!score) return null;
|
||||
|
||||
const color: "success" | "warning" | "error" | "inherit" =
|
||||
!score.hasEnoughSignal ? "inherit" : score.score >= 75 ? "success" : score.score >= 50 ? "warning" : "error";
|
||||
const bandLabel = t(`matchScoreBand_${score.band}` as any) || score.band;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums" }}>{score.hasEnoughSignal ? `${score.score}%` : "—"}</Typography>
|
||||
<Typography variant="overline">{t("matchScoreTitle")}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
|
||||
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
|
||||
</Box>
|
||||
</Box>
|
||||
{score.hasEnoughSignal ? (
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={score.score}
|
||||
color={color === "inherit" ? "primary" : color}
|
||||
sx={{ height: 8, borderRadius: 4, mb: 1.5 }}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{score.matchedKeywords.length ? score.matchedKeywords.map((k) => <Chip key={k} label={k} color="success" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreNoneYet")}</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">{t("matchScoreMissing")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{score.missingKeywords.length ? score.missingKeywords.map((k) => <Chip key={k} label={k} color="warning" variant="outlined" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreAllCovered")}</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
{score.sectionCoverage.length ? (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Typography variant="overline">{t("matchScoreSectionCoverage")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{score.sectionCoverage.map((s) => <Chip key={s.section} size="small" variant="outlined" label={`${s.section}: ${s.matched}/${s.total}`} />)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ import { api } from "../api";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
import { useCompanies } from "../hooks/useCompanies";
|
||||
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
||||
import { formatSalary } from "../salary";
|
||||
import { statusLabel, statusTone } from "../pipeline";
|
||||
import JobDetailsDialog from "./JobDetailsDialog";
|
||||
import EditJobDialog from "./EditJobDialog";
|
||||
import { useToast } from "../toast";
|
||||
@@ -97,10 +99,6 @@ interface Props {
|
||||
mode?: "jobs" | "trash";
|
||||
}
|
||||
|
||||
function normalizeStatus(status: string): string {
|
||||
return status === "Interviewing" ? "Interview" : status;
|
||||
}
|
||||
|
||||
function parseTags(raw?: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
@@ -111,21 +109,6 @@ function parseTags(raw?: string | null): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
function statusTone(status: string): string {
|
||||
switch (normalizeStatus(status)) {
|
||||
case "Offer":
|
||||
return "success";
|
||||
case "Rejected":
|
||||
return "error";
|
||||
case "Waiting":
|
||||
case "Ghosted":
|
||||
return "warning";
|
||||
case "Interview":
|
||||
return "info";
|
||||
default:
|
||||
return "primary";
|
||||
}
|
||||
}
|
||||
|
||||
function generateOverview(job: JobApplication): string {
|
||||
if (job.fullSummary) return job.fullSummary;
|
||||
@@ -546,7 +529,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{columns.status ? <Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} sx={{ fontWeight: 800 }} /> : null}
|
||||
{columns.status ? <Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} sx={{ fontWeight: 800 }} /> : null}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
|
||||
@@ -584,7 +567,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("addJobModalSalary")}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{job.salary ?? "-"}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{formatSalary(job) ?? "-"}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -694,7 +677,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
))}
|
||||
</Box>
|
||||
</TableCell>
|
||||
{columns.status ? <TableCell><Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} /></TableCell> : null}
|
||||
{columns.status ? <TableCell><Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} /></TableCell> : null}
|
||||
{columns.dateApplied ? <TableCell>{appliedDateLabel}</TableCell> : null}
|
||||
{columns.daysSince ? <TableCell>{job.daysSince}</TableCell> : null}
|
||||
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null}
|
||||
@@ -727,7 +710,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ p: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
|
||||
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job.location ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("addJobModalSalary")}</Typography><Typography>{job.salary ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("addJobModalSalary")}</Typography><Typography>{formatSalary(job) ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("settingsColumnJobUrl")}</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableOpenListing")}</a> : "-"}</Typography></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableSkills")}</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{detailTags.length ? detailTags.map((tag) => <Chip key={tag} label={tag} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobTableNoTags")}</Typography>}</Box></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableOverview")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{overview || t("jobTableNoSummaryYet")}</Typography></Box>
|
||||
|
||||
@@ -19,41 +19,22 @@ import ViewStateNotice from "./ViewStateNotice";
|
||||
import { JobApplication } from "../types";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
import { PIPELINE_STATUSES, PipelineStatus, normalizeStatus, statusLabel, statusTone } from "../pipeline";
|
||||
|
||||
const STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||
type Status = (typeof STATUSES)[number];
|
||||
const STATUSES = PIPELINE_STATUSES;
|
||||
type Status = PipelineStatus;
|
||||
|
||||
function normalizeStatus(status: string): Status | "Other" {
|
||||
if (status === "Interviewing") return "Interview";
|
||||
if ((STATUSES as readonly string[]).includes(status)) return status as Status;
|
||||
return "Other";
|
||||
}
|
||||
const TONE_PALETTE: Record<string, (theme: any) => string> = {
|
||||
error: (theme) => theme.palette.error.main,
|
||||
warning: (theme) => theme.palette.warning.main,
|
||||
success: (theme) => theme.palette.success.main,
|
||||
info: (theme) => alpha(theme.palette.primary.main, 0.95),
|
||||
primary: (theme) => theme.palette.primary.main,
|
||||
default: (theme) => theme.palette.primary.main,
|
||||
};
|
||||
|
||||
function toneColor(theme: any, status: Status | "Other"): string {
|
||||
if (status === "Rejected") return theme.palette.error.main;
|
||||
if (status === "Waiting" || status === "Ghosted") return theme.palette.warning.main;
|
||||
if (status === "Offer") return theme.palette.success.main;
|
||||
if (status === "Interview") return alpha(theme.palette.primary.main, 0.95);
|
||||
return theme.palette.primary.main;
|
||||
}
|
||||
|
||||
function statusLabel(t: (key: any, params?: any) => string, status: Status): string {
|
||||
switch (status) {
|
||||
case "Applied":
|
||||
return t("statusApplied");
|
||||
case "Waiting":
|
||||
return t("statusWaiting");
|
||||
case "Interview":
|
||||
return t("statusInterview");
|
||||
case "Offer":
|
||||
return t("statusOffer");
|
||||
case "Rejected":
|
||||
return t("statusRejected");
|
||||
case "Ghosted":
|
||||
return t("statusGhosted");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
return TONE_PALETTE[statusTone(status)](theme);
|
||||
}
|
||||
|
||||
export default function KanbanBoard() {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
import { Box, Paper, TextField, Typography } from "@mui/material";
|
||||
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useToast } from "../toast";
|
||||
|
||||
/** The bookmarklet opens the app at /?add=<current page url>, which triggers quick-capture. */
|
||||
function buildBookmarklet(origin: string): string {
|
||||
// Kept as a single minified expression; opens a small popup so the user's tab is undisturbed.
|
||||
return `javascript:void(window.open('${origin}/?add='+encodeURIComponent(location.href),'jobbjakt','width=520,height=720'))`;
|
||||
}
|
||||
|
||||
export default function QuickCaptureCard() {
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const linkRef = useRef<HTMLAnchorElement>(null);
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const bookmarklet = buildBookmarklet(origin);
|
||||
|
||||
// React refuses to render javascript: hrefs, so set it directly on the DOM node.
|
||||
useEffect(() => {
|
||||
if (linkRef.current) linkRef.current.setAttribute("href", bookmarklet);
|
||||
}, [bookmarklet]);
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsQuickCaptureTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("settingsQuickCaptureSubtitle")}</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", mb: 1.5 }}>
|
||||
<Box
|
||||
component="a"
|
||||
ref={linkRef}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
// Clicking (vs dragging) shouldn't navigate; the value is meant to be dragged to the bar.
|
||||
e.preventDefault();
|
||||
toast(t("settingsQuickCaptureDragHint"), "info");
|
||||
}}
|
||||
sx={{
|
||||
display: "inline-block",
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "primary.main",
|
||||
color: "primary.main",
|
||||
fontWeight: 800,
|
||||
textDecoration: "none",
|
||||
cursor: "grab",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{t("settingsQuickCaptureButton")}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("settingsQuickCaptureDragHint")}</Typography>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label={t("settingsQuickCaptureManual")}
|
||||
value={bookmarklet}
|
||||
fullWidth
|
||||
size="small"
|
||||
InputProps={{ readOnly: true }}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import ImportExportJobs from "./ImportExportJobs";
|
||||
import GoogleAuthCard from "./GoogleAuthCard";
|
||||
import RulesSettingsCard from "./RulesSettingsCard";
|
||||
import BackupCard from "./BackupCard";
|
||||
import QuickCaptureCard from "./QuickCaptureCard";
|
||||
import AuthStatusCard from "./AuthStatusCard";
|
||||
import { ThemeModePref } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -297,6 +298,8 @@ export default function SettingsView({
|
||||
|
||||
<ImportExportJobs />
|
||||
</Paper>
|
||||
|
||||
<QuickCaptureCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
|
||||
@@ -110,6 +110,20 @@ describe('end-to-end trust loop', () => {
|
||||
if (url === '/jobapplications/42') return Promise.resolve({ data: jobRecord } as any);
|
||||
if (url === '/auth/me') return Promise.resolve({ data: { roles: [], profileCvText: 'Master CV text' } } as any);
|
||||
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
|
||||
if (url === '/jobapplications/42/tailored-cv-draft') {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
templateId: 'ats-minimal',
|
||||
headline: 'Backend Developer',
|
||||
summary: ['Tailored for the Acme backend role'],
|
||||
selectedSkills: [],
|
||||
experience: [],
|
||||
education: [],
|
||||
customSections: [],
|
||||
status: 'saved',
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
if (url === '/attachments/42') return Promise.resolve({ data: [{ id: 9, fileName: 'resume.pdf', uploadDate: new Date().toISOString(), fileType: 'application/pdf', fileSize: 1234, purpose: 'resume', useForAi: true }] } as any);
|
||||
if (url === '/correspondence/42') return Promise.resolve({ data: correspondenceMessages } as any);
|
||||
if (url === '/gmail/status') return Promise.resolve({ data: { connected: true, gmailAddress: 'user@example.test', lastSyncedAt: new Date().toISOString() } } as any);
|
||||
@@ -207,7 +221,7 @@ describe('end-to-end trust loop', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /tailored cv/i }));
|
||||
|
||||
expect(await screen.findByDisplayValue('Saved CV')).toBeInTheDocument();
|
||||
expect((await screen.findAllByDisplayValue(/tailored for the acme backend role/i)).length).toBeGreaterThan(0);
|
||||
expect(await screen.findByDisplayValue('Saved cover letter')).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Saved application answer')).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Saved recruiter message')).toBeInTheDocument();
|
||||
|
||||
@@ -65,11 +65,16 @@ export function useViewResource<T>(
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [error, setError] = useState<ViewResourceError | null>(null);
|
||||
const hasLoadedRef = useRef(hasLoaded);
|
||||
const loadRef = useRef(load);
|
||||
|
||||
useEffect(() => {
|
||||
hasLoadedRef.current = hasLoaded;
|
||||
}, [hasLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRef.current = load;
|
||||
}, [load]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
|
||||
@@ -77,7 +82,7 @@ export function useViewResource<T>(
|
||||
setLoading(!alreadyLoaded);
|
||||
setRefreshing(alreadyLoaded);
|
||||
try {
|
||||
const next = await load();
|
||||
const next = await loadRef.current();
|
||||
setData(next);
|
||||
setError(null);
|
||||
setHasLoaded(true);
|
||||
@@ -88,7 +93,7 @@ export function useViewResource<T>(
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [enabled, errorMessage, load]);
|
||||
}, [enabled, errorMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
|
||||
@@ -77,6 +77,13 @@ export const translations = {
|
||||
addJobModalStatus: "Status",
|
||||
addJobModalJobTitle: "Job title",
|
||||
addJobModalSalary: "Salary",
|
||||
salaryMinLabel: "Salary min",
|
||||
salaryMaxLabel: "Salary max",
|
||||
salaryCurrencyLabel: "Currency",
|
||||
salaryPeriodLabel: "Per",
|
||||
salaryPeriodYear: "Year",
|
||||
salaryPeriodMonth: "Month",
|
||||
salaryPeriodHour: "Hour",
|
||||
addJobModalDeadline: "Deadline",
|
||||
addJobModalDescriptionOriginal: "Description (original)",
|
||||
addJobModalTranslatedDescription: "Translated description ({language})",
|
||||
@@ -150,6 +157,11 @@ export const translations = {
|
||||
settingsOpenReminderInbox: "Open reminders",
|
||||
settingsReviewJobs: "Review jobs",
|
||||
settingsNotificationsTitle: "Notification settings",
|
||||
settingsQuickCaptureTitle: "Quick capture bookmarklet",
|
||||
settingsQuickCaptureSubtitle: "Drag this button to your bookmarks bar. On any job posting, click it to open Add Job pre-filled from that page.",
|
||||
settingsQuickCaptureButton: "+ Save to Jobbjakt",
|
||||
settingsQuickCaptureDragHint: "Drag me to your bookmarks bar",
|
||||
settingsQuickCaptureManual: "Or copy the bookmarklet code",
|
||||
settingsNotificationsBody: "Choose which reminders should show up in your workflow. SMTP delivery can be checked from the system page.",
|
||||
settingsNotificationsDelivery: "SMTP delivery and test mail live under Admin → System → Settings.",
|
||||
settingsNotificationsFollowUpReminders: "Email reminders for follow-ups",
|
||||
@@ -328,6 +340,8 @@ export const translations = {
|
||||
dashboardApplicationActivity: "Application activity",
|
||||
dashboardMonthlyApplicationsResponses: "Monthly applications versus responses.",
|
||||
dashboardConversionFunnelTitle: "Conversion funnel",
|
||||
dashboardTimeInStageTitle: "Median time in stage",
|
||||
dashboardTimeInStageValue: "{days}d · {count} active",
|
||||
dashboardResponseSources: "Response sources",
|
||||
dashboardTopCompaniesByActivity: "Top companies by activity",
|
||||
dashboardTopSkills: "Top skills",
|
||||
@@ -772,6 +786,12 @@ export const translations = {
|
||||
jobDetailsTabFocusPlan: "Focus plan",
|
||||
jobDetailsTabInterviewPrep: "Interview prep",
|
||||
jobDetailsTabHistory: "History",
|
||||
statusSuggestionTitle: "This email looks like a move to {status}",
|
||||
statusSuggestionReason: "Matched \"{signal}\" · currently {current}",
|
||||
statusSuggestionApply: "Move to {status}",
|
||||
statusSuggestionDismiss: "Dismiss",
|
||||
statusSuggestionApplied: "Status updated.",
|
||||
statusSuggestionFailed: "Could not update status.",
|
||||
jobDetailsTailoredCvMode: "Generation mode",
|
||||
jobDetailsGenerationDefault: "Balanced",
|
||||
jobDetailsGenerationConcise: "Concise",
|
||||
@@ -860,6 +880,20 @@ export const translations = {
|
||||
jobDetailsFollowUpSent: "Follow-up sent and logged.",
|
||||
jobDetailsFollowUpSendFailed: "Failed to send follow-up.",
|
||||
jobDetailsHowYouMatch: "How you match",
|
||||
matchScoreTitle: "Match score",
|
||||
matchScoreLoading: "Scoring your CV against this role…",
|
||||
matchScoreBand_Strong: "Strong match",
|
||||
matchScoreBand_Partial: "Partial match",
|
||||
matchScoreBand_Low: "Low match",
|
||||
matchScoreBand_Unknown: "Not enough signal",
|
||||
matchScoreKeywordsCovered: "{matched}/{total} keywords",
|
||||
matchScoreNoSignal: "Add more CV detail or a fuller job description to get a reliable score.",
|
||||
matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable.",
|
||||
matchScoreMatched: "Matched keywords",
|
||||
matchScoreMissing: "Missing keywords",
|
||||
matchScoreNoneYet: "No matches found yet.",
|
||||
matchScoreAllCovered: "Every keyword is covered.",
|
||||
matchScoreSectionCoverage: "Where your CV covers this role",
|
||||
jobDetailsStrategySnapshot: "Strategy snapshot",
|
||||
jobDetailsGenerateStrategySnapshot: "Generate strategy snapshot",
|
||||
jobDetailsStrategySnapshotEmpty: "Generate a snapshot to see fit, positioning, and immediate priorities in one place.",
|
||||
@@ -987,6 +1021,13 @@ export const translations = {
|
||||
addJobModalStatus: "Status",
|
||||
addJobModalJobTitle: "Stillingstittel",
|
||||
addJobModalSalary: "Lønn",
|
||||
salaryMinLabel: "Lønn fra",
|
||||
salaryMaxLabel: "Lønn til",
|
||||
salaryCurrencyLabel: "Valuta",
|
||||
salaryPeriodLabel: "Per",
|
||||
salaryPeriodYear: "År",
|
||||
salaryPeriodMonth: "Måned",
|
||||
salaryPeriodHour: "Time",
|
||||
addJobModalDeadline: "Frist",
|
||||
addJobModalDescriptionOriginal: "Beskrivelse (original)",
|
||||
addJobModalTranslatedDescription: "Oversatt beskrivelse ({language})",
|
||||
@@ -1060,6 +1101,11 @@ export const translations = {
|
||||
settingsOpenReminderInbox: "Åpne påminnelser",
|
||||
settingsReviewJobs: "Gå til jobber",
|
||||
settingsNotificationsTitle: "Varslingsinnstillinger",
|
||||
settingsQuickCaptureTitle: "Hurtiglagring (bokmerke)",
|
||||
settingsQuickCaptureButton: "+ Lagre til Jobbjakt",
|
||||
settingsQuickCaptureSubtitle: "Dra denne knappen til bokmerkelinjen. På en stillingsannonse klikker du på den for å åpne Legg til jobb forhåndsutfylt fra siden.",
|
||||
settingsQuickCaptureDragHint: "Dra meg til bokmerkelinjen",
|
||||
settingsQuickCaptureManual: "Eller kopier bokmerkekoden",
|
||||
settingsNotificationsBody: "Velg hvilke påminnelser som skal vises i arbeidsflyten din. SMTP-levering kan kontrolleres fra systemsiden.",
|
||||
settingsNotificationsDelivery: "SMTP-levering og test-epost ligger under Admin → System → Innstillinger.",
|
||||
settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger",
|
||||
@@ -1238,6 +1284,8 @@ export const translations = {
|
||||
dashboardApplicationActivity: "Søknadsaktivitet",
|
||||
dashboardMonthlyApplicationsResponses: "Månedlige søknader versus svar.",
|
||||
dashboardConversionFunnelTitle: "Konverteringstrakt",
|
||||
dashboardTimeInStageTitle: "Median tid i fase",
|
||||
dashboardTimeInStageValue: "{days}d · {count} aktive",
|
||||
dashboardResponseSources: "Svar etter kilde",
|
||||
dashboardTopCompaniesByActivity: "Topp selskaper etter aktivitet",
|
||||
dashboardTopSkills: "Topp ferdigheter",
|
||||
@@ -1682,6 +1730,12 @@ export const translations = {
|
||||
jobDetailsTabFocusPlan: "Fokusplan",
|
||||
jobDetailsTabInterviewPrep: "Intervjuforberedelse",
|
||||
jobDetailsTabHistory: "Historikk",
|
||||
statusSuggestionTitle: "Denne e-posten ser ut som en overgang til {status}",
|
||||
statusSuggestionReason: "Traff \"{signal}\" · nå {current}",
|
||||
statusSuggestionApply: "Flytt til {status}",
|
||||
statusSuggestionDismiss: "Avvis",
|
||||
statusSuggestionApplied: "Status oppdatert.",
|
||||
statusSuggestionFailed: "Kunne ikke oppdatere status.",
|
||||
jobDetailsTailoredCvMode: "Genereringsmodus",
|
||||
jobDetailsGenerationDefault: "Balansert",
|
||||
jobDetailsGenerationConcise: "Kortfattet",
|
||||
@@ -1770,6 +1824,20 @@ export const translations = {
|
||||
jobDetailsFollowUpSent: "Oppfølging sendt og loggført.",
|
||||
jobDetailsFollowUpSendFailed: "Kunne ikke sende oppfølging.",
|
||||
jobDetailsHowYouMatch: "Slik matcher du",
|
||||
matchScoreTitle: "Match-score",
|
||||
matchScoreLoading: "Vurderer CV-en mot denne stillingen…",
|
||||
matchScoreBand_Strong: "Sterk match",
|
||||
matchScoreBand_Partial: "Delvis match",
|
||||
matchScoreBand_Low: "Lav match",
|
||||
matchScoreBand_Unknown: "For lite grunnlag",
|
||||
matchScoreKeywordsCovered: "{matched}/{total} nøkkelord",
|
||||
matchScoreNoSignal: "Legg til mer CV-innhold eller en fyldigere stillingstekst for en pålitelig score.",
|
||||
matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar.",
|
||||
matchScoreMatched: "Treff på nøkkelord",
|
||||
matchScoreMissing: "Manglende nøkkelord",
|
||||
matchScoreNoneYet: "Ingen treff ennå.",
|
||||
matchScoreAllCovered: "Alle nøkkelord er dekket.",
|
||||
matchScoreSectionCoverage: "Hvor CV-en dekker denne rollen",
|
||||
jobDetailsStrategySnapshot: "Strategioversikt",
|
||||
jobDetailsGenerateStrategySnapshot: "Generer strategioversikt",
|
||||
jobDetailsStrategySnapshotEmpty: "Generer en oversikt for å se match, posisjonering og viktigste prioriteringer på ett sted.",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { ConfirmProvider } from './confirm';
|
||||
import { PromptProvider } from './prompt';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import JobDetailsDialog from './components/JobDetailsDialog';
|
||||
import { api } from './api';
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
put: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
patch: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
delete: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
const matchScore = {
|
||||
score: 82,
|
||||
band: 'Strong',
|
||||
matchedCount: 4,
|
||||
totalKeywords: 6,
|
||||
matchedKeywords: ['C#', '.NET', 'SQL', 'Docker'],
|
||||
missingKeywords: ['Kubernetes', 'GraphQL'],
|
||||
sectionCoverage: [
|
||||
{ section: 'Skills', matched: 4, total: 6 },
|
||||
{ section: 'Experience', matched: 3, total: 6 },
|
||||
],
|
||||
hasEnoughSignal: true,
|
||||
};
|
||||
|
||||
function renderDialog() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<JobDetailsDialog open jobId={42} onClose={() => {}} initialTab={5} />
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/jobapplications/42') {
|
||||
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/match-score') {
|
||||
return Promise.resolve({ data: matchScore } as any);
|
||||
}
|
||||
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
|
||||
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
|
||||
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
|
||||
// Candidate-fit AI narrative: leave pending-ish/empty so we only assert on the fast panel.
|
||||
if (url === '/jobapplications/42/candidate-fit') return Promise.resolve({ data: null } as any);
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('match score panel shows the score, matched and missing keywords', async () => {
|
||||
renderDialog();
|
||||
|
||||
expect(await screen.findByText('82%')).toBeInTheDocument();
|
||||
expect(await screen.findByText(/strong match/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText('4/6 keywords')).toBeInTheDocument();
|
||||
|
||||
// Matched keyword chips
|
||||
expect(await screen.findByText('C#')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Docker')).toBeInTheDocument();
|
||||
|
||||
// Missing keyword chips
|
||||
expect(await screen.findByText('Kubernetes')).toBeInTheDocument();
|
||||
expect(await screen.findByText('GraphQL')).toBeInTheDocument();
|
||||
|
||||
// Section coverage
|
||||
expect(await screen.findByText('Skills: 4/6')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('match score panel degrades gracefully when there is not enough signal', async () => {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/jobapplications/42') {
|
||||
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/match-score') {
|
||||
return Promise.resolve({ data: { ...matchScore, score: 0, band: 'Unknown', matchedCount: 0, matchedKeywords: [], missingKeywords: [], sectionCoverage: [], hasEnoughSignal: false } } as any);
|
||||
}
|
||||
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
|
||||
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
|
||||
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
|
||||
if (url === '/jobapplications/42/candidate-fit') return Promise.resolve({ data: null } as any);
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
|
||||
renderDialog();
|
||||
|
||||
expect(await screen.findByText('—')).toBeInTheDocument();
|
||||
expect(await screen.findByText(/not enough signal/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
|
||||
import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
|
||||
|
||||
import { api } from "../api";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||
import CropImageDialog from "../components/CropImageDialog";
|
||||
import { useToast } from "../toast";
|
||||
@@ -28,6 +28,9 @@ import { JobApplication } from "../types";
|
||||
type CvSectionOption = "" | "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
|
||||
type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh" | "monarch" | "fjord";
|
||||
|
||||
type CvBuilderTone = "Concise and direct" | "Executive and polished" | "Technical and detailed" | "Warm and people-focused";
|
||||
type CvBuilderLanguage = "English" | "Norwegian" | "Spanish" | "French" | "German";
|
||||
|
||||
type ExtractionRun = {
|
||||
id: number;
|
||||
trigger: string;
|
||||
@@ -78,6 +81,27 @@ type CvBuilderPreview = {
|
||||
jobApplicationId?: number | null;
|
||||
};
|
||||
|
||||
type PdfCarouselItem = {
|
||||
templateId: CvSectionStyle;
|
||||
title: string;
|
||||
fileName: string;
|
||||
pdfUrl?: string;
|
||||
status: "loading" | "ready" | "error";
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type RewriteRequestPayload = {
|
||||
sectionName: string | null;
|
||||
style: CvSectionStyle;
|
||||
templateId: CvSectionStyle;
|
||||
targetRole: string | null;
|
||||
jobApplicationId: number | null;
|
||||
sourceText: string | null;
|
||||
promptBackground: string | null;
|
||||
tone: string | null;
|
||||
language: string | null;
|
||||
};
|
||||
|
||||
type MeResponse = {
|
||||
provider?: "local" | "google" | "external";
|
||||
id?: string;
|
||||
@@ -224,9 +248,16 @@ export default function ProfilePage() {
|
||||
const [cvSection, setCvSection] = useState<CvSectionOption>("");
|
||||
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("ats-minimal");
|
||||
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
|
||||
const [cvPromptBackground, setCvPromptBackground] = useState("");
|
||||
const [cvTone, setCvTone] = useState<CvBuilderTone>("Concise and direct");
|
||||
const [cvLanguage, setCvLanguage] = useState<CvBuilderLanguage>("English");
|
||||
const [selectedRewriteJobId, setSelectedRewriteJobId] = useState<string>("");
|
||||
const [rewritePreview, setRewritePreview] = useState<CvBuilderPreview | null>(null);
|
||||
const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState<RewriteTemplateOption | null>(null);
|
||||
const [pdfCarousel, setPdfCarousel] = useState<PdfCarouselItem[]>([]);
|
||||
const [activePdfIndex, setActivePdfIndex] = useState(0);
|
||||
const [buildingPdfDeck, setBuildingPdfDeck] = useState(false);
|
||||
const [downloadingPdf, setDownloadingPdf] = useState(false);
|
||||
const [savedJobs, setSavedJobs] = useState<JobApplication[]>([]);
|
||||
const [parsingCvSections, setParsingCvSections] = useState(false);
|
||||
const [reprocessingCv, setReprocessingCv] = useState(false);
|
||||
@@ -236,6 +267,16 @@ export default function ProfilePage() {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pdfCarousel.forEach((item) => {
|
||||
if (item.pdfUrl) {
|
||||
window.URL.revokeObjectURL(item.pdfUrl);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [pdfCarousel]);
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -312,6 +353,103 @@ export default function ProfilePage() {
|
||||
const selectedRewriteTemplate = REWRITE_TEMPLATES.find((option) => option.id === cvSectionStyle) ?? REWRITE_TEMPLATES[0];
|
||||
const selectedRewriteJob = savedJobs.find((job) => String(job.id) === selectedRewriteJobId) ?? null;
|
||||
const rewriteReady = Boolean(rewritePreview?.html && rewritePreview.fullText.trim());
|
||||
const activePdfItem = pdfCarousel[activePdfIndex] ?? null;
|
||||
|
||||
const releasePdfCarousel = useCallback((items: PdfCarouselItem[]) => {
|
||||
items.forEach((item) => {
|
||||
if (item.pdfUrl) {
|
||||
window.URL.revokeObjectURL(item.pdfUrl);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const buildRewritePayload = useCallback((templateId: CvSectionStyle): RewriteRequestPayload => ({
|
||||
sectionName: cvSection || null,
|
||||
style: templateId,
|
||||
templateId,
|
||||
targetRole: cvSectionTargetRole.trim() || null,
|
||||
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
|
||||
sourceText: profileCvText.trim() || null,
|
||||
promptBackground: cvPromptBackground.trim() || null,
|
||||
tone: cvTone,
|
||||
language: cvLanguage,
|
||||
}), [cvLanguage, cvPromptBackground, cvSection, cvSectionTargetRole, cvTone, profileCvText, selectedRewriteJob]);
|
||||
|
||||
const resetPdfCarousel = useCallback(() => {
|
||||
setPdfCarousel((current) => {
|
||||
releasePdfCarousel(current);
|
||||
return [];
|
||||
});
|
||||
setActivePdfIndex(0);
|
||||
}, [releasePdfCarousel]);
|
||||
|
||||
const savePdfToCarousel = useCallback(async (templateId: CvSectionStyle, download = false) => {
|
||||
const template = REWRITE_TEMPLATES.find((option) => option.id === templateId) ?? REWRITE_TEMPLATES[0];
|
||||
const payload = buildRewritePayload(templateId);
|
||||
const response = await api.post("/profile-cv/export-pdf", payload, { responseType: "blob" });
|
||||
const blob = new Blob([response.data], { type: "application/pdf" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const item: PdfCarouselItem = {
|
||||
templateId,
|
||||
title: template.title,
|
||||
fileName: rewritePreview?.suggestedFileName || `${templateId}-cv.pdf`,
|
||||
pdfUrl: url,
|
||||
status: "ready",
|
||||
};
|
||||
|
||||
setPdfCarousel((current) => {
|
||||
const existing = current.find((entry) => entry.templateId === templateId);
|
||||
if (existing?.pdfUrl) {
|
||||
window.URL.revokeObjectURL(existing.pdfUrl);
|
||||
}
|
||||
const next = existing
|
||||
? current.map((entry) => (entry.templateId === templateId ? item : entry))
|
||||
: [...current, item];
|
||||
setActivePdfIndex(next.findIndex((entry) => entry.templateId === templateId));
|
||||
return next;
|
||||
});
|
||||
|
||||
if (download) {
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = item.fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
return item;
|
||||
}, [buildRewritePayload, rewritePreview?.suggestedFileName]);
|
||||
|
||||
const buildPdfCarousel = useCallback(async () => {
|
||||
setBuildingPdfDeck(true);
|
||||
resetPdfCarousel();
|
||||
const orderedTemplates = [selectedRewriteTemplate.id, ...REWRITE_TEMPLATES.map((option) => option.id).filter((id) => id !== selectedRewriteTemplate.id)];
|
||||
const seedItems = orderedTemplates.map((templateId) => ({
|
||||
templateId,
|
||||
title: REWRITE_TEMPLATES.find((option) => option.id === templateId)?.title ?? templateId,
|
||||
fileName: `${templateId}-cv.pdf`,
|
||||
status: "loading" as const,
|
||||
}));
|
||||
setPdfCarousel(seedItems);
|
||||
setActivePdfIndex(0);
|
||||
|
||||
for (const templateId of orderedTemplates) {
|
||||
try {
|
||||
const item = await savePdfToCarousel(templateId, false);
|
||||
setPdfCarousel((current) => current.map((entry) => entry.templateId === templateId ? item : entry));
|
||||
} catch (error: any) {
|
||||
const message = getApiErrorMessage(error, `Failed to generate the ${templateId} PDF preview.`);
|
||||
setPdfCarousel((current) => current.map((entry) => entry.templateId === templateId ? { ...entry, status: "error", error: message } : entry));
|
||||
}
|
||||
}
|
||||
|
||||
setBuildingPdfDeck(false);
|
||||
}, [resetPdfCarousel, savePdfToCarousel, selectedRewriteTemplate.id]);
|
||||
|
||||
useEffect(() => {
|
||||
resetPdfCarousel();
|
||||
}, [rewritePreview?.fullText, rewritePreview?.templateId, rewritePreview?.targetRole, resetPdfCarousel]);
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: 2.5 }}>
|
||||
@@ -811,56 +949,109 @@ export default function ProfilePage() {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, minmax(0, 1fr))" }, gap: 1.5, mb: 2 }}>
|
||||
{REWRITE_TEMPLATES.map((option) => {
|
||||
const selected = option.id === cvSectionStyle;
|
||||
return (
|
||||
<Paper
|
||||
key={option.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setCvSectionStyle(option.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setCvSectionStyle(option.id);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 3.5,
|
||||
cursor: "pointer",
|
||||
border: "1px solid",
|
||||
borderColor: selected ? "primary.main" : "divider",
|
||||
boxShadow: selected ? "0 0 0 1px rgba(25,118,210,0.18), 0 12px 30px rgba(15,23,42,0.08)" : "0 6px 18px rgba(15,23,42,0.04)",
|
||||
background: selected ? `linear-gradient(180deg, ${option.accent}12 0%, rgba(255,255,255,0.96) 100%)` : "background.paper",
|
||||
transition: "transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease",
|
||||
'&:hover': { transform: 'translateY(-2px)' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1, mb: 1 }}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Paper sx={{ p: { xs: 1.5, md: 2 }, borderRadius: 4, border: "1px solid", borderColor: "divider", background: `linear-gradient(180deg, ${selectedRewriteTemplate.accent}14 0%, rgba(255,255,255,0.96) 100%)`, boxShadow: "0 18px 40px rgba(15,23,42,0.08)" }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.15fr 0.85fr" }, gap: 2, alignItems: "stretch" }}>
|
||||
<Box sx={{ p: { xs: 1.25, md: 2 }, borderRadius: 3.5, background: "rgba(255,255,255,0.82)", border: "1px solid", borderColor: "rgba(15,23,42,0.08)" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1.5, mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: option.accent, fontWeight: 900, letterSpacing: '0.14em' }}>{option.eyebrow}</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>{option.title}</Typography>
|
||||
<Typography variant="overline" sx={{ color: selectedRewriteTemplate.accent, fontWeight: 900, letterSpacing: '0.16em' }}>{selectedRewriteTemplate.eyebrow}</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900 }}>{selectedRewriteTemplate.title}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, maxWidth: 560 }}>{selectedRewriteTemplate.blurb}</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={(event) => { event.stopPropagation(); setRewritePreviewTemplate(option); }}>
|
||||
<IconButton size="small" onClick={() => setRewritePreviewTemplate(selectedRewriteTemplate)}>
|
||||
<ZoomInOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box sx={{ p: 1.25, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", minHeight: 160 }}>
|
||||
<Typography variant="caption" sx={{ display: "block", color: option.accent, fontWeight: 800, mb: 0.5 }}>{option.sampleHeading}</Typography>
|
||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mb: 1 }}>{option.sampleMeta}</Typography>
|
||||
{option.sampleBullets.map((bullet) => (
|
||||
<Typography key={bullet} variant="caption" sx={{ display: "block", color: "text.primary", mb: 0.5 }}>• {bullet}</Typography>
|
||||
))}
|
||||
|
||||
<Box sx={{ borderRadius: 3.5, overflow: "hidden", border: "1px solid", borderColor: "rgba(15,23,42,0.1)", background: "white", minHeight: { xs: 280, md: 340 }, boxShadow: "inset 0 1px 0 rgba(255,255,255,0.7)" }}>
|
||||
<Box sx={{ px: { xs: 2, md: 3 }, py: { xs: 2, md: 2.5 }, borderBottom: "1px solid", borderColor: "rgba(15,23,42,0.08)", background: `linear-gradient(135deg, ${selectedRewriteTemplate.accent}14 0%, rgba(255,255,255,0.96) 72%)` }}>
|
||||
<Typography variant="caption" sx={{ display: "block", color: selectedRewriteTemplate.accent, fontWeight: 900, letterSpacing: '0.14em', mb: 0.5 }}>{selectedRewriteTemplate.eyebrow}</Typography>
|
||||
<Typography sx={{ fontSize: { xs: '1.1rem', md: '1.35rem' }, fontWeight: 900, lineHeight: 1.1 }}>{selectedRewriteTemplate.sampleHeading}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>{selectedRewriteTemplate.sampleMeta}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ px: { xs: 2, md: 3 }, py: { xs: 2, md: 2.5 } }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Preview of the generated PDF style</Typography>
|
||||
{selectedRewriteTemplate.sampleBullets.map((bullet) => (
|
||||
<Typography key={bullet} variant="body2" sx={{ display: "block", color: "text.primary", mb: 0.85, lineHeight: 1.55 }}>• {bullet}</Typography>
|
||||
))}
|
||||
<Box sx={{ mt: 2, pt: 1.5, borderTop: "1px dashed", borderColor: "divider", display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(3, minmax(0, 1fr))" }, gap: 1 }}>
|
||||
<Chip size="small" variant="outlined" label="Readable hierarchy" />
|
||||
<Chip size="small" variant="outlined" label="PDF-first spacing" />
|
||||
<Chip size="small" variant="outlined" label="ATS-safe structure" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{option.blurb}</Typography>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>Choose a visual direction before generating</Typography>
|
||||
<Box sx={{ display: "grid", gap: 1.1 }}>
|
||||
{REWRITE_TEMPLATES.map((option) => {
|
||||
const selected = option.id === cvSectionStyle;
|
||||
return (
|
||||
<Paper
|
||||
key={option.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${option.title} template preview`}
|
||||
onClick={() => setCvSectionStyle(option.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setCvSectionStyle(option.id);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
p: 1.15,
|
||||
borderRadius: 3,
|
||||
cursor: "pointer",
|
||||
border: "1px solid",
|
||||
borderColor: selected ? "primary.main" : "divider",
|
||||
background: selected ? `linear-gradient(180deg, ${option.accent}10 0%, rgba(255,255,255,0.98) 100%)` : "rgba(255,255,255,0.84)",
|
||||
boxShadow: selected ? "0 0 0 1px rgba(25,118,210,0.16), 0 10px 24px rgba(15,23,42,0.08)" : "0 6px 16px rgba(15,23,42,0.04)",
|
||||
transition: "transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease",
|
||||
'&:hover': { transform: 'translateY(-1px)' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "92px minmax(0, 1fr)", gap: 1.1, alignItems: "stretch" }}>
|
||||
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "rgba(15,23,42,0.08)", background: `linear-gradient(180deg, ${option.accent}1e 0%, rgba(255,255,255,0.98) 100%)`, p: 1, minHeight: 102, display: "flex", flexDirection: "column", justifyContent: "space-between" }}>
|
||||
<Typography variant="caption" sx={{ color: option.accent, fontWeight: 900, letterSpacing: '0.08em' }}>{option.eyebrow}</Typography>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ display: "block", fontWeight: 800, lineHeight: 1.25 }}>{option.sampleHeading}</Typography>
|
||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.5, lineHeight: 1.25 }}>{option.sampleMeta}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>{option.title}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, lineHeight: 1.4 }}>{option.blurb}</Typography>
|
||||
</Box>
|
||||
{selected ? <Chip size="small" color="primary" label="Selected" /> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5, mb: 1.75 }}>
|
||||
<TextField
|
||||
label="Prompt-based CV brief"
|
||||
value={cvPromptBackground}
|
||||
onChange={(e) => setCvPromptBackground(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={4}
|
||||
helperText="Describe your strengths, preferred emphasis, industry background, or the angle you want the AI to lean into."
|
||||
sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}
|
||||
/>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t("profileCvSectionLabel")}</InputLabel>
|
||||
<Select value={cvSection} label={t("profileCvSectionLabel")} onChange={(e) => setCvSection(e.target.value as CvSectionOption)}>
|
||||
@@ -879,6 +1070,25 @@ export default function ProfilePage() {
|
||||
fullWidth
|
||||
helperText={selectedRewriteJob ? `Using saved job context: ${selectedRewriteJob.jobTitle}` : "Leave empty to let the selected job drive tailoring."}
|
||||
/>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Language</InputLabel>
|
||||
<Select value={cvLanguage} label="Language" onChange={(e) => setCvLanguage(e.target.value as CvBuilderLanguage)}>
|
||||
<MenuItem value="English">English</MenuItem>
|
||||
<MenuItem value="Norwegian">Norwegian</MenuItem>
|
||||
<MenuItem value="Spanish">Spanish</MenuItem>
|
||||
<MenuItem value="French">French</MenuItem>
|
||||
<MenuItem value="German">German</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Tone</InputLabel>
|
||||
<Select value={cvTone} label="Tone" onChange={(e) => setCvTone(e.target.value as CvBuilderTone)}>
|
||||
<MenuItem value="Concise and direct">Concise and direct</MenuItem>
|
||||
<MenuItem value="Executive and polished">Executive and polished</MenuItem>
|
||||
<MenuItem value="Technical and detailed">Technical and detailed</MenuItem>
|
||||
<MenuItem value="Warm and people-focused">Warm and people-focused</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth size="small" sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
|
||||
<InputLabel>Saved job context</InputLabel>
|
||||
<Select value={selectedRewriteJobId} label="Saved job context" onChange={(e) => setSelectedRewriteJobId(String(e.target.value))}>
|
||||
@@ -903,19 +1113,13 @@ export default function ProfilePage() {
|
||||
disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
|
||||
onClick={async () => {
|
||||
setRewritingSection(true);
|
||||
resetPdfCarousel();
|
||||
try {
|
||||
const res = await api.post<CvBuilderPreview>("/profile-cv/rewrite-preview", {
|
||||
sectionName: cvSection || null,
|
||||
style: cvSectionStyle,
|
||||
templateId: cvSectionStyle,
|
||||
targetRole: cvSectionTargetRole.trim() || null,
|
||||
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
|
||||
sourceText: profileCvText.trim() || null,
|
||||
});
|
||||
const res = await api.post<CvBuilderPreview>("/profile-cv/rewrite-preview", buildRewritePayload(cvSectionStyle));
|
||||
setRewritePreview(res.data);
|
||||
toast(t("profileCvSectionRewritten"), "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvSectionRewriteFailed")), "error");
|
||||
toast(getApiErrorMessage(e, t("profileCvSectionRewriteFailed")), "error");
|
||||
} finally {
|
||||
setRewritingSection(false);
|
||||
}
|
||||
@@ -925,33 +1129,27 @@ export default function ProfilePage() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!rewriteReady}
|
||||
disabled={!rewriteReady || downloadingPdf}
|
||||
onClick={async () => {
|
||||
setDownloadingPdf(true);
|
||||
try {
|
||||
const response = await api.post("/profile-cv/export-pdf", {
|
||||
sectionName: cvSection || null,
|
||||
style: cvSectionStyle,
|
||||
templateId: cvSectionStyle,
|
||||
targetRole: cvSectionTargetRole.trim() || null,
|
||||
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
|
||||
sourceText: profileCvText.trim() || null,
|
||||
}, { responseType: "blob" });
|
||||
const blob = new Blob([response.data], { type: "application/pdf" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = rewritePreview?.suggestedFileName || `${cvSectionStyle}-cv.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast("CV PDF downloaded.", "success");
|
||||
await savePdfToCarousel(cvSectionStyle, true);
|
||||
toast("CV PDF downloaded and added to the carousel.", "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || "Failed to export the CV PDF."), "error");
|
||||
toast(getApiErrorMessage(e, "Failed to export the CV PDF."), "error");
|
||||
} finally {
|
||||
setDownloadingPdf(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Download PDF
|
||||
{downloadingPdf ? "Generating PDF…" : "Download PDF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
disabled={!rewriteReady || buildingPdfDeck}
|
||||
onClick={buildPdfCarousel}
|
||||
>
|
||||
{buildingPdfDeck ? "Building PDF carousel…" : "Build PDF carousel"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -987,22 +1185,64 @@ export default function ProfilePage() {
|
||||
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Styled preview</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{selectedRewriteTemplate.title} · print-ready layout</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>PDF carousel</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{activePdfItem?.title ? `${activePdfItem.title} · generated PDF` : `${selectedRewriteTemplate.title} · print-ready layout`}
|
||||
</Typography>
|
||||
</Box>
|
||||
{rewriteReady ? <Chip size="small" variant="outlined" label={rewritePreview?.suggestedFileName || "preview.pdf"} /> : null}
|
||||
{activePdfItem?.fileName ? <Chip size="small" variant="outlined" label={activePdfItem.fileName} /> : rewriteReady ? <Chip size="small" variant="outlined" label={rewritePreview?.suggestedFileName || "preview.pdf"} /> : null}
|
||||
</Box>
|
||||
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", overflow: "hidden", minHeight: 520 }}>
|
||||
{rewriteReady ? (
|
||||
<iframe title="Profile CV preview" srcDoc={rewritePreview?.html} style={{ width: "100%", minHeight: 520, border: 0, background: "white" }} />
|
||||
) : (
|
||||
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", textAlign: "center", maxWidth: 360 }}>
|
||||
The visual preview uses the same server-rendered HTML that the PDF exporter prints. Build a preview to inspect layout, spacing, and hierarchy before you apply it.
|
||||
</Typography>
|
||||
|
||||
{pdfCarousel.length > 0 ? (
|
||||
<>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.25 }}>
|
||||
{pdfCarousel.map((item, index) => (
|
||||
<Button
|
||||
key={item.templateId}
|
||||
size="small"
|
||||
variant={index === activePdfIndex ? "contained" : "outlined"}
|
||||
color={item.status === "error" ? "error" : item.status === "ready" ? "primary" : "inherit"}
|
||||
onClick={() => setActivePdfIndex(index)}
|
||||
>
|
||||
{item.title}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", overflow: "hidden", minHeight: 520 }}>
|
||||
{activePdfItem?.status === "ready" && activePdfItem.pdfUrl ? (
|
||||
<iframe title={`${activePdfItem.title} PDF preview`} src={activePdfItem.pdfUrl} style={{ width: "100%", minHeight: 520, border: 0, background: "white" }} />
|
||||
) : activePdfItem?.status === "error" ? (
|
||||
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
|
||||
<Box sx={{ maxWidth: 420, textAlign: "center" }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900, mb: 1 }}>{activePdfItem.title} PDF unavailable</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{activePdfItem.error || "This template could not be rendered as a PDF right now."}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
|
||||
<Box sx={{ maxWidth: 420, textAlign: "center" }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900, mb: 1 }}>{activePdfItem?.title || "Preparing PDF preview"}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{buildingPdfDeck ? "The carousel is generating PDFs across the current template set." : "Generate the PDF carousel to inspect rendered export files without leaving the page."}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", overflow: "hidden", minHeight: 520 }}>
|
||||
{rewriteReady ? (
|
||||
<iframe title="Profile CV preview" srcDoc={rewritePreview?.html} style={{ width: "100%", minHeight: 520, border: 0, background: "white" }} />
|
||||
) : (
|
||||
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", textAlign: "center", maxWidth: 360 }}>
|
||||
The visual preview uses the same server-rendered HTML that the PDF exporter prints. Build a preview to inspect layout, then generate the PDF carousel to compare rendered files template by template.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { normalizeStatus, statusTone, statusLabel, PIPELINE_STATUSES } from './pipeline';
|
||||
|
||||
describe('pipeline', () => {
|
||||
test('normalizeStatus canonicalizes casing and synonyms', () => {
|
||||
expect(normalizeStatus('applied')).toBe('Applied');
|
||||
expect(normalizeStatus(' OFFER ')).toBe('Offer');
|
||||
expect(normalizeStatus('Interviewing')).toBe('Interview');
|
||||
expect(normalizeStatus('declined')).toBe('Rejected');
|
||||
});
|
||||
|
||||
test('normalizeStatus preserves unknown as Other and empty as Applied', () => {
|
||||
expect(normalizeStatus('Take-home')).toBe('Other');
|
||||
expect(normalizeStatus('')).toBe('Applied');
|
||||
expect(normalizeStatus(null)).toBe('Applied');
|
||||
});
|
||||
|
||||
test('statusTone maps stages to palette keys', () => {
|
||||
expect(statusTone('Offer')).toBe('success');
|
||||
expect(statusTone('Rejected')).toBe('error');
|
||||
expect(statusTone('Waiting')).toBe('warning');
|
||||
expect(statusTone('Ghosted')).toBe('warning');
|
||||
expect(statusTone('Interview')).toBe('info');
|
||||
expect(statusTone('Applied')).toBe('primary');
|
||||
expect(statusTone('Take-home')).toBe('default');
|
||||
});
|
||||
|
||||
test('statusLabel localizes canonical and passes through custom', () => {
|
||||
const t = (key: string) => ({ statusApplied: 'Applied', statusOffer: 'Offer' } as Record<string, string>)[key] ?? key;
|
||||
expect(statusLabel(t, 'Applied')).toBe('Applied');
|
||||
expect(statusLabel(t, 'Interviewing')).toBe('statusInterview'); // maps to canonical key
|
||||
expect(statusLabel(t, 'Take-home assignment')).toBe('Take-home assignment');
|
||||
});
|
||||
|
||||
test('canonical stage list is stable and ordered', () => {
|
||||
expect(PIPELINE_STATUSES).toEqual(['Applied', 'Waiting', 'Interview', 'Offer', 'Rejected', 'Ghosted']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Single frontend source of truth for the canonical job pipeline.
|
||||
// Mirrors the backend JobPipeline (JobTrackerApi/Services/JobPipeline.cs); keep the two in sync.
|
||||
|
||||
export const PIPELINE_STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||
|
||||
export type PipelineStatus = (typeof PIPELINE_STATUSES)[number];
|
||||
|
||||
export type StatusTone = "primary" | "info" | "success" | "warning" | "error" | "default";
|
||||
|
||||
// Legacy/synonym spellings collapse onto a canonical stage (matches the backend alias map).
|
||||
const ALIASES: Record<string, PipelineStatus> = {
|
||||
interviewing: "Interview",
|
||||
interviews: "Interview",
|
||||
interviewed: "Interview",
|
||||
declined: "Rejected",
|
||||
"no response": "Ghosted",
|
||||
"no reply": "Ghosted",
|
||||
pending: "Waiting",
|
||||
"awaiting response": "Waiting",
|
||||
};
|
||||
|
||||
/** Canonical status for a raw value, or "Other" for unknown/custom statuses. */
|
||||
export function normalizeStatus(status?: string | null): PipelineStatus | "Other" {
|
||||
const trimmed = (status ?? "").trim();
|
||||
if (!trimmed) return "Applied";
|
||||
const exact = PIPELINE_STATUSES.find((s) => s.toLowerCase() === trimmed.toLowerCase());
|
||||
if (exact) return exact;
|
||||
const alias = ALIASES[trimmed.toLowerCase()];
|
||||
return alias ?? "Other";
|
||||
}
|
||||
|
||||
/** MUI palette key for a status; both chip color and board accent derive from this. */
|
||||
export function statusTone(status?: string | null): StatusTone {
|
||||
switch (normalizeStatus(status)) {
|
||||
case "Offer":
|
||||
return "success";
|
||||
case "Rejected":
|
||||
return "error";
|
||||
case "Waiting":
|
||||
case "Ghosted":
|
||||
return "warning";
|
||||
case "Interview":
|
||||
return "info";
|
||||
case "Applied":
|
||||
return "primary";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
const LABEL_KEYS: Record<PipelineStatus, string> = {
|
||||
Applied: "statusApplied",
|
||||
Waiting: "statusWaiting",
|
||||
Interview: "statusInterview",
|
||||
Offer: "statusOffer",
|
||||
Rejected: "statusRejected",
|
||||
Ghosted: "statusGhosted",
|
||||
};
|
||||
|
||||
/** Localized label for a status, falling back to the raw value for custom statuses. */
|
||||
export function statusLabel(t: (key: any, params?: any) => string, status: string): string {
|
||||
const normalized = normalizeStatus(status);
|
||||
return normalized === "Other" ? status : t(LABEL_KEYS[normalized]);
|
||||
}
|
||||
@@ -6,6 +6,17 @@ import { I18nProvider } from './i18n/I18nProvider';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
import { api } from './api';
|
||||
|
||||
const createObjectURLMock = jest.fn(() => 'blob:mock-pdf');
|
||||
const revokeObjectURLMock = jest.fn();
|
||||
Object.defineProperty(window.URL, 'createObjectURL', {
|
||||
writable: true,
|
||||
value: createObjectURLMock,
|
||||
});
|
||||
Object.defineProperty(window.URL, 'revokeObjectURL', {
|
||||
writable: true,
|
||||
value: revokeObjectURLMock,
|
||||
});
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
@@ -22,6 +33,8 @@ jest.mock('./components/CropImageDialog', () => () => null);
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
const REWRITE_TEMPLATES_COUNT = 6;
|
||||
|
||||
const structuredCv = {
|
||||
version: '1',
|
||||
metadata: {
|
||||
@@ -131,7 +144,7 @@ beforeEach(() => {
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
mockedApi.post.mockImplementation((url: string) => {
|
||||
mockedApi.post.mockImplementation((url: string, payload?: any, config?: any) => {
|
||||
if (url === '/profile-cv/parse') {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
@@ -149,6 +162,9 @@ beforeEach(() => {
|
||||
if (url === '/profile-cv/rewrite-preview') {
|
||||
return Promise.resolve({ data: { templateId: 'harvard', html: '<html><body>Preview</body></html>', suggestedFileName: 'harvard-preview.pdf', fullText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', rewrittenText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', structuredCv, sectionName: null, jobApplicationId: 42, targetRole: 'Senior Backend Engineer' } } as any);
|
||||
}
|
||||
if (url === '/profile-cv/export-pdf') {
|
||||
return Promise.resolve({ data: new Blob([`pdf-${payload?.templateId ?? 'ats-minimal'}`], { type: 'application/pdf' }), config } as any);
|
||||
}
|
||||
if (url === '/profile-cv/reprocess') {
|
||||
return Promise.resolve({ data: { reprocessed: true } } as any);
|
||||
}
|
||||
@@ -160,6 +176,8 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
createObjectURLMock.mockClear();
|
||||
revokeObjectURLMock.mockClear();
|
||||
});
|
||||
|
||||
test('profile page loads persisted structured cv and can re-parse it', async () => {
|
||||
@@ -230,9 +248,8 @@ test('profile page rewrite tools use selected template and saved job context', a
|
||||
|
||||
expect(await screen.findByText(/template-driven cv builder/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText(/harvard/i));
|
||||
fireEvent.mouseDown(screen.getAllByRole('combobox')[1]);
|
||||
fireEvent.click(await screen.findByText(/senior backend engineer · acme systems/i));
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/prompt-based cv brief/i), { target: { value: 'Highlight backend platform ownership, distributed systems, and cross-team delivery.' } });
|
||||
fireEvent.change(screen.getByLabelText(/target role/i), { target: { value: 'Senior Platform Engineer' } });
|
||||
const rewriteButton = screen.getByRole('button', { name: /build preview/i });
|
||||
fireEvent.click(rewriteButton);
|
||||
|
||||
@@ -241,12 +258,27 @@ test('profile page rewrite tools use selected template and saved job context', a
|
||||
sectionName: null,
|
||||
style: 'harvard',
|
||||
templateId: 'harvard',
|
||||
jobApplicationId: 42,
|
||||
jobApplicationId: null,
|
||||
promptBackground: 'Highlight backend platform ownership, distributed systems, and cross-team delivery.',
|
||||
targetRole: 'Senior Platform Engineer',
|
||||
language: 'English',
|
||||
tone: 'Concise and direct',
|
||||
}));
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/preview ready/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /pdf carousel/i })).toBeInTheDocument();
|
||||
|
||||
const buildCarouselButton = screen.getByRole('button', { name: /build pdf carousel/i });
|
||||
fireEvent.click(buildCarouselButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const exportCalls = mockedApi.post.mock.calls.filter(([url]) => url === '/profile-cv/export-pdf');
|
||||
expect(exportCalls.length).toBe(REWRITE_TEMPLATES_COUNT);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(createObjectURLMock).toHaveBeenCalledTimes(REWRITE_TEMPLATES_COUNT));
|
||||
});
|
||||
|
||||
test('saving profile persists structured cv json', async () => {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { api } from './api';
|
||||
|
||||
// Avoid pulling the date-fns v4 ESM adapter into Jest; the picker isn't under test here.
|
||||
jest.mock('@mui/x-date-pickers/DatePicker', () => ({
|
||||
DatePicker: ({ label }: any) => <div>{label}</div>,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import AddJobModal from './components/AddJobModal';
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
get: jest.fn(() => Promise.resolve({ data: [] })),
|
||||
post: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
put: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
patch: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
delete: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: jest.fn(() => 'error'),
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderModal(initialUrl?: string) {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<AddJobModal open initialUrl={initialUrl} onClose={() => {}} onCreated={() => {}} />
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedApi.get.mockResolvedValue({ data: [] } as any);
|
||||
mockedApi.post.mockImplementation((url: string) => {
|
||||
if (url === '/jobimport/preview') {
|
||||
return Promise.resolve({ data: { success: true, title: 'Imported Backend Role', company: 'Acme', location: 'Oslo', description: 'desc', tags: ['C#'] } } as any);
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
test('auto-imports from initialUrl and prefills the form', async () => {
|
||||
renderModal('https://example.com/jobs/123');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/jobimport/preview', { url: 'https://example.com/jobs/123' });
|
||||
});
|
||||
|
||||
expect(await screen.findByDisplayValue('Imported Backend Role')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not auto-import when no initialUrl is given', async () => {
|
||||
renderModal(undefined);
|
||||
|
||||
// Wait for the modal to render, then confirm no import was triggered.
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalledWith('/jobimport/preview', expect.anything());
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { JobApplication } from "./types";
|
||||
|
||||
type SalaryFields = Pick<JobApplication, "salary" | "salaryMin" | "salaryMax" | "salaryCurrency" | "salaryPeriod">;
|
||||
|
||||
const PERIOD_SUFFIX: Record<string, string> = { year: "yr", month: "mo", hour: "hr" };
|
||||
|
||||
/** Structured salary when present ("60 000–70 000 NOK/yr"), otherwise the free-text field. */
|
||||
export function formatSalary(job: SalaryFields): string | null {
|
||||
const { salaryMin, salaryMax, salaryCurrency, salaryPeriod } = job;
|
||||
if (salaryMin == null && salaryMax == null) {
|
||||
return job.salary?.trim() || null;
|
||||
}
|
||||
|
||||
const fmt = (value: number) => value.toLocaleString();
|
||||
const range = salaryMin != null && salaryMax != null && salaryMin !== salaryMax
|
||||
? `${fmt(salaryMin)}–${fmt(salaryMax)}`
|
||||
: fmt((salaryMin ?? salaryMax) as number);
|
||||
const currency = salaryCurrency ? ` ${salaryCurrency}` : "";
|
||||
const period = salaryPeriod ? `/${PERIOD_SUFFIX[salaryPeriod] ?? salaryPeriod}` : "";
|
||||
return `${range}${currency}${period}`;
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import React from 'react';
|
||||
import { configure } from '@testing-library/react';
|
||||
|
||||
// Heavy MUI views (job table, workspace dialog, profile page) can exceed the
|
||||
// 1s default async query timeout on slower machines; findBy*/waitFor assertions
|
||||
// still resolve as soon as the element appears.
|
||||
configure({ asyncUtilTimeout: 4000 });
|
||||
jest.setTimeout(30000);
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
@@ -10,9 +17,14 @@ jest.mock('./api', () => ({
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: jest.fn((error: any, fallback?: string) => {
|
||||
if (typeof error?.response?.data === 'string' && error.response.data.trim()) return error.response.data;
|
||||
if (typeof error?.message === 'string' && error.message.trim()) return error.message;
|
||||
return fallback || 'Request failed.';
|
||||
const text = typeof error?.response?.data === 'string' && error.response.data.trim()
|
||||
? error.response.data.trim()
|
||||
: typeof error?.message === 'string' && error.message.trim()
|
||||
? error.message.trim()
|
||||
: '';
|
||||
if (!text) return fallback || 'Request failed.';
|
||||
if (/<\s*html\b|<\s*body\b|<\s*head\b|<\s*title\b|<\s*!doctype\b/i.test(text)) return fallback || 'Request failed.';
|
||||
return text.length > 300 ? `${text.slice(0, 297).trimEnd()}...` : text;
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { ConfirmProvider } from './confirm';
|
||||
import { PromptProvider } from './prompt';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import JobDetailsDialog from './components/JobDetailsDialog';
|
||||
import { api } from './api';
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
put: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
patch: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
delete: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: jest.fn(() => 'error'),
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderDialog() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<JobDetailsDialog open jobId={42} onClose={() => {}} />
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/jobapplications/42') {
|
||||
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/status-suggestion') {
|
||||
return Promise.resolve({ data: { hasSuggestion: true, suggestedStatus: 'Interview', currentStatus: 'Applied', signal: 'schedule an interview', confidence: 'medium' } } as any);
|
||||
}
|
||||
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
|
||||
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
|
||||
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('status suggestion banner appears and applies via PATCH', async () => {
|
||||
renderDialog();
|
||||
|
||||
expect(await screen.findByText(/looks like a move to interview/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /move to interview/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/42/status', { status: 'Interview' });
|
||||
});
|
||||
});
|
||||
|
||||
test('no banner when there is no suggestion', async () => {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/jobapplications/42') {
|
||||
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/status-suggestion') {
|
||||
return Promise.resolve({ data: { hasSuggestion: false } } as any);
|
||||
}
|
||||
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
|
||||
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
|
||||
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
|
||||
renderDialog();
|
||||
|
||||
expect(await screen.findByText(/backend developer/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/looks like a move to/i)).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -89,6 +89,10 @@ export interface JobApplication {
|
||||
dateApplied: string;
|
||||
location?: string;
|
||||
salary?: string;
|
||||
salaryMin?: number | null;
|
||||
salaryMax?: number | null;
|
||||
salaryCurrency?: string | null;
|
||||
salaryPeriod?: string | null;
|
||||
nextAction?: string;
|
||||
followUpAt?: string;
|
||||
feedbackRequestedAt?: string;
|
||||
@@ -128,6 +132,33 @@ export interface CandidateFitChannelGuidance {
|
||||
recruiterMessage: string[];
|
||||
}
|
||||
|
||||
export interface MatchScoreSectionCoverage {
|
||||
section: string;
|
||||
matched: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface StatusSuggestion {
|
||||
hasSuggestion: boolean;
|
||||
suggestedStatus?: string | null;
|
||||
currentStatus?: string | null;
|
||||
signal?: string | null;
|
||||
confidence?: string | null;
|
||||
messageDate?: string | null;
|
||||
messageSubject?: string | null;
|
||||
}
|
||||
|
||||
export interface MatchScore {
|
||||
score: number;
|
||||
band: string;
|
||||
matchedCount: number;
|
||||
totalKeywords: number;
|
||||
matchedKeywords: string[];
|
||||
missingKeywords: string[];
|
||||
sectionCoverage: MatchScoreSectionCoverage[];
|
||||
hasEnoughSignal: boolean;
|
||||
}
|
||||
|
||||
export interface CandidateFit {
|
||||
matchSummary: string;
|
||||
fitLevel: string;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<key id="b3ca4672-1056-4ac2-ba47-0432608a4115" version="1">
|
||||
<creationDate>2026-03-27T07:52:25.0540436Z</creationDate>
|
||||
<activationDate>2026-03-27T07:52:25.0540436Z</activationDate>
|
||||
<expirationDate>2026-06-25T07:52:25.0540436Z</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>mfglwuKFrMSiWcbTVDEbPYM0eGAqlsOMHe89hNOsZUguUMMiusdx3m3ZQJvxnBCxeXte6OS+zvpZl3tIizvgHg==</value>
|
||||
</masterKey>
|
||||
</descriptor>
|
||||
</descriptor>
|
||||
</key>
|
||||
@@ -0,0 +1,100 @@
|
||||
# PowerShell equivalent of start-ollama-cv.sh
|
||||
# Starts Ollama service, pulls model if needed, waits, then restarts AI service
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Change to the parent directory of scripts
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location (Join-Path $scriptDir '..')
|
||||
|
||||
$MODEL = if ($env:OLLAMA_MODEL) { $env:OLLAMA_MODEL } else { 'qwen2.5:7b' }
|
||||
$OLLAMA_WAIT_SECONDS = if ($env:OLLAMA_WAIT_SECONDS) { [int]$env:OLLAMA_WAIT_SECONDS } else { 180 }
|
||||
$PULL_WAIT_SECONDS = if ($env:OLLAMA_PULL_WAIT_SECONDS) { [int]$env:OLLAMA_PULL_WAIT_SECONDS } else { 1800 }
|
||||
|
||||
function compose {
|
||||
docker compose @args
|
||||
}
|
||||
|
||||
function wait_for_ollama {
|
||||
$deadline = (Get-Date).AddSeconds($OLLAMA_WAIT_SECONDS)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
try {
|
||||
compose exec -T ollama ollama list | Out-Null
|
||||
return $true
|
||||
} catch {
|
||||
# Ignore errors, just wait
|
||||
}
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function model_present {
|
||||
try {
|
||||
$models = compose exec -T ollama ollama list 2>$null | Select-Object -Skip 1 | ForEach-Object { $_.Split()[0] }
|
||||
return $models -contains $MODEL
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function wait_for_model {
|
||||
$deadline = (Get-Date).AddSeconds($PULL_WAIT_SECONDS)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (model_present) {
|
||||
return $true
|
||||
}
|
||||
Start-Sleep -Seconds 5
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Host "Starting Ollama service..."
|
||||
compose up -d ollama
|
||||
|
||||
if (-not (wait_for_ollama)) {
|
||||
Write-Host "Ollama did not become ready within ${OLLAMA_WAIT_SECONDS}s."
|
||||
try { compose logs --tail=200 ollama } catch { }
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Ollama is responding."
|
||||
|
||||
if (model_present) {
|
||||
Write-Host "Model already present: $MODEL"
|
||||
} else {
|
||||
Write-Host "Pulling Ollama model: $MODEL"
|
||||
try {
|
||||
compose exec -T ollama ollama pull $MODEL
|
||||
} catch {
|
||||
Write-Host "Model pull command failed."
|
||||
try { compose logs --tail=200 ollama } catch { }
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (wait_for_model)) {
|
||||
Write-Host "Model ${MODEL} did not appear within ${PULL_WAIT_SECONDS}s."
|
||||
try { compose exec -T ollama ollama list } catch { }
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Ollama model ready: $MODEL"
|
||||
|
||||
Write-Host "Restarting AI service so it can use the ready Ollama model."
|
||||
compose up -d ai-service
|
||||
|
||||
try {
|
||||
$state = compose ps ai-service --format '{{.State}}' 2>$null | Select-Object -First 1 | ForEach-Object { $_.ToLower().Trim() }
|
||||
if ($state -ne 'running') {
|
||||
Write-Host "AI service is not running after Ollama warmup."
|
||||
try { compose logs --tail=200 ai-service } catch { }
|
||||
exit 1
|
||||
}
|
||||
} catch {
|
||||
Write-Host "Failed to check AI service status."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Ollama warmup complete."
|
||||
@@ -86,6 +86,13 @@ class SummarizeRequest(BaseModel):
|
||||
top_skills: int = Field(default=8, ge=3, le=12)
|
||||
|
||||
|
||||
class RewriteRequest(BaseModel):
|
||||
instruction: str = Field(min_length=1, max_length=6000)
|
||||
text: str = Field(min_length=1, max_length=MAX_INPUT_CHARS)
|
||||
max_length: int = Field(default=220, ge=24, le=256)
|
||||
min_length: int = Field(default=80, ge=8, le=180)
|
||||
|
||||
|
||||
class CvNormalizeRequest(BaseModel):
|
||||
text: str = Field(min_length=1, max_length=50000)
|
||||
|
||||
@@ -424,6 +431,39 @@ def _ollama_generate_json(prompt: str):
|
||||
raise HTTPException(status_code=502, detail="Ollama did not return valid JSON.")
|
||||
|
||||
|
||||
def _ollama_generate_text(prompt: str) -> str:
|
||||
if not OLLAMA_MODEL:
|
||||
raise HTTPException(status_code=503, detail="OLLAMA_MODEL is not configured.")
|
||||
|
||||
payload = json.dumps({
|
||||
"model": OLLAMA_MODEL,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.2}
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib_request.Request(
|
||||
f"{OLLAMA_BASE_URL}/api/generate",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib_request.urlopen(req, timeout=180) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as ex:
|
||||
raise HTTPException(status_code=502, detail=f"Ollama request failed with {ex.code}.")
|
||||
except URLError as ex:
|
||||
raise HTTPException(status_code=503, detail=f"Ollama is unreachable: {ex.reason}.")
|
||||
|
||||
raw = (body.get("response") or "").strip()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=502, detail="Ollama returned an empty rewrite.")
|
||||
|
||||
return raw
|
||||
|
||||
|
||||
@app.post("/cv/normalize")
|
||||
async def normalize_cv(req: CvNormalizeRequest):
|
||||
prompt = f"""
|
||||
@@ -536,6 +576,49 @@ Block:
|
||||
}
|
||||
|
||||
|
||||
@app.post("/cv/rewrite")
|
||||
async def rewrite_cv(req: RewriteRequest):
|
||||
prompt = f"""
|
||||
You are an expert CV and resume writer.
|
||||
Rewrite the candidate CV into a polished, factual CV tailored to the target role.
|
||||
Return ONLY the final CV text. No analysis. No commentary. No JSON. No markdown code fences. No recruiter notes.
|
||||
|
||||
Non-negotiable rules:
|
||||
- Preserve facts only. Never invent employers, dates, locations, salaries, education, qualifications, technologies, metrics, or achievements.
|
||||
- Never output sections like 'Role summary', 'What the company wants most', 'Keywords to mirror', 'Interview focus', 'Top hard skills', or similar analysis headings.
|
||||
- Do not describe the job ad. Rewrite the candidate CV.
|
||||
- Use crisp CV language, not prose about what the company wants.
|
||||
- Keep the output directly usable as a CV.
|
||||
- If rewriting the whole CV, output a complete CV with sensible headings and bullets.
|
||||
- If rewriting only one section, return only that rewritten section.
|
||||
- Keep bullets concrete and concise.
|
||||
- If a fact is not present in the source CV, omit it.
|
||||
|
||||
Preferred whole-CV structure when the source supports it:
|
||||
# Contact
|
||||
# Professional Summary
|
||||
# Work Experience
|
||||
# Education
|
||||
# Skills
|
||||
# Certifications
|
||||
# Projects
|
||||
# Languages
|
||||
# Interests
|
||||
|
||||
Instruction:
|
||||
{req.instruction.strip()}
|
||||
|
||||
Candidate source CV:
|
||||
{req.text.strip()}
|
||||
""".strip()
|
||||
|
||||
rewritten = _ollama_generate_text(prompt).strip()
|
||||
if not rewritten:
|
||||
raise HTTPException(status_code=502, detail="Ollama returned an empty rewrite.")
|
||||
|
||||
return {"rewritten_text": rewritten}
|
||||
|
||||
|
||||
@app.post("/summarize")
|
||||
async def summarize(req: SummarizeRequest):
|
||||
if req.min_length >= req.max_length:
|
||||
|
||||
@@ -76,6 +76,24 @@ def test_health_reports_ollama_unreachable_when_configured_but_not_available(mon
|
||||
assert payload["ollama_model_available"] is False
|
||||
|
||||
|
||||
def test_rewrite_cv_returns_plain_rewritten_text(monkeypatch):
|
||||
module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b")
|
||||
monkeypatch.setattr(module, "_ollama_generate_text", lambda prompt: "# Professional Summary\nBuilt resilient backend systems.\n\n# Skills\n- C#\n- .NET")
|
||||
client = TestClient(module.app)
|
||||
|
||||
response = client.post("/cv/rewrite", json={
|
||||
"instruction": "Rewrite this CV into a cleaner master CV.",
|
||||
"text": "Professional Summary\nBuilt backend systems.",
|
||||
"max_length": 220,
|
||||
"min_length": 80,
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["rewritten_text"].startswith("# Professional Summary")
|
||||
assert "Role summary:" not in payload["rewritten_text"]
|
||||
|
||||
|
||||
def test_classify_block_returns_structured_json(monkeypatch):
|
||||
module = load_app_module(monkeypatch)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user