Files
jobtrackingapp/JobTrackerApi/Services/RulesEngine.cs
T
cesnimda eac34705e3 feat: Phase 0 foundation — Job entity, expanded pipeline, AI service lockdown, DateApplied history
Unblocks the documented core workflow and closes the AI-service exposure,
without changing existing behaviour.

Job/JobApplication split (additive; see ADR-002):
- New Job entity (the opportunity) with owner-scoped query filter; nullable
  JobApplication.JobId FK. Nothing reads Job yet.
- Migration AddJobEntityAndProspectStages, hand-edited to drop reconciler-owned
  tables the scaffolder re-emitted; verified against the real dev DB.

Pipeline: 10 internal stages across three concerns kept separate —
PipelineStage (workflow) / PipelineGroup (UI: NotApplied/Active/Closed) /
PipelineCategory (analytics). Adds Saved/Interested/Preparing/Withdrawn;
keeps Waiting and Ghosted. Kanban shows 3 grouped columns; cards keep a stage
chip and full transitions; drag applies only safe transitions (never infers
Ghosted/Withdrawn).

DateApplied nullable + SavedAt. Cleared when leaving Applied so analytics stay
accurate; the discarded date is preserved as an AppliedDateCleared JobEvent.

AI service lockdown: no host port; private ai_internal network (backend is the
only other member); X-Ai-Service-Token required on all non-/health endpoints;
AI_SERVICE_TOKEN mandatory via compose. Verified backend-only against the live
stack.

Also carries two pre-existing working-tree files (views/ProfilePage.tsx,
views/CareerWorkspacePage.tsx) so the tree is clean for the branch integration.

Tests: +40 backend (247 total), +5 sidecar (16), +15 frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:05:25 +02:00

120 lines
5.3 KiB
C#

using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services
{
public sealed record FollowUpDecision(bool NeedsFollowUp, string? Reason, bool ShouldGhost);
public static class RulesEngine
{
public static async Task<RuleSettings> GetSettings(JobTrackerContext db, CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(db.CurrentUserId))
{
var u = await db.UserRuleSettings
.AsNoTracking()
.FirstOrDefaultAsync(x => x.OwnerUserId == db.CurrentUserId, cancellationToken);
if (u is not null)
{
return new RuleSettings
{
Id = 1,
AppliedFollowUpDays = u.AppliedFollowUpDays,
AppliedGhostDays = u.AppliedGhostDays,
OfferFollowUpDays = u.OfferFollowUpDays,
OfferGhostDays = u.OfferGhostDays,
FeedbackFollowUpDays = u.FeedbackFollowUpDays,
FeedbackGhostDays = u.FeedbackGhostDays,
};
}
}
var s = await db.RuleSettings.AsNoTracking().FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
return s ?? new RuleSettings { Id = 1 };
}
public static FollowUpDecision Evaluate(
RuleSettings s,
JobApplication job,
DateTime now,
DateTime? lastMessageAt
)
{
if (job.IsDeleted) return new FollowUpDecision(false, null, false);
var status = job.Status ?? "Applied";
if (status == "Interviewing") status = "Interview";
// Nothing has been submitted in a pre-application stage, so there is nobody to chase
// and nobody to be ghosted by. Guard before any date maths: a Saved job has no
// DateApplied, and treating that as "very old" would silently auto-ghost it.
if (JobPipeline.IsProspect(status)) return new FollowUpDecision(false, null, false);
// Last activity: any explicit follow-up date, response date, feedback request, or correspondence message.
var last = Max(
job.DateApplied ?? job.SavedAt,
job.ResponseDate,
job.FollowUpAt,
job.FeedbackRequestedAt,
lastMessageAt
);
var daysSinceLast = (now - last).TotalDays;
// Applied: if no response and enough time passed since applied.
if (string.Equals(status, "Applied", StringComparison.OrdinalIgnoreCase) && !job.ResponseReceived)
{
// An Applied job should always have DateApplied. Fail safe rather than fall back to
// a synthetic date, which could ghost the job on the next rules pass.
if (job.DateApplied is null) return new FollowUpDecision(false, null, false);
var daysSinceApplied = (now - job.DateApplied.Value).TotalDays;
if (daysSinceApplied >= s.AppliedFollowUpDays)
return new FollowUpDecision(true, $"No reply after {s.AppliedFollowUpDays}d", daysSinceApplied >= s.AppliedGhostDays);
return new FollowUpDecision(false, null, daysSinceApplied >= s.AppliedGhostDays);
}
// Offer/accepted waiting on next step
if (string.Equals(status, "Offer", StringComparison.OrdinalIgnoreCase))
{
if (daysSinceLast >= s.OfferFollowUpDays)
return new FollowUpDecision(true, $"Stalled after {s.OfferFollowUpDays}d", daysSinceLast >= s.OfferGhostDays);
return new FollowUpDecision(false, null, daysSinceLast >= s.OfferGhostDays);
}
// Rejected but feedback requested
if (string.Equals(status, "Rejected", StringComparison.OrdinalIgnoreCase) && job.FeedbackRequestedAt is not null)
{
var daysSinceReq = (now - job.FeedbackRequestedAt.Value).TotalDays;
if (daysSinceReq >= s.FeedbackFollowUpDays)
return new FollowUpDecision(true, $"Feedback requested {s.FeedbackFollowUpDays}d ago", daysSinceReq >= s.FeedbackGhostDays);
return new FollowUpDecision(false, null, daysSinceReq >= s.FeedbackGhostDays);
}
// Waiting status: treat as follow-up based on applied follow-up days.
if (string.Equals(status, "Waiting", StringComparison.OrdinalIgnoreCase))
{
if (daysSinceLast >= s.AppliedFollowUpDays)
return new FollowUpDecision(true, $"Waiting {s.AppliedFollowUpDays}d", daysSinceLast >= s.AppliedGhostDays);
return new FollowUpDecision(false, null, daysSinceLast >= s.AppliedGhostDays);
}
// Default: no follow-up rule. Do not auto-ghost other statuses.
return new FollowUpDecision(false, null, false);
}
public static DateTime Max(DateTime a, params DateTime?[] rest)
{
var m = a;
foreach (var r in rest)
{
if (r is not null && r.Value > m) m = r.Value;
}
return m;
}
}
}