feat: complete phase 3 career workspace
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
using JobTrackerApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class AvatarStorageTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Store_resolve_delete_round_trip_keeps_image_out_of_database_value()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "jobtracker-avatar-test-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var stored = await AvatarStorage.StoreAsync(root, "user-1", new byte[] { 1, 2, 3 }, "image/png", default);
|
||||
|
||||
Assert.StartsWith("file:", stored);
|
||||
Assert.Equal("data:image/png;base64,AQID", AvatarStorage.Resolve(stored));
|
||||
AvatarStorage.Delete(stored);
|
||||
Assert.Null(AvatarStorage.Resolve(stored));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,10 @@ public sealed class CareerProfileServiceTests
|
||||
Contact = { FullName = "Ada Lovelace", Email = "ada@example.com", Headline = "Engineer" },
|
||||
Summary = { "First", "Second" },
|
||||
Interests = { "Chess" },
|
||||
Awards = { "Engineering prize" },
|
||||
Publications = { "Reliable systems" },
|
||||
Organisations = { "ACM" },
|
||||
References = { "Available on request" },
|
||||
Jobs =
|
||||
{
|
||||
new StructuredCvJob { Title = "Senior Eng", Company = "Acme", Start = "Jan 2020", End = "Present", IsCurrent = true, Bullets = { "Built X" }, Skills = { "C#" } },
|
||||
@@ -130,6 +134,10 @@ public sealed class CareerProfileServiceTests
|
||||
Assert.Equal("Ada Lovelace", loaded.Contact.FullName);
|
||||
Assert.Equal(new[] { "First", "Second" }, loaded.Summary);
|
||||
Assert.Equal(new[] { "Chess" }, loaded.Interests);
|
||||
Assert.Equal(new[] { "Engineering prize" }, loaded.Awards);
|
||||
Assert.Equal(new[] { "Reliable systems" }, loaded.Publications);
|
||||
Assert.Equal(new[] { "ACM" }, loaded.Organisations);
|
||||
Assert.Equal(new[] { "Available on request" }, loaded.References);
|
||||
Assert.Equal(2, loaded.Jobs.Count);
|
||||
Assert.Equal("Senior Eng", loaded.Jobs[0].Title); // order preserved
|
||||
Assert.True(loaded.Jobs[0].IsCurrent);
|
||||
|
||||
@@ -24,8 +24,9 @@ public sealed class AuthController : ControllerBase
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
private readonly ITwoFactorPendingTokenService _twoFactorPending;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly string _avatarDataRoot;
|
||||
|
||||
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db)
|
||||
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db, IHostEnvironment? environment = null)
|
||||
{
|
||||
_cfg = cfg;
|
||||
_users = users;
|
||||
@@ -36,6 +37,7 @@ public sealed class AuthController : ControllerBase
|
||||
_logger = logger;
|
||||
_twoFactorPending = twoFactorPending;
|
||||
_db = db;
|
||||
_avatarDataRoot = Path.GetFullPath((_cfg["Data:Root"] ?? environment?.ContentRootPath ?? AppContext.BaseDirectory).Trim());
|
||||
}
|
||||
|
||||
[HttpGet("config")]
|
||||
@@ -575,8 +577,7 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest("Only PNG, JPEG, or WebP images are supported.");
|
||||
}
|
||||
|
||||
var base64 = Convert.ToBase64String(bytes);
|
||||
user.AvatarImageDataUrl = $"data:{detectedContentType};base64,{base64}";
|
||||
user.AvatarImageDataUrl = await AvatarStorage.StoreAsync(_avatarDataRoot, user.Id, bytes, detectedContentType, HttpContext.RequestAborted);
|
||||
|
||||
var result = await _users.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
@@ -584,7 +585,7 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
return Ok(new { avatarImageDataUrl = user.AvatarImageDataUrl });
|
||||
return Ok(new { avatarImageDataUrl = AvatarStorage.Resolve(user.AvatarImageDataUrl) });
|
||||
}
|
||||
|
||||
[HttpDelete("avatar")]
|
||||
@@ -597,6 +598,7 @@ public sealed class AuthController : ControllerBase
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var storedAvatar = user.AvatarImageDataUrl;
|
||||
user.AvatarImageDataUrl = null;
|
||||
var result = await _users.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
@@ -604,6 +606,7 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
AvatarStorage.Delete(storedAvatar);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -876,7 +879,7 @@ public sealed class AuthController : ControllerBase
|
||||
DisplayName: user.DisplayName,
|
||||
ProfileCvText: user.ProfileCvText,
|
||||
ProfileCvStructureJson: user.ProfileCvStructureJson,
|
||||
AvatarImageDataUrl: user.AvatarImageDataUrl,
|
||||
AvatarImageDataUrl: AvatarStorage.Resolve(user.AvatarImageDataUrl),
|
||||
Roles: roles,
|
||||
GoogleLink: new GoogleLinkDto(
|
||||
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
|
||||
|
||||
@@ -229,7 +229,7 @@ public sealed class CvVariantController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(name)) name = user.UserName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(name)) name = user.Email?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(name)) name = "Your Name";
|
||||
return new CvRenderPerson(name!, user.AvatarImageDataUrl);
|
||||
return new CvRenderPerson(name!, AvatarStorage.Resolve(user.AvatarImageDataUrl));
|
||||
}
|
||||
|
||||
private static VariantDto ToDto(CvVariant v) => new(
|
||||
|
||||
@@ -1821,7 +1821,7 @@ Candidate master CV:
|
||||
? request!.PhotoDataUrl
|
||||
: request?.UseProfileAvatar == false
|
||||
? null
|
||||
: user.AvatarImageDataUrl;
|
||||
: AvatarStorage.Resolve(user.AvatarImageDataUrl);
|
||||
var rendered = RenderTailoredCv(job, document, user, photoDataUrl);
|
||||
return Ok(new TailoredCvPreviewDto(rendered.TemplateId, rendered.Html, rendered.SuggestedFileName));
|
||||
}
|
||||
@@ -1852,7 +1852,7 @@ Candidate master CV:
|
||||
? request!.PhotoDataUrl
|
||||
: request?.UseProfileAvatar == false
|
||||
? null
|
||||
: user.AvatarImageDataUrl;
|
||||
: AvatarStorage.Resolve(user.AvatarImageDataUrl);
|
||||
var rendered = RenderTailoredCv(job, document, user, photoDataUrl);
|
||||
var artifact = await _cvPdfExporter.ExportAsync(rendered, cancellationToken);
|
||||
return File(artifact.Bytes, "application/pdf", artifact.FileName);
|
||||
|
||||
@@ -53,9 +53,18 @@ public sealed class ProfileCvController : ControllerBase
|
||||
["languages"] = "Languages",
|
||||
["interests"] = "Interests",
|
||||
["hobbies"] = "Interests",
|
||||
["awards"] = "Awards",
|
||||
["honours"] = "Awards",
|
||||
["honors"] = "Awards",
|
||||
["publications"] = "Publications",
|
||||
["organisations"] = "Organisations",
|
||||
["organizations"] = "Organisations",
|
||||
["memberships"] = "Organisations",
|
||||
["references"] = "References",
|
||||
};
|
||||
|
||||
private const long MaxFileSizeBytes = 5 * 1024 * 1024;
|
||||
private const int ExtractionRunRetentionCount = 20;
|
||||
private const string ParserVersion = "m005-s01";
|
||||
private const string NormalizerVersion = "m005-s01";
|
||||
private const string LlmPromptVersion = "m005-s01";
|
||||
@@ -168,6 +177,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
run.Status = "pending_review";
|
||||
run.CompletedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(HttpContext.RequestAborted);
|
||||
await PruneExtractionRunsAsync(user.Id, HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -185,6 +195,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
run.ErrorMessage = ex.Message;
|
||||
run.CompletedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(HttpContext.RequestAborted);
|
||||
await PruneExtractionRunsAsync(user.Id, HttpContext.RequestAborted);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -625,7 +636,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(candidateName)) candidateName = user.UserName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(candidateName)) candidateName = user.Email?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(candidateName)) candidateName = "Your Name";
|
||||
return _cvTemplateRenderer.Render(document, document.TemplateId, candidateName!, targetRole, companyName, user.AvatarImageDataUrl);
|
||||
return _cvTemplateRenderer.Render(document, document.TemplateId, candidateName!, targetRole, companyName, AvatarStorage.Resolve(user.AvatarImageDataUrl));
|
||||
}
|
||||
|
||||
private static TailoredCvDocument BuildMasterCvDocument(StructuredCvProfile structuredCv, string templateId, string? targetRole, string? fallbackHeadline, string? companyName)
|
||||
@@ -903,6 +914,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await PruneExtractionRunsAsync(user.Id, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<CvExtractionRun> CreateQueuedRunAsync(string ownerUserId, int? artifactId, string trigger, CancellationToken cancellationToken)
|
||||
@@ -1011,6 +1023,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
run.ErrorMessage = ex.Message;
|
||||
run.CompletedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await PruneExtractionRunsAsync(user.Id, cancellationToken);
|
||||
await SendRunCompletionEmailAsync(user, run, false, cancellationToken);
|
||||
_logger.LogWarning(ex, "CV processing run {RunId} failed for user {UserId}", run.Id, user.Id);
|
||||
}
|
||||
@@ -1024,6 +1037,19 @@ public sealed class ProfileCvController : ControllerBase
|
||||
run.Status = "pending_review";
|
||||
run.CompletedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await PruneExtractionRunsAsync(run.OwnerUserId, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task PruneExtractionRunsAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var expired = await _db.CvExtractionRuns
|
||||
.Where(x => x.OwnerUserId == ownerUserId && x.Status != "queued" && x.Status != "running")
|
||||
.OrderByDescending(x => x.StartedAtUtc)
|
||||
.Skip(ExtractionRunRetentionCount)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (expired.Count == 0) return;
|
||||
_db.CvExtractionRuns.RemoveRange(expired);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SendRunCompletionEmailAsync(ApplicationUser user, CvExtractionRun run, bool success, CancellationToken cancellationToken)
|
||||
|
||||
@@ -44,6 +44,6 @@ public sealed class PublicCvController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(name)) name = user.DisplayName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(name)) name = user.UserName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(name)) name = "Candidate";
|
||||
return new CvRenderPerson(name!, user.AvatarImageDataUrl);
|
||||
return new CvRenderPerson(name!, AvatarStorage.Resolve(user.AvatarImageDataUrl));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public static class AvatarStorage
|
||||
{
|
||||
private const string FilePrefix = "file:";
|
||||
|
||||
public static async Task<string> StoreAsync(string dataRoot, string userId, byte[] bytes, string contentType, CancellationToken cancellationToken)
|
||||
{
|
||||
var userKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(userId))).ToLowerInvariant();
|
||||
var folder = Path.Combine(dataRoot, "Avatars", userKey);
|
||||
Directory.CreateDirectory(folder);
|
||||
var extension = contentType switch { "image/png" => ".png", "image/webp" => ".webp", _ => ".jpg" };
|
||||
var path = Path.Combine(folder, "avatar" + extension);
|
||||
foreach (var old in Directory.EnumerateFiles(folder, "avatar.*")) if (!string.Equals(old, path, StringComparison.OrdinalIgnoreCase)) File.Delete(old);
|
||||
await File.WriteAllBytesAsync(path, bytes, cancellationToken);
|
||||
return FilePrefix + path;
|
||||
}
|
||||
|
||||
public static string? Resolve(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || !value.StartsWith(FilePrefix, StringComparison.Ordinal)) return value;
|
||||
var path = value[FilePrefix.Length..];
|
||||
if (!File.Exists(path)) return null;
|
||||
var contentType = Path.GetExtension(path).ToLowerInvariant() switch { ".png" => "image/png", ".webp" => "image/webp", _ => "image/jpeg" };
|
||||
return $"data:{contentType};base64,{Convert.ToBase64String(File.ReadAllBytes(path))}";
|
||||
}
|
||||
|
||||
public static void Delete(string? value)
|
||||
{
|
||||
if (value?.StartsWith(FilePrefix, StringComparison.Ordinal) != true) return;
|
||||
var path = value[FilePrefix.Length..];
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ public static class CareerProfileMapper
|
||||
public StructuredCvContact Contact { get; set; } = new();
|
||||
public List<string> Summary { get; set; } = new();
|
||||
public List<string> Interests { get; set; } = new();
|
||||
public List<string> Awards { get; set; } = new();
|
||||
public List<string> Publications { get; set; } = new();
|
||||
public List<string> Organisations { get; set; } = new();
|
||||
public List<string> References { get; set; } = new();
|
||||
public List<StructuredCvOtherSection> OtherSections { get; set; } = new();
|
||||
public List<StructuredCvSection> Sections { get; set; } = new();
|
||||
}
|
||||
@@ -35,6 +39,10 @@ public static class CareerProfileMapper
|
||||
Contact = p.Contact,
|
||||
Summary = p.Summary,
|
||||
Interests = p.Interests,
|
||||
Awards = p.Awards,
|
||||
Publications = p.Publications,
|
||||
Organisations = p.Organisations,
|
||||
References = p.References,
|
||||
OtherSections = p.OtherSections,
|
||||
Sections = p.Sections,
|
||||
}, JsonOptions);
|
||||
@@ -125,6 +133,10 @@ public static class CareerProfileMapper
|
||||
Contact = tail.Contact,
|
||||
Summary = tail.Summary,
|
||||
Interests = tail.Interests,
|
||||
Awards = tail.Awards,
|
||||
Publications = tail.Publications,
|
||||
Organisations = tail.Organisations,
|
||||
References = tail.References,
|
||||
OtherSections = tail.OtherSections,
|
||||
Sections = tail.Sections,
|
||||
Jobs = experiences.Select(x => new StructuredCvJob
|
||||
|
||||
@@ -71,6 +71,10 @@ public sealed class CvProfileDiffService : ICvProfileDiffService
|
||||
DiffLanguages(current.Languages, extracted.Languages),
|
||||
DiffScalars("Skills", current.Skills, extracted.Skills),
|
||||
DiffScalars("Interests", current.Interests, extracted.Interests),
|
||||
DiffScalars("Awards", current.Awards, extracted.Awards),
|
||||
DiffScalars("Publications", current.Publications, extracted.Publications),
|
||||
DiffScalars("Organisations", current.Organisations, extracted.Organisations),
|
||||
DiffScalars("References", current.References, extracted.References),
|
||||
}.Where(c => c is not null).Select(c => c!).ToList(),
|
||||
};
|
||||
}
|
||||
@@ -89,6 +93,10 @@ public sealed class CvProfileDiffService : ICvProfileDiffService
|
||||
MergeLanguages(merged.Languages, extracted.Languages);
|
||||
AppendUnique(merged.Skills, extracted.Skills);
|
||||
AppendUnique(merged.Interests, extracted.Interests);
|
||||
AppendUnique(merged.Awards, extracted.Awards);
|
||||
AppendUnique(merged.Publications, extracted.Publications);
|
||||
AppendUnique(merged.Organisations, extracted.Organisations);
|
||||
AppendUnique(merged.References, extracted.References);
|
||||
MergeList(merged.OtherSections, extracted.OtherSections, x => Norm(x.Title), (a, b) => AppendUnique(a.Items, b.Items));
|
||||
if (extracted.Sections.Count > 0) merged.Sections = extracted.Sections;
|
||||
foreach (var field in extracted.Metadata.Fields) merged.Metadata.Fields[field.Key] = field.Value;
|
||||
|
||||
@@ -79,6 +79,10 @@ public static class CvVariantResolver
|
||||
["certifications"] = CertificationSection(profile, settings),
|
||||
["languages"] = LanguageSection(profile),
|
||||
["interests"] = TagSection("interests", "Interests", profile.Interests),
|
||||
["awards"] = BulletSection("awards", "Awards", profile.Awards),
|
||||
["publications"] = BulletSection("publications", "Publications", profile.Publications),
|
||||
["organisations"] = BulletSection("organisations", "Organisations", profile.Organisations),
|
||||
["references"] = BulletSection("references", "References", profile.References),
|
||||
};
|
||||
|
||||
// OtherSections from the master profile become body sections keyed other:<n>.
|
||||
|
||||
@@ -13,6 +13,10 @@ public sealed class StructuredCvProfile
|
||||
public List<string> Skills { get; set; } = new();
|
||||
public List<StructuredCvLanguage> Languages { get; set; } = new();
|
||||
public List<string> Interests { get; set; } = new();
|
||||
public List<string> Awards { get; set; } = new();
|
||||
public List<string> Publications { get; set; } = new();
|
||||
public List<string> Organisations { get; set; } = new();
|
||||
public List<string> References { get; set; } = new();
|
||||
public List<StructuredCvOtherSection> OtherSections { get; set; } = new();
|
||||
public List<StructuredCvSection> Sections { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -82,6 +82,10 @@ public static class StructuredCvProfileJson
|
||||
primary.Interests = primary.Interests.Count == 0
|
||||
? secondary.Interests
|
||||
: primary.Interests.Concat(secondary.Interests).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
MergeStrings(primary.Awards, secondary.Awards);
|
||||
MergeStrings(primary.Publications, secondary.Publications);
|
||||
MergeStrings(primary.Organisations, secondary.Organisations);
|
||||
MergeStrings(primary.References, secondary.References);
|
||||
if (primary.OtherSections.Count == 0) primary.OtherSections = secondary.OtherSections;
|
||||
if (primary.Sections.Count == 0) primary.Sections = secondary.Sections;
|
||||
|
||||
@@ -126,6 +130,22 @@ public static class StructuredCvProfileJson
|
||||
case "interests":
|
||||
profile.Interests = SplitList(section.Content);
|
||||
break;
|
||||
case "awards":
|
||||
case "honours":
|
||||
case "honors":
|
||||
profile.Awards = SplitList(section.Content);
|
||||
break;
|
||||
case "publications":
|
||||
profile.Publications = SplitList(section.Content);
|
||||
break;
|
||||
case "organisations":
|
||||
case "organizations":
|
||||
case "memberships":
|
||||
profile.Organisations = SplitList(section.Content);
|
||||
break;
|
||||
case "references":
|
||||
profile.References = SplitList(section.Content);
|
||||
break;
|
||||
case "work experience":
|
||||
case "experience":
|
||||
case "employment history":
|
||||
@@ -193,6 +213,10 @@ public static class StructuredCvProfileJson
|
||||
.Where(language => !string.IsNullOrWhiteSpace(language.Name))
|
||||
.ToList();
|
||||
profile.Interests = CleanList(profile.Interests);
|
||||
profile.Awards = CleanList(profile.Awards);
|
||||
profile.Publications = CleanList(profile.Publications);
|
||||
profile.Organisations = CleanList(profile.Organisations);
|
||||
profile.References = CleanList(profile.References);
|
||||
profile.OtherSections = (profile.OtherSections ?? new List<StructuredCvOtherSection>())
|
||||
.Select(section => new StructuredCvOtherSection
|
||||
{
|
||||
@@ -630,6 +654,11 @@ public static class StructuredCvProfileJson
|
||||
|
||||
AddSectionIfAny(sections, "Interests", profile.Interests);
|
||||
|
||||
AddSectionIfAny(sections, "Awards", profile.Awards);
|
||||
AddSectionIfAny(sections, "Publications", profile.Publications);
|
||||
AddSectionIfAny(sections, "Organisations", profile.Organisations);
|
||||
AddSectionIfAny(sections, "References", profile.References);
|
||||
|
||||
foreach (var other in profile.OtherSections)
|
||||
{
|
||||
AddSectionIfAny(sections, other.Title ?? "Other", other.Items);
|
||||
@@ -638,6 +667,12 @@ public static class StructuredCvProfileJson
|
||||
return NormalizeSections(sections);
|
||||
}
|
||||
|
||||
private static void MergeStrings(List<string> primary, IEnumerable<string> secondary)
|
||||
{
|
||||
foreach (var value in secondary)
|
||||
if (!primary.Contains(value, StringComparer.OrdinalIgnoreCase)) primary.Add(value);
|
||||
}
|
||||
|
||||
private static void AddSectionIfAny(List<StructuredCvSection> sections, string name, IEnumerable<string>? lines)
|
||||
{
|
||||
var content = string.Join("\n", (lines ?? Array.Empty<string>()).Where(line => !string.IsNullOrWhiteSpace(line)).Select(line => line.Trim())).Trim();
|
||||
|
||||
@@ -75,20 +75,19 @@ Goal: one professional source of truth that can actually feed outputs.
|
||||
> **Foundation SHIPPED 2026-07-18** (commits `9c8644e`…`a1dd447`). The relational master career
|
||||
> profile is built, tested (23 backend + frontend tests), migrated (verified on the live DB), wired
|
||||
> to `/career`, with completeness overview + version-history restore. See
|
||||
> `docs/architecture/career-profile-model.md` (Implementation status). Tasks 3.1, 3.3, 3.4 and the
|
||||
> versioning/validation goals are delivered. Remaining below: 3.2 (long-tail-as-relational, deferred
|
||||
> — currently JSON), 3.5 (CV variants — Phase 4), 3.6 (retention), plus a full section-by-section UX
|
||||
> redesign of the editor (polish; the functional workspace exists).
|
||||
> `docs/architecture/career-profile-model.md` (Implementation status). **Phase 3 completed 2026-07-30:**
|
||||
> the workspace, Master CV separation, variants, long-tail sections, extraction retention, and
|
||||
> file-backed avatars are delivered.
|
||||
|
||||
| # | Task | Priority | Difficulty | Dependencies | Expected value |
|
||||
|---|---|---|---|---|---|
|
||||
| 3.1 | ✅ **DONE** — Experience/Education/Skills/Projects/Certifications/Languages are relational children of `CareerProfile`; long tail is JSON. Projection service keeps the blob as a derived read-model; lazy backfill from existing data. | **P1** | **L** | 1.9 | Delivered. Queryable structured career data is now the source of truth; the blob is derived. |
|
||||
| 3.2 | **Add the missing profile sections** — Awards, Publications, Organisations, References | **P1** | **S** | 3.1 | The guide names them; `StructuredCvProfile` has no home for them beyond generic `OtherSections`. These are the blob half of 3.1. |
|
||||
| 3.3 | **Real Career Workspace page** — replace the 36-line tab facade | **P1** | **M** | 2.1, 2.2 | Currently a wrapper around `ProfilePage`. |
|
||||
| 3.4 | **Separate Career Profile from Master CV** | **P1** | **M** | 3.1 | The glossary is explicit — "The career profile is NOT a CV"; Master CV is a *generated representation*. Code has one blob. Getting this wrong makes Phase 4 impossible. |
|
||||
| 3.5 | **CV variants** (Software Engineer CV / Management CV) | **P2** | **M** | 3.4 | In the glossary; no code. Distinct from per-application `TailoredCvDraft`, which works correctly and must not be disturbed. |
|
||||
| 3.6 | **Retention policy for `CvExtractionRun`** | **P2** | **S** | none | Three copies of every CV (raw/normalized/structured), unbounded. |
|
||||
| 3.7 | **Move avatars out of the DB column** | **P3** | **S** | none | Base64 blob on the `/auth/me` hot path. |
|
||||
| 3.2 | ✅ **DONE (2026-07-30)** — added Awards, Publications, Organisations, and References across extraction, review/merge, JSON storage, editing, and rendering. | **P1** | **S** | 3.1 | The guide names them; `StructuredCvProfile` has no home for them beyond generic `OtherSections`. These are the blob half of 3.1. |
|
||||
| 3.3 | ✅ **DONE (2026-07-30)** — dedicated Career Workspace page and focused section components. | **P1** | **M** | 2.1, 2.2 | Currently a wrapper around `ProfilePage`. |
|
||||
| 3.4 | ✅ **DONE (2026-07-30)** — Career Profile is the source of truth; Master CV is a generated representation. | **P1** | **M** | 3.1 | The glossary is explicit — "The career profile is NOT a CV"; Master CV is a *generated representation*. Code has one blob. Getting this wrong makes Phase 4 impossible. |
|
||||
| 3.5 | ✅ **DONE (2026-07-30)** — named CV variants with stable profile-item references. | **P2** | **M** | 3.4 | In the glossary; no code. Distinct from per-application `TailoredCvDraft`, which works correctly and must not be disturbed. |
|
||||
| 3.6 | ✅ **DONE (2026-07-30)** — retain the newest 20 completed extraction runs per user; queued/running work is protected. | **P2** | **S** | none | Three copies of every CV (raw/normalized/structured), unbounded. |
|
||||
| 3.7 | ✅ **DONE (2026-07-30)** — new avatars live in persistent file storage; the DB stores only an internal file reference, with legacy data-URL compatibility. | **P3** | **S** | none | Base64 blob on the `/auth/me` hot path. |
|
||||
|
||||
**Do not disturb:** `TailoredCvDraft` correctly implements "the master CV must never be modified automatically" — the single most important documented invariant, and it already holds.
|
||||
|
||||
|
||||
@@ -252,6 +252,10 @@ export const translations = {
|
||||
profileCvStructuredSummary: "Professional summary",
|
||||
profileCvStructuredSkills: "Skills",
|
||||
profileCvStructuredInterests: "Interests",
|
||||
profileCvStructuredAwards: "Awards",
|
||||
profileCvStructuredPublications: "Publications",
|
||||
profileCvStructuredOrganisations: "Organisations",
|
||||
profileCvStructuredReferences: "References",
|
||||
profileCvStructuredLanguages: "Languages",
|
||||
profileCvStructuredJobs: "Work experience",
|
||||
profileCvStructuredEducation: "Education",
|
||||
@@ -1325,6 +1329,10 @@ export const translations = {
|
||||
profileCvStructuredSummary: "Sammendrags-punkter",
|
||||
profileCvStructuredSkills: "Kjernekompetanse",
|
||||
profileCvStructuredInterests: "Interesser",
|
||||
profileCvStructuredAwards: "Priser og utmerkelser",
|
||||
profileCvStructuredPublications: "Publikasjoner",
|
||||
profileCvStructuredOrganisations: "Organisasjoner",
|
||||
profileCvStructuredReferences: "Referanser",
|
||||
profileCvStructuredLanguages: "Språk",
|
||||
profileCvStructuredJobs: "Arbeidserfaring",
|
||||
profileCvStructuredEducation: "Utdanning",
|
||||
|
||||
@@ -93,6 +93,10 @@ export type StructuredCvProfile = {
|
||||
skills: string[];
|
||||
languages: StructuredCvLanguage[];
|
||||
interests: string[];
|
||||
awards: string[];
|
||||
publications: string[];
|
||||
organisations: string[];
|
||||
references: string[];
|
||||
otherSections: StructuredCvOtherSection[];
|
||||
sections: ParsedCvSection[];
|
||||
};
|
||||
@@ -121,6 +125,10 @@ export function emptyStructuredCv(): StructuredCvProfile {
|
||||
skills: [],
|
||||
languages: [],
|
||||
interests: [],
|
||||
awards: [],
|
||||
publications: [],
|
||||
organisations: [],
|
||||
references: [],
|
||||
otherSections: [],
|
||||
sections: [],
|
||||
};
|
||||
@@ -183,6 +191,10 @@ function buildLegacyStructuredCv(sections: ParsedCvSection[]): StructuredCvProfi
|
||||
skills,
|
||||
languages,
|
||||
interests,
|
||||
awards: linesFromSection(sections, ["awards", "honours", "honors"]),
|
||||
publications: linesFromSection(sections, ["publications"]),
|
||||
organisations: linesFromSection(sections, ["organisations", "organizations", "memberships"]),
|
||||
references: linesFromSection(sections, ["references"]),
|
||||
sections,
|
||||
};
|
||||
}
|
||||
@@ -274,6 +286,10 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
|
||||
}))
|
||||
: [],
|
||||
interests: normalizeList(source.interests),
|
||||
awards: normalizeList(source.awards),
|
||||
publications: normalizeList(source.publications),
|
||||
organisations: normalizeList(source.organisations),
|
||||
references: normalizeList(source.references),
|
||||
otherSections: Array.isArray(source.otherSections)
|
||||
? source.otherSections.map((section: any) => ({
|
||||
title: normalizeString(section?.title),
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
EducationSection,
|
||||
InterestsSection,
|
||||
LanguagesSection,
|
||||
LongTailSections,
|
||||
OtherSectionsSection,
|
||||
PersonalInformationSection,
|
||||
ProfessionalSummarySection,
|
||||
@@ -647,6 +648,8 @@ export default function CareerProfilePage() {
|
||||
<InterestsSection value={structuredCv.interests} onChange={(next) => setStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} />
|
||||
</Box>
|
||||
|
||||
<LongTailSections values={{ awards: structuredCv.awards, publications: structuredCv.publications, organisations: structuredCv.organisations, references: structuredCv.references }} onChange={(key, next) => setStructuredCv((prev) => ({ ...prev, [key]: next }))} />
|
||||
|
||||
<LanguagesSection value={structuredCv.languages} onChange={(next) => setStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} />
|
||||
|
||||
<WorkExperienceSection value={structuredCv.jobs} onChange={(next) => setStructuredCv((prev) => ({ ...prev, jobs: next }))} />
|
||||
|
||||
@@ -122,6 +122,12 @@ export function InterestsSection({ value, onChange, getMetadata }: { value: stri
|
||||
return <LinesField label={t("profileCvStructuredInterests")} value={value} onChange={onChange} metadata={getMetadata("interests")} minRows={4} />;
|
||||
}
|
||||
|
||||
export function LongTailSections({ values, onChange }: { values: Record<"awards" | "publications" | "organisations" | "references", string[]>; onChange: (key: keyof typeof values, next: string[]) => void }) {
|
||||
const { t } = useI18n();
|
||||
const labels = { awards: t("profileCvStructuredAwards"), publications: t("profileCvStructuredPublications"), organisations: t("profileCvStructuredOrganisations"), references: t("profileCvStructuredReferences") };
|
||||
return <Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>{(Object.keys(values) as (keyof typeof values)[]).map((key) => <LinesField key={key} label={labels[key]} value={values[key]} onChange={(next) => onChange(key, next)} minRows={3} />)}</Box>;
|
||||
}
|
||||
|
||||
export function LanguagesSection({ value, onChange, getMetadata }: { value: StructuredCvLanguage[]; onChange: (next: StructuredCvLanguage[]) => void; getMetadata: MetadataLookup }) {
|
||||
const { t } = useI18n();
|
||||
const update = (index: number, patch: Partial<StructuredCvLanguage>) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));
|
||||
|
||||
Reference in New Issue
Block a user