From 66cc6a7db4db321bb2c2fe5e6363734abe599bcb Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 17 Jul 2026 19:59:38 +0200 Subject: [PATCH] feat(phase-2): separate /profile (identity) from /career (master profile) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 — Career/Profile separation. The master career profile is the source of truth; identity and career data are now saved independently so neither wipes the other. CV Builder deliberately not built yet. Backend — PUT /auth/profile is now a partial update: - null/omitted field -> unchanged; "" -> cleared; value -> set (trimmed). - Email/UserName never cleared to empty (login identifiers). This lets /profile save identity fields and /career save the master-profile fields through the same endpoint without one nulling the other. 4 new tests cover the data-integrity guarantees (identity save keeps the CV, career save keeps identity, empty clears, null leaves). Frontend: - ProfilePage save payload is now scoped by careerOnly: /career sends only { profileCvText, profileCvStructureJson }, /profile sends only identity. - CareerWorkspacePage: removed the inert "CV Builder" tab (careerView) — Phase 2 establishes the master profile only; the builder is Phase 4. - Dropped the dead careerView prop. - Updated the CV-save test to render career mode and assert identity is excluded. Source-of-truth flip (CareerProfileService authoritative) stays deferred to F5 per the branch design; CareerProfileService keeps mirroring via its dual-write. Co-Authored-By: Claude Opus 4.8 --- .../AuthAndSystemControllerTests.cs | 81 +++++++++++++++++++ JobTrackerApi/Controllers/AuthController.cs | 37 +++++---- job-tracker-ui/src/profile-page.test.tsx | 13 ++- .../src/views/CareerWorkspacePage.tsx | 23 ++---- job-tracker-ui/src/views/ProfilePage.tsx | 17 +++- 5 files changed, 132 insertions(+), 39 deletions(-) diff --git a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs index aa66f69..c31a04a 100644 --- a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs +++ b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs @@ -421,6 +421,87 @@ public sealed class AuthAndSystemControllerTests Assert.Equal("Ada L.", user.DisplayName); } + private static AuthController BuildProfileController(ApplicationUser user) + { + var userManager = CreateUserManager(); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + return new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()); + } + + [Fact] + public async Task Identity_only_save_does_not_wipe_the_master_cv() + { + // The Phase 2 /profile save omits the CV fields. A null field must leave the master CV + // untouched -- the data-integrity guarantee behind splitting the profile/career save. + var user = new ApplicationUser + { + Email = "ada@example.com", + UserName = "ada", + ProfileCvText = "Existing master CV text", + ProfileCvStructureJson = "{\"jobs\":[]}", + }; + var controller = BuildProfileController(user); + + var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(null, null, "Ada", "Lovelace", "Ada L.", null, null)); + + Assert.IsType(result); + Assert.Equal("Ada", user.FirstName); + Assert.Equal("Existing master CV text", user.ProfileCvText); + Assert.Equal("{\"jobs\":[]}", user.ProfileCvStructureJson); + } + + [Fact] + public async Task Career_only_save_does_not_wipe_identity() + { + // The mirror: /career saves the master profile and omits identity fields. + var user = new ApplicationUser + { + Email = "ada@example.com", + UserName = "ada", + FirstName = "Ada", + LastName = "Lovelace", + DisplayName = "Ada L.", + }; + var controller = BuildProfileController(user); + + var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(null, null, null, null, null, "New CV text", "{\"jobs\":[1]}")); + + Assert.IsType(result); + Assert.Equal("Ada", user.FirstName); + Assert.Equal("Lovelace", user.LastName); + Assert.Equal("Ada L.", user.DisplayName); + Assert.Equal("New CV text", user.ProfileCvText); + Assert.Equal("{\"jobs\":[1]}", user.ProfileCvStructureJson); + } + + [Fact] + public async Task Empty_string_clears_a_field_but_null_leaves_it() + { + var user = new ApplicationUser { Email = "ada@example.com", UserName = "ada", DisplayName = "Ada L.", FirstName = "Ada" }; + var controller = BuildProfileController(user); + + // DisplayName "" -> cleared; FirstName null -> unchanged. + var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(null, null, null, null, "", null, null)); + + Assert.IsType(result); + Assert.Null(user.DisplayName); + Assert.Equal("Ada", user.FirstName); + } + + [Fact] + public async Task Email_and_username_are_never_cleared_to_empty() + { + var user = new ApplicationUser { Email = "ada@example.com", UserName = "ada" }; + var controller = BuildProfileController(user); + + var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest("", "", null, null, null, null, null)); + + Assert.IsType(result); + Assert.Equal("ada@example.com", user.Email); + Assert.Equal("ada", user.UserName); + } + [Fact] public async Task Request_password_reset_returns_service_unavailable_when_email_send_fails() { diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index f7ee64a..0a5cbf6 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -363,21 +363,28 @@ public sealed class AuthController : ControllerBase return StatusCode(501, "Profile updates are only supported for local username/password accounts."); } - var email = TrimOrNull(request.Email); - var userName = TrimOrNull(request.UserName); - var firstName = TrimOrNull(request.FirstName); - var lastName = TrimOrNull(request.LastName); - var displayName = TrimOrNull(request.DisplayName); - var profileCvText = TrimOrNull(request.ProfileCvText); - var profileCvStructureJson = TrimOrNull(request.ProfileCvStructureJson); - - if (email is not null) user.Email = email; - if (userName is not null) user.UserName = userName; - user.FirstName = firstName; - user.LastName = lastName; - user.DisplayName = displayName; - user.ProfileCvText = profileCvText; - user.ProfileCvStructureJson = profileCvStructureJson; + // Partial update. A field is only touched when the request actually carries it: + // - null / omitted -> leave unchanged (the caller isn't editing this field) + // - "" -> explicitly clear + // - "value" -> set (trimmed) + // This lets /profile save identity fields and /career save the master-profile fields + // through the same endpoint without one wiping the other. Email and UserName are the + // login identifiers and are never cleared to empty. + if (request.Email is not null) + { + var v = request.Email.Trim(); + if (v.Length > 0) user.Email = v; + } + if (request.UserName is not null) + { + var v = request.UserName.Trim(); + if (v.Length > 0) user.UserName = v; + } + if (request.FirstName is not null) user.FirstName = TrimOrNull(request.FirstName); + if (request.LastName is not null) user.LastName = TrimOrNull(request.LastName); + if (request.DisplayName is not null) user.DisplayName = TrimOrNull(request.DisplayName); + if (request.ProfileCvText is not null) user.ProfileCvText = TrimOrNull(request.ProfileCvText); + if (request.ProfileCvStructureJson is not null) user.ProfileCvStructureJson = TrimOrNull(request.ProfileCvStructureJson); var res = await _users.UpdateAsync(user); if (!res.Succeeded) diff --git a/job-tracker-ui/src/profile-page.test.tsx b/job-tracker-ui/src/profile-page.test.tsx index b143415..da79e1b 100644 --- a/job-tracker-ui/src/profile-page.test.tsx +++ b/job-tracker-ui/src/profile-page.test.tsx @@ -76,11 +76,11 @@ const structuredCv = { ], }; -function renderPage() { +function renderPage(props: { careerOnly?: boolean } = {}) { return render( - + , ); @@ -281,8 +281,10 @@ test('profile page rewrite tools use selected template and saved job context', a await waitFor(() => expect(createObjectURLMock).toHaveBeenCalledTimes(REWRITE_TEMPLATES_COUNT)); }); -test('saving profile persists structured cv json', async () => { - renderPage(); +test('saving the master profile (career) persists structured cv json', async () => { + // Phase 2: the master-profile save lives on /career (careerOnly). It sends only the CV fields — + // identity is saved separately on /profile — so the payload carries profileCvStructureJson. + renderPage({ careerOnly: true }); expect(await screen.findByText(/cv ready/i)).toBeInTheDocument(); const fullNameInput = screen.getByLabelText(/full name/i); @@ -301,4 +303,7 @@ test('saving profile persists structured cv json', async () => { expect(parsed.contact.fullName).toBe('Updated Demo User'); expect(parsed.skills).toEqual(['.NET', 'SQL']); expect(parsed.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(); }); diff --git a/job-tracker-ui/src/views/CareerWorkspacePage.tsx b/job-tracker-ui/src/views/CareerWorkspacePage.tsx index 98c249f..433c8ef 100644 --- a/job-tracker-ui/src/views/CareerWorkspacePage.tsx +++ b/job-tracker-ui/src/views/CareerWorkspacePage.tsx @@ -1,13 +1,13 @@ -import React, { useState } from "react"; +import React from "react"; -import { Alert, Box, Paper, Tab, Tabs, Typography } from "@mui/material"; +import { Alert, Box, Paper, Typography } from "@mui/material"; import ProfilePage from "./ProfilePage"; +// Phase 2: /career is the Career Workspace — the master career profile, which is the single source +// of truth for all generated documents. The CV Builder is deliberately NOT here yet (Phase 4); +// Phase 2 only establishes the master profile. The previously-inert "CV Builder" tab was removed. export default function CareerWorkspacePage() { - const [tab, setTab] = useState<"master" | "builder">("master"); - const [hasMasterCv, setHasMasterCv] = useState(false); - return ( @@ -19,17 +19,8 @@ export default function CareerWorkspacePage() { Your master profile is the source of truth. Job-specific CV drafts remain separate and never overwrite it. - - setTab(value)} variant="scrollable" allowScrollButtonsMobile sx={{ px: 1.5, pt: 1 }}> - - - - {!hasMasterCv ? - Create your Master CV first. Upload an existing CV or add your career history manually; the builder will unlock when the profile has content. - : null} - - - + + ); diff --git a/job-tracker-ui/src/views/ProfilePage.tsx b/job-tracker-ui/src/views/ProfilePage.tsx index 4ab3a57..183fff4 100644 --- a/job-tracker-ui/src/views/ProfilePage.tsx +++ b/job-tracker-ui/src/views/ProfilePage.tsx @@ -226,13 +226,16 @@ function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata }) ); } +// careerOnly splits the two Phase 2 surfaces this component still backs: +// false -> /profile: account identity + security + preferences +// true -> /career: the master career profile (source of truth) +// Fully separating this into two components is roadmap 2.2; for now the fork keeps each route's +// content — and its Save payload — cleanly scoped. export default function ProfilePage({ careerOnly = false, - careerView = "master", onMasterCvAvailabilityChange, }: { careerOnly?: boolean; - careerView?: "master" | "builder"; onMasterCvAvailabilityChange?: (hasMasterCv: boolean) => void; }) { const { toast } = useToast(); @@ -1310,8 +1313,14 @@ export default function ProfilePage({ onClick={async () => { setLoading(true); try { - await api.put("/auth/profile", { email, userName, firstName, lastName, displayName, profileCvText, profileCvStructureJson: JSON.stringify(structuredCv) }); - window.localStorage.setItem("profileHeadline", headline.trim()); + // Scoped save: /career persists only the master profile, /profile only identity. + // The backend (PUT /auth/profile) does partial updates — omitted fields are left + // unchanged — so neither surface wipes the other's data. + const payload = careerOnly + ? { profileCvText, profileCvStructureJson: JSON.stringify(structuredCv) } + : { email, userName, firstName, lastName, displayName }; + await api.put("/auth/profile", payload); + if (!careerOnly) window.localStorage.setItem("profileHeadline", headline.trim()); await loadProfile(); toast(t("profileUpdated"), "success"); } catch (e: any) {