Compare commits

...

11 Commits

Author SHA1 Message Date
cesnimda 717d1b9963 perf(db): add remaining hot-path indexes (status filter, correspondence/event FKs)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 20:22:23 +02:00
cesnimda 3e09e74fc8 refactor(api): extract Gmail DTOs/parsers, batch N+1 loops
- Move inline DTOs to GmailDtos.cs, pure parse helpers to GmailParsing.cs
- Batch per-message existence checks in CreateSuggestedJob/RefreshLinkedThreads
- Remove redundant second pass in RelinkThread, reuse existing HashSet
- Replace ToListAsync+scan with FirstOrDefaultAsync for GmailReviewDecisions lookups

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 20:17:33 +02:00
cesnimda 4cfdc95b59 refactor(api): extract ProfileCv DTOs, add missing AsNoTracking on reads 2026-07-12 20:12:36 +02:00
cesnimda ea6c3650f3 refactor(api): extract JobApplications DTOs and helpers, fix N+1 aggregation
- Move inline DTOs to JobApplicationDtos.cs, pure static helpers to JobApplicationHelpers.cs
- GetStats aggregates server-side (COUNT/GROUP BY) instead of loading the full table
- Cache RuleSettings via IMemoryCache, keyed per-user (RulesEngine.GetSettings falls back
  to per-user UserRuleSettings overrides, so a single global cache key would leak settings
  across users)
- Add missing AsNoTracking() to read-only GET endpoints (GetAll, GetById, GetBoard,
  GetReminders, GetStatusSuggestion, GetMatchScore, GetCandidateFit, GetFocusPlan,
  GetInterviewPrep, GetReadiness)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 20:08:59 +02:00
