Files
jobtrackingapp/JobTrackerApi/Services/InterviewPrepService.cs
T
2026-08-31 17:20:58 +02:00

223 lines
9.8 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",
[InterviewPrepCategories.Debrief] = "Interview debrief",
};
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);
}