feat(career): master career profile API
CI and Deploy / test (push) Failing after 1m56s
CI and Deploy / deploy (push) Has been skipped

Phase 3, API layer. GET/PUT /api/career/profile — the endpoint the /career editor
uses to read and write the master profile.

- GET: returns the structured profile (assembled from the relational children,
  backfilled from the blob if needed) plus a completeness scorecard.
- PUT: validates limits, persists via CareerProfileService (relational children +
  append-only version), then serializes the result into
  ApplicationUser.ProfileCvStructureJson so the legacy read paths stay in sync.
  Identity fields are untouched (they belong to /profile).
- GET /completeness: just the scorecard, for the overview.
- CareerCompleteness: weighted percent + missing sections.
- CareerProfileValidator: item-count/length limits (abuse guard, NOT completeness
  — a work-in-progress profile always saves).

Tests (+4): put/get round-trip + projection sync, completeness, over-limit
rejection, empty WIP profile accepted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-18 00:43:26 +02:00
parent 46ff9454a8
commit b203120ab4
4 changed files with 282 additions and 0 deletions
@@ -0,0 +1,46 @@
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
// Phase 3: the /career overview scorecard — "Profile completeness: 70%, missing: skills, projects".
// Weighted so the sections that most affect a CV (identity, experience) count for more.
public static class CareerCompleteness
{
private sealed record Section(string Key, string Label, int Weight, Func<StructuredCvProfile, int> Count);
private static readonly Section[] Sections =
{
new("personal", "Personal details", 20, p =>
(!string.IsNullOrWhiteSpace(p.Contact.FullName) ? 1 : 0)
+ (!string.IsNullOrWhiteSpace(p.Contact.Email) ? 1 : 0)),
new("summary", "Professional summary", 10, p => p.Summary.Count(s => !string.IsNullOrWhiteSpace(s))),
new("experience", "Experience", 25, p => p.Jobs.Count),
new("education", "Education", 15, p => p.Education.Count),
new("skills", "Skills", 15, p => p.Skills.Count(s => !string.IsNullOrWhiteSpace(s))),
new("projects", "Projects", 10, p => p.Projects.Count),
new("languages", "Languages", 5, p => p.Languages.Count),
};
public static CareerCompletenessDto Evaluate(StructuredCvProfile profile)
{
var statuses = new List<CareerSectionStatusDto>();
var missing = new List<string>();
var earned = 0;
var total = 0;
foreach (var s in Sections)
{
total += s.Weight;
var count = s.Count(profile);
// "personal" needs both name + email (count 2); the rest need at least one item.
var complete = s.Key == "personal" ? count >= 2 : count >= 1;
if (complete) earned += s.Weight;
else missing.Add(s.Label);
statuses.Add(new CareerSectionStatusDto(s.Key, s.Label, complete, count));
}
var percent = total == 0 ? 0 : (int)Math.Round(100.0 * earned / total);
return new CareerCompletenessDto(percent, missing, statuses);
}
}
@@ -0,0 +1,54 @@
using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
// Phase 3 (validation): guards against abuse/DoS on the career-profile write path, NOT content
// completeness (that is CareerCompleteness's job — a WIP profile must always be savable). Enforces
// item counts and string lengths so a single PUT cannot store an unbounded blob. Returns an error
// string to reject, or null to accept.
public static class CareerProfileValidator
{
private const int MaxItemsPerSection = 200; // generous; a real CV has < 50
private const int MaxListEntries = 200; // bullets/skills/details per item
private const int MaxShortField = 500; // titles, names, single-line fields
private const int MaxLongField = 5000; // a single bullet/summary line
public static string? Validate(StructuredCvProfile p)
{
if (p.Jobs.Count > MaxItemsPerSection) return $"Too many experience entries (max {MaxItemsPerSection}).";
if (p.Education.Count > MaxItemsPerSection) return $"Too many education entries (max {MaxItemsPerSection}).";
if (p.Skills.Count > MaxItemsPerSection) return $"Too many skills (max {MaxItemsPerSection}).";
if (p.Projects.Count > MaxItemsPerSection) return $"Too many projects (max {MaxItemsPerSection}).";
if (p.Certifications.Count > MaxItemsPerSection) return $"Too many certifications (max {MaxItemsPerSection}).";
if (p.Languages.Count > MaxItemsPerSection) return $"Too many languages (max {MaxItemsPerSection}).";
if (p.Summary.Count > MaxListEntries) return $"Summary is too long (max {MaxListEntries} lines).";
foreach (var j in p.Jobs)
{
if (Over(j.Title, MaxShortField) || Over(j.Company, MaxShortField) || Over(j.Location, MaxShortField))
return "An experience field exceeds the allowed length.";
if (j.Bullets.Count > MaxListEntries || j.Skills.Count > MaxListEntries) return "An experience has too many bullets/skills.";
if (j.Bullets.Any(b => Over(b, MaxLongField))) return "An experience bullet is too long.";
}
foreach (var e in p.Education)
{
if (Over(e.Qualification, MaxShortField) || Over(e.Institution, MaxShortField)) return "An education field exceeds the allowed length.";
if (e.Details.Count > MaxListEntries) return "An education entry has too many details.";
}
foreach (var pr in p.Projects)
{
if (Over(pr.Name, MaxShortField) || Over(pr.Role, MaxShortField)) return "A project field exceeds the allowed length.";
if (pr.Bullets.Count > MaxListEntries || pr.Skills.Count > MaxListEntries) return "A project has too many bullets/skills.";
}
foreach (var s in p.Skills)
if (Over(s, MaxShortField)) return "A skill entry is too long.";
if (Over(p.Contact.FullName, MaxShortField) || Over(p.Contact.Email, MaxShortField)
|| Over(p.Contact.Headline, MaxShortField) || Over(p.Contact.Location, MaxShortField))
return "A contact field exceeds the allowed length.";
return null;
}
private static bool Over(string? value, int max) => value is not null && value.Length > max;
}