feat(career): wire /career to the relational profile API + completeness overview
CI and Deploy / test (push) Failing after 1m52s
CI and Deploy / deploy (push) Has been skipped

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 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-18 00:53:35 +02:00
parent b203120ab4
commit 2b57d65715
4 changed files with 84 additions and 29 deletions
@@ -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<OkObjectResult>(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<CareerProfileDto>(Assert.IsType<OkObjectResult>(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<BadRequestObjectResult>(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<OkObjectResult>(put.Result);
@@ -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));
}
/// <summary>
@@ -46,23 +46,25 @@ public sealed class CareerProfileController : ControllerBase
/// </summary>
[HttpPut]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<CareerProfileDto>> Put([FromBody] StructuredCvProfile? request, CancellationToken cancellationToken)
public async Task<ActionResult<CareerProfileDto>> 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));
}
/// <summary>Just the completeness scorecard, for the /career overview.</summary>
@@ -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<string> Missing, List<CareerSectionStatusDto> Sections);
+24 -8
View File
@@ -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();
});
+46 -11
View File
@@ -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<StructuredCvProfile>(emptyStructuredCv());
const [completeness, setCompleteness] = useState<CareerCompleteness | null>(null);
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
const runStatusRef = useRef<Record<number, string>>({});
@@ -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<CareerProfileResponse>("/career/profile"),
api.get<MeResponse>("/auth/me"),
api.get<ExtractionRun[]>("/profile-cv/runs").catch(() => ({ data: [] as ExtractionRun[] } as any)),
api.get<JobListResponse>("/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 (
<Paper sx={{ mt: 0, p: 2.5, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
{completeness ? (
<Box sx={{ mb: 2.5, p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 2, flexWrap: "wrap", mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>Profile completeness</Typography>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{completeness.percent}%</Typography>
</Box>
<LinearProgress
variant="determinate"
value={completeness.percent}
color={completeness.percent >= 80 ? "success" : completeness.percent >= 40 ? "primary" : "warning"}
sx={{ height: 8, borderRadius: 999 }}
/>
{completeness.missing.length > 0 ? (
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", alignItems: "center", mt: 1.25 }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}>Missing:</Typography>
{completeness.missing.map((label) => (
<Chip key={label} size="small" label={label} sx={{ height: 22, fontWeight: 700 }} />
))}
</Box>
) : (
<Typography variant="body2" sx={{ color: "success.main", mt: 1.25, fontWeight: 700 }}>Your master profile is complete.</Typography>
)}
</Box>
) : null}
<CropImageDialog
open={cropOpen}
file={avatarFile}
@@ -1268,10 +1301,12 @@ export default function CareerProfilePage() {
onClick={async () => {
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<CareerProfileResponse>("/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");