backend phase 15 & frontend phase 8

This commit is contained in:
hamid
2026-07-10 03:22:29 +03:30
parent 93cc5ecb98
commit cd6c2591a6
154 changed files with 15335 additions and 37 deletions
@@ -0,0 +1,31 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Default <see cref="ILicenseVerificationService"/> — and the mock. The MoH establishment-permit registry and
/// eNamad have <b>no public B2B API</b>, so at MVP licensing is a manual admin approval: every call returns
/// <see cref="LicenseVerificationStatus.NeedsManualReview"/>, and <c>VerifyPartnerCenter</c> records the human
/// decision. Set <see cref="LicenseVerificationOptions.AutoApprove"/> to have the mock return
/// <see cref="LicenseVerificationStatus.Valid"/> (test the auto-approve path). When a real registry/API becomes
/// available, a real implementation replaces this registration and starts returning real verdicts — callers are
/// unchanged.
/// </summary>
public sealed class MockLicenseVerificationService(IOptions<SeamOptions> options) : ILicenseVerificationService
{
private LicenseVerificationOptions Options => options.Value.LicenseVerification;
public Task<LicenseVerdict> VerifyEstablishmentPermitAsync(string permitNo, CancellationToken cancellationToken = default)
=> Task.FromResult(Verdict($"establishment permit '{permitNo}'"));
public Task<LicenseVerdict> VerifyENamadAsync(string enamadCode, CancellationToken cancellationToken = default)
=> Task.FromResult(Verdict($"eNamad '{enamadCode}'"));
private LicenseVerdict Verdict(string subject)
=> Options.AutoApprove
? new LicenseVerdict(LicenseVerificationStatus.Valid, $"Auto-approved (mock) for {subject}.")
: new LicenseVerdict(LicenseVerificationStatus.NeedsManualReview,
$"No automated registry for {subject}; requires a manual admin decision.");
}
@@ -21,6 +21,19 @@ public sealed class SeamOptions
public CurrencyOptions Currency { get; set; } = new();
public BankTransferOptions BankTransfer { get; set; } = new();
public ReviewModerationOptions ReviewModeration { get; set; } = new();
public LicenseVerificationOptions LicenseVerification { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>ILicenseVerificationService</c> (b15 partner-center eNamad / MoH establishment-permit
/// check). By default every check returns <c>NeedsManualReview</c> so <c>VerifyPartnerCenter</c> records the
/// human admin decision. Set <see cref="AutoApprove"/> to have the mock return <c>Valid</c> (test the
/// auto-approve path). The real eNamad / MoH registry adapter ignores these knobs.
/// </summary>
public sealed class LicenseVerificationOptions
{
/// <summary>When true, permit/eNamad checks auto-approve (return <c>Valid</c>) instead of requiring a manual decision.</summary>
public bool AutoApprove { get; set; }
}
/// <summary>
@@ -87,6 +87,12 @@ public static class ServiceCollectionExtension
// keeps decision authority + the human override, so the real impl never touches the handler.
services.AddSingleton<IReviewModerationService, MockReviewModerationService>();
// Partner-center licensing (backend-phase-15). eNamad / MoH establishment-permit registries have no
// public B2B API, so the mock returns NeedsManualReview (manual admin approval at MVP; config can force
// auto-approve for tests). A real registry/API client swaps in by a registration change only —
// VerifyPartnerCenter records the decision and is never touched.
services.AddSingleton<ILicenseVerificationService, MockLicenseVerificationService>();
return services;
}
}
@@ -172,5 +172,12 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
{
builder.Property(p => p.IbanSnapshot).HasConversion(encrypted);
});
// b15 partner-center settlement account: the center's IBAN (only when merchant-of-record) is encrypted
// at rest through the same seam and never serialized in plaintext — reads mask it to the last 4 digits.
modelBuilder.Entity<Baya.Domain.Entities.PartnerCenters.PartnerCenter>(builder =>
{
builder.Property(c => c.SettlementIban).HasConversion(encrypted);
});
}
}
@@ -0,0 +1,43 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Messaging;
using Baya.Domain.Entities.Refunds;
using Baya.Domain.Entities.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.MessagingConfig;
/// <summary>
/// <c>tickets</c> — the root of all post-booking communication. <c>UNIQUE(reference_code)</c> backs the stable
/// human-facing support id; the <c>status</c> and <c>(status, created_at)</c> indexes serve the admin queue;
/// the <c>booking_id</c>/<c>refund_id</c> indexes serve the "tickets for this booking/refund" lookups. Both
/// links are optional (nullable FK) — a pure support ticket has neither.
/// </summary>
internal sealed class TicketConfig : IEntityTypeConfiguration<Ticket>
{
public void Configure(EntityTypeBuilder<Ticket> builder)
{
builder.ToTable("Tickets", "messaging");
builder.Property(t => t.ReferenceCode).HasMaxLength(40).IsRequired();
builder.Property(t => t.Subject).HasMaxLength(300);
builder.Property(t => t.Status).HasMaxLength(20).IsRequired();
builder.Property(t => t.Category).HasMaxLength(30).IsRequired();
builder.HasIndex(t => t.ReferenceCode).IsUnique();
builder.HasIndex(t => t.Status);
builder.HasIndex(t => t.BookingId);
builder.HasIndex(t => t.RefundId);
builder.HasIndex(t => new { t.Status, t.CreatedAt });
builder.HasOne<User>().WithMany().HasForeignKey(t => t.OpenedById).IsRequired();
builder.HasOne<User>().WithMany().HasForeignKey(t => t.ClosedById).IsRequired(false);
builder.HasOne<Booking>().WithMany().HasForeignKey(t => t.BookingId).IsRequired(false);
builder.HasOne<Refund>().WithMany().HasForeignKey(t => t.RefundId).IsRequired(false);
builder.HasMany(t => t.Participants).WithOne(p => p.Ticket).HasForeignKey(p => p.TicketId).IsRequired();
builder.HasMany(t => t.Messages).WithOne(m => m.Ticket).HasForeignKey(m => m.TicketId).IsRequired();
builder.HasQueryFilter(t => t.DeletedAt == null);
}
}
@@ -0,0 +1,27 @@
using Baya.Domain.Entities.Messaging;
using Baya.Domain.Entities.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.MessagingConfig;
/// <summary>
/// <c>ticket_messages</c> — individual messages. <c>is_internal</c> (default 0) is the hard visibility boundary
/// (admin-only note); the <c>(ticket_id, sent_at)</c> index serves the ordered thread read.
/// </summary>
internal sealed class TicketMessageConfig : IEntityTypeConfiguration<TicketMessage>
{
public void Configure(EntityTypeBuilder<TicketMessage> builder)
{
builder.ToTable("TicketMessages", "messaging");
builder.Property(m => m.Body).HasMaxLength(4000).IsRequired();
builder.Property(m => m.IsInternal).HasDefaultValue(false);
builder.HasIndex(m => new { m.TicketId, m.SentAt });
builder.HasOne<User>().WithMany().HasForeignKey(m => m.SenderId).IsRequired();
builder.HasQueryFilter(m => m.DeletedAt == null);
}
}
@@ -0,0 +1,28 @@
using Baya.Domain.Entities.Messaging;
using Baya.Domain.Entities.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.MessagingConfig;
/// <summary>
/// <c>ticket_participants</c> — who is on a thread. <c>UNIQUE(ticket_id, user_id)</c> is the authoritative
/// backstop against adding a user twice (a duplicate add is a clean conflict, never a raw DB error); removal is
/// a soft <c>removed_at</c> stamp, so the unique row survives and a re-add resurrects it. The
/// <c>(user_id, ticket_id)</c> index serves <c>ListMyTickets</c>.
/// </summary>
internal sealed class TicketParticipantConfig : IEntityTypeConfiguration<TicketParticipant>
{
public void Configure(EntityTypeBuilder<TicketParticipant> builder)
{
builder.ToTable("TicketParticipants", "messaging");
builder.Property(p => p.RoleOnTicket).HasMaxLength(20);
builder.HasIndex(p => new { p.TicketId, p.UserId }).IsUnique();
builder.HasIndex(p => new { p.UserId, p.TicketId });
builder.HasOne<User>().WithMany().HasForeignKey(p => p.UserId).IsRequired();
builder.HasOne<User>().WithMany().HasForeignKey(p => p.AddedById).IsRequired(false);
}
}
@@ -0,0 +1,45 @@
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.PartnerCenters;
using Baya.Domain.Entities.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.PartnerCentersConfig;
/// <summary>
/// <c>partner_centers</c> — the licensed sponsor center. <c>settlement_iban</c> is encrypted at rest (converter
/// wired in <c>ApplicationDbContext</c>) and masked in reads; <c>commission_rate</c> is the center's own cut
/// (separate from <c>platform_fee_rate</c>). Indexes on <c>is_active</c> (the active-center list) and
/// <c>admin_user_id</c> (the center portal scope). The 1:N sponsorship to <c>nurse_profiles</c> is configured
/// here (adds the <c>nurse_profiles.partner_center_id</c> FK in place, without forking a parallel table).
/// </summary>
internal sealed class PartnerCenterConfig : IEntityTypeConfiguration<PartnerCenter>
{
public void Configure(EntityTypeBuilder<PartnerCenter> builder)
{
builder.ToTable("PartnerCenters", "partner");
builder.Property(c => c.Name).HasMaxLength(300).IsRequired();
builder.Property(c => c.LegalEntityType).HasMaxLength(30);
builder.Property(c => c.MohEstablishmentPermitNo).HasMaxLength(100).IsRequired();
builder.Property(c => c.TechnicalDirectorLicenseNo).HasMaxLength(100);
builder.Property(c => c.EnamadCode).HasMaxLength(100);
builder.Property(c => c.SettlementIban).HasMaxLength(256); // ciphertext is longer than the 34-char plaintext
builder.Property(c => c.CommissionRate).HasPrecision(5, 4);
builder.HasIndex(c => c.IsActive);
builder.HasIndex(c => c.AdminUserId);
builder.HasOne<User>().WithMany().HasForeignKey(c => c.AdminUserId).IsRequired();
builder.HasOne<User>().WithMany().HasForeignKey(c => c.TechnicalDirectorNurseUserId).IsRequired(false);
// 1:N sponsorship — adds nurse_profiles.partner_center_id FK in place (nullable; NULL once Balinyaar
// holds its own permit). No inverse navigation on NurseProfile (kept lean).
builder.HasMany<NurseProfile>()
.WithOne()
.HasForeignKey(n => n.PartnerCenterId)
.IsRequired(false);
builder.HasQueryFilter(c => c.DeletedAt == null);
}
}
@@ -0,0 +1,332 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class MessagingAndPartnerCenters : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "partner");
migrationBuilder.EnsureSchema(
name: "messaging");
migrationBuilder.CreateTable(
name: "PartnerCenters",
schema: "partner",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
LegalEntityType = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: true),
MohEstablishmentPermitNo = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
TechnicalDirectorNurseUserId = table.Column<int>(type: "int", nullable: true),
TechnicalDirectorLicenseNo = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
EnamadCode = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
SettlementIban = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
IsMerchantOfRecord = table.Column<bool>(type: "bit", nullable: false),
CommissionRate = table.Column<decimal>(type: "decimal(5,4)", precision: 5, scale: 4, nullable: true),
AdminUserId = table.Column<int>(type: "int", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
VerifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", 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_PartnerCenters", x => x.Id);
table.ForeignKey(
name: "FK_PartnerCenters_Users_AdminUserId",
column: x => x.AdminUserId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_PartnerCenters_Users_TechnicalDirectorNurseUserId",
column: x => x.TechnicalDirectorNurseUserId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId");
});
migrationBuilder.CreateTable(
name: "Tickets",
schema: "messaging",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ReferenceCode = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
Subject = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: true),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
Category = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false),
BookingId = table.Column<long>(type: "bigint", nullable: true),
RefundId = table.Column<long>(type: "bigint", nullable: true),
OpenedById = table.Column<int>(type: "int", nullable: false),
ClosedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
ClosedById = table.Column<int>(type: "int", 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_Tickets", x => x.Id);
table.ForeignKey(
name: "FK_Tickets_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Tickets_Refunds_RefundId",
column: x => x.RefundId,
principalSchema: "payments",
principalTable: "Refunds",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Tickets_Users_ClosedById",
column: x => x.ClosedById,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId");
table.ForeignKey(
name: "FK_Tickets_Users_OpenedById",
column: x => x.OpenedById,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "TicketMessages",
schema: "messaging",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
TicketId = table.Column<long>(type: "bigint", nullable: false),
SenderId = table.Column<int>(type: "int", nullable: false),
Body = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: false),
IsInternal = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
SentAt = table.Column<DateTimeOffset>(type: "datetimeoffset", 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_TicketMessages", x => x.Id);
table.ForeignKey(
name: "FK_TicketMessages_Tickets_TicketId",
column: x => x.TicketId,
principalSchema: "messaging",
principalTable: "Tickets",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TicketMessages_Users_SenderId",
column: x => x.SenderId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "TicketParticipants",
schema: "messaging",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
TicketId = table.Column<long>(type: "bigint", nullable: false),
UserId = table.Column<int>(type: "int", nullable: false),
RoleOnTicket = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
AddedById = table.Column<int>(type: "int", nullable: true),
RemovedAt = 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_TicketParticipants", x => x.Id);
table.ForeignKey(
name: "FK_TicketParticipants_Tickets_TicketId",
column: x => x.TicketId,
principalSchema: "messaging",
principalTable: "Tickets",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TicketParticipants_Users_AddedById",
column: x => x.AddedById,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId");
table.ForeignKey(
name: "FK_TicketParticipants_Users_UserId",
column: x => x.UserId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_NurseProfiles_PartnerCenterId",
schema: "usr",
table: "NurseProfiles",
column: "PartnerCenterId");
migrationBuilder.CreateIndex(
name: "IX_PartnerCenters_AdminUserId",
schema: "partner",
table: "PartnerCenters",
column: "AdminUserId");
migrationBuilder.CreateIndex(
name: "IX_PartnerCenters_IsActive",
schema: "partner",
table: "PartnerCenters",
column: "IsActive");
migrationBuilder.CreateIndex(
name: "IX_PartnerCenters_TechnicalDirectorNurseUserId",
schema: "partner",
table: "PartnerCenters",
column: "TechnicalDirectorNurseUserId");
migrationBuilder.CreateIndex(
name: "IX_TicketMessages_SenderId",
schema: "messaging",
table: "TicketMessages",
column: "SenderId");
migrationBuilder.CreateIndex(
name: "IX_TicketMessages_TicketId_SentAt",
schema: "messaging",
table: "TicketMessages",
columns: new[] { "TicketId", "SentAt" });
migrationBuilder.CreateIndex(
name: "IX_TicketParticipants_AddedById",
schema: "messaging",
table: "TicketParticipants",
column: "AddedById");
migrationBuilder.CreateIndex(
name: "IX_TicketParticipants_TicketId_UserId",
schema: "messaging",
table: "TicketParticipants",
columns: new[] { "TicketId", "UserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TicketParticipants_UserId_TicketId",
schema: "messaging",
table: "TicketParticipants",
columns: new[] { "UserId", "TicketId" });
migrationBuilder.CreateIndex(
name: "IX_Tickets_BookingId",
schema: "messaging",
table: "Tickets",
column: "BookingId");
migrationBuilder.CreateIndex(
name: "IX_Tickets_ClosedById",
schema: "messaging",
table: "Tickets",
column: "ClosedById");
migrationBuilder.CreateIndex(
name: "IX_Tickets_OpenedById",
schema: "messaging",
table: "Tickets",
column: "OpenedById");
migrationBuilder.CreateIndex(
name: "IX_Tickets_ReferenceCode",
schema: "messaging",
table: "Tickets",
column: "ReferenceCode",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Tickets_RefundId",
schema: "messaging",
table: "Tickets",
column: "RefundId");
migrationBuilder.CreateIndex(
name: "IX_Tickets_Status",
schema: "messaging",
table: "Tickets",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_Tickets_Status_CreatedAt",
schema: "messaging",
table: "Tickets",
columns: new[] { "Status", "CreatedAt" });
migrationBuilder.AddForeignKey(
name: "FK_NurseProfiles_PartnerCenters_PartnerCenterId",
schema: "usr",
table: "NurseProfiles",
column: "PartnerCenterId",
principalSchema: "partner",
principalTable: "PartnerCenters",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_NurseProfiles_PartnerCenters_PartnerCenterId",
schema: "usr",
table: "NurseProfiles");
migrationBuilder.DropTable(
name: "PartnerCenters",
schema: "partner");
migrationBuilder.DropTable(
name: "TicketMessages",
schema: "messaging");
migrationBuilder.DropTable(
name: "TicketParticipants",
schema: "messaging");
migrationBuilder.DropTable(
name: "Tickets",
schema: "messaging");
migrationBuilder.DropIndex(
name: "IX_NurseProfiles_PartnerCenterId",
schema: "usr",
table: "NurseProfiles");
}
}
}
@@ -2716,6 +2716,8 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("PartnerCenterId");
b.HasIndex("UserId")
.IsUnique();
@@ -2885,6 +2887,182 @@ namespace Baya.Infrastructure.Persistence.Migrations
});
});
modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long?>("BookingId")
.HasColumnType("bigint");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.Property<DateTimeOffset?>("ClosedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ClosedById")
.HasColumnType("int");
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<int>("OpenedById")
.HasColumnType("int");
b.Property<string>("ReferenceCode")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<long?>("RefundId")
.HasColumnType("bigint");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Subject")
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.HasKey("Id");
b.HasIndex("BookingId");
b.HasIndex("ClosedById");
b.HasIndex("OpenedById");
b.HasIndex("ReferenceCode")
.IsUnique();
b.HasIndex("RefundId");
b.HasIndex("Status");
b.HasIndex("Status", "CreatedAt");
b.ToTable("Tickets", "messaging");
});
modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketMessage", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<bool>("IsInternal")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int>("SenderId")
.HasColumnType("int");
b.Property<DateTimeOffset>("SentAt")
.HasColumnType("datetimeoffset");
b.Property<long>("TicketId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("SenderId");
b.HasIndex("TicketId", "SentAt");
b.ToTable("TicketMessages", "messaging");
});
modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketParticipant", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<int?>("AddedById")
.HasColumnType("int");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("RemovedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("RoleOnTicket")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<long>("TicketId")
.HasColumnType("bigint");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("AddedById");
b.HasIndex("TicketId", "UserId")
.IsUnique();
b.HasIndex("UserId", "TicketId");
b.ToTable("TicketParticipants", "messaging");
});
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
{
b.Property<long>("Id")
@@ -2930,6 +3108,85 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("Notifications", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.PartnerCenters.PartnerCenter", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AdminUserId")
.HasColumnType("int");
b.Property<decimal?>("CommissionRate")
.HasPrecision(5, 4)
.HasColumnType("decimal(5,4)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("EnamadCode")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsMerchantOfRecord")
.HasColumnType("bit");
b.Property<string>("LegalEntityType")
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("MohEstablishmentPermitNo")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<string>("SettlementIban")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("TechnicalDirectorLicenseNo")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int?>("TechnicalDirectorNurseUserId")
.HasColumnType("int");
b.Property<DateTimeOffset?>("VerifiedAt")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("AdminUserId");
b.HasIndex("IsActive");
b.HasIndex("TechnicalDirectorNurseUserId");
b.ToTable("PartnerCenters", "partner");
});
modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b =>
{
b.Property<long>("Id")
@@ -5078,6 +5335,10 @@ namespace Baya.Infrastructure.Persistence.Migrations
modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b =>
{
b.HasOne("Baya.Domain.Entities.PartnerCenters.PartnerCenter", null)
.WithMany()
.HasForeignKey("PartnerCenterId");
b.HasOne("Baya.Domain.Entities.User.User", "User")
.WithOne()
.HasForeignKey("Baya.Domain.Entities.Identity.NurseProfile", "UserId")
@@ -5107,6 +5368,65 @@ namespace Baya.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
.WithMany()
.HasForeignKey("BookingId");
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("ClosedById");
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("OpenedById")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Refunds.Refund", null)
.WithMany()
.HasForeignKey("RefundId");
});
modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketMessage", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Messaging.Ticket", "Ticket")
.WithMany("Messages")
.HasForeignKey("TicketId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Ticket");
});
modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketParticipant", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("AddedById");
b.HasOne("Baya.Domain.Entities.Messaging.Ticket", "Ticket")
.WithMany("Participants")
.HasForeignKey("TicketId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Ticket");
});
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
@@ -5116,6 +5436,19 @@ namespace Baya.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.PartnerCenters.PartnerCenter", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("AdminUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("TechnicalDirectorNurseUserId");
});
modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
@@ -5530,6 +5863,13 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.Navigation("BankAccounts");
});
modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b =>
{
b.Navigation("Messages");
b.Navigation("Participants");
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b =>
{
b.Navigation("BookingLinks");
@@ -29,6 +29,8 @@ public class UnitOfWork : IUnitOfWork
public IPayoutRepository PayoutRepository { get; }
public IReviewRepository ReviewRepository { get; }
public IPatientCareRecordRepository PatientCareRecordRepository { get; }
public ITicketRepository TicketRepository { get; }
public IPartnerCenterRepository PartnerCenterRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -56,6 +58,8 @@ public class UnitOfWork : IUnitOfWork
PayoutRepository = new PayoutRepository(_db);
ReviewRepository = new ReviewRepository(_db);
PatientCareRecordRepository = new PatientCareRecordRepository(_db);
TicketRepository = new TicketRepository(_db);
PartnerCenterRepository = new PartnerCenterRepository(_db);
}
public Task CommitAsync()
@@ -0,0 +1,145 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Invoices;
using Baya.Domain.Entities.PartnerCenters;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class PartnerCenterRepository : BaseAsyncRepository<PartnerCenter>, IPartnerCenterRepository
{
private const int DashboardNurseCap = 50;
public PartnerCenterRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task AddAsync(PartnerCenter center, CancellationToken cancellationToken) => base.AddAsync(center);
public Task<PartnerCenter?> GetTrackedAsync(long centerId, CancellationToken cancellationToken)
=> Entities.FirstOrDefaultAsync(c => c.Id == centerId, cancellationToken);
public Task<bool> ExistsAsync(long centerId, CancellationToken cancellationToken)
=> Entities.AnyAsync(c => c.Id == centerId, cancellationToken);
public async Task<PartnerCenterDetailDto?> GetDetailAsync(long centerId, CancellationToken cancellationToken)
{
// Project (the converter decrypts settlement_iban here); the plaintext IBAN is masked in memory below and
// never leaves this method.
var raw = await Entities.AsNoTracking()
.Where(c => c.Id == centerId)
.Select(c => new
{
c.Id, c.Name, c.LegalEntityType, c.MohEstablishmentPermitNo, c.TechnicalDirectorNurseUserId,
c.TechnicalDirectorLicenseNo, c.EnamadCode, c.SettlementIban, c.IsMerchantOfRecord, c.CommissionRate,
c.AdminUserId, c.IsActive, c.VerifiedAt, c.CreatedAt,
SponsoredCount = DbContext.Set<NurseProfile>().Count(n => n.PartnerCenterId == c.Id)
})
.FirstOrDefaultAsync(cancellationToken);
if (raw is null)
return null;
return new PartnerCenterDetailDto(
raw.Id, raw.Name, raw.LegalEntityType, raw.MohEstablishmentPermitNo, raw.TechnicalDirectorNurseUserId,
raw.TechnicalDirectorLicenseNo, raw.EnamadCode,
string.IsNullOrEmpty(raw.SettlementIban) ? null : Mask.IbanTail(raw.SettlementIban),
raw.IsMerchantOfRecord, raw.CommissionRate, raw.AdminUserId, raw.IsActive, raw.VerifiedAt,
raw.SponsoredCount, raw.CreatedAt);
}
public async Task<PagedResult<PartnerCenterListItemDto>> ListAsync(bool? isActive, int page, int pageSize, CancellationToken cancellationToken)
{
var query = Entities.AsNoTracking().AsQueryable();
if (isActive is { } active)
query = query.Where(c => c.IsActive == active);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(c => c.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(c => new PartnerCenterListItemDto(
c.Id, c.Name, c.LegalEntityType, c.IsMerchantOfRecord, c.IsActive, c.VerifiedAt,
DbContext.Set<NurseProfile>().Count(n => n.PartnerCenterId == c.Id),
c.AdminUserId))
.ToListAsync(cancellationToken);
return new PagedResult<PartnerCenterListItemDto>(items, total, page, pageSize);
}
public async Task<CenterForBookingDto?> ResolveCenterForBookingAsync(long bookingId, CancellationToken cancellationToken)
{
var booking = await DbContext.Set<Booking>().AsNoTracking()
.Where(b => b.Id == bookingId)
.Select(b => new { b.NurseId })
.FirstOrDefaultAsync(cancellationToken);
if (booking is null)
return null;
var centerId = await DbContext.Set<NurseProfile>().AsNoTracking()
.Where(n => n.Id == booking.NurseId)
.Select(n => n.PartnerCenterId)
.FirstOrDefaultAsync(cancellationToken);
if (centerId is { } id)
{
var center = await Entities.AsNoTracking()
.Where(c => c.Id == id)
.Select(c => new { c.Id, c.Name, c.IsMerchantOfRecord })
.FirstOrDefaultAsync(cancellationToken);
// Merchant-of-record center → it is the invoice issuer + settlement target. Otherwise the platform
// issues (a non-MoR sponsor does not change the issuer).
if (center is { IsMerchantOfRecord: true })
return new CenterForBookingDto(bookingId, InvoiceIssuingEntityType.PartnerCenter, center.Id, center.Name, true);
}
return new CenterForBookingDto(bookingId, InvoiceIssuingEntityType.Platform, null, null, false);
}
public Task<int?> GetAdminUserIdAsync(long centerId, CancellationToken cancellationToken)
=> Entities.AsNoTracking()
.Where(c => c.Id == centerId)
.Select(c => (int?)c.AdminUserId)
.FirstOrDefaultAsync(cancellationToken);
public async Task<CenterDashboardDto?> GetDashboardAsync(long centerId, CancellationToken cancellationToken)
{
var center = await Entities.AsNoTracking()
.Where(c => c.Id == centerId)
.Select(c => new { c.Id, c.Name, c.IsMerchantOfRecord, c.IsActive, c.SettlementIban })
.FirstOrDefaultAsync(cancellationToken);
if (center is null)
return null;
var sponsoredNurseCount = await DbContext.Set<NurseProfile>().AsNoTracking()
.CountAsync(n => n.PartnerCenterId == centerId, cancellationToken);
var sponsoredNurses = await DbContext.Set<NurseProfile>().AsNoTracking()
.Where(n => n.PartnerCenterId == centerId)
.OrderBy(n => n.Id)
.Take(DashboardNurseCap)
.Select(n => new SponsoredNurseDto(n.Id, n.UserId, n.IsVerified, n.AverageRating, n.TotalCompletedBookings))
.ToListAsync(cancellationToken);
var sponsoredBookingCount = await DbContext.Set<Booking>().AsNoTracking()
.CountAsync(b => DbContext.Set<NurseProfile>().Any(n => n.Id == b.NurseId && n.PartnerCenterId == centerId), cancellationToken);
var invoiceCount = await DbContext.Set<Invoice>().AsNoTracking()
.CountAsync(i => i.PartnerCenterId == centerId, cancellationToken);
return new CenterDashboardDto(
center.Id, center.Name, center.IsMerchantOfRecord, center.IsActive,
string.IsNullOrEmpty(center.SettlementIban) ? null : Mask.IbanTail(center.SettlementIban),
sponsoredNurseCount, sponsoredBookingCount, invoiceCount, sponsoredNurses);
}
}
@@ -0,0 +1,158 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Messaging;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class TicketRepository : BaseAsyncRepository<Ticket>, ITicketRepository
{
public TicketRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task AddAsync(Ticket ticket, CancellationToken cancellationToken) => base.AddAsync(ticket);
public async Task AddMessageAsync(TicketMessage message, CancellationToken cancellationToken)
=> await DbContext.Set<TicketMessage>().AddAsync(message, cancellationToken);
public async Task AddParticipantAsync(TicketParticipant participant, CancellationToken cancellationToken)
=> await DbContext.Set<TicketParticipant>().AddAsync(participant, cancellationToken);
public Task<Ticket?> GetTrackedAsync(long ticketId, CancellationToken cancellationToken)
=> Entities.FirstOrDefaultAsync(t => t.Id == ticketId, cancellationToken);
public Task<bool> ReferenceCodeExistsAsync(string referenceCode, CancellationToken cancellationToken)
=> Entities.AnyAsync(t => t.ReferenceCode == referenceCode, cancellationToken);
public Task<TicketParticipant?> GetParticipantAsync(long ticketId, int userId, CancellationToken cancellationToken)
=> DbContext.Set<TicketParticipant>().FirstOrDefaultAsync(p => p.TicketId == ticketId && p.UserId == userId, cancellationToken);
public Task<bool> IsActiveParticipantAsync(long ticketId, int userId, CancellationToken cancellationToken)
=> DbContext.Set<TicketParticipant>().AsNoTracking()
.AnyAsync(p => p.TicketId == ticketId && p.UserId == userId && p.RemovedAt == null, cancellationToken);
public Task<bool> CoordinationTicketExistsForBookingAsync(long bookingId, CancellationToken cancellationToken)
=> Entities.AnyAsync(t => t.BookingId == bookingId && t.Category == TicketCategory.Coordination, cancellationToken);
public async Task<BookingPartyUserIds?> GetBookingPartyUserIdsAsync(long bookingId, CancellationToken cancellationToken)
{
var parties = await DbContext.Set<Booking>().AsNoTracking()
.Where(b => b.Id == bookingId)
.Select(b => new
{
CustomerUserId = DbContext.Set<Domain.Entities.Identity.CustomerProfile>()
.Where(c => c.Id == b.CustomerId).Select(c => (int?)c.UserId).FirstOrDefault(),
NurseUserId = DbContext.Set<Domain.Entities.Identity.NurseProfile>()
.Where(n => n.Id == b.NurseId).Select(n => (int?)n.UserId).FirstOrDefault()
})
.FirstOrDefaultAsync(cancellationToken);
if (parties is null || parties.CustomerUserId is not { } customerUserId || parties.NurseUserId is not { } nurseUserId)
return null;
return new BookingPartyUserIds(customerUserId, nurseUserId);
}
public Task<TicketHeaderDto?> GetHeaderAsync(long ticketId, CancellationToken cancellationToken)
=> Entities.AsNoTracking()
.Where(t => t.Id == ticketId)
.Select(t => new TicketHeaderDto(
t.Id, t.ReferenceCode, t.Subject, t.Status, t.Category, t.BookingId, t.RefundId, t.OpenedById, t.ClosedAt))
.FirstOrDefaultAsync(cancellationToken);
public Task<bool> IsUserPartyToBookingAsync(long bookingId, int userId, CancellationToken cancellationToken)
=> DbContext.Set<Booking>().AsNoTracking()
.Where(b => b.Id == bookingId)
.AnyAsync(b =>
DbContext.Set<Domain.Entities.Identity.CustomerProfile>().Any(c => c.Id == b.CustomerId && c.UserId == userId) ||
DbContext.Set<Domain.Entities.Identity.NurseProfile>().Any(n => n.Id == b.NurseId && n.UserId == userId),
cancellationToken);
public async Task<IReadOnlyList<TicketParticipantDto>> GetActiveParticipantsAsync(long ticketId, CancellationToken cancellationToken)
=> await DbContext.Set<TicketParticipant>().AsNoTracking()
.Where(p => p.TicketId == ticketId && p.RemovedAt == null)
.OrderBy(p => p.Id)
.Select(p => new TicketParticipantDto(p.UserId, p.RoleOnTicket))
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<int>> GetActiveParticipantUserIdsAsync(long ticketId, CancellationToken cancellationToken)
=> await DbContext.Set<TicketParticipant>().AsNoTracking()
.Where(p => p.TicketId == ticketId && p.RemovedAt == null)
.Select(p => p.UserId)
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<TicketMessageDto>> GetMessagesAsync(long ticketId, bool includeInternal, CancellationToken cancellationToken)
{
var query = DbContext.Set<TicketMessage>().AsNoTracking().Where(m => m.TicketId == ticketId);
// The hard visibility boundary: the user view strips every internal message in the projection.
if (!includeInternal)
query = query.Where(m => !m.IsInternal);
// Order by the monotonic identity, not sent_at: it matches send order and, unlike a DateTimeOffset
// ORDER BY, the SQLite test provider can translate it.
return await query
.OrderBy(m => m.Id)
.Select(m => new TicketMessageDto(m.Id, m.SenderId, m.Body, m.IsInternal, m.SentAt))
.ToListAsync(cancellationToken);
}
public async Task<PagedResult<TicketSummaryDto>> ListMyTicketsAsync(
int userId, string? status, string? referenceCode, int page, int pageSize, CancellationToken cancellationToken)
{
var participantTickets = DbContext.Set<TicketParticipant>().AsNoTracking()
.Where(p => p.UserId == userId && p.RemovedAt == null)
.Select(p => p.TicketId);
var query = Entities.AsNoTracking().Where(t => participantTickets.Contains(t.Id));
query = ApplyTicketFilters(query, status, referenceCode);
return await PageAsync(query, page, pageSize, cancellationToken);
}
public async Task<PagedResult<TicketSummaryDto>> ListForAdminAsync(
string? status, string? category, string? referenceCode, long? bookingId, long? refundId,
int page, int pageSize, CancellationToken cancellationToken)
{
var query = Entities.AsNoTracking().AsQueryable();
query = ApplyTicketFilters(query, status, referenceCode);
if (!string.IsNullOrWhiteSpace(category))
query = query.Where(t => t.Category == category);
if (bookingId is { } b)
query = query.Where(t => t.BookingId == b);
if (refundId is { } r)
query = query.Where(t => t.RefundId == r);
return await PageAsync(query, page, pageSize, cancellationToken);
}
private static IQueryable<Ticket> ApplyTicketFilters(IQueryable<Ticket> query, string? status, string? referenceCode)
{
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(t => t.Status == status);
if (!string.IsNullOrWhiteSpace(referenceCode))
query = query.Where(t => t.ReferenceCode == referenceCode);
return query;
}
private static async Task<PagedResult<TicketSummaryDto>> PageAsync(
IQueryable<Ticket> query, int page, int pageSize, CancellationToken cancellationToken)
{
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(t => t.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(t => new TicketSummaryDto(
t.Id, t.ReferenceCode, t.Subject, t.Status, t.Category, t.BookingId, t.RefundId, t.CreatedAt))
.ToListAsync(cancellationToken);
return new PagedResult<TicketSummaryDto>(items, total, page, pageSize);
}
}