Files
jobtrackingapp/JobTrackerApi/Services/JobLifecycleEvents.cs
T
2026-08-31 11:59:49 +02:00

107 lines
4.2 KiB
C#

using JobTrackerApi.Data;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
// Phase 5.6 — emitting the lifecycle events the timeline already knows how to read.
//
// JobEvent stays the single history source: this adds rows to it, it does not add a second store.
// Every emitter lives here rather than at each call site, so the two status-change boundaries in
// JobApplicationsController cannot drift apart, and a third boundary gets the behaviour for free.
//
// Events are derived from the TRANSITION, not from the resulting state, so one user action produces
// at most one lifecycle event and re-saving an unchanged status produces none.
// docs/architecture/application-workspace.md.
public static class JobLifecycleEvents
{
/// <summary>
/// Keeps the application date aligned with the pipeline stage and preserves a cleared date in
/// the event ledger. Both full-record and status-only updates use this single boundary.
/// </summary>
public static void SyncAppliedDateWithHistory(JobTrackerContext db, JobApplication job, DateTime at)
{
var cleared = JobPipeline.SyncAppliedDate(job, at);
if (cleared is null) return;
db.JobEvents.Add(new JobEvent
{
JobApplicationId = job.Id,
Type = JobPipeline.AppliedDateClearedEvent,
OldValue = cleared.Value.ToString("o"),
NewValue = null,
Note = $"Moved to {job.Status} before applying; application date cleared.",
At = at,
});
}
// Records the status change itself plus, when the transition warrants it, one lifecycle event.
// Replaces the hand-written StatusChanged block at each call site.
public static void RecordStatusChange(JobTrackerContext db, JobApplication job, string? oldStatus, DateTime at)
{
var from = oldStatus ?? string.Empty;
var to = job.Status ?? string.Empty;
if (string.Equals(from, to, StringComparison.OrdinalIgnoreCase)) return;
db.JobEvents.Add(new JobEvent
{
JobApplicationId = job.Id,
Type = "StatusChanged",
OldValue = oldStatus,
NewValue = job.Status,
At = at,
});
var lifecycle = LifecycleFor(from, to);
if (lifecycle is null) return;
db.JobEvents.Add(new JobEvent
{
JobApplicationId = job.Id,
Type = lifecycle,
// Useful metadata: which transition produced it, so the timeline can say more than the type.
OldValue = oldStatus,
NewValue = job.Status,
At = at,
});
}
// At most one lifecycle event per transition. Ordered so an Interview -> Offer move reports the
// offer, which is the thing the user actually cares about.
private static string? LifecycleFor(string from, string to)
{
var wasInterview = IsInterviewStage(from);
var isInterview = IsInterviewStage(to);
if (IsOfferStage(to) && !IsOfferStage(from)) return "OfferReceived";
if (isInterview && !wasInterview) return "InterviewScheduled";
// Only counts as completed when the application moved FORWARD out of the interview stage.
// Dragging a card back to an earlier stage is a correction, not a completed interview.
if (wasInterview && !isInterview && JobPipeline.OrderOf(to) > JobPipeline.OrderOf(from))
{
return "InterviewCompleted";
}
return null;
}
// Emitted when a follow-up checklist item is ticked off. The task itself stays a checklist item —
// this only records that it happened.
public static void RecordFollowUpCompleted(JobTrackerContext db, int jobApplicationId, string title)
{
db.JobEvents.Add(new JobEvent
{
JobApplicationId = jobApplicationId,
Type = "FollowUpCompleted",
NewValue = title,
At = DateTime.Now,
});
}
private static bool IsInterviewStage(string? status) =>
(status ?? string.Empty).Contains("interview", StringComparison.OrdinalIgnoreCase);
private static bool IsOfferStage(string? status) =>
(status ?? string.Empty).Contains("offer", StringComparison.OrdinalIgnoreCase);
}