backend phase 15 & frontend phase 8
This commit is contained in:
+31
@@ -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>
|
||||
|
||||
+6
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
}
|
||||
+27
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
+5935
File diff suppressed because it is too large
Load Diff
+332
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+340
@@ -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");
|
||||
|
||||
+4
@@ -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()
|
||||
|
||||
+145
@@ -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);
|
||||
}
|
||||
}
|
||||
+158
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user