cesnimda bd07876a41 fix(db): stop startup crash from MySQL composite-index key length
Prod was hard-down: InitializeJobTrackerAsync threw an unhandled
MySqlException ("Specified key was too long; max key length is 3072
bytes") while creating IX_JobApplications_OwnerUserId_FollowUpAt,
which crashed Program.Main before the app could start (surfaced to
users as a 500 on Google sign-in, but really affected every request).

Root cause: this reconciler assumes OwnerUserId is varchar(255), but
the live column was provisioned wider by an earlier EF migration,
close enough to the utf8mb4 3072-byte limit that pairing it with a
second column tips a composite index over.

Fix:
- Prefix-index OwnerUserId at 191 chars (safe under the legacy
  767-byte-per-column limit, still far wider than the GUID-like
  Identity ids actually stored) in every composite/unique index that
  includes it, so index creation no longer depends on the column's
  actual declared width.
- Wrap each CREATE INDEX in try/catch + LogWarning instead of letting
  it propagate: a schema reconciler is best-effort and one failed
  index must never crash startup, matching the existing non-fatal
  pattern already used a few lines below for legacy-schema ownership
  claims.

Backend build + full test suite (177 passing) verified green.
2026-07-12 19:50:58 +02:00
cesnimda 0cd1ba398e Merge pull request 'feat(ux): product/UX review implementation (onboarding, empty states, a11y, mobile kanban)' (#27) from feat/ux-review-quick-wins into main
CI and Deploy / test (push) Successful in 2m7s
CI and Deploy / deploy (push) Successful in 40s
2026-07-12 04:30:00 +02:00
cesnimda d5d82cb528 feat(ux): onboarding checklist, dashboard-first landing (fixed)
CI and Deploy / test (pull_request) Successful in 2m5s
CI and Deploy / deploy (pull_request) Has been skipped
Dashboard onboarding checklist: a dismissible card with 3 steps (add
CV, import first job, check match score), each linking straight to
where you'd do it. Auto-hides once both CV and a job exist; otherwise
persists per-user via localStorage until dismissed.

Fixes the actual authenticated-landing redirect to /dashboard: my
earlier commit changed App.tsx's inner Shell route for "/", which
turned out to be dead code -- the outer router claims "/" for
LandingPage first, so Shell's own "/" route is never reached on a
direct hit. The real redirect lives in LandingPage.tsx's post-auth-check
navigate() and LoginPage.tsx's post-login nextPath default; both now
point at /dashboard. Verified live: an authenticated visitor hitting
"/" now lands on Dashboard with the onboarding checklist visible,
confirmed via rendered page text and screenshot.
2026-07-12 04:26:17 +02:00
cesnimda 9615ee3f41 feat(ux): per-view subtitles, correspondence cross-links, mobile kanban, a11y
Continuing the product/UX review's deferred items:

- Every top-level view now gets a one-line subtitle under its title
  (Dashboard/Jobs/Kanban/Reminders/Correspondence/Gmail review) stating
  what that specific view is for, instead of navigation being the only
  signal of what each page does.
- Correspondence inbox and Gmail review queue cross-link to each other
  instead of being two unexplained flat sidebar items -- kept both nav
  entries (renaming/nesting risked breaking muscle memory) but made the
  relationship between them explicit in the UI itself.
- Kanban board switches to a horizontal scroll-snap row on phone-width
  viewports instead of stacking all 5 columns vertically, which meant
  a lot of scrolling to see anything past "Applied".
- Match-score ring gets an aria-label with the actual percentage --
  it was two nested decorative CircularProgress elements with no
  accessible text. (Keyboard-accessible status changes on kanban cards
  were already covered by the existing "..." menu -- no gap there.)
2026-07-12 04:14:37 +02:00
cesnimda 58868fc2b6 feat(ux): first-time onboarding, empty states, and copy fixes
Implements the six "propose first" items from the product/UX review:

- "/" now redirects to /dashboard instead of the empty /jobs table --
  a new user's first screen is now an overview with orientation, not
  a data table with zero rows and four filter dropdowns.
- Jobs table gets a real first-time empty state (distinct from "no
  results match your filters") pointing at Add Job and the bookmarklet,
  instead of a bare "No jobs found."
- Match Score card and Candidate Fit tab now each get a one-line
  caption explaining what they are and how they differ (deterministic
  keyword coverage vs. AI opinion) -- they previously sat side by side
  with no explanation of why there are two.
- Google sign-in hint now reflects self-serve signup when
  Auth:AllowRegistration is on, instead of always implying you need an
  existing linked account.
- Quick Search button now shows its keyboard shortcut (Ctrl+K / ⌘K)
  inline instead of being undiscoverable.
2026-07-12 04:05:32 +02:00
cesnimda 7dadf8dde4 Merge pull request 'fix(auth): Google Sign-In audience mismatch + remove per-user accent color' (#26) from fix/google-signin-and-theming-cleanup into main
CI and Deploy / test (push) Successful in 2m5s
CI and Deploy / deploy (push) Successful in 1m10s
2026-07-12 03:06:35 +02:00
cesnimda b2e176940c Merge pull request 'fix(deploy): copy .npmrc before npm ci in frontend Dockerfile' (#25) from fix/docker-npmrc-not-copied into main
CI and Deploy / test (push) Successful in 2m1s
CI and Deploy / deploy (push) Successful in 2m13s
2026-07-12 02:18:47 +02:00
32 changed files with 1372 additions and 1134 deletions
+11
View File
@@ -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);
+34 -34
View File
@@ -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);
+14 -14
View File
@@ -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);
+2 -1
View File
@@ -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;
+70 -73
View File
@@ -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);
+30 -19
View File
@@ -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)
);
@@ -1,12 +1,10 @@
using JobTrackerApi.Services;
namespace JobTrackerApi.Services;
namespace JobTrackerApi.Controllers;
// Pure parsing/formatting helpers for GmailController, split out for readability (Wave 2 safe
// refactor -- no behaviour change). All are static and side-effect free.
public sealed partial class GmailController
// Pure parsing/formatting helpers used by GmailController, split out for readability (no
// behaviour change). All are static and side-effect free.
public static class GmailParsing
{
private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
public static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
{
var bounded = (query ?? string.Empty).Trim();
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
@@ -25,7 +23,7 @@ public sealed partial class GmailController
return bounded.Trim();
}
private static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
public static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
{
var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value)))));
if (string.IsNullOrWhiteSpace(sample)) return false;
@@ -40,7 +38,7 @@ public sealed partial class GmailController
|| sample.Contains("rejection", StringComparison.OrdinalIgnoreCase);
}
private static string ToConfidence(int score)
public static string ToConfidence(int score)
{
return score switch
{
@@ -50,21 +48,21 @@ public sealed partial class GmailController
};
}
private static string? ExtractFirstEmail(string? value)
public static string? ExtractFirstEmail(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return match.Success ? match.Value : null;
}
private static string? ExtractRecruiterName(string? value)
public static string? ExtractRecruiterName(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var trimmed = value.Split('<')[0].Trim().Trim('"');
return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed;
}
private static string? ExtractCompanyName(string? from, string? subject)
public static string? ExtractCompanyName(string? from, string? subject)
{
var subjectText = (subject ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(subjectText))
@@ -77,7 +75,7 @@ public sealed partial class GmailController
return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null;
}
private static string? ExtractRoleFromSubject(string? subject)
public static string? ExtractRoleFromSubject(string? subject)
{
if (string.IsNullOrWhiteSpace(subject)) return null;
var trimmed = subject.Trim();
@@ -88,7 +86,7 @@ public sealed partial class GmailController
return trimmed.Length <= 120 ? trimmed : trimmed[..120];
}
private static string BuildPopupHtml(bool success, string message)
public static string BuildPopupHtml(bool success, string message)
{
var escaped = System.Net.WebUtility.HtmlEncode(message);
var status = success ? "connected" : "error";
@@ -0,0 +1,622 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Services.JobImport;
namespace JobTrackerApi.Services
{
/// <summary>
/// Pure, stateless helpers extracted from JobApplicationsController. None of these touch
/// the database, AI services, or other instance state -- same inputs always produce the
/// same outputs, so they are safe to share as static methods.
/// </summary>
public static class JobApplicationHelpers
{
private const string ApplicationAnswerDraftStart = "<<<APPLICATION_ANSWER_DRAFT>>>";
private const string ApplicationAnswerDraftEnd = "<<<END_APPLICATION_ANSWER_DRAFT>>>";
public static string GetPreferredDisplayName(ApplicationUser? user)
{
if (user is null) return "Your Name";
if (!string.IsNullOrWhiteSpace(user.DisplayName)) return user.DisplayName.Trim();
var fullName = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x)));
if (!string.IsNullOrWhiteSpace(fullName)) return fullName;
if (!string.IsNullOrWhiteSpace(user.UserName)) return user.UserName.Trim();
if (!string.IsNullOrWhiteSpace(user.Email)) return user.Email.Trim();
return "Your Name";
}
public static string BuildGreeting(JobApplication job)
{
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName)) return $"Hi {job.Company.RecruiterName.Trim()},";
if (!string.IsNullOrWhiteSpace(job.Company?.Name)) return $"Hi {job.Company.Name.Trim()} team,";
return "Hi there,";
}
public static string BuildStructuredCvContext(ApplicationUser? user)
{
var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson);
var blocks = new List<string>();
var contactLines = new List<string>();
if (!string.IsNullOrWhiteSpace(structured.Contact.FullName)) contactLines.Add($"Name: {structured.Contact.FullName}");
if (!string.IsNullOrWhiteSpace(structured.Contact.Headline)) contactLines.Add($"Headline: {structured.Contact.Headline}");
if (!string.IsNullOrWhiteSpace(structured.Contact.Email)) contactLines.Add($"Email: {structured.Contact.Email}");
if (!string.IsNullOrWhiteSpace(structured.Contact.Location)) contactLines.Add($"Location: {structured.Contact.Location}");
if (!string.IsNullOrWhiteSpace(structured.Contact.LinkedIn)) contactLines.Add($"LinkedIn: {structured.Contact.LinkedIn}");
if (contactLines.Count > 0) blocks.Add($"Contact:\n{string.Join("\n", contactLines)}");
if (structured.Summary.Count > 0)
{
blocks.Add($"Summary:\n- {string.Join("\n- ", structured.Summary.Take(4))}");
}
if (structured.Skills.Count > 0)
{
blocks.Add($"Skills:\n{string.Join(", ", structured.Skills.Take(16))}");
}
if (structured.Jobs.Count > 0)
{
var jobBlocks = structured.Jobs.Take(3).Select(job =>
{
var header = string.Join(" | ", new[] { job.Title, job.Company, job.Location, FormatStructuredDateRange(job.Start, job.End, job.IsCurrent) }.Where(value => !string.IsNullOrWhiteSpace(value)));
var bullets = job.Bullets.Take(3).Select(bullet => $"- {bullet}");
return string.Join("\n", new[] { header }.Concat(bullets).Where(value => !string.IsNullOrWhiteSpace(value)));
}).Where(value => !string.IsNullOrWhiteSpace(value)).ToList();
if (jobBlocks.Count > 0) blocks.Add($"Work Experience:\n{string.Join("\n\n", jobBlocks)}");
}
if (structured.Education.Count > 0)
{
var items = structured.Education.Take(3).Select(education => string.Join(" | ", new[] { education.Qualification, education.Institution, education.Location, FormatStructuredDateRange(education.Start, education.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value))));
blocks.Add($"Education:\n- {string.Join("\n- ", items)}");
}
if (structured.Languages.Count > 0)
{
var items = structured.Languages.Take(5).Select(language => string.Join(": ", new[] { language.Name, language.Level }.Where(value => !string.IsNullOrWhiteSpace(value))));
blocks.Add($"Languages:\n- {string.Join("\n- ", items)}");
}
if (structured.OtherSections.Count > 0)
{
var items = structured.OtherSections.Take(2)
.Where(section => !string.IsNullOrWhiteSpace(section.Title) && section.Items.Count > 0)
.Select(section => $"{section.Title}: {string.Join("; ", section.Items.Take(4))}")
.ToList();
if (items.Count > 0) blocks.Add($"Other sections:\n- {string.Join("\n- ", items)}");
}
if (blocks.Count == 0 && structured.Sections.Count > 0)
{
blocks.AddRange(structured.Sections.Take(6).Select(section => $"{section.Name}:\n{section.Content}"));
}
return blocks.Count > 0
? $"Structured CV:\n{string.Join("\n\n", blocks)}"
: string.Empty;
}
public static string BuildCvSearchCorpus(ApplicationUser? user)
{
var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson);
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(user?.ProfileCvText)) parts.Add(user.ProfileCvText!);
if (!string.IsNullOrWhiteSpace(structured.Contact.Headline)) parts.Add(structured.Contact.Headline!);
if (structured.Summary.Count > 0) parts.Add(string.Join("\n", structured.Summary));
if (structured.Skills.Count > 0) parts.Add(string.Join("\n", structured.Skills));
if (structured.Jobs.Count > 0)
{
parts.Add(string.Join("\n", structured.Jobs.SelectMany(job => new[] { job.Title, job.Company, job.Location }.Where(value => !string.IsNullOrWhiteSpace(value)).Concat(job.Bullets).Concat(job.Skills))));
}
if (structured.Education.Count > 0)
{
parts.Add(string.Join("\n", structured.Education.SelectMany(education => new[] { education.Qualification, education.Institution, education.Location }.Where(value => !string.IsNullOrWhiteSpace(value)).Concat(education.Details))));
}
if (structured.Languages.Count > 0)
{
parts.Add(string.Join("\n", structured.Languages.Select(language => string.Join(" ", new[] { language.Name, language.Level, language.Notes }.Where(value => !string.IsNullOrWhiteSpace(value))))));
}
return string.Join("\n", parts.Where(part => !string.IsNullOrWhiteSpace(part)));
}
public static string? FormatStructuredDateRange(string? start, string? end, bool isCurrent)
{
if (string.IsNullOrWhiteSpace(start) && string.IsNullOrWhiteSpace(end)) return null;
if (string.IsNullOrWhiteSpace(start)) return end;
return $"{start} - {(isCurrent ? "Present" : end ?? "Present")}";
}
public static string ComputeGenerationContextHash(string value)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
public static int ScoreTailoredExperience(StructuredCvJob job, IEnumerable<string> matchedTags)
{
var corpus = string.Join("\n", new[] { job.Title, job.Company, job.Location, string.Join("\n", job.Bullets), string.Join("\n", job.Skills) }
.Where(value => !string.IsNullOrWhiteSpace(value)))
.ToLowerInvariant();
var score = 0;
foreach (var tag in matchedTags.Where(tag => !string.IsNullOrWhiteSpace(tag)))
{
if (corpus.Contains(tag.ToLowerInvariant(), StringComparison.Ordinal)) score += 4;
}
score += Math.Min(job.Bullets.Count, 4);
return score;
}
public static List<string> SelectTailoredSkills(StructuredCvProfile structured, string jobText)
{
var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var prioritized = structured.Skills
.Select(skill => new
{
Skill = skill,
Score = jobTags.Any(tag => skill.Contains(tag, StringComparison.OrdinalIgnoreCase) || tag.Contains(skill, StringComparison.OrdinalIgnoreCase)) ? 2 : 0
})
.OrderByDescending(entry => entry.Score)
.ThenBy(entry => entry.Skill, StringComparer.OrdinalIgnoreCase)
.Select(entry => entry.Skill)
.ToList();
if (prioritized.Count == 0)
{
prioritized = structured.Jobs.SelectMany(job => job.Skills).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
return prioritized.Take(10).ToList();
}
public static TailoredCvDocument BuildLegacyTailoredCvFallback(JobApplication job)
{
var text = (job.TailoredCvText ?? string.Empty).Trim();
var document = new TailoredCvDocument
{
Headline = job.JobTitle,
CustomSections = string.IsNullOrWhiteSpace(text)
? new List<TailoredCvCustomSection>()
: new List<TailoredCvCustomSection>
{
new TailoredCvCustomSection
{
Title = "Legacy draft text",
Items = text.Split(new[] { "\r\n\r\n", "\n\n" }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(),
}
}
};
return TailoredCvDraftJson.Normalize(document);
}
public static TailoredCvDraftDto ToTailoredCvDraftDto(TailoredCvDraft draft)
{
var document = TailoredCvDraftJson.FromDraft(draft);
return new TailoredCvDraftDto(
draft.Id,
draft.CanonicalProfileVersion,
draft.TemplateId,
document.Headline,
document.Summary,
document.SelectedSkills,
document.Experience,
document.Education,
document.CustomSections,
document.RenderOptions,
draft.GenerationContextHash,
draft.LastGeneratedAtUtc,
draft.LastEditedAtUtc,
draft.Status,
TailoredCvDraftJson.RenderPlainText(document),
false);
}
public static TailoredCvDraftDto ToLegacyTailoredCvDraftDto(JobApplication job)
{
var document = BuildLegacyTailoredCvFallback(job);
return new TailoredCvDraftDto(
null,
null,
"legacy-text",
document.Headline,
document.Summary,
document.SelectedSkills,
document.Experience,
document.Education,
document.CustomSections,
document.RenderOptions,
null,
null,
job.TailoredCvUpdatedAt,
string.IsNullOrWhiteSpace(job.TailoredCvText) ? "empty" : "legacy-import",
TailoredCvDraftJson.RenderPlainText(document),
true);
}
public static TailoredCvDocument BuildTailoredCvDocumentForRender(SaveTailoredCvDraftRequest? request, TailoredCvDraft? draft, JobApplication job)
{
var baseDocument = draft is not null ? TailoredCvDraftJson.FromDraft(draft) : BuildLegacyTailoredCvFallback(job);
if (request is null)
{
return baseDocument;
}
return TailoredCvDraftJson.Normalize(new TailoredCvDocument
{
TemplateId = request.TemplateId ?? baseDocument.TemplateId ?? "ats-minimal",
Headline = request.Headline ?? baseDocument.Headline,
Summary = request.Summary ?? baseDocument.Summary,
SelectedSkills = request.SelectedSkills ?? baseDocument.SelectedSkills,
Experience = request.Experience ?? baseDocument.Experience,
Education = request.Education ?? baseDocument.Education,
CustomSections = request.CustomSections ?? baseDocument.CustomSections,
RenderOptions = request.RenderOptions ?? baseDocument.RenderOptions,
});
}
public static string? ExtractSavedApplicationAnswerDraft(string? notes)
{
var value = (notes ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(value)) return null;
var startIndex = value.IndexOf(ApplicationAnswerDraftStart, StringComparison.Ordinal);
var endIndex = value.IndexOf(ApplicationAnswerDraftEnd, StringComparison.Ordinal);
if (startIndex >= 0 && endIndex > startIndex)
{
var between = value[(startIndex + ApplicationAnswerDraftStart.Length)..endIndex].Trim();
return string.IsNullOrWhiteSpace(between) ? null : between;
}
const string legacyPrefix = "Application answer draft:";
var legacyIndex = value.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase);
if (legacyIndex >= 0)
{
var legacy = value[(legacyIndex + legacyPrefix.Length)..].Trim();
return string.IsNullOrWhiteSpace(legacy) ? null : legacy;
}
return null;
}
public static string BuildFollowUpSubject(JobApplication job, Correspondence? lastMessage)
{
var subject = (lastMessage?.Subject ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(subject))
{
return subject.StartsWith("Re:", StringComparison.OrdinalIgnoreCase)
? subject
: $"Re: {subject}";
}
return $"Following up on {job.JobTitle} application";
}
public static List<string> BuildFollowUpContextSignals(JobApplication job, Correspondence? lastMessage, CorrespondenceContextResult? correspondenceContext, SavedPackageMaterial savedPackageMaterial, string? savedApplicationAnswer)
{
var signals = new List<string>();
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName)) signals.Add($"Recruiter contact: {job.Company.RecruiterName.Trim()}");
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) signals.Add($"Recruiter email on file: {job.Company.RecruiterEmail.Trim()}");
if (lastMessage is not null)
{
signals.Add($"Latest correspondence: {lastMessage.Date:yyyy-MM-dd} — {lastMessage.Subject ?? "(no subject)"}");
}
if (correspondenceContext?.Participants.Count > 0)
{
signals.Add($"Thread participants: {string.Join(", ", correspondenceContext.Participants.Take(3))}");
}
if (!string.IsNullOrWhiteSpace(savedPackageMaterial.CoverLetterText)) signals.Add("Saved cover letter available");
if (!string.IsNullOrWhiteSpace(savedPackageMaterial.RecruiterMessageDraft)) signals.Add("Saved recruiter message available");
if (!string.IsNullOrWhiteSpace(savedPackageMaterial.TailoredCvText)) signals.Add("Saved tailored CV available");
if (!string.IsNullOrWhiteSpace(savedApplicationAnswer)) signals.Add("Saved application answer available");
if (correspondenceContext is not null)
{
foreach (var signal in correspondenceContext.Signals)
{
if (!signals.Contains(signal, StringComparer.OrdinalIgnoreCase)) signals.Add(signal);
}
}
return signals.Take(6).ToList();
}
public static bool IsExtractableAttachmentExtension(string? extension)
{
return extension?.Trim().ToLowerInvariant() switch
{
".pdf" => true,
".docx" => true,
".txt" => true,
".md" => true,
".png" => true,
".jpg" => true,
".jpeg" => true,
".webp" => true,
_ => false,
};
}
public static List<string> BuildFollowUpApproach(string status, List<string> matchedTags, List<string> missingTags)
{
var normalized = (status ?? string.Empty).Trim();
var advice = new List<string>();
switch (normalized)
{
case "Applied":
advice.Add("Follow up briefly, reaffirm interest, and reference the date you applied.");
advice.Add("Mention one or two of the strongest overlaps from the posting instead of repeating your whole background.");
break;
case "Waiting":
advice.Add("Acknowledge that you are following up on next steps and keep the message light but specific.");
advice.Add("Use one proof point that shows why you remain a strong fit.");
break;
case "Interview":
case "Interviewing":
advice.Add("Focus on momentum, appreciation, and readiness for the next step.");
advice.Add("Reference a memorable point from the process, discussion, or role priorities if possible.");
break;
case "Offer":
advice.Add("Keep the tone warm and professional, and focus on clarifying next steps or timing.");
advice.Add("Avoid sounding pushy; frame the note around alignment and practical progress.");
break;
case "Rejected":
advice.Add("If appropriate, ask for feedback with a respectful and concise tone.");
advice.Add("Keep the door open for future opportunities instead of arguing the decision.");
break;
default:
advice.Add("Match the tone to the current stage and be specific about why you are following up now.");
advice.Add("Keep it concise, credible, and easy to respond to.");
break;
}
if (matchedTags.Any()) advice.Add($"Lead with relevant overlap such as {string.Join(", ", matchedTags.Take(2))}.");
if (missingTags.Any()) advice.Add($"Do not overstate areas like {string.Join(", ", missingTags.Take(2))}; frame them honestly.");
return advice.Distinct(StringComparer.OrdinalIgnoreCase).Take(5).ToList();
}
public static IEnumerable<string> SplitTags(string? s)
{
if (string.IsNullOrWhiteSpace(s)) yield break;
var trimmed = s.Trim();
List<string>? jsonTags = null;
if (trimmed.StartsWith("[") && trimmed.EndsWith("]"))
{
try
{
jsonTags = JsonSerializer.Deserialize<List<string>>(trimmed);
}
catch
{
jsonTags = null;
}
}
if (jsonTags is not null)
{
foreach (var x in jsonTags)
{
var t = (x ?? string.Empty).Trim();
if (t.Length == 0) continue;
yield return t;
}
yield break;
}
foreach (var raw in trimmed.Split(new[] { ',', ';', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries))
{
var t = raw.Trim();
if (t.Length == 0) continue;
yield return t;
}
}
public static string NormalizeForComparison(string value)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
return new string(value.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray());
}
public static string BuildSummarySource(JobApplication job)
{
// Prefer translated text for summaries and skill extraction so non-English
// postings become easier to understand while keeping the original text intact.
var parts = new[]
{
job.TranslatedDescription,
job.Description,
job.Notes
};
return string.Join("\n\n", parts.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!.Trim()));
}
public static string? NormalizeTags(string? raw)
{
var normalized = SplitTags(raw)
.Select(tag => tag.Trim())
.Where(tag => tag.Length > 0)
.GroupBy(tag => tag, StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var first = group.First();
return string.Join(" ", first.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(part => char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant()));
})
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
.ToList();
return normalized.Count == 0 ? null : JsonSerializer.Serialize(normalized);
}
public static string? NormalizeUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url)) return null;
var value = url.Trim();
return Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri.ToString() : value;
}
public static string RemoveSavedApplicationAnswerDraft(string? notes)
{
var value = notes ?? string.Empty;
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
var startIndex = value.IndexOf(ApplicationAnswerDraftStart, StringComparison.Ordinal);
var endIndex = value.IndexOf(ApplicationAnswerDraftEnd, StringComparison.Ordinal);
if (startIndex >= 0 && endIndex > startIndex)
{
var before = value[..startIndex].Trim();
var after = value[(endIndex + ApplicationAnswerDraftEnd.Length)..].Trim();
return string.Join("\n\n", new[] { before, after }.Where(part => !string.IsNullOrWhiteSpace(part))).Trim();
}
const string legacyPrefix = "Application answer draft:";
var legacyIndex = value.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase);
if (legacyIndex >= 0)
{
return value[..legacyIndex].Trim();
}
return value.Trim();
}
public static bool HasInterviewPrepNotes(string? notes) => !string.IsNullOrWhiteSpace(RemoveSavedApplicationAnswerDraft(notes));
public static bool IsInterviewStage(string status) =>
status.Contains("Interview", StringComparison.OrdinalIgnoreCase);
public static bool IsActiveWorkflowStatus(string status)
{
var normalized = (status ?? string.Empty).Trim();
return normalized switch
{
"Applied" => true,
"Waiting" => true,
"Interview" => true,
"Interviewing" => true,
"Offer" => true,
_ => false,
};
}
public static WorkflowSignalDto BuildWorkflowSignal(JobApplication job, FollowUpDecision followUpDecision)
{
var hasTailoredCv = !string.IsNullOrWhiteSpace(job.TailoredCvText);
var hasSavedApplicationAnswerDraft = !string.IsNullOrWhiteSpace(ExtractSavedApplicationAnswerDraft(job.Notes));
var hasInterviewPrepNotes = HasInterviewPrepNotes(job.Notes);
var needsInterviewPrep = IsInterviewStage(job.Status) && !hasInterviewPrepNotes;
var hasPackageGap = IsActiveWorkflowStatus(job.Status) && (!hasTailoredCv || !hasSavedApplicationAnswerDraft);
var needsFollowUpAction = followUpDecision.NeedsFollowUp || (!job.ResponseReceived && job.FollowUpAt is null);
if (needsInterviewPrep)
{
return new WorkflowSignalDto(
ActionKey: "interview-prep",
Reason: "Interview stage reached but prep notes are still missing.",
WorkspaceTab: "interview-prep",
FollowMode: null,
NeedsAttention: true,
HasPackageGap: hasPackageGap,
NeedsInterviewPrep: true,
NeedsFollowUpAction: needsFollowUpAction,
HasTailoredCv: hasTailoredCv,
HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft,
HasInterviewPrepNotes: hasInterviewPrepNotes);
}
if (hasPackageGap)
{
var reason = !hasTailoredCv && !hasSavedApplicationAnswerDraft
? "Tailored CV and saved application answers still need work."
: !hasTailoredCv
? "Tailored CV missing for this role."
: "Saved application answers still need work.";
return new WorkflowSignalDto(
ActionKey: "package-work",
Reason: reason,
WorkspaceTab: "tailored-cv",
FollowMode: null,
NeedsAttention: true,
HasPackageGap: true,
NeedsInterviewPrep: needsInterviewPrep,
NeedsFollowUpAction: needsFollowUpAction,
HasTailoredCv: hasTailoredCv,
HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft,
HasInterviewPrepNotes: hasInterviewPrepNotes);
}
if (needsFollowUpAction)
{
var reason = !string.IsNullOrWhiteSpace(followUpDecision.Reason)
? followUpDecision.Reason!
: !job.ResponseReceived && job.FollowUpAt is null
? "No response yet and no follow-up is scheduled."
: "Follow-up is due for this role.";
return new WorkflowSignalDto(
ActionKey: "follow-up",
Reason: reason,
WorkspaceTab: "follow-up",
FollowMode: "waiting-update",
NeedsAttention: true,
HasPackageGap: hasPackageGap,
NeedsInterviewPrep: needsInterviewPrep,
NeedsFollowUpAction: true,
HasTailoredCv: hasTailoredCv,
HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft,
HasInterviewPrepNotes: hasInterviewPrepNotes);
}
return new WorkflowSignalDto(
ActionKey: "review-readiness",
Reason: "No urgent workflow gaps are blocking this job right now.",
WorkspaceTab: "readiness",
FollowMode: null,
NeedsAttention: false,
HasPackageGap: hasPackageGap,
NeedsInterviewPrep: needsInterviewPrep,
NeedsFollowUpAction: needsFollowUpAction,
HasTailoredCv: hasTailoredCv,
HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft,
HasInterviewPrepNotes: hasInterviewPrepNotes);
}
public static List<string> BuildReadinessReminders(JobApplication job, WorkflowSignalDto workflowSignal)
{
var reminders = new List<string>();
if (workflowSignal.HasPackageGap)
{
reminders.Add(workflowSignal.HasTailoredCv
? "Saved application answers are still missing from the package."
: workflowSignal.HasSavedApplicationAnswerDraft
? "This role is active but still missing a tailored CV."
: "This role is active but still needs a tailored CV and saved application answers.");
}
if (workflowSignal.NeedsInterviewPrep)
{
reminders.Add("Interview stage reached but prep notes are still missing.");
}
if (workflowSignal.NeedsFollowUpAction)
{
reminders.Add(job.FollowUpAt is null
? "No response yet and no follow-up is scheduled."
: workflowSignal.Reason);
}
return reminders
.Where(reminder => !string.IsNullOrWhiteSpace(reminder))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
}
}
@@ -688,6 +688,17 @@ public static class StartupInitializationExtensions
{
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_IsDeleted" ON "JobApplications" ("OwnerUserId", "IsDeleted");""");
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_FollowUpAt" ON "JobApplications" ("OwnerUserId", "FollowUpAt");""");
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_IsDeleted_Status" ON "JobApplications" ("OwnerUserId", "IsDeleted", "Status");""");
}
if (HasTable(conn, "Correspondences"))
{
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_Correspondences_JobApplicationId" ON "Correspondences" ("JobApplicationId");""");
}
if (HasTable(conn, "JobEvents"))
{
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobEvents_JobApplicationId" ON "JobEvents" ("JobApplicationId");""");
}
// Ensure data folder exists before creating/opening SQLite files.
@@ -977,105 +988,66 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
// Schema reconciliation must never crash app startup: an index that fails
// (e.g. combined key exceeds MySQL's 3072-byte limit because an older
// migration made OwnerUserId wider than the varchar(255) this reconciler
// assumes) is logged and skipped rather than taking prod down. OwnerUserId
// is prefix-indexed at 191 chars (safe under utf8mb4's 767-byte legacy
// per-column key limit, and far longer than the GUID-like Identity ids
// actually stored there) so composite indexes stay well under the cap
// regardless of the column's declared width.
void TryCreateIndex(string table, string indexName, string columnsSql)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_Companies_OwnerUserId` ON `Companies` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
if (MySqlIndexExists(conn, table, indexName)) return;
try
{
using var cmd = conn.CreateCommand();
cmd.CommandText = $"CREATE INDEX `{indexName}` ON `{table}` ({columnsSql});";
cmd.ExecuteNonQuery();
}
catch (Exception ex)
{
app.Logger.LogWarning(ex, "Skipping index {Index} on {Table} during startup reconciliation.", indexName, table);
}
}
if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId"))
void TryCreateUniqueIndex(string table, string indexName, string columnsSql)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId` ON `JobApplications` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
if (MySqlIndexExists(conn, table, indexName)) return;
try
{
using var cmd = conn.CreateCommand();
cmd.CommandText = $"CREATE UNIQUE INDEX `{indexName}` ON `{table}` ({columnsSql});";
cmd.ExecuteNonQuery();
}
catch (Exception ex)
{
app.Logger.LogWarning(ex, "Skipping unique index {Index} on {Table} during startup reconciliation.", indexName, table);
}
}
TryCreateIndex("Companies", "IX_Companies_OwnerUserId", "`OwnerUserId`(191)");
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId", "`OwnerUserId`(191)");
// Hot-path composite indexes for tenant-scoped list/board/stats/analytics
// (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt).
if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId_IsDeleted` ON `JobApplications` (`OwnerUserId`, `IsDeleted`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId_FollowUpAt"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId_FollowUpAt` ON `JobApplications` (`OwnerUserId`, `FollowUpAt`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc` ON `CvUploadArtifacts` (`OwnerUserId`, `UploadedAtUtc`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "CvExtractionRuns", "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_OwnerUserId_StartedAtUtc` ON `CvExtractionRuns` (`OwnerUserId`, `StartedAtUtc`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "CvExtractionRuns", "IX_CvExtractionRuns_ArtifactId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_ArtifactId` ON `CvExtractionRuns` (`ArtifactId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "GmailConnections", "IX_GmailConnections_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_GmailConnections_OwnerUserId` ON `GmailConnections` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "GmailConnections", "IX_GmailConnections_OwnerUserId_GmailAddress"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_GmailConnections_OwnerUserId_GmailAddress` ON `GmailConnections` (`OwnerUserId`, `GmailAddress`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_MicrosoftGraphConnections_OwnerUserId` ON `MicrosoftGraphConnections` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_MicrosoftGraphConnections_OwnerUserId_MailAddress` ON `MicrosoftGraphConnections` (`OwnerUserId`, `MailAddress`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "ImapConnections", "IX_ImapConnections_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_ImapConnections_OwnerUserId` ON `ImapConnections` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_TailoredCvDrafts_OwnerUserId_JobApplicationId` ON `TailoredCvDrafts` (`OwnerUserId`, `JobApplicationId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_TailoredCvDrafts_JobApplicationId` ON `TailoredCvDrafts` (`JobApplicationId`);";
cmd.ExecuteNonQuery();
}
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted", "`OwnerUserId`(191), `IsDeleted`");
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_FollowUpAt", "`OwnerUserId`(191), `FollowUpAt`");
// Status is longtext in MySQL (see JobTrackerContext.OnModelCreating), so it
// needs an explicit prefix length to be indexable under MariaDB's key-length rules.
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted_Status", "`OwnerUserId`(191), `IsDeleted`, `Status`(50)");
TryCreateIndex("Correspondences", "IX_Correspondences_JobApplicationId", "`JobApplicationId`");
TryCreateIndex("JobEvents", "IX_JobEvents_JobApplicationId", "`JobApplicationId`");
TryCreateIndex("CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc", "`OwnerUserId`(191), `UploadedAtUtc`");
TryCreateIndex("CvExtractionRuns", "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc", "`OwnerUserId`(191), `StartedAtUtc`");
TryCreateIndex("CvExtractionRuns", "IX_CvExtractionRuns_ArtifactId", "`ArtifactId`");
TryCreateIndex("GmailConnections", "IX_GmailConnections_OwnerUserId", "`OwnerUserId`(191)");
TryCreateUniqueIndex("GmailConnections", "IX_GmailConnections_OwnerUserId_GmailAddress", "`OwnerUserId`(191), `GmailAddress`(191)");
TryCreateIndex("MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId", "`OwnerUserId`(191)");
TryCreateUniqueIndex("MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress", "`OwnerUserId`(191), `MailAddress`(191)");
TryCreateUniqueIndex("ImapConnections", "IX_ImapConnections_OwnerUserId", "`OwnerUserId`(191)");
TryCreateUniqueIndex("TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId", "`OwnerUserId`(191), `JobApplicationId`");
TryCreateIndex("TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId", "`JobApplicationId`");
}
}
+23 -3
View File
@@ -100,6 +100,16 @@ function titleFor(path: string, t: (k: any) => string): string {
return t("appTitle");
}
function subtitleFor(path: string, t: (k: any) => string): string | undefined {
if (path === "/dashboard") return t("dashboardPageSubtitle");
if (path.startsWith("/jobs")) return t("jobsPageSubtitle");
if (path.startsWith("/kanban")) return t("kanbanPageSubtitle");
if (path.startsWith("/reminders")) return t("remindersPageSubtitle");
if (path.startsWith("/correspondence/review")) return t("gmailReviewPageSubtitle");
if (path.startsWith("/correspondence")) return t("correspondencePageSubtitle");
return undefined;
}
function PageLoader() {
return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
}
@@ -123,6 +133,9 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
const path = location.pathname;
const isJobs = path.startsWith("/jobs");
const shortcutHint = useMemo(() => (
typeof navigator !== "undefined" && /Mac|iPhone|iPod|iPad/.test(navigator.platform) ? "⌘K" : "Ctrl+K"
), []);
useEffect(() => {
api.get<AuthConfig>("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false));
@@ -206,6 +219,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
if (requireAuth && !me) return <Navigate to="/" replace state={{ from: path }} />;
const pageTitle = titleFor(path, t);
const pageSubtitle = subtitleFor(path, t);
const breadcrumbs = breadcrumbsFor(path, t);
const setAndPersistPageSize = (n: 15 | 20 | 25) => { setJobPageSize(n); window.localStorage.setItem("jobPageSize", String(n)); };
const setAndPersistColumns = (next: JobTableColumns) => { setJobColumns(next); window.localStorage.setItem("jobColumns", JSON.stringify(next)); };
@@ -246,14 +260,19 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<IconButton
color="secondary"
size="small"
title={t("quickSearch")}
title={`${t("quickSearch")} (${shortcutHint})`}
onClick={() => setQuickOpen(true)}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42, flex: "0 0 auto" }}
>
<SearchIcon fontSize="small" />
</IconButton>
) : (
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)}>{t("quickSearch")}</Button>
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)} sx={{ gap: 0.5 }}>
{t("quickSearch")}
<Box component="span" sx={{ ml: 0.75, px: 0.75, py: 0.125, borderRadius: 1, border: "1px solid", borderColor: "divider", fontSize: 11, fontWeight: 700, color: "text.secondary", lineHeight: 1.6 }}>
{shortcutHint}
</Box>
</Button>
)}
{isJobs ? (
<Button variant="contained" onClick={() => setAddOpen(true)} sx={{ flex: { xs: 1, sm: "0 0 auto" }, minHeight: 42 }}>
@@ -267,6 +286,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<>
<AppShell
pageTitle={pageTitle}
pageSubtitle={pageSubtitle}
breadcrumbs={breadcrumbs}
pathname={path}
nav={nav}
@@ -284,7 +304,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Navigate to="/jobs" replace />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardView />} />
<Route path="/jobs" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="jobs" />} />
<Route path="/reminders" element={<RemindersView />} />
@@ -23,6 +23,7 @@ import AutoGraphIcon from "@mui/icons-material/AutoGraph";
import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import OnboardingChecklist from "./OnboardingChecklist";
import { getUserKeyFromToken } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
import { statusLabel } from "../pipeline";
@@ -287,6 +288,7 @@ export default function DashboardView() {
return (
<Box>
<OnboardingChecklist hasJobs={(stats?.total ?? 0) > 0} />
<SectionCard
sx={{
backgroundColor: "background.paper",
@@ -51,6 +51,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
const { t } = useI18n();
const [me, setMe] = useState<MeResponse | null>(null);
const [working, setWorking] = useState(false);
const [allowRegistration, setAllowRegistration] = useState(false);
const hostRef = useRef<HTMLDivElement | null>(null);
const clientId = (process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "").trim();
@@ -72,6 +73,9 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
useEffect(() => {
void refreshMe();
api.get<{ allowRegistration: boolean }>("/auth/config").then((res) => {
setAllowRegistration(Boolean(res.data?.allowRegistration));
}).catch(() => setAllowRegistration(false));
}, []);
useEffect(() => {
@@ -156,7 +160,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
{!signedIn ? (
<Typography sx={{ color: "text.secondary" }}>
{t("googleSignInHint")}
{allowRegistration ? t("googleSignInHintSelfServe") : t("googleSignInHint")}
</Typography>
) : me?.provider === "local" ? (
<Typography sx={{ color: "text.secondary" }}>
@@ -1132,6 +1132,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Box><Typography variant="overline">{t("jobDetailsHowYouMatch")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{candidateFit.matchSummary}</Typography></Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
@@ -1230,13 +1231,14 @@ function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading:
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", gap: 2.5, alignItems: "center", flexWrap: "wrap", mb: 1.5 }}>
{score.hasEnoughSignal ? (
<Box sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
<CircularProgress variant="determinate" value={100} size={92} thickness={4} sx={{ color: "divider", position: "absolute" }} />
<Box role="img" aria-label={`${t("matchScoreTitle")}: ${score.score}%`} sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
<CircularProgress variant="determinate" value={100} size={92} thickness={4} aria-hidden="true" sx={{ color: "divider", position: "absolute" }} />
<CircularProgress
variant="determinate"
value={score.score}
size={92}
thickness={4}
aria-hidden="true"
color={color === "inherit" ? "primary" : color}
sx={{ position: "absolute", "& .MuiCircularProgress-circle": { strokeLinecap: "round" } }}
/>
+25 -2
View File
@@ -110,6 +110,19 @@ function parseTags(raw?: string | null): string[] {
}
function EmptyJobsState({ firstTime, onOpenSettings, t }: { firstTime: boolean; onOpenSettings: () => void; t: (key: any) => string }) {
if (!firstTime) {
return <Typography sx={{ py: 2, textAlign: "center", color: "text.secondary" }}>{t("jobTableNoJobsFound")}</Typography>;
}
return (
<Box sx={{ py: 4, textAlign: "center" }}>
<Typography sx={{ fontWeight: 800, mb: 0.5 }}>{t("jobTableEmptyFirstTimeTitle")}</Typography>
<Typography sx={{ color: "text.secondary", mb: 1.5, maxWidth: 440, mx: "auto" }}>{t("jobTableEmptyFirstTimeBody")}</Typography>
<Button variant="text" onClick={onOpenSettings}>{t("jobTableEmptyFirstTimeBookmarklet")}</Button>
</Box>
);
}
function generateOverview(job: JobApplication): string {
if (job.fullSummary) return job.fullSummary;
if (job.shortSummary) return job.shortSummary;
@@ -220,6 +233,12 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
return jobs.filter((job) => needsWorkflowWork(job));
}, [jobs, readinessFilter]);
// Distinguishes "you have zero jobs, period" from "no results match your filters" so the
// empty state can actually help a first-time user instead of just saying "nothing here".
const noFiltersActive = !debouncedSearch.trim() && statusFilter === "All" && companyFilterId === "All"
&& !debouncedLocation.trim() && !needsFollowUpOnly && readinessFilter === "all";
const isFirstTimeEmpty = mode === "jobs" && total === 0 && noFiltersActive;
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const selectedAllOnPage = filteredJobs.length > 0 && filteredJobs.every((job) => selectedIdSet.has(job.id));
@@ -629,7 +648,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</Paper>
);
})}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography> : null}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? (
<EmptyJobsState firstTime={isFirstTimeEmpty} onOpenSettings={() => navigate("/settings")} t={t} />
) : null}
</Stack>
) : (
<Box sx={{ overflowX: "auto" }}>
@@ -721,7 +742,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
</React.Fragment>
);
})}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null}
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? (
<TableRow><TableCell colSpan={visibleDesktopColumns}><EmptyJobsState firstTime={isFirstTimeEmpty} onOpenSettings={() => navigate("/settings")} t={t} /></TableCell></TableRow>
) : null}
</TableBody>
</Table>
</Box>
+14 -1
View File
@@ -101,7 +101,18 @@ export default function KanbanBoard() {
/>
{!jobsResource.loading && !jobsResource.error ? (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" }, gap: 2, alignItems: "start" }}>
<Box
sx={{
display: { xs: "flex", md: "grid" },
gridTemplateColumns: { md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" },
gap: 2,
alignItems: "start",
overflowX: { xs: "auto", md: "visible" },
scrollSnapType: { xs: "x mandatory", md: "none" },
pb: { xs: 1, md: 0 },
"-webkit-overflow-scrolling": "touch",
}}
>
{STATUSES.map((status) => {
const c = toneColor(theme, status);
const list = groups.get(status) ?? [];
@@ -114,6 +125,8 @@ export default function KanbanBoard() {
p: 1.5,
borderRadius: 3,
minHeight: 220,
flex: { xs: "0 0 85vw", md: "none" },
scrollSnapAlign: { xs: "start", md: "none" },
border: "1px solid",
borderColor: "divider",
background: theme.palette.mode === "dark" ? alpha(theme.palette.common.white, 0.02) : alpha(theme.palette.text.primary, 0.015),
@@ -0,0 +1,81 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, IconButton, Paper, Stack, Typography } from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";
import CloseIcon from "@mui/icons-material/Close";
import { alpha, useTheme } from "@mui/material/styles";
import { api } from "../api";
import { getUserKeyFromToken } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
function dismissKey() {
return `onboardingChecklistDismissed:${getUserKeyFromToken()}`;
}
type MeResponse = { profileCvText?: string | null };
export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
const theme = useTheme();
const navigate = useNavigate();
const { t } = useI18n();
const [hasCv, setHasCv] = useState<boolean | null>(null);
const [dismissed, setDismissed] = useState(() => window.localStorage.getItem(dismissKey()) === "1");
useEffect(() => {
let active = true;
api.get<MeResponse>("/auth/me")
.then((r) => { if (active) setHasCv(Boolean(r.data?.profileCvText?.trim())); })
.catch(() => { if (active) setHasCv(false); });
return () => { active = false; };
}, []);
const allDone = hasCv === true && hasJobs;
if (dismissed || allDone || hasCv === null) return null;
const dismiss = () => {
window.localStorage.setItem(dismissKey(), "1");
setDismissed(true);
};
const steps = [
{ done: hasCv, label: t("onboardingStepCv"), action: () => navigate("/profile"), actionLabel: t("onboardingStepCvAction") },
{ done: hasJobs, label: t("onboardingStepJob"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepJobAction") },
{ done: hasCv === true && hasJobs, label: t("onboardingStepMatch"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepMatchAction") },
];
return (
<Paper
sx={{
p: 2.25,
mb: 2,
borderRadius: 4,
border: "1px solid",
borderColor: alpha(theme.palette.primary.main, 0.25),
background: alpha(theme.palette.primary.main, 0.04),
position: "relative",
}}
>
<IconButton size="small" onClick={dismiss} aria-label={t("onboardingDismiss")} sx={{ position: "absolute", top: 8, right: 8 }}>
<CloseIcon fontSize="small" />
</IconButton>
<Typography sx={{ fontWeight: 900, mb: 0.25 }}>{t("onboardingTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("onboardingBody")}</Typography>
<Stack spacing={1}>
{steps.map((step) => (
<Box key={step.label} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{step.done ? <CheckCircleIcon fontSize="small" color="success" /> : <RadioButtonUncheckedIcon fontSize="small" sx={{ color: "text.secondary" }} />}
<Typography variant="body2" sx={{ fontWeight: step.done ? 400 : 700, color: step.done ? "text.secondary" : "text.primary", textDecoration: step.done ? "line-through" : "none" }}>
{step.label}
</Typography>
</Box>
{!step.done ? <Button size="small" variant="text" onClick={step.action}>{step.actionLabel}</Button> : null}
</Box>
))}
</Stack>
</Paper>
);
}
+42 -2
View File
@@ -18,6 +18,12 @@ export const translations = {
home: "Home",
analytics: "Analytics",
overview: "Overview",
dashboardPageSubtitle: "Your search at a glance — response rate, funnel, and what needs attention.",
jobsPageSubtitle: "Filter, search, and manage every application in one table.",
kanbanPageSubtitle: "Drag a card between stages to update its status.",
remindersPageSubtitle: "Everything due for follow-up, soonest first.",
correspondencePageSubtitle: "Every message linked to a job, in one inbox.",
gmailReviewPageSubtitle: "Review Gmail threads before linking them to a job.",
account: "Account",
profile: "Profile",
admin: "Admin",
@@ -306,6 +312,15 @@ export const translations = {
cropDialogSave: "Save image",
dashboardOverviewTitle: "Dashboard overview",
dashboardHeroLabel: "Job search overview",
onboardingTitle: "Get set up",
onboardingBody: "A few steps to get the most out of Jobbjakt.",
onboardingDismiss: "Dismiss",
onboardingStepCv: "Add your CV",
onboardingStepCvAction: "Add CV",
onboardingStepJob: "Import your first job",
onboardingStepJobAction: "Add job",
onboardingStepMatch: "Check your CV match score on a job",
onboardingStepMatchAction: "Open jobs",
dashboardResponseRate: "{rate}% response rate",
dashboardMonthsShort: "{count} mo",
dashboardAppliedCount: "{count} applied",
@@ -606,6 +621,7 @@ export const translations = {
googleAvailableToLink: "Available to link",
googleLinkedDate: "Linked {date}",
googleSignInHint: "Sign in with a Google account that has already been linked to your Jobbjakt user.",
googleSignInHintSelfServe: "Continue with Google. New here? We'll create your account automatically.",
continueWithGoogle: "Continue with Google",
signInWithGoogle: "Sign in with Google",
linkWithGoogle: "Link with Google",
@@ -750,6 +766,9 @@ export const translations = {
jobTableOverview: "Overview",
jobTableNoSummaryYet: "No summary yet.",
jobTableNoJobsFound: "No jobs found.",
jobTableEmptyFirstTimeTitle: "No jobs yet — let's fix that.",
jobTableEmptyFirstTimeBody: "Click \"Add job\" above to add one manually, or paste a job posting URL. There's also a one-click bookmarklet that captures a posting straight from the page you're viewing.",
jobTableEmptyFirstTimeBookmarklet: "Set up the bookmarklet",
jobTableSetStatus: "Set {status}",
editJobTitle: "Edit job",
editJobIntro: "Update job details, timeline status, documents, and notes from one editing workspace.",
@@ -894,6 +913,7 @@ export const translations = {
jobDetailsFollowUpSent: "Follow-up sent and logged.",
jobDetailsFollowUpSendFailed: "Failed to send follow-up.",
jobDetailsHowYouMatch: "How you match",
jobDetailsAiFitHint: "AI opinion — strengths, gaps, and a tailored pitch based on your CV and this posting.",
matchScoreTitle: "Match score",
matchScoreLoading: "Scoring your CV against this role…",
matchScoreBand_Strong: "Strong match",
@@ -902,7 +922,7 @@ export const translations = {
matchScoreBand_Unknown: "Not enough signal",
matchScoreKeywordsCovered: "{matched}/{total} keywords",
matchScoreNoSignal: "Add more CV detail or a fuller job description to get a reliable score.",
matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable.",
matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable. For a written opinion on strengths and gaps, see the AI section below.",
matchScoreMatched: "Matched keywords",
matchScoreMissing: "Missing keywords",
matchScoreNoneYet: "No matches found yet.",
@@ -976,6 +996,12 @@ export const translations = {
home: "Hjem",
analytics: "Analyse",
overview: "Oversikt",
dashboardPageSubtitle: "Søket ditt i korte trekk — svarrate, trakt og hva som trenger oppmerksomhet.",
jobsPageSubtitle: "Filtrer, søk og administrer alle søknader i én tabell.",
kanbanPageSubtitle: "Dra et kort mellom stadier for å oppdatere status.",
remindersPageSubtitle: "Alt som trenger oppfølging, snarest først.",
correspondencePageSubtitle: "Alle meldinger koblet til en jobb, i én innboks.",
gmailReviewPageSubtitle: "Se gjennom Gmail-tråder før du kobler dem til en jobb.",
account: "Konto",
profile: "Profil",
admin: "Admin",
@@ -1264,6 +1290,15 @@ export const translations = {
cropDialogSave: "Lagre bilde",
dashboardOverviewTitle: "Dashboard-oversikt",
dashboardHeroLabel: "Oversikt over jobbsøket",
onboardingTitle: "Kom i gang",
onboardingBody: "Noen få steg for å få mest mulig ut av Jobbjakt.",
onboardingDismiss: "Lukk",
onboardingStepCv: "Legg til CV-en din",
onboardingStepCvAction: "Legg til CV",
onboardingStepJob: "Importer din første jobb",
onboardingStepJobAction: "Legg til jobb",
onboardingStepMatch: "Sjekk CV-matchscoren på en jobb",
onboardingStepMatchAction: "Åpne jobber",
dashboardResponseRate: "{rate}% svarrate",
dashboardMonthsShort: "{count} md",
dashboardAppliedCount: "{count} søkt",
@@ -1564,6 +1599,7 @@ export const translations = {
googleAvailableToLink: "Tilgjengelig for kobling",
googleLinkedDate: "Koblet {date}",
googleSignInHint: "Logg inn med en Google-konto som allerede er koblet til Jobbjakt-brukeren din.",
googleSignInHintSelfServe: "Fortsett med Google. Ny her? Vi oppretter kontoen din automatisk.",
continueWithGoogle: "Fortsett med Google",
signInWithGoogle: "Logg inn med Google",
linkWithGoogle: "Koble til med Google",
@@ -1708,6 +1744,9 @@ export const translations = {
jobTableOverview: "Oversikt",
jobTableNoSummaryYet: "Ingen oppsummering ennå.",
jobTableNoJobsFound: "Ingen jobber funnet.",
jobTableEmptyFirstTimeTitle: "Ingen jobber ennå — la oss fikse det.",
jobTableEmptyFirstTimeBody: "Klikk \"Legg til jobb\" over for å legge til en manuelt, eller lim inn en lenke til en stillingsannonse. Det finnes også et bokmerke som fanger en annonse rett fra siden du ser på.",
jobTableEmptyFirstTimeBookmarklet: "Sett opp bokmerket",
jobTableSetStatus: "Sett {status}",
editJobTitle: "Rediger jobb",
editJobIntro: "Oppdater jobbdetaljer, status i tidslinjen, dokumenter og notater fra ett redigeringsområde.",
@@ -1852,6 +1891,7 @@ export const translations = {
jobDetailsFollowUpSent: "Oppfølging sendt og loggført.",
jobDetailsFollowUpSendFailed: "Kunne ikke sende oppfølging.",
jobDetailsHowYouMatch: "Slik matcher du",
jobDetailsAiFitHint: "AI-vurdering — styrker, svakheter og et skreddersydd pitch basert på CV-en din og denne annonsen.",
matchScoreTitle: "Match-score",
matchScoreLoading: "Vurderer CV-en mot denne stillingen…",
matchScoreBand_Strong: "Sterk match",
@@ -1860,7 +1900,7 @@ export const translations = {
matchScoreBand_Unknown: "For lite grunnlag",
matchScoreKeywordsCovered: "{matched}/{total} nøkkelord",
matchScoreNoSignal: "Legg til mer CV-innhold eller en fyldigere stillingstekst for en pålitelig score.",
matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar.",
matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar. For en skriftlig vurdering av styrker og svakheter, se AI-seksjonen under.",
matchScoreMatched: "Treff på nøkkelord",
matchScoreMissing: "Manglende nøkkelord",
matchScoreNoneYet: "Ingen treff ennå.",
+7
View File
@@ -59,6 +59,7 @@ const SIDEBAR_SELECTED_ICON = "#a5b4fc";
export default function AppShell({
pageTitle,
pageSubtitle,
breadcrumbs,
pathname,
nav,
@@ -76,6 +77,7 @@ export default function AppShell({
children,
}: {
pageTitle: string;
pageSubtitle?: string;
breadcrumbs: string[];
pathname: string;
nav: NavItem[];
@@ -481,6 +483,11 @@ export default function AppShell({
<Typography variant="h5" sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>
{pageTitle}
</Typography>
{pageSubtitle ? (
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, overflowWrap: "anywhere" }}>
{pageSubtitle}
</Typography>
) : null}
</Box>
</Box>
@@ -82,10 +82,11 @@ export default function CorrespondenceInboxPage() {
Cross-job view of imported correspondence and Gmail-linked history.
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip icon={<MailOutlineIcon />} label={`${items.length} items`} variant="outlined" />
<Chip label={`${filteredSummary.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} />
<Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" />
<Button variant="outlined" size="small" onClick={() => navigate("/correspondence/review")}>Review Gmail queue</Button>
</Box>
</Box>
@@ -138,6 +138,7 @@ export default function GmailReviewPage() {
<Button variant="outlined" onClick={() => void load()} disabled={loading || syncing}>
{loading ? "Loading..." : "Refresh"}
</Button>
<Button variant="text" onClick={() => navigate("/correspondence")}>Back to inbox</Button>
</Box>
</Box>
+1 -1
View File
@@ -50,7 +50,7 @@ export default function LandingPage() {
let active = true;
api
.get("/auth/me")
.then(() => { if (active) navigate("/jobs", { replace: true }); })
.then(() => { if (active) navigate("/dashboard", { replace: true }); })
.catch(() => { if (active) setChecking(false); });
return () => { active = false; };
}, [navigate]);
+1 -1
View File
@@ -33,7 +33,7 @@ export default function LoginPage() {
const [rememberMe, setRememberMe] = useState(() => getRememberMePref());
const [loading, setLoading] = useState(false);
const nextPath = (location?.state?.from as string | undefined) ?? "/jobs";
const nextPath = (location?.state?.from as string | undefined) ?? "/dashboard";
useEffect(() => {
api