feat(workspace): Application Workspace foundation (Phase 5 milestone 1)
CI and Deploy / test (push) Failing after 1m13s
CI and Deploy / deploy (push) Has been skipped

Every JobApplication gets a dedicated workspace at /applications/{id} — a
surface, not a new data store. It owns no data and duplicates none: CV comes
from the Phase 4 CvVariant lens, analysis/match/interview from the existing
AiWorkspacePanel, documents from Attachments, communication from
Correspondence, activity from JobEvent, stage semantics from JobPipeline. No
career data is copied and nothing here writes.

- GET /api/jobapplications/{id}/workspace: one aggregate read (role, company,
  stage, dates, attached CV variant, cover letter, documents, AI history,
  recent activity) replacing the page fanning out across endpoints
- Next recommended action: ordered rules answering "what do I do next?", the
  core product principle for this phase
- ApplicationWorkspacePage: left nav + linkable ?section=, reusing the existing
  component for each domain; later-milestone sections say so rather than faking
- Entry point from the job dialog via an optional onOpenWorkspace callback —
  the dialog must not depend on router context (it is mounted without a
  <Router> in several suites), so the caller owns navigation
- 8 backend tests (aggregate, CV variant surfacing, counts, activity ordering,
  next-step rules, tenant scoping)

Local: 314 backend, 88/88 frontend (31 suites), tsc clean, production build ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-18 23:57:24 +02:00
parent 3b59152782
commit e55a6e86b7
10 changed files with 797 additions and 2 deletions
@@ -0,0 +1,34 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace JobTrackerApi.Controllers;
// Phase 5 Milestone 1 — one aggregate read for the Application Workspace overview, so the page loads
// from a single call instead of fanning out. Read-only; owns no data.
// docs/architecture/application-workspace.md.
[ApiController]
[Route("api/jobapplications/{jobId:int}/workspace")]
[Authorize(AuthenticationSchemes = "local")]
public sealed class ApplicationWorkspaceController : ControllerBase
{
private readonly UserManager<ApplicationUser> _users;
private readonly IApplicationWorkspaceService _workspace;
public ApplicationWorkspaceController(UserManager<ApplicationUser> users, IApplicationWorkspaceService workspace)
{
_users = users;
_workspace = workspace;
}
[HttpGet]
public async Task<ActionResult<WorkspaceOverviewDto>> GetOverview(int jobId, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var overview = await _workspace.GetOverviewAsync(user.Id, jobId, ct);
return overview is null ? NotFound() : Ok(overview);
}
}
+1
View File
@@ -41,6 +41,7 @@ builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
builder.Services.AddScoped<ICvVariantService, CvVariantService>();
builder.Services.AddScoped<IAiWorkspaceService, AiWorkspaceService>();
builder.Services.AddScoped<IApplicationWorkspaceService, ApplicationWorkspaceService>();
builder.Services.AddSingleton<AppPaths>();
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
@@ -0,0 +1,156 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
// Phase 5 Milestone 1 — the Application Workspace overview.
//
// This is an AGGREGATE READ ONLY. It owns no data and duplicates none: the CV comes from the Phase 4
// CvVariant lens, documents from Attachments, activity from JobEvent, AI history from AiInteraction,
// stage semantics from JobPipeline. Nothing here writes, and no career data is copied.
// docs/architecture/application-workspace.md.
public sealed record WorkspaceCvDto(int? VariantId, string? VariantName, string? ThemeId, bool HasTailoredCvText, DateTimeOffset? UpdatedAtUtc);
public sealed record WorkspaceActivityDto(string Type, string? Detail, DateTime At);
public sealed record WorkspaceNextStepDto(string Key, string Label, string Reason, string? Section);
public sealed record WorkspaceOverviewDto(
int Id,
string JobTitle,
string? Company,
string? Location,
string? Salary,
string Status,
string StageGroup,
int StageOrder,
DateTime? DateApplied,
DateTime? Deadline,
DateTime? FollowUpAt,
string? NextAction,
string? JobUrl,
bool HasJobDescription,
WorkspaceCvDto Cv,
bool HasCoverLetter,
int DocumentCount,
bool HasPortfolio,
int AiInteractionCount,
DateTimeOffset? LastAiAtUtc,
IReadOnlyList<WorkspaceActivityDto> RecentActivity,
WorkspaceNextStepDto? NextStep);
public interface IApplicationWorkspaceService
{
Task<WorkspaceOverviewDto?> GetOverviewAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
}
public sealed class ApplicationWorkspaceService : IApplicationWorkspaceService
{
private const int RecentActivityCount = 8;
private readonly JobTrackerContext _db;
public ApplicationWorkspaceService(JobTrackerContext db)
{
_db = db;
}
public async Task<WorkspaceOverviewDto?> GetOverviewAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
{
var job = await _db.JobApplications.AsNoTracking().Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
if (job is null) return null;
// CV: the most recently touched variant attached to this application (Phase 4 lens).
var variant = await _db.CvVariants.AsNoTracking()
.Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId)
.OrderByDescending(v => v.UpdatedAtUtc)
.FirstOrDefaultAsync(ct);
var cv = new WorkspaceCvDto(
variant?.Id,
variant?.Name,
variant is null ? null : CvVariantSettingsJson.Deserialize(variant.SettingsJson).ThemeId,
!string.IsNullOrWhiteSpace(job.TailoredCvText),
variant?.UpdatedAtUtc);
var documentCount = await _db.Attachments.AsNoTracking()
.CountAsync(a => a.JobApplicationId == jobApplicationId, ct);
var aiCount = await _db.AiInteractions.AsNoTracking()
.CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId, ct);
var lastAi = await _db.AiInteractions.AsNoTracking()
.Where(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId)
.OrderByDescending(a => a.CreatedAtUtc)
.Select(a => (DateTimeOffset?)a.CreatedAtUtc)
.FirstOrDefaultAsync(ct);
var activity = await _db.JobEvents.AsNoTracking()
.Where(e => e.JobApplicationId == jobApplicationId)
.OrderByDescending(e => e.At)
.Take(RecentActivityCount)
.Select(e => new WorkspaceActivityDto(e.Type, e.Note ?? e.NewValue, e.At))
.ToListAsync(ct);
var stage = JobPipeline.Stages.FirstOrDefault(s => string.Equals(s.Key, JobPipeline.Normalize(job.Status), StringComparison.OrdinalIgnoreCase));
var hasCoverLetter = job.HasCoverLetter || !string.IsNullOrWhiteSpace(job.CoverLetterText);
return new WorkspaceOverviewDto(
job.Id,
job.JobTitle,
job.Company?.Name,
job.Location,
job.Salary,
job.Status,
stage?.Group.ToString() ?? "Active",
JobPipeline.OrderOf(job.Status),
job.DateApplied,
job.Deadline,
job.FollowUpAt,
job.NextAction,
job.JobUrl,
!string.IsNullOrWhiteSpace(job.Description),
cv,
hasCoverLetter,
documentCount,
job.HasPortfolio,
aiCount,
lastAi,
activity,
NextStep(job, cv, hasCoverLetter, documentCount));
}
// "The user should never ask what to do next." First unmet rule in priority order wins. Ordered so
// the answer matches where the application actually is: understand the role, prepare the material,
// send it, then chase it.
private static WorkspaceNextStepDto? NextStep(JobApplication job, WorkspaceCvDto cv, bool hasCoverLetter, int documentCount)
{
if (string.IsNullOrWhiteSpace(job.Description))
return new("add-job-details", "Add the job advert", "Analysis and matching need the advert text.", "job-details");
if (JobPipeline.IsProspect(job.Status))
{
if (cv.VariantId is null && !cv.HasTailoredCvText)
return new("prepare-cv", "Prepare a CV for this role", "No CV variant is attached yet.", "cv");
if (!hasCoverLetter)
return new("write-cover-letter", "Write a cover letter", "A tailored letter measurably lifts response rates.", "cover-letter");
return new("submit-application", "Submit the application", "The material is ready — move it out of the prospect stage.", "overview");
}
if (cv.VariantId is null && !cv.HasTailoredCvText)
return new("prepare-cv", "Prepare a CV for this role", "No CV variant is attached to this application.", "cv");
if (!hasCoverLetter)
return new("write-cover-letter", "Write a cover letter", "No cover letter draft saved for this application.", "cover-letter");
if (documentCount == 0)
return new("attach-documents", "Attach supporting documents", "Certificates or references strengthen the application.", "documents");
if (IsInterviewStage(job.Status))
return new("prepare-interview", "Prepare for the interview", "This application has reached the interview stage.", "interview");
if (job.FollowUpAt is null && job.DateApplied is not null)
return new("schedule-follow-up", "Schedule a follow-up", "Applied with no follow-up date set.", "overview");
if (string.IsNullOrWhiteSpace(job.NextAction))
return new("set-next-action", "Write the next action", "Keeps the application moving deliberately.", "overview");
return null;
}
private static bool IsInterviewStage(string? status) =>
(status ?? string.Empty).Contains("interview", StringComparison.OrdinalIgnoreCase);
}