backend phase 8
This commit is contained in:
+55
@@ -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);
|
||||
}
|
||||
}
|
||||
+3660
File diff suppressed because it is too large
Load Diff
+135
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
@@ -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")
|
||||
|
||||
+197
@@ -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);
|
||||
}
|
||||
+2
@@ -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()
|
||||
|
||||
+6
@@ -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);
|
||||
}
|
||||
|
||||
+5
@@ -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.
|
||||
|
||||
+53
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user