feat(timeline): emit application lifecycle events
The timeline could interpret InterviewScheduled, InterviewCompleted, OfferReceived and FollowUpCompleted, but only StatusChanged and FollowUpSet were ever written, so those branches never rendered. Events are now derived from the status TRANSITION in one shared emitter rather than at each call site, so the two status-change boundaries in JobApplicationsController cannot drift apart and a third would get the behaviour for free. Both boundaries now call it instead of hand-writing the StatusChanged block. Deriving from the transition rather than the resulting state is what prevents duplicates: one user action produces at most one lifecycle event, re-saving an unchanged status produces none, and reaching an offer twice records it once. Moving an application backwards is treated as a correction, not a completed interview, so only a forward move out of an interview stage counts. An Interview to Offer move reports the offer, which is the thing the user cares about. Completing a follow-up checklist item emits FollowUpCompleted, guarded on the same transition rule so re-saving a done item stays silent. The task itself remains a checklist item — this only records that it happened. No new history store: every event is a JobEvent row, which stays the single source of application history. 393 backend tests pass, including timeline rendering of the emitted events. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
// Lifecycle events are derived from the TRANSITION, so one user action produces at most one event and
|
||||
// an unchanged save produces none. JobEvent stays the only history store — these tests assert rows are
|
||||
// added there and nowhere else.
|
||||
public sealed class JobLifecycleEventTests
|
||||
{
|
||||
private static (JobTrackerContext db, ApplicationTimelineService timeline) New(string userId)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(s => s.UserId).Returns(userId);
|
||||
var db = new JobTrackerContext(options, currentUser.Object);
|
||||
return (db, new ApplicationTimelineService(db));
|
||||
}
|
||||
|
||||
private static async Task<JobApplication> SeedAsync(JobTrackerContext db, string owner, string status)
|
||||
{
|
||||
var company = new Company { OwnerUserId = owner, Name = "Acme" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
var job = new JobApplication { OwnerUserId = owner, CompanyId = company.Id, JobTitle = "Dev", Status = status };
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
return job;
|
||||
}
|
||||
|
||||
private static async Task<List<string>> MoveAsync(JobTrackerContext db, JobApplication job, string to)
|
||||
{
|
||||
var old = job.Status;
|
||||
job.Status = to;
|
||||
JobLifecycleEvents.RecordStatusChange(db, job, old, DateTime.Now);
|
||||
await db.SaveChangesAsync();
|
||||
return await db.JobEvents.AsNoTracking().Select(e => e.Type).ToListAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Entering_an_interview_stage_records_interview_scheduled()
|
||||
{
|
||||
var (db, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedAsync(db, "user-1", "Applied");
|
||||
|
||||
var types = await MoveAsync(db, job, "Interview");
|
||||
|
||||
Assert.Contains("StatusChanged", types);
|
||||
Assert.Contains("InterviewScheduled", types);
|
||||
Assert.Equal(2, types.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Moving_forward_out_of_an_interview_records_interview_completed()
|
||||
{
|
||||
var (db, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedAsync(db, "user-1", "Interview");
|
||||
|
||||
var types = await MoveAsync(db, job, "Offer");
|
||||
|
||||
// An offer outranks the completed interview: one lifecycle event per action, the one that matters.
|
||||
Assert.Contains("OfferReceived", types);
|
||||
Assert.DoesNotContain("InterviewCompleted", types);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Leaving_an_interview_for_a_later_non_offer_stage_records_interview_completed()
|
||||
{
|
||||
var (db, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedAsync(db, "user-1", "Interview");
|
||||
|
||||
var types = await MoveAsync(db, job, "Rejected");
|
||||
|
||||
Assert.Contains("InterviewCompleted", types);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dragging_an_application_backwards_is_a_correction_not_a_completed_interview()
|
||||
{
|
||||
var (db, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedAsync(db, "user-1", "Interview");
|
||||
|
||||
var types = await MoveAsync(db, job, "Applied");
|
||||
|
||||
Assert.Contains("StatusChanged", types);
|
||||
Assert.DoesNotContain("InterviewCompleted", types);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reaching_an_offer_records_offer_received_once()
|
||||
{
|
||||
var (db, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedAsync(db, "user-1", "Applied");
|
||||
|
||||
await MoveAsync(db, job, "Offer");
|
||||
// Saving the same status again is not a new action, so nothing more is recorded.
|
||||
var types = await MoveAsync(db, job, "Offer");
|
||||
|
||||
Assert.Single(types, t => t == "OfferReceived");
|
||||
Assert.Single(types, t => t == "StatusChanged");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unchanged_status_records_nothing_at_all()
|
||||
{
|
||||
var (db, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedAsync(db, "user-1", "Applied");
|
||||
|
||||
var types = await MoveAsync(db, job, "Applied");
|
||||
|
||||
Assert.Empty(types);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completing_a_follow_up_checklist_item_records_it_once()
|
||||
{
|
||||
var (db, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedAsync(db, "user-1", "Applied");
|
||||
var svc = new ApplicationChecklistService(db);
|
||||
var checklist = await svc.GetAsync("user-1", job.Id, default);
|
||||
var followUp = checklist!.Items.First(i =>
|
||||
i.Category == ChecklistCategories.FollowUp && i.Status == ChecklistStatuses.Pending);
|
||||
|
||||
await svc.UpdateAsync("user-1", job.Id, followUp.Id,
|
||||
new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default);
|
||||
// Re-saving an already-done item must not emit a second event.
|
||||
await svc.UpdateAsync("user-1", job.Id, followUp.Id,
|
||||
new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default);
|
||||
|
||||
Assert.Equal(1, await db.JobEvents.CountAsync(e => e.Type == "FollowUpCompleted"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completing_a_non_follow_up_item_records_nothing()
|
||||
{
|
||||
var (db, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedAsync(db, "user-1", "Applied");
|
||||
var svc = new ApplicationChecklistService(db);
|
||||
var checklist = await svc.GetAsync("user-1", job.Id, default);
|
||||
var preparation = checklist!.Items.First(i =>
|
||||
i.Category == ChecklistCategories.Preparation && i.Status == ChecklistStatuses.Pending);
|
||||
|
||||
await svc.UpdateAsync("user-1", job.Id, preparation.Id,
|
||||
new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default);
|
||||
|
||||
Assert.Equal(0, await db.JobEvents.CountAsync(e => e.Type == "FollowUpCompleted"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_timeline_renders_the_emitted_events()
|
||||
{
|
||||
var (db, timeline) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedAsync(db, "user-1", "Applied");
|
||||
|
||||
await MoveAsync(db, job, "Interview");
|
||||
await MoveAsync(db, job, "Offer");
|
||||
|
||||
var result = await timeline.GetAsync("user-1", job.Id, null, false, default);
|
||||
var summaries = result!.Days.SelectMany(d => d.Events).Select(e => e.Summary).ToList();
|
||||
|
||||
Assert.Contains("Interview scheduled — Interview", summaries);
|
||||
Assert.Contains("Offer received", summaries);
|
||||
// Interviews and offers belong on the milestone spine.
|
||||
Assert.Contains(result.Milestones, m => m.Type == "InterviewScheduled");
|
||||
Assert.Contains(result.Milestones, m => m.Type == "OfferReceived");
|
||||
}
|
||||
}
|
||||
@@ -851,17 +851,8 @@ Canonical profile:
|
||||
At = DateTime.Now
|
||||
});
|
||||
}
|
||||
if (!string.Equals(oldStatus, job.Status, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_db.JobEvents.Add(new JobEvent
|
||||
{
|
||||
JobApplicationId = job.Id,
|
||||
Type = "StatusChanged",
|
||||
OldValue = oldStatus,
|
||||
NewValue = job.Status,
|
||||
At = request.StatusChangedAt ?? DateTime.Now
|
||||
});
|
||||
}
|
||||
// Records StatusChanged plus any lifecycle event the transition implies.
|
||||
JobLifecycleEvents.RecordStatusChange(_db, job, oldStatus, request.StatusChangedAt ?? DateTime.Now);
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
@@ -884,17 +875,7 @@ Canonical profile:
|
||||
// Stamps DateApplied when the job leaves the pre-application stages (e.g. the user
|
||||
// drags Preparing -> Applied), and clears it if they move back.
|
||||
SyncAppliedDateWithHistory(job);
|
||||
if (!string.Equals(old, job.Status, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_db.JobEvents.Add(new JobEvent
|
||||
{
|
||||
JobApplicationId = job.Id,
|
||||
Type = "StatusChanged",
|
||||
OldValue = old,
|
||||
NewValue = job.Status,
|
||||
At = DateTime.Now
|
||||
});
|
||||
}
|
||||
JobLifecycleEvents.RecordStatusChange(_db, job, old, DateTime.Now);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return NoContent();
|
||||
|
||||
@@ -193,9 +193,18 @@ public sealed class ApplicationChecklistService : IApplicationChecklistService
|
||||
if (ChecklistCategories.IsValid(input.Category)) item.Category = input.Category!;
|
||||
}
|
||||
|
||||
var wasDone = item.Status == ChecklistStatuses.Done;
|
||||
|
||||
if (ChecklistStatuses.IsValid(input.Status)) Stamp(item, input.Status!);
|
||||
else Stamp(item);
|
||||
|
||||
// Ticking off a follow-up task is a real lifecycle moment, so the timeline records it. Guarded
|
||||
// on the transition, so re-saving an already-done item does not emit a second event.
|
||||
if (!wasDone && item.Status == ChecklistStatuses.Done && item.Category == ChecklistCategories.FollowUp)
|
||||
{
|
||||
JobLifecycleEvents.RecordFollowUpCompleted(_db, jobApplicationId, item.Title);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Project(item);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user