223 lines
9.8 KiB
C#
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);
|
|
}
|