feat(career): wire /career to the relational profile API + completeness overview
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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user