diff --git a/src/JobsMedical.Web/Pages/Index.cshtml.cs b/src/JobsMedical.Web/Pages/Index.cshtml.cs index 1f551b1..d1f00b0 100644 --- a/src/JobsMedical.Web/Pages/Index.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Index.cshtml.cs @@ -50,7 +50,8 @@ public class IndexModel : PageModel .Include(j => j.Facility).ThenInclude(f => f.City) .Include(j => j.Facility).ThenInclude(f => f.District) .Include(j => j.Role) - .Where(j => j.Status == ShiftStatus.Open) + .Where(j => j.Status == ShiftStatus.Open + && j.CreatedAt >= JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc) .OrderByDescending(j => j.CreatedAt) .Take(3) .ToListAsync(); diff --git a/src/JobsMedical.Web/Pages/Jobs/Index.cshtml.cs b/src/JobsMedical.Web/Pages/Jobs/Index.cshtml.cs index 2c63e85..5b339b9 100644 --- a/src/JobsMedical.Web/Pages/Jobs/Index.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Jobs/Index.cshtml.cs @@ -39,7 +39,8 @@ public class IndexModel : PageModel .Include(j => j.Facility).ThenInclude(f => f.City) .Include(j => j.Facility).ThenInclude(f => f.District) .Include(j => j.Role) - .Where(j => j.Status == ShiftStatus.Open); + .Where(j => j.Status == ShiftStatus.Open + && j.CreatedAt >= JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc); if (CityId is not null) q = q.Where(j => j.Facility.CityId == CityId); if (DistrictId is not null) q = q.Where(j => j.Facility.DistrictId == DistrictId); diff --git a/src/JobsMedical.Web/Program.cs b/src/JobsMedical.Web/Program.cs index 00147cc..be5d068 100644 --- a/src/JobsMedical.Web/Program.cs +++ b/src/JobsMedical.Web/Program.cs @@ -44,6 +44,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHostedService(); @@ -74,6 +75,10 @@ using (var scope = app.Services.CreateScope()) db.Database.Migrate(); // Production seeds reference data only (no demo facilities/shifts); dev seeds the full board. await SeedData.EnsureSeededAsync(db, app.Environment.IsDevelopment()); + // Archive any listings that went stale while the app was down. + await scope.ServiceProvider + .GetRequiredService() + .ArchiveStaleAsync(); } // Configure the HTTP request pipeline. diff --git a/src/JobsMedical.Web/Services/Scraping/IngestionWorker.cs b/src/JobsMedical.Web/Services/Scraping/IngestionWorker.cs index ef3b920..66fd490 100644 --- a/src/JobsMedical.Web/Services/Scraping/IngestionWorker.cs +++ b/src/JobsMedical.Web/Services/Scraping/IngestionWorker.cs @@ -28,6 +28,10 @@ public class IngestionWorker : BackgroundService try { using var scope = _scopes.CreateScope(); + + // Always archive stale listings (independent of ingestion being on). + await scope.ServiceProvider.GetRequiredService().ArchiveStaleAsync(stoppingToken); + var settings = await scope.ServiceProvider .GetRequiredService().GetAsync(); diff --git a/src/JobsMedical.Web/Services/Scraping/ListingArchiver.cs b/src/JobsMedical.Web/Services/Scraping/ListingArchiver.cs new file mode 100644 index 0000000..a20d6bb --- /dev/null +++ b/src/JobsMedical.Web/Services/Scraping/ListingArchiver.cs @@ -0,0 +1,49 @@ +using JobsMedical.Web.Data; +using JobsMedical.Web.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobsMedical.Web.Services.Scraping; + +/// +/// Centralizes "what counts as a live listing". Jobs have no end date, so they go stale after a +/// window; shifts expire once their date has passed. Public screens hide anything outside these +/// rules, and flips stale Open rows to Expired so dashboards/DB agree. +/// +public static class ListingPolicy +{ + /// A job opening older than this (since posting) is treated as stale and hidden. + public const int JobFreshnessDays = 30; + + public static DateTime JobCutoffUtc => DateTime.UtcNow.AddDays(-JobFreshnessDays); +} + +/// Sweeps stale listings into the Expired state (archive). Idempotent and cheap. +public class ListingArchiver +{ + private readonly AppDbContext _db; + private readonly ILogger _log; + + public ListingArchiver(AppDbContext db, ILogger log) + { + _db = db; + _log = log; + } + + public async Task ArchiveStaleAsync(CancellationToken ct = default) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var jobCutoff = ListingPolicy.JobCutoffUtc; + + var expiredShifts = await _db.Shifts + .Where(s => s.Status == ShiftStatus.Open && s.Date < today) + .ExecuteUpdateAsync(u => u.SetProperty(s => s.Status, ShiftStatus.Expired), ct); + + var expiredJobs = await _db.JobOpenings + .Where(j => j.Status == ShiftStatus.Open && j.CreatedAt < jobCutoff) + .ExecuteUpdateAsync(u => u.SetProperty(j => j.Status, ShiftStatus.Expired), ct); + + if (expiredShifts + expiredJobs > 0) + _log.LogInformation("Archived {Shifts} shifts + {Jobs} jobs as expired", expiredShifts, expiredJobs); + return expiredShifts + expiredJobs; + } +}