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
+52 -22
View File
@@ -52,6 +52,7 @@ const tailoring = {
};
const coverLetter = {
revision: 7,
text: "Dear team",
currentVersion: 2,
versions: [
@@ -232,7 +233,7 @@ test("editing marks the draft dirty and saving sends the new text", async () =>
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
"/jobapplications/7/cover-letter",
{ text: "Dear hiring team", source: "manual", aiAction: undefined },
{ text: "Dear hiring team", source: "manual", aiAction: undefined, expectedVersion: 7 },
));
});
@@ -255,7 +256,7 @@ test("restoring an old version calls the restore endpoint", async () => {
fireEvent.click(await screen.findByRole("button", { name: /Restore version 1/i }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(
"/jobapplications/7/cover-letter/versions/1/restore"));
"/jobapplications/7/cover-letter/versions/1/restore", { expectedVersion: 7 }));
});
test("an empty cover letter offers the template and an empty history", async () => {
@@ -321,7 +322,7 @@ test("AI cover letter suggestions use the linked CV and require explicit apply",
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
"/jobapplications/7/cover-letter",
{ text: "A tailored, truthful suggestion.", source: "ai", aiAction: "generate" },
{ text: "A tailored, truthful suggestion.", source: "ai", aiAction: "generate", expectedVersion: 7 },
));
});
@@ -355,11 +356,11 @@ test("a failed load surfaces an error", async () => {
// ---------- Application package drafts ----------
test("application answer and recruiter drafts save from the dedicated workspace", async () => {
mockedApi.put.mockResolvedValue({ data: undefined } as any);
mockedApi.put.mockResolvedValue({ data: undefined, headers: { "x-job-version": "8" } } as any);
const onSaved = jest.fn();
render(
<ApplicationPackageDraftsSection
<ApplicationPackageDraftsSection initialRevision={7}
jobId={7}
initialApplicationAnswer="Saved answer"
initialRecruiterMessage="Saved recruiter note"
@@ -376,17 +377,17 @@ test("application answer and recruiter drafts save from the dedicated workspace"
"/jobapplications/7/application-drafts",
{
applicationAnswerDraft: "Edited answer",
recruiterMessageDraft: "Edited recruiter note",
recruiterMessageDraft: "Edited recruiter note", expectedVersion: 7,
},
));
expect(onSaved).toHaveBeenCalled();
});
test("application package drafts can be cleared without deleting ordinary notes", async () => {
mockedApi.put.mockResolvedValue({ data: undefined } as any);
mockedApi.put.mockResolvedValue({ data: undefined, headers: { "x-job-version": "8" } } as any);
render(
<ApplicationPackageDraftsSection
<ApplicationPackageDraftsSection initialRevision={7}
jobId={7}
initialApplicationAnswer="Saved answer"
initialRecruiterMessage="Saved recruiter note"
@@ -399,7 +400,7 @@ test("application package drafts can be cleared without deleting ordinary notes"
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
"/jobapplications/7/application-drafts",
{ applicationAnswerDraft: "", recruiterMessageDraft: "" },
{ applicationAnswerDraft: "", recruiterMessageDraft: "", expectedVersion: 7 },
));
});
@@ -428,7 +429,7 @@ test("failed cover-letter saving retains editable dirty text and original Save r
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2));
const payload = { text, source: "manual", aiAction: undefined };
const payload = { text, source: "manual", aiAction: undefined, expectedVersion: 7 };
expect(mockedApi.put).toHaveBeenNthCalledWith(1, "/jobapplications/7/cover-letter", payload);
expect(mockedApi.put).toHaveBeenNthCalledWith(2, "/jobapplications/7/cover-letter", payload);
await waitFor(() => expect(onDirtyChange).toHaveBeenLastCalledWith(false));
@@ -437,10 +438,10 @@ test("failed cover-letter saving retains editable dirty text and original Save r
});
test("failed application-answer saving keeps both dirty drafts editable and retries only the original save payload", async () => {
mockedApi.put.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce({ status: 204 } as any);
mockedApi.put.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce({ status: 204, headers: { "x-job-version": "8" } } as any);
const onDirtyChange = jest.fn();
const onSaved = jest.fn();
render(<ApplicationPackageDraftsSection jobId={7} initialApplicationAnswer="Saved answer" initialRecruiterMessage="Saved message" onDirtyChange={onDirtyChange} onSaved={onSaved} />);
render(<ApplicationPackageDraftsSection initialRevision={7} jobId={7} initialApplicationAnswer="Saved answer" initialRecruiterMessage="Saved message" onDirtyChange={onDirtyChange} onSaved={onSaved} />);
fireEvent.change(screen.getByLabelText(copy("assetsApplicationAnswer")), { target: { value: "Retained answer" } });
fireEvent.change(screen.getByLabelText(copy("assetsRecruiterMessage")), { target: { value: "Retained recruiter draft" } });
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
@@ -456,7 +457,7 @@ test("failed application-answer saving keeps both dirty drafts editable and retr
expect(mockedApi.get).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));
const payload = { applicationAnswerDraft: "Retained answer", recruiterMessageDraft: "Retained recruiter draft" };
const payload = { applicationAnswerDraft: "Retained answer", recruiterMessageDraft: "Retained recruiter draft", expectedVersion: 7 };
expect(mockedApi.put).toHaveBeenNthCalledWith(1, "/jobapplications/7/application-drafts", payload);
expect(mockedApi.put).toHaveBeenNthCalledWith(2, "/jobapplications/7/application-drafts", payload);
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
@@ -505,7 +506,7 @@ test("dirty version restore cancels without a write and confirmed held restore a
const confirm = within(confirmed).getByRole("button", { name: copy("assetsRestoreConfirm") });
act(() => { fireEvent.click(confirm); fireEvent.click(confirm); });
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/cover-letter/versions/1/restore");
expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/cover-letter/versions/1/restore", { expectedVersion: 7 });
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("button", { name: /Restore version 1/i })).toBeDisabled();
await act(async () => { write.resolve({ data: {
@@ -533,7 +534,7 @@ test("held letter Save submits one snapshot and prevents an AI Apply from replac
const save = section("workspaceCoverLetter").getByRole("button", { name: "Save" });
act(() => { fireEvent.click(save); fireEvent.click(save); });
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Explicit manual save snapshot", source: "manual", aiAction: undefined });
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Explicit manual save snapshot", source: "manual", aiAction: undefined, expectedVersion: 7 });
expect(apply).toBeDisabled();
fireEvent.click(apply);
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Explicit manual save snapshot");
@@ -563,7 +564,7 @@ test("held AI generation locks its controls and preserves captured action attrib
fireEvent.click(screen.getByRole("button", { name: /Apply to editor/i }));
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "Captured generated suggestion" } } as any);
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: "Save" }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Captured generated suggestion", source: "ai", aiAction: "generate" }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Captured generated suggestion", source: "ai", aiAction: "generate", expectedVersion: 7 }));
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
@@ -719,20 +720,20 @@ test("keyed job changes keep job B's answer drafts and callbacks independent of
const onSavedA = jest.fn();
const onSavedB = jest.fn();
const onDirtyB = jest.fn();
const mounted = render(<ApplicationPackageDraftsSection key={7} jobId={7}
const mounted = render(<ApplicationPackageDraftsSection initialRevision={7} key={7} jobId={7}
initialApplicationAnswer="Saved A answer" initialRecruiterMessage="Saved A message" onSaved={onSavedA} />);
fireEvent.change(screen.getByRole("textbox", { name: copy("assetsApplicationAnswer") }), { target: { value: "Pending A answer" } });
fireEvent.change(screen.getByRole("textbox", { name: copy("assetsRecruiterMessage") }), { target: { value: "Pending A message" } });
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/application-drafts", {
applicationAnswerDraft: "Pending A answer", recruiterMessageDraft: "Pending A message",
applicationAnswerDraft: "Pending A answer", recruiterMessageDraft: "Pending A message", expectedVersion: 7,
});
mounted.rerender(withProviders(<ApplicationPackageDraftsSection key={8} jobId={8}
mounted.rerender(withProviders(<ApplicationPackageDraftsSection initialRevision={7} key={8} jobId={8}
initialApplicationAnswer="Saved B answer" initialRecruiterMessage="Saved B message" onSaved={onSavedB} onDirtyChange={onDirtyB} />));
fireEvent.change(screen.getByRole("textbox", { name: copy("assetsApplicationAnswer") }), { target: { value: "Unsaved B answer" } });
fireEvent.change(screen.getByRole("textbox", { name: copy("assetsRecruiterMessage") }), { target: { value: "Unsaved B message" } });
await act(async () => { saveA.resolve({ data: undefined }); await saveA.promise; });
await act(async () => { saveA.resolve({ data: undefined, headers: { "x-job-version": "8" } }); await saveA.promise; });
expect(screen.getByRole("textbox", { name: copy("assetsApplicationAnswer") })).toHaveValue("Unsaved B answer");
expect(screen.getByRole("textbox", { name: copy("assetsRecruiterMessage") })).toHaveValue("Unsaved B message");
expect(screen.getByRole("button", { name: copy("assetsSaveDrafts") })).toBeEnabled();
@@ -741,11 +742,40 @@ test("keyed job changes keep job B's answer drafts and callbacks independent of
expect(onSavedB).not.toHaveBeenCalled();
expect(mockedApi.put).toHaveBeenCalledTimes(1);
mockedApi.put.mockResolvedValueOnce({ data: undefined } as any);
mockedApi.put.mockResolvedValueOnce({ data: undefined, headers: { "x-job-version": "8" } } as any);
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
await waitFor(() => expect(onSavedB).toHaveBeenCalledTimes(1));
expect(mockedApi.put).toHaveBeenLastCalledWith("/jobapplications/8/application-drafts", {
applicationAnswerDraft: "Unsaved B answer", recruiterMessageDraft: "Unsaved B message",
applicationAnswerDraft: "Unsaved B answer", recruiterMessageDraft: "Unsaved B message", expectedVersion: 7,
});
expect(onSavedA).not.toHaveBeenCalled();
});
test("a parent refresh cannot attach a newer revision to dirty application drafts", async () => {
mockedApi.put.mockResolvedValue({ data: undefined, headers: { "x-job-version": "8" } } as any);
const mounted = render(<ApplicationPackageDraftsSection jobId={7} initialRevision={7} initialApplicationAnswer="Original answer" initialRecruiterMessage="Original message" />);
fireEvent.change(screen.getByLabelText(copy("assetsApplicationAnswer")), { target: { value: "Local answer" } });
mounted.rerender(withProviders(<ApplicationPackageDraftsSection jobId={7} initialRevision={11} initialApplicationAnswer="Remote answer" initialRecruiterMessage="Remote message" />));
expect(screen.getByLabelText(copy("assetsApplicationAnswer"))).toHaveValue("Local answer");
expect(screen.getByLabelText(copy("assetsRecruiterMessage"))).toHaveValue("Original message");
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/application-drafts", { applicationAnswerDraft: "Local answer", recruiterMessageDraft: "Original message", expectedVersion: 7 }));
await waitFor(() => expect(screen.queryByText(copy("assetsUnsavedChanges"))).not.toBeInTheDocument());
fireEvent.change(screen.getByLabelText(copy("assetsApplicationAnswer")), { target: { value: "Next answer" } });
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
await waitFor(() => expect(mockedApi.put).toHaveBeenLastCalledWith("/jobapplications/7/application-drafts", { applicationAnswerDraft: "Next answer", recruiterMessageDraft: "Original message", expectedVersion: 8 }));
});
test.each([409, 0])("cover-letter rejection (%s) retains draft and source revision without a fresh read", async (status) => {
routeGet(); mockedApi.put.mockRejectedValue({ response: { status, data: { code: "revision_conflict" } } });
render(<ApplicationCoverLetterSection jobId={7} />);
fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: "Retained conflict draft" } });
const reads = mockedApi.get.mock.calls.length;
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") }));
await section("workspaceCoverLetter").findByRole("alert");
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue("Retained conflict draft");
expect(screen.getByText(copy("assetsUnsavedChanges"))).toBeInTheDocument();
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Retained conflict draft", source: "manual", aiAction: undefined, expectedVersion: 7 });
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
if (status === 409) expect(section("workspaceCoverLetter").getByRole("alert")).toHaveTextContent(copy("revisionConflict"));
});
+12 -6
View File
@@ -14,6 +14,7 @@ export type WorkspaceNextStep = { key: string; label: string; reason: string; se
export type WorkspaceOverview = {
id: number;
revision: number;
jobTitle: string;
company: string | null;
location: string | null;
@@ -252,6 +253,7 @@ export type CoverLetterVersion = {
};
export type CoverLetter = {
revision: number;
text: string | null;
currentVersion: number;
versions: CoverLetterVersion[];
@@ -295,10 +297,10 @@ export const applicationAssetsApi = {
api.get<TailoringPlan>(`/jobapplications/${jobId}/tailoring`).then((r) => r.data),
coverLetter: (jobId: number) =>
api.get<CoverLetter>(`/jobapplications/${jobId}/cover-letter`).then((r) => r.data),
saveCoverLetter: (jobId: number, text: string, source = "manual", aiAction?: string) =>
api.put<CoverLetter>(`/jobapplications/${jobId}/cover-letter`, { text, source, aiAction }).then((r) => r.data),
restoreCoverLetter: (jobId: number, version: number) =>
api.post<CoverLetter>(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`).then((r) => r.data),
saveCoverLetter: (jobId: number, text: string, expectedVersion: number, source = "manual", aiAction?: string) =>
api.put<CoverLetter>(`/jobapplications/${jobId}/cover-letter`, { text, source, aiAction, expectedVersion }).then((r) => r.data),
restoreCoverLetter: (jobId: number, version: number, expectedVersion: number) =>
api.post<CoverLetter>(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`, { expectedVersion }).then((r) => r.data),
submittedPackages: (jobId: number) =>
api.get<SubmittedPackage[]>(`/jobapplications/${jobId}/submitted-packages`).then((r) => r.data),
captureSubmittedPackage: (jobId: number) =>
@@ -307,8 +309,12 @@ export const applicationAssetsApi = {
api.get<SubmittedPackageDetail>(`/jobapplications/${jobId}/submitted-packages/${packageId}`).then((r) => r.data),
downloadSubmittedAttachment: (jobId: number, packageId: number, attachmentId: number) =>
api.get<Blob>(`/jobapplications/${jobId}/submitted-packages/${packageId}/attachments/${attachmentId}`, { responseType: "blob" }).then((r) => r.data),
saveApplicationDrafts: (jobId: number, applicationAnswerDraft: string, recruiterMessageDraft: string) =>
api.put(`/jobapplications/${jobId}/application-drafts`, { applicationAnswerDraft, recruiterMessageDraft }).then(() => undefined),
saveApplicationDrafts: (jobId: number, applicationAnswerDraft: string, recruiterMessageDraft: string, expectedVersion: number) =>
api.put(`/jobapplications/${jobId}/application-drafts`, { applicationAnswerDraft, recruiterMessageDraft, expectedVersion }).then((response) => {
const revision = Number(response.headers?.["x-job-version"]);
if (!Number.isInteger(revision) || revision <= 0) throw new Error("Missing committed job revision.");
return revision;
}),
};
// Phase 5.5 — Interview preparation and follow-up. Prep content is the user's; AI suggestions come
+49 -9
View File
@@ -29,7 +29,7 @@ function deferred<T>() {
}
function sourceResponse() {
return { data: { profile, cvText: rawText, completeness: { percent: 72, missing: [], sections: [] } } };
return { data: { revision: 7, profile, cvText: rawText, completeness: { percent: 72, missing: [], sections: [] } } };
}
function renderPage() {
@@ -88,10 +88,10 @@ beforeEach(() => {
{ version: 3, source: "manual", createdAtUtc: "2026-10-11T01:00:00Z", isCurrent: false },
] } as any);
if (url === "/profile-cv/runs") return Promise.resolve({ data: [] } as any);
return Promise.resolve({ data: {} } as any);
return Promise.resolve({ data: { revision: 8 } } as any);
});
mockedApi.put.mockResolvedValue({ data: {} } as any);
mockedApi.post.mockResolvedValue({ data: {} } as any);
mockedApi.put.mockResolvedValue({ data: { revision: 8 } } as any);
mockedApi.post.mockResolvedValue({ data: { revision: 8 } } as any);
});
test("a locale update retains dirty structured and raw drafts without reading their source again", async () => {
@@ -124,7 +124,7 @@ test("a pending initial read keeps one request and reports its failure in the la
expect(screen.getByRole("button", { name: en.profileSaveChanges })).toBeDisabled();
expect(screen.queryByRole("button", { name: en.saving })).not.toBeInTheDocument();
await changeLocale();
await act(async () => { pending.reject({ isAxiosError: true, message: "Request failed with status code 500", response: { status: 500, data: {} } }); });
await act(async () => { pending.reject({ isAxiosError: true, message: "Request failed with status code 500", response: { status: 500, data: { revision: 8 } } }); });
expect(readCount("/career/profile")).toBe(1);
expect(readCount("/auth/me")).toBe(1);
@@ -164,7 +164,7 @@ test("a held Save locks every editor mutation, raw input and history restore unt
expect(screen.getByLabelText(en.profileCvTextLabel)).toBeDisabled();
expect(mockedApi.post).not.toHaveBeenCalled();
} finally {
await act(async () => { pending.resolve({ data: {} }); await pending.promise; });
await act(async () => { pending.resolve({ data: { revision: 8 } }); await pending.promise; });
}
fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceProfileTab }));
expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Snapshot owner");
@@ -187,13 +187,13 @@ test("two Save activations before React commits pending state send only one cano
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(mockedApi.post).not.toHaveBeenCalled();
} finally {
await act(async () => { pending.resolve({ data: {} }); await pending.promise; });
await act(async () => { pending.resolve({ data: { revision: 8 } }); await pending.promise; });
}
});
test("a failed Save retains a scoped localized error and draft for retry through the same Save button", async () => {
localStorage.setItem("uiLanguage", "nb");
mockedApi.put.mockRejectedValueOnce({ isAxiosError: true, message: "Request failed with status code 500", response: { status: 500, data: {} } });
mockedApi.put.mockRejectedValueOnce({ isAxiosError: true, message: "Request failed with status code 500", response: { status: 500, data: { revision: 8 } } });
renderPage();
await waitFor(() => expect(screen.getByLabelText(nb.profileCvContactFullName)).toHaveValue("Recorded owner"));
fireEvent.change(screen.getByLabelText(nb.profileCvContactFullName), { target: { value: "Beholdt utkast" } });
@@ -259,12 +259,52 @@ test("manual edits preserve long-tail data, custom links, opaque IDs and modeled
awards: ["Recorded award", "Manual award"],
contact: { ...profile.contact, links: [{ label: "Edited portfolio", url: "https://example.test/portfolio" }] },
jobs: [{ ...profile.jobs[0], title: "Edited role" }],
}, cvText: "Manually retained source\nNo extraction requested" });
}, cvText: "Manually retained source\nNo extraction requested", expectedVersion: 7 });
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.patch).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
});
test.each([409, 0])("a rejected save (%s) keeps the draft and its original revision for deliberate retry", async (status) => {
mockedApi.put.mockRejectedValueOnce({ response: { status, data: { code: "revision_conflict" } } });
renderPage();
await ready();
fireEvent.change(screen.getByLabelText(en.profileCvContactFullName), { target: { value: "Recoverable draft" } });
fireEvent.click(screen.getByRole("button", { name: en.profileSaveChanges }));
await editor().findByRole("alert");
expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Recoverable draft");
expect(screen.getByText(en.assetsUnsavedChanges)).toBeInTheDocument();
expect(mockedApi.put).toHaveBeenLastCalledWith("/career/profile", expect.objectContaining({ expectedVersion: 7 }));
expect(readCount("/career/profile")).toBe(1);
fireEvent.click(screen.getByRole("button", { name: en.profileSaveChanges }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2));
expect(mockedApi.put.mock.calls[1][1]).toEqual(mockedApi.put.mock.calls[0][1]);
});
test("consecutive acknowledged career saves advance the revision returned with the saved snapshot", async () => {
mockedApi.put.mockResolvedValueOnce({ data: { ...sourceResponse().data, revision: 8 } } as any);
renderPage(); await ready();
fireEvent.click(screen.getByRole("button", { name: en.profileSaveChanges }));
await waitFor(() => expect(screen.getByRole("button", { name: en.profileSaveChanges })).toBeEnabled());
fireEvent.change(screen.getByLabelText(en.profileCvContactFullName), { target: { value: "Next snapshot" } });
fireEvent.click(screen.getByRole("button", { name: en.profileSaveChanges }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2));
expect(mockedApi.put.mock.calls.map(([, body]) => (body as any).expectedVersion)).toEqual([7, 8]);
});
test("a delayed prior lifecycle read cannot replace edits in the current career editor", async () => {
const oldRead = deferred<any>(); let reads = 0;
const get = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string) => url === "/career/profile" && ++reads === 1 ? oldRead.promise : get(url));
render(<React.StrictMode><ToastProvider><I18nProvider><CareerProfilePage /></I18nProvider></ToastProvider></React.StrictMode>);
await ready();
fireEvent.change(screen.getByLabelText(en.profileCvContactFullName), { target: { value: "Current lifecycle draft" } });
await act(async () => oldRead.resolve({ data: { ...sourceResponse().data, revision: 2, profile: { ...profile, contact: { ...profile.contact, fullName: "Late old source" } } } }));
expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Current lifecycle draft");
fireEvent.click(screen.getByRole("button", { name: en.profileSaveChanges }));
expect(mockedApi.put).toHaveBeenCalledWith("/career/profile", expect.objectContaining({ expectedVersion: 7 }));
});
test("a read-only provider cannot edit canonical fields or trigger canonical writes", async () => {
provider = "external";
renderPage();
@@ -18,7 +18,7 @@ const mockedApi = api as jest.Mocked<typeof api>;
const en = translations.en;
const nb = translations.nb;
const profile = { ...emptyStructuredCv(), contact: { fullName: "Recorded owner" }, languages: [{ name: "Norwegian", level: "A2" }] };
const source = { data: { profile, cvText: "Retained canonical raw", completeness: { percent: 50, missing: [], sections: [] } } };
const source = { data: { revision: 7, profile, cvText: "Retained canonical raw", completeness: { percent: 50, missing: [], sections: [] } } };
const versions = [
{ version: 2, source: "manual", createdAtUtc: "2026-10-12T01:00:00Z", isCurrent: true },
{ version: 1, source: "manual", createdAtUtc: "2026-10-11T01:00:00Z", isCurrent: false },
@@ -69,7 +69,7 @@ beforeEach(() => {
if (url === "/auth/me") return Promise.resolve({ data: { id: "owner", provider: "local", email: "owner@example.test" } } as any);
if (url === "/career/profile/versions") return Promise.resolve({ data: versions } as any);
if (url === "/profile-cv/runs") return Promise.resolve({ data: runs } as any);
if (url.endsWith("/diff")) return Promise.resolve({ data: { runId: Number(url.split("/")[3]), status: "pending_review", diff } } as any);
if (url.endsWith("/diff")) return Promise.resolve({ data: { runId: Number(url.split("/")[3]), status: "pending_review", revision: 7, diff } } as any);
return Promise.reject(new Error(`Unexpected GET ${url}`));
});
mockedApi.put.mockResolvedValue(source as any);
@@ -135,7 +135,7 @@ test("selecting then deselecting low-confidence data sends the explicit empty se
fireEvent.click(chooseLow()); expect(chooseLow()).not.toBeChecked();
runs = [{ ...run, status: "applied" }];
fireEvent.click(screen.getByRole("button", { name: en.profileCvApplyChanges }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/profile-cv/runs/41/accept", { acceptedLowConfidenceIds: [] }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/profile-cv/runs/41/accept", { expectedVersion: 7, acceptedLowConfidenceIds: [] }));
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
@@ -145,7 +145,7 @@ test("held acceptance locks review choices and all canonical editors and prevent
const apply = screen.getByRole("button", { name: en.profileCvApplyChanges }); apply.focus(); fireEvent.click(apply); fireEvent.click(apply);
expect(screen.getByRole("article", { name: "File import 41" })).toHaveFocus();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledWith("/profile-cv/runs/41/accept", { acceptedLowConfidenceIds: ["Languages|french"] });
expect(mockedApi.post).toHaveBeenCalledWith("/profile-cv/runs/41/accept", { expectedVersion: 7, acceptedLowConfidenceIds: ["Languages|french"] });
expect(chooseLow()).toBeDisabled(); expect(screen.getByRole("button", { name: en.careerImportApplying })).toBeDisabled();
navigate(en.careerWorkspaceProfileTab);
expect(screen.getByLabelText(en.profileCvContactFullName)).toBeDisabled();
@@ -241,7 +241,7 @@ test("a failed operation retry stays scoped and only resends the same operation
test("Bokmål localizes comparison labels while preserving imported facts named like UI labels or object properties", async () => {
localStorage.setItem("uiLanguage", "nb");
const read = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string) => url.endsWith("/diff") ? Promise.resolve({ data: { runId: 41, diff: {
mockedApi.get.mockImplementation((url: string) => url.endsWith("/diff") ? Promise.resolve({ data: { runId: 41, revision: 7, diff: {
totalAdded: 2, totalUpdated: 0, totalLowConfidence: 1, hasChanges: true, categories: [
{ category: "Projects", added: [{ id: "Projects|references", label: "References", confidence: "Medium", fieldChanges: [] }], updated: [], unchangedCount: 0, lowConfidenceCount: 0 },
{ category: "Languages", added: [{ id: "Languages|constructor", label: "constructor", confidence: "Low", fieldChanges: [] }], updated: [], unchangedCount: 0, lowConfidenceCount: 1 },
@@ -259,7 +259,7 @@ test("Bokmål localizes comparison labels while preserving imported facts named
test("the known low-confidence contact placeholder is localized", async () => {
localStorage.setItem("uiLanguage", "nb");
const read = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string) => url.endsWith("/diff") ? Promise.resolve({ data: { runId: 41, diff: {
mockedApi.get.mockImplementation((url: string) => url.endsWith("/diff") ? Promise.resolve({ data: { runId: 41, revision: 7, diff: {
totalAdded: 0, totalUpdated: 1, totalLowConfidence: 1, hasChanges: true, categories: [
{ category: "Contact", added: [], updated: [{ id: "Contact", label: "Contact details", confidence: "Low", fieldChanges: [{ field: "Email", oldValue: "current@example.test", newValue: "pending@example.test" }] }], unchangedCount: 0, lowConfidenceCount: 1 },
],
@@ -17,7 +17,7 @@ jest.mock("./cvBuilder", () => ({
const mockedList = cvBuilderApi.list as jest.MockedFunction<typeof cvBuilderApi.list>;
const mockedApi = api as jest.Mocked<typeof api>;
const careerResponse = { profile: { version: "1", contact: { fullName: "Stored career name" }, skills: ["Stored C#"] }, completeness: { percent: 70, missing: ["Projects"], sections: [] }, cvText: "Stored raw text" };
const careerResponse = { revision: 7, profile: { version: "1", contact: { fullName: "Stored career name" }, skills: ["Stored C#"] }, completeness: { percent: 70, missing: ["Projects"], sections: [] }, cvText: "Stored raw text" };
function renderOverview({
percent = 0,
@@ -38,7 +38,7 @@ type AssetError = ViewResourceError & { messageKey: TranslationKey; retry?: () =
function assetError(error: unknown, messageKey: TranslationKey, retryable = false): AssetError {
const status = (error as { response?: { status?: number } })?.response?.status;
const unauthorized = status === 401 || status === 403;
return { kind: unauthorized ? "unauthorized" : "error", message: getApiErrorMessage(error, ""), messageKey, retryable: retryable && !unauthorized, status };
return { kind: unauthorized ? "unauthorized" : "error", message: status === 409 ? "" : getApiErrorMessage(error, ""), messageKey: status === 409 ? "revisionConflict" : messageKey, retryable: retryable && !unauthorized, status };
}
function useAsset<T>(load: () => Promise<T>, deps: React.DependencyList) {
@@ -538,7 +538,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
contentRef.current?.focus();
setBusy(true);
try {
const next = await applicationAssetsApi.saveCoverLetter(jobId, value, source, aiAction);
const next = await applicationAssetsApi.saveCoverLetter(jobId, value, data.revision, source, aiAction);
if (!isCurrent()) return;
setData(next);
setDraft(null);
@@ -568,7 +568,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
if (!isCurrent()) return;
contentRef.current?.focus();
setBusy(true);
const next = await applicationAssetsApi.restoreCoverLetter(jobId, version);
const next = await applicationAssetsApi.restoreCoverLetter(jobId, version, data.revision);
if (!isCurrent()) return;
setData(next);
setDraft(null);
@@ -832,18 +832,21 @@ export function ApplicationPackageDraftsSection({
jobId,
initialApplicationAnswer,
initialRecruiterMessage,
initialRevision,
onSaved,
onDirtyChange,
}: {
jobId: number;
initialApplicationAnswer: string;
initialRecruiterMessage: string;
initialRevision: number;
onSaved?: () => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const { t } = useI18n();
const initial = { applicationAnswer: initialApplicationAnswer, recruiterMessage: initialRecruiterMessage };
const [saved, setSaved] = useState<PackageDrafts>(initial);
const [revision, setRevision] = useState(initialRevision);
const [draft, setDraft] = useState<PackageDrafts | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<AssetError | null>(null);
@@ -853,10 +856,10 @@ export function ApplicationPackageDraftsSection({
useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
useEffect(() => {
if (draft === null) setSaved(initial);
if (draft === null) { setSaved(initial); setRevision(initialRevision); }
// `draft` is deliberately excluded: a parent refresh must never overwrite in-progress edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jobId, initialApplicationAnswer, initialRecruiterMessage]);
}, [jobId, initialApplicationAnswer, initialRecruiterMessage, initialRevision]);
const value = draft ?? saved;
const dirty = draft !== null && (
@@ -876,8 +879,9 @@ export function ApplicationPackageDraftsSection({
contentRef.current?.focus();
setBusy(true);
try {
await applicationAssetsApi.saveApplicationDrafts(jobId, value.applicationAnswer, value.recruiterMessage);
const nextRevision = await applicationAssetsApi.saveApplicationDrafts(jobId, value.applicationAnswer, value.recruiterMessage, revision);
if (!active.current) return;
setRevision(nextRevision);
setSaved(value);
setDraft(null);
setError(null);
+49 -32
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
Autocomplete,
@@ -71,6 +71,9 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
const { toast } = useToast();
const { t } = useI18n();
const [loading, setLoading] = useState(false);
const [version, setVersion] = useState<number | null>(null);
const session = useRef(0);
const saving = useRef(false);
const { companies } = useCompanies();
const [company, setCompany] = useState<Company | null>(null);
@@ -103,10 +106,15 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
const [hasOtherAttachment, setHasOtherAttachment] = useState(false);
useEffect(() => {
const current = ++session.current;
saving.current = false;
setVersion(null);
if (!open || !jobId) return;
setLoading(true);
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
if (current !== session.current) return;
const j = r.data;
setVersion(j.version);
setCompany(j.company ?? null);
setJobTitle(j.jobTitle ?? "");
setStatus(j.status ?? "Applied");
@@ -135,16 +143,24 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
setHasCoverLetter(Boolean((j as any).hasCoverLetter));
setHasPortfolio(Boolean((j as any).hasPortfolio));
setHasOtherAttachment(Boolean((j as any).hasOtherAttachment));
}).finally(() => setLoading(false));
}).catch(() => {
if (current === session.current) toast(t("editJobSaveFailed"), "error");
}).finally(() => { if (current === session.current) setLoading(false); });
return () => { session.current = current + 1; };
// Locale updates must retain the loaded draft.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, jobId]);
const canSave = useMemo(() => !!company?.id && jobTitle.trim().length > 0 && !loading, [company, jobTitle, loading]);
const canSave = useMemo(() => !!company?.id && jobTitle.trim().length > 0 && version != null && !loading, [company, jobTitle, loading, version]);
const save = async () => {
if (!jobId || !company?.id) return;
if (!jobId || !company?.id || !canSave || saving.current) return;
const current = session.current;
saving.current = true;
setLoading(true);
try {
await api.put(`/jobapplications/${jobId}`, {
expectedVersion: version,
jobTitle: jobTitle.trim(),
companyId: company.id,
status,
@@ -169,18 +185,19 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
dateApplied: dateApplied || null,
jobUrl: jobUrl.trim() || null,
});
if (current !== session.current) return;
toast(t("save"), "success");
onSaved();
onClose();
} catch {
toast(t("editJobSaveFailed"), "error");
} catch (error) {
if (current === session.current) toast(t((error as { response?: { status?: number } }).response?.status === 409 ? "revisionConflict" : "editJobSaveFailed"), "error");
} finally {
setLoading(false);
if (current === session.current) { saving.current = false; setLoading(false); }
}
};
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<Dialog open={open} onClose={() => { if (!saving.current) onClose(); }} fullWidth maxWidth="md">
<DialogTitle>{t("editJobTitle")}</DialogTitle>
<DialogContent>
<Box sx={{ mt: 1, mb: 2, p: 1.5, borderRadius: 3, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.paper" }}>
@@ -193,48 +210,48 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobApplicationDetails")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
<Autocomplete options={companies} getOptionLabel={(c) => c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => <TextField {...params} label={t("company")} sx={FIELD_SX} />} />
<TextField label={t("editJobJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} sx={FIELD_SX} />
<DatePicker label={t("editJobAppliedOn")} value={parsePickerDate(dateApplied)} onChange={(value) => setDateApplied(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} sx={FIELD_SX} />
<Autocomplete disabled={loading} options={companies} getOptionLabel={(c) => c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => <TextField {...params} label={t("company")} sx={FIELD_SX} />} />
<TextField disabled={loading} label={t("editJobJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} sx={FIELD_SX} />
<DatePicker disabled={loading} label={t("editJobAppliedOn")} value={parsePickerDate(dateApplied)} onChange={(value) => setDateApplied(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField disabled={loading} label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} sx={FIELD_SX} />
</Box>
</Paper>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobStatusUpdate")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)} sx={FIELD_SX}>
<TextField disabled={loading} select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)} sx={FIELD_SX}>
{PIPELINE_STATUSES.map((s) => <MenuItem key={s} value={s}>{statusLabel(t, s)}</MenuItem>)}
</TextField>
<DatePicker label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { ...PICKER_TEXT_FIELD_PROPS, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} />
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box>
<DatePicker label={t("editJobReplyReceivedOn")} disabled={!responseReceived} value={parsePickerDate(responseDate)} onChange={(value) => setResponseDate(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField label={t("editJobNextAction")} value={nextAction} onChange={(e) => setNextAction(e.target.value)} sx={FIELD_SX} />
<DatePicker label={t("editJobFollowUpOn")} value={parsePickerDate(followUpAt)} onChange={(value) => setFollowUpAt(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<DatePicker disabled={loading} label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { ...PICKER_TEXT_FIELD_PROPS, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} />
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox disabled={loading} checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box>
<DatePicker disabled={loading || !responseReceived} label={t("editJobReplyReceivedOn")} value={parsePickerDate(responseDate)} onChange={(value) => setResponseDate(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField disabled={loading} label={t("editJobNextAction")} value={nextAction} onChange={(e) => setNextAction(e.target.value)} sx={FIELD_SX} />
<DatePicker disabled={loading} label={t("editJobFollowUpOn")} value={parsePickerDate(followUpAt)} onChange={(value) => setFollowUpAt(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
</Box>
</Paper>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobRoleDetails")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryMinLabel")} type="number" value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryMaxLabel")} type="number" value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryCurrencyLabel")} value={salaryCurrency} onChange={(e) => setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} />
<TextField select SelectProps={{ native: true }} label={t("salaryPeriodLabel")} value={salaryPeriod} onChange={(e) => setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}>
<TextField disabled={loading} label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
<TextField disabled={loading} label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
<TextField disabled={loading} label={t("salaryMinLabel")} type="number" value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} sx={FIELD_SX} />
<TextField disabled={loading} label={t("salaryMaxLabel")} type="number" value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} sx={FIELD_SX} />
<TextField disabled={loading} label={t("salaryCurrencyLabel")} value={salaryCurrency} onChange={(e) => setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} />
<TextField disabled={loading} select SelectProps={{ native: true }} label={t("salaryPeriodLabel")} value={salaryPeriod} onChange={(e) => setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}>
<option value=""></option>
<option value="year">{t("salaryPeriodYear")}</option>
<option value="month">{t("salaryPeriodMonth")}</option>
<option value="hour">{t("salaryPeriodHour")}</option>
</TextField>
<DatePicker label={t("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} sx={FIELD_SX} />
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput value={tags} onChange={setTags} /></Box>
<TextField label={t("editJobNotes")} value={notes} onChange={(e) => setNotes(e.target.value)} multiline rows={4} helperText={t("correspondenceCharacters", { count: notes.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField label={t("editJobDescriptionOriginal")} value={description} onChange={(e) => setDescription(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: description.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField label={t("editJobTranslatedDescription")} value={translatedDescription} onChange={(e) => setTranslatedDescription(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: translatedDescription.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField label={t("editJobCoverLetter")} value={coverLetterText} onChange={(e) => setCoverLetterText(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: coverLetterText.length })} sx={{ gridColumn: "1 / -1" }} />
<DatePicker disabled={loading} label={t("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField disabled={loading} label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} sx={FIELD_SX} />
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput disabled={loading} value={tags} onChange={setTags} /></Box>
<TextField disabled={loading} label={t("editJobNotes")} value={notes} onChange={(e) => setNotes(e.target.value)} multiline rows={4} helperText={t("correspondenceCharacters", { count: notes.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField disabled={loading} label={t("editJobDescriptionOriginal")} value={description} onChange={(e) => setDescription(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: description.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField disabled={loading} label={t("editJobTranslatedDescription")} value={translatedDescription} onChange={(e) => setTranslatedDescription(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: translatedDescription.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField disabled={loading} label={t("editJobCoverLetter")} value={coverLetterText} onChange={(e) => setCoverLetterText(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: coverLetterText.length })} sx={{ gridColumn: "1 / -1" }} />
</Box>
</Paper>
@@ -252,7 +269,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t("cancel")}</Button>
<Button onClick={onClose} disabled={loading}>{t("cancel")}</Button>
<Button variant="contained" onClick={save} disabled={!canSave}>{t("save")}</Button>
</DialogActions>
</Dialog>
@@ -11,7 +11,7 @@ import { checkSessionCookie, getSessionContext, isSessionInvalidated, SESSION_IN
// savedAt is omitted alongside id: both are assigned by the server on create, so an imported
// row has no business supplying one.
type ImportJob = Omit<JobApplication, "id" | "company" | "savedAt"> & {
type ImportJob = Omit<JobApplication, "id" | "company" | "savedAt" | "version"> & {
company: Pick<Company, "name" | "location" | "source">;
};
@@ -7,14 +7,17 @@ export default function TagsInput({
label,
value,
onChange,
disabled = false,
}: {
label?: string;
value: string[];
onChange: (next: string[]) => void;
disabled?: boolean;
}) {
const { t } = useI18n();
return (
<Autocomplete
disabled={disabled}
multiple
freeSolo
options={[]}
@@ -0,0 +1,52 @@
import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
import { ToastProvider } from "./toast";
import EditJobDialog from "./components/EditJobDialog";
jest.mock("./api", () => ({ api: { get: jest.fn(), put: jest.fn() } }));
jest.mock("./hooks/useCompanies", () => ({ useCompanies: () => ({ companies: [{ id: 2, name: "Synthetic company" }] }) }));
const client = api as jest.Mocked<typeof api>;
function deferred() { let resolve!: (value: any) => void; const promise = new Promise<any>((done) => { resolve = done; }); return { promise, resolve }; }
const job = (id: number, version: number, jobTitle: string) => ({ id, version, jobTitle, status: "Applied", company: { id: 2, name: "Synthetic company" }, dateApplied: "2026-10-01" });
const view = (id: number, onSaved = jest.fn(), onClose = jest.fn()) => <LocalizationProvider dateAdapter={AdapterDateFns}><ToastProvider><I18nProvider><EditJobDialog open jobId={id} onSaved={onSaved} onClose={onClose} /></I18nProvider></ToastProvider></LocalizationProvider>;
beforeEach(() => { jest.resetAllMocks(); localStorage.clear(); client.get.mockResolvedValue({ data: job(1, 7, "Recorded title") } as any); });
test.each([409, 0])("job save rejection (%s) preserves the draft and sends the source revision", async (status) => {
client.put.mockRejectedValue({ response: { status, data: { code: "revision_conflict" } } });
const onSaved = jest.fn(); const onClose = jest.fn();
render(view(1, onSaved, onClose));
await screen.findByDisplayValue("Recorded title");
fireEvent.change(screen.getByLabelText("Job title"), { target: { value: "Recoverable title" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Save" })).toBeEnabled());
expect(screen.getByLabelText("Job title")).toHaveValue("Recoverable title");
expect(client.put).toHaveBeenCalledWith("/jobapplications/1", expect.objectContaining({ jobTitle: "Recoverable title", expectedVersion: 7 }));
expect(onSaved).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
});
test("a held job save excludes editing and closing until the captured snapshot settles", async () => {
const pending = deferred(); client.put.mockReturnValue(pending.promise);
const onClose = jest.fn(); render(view(1, jest.fn(), onClose));
await screen.findByDisplayValue("Recorded title");
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(screen.getByLabelText("Job title")).toBeDisabled();
expect(screen.getByRole("combobox", { name: "Current status" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
await act(async () => pending.resolve({ data: undefined, headers: { "x-job-version": "8" } }));
});
test("a delayed old job read cannot overwrite the newer job session", async () => {
const oldRead = deferred(); client.get.mockReturnValueOnce(oldRead.promise).mockResolvedValueOnce({ data: job(2, 11, "New session title") } as any);
const mounted = render(view(1)); mounted.rerender(view(2));
await screen.findByDisplayValue("New session title");
fireEvent.change(screen.getByLabelText("Job title"), { target: { value: "New session draft" } });
await act(async () => oldRead.resolve({ data: job(1, 7, "Late old title") }));
expect(screen.getByLabelText("Job title")).toHaveValue("New session draft");
});
+2
View File
@@ -2558,6 +2558,7 @@ export const translations = {
editJobPortfolio: "Portfolio",
editJobOtherAttachment: "Other attachment",
editJobSaveFailed: "Save failed.",
revisionConflict: "This record changed elsewhere. Your draft is retained. Reload and review the latest version before saving.",
savedViewsTooltip: "Saved views",
savedViewsTitle: "Saved views",
savedViewsSubtitle: "Save the current filters as a 1-click view.",
@@ -5431,6 +5432,7 @@ export const translations = {
editJobPortfolio: "Portefølje",
editJobOtherAttachment: "Annet vedlegg",
editJobSaveFailed: "Lagring mislyktes.",
revisionConflict: "Denne oppføringen ble endret et annet sted. Utkastet ditt er beholdt. Last inn og gjennomgå siste versjon før du lagrer.",
savedViewsTooltip: "Lagrede visninger",
savedViewsTitle: "Lagrede visninger",
savedViewsSubtitle: "Lagre gjeldende filtre som en visning med ett klikk.",
@@ -31,6 +31,7 @@ function copy(key: string, language: UiLanguage = "en", params: Record<string, n
function offer(id: number, overrides: Partial<JobApplication> = {}): JobApplication {
return {
version: 1,
id, jobTitle: `Offer role ${id}`, company: { id, name: `Company ${id}` }, status: "Offer",
dateApplied: "2026-08-01", savedAt: "2026-07-20", responseReceived: true, daysSince: 10,
...overrides,
@@ -63,6 +63,7 @@ test("[P7-11 reminders] generated fixed and parameterized reasons follow locale
function job(id: number, overrides: Partial<JobApplication> = {}): JobApplication {
return {
version: 1,
id, jobTitle: `Reminder role ${id}`, company: { id, name: `Company ${id}` }, status: "Waiting",
dateApplied: "2026-11-01", savedAt: "2026-10-20", responseReceived: false, daysSince: 14,
needsFollowUp: true, followUpAt: "2026-11-17", followUpReason: "Legacy source reason",
+1
View File
@@ -5,6 +5,7 @@ const DEFAULT_SECTION_ORDER = ["summary", "skills", "experience", "education", "
export function emptyTailoredCvDraft(): TailoredCvDraft {
return {
revision: null,
templateId: "ats-minimal",
headline: "",
summary: [],
+2
View File
@@ -62,6 +62,7 @@ export interface TailoredCvRenderOptions {
}
export interface TailoredCvDraft {
revision: number | null;
id?: number | null;
canonicalProfileVersion?: number | null;
templateId: string;
@@ -82,6 +83,7 @@ export interface TailoredCvDraft {
export interface JobApplication {
id: number;
version: number;
jobTitle: string;
company: Company;
companyId?: number;
@@ -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);
@@ -16,6 +16,7 @@ const mockedApi = api as jest.Mocked<typeof api>;
function buildJob(overrides: Partial<JobApplication>): JobApplication {
return {
version: 1,
id: 1,
jobTitle: 'Backend Developer',
company: { id: 1, name: 'Acme' },