fix(career): preserve edits during polling
CI and Deploy / test (pull_request) Failing after 2m59s
CI and Deploy / deploy (pull_request) Has been skipped

Refresh extraction runs without reloading controlled profile fields. Keep stored-profile AI actions gated until pending edits are saved.
This commit is contained in:
cesnimda
2026-08-15 13:49:58 +02:00
parent 998ee07a9a
commit d424633f95
8 changed files with 136 additions and 45 deletions
+52 -29
View File
@@ -163,6 +163,7 @@ export default function CareerProfilePage() {
const [parsingCvSections, setParsingCvSections] = useState(false);
const [reprocessingCv, setReprocessingCv] = useState(false);
const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv());
const [profileDirty, setProfileDirty] = useState(false);
const [completeness, setCompleteness] = useState<CareerCompleteness | null>(null);
const [versions, setVersions] = useState<CareerVersion[]>([]);
// The raw import/section parser remains available as an advanced recovery tool.
@@ -183,6 +184,7 @@ export default function CareerProfilePage() {
const r = await api.post<CareerProfileResponse>(`/career/profile/versions/${version}/restore`);
setStructuredCv(normalizeStructuredCv(r.data?.profile ?? emptyStructuredCv()));
setCompleteness(r.data?.completeness ?? null);
setProfileDirty(false);
await loadVersions();
toast(t("profileUpdated"), "success");
} catch (e: any) {
@@ -197,26 +199,38 @@ export default function CareerProfilePage() {
const [acceptedLowConfidenceIds, setAcceptedLowConfidenceIds] = useState<Record<number, string[]>>({});
const runStatusRef = useRef<Record<number, string>>({});
const editStructuredCv = useCallback((next: React.SetStateAction<StructuredCvProfile>) => {
setStructuredCv(next);
setProfileDirty(true);
}, []);
const loadExtractionRuns = useCallback(async () => {
try {
const response = await api.get<ExtractionRun[]>("/profile-cv/runs");
setExtractionRuns(response.data ?? []);
} catch {
// Polling failure must not clear existing run state or touch unsaved profile edits.
}
}, []);
const loadProfile = useCallback(async () => {
setLoading(true);
try {
// /career reads the structured profile from the relational source of truth (/career/profile);
// /auth/me still provides the account row (avatar, provider chips) shown in the header.
const [careerResponse, meResponse, runsResponse] = await Promise.all([
const [careerResponse, meResponse] = await Promise.all([
api.get<CareerProfileResponse>("/career/profile"),
api.get<MeResponse>("/auth/me"),
api.get<ExtractionRun[]>("/profile-cv/runs").catch(() => ({ data: [] as ExtractionRun[] } as any)),
]);
setMe(meResponse.data);
setProfileCvText(careerResponse.data?.cvText ?? "");
setStructuredCv(normalizeStructuredCv(careerResponse.data?.profile ?? emptyStructuredCv()));
setCompleteness(careerResponse.data?.completeness ?? null);
setExtractionRuns(runsResponse.data ?? []);
setProfileDirty(false);
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
setLoadError(null);
} catch (error: any) {
setMe(null);
setExtractionRuns([]);
setLoadError(String(error?.response?.data || error?.message || "Unable to load profile right now."));
} finally {
setLoading(false);
@@ -225,8 +239,9 @@ export default function CareerProfilePage() {
useEffect(() => {
void loadProfile();
void loadExtractionRuns();
void loadVersions();
}, [loadProfile, loadVersions]);
}, [loadExtractionRuns, loadProfile, loadVersions]);
useEffect(() => {
const activeRuns = extractionRuns.filter((run) => run.operation
@@ -235,11 +250,11 @@ export default function CareerProfilePage() {
if (activeRuns.length === 0) return;
const timer = window.setInterval(() => {
void loadProfile();
void loadExtractionRuns();
}, 4000);
return () => window.clearInterval(timer);
}, [extractionRuns, loadProfile]);
}, [extractionRuns, loadExtractionRuns]);
useEffect(() => {
const pending = extractionRuns.filter((run) => run.status === "pending_review" && !runDiffs[run.id]);
@@ -397,6 +412,7 @@ export default function CareerProfilePage() {
<Box id="career-cv-import" sx={{ gridColumn: "1 / -1", p: { xs: 1.5, sm: 2 }, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", display: careerOnly ? "block" : "none", scrollMarginTop: 96 }}>
{!canUseAi && <Alert severity="info" sx={{ mb: 2 }} action={<Button href="/settings" size="small">View Pro</Button>}>AI CV import, rebuilding, improvement, and reprocessing require Pro. Manual profile editing remains available.</Alert>}
{profileDirty ? <Alert severity="warning" sx={{ mb: 2 }}>You have unsaved career edits. Save them before running actions that use the stored profile.</Alert> : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
<Box>
<Typography variant="h6">{t("profileMasterCv")}</Typography>
@@ -419,7 +435,7 @@ export default function CareerProfilePage() {
setUploadingCv(true);
try {
const res = await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
await loadProfile();
await loadExtractionRuns();
toast(`Queued CV upload (run ${res.data.extractionRunId}).`, "info");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvUploadFailed")), "error");
@@ -433,12 +449,12 @@ export default function CareerProfilePage() {
</Button>
<Button
variant="outlined"
disabled={!canUseAi || !isLocal || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
disabled={!canUseAi || !isLocal || profileDirty || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setRebuildingCv(true);
try {
const res = await api.post<QueuedCvRunResponse>("/profile-cv/rebuild");
await loadProfile();
await loadExtractionRuns();
toast(`Queued CV rebuild (run ${res.data.extractionRunId}).`, "info");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvRebuildFailed")), "error");
@@ -451,12 +467,12 @@ export default function CareerProfilePage() {
</Button>
<Button
variant="outlined"
disabled={!canUseAi || !isLocal || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
disabled={!canUseAi || !isLocal || profileDirty || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setImprovingCv(true);
try {
const res = await api.post<QueuedCvRunResponse>("/profile-cv/improve");
await loadProfile();
await loadExtractionRuns();
toast(`Queued CV improve run (run ${res.data.extractionRunId}).`, "info");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvImproveFailed")), "error");
@@ -474,7 +490,7 @@ export default function CareerProfilePage() {
setReprocessingCv(true);
try {
const res = await api.post<QueuedCvRunResponse>("/profile-cv/reprocess");
await loadProfile();
await loadExtractionRuns();
toast(`Queued CV reprocess run (run ${res.data.extractionRunId}).`, "info");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvReprocessFailed")), "error");
@@ -505,7 +521,7 @@ export default function CareerProfilePage() {
<TextField
label={t("profileCvTextLabel")}
value={profileCvText}
onChange={(e) => setProfileCvText(e.target.value)}
onChange={(e) => { setProfileCvText(e.target.value); setProfileDirty(true); }}
helperText={t("profileCvTextHelp")}
multiline
minRows={12}
@@ -551,7 +567,7 @@ export default function CareerProfilePage() {
<Button size="small" color="inherit" sx={{ mt: 0.75 }} onClick={async () => {
try {
await api.post(`/operations/${run.operation!.id}/cancel`);
await loadProfile();
await loadExtractionRuns();
toast("CV processing cancellation requested.", "info");
} catch (error) {
toast(getApiErrorMessage(error, "Could not cancel CV processing."), "error");
@@ -562,7 +578,7 @@ export default function CareerProfilePage() {
<Button size="small" color="inherit" sx={{ mt: 0.75 }} onClick={async () => {
try {
await api.post(`/operations/${run.operation!.id}/retry`);
await loadProfile();
await loadExtractionRuns();
toast("CV processing queued again.", "info");
} catch (error) {
toast(getApiErrorMessage(error, "Could not retry CV processing."), "error");
@@ -598,13 +614,18 @@ export default function CareerProfilePage() {
label={`Include low-confidence ${change.category}: ${change.label}`}
/>
))}
{profileDirty ? (
<Alert severity="warning" sx={{ mt: 1 }}>
Save your current career-profile edits before applying imported changes.
</Alert>
) : null}
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
<Button size="small" variant="contained" disabled={!runDiffs[run.id] || reviewingRunId !== null} onClick={async () => {
<Button size="small" variant="contained" disabled={!runDiffs[run.id] || reviewingRunId !== null || profileDirty} onClick={async () => {
setReviewingRunId(run.id);
try {
await api.post(`/profile-cv/runs/${run.id}/accept`, { acceptedLowConfidenceIds: acceptedLowConfidenceIds[run.id] ?? [] });
setRunDiffs((current) => { const next = { ...current }; delete next[run.id]; return next; });
await Promise.all([loadProfile(), loadVersions()]);
await Promise.all([loadProfile(), loadExtractionRuns(), loadVersions()]);
toast("CV changes merged into your career profile.", "success");
} catch (error) {
toast(getApiErrorMessage(error, "Could not apply CV changes."), "error");
@@ -615,7 +636,7 @@ export default function CareerProfilePage() {
try {
await api.post(`/profile-cv/runs/${run.id}/discard`);
setRunDiffs((current) => { const next = { ...current }; delete next[run.id]; return next; });
await loadProfile();
await loadExtractionRuns();
toast("CV extraction discarded.", "info");
} catch (error) {
toast(getApiErrorMessage(error, "Could not discard CV extraction."), "error");
@@ -649,7 +670,7 @@ export default function CareerProfilePage() {
setParsingCvSections(true);
try {
const res = await api.post<{ structuredCv?: StructuredCvProfile }>("/profile-cv/parse", { text: profileCvText });
setStructuredCv(normalizeStructuredCv(res.data?.structuredCv));
editStructuredCv(normalizeStructuredCv(res.data?.structuredCv));
toast(t("profileCvStructureParsed"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvStructureParseFailed")), "error");
@@ -689,25 +710,25 @@ export default function CareerProfilePage() {
<PersonalInformationSection
value={structuredCv.contact}
onChange={(next) => setStructuredCv((prev) => ({ ...prev, contact: next }))}
onChange={(next) => editStructuredCv((prev) => ({ ...prev, contact: next }))}
getMetadata={metaFor}
/>
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<ProfessionalSummarySection value={structuredCv.summary} onChange={(next) => setStructuredCv((prev) => ({ ...prev, summary: next }))} getMetadata={metaFor} />
<SkillsSection value={structuredCv.skills} onChange={(next) => setStructuredCv((prev) => ({ ...prev, skills: next }))} getMetadata={metaFor} />
<InterestsSection value={structuredCv.interests} onChange={(next) => setStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} />
<ProfessionalSummarySection value={structuredCv.summary} onChange={(next) => editStructuredCv((prev) => ({ ...prev, summary: next }))} getMetadata={metaFor} />
<SkillsSection value={structuredCv.skills} onChange={(next) => editStructuredCv((prev) => ({ ...prev, skills: next }))} getMetadata={metaFor} />
<InterestsSection value={structuredCv.interests} onChange={(next) => editStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} />
</Box>
<LongTailSections values={{ awards: structuredCv.awards, publications: structuredCv.publications, organisations: structuredCv.organisations, references: structuredCv.references }} onChange={(key, next) => setStructuredCv((prev) => ({ ...prev, [key]: next }))} />
<LongTailSections values={{ awards: structuredCv.awards, publications: structuredCv.publications, organisations: structuredCv.organisations, references: structuredCv.references }} onChange={(key, next) => editStructuredCv((prev) => ({ ...prev, [key]: next }))} />
<LanguagesSection value={structuredCv.languages} onChange={(next) => setStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} />
<LanguagesSection value={structuredCv.languages} onChange={(next) => editStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} />
<WorkExperienceSection value={structuredCv.jobs} onChange={(next) => setStructuredCv((prev) => ({ ...prev, jobs: next }))} />
<WorkExperienceSection value={structuredCv.jobs} onChange={(next) => editStructuredCv((prev) => ({ ...prev, jobs: next }))} />
<EducationSection value={structuredCv.education} onChange={(next) => setStructuredCv((prev) => ({ ...prev, education: next }))} />
<EducationSection value={structuredCv.education} onChange={(next) => editStructuredCv((prev) => ({ ...prev, education: next }))} />
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => setStructuredCv((prev) => ({ ...prev, otherSections: next }))} />
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => editStructuredCv((prev) => ({ ...prev, otherSections: next }))} />
</Box>
<Box sx={{ mt: 1, display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
@@ -720,6 +741,7 @@ export default function CareerProfilePage() {
</Box>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
{profileDirty ? <Chip size="small" color="warning" variant="outlined" label="Unsaved changes" /> : null}
<Button
variant="contained"
disabled={!isLocal || loading}
@@ -732,6 +754,7 @@ export default function CareerProfilePage() {
const saved = await api.put<CareerProfileResponse>("/career/profile", { profile: structuredCv, cvText: profileCvText });
setStructuredCv(normalizeStructuredCv(saved.data?.profile ?? structuredCv));
setCompleteness(saved.data?.completeness ?? null);
setProfileDirty(false);
toast(t("profileUpdated"), "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || t("profileUpdateFailed");