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 (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);
|
Assert.IsType<OkObjectResult>(put.Result);
|
||||||
|
|
||||||
// The derived blob projection is kept in sync for legacy readers.
|
// 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()
|
public async Task Get_reports_completeness_with_missing_sections()
|
||||||
{
|
{
|
||||||
var (controller, _, _) = Build();
|
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 get = await controller.Get(CancellationToken.None);
|
||||||
var dto = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>(get.Result).Value);
|
var dto = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>(get.Result).Value);
|
||||||
@@ -81,7 +81,7 @@ public sealed class CareerProfileControllerTests
|
|||||||
var huge = new StructuredCvProfile();
|
var huge = new StructuredCvProfile();
|
||||||
for (var i = 0; i < 500; i++) huge.Skills.Add($"skill-{i}");
|
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);
|
Assert.IsType<BadRequestObjectResult>(put.Result);
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ public sealed class CareerProfileControllerTests
|
|||||||
{
|
{
|
||||||
var (controller, _, _) = Build();
|
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.
|
// Completeness, not validation, is what flags an incomplete profile — an empty profile saves.
|
||||||
Assert.IsType<OkObjectResult>(put.Result);
|
Assert.IsType<OkObjectResult>(put.Result);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public sealed class CareerProfileController : ControllerBase
|
|||||||
if (user is null) return Unauthorized();
|
if (user is null) return Unauthorized();
|
||||||
|
|
||||||
var profile = await _career.LoadStructuredAsync(user.Id, cancellationToken);
|
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>
|
/// <summary>
|
||||||
@@ -46,23 +46,25 @@ public sealed class CareerProfileController : ControllerBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPut]
|
[HttpPut]
|
||||||
[Authorize(AuthenticationSchemes = "local")]
|
[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);
|
var user = await _users.GetUserAsync(User);
|
||||||
if (user is null) return StatusCode(501, "The career profile can only be edited on local accounts.");
|
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);
|
var error = CareerProfileValidator.Validate(profile);
|
||||||
if (error is not null) return BadRequest(error);
|
if (error is not null) return BadRequest(error);
|
||||||
|
|
||||||
var saved = await _career.SaveVersionAsync(user.Id, profile, "manual", cancellationToken);
|
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);
|
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);
|
var res = await _users.UpdateAsync(user);
|
||||||
if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
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>
|
/// <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);
|
public sealed record CareerCompletenessDto(int Percent, List<string> Missing, List<CareerSectionStatusDto> Sections);
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ void ProfilePage;
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockedApi.get.mockImplementation((url: string) => {
|
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') {
|
if (url === '/auth/me') {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
data: {
|
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));
|
await waitFor(() => expect(createObjectURLMock).toHaveBeenCalledTimes(REWRITE_TEMPLATES_COUNT));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('saving the master profile (career) persists structured cv json', async () => {
|
test('saving the master profile (career) persists the structured profile via /career/profile', async () => {
|
||||||
// Phase 2: the master-profile save lives on /career (CareerProfilePage). It sends only the CV
|
// Phase 3: /career saves the master profile through the relational API. The payload carries the
|
||||||
// fields — identity is saved separately on /profile — so the payload carries profileCvStructureJson.
|
// 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);
|
renderWith(CareerProfilePage);
|
||||||
|
|
||||||
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
|
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);
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
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 payload = mockedApi.put.mock.calls[0][1] as any;
|
||||||
const parsed = JSON.parse(payload.profileCvStructureJson);
|
expect(payload.profile.contact.fullName).toBe('Updated Demo User');
|
||||||
expect(parsed.contact.fullName).toBe('Updated Demo User');
|
expect(payload.profile.skills).toEqual(['.NET', 'SQL']);
|
||||||
expect(parsed.skills).toEqual(['.NET', 'SQL']);
|
expect(payload.profile.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).
|
// The career save must NOT carry identity fields (they belong to /profile).
|
||||||
expect(payload.email).toBeUndefined();
|
expect(payload.email).toBeUndefined();
|
||||||
expect(payload.displayName).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
|
// 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
|
// truth for all future generated documents. Split out from ProfilePage in Phase 2.2; wired to the
|
||||||
// only the master-profile fields (partial update), never identity/security.
|
// relational /career/profile API in Phase 3.
|
||||||
export default function CareerProfilePage() {
|
export default function CareerProfilePage() {
|
||||||
// Retained so the shared JSX (copied from ProfilePage) reads identically; hardcoded for /career.
|
// Retained so the shared JSX (copied from ProfilePage) reads identically; hardcoded for /career.
|
||||||
const careerOnly = true;
|
const careerOnly = true;
|
||||||
@@ -266,6 +271,7 @@ export default function CareerProfilePage() {
|
|||||||
const [parsingCvSections, setParsingCvSections] = useState(false);
|
const [parsingCvSections, setParsingCvSections] = useState(false);
|
||||||
const [reprocessingCv, setReprocessingCv] = useState(false);
|
const [reprocessingCv, setReprocessingCv] = useState(false);
|
||||||
const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv());
|
const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv());
|
||||||
|
const [completeness, setCompleteness] = useState<CareerCompleteness | null>(null);
|
||||||
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
|
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
|
||||||
const runStatusRef = useRef<Record<number, string>>({});
|
const runStatusRef = useRef<Record<number, string>>({});
|
||||||
|
|
||||||
@@ -294,15 +300,18 @@ export default function CareerProfilePage() {
|
|||||||
const loadProfile = useCallback(async () => {
|
const loadProfile = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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<MeResponse>("/auth/me"),
|
||||||
api.get<ExtractionRun[]>("/profile-cv/runs").catch(() => ({ data: [] as ExtractionRun[] } as any)),
|
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)),
|
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(meResponse.data);
|
||||||
setMe(r.data);
|
setProfileCvText(careerResponse.data?.cvText ?? "");
|
||||||
setProfileCvText(r.data?.profileCvText ?? "");
|
setStructuredCv(normalizeStructuredCv(careerResponse.data?.profile ?? emptyStructuredCv()));
|
||||||
setStructuredCv(parseStructuredCvJson(r.data?.profileCvStructureJson));
|
setCompleteness(careerResponse.data?.completeness ?? null);
|
||||||
setExtractionRuns(runsResponse.data ?? []);
|
setExtractionRuns(runsResponse.data ?? []);
|
||||||
setSavedJobs(jobsResponse.data?.items ?? []);
|
setSavedJobs(jobsResponse.data?.items ?? []);
|
||||||
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
|
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
|
||||||
@@ -462,6 +471,30 @@ export default function CareerProfilePage() {
|
|||||||
|
|
||||||
return (
|
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)" }}>
|
<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
|
<CropImageDialog
|
||||||
open={cropOpen}
|
open={cropOpen}
|
||||||
file={avatarFile}
|
file={avatarFile}
|
||||||
@@ -1268,10 +1301,12 @@ export default function CareerProfilePage() {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// /career saves only the master profile. The backend does partial updates, so
|
// Save the master profile through the relational source of truth. The endpoint
|
||||||
// omitting identity fields leaves them untouched (they are owned by /profile).
|
// persists the structured children + version and keeps the legacy blob projection
|
||||||
await api.put("/auth/profile", { profileCvText, profileCvStructureJson: JSON.stringify(structuredCv) });
|
// in sync; identity fields are never touched (they belong to /profile).
|
||||||
await loadProfile();
|
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");
|
toast(t("profileUpdated"), "success");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.response?.data || e?.message || t("profileUpdateFailed");
|
const msg = e?.response?.data || e?.message || t("profileUpdateFailed");
|
||||||
|
|||||||
Reference in New Issue
Block a user