feat: complete phase 3 career workspace
CI and Deploy / test (push) Failing after 1m6s
CI and Deploy / deploy (push) Has been skipped

This commit is contained in:
cesnimda
2026-07-30 22:19:13 +02:00
parent f8a7cf5205
commit 4cf26405f6
18 changed files with 215 additions and 20 deletions
+8 -5
View File
@@ -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));
}
}
+37
View File
@@ -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
+8
View File
@@ -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;
+4
View File
@@ -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>.