feat(workspace): application assets workflow
CI and Deploy / test (push) Failing after 1m11s
CI and Deploy / deploy (push) Has been skipped

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>
This commit is contained in:
cesnimda
2026-07-19 16:36:27 +02:00
parent a7cecce13d
commit 02b38f7acb
16 changed files with 3867 additions and 7 deletions
@@ -0,0 +1,368 @@
import React, { useCallback, useEffect, useState } from "react";
import {
Alert, Box, Button, Chip, Divider, IconButton, MenuItem, Paper, Skeleton, Stack, TextField,
Tooltip, Typography,
} from "@mui/material";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import RestoreIcon from "@mui/icons-material/Restore";
import { getApiErrorMessage } from "../api";
import {
ApplicationCv, CoverLetter, TailoringPlan, applicationAssetsApi,
} from "../applicationWorkspace";
// Phase 5.4 — Application Assets sections for the workspace.
//
// These compose systems that already exist. CV editing, preview, PDF export, themes and version
// history all live in the CV builder at /cv-builder — this section only chooses WHICH variant the
// application uses and links out. The cover letter is the one thing genuinely owned here, because it
// is application-specific by nature. docs/architecture/application-workspace.md.
function useAsset<T>(load: () => Promise<T>, deps: React.DependencyList) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
const run = useCallback(load, deps);
const reload = useCallback(() => {
let cancelled = false;
setLoading(true);
run()
.then((r) => {
if (!cancelled) {
setData(r);
setError(null);
}
})
.catch((err) => {
if (!cancelled) setError(getApiErrorMessage(err, "Could not load this section."));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [run]);
useEffect(() => reload(), [reload]);
return { data, error, loading, setData, setError, reload };
}
function Shell({ title, subtitle, loading, error, children }: {
title: string;
subtitle?: string;
loading: boolean;
error: string | null;
children: React.ReactNode;
}) {
return (
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{title}</Typography>
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
<Divider sx={{ my: 1.5 }} />
{loading ? (
<Stack spacing={1}>{[0, 1, 2].map((i) => <Skeleton key={i} variant="rounded" height={44} />)}</Stack>
) : error ? (
<Alert severity="error">{error}</Alert>
) : (
children
)}
</Paper>
);
}
// ---------- CV ----------
export function ApplicationCvSection({ jobId }: { jobId: number }) {
const { data, error, loading, setData, setError } = useAsset<ApplicationCv>(
() => applicationAssetsApi.cv(jobId),
[jobId],
);
const [busy, setBusy] = useState(false);
const attach = async (variantId: number | null) => {
setBusy(true);
try {
setData(await applicationAssetsApi.attachVariant(jobId, variantId));
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, "Could not change the attached CV."));
} finally {
setBusy(false);
}
};
const attached = data?.attachedVariantId ?? "";
return (
<Stack spacing={2}>
<Shell
title="CV"
subtitle="Which CV variant this application uses. Variants are lenses over your master career profile."
loading={loading}
error={error}
>
<Stack spacing={2}>
{(data?.availableVariants.length ?? 0) === 0 ? (
<Alert severity="info" sx={{ borderRadius: 2 }}>
No CV variants yet. Build one in the CV builder it starts from your master career
profile, so you never retype your history.
</Alert>
) : (
<TextField
select
size="small"
fullWidth
label="Attached CV variant"
value={attached}
disabled={busy}
onChange={(e) => attach(e.target.value === "" ? null : Number(e.target.value))}
helperText="Changing this only re-points the application. The variant itself is untouched."
>
<MenuItem value="">None</MenuItem>
{(data?.availableVariants ?? []).map((v) => (
<MenuItem key={v.id} value={v.id}>
{v.name} · {v.themeId} · v{v.version}
</MenuItem>
))}
</TextField>
)}
{data?.attachedVariantId ? (
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={1}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{data.attachedVariantName}</Typography>
<Typography variant="caption" color="text.secondary">
Theme {data.attachedThemeId} · version {data.attachedVersion}
{data.attachedIsPublic ? " · public" : ""}
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Button
size="small"
variant="outlined"
endIcon={<OpenInNewIcon fontSize="small" />}
href={`/cv-builder?variant=${data.attachedVariantId}`}
>
Edit, preview and export
</Button>
</Stack>
</Stack>
</Paper>
) : (
<Typography variant="body2" color="text.secondary">
No CV attached to this application yet.
</Typography>
)}
{data?.hasTailoredCvText && (
<Alert severity="info" sx={{ borderRadius: 2 }}>
This application also has legacy tailored CV text saved on it. A CV variant supersedes it.
</Alert>
)}
</Stack>
</Shell>
<ApplicationTailoringSection jobId={jobId} />
</Stack>
);
}
// ---------- Tailoring ----------
export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
const { data, error, loading } = useAsset<TailoringPlan>(
() => applicationAssetsApi.tailoring(jobId),
[jobId],
);
return (
<Shell
title="Tailoring"
subtitle="What to emphasise for this advert. Suggestions only — nothing here edits your profile or your CV."
loading={loading}
error={error}
>
<Stack spacing={2}>
{data && !data.hasCareerProfile && (
<Alert severity="info" sx={{ borderRadius: 2 }}>
Build your career profile to get experience and project suggestions.
</Alert>
)}
{data && !data.hasJobDescription && (
<Alert severity="info" sx={{ borderRadius: 2 }}>
Paste the advert text to get keyword and requirement suggestions.
</Alert>
)}
{(data?.suggestions.length ?? 0) === 0 ? (
<Typography variant="body2" color="text.secondary">
Nothing to suggest yet.
</Typography>
) : (
(data?.suggestions ?? []).map((s) => (
<Box key={s.kind}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{s.title}</Typography>
{s.detail && (
<Typography variant="caption" color="text.secondary">{s.detail}</Typography>
)}
<Stack direction="row" flexWrap="wrap" gap={0.5} sx={{ mt: 0.75 }}>
{s.items.map((item) => (
<Chip key={item} size="small" label={item} variant="outlined" />
))}
</Stack>
</Box>
))
)}
</Stack>
</Shell>
);
}
// ---------- Cover letter ----------
const TEMPLATE = `Dear Hiring Manager,
I am writing to apply for the [role] position at [company]. [One sentence on why this company, specifically.]
In my current role I [the most relevant thing you have done, with a concrete outcome]. [A second example that matches what the advert asks for.]
[Why you want this job, in your own words.]
I would welcome the chance to talk it through.
Kind regards,
[Your name]`;
export function ApplicationCoverLetterSection({ jobId }: { jobId: number }) {
const { data, error, loading, setData, setError } = useAsset<CoverLetter>(
() => applicationAssetsApi.coverLetter(jobId),
[jobId],
);
const [draft, setDraft] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// The textarea is only seeded from the server until the user starts typing, so a reload never
// clobbers unsaved edits.
const text = draft ?? data?.text ?? "";
const dirty = draft !== null && draft !== (data?.text ?? "");
const save = async (value: string, source = "manual") => {
setBusy(true);
try {
setData(await applicationAssetsApi.saveCoverLetter(jobId, value, source));
setDraft(null);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, "Could not save the cover letter."));
} finally {
setBusy(false);
}
};
const restore = async (version: number) => {
setBusy(true);
try {
setData(await applicationAssetsApi.restoreCoverLetter(jobId, version));
setDraft(null);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, "Could not restore that version."));
} finally {
setBusy(false);
}
};
return (
<Stack spacing={2}>
<Shell
title="Cover letter"
subtitle="Every save keeps the previous text, so nothing you write is ever lost."
loading={loading}
error={error}
>
<Stack spacing={2}>
<TextField
multiline
minRows={12}
fullWidth
label="Cover letter"
value={text}
disabled={busy}
onChange={(e) => setDraft(e.target.value)}
placeholder="Write it yourself, start from the template, or generate a draft with the AI panel below."
/>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Button variant="contained" disabled={busy || !dirty} onClick={() => save(text)}>
Save
</Button>
<Button disabled={busy || !dirty} onClick={() => setDraft(null)}>
Discard changes
</Button>
<Button
disabled={busy || text.trim().length > 0}
onClick={() => setDraft(TEMPLATE)}
>
Start from template
</Button>
{dirty && (
<Chip size="small" color="warning" variant="outlined" label="Unsaved changes" />
)}
</Stack>
</Stack>
</Shell>
<Shell title="Version history" loading={loading} error={null}>
{(data?.versions.length ?? 0) === 0 ? (
<Typography variant="body2" color="text.secondary">
No versions yet. The first save starts the history.
</Typography>
) : (
<Stack spacing={0.5}>
{(data?.versions ?? []).map((v) => (
<Stack
key={v.version}
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ py: 0.5 }}
>
<Box sx={{ minWidth: 0 }}>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
<Typography variant="body2" sx={{ fontWeight: 600 }}>v{v.version}</Typography>
<Chip size="small" variant="outlined" label={v.aiAction ? `${v.source} · ${v.aiAction}` : v.source} />
{v.isCurrent && <Chip size="small" color="primary" variant="outlined" label="Current" />}
</Stack>
<Typography variant="caption" color="text.secondary">
{new Date(v.createdAtUtc).toLocaleString()} · {v.length} characters
</Typography>
</Box>
{!v.isCurrent && (
<Tooltip title="Restore this version">
<span>
<IconButton
size="small"
disabled={busy}
aria-label={`Restore version ${v.version}`}
onClick={() => restore(v.version)}
>
<RestoreIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
)}
</Stack>
))}
</Stack>
)}
</Shell>
</Stack>
);
}