02b38f7acb
Phase 5.4. Connects the career outputs a user already has to one job
application, without building a second copy of any of them.
The flow is strictly one-directional — CareerProfile -> CvVariant ->
application output — and nothing writes back up. No code path in this phase
touches CareerProfile or its children.
CV integration re-points rather than duplicates. GET/PUT /{id}/cv attaches one
variant to an application via CvVariant.JobApplicationId; replacing detaches the
previous variant instead of deleting it. Creating, duplicating, editing, theming,
previewing, exporting PDF and version history all stay in the existing CV
builder, which the section links into. There is no second CV system.
Tailoring composes the Phase 5.3 analysis and match into skills to highlight,
experience to prioritise, projects to emphasise, keywords to include and gaps to
address. Deterministic and advisory: it says what the user could emphasise and
the user edits the variant themselves. Nothing auto-applies.
Cover letters gain the history they were missing. JobApplication.CoverLetterText
stays the current text with its API contract unchanged; CoverLetterVersions
records what it used to be, so an AI rewrite is never destructive. Restore is
additive — the old text comes back as a new version, so what you restored from
still exists. Source and AiAction record whether the user wrote a version or
approved it from a suggestion, and an AI generation only becomes a version once
the user saves it.
Documents are untouched: the existing Attachment system already covers CV, cover
letter, certificates and portfolio files with a Purpose field, so the workspace
mounts that component rather than adding a second upload path.
CoverLetterVersions is the only new table — reconciler-owned, no-op migration,
guarded on JobApplications, and verified on a fresh MariaDB 11: int
AUTO_INCREMENT primary key, varchar(255) owner, datetime(6), composite index
inside the key limit.
360 backend tests, 115 frontend tests, type check, Release build and the
production build all pass locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
90 lines
3.9 KiB
C#
90 lines
3.9 KiB
C#
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace JobTrackerApi.Controllers;
|
|
|
|
// Phase 5.4 — Application Assets. Connects existing career outputs to one application.
|
|
//
|
|
// Deliberately thin: CV variant CRUD, preview, PDF export, themes and version history all stay on
|
|
// /api/cv (CvVariantController), and documents stay on /api/attachments. These routes only cover what
|
|
// is genuinely application-scoped — which variant this application uses, what to tailor, and the
|
|
// cover letter with its history. Every route is tenant-scoped and returns 404 for another user's
|
|
// application. docs/architecture/application-workspace.md.
|
|
[ApiController]
|
|
[Route("api/jobapplications/{jobId:int}")]
|
|
[Authorize(AuthenticationSchemes = "local")]
|
|
public sealed class ApplicationAssetsController : ControllerBase
|
|
{
|
|
public sealed record AttachVariantRequest(int? VariantId);
|
|
public sealed record SaveCoverLetterRequest(string? Text, string? Source, string? AiAction);
|
|
|
|
private readonly UserManager<ApplicationUser> _users;
|
|
private readonly IApplicationAssetsService _assets;
|
|
|
|
public ApplicationAssetsController(UserManager<ApplicationUser> users, IApplicationAssetsService assets)
|
|
{
|
|
_users = users;
|
|
_assets = assets;
|
|
}
|
|
|
|
[HttpGet("cv")]
|
|
public async Task<ActionResult<ApplicationCvDto>> GetCv(int jobId, CancellationToken ct)
|
|
{
|
|
var userId = await CurrentUserIdAsync();
|
|
if (userId is null) return Unauthorized();
|
|
var result = await _assets.GetCvAsync(userId, jobId, ct);
|
|
return result is null ? NotFound() : Ok(result);
|
|
}
|
|
|
|
[HttpPut("cv")]
|
|
public async Task<ActionResult<ApplicationCvDto>> AttachVariant(int jobId, [FromBody] AttachVariantRequest request, CancellationToken ct)
|
|
{
|
|
var userId = await CurrentUserIdAsync();
|
|
if (userId is null) return Unauthorized();
|
|
var result = await _assets.AttachVariantAsync(userId, jobId, request?.VariantId, ct);
|
|
return result is null ? NotFound() : Ok(result);
|
|
}
|
|
|
|
[HttpGet("tailoring")]
|
|
public async Task<ActionResult<TailoringPlanDto>> GetTailoring(int jobId, CancellationToken ct)
|
|
{
|
|
var userId = await CurrentUserIdAsync();
|
|
if (userId is null) return Unauthorized();
|
|
var result = await _assets.GetTailoringPlanAsync(userId, jobId, ct);
|
|
return result is null ? NotFound() : Ok(result);
|
|
}
|
|
|
|
[HttpGet("cover-letter")]
|
|
public async Task<ActionResult<CoverLetterDto>> GetCoverLetter(int jobId, CancellationToken ct)
|
|
{
|
|
var userId = await CurrentUserIdAsync();
|
|
if (userId is null) return Unauthorized();
|
|
var result = await _assets.GetCoverLetterAsync(userId, jobId, ct);
|
|
return result is null ? NotFound() : Ok(result);
|
|
}
|
|
|
|
[HttpPut("cover-letter")]
|
|
public async Task<ActionResult<CoverLetterDto>> SaveCoverLetter(int jobId, [FromBody] SaveCoverLetterRequest request, CancellationToken ct)
|
|
{
|
|
var userId = await CurrentUserIdAsync();
|
|
if (userId is null) return Unauthorized();
|
|
var result = await _assets.SaveCoverLetterAsync(
|
|
userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, ct);
|
|
return result is null ? NotFound() : Ok(result);
|
|
}
|
|
|
|
[HttpPost("cover-letter/versions/{version:int}/restore")]
|
|
public async Task<ActionResult<CoverLetterDto>> RestoreCoverLetter(int jobId, int version, CancellationToken ct)
|
|
{
|
|
var userId = await CurrentUserIdAsync();
|
|
if (userId is null) return Unauthorized();
|
|
var result = await _assets.RestoreCoverLetterAsync(userId, jobId, version, ct);
|
|
return result is null ? NotFound() : Ok(result);
|
|
}
|
|
|
|
private async Task<string?> CurrentUserIdAsync() => (await _users.GetUserAsync(User))?.Id;
|
|
}
|