using JobTrackerApi.Data; using JobTrackerApi.Models; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Services; // Phase 5.5 — Interview preparation and follow-up. // // The prep content is the USER's. AI suggestions arrive through the existing AiWorkspaceService // "interview" module and only become prep items when the user accepts them, which is what approval // means here. Nothing in this service writes to the CareerProfile, a CvVariant, or the application's // own fields — except FollowUpAt, which is the one thing a follow-up genuinely is. // // Follow-ups reuse what already exists: JobApplication.FollowUpAt for the date (RulesEngine and the // reminder hosted service already act on it) and ApplicationChecklistItem for the task. No second // reminder system. docs/architecture/application-workspace.md. public sealed record InterviewPrepItemDto( int Id, string Category, string Title, string? Content, string Source, bool IsPrepared, int SortOrder, DateTimeOffset UpdatedAtUtc); public sealed record InterviewPrepGroupDto(string Category, string Label, IReadOnlyList Items); public sealed record InterviewPrepBoardDto( IReadOnlyList Groups, int Total, int Prepared, int Percent, bool IsInterviewStage, int AiSuggestionCount); public sealed record InterviewPrepInput(string? Category, string? Title, string? Content, string? Source, bool? IsPrepared); public sealed record FollowUpDto(DateTime? FollowUpAt, string? NextAction, bool ResponseReceived, int OpenFollowUpTasks); public interface IInterviewPrepService { Task GetAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); Task AddAsync(string ownerUserId, int jobApplicationId, InterviewPrepInput input, CancellationToken ct); Task UpdateAsync(string ownerUserId, int jobApplicationId, int itemId, InterviewPrepInput input, CancellationToken ct); Task DeleteAsync(string ownerUserId, int jobApplicationId, int itemId, CancellationToken ct); Task GetFollowUpAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); Task SetFollowUpAsync(string ownerUserId, int jobApplicationId, DateTime? followUpAt, string? nextAction, CancellationToken ct); } public sealed class InterviewPrepService : IInterviewPrepService { private static readonly Dictionary Labels = new(StringComparer.Ordinal) { [InterviewPrepCategories.CompanyResearch] = "Company research", [InterviewPrepCategories.Technical] = "Technical preparation", [InterviewPrepCategories.Behavioural] = "Behavioural questions", [InterviewPrepCategories.Star] = "STAR examples", [InterviewPrepCategories.Question] = "Questions to ask them", [InterviewPrepCategories.Note] = "Notes", }; private readonly JobTrackerContext _db; public InterviewPrepService(JobTrackerContext db) { _db = db; } public async Task GetAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) { var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct); if (job is null) return null; var items = await _db.InterviewPrepItems.AsNoTracking() .Where(i => i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId) .ToListAsync(ct); var aiCount = await _db.AiInteractions.AsNoTracking() .CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId && a.Module == "interview", ct); var groups = items .GroupBy(i => i.Category) .OrderBy(g => InterviewPrepCategories.Rank(g.Key)) .Select(g => new InterviewPrepGroupDto( g.Key, Labels.TryGetValue(g.Key, out var label) ? label : g.Key, g.OrderBy(i => i.SortOrder).ThenBy(i => i.Id).Select(Project).ToList())) .ToList(); var prepared = items.Count(i => i.IsPrepared); return new InterviewPrepBoardDto( groups, items.Count, prepared, items.Count == 0 ? 0 : (int)Math.Round(prepared * 100.0 / items.Count), IsInterviewStage(job.Status), aiCount); } public async Task AddAsync(string ownerUserId, int jobApplicationId, InterviewPrepInput input, CancellationToken ct) { var title = (input.Title ?? string.Empty).Trim(); if (title.Length == 0) return null; var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct); if (job is null) return null; var maxSort = await _db.InterviewPrepItems .Where(i => i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId) .Select(i => (int?)i.SortOrder) .MaxAsync(ct) ?? 0; var item = new InterviewPrepItem { OwnerUserId = ownerUserId, JobApplicationId = jobApplicationId, Category = InterviewPrepCategories.IsValid(input.Category) ? input.Category! : InterviewPrepCategories.Note, Title = title, Content = Blank(input.Content), // An accepted AI suggestion is recorded as such, but is fully the user's to edit after. Source = InterviewPrepSources.IsValid(input.Source) ? input.Source! : InterviewPrepSources.User, IsPrepared = input.IsPrepared ?? false, SortOrder = maxSort + 1, }; _db.InterviewPrepItems.Add(item); await _db.SaveChangesAsync(ct); return Project(item); } public async Task UpdateAsync(string ownerUserId, int jobApplicationId, int itemId, InterviewPrepInput input, CancellationToken ct) { var item = await _db.InterviewPrepItems .FirstOrDefaultAsync(i => i.Id == itemId && i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId, ct); if (item is null) return null; if (!string.IsNullOrWhiteSpace(input.Title)) item.Title = input.Title!.Trim(); if (input.Content is not null) item.Content = Blank(input.Content); if (InterviewPrepCategories.IsValid(input.Category)) item.Category = input.Category!; if (input.IsPrepared is not null) item.IsPrepared = input.IsPrepared.Value; item.UpdatedAtUtc = DateTimeOffset.UtcNow; await _db.SaveChangesAsync(ct); return Project(item); } public async Task DeleteAsync(string ownerUserId, int jobApplicationId, int itemId, CancellationToken ct) { var item = await _db.InterviewPrepItems .FirstOrDefaultAsync(i => i.Id == itemId && i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId, ct); if (item is null) return false; _db.InterviewPrepItems.Remove(item); await _db.SaveChangesAsync(ct); return true; } // ---------- follow-up ---------- public async Task GetFollowUpAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) { var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct); if (job is null) return null; return await BuildFollowUpAsync(ownerUserId, job, ct); } // Sets the date the existing reminder machinery already reads, and records a JobEvent so the // timeline shows it. The follow-up TASK itself stays a checklist item — this does not invent a // second to-do list. public async Task SetFollowUpAsync(string ownerUserId, int jobApplicationId, DateTime? followUpAt, string? nextAction, CancellationToken ct) { var job = await _db.JobApplications .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); if (job is null) return null; var previous = job.FollowUpAt; job.FollowUpAt = followUpAt; if (nextAction is not null) job.NextAction = Blank(nextAction); // Same event type the rest of the app already emits, so the timeline reads it unchanged. _db.JobEvents.Add(new JobEvent { JobApplicationId = jobApplicationId, Type = "FollowUpSet", OldValue = previous?.ToString("yyyy-MM-dd"), NewValue = followUpAt?.ToString("yyyy-MM-dd"), At = DateTime.Now, }); await _db.SaveChangesAsync(ct); return await BuildFollowUpAsync(ownerUserId, job, ct); } private async Task BuildFollowUpAsync(string ownerUserId, JobApplication job, CancellationToken ct) { // The follow-up tasks are checklist items — counted here, owned there. var openTasks = await _db.ApplicationChecklistItems.AsNoTracking() .CountAsync(i => i.OwnerUserId == ownerUserId && i.JobApplicationId == job.Id && i.Category == ChecklistCategories.FollowUp && i.Status == ChecklistStatuses.Pending, ct); return new FollowUpDto(job.FollowUpAt, job.NextAction, job.ResponseReceived, openTasks); } private Task LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) => _db.JobApplications.AsNoTracking() .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); private static bool IsInterviewStage(string? status) => (status ?? string.Empty).Contains("interview", StringComparison.OrdinalIgnoreCase); private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static InterviewPrepItemDto Project(InterviewPrepItem i) => new(i.Id, i.Category, i.Title, i.Content, i.Source, i.IsPrepared, i.SortOrder, i.UpdatedAtUtc); }