Hide + archive stale listings (old jobs, expired shifts)
- ListingPolicy.JobFreshnessDays=30: public /Jobs and home hide jobs older than the cutoff (shifts already require Date>=today) - ListingArchiver flips stale Open→Expired: shifts past their date, jobs older than the cutoff. Runs at startup and on every IngestionWorker cycle (independent of ingestion being enabled) - Verified: backdated job dropped off /Jobs (6→5) and was archived to Expired on the sweep Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -50,7 +50,8 @@ public class IndexModel : PageModel
|
|||||||
.Include(j => j.Facility).ThenInclude(f => f.City)
|
.Include(j => j.Facility).ThenInclude(f => f.City)
|
||||||
.Include(j => j.Facility).ThenInclude(f => f.District)
|
.Include(j => j.Facility).ThenInclude(f => f.District)
|
||||||
.Include(j => j.Role)
|
.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)
|
.OrderByDescending(j => j.CreatedAt)
|
||||||
.Take(3)
|
.Take(3)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ public class IndexModel : PageModel
|
|||||||
.Include(j => j.Facility).ThenInclude(f => f.City)
|
.Include(j => j.Facility).ThenInclude(f => f.City)
|
||||||
.Include(j => j.Facility).ThenInclude(f => f.District)
|
.Include(j => j.Facility).ThenInclude(f => f.District)
|
||||||
.Include(j => j.Role)
|
.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 (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);
|
if (DistrictId is not null) q = q.Where(j => j.Facility.DistrictId == DistrictId);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
|
|||||||
JobsMedical.Web.Services.Scraping.DivarListingSource>();
|
JobsMedical.Web.Services.Scraping.DivarListingSource>();
|
||||||
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
|
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
|
||||||
JobsMedical.Web.Services.Scraping.MedjobsListingSource>();
|
JobsMedical.Web.Services.Scraping.MedjobsListingSource>();
|
||||||
|
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.ListingArchiver>();
|
||||||
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.IngestionService>();
|
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.IngestionService>();
|
||||||
builder.Services.AddHostedService<JobsMedical.Web.Services.Scraping.IngestionWorker>();
|
builder.Services.AddHostedService<JobsMedical.Web.Services.Scraping.IngestionWorker>();
|
||||||
|
|
||||||
@@ -74,6 +75,10 @@ using (var scope = app.Services.CreateScope())
|
|||||||
db.Database.Migrate();
|
db.Database.Migrate();
|
||||||
// Production seeds reference data only (no demo facilities/shifts); dev seeds the full board.
|
// Production seeds reference data only (no demo facilities/shifts); dev seeds the full board.
|
||||||
await SeedData.EnsureSeededAsync(db, app.Environment.IsDevelopment());
|
await SeedData.EnsureSeededAsync(db, app.Environment.IsDevelopment());
|
||||||
|
// Archive any listings that went stale while the app was down.
|
||||||
|
await scope.ServiceProvider
|
||||||
|
.GetRequiredService<JobsMedical.Web.Services.Scraping.ListingArchiver>()
|
||||||
|
.ArchiveStaleAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ public class IngestionWorker : BackgroundService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var scope = _scopes.CreateScope();
|
using var scope = _scopes.CreateScope();
|
||||||
|
|
||||||
|
// Always archive stale listings (independent of ingestion being on).
|
||||||
|
await scope.ServiceProvider.GetRequiredService<ListingArchiver>().ArchiveStaleAsync(stoppingToken);
|
||||||
|
|
||||||
var settings = await scope.ServiceProvider
|
var settings = await scope.ServiceProvider
|
||||||
.GetRequiredService<SettingsService>().GetAsync();
|
.GetRequiredService<SettingsService>().GetAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using JobsMedical.Web.Data;
|
||||||
|
using JobsMedical.Web.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Services.Scraping;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="ListingArchiver"/> flips stale Open rows to Expired so dashboards/DB agree.
|
||||||
|
/// </summary>
|
||||||
|
public static class ListingPolicy
|
||||||
|
{
|
||||||
|
/// <summary>A job opening older than this (since posting) is treated as stale and hidden.</summary>
|
||||||
|
public const int JobFreshnessDays = 30;
|
||||||
|
|
||||||
|
public static DateTime JobCutoffUtc => DateTime.UtcNow.AddDays(-JobFreshnessDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sweeps stale listings into the Expired state (archive). Idempotent and cheap.</summary>
|
||||||
|
public class ListingArchiver
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ILogger<ListingArchiver> _log;
|
||||||
|
|
||||||
|
public ListingArchiver(AppDbContext db, ILogger<ListingArchiver> log)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user