feat(workspace): Application Workspace foundation (Phase 5 milestone 1)
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user