Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 717d1b9963 | |||
| 3e09e74fc8 | |||
| 4cfdc95b59 | |||
| ea6c3650f3 |
@@ -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.
|
||||
@@ -1022,6 +1033,11 @@ public static class StartupInitializationExtensions
|
||||
// (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt).
|
||||
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`");
|
||||
|
||||
Reference in New Issue
Block a user