02b38f7acb
Phase 5.4. Connects the career outputs a user already has to one job
application, without building a second copy of any of them.
The flow is strictly one-directional — CareerProfile -> CvVariant ->
application output — and nothing writes back up. No code path in this phase
touches CareerProfile or its children.
CV integration re-points rather than duplicates. GET/PUT /{id}/cv attaches one
variant to an application via CvVariant.JobApplicationId; replacing detaches the
previous variant instead of deleting it. Creating, duplicating, editing, theming,
previewing, exporting PDF and version history all stay in the existing CV
builder, which the section links into. There is no second CV system.
Tailoring composes the Phase 5.3 analysis and match into skills to highlight,
experience to prioritise, projects to emphasise, keywords to include and gaps to
address. Deterministic and advisory: it says what the user could emphasise and
the user edits the variant themselves. Nothing auto-applies.
Cover letters gain the history they were missing. JobApplication.CoverLetterText
stays the current text with its API contract unchanged; CoverLetterVersions
records what it used to be, so an AI rewrite is never destructive. Restore is
additive — the old text comes back as a new version, so what you restored from
still exists. Source and AiAction record whether the user wrote a version or
approved it from a suggestion, and an AI generation only becomes a version once
the user saves it.
Documents are untouched: the existing Attachment system already covers CV, cover
letter, certificates and portfolio files with a Purpose field, so the workspace
mounts that component rather than adding a second upload path.
CoverLetterVersions is the only new table — reconciler-owned, no-op migration,
guarded on JobApplications, and verified on a fresh MariaDB 11: int
AUTO_INCREMENT primary key, varchar(255) owner, datetime(6), composite index
inside the key limit.
360 backend tests, 115 frontend tests, type check, Release build and the
production build all pass locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
317 lines
16 KiB
Markdown
317 lines
16 KiB
Markdown
# Application Workspace (Phase 5)
|
||
|
||
> Phase 5, Milestones 1–2 (2026-07-19). The per-application workspace: what it is, what it deliberately
|
||
> is not, and how it composes existing systems. Companion to `cv-builder.md`,
|
||
> `ai-career-assistant.md`, `career-profile-model.md`.
|
||
|
||
## What it is
|
||
|
||
A dedicated surface for one `JobApplication` at `/applications/{id}`, so an application is a place you
|
||
work rather than a row you edit in a modal. Job tracking stays the product; the workspace is the
|
||
application's home.
|
||
|
||
**Core principle:** the user should never ask *"what do I do next?"* — the overview always answers it.
|
||
|
||
## What it is NOT
|
||
|
||
The workspace **owns no data and duplicates none**. It is an aggregate read plus a navigation shell:
|
||
|
||
| Section | Backed by (existing system) |
|
||
|---|---|
|
||
| Timeline | `JobEvent` — interpreted, never replaced |
|
||
| Analysis / Match | the advert and the master `CareerProfile`, read deterministically |
|
||
| Checklist | `ApplicationChecklistItem` — completion state only, seeded from the readiness signals |
|
||
| CV | Phase 4 `CvVariant` — a lens over the master `CareerProfile`; the application only points at one |
|
||
| Cover Letter | `JobApplication.CoverLetterText` + `CoverLetterVersions` history |
|
||
| Analysis / Match / Interview | Phase 5 `AiWorkspacePanel` + `AiInteraction` history |
|
||
| Documents | `Attachment` |
|
||
| Communication | `Correspondence` |
|
||
| Activity / Timeline | `JobEvent` |
|
||
| Stage semantics | `JobPipeline` |
|
||
|
||
No career data is copied into the application. Nothing in this feature writes to the master profile,
|
||
a CV variant, or a cover letter.
|
||
|
||
## Backend
|
||
|
||
`GET /api/jobapplications/{id}/workspace` → `WorkspaceOverviewDto`
|
||
(`ApplicationWorkspaceController` + `ApplicationWorkspaceService`).
|
||
|
||
One aggregate read instead of the page fanning out: role/company/location/salary, status + pipeline
|
||
group, applied/deadline/follow-up dates, the attached CV variant (id, name, theme), cover-letter
|
||
presence, document count, AI interaction count + last run, recent `JobEvent` activity, and the
|
||
computed **next recommended action**.
|
||
|
||
Read-only and tenant-scoped (`OwnerUserId`), returning 404 for another user's application.
|
||
|
||
### Next recommended action
|
||
|
||
Milestone 2 moved this onto the checklist: **the first pending checklist item**, ordered by category
|
||
priority (`preparation` → `submission` → `follow-up` → `interview` → `custom`) then the user's own
|
||
ordering. `null` means nothing is outstanding.
|
||
|
||
That means the overview cannot recommend something the user has already ticked off, a dismissed item
|
||
never comes back as a recommendation, and a task the user added themselves can legitimately be the
|
||
next action. There is no second ruleset to keep in sync.
|
||
|
||
## The checklist
|
||
|
||
`ApplicationChecklistItem` — a **workflow guidance layer**, not a store of truth. It records only "is
|
||
this step done, and does the user still want it". The CV still lives in `CvVariant`, documents in
|
||
`Attachment`, history in `JobEvent`, follow-up in `JobApplication.FollowUpAt`.
|
||
|
||
### System items and auto-completion
|
||
|
||
Each default item carries a stable `SystemKey` and usually an `AutoSignal` — the *same* signal
|
||
`/readiness` already computed. On every read the service re-syncs:
|
||
|
||
- signal satisfied + item pending → **done**, `IsAutoCompleted = true`
|
||
- signal no longer satisfied + item was auto-completed → back to **pending**
|
||
- a **manual** tick clears `IsAutoCompleted` and therefore sticks, even against the signal
|
||
|
||
So "readiness says the CV is missing" and "the checklist says Prepare a CV is pending" cannot drift
|
||
apart — they read the same state. The user always wins over the signal.
|
||
|
||
| System key | Category | Signal |
|
||
|---|---|---|
|
||
| `review-job-details` | preparation | advert text present |
|
||
| `complete-career-profile` | preparation | career profile with at least one experience |
|
||
| `prepare-cv` | preparation | CV variant attached, or tailored CV text |
|
||
| `review-cv-match` | preparation | *manual* |
|
||
| `create-cover-letter` | preparation | cover letter present |
|
||
| `attach-portfolio` | preparation | `HasPortfolio` |
|
||
| `attach-supporting-documents` | preparation | at least one `Attachment` |
|
||
| `save-application-answers` | preparation | saved answer draft in `Notes` |
|
||
| `capture-recruiter-contact` | preparation | `Company.RecruiterEmail` |
|
||
| `confirm-submitted` | submission | applied date set and out of the prospect stage |
|
||
| `add-follow-up-reminder` | follow-up | `FollowUpAt` set |
|
||
| `set-next-action` | follow-up | `NextAction` written |
|
||
| `prepare-interview-notes` | interview | prep notes present, **or** not at an interview stage |
|
||
| `research-company` | interview | *manual* |
|
||
|
||
Seeding is idempotent per `(JobApplicationId, SystemKey)` — enforced by a unique index, so a re-read
|
||
never duplicates. Custom items have a `NULL` `SystemKey`; both SQLite and MariaDB treat NULLs as
|
||
distinct in a unique index, so a user can add as many as they like.
|
||
|
||
Deleting a **system** item dismisses it (a hard delete would be undone by the next seed); deleting a
|
||
**custom** item removes the row. Dismissed items leave the progress denominator entirely.
|
||
|
||
### API
|
||
|
||
`/api/jobapplications/{id}/checklist` — `GET` (seeds + syncs + returns items and progress),
|
||
`POST` (custom item), `PATCH /{itemId}`, `DELETE /{itemId}`, `PUT /order` (array of ids).
|
||
Tenant-scoped on `OwnerUserId`; another user's application is a 404.
|
||
|
||
### Relationship to the other systems
|
||
|
||
- **Not `JobEvent`** — the checklist is forward-looking intent; `JobEvent` is the append-only history.
|
||
- **Not follow-ups** — `FollowUpAt`, `RulesEngine` and the reminder hosted service still own
|
||
scheduling. The checklist only asks whether a follow-up exists.
|
||
- **Not `Attachment` / `CvVariant`** — it reads their presence as a signal and stores nothing of them.
|
||
|
||
### Future AI suggestions
|
||
|
||
An AI-suggested task is just a checklist row with `IsSystemGenerated = false` and no `AutoSignal`,
|
||
created after the user approves it. Nothing in the AI path may create, complete or delete an item
|
||
without approval — same rule as everywhere else (`ai-career-assistant.md`).
|
||
|
||
### Schema provisioning
|
||
|
||
`ApplicationChecklistItems` follows the established MariaDB-safe path: the scaffolded migration
|
||
(`20260719085904_AddApplicationChecklistItems`) is a **no-op**, and the table is created by the
|
||
idempotent reconciler in `StartupInitializationExtensions`, which has correct DDL per provider. A
|
||
SQLite-scaffolded migration would emit `TEXT` datetimes and a PK without `AUTO_INCREMENT` on MariaDB —
|
||
the failure that crashed prod startup for the Phase 4 tables.
|
||
|
||
Verified on MariaDB 11: `int AUTO_INCREMENT` PK, `varchar`/`datetime(6)`/`tinyint(1)` columns, both
|
||
indexes inside the 3072-byte key limit, cascade delete from `JobApplications`, the unique index
|
||
rejecting a duplicate system key, and NULL system keys not colliding.
|
||
|
||
## Frontend
|
||
|
||
`ApplicationWorkspacePage` (`/applications/:id`) — a left nav plus a content pane, section selected by
|
||
`?section=`, so a section is linkable and survives refresh. Reached from the job dialog's "Open
|
||
application workspace" button.
|
||
|
||
The dialog passes an optional `onOpenWorkspace` callback rather than calling `useNavigate` itself:
|
||
`JobDetailsDialog` must stay renderable without a `<Router>` (several suites mount it standalone), so
|
||
router context belongs to the caller.
|
||
|
||
The Checklist section (`ApplicationChecklist`) groups items by category, shows a completion bar, and
|
||
supports tick/untick, add, remove and reorder. System items are labelled "Detected" when a signal
|
||
completed them, custom items "Yours". Every mutation re-reads, because only the backend's sync knows
|
||
the real post-mutation state.
|
||
|
||
Implemented now: Overview, Checklist, Job Details, and the sections that reuse an existing component
|
||
(Analysis/Match/Interview → `AiWorkspacePanel`, Documents → `Attachments`,
|
||
Communication → `Correspondence`). Sections owned by later milestones state their milestone instead of
|
||
faking functionality.
|
||
|
||
## Relationship to `/readiness`
|
||
|
||
`GET /{id}/readiness` keeps its DTO shape (`score`, `level`, `completed`, `missing`, `reminders`,
|
||
`workflowSignal`) and still backs the dialog's Readiness tab — but as of Milestone 2 it no longer runs
|
||
its own parallel checklist. It **projects** the persisted checklist:
|
||
|
||
- `completed` / `missing` — the live (non-dismissed) items by status
|
||
- `score` — the checklist completion percentage
|
||
- `level` — Ready ≥ 80, Needs polish ≥ 60, otherwise Needs work
|
||
- `reminders` / `workflowSignal` — unchanged; `BuildWorkflowSignal` remains the health/attention view
|
||
|
||
So the division is: **the checklist is the workflow the user drives, readiness is the calculation and
|
||
health indicator derived from it.** One system, two projections.
|
||
|
||
## Application intelligence (Phase 5.3)
|
||
|
||
Three read-only reads that answer "how suitable is this job", "how does my experience match", "what am
|
||
I missing", "what happened previously". All deterministic, all owned by nothing:
|
||
|
||
| Endpoint | Reads | Owns |
|
||
|---|---|---|
|
||
| `GET /{id}/timeline` | `JobEvent` | nothing |
|
||
| `GET /{id}/analysis` | `JobApplication.Description` | nothing |
|
||
| `GET /{id}/match` | `CareerProfile` + the advert | nothing |
|
||
|
||
No new table, no new column. `ApplicationTimelineService` and `ApplicationIntelligenceService` write
|
||
nothing at all.
|
||
|
||
### The AI boundary
|
||
|
||
**The deterministic answer and the AI narrative are separate on purpose.**
|
||
|
||
- The three endpoints above never call the AI. Opening the Analysis or Match section costs nothing
|
||
and cannot change anything — the page renders a computed answer.
|
||
- The narrative lives where it already did: `AiWorkspaceService`'s `job-analysis` and `career-match`
|
||
modules, reached from `AiWorkspacePanel`, generated only when the user asks.
|
||
- Every generation is appended as an `AiInteraction` — that append-only history *is* the versioning,
|
||
and the user restores, compares or deletes from it.
|
||
- AI output is a suggestion. Nothing in this phase writes to the `CareerProfile`, a `CvVariant`, a
|
||
cover letter, or the `JobApplication`. `Match_never_writes_to_the_career_profile` pins that.
|
||
|
||
So a user gets a trustworthy number for free, and pays for prose only when they want it.
|
||
|
||
### Timeline
|
||
|
||
`JobEvent` stays the source of historical truth; the service is an interpretation layer over it. Each
|
||
row gains a readable summary (`("StatusChanged", "Applied", "Interview")` → "Moved from Applied to
|
||
Interview"), a category (`lifecycle`, `stage`, `follow-up`, `communication`, `ai`) and a milestone
|
||
flag. Events group by day with relative labels.
|
||
|
||
Milestones are the stages that mean something happened — applied, interview, offer, rejected,
|
||
accepted, declined — plus creation and replies received. They are returned **unfiltered**: narrowing
|
||
the detail below must not hide what actually happened.
|
||
|
||
An unrecognised future `Type` degrades to a humanised sentence rather than disappearing.
|
||
|
||
### Job analysis
|
||
|
||
Structured extraction from the advert, reusing the existing `SkillTagger` so the vocabulary matches
|
||
the job importer and the CV match. Facts (role, company, location, employment type, seniority,
|
||
salary), lists (technologies, skills, responsibilities, requirements, keywords, interview topics),
|
||
and — deliberately — **what the advert does not say**, which is usually the more useful half.
|
||
|
||
With no advert saved it degrades to the fields on the application and says so.
|
||
|
||
### Career matching
|
||
|
||
Feeds the master `CareerProfile` into the same `JobCvMatchService` the CV builder uses, so one
|
||
application scores identically whichever surface asks. Returns the score and band, matched and
|
||
missing skills, and — the part that makes it actionable — **which experience and project entries are
|
||
the evidence** for each matched keyword, ranked by hit count.
|
||
|
||
Suggestions describe what the user could change. They never change it.
|
||
|
||
With no profile it returns score 0 and asks the user to build one, rather than implying a bad match.
|
||
|
||
## Application assets (Phase 5.4)
|
||
|
||
The workspace becomes the place an application is prepared. The rule is one-directional:
|
||
|
||
```
|
||
CareerProfile → CvVariant → application-specific output
|
||
```
|
||
|
||
Nothing flows back up. No code path in this phase writes to `CareerProfile` or its children —
|
||
`Tailoring_never_writes_to_the_career_profile_or_the_variant` pins it.
|
||
|
||
### What is owned where
|
||
|
||
| Asset | Owned by | This phase adds |
|
||
|---|---|---|
|
||
| CV content | `CareerProfile` (master) | nothing |
|
||
| CV variant, preview, PDF, themes, version history | `CvVariantService` / `/api/cv` | nothing |
|
||
| Which variant an application uses | `CvVariant.JobApplicationId` | the attach/detach route |
|
||
| Documents | `Attachment` / `/api/attachments` | nothing |
|
||
| Cover letter text | `JobApplication.CoverLetterText` | version history |
|
||
| AI narrative | `AiInteraction` | nothing |
|
||
|
||
The only new table is `CoverLetterVersions` — reconciler-owned, no-op migration, guarded on
|
||
`JobApplications` (`docs/infrastructure/database-ownership.md`). Verified on MariaDB 11:
|
||
`int AUTO_INCREMENT` PK, `varchar(255)` owner, `datetime(6)`, composite index inside the key limit.
|
||
|
||
### CV integration
|
||
|
||
`GET/PUT /{id}/cv` reads and re-points. Attaching sets `CvVariant.JobApplicationId`; **one variant per
|
||
application**, so the workspace can answer "which CV am I sending". Replacing detaches the previous
|
||
variant rather than deleting it — it is still the user's to reuse. Everything else (create, duplicate,
|
||
edit, theme, preview, export PDF, versions, restore) is a link into the existing CV builder. There is
|
||
deliberately no second CV system.
|
||
|
||
### Tailoring
|
||
|
||
`GET /{id}/tailoring` composes the Phase 5.3 analysis and match into five suggestion kinds: skills to
|
||
highlight, experience to prioritise, projects to emphasise, keywords to include, gaps to address.
|
||
|
||
Deterministic and advisory. It returns what the user *could* emphasise; the user edits the variant in
|
||
the builder. Nothing auto-applies, and no suggestion mutates a variant or the profile.
|
||
|
||
### Cover letter
|
||
|
||
`JobApplication.CoverLetterText` stays the current text and its existing API contract is unchanged.
|
||
`CoverLetterVersions` records what it used to be, so an AI rewrite is never destructive.
|
||
|
||
- Every save that changes the text snapshots a new version; an unchanged save is a no-op, so autosave
|
||
never burns history.
|
||
- **Restore is additive** — the old text returns as a *new* version, so what you restored from is
|
||
still there.
|
||
- `Source` (`manual | ai | template | restore`) and `AiAction` record how each version came about, so
|
||
the history shows what the user wrote versus what they approved from a suggestion.
|
||
- An AI generation on its own is only an `AiInteraction`. It becomes a version when the user saves it
|
||
— that is what "requires approval" means here.
|
||
|
||
Creation methods: write it, start from the built-in template, or generate from the AI panel below the
|
||
editor. The editor is always the user's; generation is never triggered by opening the page.
|
||
|
||
### Documents
|
||
|
||
Unchanged. The existing `Attachments` component and `/api/attachments` already handle CV, cover
|
||
letter, certificates, portfolio and other files with a `Purpose` field, and `JobApplication.HasResume`
|
||
/ `HasCoverLetter` / `HasPortfolio` are derived from it. The workspace mounts that component; no new
|
||
storage, no duplicate upload path. Files stay private to the owning user.
|
||
|
||
### Future extension points
|
||
|
||
- **Another asset type**: add a section, compose the service that already owns it — do not add storage.
|
||
- **AI cover-letter actions** (improve, shorten, expand, tone, tailor): already modelled by
|
||
`CoverLetterVersion.AiAction`; wire a new mode in `AiWorkspaceService` and save the approved result.
|
||
- **Multiple attached variants**: relax the one-per-application rule in `AttachVariantAsync`; the DTO
|
||
already carries the full variant list.
|
||
|
||
## Extension points
|
||
|
||
- **New section**: add to `WORKSPACE_SECTIONS` and render it; nav is data-driven.
|
||
- **New next-action rule**: add one clause to `ApplicationWorkspaceService.NextStep` — ordered, so
|
||
position is the priority.
|
||
- **More overview data**: extend `WorkspaceOverviewDto`; the page reads one payload.
|
||
|
||
## Milestones
|
||
|
||
1. ✅ Workspace foundation — route, nav shell, aggregate overview, next recommended action.
|
||
2. ✅ Checklist and progress tracking — persisted items, auto-completion from the readiness signals,
|
||
custom items, reordering, dismissal; readiness refactored into a projection of it.
|
||
3. ✅ Application intelligence — timeline interpretation, structured job analysis, career matching
|
||
(Phase 5.3, all three deterministic and read-only).
|
||
4. ✅ Application assets — CV variant association, tailoring suggestions, cover letter workflow with
|
||
version history, documents (Phase 5.4).
|
||
7. Cover letter workflow. 8. Documents. 9. Interview preparation. 10. Dashboard improvements.
|