Complete S02 application package drafting loop
This commit is contained in:
+1
-1
@@ -11,4 +11,4 @@
|
||||
| D003 | M001 | scope | Primary user | Individual job seeker | The product is designed for individuals managing their own search, not recruiter or team workflows. | Yes — if product direction changes later | collaborative |
|
||||
| D004 | M001 | pattern | Daily navigation hierarchy | Job table first, then follow-up/dashboard, then individual job workspace | The user explicitly described this as the intended control flow for daily use. | Yes — if real usage disproves the hierarchy | collaborative |
|
||||
| D005 | M001 | roadmap | First milestone focus | Prioritize Gmail import quality and AI draft quality before broader expansion | The user identified Gmail import and AI drafts as the weakest current areas and the first bar for daily use. | Yes — if execution proves another blocker is more fundamental | collaborative |
|
||||
| D006 | M001/S01 planning | architecture | How S01 will improve Gmail import matching | Add a job-scoped backend Gmail matching contract and persist Gmail thread/message metadata on correspondence instead of relying on client-only scoring | Backend ranking can use owned job, company, recruiter, and prior correspondence context consistently, gives the UI explicit match reasons and duplicate state, and preserves thread continuity for later reply/follow-up slices without changing the no-auto-send boundary. | Yes — if real Gmail usage shows the ranking contract or stored metadata is insufficient | agent |
|
||||
| D006 | M001/S02 | workspace-persistence | How the saved application answer draft should persist inside the job workspace before a dedicated field exists | Store the application answer draft in a replaceable notes block and make SaveApplicationDrafts overwrite notes when notes are explicitly provided | The existing append-only notes behavior made the Tailored CV workspace untrustworthy because repeated saves duplicated the application answer indefinitely. A replaceable notes block preserves current schema compatibility while giving the workspace a stable saved/read-back loop for later slices. | Yes | agent |
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ The product must let one person run a real job search without losing the thread:
|
||||
|
||||
## Current State
|
||||
|
||||
A substantial brownfield app already exists. The repo has a React frontend, an ASP.NET Core API, and a local FastAPI AI service. Current capabilities already include job tracking, companies, attachments, correspondence, reminders, job import preview, Gmail connection/import, profile CV upload/parsing/rewrite flows, AI-assisted tailored CV and cover-letter generation, candidate-fit/focus-plan/interview-prep/readiness endpoints, and dashboard/system surfaces. S01 has now moved Gmail import from a generic search surface to a job-aware workspace flow: the backend ranks likely Gmail threads/messages for a specific job, imports report duplicate state explicitly, correspondence persists Gmail thread and sender/recipient metadata, and the job workspace renders those ranked suggestions directly. The next phase is not greenfield feature invention; it is continuing to turn existing capability into a more coherent, more trustworthy daily workflow.
|
||||
A substantial brownfield app already exists. The repo has a React frontend, an ASP.NET Core API, and a local FastAPI AI service. Current capabilities already include job tracking, companies, attachments, correspondence, reminders, job import preview, Gmail connection/import, profile CV upload/parsing/rewrite flows, AI-assisted tailored CV and cover-letter generation, candidate-fit/focus-plan/interview-prep/readiness endpoints, and dashboard/system surfaces. S01 moved Gmail import from a generic search surface to a job-aware workspace flow: the backend ranks likely Gmail threads/messages for a specific job, imports report duplicate state explicitly, correspondence persists Gmail thread and sender/recipient metadata, and the job workspace renders those ranked suggestions directly. S02 then tightened the package loop around that context: backend package generation now consumes imported correspondence plus recruiter/job state, and the Tailored CV tab treats tailored CV, cover letter, recruiter message, and saved application-answer material as one editable workspace tied back to the job instead of a throwaway preview pane. The next phase is not greenfield feature invention; it is continuing to turn existing capability into a more coherent, more trustworthy daily workflow.
|
||||
|
||||
## Architecture / Key Patterns
|
||||
|
||||
|
||||
+52
-64
@@ -2,52 +2,9 @@
|
||||
|
||||
This file is the explicit capability and coverage contract for the project.
|
||||
|
||||
Use it to track what is actively in scope, what has been validated by completed work, what is intentionally deferred, and what is explicitly out of scope.
|
||||
|
||||
Guidelines:
|
||||
- Keep requirements capability-oriented, not a giant feature wishlist.
|
||||
- Requirements should be atomic, testable, and stated in plain language.
|
||||
- Every **Active** requirement should be mapped to a slice, deferred, blocked with reason, or moved out of scope.
|
||||
- Each requirement should have one accountable primary owner and may have supporting slices.
|
||||
- Research may suggest requirements, but research does not silently make them binding.
|
||||
- Validation means the requirement was actually proven by completed work and verification, not just discussed.
|
||||
|
||||
## Active
|
||||
|
||||
### R001 — External job import starts the workflow
|
||||
- Class: primary-user-loop
|
||||
- Status: active
|
||||
- Description: The user finds a job outside the app, imports it into the app, and starts the application workflow from that imported role.
|
||||
- Why it matters: The product is not a job board replacement; the import step is the real start of the user loop.
|
||||
- Source: user
|
||||
- Primary owning slice: M001/S01
|
||||
- Supporting slices: M001/S05
|
||||
- Validation: mapped
|
||||
- Notes: This is a hard product-shape requirement, not a convenience feature.
|
||||
|
||||
### R002 — Gmail import feels smart enough to trust
|
||||
- Class: integration
|
||||
- Status: active
|
||||
- Description: Gmail connection, message retrieval, and message/thread import must help the user pull real correspondence into the right job with materially less manual cleanup.
|
||||
- Why it matters: Gmail import is one of the two clearest current weaknesses and a major trust surface for daily use.
|
||||
- Source: user
|
||||
- Primary owning slice: M001/S01
|
||||
- Supporting slices: M001/S03, M001/S05
|
||||
- Validation: mapped
|
||||
- Notes: Matching quality and import clarity matter more than merely exposing the API surface.
|
||||
|
||||
### R003 — AI application drafts are materially useful
|
||||
- Class: differentiator
|
||||
- Status: active
|
||||
- Description: Tailored CV and cover-letter drafts must feel specific, credible, and good enough that the user wants to start from them.
|
||||
- Why it matters: Draft generation exists already, but the milestone bar is actual usefulness rather than feature presence.
|
||||
- Source: user
|
||||
- Primary owning slice: M001/S02
|
||||
- Supporting slices: M001/S05
|
||||
- Validation: mapped
|
||||
- Notes: “A really good AI draft” is an explicit milestone success bar from the discussion.
|
||||
|
||||
### R004 — Follow-up and reply drafts use real context
|
||||
### R004 — The app must generate follow-up and reply drafts from the imported job, saved application material, and correspondence context tied to that job.
|
||||
- Class: primary-user-loop
|
||||
- Status: active
|
||||
- Description: The app must generate follow-up and reply drafts from the imported job, saved application material, and correspondence context tied to that job.
|
||||
@@ -58,7 +15,7 @@ Guidelines:
|
||||
- Validation: mapped
|
||||
- Notes: The user stays in control of sending; the app provides strong drafts only.
|
||||
|
||||
### R005 — Job table is the primary daily control surface
|
||||
### R005 — The first page should give the user a clear overview of jobs, status, readiness, and what needs attention.
|
||||
- Class: continuity
|
||||
- Status: active
|
||||
- Description: The first page should give the user a clear overview of jobs, status, readiness, and what needs attention.
|
||||
@@ -69,7 +26,7 @@ Guidelines:
|
||||
- Validation: mapped
|
||||
- Notes: This should feel like “scan the field” before drilling into one job.
|
||||
|
||||
### R006 — Follow-up/dashboard surfaces the right urgency
|
||||
### R006 — The dashboard and follow-up surfaces must clearly show next actions, neglected threads, and jobs that need attention now.
|
||||
- Class: continuity
|
||||
- Status: active
|
||||
- Description: The dashboard and follow-up surfaces must clearly show next actions, neglected threads, and jobs that need attention now.
|
||||
@@ -80,7 +37,7 @@ Guidelines:
|
||||
- Validation: mapped
|
||||
- Notes: The follow-up/dashboard view is the second navigation priority after the table.
|
||||
|
||||
### R007 — Individual job workspace supports focused execution
|
||||
### R007 — Each job needs a workspace where the user can update status, review/import correspondence, edit drafts, and prepare follow-ups.
|
||||
- Class: core-capability
|
||||
- Status: active
|
||||
- Description: Each job needs a workspace where the user can update status, review/import correspondence, edit drafts, and prepare follow-ups.
|
||||
@@ -91,7 +48,7 @@ Guidelines:
|
||||
- Validation: mapped
|
||||
- Notes: This is where the product should feel connected instead of scattered.
|
||||
|
||||
### R008 — Outbound actions remain manual and user-controlled
|
||||
### R008 — The app may draft application, reply, and follow-up content, but it must not auto-send emails or auto-apply to jobs.
|
||||
- Class: constraint
|
||||
- Status: active
|
||||
- Description: The app may draft application, reply, and follow-up content, but it must not auto-send emails or auto-apply to jobs.
|
||||
@@ -102,7 +59,7 @@ Guidelines:
|
||||
- Validation: mapped
|
||||
- Notes: This is a durable trust constraint across all milestones.
|
||||
|
||||
### R009 — Product is designed for an individual, not a team workflow
|
||||
### R009 — Core UX, data model emphasis, and roadmap decisions should optimize for one person managing their own search.
|
||||
- Class: constraint
|
||||
- Status: active
|
||||
- Description: Core UX, data model emphasis, and roadmap decisions should optimize for one person managing their own search.
|
||||
@@ -113,7 +70,7 @@ Guidelines:
|
||||
- Validation: mapped
|
||||
- Notes: Shared/team workflows are not the current product target.
|
||||
|
||||
### R010 — Tracking continuity survives manual and imported updates
|
||||
### R010 — The app must preserve a coherent history across manual status changes, imported Gmail correspondence, reminders, and follow-up work.
|
||||
- Class: continuity
|
||||
- Status: active
|
||||
- Description: The app must preserve a coherent history across manual status changes, imported Gmail correspondence, reminders, and follow-up work.
|
||||
@@ -126,11 +83,42 @@ Guidelines:
|
||||
|
||||
## Validated
|
||||
|
||||
None yet.
|
||||
### R001 — The user finds a job outside the app, imports it into the app, and starts the application workflow from that imported role.
|
||||
- Class: primary-user-loop
|
||||
- Status: validated
|
||||
- Description: The user finds a job outside the app, imports it into the app, and starts the application workflow from that imported role.
|
||||
- Why it matters: The product is not a job board replacement; the import step is the real start of the user loop.
|
||||
- Source: user
|
||||
- Primary owning slice: M001/S01
|
||||
- Supporting slices: M001/S05
|
||||
- Validation: S01 completed with a job-scoped Gmail import loop wired into the job workspace: backend `GET /api/gmail/job-candidates` uses the owned job as context, imports target that job directly, and focused UI verification passed in `job-tracker-ui/src/correspondence-gmail-import.test.tsx`.
|
||||
- Notes: Validation is contract/UI-level plus workspace integration. Live user UAT of the broader milestone loop still remains for later slices.
|
||||
|
||||
### R002 — Gmail connection, message retrieval, and message/thread import must help the user pull real correspondence into the right job with materially less manual cleanup.
|
||||
- Class: integration
|
||||
- Status: validated
|
||||
- Description: Gmail connection, message retrieval, and message/thread import must help the user pull real correspondence into the right job with materially less manual cleanup.
|
||||
- Why it matters: Gmail import is one of the two clearest current weaknesses and a major trust surface for daily use.
|
||||
- Source: user
|
||||
- Primary owning slice: M001/S01
|
||||
- Supporting slices: M001/S03, M001/S05
|
||||
- Validation: S01 completed with backend-ranked job-aware Gmail candidates, explicit imported/skipped import payloads, persisted thread/sender/recipient metadata, and focused verification across isolated `GmailControllerTests` plus `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/correspondence-gmail-import.test.tsx`.
|
||||
- Notes: The broader JobTrackerApi.Tests project still has unrelated compile drift, so Gmail backend coverage was isolated instead of run through the full test project. Live Gmail UAT is still needed to judge trust on a real inbox.
|
||||
|
||||
### R003 — Tailored CV and cover-letter drafts must feel specific, credible, and good enough that the user wants to start from them.
|
||||
- Class: differentiator
|
||||
- Status: validated
|
||||
- Description: Tailored CV and cover-letter drafts must feel specific, credible, and good enough that the user wants to start from them.
|
||||
- Why it matters: Draft generation exists already, but the milestone bar is actual usefulness rather than feature presence.
|
||||
- Source: user
|
||||
- Primary owning slice: M001/S02
|
||||
- Supporting slices: M001/S05
|
||||
- Validation: Validated by M001/S02: backend application-package generation now uses recruiter/job/profile/attachment/imported-correspondence context, focused backend tests pass in an isolated harness, and the Tailored CV workspace test proves generation, editing, save, and saved-state redisplay behavior.
|
||||
- Notes: S02 proved the application-package drafts are materially more specific and persist as job-tied working material. Broader end-to-end live-loop revalidation still remains in M001/S05.
|
||||
|
||||
## Deferred
|
||||
|
||||
### R011 — Stronger tracking control-center analytics
|
||||
### R011 — The app should later expand overview analytics, saved views, and clearer strategy readouts beyond the core daily loop.
|
||||
- Class: operability
|
||||
- Status: deferred
|
||||
- Description: The app should later expand overview analytics, saved views, and clearer strategy readouts beyond the core daily loop.
|
||||
@@ -141,7 +129,7 @@ None yet.
|
||||
- Validation: unmapped
|
||||
- Notes: Deferred because Gmail import and draft quality are higher-value first fixes.
|
||||
|
||||
### R012 — Broader inbox-aware assistance beyond initial Gmail improvements
|
||||
### R012 — The product may later add richer message understanding, smarter thread handling, and broader inbox-aware assistance after the first Gmail milestone.
|
||||
- Class: integration
|
||||
- Status: deferred
|
||||
- Description: The product may later add richer message understanding, smarter thread handling, and broader inbox-aware assistance after the first Gmail milestone.
|
||||
@@ -152,7 +140,7 @@ None yet.
|
||||
- Validation: unmapped
|
||||
- Notes: This is the natural next step after M001 proves the core Gmail path.
|
||||
|
||||
### R013 — Richer AI coaching beyond the application/follow-up core
|
||||
### R013 — The app may later add broader strategic coaching and more advanced guidance beyond application package and follow-up/reply drafting.
|
||||
- Class: differentiator
|
||||
- Status: deferred
|
||||
- Description: The app may later add broader strategic coaching and more advanced guidance beyond application package and follow-up/reply drafting.
|
||||
@@ -165,7 +153,7 @@ None yet.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
### R014 — Auto-apply to jobs
|
||||
### R014 — The app will not automatically submit applications to external job sites.
|
||||
- Class: anti-feature
|
||||
- Status: out-of-scope
|
||||
- Description: The app will not automatically submit applications to external job sites.
|
||||
@@ -176,7 +164,7 @@ None yet.
|
||||
- Validation: n/a
|
||||
- Notes: The app starts after discovery/import, not at job search submission.
|
||||
|
||||
### R015 — Auto-send outbound email or messages
|
||||
### R015 — The app will not send replies, follow-ups, or other communication autonomously.
|
||||
- Class: anti-feature
|
||||
- Status: out-of-scope
|
||||
- Description: The app will not send replies, follow-ups, or other communication autonomously.
|
||||
@@ -187,7 +175,7 @@ None yet.
|
||||
- Validation: n/a
|
||||
- Notes: Drafting is allowed; autonomous sending is not.
|
||||
|
||||
### R016 — Recruiter CRM or team collaboration workflows
|
||||
### R016 — The product will not optimize for shared pipelines, recruiter operations, or multi-user coaching workflows right now.
|
||||
- Class: out-of-scope
|
||||
- Status: out-of-scope
|
||||
- Description: The product will not optimize for shared pipelines, recruiter operations, or multi-user coaching workflows right now.
|
||||
@@ -198,7 +186,7 @@ None yet.
|
||||
- Validation: n/a
|
||||
- Notes: Multi-user admin surfaces may exist technically, but they are not the roadmap center.
|
||||
|
||||
### R017 — In-app job discovery replacing job boards
|
||||
### R017 — The app will not try to replace external job boards as the main discovery surface.
|
||||
- Class: out-of-scope
|
||||
- Status: out-of-scope
|
||||
- Description: The app will not try to replace external job boards as the main discovery surface.
|
||||
@@ -213,9 +201,9 @@ None yet.
|
||||
|
||||
| ID | Class | Status | Primary owner | Supporting | Proof |
|
||||
|---|---|---|---|---|---|
|
||||
| R001 | primary-user-loop | active | M001/S01 | M001/S05 | mapped |
|
||||
| R002 | integration | active | M001/S01 | M001/S03, M001/S05 | mapped |
|
||||
| R003 | differentiator | active | M001/S02 | M001/S05 | mapped |
|
||||
| R001 | primary-user-loop | validated | M001/S01 | M001/S05 | S01 completed with a job-scoped Gmail import loop wired into the job workspace: backend `GET /api/gmail/job-candidates` uses the owned job as context, imports target that job directly, and focused UI verification passed in `job-tracker-ui/src/correspondence-gmail-import.test.tsx`. |
|
||||
| R002 | integration | validated | M001/S01 | M001/S03, M001/S05 | S01 completed with backend-ranked job-aware Gmail candidates, explicit imported/skipped import payloads, persisted thread/sender/recipient metadata, and focused verification across isolated `GmailControllerTests` plus `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/correspondence-gmail-import.test.tsx`. |
|
||||
| R003 | differentiator | validated | M001/S02 | M001/S05 | Validated by M001/S02: backend application-package generation now uses recruiter/job/profile/attachment/imported-correspondence context, focused backend tests pass in an isolated harness, and the Tailored CV workspace test proves generation, editing, save, and saved-state redisplay behavior. |
|
||||
| R004 | primary-user-loop | active | M001/S03 | M001/S01, M001/S02, M001/S05 | mapped |
|
||||
| R005 | continuity | active | M001/S04 | M001/S05 | mapped |
|
||||
| R006 | continuity | active | M001/S04 | M001/S05 | mapped |
|
||||
@@ -233,7 +221,7 @@ None yet.
|
||||
|
||||
## Coverage Summary
|
||||
|
||||
- Active requirements: 10
|
||||
- Mapped to slices: 10
|
||||
- Validated: 0
|
||||
- Active requirements: 7
|
||||
- Mapped to slices: 7
|
||||
- Validated: 3 (R001, R002, R003)
|
||||
- Unmapped active requirements: 0
|
||||
|
||||
@@ -53,7 +53,7 @@ This milestone is complete only when all are true:
|
||||
- [x] **S01: Smarter Gmail import and matching** `risk:high` `depends:[]`
|
||||
> After this: User can connect Gmail, review likely messages or threads for a job, and import correspondence with much better matching confidence and less manual cleanup.
|
||||
|
||||
- [ ] **S02: Stronger AI application package drafting** `risk:high` `depends:[S01]`
|
||||
- [x] **S02: Stronger AI application package drafting** `risk:high` `depends:[S01]`
|
||||
> After this: From an imported job plus profile/CV context, the app generates materially better tailored CV and cover-letter drafts that feel specific and usable.
|
||||
|
||||
- [ ] **S03: Reply and follow-up drafting from real thread context** `risk:medium` `depends:[S01,S02]`
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# S02: Stronger AI application package drafting
|
||||
|
||||
**Goal:** Make the application package generator use imported job/correspondence context well enough that tailored CV, cover-letter, and recruiter-message drafts feel specific, credible, and worth starting from inside the job workspace.
|
||||
**Demo:** From a job that already has imported correspondence and profile/CV context, the user generates an application package in the workspace, sees drafts that clearly reflect the job plus imported Gmail context, edits and saves those drafts, and reopens the job to find the saved material still there as working output.
|
||||
|
||||
S02 directly owns active requirement **R003** and materially supports **R007** because the job workspace needs to behave like a place to prepare real application material, not just preview one-off AI output. The key risk is not missing endpoints; it is that the existing generator in `JobApplicationsController` may still produce generic drafts because it underuses S01 correspondence and saved recruiter/job context. The slice therefore stays backend-first again, then tightens the workspace save/edit loop around the improved contract.
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- Application-package generation in `JobTrackerApi/Controllers/JobApplicationsController.cs` uses imported correspondence, recruiter/job context, profile CV structure, and attachment context deliberately enough to produce more specific drafts.
|
||||
- The generated package returns and persists real working material for the job: tailored CV, cover letter, recruiter message, and any supporting package signals the workspace needs to show what was saved.
|
||||
- The job workspace in `job-tracker-ui/src/components/JobDetailsDialog.tsx` makes generation, editing, and saving feel like one coherent loop instead of disconnected draft widgets.
|
||||
|
||||
## Proof Level
|
||||
|
||||
- This slice proves: integration
|
||||
- Real runtime required: yes
|
||||
- Human/UAT required: yes
|
||||
|
||||
## Verification
|
||||
|
||||
- `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter JobApplicationsApplicationPackageTests`
|
||||
- `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/job-details-generated-drafts.test.tsx`
|
||||
- Manual UAT: open a job with imported correspondence, generate the application package, confirm the drafts mention job/company/correspondence-specific details, save edits in the workspace, reload the job, and verify the saved package material is still present and usable.
|
||||
|
||||
## Observability / Diagnostics
|
||||
|
||||
- Runtime signals: application-package responses should expose the specific draft artifacts and package signals used by the workspace, while save endpoints should update persisted job fields clearly enough to inspect reload behavior.
|
||||
- Inspection surfaces: `POST /api/jobapplications/{id}/generate-application-package`, `PUT /api/jobapplications/{id}/tailored-cv`, `PUT /api/jobapplications/{id}/application-drafts`, and the Tailored CV tab in `job-tracker-ui/src/components/JobDetailsDialog.tsx`.
|
||||
- Failure visibility: backend tests should make empty-context and weak-context failures obvious, while the workspace should distinguish generation failure, unsaved edits, and saved package state.
|
||||
- Redaction constraints: no raw secrets in AI prompts or logs; correspondence/body context should stay inside application data flows, not new diagnostic logging.
|
||||
|
||||
## Integration Closure
|
||||
|
||||
- Upstream surfaces consumed: S01 correspondence persistence in `Models/Correspondence.cs`, Gmail-linked workspace rendering in `job-tracker-ui/src/components/Correspondence.tsx`, profile CV state from `JobTrackerApi/Controllers/ProfileCvController.cs`, and the existing package endpoints/UI in `JobTrackerApi/Controllers/JobApplicationsController.cs` and `job-tracker-ui/src/components/JobDetailsDialog.tsx`.
|
||||
- New wiring introduced in this slice: stronger draft-generation context assembly that consumes imported correspondence, plus clearer persistence/display of saved application package material in the job workspace.
|
||||
- What remains before the milestone is truly usable end-to-end: S03 still needs to consume the saved package plus imported correspondence for reply/follow-up drafting, and final milestone slices still need full live-loop revalidation.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] **T01: Strengthen application-package context assembly and backend draft tests** `est:4h`
|
||||
- Why: S02 succeeds or fails on draft quality, so the generator must consume the right job, correspondence, recruiter, attachment, and profile-CV context before the workspace polish matters.
|
||||
- Files: `JobTrackerApi/Controllers/JobApplicationsController.cs`, `JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs`
|
||||
- Do: Audit `generate-application-package` and related helpers, add deliberate use of imported correspondence and recruiter/job context to the package prompt assembly, keep the no-auto-send boundary intact, and add focused backend tests that prove the returned package reflects stronger job-specific context instead of generic output.
|
||||
- Verify: `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter JobApplicationsApplicationPackageTests`
|
||||
- Done when: the backend package contract clearly uses imported job context and focused backend tests prove the response contains stronger, job-specific draft material.
|
||||
- [x] **T02: Make the job workspace save and present the application package as real working material** `est:4h`
|
||||
- Why: Even a better generator will still feel weak if the workspace treats drafts like disposable previews instead of editable saved material tied to the job.
|
||||
- Files: `job-tracker-ui/src/components/JobDetailsDialog.tsx`, `job-tracker-ui/src/types.ts`, `job-tracker-ui/src/job-details-generated-drafts.test.tsx`
|
||||
- Do: Refine the Tailored CV/application package tab so generated artifacts, saved edits, and package state read clearly as one working loop; surface the stronger backend outputs without adding a second draft system; and expand the existing dialog test to prove generation, editing, saving, and reload behavior for the package flow.
|
||||
- Verify: `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/job-details-generated-drafts.test.tsx`
|
||||
- Done when: the workspace generates, edits, saves, and re-displays application package material coherently, and the focused frontend test proves that loop.
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `JobTrackerApi/Controllers/JobApplicationsController.cs`
|
||||
- `JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs`
|
||||
- `job-tracker-ui/src/components/JobDetailsDialog.tsx`
|
||||
- `job-tracker-ui/src/types.ts`
|
||||
- `job-tracker-ui/src/job-details-generated-drafts.test.tsx`
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: S02 summary
|
||||
status: done
|
||||
verification:
|
||||
- $HOME/.dotnet/dotnet build JobTrackerApi/JobTrackerApi.csproj
|
||||
- docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:9.0 bash -lc '...dotnet test /tmp/apptests/AppPkgTests.csproj...'
|
||||
- CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/job-details-generated-drafts.test.tsx
|
||||
---
|
||||
|
||||
S02 made the application-package flow materially more useful and more trustworthy.
|
||||
|
||||
Delivered:
|
||||
- stronger backend package-context assembly in `JobTrackerApi/Controllers/JobApplicationsController.cs`
|
||||
- imported correspondence is now part of package generation
|
||||
- recruiter/job/profile/attachment context is threaded into the package prompts deliberately
|
||||
- returned key points can include correspondence-derived and attachment-derived signals
|
||||
- focused backend proof in `JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs`
|
||||
- package generation responds to imported correspondence and recruiter context
|
||||
- notes-saving behavior for application-answer material replaces rather than appends endlessly
|
||||
- stronger job workspace loop in `job-tracker-ui/src/components/JobDetailsDialog.tsx`
|
||||
- saved job material loads back into the Tailored CV tab as the starting workspace state
|
||||
- generation replaces the working draft for tailored CV, cover letter, application answer, and recruiter message
|
||||
- save writes the package back as job-tied working material rather than leaving it as a temporary preview
|
||||
- the UI distinguishes saved state, generated-only state, and unsaved edits
|
||||
- focused frontend proof in `job-tracker-ui/src/job-details-generated-drafts.test.tsx`
|
||||
- verifies saved-state load, generation, editing, coherent save payload, and saved-state redisplay behavior
|
||||
|
||||
Net effect:
|
||||
- S01 imported Gmail context now materially improves draft quality instead of sitting unused.
|
||||
- The job workspace behaves more like a preparation surface the user can trust and reuse later.
|
||||
- The manual-send boundary remains intact; S02 improved drafting and persistence only.
|
||||
|
||||
Remaining milestone gap after S02:
|
||||
- S03 still needs to turn the imported thread context plus saved package state into strong reply/follow-up drafting.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
estimated_steps: 4
|
||||
estimated_files: 2
|
||||
skills_used:
|
||||
- best-practices
|
||||
- test
|
||||
---
|
||||
|
||||
# T01: Strengthen application-package context assembly and backend draft tests
|
||||
|
||||
**Slice:** S02 — Stronger AI application package drafting
|
||||
**Milestone:** M001
|
||||
|
||||
## Description
|
||||
|
||||
Make the backend application-package generator use the context S01 now provides. The executor should keep the existing package endpoint, but improve how it builds prompts and selects context so the tailored CV, cover letter, recruiter message, and supporting signals reflect imported correspondence, recruiter/job details, profile CV structure, and attachment context more convincingly.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Inspect `JobTrackerApi/Controllers/JobApplicationsController.cs` around `generate-application-package` and identify which job, recruiter, correspondence, attachment, and profile-CV signals are already available but underused.
|
||||
2. Refine the package-context assembly and prompt shape so imported correspondence and recruiter/job-specific details influence the generated drafts directly without weakening the no-auto-send boundary.
|
||||
3. Add a focused backend test file `JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs` that exercises the package endpoint contract with real job/correspondence/profile context and asserts the returned artifacts are specific to that context.
|
||||
4. Keep the output contract stable unless a change materially improves the workspace; if it changes, make the added fields explicit and limited to what T02 will consume.
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] Imported correspondence from S01 is deliberately consumed in package generation instead of remaining disconnected from the draft flow.
|
||||
- [ ] Backend tests prove package output responds to job-specific context rather than generic fallback behavior.
|
||||
- [ ] The generator still returns review-only draft material and does not cross the manual-send boundary.
|
||||
|
||||
## Verification
|
||||
|
||||
- `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter JobApplicationsApplicationPackageTests`
|
||||
- Confirm the focused test covers correspondence-aware package context and expected package artifacts.
|
||||
|
||||
## Observability Impact
|
||||
|
||||
- Signals added/changed: clearer package-response artifacts and stronger context assembly around job/correspondence/profile inputs.
|
||||
- How a future agent inspects this: read `JobTrackerApi/Controllers/JobApplicationsController.cs` and `JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs`.
|
||||
- Failure state exposed: focused backend verification should show whether weak drafts come from missing context assembly, empty correspondence state, or prompt/output contract drift.
|
||||
|
||||
## Inputs
|
||||
|
||||
- `JobTrackerApi/Controllers/JobApplicationsController.cs` — current application-package and related AI draft endpoints.
|
||||
- `Models/Correspondence.cs` — persisted Gmail-linked correspondence metadata from S01.
|
||||
- `JobTrackerApi/Controllers/ProfileCvController.cs` — profile CV structure/source-of-truth behavior.
|
||||
|
||||
## Expected Output
|
||||
|
||||
- `JobTrackerApi/Controllers/JobApplicationsController.cs` — stronger package-generation context and/or response contract.
|
||||
- `JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs` — focused backend proof for context-aware package generation.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
title: T01 summary
|
||||
status: done
|
||||
files:
|
||||
- JobTrackerApi/Controllers/JobApplicationsController.cs
|
||||
- JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs
|
||||
verification:
|
||||
- $HOME/.dotnet/dotnet build JobTrackerApi/JobTrackerApi.csproj
|
||||
- docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:9.0 bash -lc '...dotnet test /tmp/apptests/AppPkgTests.csproj...'
|
||||
---
|
||||
|
||||
Strengthened application-package generation so it now consumes imported correspondence and recruiter/job context instead of relying mostly on job description text plus the profile CV.
|
||||
|
||||
What changed:
|
||||
- `JobTrackerApi/Controllers/JobApplicationsController.cs`
|
||||
- added `BuildCorrespondenceContextAsync(...)` to gather recent imported correspondence, participants, thread ids, and AI-derived package signals
|
||||
- enriched `generate-application-package` context with:
|
||||
- recruiter name/email and greeting baseline
|
||||
- imported correspondence context from S01
|
||||
- existing saved job material (tailored CV / cover letter / recruiter message) when present
|
||||
- job URL in the package context
|
||||
- updated prompts so tailored CV, cover letter, application answer, recruiter message, and variants all explicitly use imported correspondence when helpful without crossing the manual-send boundary
|
||||
- expanded `KeyPoints` to include correspondence-derived and attachment-derived signals
|
||||
- `JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs`
|
||||
- added focused backend proof that package generation reacts to imported correspondence and recruiter context rather than falling back to generic output
|
||||
|
||||
Verification:
|
||||
- Native backend build passed with `$HOME/.dotnet/dotnet build JobTrackerApi/JobTrackerApi.csproj`
|
||||
- Focused application-package backend test passed in an isolated Docker harness (`1 passed`)
|
||||
|
||||
Important caveat:
|
||||
- As with S01 backend verification, the repository’s broader `JobTrackerApi.Tests` project still has unrelated compile drift, so the focused package test was isolated instead of relying on the full test project.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
estimated_steps: 4
|
||||
estimated_files: 3
|
||||
skills_used:
|
||||
- react-best-practices
|
||||
- test
|
||||
---
|
||||
|
||||
# T02: Make the job workspace save and present the application package as real working material
|
||||
|
||||
**Slice:** S02 — Stronger AI application package drafting
|
||||
**Milestone:** M001
|
||||
|
||||
## Description
|
||||
|
||||
Turn the Tailored CV/application package area into a coherent working loop. The workspace should make it obvious what was generated, what was edited, what was saved to the job, and what can be reused later, instead of feeling like a temporary AI preview pane.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Update `job-tracker-ui/src/types.ts` if T01 changes the package contract or exposes stronger saved draft/package state.
|
||||
2. Refine `job-tracker-ui/src/components/JobDetailsDialog.tsx` so generation, editing, saving, and redisplay of application package material feel like one continuous workflow tied to the job.
|
||||
3. Expand `job-tracker-ui/src/job-details-generated-drafts.test.tsx` to prove the stronger package flow: generate with contextual outputs, edit/save the important draft artifacts, and verify the saved material is reflected back in the dialog state.
|
||||
4. Keep the UI focused on working material already tied to the job; do not introduce a second competing draft surface or outbound automation.
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] The Tailored CV tab clearly presents generated package artifacts as editable, savable job material rather than disposable previews.
|
||||
- [ ] Saved package edits update dialog state in a way the user can trust and later slices can reuse.
|
||||
- [ ] The focused React test proves generation and save behavior for the package loop.
|
||||
|
||||
## Verification
|
||||
|
||||
- `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/job-details-generated-drafts.test.tsx`
|
||||
- Confirm the expanded test proves generation, editing, and save-state behavior in the dialog.
|
||||
|
||||
## Observability Impact
|
||||
|
||||
- Signals added/changed: clearer UI state around generated vs saved package material and stronger test coverage for package-loop regressions.
|
||||
- How a future agent inspects this: open the Tailored CV tab in `job-tracker-ui/src/components/JobDetailsDialog.tsx` and read `job-tracker-ui/src/job-details-generated-drafts.test.tsx`.
|
||||
- Failure state exposed: the workspace and test should distinguish generation failure, unsaved edits, and saved package state instead of collapsing them into generic draft text.
|
||||
|
||||
## Inputs
|
||||
|
||||
- `job-tracker-ui/src/components/JobDetailsDialog.tsx` — existing package-generation and save UI.
|
||||
- `job-tracker-ui/src/types.ts` — frontend package contracts.
|
||||
- `JobTrackerApi/Controllers/JobApplicationsController.cs` — T01 package-generation contract.
|
||||
- `job-tracker-ui/src/job-details-generated-drafts.test.tsx` — current focused dialog test.
|
||||
|
||||
## Expected Output
|
||||
|
||||
- `job-tracker-ui/src/components/JobDetailsDialog.tsx` — stronger package generation/edit/save flow.
|
||||
- `job-tracker-ui/src/types.ts` — aligned package contract for the workspace.
|
||||
- `job-tracker-ui/src/job-details-generated-drafts.test.tsx` — focused frontend proof for the improved package loop.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: T02 summary
|
||||
status: done
|
||||
files:
|
||||
- job-tracker-ui/src/components/JobDetailsDialog.tsx
|
||||
- job-tracker-ui/src/job-details-generated-drafts.test.tsx
|
||||
verification:
|
||||
- CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/job-details-generated-drafts.test.tsx
|
||||
- $HOME/.dotnet/dotnet build JobTrackerApi/JobTrackerApi.csproj
|
||||
- docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:9.0 bash -lc '...dotnet test /tmp/apptests/AppPkgTests.csproj...'
|
||||
---
|
||||
|
||||
Turned the Tailored CV tab into a real package workspace instead of a one-shot draft preview.
|
||||
|
||||
What changed:
|
||||
- `job-tracker-ui/src/components/JobDetailsDialog.tsx`
|
||||
- added package workspace state for cover letter, application answer, and recruiter message alongside the tailored CV
|
||||
- initialized that workspace from saved job material so reopening the dialog shows the last trusted copy, not just blank/generated state
|
||||
- generation now replaces the editable working copy for all package artifacts, not only the tailored CV
|
||||
- added package-level save flow that writes the tailored CV plus draft artifacts back to the job together
|
||||
- added explicit saved/generated/unsaved status chips so the user can tell what is persisted versus still in the working draft
|
||||
- added reset-to-saved behavior so the workspace can recover from unwanted edits
|
||||
- normalized application-answer persistence into a replaceable notes block instead of endlessly appending
|
||||
- `job-tracker-ui/src/job-details-generated-drafts.test.tsx`
|
||||
- expanded the focused dialog test to prove saved material loads into the workspace, generation replaces the working copy, edits can be saved, and the save payload reflects the coherent package state
|
||||
|
||||
Backend support tightened during T02:
|
||||
- `JobTrackerApi/Controllers/JobApplicationsController.cs`
|
||||
- `SaveApplicationDrafts` now replaces `Notes` when notes are provided, which makes the saved application-answer loop trustworthy instead of append-only
|
||||
- `JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs`
|
||||
- added focused proof that notes replacement no longer appends indefinitely
|
||||
|
||||
Verification:
|
||||
- Frontend focused test passed: `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/job-details-generated-drafts.test.tsx`
|
||||
- Backend host build passed: `$HOME/.dotnet/dotnet build JobTrackerApi/JobTrackerApi.csproj`
|
||||
- Focused backend package tests passed in isolated Docker harness (`2 passed`)
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class JobApplicationsApplicationPackageTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Save_application_drafts_replaces_notes_instead_of_appending()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var company = new Company
|
||||
{
|
||||
Name = "Acme",
|
||||
OwnerUserId = "user-1"
|
||||
};
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var job = new JobApplication
|
||||
{
|
||||
JobTitle = "Backend Developer",
|
||||
CompanyId = company.Id,
|
||||
OwnerUserId = "user-1",
|
||||
Notes = "Old notes"
|
||||
};
|
||||
db.JobApplications.Add(job);
|
||||
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);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
var saved = await db.JobApplications.FirstAsync();
|
||||
Assert.Equal("Updated notes block", saved.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generate_application_package_uses_imported_correspondence_and_recruiter_context()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var company = new Company
|
||||
{
|
||||
Name = "Acme",
|
||||
RecruiterName = "Maria Recruiter",
|
||||
RecruiterEmail = "maria@acme.test",
|
||||
OwnerUserId = "user-1"
|
||||
};
|
||||
db.Companies.Add(company);
|
||||
db.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = "user-1",
|
||||
UserName = "user@example.test",
|
||||
Email = "user@example.test",
|
||||
ProfileCvText = "Built .NET APIs and led backend delivery.",
|
||||
ProfileCvStructureJson = "[]"
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var job = new JobApplication
|
||||
{
|
||||
JobTitle = "Backend Developer",
|
||||
CompanyId = company.Id,
|
||||
OwnerUserId = "user-1",
|
||||
Description = "Need .NET, APIs, and async collaboration with recruiters.",
|
||||
Notes = "Priority role",
|
||||
ShortSummary = "Acme backend hiring"
|
||||
};
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.Correspondences.Add(new Correspondence
|
||||
{
|
||||
JobApplicationId = job.Id,
|
||||
From = "Company",
|
||||
Subject = "Backend Developer interview",
|
||||
ExternalThreadId = "thread-1",
|
||||
ExternalFrom = "Maria Recruiter <maria@acme.test>",
|
||||
ExternalTo = "user@example.test",
|
||||
Content = "We want someone who can own .NET APIs and communicate clearly with stakeholders.",
|
||||
Date = DateTime.UtcNow.AddDays(-1)
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var summarizer = new Mock<ISummarizerService>();
|
||||
summarizer
|
||||
.Setup(service => service.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
|
||||
.ReturnsAsync((string instruction, string context, int _, int __) =>
|
||||
{
|
||||
if (instruction.Contains("List up to 4 concrete application-package signals", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Use recruiter language about owning .NET APIs.\nMention the interview timeline from imported correspondence.";
|
||||
}
|
||||
|
||||
if (instruction.Contains("cover letter", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return context.Contains("Imported correspondence context:", StringComparison.OrdinalIgnoreCase)
|
||||
&& context.Contains("Maria Recruiter", StringComparison.OrdinalIgnoreCase)
|
||||
? "Cover letter tailored with recruiter context and imported correspondence."
|
||||
: "Generic cover letter.";
|
||||
}
|
||||
|
||||
if (instruction.Contains("recruiter intro message", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return context.Contains("Recruiter email: maria@acme.test", StringComparison.OrdinalIgnoreCase)
|
||||
? "Recruiter message that uses Maria's context."
|
||||
: "Generic recruiter message.";
|
||||
}
|
||||
|
||||
if (instruction.Contains("application answer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Application answer grounded in the imported thread.";
|
||||
}
|
||||
|
||||
if (instruction.Contains("Rewrite the candidate CV", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Tailored CV that highlights .NET API ownership for Acme.";
|
||||
}
|
||||
|
||||
return "Variant draft";
|
||||
});
|
||||
|
||||
var controller = CreateController(db, summarizer.Object, "user-1");
|
||||
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);
|
||||
|
||||
Assert.Contains("Tailored CV", payload.TailoredCvText);
|
||||
Assert.Equal("Cover letter tailored with recruiter context and imported correspondence.", payload.CoverLetterDraft);
|
||||
Assert.Equal("Recruiter message that uses Maria's context.", payload.RecruiterMessageDraft);
|
||||
Assert.Contains(payload.KeyPoints, item => item.Contains("interview timeline", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(payload.KeyPoints, item => item.Contains("recruiter language", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId)
|
||||
{
|
||||
var controller = new JobApplicationsController(db, summarizer, Mock.Of<IAppEmailSender>(), CreateUserManager().Object);
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId)
|
||||
}, "test"))
|
||||
}
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static JobTrackerContext CreateDb()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(service => service.UserId).Returns("user-1");
|
||||
return new JobTrackerContext(options, currentUser.Object);
|
||||
}
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
|
||||
{
|
||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||
return new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object,
|
||||
Options.Create(new IdentityOptions()),
|
||||
new PasswordHasher<ApplicationUser>(),
|
||||
Array.Empty<IUserValidator<ApplicationUser>>(),
|
||||
Array.Empty<IPasswordValidator<ApplicationUser>>(),
|
||||
new UpperInvariantLookupNormalizer(),
|
||||
new IdentityErrorDescriber(),
|
||||
null!,
|
||||
new NullLogger<UserManager<ApplicationUser>>());
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,7 @@ namespace JobTrackerApi.Controllers
|
||||
}
|
||||
|
||||
private sealed record AttachmentContextResult(string Context, List<string> Signals, List<string> UsedFiles);
|
||||
private sealed record CorrespondenceContextResult(string Context, List<string> Signals, List<string> Participants, List<string> ThreadIds);
|
||||
|
||||
private async Task<AttachmentContextResult?> BuildAttachmentContextAsync(int jobId, CancellationToken cancellationToken, string? attachmentIdsCsv = null)
|
||||
{
|
||||
@@ -215,6 +216,70 @@ namespace JobTrackerApi.Controllers
|
||||
return new AttachmentContextResult(context.ToString().Trim(), signals, usedFiles);
|
||||
}
|
||||
|
||||
private async Task<CorrespondenceContextResult?> BuildCorrespondenceContextAsync(int jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var messages = await _db.Correspondences
|
||||
.AsNoTracking()
|
||||
.Where(message => message.JobApplicationId == jobId)
|
||||
.OrderByDescending(message => message.Date)
|
||||
.Take(6)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (messages.Count == 0) return null;
|
||||
|
||||
messages = messages
|
||||
.OrderBy(message => message.Date)
|
||||
.ToList();
|
||||
|
||||
var participants = messages
|
||||
.SelectMany(message => new[] { message.ExternalFrom, message.ExternalTo })
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value!.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(6)
|
||||
.ToList();
|
||||
|
||||
var threadIds = messages
|
||||
.Select(message => message.ExternalThreadId)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value!.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Take(4)
|
||||
.ToList();
|
||||
|
||||
var timeline = messages.Select(message =>
|
||||
{
|
||||
var content = (message.Content ?? string.Empty).Trim();
|
||||
if (content.Length > 320)
|
||||
{
|
||||
content = content[..320].TrimEnd() + "…";
|
||||
}
|
||||
|
||||
return $"- {message.Date:yyyy-MM-dd} | From={message.From} | Subject={message.Subject ?? "(no subject)"} | ExternalFrom={message.ExternalFrom ?? ""} | ExternalTo={message.ExternalTo ?? ""}\n {content}";
|
||||
}).ToList();
|
||||
|
||||
var context = new StringBuilder();
|
||||
context.AppendLine("Imported correspondence context:");
|
||||
if (participants.Count > 0)
|
||||
{
|
||||
context.AppendLine($"Participants: {string.Join(", ", participants)}");
|
||||
}
|
||||
if (threadIds.Count > 0)
|
||||
{
|
||||
context.AppendLine($"Threads: {string.Join(", ", threadIds)}");
|
||||
}
|
||||
context.AppendLine("Timeline:");
|
||||
context.AppendLine(string.Join("\n", timeline));
|
||||
|
||||
var signals = await BuildListFromAiAsync(
|
||||
"List up to 4 concrete application-package signals from this imported correspondence. Focus on recruiter priorities, specific role language, next steps, constraints, and phrasing that should influence a tailored CV, cover letter, or recruiter message. Return one short signal per line with no numbering.",
|
||||
context.ToString(),
|
||||
cancellationToken,
|
||||
fallbackPrefix: messages.Last().Subject ?? "imported correspondence");
|
||||
|
||||
return new CorrespondenceContextResult(context.ToString().Trim(), signals, participants, threadIds);
|
||||
}
|
||||
|
||||
private static bool IsExtractableAttachmentExtension(string? extension)
|
||||
{
|
||||
return extension?.Trim().ToLowerInvariant() switch
|
||||
@@ -1511,6 +1576,7 @@ namespace JobTrackerApi.Controllers
|
||||
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
|
||||
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);
|
||||
private 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);
|
||||
|
||||
@@ -1826,9 +1892,7 @@ Candidate master CV:
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Notes))
|
||||
{
|
||||
job.Notes = string.IsNullOrWhiteSpace(job.Notes)
|
||||
? request.Notes.Trim()
|
||||
: $"{job.Notes.Trim()}\n\n{request.Notes.Trim()}";
|
||||
job.Notes = request.Notes.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.RecruiterMessageDraft))
|
||||
@@ -1858,7 +1922,7 @@ Candidate master CV:
|
||||
return BadRequest("Add your profile CV text on the Profile page before generating an application package.");
|
||||
}
|
||||
|
||||
var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary }
|
||||
var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary, job.JobUrl }
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
if (string.IsNullOrWhiteSpace(jobText))
|
||||
{
|
||||
@@ -1869,6 +1933,13 @@ Candidate master CV:
|
||||
var coverLetterStyleInstruction = BuildCoverLetterStyleInstruction(coverLetterStyle);
|
||||
var structuredCvContext = BuildStructuredCvContext(user);
|
||||
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
|
||||
var correspondenceContext = await BuildCorrespondenceContextAsync(id, cancellationToken);
|
||||
var savedPackageMaterial = new SavedPackageMaterial(job.TailoredCvText, job.CoverLetterText, job.RecruiterMessageDraft, job.Notes);
|
||||
|
||||
var recruiterContext = new StringBuilder();
|
||||
recruiterContext.AppendLine($"Recruiter name: {job.Company?.RecruiterName ?? ""}");
|
||||
recruiterContext.AppendLine($"Recruiter email: {job.Company?.RecruiterEmail ?? ""}");
|
||||
recruiterContext.AppendLine($"Greeting baseline: {BuildGreeting(job)}");
|
||||
|
||||
var packageContext = $@"Job title: {job.JobTitle}
|
||||
Company: {job.Company?.Name}
|
||||
@@ -1876,32 +1947,39 @@ Status: {job.Status}
|
||||
Generation mode: {mode ?? "default"}
|
||||
Cover-letter style: {coverLetterStyle ?? "balanced"}
|
||||
|
||||
Recruiter and company context:
|
||||
{recruiterContext.ToString().Trim()}
|
||||
|
||||
Job context:
|
||||
{jobText}
|
||||
{(correspondenceContext is not null ? $"\n\nImported correspondence:\n{correspondenceContext.Context}" : string.Empty)}
|
||||
{(!string.IsNullOrWhiteSpace(savedPackageMaterial.TailoredCvText) || !string.IsNullOrWhiteSpace(savedPackageMaterial.CoverLetterText) || !string.IsNullOrWhiteSpace(savedPackageMaterial.RecruiterMessageDraft)
|
||||
? $"\n\nExisting saved job material:\nTailored CV draft: {savedPackageMaterial.TailoredCvText ?? ""}\nCover letter draft: {savedPackageMaterial.CoverLetterText ?? ""}\nRecruiter message draft: {savedPackageMaterial.RecruiterMessageDraft ?? ""}"
|
||||
: string.Empty)}
|
||||
|
||||
Candidate master CV:
|
||||
{cvText}{(!string.IsNullOrWhiteSpace(structuredCvContext) ? $"\n\n{structuredCvContext}" : string.Empty)}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}";
|
||||
|
||||
var tailoredCvText = await _summarizer.SummarizeSectionAsync(
|
||||
$"Rewrite the candidate CV into a tailored role-specific resume draft. Keep it credible, structured, and focused on the strongest overlaps with this job. {packageModeInstruction}",
|
||||
$"Rewrite the candidate CV into a tailored role-specific resume draft. Keep it credible, structured, and focused on the strongest overlaps with this job. Use imported correspondence and recruiter language when it sharpens specificity, but do not invent facts. {packageModeInstruction}",
|
||||
packageContext,
|
||||
256,
|
||||
120) ?? cvText;
|
||||
|
||||
var coverLetterDraft = await _summarizer.SummarizeSectionAsync(
|
||||
$"Write a concise but high-quality cover letter for this candidate and job. Use the candidate CV as the source of evidence, mirror the priorities of the posting, mention concrete overlap instead of generic enthusiasm, and make the letter feel specific to this company and role. Keep it credible, polished, and directly aligned to the role. {packageModeInstruction} {coverLetterStyleInstruction}",
|
||||
$"Write a concise but high-quality cover letter for this candidate and job. Use the candidate CV as the source of evidence, mirror the priorities of the posting, incorporate relevant signals from imported correspondence when available, mention concrete overlap instead of generic enthusiasm, and make the letter feel specific to this company and role. Keep it credible, polished, and directly aligned to the role. {packageModeInstruction} {coverLetterStyleInstruction}",
|
||||
packageContext,
|
||||
260,
|
||||
110);
|
||||
|
||||
var applicationAnswerDraft = await _summarizer.SummarizeSectionAsync(
|
||||
$"Write a short application answer for why this candidate is a fit for the role. Keep it under 180 words. {packageModeInstruction}",
|
||||
$"Write a short application answer for why this candidate is a fit for the role. Keep it under 180 words, use specific evidence from the CV and imported correspondence where helpful, and avoid generic filler. {packageModeInstruction}",
|
||||
packageContext,
|
||||
170,
|
||||
70);
|
||||
|
||||
var coverLetterVariants = await BuildDraftVariantsAsync(
|
||||
"Write a concise, job-specific cover letter for this candidate and role. Use concrete evidence from the CV and job context, avoid generic enthusiasm, and keep the tone credible and polished.",
|
||||
"Write a concise, job-specific cover letter for this candidate and role. Use concrete evidence from the CV, recruiter context, and imported correspondence where relevant, avoid generic enthusiasm, and keep the tone credible and polished.",
|
||||
packageContext,
|
||||
cancellationToken,
|
||||
"concise and efficient",
|
||||
@@ -1909,13 +1987,13 @@ Candidate master CV:
|
||||
"confident and high-conviction");
|
||||
|
||||
var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync(
|
||||
$"Write a short recruiter intro message for this candidate and role. Make it feel specific to the posting by mentioning the exact role, company, and one or two concrete overlaps from the candidate profile or job context. Keep it warm, direct, and concise. {packageModeInstruction}",
|
||||
$"Write a short recruiter intro message for this candidate and role. Make it feel specific to the posting by mentioning the exact role, company, and one or two concrete overlaps from the candidate profile, job context, or imported correspondence. If recruiter details are available, use them naturally. Keep it warm, direct, and concise. {packageModeInstruction}",
|
||||
packageContext,
|
||||
140,
|
||||
55);
|
||||
|
||||
var recruiterMessageVariants = await BuildDraftVariantsAsync(
|
||||
"Write a short recruiter intro message for this candidate and role. Mention the exact role, company, and one or two concrete overlaps. Keep it natural, specific, and easy to respond to.",
|
||||
"Write a short recruiter intro message for this candidate and role. Mention the exact role, company, recruiter context, and one or two concrete overlaps. Keep it natural, specific, and easy to respond to.",
|
||||
packageContext,
|
||||
cancellationToken,
|
||||
"warm and conversational",
|
||||
@@ -1924,10 +2002,34 @@ Candidate master CV:
|
||||
|
||||
var keyPoints = SkillTagger.Detect(jobText)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(5)
|
||||
.Take(4)
|
||||
.Select(x => $"Lead with evidence of {x}.")
|
||||
.ToList();
|
||||
|
||||
if (correspondenceContext is not null)
|
||||
{
|
||||
foreach (var signal in correspondenceContext.Signals)
|
||||
{
|
||||
if (!keyPoints.Contains(signal, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
keyPoints.Add(signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attachmentContext is not null)
|
||||
{
|
||||
foreach (var signal in attachmentContext.Signals)
|
||||
{
|
||||
if (!keyPoints.Contains(signal, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
keyPoints.Add(signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keyPoints = keyPoints.Take(6).ToList();
|
||||
|
||||
return Ok(new GenerateApplicationPackageDto(
|
||||
TailoredCvText: tailoredCvText,
|
||||
CoverLetterDraft: coverLetterDraft,
|
||||
|
||||
@@ -47,6 +47,13 @@ type FollowUpDraft = {
|
||||
|
||||
type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview";
|
||||
type CoverLetterStyle = "balanced" | "concise" | "formal" | "bold";
|
||||
type PackageDraftKind = "tailoredCv" | "coverLetter" | "applicationAnswer" | "recruiterMessage";
|
||||
|
||||
type PackageWorkspaceState = {
|
||||
coverLetter: string;
|
||||
applicationAnswer: string;
|
||||
recruiterMessage: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -82,6 +89,54 @@ function copyLines(items: string[]) {
|
||||
return navigator.clipboard.writeText(items.map((item) => `• ${item}`).join("\n"));
|
||||
}
|
||||
|
||||
const APPLICATION_ANSWER_START = "<<<APPLICATION_ANSWER_DRAFT>>>";
|
||||
const APPLICATION_ANSWER_END = "<<<END_APPLICATION_ANSWER_DRAFT>>>";
|
||||
|
||||
function extractApplicationAnswerDraft(notes?: string | null) {
|
||||
const value = (notes ?? "").trim();
|
||||
if (!value) return "";
|
||||
|
||||
const startIndex = value.indexOf(APPLICATION_ANSWER_START);
|
||||
const endIndex = value.indexOf(APPLICATION_ANSWER_END);
|
||||
if (startIndex >= 0 && endIndex > startIndex) {
|
||||
return value.slice(startIndex + APPLICATION_ANSWER_START.length, endIndex).trim();
|
||||
}
|
||||
|
||||
const legacyMatch = value.match(/Application answer draft:\s*\n([\s\S]*)$/i);
|
||||
return legacyMatch?.[1]?.trim() ?? "";
|
||||
}
|
||||
|
||||
function upsertApplicationAnswerDraft(notes: string | null | undefined, draft: string) {
|
||||
const trimmedNotes = (notes ?? "").trim();
|
||||
const trimmedDraft = draft.trim();
|
||||
const block = trimmedDraft
|
||||
? `${APPLICATION_ANSWER_START}\n${trimmedDraft}\n${APPLICATION_ANSWER_END}`
|
||||
: "";
|
||||
|
||||
if (!trimmedNotes) return block;
|
||||
|
||||
const markerPattern = new RegExp(`${APPLICATION_ANSWER_START}[\\s\\S]*?${APPLICATION_ANSWER_END}`, "g");
|
||||
if (markerPattern.test(trimmedNotes)) {
|
||||
return block ? trimmedNotes.replace(markerPattern, block).trim() : trimmedNotes.replace(markerPattern, "").trim();
|
||||
}
|
||||
|
||||
const legacyPattern = /(?:\n\n)?Application answer draft:\s*\n[\s\S]*$/i;
|
||||
if (legacyPattern.test(trimmedNotes)) {
|
||||
return block ? trimmedNotes.replace(legacyPattern, `\n\n${block}`).trim() : trimmedNotes.replace(legacyPattern, "").trim();
|
||||
}
|
||||
|
||||
return block ? `${trimmedNotes}\n\n${block}` : trimmedNotes;
|
||||
}
|
||||
|
||||
function getWorkspaceStatus(currentValue: string, savedValue: string) {
|
||||
const current = currentValue.trim();
|
||||
const saved = savedValue.trim();
|
||||
if (current && current !== saved) return { label: "Unsaved edits", color: "warning" as const };
|
||||
if (saved) return { label: "Saved to job", color: "success" as const };
|
||||
if (current) return { label: "Generated only", color: "default" as const };
|
||||
return { label: "Empty", color: "default" as const };
|
||||
}
|
||||
|
||||
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
@@ -113,6 +168,9 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const [generationMode, setGenerationMode] = useState<GenerationMode>("default");
|
||||
const [coverLetterStyle, setCoverLetterStyle] = useState<CoverLetterStyle>("balanced");
|
||||
const [tailoredCvText, setTailoredCvText] = useState("");
|
||||
const [packageWorkspace, setPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
const [savedPackageWorkspace, setSavedPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
const [packageGeneratedAt, setPackageGeneratedAt] = useState<string | null>(null);
|
||||
const [draftRecipient, setDraftRecipient] = useState("");
|
||||
const [followUpMode, setFollowUpMode] = useState(initialFollowUpMode || "post-apply");
|
||||
const [draftReloadToken, setDraftReloadToken] = useState(0);
|
||||
@@ -131,9 +189,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
setApplicationPackage(null);
|
||||
setJobAttachments([]);
|
||||
setSelectedAttachmentIds([]);
|
||||
setPackageGeneratedAt(null);
|
||||
setPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
setSavedPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
|
||||
setJob(r.data);
|
||||
setTailoredCvText(r.data.tailoredCvText ?? "");
|
||||
const savedWorkspace = {
|
||||
coverLetter: r.data.coverLetterText ?? "",
|
||||
applicationAnswer: extractApplicationAnswerDraft(r.data.notes),
|
||||
recruiterMessage: r.data.recruiterMessageDraft ?? "",
|
||||
};
|
||||
setSavedPackageWorkspace(savedWorkspace);
|
||||
setPackageWorkspace(savedWorkspace);
|
||||
setDraftRecipient(r.data.company?.recruiterEmail ?? "");
|
||||
setFollowUpMode(initialFollowUpMode || (r.data.status?.includes("Interview") ? "post-interview" : r.data.status === "Waiting" ? "waiting-update" : r.data.status === "Offer" ? "offer-checkin" : r.data.status === "Rejected" ? "feedback-request" : "post-apply"));
|
||||
});
|
||||
@@ -243,6 +311,73 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
</Box>
|
||||
) : null;
|
||||
|
||||
const tailoredCvStatus = getWorkspaceStatus(tailoredCvText, job?.tailoredCvText ?? "");
|
||||
const coverLetterStatus = getWorkspaceStatus(packageWorkspace.coverLetter, savedPackageWorkspace.coverLetter);
|
||||
const applicationAnswerStatus = getWorkspaceStatus(packageWorkspace.applicationAnswer, savedPackageWorkspace.applicationAnswer);
|
||||
const recruiterMessageStatus = getWorkspaceStatus(packageWorkspace.recruiterMessage, savedPackageWorkspace.recruiterMessage);
|
||||
const hasUnsavedPackageChanges = [
|
||||
tailoredCvText.trim() !== (job?.tailoredCvText ?? "").trim(),
|
||||
packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim(),
|
||||
packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim(),
|
||||
packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim(),
|
||||
].some(Boolean);
|
||||
|
||||
const savePackageWorkspace = async () => {
|
||||
if (!jobId || !job) return;
|
||||
|
||||
const nextNotes = upsertApplicationAnswerDraft(job.notes, packageWorkspace.applicationAnswer);
|
||||
const tailoredCvChanged = tailoredCvText.trim() !== (job.tailoredCvText ?? "").trim();
|
||||
const draftsChanged =
|
||||
packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim() ||
|
||||
packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim() ||
|
||||
packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim();
|
||||
|
||||
if (!tailoredCvChanged && !draftsChanged) {
|
||||
toast("No unsaved package changes.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (tailoredCvChanged) {
|
||||
setSavingTailoredCv(true);
|
||||
await api.put(`/jobapplications/${jobId}/tailored-cv`, { tailoredCvText });
|
||||
}
|
||||
|
||||
if (draftsChanged) {
|
||||
setSavingApplicationDrafts(true);
|
||||
await api.put(`/jobapplications/${jobId}/application-drafts`, {
|
||||
coverLetterText: packageWorkspace.coverLetter,
|
||||
notes: nextNotes,
|
||||
recruiterMessageDraft: packageWorkspace.recruiterMessage,
|
||||
});
|
||||
}
|
||||
|
||||
setJob((prev) => prev ? {
|
||||
...prev,
|
||||
tailoredCvText,
|
||||
tailoredCvUpdatedAt: tailoredCvChanged ? new Date().toISOString() : prev.tailoredCvUpdatedAt,
|
||||
coverLetterText: packageWorkspace.coverLetter,
|
||||
recruiterMessageDraft: packageWorkspace.recruiterMessage,
|
||||
notes: nextNotes,
|
||||
} : prev);
|
||||
setSavedPackageWorkspace({ ...packageWorkspace });
|
||||
setReadiness(null);
|
||||
setInterviewPrep(null);
|
||||
toast("Application package saved to this job.", "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to save the application package."), "error");
|
||||
} finally {
|
||||
setSavingTailoredCv(false);
|
||||
setSavingApplicationDrafts(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetPackageWorkspaceToSaved = () => {
|
||||
setTailoredCvText(job?.tailoredCvText ?? "");
|
||||
setPackageWorkspace(savedPackageWorkspace);
|
||||
toast("Restored the last saved package.", "info");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="lg">
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
@@ -362,9 +497,12 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
|
||||
{tab === 3 && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: hasUnsavedPackageChanges ? "warning.main" : "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Typography variant="overline">{t("jobDetailsTabTailoredCv")}</Typography>
|
||||
<Box>
|
||||
<Typography variant="overline">{t("jobDetailsTabTailoredCv")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Build the package here, then save the working copy back onto this job.</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>{t("jobDetailsTailoredCvMode")}</InputLabel>
|
||||
@@ -401,6 +539,12 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode, coverLetterStyle, attachmentIds: selectedAttachmentIds.join(",") || undefined } });
|
||||
setApplicationPackage(res.data);
|
||||
setTailoredCvText(res.data.tailoredCvText ?? "");
|
||||
setPackageWorkspace({
|
||||
coverLetter: res.data.coverLetterDraft ?? "",
|
||||
applicationAnswer: res.data.applicationAnswerDraft ?? "",
|
||||
recruiterMessage: res.data.recruiterMessageDraft ?? "",
|
||||
});
|
||||
setPackageGeneratedAt(new Date().toISOString());
|
||||
toast(t("jobDetailsPackageGenerated"), "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, t("jobDetailsPackageGenerationFailed")), "error");
|
||||
@@ -408,78 +552,59 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
setGeneratingPackage(false);
|
||||
}
|
||||
}}>{generatingPackage ? t("jobDetailsGeneratingPackage") : t("jobDetailsGeneratePackage")}</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => setTailoredCvText("")}>{t("jobDetailsClear")}</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(tailoredCvText)}>{t("jobDetailsCopy")}</Button>
|
||||
<Button size="small" variant="contained" disabled={savingTailoredCv} onClick={async () => {
|
||||
if (!jobId) return;
|
||||
setSavingTailoredCv(true);
|
||||
try {
|
||||
await api.put(`/jobapplications/${jobId}/tailored-cv`, { tailoredCvText });
|
||||
setJob((prev) => prev ? { ...prev, tailoredCvText, tailoredCvUpdatedAt: new Date().toISOString() } : prev);
|
||||
setReadiness(null);
|
||||
setInterviewPrep(null);
|
||||
toast(t("jobDetailsTailoredCvSaved"), "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, t("jobDetailsTailoredCvSaveFailed")), "error");
|
||||
} finally {
|
||||
setSavingTailoredCv(false);
|
||||
}
|
||||
}}>{savingTailoredCv ? t("jobDetailsSaving") : t("jobDetailsSaveTailoredCv")}</Button>
|
||||
<Button size="small" variant="outlined" disabled={!hasUnsavedPackageChanges} onClick={resetPackageWorkspaceToSaved}>Reset to saved</Button>
|
||||
<Button size="small" variant="contained" disabled={savingTailoredCv || savingApplicationDrafts} onClick={savePackageWorkspace}>{savingTailoredCv || savingApplicationDrafts ? t("jobDetailsSaving") : "Save package to job"}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.5 }}>
|
||||
<Chip size="small" label={`Tailored CV · ${tailoredCvStatus.label}`} color={tailoredCvStatus.color} />
|
||||
<Chip size="small" label={`Cover letter · ${coverLetterStatus.label}`} color={coverLetterStatus.color} />
|
||||
<Chip size="small" label={`Application answer · ${applicationAnswerStatus.label}`} color={applicationAnswerStatus.color} />
|
||||
<Chip size="small" label={`Recruiter message · ${recruiterMessageStatus.label}`} color={recruiterMessageStatus.color} />
|
||||
{packageGeneratedAt ? <Chip size="small" variant="outlined" label={`Generated ${new Date(packageGeneratedAt).toLocaleTimeString()}`} /> : null}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("jobDetailsTailoredCvIntro")}</Typography>
|
||||
<TextField value={tailoredCvText} onChange={(e) => setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder={t("jobDetailsTailoredCvPlaceholder")} />
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>{t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })}</Typography>
|
||||
</Box>
|
||||
|
||||
{applicationPackage ? (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<DraftCard title={t("jobDetailsCoverLetterDraft")} content={applicationPackage.coverLetterDraft ?? t("jobDetailsNoDraftAvailable")} onSave={async (content) => {
|
||||
if (!jobId) return;
|
||||
setSavingApplicationDrafts(true);
|
||||
try {
|
||||
await api.put(`/jobapplications/${jobId}/application-drafts`, { coverLetterText: content });
|
||||
setJob((prev) => prev ? { ...prev, coverLetterText: content } : prev);
|
||||
setReadiness(null);
|
||||
toast(t("jobDetailsCoverLetterSaved"), "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, t("jobDetailsCoverLetterSaveFailed")), "error");
|
||||
} finally {
|
||||
setSavingApplicationDrafts(false);
|
||||
}
|
||||
}} saving={savingApplicationDrafts} />
|
||||
<DraftCard title={t("jobDetailsShortApplicationAnswer")} content={applicationPackage.applicationAnswerDraft ?? t("jobDetailsNoDraftAvailable")} onSave={async (content) => {
|
||||
if (!jobId) return;
|
||||
setSavingApplicationDrafts(true);
|
||||
try {
|
||||
await api.put(`/jobapplications/${jobId}/application-drafts`, { notes: `Application answer draft:\n${content}` });
|
||||
setReadiness(null);
|
||||
toast(t("jobDetailsApplicationAnswerSaved"), "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, t("jobDetailsApplicationAnswerSaveFailed")), "error");
|
||||
} finally {
|
||||
setSavingApplicationDrafts(false);
|
||||
}
|
||||
}} saving={savingApplicationDrafts} />
|
||||
<DraftCard title={t("jobDetailsRecruiterMessageDraft")} content={applicationPackage.recruiterMessageDraft ?? t("jobDetailsNoDraftAvailable")} onSave={async (content) => {
|
||||
if (!jobId) return;
|
||||
setSavingApplicationDrafts(true);
|
||||
try {
|
||||
await api.put(`/jobapplications/${jobId}/application-drafts`, { recruiterMessageDraft: content });
|
||||
setJob((prev) => prev ? { ...prev, recruiterMessageDraft: content } : prev);
|
||||
toast(t("jobDetailsRecruiterMessageSaved"), "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, t("jobDetailsRecruiterMessageSaveFailed")), "error");
|
||||
} finally {
|
||||
setSavingApplicationDrafts(false);
|
||||
}
|
||||
}} saving={savingApplicationDrafts} />
|
||||
<ListCard title={t("jobDetailsKeyPoints")} items={applicationPackage.keyPoints} />
|
||||
<ListCard title={t("jobDetailsCoverLetterVariants")} items={applicationPackage.coverLetterVariants.length > 0 ? applicationPackage.coverLetterVariants : [t("jobDetailsNoDraftAvailable")]} />
|
||||
<ListCard title={t("jobDetailsRecruiterMessageVariants")} items={applicationPackage.recruiterMessageVariants.length > 0 ? applicationPackage.recruiterMessageVariants : [t("jobDetailsNoDraftAvailable")]} />
|
||||
<ListCard title={t("jobDetailsAttachmentSignals")} items={applicationPackage.attachmentSignals.length > 0 ? applicationPackage.attachmentSignals : [t("jobDetailsNoAttachmentSignals")]} subtitle={applicationPackage.attachmentFilesUsed.length > 0 ? applicationPackage.attachmentFilesUsed.join(", ") : undefined} />
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsCoverLetterDraft")}
|
||||
value={packageWorkspace.coverLetter}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, coverLetter: value }))}
|
||||
statusLabel={coverLetterStatus.label}
|
||||
statusColor={coverLetterStatus.color}
|
||||
/>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsShortApplicationAnswer")}
|
||||
value={packageWorkspace.applicationAnswer}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, applicationAnswer: value }))}
|
||||
statusLabel={applicationAnswerStatus.label}
|
||||
statusColor={applicationAnswerStatus.color}
|
||||
/>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsRecruiterMessageDraft")}
|
||||
value={packageWorkspace.recruiterMessage}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, recruiterMessage: value }))}
|
||||
statusLabel={recruiterMessageStatus.label}
|
||||
statusColor={recruiterMessageStatus.color}
|
||||
/>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Typography variant="overline">Saved working material</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>These saved copies are what later slices can trust and reuse.</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Typography variant="body2"><strong>Tailored CV:</strong> {(job?.tailoredCvText ?? "").trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
<Typography variant="body2"><strong>Cover letter:</strong> {savedPackageWorkspace.coverLetter.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
<Typography variant="body2"><strong>Application answer:</strong> {savedPackageWorkspace.applicationAnswer.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
<Typography variant="body2"><strong>Recruiter message:</strong> {savedPackageWorkspace.recruiterMessage.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
<ListCard title={t("jobDetailsKeyPoints")} items={applicationPackage?.keyPoints ?? ["Generate a package to pull in role-specific talking points."]} />
|
||||
<ListCard title={t("jobDetailsCoverLetterVariants")} items={applicationPackage?.coverLetterVariants?.length ? applicationPackage.coverLetterVariants : [t("jobDetailsNoDraftAvailable")]} />
|
||||
<ListCard title={t("jobDetailsRecruiterMessageVariants")} items={applicationPackage?.recruiterMessageVariants?.length ? applicationPackage.recruiterMessageVariants : [t("jobDetailsNoDraftAvailable")]} />
|
||||
<ListCard title={t("jobDetailsAttachmentSignals")} items={applicationPackage?.attachmentSignals?.length ? applicationPackage.attachmentSignals : [t("jobDetailsNoAttachmentSignals")]} subtitle={applicationPackage?.attachmentFilesUsed?.length ? applicationPackage.attachmentFilesUsed.join(", ") : undefined} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -653,6 +778,23 @@ function ListCard({ title, items, subtitle }: { title: string; items: string[];
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceDraftCard({ title, value, onChange, statusLabel, statusColor }: { title: string; value: string; onChange: (value: string) => void; statusLabel: string; statusColor: "default" | "success" | "warning" }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Typography variant="overline">{title}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Chip size="small" color={statusColor} label={statusLabel} />
|
||||
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<TextField value={value} onChange={(e) => onChange(e.target.value)} multiline minRows={7} fullWidth />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise<void> | void; saving?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = React.useState(content);
|
||||
|
||||
@@ -46,7 +46,19 @@ beforeEach(() => {
|
||||
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/jobapplications/42') {
|
||||
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme', recruiterEmail: 'recruiter@acme.test' }, tailoredCvText: '', shortSummary: 'summary' } } as any);
|
||||
return Promise.resolve({ data: {
|
||||
id: 42,
|
||||
jobTitle: 'Backend Developer',
|
||||
status: 'Applied',
|
||||
dateApplied: new Date().toISOString(),
|
||||
daysSince: 3,
|
||||
company: { name: 'Acme', recruiterEmail: 'recruiter@acme.test' },
|
||||
tailoredCvText: 'Saved CV',
|
||||
coverLetterText: 'Saved cover letter',
|
||||
recruiterMessageDraft: 'Saved recruiter message',
|
||||
notes: 'Original notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nSaved application answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
|
||||
shortSummary: 'summary'
|
||||
} } as any);
|
||||
}
|
||||
if (url === '/auth/me') {
|
||||
return Promise.resolve({ data: { roles: [], profileCvText: 'Master CV text' } } as any);
|
||||
@@ -73,25 +85,40 @@ afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('generated application package can be edited and saved', async () => {
|
||||
test('application package workspace reflects saved job material, generated drafts, and save state', async () => {
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
|
||||
expect(await screen.findByRole('button', { name: /generate application package/i })).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByDisplayValue('Saved CV')).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Saved cover letter')).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Saved application answer')).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Saved recruiter message')).toBeInTheDocument();
|
||||
expect(await screen.findByText(/saved working material/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate application package/i }));
|
||||
|
||||
expect(await screen.findByDisplayValue('Generated CV')).toBeInTheDocument();
|
||||
expect(await screen.findByText(/cover letter variants/i)).toBeInTheDocument();
|
||||
const coverLetter = await screen.findByDisplayValue('Draft letter');
|
||||
fireEvent.change(coverLetter, { target: { value: 'Edited cover letter' } });
|
||||
const applicationAnswer = await screen.findByDisplayValue('Draft answer');
|
||||
const recruiterMessage = await screen.findByDisplayValue('Recruiter hello');
|
||||
|
||||
const saveButtons = await screen.findAllByRole('button', { name: /^save$/i });
|
||||
fireEvent.click(saveButtons[0]);
|
||||
fireEvent.change(coverLetter, { target: { value: 'Edited cover letter' } });
|
||||
fireEvent.change(applicationAnswer, { target: { value: 'Edited answer' } });
|
||||
fireEvent.change(recruiterMessage, { target: { value: 'Edited recruiter note' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /save package to job/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/application-drafts', { coverLetterText: 'Edited cover letter' });
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/tailored-cv', { tailoredCvText: 'Generated CV' });
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/application-drafts', {
|
||||
coverLetterText: 'Edited cover letter',
|
||||
notes: 'Original notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nEdited answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
|
||||
recruiterMessageDraft: 'Edited recruiter note',
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findAllByText(/saved to job/i)).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
test('strategy snapshot can be generated from overview', async () => {
|
||||
|
||||
Reference in New Issue
Block a user