refinement phase 7

This commit is contained in:
hamid
2026-07-13 17:48:50 +03:30
parent 70268ecc06
commit 7edadadea1
30 changed files with 6971 additions and 154 deletions
@@ -1,53 +0,0 @@
#nullable enable
using Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Booking;
/// <summary>
/// The recurring expiry sweep for <c>booking_requests</c> (reuses the b1 in-process interval-runner seam;
/// real Hangfire/Quartz is deferred). Each tick sends <see cref="ExpireBookingRequestsCommand"/>, which
/// transitions stale rows (<c>expired_no_response</c> / <c>payment_deadline_expired</c>) in bounded,
/// idempotent batches. The interval is short because the payment window is only 30 minutes; there is no b1
/// interval config key for booking expiry, so it is a documented constant.
/// </summary>
internal sealed class BookingRequestExpiryHostedService(
IServiceScopeFactory scopeFactory,
ILogger<BookingRequestExpiryHostedService> logger) : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await SweepSafely(stoppingToken);
using var timer = new PeriodicTimer(Interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
await SweepSafely(stoppingToken);
}
private async Task SweepSafely(CancellationToken cancellationToken)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
var result = await sender.Send(new ExpireBookingRequestsCommand(), cancellationToken);
if (result.IsSuccess && result.Result is { } counts && (counts.ExpiredNoResponse > 0 || counts.PaymentDeadlineExpired > 0))
logger.LogInformation(
"Booking-request expiry swept {NoResponse} expired-no-response and {PaymentExpired} payment-window-expired requests",
counts.ExpiredNoResponse, counts.PaymentDeadlineExpired);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Host is shutting down — expected, don't log as an error.
}
catch (Exception ex)
{
logger.LogError(ex, "Booking-request expiry sweep failed");
}
}
}
@@ -1,50 +0,0 @@
#nullable enable
using Baya.Application.Contracts.Notifications;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Notifications;
/// <summary>
/// The scheduling seam for the notification retention job (mock = an in-process interval runner). It
/// periodically hard-deletes read notifications older than the retention window; unread notifications are
/// never deleted. Real Hangfire/Quartz is deferred — swapping it in is a registration change here.
/// </summary>
internal sealed class NotificationRetentionHostedService(
IServiceScopeFactory scopeFactory,
ILogger<NotificationRetentionHostedService> logger) : BackgroundService
{
private const int RetentionDays = 90;
private static readonly TimeSpan Interval = TimeSpan.FromHours(24);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Run once at startup, then on the interval.
await PurgeSafely(stoppingToken);
using var timer = new PeriodicTimer(Interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
await PurgeSafely(stoppingToken);
}
private async Task PurgeSafely(CancellationToken cancellationToken)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<INotificationService>();
var removed = await notifications.PurgeOldReadAsync(RetentionDays, cancellationToken);
if (removed > 0)
logger.LogInformation("Notification retention purged {Count} read notifications older than {Days}d", removed, RetentionDays);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Host is shutting down — expected, don't log as an error.
}
catch (Exception ex)
{
logger.LogError(ex, "Notification retention purge failed");
}
}
}
@@ -0,0 +1,30 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Baya.Infrastructure.Persistence.Services.Scheduling;
/// <summary>
/// One recurring background job driven by <see cref="RecurringJobSchedulerHostedService"/>. Each
/// implementation re-homes a previously admin-manual (or hardcoded-interval) sweep behind the same seam so
/// the platform runs itself: the scheduler owns the loop, the per-tick DI scope, the distributed lock, and
/// the error handling, while the job only says <em>how often</em> it runs and <em>what</em> one idempotent
/// run does. Every run must be idempotent — a scheduler retry (or a second instance once the lock is
/// Redis-backed) must never double-pay or double-post; the DB uniques/state-machines are the backstop.
/// </summary>
internal interface IRecurringJob
{
/// <summary>Stable identifier — used for the distributed-lock key (<c>scheduler:{Name}</c>) and log scope.</summary>
string Name { get; }
/// <summary>
/// Resolves this job's run interval, read fresh each tick (usually from a <c>platform_configs</c> cadence
/// key via <see cref="Baya.Application.Contracts.Configuration.IPlatformConfig"/>) so an admin cadence
/// change takes effect without a restart. <paramref name="services"/> is the per-tick scoped provider.
/// </summary>
ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken);
/// <summary>Executes one idempotent run within the provided per-tick DI scope.</summary>
ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken);
}
@@ -0,0 +1,36 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
/// <summary>
/// Auto-expires stale <c>booking_requests</c> (re-homed from the b8 <c>BookingRequestExpiryHostedService</c>).
/// Each run sends <see cref="ExpireBookingRequestsCommand"/>, which transitions stale rows
/// (<c>expired_no_response</c> / <c>payment_deadline_expired</c>) in bounded, idempotent batches. The interval
/// is a short constant because the payment window is only 30 minutes; there is no cadence config key for it.
/// Also reachable via the admin manual trigger.
/// </summary>
internal sealed class BookingRequestExpiryJob(ILogger<BookingRequestExpiryJob> logger) : IRecurringJob
{
public string Name => "booking_request_expiry";
public ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
=> ValueTask.FromResult(TimeSpan.FromMinutes(1));
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new ExpireBookingRequestsCommand(), cancellationToken);
if (result.IsSuccess && result.Result is { } counts && (counts.ExpiredNoResponse > 0 || counts.PaymentDeadlineExpired > 0))
logger.LogInformation(
"Booking-request expiry swept {NoResponse} expired-no-response and {PaymentExpired} payment-window-expired requests",
counts.ExpiredNoResponse, counts.PaymentDeadlineExpired);
}
}
@@ -0,0 +1,40 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
/// <summary>
/// Scans for lapsed time-limited verification steps (criminal-record especially), reverting each to expired,
/// raising a renewal alert/notification, and re-gating bookability. Previously admin-manual
/// (<c>admin_verifications/scan_expiring</c>) — now scheduled on the <c>verification_expiry_scan_cadence_hours</c>
/// cadence; the admin trigger remains an override. Sends <see cref="ScanExpiringCredentialsCommand"/> (idempotent).
/// </summary>
internal sealed class CredentialExpiryScanJob(ILogger<CredentialExpiryScanJob> logger) : IRecurringJob
{
public string Name => "verification_expiry_scan";
public async ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var config = services.GetRequiredService<IPlatformConfig>();
var hours = await config.GetConfig<int>("verification_expiry_scan_cadence_hours", cancellationToken);
return TimeSpan.FromHours(hours);
}
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new ScanExpiringCredentialsCommand(), cancellationToken);
if (result.IsSuccess && result.Result is { } scan && scan.RevertedNurses > 0)
logger.LogInformation(
"Credential-expiry scan reverted {Nurses} nurse(s) across {Steps} expired step(s)",
scan.RevertedNurses, scan.ScannedSteps);
}
}
@@ -0,0 +1,38 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Features.Bookings.Commands.DetectNoShowSessions;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
/// <summary>
/// Flags booking sessions whose scheduled start passed with no EVV check-in as no-shows. Previously admin-manual
/// (<c>admin_evv/detect_no_shows</c>) — now scheduled on the <c>no_show_scan_cadence_hours</c> cadence; the admin
/// trigger remains an override. Sends <see cref="DetectNoShowSessionsCommand"/> (idempotent — an already-flagged
/// session is not re-flagged).
/// </summary>
internal sealed class NoShowSweepJob(ILogger<NoShowSweepJob> logger) : IRecurringJob
{
public string Name => "no_show_sweep";
public async ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var config = services.GetRequiredService<IPlatformConfig>();
var hours = await config.GetConfig<int>("no_show_scan_cadence_hours", cancellationToken);
return TimeSpan.FromHours(hours);
}
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new DetectNoShowSessionsCommand(), cancellationToken);
if (result.IsSuccess && result.Result is { Missed: > 0 } sweep)
logger.LogInformation("No-show sweep flagged {Missed} missed session(s)", sweep.Missed);
}
}
@@ -0,0 +1,33 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Contracts.Notifications;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
/// <summary>
/// Hard-deletes read notifications older than the retention window (re-homed from the b1
/// <c>NotificationRetentionHostedService</c>). Unread notifications are never deleted. Retention window and
/// cadence are documented constants (no config key).
/// </summary>
internal sealed class NotificationRetentionJob(ILogger<NotificationRetentionJob> logger) : IRecurringJob
{
private const int RetentionDays = 90;
public string Name => "notification_retention";
public ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
=> ValueTask.FromResult(TimeSpan.FromHours(24));
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var notifications = services.GetRequiredService<INotificationService>();
var removed = await notifications.PurgeOldReadAsync(RetentionDays, cancellationToken);
if (removed > 0)
logger.LogInformation("Notification retention purged {Count} read notifications older than {Days}d", removed, RetentionDays);
}
}
@@ -0,0 +1,58 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
/// <summary>
/// Generates the weekly nurse-payout batch (previously admin-manual — <c>nurse_payout_interval_days</c> was seeded
/// but nothing read it, so nurses were paid only when an operator clicked). Scheduled on that cadence, it opens a
/// <c>draft</c> batch over the trailing window; the admin generate trigger remains an override.
///
/// <para><b>Generation only — never money movement.</b> Per the phase's "keep processing human-approved until trust
/// is earned", this schedules <em>batch generation</em>; the irreversible <c>process</c> step stays an explicit
/// admin action. The batch is <b>system-initiated</b> (<see cref="GeneratePayoutBatchCommand.SystemInitiated"/> →
/// null initiator). A quiet week (no eligible bookings) returns a benign failure, logged at debug — not an error.
/// Re-running over an overlapping window is safe: the <c>nurse_payout_booking_links.booking_id</c> UNIQUE prevents
/// re-selecting an already-paid booking.</para>
/// </summary>
internal sealed class WeeklyPayoutGenerationJob(ILogger<WeeklyPayoutGenerationJob> logger) : IRecurringJob
{
public string Name => "weekly_payout_generation";
public async ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var config = services.GetRequiredService<IPlatformConfig>();
var days = await config.GetConfig<int>("nurse_payout_interval_days", cancellationToken);
return TimeSpan.FromDays(days);
}
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var clock = services.GetRequiredService<IDateTimeProvider>();
var config = services.GetRequiredService<IPlatformConfig>();
var sender = services.GetRequiredService<ISender>();
var intervalDays = await config.GetConfig<int>("nurse_payout_interval_days", cancellationToken);
var periodEnd = DateOnly.FromDateTime(clock.UtcNow.UtcDateTime);
var periodStart = periodEnd.AddDays(-Math.Max(intervalDays, 1));
var result = await sender.Send(
new GeneratePayoutBatchCommand(periodStart, periodEnd) { SystemInitiated = true }, cancellationToken);
if (result.IsSuccess && result.Result is { } generated)
logger.LogInformation(
"Weekly payout generation opened a draft batch of {Count} payout(s) totalling {Total} IRR (awaiting admin process)",
generated.Batch.PayoutCount, generated.Batch.TotalAmount);
else
// Expected on a quiet week (no eligible bookings) — not an operational error.
logger.LogDebug("Weekly payout generation produced no batch this run (no payout-eligible bookings).");
}
}
@@ -0,0 +1,113 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Contracts.Payments;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling;
/// <summary>
/// The single in-process job scheduler (refinement-phase-7): it drives every registered <see cref="IRecurringJob"/>
/// on its own cadence, replacing the two hand-written <c>PeriodicTimer</c> hosted services and giving the four
/// previously admin-manual sweeps (credential-expiry scan, EVV no-show sweep, weekly payout-batch generation,
/// and — Phase 8 — the Moadian reconciliation poll) a schedule. It intentionally uses <b>no new infrastructure</b>:
/// SQL Server stays the only external dependency, so a single-instance MVP needs neither Hangfire/Quartz nor Redis.
///
/// <para><b>Multi-instance readiness.</b> Each tick runs under <see cref="IDistributedLock"/>
/// (<c>scheduler:{Name}</c>). Today that lock is in-process (a no-op across instances); the moment a second API
/// instance runs it becomes the scale-out gate — swapping the lock seam to Redis serializes ticks across nodes.
/// Because every job is idempotent and the DB uniques/state-machines are authoritative, even a double-run is safe.</para>
///
/// <para><b>Not started under the "Testing" environment</b> so integration tests (WebApplicationFactory over
/// in-memory SQLite) stay deterministic — no background sweep mutates rows mid-assertion. Each job's underlying
/// command/service is unit-tested directly instead.</para>
/// </summary>
internal sealed class RecurringJobSchedulerHostedService(
IServiceScopeFactory scopeFactory,
IEnumerable<IRecurringJob> jobs,
IDistributedLock distributedLock,
IHostEnvironment environment,
ILogger<RecurringJobSchedulerHostedService> logger) : BackgroundService
{
// Floor so a mis-set cadence key (0 or negative) can never turn a loop into a hot spin.
private static readonly TimeSpan MinInterval = TimeSpan.FromSeconds(10);
// Used when a job's interval resolution throws (e.g. the config store is briefly unreachable).
private static readonly TimeSpan FallbackInterval = TimeSpan.FromMinutes(5);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (environment.IsEnvironment("Testing"))
return;
var jobList = jobs.ToArray();
logger.LogInformation("Recurring job scheduler starting with {Count} job(s): {Jobs}",
jobList.Length, string.Join(", ", jobList.Select(j => j.Name)));
// One independent loop per job so their cadences don't couple. A crash in one loop never stops the others.
await Task.WhenAll(jobList.Select(job => RunJobLoopAsync(job, stoppingToken)));
}
private async Task RunJobLoopAsync(IRecurringJob job, CancellationToken stoppingToken)
{
// Run once at startup, then on the job's own (re-read) cadence.
while (!stoppingToken.IsCancellationRequested)
{
var interval = await TickAndResolveIntervalAsync(job, stoppingToken);
try
{
await Task.Delay(interval, stoppingToken);
}
catch (OperationCanceledException)
{
return; // Host is shutting down.
}
}
}
private async Task<TimeSpan> TickAndResolveIntervalAsync(IRecurringJob job, CancellationToken stoppingToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var services = scope.ServiceProvider;
await RunTickAsync(job, services, stoppingToken);
return await ResolveIntervalAsync(job, services, stoppingToken);
}
private async Task RunTickAsync(IRecurringJob job, IServiceProvider services, CancellationToken stoppingToken)
{
try
{
await using var handle = await distributedLock.AcquireAsync($"scheduler:{job.Name}", stoppingToken);
await job.RunAsync(services, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Host is shutting down — expected, don't log as an error.
}
catch (Exception ex)
{
// A failing tick must never kill the loop — the next tick retries on schedule.
logger.LogError(ex, "Recurring job {Job} failed", job.Name);
}
}
private async Task<TimeSpan> ResolveIntervalAsync(IRecurringJob job, IServiceProvider services, CancellationToken stoppingToken)
{
try
{
var interval = await job.GetIntervalAsync(services, stoppingToken);
return interval < MinInterval ? MinInterval : interval;
}
catch (Exception ex)
{
logger.LogError(ex, "Recurring job {Job} interval resolution failed; using fallback {Fallback}", job.Name, FallbackInterval);
return FallbackInterval;
}
}
}