feat(phase-2): separate /profile (identity) from /career (master profile)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -421,6 +421,87 @@ public sealed class AuthAndSystemControllerTests
|
|||||||
Assert.Equal("Ada L.", user.DisplayName);
|
Assert.Equal("Ada L.", user.DisplayName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static AuthController BuildProfileController(ApplicationUser user)
|
||||||
|
{
|
||||||
|
var userManager = CreateUserManager();
|
||||||
|
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
|
||||||
|
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||||
|
return new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), 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<NoContentResult>(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<NoContentResult>(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<NoContentResult>(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<NoContentResult>(result);
|
||||||
|
Assert.Equal("ada@example.com", user.Email);
|
||||||
|
Assert.Equal("ada", user.UserName);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Request_password_reset_returns_service_unavailable_when_email_send_fails()
|
public async Task Request_password_reset_returns_service_unavailable_when_email_send_fails()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -363,21 +363,28 @@ public sealed class AuthController : ControllerBase
|
|||||||
return StatusCode(501, "Profile updates are only supported for local username/password accounts.");
|
return StatusCode(501, "Profile updates are only supported for local username/password accounts.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var email = TrimOrNull(request.Email);
|
// Partial update. A field is only touched when the request actually carries it:
|
||||||
var userName = TrimOrNull(request.UserName);
|
// - null / omitted -> leave unchanged (the caller isn't editing this field)
|
||||||
var firstName = TrimOrNull(request.FirstName);
|
// - "" -> explicitly clear
|
||||||
var lastName = TrimOrNull(request.LastName);
|
// - "value" -> set (trimmed)
|
||||||
var displayName = TrimOrNull(request.DisplayName);
|
// This lets /profile save identity fields and /career save the master-profile fields
|
||||||
var profileCvText = TrimOrNull(request.ProfileCvText);
|
// through the same endpoint without one wiping the other. Email and UserName are the
|
||||||
var profileCvStructureJson = TrimOrNull(request.ProfileCvStructureJson);
|
// login identifiers and are never cleared to empty.
|
||||||
|
if (request.Email is not null)
|
||||||
if (email is not null) user.Email = email;
|
{
|
||||||
if (userName is not null) user.UserName = userName;
|
var v = request.Email.Trim();
|
||||||
user.FirstName = firstName;
|
if (v.Length > 0) user.Email = v;
|
||||||
user.LastName = lastName;
|
}
|
||||||
user.DisplayName = displayName;
|
if (request.UserName is not null)
|
||||||
user.ProfileCvText = profileCvText;
|
{
|
||||||
user.ProfileCvStructureJson = profileCvStructureJson;
|
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);
|
var res = await _users.UpdateAsync(user);
|
||||||
if (!res.Succeeded)
|
if (!res.Succeeded)
|
||||||
|
|||||||
@@ -76,11 +76,11 @@ const structuredCv = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage(props: { careerOnly?: boolean } = {}) {
|
||||||
return render(
|
return render(
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<ProfilePage />
|
<ProfilePage {...props} />
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</ToastProvider>,
|
</ToastProvider>,
|
||||||
);
|
);
|
||||||
@@ -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));
|
await waitFor(() => expect(createObjectURLMock).toHaveBeenCalledTimes(REWRITE_TEMPLATES_COUNT));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('saving profile persists structured cv json', async () => {
|
test('saving the master profile (career) persists structured cv json', async () => {
|
||||||
renderPage();
|
// 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();
|
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
|
||||||
const fullNameInput = screen.getByLabelText(/full name/i);
|
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.contact.fullName).toBe('Updated Demo User');
|
||||||
expect(parsed.skills).toEqual(['.NET', 'SQL']);
|
expect(parsed.skills).toEqual(['.NET', 'SQL']);
|
||||||
expect(parsed.jobs[0].title).toBe('System Developer');
|
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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
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() {
|
export default function CareerWorkspacePage() {
|
||||||
const [tab, setTab] = useState<"master" | "builder">("master");
|
|
||||||
const [hasMasterCv, setHasMasterCv] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: "grid", gap: 2 }}>
|
<Box sx={{ display: "grid", gap: 2 }}>
|
||||||
<Paper sx={{ p: 2.5, borderRadius: 4, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
<Paper sx={{ p: 2.5, borderRadius: 4, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
||||||
@@ -19,17 +19,8 @@ export default function CareerWorkspacePage() {
|
|||||||
<Alert severity="info" sx={{ borderRadius: 3 }}>
|
<Alert severity="info" sx={{ borderRadius: 3 }}>
|
||||||
Your master profile is the source of truth. Job-specific CV drafts remain separate and never overwrite it.
|
Your master profile is the source of truth. Job-specific CV drafts remain separate and never overwrite it.
|
||||||
</Alert>
|
</Alert>
|
||||||
<Paper sx={{ borderRadius: 4, overflow: "hidden", boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
<Paper sx={{ borderRadius: 4, p: { xs: 1.5, md: 2.5 }, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
||||||
<Tabs value={tab} onChange={(_, value) => setTab(value)} variant="scrollable" allowScrollButtonsMobile sx={{ px: 1.5, pt: 1 }}>
|
<ProfilePage careerOnly />
|
||||||
<Tab value="master" label="Master CV" />
|
|
||||||
<Tab value="builder" label="CV Builder" disabled={!hasMasterCv} />
|
|
||||||
</Tabs>
|
|
||||||
{!hasMasterCv ? <Alert severity="info" sx={{ mx: 2.5, mb: 0, borderRadius: 3 }}>
|
|
||||||
Create your Master CV first. Upload an existing CV or add your career history manually; the builder will unlock when the profile has content.
|
|
||||||
</Alert> : null}
|
|
||||||
<Box sx={{ p: { xs: 1.5, md: 2.5 } }}>
|
|
||||||
<ProfilePage careerOnly careerView={tab} onMasterCvAvailabilityChange={setHasMasterCv} />
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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({
|
export default function ProfilePage({
|
||||||
careerOnly = false,
|
careerOnly = false,
|
||||||
careerView = "master",
|
|
||||||
onMasterCvAvailabilityChange,
|
onMasterCvAvailabilityChange,
|
||||||
}: {
|
}: {
|
||||||
careerOnly?: boolean;
|
careerOnly?: boolean;
|
||||||
careerView?: "master" | "builder";
|
|
||||||
onMasterCvAvailabilityChange?: (hasMasterCv: boolean) => void;
|
onMasterCvAvailabilityChange?: (hasMasterCv: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -1310,8 +1313,14 @@ export default function ProfilePage({
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.put("/auth/profile", { email, userName, firstName, lastName, displayName, profileCvText, profileCvStructureJson: JSON.stringify(structuredCv) });
|
// Scoped save: /career persists only the master profile, /profile only identity.
|
||||||
window.localStorage.setItem("profileHeadline", headline.trim());
|
// 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();
|
await loadProfile();
|
||||||
toast(t("profileUpdated"), "success");
|
toast(t("profileUpdated"), "success");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
Reference in New Issue
Block a user