Files
jobtrackingapp/JobTrackerApi/Services/ApplicationWorkspaceService.cs
T
cesnimda 109745edb0
CI and Deploy / test (pull_request) Failing after 2m51s
CI and Deploy / deploy (pull_request) Has been skipped
feat(jobs): add dedicated workspace page
Make /jobs/:id the canonical application workspace while preserving list state and compatibility links. Replace popup and expandable-row navigation with accessible whole-row routing and richer job details.
2026-08-15 13:33:00 +02:00

162 lines
6.8 KiB
C#

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,
DateTime SavedAt,
string? Description,
string? TranslatedDescription,
string? DescriptionLanguage,
IReadOnlyList<string> Tags,
string? Notes,
string? Source,
string? CountryCode,
bool HasJobDescription,
WorkspaceCvDto Cv,
bool HasCoverLetter,
int DocumentCount,
bool HasPortfolio,
int AiInteractionCount,
DateTimeOffset? LastAiAtUtc,
IReadOnlyList<WorkspaceActivityDto> RecentActivity,
WorkspaceNextStepDto? NextStep,
ChecklistProgressDto? ChecklistProgress);
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;
private readonly IApplicationChecklistService _checklist;
public ApplicationWorkspaceService(JobTrackerContext db, IApplicationChecklistService checklist)
{
_db = db;
_checklist = checklist;
}
public async Task<WorkspaceOverviewDto?> GetOverviewAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
{
var job = await _db.JobApplications.AsNoTracking()
.Include(j => j.Company)
.Include(j => j.Job)
.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 variantQuery = _db.CvVariants.AsNoTracking()
.Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId);
var variant = _db.Database.IsSqlite()
? (await variantQuery.ToListAsync(ct)).MaxBy(v => v.UpdatedAtUtc)
: await variantQuery.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 aiDates = _db.AiInteractions.AsNoTracking()
.Where(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId)
.Select(a => a.CreatedAtUtc);
var lastAi = _db.Database.IsSqlite()
? (await aiDates.ToListAsync(ct)).Select(x => (DateTimeOffset?)x).Max()
: await aiDates.OrderByDescending(x => x).Select(x => (DateTimeOffset?)x).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);
// The checklist is the single workflow surface, so the overview's "next step" and progress both
// come from it rather than a parallel ruleset. Seeds itself on first read.
var checklist = await _checklist.GetAsync(ownerUserId, jobApplicationId, 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,
job.SavedAt,
job.Description,
job.TranslatedDescription,
job.DescriptionLanguage,
JobApplicationHelpers.SplitTags(job.Tags).Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
job.Notes,
job.Job?.Source,
job.Job?.CountryCode,
!string.IsNullOrWhiteSpace(job.Description) || !string.IsNullOrWhiteSpace(job.TranslatedDescription),
cv,
hasCoverLetter,
documentCount,
job.HasPortfolio,
aiCount,
lastAi,
activity,
NextStep(checklist),
checklist?.Progress);
}
// "The user should never ask what to do next." Milestone 2 moved this onto the checklist: the first
// pending item, in category priority order (preparation, submission, follow-up, interview, custom)
// then the user's own ordering. One workflow surface — the overview cannot recommend something the
// checklist has already been ticked off, and a user-added task can be the next action.
private static WorkspaceNextStepDto? NextStep(ChecklistDto? checklist)
{
if (checklist is null) return null;
var next = ApplicationChecklistService.NextPending(checklist);
return next is null
? null
: new WorkspaceNextStepDto(next.SystemKey ?? $"custom-{next.Id}", next.Title, next.Description ?? string.Empty, next.Section ?? "checklist");
}
}