166 lines
7.0 KiB
C#
166 lines
7.0 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? ApplicationAnswerDraft,
|
|
string? RecruiterMessageDraft,
|
|
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(),
|
|
JobApplicationHelpers.RemoveSavedApplicationAnswerDraft(job.Notes),
|
|
JobApplicationHelpers.ExtractSavedApplicationAnswerDraft(job.Notes),
|
|
job.RecruiterMessageDraft,
|
|
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");
|
|
}
|
|
}
|