backend phase 8

This commit is contained in:
hamid
2026-07-06 02:48:56 +03:30
parent 99ebf5d881
commit 2cfc082a04
55 changed files with 7480 additions and 7 deletions
@@ -0,0 +1,55 @@
using Baya.Domain.Entities.Booking;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.BookingConfig;
internal sealed class BookingRequestConfig : IEntityTypeConfiguration<BookingRequest>
{
public void Configure(EntityTypeBuilder<BookingRequest> builder)
{
builder.ToTable("BookingRequests", "booking");
// required_caregiver_gender is a closed code set (male/female/any); customer_notes is DELIBERATELY
// unencrypted, limited stage-1 clinical context — never routed through the field encryptor.
builder.Property(r => r.RequiredCaregiverGender).HasMaxLength(10);
builder.Property(r => r.CustomerNotes).HasMaxLength(1000);
builder.Property(r => r.Status).HasMaxLength(50).IsRequired();
builder.Property(r => r.NurseRejectionReason).HasMaxLength(500);
builder.Property(r => r.NurseResponseDeadlineAt).IsRequired();
// Inbox lists read on (party, status), actionable-first; the two deadline indexes let the expiry
// sweep select stale rows through a covering index instead of scanning the table.
builder.HasIndex(r => new { r.NurseId, r.Status });
builder.HasIndex(r => new { r.CustomerId, r.Status });
builder.HasIndex(r => new { r.Status, r.NurseResponseDeadlineAt });
builder.HasIndex(r => new { r.Status, r.PaymentDeadlineAt });
builder.HasOne(r => r.Customer)
.WithMany()
.HasForeignKey(r => r.CustomerId)
.IsRequired();
builder.HasOne(r => r.Nurse)
.WithMany()
.HasForeignKey(r => r.NurseId)
.IsRequired();
builder.HasOne(r => r.Patient)
.WithMany()
.HasForeignKey(r => r.PatientId)
.IsRequired();
builder.HasOne(r => r.Variant)
.WithMany()
.HasForeignKey(r => r.VariantId)
.IsRequired();
builder.HasOne(r => r.CustomerAddress)
.WithMany()
.HasForeignKey(r => r.CustomerAddressId)
.IsRequired();
builder.HasQueryFilter(r => r.DeletedAt == null);
}
}
@@ -0,0 +1,135 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class BookingRequests : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "booking");
migrationBuilder.CreateTable(
name: "BookingRequests",
schema: "booking",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CustomerId = table.Column<long>(type: "bigint", nullable: false),
NurseId = table.Column<long>(type: "bigint", nullable: false),
PatientId = table.Column<long>(type: "bigint", nullable: false),
VariantId = table.Column<long>(type: "bigint", nullable: false),
CustomerAddressId = table.Column<long>(type: "bigint", nullable: false),
RequiredCaregiverGender = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
RequestedDate = table.Column<DateOnly>(type: "date", nullable: false),
RequestedTimeStart = table.Column<TimeOnly>(type: "time", nullable: false),
RequestedTimeEnd = table.Column<TimeOnly>(type: "time", nullable: false),
CustomerNotes = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
Status = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
NurseResponseDeadlineAt = table.Column<DateTime>(type: "datetime2", nullable: false),
PaymentDeadlineAt = table.Column<DateTime>(type: "datetime2", nullable: true),
NurseRejectionReason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_BookingRequests", x => x.Id);
table.ForeignKey(
name: "FK_BookingRequests_CustomerAddresses_CustomerAddressId",
column: x => x.CustomerAddressId,
principalSchema: "usr",
principalTable: "CustomerAddresses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_BookingRequests_CustomerProfiles_CustomerId",
column: x => x.CustomerId,
principalSchema: "usr",
principalTable: "CustomerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_BookingRequests_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_BookingRequests_NurseServiceVariants_VariantId",
column: x => x.VariantId,
principalSchema: "catalog",
principalTable: "NurseServiceVariants",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_BookingRequests_Patients_PatientId",
column: x => x.PatientId,
principalSchema: "usr",
principalTable: "Patients",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_BookingRequests_CustomerAddressId",
schema: "booking",
table: "BookingRequests",
column: "CustomerAddressId");
migrationBuilder.CreateIndex(
name: "IX_BookingRequests_CustomerId_Status",
schema: "booking",
table: "BookingRequests",
columns: new[] { "CustomerId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_BookingRequests_NurseId_Status",
schema: "booking",
table: "BookingRequests",
columns: new[] { "NurseId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_BookingRequests_PatientId",
schema: "booking",
table: "BookingRequests",
column: "PatientId");
migrationBuilder.CreateIndex(
name: "IX_BookingRequests_Status_NurseResponseDeadlineAt",
schema: "booking",
table: "BookingRequests",
columns: new[] { "Status", "NurseResponseDeadlineAt" });
migrationBuilder.CreateIndex(
name: "IX_BookingRequests_Status_PaymentDeadlineAt",
schema: "booking",
table: "BookingRequests",
columns: new[] { "Status", "PaymentDeadlineAt" });
migrationBuilder.CreateIndex(
name: "IX_BookingRequests_VariantId",
schema: "booking",
table: "BookingRequests",
column: "VariantId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BookingRequests",
schema: "booking");
}
}
}
@@ -98,6 +98,95 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("AuditLogs", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<long>("CustomerAddressId")
.HasColumnType("bigint");
b.Property<long>("CustomerId")
.HasColumnType("bigint");
b.Property<string>("CustomerNotes")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NurseId")
.HasColumnType("bigint");
b.Property<string>("NurseRejectionReason")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime>("NurseResponseDeadlineAt")
.HasColumnType("datetime2");
b.Property<long>("PatientId")
.HasColumnType("bigint");
b.Property<DateTime?>("PaymentDeadlineAt")
.HasColumnType("datetime2");
b.Property<DateOnly>("RequestedDate")
.HasColumnType("date");
b.Property<TimeOnly>("RequestedTimeEnd")
.HasColumnType("time");
b.Property<TimeOnly>("RequestedTimeStart")
.HasColumnType("time");
b.Property<string>("RequiredCaregiverGender")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<long>("VariantId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("CustomerAddressId");
b.HasIndex("PatientId");
b.HasIndex("VariantId");
b.HasIndex("CustomerId", "Status");
b.HasIndex("NurseId", "Status");
b.HasIndex("Status", "NurseResponseDeadlineAt");
b.HasIndex("Status", "PaymentDeadlineAt");
b.ToTable("BookingRequests", "booking");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
{
b.Property<long>("Id")
@@ -3076,6 +3165,49 @@ namespace Baya.Infrastructure.Persistence.Migrations
.HasForeignKey("ActorUserId");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", "CustomerAddress")
.WithMany()
.HasForeignKey("CustomerAddressId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse")
.WithMany()
.HasForeignKey("NurseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.Patient", "Patient")
.WithMany()
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant")
.WithMany()
.HasForeignKey("VariantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Customer");
b.Navigation("CustomerAddress");
b.Navigation("Nurse");
b.Navigation("Patient");
b.Navigation("Variant");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse")
@@ -0,0 +1,197 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class BookingRequestRepository : BaseAsyncRepository<BookingRequest>, IBookingRequestRepository
{
public BookingRequestRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task AddAsync(BookingRequest request, CancellationToken cancellationToken)
=> base.AddAsync(request);
public Task<BookingRequest?> GetTrackedForCustomerAsync(long id, long customerId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(r => r.Id == id && r.CustomerId == customerId, cancellationToken);
public Task<BookingRequest?> GetTrackedForNurseAsync(long id, long nurseId, CancellationToken cancellationToken)
=> Table
.Include(r => r.Customer)
.FirstOrDefaultAsync(r => r.Id == id && r.NurseId == nurseId, cancellationToken);
public async Task<IReadOnlyList<BookingRequest>> GetStalePendingAsync(DateTime now, int batchSize, CancellationToken cancellationToken)
=> await Table
.Include(r => r.Customer)
.Where(r => r.Status == BookingRequestStatus.PendingNurseResponse && r.NurseResponseDeadlineAt <= now)
// Order by id (not the deadline): the whole batch is stale, order is irrelevant for correctness,
// and DateTimeOffset is not sortable on the SQLite test provider.
.OrderBy(r => r.Id)
.Take(batchSize)
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<BookingRequest>> GetStaleAcceptedAsync(DateTime now, int batchSize, CancellationToken cancellationToken)
=> await Table
.Include(r => r.Customer)
.Where(r => r.Status == BookingRequestStatus.AcceptedAwaitingPayment
&& r.PaymentDeadlineAt != null && r.PaymentDeadlineAt <= now)
.OrderBy(r => r.Id)
.Take(batchSize)
.ToListAsync(cancellationToken);
public async Task<PagedResult<BookingRequestListItemDto>> ListForCustomerAsync(
long customerId, string? status, int page, int pageSize, CancellationToken cancellationToken)
{
var query = TableNoTracking.Where(r => r.CustomerId == customerId);
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(r => r.Status == status);
var total = await query.CountAsync(cancellationToken);
var rows = await OrderActionableFirst(query)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(r => new CustomerRow(
r.Id,
r.Status,
r.Nurse.User.Name,
r.Nurse.User.FamilyName,
r.Nurse.AverageRating,
r.RequiredCaregiverGender,
r.RequestedDate,
r.RequestedTimeStart,
r.RequestedTimeEnd,
r.NurseResponseDeadlineAt,
r.PaymentDeadlineAt))
.ToListAsync(cancellationToken);
var items = rows
.Select(r => new BookingRequestListItemDto(
r.Id, r.Status, ComposeName(r.NurseName, r.NurseFamily), r.NurseRating,
r.RequiredCaregiverGender, r.RequestedDate, r.RequestedTimeStart, r.RequestedTimeEnd,
r.NurseResponseDeadlineAt, r.PaymentDeadlineAt, CustomerNotes: null))
.ToList();
return new PagedResult<BookingRequestListItemDto>(items, total, page, pageSize);
}
public async Task<PagedResult<BookingRequestListItemDto>> ListForNurseAsync(
long nurseId, string? status, int page, int pageSize, CancellationToken cancellationToken)
{
var query = TableNoTracking.Where(r => r.NurseId == nurseId);
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(r => r.Status == status);
var total = await query.CountAsync(cancellationToken);
var items = await OrderActionableFirst(query)
.Skip((page - 1) * pageSize)
.Take(pageSize)
// Nurse inbox: counterparty is the patient, and stage-1 customer_notes is the ONLY clinical
// context surfaced. No encrypted/care field is projected here, ever.
.Select(r => new BookingRequestListItemDto(
r.Id,
r.Status,
r.Patient.DisplayName,
null,
r.RequiredCaregiverGender,
r.RequestedDate,
r.RequestedTimeStart,
r.RequestedTimeEnd,
r.NurseResponseDeadlineAt,
r.PaymentDeadlineAt,
r.CustomerNotes))
.ToListAsync(cancellationToken);
return new PagedResult<BookingRequestListItemDto>(items, total, page, pageSize);
}
public async Task<BookingRequestDetailProjection?> GetDetailAsync(long id, CancellationToken cancellationToken)
{
var row = await TableNoTracking
.Where(r => r.Id == id)
.Select(r => new DetailRow(
r.Id,
r.Status,
r.CustomerId,
r.NurseId,
r.Nurse.User.Name,
r.Nurse.User.FamilyName,
r.Nurse.AverageRating,
r.Nurse.TotalReviews,
r.PatientId,
r.Patient.DisplayName,
r.VariantId,
r.Variant.DisplayName,
r.Variant.PriceUnit,
r.CustomerAddressId,
r.CustomerAddress.Title,
r.CustomerAddress.CityId,
r.CustomerAddress.City.NameFa,
r.CustomerAddress.City.NameEn,
r.CustomerAddress.DistrictId,
r.CustomerAddress.DistrictId == null ? null : r.CustomerAddress.District.NameFa,
r.CustomerAddress.DistrictId == null ? null : r.CustomerAddress.District.NameEn,
r.CustomerAddress.AddressLine,
r.CustomerAddress.PostalCode,
r.CustomerAddress.RecipientName,
r.CustomerAddress.RecipientPhone,
r.RequiredCaregiverGender,
r.RequestedDate,
r.RequestedTimeStart,
r.RequestedTimeEnd,
r.CustomerNotes,
r.NurseResponseDeadlineAt,
r.PaymentDeadlineAt,
r.NurseRejectionReason,
r.CreatedAt))
.FirstOrDefaultAsync(cancellationToken);
if (row is null)
return null;
return new BookingRequestDetailProjection(
row.Id, row.Status, row.CustomerId, row.NurseId,
ComposeName(row.NurseName, row.NurseFamily), row.NurseRating, row.NurseTotalReviews,
row.PatientId, row.PatientName,
row.VariantId, row.VariantLabel, row.VariantPriceUnit,
row.CustomerAddressId, row.AddressTitle, row.CityId, row.CityNameFa, row.CityNameEn,
row.DistrictId, row.DistrictNameFa, row.DistrictNameEn,
row.AddressLine, row.PostalCode, row.RecipientName, row.RecipientPhone,
row.RequiredCaregiverGender, row.RequestedDate, row.RequestedTimeStart, row.RequestedTimeEnd,
row.CustomerNotes, row.NurseResponseDeadlineAt, row.PaymentDeadlineAt,
row.NurseRejectionReason, row.CreatedAt);
}
// Actionable (awaiting a party's action) rows float above terminal ones, then most-recent first. Recency
// (id) rather than the deadline is the secondary key: for a given status the deadline tracks creation
// time anyway, and DateTimeOffset is not sortable on the SQLite test provider.
private static IQueryable<BookingRequest> OrderActionableFirst(IQueryable<BookingRequest> query)
=> query
.OrderByDescending(r => r.Status == BookingRequestStatus.PendingNurseResponse
|| r.Status == BookingRequestStatus.AcceptedAwaitingPayment)
.ThenByDescending(r => r.Id);
private static string ComposeName(string? name, string? familyName)
=> string.Join(' ', new[] { name, familyName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
private sealed record CustomerRow(
long Id, string Status, string? NurseName, string? NurseFamily, decimal NurseRating,
string? RequiredCaregiverGender, DateOnly RequestedDate, TimeOnly RequestedTimeStart, TimeOnly RequestedTimeEnd,
DateTime NurseResponseDeadlineAt, DateTime? PaymentDeadlineAt);
private sealed record DetailRow(
long Id, string Status, long CustomerId, long NurseId,
string? NurseName, string? NurseFamily, decimal NurseRating, int NurseTotalReviews,
long PatientId, string PatientName,
long VariantId, string VariantLabel, string VariantPriceUnit,
long CustomerAddressId, string AddressTitle, long CityId, string CityNameFa, string CityNameEn,
long? DistrictId, string? DistrictNameFa, string? DistrictNameEn,
string? AddressLine, string? PostalCode, string? RecipientName, string? RecipientPhone,
string? RequiredCaregiverGender, DateOnly RequestedDate, TimeOnly RequestedTimeStart, TimeOnly RequestedTimeEnd,
string? CustomerNotes, DateTime NurseResponseDeadlineAt, DateTime? PaymentDeadlineAt,
string? NurseRejectionReason, DateTimeOffset CreatedAt);
}
@@ -19,6 +19,7 @@ public class UnitOfWork : IUnitOfWork
public ICatalogRepository CatalogRepository { get; }
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
public IVerificationRepository VerificationRepository { get; }
public IBookingRequestRepository BookingRequestRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -36,6 +37,7 @@ public class UnitOfWork : IUnitOfWork
CatalogRepository = new CatalogRepository(_db);
NurseServiceVariantRepository = new NurseServiceVariantRepository(_db);
VerificationRepository = new VerificationRepository(_db);
BookingRequestRepository = new BookingRequestRepository(_db);
}
public Task CommitAsync()
@@ -49,4 +49,10 @@ internal sealed class NurseProfileRepository : BaseAsyncRepository<NurseProfile>
.Where(p => p.UserId == userId)
.Select(p => new NurseIdentityContext(p.Id, p.User.NationalId))
.FirstOrDefaultAsync(cancellationToken);
public Task<NurseBookingContext> GetBookingContextByIdAsync(long nurseProfileId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(p => p.Id == nurseProfileId)
.Select(p => new NurseBookingContext(p.UserId, p.User.Gender, p.IsVerified, p.IsAcceptingBookings))
.FirstOrDefaultAsync(cancellationToken);
}
@@ -11,6 +11,7 @@ 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;
@@ -53,6 +54,10 @@ public static class ServiceCollectionExtensions
// 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>();
// 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
// MVP backend; a later ElasticNurseSearch drops in here with no caller change.
@@ -0,0 +1,53 @@
#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");
}
}
}