Files
jobtrackingapp/JobTrackerApi/Services/InterviewPrepService.cs
T
cesnimda 3d74baef78
CI and Deploy / test (push) Failing after 3m54s
CI and Deploy / deploy (push) Has been skipped
feat(workspace): interview and follow-up workflow
Phase 5.5. Completes the lifecycle after submission: prepare, communicate, chase.

Interview preparation gets a durable, user-owned store. There were already two
per-application AI stores, InterviewPrepNote and AiWorkspaceNote, but both are
caches that regenerate when their context signature changes — anything a user
typed into them would eventually be overwritten. InterviewPrepItem is the side
nothing regenerates, covering company research, technical notes, behavioural
answers, STAR examples and the user's own questions in one table, because those
categories differ only by label and adding one must not need a migration. Each
item records whether the user wrote it or accepted a suggestion, and an
IsPrepared flag makes the section double as the preparation checklist.

Generation stays in the existing AiWorkspaceService "interview" module, appended
to AiInteraction as before. A suggestion is history until the user adds it as a
prep item; opening the section generates nothing.

Follow-up reuses what exists rather than adding a tracker. The date is
JobApplication.FollowUpAt, the same field RulesEngine and the reminder hosted
service already act on, so reminders keep working with no new wiring. The task
stays an ApplicationChecklistItem in the follow-up category — the section counts
open tasks without owning them. The record is a FollowUpSet JobEvent, the same
type the rest of the app emits.

Communication is untouched: Correspondence already owns recruiter contacts,
history and notes, and the workspace already mounted it.

The timeline interpreter learned five more types — InterviewScheduled,
InterviewCompleted and OfferReceived as milestones, FollowUpCreated and
FollowUpCompleted as routine, deliberately outside the milestone spine so it
stays a summary of what actually happened. JobEvent remains the history source.

InterviewPrepItems is reconciler-owned with a no-op migration, guarded on
JobApplications, and verified on a fresh MariaDB 11: int AUTO_INCREMENT primary
key, varchar owner and title, tinyint flag, datetime(6), composite index inside
the key limit.

371 backend tests, 128 frontend tests, Release build and the production build all
pass locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:01:33 +02:00

222 lines
9.7 KiB
C#

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<InterviewPrepItemDto> Items);
public sealed record InterviewPrepBoardDto(
IReadOnlyList<InterviewPrepGroupDto> 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<InterviewPrepBoardDto?> GetAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
Task<InterviewPrepItemDto?> AddAsync(string ownerUserId, int jobApplicationId, InterviewPrepInput input, CancellationToken ct);
Task<InterviewPrepItemDto?> UpdateAsync(string ownerUserId, int jobApplicationId, int itemId, InterviewPrepInput input, CancellationToken ct);
Task<bool> DeleteAsync(string ownerUserId, int jobApplicationId, int itemId, CancellationToken ct);
Task<FollowUpDto?> GetFollowUpAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
Task<FollowUpDto?> SetFollowUpAsync(string ownerUserId, int jobApplicationId, DateTime? followUpAt, string? nextAction, CancellationToken ct);
}
public sealed class InterviewPrepService : IInterviewPrepService
{
private static readonly Dictionary<string, string> 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<InterviewPrepBoardDto?> 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<InterviewPrepItemDto?> 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<InterviewPrepItemDto?> 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<bool> 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<FollowUpDto?> 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<FollowUpDto?> 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<FollowUpDto> 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<JobApplication?> 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);
}