fix(rd21): retain draft revisions and qualify conflict recovery
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user