fix(rd21): retain draft revisions and qualify conflict recovery

This commit is contained in:
cesnimda
2026-10-23 12:34:34 +02:00
parent 12f342c09c
commit 05a738693e
53 changed files with 813 additions and 192 deletions
@@ -264,6 +264,7 @@ export function ApplicationWorkspace({
{section === "application" && subsection === "answers" && overview && jobId > 0 && <ApplicationPackageDraftsSection
key={jobId}
jobId={jobId}
initialRevision={overview.revision}
initialApplicationAnswer={overview?.applicationAnswerDraft ?? ""}
initialRecruiterMessage={overview?.recruiterMessageDraft ?? ""}
onSaved={load}
+35 -18
View File
@@ -68,7 +68,7 @@ type CvImportDiff = {
}>;
};
type CvRunDiffResponse = { runId: number; status: string; diff: CvImportDiff };
type CvRunDiffResponse = { runId: number; status: string; diff: CvImportDiff; revision: number };
type QueuedCvRunResponse = {
queued: boolean;
@@ -146,7 +146,7 @@ function initialsFrom(values: Array<string | undefined>) {
// Phase 3: the master profile now comes from the relational source of truth via /career/profile.
type CareerSectionStatus = { key: string; label: string; complete: boolean; count: number };
type CareerCompleteness = { percent: number; missing: string[]; sections: CareerSectionStatus[] };
type CareerProfileResponse = { profile: StructuredCvProfile; completeness: CareerCompleteness; cvText?: string | null };
type CareerProfileResponse = { profile: StructuredCvProfile; completeness: CareerCompleteness; cvText?: string | null; revision: number };
type CareerVersion = { version: number; source: string; createdAtUtc: string; isCurrent: boolean };
type CareerWorkspaceSection = "overview" | "profile" | "evidence" | "import";
@@ -175,6 +175,8 @@ export default function CareerProfilePage() {
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const profileMutationPending = useRef(false);
const [profileRevision, setProfileRevision] = useState<number | null>(null);
const profileReadId = useRef(0);
const [uploadingCv, setUploadingCv] = useState(false);
const [improvingCv, setImprovingCv] = useState(false);
const [rebuildingCv, setRebuildingCv] = useState(false);
@@ -231,7 +233,9 @@ export default function CareerProfilePage() {
setLoading(true);
setRestoreError(null);
try {
const r = await api.post<CareerProfileResponse>(`/career/profile/versions/${version}/restore`);
const r = await api.post<CareerProfileResponse>(`/career/profile/versions/${version}/restore`, { expectedVersion: profileRevision });
setProfileRevision(r.data.revision);
setProfileCvText(r.data.cvText ?? "");
setStructuredCv(normalizeStructuredCv(r.data?.profile ?? emptyStructuredCv()));
setCompleteness(r.data?.completeness ?? null);
setProfileDirty(false);
@@ -239,19 +243,20 @@ export default function CareerProfilePage() {
await loadVersions();
toast(t("profileUpdated"), "success");
} catch (error) {
const message = getApiErrorMessage(error, "");
const message = (error as { response?: { status?: number } }).response?.status === 409 ? "revision_conflict" : getApiErrorMessage(error, "");
setRestoreError(message);
toast(message || translationRef.current("careerRestoreFailed"), "error");
toast(message === "revision_conflict" ? translationRef.current("revisionConflict") : message || translationRef.current("careerRestoreFailed"), "error");
} finally {
profileMutationPending.current = false;
setLoading(false);
}
}, [loadVersions, loading, me?.provider, profileDirty, t, toast]);
}, [loadVersions, loading, me?.provider, profileDirty, profileRevision, t, toast]);
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
const [runsLoading, setRunsLoading] = useState(true);
const [runsLoadError, setRunsLoadError] = useState<string | null>(null);
const runsReadId = useRef(0);
const [runDiffs, setRunDiffs] = useState<Record<number, CvImportDiff>>({});
const runDiffRevisions = useRef<Record<number, number>>({});
const [runDiffErrors, setRunDiffErrors] = useState<Record<number, string>>({});
const [runDiffLoading, setRunDiffLoading] = useState<Record<number, boolean>>({});
const diffReadGeneration = useRef(0);
@@ -269,6 +274,7 @@ export default function CareerProfilePage() {
// A comparison and its choices refer to the source that was current when it was read.
++diffReadGeneration.current;
setRunDiffs({});
runDiffRevisions.current = {};
setRunDiffErrors({});
setRunDiffLoading({});
setAcceptedLowConfidenceIds({});
@@ -283,7 +289,10 @@ export default function CareerProfilePage() {
setRunDiffErrors((current) => { const next = { ...current }; delete next[runId]; return next; });
try {
const response = await api.get<CvRunDiffResponse>(`/profile-cv/runs/${runId}/diff`);
if (isCurrent()) setRunDiffs((current) => ({ ...current, [runId]: response.data.diff }));
if (isCurrent()) {
runDiffRevisions.current[runId] = response.data.revision;
setRunDiffs((current) => ({ ...current, [runId]: response.data.diff }));
}
} catch (error) {
if (isCurrent()) setRunDiffErrors((current) => ({ ...current, [runId]: getApiErrorMessage(error, "") }));
} finally {
@@ -329,6 +338,7 @@ export default function CareerProfilePage() {
}, [loadExtractionRuns, t, toast]);
const loadProfile = useCallback(async () => {
const readId = ++profileReadId.current;
setLoading(true);
try {
// /career reads the structured profile from the relational source of truth (/career/profile);
@@ -337,6 +347,8 @@ export default function CareerProfilePage() {
api.get<CareerProfileResponse>("/career/profile"),
api.get<MeResponse>("/auth/me"),
]);
if (readId !== profileReadId.current) return;
setProfileRevision(careerResponse.data.revision);
setMe(meResponse.data);
setProfileCvText(careerResponse.data?.cvText ?? "");
const profile = normalizeStructuredCv(careerResponse.data?.profile ?? emptyStructuredCv());
@@ -346,10 +358,11 @@ export default function CareerProfilePage() {
setHeadline(profile.contact.headline ?? "");
setLoadError(null);
} catch (error) {
if (readId !== profileReadId.current) return;
setMe(null);
setLoadError(getApiErrorMessage(error, ""));
} finally {
setLoading(false);
if (readId === profileReadId.current) setLoading(false);
}
}, []);
@@ -357,6 +370,8 @@ export default function CareerProfilePage() {
void loadProfile();
void loadExtractionRuns();
void loadVersions();
const readId = profileReadId.current;
return () => { profileReadId.current = readId + 1; };
}, [loadExtractionRuns, loadProfile, loadVersions]);
useEffect(() => {
@@ -411,7 +426,7 @@ export default function CareerProfilePage() {
setReviewAction(action);
setRunActionErrors((current) => { const next = { ...current }; delete next[runId]; return next; });
try {
if (action === "accept") await api.post(`/profile-cv/runs/${runId}/accept`, { acceptedLowConfidenceIds: acceptedLowConfidenceIds[runId] ?? [] });
if (action === "accept") await api.post(`/profile-cv/runs/${runId}/accept`, { acceptedLowConfidenceIds: acceptedLowConfidenceIds[runId] ?? [], expectedVersion: runDiffRevisions.current[runId] });
else await api.post(`/profile-cv/runs/${runId}/discard`);
setExtractionRuns((current) => current.map((run) => run.id === runId ? { ...run, status: action === "accept" ? "applied" : "discarded" } : run));
if (action === "accept") {
@@ -420,8 +435,8 @@ export default function CareerProfilePage() {
} else await loadExtractionRuns();
toast(translationRef.current(action === "accept" ? "profileCvChangesApplied" : "profileCvExtractionDiscarded"), action === "accept" ? "success" : "info");
} catch (error) {
const key = action === "accept" ? "profileCvApplyChangesFailed" : "profileCvDiscardFailed";
const message = getApiErrorMessage(error, "");
const key = (error as { response?: { status?: number } }).response?.status === 409 ? "revisionConflict" : action === "accept" ? "profileCvApplyChangesFailed" : "profileCvDiscardFailed";
const message = key === "revisionConflict" ? "" : getApiErrorMessage(error, "");
setRunActionErrors((current) => ({ ...current, [runId]: { key, message } }));
toast(message || translationRef.current(key), "error");
} finally {
@@ -543,7 +558,7 @@ export default function CareerProfilePage() {
versionsError={versionsError}
onRetryVersions={() => void loadVersions()}
restoreDisabled={profileDirty}
restoreError={restoreError}
restoreError={restoreError === "revision_conflict" ? t("revisionConflict") : restoreError}
/> : null}
{workspaceSection === "profile" ? <CropImageDialog
open={cropOpen}
@@ -903,13 +918,14 @@ export default function CareerProfilePage() {
profileMutationPending.current = true;
setParsingCvSections(true);
try {
const res = await api.post<{ structuredCv?: StructuredCvProfile }>("/profile-cv/parse", { text: profileCvText });
const res = await api.post<{ structuredCv?: StructuredCvProfile; revision: number }>("/profile-cv/parse", { text: profileCvText, expectedVersion: profileRevision });
setProfileRevision(res.data.revision);
editStructuredCv(normalizeStructuredCv(res.data?.structuredCv));
invalidateImportComparisons();
void loadVersions();
toast(t("profileCvStructureParsed"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvStructureParseFailed")), "error");
toast(e?.response?.status === 409 ? t("revisionConflict") : getApiErrorMessage(e, t("profileCvStructureParseFailed")), "error");
} finally {
profileMutationPending.current = false;
setParsingCvSections(false);
@@ -981,7 +997,7 @@ export default function CareerProfilePage() {
<Typography variant="body2" color="text.secondary" sx={{ width: "100%" }}>{t("profileCvStructuredEditorHelp")}</Typography>
{saveError !== null ? <Alert severity="error" sx={{ width: "100%" }}>
{t("profileUpdateFailed")}
{saveError ? <Typography variant="body2" sx={{ mt: 0.5 }}>{saveError}</Typography> : null}
{saveError ? <Typography variant="body2" sx={{ mt: 0.5 }}>{saveError === "revision_conflict" ? t("revisionConflict") : saveError}</Typography> : null}
</Alert> : null}
{profileDirty ? <Chip size="small" color="warning" variant="outlined" label={t("assetsUnsavedChanges")} /> : null}
<Button
@@ -996,7 +1012,8 @@ export default function CareerProfilePage() {
// Save the master profile through the relational source of truth. The endpoint
// persists the structured children + version and keeps the legacy blob projection
// in sync; identity fields are never touched (they belong to /profile).
const saved = await api.put<CareerProfileResponse>("/career/profile", { profile: structuredCv, cvText: profileCvText });
const saved = await api.put<CareerProfileResponse>("/career/profile", { profile: structuredCv, cvText: profileCvText, expectedVersion: profileRevision });
setProfileRevision(saved.data.revision);
setStructuredCv(normalizeStructuredCv(saved.data?.profile ?? structuredCv));
setCompleteness(saved.data?.completeness ?? null);
setProfileDirty(false);
@@ -1004,9 +1021,9 @@ export default function CareerProfilePage() {
void loadVersions();
toast(t("profileUpdated"), "success");
} catch (error) {
const message = getApiErrorMessage(error, "");
const message = (error as { response?: { status?: number } }).response?.status === 409 ? "revision_conflict" : getApiErrorMessage(error, "");
setSaveError(message);
toast(message || translationRef.current("profileUpdateFailed"), "error");
toast(message === "revision_conflict" ? translationRef.current("revisionConflict") : message || translationRef.current("profileUpdateFailed"), "error");
} finally {
profileMutationPending.current = false;
setSaving(false);