From 2b57d65715fa5bc249aab398eeddd932f3210aad Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 18 Jul 2026 00:53:35 +0200 Subject: [PATCH] feat(career): wire /career to the relational profile API + completeness overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3, frontend. /career now reads and writes the master profile through the relational source of truth instead of the legacy blob path. - CareerProfilePage loads GET /career/profile (structured profile from the relational children + cvText + completeness) and saves PUT /career/profile ({ profile, cvText }). This keeps the relational store authoritative — the previous PUT /auth/profile blob write left it stale after first load. - Added a "Profile completeness" overview (percent bar + missing sections) at the top of /career, from the server scorecard. - PUT /career/profile now accepts { profile, cvText } so the single /career save covers both the structured profile and the raw imported text; GET returns cvText. Tests: career-save asserts the /career/profile payload; new completeness-overview test; controller tests updated for the request wrapper. 75/76 frontend pass (the 1 failure is the unrelated pre-existing settings-view suite); prod build clean. Co-Authored-By: Claude Opus 4.8 --- .../CareerProfileControllerTests.cs | 8 +-- .../Controllers/CareerProfileController.cs | 16 ++++-- job-tracker-ui/src/profile-page.test.tsx | 32 ++++++++--- .../src/views/CareerProfilePage.tsx | 57 +++++++++++++++---- 4 files changed, 84 insertions(+), 29 deletions(-) diff --git a/JobTrackerApi.Tests/CareerProfileControllerTests.cs b/JobTrackerApi.Tests/CareerProfileControllerTests.cs index 6b2eeee..ff82ca7 100644 --- a/JobTrackerApi.Tests/CareerProfileControllerTests.cs +++ b/JobTrackerApi.Tests/CareerProfileControllerTests.cs @@ -47,7 +47,7 @@ public sealed class CareerProfileControllerTests { var (controller, _, user) = Build(); - var put = await controller.Put(Sample(), CancellationToken.None); + var put = await controller.Put(new CareerProfileSaveRequest(Sample(), null), CancellationToken.None); Assert.IsType(put.Result); // The derived blob projection is kept in sync for legacy readers. @@ -64,7 +64,7 @@ public sealed class CareerProfileControllerTests public async Task Get_reports_completeness_with_missing_sections() { var (controller, _, _) = Build(); - await controller.Put(Sample(), CancellationToken.None); // has personal + experience + skills + await controller.Put(new CareerProfileSaveRequest(Sample(), null), CancellationToken.None); // has personal + experience + skills var get = await controller.Get(CancellationToken.None); var dto = Assert.IsType(Assert.IsType(get.Result).Value); @@ -81,7 +81,7 @@ public sealed class CareerProfileControllerTests var huge = new StructuredCvProfile(); for (var i = 0; i < 500; i++) huge.Skills.Add($"skill-{i}"); - var put = await controller.Put(huge, CancellationToken.None); + var put = await controller.Put(new CareerProfileSaveRequest(huge, null), CancellationToken.None); Assert.IsType(put.Result); } @@ -91,7 +91,7 @@ public sealed class CareerProfileControllerTests { var (controller, _, _) = Build(); - var put = await controller.Put(new StructuredCvProfile(), CancellationToken.None); + var put = await controller.Put(new CareerProfileSaveRequest(new StructuredCvProfile(), null), CancellationToken.None); // Completeness, not validation, is what flags an incomplete profile — an empty profile saves. Assert.IsType(put.Result); diff --git a/JobTrackerApi/Controllers/CareerProfileController.cs b/JobTrackerApi/Controllers/CareerProfileController.cs index 348406c..ccbc1f9 100644 --- a/JobTrackerApi/Controllers/CareerProfileController.cs +++ b/JobTrackerApi/Controllers/CareerProfileController.cs @@ -36,7 +36,7 @@ public sealed class CareerProfileController : ControllerBase if (user is null) return Unauthorized(); var profile = await _career.LoadStructuredAsync(user.Id, cancellationToken); - return Ok(new CareerProfileDto(profile, CareerCompleteness.Evaluate(profile))); + return Ok(new CareerProfileDto(profile, CareerCompleteness.Evaluate(profile), user.ProfileCvText)); } /// @@ -46,23 +46,25 @@ public sealed class CareerProfileController : ControllerBase /// [HttpPut] [Authorize(AuthenticationSchemes = "local")] - public async Task> Put([FromBody] StructuredCvProfile? request, CancellationToken cancellationToken) + public async Task> Put([FromBody] CareerProfileSaveRequest? request, CancellationToken cancellationToken) { var user = await _users.GetUserAsync(User); if (user is null) return StatusCode(501, "The career profile can only be edited on local accounts."); - var profile = StructuredCvProfileJson.Normalize(request); + var profile = StructuredCvProfileJson.Normalize(request?.Profile); var error = CareerProfileValidator.Validate(profile); if (error is not null) return BadRequest(error); var saved = await _career.SaveVersionAsync(user.Id, profile, "manual", cancellationToken); - // Keep the derived projection in sync for legacy readers. + // Keep the derived projection in sync for legacy readers. CvText (the raw imported text) is + // part of the career profile and is set here too; identity fields are never touched. user.ProfileCvStructureJson = StructuredCvProfileJson.Serialize(saved); + if (request?.CvText is not null) user.ProfileCvText = string.IsNullOrWhiteSpace(request.CvText) ? null : request.CvText; var res = await _users.UpdateAsync(user); if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); - return Ok(new CareerProfileDto(saved, CareerCompleteness.Evaluate(saved))); + return Ok(new CareerProfileDto(saved, CareerCompleteness.Evaluate(saved), user.ProfileCvText)); } /// Just the completeness scorecard, for the /career overview. @@ -76,7 +78,9 @@ public sealed class CareerProfileController : ControllerBase } } -public sealed record CareerProfileDto(StructuredCvProfile Profile, CareerCompletenessDto Completeness); +public sealed record CareerProfileSaveRequest(StructuredCvProfile? Profile, string? CvText); + +public sealed record CareerProfileDto(StructuredCvProfile Profile, CareerCompletenessDto Completeness, string? CvText); public sealed record CareerCompletenessDto(int Percent, List Missing, List Sections); diff --git a/job-tracker-ui/src/profile-page.test.tsx b/job-tracker-ui/src/profile-page.test.tsx index 13e9424..2ba15cf 100644 --- a/job-tracker-ui/src/profile-page.test.tsx +++ b/job-tracker-ui/src/profile-page.test.tsx @@ -96,6 +96,16 @@ void ProfilePage; beforeEach(() => { mockedApi.get.mockImplementation((url: string) => { + if (url === '/career/profile') { + // Phase 3: /career reads the structured profile from the relational source of truth. + return Promise.resolve({ + data: { + profile: structuredCv, + cvText: 'Professional Summary\nBuilt backend systems', + completeness: { percent: 70, missing: ['Projects'], sections: [] }, + }, + } as any); + } if (url === '/auth/me') { return Promise.resolve({ data: { @@ -289,9 +299,10 @@ test('profile page rewrite tools use selected template and saved job context', a await waitFor(() => expect(createObjectURLMock).toHaveBeenCalledTimes(REWRITE_TEMPLATES_COUNT)); }); -test('saving the master profile (career) persists structured cv json', async () => { - // Phase 2: the master-profile save lives on /career (CareerProfilePage). It sends only the CV - // fields — identity is saved separately on /profile — so the payload carries profileCvStructureJson. +test('saving the master profile (career) persists the structured profile via /career/profile', async () => { + // Phase 3: /career saves the master profile through the relational API. The payload carries the + // structured profile object (not the legacy flat blob) and never identity fields. + mockedApi.put.mockResolvedValue({ data: { profile: structuredCv, completeness: { percent: 70, missing: [], sections: [] }, cvText: '' } } as any); renderWith(CareerProfilePage); expect(await screen.findByText(/cv ready/i)).toBeInTheDocument(); @@ -303,15 +314,20 @@ test('saving the master profile (career) persists structured cv json', async () fireEvent.click(saveButton); await waitFor(() => { - expect(mockedApi.put).toHaveBeenCalled(); + expect(mockedApi.put).toHaveBeenCalledWith('/career/profile', expect.anything()); }); const payload = mockedApi.put.mock.calls[0][1] as any; - const parsed = JSON.parse(payload.profileCvStructureJson); - expect(parsed.contact.fullName).toBe('Updated Demo User'); - expect(parsed.skills).toEqual(['.NET', 'SQL']); - expect(parsed.jobs[0].title).toBe('System Developer'); + expect(payload.profile.contact.fullName).toBe('Updated Demo User'); + expect(payload.profile.skills).toEqual(['.NET', 'SQL']); + expect(payload.profile.jobs[0].title).toBe('System Developer'); // The career save must NOT carry identity fields (they belong to /profile). expect(payload.email).toBeUndefined(); expect(payload.displayName).toBeUndefined(); }); + +test('/career shows the profile completeness overview', async () => { + renderWith(CareerProfilePage); + expect(await screen.findByText(/profile completeness/i)).toBeInTheDocument(); + expect(screen.getByText(/70%/)).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/views/CareerProfilePage.tsx b/job-tracker-ui/src/views/CareerProfilePage.tsx index 9f3bc32..3ec6b9c 100644 --- a/job-tracker-ui/src/views/CareerProfilePage.tsx +++ b/job-tracker-ui/src/views/CareerProfilePage.tsx @@ -226,9 +226,14 @@ function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata }) ); } +// 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 }; + // CareerProfilePage backs /career: the master career profile — the single editable source of -// truth for all future generated documents. Split out from ProfilePage in Phase 2.2. It saves -// only the master-profile fields (partial update), never identity/security. +// truth for all future generated documents. Split out from ProfilePage in Phase 2.2; wired to the +// relational /career/profile API in Phase 3. export default function CareerProfilePage() { // Retained so the shared JSX (copied from ProfilePage) reads identically; hardcoded for /career. const careerOnly = true; @@ -266,6 +271,7 @@ export default function CareerProfilePage() { const [parsingCvSections, setParsingCvSections] = useState(false); const [reprocessingCv, setReprocessingCv] = useState(false); const [structuredCv, setStructuredCv] = useState(emptyStructuredCv()); + const [completeness, setCompleteness] = useState(null); const [extractionRuns, setExtractionRuns] = useState([]); const runStatusRef = useRef>({}); @@ -294,15 +300,18 @@ export default function CareerProfilePage() { const loadProfile = useCallback(async () => { setLoading(true); try { - const [profileResponse, runsResponse, jobsResponse] = await Promise.all([ + // /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, jobsResponse] = await Promise.all([ + api.get("/career/profile"), api.get("/auth/me"), api.get("/profile-cv/runs").catch(() => ({ data: [] as ExtractionRun[] } as any)), api.get("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } }).catch(() => ({ data: { items: [], total: 0, page: 1, pageSize: 100 } } as any)), ]); - const r = profileResponse; - setMe(r.data); - setProfileCvText(r.data?.profileCvText ?? ""); - setStructuredCv(parseStructuredCvJson(r.data?.profileCvStructureJson)); + setMe(meResponse.data); + setProfileCvText(careerResponse.data?.cvText ?? ""); + setStructuredCv(normalizeStructuredCv(careerResponse.data?.profile ?? emptyStructuredCv())); + setCompleteness(careerResponse.data?.completeness ?? null); setExtractionRuns(runsResponse.data ?? []); setSavedJobs(jobsResponse.data?.items ?? []); setHeadline(window.localStorage.getItem("profileHeadline") ?? ""); @@ -462,6 +471,30 @@ export default function CareerProfilePage() { return ( + {completeness ? ( + + + Profile completeness + {completeness.percent}% + + = 80 ? "success" : completeness.percent >= 40 ? "primary" : "warning"} + sx={{ height: 8, borderRadius: 999 }} + /> + {completeness.missing.length > 0 ? ( + + Missing: + {completeness.missing.map((label) => ( + + ))} + + ) : ( + Your master profile is complete. + )} + + ) : null} { setLoading(true); try { - // /career saves only the master profile. The backend does partial updates, so - // omitting identity fields leaves them untouched (they are owned by /profile). - await api.put("/auth/profile", { profileCvText, profileCvStructureJson: JSON.stringify(structuredCv) }); - await loadProfile(); + // 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("/career/profile", { profile: structuredCv, cvText: profileCvText }); + setStructuredCv(normalizeStructuredCv(saved.data?.profile ?? structuredCv)); + setCompleteness(saved.data?.completeness ?? null); toast(t("profileUpdated"), "success"); } catch (e: any) { const msg = e?.response?.data || e?.message || t("profileUpdateFailed");