refinement phase 7
This commit is contained in:
+38
-11
@@ -56,10 +56,15 @@ You are a **senior .NET software engineer** working on this codebase. That means
|
||||
| Update DB | `dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
|
||||
|
||||
**Default URL:** `https://localhost:5002` — Swagger at `/swagger`.
|
||||
On boot (non-Testing), `Program.cs` calls `ApplyMigrationsAsync()` + `SeedDefaultUsersAsync()` (roles always;
|
||||
a bootstrap admin **only if `Seed:AdminUsername`/`Seed:AdminPassword` are configured** — never a committed
|
||||
credential), and **only in Development** `SeedPaymentGatewaysAsync()` (the sandbox gateway) + `SeedDemoWorldAsync()`
|
||||
(the demo marketplace, see Persistence below). A reachable SQL Server is required to start. Startup **fails fast**
|
||||
**Migrations are split from boot (refinement-phase-7).** `dotnet run -- migrate` (the deploy-time one-shot / a CI
|
||||
`dotnet ef database update`) applies migrations + the idempotent seeders, then exits — so multi-instance boots never
|
||||
race on DDL and the runtime login needs no permanent DDL rights. **In Development**, boot still migrates + seeds for
|
||||
convenience: `Program.cs` calls `ApplyMigrationsAsync()` + `SeedDefaultUsersAsync()` (roles always; a bootstrap admin
|
||||
**only if `Seed:AdminUsername`/`Seed:AdminPassword` are configured** — never a committed credential) + the
|
||||
Development-only `SeedPaymentGatewaysAsync()` (sandbox gateway) + `SeedDemoWorldAsync()` (demo marketplace, see
|
||||
Persistence below). **In deployed environments**, boot instead only *checks* the schema is current
|
||||
(`EnsureSchemaUpToDateAsync` — fail fast on a pending migration) and seeds roles/break-glass admin (idempotent). A
|
||||
reachable SQL Server is required to start. Startup **fails fast**
|
||||
(`StartupSecretsGuard`) if a load-bearing secret — the DB connection strings, and in deployed environments the
|
||||
JWE + field-encryption keys — is missing or left at its committed `SET_VIA_USER_SECRETS_OR_ENV` placeholder
|
||||
(refinement-phase-5). Development supplies working dev-only crypto keys via `appsettings.Development.json`; only
|
||||
@@ -90,7 +95,7 @@ src/
|
||||
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), Payments/ (b10 ledger/txn/webhook/gateway + LedgerPosting; b11 adds Refunds/ + Invoices/), Bnpl/ (b12 BnplTransaction + BnplStatus/BnplTransitions/BnplEligibilityStatus/BnplProviderCodes — the net-of-fee card-payment model), Payouts/ (b13 NursePayoutBatch/NursePayout/NursePayoutBookingLink + PayoutBatchStatus/PayoutStatus/*Transitions — the weekly payout run), Reviews/ (b14 Review (IAuditable) + ReviewModerationStatus/ReviewModerationAction codes + ReviewTagMaster/ReviewTagLink + PatientCareRecord — moderated reviews, tag vocab & patient-scoped encrypted clinical notes), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
|
||||
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); Payments area = the b10 money core (initiate/webhook/confirm-post-ledger/nurse-payable-balance); Refunds + Invoices areas = the b11 reversal leg (create refund/confirm-settlement/mark-failed [refinement-phase-6: the BNPL/manual `processing → succeeded` clearing]/write-off clawback/list/refund-status; issue invoice/get invoice); Bnpl area = the b12 provider-financed-installment checkout (eligibility/initiate/verify/settle/revert/callback/status + BookingConversion shared with b10); Payouts area = the b13 weekly payout engine (compute-eligible/generate-batch/process/retry/mark-failed + admin batch detail/list + nurse history; PayoutSettlement shared ledger+clawback-netting step); Reviews area = the b14 reviews & ratings (submit/moderate/attach-tags + public list/tag-aggregates + admin moderation-queue; RecomputeNurseRating from-source helper + ReviewCache); PatientCareRecords area = the b14 encrypted patient-scoped clinical notes (write/history under strict clinical access); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + Contracts/Reviews IReviewModerationService (AI review pre-screen seam) + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository + IReviewRepository + IPatientCareRecordRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
|
||||
├── Infrastructure/
|
||||
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed + ReviewsConfig/ — b14 reviews/tags-master (seeded)/tag-links/patient-care-records configs), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository + b14 ReviewRepository + PatientCareRecordRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch + Booking/ = BookingRequestExpiryHostedService)
|
||||
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed + ReviewsConfig/ — b14 reviews/tags-master (seeded)/tag-links/patient-care-records configs), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository + b14 ReviewRepository + PatientCareRecordRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + Scheduling/ = RecurringJobSchedulerHostedService + Jobs/ (the IRecurringJob crons — refinement-phase-7) + Search/ = SearchIndexMaintainer + SqlNurseSearch)
|
||||
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
|
||||
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator + MockBankTransferProvider + MockReviewModerationService) + AddCrossCuttingSeams
|
||||
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
|
||||
@@ -127,9 +132,8 @@ contracts — `IPlatformConfig` (typed cached config), `IHolidayCalendar` (bank-
|
||||
trail), `INotificationService` (per-user notification reads/commands), `ISupportAlertService` (internal
|
||||
worklist) — are implemented in **`Baya.Infrastructure.Persistence/Services/`** and registered by
|
||||
`AddPersistenceServices`, *not* in CrossCutting. The real `INotificationDispatcher` (in-app
|
||||
`notifications` write) also lives there and **supersedes** the b0 log stub. The
|
||||
`NotificationRetentionHostedService` (the retention/`IJobScheduler` seam) is registered as a hosted
|
||||
service there too. Other domains call these contracts; they never re-create the tables. The
|
||||
`notifications` write) also lives there and **supersedes** the b0 log stub. Other domains call these
|
||||
contracts; they never re-create the tables. The
|
||||
`AuditFieldInterceptor` additionally writes an append-only `audit_logs` row for any `IAuditable` entity
|
||||
(`PlatformConfig`, `PartnerCenter`, `Review`, and — refinement-phase-6 — the admin-decided money & trust
|
||||
entities `Refund`, `NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification`; encrypted columns
|
||||
@@ -241,8 +245,8 @@ b9/b10). One customer requests one nurse for a patient/variant/address/date; the
|
||||
30-minute payment window) or rejects before a frozen response deadline; unanswered/unpaid requests auto-expire.
|
||||
Features under `Baya.Application/Features/Booking/{Commands|Queries}/`; config in
|
||||
`Persistence/Configuration/BookingConfig/`; per-domain repo (`IBookingRequestRepository`) on `IUnitOfWork`;
|
||||
the recurring sweep is `Persistence/Services/Booking/BookingRequestExpiryHostedService` (reuses the b1
|
||||
`IJobScheduler`/`BackgroundService` seam). Load-bearing rules:
|
||||
the recurring expiry sweep is the `booking_request_expiry` `IRecurringJob` run by the scheduler (see
|
||||
"Unattended operation" below — refinement-phase-7 re-homed it from a standalone hosted service). Load-bearing rules:
|
||||
- **No money, ever, and no `bookings` row.** A request carries no price/total; accept only opens the payment
|
||||
window. b9 consumes an `accepted_awaiting_payment` request → creates the booking → sets it `converted`.
|
||||
- **Two-stage clinical disclosure (stage 1).** The nurse sees **only** the unencrypted, limited `customer_notes`
|
||||
@@ -512,6 +516,29 @@ controllers `TicketsController` / `AdminTicketsController` / `AdminPartnerCenter
|
||||
manual-approve at MVP; `VerifyPartnerCenter` records the human decision. There is **no** telephony/VoIP seam
|
||||
(the emergency call is an out-of-platform `tel:` link by design). This is the last backend phase.
|
||||
|
||||
**Unattended operation — the recurring-job scheduler (refinement-phase-7).** A single in-process scheduler,
|
||||
`Persistence/Services/Scheduling/RecurringJobSchedulerHostedService`, drives every registered `IRecurringJob`
|
||||
(`Services/Scheduling/Jobs/`) on its own cadence — replacing the two stand-alone `PeriodicTimer` hosted services
|
||||
and giving the previously admin-manual sweeps a schedule, **using no new infrastructure** (SQL Server stays the
|
||||
only external dependency). Jobs, each reading its seeded `platform_configs` cadence key via `IPlatformConfig`:
|
||||
`booking_request_expiry` (1 min const) · `notification_retention` (24 h const) · `verification_expiry_scan`
|
||||
(`verification_expiry_scan_cadence_hours`) · `no_show_sweep` (`no_show_scan_cadence_hours`) ·
|
||||
`weekly_payout_generation` (`nurse_payout_interval_days`). Load-bearing rules:
|
||||
- **Add a cron = implement `IRecurringJob` + one `AddSingleton<IRecurringJob, …>()`** in `AddPersistenceServices`.
|
||||
Phase 8 registers the Moadian reconciliation + refund-settlement poll exactly this way. The scheduler owns the
|
||||
per-tick DI scope, error isolation (a throwing tick never kills the loop), and the lock; a job says only *how
|
||||
often* and *what one idempotent run does*.
|
||||
- **Jobs must be idempotent** — a 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. Each tick runs under
|
||||
`IDistributedLock("scheduler:{name}")` — in-proc today, the **>1-instance scale-out gate** (swap the seam to
|
||||
Redis to serialize ticks across nodes; single-instance MVP needs neither Redis nor Hangfire/Quartz).
|
||||
- **Money movement stays human-approved.** The payout job schedules *generation* only (a `draft` batch, recorded
|
||||
system-initiated — `NursePayoutBatch.InitiatedByAdminId` is nullable = "no human initiator"); the irreversible
|
||||
`process` step remains an explicit admin action. The command's `SystemInitiated` flag is scheduler-only —
|
||||
`AdminPayoutsController` neutralizes any request-supplied value.
|
||||
- **Admin manual triggers remain overrides** (the same idempotent commands). The scheduler is **dormant under the
|
||||
`Testing` environment** so integration tests stay deterministic; each job/command is unit-tested directly.
|
||||
|
||||
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
|
||||
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
|
||||
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
|
||||
@@ -529,7 +556,7 @@ builder.ValidateRequiredSecrets() // refinement-phase-5: fail fast on missing/p
|
||||
ConfigureHealthChecks() · SetupOpenTelemetry()
|
||||
AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate)
|
||||
RegisterIdentityServices(…, requireHttpsMetadata) // Identity, JWT/JWE (RequireHttpsMetadata on outside Dev/Testing), ICurrentUser
|
||||
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories
|
||||
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories, the IRecurringJob crons + RecurringJobSchedulerHostedService (refinement-phase-7)
|
||||
AddCrossCuttingSeams(config) // IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher (mocks)
|
||||
AddWebFrameworkServices() // API versioning + snake_case routing
|
||||
AddCorsPolicies(config) // browser CORS policy from Cors:AllowedOrigins (refinement-phase-0; default http://localhost:3000 in Dev)
|
||||
|
||||
@@ -42,7 +42,9 @@ public sealed class AdminPayoutsController(ISender sender) : BaseController
|
||||
[HttpPost("batches")]
|
||||
[ProducesOkApiResponseType<GeneratePayoutBatchResult>]
|
||||
public async Task<IActionResult> Generate(GeneratePayoutBatchCommand command, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(command, cancellationToken));
|
||||
// SystemInitiated is scheduler-only — neutralize any request-supplied value so an API caller can never
|
||||
// record a batch without an authenticated admin initiator (refinement-phase-7).
|
||||
=> OperationResult(await sender.Send(command with { SystemInitiated = false }, cancellationToken));
|
||||
|
||||
[HttpPost("batches/{id}/process")]
|
||||
[ProducesOkApiResponseType<ExecutePayoutBatchResult>]
|
||||
|
||||
@@ -110,22 +110,43 @@ builder.Services.ConfigureGrpcPluginServices();
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
// Integration tests (WebApplicationFactory, env "Testing") run on in-memory SQLite — the SQL Server
|
||||
// migrations can't apply there; the test factory does EnsureCreated + seeding itself.
|
||||
if (!app.Environment.IsEnvironment("Testing"))
|
||||
// Deploy-time migration one-shot (refinement-phase-7): `dotnet run -- migrate` (or `<binary> migrate`) applies
|
||||
// EF migrations + the idempotent seeders, then exits. Running DDL as a separate deploy step means normal boots —
|
||||
// especially concurrent multi-instance start-ups — never race on schema, and the runtime login needs no
|
||||
// permanent DDL rights.
|
||||
if (args.Any(a => string.Equals(a, "migrate", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
await app.ApplyMigrationsAsync();
|
||||
await app.SeedDefaultUsersAsync();
|
||||
|
||||
// Development-only: a sandbox payment gateway (all-zeros merchant id) and a demo marketplace
|
||||
// (nurses/variants/search rows, customers/patients) so the real-path screens aren't empty. Neither
|
||||
// belongs in a deployed DB — a production gateway is an admin action, so this never runs in
|
||||
// Production/Staging. Both are idempotent.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
await app.SeedPaymentGatewaysAsync();
|
||||
await app.SeedDemoWorldAsync();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Integration tests (WebApplicationFactory, env "Testing") run on in-memory SQLite — the SQL Server
|
||||
// migrations can't apply there; the test factory does EnsureCreated + seeding itself.
|
||||
if (!app.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
// Local convenience: apply migrations + seed on boot. Development-only: a sandbox payment gateway
|
||||
// (all-zeros merchant id) and a demo marketplace (nurses/variants/search rows, customers/patients) so the
|
||||
// real-path screens aren't empty. Neither belongs in a deployed DB — both are idempotent.
|
||||
await app.ApplyMigrationsAsync();
|
||||
await app.SeedDefaultUsersAsync();
|
||||
await app.SeedPaymentGatewaysAsync();
|
||||
await app.SeedDemoWorldAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Deployed: DDL is the separate `migrate` step above. Boot only *checks* the schema is current (fail fast
|
||||
// on a pending migration) and seeds idempotent runtime data (roles + any configured break-glass admin).
|
||||
await app.EnsureSchemaUpToDateAsync();
|
||||
await app.SeedDefaultUsersAsync();
|
||||
}
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
|
||||
+9
-2
@@ -33,7 +33,14 @@ internal sealed class GeneratePayoutBatchCommandHandler(
|
||||
public async ValueTask<OperationResult<GeneratePayoutBatchResult>> Handle(
|
||||
GeneratePayoutBatchCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
// A scheduled (system-initiated) run has no human initiator; the HTTP path still requires an authenticated
|
||||
// admin and records their id. SystemInitiated can only be set in-process by the scheduler.
|
||||
int? initiatedByAdminId;
|
||||
if (request.SystemInitiated)
|
||||
initiatedByAdminId = null;
|
||||
else if (currentUser.UserId is { } adminId)
|
||||
initiatedByAdminId = adminId;
|
||||
else
|
||||
return OperationResult<GeneratePayoutBatchResult>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
@@ -57,7 +64,7 @@ internal sealed class GeneratePayoutBatchCommandHandler(
|
||||
PeriodStart = request.PeriodStart,
|
||||
PeriodEnd = periodEnd,
|
||||
ProcessingDate = processingDate,
|
||||
InitiatedByAdminId = adminId
|
||||
InitiatedByAdminId = initiatedByAdminId
|
||||
};
|
||||
|
||||
var nurseIds = eligible.Select(e => e.NurseId).Distinct().ToList();
|
||||
|
||||
+10
-1
@@ -9,4 +9,13 @@ namespace Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
|
||||
/// clawbacks, snapshotting the verified primary IBAN, linking each booking under the UNIQUE guard). Returns the
|
||||
/// draft batch + payouts for admin preview; no money moves until <c>process</c>.</summary>
|
||||
public record GeneratePayoutBatchCommand(DateOnly PeriodStart, DateOnly PeriodEnd)
|
||||
: IRequest<OperationResult<GeneratePayoutBatchResult>>;
|
||||
: IRequest<OperationResult<GeneratePayoutBatchResult>>
|
||||
{
|
||||
/// <summary>
|
||||
/// True only when the in-process scheduler runs the weekly generation unattended (refinement-phase-7): the
|
||||
/// batch is recorded with a null initiator instead of requiring an authenticated admin. The HTTP path always
|
||||
/// leaves this <c>false</c> — <c>AdminPayoutsController</c> neutralizes any request-supplied value — so it can
|
||||
/// never be set by an API caller.
|
||||
/// </summary>
|
||||
public bool SystemInitiated { get; init; }
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ public record PayoutBatchDto(
|
||||
string TotalAmount,
|
||||
int PayoutCount,
|
||||
string Status,
|
||||
int InitiatedByAdminId,
|
||||
int? InitiatedByAdminId,
|
||||
DateTime? ProcessedAt,
|
||||
string? FailureNotes,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
@@ -35,8 +35,9 @@ public class NursePayoutBatch : BaseEntity<long>, IAuditable
|
||||
/// <summary>Guarded — mutated only through <see cref="TransitionTo"/> so every write goes through the machine.</summary>
|
||||
public string Status { get; private set; } = PayoutBatchStatus.Draft;
|
||||
|
||||
/// <summary>The admin who initiated the run (FK <c>users</c>). A future cron sets its own service id.</summary>
|
||||
public int InitiatedByAdminId { get; set; }
|
||||
/// <summary>The admin who initiated the run (FK <c>users</c>), or <c>null</c> for a system-initiated
|
||||
/// (scheduled/unattended) batch — the weekly cron has no human initiator (refinement-phase-7).</summary>
|
||||
public int? InitiatedByAdminId { get; set; }
|
||||
|
||||
public DateTime? ProcessedAt { get; private set; }
|
||||
|
||||
|
||||
+2
-1
@@ -25,7 +25,8 @@ internal sealed class NursePayoutBatchConfig : IEntityTypeConfiguration<NursePay
|
||||
|
||||
builder.HasMany(b => b.Payouts).WithOne(p => p.Batch).HasForeignKey(p => p.BatchId).IsRequired();
|
||||
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(b => b.InitiatedByAdminId).IsRequired();
|
||||
// Nullable: a system-initiated (scheduled) batch has no admin initiator (refinement-phase-7).
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(b => b.InitiatedByAdminId).IsRequired(false);
|
||||
|
||||
builder.HasQueryFilter(b => b.DeletedAt == null);
|
||||
}
|
||||
|
||||
+6046
File diff suppressed because it is too large
Load Diff
+67
@@ -0,0 +1,67 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RefinementPhase7SystemPayoutBatch : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_NursePayoutBatches_Users_InitiatedByAdminId",
|
||||
schema: "payouts",
|
||||
table: "NursePayoutBatches");
|
||||
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "InitiatedByAdminId",
|
||||
schema: "payouts",
|
||||
table: "NursePayoutBatches",
|
||||
type: "int",
|
||||
nullable: true,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "int");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_NursePayoutBatches_Users_InitiatedByAdminId",
|
||||
schema: "payouts",
|
||||
table: "NursePayoutBatches",
|
||||
column: "InitiatedByAdminId",
|
||||
principalSchema: "usr",
|
||||
principalTable: "Users",
|
||||
principalColumn: "UserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_NursePayoutBatches_Users_InitiatedByAdminId",
|
||||
schema: "payouts",
|
||||
table: "NursePayoutBatches");
|
||||
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "InitiatedByAdminId",
|
||||
schema: "payouts",
|
||||
table: "NursePayoutBatches",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "int",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_NursePayoutBatches_Users_InitiatedByAdminId",
|
||||
schema: "payouts",
|
||||
table: "NursePayoutBatches",
|
||||
column: "InitiatedByAdminId",
|
||||
principalSchema: "usr",
|
||||
principalTable: "Users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -3638,7 +3638,7 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<int>("InitiatedByAdminId")
|
||||
b.Property<int?>("InitiatedByAdminId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
@@ -5610,9 +5610,7 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.User.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("InitiatedByAdminId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
.HasForeignKey("InitiatedByAdminId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBookingLink", b =>
|
||||
|
||||
+31
-7
@@ -13,11 +13,12 @@ using Baya.Infrastructure.Persistence.Interceptors;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Baya.Infrastructure.Persistence.Services.Analytics;
|
||||
using Baya.Infrastructure.Persistence.Services.Audit;
|
||||
using Baya.Infrastructure.Persistence.Services.Booking;
|
||||
using Baya.Infrastructure.Persistence.Services.Configuration;
|
||||
using Baya.Infrastructure.Persistence.Services.Holidays;
|
||||
using Baya.Infrastructure.Persistence.Services.Notifications;
|
||||
using Baya.Infrastructure.Persistence.Services.Payments;
|
||||
using Baya.Infrastructure.Persistence.Services.Scheduling;
|
||||
using Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
|
||||
using Baya.Infrastructure.Persistence.Services.Search;
|
||||
using Baya.Infrastructure.Persistence.Services.Seeding;
|
||||
using Baya.Infrastructure.Persistence.Services.SupportAlerts;
|
||||
@@ -60,12 +61,16 @@ public static class ServiceCollectionExtensions
|
||||
// supersedes the interim NursePayoutStatusService (dispute-window derivation); the refund fork is unchanged.
|
||||
services.AddScoped<INursePayoutStatus, NursePayoutLinkStatusService>();
|
||||
|
||||
// Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred).
|
||||
services.AddHostedService<NotificationRetentionHostedService>();
|
||||
|
||||
// Booking-request expiry sweep (same in-process interval-runner seam): auto-expires stale
|
||||
// pending/awaiting-payment requests. Also reachable via the admin manual-trigger endpoint.
|
||||
services.AddHostedService<BookingRequestExpiryHostedService>();
|
||||
// Unattended operation (refinement-phase-7). One in-process scheduler drives every IRecurringJob on its
|
||||
// own cadence — the two re-homed sweeps plus the previously admin-manual crons, each reading its seeded
|
||||
// cadence key. No new infra: SQL Server stays the only external dependency (Hangfire/Quartz + Redis are the
|
||||
// documented scale-out gate for >1 instance). Phase 8 adds the Moadian reconciliation job the same way.
|
||||
services.AddSingleton<IRecurringJob, BookingRequestExpiryJob>();
|
||||
services.AddSingleton<IRecurringJob, NotificationRetentionJob>();
|
||||
services.AddSingleton<IRecurringJob, CredentialExpiryScanJob>();
|
||||
services.AddSingleton<IRecurringJob, NoShowSweepJob>();
|
||||
services.AddSingleton<IRecurringJob, WeeklyPayoutGenerationJob>();
|
||||
services.AddHostedService<RecurringJobSchedulerHostedService>();
|
||||
|
||||
// Search (backend-phase-7). The index maintainer keeps nurse_search_index consistent inline inside
|
||||
// each source write's unit of work. The INurseSearch backend is config-selected — SQL is the real
|
||||
@@ -97,6 +102,25 @@ public static class ServiceCollectionExtensions
|
||||
await context.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Boot-time schema <b>check</b> (refinement-phase-7) for deployed environments: applying migrations is a
|
||||
/// separate deploy step (the <c>migrate</c> one-shot / a CI <c>dotnet ef database update</c>), so concurrent
|
||||
/// multi-instance start-ups never race on DDL and the app login needs no permanent DDL rights. If any
|
||||
/// migration is pending, fail fast with a clear message rather than starting against a stale schema.
|
||||
/// </summary>
|
||||
public static async Task EnsureSchemaUpToDateAsync(this WebApplication app)
|
||||
{
|
||||
await using var scope = app.Services.CreateAsyncScope();
|
||||
var context = scope.ServiceProvider.GetService<ApplicationDbContext>()
|
||||
?? throw new Exception("Database Context Not Found");
|
||||
|
||||
var pending = (await context.Database.GetPendingMigrationsAsync()).ToList();
|
||||
if (pending.Count > 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Database schema is not up to date — {pending.Count} migration(s) pending: {string.Join(", ", pending)}. " +
|
||||
"Run the deploy-time migration step (`dotnet run -- migrate`, or `dotnet ef database update` in CI) before starting the API.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotently seeds one active <c>standard</c> payment gateway so the b10 card rail has a selectable
|
||||
/// provider out of the box. <c>config_json</c> is encrypted at rest by the EF converter on save (so it
|
||||
|
||||
-53
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-50
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
+58
@@ -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).");
|
||||
}
|
||||
}
|
||||
+113
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Infrastructure.Persistence.Services.Scheduling;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestration tests for <c>RecurringJobSchedulerHostedService</c> (refinement-phase-7): it runs each job at
|
||||
/// startup under the distributed lock, keeps sibling loops alive when one job throws, and stays dormant under the
|
||||
/// Testing environment so integration tests are deterministic.
|
||||
/// </summary>
|
||||
public sealed class RecurringJobSchedulerTests
|
||||
{
|
||||
private sealed class FakeEnvironment(string environmentName) : IHostEnvironment
|
||||
{
|
||||
public string EnvironmentName { get; set; } = environmentName;
|
||||
public string ApplicationName { get; set; } = "Baya.Test";
|
||||
public string ContentRootPath { get; set; } = ".";
|
||||
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
|
||||
}
|
||||
|
||||
private sealed class CountingJob(string name, bool throws = false) : IRecurringJob
|
||||
{
|
||||
private int _runCount;
|
||||
public int RunCount => Volatile.Read(ref _runCount);
|
||||
public string Name => name;
|
||||
|
||||
// Long interval: run once at startup, then idle for the whole test window.
|
||||
public ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(TimeSpan.FromHours(1));
|
||||
|
||||
public ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref _runCount);
|
||||
if (throws)
|
||||
throw new InvalidOperationException("boom");
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static (IServiceScopeFactory scopeFactory, IDistributedLock @lock) Fakes(out IDistributedLock recordingLock)
|
||||
{
|
||||
var scopeFactory = new ServiceCollection().BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
|
||||
recordingLock = Substitute.For<IDistributedLock>();
|
||||
recordingLock.AcquireAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ValueTask<IAsyncDisposable>(Substitute.For<IAsyncDisposable>()));
|
||||
return (scopeFactory, recordingLock);
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (condition())
|
||||
return;
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunsJobAtStartup_UnderPerJobLock()
|
||||
{
|
||||
var (scopeFactory, @lock) = Fakes(out var recordingLock);
|
||||
var job = new CountingJob("fake_job");
|
||||
var scheduler = new RecurringJobSchedulerHostedService(
|
||||
scopeFactory, [job], @lock, new FakeEnvironment("Development"),
|
||||
NullLogger<RecurringJobSchedulerHostedService>.Instance);
|
||||
|
||||
await scheduler.StartAsync(default);
|
||||
await WaitUntilAsync(() => job.RunCount >= 1, TimeSpan.FromSeconds(3));
|
||||
await scheduler.StopAsync(default);
|
||||
|
||||
Assert.True(job.RunCount >= 1);
|
||||
await recordingLock.Received().AcquireAsync("scheduler:fake_job", Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OneJobThrowing_DoesNotStopSiblingJobs()
|
||||
{
|
||||
var (scopeFactory, @lock) = Fakes(out _);
|
||||
var throwing = new CountingJob("throwing", throws: true);
|
||||
var healthy = new CountingJob("healthy");
|
||||
var scheduler = new RecurringJobSchedulerHostedService(
|
||||
scopeFactory, [throwing, healthy], @lock, new FakeEnvironment("Development"),
|
||||
NullLogger<RecurringJobSchedulerHostedService>.Instance);
|
||||
|
||||
await scheduler.StartAsync(default);
|
||||
await WaitUntilAsync(() => healthy.RunCount >= 1, TimeSpan.FromSeconds(3));
|
||||
await scheduler.StopAsync(default);
|
||||
|
||||
Assert.True(throwing.RunCount >= 1);
|
||||
Assert.True(healthy.RunCount >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestingEnvironment_KeepsSchedulerDormant()
|
||||
{
|
||||
var (scopeFactory, @lock) = Fakes(out _);
|
||||
var job = new CountingJob("fake_job");
|
||||
var scheduler = new RecurringJobSchedulerHostedService(
|
||||
scopeFactory, [job], @lock, new FakeEnvironment("Testing"),
|
||||
NullLogger<RecurringJobSchedulerHostedService>.Instance);
|
||||
|
||||
await scheduler.StartAsync(default);
|
||||
await Task.Delay(200);
|
||||
await scheduler.StopAsync(default);
|
||||
|
||||
Assert.Equal(0, job.RunCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Features.Bookings.Commands.DetectNoShowSessions;
|
||||
using Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
|
||||
using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Baya.Application.Models.Verification;
|
||||
using Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the recurring jobs (refinement-phase-7): each reads its seeded cadence key and dispatches the
|
||||
/// same idempotent command the admin manual trigger sends. No timers involved — pure job behaviour.
|
||||
/// </summary>
|
||||
public sealed class RecurringJobTests
|
||||
{
|
||||
private static IServiceProvider ProviderWith(params (Type Type, object Impl)[] services)
|
||||
{
|
||||
var provider = Substitute.For<IServiceProvider>();
|
||||
foreach (var (type, impl) in services)
|
||||
provider.GetService(type).Returns(impl);
|
||||
return provider;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CredentialExpiryScan_ReadsCadenceKey_AndSendsScanCommand()
|
||||
{
|
||||
var config = Substitute.For<IPlatformConfig>();
|
||||
config.GetConfig<int>("verification_expiry_scan_cadence_hours", Arg.Any<CancellationToken>())
|
||||
.Returns(new ValueTask<int>(6));
|
||||
var sender = Substitute.For<ISender>();
|
||||
sender.Send(Arg.Any<ScanExpiringCredentialsCommand>(), Arg.Any<CancellationToken>())
|
||||
.Returns(OperationResult<ScanExpiringResult>.SuccessResult(new ScanExpiringResult(0, 0)));
|
||||
|
||||
var job = new CredentialExpiryScanJob(NullLogger<CredentialExpiryScanJob>.Instance);
|
||||
var provider = ProviderWith((typeof(IPlatformConfig), config), (typeof(ISender), sender));
|
||||
|
||||
var interval = await job.GetIntervalAsync(provider, default);
|
||||
await job.RunAsync(provider, default);
|
||||
|
||||
Assert.Equal(TimeSpan.FromHours(6), interval);
|
||||
await sender.Received(1).Send(Arg.Any<ScanExpiringCredentialsCommand>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoShowSweep_ReadsCadenceKey_AndSendsDetectCommand()
|
||||
{
|
||||
var config = Substitute.For<IPlatformConfig>();
|
||||
config.GetConfig<int>("no_show_scan_cadence_hours", Arg.Any<CancellationToken>())
|
||||
.Returns(new ValueTask<int>(1));
|
||||
var sender = Substitute.For<ISender>();
|
||||
sender.Send(Arg.Any<DetectNoShowSessionsCommand>(), Arg.Any<CancellationToken>())
|
||||
.Returns(OperationResult<NoShowSweepResult>.SuccessResult(new NoShowSweepResult(0)));
|
||||
|
||||
var job = new NoShowSweepJob(NullLogger<NoShowSweepJob>.Instance);
|
||||
var provider = ProviderWith((typeof(IPlatformConfig), config), (typeof(ISender), sender));
|
||||
|
||||
var interval = await job.GetIntervalAsync(provider, default);
|
||||
await job.RunAsync(provider, default);
|
||||
|
||||
Assert.Equal(TimeSpan.FromHours(1), interval);
|
||||
await sender.Received(1).Send(Arg.Any<DetectNoShowSessionsCommand>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WeeklyPayoutGeneration_ReadsInterval_AndSendsSystemInitiatedBatchOverTrailingWindow()
|
||||
{
|
||||
var config = Substitute.For<IPlatformConfig>();
|
||||
config.GetConfig<int>("nurse_payout_interval_days", Arg.Any<CancellationToken>())
|
||||
.Returns(new ValueTask<int>(7));
|
||||
var clock = Substitute.For<IDateTimeProvider>();
|
||||
clock.UtcNow.Returns(new DateTimeOffset(2026, 7, 13, 9, 0, 0, TimeSpan.Zero));
|
||||
var sender = Substitute.For<ISender>();
|
||||
sender.Send(Arg.Any<GeneratePayoutBatchCommand>(), Arg.Any<CancellationToken>())
|
||||
.Returns(OperationResult<GeneratePayoutBatchResult>.SuccessResult(
|
||||
new GeneratePayoutBatchResult(
|
||||
new PayoutBatchDto(1, new DateOnly(2026, 7, 6), new DateOnly(2026, 7, 13),
|
||||
new DateOnly(2026, 7, 13), "0", 0, "draft", null, null, null, DateTimeOffset.UtcNow),
|
||||
[], [])));
|
||||
|
||||
var job = new WeeklyPayoutGenerationJob(NullLogger<WeeklyPayoutGenerationJob>.Instance);
|
||||
var provider = ProviderWith(
|
||||
(typeof(IPlatformConfig), config), (typeof(IDateTimeProvider), clock), (typeof(ISender), sender));
|
||||
|
||||
var interval = await job.GetIntervalAsync(provider, default);
|
||||
await job.RunAsync(provider, default);
|
||||
|
||||
Assert.Equal(TimeSpan.FromDays(7), interval);
|
||||
await sender.Received(1).Send(
|
||||
Arg.Is<GeneratePayoutBatchCommand>(c =>
|
||||
c.SystemInitiated
|
||||
&& c.PeriodEnd == new DateOnly(2026, 7, 13)
|
||||
&& c.PeriodStart == new DateOnly(2026, 7, 6)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user