backend phase 9

This commit is contained in:
hamid
2026-07-06 19:23:44 +03:30
parent 2cfc082a04
commit 12c7e51c32
101 changed files with 11666 additions and 8 deletions
@@ -0,0 +1,28 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Deterministic mock <see cref="IPaymentCaptureSimulator"/> — the temporary conversion trigger until b10's
/// real card capture. It returns a <i>succeeded</i> capture with a stable fake gateway reference and the
/// configured PSP fee, so <c>ConvertRequestToBookingCommand</c> can be exercised end-to-end now. Set
/// <see cref="PaymentCaptureOptions.ForceFailure"/> to exercise the "capture failed → no booking" path.
/// The real capture replaces this registration in b10 — no mock behaviour is baked into any handler.
/// </summary>
public sealed class MockPaymentCaptureSimulator(IOptions<SeamOptions> options) : IPaymentCaptureSimulator
{
private readonly PaymentCaptureOptions _options = options.Value.PaymentCapture;
public ValueTask<PaymentCaptureResult> ConfirmCaptureAsync(long bookingRequestId, CancellationToken cancellationToken = default)
{
if (_options.ForceFailure)
return ValueTask.FromResult(new PaymentCaptureResult(false, string.Empty, null));
return ValueTask.FromResult(new PaymentCaptureResult(
Succeeded: true,
GatewayReference: $"mock-capture-{bookingRequestId}",
PspFeeAmount: _options.PspFeeAmount));
}
}
@@ -14,6 +14,21 @@ public sealed class SeamOptions
public GeocodingOptions Geocoding { get; set; } = new();
public ShahkarOptions Shahkar { get; set; } = new();
public IdentityKycOptions IdentityKyc { get; set; } = new();
public PaymentCaptureOptions PaymentCapture { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>IPaymentCaptureSimulator</c> (backend-phase-9). By default every capture succeeds with
/// a fake gateway reference and <see cref="PspFeeAmount"/>. Set <see cref="ForceFailure"/> to exercise the
/// "capture failed → no booking" path. The real b10 card capture ignores these.
/// </summary>
public sealed class PaymentCaptureOptions
{
/// <summary>When true, every capture returns a failed result so conversion refuses to create a booking.</summary>
public bool ForceFailure { get; set; }
/// <summary>The PSP/gateway fee (IRR) the mock reports on a successful capture.</summary>
public long? PspFeeAmount { get; set; }
}
/// <summary>
@@ -43,6 +43,11 @@ public static class ServiceCollectionExtension
services.AddSingleton<IIdentityKycProvider, MockIdentityKycProvider>();
services.AddSingleton<ICredentialVerifier, MockCredentialVerifier>();
// Payment-capture trigger (backend-phase-9). The mock returns a deterministic succeeded capture so
// ConvertRequestToBooking is testable now; in b10 the real card capture replaces this registration
// and calls ConvertRequestToBooking directly on a real payment_transactions.succeeded.
services.AddSingleton<IPaymentCaptureSimulator, MockPaymentCaptureSimulator>();
return services;
}
}
@@ -1,6 +1,7 @@
using System.Reflection;
using Baya.Application.Contracts.Common;
using Baya.Domain.Common;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
@@ -139,5 +140,22 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
{
builder.Property(c => c.CredentialNumber).HasConversion(encrypted);
});
// b9 snapshot + clinical PII: the frozen address snapshot and every booking_care_instructions field
// are encrypted at rest through the same seam. The care fields are the stage-2 clinical disclosure —
// decrypted only in the gated care-instructions read (assigned nurse + admin, post-confirmation).
modelBuilder.Entity<Booking>(builder =>
{
builder.Property(b => b.AddressSnapshotJson).HasConversion(encrypted);
});
modelBuilder.Entity<BookingCareInstruction>(builder =>
{
builder.Property(c => c.CurrentConditions).HasConversion(encrypted);
builder.Property(c => c.Medications).HasConversion(encrypted);
builder.Property(c => c.Allergies).HasConversion(encrypted);
builder.Property(c => c.SpecialInstructions).HasConversion(encrypted);
builder.Property(c => c.EmergencyContactName).HasConversion(encrypted);
builder.Property(c => c.EmergencyContactPhone).HasConversion(encrypted);
});
}
}
@@ -0,0 +1,24 @@
using Baya.Domain.Entities.Booking;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.BookingConfig;
internal sealed class BookingCareInstructionConfig : IEntityTypeConfiguration<BookingCareInstruction>
{
public void Configure(EntityTypeBuilder<BookingCareInstruction> builder)
{
builder.ToTable("BookingCareInstructions", "booking");
// All clinical fields are encrypted at rest (converters wired in ApplicationDbContext) and are
// nvarchar(max); shorter contact fields still ride the same seam, so no length cap here.
// 1:1 with the booking (UNIQUE index created automatically).
builder.HasOne(c => c.Booking)
.WithOne(b => b.CareInstructions)
.HasForeignKey<BookingCareInstruction>(c => c.BookingId)
.IsRequired();
builder.HasQueryFilter(c => c.DeletedAt == null);
}
}
@@ -0,0 +1,56 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.BookingConfig;
internal sealed class BookingConfig : IEntityTypeConfiguration<Booking>
{
public void Configure(EntityTypeBuilder<Booking> builder)
{
// The three-amount split is a DB CHECK (not handler-only): gross = commission + payout, all ≥ 0.
builder.ToTable("Bookings", "booking", t => t.HasCheckConstraint(
"CK_Bookings_AmountSplit",
"[GrossPriceIrr] = [BalinyaarCommissionIrr] + [NursePayoutAmount] " +
"AND [GrossPriceIrr] >= 0 AND [BalinyaarCommissionIrr] >= 0 AND [NursePayoutAmount] >= 0"));
// Snapshots freeze history. AddressSnapshotJson is encrypted at rest (converter wired in
// ApplicationDbContext); both are nvarchar(max) so no length cap.
builder.Property(b => b.VariantSnapshotJson).IsRequired();
builder.Property(b => b.AddressSnapshotJson).IsRequired();
builder.Property(b => b.PlatformFeeRate).HasPrecision(5, 4);
builder.Property(b => b.CancellationRefundPercentage).HasPrecision(5, 2);
builder.Property(b => b.Status).HasMaxLength(30).IsRequired();
builder.Property(b => b.CancellationReason).HasMaxLength(500);
builder.Property(b => b.CancelledBy).HasMaxLength(20);
builder.Property(b => b.CancellationPolicyCode).HasMaxLength(50);
builder.HasIndex(b => new { b.CustomerId, b.Status });
builder.HasIndex(b => new { b.NurseId, b.Status });
// b13 selects payout-eligible bookings through the dispute-window close.
builder.HasIndex(b => b.DisputeWindowEndsAt);
// 1:1 with the request that created it (UNIQUE index created automatically). No navigation either
// side — the request is read by id at conversion time.
builder.HasOne<BookingRequest>()
.WithOne()
.HasForeignKey<Booking>(b => b.BookingRequestId)
.IsRequired();
// Denormalized FKs for query performance (no navigations on Booking). partner_center_id is left
// FK-less and nullable — partner_centers is DEFERRED to b15.
builder.HasOne<CustomerProfile>().WithMany().HasForeignKey(b => b.CustomerId).IsRequired();
builder.HasOne<NurseProfile>().WithMany().HasForeignKey(b => b.NurseId).IsRequired();
builder.HasOne<Patient>().WithMany().HasForeignKey(b => b.PatientId).IsRequired();
builder.HasOne<NurseServiceVariant>().WithMany().HasForeignKey(b => b.VariantId).IsRequired();
builder.HasOne<CustomerAddress>().WithMany().HasForeignKey(b => b.CustomerAddressId).IsRequired();
builder.HasMany(b => b.Sessions).WithOne(s => s.Booking).HasForeignKey(s => s.BookingId);
builder.HasQueryFilter(b => b.DeletedAt == null);
}
}
@@ -0,0 +1,26 @@
using Baya.Domain.Entities.Booking;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.BookingConfig;
internal sealed class BookingSessionConfig : IEntityTypeConfiguration<BookingSession>
{
public void Configure(EntityTypeBuilder<BookingSession> builder)
{
builder.ToTable("BookingSessions", "booking");
builder.Property(s => s.Status).HasMaxLength(20).IsRequired();
// The nurse's "today" view and the no-show sweep both read (status, scheduled_date).
builder.HasIndex(s => new { s.BookingId, s.SessionIndex });
builder.HasIndex(s => new { s.Status, s.ScheduledDate });
// 1:1 with its EVV record (the FK is on the verification side, on the session).
builder.HasOne(s => s.Verification)
.WithOne(v => v.Session)
.HasForeignKey<VisitVerification>(v => v.BookingSessionId);
builder.HasQueryFilter(s => s.DeletedAt == null);
}
}
@@ -0,0 +1,59 @@
using Baya.Domain.Entities.Booking;
using Baya.Infrastructure.Persistence.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.BookingConfig;
internal sealed class CancellationPolicyConfig : IEntityTypeConfiguration<CancellationPolicy>
{
public void Configure(EntityTypeBuilder<CancellationPolicy> builder)
{
builder.ToTable("CancellationPolicies", "booking");
builder.Property(p => p.Code).HasMaxLength(50).IsRequired();
builder.Property(p => p.AppliesTo).HasMaxLength(20).IsRequired();
builder.Property(p => p.RefundPercentage).HasPrecision(5, 2);
builder.Property(p => p.FeeRate).HasPrecision(5, 4);
builder.HasIndex(p => p.Code).IsUnique();
builder.HasIndex(p => new { p.AppliesTo, p.IsActive });
builder.HasQueryFilter(p => p.DeletedAt == null);
builder.HasData(Seed());
}
// Baseline tiers, seeded via HasData so they land with this phase's migration on a fresh DB. Numbers the
// product doc leaves open (the nurse penalty) default safe and are flagged in the phase report — the
// nurse-no-show penalty is modelled (FeeRate) but posting it is deferred to payouts (b13).
private static object[] Seed()
{
var ts = SeedConstants.Timestamp;
(long Id, string Code, string AppliesTo, int? Min, int? Max, decimal Refund, long Fee, decimal? Rate)[] rows =
[
(1, CancellationPolicyCode.Standard24h, CancellationActor.Customer, 24, null, 100m, 0L, null),
// Open lower bound so a cancel < 24h before start (or after it) always resolves a customer tier.
(2, CancellationPolicyCode.StandardInside24h, CancellationActor.Customer, null, 24, 50m, 0L, null),
(3, CancellationPolicyCode.NurseNoShow, CancellationActor.Nurse, null, null, 100m, 0L, 0m),
(4, CancellationPolicyCode.AdminCancellation, CancellationActor.Admin, null, null, 100m, 0L, null),
];
return rows
.Select(r => (object)new
{
r.Id,
r.Code,
r.AppliesTo,
HoursBeforeStartMin = r.Min,
HoursBeforeStartMax = r.Max,
RefundPercentage = r.Refund,
FeeAmountIrr = r.Fee,
FeeRate = r.Rate,
IsActive = true,
CreatedAt = ts
})
.ToArray();
}
}
@@ -0,0 +1,27 @@
using Baya.Domain.Entities.Booking;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.BookingConfig;
internal sealed class VisitVerificationConfig : IEntityTypeConfiguration<VisitVerification>
{
public void Configure(EntityTypeBuilder<VisitVerification> builder)
{
builder.ToTable("VisitVerifications", "booking");
builder.Property(v => v.Status).HasMaxLength(20).IsRequired();
builder.Property(v => v.CheckInLat).HasPrecision(9, 6);
builder.Property(v => v.CheckInLng).HasPrecision(9, 6);
builder.Property(v => v.CheckOutLat).HasPrecision(9, 6);
builder.Property(v => v.CheckOutLng).HasPrecision(9, 6);
builder.Property(v => v.CheckInDistanceMeters).HasPrecision(10, 2);
// The FK/1:1 to the session is configured on BookingSessionConfig; the UNIQUE index on
// BookingSessionId is created automatically. The admin mismatch queue reads on the advisory flag.
builder.HasIndex(v => v.CheckInAddressMatch);
builder.HasQueryFilter(v => v.DeletedAt == null);
}
}
@@ -44,6 +44,8 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration<PlatformCo
(14, "auth_otp_max_attempts", "5", ConfigDataType.Int, "Wrong-code attempts allowed before OTP verification is refused until a fresh code."),
(15, "auth_session_ttl_days", "30", ConfigDataType.Int, "Refresh-token session lifetime in days."),
(16, "verification_expiry_scan_cadence_hours", "24", ConfigDataType.Int, "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today)."),
(17, "no_show_threshold_minutes", "60", ConfigDataType.Int, "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show."),
(18, "no_show_scan_cadence_hours", "1", ConfigDataType.Int, "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today)."),
];
return rows
@@ -0,0 +1,379 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class BookingsSessionsEvvCancellation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Bookings",
schema: "booking",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BookingRequestId = table.Column<long>(type: "bigint", nullable: false),
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),
PartnerCenterId = table.Column<long>(type: "bigint", nullable: true),
VariantSnapshotJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
AddressSnapshotJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
GrossPriceIrr = table.Column<long>(type: "bigint", nullable: false),
BalinyaarCommissionIrr = table.Column<long>(type: "bigint", nullable: false),
PlatformFeeRate = table.Column<decimal>(type: "decimal(5,4)", precision: 5, scale: 4, nullable: false),
NursePayoutAmount = table.Column<long>(type: "bigint", nullable: false),
PspFeeAmount = table.Column<long>(type: "bigint", nullable: true),
SessionCount = table.Column<short>(type: "smallint", nullable: false),
ScheduledDate = table.Column<DateOnly>(type: "date", nullable: false),
ScheduledTimeStart = table.Column<TimeOnly>(type: "time", nullable: false),
ScheduledTimeEnd = table.Column<TimeOnly>(type: "time", nullable: false),
Status = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false),
ConfirmedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CancelledAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CancellationReason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CancelledBy = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
CancellationPolicyCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
CancellationRefundPercentage = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: true),
RefundableAmountIrr = table.Column<long>(type: "bigint", nullable: true),
CompletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DisputeWindowEndsAt = table.Column<DateTime>(type: "datetime2", 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_Bookings", x => x.Id);
table.CheckConstraint("CK_Bookings_AmountSplit", "[GrossPriceIrr] = [BalinyaarCommissionIrr] + [NursePayoutAmount] AND [GrossPriceIrr] >= 0 AND [BalinyaarCommissionIrr] >= 0 AND [NursePayoutAmount] >= 0");
table.ForeignKey(
name: "FK_Bookings_BookingRequests_BookingRequestId",
column: x => x.BookingRequestId,
principalSchema: "booking",
principalTable: "BookingRequests",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Bookings_CustomerAddresses_CustomerAddressId",
column: x => x.CustomerAddressId,
principalSchema: "usr",
principalTable: "CustomerAddresses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Bookings_CustomerProfiles_CustomerId",
column: x => x.CustomerId,
principalSchema: "usr",
principalTable: "CustomerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Bookings_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Bookings_NurseServiceVariants_VariantId",
column: x => x.VariantId,
principalSchema: "catalog",
principalTable: "NurseServiceVariants",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Bookings_Patients_PatientId",
column: x => x.PatientId,
principalSchema: "usr",
principalTable: "Patients",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "CancellationPolicies",
schema: "booking",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Code = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
AppliesTo = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
HoursBeforeStartMin = table.Column<int>(type: "int", nullable: true),
HoursBeforeStartMax = table.Column<int>(type: "int", nullable: true),
RefundPercentage = table.Column<decimal>(type: "decimal(5,2)", precision: 5, scale: 2, nullable: false),
FeeAmountIrr = table.Column<long>(type: "bigint", nullable: false),
FeeRate = table.Column<decimal>(type: "decimal(5,4)", precision: 5, scale: 4, nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
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_CancellationPolicies", x => x.Id);
});
migrationBuilder.CreateTable(
name: "BookingCareInstructions",
schema: "booking",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BookingId = table.Column<long>(type: "bigint", nullable: false),
CurrentConditions = table.Column<string>(type: "nvarchar(max)", nullable: true),
Medications = table.Column<string>(type: "nvarchar(max)", nullable: true),
Allergies = table.Column<string>(type: "nvarchar(max)", nullable: true),
SpecialInstructions = table.Column<string>(type: "nvarchar(max)", nullable: true),
EmergencyContactName = table.Column<string>(type: "nvarchar(max)", nullable: true),
EmergencyContactPhone = table.Column<string>(type: "nvarchar(max)", 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_BookingCareInstructions", x => x.Id);
table.ForeignKey(
name: "FK_BookingCareInstructions_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "BookingSessions",
schema: "booking",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BookingId = table.Column<long>(type: "bigint", nullable: false),
SessionIndex = table.Column<int>(type: "int", nullable: false),
ScheduledDate = table.Column<DateOnly>(type: "date", nullable: false),
ScheduledTimeStart = table.Column<TimeOnly>(type: "time", nullable: false),
ScheduledTimeEnd = table.Column<TimeOnly>(type: "time", nullable: false),
VisitPayoutAmount = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
PayoutEligibleAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CancellationEventId = table.Column<long>(type: "bigint", 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_BookingSessions", x => x.Id);
table.ForeignKey(
name: "FK_BookingSessions_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "VisitVerifications",
schema: "booking",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BookingSessionId = table.Column<long>(type: "bigint", nullable: false),
CheckInAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CheckInLat = table.Column<decimal>(type: "decimal(9,6)", precision: 9, scale: 6, nullable: true),
CheckInLng = table.Column<decimal>(type: "decimal(9,6)", precision: 9, scale: 6, nullable: true),
CheckOutAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CheckOutLat = table.Column<decimal>(type: "decimal(9,6)", precision: 9, scale: 6, nullable: true),
CheckOutLng = table.Column<decimal>(type: "decimal(9,6)", precision: 9, scale: 6, nullable: true),
CheckInAddressMatch = table.Column<bool>(type: "bit", nullable: true),
CheckInDistanceMeters = table.Column<decimal>(type: "decimal(10,2)", precision: 10, scale: 2, nullable: true),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
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_VisitVerifications", x => x.Id);
table.ForeignKey(
name: "FK_VisitVerifications_BookingSessions_BookingSessionId",
column: x => x.BookingSessionId,
principalSchema: "booking",
principalTable: "BookingSessions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.InsertData(
schema: "booking",
table: "CancellationPolicies",
columns: new[] { "Id", "AppliesTo", "Code", "CreatedAt", "CreatedById", "DeletedAt", "FeeAmountIrr", "FeeRate", "HoursBeforeStartMax", "HoursBeforeStartMin", "IsActive", "ModifiedAt", "ModifiedById", "RefundPercentage" },
values: new object[,]
{
{ 1L, "customer", "standard_24h", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, 0L, null, null, 24, true, null, null, 100m },
{ 2L, "customer", "standard_inside_24h", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, 0L, null, 24, null, true, null, null, 50m },
{ 3L, "nurse", "nurse_no_show", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, 0L, 0m, null, null, true, null, null, 100m },
{ 4L, "admin", "admin_cancellation", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, 0L, null, null, null, true, null, null, 100m }
});
migrationBuilder.InsertData(
schema: "ops",
table: "PlatformConfigs",
columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" },
values: new object[,]
{
{ 17L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show.", "no_show_threshold_minutes", null, null, "60" },
{ 18L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today).", "no_show_scan_cadence_hours", null, null, "1" }
});
migrationBuilder.CreateIndex(
name: "IX_BookingCareInstructions_BookingId",
schema: "booking",
table: "BookingCareInstructions",
column: "BookingId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Bookings_BookingRequestId",
schema: "booking",
table: "Bookings",
column: "BookingRequestId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Bookings_CustomerAddressId",
schema: "booking",
table: "Bookings",
column: "CustomerAddressId");
migrationBuilder.CreateIndex(
name: "IX_Bookings_CustomerId_Status",
schema: "booking",
table: "Bookings",
columns: new[] { "CustomerId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_Bookings_DisputeWindowEndsAt",
schema: "booking",
table: "Bookings",
column: "DisputeWindowEndsAt");
migrationBuilder.CreateIndex(
name: "IX_Bookings_NurseId_Status",
schema: "booking",
table: "Bookings",
columns: new[] { "NurseId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_Bookings_PatientId",
schema: "booking",
table: "Bookings",
column: "PatientId");
migrationBuilder.CreateIndex(
name: "IX_Bookings_VariantId",
schema: "booking",
table: "Bookings",
column: "VariantId");
migrationBuilder.CreateIndex(
name: "IX_BookingSessions_BookingId_SessionIndex",
schema: "booking",
table: "BookingSessions",
columns: new[] { "BookingId", "SessionIndex" });
migrationBuilder.CreateIndex(
name: "IX_BookingSessions_Status_ScheduledDate",
schema: "booking",
table: "BookingSessions",
columns: new[] { "Status", "ScheduledDate" });
migrationBuilder.CreateIndex(
name: "IX_CancellationPolicies_AppliesTo_IsActive",
schema: "booking",
table: "CancellationPolicies",
columns: new[] { "AppliesTo", "IsActive" });
migrationBuilder.CreateIndex(
name: "IX_CancellationPolicies_Code",
schema: "booking",
table: "CancellationPolicies",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_VisitVerifications_BookingSessionId",
schema: "booking",
table: "VisitVerifications",
column: "BookingSessionId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_VisitVerifications_CheckInAddressMatch",
schema: "booking",
table: "VisitVerifications",
column: "CheckInAddressMatch");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BookingCareInstructions",
schema: "booking");
migrationBuilder.DropTable(
name: "CancellationPolicies",
schema: "booking");
migrationBuilder.DropTable(
name: "VisitVerifications",
schema: "booking");
migrationBuilder.DropTable(
name: "BookingSessions",
schema: "booking");
migrationBuilder.DropTable(
name: "Bookings",
schema: "booking");
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 17L);
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 18L);
}
}
}
@@ -98,6 +98,197 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("AuditLogs", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AddressSnapshotJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<long>("BalinyaarCommissionIrr")
.HasColumnType("bigint");
b.Property<long>("BookingRequestId")
.HasColumnType("bigint");
b.Property<string>("CancellationPolicyCode")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("CancellationReason")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<decimal?>("CancellationRefundPercentage")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.Property<DateTime?>("CancelledAt")
.HasColumnType("datetime2");
b.Property<string>("CancelledBy")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("ConfirmedAt")
.HasColumnType("datetime2");
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<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTime?>("DisputeWindowEndsAt")
.HasColumnType("datetime2");
b.Property<long>("GrossPriceIrr")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NurseId")
.HasColumnType("bigint");
b.Property<long>("NursePayoutAmount")
.HasColumnType("bigint");
b.Property<long?>("PartnerCenterId")
.HasColumnType("bigint");
b.Property<long>("PatientId")
.HasColumnType("bigint");
b.Property<decimal>("PlatformFeeRate")
.HasPrecision(5, 4)
.HasColumnType("decimal(5,4)");
b.Property<long?>("PspFeeAmount")
.HasColumnType("bigint");
b.Property<long?>("RefundableAmountIrr")
.HasColumnType("bigint");
b.Property<DateOnly>("ScheduledDate")
.HasColumnType("date");
b.Property<TimeOnly>("ScheduledTimeEnd")
.HasColumnType("time");
b.Property<TimeOnly>("ScheduledTimeStart")
.HasColumnType("time");
b.Property<short>("SessionCount")
.HasColumnType("smallint");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.Property<long>("VariantId")
.HasColumnType("bigint");
b.Property<string>("VariantSnapshotJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("BookingRequestId")
.IsUnique();
b.HasIndex("CustomerAddressId");
b.HasIndex("DisputeWindowEndsAt");
b.HasIndex("PatientId");
b.HasIndex("VariantId");
b.HasIndex("CustomerId", "Status");
b.HasIndex("NurseId", "Status");
b.ToTable("Bookings", "booking", t =>
{
t.HasCheckConstraint("CK_Bookings_AmountSplit", "[GrossPriceIrr] = [BalinyaarCommissionIrr] + [NursePayoutAmount] AND [GrossPriceIrr] >= 0 AND [BalinyaarCommissionIrr] >= 0 AND [NursePayoutAmount] >= 0");
});
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingCareInstruction", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Allergies")
.HasColumnType("nvarchar(max)");
b.Property<long>("BookingId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("CurrentConditions")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("EmergencyContactName")
.HasColumnType("nvarchar(max)");
b.Property<string>("EmergencyContactPhone")
.HasColumnType("nvarchar(max)");
b.Property<string>("Medications")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("SpecialInstructions")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("BookingId")
.IsUnique();
b.ToTable("BookingCareInstructions", "booking");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b =>
{
b.Property<long>("Id")
@@ -187,6 +378,245 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("BookingRequests", "booking");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long>("BookingId")
.HasColumnType("bigint");
b.Property<long?>("CancellationEventId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<DateTime?>("PayoutEligibleAt")
.HasColumnType("datetime2");
b.Property<DateOnly>("ScheduledDate")
.HasColumnType("date");
b.Property<TimeOnly>("ScheduledTimeEnd")
.HasColumnType("time");
b.Property<TimeOnly>("ScheduledTimeStart")
.HasColumnType("time");
b.Property<int>("SessionIndex")
.HasColumnType("int");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<long>("VisitPayoutAmount")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("BookingId", "SessionIndex");
b.HasIndex("Status", "ScheduledDate");
b.ToTable("BookingSessions", "booking");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.CancellationPolicy", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AppliesTo")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<long>("FeeAmountIrr")
.HasColumnType("bigint");
b.Property<decimal?>("FeeRate")
.HasPrecision(5, 4)
.HasColumnType("decimal(5,4)");
b.Property<int?>("HoursBeforeStartMax")
.HasColumnType("int");
b.Property<int?>("HoursBeforeStartMin")
.HasColumnType("int");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<decimal>("RefundPercentage")
.HasPrecision(5, 2)
.HasColumnType("decimal(5,2)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("AppliesTo", "IsActive");
b.ToTable("CancellationPolicies", "booking");
b.HasData(
new
{
Id = 1L,
AppliesTo = "customer",
Code = "standard_24h",
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
FeeAmountIrr = 0L,
HoursBeforeStartMin = 24,
IsActive = true,
RefundPercentage = 100m
},
new
{
Id = 2L,
AppliesTo = "customer",
Code = "standard_inside_24h",
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
FeeAmountIrr = 0L,
HoursBeforeStartMax = 24,
IsActive = true,
RefundPercentage = 50m
},
new
{
Id = 3L,
AppliesTo = "nurse",
Code = "nurse_no_show",
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
FeeAmountIrr = 0L,
FeeRate = 0m,
IsActive = true,
RefundPercentage = 100m
},
new
{
Id = 4L,
AppliesTo = "admin",
Code = "admin_cancellation",
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
FeeAmountIrr = 0L,
IsActive = true,
RefundPercentage = 100m
});
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.VisitVerification", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long>("BookingSessionId")
.HasColumnType("bigint");
b.Property<bool?>("CheckInAddressMatch")
.HasColumnType("bit");
b.Property<DateTime?>("CheckInAt")
.HasColumnType("datetime2");
b.Property<decimal?>("CheckInDistanceMeters")
.HasPrecision(10, 2)
.HasColumnType("decimal(10,2)");
b.Property<decimal?>("CheckInLat")
.HasPrecision(9, 6)
.HasColumnType("decimal(9,6)");
b.Property<decimal?>("CheckInLng")
.HasPrecision(9, 6)
.HasColumnType("decimal(9,6)");
b.Property<DateTime?>("CheckOutAt")
.HasColumnType("datetime2");
b.Property<decimal?>("CheckOutLat")
.HasPrecision(9, 6)
.HasColumnType("decimal(9,6)");
b.Property<decimal?>("CheckOutLng")
.HasPrecision(9, 6)
.HasColumnType("decimal(9,6)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.HasKey("Id");
b.HasIndex("BookingSessionId")
.IsUnique();
b.HasIndex("CheckInAddressMatch");
b.ToTable("VisitVerifications", "booking");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
{
b.Property<long>("Id")
@@ -707,6 +1137,24 @@ namespace Baya.Infrastructure.Persistence.Migrations
Description = "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today).",
Key = "verification_expiry_scan_cadence_hours",
Value = "24"
},
new
{
Id = 17L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "int",
Description = "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show.",
Key = "no_show_threshold_minutes",
Value = "60"
},
new
{
Id = 18L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "int",
Description = "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today).",
Key = "no_show_scan_cadence_hours",
Value = "1"
});
});
@@ -3165,6 +3613,56 @@ namespace Baya.Infrastructure.Persistence.Migrations
.HasForeignKey("ActorUserId");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null)
.WithOne()
.HasForeignKey("Baya.Domain.Entities.Booking.Booking", "BookingRequestId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", null)
.WithMany()
.HasForeignKey("CustomerAddressId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null)
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
.WithMany()
.HasForeignKey("NurseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.Patient", null)
.WithMany()
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", null)
.WithMany()
.HasForeignKey("VariantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingCareInstruction", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", "Booking")
.WithOne("CareInstructions")
.HasForeignKey("Baya.Domain.Entities.Booking.BookingCareInstruction", "BookingId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Booking");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", "CustomerAddress")
@@ -3208,6 +3706,28 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.Navigation("Variant");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", "Booking")
.WithMany("Sessions")
.HasForeignKey("BookingId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Booking");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.VisitVerification", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.BookingSession", "Session")
.WithOne("Verification")
.HasForeignKey("Baya.Domain.Entities.Booking.VisitVerification", "BookingSessionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Session");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse")
@@ -3578,6 +4098,18 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.Navigation("StepType");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b =>
{
b.Navigation("CareInstructions");
b.Navigation("Sessions");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b =>
{
b.Navigation("Verification");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
{
b.Navigation("Options");
@@ -0,0 +1,272 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class BookingRepository : BaseAsyncRepository<Booking>, IBookingRepository
{
public BookingRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task AddAsync(Booking booking, CancellationToken cancellationToken)
=> base.AddAsync(booking);
public Task<long?> GetBookingIdByRequestIdAsync(long bookingRequestId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(b => b.BookingRequestId == bookingRequestId)
.Select(b => (long?)b.Id)
.FirstOrDefaultAsync(cancellationToken);
public async Task<BookingDetailProjection?> GetDetailAsync(long id, CancellationToken cancellationToken)
{
var row = await (
from b in TableNoTracking
where b.Id == id
join n in DbContext.Set<NurseProfile>() on b.NurseId equals n.Id
join p in DbContext.Set<Patient>() on b.PatientId equals p.Id
select new
{
Booking = b,
NurseName = n.User.Name,
NurseFamily = n.User.FamilyName,
PatientName = p.DisplayName,
Sessions = b.Sessions
.OrderBy(s => s.SessionIndex)
.Select(s => new BookingSessionProjection(
s.Id,
s.SessionIndex,
s.ScheduledDate,
s.ScheduledTimeStart,
s.ScheduledTimeEnd,
s.Status,
s.VisitPayoutAmount,
s.PayoutEligibleAt,
s.Verification == null ? null : s.Verification.Status,
s.Verification == null ? null : s.Verification.CheckInAt,
s.Verification == null ? null : s.Verification.CheckOutAt,
s.Verification == null ? null : s.Verification.CheckInAddressMatch))
.ToList()
})
.FirstOrDefaultAsync(cancellationToken);
if (row is null)
return null;
var bk = row.Booking;
return new BookingDetailProjection(
bk.Id, bk.BookingRequestId, bk.Status, bk.CustomerId, bk.NurseId,
ComposeName(row.NurseName, row.NurseFamily), bk.PatientId, row.PatientName,
bk.VariantId, bk.VariantSnapshotJson, bk.CustomerAddressId, bk.AddressSnapshotJson,
bk.GrossPriceIrr, bk.BalinyaarCommissionIrr, bk.PlatformFeeRate, bk.NursePayoutAmount, bk.PspFeeAmount,
bk.SessionCount, bk.ScheduledDate, bk.ScheduledTimeStart, bk.ScheduledTimeEnd,
bk.ConfirmedAt, bk.CompletedAt, bk.CancelledAt, bk.CancelledBy, bk.CancellationReason,
bk.CancellationPolicyCode, bk.CancellationRefundPercentage, bk.RefundableAmountIrr,
bk.DisputeWindowEndsAt, bk.CreatedAt, row.Sessions);
}
public Task<PagedResult<BookingListItemDto>> ListForCustomerAsync(long customerId, string? status, int page, int pageSize, CancellationToken cancellationToken)
=> ListAsync(TableNoTracking.Where(b => b.CustomerId == customerId), status, forNurse: false, page, pageSize, cancellationToken);
public Task<PagedResult<BookingListItemDto>> ListForNurseAsync(long nurseId, string? status, int page, int pageSize, CancellationToken cancellationToken)
=> ListAsync(TableNoTracking.Where(b => b.NurseId == nurseId), status, forNurse: true, page, pageSize, cancellationToken);
public Task<PagedResult<BookingListItemDto>> ListAllAsync(string? status, int page, int pageSize, CancellationToken cancellationToken)
=> ListAsync(TableNoTracking, status, forNurse: false, page, pageSize, cancellationToken);
private async Task<PagedResult<BookingListItemDto>> ListAsync(
IQueryable<Booking> source, string? status, bool forNurse, int page, int pageSize, CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(status))
source = source.Where(b => b.Status == status);
var total = await source.CountAsync(cancellationToken);
var rows = await (
from b in source
join n in DbContext.Set<NurseProfile>() on b.NurseId equals n.Id
join p in DbContext.Set<Patient>() on b.PatientId equals p.Id
orderby b.Id descending
select new
{
b.Id,
b.Status,
NurseName = n.User.Name,
NurseFamily = n.User.FamilyName,
PatientName = p.DisplayName,
b.ScheduledDate,
b.SessionCount,
b.GrossPriceIrr,
b.NursePayoutAmount,
b.DisputeWindowEndsAt,
b.CreatedAt
})
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
var items = rows
.Select(r => new BookingListItemDto(
r.Id,
r.Status,
forNurse ? r.PatientName : ComposeName(r.NurseName, r.NurseFamily),
r.ScheduledDate,
r.SessionCount,
(forNurse ? r.NursePayoutAmount : r.GrossPriceIrr).ToString(),
r.DisputeWindowEndsAt,
r.CreatedAt))
.ToList();
return new PagedResult<BookingListItemDto>(items, total, page, pageSize);
}
public Task<BookingParticipants?> GetParticipantsAsync(long bookingId, CancellationToken cancellationToken)
=> (
from b in TableNoTracking
where b.Id == bookingId
join c in DbContext.Set<CustomerProfile>() on b.CustomerId equals c.Id
join n in DbContext.Set<NurseProfile>() on b.NurseId equals n.Id
select new BookingParticipants(c.UserId, n.UserId))
.FirstOrDefaultAsync(cancellationToken);
public Task<Booking?> GetTrackedWithSessionsAsync(long id, CancellationToken cancellationToken)
=> Table.Include(b => b.Sessions).FirstOrDefaultAsync(b => b.Id == id, cancellationToken);
public Task<Booking?> GetTrackedWithCareAsync(long id, CancellationToken cancellationToken)
=> Table.Include(b => b.CareInstructions).FirstOrDefaultAsync(b => b.Id == id, cancellationToken);
public async Task<Booking?> GetTrackedBookingBySessionAsync(long sessionId, CancellationToken cancellationToken)
{
var bookingId = await DbContext.Set<BookingSession>()
.Where(s => s.Id == sessionId)
.Select(s => (long?)s.BookingId)
.FirstOrDefaultAsync(cancellationToken);
if (bookingId is not { } bid)
return null;
return await Table
.Include(b => b.Sessions).ThenInclude(s => s.Verification)
.FirstOrDefaultAsync(b => b.Id == bid, cancellationToken);
}
public Task<CareInstructionsGate?> GetCareInstructionsGateAsync(long bookingId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(b => b.Id == bookingId)
.Select(b => new CareInstructionsGate(
b.Status,
b.NurseId,
b.CustomerId,
b.CareInstructions == null
? null
: new CareInstructionsDto(
b.Id,
b.CareInstructions.CurrentConditions,
b.CareInstructions.Medications,
b.CareInstructions.Allergies,
b.CareInstructions.SpecialInstructions,
b.CareInstructions.EmergencyContactName,
b.CareInstructions.EmergencyContactPhone)))
.FirstOrDefaultAsync(cancellationToken);
public async Task<PagedResult<BookingSessionListItemDto>> ListSessionsForNurseAsync(
long nurseId, DateOnly? date, int page, int pageSize, CancellationToken cancellationToken)
{
var query = DbContext.Set<BookingSession>().AsNoTracking()
.Where(s => s.Booking.NurseId == nurseId);
if (date is { } d)
query = query.Where(s => s.ScheduledDate == d);
var total = await query.CountAsync(cancellationToken);
var items = await (
from s in query
join p in DbContext.Set<Patient>() on s.Booking.PatientId equals p.Id
orderby s.ScheduledDate, s.ScheduledTimeStart
select new BookingSessionListItemDto(
s.Id,
s.BookingId,
s.SessionIndex,
p.DisplayName,
s.ScheduledDate,
s.ScheduledTimeStart,
s.ScheduledTimeEnd,
s.Status,
s.Verification == null ? VisitVerificationStatus.Pending : s.Verification.Status))
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return new PagedResult<BookingSessionListItemDto>(items, total, page, pageSize);
}
public Task<EvvGate?> GetEvvForSessionAsync(long sessionId, CancellationToken cancellationToken)
=> DbContext.Set<BookingSession>().AsNoTracking()
.Where(s => s.Id == sessionId)
.Select(s => new EvvGate(
s.Booking.NurseId,
s.Booking.CustomerId,
s.Verification == null
? null
: new VisitVerificationDto(
s.Verification.Id,
s.Id,
s.Verification.Status,
s.Verification.CheckInAt,
s.Verification.CheckInLat,
s.Verification.CheckInLng,
s.Verification.CheckOutAt,
s.Verification.CheckOutLat,
s.Verification.CheckOutLng,
s.Verification.CheckInAddressMatch,
s.Verification.CheckInDistanceMeters)))
.FirstOrDefaultAsync(cancellationToken);
public async Task<PagedResult<AdminEvvItemDto>> ListAdminEvvAsync(string type, int page, int pageSize, CancellationToken cancellationToken)
{
var query = DbContext.Set<BookingSession>().AsNoTracking().AsQueryable();
// The two admin review lanes: an advisory location mismatch, or a no-show (missed) session.
query = type == "no_show"
? query.Where(s => s.Status == BookingSessionStatus.Missed)
: query.Where(s => s.Verification != null && s.Verification.CheckInAddressMatch == false);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(s => s.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(s => new AdminEvvItemDto(
s.Id,
s.BookingId,
s.Booking.NurseId,
s.Status,
s.ScheduledDate,
s.ScheduledTimeStart,
s.Verification == null ? null : s.Verification.CheckInAt,
s.Verification == null ? null : s.Verification.CheckInAddressMatch,
s.Verification == null ? null : s.Verification.CheckInDistanceMeters))
.ToListAsync(cancellationToken);
return new PagedResult<AdminEvvItemDto>(items, total, page, pageSize);
}
public async Task<IReadOnlyList<BookingSession>> GetNoShowCandidatesAsync(DateOnly today, int batchSize, CancellationToken cancellationToken)
=> await DbContext.Set<BookingSession>()
.Include(s => s.Booking)
.Where(s => s.Status == BookingSessionStatus.Scheduled && s.ScheduledDate <= today)
.OrderBy(s => s.Id)
.Take(batchSize)
.ToListAsync(cancellationToken);
private static string ComposeName(string? name, string? familyName)
=> string.Join(' ', new[] { name, familyName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
}
@@ -1,6 +1,7 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Catalog;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Baya.Infrastructure.Persistence.Repositories.Common;
@@ -166,6 +167,61 @@ internal sealed class BookingRequestRepository : BaseAsyncRepository<BookingRequ
row.NurseRejectionReason, row.CreatedAt);
}
public Task<BookingRequest?> GetTrackedByIdAsync(long id, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(r => r.Id == id, cancellationToken);
public Task<BookingConversionSource?> GetConversionSourceAsync(long id, CancellationToken cancellationToken)
=> TableNoTracking
.Where(r => r.Id == id)
.Select(r => new BookingConversionSource(
r.Id,
r.Status,
r.CustomerId,
r.Customer.UserId,
r.NurseId,
r.Nurse.UserId,
r.PatientId,
r.Patient.DisplayName,
r.VariantId,
r.CustomerAddressId,
r.RequestedDate,
r.RequestedTimeStart,
r.RequestedTimeEnd,
new VariantSnapshot(
r.Variant.Id,
r.Variant.ServiceCategoryId,
r.Variant.ServiceCategory.NameFa,
r.Variant.ServiceCategory.NameEn,
r.Variant.Price,
r.Variant.PriceUnit,
r.Variant.SessionCount,
r.Variant.DisplayName,
r.Variant.Options
.Select(o => new VariantOptionDto(
o.OptionGroupId,
o.OptionGroup.NameFa,
o.OptionGroup.NameEn,
o.OptionValueId,
o.OptionValue.NameFa,
o.OptionValue.NameEn))
.ToList()),
new AddressSnapshot(
r.CustomerAddress.Id,
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.CustomerAddress.Latitude,
r.CustomerAddress.Longitude)))
.FirstOrDefaultAsync(cancellationToken);
// 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.
@@ -0,0 +1,36 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Domain.Entities.Booking;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class CancellationPolicyRepository : BaseAsyncRepository<CancellationPolicy>, ICancellationPolicyRepository
{
public CancellationPolicyRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public async Task<IReadOnlyList<CancellationPolicy>> GetActiveForActorAsync(string appliesTo, CancellationToken cancellationToken)
=> await TableNoTracking
.Where(p => p.AppliesTo == appliesTo && p.IsActive)
// Tighter (bounded) buckets first so the resolver prefers the most specific covering tier.
.OrderBy(p => p.HoursBeforeStartMin ?? int.MinValue)
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<CancellationPolicyDto>> ListAsync(CancellationToken cancellationToken)
=> await TableNoTracking
.OrderBy(p => p.AppliesTo).ThenBy(p => p.Code)
.Select(p => new CancellationPolicyDto(
p.Id, p.Code, p.AppliesTo, p.HoursBeforeStartMin, p.HoursBeforeStartMax,
p.RefundPercentage, p.FeeAmountIrr.ToString(), p.FeeRate, p.IsActive))
.ToListAsync(cancellationToken);
public Task<CancellationPolicy?> GetTrackedByCodeAsync(string code, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(p => p.Code == code, cancellationToken);
public Task AddAsync(CancellationPolicy policy, CancellationToken cancellationToken)
=> base.AddAsync(policy);
}
@@ -20,6 +20,8 @@ public class UnitOfWork : IUnitOfWork
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
public IVerificationRepository VerificationRepository { get; }
public IBookingRequestRepository BookingRequestRepository { get; }
public IBookingRepository BookingRepository { get; }
public ICancellationPolicyRepository CancellationPolicyRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -38,6 +40,8 @@ public class UnitOfWork : IUnitOfWork
NurseServiceVariantRepository = new NurseServiceVariantRepository(_db);
VerificationRepository = new VerificationRepository(_db);
BookingRequestRepository = new BookingRequestRepository(_db);
BookingRepository = new BookingRepository(_db);
CancellationPolicyRepository = new CancellationPolicyRepository(_db);
}
public Task CommitAsync()