Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 717d1b9963 | |||
| 3e09e74fc8 | |||
| 4cfdc95b59 | |||
| ea6c3650f3 | |||
| bd07876a41 | |||
| 0cd1ba398e | |||
| d5d82cb528 | |||
| 9615ee3f41 | |||
| 58868fc2b6 | |||
| 7dadf8dde4 | |||
| b2e176940c |
@@ -71,6 +71,11 @@ namespace JobTrackerApi.Data
|
||||
modelBuilder.Entity<JobApplication>()
|
||||
.HasIndex(j => new { j.OwnerUserId, j.FollowUpAt });
|
||||
|
||||
// Board/list endpoints that filter by both IsDeleted and Status. Same MySQL
|
||||
// longtext-prefix caveat as above; the reconciler applies `Status(50)` there.
|
||||
modelBuilder.Entity<JobApplication>()
|
||||
.HasIndex(j => new { j.OwnerUserId, j.IsDeleted, j.Status });
|
||||
|
||||
modelBuilder.Entity<Company>()
|
||||
.HasIndex(c => c.OwnerUserId);
|
||||
|
||||
@@ -81,6 +86,9 @@ namespace JobTrackerApi.Data
|
||||
.HasForeignKey(c => c.JobApplicationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<Correspondence>()
|
||||
.HasIndex(c => c.JobApplicationId);
|
||||
|
||||
modelBuilder.Entity<GmailConnection>()
|
||||
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||
|
||||
@@ -111,6 +119,9 @@ namespace JobTrackerApi.Data
|
||||
.HasForeignKey(e => e.JobApplicationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<JobEvent>()
|
||||
.HasIndex(e => e.JobApplicationId);
|
||||
|
||||
modelBuilder.Entity<CvUploadArtifact>()
|
||||
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ public sealed class GmailControllerTests
|
||||
var result = await controller.Status(CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailConnectionStatusDto>(ok.Value);
|
||||
var payload = Assert.IsType<GmailConnectionStatusDto>(ok.Value);
|
||||
Assert.True(payload.Connected);
|
||||
Assert.Equal("user@example.test", payload.GmailAddress);
|
||||
Assert.Equal("list-messages", payload.LastSyncMode);
|
||||
@@ -54,7 +54,7 @@ public sealed class GmailControllerTests
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(db, Mock.Of<IGmailOAuthService>(), "user-1");
|
||||
|
||||
var result = await controller.ImportThread(new GmailController.ImportGmailThreadRequest(1, "thread-1", Array.Empty<string>()), CancellationToken.None);
|
||||
var result = await controller.ImportThread(new ImportGmailThreadRequest(1, "thread-1", Array.Empty<string>()), CancellationToken.None);
|
||||
|
||||
var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result);
|
||||
Assert.Equal("At least one messageId is required.", badRequest.Value);
|
||||
@@ -159,7 +159,7 @@ public sealed class GmailControllerTests
|
||||
var result = await controller.JobCandidates(job.Id, overrideQuery, 6, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailJobMatchesResponseDto>(ok.Value);
|
||||
var payload = Assert.IsType<GmailJobMatchesResponseDto>(ok.Value);
|
||||
|
||||
Assert.Equal(job.Id, payload.JobApplicationId);
|
||||
Assert.Contains(overrideQuery, payload.Queries);
|
||||
@@ -221,7 +221,7 @@ public sealed class GmailControllerTests
|
||||
var result = await controller.JobCandidates(job.Id, null, 6, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailJobMatchesResponseDto>(ok.Value);
|
||||
var payload = Assert.IsType<GmailJobMatchesResponseDto>(ok.Value);
|
||||
Assert.NotEmpty(payload.Queries);
|
||||
Assert.Equal(0, payload.CandidateMessageCount);
|
||||
Assert.Equal(0, payload.CandidateThreadCount);
|
||||
@@ -264,9 +264,9 @@ public sealed class GmailControllerTests
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
|
||||
var first = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None);
|
||||
var first = await controller.Import(new ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None);
|
||||
var firstOk = Assert.IsType<OkObjectResult>(first.Result);
|
||||
var firstPayload = Assert.IsType<GmailController.GmailImportMessageResultDto>(firstOk.Value);
|
||||
var firstPayload = Assert.IsType<GmailImportMessageResultDto>(firstOk.Value);
|
||||
Assert.Equal(1, firstPayload.Imported);
|
||||
Assert.Equal(0, firstPayload.Skipped);
|
||||
Assert.Equal("thread-1", firstPayload.ThreadId);
|
||||
@@ -279,9 +279,9 @@ public sealed class GmailControllerTests
|
||||
Assert.Single(firstPayload.Message.AttachmentMetadata);
|
||||
Assert.Equal("cv.pdf", firstPayload.Message.AttachmentMetadata[0].FileName);
|
||||
|
||||
var second = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None);
|
||||
var second = await controller.Import(new ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None);
|
||||
var secondOk = Assert.IsType<OkObjectResult>(second.Result);
|
||||
var secondPayload = Assert.IsType<GmailController.GmailImportMessageResultDto>(secondOk.Value);
|
||||
var secondPayload = Assert.IsType<GmailImportMessageResultDto>(secondOk.Value);
|
||||
Assert.Equal(0, secondPayload.Imported);
|
||||
Assert.Equal(1, secondPayload.Skipped);
|
||||
Assert.Equal("thread-1", secondPayload.ThreadId);
|
||||
@@ -340,18 +340,18 @@ public sealed class GmailControllerTests
|
||||
Array.Empty<GmailMessageAttachment>()));
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var request = new GmailController.ImportGmailThreadRequest(job.Id, "thread-1", new[] { "msg-1", "msg-2" });
|
||||
var request = new ImportGmailThreadRequest(job.Id, "thread-1", new[] { "msg-1", "msg-2" });
|
||||
|
||||
var first = await controller.ImportThread(request, CancellationToken.None);
|
||||
var firstOk = Assert.IsType<OkObjectResult>(first.Result);
|
||||
var firstPayload = Assert.IsType<GmailController.GmailImportResultDto>(firstOk.Value);
|
||||
var firstPayload = Assert.IsType<GmailImportResultDto>(firstOk.Value);
|
||||
Assert.Equal(2, firstPayload.Imported);
|
||||
Assert.Equal(0, firstPayload.Skipped);
|
||||
Assert.Equal("thread-1", firstPayload.ThreadId);
|
||||
|
||||
var second = await controller.ImportThread(request, CancellationToken.None);
|
||||
var secondOk = Assert.IsType<OkObjectResult>(second.Result);
|
||||
var secondPayload = Assert.IsType<GmailController.GmailImportResultDto>(secondOk.Value);
|
||||
var secondPayload = Assert.IsType<GmailImportResultDto>(secondOk.Value);
|
||||
Assert.Equal(0, secondPayload.Imported);
|
||||
Assert.Equal(2, secondPayload.Skipped);
|
||||
|
||||
@@ -414,10 +414,10 @@ public sealed class GmailControllerTests
|
||||
Array.Empty<GmailMessageAttachment>()));
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(job.Id), CancellationToken.None);
|
||||
var result = await controller.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(job.Id), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailThreadRefreshResultDto>(ok.Value);
|
||||
var payload = Assert.IsType<GmailThreadRefreshResultDto>(ok.Value);
|
||||
Assert.Equal(job.Id, payload.JobApplicationId);
|
||||
Assert.Equal(1, payload.ThreadsChecked);
|
||||
Assert.Equal(1, payload.Imported);
|
||||
@@ -461,7 +461,7 @@ public sealed class GmailControllerTests
|
||||
var disconnectedGmail = new Mock<IGmailOAuthService>(MockBehavior.Strict);
|
||||
disconnectedGmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>())).ReturnsAsync((GmailConnection?)null);
|
||||
var disconnectedController = CreateController(db, disconnectedGmail.Object, "user-1");
|
||||
var disconnectedResult = await disconnectedController.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(linkedJob.Id), CancellationToken.None);
|
||||
var disconnectedResult = await disconnectedController.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(linkedJob.Id), CancellationToken.None);
|
||||
var conflict = Assert.IsType<ConflictObjectResult>(disconnectedResult.Result);
|
||||
Assert.Equal("Connect Gmail before refreshing linked threads.", conflict.Value);
|
||||
|
||||
@@ -469,10 +469,10 @@ public sealed class GmailControllerTests
|
||||
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow });
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var emptyResult = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(emptyJob.Id), CancellationToken.None);
|
||||
var emptyResult = await controller.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(emptyJob.Id), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(emptyResult.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailThreadRefreshResultDto>(ok.Value);
|
||||
var payload = Assert.IsType<GmailThreadRefreshResultDto>(ok.Value);
|
||||
Assert.Equal(0, payload.ThreadsChecked);
|
||||
Assert.Equal(0, payload.Imported);
|
||||
Assert.Equal(0, payload.Skipped);
|
||||
@@ -526,7 +526,7 @@ public sealed class GmailControllerTests
|
||||
var result = await controller.ReviewCandidates(null, 6, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailReviewQueueResponseDto>(ok.Value);
|
||||
var payload = Assert.IsType<GmailReviewQueueResponseDto>(ok.Value);
|
||||
Assert.Equal(1, payload.CandidateThreadCount);
|
||||
Assert.Single(payload.Threads);
|
||||
Assert.Equal("thread-top", payload.Threads[0].ThreadId);
|
||||
@@ -541,7 +541,7 @@ public sealed class GmailControllerTests
|
||||
var gmail = new Mock<IGmailOAuthService>(MockBehavior.Strict);
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
|
||||
var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(0), CancellationToken.None);
|
||||
var result = await controller.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(0), CancellationToken.None);
|
||||
|
||||
var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result);
|
||||
Assert.Equal("Valid jobApplicationId is required.", badRequest.Value);
|
||||
@@ -566,7 +566,7 @@ public sealed class GmailControllerTests
|
||||
|
||||
var gmail = new Mock<IGmailOAuthService>(MockBehavior.Strict);
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(foreignJob.Id), CancellationToken.None);
|
||||
var result = await controller.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(foreignJob.Id), CancellationToken.None);
|
||||
|
||||
var notFound = Assert.IsType<NotFoundObjectResult>(result.Result);
|
||||
Assert.Equal("Job application not found.", notFound.Value);
|
||||
@@ -639,7 +639,7 @@ public sealed class GmailControllerTests
|
||||
Array.Empty<GmailMessageAttachment>()));
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var result = await controller.SaveReviewDecision(new GmailController.SaveGmailReviewDecisionRequest("thread-1", "linked", job.Id, "Strong recruiter match"), CancellationToken.None);
|
||||
var result = await controller.SaveReviewDecision(new SaveGmailReviewDecisionRequest("thread-1", "linked", job.Id, "Strong recruiter match"), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var decision = await db.GmailReviewDecisions.SingleAsync();
|
||||
@@ -719,10 +719,10 @@ public sealed class GmailControllerTests
|
||||
Array.Empty<GmailMessageAttachment>()));
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var result = await controller.ManualSync(new GmailController.GmailManualSyncRequest(365, 8, true, false), CancellationToken.None);
|
||||
var result = await controller.ManualSync(new GmailManualSyncRequest(365, 8, true, false), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailManualSyncResultDto>(ok.Value);
|
||||
var payload = Assert.IsType<GmailManualSyncResultDto>(ok.Value);
|
||||
Assert.Equal(1, payload.AutoLinkedThreadCount);
|
||||
Assert.Equal(1, payload.ImportedThreads);
|
||||
Assert.Equal(1, payload.ImportedMessages);
|
||||
@@ -770,7 +770,7 @@ public sealed class GmailControllerTests
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
|
||||
var reviewQueue = new GmailController.GmailReviewQueueResponseDto(
|
||||
var reviewQueue = new GmailReviewQueueResponseDto(
|
||||
Array.Empty<string>(),
|
||||
1,
|
||||
0,
|
||||
@@ -778,7 +778,7 @@ public sealed class GmailControllerTests
|
||||
1,
|
||||
new[]
|
||||
{
|
||||
new GmailController.GmailReviewThreadDto(
|
||||
new GmailReviewThreadDto(
|
||||
"thread-suggested",
|
||||
"Platform Engineer interview",
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
@@ -787,10 +787,10 @@ public sealed class GmailControllerTests
|
||||
false,
|
||||
null,
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<GmailController.GmailReviewJobCandidateDto>(),
|
||||
Array.Empty<GmailReviewJobCandidateDto>(),
|
||||
new[]
|
||||
{
|
||||
new GmailController.GmailJobMatchedMessageDto(
|
||||
new GmailJobMatchedMessageDto(
|
||||
"msg-s1",
|
||||
"thread-suggested",
|
||||
"Platform Engineer interview",
|
||||
@@ -802,16 +802,16 @@ public sealed class GmailControllerTests
|
||||
"low",
|
||||
false,
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<GmailController.GmailJobMatchReasonDto>())
|
||||
Array.Empty<GmailJobMatchReasonDto>())
|
||||
})
|
||||
});
|
||||
|
||||
var suggested = Assert.IsType<OkObjectResult>((await controller.SuggestedJobs(CancellationToken.None)).Result);
|
||||
Assert.IsType<GmailController.GmailSuggestedJobsResponseDto>(suggested.Value);
|
||||
Assert.IsType<GmailSuggestedJobsResponseDto>(suggested.Value);
|
||||
|
||||
var create = await controller.CreateSuggestedJob(new GmailController.CreateSuggestedGmailJobRequest("thread-suggested", "Beta", "Platform Engineer", "Nina Recruiter", "nina@beta.test", "Create from Gmail suggestion", "Applied"), CancellationToken.None);
|
||||
var create = await controller.CreateSuggestedJob(new CreateSuggestedGmailJobRequest("thread-suggested", "Beta", "Platform Engineer", "Nina Recruiter", "nina@beta.test", "Create from Gmail suggestion", "Applied"), CancellationToken.None);
|
||||
var createOk = Assert.IsType<OkObjectResult>(create.Result);
|
||||
var created = Assert.IsType<GmailController.CreatedSuggestedGmailJobDto>(createOk.Value);
|
||||
var created = Assert.IsType<CreatedSuggestedGmailJobDto>(createOk.Value);
|
||||
Assert.True(created.JobApplicationId > 0);
|
||||
Assert.Equal(1, created.Imported);
|
||||
Assert.Equal("thread-suggested", created.ThreadId);
|
||||
@@ -837,10 +837,10 @@ public sealed class GmailControllerTests
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, Mock.Of<IGmailOAuthService>(), "user-1");
|
||||
var result = await controller.UnlinkThread(new GmailController.UnlinkGmailThreadRequest(job.Id, "thread-1", "Need manual review", "review"), CancellationToken.None);
|
||||
var result = await controller.UnlinkThread(new UnlinkGmailThreadRequest(job.Id, "thread-1", "Need manual review", "review"), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailUnlinkResultDto>(ok.Value);
|
||||
var payload = Assert.IsType<GmailUnlinkResultDto>(ok.Value);
|
||||
Assert.Equal(2, payload.RemovedMessages);
|
||||
Assert.Equal("review", payload.Decision);
|
||||
Assert.Empty(await db.Correspondences.ToListAsync());
|
||||
@@ -895,10 +895,10 @@ public sealed class GmailControllerTests
|
||||
Array.Empty<GmailMessageAttachment>()));
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var result = await controller.RelinkThread(new GmailController.RelinkGmailThreadRequest(targetJob.Id, "thread-1", true, "Move to target"), CancellationToken.None);
|
||||
var result = await controller.RelinkThread(new RelinkGmailThreadRequest(targetJob.Id, "thread-1", true, "Move to target"), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailRelinkResultDto>(ok.Value);
|
||||
var payload = Assert.IsType<GmailRelinkResultDto>(ok.Value);
|
||||
Assert.Equal(1, payload.UnlinkedMessages);
|
||||
Assert.Equal(1, payload.Imported);
|
||||
var stored = await db.Correspondences.SingleAsync();
|
||||
|
||||
@@ -39,7 +39,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1");
|
||||
var result = await controller.SaveApplicationDrafts(job.Id, new JobApplicationsController.SaveApplicationDraftsRequest(null, "Updated notes block", null), CancellationToken.None);
|
||||
var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, "Updated notes block", null), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
var saved = await db.JobApplications.FirstAsync();
|
||||
@@ -135,7 +135,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
var result = await controller.GenerateApplicationPackage(job.Id, null, null, null, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<JobApplicationsController.GenerateApplicationPackageDto>(ok.Value);
|
||||
var payload = Assert.IsType<GenerateApplicationPackageDto>(ok.Value);
|
||||
|
||||
Assert.Contains("Tailored CV", payload.TailoredCvText);
|
||||
Assert.Equal("Cover letter tailored with recruiter context and imported correspondence.", payload.CoverLetterDraft);
|
||||
@@ -261,7 +261,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
var result = await controller.GetTailoredCvDraft(job.Id, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<JobApplicationsController.TailoredCvDraftDto>(ok.Value);
|
||||
var payload = Assert.IsType<TailoredCvDraftDto>(ok.Value);
|
||||
Assert.True(payload.IsLegacyFallback);
|
||||
Assert.Equal("legacy-text", payload.TemplateId);
|
||||
Assert.Contains("Existing tailored CV text", payload.RenderedText);
|
||||
@@ -337,14 +337,14 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
var controller = CreateController(db, summarizer.Object, "user-1");
|
||||
var generateResult = await controller.GenerateTailoredCvDraft(job.Id, "ats", CancellationToken.None);
|
||||
var generateOk = Assert.IsType<OkObjectResult>(generateResult.Result);
|
||||
var generated = Assert.IsType<JobApplicationsController.TailoredCvDraftDto>(generateOk.Value);
|
||||
var generated = Assert.IsType<TailoredCvDraftDto>(generateOk.Value);
|
||||
|
||||
Assert.False(generated.IsLegacyFallback);
|
||||
Assert.Equal(7, generated.CanonicalProfileVersion);
|
||||
Assert.Equal("Senior Backend Engineer", generated.Headline);
|
||||
Assert.Contains("Led backend API delivery.", generated.RenderedText);
|
||||
|
||||
var saveResult = await controller.SaveTailoredCvDraft(job.Id, new JobApplicationsController.SaveTailoredCvDraftRequest(
|
||||
var saveResult = await controller.SaveTailoredCvDraft(job.Id, new SaveTailoredCvDraftRequest(
|
||||
generated.TemplateId,
|
||||
"Principal Backend Engineer",
|
||||
new List<string> { "Own backend delivery for critical APIs." },
|
||||
@@ -395,7 +395,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
var renderer = new TestCvTemplateRenderer();
|
||||
var exporter = new TestCvPdfExporter();
|
||||
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1", renderer, exporter);
|
||||
var request = new JobApplicationsController.TailoredCvRenderRequest(
|
||||
var request = new TailoredCvRenderRequest(
|
||||
"ats-minimal",
|
||||
"Backend Engineer",
|
||||
new List<string> { "Built APIs" },
|
||||
@@ -409,7 +409,7 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
|
||||
var previewResult = await controller.PreviewTailoredCv(job.Id, request, CancellationToken.None);
|
||||
var ok = Assert.IsType<OkObjectResult>(previewResult.Result);
|
||||
var preview = Assert.IsType<JobApplicationsController.TailoredCvPreviewDto>(ok.Value);
|
||||
var preview = Assert.IsType<TailoredCvPreviewDto>(ok.Value);
|
||||
Assert.Equal("ats-minimal", preview.TemplateId);
|
||||
Assert.Equal("preview.pdf", preview.SuggestedFileName);
|
||||
Assert.Equal("data:image/png;base64,abc123", renderer.LastPhotoDataUrl);
|
||||
|
||||
@@ -9,7 +9,7 @@ public sealed class JobApplicationsControllerTests
|
||||
[Fact]
|
||||
public void Application_package_record_exposes_expected_fields()
|
||||
{
|
||||
var type = typeof(JobApplicationsController).GetNestedType("GenerateApplicationPackageDto", BindingFlags.Public | BindingFlags.NonPublic);
|
||||
var type = typeof(GenerateApplicationPackageDto);
|
||||
Assert.NotNull(type);
|
||||
|
||||
var props = type!.GetProperties(BindingFlags.Public | BindingFlags.Instance).Select(x => x.Name).ToHashSet();
|
||||
@@ -23,7 +23,7 @@ public sealed class JobApplicationsControllerTests
|
||||
[Fact]
|
||||
public void Save_application_drafts_request_supports_cover_letter_and_notes()
|
||||
{
|
||||
var type = typeof(JobApplicationsController).GetNestedType("SaveApplicationDraftsRequest", BindingFlags.Public | BindingFlags.NonPublic);
|
||||
var type = typeof(SaveApplicationDraftsRequest);
|
||||
Assert.NotNull(type);
|
||||
|
||||
var ctor = type!.GetConstructors().Single();
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, "user-1");
|
||||
var result = await controller.SaveApplicationDrafts(job.Id, new JobApplicationsController.SaveApplicationDraftsRequest(" Cover letter body ", " Notes body ", null), CancellationToken.None);
|
||||
var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(" Cover letter body ", " Notes body ", null), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
var saved = await db.JobApplications.FirstAsync();
|
||||
@@ -83,7 +83,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(ok.Value);
|
||||
var dto = Assert.IsType<StatusSuggestionDto>(ok.Value);
|
||||
Assert.True(dto.HasSuggestion);
|
||||
Assert.Equal("Rejected", dto.SuggestedStatus);
|
||||
}
|
||||
@@ -114,7 +114,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(ok.Value);
|
||||
var dto = Assert.IsType<StatusSuggestionDto>(ok.Value);
|
||||
Assert.False(dto.HasSuggestion);
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
var result = await controller.GetMatchScore(job.Id, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var dto = Assert.IsType<JobApplicationsController.MatchScoreDto>(ok.Value);
|
||||
var dto = Assert.IsType<MatchScoreDto>(ok.Value);
|
||||
Assert.True(dto.HasEnoughSignal);
|
||||
Assert.True(dto.Score >= 75, $"expected strong score, got {dto.Score}");
|
||||
Assert.Contains("C#", dto.MatchedKeywords);
|
||||
@@ -181,7 +181,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, "user-1");
|
||||
var request = new JobApplicationsController.CreateJobApplicationRequest(
|
||||
var request = new CreateJobApplicationRequest(
|
||||
JobTitle: "Backend Dev",
|
||||
CompanyId: company.Id,
|
||||
Status: null,
|
||||
@@ -237,7 +237,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, "user-1");
|
||||
var request = new JobApplicationsController.UpdateJobApplicationRequest(
|
||||
var request = new UpdateJobApplicationRequest(
|
||||
JobTitle: "Backend Dev",
|
||||
CompanyId: company.Id,
|
||||
Status: "Applied",
|
||||
|
||||
@@ -92,7 +92,7 @@ public sealed class JobApplicationsFollowUpDraftTests
|
||||
var result = await controller.GetFollowUpDraft(job.Id, "waiting-update", null, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<JobApplicationsController.FollowUpDraftDto>(ok.Value);
|
||||
var payload = Assert.IsType<FollowUpDraftDto>(ok.Value);
|
||||
|
||||
Assert.Equal("Re: Backend Developer application update", payload.Subject);
|
||||
Assert.Contains("Maria", payload.Body);
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class JobApplicationsMariaDraftTests
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, "user-1");
|
||||
var result = await controller.SaveApplicationDrafts(job.Id, new JobApplicationsController.SaveApplicationDraftsRequest(null, null, " Recruiter hello "), CancellationToken.None);
|
||||
var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(null, null, " Recruiter hello "), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
var saved = await db.JobApplications.FirstAsync();
|
||||
|
||||
@@ -43,7 +43,7 @@ public sealed class JobApplicationsWorkflowSignalsTests
|
||||
var result = await controller.GetReadiness(job.Id, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<JobApplicationsController.ReadinessDto>(ok.Value);
|
||||
var payload = Assert.IsType<ReadinessDto>(ok.Value);
|
||||
|
||||
Assert.Equal("package-work", payload.WorkflowSignal.ActionKey);
|
||||
Assert.True(payload.WorkflowSignal.HasPackageGap);
|
||||
@@ -92,7 +92,7 @@ public sealed class JobApplicationsWorkflowSignalsTests
|
||||
var result = await controller.GetReminders(14, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<List<JobApplicationsController.JobApplicationDto>>(ok.Value);
|
||||
var payload = Assert.IsType<List<JobApplicationDto>>(ok.Value);
|
||||
|
||||
var packageReminder = Assert.Single(payload, item => item.Id == packageGapJob.Id);
|
||||
Assert.Equal("package-work", packageReminder.WorkflowSignal.ActionKey);
|
||||
|
||||
@@ -123,7 +123,7 @@ public sealed class ProfileCvControllerTests
|
||||
var result = await controller.GetRuns();
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var runs = Assert.IsAssignableFrom<IEnumerable<ProfileCvController.CvExtractionRunListItem>>(ok.Value);
|
||||
var runs = Assert.IsAssignableFrom<IEnumerable<CvExtractionRunListItem>>(ok.Value);
|
||||
var single = Assert.Single(runs);
|
||||
Assert.Equal("upload", single.Trigger);
|
||||
Assert.Equal("applied", single.Status);
|
||||
@@ -611,7 +611,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode);
|
||||
var payload = Assert.IsType<ProfileCvController.CvRewriteFailureDto>(objectResult.Value);
|
||||
var payload = Assert.IsType<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);
|
||||
@@ -673,7 +673,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode);
|
||||
var payload = Assert.IsType<ProfileCvController.CvRewriteFailureDto>(objectResult.Value);
|
||||
var payload = Assert.IsType<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);
|
||||
@@ -766,7 +766,7 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
|
||||
|
||||
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(user.ProfileCvText));
|
||||
var result = await controller.Parse(new ParseCvRequest(user.ProfileCvText));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
@@ -800,7 +800,7 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
|
||||
|
||||
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(user.ProfileCvText));
|
||||
var result = await controller.Parse(new ParseCvRequest(user.ProfileCvText));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
@@ -838,7 +838,7 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object);
|
||||
|
||||
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
@@ -878,7 +878,7 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object);
|
||||
|
||||
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
@@ -914,7 +914,7 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object);
|
||||
|
||||
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
@@ -1030,7 +1030,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths(), null, normalizer.Object);
|
||||
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
|
||||
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
@@ -1069,7 +1069,7 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
|
||||
|
||||
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(rawSource));
|
||||
var result = await controller.Parse(new ParseCvRequest(rawSource));
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
Assert.NotNull(ok.Value);
|
||||
|
||||
@@ -1098,7 +1098,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
|
||||
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
@@ -1129,7 +1129,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
|
||||
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
@@ -1158,7 +1158,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
|
||||
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
@@ -1186,7 +1186,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
|
||||
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
|
||||
var result = await controller.Parse(new ParseCvRequest(source));
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
|
||||
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
|
||||
@@ -7,13 +7,14 @@ using JobTrackerApi.Services.EmailProviders;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static JobTrackerApi.Services.GmailParsing;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/gmail")]
|
||||
[Authorize]
|
||||
public sealed partial class GmailController : ControllerBase
|
||||
public sealed class GmailController : ControllerBase
|
||||
{
|
||||
private readonly IGmailOAuthService _gmail;
|
||||
private readonly IGmailJobMatchingService _matching;
|
||||
|
||||
@@ -2,78 +2,75 @@ using JobTrackerApi.Models;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
// DTOs for GmailController, split out for readability (Wave 2 safe refactor -- no behaviour
|
||||
// change; these were previously nested inline in the controller file).
|
||||
public partial class GmailController
|
||||
{
|
||||
public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId);
|
||||
public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message);
|
||||
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
|
||||
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds);
|
||||
public sealed record RefreshLinkedThreadsRequest(int JobApplicationId);
|
||||
public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate);
|
||||
public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList<GmailThreadRefreshThreadDto> Threads);
|
||||
public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points);
|
||||
public sealed record GmailJobMatchedMessageDto(
|
||||
string Id,
|
||||
string ThreadId,
|
||||
string Subject,
|
||||
string From,
|
||||
string To,
|
||||
DateTimeOffset? Date,
|
||||
string Snippet,
|
||||
int Score,
|
||||
string Confidence,
|
||||
bool AlreadyImported,
|
||||
IReadOnlyList<string> MatchedQueries,
|
||||
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons);
|
||||
public sealed record GmailJobMatchedThreadDto(
|
||||
string ThreadId,
|
||||
string Subject,
|
||||
int Score,
|
||||
string Confidence,
|
||||
bool HasImportedMessages,
|
||||
int ImportedMessageCount,
|
||||
int MessageCount,
|
||||
DateTimeOffset? LatestDate,
|
||||
IReadOnlyList<string> MatchedQueries,
|
||||
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
|
||||
IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
||||
public sealed record GmailJobMatchesResponseDto(
|
||||
int JobApplicationId,
|
||||
string JobTitle,
|
||||
string CompanyName,
|
||||
string? RecruiterName,
|
||||
string? RecruiterEmail,
|
||||
IReadOnlyList<string> Queries,
|
||||
int CandidateMessageCount,
|
||||
int CandidateThreadCount,
|
||||
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
|
||||
// DTOs for GmailController, split out for readability (no behaviour change; these were
|
||||
// previously nested inside the controller class).
|
||||
public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId);
|
||||
public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message);
|
||||
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
|
||||
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds);
|
||||
public sealed record RefreshLinkedThreadsRequest(int JobApplicationId);
|
||||
public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate);
|
||||
public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList<GmailThreadRefreshThreadDto> Threads);
|
||||
public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points);
|
||||
public sealed record GmailJobMatchedMessageDto(
|
||||
string Id,
|
||||
string ThreadId,
|
||||
string Subject,
|
||||
string From,
|
||||
string To,
|
||||
DateTimeOffset? Date,
|
||||
string Snippet,
|
||||
int Score,
|
||||
string Confidence,
|
||||
bool AlreadyImported,
|
||||
IReadOnlyList<string> MatchedQueries,
|
||||
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons);
|
||||
public sealed record GmailJobMatchedThreadDto(
|
||||
string ThreadId,
|
||||
string Subject,
|
||||
int Score,
|
||||
string Confidence,
|
||||
bool HasImportedMessages,
|
||||
int ImportedMessageCount,
|
||||
int MessageCount,
|
||||
DateTimeOffset? LatestDate,
|
||||
IReadOnlyList<string> MatchedQueries,
|
||||
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
|
||||
IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
||||
public sealed record GmailJobMatchesResponseDto(
|
||||
int JobApplicationId,
|
||||
string JobTitle,
|
||||
string CompanyName,
|
||||
string? RecruiterName,
|
||||
string? RecruiterEmail,
|
||||
IReadOnlyList<string> Queries,
|
||||
int CandidateMessageCount,
|
||||
int CandidateThreadCount,
|
||||
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
|
||||
|
||||
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
|
||||
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
||||
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads);
|
||||
public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note);
|
||||
public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash);
|
||||
public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt);
|
||||
public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList<string> MatchedQueries, string Preview);
|
||||
public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> Items);
|
||||
public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status);
|
||||
public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped);
|
||||
public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note);
|
||||
public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages);
|
||||
public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision);
|
||||
public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision);
|
||||
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
|
||||
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
||||
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads);
|
||||
public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note);
|
||||
public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash);
|
||||
public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt);
|
||||
public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList<string> MatchedQueries, string Preview);
|
||||
public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> Items);
|
||||
public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status);
|
||||
public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped);
|
||||
public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note);
|
||||
public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages);
|
||||
public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision);
|
||||
public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision);
|
||||
|
||||
public sealed record GmailConnectionStatusDto(
|
||||
bool Connected,
|
||||
string? GmailAddress,
|
||||
DateTimeOffset? ConnectedAt,
|
||||
DateTimeOffset? LastSyncedAt,
|
||||
DateTimeOffset? LastSyncAttemptedAt,
|
||||
DateTimeOffset? LastSyncSucceededAt,
|
||||
string? LastSyncMode,
|
||||
string? LastSyncSource,
|
||||
string? LastSyncStatus,
|
||||
string? LastSyncError);
|
||||
}
|
||||
public sealed record GmailConnectionStatusDto(
|
||||
bool Connected,
|
||||
string? GmailAddress,
|
||||
DateTimeOffset? ConnectedAt,
|
||||
DateTimeOffset? LastSyncedAt,
|
||||
DateTimeOffset? LastSyncAttemptedAt,
|
||||
DateTimeOffset? LastSyncSucceededAt,
|
||||
string? LastSyncMode,
|
||||
string? LastSyncSource,
|
||||
string? LastSyncStatus,
|
||||
string? LastSyncError);
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
using JobTrackerApi.Models;
|
||||
|
||||
namespace JobTrackerApi.Controllers
|
||||
{
|
||||
public sealed record TailoredCvPreviewDto(string TemplateId, string Html, string SuggestedFileName);
|
||||
|
||||
public sealed record TailoredCvRenderRequest(
|
||||
string? TemplateId,
|
||||
string? Headline,
|
||||
List<string>? Summary,
|
||||
List<string>? SelectedSkills,
|
||||
List<TailoredCvExperienceItem>? Experience,
|
||||
List<TailoredCvEducationItem>? Education,
|
||||
List<TailoredCvCustomSection>? CustomSections,
|
||||
TailoredCvRenderOptions? RenderOptions,
|
||||
string? PhotoDataUrl,
|
||||
bool? UseProfileAvatar);
|
||||
|
||||
public sealed record AttachmentContextResult(string Context, List<string> Signals, List<string> UsedFiles);
|
||||
public sealed record CorrespondenceContextResult(string Context, List<string> Signals, List<string> Participants, List<string> ThreadIds);
|
||||
|
||||
public sealed record WorkflowSignalDto(
|
||||
string ActionKey,
|
||||
string Reason,
|
||||
string WorkspaceTab,
|
||||
string? FollowMode,
|
||||
bool NeedsAttention,
|
||||
bool HasPackageGap,
|
||||
bool NeedsInterviewPrep,
|
||||
bool NeedsFollowUpAction,
|
||||
bool HasTailoredCv,
|
||||
bool HasSavedApplicationAnswerDraft,
|
||||
bool HasInterviewPrepNotes
|
||||
);
|
||||
|
||||
public sealed record PagedResult<T>(List<T> Items, int Total, int Page, int PageSize);
|
||||
|
||||
public sealed record JobApplicationDto(
|
||||
int Id,
|
||||
int CompanyId,
|
||||
Company Company,
|
||||
string JobTitle,
|
||||
string Status,
|
||||
DateTime DateApplied,
|
||||
bool ResponseReceived,
|
||||
DateTime? ResponseDate,
|
||||
string? Notes,
|
||||
string? CoverLetterText,
|
||||
string? JobUrl,
|
||||
string? Description,
|
||||
string? TranslatedDescription,
|
||||
string? DescriptionLanguage,
|
||||
string? Tags,
|
||||
DateTime? Deadline,
|
||||
string? Location,
|
||||
string? Salary,
|
||||
decimal? SalaryMin,
|
||||
decimal? SalaryMax,
|
||||
string? SalaryCurrency,
|
||||
string? SalaryPeriod,
|
||||
string? NextAction,
|
||||
DateTime? FollowUpAt,
|
||||
DateTime? FeedbackRequestedAt,
|
||||
bool HasResume,
|
||||
bool HasCoverLetter,
|
||||
bool HasPortfolio,
|
||||
bool HasOtherAttachment,
|
||||
bool IsDeleted,
|
||||
DateTime? DeletedAt,
|
||||
int DaysSince,
|
||||
bool NeedsFollowUp,
|
||||
string? FollowUpReason,
|
||||
string? TailoredCvText,
|
||||
WorkflowSignalDto WorkflowSignal,
|
||||
string? ShortSummary,
|
||||
string? FullSummary
|
||||
);
|
||||
|
||||
public sealed record CreateJobApplicationRequest(
|
||||
string JobTitle,
|
||||
int CompanyId,
|
||||
string? Status,
|
||||
string? Location,
|
||||
string? Salary,
|
||||
decimal? SalaryMin,
|
||||
decimal? SalaryMax,
|
||||
string? SalaryCurrency,
|
||||
string? SalaryPeriod,
|
||||
string? NextAction,
|
||||
DateTime? FollowUpAt,
|
||||
string? Notes,
|
||||
string? Description,
|
||||
string? TranslatedDescription,
|
||||
string? DescriptionLanguage,
|
||||
string? Tags,
|
||||
DateTime? Deadline,
|
||||
string? CoverLetterText,
|
||||
string? JobUrl,
|
||||
DateTime? DateApplied,
|
||||
DateTime? FeedbackRequestedAt
|
||||
);
|
||||
|
||||
public sealed record UpdateJobApplicationRequest(
|
||||
string JobTitle,
|
||||
int CompanyId,
|
||||
string Status,
|
||||
bool ResponseReceived,
|
||||
DateTime? ResponseDate,
|
||||
string? Location,
|
||||
string? Salary,
|
||||
decimal? SalaryMin,
|
||||
decimal? SalaryMax,
|
||||
string? SalaryCurrency,
|
||||
string? SalaryPeriod,
|
||||
string? NextAction,
|
||||
DateTime? FollowUpAt,
|
||||
string? Notes,
|
||||
string? Description,
|
||||
string? TranslatedDescription,
|
||||
string? DescriptionLanguage,
|
||||
string? Tags,
|
||||
DateTime? Deadline,
|
||||
string? CoverLetterText,
|
||||
string? JobUrl,
|
||||
DateTime? DateApplied,
|
||||
DateTime? FeedbackRequestedAt,
|
||||
DateTime? StatusChangedAt
|
||||
);
|
||||
|
||||
public sealed record UpdateStatusRequest(string Status);
|
||||
|
||||
public sealed record PipelineStageDto(string Key, int Order, string Category);
|
||||
|
||||
public sealed record StatusSuggestionDto(
|
||||
bool HasSuggestion,
|
||||
string? SuggestedStatus,
|
||||
string? CurrentStatus,
|
||||
string? Signal,
|
||||
string? Confidence,
|
||||
DateTime? MessageDate,
|
||||
string? MessageSubject);
|
||||
|
||||
public sealed record FollowUpRequest(DateTime? FollowUpAt);
|
||||
|
||||
public sealed record JobEventDto(int Id, string Type, string? OldValue, string? NewValue, string? Note, DateTime At);
|
||||
|
||||
public sealed record TimelineItemDto(string Kind, DateTime At, object Data);
|
||||
|
||||
public sealed record AnalyticsPoint(string Month, int Applied, int Responses);
|
||||
|
||||
public sealed record TagPoint(string Tag, int Count);
|
||||
|
||||
public sealed record TagTrendSeries(string Tag, List<int> Counts);
|
||||
public sealed record TagTrendPoint(string Month, List<int> Counts);
|
||||
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);
|
||||
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn, string ContextSummary, List<string> ContextSignals, string? ThreadSubject, string? LastCorrespondenceFrom, DateTime? LastCorrespondenceAt);
|
||||
public sealed record FocusPlanDto(
|
||||
List<string> ImmediatePriorities,
|
||||
List<string> CvBulletIdeas,
|
||||
List<string> ProofPointsToLeadWith,
|
||||
List<string> CoverLetterAngles,
|
||||
List<string> FollowUpApproach,
|
||||
string StrategicSummary);
|
||||
public sealed record SendFollowUpRequest(string? ToEmail, string Subject, string Body, DateTime? NextFollowUpAt);
|
||||
public sealed record TagTrendResponse(List<string> Months, List<TagTrendSeries> Series);
|
||||
public sealed record CandidateFitChannelGuidanceDto(List<string> Cv, List<string> CoverLetter, List<string> Interview, List<string> RecruiterMessage);
|
||||
public sealed record CandidateFitDto(
|
||||
string MatchSummary,
|
||||
string FitLevel,
|
||||
int MatchScore,
|
||||
List<string> Strengths,
|
||||
List<string> Gaps,
|
||||
List<string> Mention,
|
||||
List<string> Avoid,
|
||||
List<string> CvImprovements,
|
||||
List<string> MissingKeywords,
|
||||
List<string> InterviewPrep,
|
||||
string TailoredPitch,
|
||||
CandidateFitChannelGuidanceDto Guidance,
|
||||
string? CoverLetterDraft,
|
||||
string? RecruiterMessageDraft);
|
||||
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
|
||||
public sealed record TailoredCvDraftDto(
|
||||
int? Id,
|
||||
int? CanonicalProfileVersion,
|
||||
string TemplateId,
|
||||
string? Headline,
|
||||
List<string> Summary,
|
||||
List<string> SelectedSkills,
|
||||
List<TailoredCvExperienceItem> Experience,
|
||||
List<TailoredCvEducationItem> Education,
|
||||
List<TailoredCvCustomSection> CustomSections,
|
||||
TailoredCvRenderOptions RenderOptions,
|
||||
string? GenerationContextHash,
|
||||
DateTimeOffset? LastGeneratedAtUtc,
|
||||
DateTimeOffset? LastEditedAtUtc,
|
||||
string Status,
|
||||
string RenderedText,
|
||||
bool IsLegacyFallback);
|
||||
public sealed record SaveTailoredCvDraftRequest(
|
||||
string? TemplateId,
|
||||
string? Headline,
|
||||
List<string>? Summary,
|
||||
List<string>? SelectedSkills,
|
||||
List<TailoredCvExperienceItem>? Experience,
|
||||
List<TailoredCvEducationItem>? Education,
|
||||
List<TailoredCvCustomSection>? CustomSections,
|
||||
TailoredCvRenderOptions? RenderOptions,
|
||||
string? Status);
|
||||
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints, List<string> AttachmentSignals, List<string> AttachmentFilesUsed, List<string> CoverLetterVariants, List<string> RecruiterMessageVariants);
|
||||
public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft);
|
||||
public sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes);
|
||||
public sealed record InterviewPrepDto(string Summary, List<string> TalkingPoints, List<string> LikelyQuestions, List<string> WeakSpots);
|
||||
public sealed record ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders, WorkflowSignalDto WorkflowSignal);
|
||||
|
||||
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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -113,25 +113,8 @@ public sealed class ProfileCvController : ControllerBase
|
||||
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);
|
||||
public sealed record CvExtractionRunListItem(
|
||||
int Id,
|
||||
string Trigger,
|
||||
string Status,
|
||||
string? ArtifactFileName,
|
||||
DateTimeOffset StartedAtUtc,
|
||||
DateTimeOffset? CompletedAtUtc,
|
||||
DateTimeOffset? AppliedAtUtc,
|
||||
string ParserVersion,
|
||||
string NormalizerVersion,
|
||||
string LlmPromptVersion,
|
||||
string? ErrorMessage);
|
||||
|
||||
[HttpPost("upload")]
|
||||
[RequestSizeLimit(MaxFileSizeBytes)]
|
||||
@@ -254,6 +237,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var artifact = await _db.CvUploadArtifacts
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(x => x.UploadedAtUtc)
|
||||
.FirstOrDefaultAsync(x => x.OwnerUserId == user.Id, HttpContext.RequestAborted);
|
||||
|
||||
@@ -941,7 +925,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
}
|
||||
case "reprocess":
|
||||
{
|
||||
var artifact = await _db.CvUploadArtifacts.FirstOrDefaultAsync(x => x.Id == run.ArtifactId && x.OwnerUserId == user.Id, cancellationToken);
|
||||
var artifact = await _db.CvUploadArtifacts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == run.ArtifactId && x.OwnerUserId == user.Id, cancellationToken);
|
||||
if (artifact is null) throw new InvalidOperationException("Upload a CV before reprocessing it.");
|
||||
if (string.IsNullOrWhiteSpace(artifact.StoragePath) || !System.IO.File.Exists(artifact.StoragePath))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using JobTrackerApi.Models;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
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);
|
||||
public sealed record CvExtractionRunListItem(
|
||||
int Id,
|
||||
string Trigger,
|
||||
string Status,
|
||||
string? ArtifactFileName,
|
||||
DateTimeOffset StartedAtUtc,
|
||||
DateTimeOffset? CompletedAtUtc,
|
||||
DateTimeOffset? AppliedAtUtc,
|
||||
string ParserVersion,
|
||||
string NormalizerVersion,
|
||||
string LlmPromptVersion,
|
||||
string? ErrorMessage);
|
||||
@@ -22,34 +22,45 @@ namespace JobTrackerApi.Services
|
||||
public async Task<JobStats> GetStatsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var last30 = now.AddDays(-30);
|
||||
|
||||
// Project to only the columns the stats need instead of materialising full
|
||||
// JobApplication rows (which drag large Description/TranslatedDescription/
|
||||
// TailoredCvText/Notes blobs). Aggregation stays in memory over a small
|
||||
// per-tenant set.
|
||||
var all = await _db.JobApplications
|
||||
// Aggregate server-side (COUNT/GROUP BY) instead of pulling every row into memory.
|
||||
var total = await _db.JobApplications.AsNoTracking().CountAsync(cancellationToken);
|
||||
var active = await _db.JobApplications.AsNoTracking().CountAsync(j => !j.IsDeleted, cancellationToken);
|
||||
var appliedLast30Days = await _db.JobApplications.AsNoTracking()
|
||||
.CountAsync(j => !j.IsDeleted && j.DateApplied >= last30, cancellationToken);
|
||||
|
||||
var byStatus = await _db.JobApplications
|
||||
.AsNoTracking()
|
||||
.Select(j => new { j.IsDeleted, j.Status, j.DateApplied })
|
||||
.Where(j => !j.IsDeleted)
|
||||
.GroupBy(j => j.Status)
|
||||
.Select(g => new { Status = g.Key, Count = g.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var active = all.Where(j => !j.IsDeleted).ToList();
|
||||
var byStatusDict = byStatus
|
||||
.GroupBy(x => string.IsNullOrWhiteSpace(x.Status) ? "Unknown" : x.Status)
|
||||
.OrderByDescending(g => g.Sum(x => x.Count))
|
||||
.ToDictionary(g => g.Key, g => g.Sum(x => x.Count));
|
||||
|
||||
var byStatus = active
|
||||
.GroupBy(j => string.IsNullOrWhiteSpace(j.Status) ? "Unknown" : j.Status)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
// ponytail: average age needs a per-row day-diff that doesn't translate identically
|
||||
// across the SQLite/MySQL providers this app runs on, so pull just the DateApplied
|
||||
// column (no wide blob columns) for active rows and average client-side.
|
||||
var activeDates = active == 0
|
||||
? new List<DateTime>()
|
||||
: await _db.JobApplications.AsNoTracking()
|
||||
.Where(j => !j.IsDeleted)
|
||||
.Select(j => j.DateApplied)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var appliedLast30Days = active.Count(j => (now - j.DateApplied).TotalDays <= 30);
|
||||
|
||||
var avgDays = active.Count == 0
|
||||
var avgDays = activeDates.Count == 0
|
||||
? 0
|
||||
: active.Average(j => Math.Max(0, (now - j.DateApplied).TotalDays));
|
||||
: activeDates.Average(d => Math.Max(0, (now - d).TotalDays));
|
||||
|
||||
return new JobStats(
|
||||
Total: all.Count,
|
||||
Active: active.Count,
|
||||
Deleted: all.Count - active.Count,
|
||||
ByStatus: byStatus,
|
||||
Total: total,
|
||||
Active: active,
|
||||
Deleted: total - active,
|
||||
ByStatus: byStatusDict,
|
||||
AppliedLast30Days: appliedLast30Days,
|
||||
AverageDaysSinceApplied: Math.Round(avgDays, 1)
|
||||
);
|
||||
|
||||
+12
-14
@@ -1,12 +1,10 @@
|
||||
using JobTrackerApi.Services;
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
// Pure parsing/formatting helpers for GmailController, split out for readability (Wave 2 safe
|
||||
// refactor -- no behaviour change). All are static and side-effect free.
|
||||
public sealed partial class GmailController
|
||||
// Pure parsing/formatting helpers used by GmailController, split out for readability (no
|
||||
// behaviour change). All are static and side-effect free.
|
||||
public static class GmailParsing
|
||||
{
|
||||
private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
|
||||
public static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
|
||||
{
|
||||
var bounded = (query ?? string.Empty).Trim();
|
||||
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -25,7 +23,7 @@ public sealed partial class GmailController
|
||||
return bounded.Trim();
|
||||
}
|
||||
|
||||
private static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
|
||||
public static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
|
||||
{
|
||||
var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value)))));
|
||||
if (string.IsNullOrWhiteSpace(sample)) return false;
|
||||
@@ -40,7 +38,7 @@ public sealed partial class GmailController
|
||||
|| sample.Contains("rejection", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ToConfidence(int score)
|
||||
public static string ToConfidence(int score)
|
||||
{
|
||||
return score switch
|
||||
{
|
||||
@@ -50,21 +48,21 @@ public sealed partial class GmailController
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ExtractFirstEmail(string? value)
|
||||
public static string? ExtractFirstEmail(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
return match.Success ? match.Value : null;
|
||||
}
|
||||
|
||||
private static string? ExtractRecruiterName(string? value)
|
||||
public static string? ExtractRecruiterName(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var trimmed = value.Split('<')[0].Trim().Trim('"');
|
||||
return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed;
|
||||
}
|
||||
|
||||
private static string? ExtractCompanyName(string? from, string? subject)
|
||||
public static string? ExtractCompanyName(string? from, string? subject)
|
||||
{
|
||||
var subjectText = (subject ?? string.Empty).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(subjectText))
|
||||
@@ -77,7 +75,7 @@ public sealed partial class GmailController
|
||||
return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null;
|
||||
}
|
||||
|
||||
private static string? ExtractRoleFromSubject(string? subject)
|
||||
public static string? ExtractRoleFromSubject(string? subject)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subject)) return null;
|
||||
var trimmed = subject.Trim();
|
||||
@@ -88,7 +86,7 @@ public sealed partial class GmailController
|
||||
return trimmed.Length <= 120 ? trimmed : trimmed[..120];
|
||||
}
|
||||
|
||||
private static string BuildPopupHtml(bool success, string message)
|
||||
public static string BuildPopupHtml(bool success, string message)
|
||||
{
|
||||
var escaped = System.Net.WebUtility.HtmlEncode(message);
|
||||
var status = success ? "connected" : "error";
|
||||
@@ -0,0 +1,622 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services.JobImport;
|
||||
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure, stateless helpers extracted from JobApplicationsController. None of these touch
|
||||
/// the database, AI services, or other instance state -- same inputs always produce the
|
||||
/// same outputs, so they are safe to share as static methods.
|
||||
/// </summary>
|
||||
public static class JobApplicationHelpers
|
||||
{
|
||||
private const string ApplicationAnswerDraftStart = "<<<APPLICATION_ANSWER_DRAFT>>>";
|
||||
private const string ApplicationAnswerDraftEnd = "<<<END_APPLICATION_ANSWER_DRAFT>>>";
|
||||
|
||||
public static string GetPreferredDisplayName(ApplicationUser? user)
|
||||
{
|
||||
if (user is null) return "Your Name";
|
||||
if (!string.IsNullOrWhiteSpace(user.DisplayName)) return user.DisplayName.Trim();
|
||||
var fullName = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
if (!string.IsNullOrWhiteSpace(fullName)) return fullName;
|
||||
if (!string.IsNullOrWhiteSpace(user.UserName)) return user.UserName.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(user.Email)) return user.Email.Trim();
|
||||
return "Your Name";
|
||||
}
|
||||
|
||||
public static string BuildGreeting(JobApplication job)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName)) return $"Hi {job.Company.RecruiterName.Trim()},";
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.Name)) return $"Hi {job.Company.Name.Trim()} team,";
|
||||
return "Hi there,";
|
||||
}
|
||||
|
||||
public static string BuildStructuredCvContext(ApplicationUser? user)
|
||||
{
|
||||
var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson);
|
||||
var blocks = new List<string>();
|
||||
|
||||
var contactLines = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(structured.Contact.FullName)) contactLines.Add($"Name: {structured.Contact.FullName}");
|
||||
if (!string.IsNullOrWhiteSpace(structured.Contact.Headline)) contactLines.Add($"Headline: {structured.Contact.Headline}");
|
||||
if (!string.IsNullOrWhiteSpace(structured.Contact.Email)) contactLines.Add($"Email: {structured.Contact.Email}");
|
||||
if (!string.IsNullOrWhiteSpace(structured.Contact.Location)) contactLines.Add($"Location: {structured.Contact.Location}");
|
||||
if (!string.IsNullOrWhiteSpace(structured.Contact.LinkedIn)) contactLines.Add($"LinkedIn: {structured.Contact.LinkedIn}");
|
||||
if (contactLines.Count > 0) blocks.Add($"Contact:\n{string.Join("\n", contactLines)}");
|
||||
|
||||
if (structured.Summary.Count > 0)
|
||||
{
|
||||
blocks.Add($"Summary:\n- {string.Join("\n- ", structured.Summary.Take(4))}");
|
||||
}
|
||||
|
||||
if (structured.Skills.Count > 0)
|
||||
{
|
||||
blocks.Add($"Skills:\n{string.Join(", ", structured.Skills.Take(16))}");
|
||||
}
|
||||
|
||||
if (structured.Jobs.Count > 0)
|
||||
{
|
||||
var jobBlocks = structured.Jobs.Take(3).Select(job =>
|
||||
{
|
||||
var header = string.Join(" | ", new[] { job.Title, job.Company, job.Location, FormatStructuredDateRange(job.Start, job.End, job.IsCurrent) }.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
var bullets = job.Bullets.Take(3).Select(bullet => $"- {bullet}");
|
||||
return string.Join("\n", new[] { header }.Concat(bullets).Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
}).Where(value => !string.IsNullOrWhiteSpace(value)).ToList();
|
||||
if (jobBlocks.Count > 0) blocks.Add($"Work Experience:\n{string.Join("\n\n", jobBlocks)}");
|
||||
}
|
||||
|
||||
if (structured.Education.Count > 0)
|
||||
{
|
||||
var items = structured.Education.Take(3).Select(education => string.Join(" | ", new[] { education.Qualification, education.Institution, education.Location, FormatStructuredDateRange(education.Start, education.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value))));
|
||||
blocks.Add($"Education:\n- {string.Join("\n- ", items)}");
|
||||
}
|
||||
|
||||
if (structured.Languages.Count > 0)
|
||||
{
|
||||
var items = structured.Languages.Take(5).Select(language => string.Join(": ", new[] { language.Name, language.Level }.Where(value => !string.IsNullOrWhiteSpace(value))));
|
||||
blocks.Add($"Languages:\n- {string.Join("\n- ", items)}");
|
||||
}
|
||||
|
||||
if (structured.OtherSections.Count > 0)
|
||||
{
|
||||
var items = structured.OtherSections.Take(2)
|
||||
.Where(section => !string.IsNullOrWhiteSpace(section.Title) && section.Items.Count > 0)
|
||||
.Select(section => $"{section.Title}: {string.Join("; ", section.Items.Take(4))}")
|
||||
.ToList();
|
||||
if (items.Count > 0) blocks.Add($"Other sections:\n- {string.Join("\n- ", items)}");
|
||||
}
|
||||
|
||||
if (blocks.Count == 0 && structured.Sections.Count > 0)
|
||||
{
|
||||
blocks.AddRange(structured.Sections.Take(6).Select(section => $"{section.Name}:\n{section.Content}"));
|
||||
}
|
||||
|
||||
return blocks.Count > 0
|
||||
? $"Structured CV:\n{string.Join("\n\n", blocks)}"
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
public static string BuildCvSearchCorpus(ApplicationUser? user)
|
||||
{
|
||||
var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson);
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(user?.ProfileCvText)) parts.Add(user.ProfileCvText!);
|
||||
if (!string.IsNullOrWhiteSpace(structured.Contact.Headline)) parts.Add(structured.Contact.Headline!);
|
||||
if (structured.Summary.Count > 0) parts.Add(string.Join("\n", structured.Summary));
|
||||
if (structured.Skills.Count > 0) parts.Add(string.Join("\n", structured.Skills));
|
||||
if (structured.Jobs.Count > 0)
|
||||
{
|
||||
parts.Add(string.Join("\n", structured.Jobs.SelectMany(job => new[] { job.Title, job.Company, job.Location }.Where(value => !string.IsNullOrWhiteSpace(value)).Concat(job.Bullets).Concat(job.Skills))));
|
||||
}
|
||||
if (structured.Education.Count > 0)
|
||||
{
|
||||
parts.Add(string.Join("\n", structured.Education.SelectMany(education => new[] { education.Qualification, education.Institution, education.Location }.Where(value => !string.IsNullOrWhiteSpace(value)).Concat(education.Details))));
|
||||
}
|
||||
if (structured.Languages.Count > 0)
|
||||
{
|
||||
parts.Add(string.Join("\n", structured.Languages.Select(language => string.Join(" ", new[] { language.Name, language.Level, language.Notes }.Where(value => !string.IsNullOrWhiteSpace(value))))));
|
||||
}
|
||||
return string.Join("\n", parts.Where(part => !string.IsNullOrWhiteSpace(part)));
|
||||
}
|
||||
|
||||
public static string? FormatStructuredDateRange(string? start, string? end, bool isCurrent)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(start) && string.IsNullOrWhiteSpace(end)) return null;
|
||||
if (string.IsNullOrWhiteSpace(start)) return end;
|
||||
return $"{start} - {(isCurrent ? "Present" : end ?? "Present")}";
|
||||
}
|
||||
|
||||
public static string ComputeGenerationContextHash(string value)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static int ScoreTailoredExperience(StructuredCvJob job, IEnumerable<string> matchedTags)
|
||||
{
|
||||
var corpus = string.Join("\n", new[] { job.Title, job.Company, job.Location, string.Join("\n", job.Bullets), string.Join("\n", job.Skills) }
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value)))
|
||||
.ToLowerInvariant();
|
||||
var score = 0;
|
||||
foreach (var tag in matchedTags.Where(tag => !string.IsNullOrWhiteSpace(tag)))
|
||||
{
|
||||
if (corpus.Contains(tag.ToLowerInvariant(), StringComparison.Ordinal)) score += 4;
|
||||
}
|
||||
score += Math.Min(job.Bullets.Count, 4);
|
||||
return score;
|
||||
}
|
||||
|
||||
public static List<string> SelectTailoredSkills(StructuredCvProfile structured, string jobText)
|
||||
{
|
||||
var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var prioritized = structured.Skills
|
||||
.Select(skill => new
|
||||
{
|
||||
Skill = skill,
|
||||
Score = jobTags.Any(tag => skill.Contains(tag, StringComparison.OrdinalIgnoreCase) || tag.Contains(skill, StringComparison.OrdinalIgnoreCase)) ? 2 : 0
|
||||
})
|
||||
.OrderByDescending(entry => entry.Score)
|
||||
.ThenBy(entry => entry.Skill, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(entry => entry.Skill)
|
||||
.ToList();
|
||||
|
||||
if (prioritized.Count == 0)
|
||||
{
|
||||
prioritized = structured.Jobs.SelectMany(job => job.Skills).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
return prioritized.Take(10).ToList();
|
||||
}
|
||||
|
||||
public static TailoredCvDocument BuildLegacyTailoredCvFallback(JobApplication job)
|
||||
{
|
||||
var text = (job.TailoredCvText ?? string.Empty).Trim();
|
||||
var document = new TailoredCvDocument
|
||||
{
|
||||
Headline = job.JobTitle,
|
||||
CustomSections = string.IsNullOrWhiteSpace(text)
|
||||
? new List<TailoredCvCustomSection>()
|
||||
: new List<TailoredCvCustomSection>
|
||||
{
|
||||
new TailoredCvCustomSection
|
||||
{
|
||||
Title = "Legacy draft text",
|
||||
Items = text.Split(new[] { "\r\n\r\n", "\n\n" }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(),
|
||||
}
|
||||
}
|
||||
};
|
||||
return TailoredCvDraftJson.Normalize(document);
|
||||
}
|
||||
|
||||
public static TailoredCvDraftDto ToTailoredCvDraftDto(TailoredCvDraft draft)
|
||||
{
|
||||
var document = TailoredCvDraftJson.FromDraft(draft);
|
||||
return new TailoredCvDraftDto(
|
||||
draft.Id,
|
||||
draft.CanonicalProfileVersion,
|
||||
draft.TemplateId,
|
||||
document.Headline,
|
||||
document.Summary,
|
||||
document.SelectedSkills,
|
||||
document.Experience,
|
||||
document.Education,
|
||||
document.CustomSections,
|
||||
document.RenderOptions,
|
||||
draft.GenerationContextHash,
|
||||
draft.LastGeneratedAtUtc,
|
||||
draft.LastEditedAtUtc,
|
||||
draft.Status,
|
||||
TailoredCvDraftJson.RenderPlainText(document),
|
||||
false);
|
||||
}
|
||||
|
||||
public static TailoredCvDraftDto ToLegacyTailoredCvDraftDto(JobApplication job)
|
||||
{
|
||||
var document = BuildLegacyTailoredCvFallback(job);
|
||||
return new TailoredCvDraftDto(
|
||||
null,
|
||||
null,
|
||||
"legacy-text",
|
||||
document.Headline,
|
||||
document.Summary,
|
||||
document.SelectedSkills,
|
||||
document.Experience,
|
||||
document.Education,
|
||||
document.CustomSections,
|
||||
document.RenderOptions,
|
||||
null,
|
||||
null,
|
||||
job.TailoredCvUpdatedAt,
|
||||
string.IsNullOrWhiteSpace(job.TailoredCvText) ? "empty" : "legacy-import",
|
||||
TailoredCvDraftJson.RenderPlainText(document),
|
||||
true);
|
||||
}
|
||||
|
||||
public static TailoredCvDocument BuildTailoredCvDocumentForRender(SaveTailoredCvDraftRequest? request, TailoredCvDraft? draft, JobApplication job)
|
||||
{
|
||||
var baseDocument = draft is not null ? TailoredCvDraftJson.FromDraft(draft) : BuildLegacyTailoredCvFallback(job);
|
||||
if (request is null)
|
||||
{
|
||||
return baseDocument;
|
||||
}
|
||||
|
||||
return TailoredCvDraftJson.Normalize(new TailoredCvDocument
|
||||
{
|
||||
TemplateId = request.TemplateId ?? baseDocument.TemplateId ?? "ats-minimal",
|
||||
Headline = request.Headline ?? baseDocument.Headline,
|
||||
Summary = request.Summary ?? baseDocument.Summary,
|
||||
SelectedSkills = request.SelectedSkills ?? baseDocument.SelectedSkills,
|
||||
Experience = request.Experience ?? baseDocument.Experience,
|
||||
Education = request.Education ?? baseDocument.Education,
|
||||
CustomSections = request.CustomSections ?? baseDocument.CustomSections,
|
||||
RenderOptions = request.RenderOptions ?? baseDocument.RenderOptions,
|
||||
});
|
||||
}
|
||||
|
||||
public static string? ExtractSavedApplicationAnswerDraft(string? notes)
|
||||
{
|
||||
var value = (notes ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
|
||||
var startIndex = value.IndexOf(ApplicationAnswerDraftStart, StringComparison.Ordinal);
|
||||
var endIndex = value.IndexOf(ApplicationAnswerDraftEnd, StringComparison.Ordinal);
|
||||
if (startIndex >= 0 && endIndex > startIndex)
|
||||
{
|
||||
var between = value[(startIndex + ApplicationAnswerDraftStart.Length)..endIndex].Trim();
|
||||
return string.IsNullOrWhiteSpace(between) ? null : between;
|
||||
}
|
||||
|
||||
const string legacyPrefix = "Application answer draft:";
|
||||
var legacyIndex = value.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase);
|
||||
if (legacyIndex >= 0)
|
||||
{
|
||||
var legacy = value[(legacyIndex + legacyPrefix.Length)..].Trim();
|
||||
return string.IsNullOrWhiteSpace(legacy) ? null : legacy;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string BuildFollowUpSubject(JobApplication job, Correspondence? lastMessage)
|
||||
{
|
||||
var subject = (lastMessage?.Subject ?? string.Empty).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
return subject.StartsWith("Re:", StringComparison.OrdinalIgnoreCase)
|
||||
? subject
|
||||
: $"Re: {subject}";
|
||||
}
|
||||
|
||||
return $"Following up on {job.JobTitle} application";
|
||||
}
|
||||
|
||||
public static List<string> BuildFollowUpContextSignals(JobApplication job, Correspondence? lastMessage, CorrespondenceContextResult? correspondenceContext, SavedPackageMaterial savedPackageMaterial, string? savedApplicationAnswer)
|
||||
{
|
||||
var signals = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName)) signals.Add($"Recruiter contact: {job.Company.RecruiterName.Trim()}");
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) signals.Add($"Recruiter email on file: {job.Company.RecruiterEmail.Trim()}");
|
||||
if (lastMessage is not null)
|
||||
{
|
||||
signals.Add($"Latest correspondence: {lastMessage.Date:yyyy-MM-dd} — {lastMessage.Subject ?? "(no subject)"}");
|
||||
}
|
||||
if (correspondenceContext?.Participants.Count > 0)
|
||||
{
|
||||
signals.Add($"Thread participants: {string.Join(", ", correspondenceContext.Participants.Take(3))}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(savedPackageMaterial.CoverLetterText)) signals.Add("Saved cover letter available");
|
||||
if (!string.IsNullOrWhiteSpace(savedPackageMaterial.RecruiterMessageDraft)) signals.Add("Saved recruiter message available");
|
||||
if (!string.IsNullOrWhiteSpace(savedPackageMaterial.TailoredCvText)) signals.Add("Saved tailored CV available");
|
||||
if (!string.IsNullOrWhiteSpace(savedApplicationAnswer)) signals.Add("Saved application answer available");
|
||||
|
||||
if (correspondenceContext is not null)
|
||||
{
|
||||
foreach (var signal in correspondenceContext.Signals)
|
||||
{
|
||||
if (!signals.Contains(signal, StringComparer.OrdinalIgnoreCase)) signals.Add(signal);
|
||||
}
|
||||
}
|
||||
|
||||
return signals.Take(6).ToList();
|
||||
}
|
||||
|
||||
public static bool IsExtractableAttachmentExtension(string? extension)
|
||||
{
|
||||
return extension?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
".pdf" => true,
|
||||
".docx" => true,
|
||||
".txt" => true,
|
||||
".md" => true,
|
||||
".png" => true,
|
||||
".jpg" => true,
|
||||
".jpeg" => true,
|
||||
".webp" => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
public static List<string> BuildFollowUpApproach(string status, List<string> matchedTags, List<string> missingTags)
|
||||
{
|
||||
var normalized = (status ?? string.Empty).Trim();
|
||||
var advice = new List<string>();
|
||||
|
||||
switch (normalized)
|
||||
{
|
||||
case "Applied":
|
||||
advice.Add("Follow up briefly, reaffirm interest, and reference the date you applied.");
|
||||
advice.Add("Mention one or two of the strongest overlaps from the posting instead of repeating your whole background.");
|
||||
break;
|
||||
case "Waiting":
|
||||
advice.Add("Acknowledge that you are following up on next steps and keep the message light but specific.");
|
||||
advice.Add("Use one proof point that shows why you remain a strong fit.");
|
||||
break;
|
||||
case "Interview":
|
||||
case "Interviewing":
|
||||
advice.Add("Focus on momentum, appreciation, and readiness for the next step.");
|
||||
advice.Add("Reference a memorable point from the process, discussion, or role priorities if possible.");
|
||||
break;
|
||||
case "Offer":
|
||||
advice.Add("Keep the tone warm and professional, and focus on clarifying next steps or timing.");
|
||||
advice.Add("Avoid sounding pushy; frame the note around alignment and practical progress.");
|
||||
break;
|
||||
case "Rejected":
|
||||
advice.Add("If appropriate, ask for feedback with a respectful and concise tone.");
|
||||
advice.Add("Keep the door open for future opportunities instead of arguing the decision.");
|
||||
break;
|
||||
default:
|
||||
advice.Add("Match the tone to the current stage and be specific about why you are following up now.");
|
||||
advice.Add("Keep it concise, credible, and easy to respond to.");
|
||||
break;
|
||||
}
|
||||
|
||||
if (matchedTags.Any()) advice.Add($"Lead with relevant overlap such as {string.Join(", ", matchedTags.Take(2))}.");
|
||||
if (missingTags.Any()) advice.Add($"Do not overstate areas like {string.Join(", ", missingTags.Take(2))}; frame them honestly.");
|
||||
|
||||
return advice.Distinct(StringComparer.OrdinalIgnoreCase).Take(5).ToList();
|
||||
}
|
||||
|
||||
public static IEnumerable<string> SplitTags(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) yield break;
|
||||
|
||||
var trimmed = s.Trim();
|
||||
|
||||
List<string>? jsonTags = null;
|
||||
if (trimmed.StartsWith("[") && trimmed.EndsWith("]"))
|
||||
{
|
||||
try
|
||||
{
|
||||
jsonTags = JsonSerializer.Deserialize<List<string>>(trimmed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
jsonTags = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonTags is not null)
|
||||
{
|
||||
foreach (var x in jsonTags)
|
||||
{
|
||||
var t = (x ?? string.Empty).Trim();
|
||||
if (t.Length == 0) continue;
|
||||
yield return t;
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var raw in trimmed.Split(new[] { ',', ';', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var t = raw.Trim();
|
||||
if (t.Length == 0) continue;
|
||||
yield return t;
|
||||
}
|
||||
}
|
||||
|
||||
public static string NormalizeForComparison(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||
return new string(value.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray());
|
||||
}
|
||||
|
||||
public static string BuildSummarySource(JobApplication job)
|
||||
{
|
||||
// Prefer translated text for summaries and skill extraction so non-English
|
||||
// postings become easier to understand while keeping the original text intact.
|
||||
var parts = new[]
|
||||
{
|
||||
job.TranslatedDescription,
|
||||
job.Description,
|
||||
job.Notes
|
||||
};
|
||||
|
||||
return string.Join("\n\n", parts.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!.Trim()));
|
||||
}
|
||||
|
||||
public static string? NormalizeTags(string? raw)
|
||||
{
|
||||
var normalized = SplitTags(raw)
|
||||
.Select(tag => tag.Trim())
|
||||
.Where(tag => tag.Length > 0)
|
||||
.GroupBy(tag => tag, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group =>
|
||||
{
|
||||
var first = group.First();
|
||||
return string.Join(" ", first.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(part => char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant()));
|
||||
})
|
||||
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return normalized.Count == 0 ? null : JsonSerializer.Serialize(normalized);
|
||||
}
|
||||
|
||||
public static string? NormalizeUrl(string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return null;
|
||||
var value = url.Trim();
|
||||
return Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri.ToString() : value;
|
||||
}
|
||||
|
||||
public static string RemoveSavedApplicationAnswerDraft(string? notes)
|
||||
{
|
||||
var value = notes ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||
|
||||
var startIndex = value.IndexOf(ApplicationAnswerDraftStart, StringComparison.Ordinal);
|
||||
var endIndex = value.IndexOf(ApplicationAnswerDraftEnd, StringComparison.Ordinal);
|
||||
if (startIndex >= 0 && endIndex > startIndex)
|
||||
{
|
||||
var before = value[..startIndex].Trim();
|
||||
var after = value[(endIndex + ApplicationAnswerDraftEnd.Length)..].Trim();
|
||||
return string.Join("\n\n", new[] { before, after }.Where(part => !string.IsNullOrWhiteSpace(part))).Trim();
|
||||
}
|
||||
|
||||
const string legacyPrefix = "Application answer draft:";
|
||||
var legacyIndex = value.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase);
|
||||
if (legacyIndex >= 0)
|
||||
{
|
||||
return value[..legacyIndex].Trim();
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
public static bool HasInterviewPrepNotes(string? notes) => !string.IsNullOrWhiteSpace(RemoveSavedApplicationAnswerDraft(notes));
|
||||
|
||||
public static bool IsInterviewStage(string status) =>
|
||||
status.Contains("Interview", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static bool IsActiveWorkflowStatus(string status)
|
||||
{
|
||||
var normalized = (status ?? string.Empty).Trim();
|
||||
return normalized switch
|
||||
{
|
||||
"Applied" => true,
|
||||
"Waiting" => true,
|
||||
"Interview" => true,
|
||||
"Interviewing" => true,
|
||||
"Offer" => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
public static WorkflowSignalDto BuildWorkflowSignal(JobApplication job, FollowUpDecision followUpDecision)
|
||||
{
|
||||
var hasTailoredCv = !string.IsNullOrWhiteSpace(job.TailoredCvText);
|
||||
var hasSavedApplicationAnswerDraft = !string.IsNullOrWhiteSpace(ExtractSavedApplicationAnswerDraft(job.Notes));
|
||||
var hasInterviewPrepNotes = HasInterviewPrepNotes(job.Notes);
|
||||
var needsInterviewPrep = IsInterviewStage(job.Status) && !hasInterviewPrepNotes;
|
||||
var hasPackageGap = IsActiveWorkflowStatus(job.Status) && (!hasTailoredCv || !hasSavedApplicationAnswerDraft);
|
||||
var needsFollowUpAction = followUpDecision.NeedsFollowUp || (!job.ResponseReceived && job.FollowUpAt is null);
|
||||
|
||||
if (needsInterviewPrep)
|
||||
{
|
||||
return new WorkflowSignalDto(
|
||||
ActionKey: "interview-prep",
|
||||
Reason: "Interview stage reached but prep notes are still missing.",
|
||||
WorkspaceTab: "interview-prep",
|
||||
FollowMode: null,
|
||||
NeedsAttention: true,
|
||||
HasPackageGap: hasPackageGap,
|
||||
NeedsInterviewPrep: true,
|
||||
NeedsFollowUpAction: needsFollowUpAction,
|
||||
HasTailoredCv: hasTailoredCv,
|
||||
HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft,
|
||||
HasInterviewPrepNotes: hasInterviewPrepNotes);
|
||||
}
|
||||
|
||||
if (hasPackageGap)
|
||||
{
|
||||
var reason = !hasTailoredCv && !hasSavedApplicationAnswerDraft
|
||||
? "Tailored CV and saved application answers still need work."
|
||||
: !hasTailoredCv
|
||||
? "Tailored CV missing for this role."
|
||||
: "Saved application answers still need work.";
|
||||
|
||||
return new WorkflowSignalDto(
|
||||
ActionKey: "package-work",
|
||||
Reason: reason,
|
||||
WorkspaceTab: "tailored-cv",
|
||||
FollowMode: null,
|
||||
NeedsAttention: true,
|
||||
HasPackageGap: true,
|
||||
NeedsInterviewPrep: needsInterviewPrep,
|
||||
NeedsFollowUpAction: needsFollowUpAction,
|
||||
HasTailoredCv: hasTailoredCv,
|
||||
HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft,
|
||||
HasInterviewPrepNotes: hasInterviewPrepNotes);
|
||||
}
|
||||
|
||||
if (needsFollowUpAction)
|
||||
{
|
||||
var reason = !string.IsNullOrWhiteSpace(followUpDecision.Reason)
|
||||
? followUpDecision.Reason!
|
||||
: !job.ResponseReceived && job.FollowUpAt is null
|
||||
? "No response yet and no follow-up is scheduled."
|
||||
: "Follow-up is due for this role.";
|
||||
|
||||
return new WorkflowSignalDto(
|
||||
ActionKey: "follow-up",
|
||||
Reason: reason,
|
||||
WorkspaceTab: "follow-up",
|
||||
FollowMode: "waiting-update",
|
||||
NeedsAttention: true,
|
||||
HasPackageGap: hasPackageGap,
|
||||
NeedsInterviewPrep: needsInterviewPrep,
|
||||
NeedsFollowUpAction: true,
|
||||
HasTailoredCv: hasTailoredCv,
|
||||
HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft,
|
||||
HasInterviewPrepNotes: hasInterviewPrepNotes);
|
||||
}
|
||||
|
||||
return new WorkflowSignalDto(
|
||||
ActionKey: "review-readiness",
|
||||
Reason: "No urgent workflow gaps are blocking this job right now.",
|
||||
WorkspaceTab: "readiness",
|
||||
FollowMode: null,
|
||||
NeedsAttention: false,
|
||||
HasPackageGap: hasPackageGap,
|
||||
NeedsInterviewPrep: needsInterviewPrep,
|
||||
NeedsFollowUpAction: needsFollowUpAction,
|
||||
HasTailoredCv: hasTailoredCv,
|
||||
HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft,
|
||||
HasInterviewPrepNotes: hasInterviewPrepNotes);
|
||||
}
|
||||
|
||||
public static List<string> BuildReadinessReminders(JobApplication job, WorkflowSignalDto workflowSignal)
|
||||
{
|
||||
var reminders = new List<string>();
|
||||
|
||||
if (workflowSignal.HasPackageGap)
|
||||
{
|
||||
reminders.Add(workflowSignal.HasTailoredCv
|
||||
? "Saved application answers are still missing from the package."
|
||||
: workflowSignal.HasSavedApplicationAnswerDraft
|
||||
? "This role is active but still missing a tailored CV."
|
||||
: "This role is active but still needs a tailored CV and saved application answers.");
|
||||
}
|
||||
|
||||
if (workflowSignal.NeedsInterviewPrep)
|
||||
{
|
||||
reminders.Add("Interview stage reached but prep notes are still missing.");
|
||||
}
|
||||
|
||||
if (workflowSignal.NeedsFollowUpAction)
|
||||
{
|
||||
reminders.Add(job.FollowUpAt is null
|
||||
? "No response yet and no follow-up is scheduled."
|
||||
: workflowSignal.Reason);
|
||||
}
|
||||
|
||||
return reminders
|
||||
.Where(reminder => !string.IsNullOrWhiteSpace(reminder))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -688,6 +688,17 @@ public static class StartupInitializationExtensions
|
||||
{
|
||||
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_IsDeleted" ON "JobApplications" ("OwnerUserId", "IsDeleted");""");
|
||||
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_FollowUpAt" ON "JobApplications" ("OwnerUserId", "FollowUpAt");""");
|
||||
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_IsDeleted_Status" ON "JobApplications" ("OwnerUserId", "IsDeleted", "Status");""");
|
||||
}
|
||||
|
||||
if (HasTable(conn, "Correspondences"))
|
||||
{
|
||||
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_Correspondences_JobApplicationId" ON "Correspondences" ("JobApplicationId");""");
|
||||
}
|
||||
|
||||
if (HasTable(conn, "JobEvents"))
|
||||
{
|
||||
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobEvents_JobApplicationId" ON "JobEvents" ("JobApplicationId");""");
|
||||
}
|
||||
|
||||
// Ensure data folder exists before creating/opening SQLite files.
|
||||
@@ -977,105 +988,66 @@ public static class StartupInitializationExtensions
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
||||
// Schema reconciliation must never crash app startup: an index that fails
|
||||
// (e.g. combined key exceeds MySQL's 3072-byte limit because an older
|
||||
// migration made OwnerUserId wider than the varchar(255) this reconciler
|
||||
// assumes) is logged and skipped rather than taking prod down. OwnerUserId
|
||||
// is prefix-indexed at 191 chars (safe under utf8mb4's 767-byte legacy
|
||||
// per-column key limit, and far longer than the GUID-like Identity ids
|
||||
// actually stored there) so composite indexes stay well under the cap
|
||||
// regardless of the column's declared width.
|
||||
void TryCreateIndex(string table, string indexName, string columnsSql)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_Companies_OwnerUserId` ON `Companies` (`OwnerUserId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
if (MySqlIndexExists(conn, table, indexName)) return;
|
||||
try
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"CREATE INDEX `{indexName}` ON `{table}` ({columnsSql});";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
app.Logger.LogWarning(ex, "Skipping index {Index} on {Table} during startup reconciliation.", indexName, table);
|
||||
}
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId"))
|
||||
void TryCreateUniqueIndex(string table, string indexName, string columnsSql)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId` ON `JobApplications` (`OwnerUserId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
if (MySqlIndexExists(conn, table, indexName)) return;
|
||||
try
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"CREATE UNIQUE INDEX `{indexName}` ON `{table}` ({columnsSql});";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
app.Logger.LogWarning(ex, "Skipping unique index {Index} on {Table} during startup reconciliation.", indexName, table);
|
||||
}
|
||||
}
|
||||
|
||||
TryCreateIndex("Companies", "IX_Companies_OwnerUserId", "`OwnerUserId`(191)");
|
||||
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId", "`OwnerUserId`(191)");
|
||||
|
||||
// Hot-path composite indexes for tenant-scoped list/board/stats/analytics
|
||||
// (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt).
|
||||
if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId_IsDeleted` ON `JobApplications` (`OwnerUserId`, `IsDeleted`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId_FollowUpAt"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId_FollowUpAt` ON `JobApplications` (`OwnerUserId`, `FollowUpAt`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc` ON `CvUploadArtifacts` (`OwnerUserId`, `UploadedAtUtc`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "CvExtractionRuns", "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_OwnerUserId_StartedAtUtc` ON `CvExtractionRuns` (`OwnerUserId`, `StartedAtUtc`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "CvExtractionRuns", "IX_CvExtractionRuns_ArtifactId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_ArtifactId` ON `CvExtractionRuns` (`ArtifactId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "GmailConnections", "IX_GmailConnections_OwnerUserId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_GmailConnections_OwnerUserId` ON `GmailConnections` (`OwnerUserId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "GmailConnections", "IX_GmailConnections_OwnerUserId_GmailAddress"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_GmailConnections_OwnerUserId_GmailAddress` ON `GmailConnections` (`OwnerUserId`, `GmailAddress`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_MicrosoftGraphConnections_OwnerUserId` ON `MicrosoftGraphConnections` (`OwnerUserId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_MicrosoftGraphConnections_OwnerUserId_MailAddress` ON `MicrosoftGraphConnections` (`OwnerUserId`, `MailAddress`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "ImapConnections", "IX_ImapConnections_OwnerUserId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_ImapConnections_OwnerUserId` ON `ImapConnections` (`OwnerUserId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_TailoredCvDrafts_OwnerUserId_JobApplicationId` ON `TailoredCvDrafts` (`OwnerUserId`, `JobApplicationId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_TailoredCvDrafts_JobApplicationId` ON `TailoredCvDrafts` (`JobApplicationId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted", "`OwnerUserId`(191), `IsDeleted`");
|
||||
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_FollowUpAt", "`OwnerUserId`(191), `FollowUpAt`");
|
||||
// Status is longtext in MySQL (see JobTrackerContext.OnModelCreating), so it
|
||||
// needs an explicit prefix length to be indexable under MariaDB's key-length rules.
|
||||
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted_Status", "`OwnerUserId`(191), `IsDeleted`, `Status`(50)");
|
||||
TryCreateIndex("Correspondences", "IX_Correspondences_JobApplicationId", "`JobApplicationId`");
|
||||
TryCreateIndex("JobEvents", "IX_JobEvents_JobApplicationId", "`JobApplicationId`");
|
||||
TryCreateIndex("CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc", "`OwnerUserId`(191), `UploadedAtUtc`");
|
||||
TryCreateIndex("CvExtractionRuns", "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc", "`OwnerUserId`(191), `StartedAtUtc`");
|
||||
TryCreateIndex("CvExtractionRuns", "IX_CvExtractionRuns_ArtifactId", "`ArtifactId`");
|
||||
TryCreateIndex("GmailConnections", "IX_GmailConnections_OwnerUserId", "`OwnerUserId`(191)");
|
||||
TryCreateUniqueIndex("GmailConnections", "IX_GmailConnections_OwnerUserId_GmailAddress", "`OwnerUserId`(191), `GmailAddress`(191)");
|
||||
TryCreateIndex("MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId", "`OwnerUserId`(191)");
|
||||
TryCreateUniqueIndex("MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress", "`OwnerUserId`(191), `MailAddress`(191)");
|
||||
TryCreateUniqueIndex("ImapConnections", "IX_ImapConnections_OwnerUserId", "`OwnerUserId`(191)");
|
||||
TryCreateUniqueIndex("TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId", "`OwnerUserId`(191), `JobApplicationId`");
|
||||
TryCreateIndex("TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId", "`JobApplicationId`");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,16 @@ function titleFor(path: string, t: (k: any) => string): string {
|
||||
return t("appTitle");
|
||||
}
|
||||
|
||||
function subtitleFor(path: string, t: (k: any) => string): string | undefined {
|
||||
if (path === "/dashboard") return t("dashboardPageSubtitle");
|
||||
if (path.startsWith("/jobs")) return t("jobsPageSubtitle");
|
||||
if (path.startsWith("/kanban")) return t("kanbanPageSubtitle");
|
||||
if (path.startsWith("/reminders")) return t("remindersPageSubtitle");
|
||||
if (path.startsWith("/correspondence/review")) return t("gmailReviewPageSubtitle");
|
||||
if (path.startsWith("/correspondence")) return t("correspondencePageSubtitle");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function PageLoader() {
|
||||
return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
|
||||
}
|
||||
@@ -123,6 +133,9 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
|
||||
const path = location.pathname;
|
||||
const isJobs = path.startsWith("/jobs");
|
||||
const shortcutHint = useMemo(() => (
|
||||
typeof navigator !== "undefined" && /Mac|iPhone|iPod|iPad/.test(navigator.platform) ? "⌘K" : "Ctrl+K"
|
||||
), []);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<AuthConfig>("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false));
|
||||
@@ -206,6 +219,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
if (requireAuth && !me) return <Navigate to="/" replace state={{ from: path }} />;
|
||||
|
||||
const pageTitle = titleFor(path, t);
|
||||
const pageSubtitle = subtitleFor(path, t);
|
||||
const breadcrumbs = breadcrumbsFor(path, t);
|
||||
const setAndPersistPageSize = (n: 15 | 20 | 25) => { setJobPageSize(n); window.localStorage.setItem("jobPageSize", String(n)); };
|
||||
const setAndPersistColumns = (next: JobTableColumns) => { setJobColumns(next); window.localStorage.setItem("jobColumns", JSON.stringify(next)); };
|
||||
@@ -246,14 +260,19 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("quickSearch")}
|
||||
title={`${t("quickSearch")} (${shortcutHint})`}
|
||||
onClick={() => setQuickOpen(true)}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42, flex: "0 0 auto" }}
|
||||
>
|
||||
<SearchIcon fontSize="small" />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)}>{t("quickSearch")}</Button>
|
||||
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)} sx={{ gap: 0.5 }}>
|
||||
{t("quickSearch")}
|
||||
<Box component="span" sx={{ ml: 0.75, px: 0.75, py: 0.125, borderRadius: 1, border: "1px solid", borderColor: "divider", fontSize: 11, fontWeight: 700, color: "text.secondary", lineHeight: 1.6 }}>
|
||||
{shortcutHint}
|
||||
</Box>
|
||||
</Button>
|
||||
)}
|
||||
{isJobs ? (
|
||||
<Button variant="contained" onClick={() => setAddOpen(true)} sx={{ flex: { xs: 1, sm: "0 0 auto" }, minHeight: 42 }}>
|
||||
@@ -267,6 +286,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
<>
|
||||
<AppShell
|
||||
pageTitle={pageTitle}
|
||||
pageSubtitle={pageSubtitle}
|
||||
breadcrumbs={breadcrumbs}
|
||||
pathname={path}
|
||||
nav={nav}
|
||||
@@ -284,7 +304,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/jobs" replace />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardView />} />
|
||||
<Route path="/jobs" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="jobs" />} />
|
||||
<Route path="/reminders" element={<RemindersView />} />
|
||||
|
||||
@@ -23,6 +23,7 @@ import AutoGraphIcon from "@mui/icons-material/AutoGraph";
|
||||
|
||||
import { api } from "../api";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
import OnboardingChecklist from "./OnboardingChecklist";
|
||||
import { getUserKeyFromToken } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { statusLabel } from "../pipeline";
|
||||
@@ -287,6 +288,7 @@ export default function DashboardView() {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<OnboardingChecklist hasJobs={(stats?.total ?? 0) > 0} />
|
||||
<SectionCard
|
||||
sx={{
|
||||
backgroundColor: "background.paper",
|
||||
|
||||
@@ -51,6 +51,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
const { t } = useI18n();
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [allowRegistration, setAllowRegistration] = useState(false);
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const clientId = (process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "").trim();
|
||||
@@ -72,6 +73,9 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
|
||||
useEffect(() => {
|
||||
void refreshMe();
|
||||
api.get<{ allowRegistration: boolean }>("/auth/config").then((res) => {
|
||||
setAllowRegistration(Boolean(res.data?.allowRegistration));
|
||||
}).catch(() => setAllowRegistration(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -156,7 +160,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
|
||||
{!signedIn ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
{t("googleSignInHint")}
|
||||
{allowRegistration ? t("googleSignInHintSelfServe") : t("googleSignInHint")}
|
||||
</Typography>
|
||||
) : me?.provider === "local" ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
|
||||
@@ -1132,6 +1132,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
<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 }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Box><Typography variant="overline">{t("jobDetailsHowYouMatch")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{candidateFit.matchSummary}</Typography></Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
@@ -1230,13 +1231,14 @@ function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading:
|
||||
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", gap: 2.5, alignItems: "center", flexWrap: "wrap", mb: 1.5 }}>
|
||||
{score.hasEnoughSignal ? (
|
||||
<Box sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
|
||||
<CircularProgress variant="determinate" value={100} size={92} thickness={4} sx={{ color: "divider", position: "absolute" }} />
|
||||
<Box role="img" aria-label={`${t("matchScoreTitle")}: ${score.score}%`} sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
|
||||
<CircularProgress variant="determinate" value={100} size={92} thickness={4} aria-hidden="true" sx={{ color: "divider", position: "absolute" }} />
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
value={score.score}
|
||||
size={92}
|
||||
thickness={4}
|
||||
aria-hidden="true"
|
||||
color={color === "inherit" ? "primary" : color}
|
||||
sx={{ position: "absolute", "& .MuiCircularProgress-circle": { strokeLinecap: "round" } }}
|
||||
/>
|
||||
|
||||
@@ -110,6 +110,19 @@ function parseTags(raw?: string | null): string[] {
|
||||
}
|
||||
|
||||
|
||||
function EmptyJobsState({ firstTime, onOpenSettings, t }: { firstTime: boolean; onOpenSettings: () => void; t: (key: any) => string }) {
|
||||
if (!firstTime) {
|
||||
return <Typography sx={{ py: 2, textAlign: "center", color: "text.secondary" }}>{t("jobTableNoJobsFound")}</Typography>;
|
||||
}
|
||||
return (
|
||||
<Box sx={{ py: 4, textAlign: "center" }}>
|
||||
<Typography sx={{ fontWeight: 800, mb: 0.5 }}>{t("jobTableEmptyFirstTimeTitle")}</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 1.5, maxWidth: 440, mx: "auto" }}>{t("jobTableEmptyFirstTimeBody")}</Typography>
|
||||
<Button variant="text" onClick={onOpenSettings}>{t("jobTableEmptyFirstTimeBookmarklet")}</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function generateOverview(job: JobApplication): string {
|
||||
if (job.fullSummary) return job.fullSummary;
|
||||
if (job.shortSummary) return job.shortSummary;
|
||||
@@ -220,6 +233,12 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
return jobs.filter((job) => needsWorkflowWork(job));
|
||||
}, [jobs, readinessFilter]);
|
||||
|
||||
// Distinguishes "you have zero jobs, period" from "no results match your filters" so the
|
||||
// empty state can actually help a first-time user instead of just saying "nothing here".
|
||||
const noFiltersActive = !debouncedSearch.trim() && statusFilter === "All" && companyFilterId === "All"
|
||||
&& !debouncedLocation.trim() && !needsFollowUpOnly && readinessFilter === "all";
|
||||
const isFirstTimeEmpty = mode === "jobs" && total === 0 && noFiltersActive;
|
||||
|
||||
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
|
||||
const selectedAllOnPage = filteredJobs.length > 0 && filteredJobs.every((job) => selectedIdSet.has(job.id));
|
||||
@@ -629,7 +648,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography> : null}
|
||||
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? (
|
||||
<EmptyJobsState firstTime={isFirstTimeEmpty} onOpenSettings={() => navigate("/settings")} t={t} />
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box sx={{ overflowX: "auto" }}>
|
||||
@@ -721,7 +742,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null}
|
||||
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? (
|
||||
<TableRow><TableCell colSpan={visibleDesktopColumns}><EmptyJobsState firstTime={isFirstTimeEmpty} onOpenSettings={() => navigate("/settings")} t={t} /></TableCell></TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
@@ -101,7 +101,18 @@ export default function KanbanBoard() {
|
||||
/>
|
||||
|
||||
{!jobsResource.loading && !jobsResource.error ? (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" }, gap: 2, alignItems: "start" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: "flex", md: "grid" },
|
||||
gridTemplateColumns: { md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" },
|
||||
gap: 2,
|
||||
alignItems: "start",
|
||||
overflowX: { xs: "auto", md: "visible" },
|
||||
scrollSnapType: { xs: "x mandatory", md: "none" },
|
||||
pb: { xs: 1, md: 0 },
|
||||
"-webkit-overflow-scrolling": "touch",
|
||||
}}
|
||||
>
|
||||
{STATUSES.map((status) => {
|
||||
const c = toneColor(theme, status);
|
||||
const list = groups.get(status) ?? [];
|
||||
@@ -114,6 +125,8 @@ export default function KanbanBoard() {
|
||||
p: 1.5,
|
||||
borderRadius: 3,
|
||||
minHeight: 220,
|
||||
flex: { xs: "0 0 85vw", md: "none" },
|
||||
scrollSnapAlign: { xs: "start", md: "none" },
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
background: theme.palette.mode === "dark" ? alpha(theme.palette.common.white, 0.02) : alpha(theme.palette.text.primary, 0.015),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Box, Button, IconButton, Paper, Stack, Typography } from "@mui/material";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { alpha, useTheme } from "@mui/material/styles";
|
||||
|
||||
import { api } from "../api";
|
||||
import { getUserKeyFromToken } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
function dismissKey() {
|
||||
return `onboardingChecklistDismissed:${getUserKeyFromToken()}`;
|
||||
}
|
||||
|
||||
type MeResponse = { profileCvText?: string | null };
|
||||
|
||||
export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const [hasCv, setHasCv] = useState<boolean | null>(null);
|
||||
const [dismissed, setDismissed] = useState(() => window.localStorage.getItem(dismissKey()) === "1");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api.get<MeResponse>("/auth/me")
|
||||
.then((r) => { if (active) setHasCv(Boolean(r.data?.profileCvText?.trim())); })
|
||||
.catch(() => { if (active) setHasCv(false); });
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
const allDone = hasCv === true && hasJobs;
|
||||
if (dismissed || allDone || hasCv === null) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
window.localStorage.setItem(dismissKey(), "1");
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{ done: hasCv, label: t("onboardingStepCv"), action: () => navigate("/profile"), actionLabel: t("onboardingStepCvAction") },
|
||||
{ done: hasJobs, label: t("onboardingStepJob"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepJobAction") },
|
||||
{ done: hasCv === true && hasJobs, label: t("onboardingStepMatch"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepMatchAction") },
|
||||
];
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2.25,
|
||||
mb: 2,
|
||||
borderRadius: 4,
|
||||
border: "1px solid",
|
||||
borderColor: alpha(theme.palette.primary.main, 0.25),
|
||||
background: alpha(theme.palette.primary.main, 0.04),
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<IconButton size="small" onClick={dismiss} aria-label={t("onboardingDismiss")} sx={{ position: "absolute", top: 8, right: 8 }}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Typography sx={{ fontWeight: 900, mb: 0.25 }}>{t("onboardingTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("onboardingBody")}</Typography>
|
||||
<Stack spacing={1}>
|
||||
{steps.map((step) => (
|
||||
<Box key={step.label} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{step.done ? <CheckCircleIcon fontSize="small" color="success" /> : <RadioButtonUncheckedIcon fontSize="small" sx={{ color: "text.secondary" }} />}
|
||||
<Typography variant="body2" sx={{ fontWeight: step.done ? 400 : 700, color: step.done ? "text.secondary" : "text.primary", textDecoration: step.done ? "line-through" : "none" }}>
|
||||
{step.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
{!step.done ? <Button size="small" variant="text" onClick={step.action}>{step.actionLabel}</Button> : null}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,12 @@ export const translations = {
|
||||
home: "Home",
|
||||
analytics: "Analytics",
|
||||
overview: "Overview",
|
||||
dashboardPageSubtitle: "Your search at a glance — response rate, funnel, and what needs attention.",
|
||||
jobsPageSubtitle: "Filter, search, and manage every application in one table.",
|
||||
kanbanPageSubtitle: "Drag a card between stages to update its status.",
|
||||
remindersPageSubtitle: "Everything due for follow-up, soonest first.",
|
||||
correspondencePageSubtitle: "Every message linked to a job, in one inbox.",
|
||||
gmailReviewPageSubtitle: "Review Gmail threads before linking them to a job.",
|
||||
account: "Account",
|
||||
profile: "Profile",
|
||||
admin: "Admin",
|
||||
@@ -306,6 +312,15 @@ export const translations = {
|
||||
cropDialogSave: "Save image",
|
||||
dashboardOverviewTitle: "Dashboard overview",
|
||||
dashboardHeroLabel: "Job search overview",
|
||||
onboardingTitle: "Get set up",
|
||||
onboardingBody: "A few steps to get the most out of Jobbjakt.",
|
||||
onboardingDismiss: "Dismiss",
|
||||
onboardingStepCv: "Add your CV",
|
||||
onboardingStepCvAction: "Add CV",
|
||||
onboardingStepJob: "Import your first job",
|
||||
onboardingStepJobAction: "Add job",
|
||||
onboardingStepMatch: "Check your CV match score on a job",
|
||||
onboardingStepMatchAction: "Open jobs",
|
||||
dashboardResponseRate: "{rate}% response rate",
|
||||
dashboardMonthsShort: "{count} mo",
|
||||
dashboardAppliedCount: "{count} applied",
|
||||
@@ -606,6 +621,7 @@ export const translations = {
|
||||
googleAvailableToLink: "Available to link",
|
||||
googleLinkedDate: "Linked {date}",
|
||||
googleSignInHint: "Sign in with a Google account that has already been linked to your Jobbjakt user.",
|
||||
googleSignInHintSelfServe: "Continue with Google. New here? We'll create your account automatically.",
|
||||
continueWithGoogle: "Continue with Google",
|
||||
signInWithGoogle: "Sign in with Google",
|
||||
linkWithGoogle: "Link with Google",
|
||||
@@ -750,6 +766,9 @@ export const translations = {
|
||||
jobTableOverview: "Overview",
|
||||
jobTableNoSummaryYet: "No summary yet.",
|
||||
jobTableNoJobsFound: "No jobs found.",
|
||||
jobTableEmptyFirstTimeTitle: "No jobs yet — let's fix that.",
|
||||
jobTableEmptyFirstTimeBody: "Click \"Add job\" above to add one manually, or paste a job posting URL. There's also a one-click bookmarklet that captures a posting straight from the page you're viewing.",
|
||||
jobTableEmptyFirstTimeBookmarklet: "Set up the bookmarklet",
|
||||
jobTableSetStatus: "Set {status}",
|
||||
editJobTitle: "Edit job",
|
||||
editJobIntro: "Update job details, timeline status, documents, and notes from one editing workspace.",
|
||||
@@ -894,6 +913,7 @@ export const translations = {
|
||||
jobDetailsFollowUpSent: "Follow-up sent and logged.",
|
||||
jobDetailsFollowUpSendFailed: "Failed to send follow-up.",
|
||||
jobDetailsHowYouMatch: "How you match",
|
||||
jobDetailsAiFitHint: "AI opinion — strengths, gaps, and a tailored pitch based on your CV and this posting.",
|
||||
matchScoreTitle: "Match score",
|
||||
matchScoreLoading: "Scoring your CV against this role…",
|
||||
matchScoreBand_Strong: "Strong match",
|
||||
@@ -902,7 +922,7 @@ export const translations = {
|
||||
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.",
|
||||
matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable. For a written opinion on strengths and gaps, see the AI section below.",
|
||||
matchScoreMatched: "Matched keywords",
|
||||
matchScoreMissing: "Missing keywords",
|
||||
matchScoreNoneYet: "No matches found yet.",
|
||||
@@ -976,6 +996,12 @@ export const translations = {
|
||||
home: "Hjem",
|
||||
analytics: "Analyse",
|
||||
overview: "Oversikt",
|
||||
dashboardPageSubtitle: "Søket ditt i korte trekk — svarrate, trakt og hva som trenger oppmerksomhet.",
|
||||
jobsPageSubtitle: "Filtrer, søk og administrer alle søknader i én tabell.",
|
||||
kanbanPageSubtitle: "Dra et kort mellom stadier for å oppdatere status.",
|
||||
remindersPageSubtitle: "Alt som trenger oppfølging, snarest først.",
|
||||
correspondencePageSubtitle: "Alle meldinger koblet til en jobb, i én innboks.",
|
||||
gmailReviewPageSubtitle: "Se gjennom Gmail-tråder før du kobler dem til en jobb.",
|
||||
account: "Konto",
|
||||
profile: "Profil",
|
||||
admin: "Admin",
|
||||
@@ -1264,6 +1290,15 @@ export const translations = {
|
||||
cropDialogSave: "Lagre bilde",
|
||||
dashboardOverviewTitle: "Dashboard-oversikt",
|
||||
dashboardHeroLabel: "Oversikt over jobbsøket",
|
||||
onboardingTitle: "Kom i gang",
|
||||
onboardingBody: "Noen få steg for å få mest mulig ut av Jobbjakt.",
|
||||
onboardingDismiss: "Lukk",
|
||||
onboardingStepCv: "Legg til CV-en din",
|
||||
onboardingStepCvAction: "Legg til CV",
|
||||
onboardingStepJob: "Importer din første jobb",
|
||||
onboardingStepJobAction: "Legg til jobb",
|
||||
onboardingStepMatch: "Sjekk CV-matchscoren på en jobb",
|
||||
onboardingStepMatchAction: "Åpne jobber",
|
||||
dashboardResponseRate: "{rate}% svarrate",
|
||||
dashboardMonthsShort: "{count} md",
|
||||
dashboardAppliedCount: "{count} søkt",
|
||||
@@ -1564,6 +1599,7 @@ export const translations = {
|
||||
googleAvailableToLink: "Tilgjengelig for kobling",
|
||||
googleLinkedDate: "Koblet {date}",
|
||||
googleSignInHint: "Logg inn med en Google-konto som allerede er koblet til Jobbjakt-brukeren din.",
|
||||
googleSignInHintSelfServe: "Fortsett med Google. Ny her? Vi oppretter kontoen din automatisk.",
|
||||
continueWithGoogle: "Fortsett med Google",
|
||||
signInWithGoogle: "Logg inn med Google",
|
||||
linkWithGoogle: "Koble til med Google",
|
||||
@@ -1708,6 +1744,9 @@ export const translations = {
|
||||
jobTableOverview: "Oversikt",
|
||||
jobTableNoSummaryYet: "Ingen oppsummering ennå.",
|
||||
jobTableNoJobsFound: "Ingen jobber funnet.",
|
||||
jobTableEmptyFirstTimeTitle: "Ingen jobber ennå — la oss fikse det.",
|
||||
jobTableEmptyFirstTimeBody: "Klikk \"Legg til jobb\" over for å legge til en manuelt, eller lim inn en lenke til en stillingsannonse. Det finnes også et bokmerke som fanger en annonse rett fra siden du ser på.",
|
||||
jobTableEmptyFirstTimeBookmarklet: "Sett opp bokmerket",
|
||||
jobTableSetStatus: "Sett {status}",
|
||||
editJobTitle: "Rediger jobb",
|
||||
editJobIntro: "Oppdater jobbdetaljer, status i tidslinjen, dokumenter og notater fra ett redigeringsområde.",
|
||||
@@ -1852,6 +1891,7 @@ export const translations = {
|
||||
jobDetailsFollowUpSent: "Oppfølging sendt og loggført.",
|
||||
jobDetailsFollowUpSendFailed: "Kunne ikke sende oppfølging.",
|
||||
jobDetailsHowYouMatch: "Slik matcher du",
|
||||
jobDetailsAiFitHint: "AI-vurdering — styrker, svakheter og et skreddersydd pitch basert på CV-en din og denne annonsen.",
|
||||
matchScoreTitle: "Match-score",
|
||||
matchScoreLoading: "Vurderer CV-en mot denne stillingen…",
|
||||
matchScoreBand_Strong: "Sterk match",
|
||||
@@ -1860,7 +1900,7 @@ export const translations = {
|
||||
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.",
|
||||
matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar. For en skriftlig vurdering av styrker og svakheter, se AI-seksjonen under.",
|
||||
matchScoreMatched: "Treff på nøkkelord",
|
||||
matchScoreMissing: "Manglende nøkkelord",
|
||||
matchScoreNoneYet: "Ingen treff ennå.",
|
||||
|
||||
@@ -59,6 +59,7 @@ const SIDEBAR_SELECTED_ICON = "#a5b4fc";
|
||||
|
||||
export default function AppShell({
|
||||
pageTitle,
|
||||
pageSubtitle,
|
||||
breadcrumbs,
|
||||
pathname,
|
||||
nav,
|
||||
@@ -76,6 +77,7 @@ export default function AppShell({
|
||||
children,
|
||||
}: {
|
||||
pageTitle: string;
|
||||
pageSubtitle?: string;
|
||||
breadcrumbs: string[];
|
||||
pathname: string;
|
||||
nav: NavItem[];
|
||||
@@ -481,6 +483,11 @@ export default function AppShell({
|
||||
<Typography variant="h5" sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>
|
||||
{pageTitle}
|
||||
</Typography>
|
||||
{pageSubtitle ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, overflowWrap: "anywhere" }}>
|
||||
{pageSubtitle}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -82,10 +82,11 @@ export default function CorrespondenceInboxPage() {
|
||||
Cross-job view of imported correspondence and Gmail-linked history.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Chip icon={<MailOutlineIcon />} label={`${items.length} items`} variant="outlined" />
|
||||
<Chip label={`${filteredSummary.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} />
|
||||
<Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" />
|
||||
<Button variant="outlined" size="small" onClick={() => navigate("/correspondence/review")}>Review Gmail queue</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ export default function GmailReviewPage() {
|
||||
<Button variant="outlined" onClick={() => void load()} disabled={loading || syncing}>
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</Button>
|
||||
<Button variant="text" onClick={() => navigate("/correspondence")}>Back to inbox</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function LandingPage() {
|
||||
let active = true;
|
||||
api
|
||||
.get("/auth/me")
|
||||
.then(() => { if (active) navigate("/jobs", { replace: true }); })
|
||||
.then(() => { if (active) navigate("/dashboard", { replace: true }); })
|
||||
.catch(() => { if (active) setChecking(false); });
|
||||
return () => { active = false; };
|
||||
}, [navigate]);
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function LoginPage() {
|
||||
const [rememberMe, setRememberMe] = useState(() => getRememberMePref());
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const nextPath = (location?.state?.from as string | undefined) ?? "/jobs";
|
||||
const nextPath = (location?.state?.from as string | undefined) ?? "/dashboard";
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
|
||||
Reference in New Issue
Block a user