107 lines
4.2 KiB
C#
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);
|
|
}
|