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,16 @@
using Baya.Domain.Entities.User;
namespace Baya.Application.Common;
/// <summary>
/// The internal admin/staff role set. A caller holding any of these is "staff" — the ticket system grants staff
/// full read/attach on any ticket (and the right to post internal notes), and admin endpoints authorize against
/// the narrowest fitting scope. Kept in one place so every handler asks the same question.
/// </summary>
public static class StaffRoles
{
public static readonly IReadOnlyList<string> All =
[RoleNames.Admin, RoleNames.Support, RoleNames.Finance, RoleNames.Moderation, RoleNames.SuperAdmin];
public static bool IsStaff(IEnumerable<string> roles) => roles.Any(All.Contains);
}
@@ -0,0 +1,31 @@
#nullable enable
namespace Baya.Application.Contracts.Common;
/// <summary>
/// Seam for verifying a partner center's licensing — the MoH establishment permit (پروانه تأسیس) and the
/// eNamad trust seal (نماد اعتماد الکترونیکی) — against its authoritative source. There is <b>no public B2B
/// API</b> for these today, so the default implementation returns
/// <see cref="LicenseVerificationStatus.NeedsManualReview"/>: <c>VerifyPartnerCenter</c> records the human
/// admin decision. When a real eNamad / MoH registry endpoint becomes available a real implementation replaces
/// this registration and starts returning <see cref="LicenseVerificationStatus.Valid"/>/<see cref="LicenseVerificationStatus.Invalid"/>
/// — <c>VerifyPartnerCenter</c> is unchanged.
/// </summary>
public interface ILicenseVerificationService
{
Task<LicenseVerdict> VerifyEstablishmentPermitAsync(string permitNo, CancellationToken cancellationToken = default);
Task<LicenseVerdict> VerifyENamadAsync(string enamadCode, CancellationToken cancellationToken = default);
}
/// <summary>Whether a license can be verified automatically or needs a manual admin decision.</summary>
public enum LicenseVerificationStatus
{
NeedsManualReview,
Valid,
Invalid
}
/// <summary>Outcome of an <see cref="ILicenseVerificationService"/> check.</summary>
/// <param name="Status">Manual today; automated once a registry/API is available.</param>
/// <param name="Reason">Human-readable reason for the verdict (esp. for manual/invalid).</param>
public readonly record struct LicenseVerdict(LicenseVerificationStatus Status, string Reason);
@@ -0,0 +1,38 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Baya.Domain.Entities.PartnerCenters;
namespace Baya.Application.Contracts.Persistence;
/// <summary>
/// The <c>partner_centers</c> aggregate. Writes load tracked rows; reads project to DTOs and <b>never</b> return
/// the plaintext/full <c>settlement_iban</c> (masked to last 4). <see cref="ResolveCenterForBookingAsync"/> is
/// the single merchant-of-record resolver: invoices + settlement follow the center, not a hardcoded platform.
/// </summary>
public interface IPartnerCenterRepository
{
Task AddAsync(PartnerCenter center, CancellationToken cancellationToken);
/// <summary>Tracked center — for update/verify/activate. Null if absent.</summary>
Task<PartnerCenter?> GetTrackedAsync(long centerId, CancellationToken cancellationToken);
Task<bool> ExistsAsync(long centerId, CancellationToken cancellationToken);
/// <summary>The admin detail view, with the settlement IBAN masked and the sponsored-nurse count. Null if absent.</summary>
Task<PartnerCenterDetailDto?> GetDetailAsync(long centerId, CancellationToken cancellationToken);
/// <summary>Admin paginated list (no IBAN), optional active filter, with sponsored-nurse counts.</summary>
Task<PagedResult<PartnerCenterListItemDto>> ListAsync(bool? isActive, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>Resolves which center legally covers a booking (booking → nurse → <c>partner_center_id</c>),
/// returning the issuer/settlement decision. Issuer is <c>partner_center</c> only when the sponsoring center
/// is merchant-of-record; <c>platform</c> otherwise (incl. an unsponsored nurse). Null if the booking is absent.</summary>
Task<CenterForBookingDto?> ResolveCenterForBookingAsync(long bookingId, CancellationToken cancellationToken);
/// <summary>The center's dashboard admin <c>users.id</c> — the portal-scope check. Null if the center is absent.</summary>
Task<int?> GetAdminUserIdAsync(long centerId, CancellationToken cancellationToken);
/// <summary>The center dashboard read model (sponsored nurses + booking/invoice counts). Null if absent.</summary>
Task<CenterDashboardDto?> GetDashboardAsync(long centerId, CancellationToken cancellationToken);
}
@@ -0,0 +1,70 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Baya.Domain.Entities.Messaging;
namespace Baya.Application.Contracts.Persistence;
/// <summary>
/// The <c>tickets</c> aggregate (tickets + participants + messages). Writes load tracked rows; reads project to
/// DTOs. The <b>hard visibility boundary</b> lives here: <see cref="GetMessagesAsync"/> only returns
/// <c>is_internal</c> messages when <paramref name="includeInternal"/> (the admin view) is true — an internal
/// note is filtered out of the projection for the user view, never surfaced downstream.
/// </summary>
public interface ITicketRepository
{
Task AddAsync(Ticket ticket, CancellationToken cancellationToken);
Task AddMessageAsync(TicketMessage message, CancellationToken cancellationToken);
Task AddParticipantAsync(TicketParticipant participant, CancellationToken cancellationToken);
/// <summary>Tracked ticket (no includes) — for close/reopen transitions. Null if absent.</summary>
Task<Ticket?> GetTrackedAsync(long ticketId, CancellationToken cancellationToken);
/// <summary>True if a ticket already uses this reference code — the collision check before minting.</summary>
Task<bool> ReferenceCodeExistsAsync(string referenceCode, CancellationToken cancellationToken);
/// <summary>Tracked participant row for a (ticket, user) — active or soft-removed — so an add can resurrect
/// a removed row and a remove can soft-stamp it. Null if the user was never on the ticket.</summary>
Task<TicketParticipant?> GetParticipantAsync(long ticketId, int userId, CancellationToken cancellationToken);
/// <summary>True if the user is an <b>active</b> participant on the ticket — the authorization backstop for
/// reads/posts.</summary>
Task<bool> IsActiveParticipantAsync(long ticketId, int userId, CancellationToken cancellationToken);
/// <summary>True if a <c>coordination</c> ticket already exists for the booking — the auto-create idempotency guard.</summary>
Task<bool> CoordinationTicketExistsForBookingAsync(long bookingId, CancellationToken cancellationToken);
/// <summary>The booking's customer + nurse <c>users.id</c> — the two participants of a coordination ticket.
/// Null when the booking is absent.</summary>
Task<BookingPartyUserIds?> GetBookingPartyUserIdsAsync(long bookingId, CancellationToken cancellationToken);
/// <summary>The ticket's header facts (no messages) — for authorization + the thread response header. Null if absent.</summary>
Task<TicketHeaderDto?> GetHeaderAsync(long ticketId, CancellationToken cancellationToken);
/// <summary>True if the booking exists and the given user is its customer or nurse — the tenancy check for
/// opening a booking-linked ticket.</summary>
Task<bool> IsUserPartyToBookingAsync(long bookingId, int userId, CancellationToken cancellationToken);
/// <summary>The active participants on a thread.</summary>
Task<IReadOnlyList<TicketParticipantDto>> GetActiveParticipantsAsync(long ticketId, CancellationToken cancellationToken);
/// <summary>The user ids of the active participants (for notification fan-out).</summary>
Task<IReadOnlyList<int>> GetActiveParticipantUserIdsAsync(long ticketId, CancellationToken cancellationToken);
/// <summary>The ordered thread messages. <paramref name="includeInternal"/> = false (the user view) strips
/// every <c>is_internal</c> message in the projection; true (the admin view) returns them.</summary>
Task<IReadOnlyList<TicketMessageDto>> GetMessagesAsync(long ticketId, bool includeInternal, CancellationToken cancellationToken);
/// <summary>Paginated tickets the user participates in (active membership), filterable by status and
/// reference code, newest first.</summary>
Task<PagedResult<TicketSummaryDto>> ListMyTicketsAsync(int userId, string? status, string? referenceCode, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>The admin global queue — paginated, filter by status/category, search by reference code, optional
/// booking/refund link, newest first.</summary>
Task<PagedResult<TicketSummaryDto>> ListForAdminAsync(string? status, string? category, string? referenceCode, long? bookingId, long? refundId, int page, int pageSize, CancellationToken cancellationToken);
}
/// <summary>The customer + nurse owning <c>users.id</c> for a booking.</summary>
public readonly record struct BookingPartyUserIds(int CustomerUserId, int NurseUserId);
@@ -25,6 +25,8 @@ public interface IUnitOfWork
public IPayoutRepository PayoutRepository { get; }
public IReviewRepository ReviewRepository { get; }
public IPatientCareRecordRepository PatientCareRecordRepository { get; }
public ITicketRepository TicketRepository { get; }
public IPartnerCenterRepository PartnerCenterRepository { get; }
Task CommitAsync();
ValueTask RollBackAsync();
}
@@ -28,7 +28,8 @@ internal sealed class SettleBnplOrderCommandHandler(
ISettlementSplitProvider settlementSplitProvider,
IDistributedLock distributedLock,
IDateTimeProvider dateTimeProvider,
INotificationDispatcher notifications)
INotificationDispatcher notifications,
ISender sender)
: IRequestHandler<SettleBnplOrderCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(SettleBnplOrderCommand request, CancellationToken cancellationToken)
@@ -121,6 +122,10 @@ internal sealed class SettleBnplOrderCommandHandler(
if (conversion.Created && conversion.CustomerUserId is { } customerUserId && conversion.NurseUserId is { } nurseUserId)
await NotifyConfirmedAsync(customerUserId, nurseUserId, conversion.BookingId, cancellationToken);
// Open the booking-coordination ticket once the booking is confirmed (idempotent, one per booking) — b15.
if (conversion.Created)
await sender.Send(new Messaging.Commands.AutoCreateCoordinationTicket.AutoCreateCoordinationTicketCommand(conversion.BookingId), cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
@@ -48,6 +48,11 @@ internal sealed class IssueInvoiceCommandHandler(
var sequence = await unitOfWork.InvoiceRepository.ReserveNextInvoiceNumberAsync(cancellationToken);
var invoiceNumber = $"INV-{sequence:D10}";
// Merchant-of-record resolution (b15): the invoice issuer + settlement target follow partner_centers, not
// a hardcoded platform. GetCenterForBooking is the single resolver — it returns partner_center only when a
// merchant-of-record center legally covers the booking, and platform otherwise.
var issuer = await unitOfWork.PartnerCenterRepository.ResolveCenterForBookingAsync(request.BookingId, cancellationToken);
var submission = new InvoiceSubmission(invoiceNumber, request.BookingId, amounts.GrossIrr, amounts.PlatformCommissionIrr, vatIrr);
var moadian = await moadianClient.SubmitAsync(submission, cancellationToken);
@@ -55,7 +60,8 @@ internal sealed class IssueInvoiceCommandHandler(
{
BookingId = request.BookingId,
InvoiceNumber = invoiceNumber,
IssuingEntityType = InvoiceIssuingEntityType.Platform,
IssuingEntityType = issuer?.IssuingEntityType ?? InvoiceIssuingEntityType.Platform,
PartnerCenterId = issuer?.PartnerCenterId,
GrossIrr = amounts.GrossIrr,
PlatformCommissionIrr = amounts.PlatformCommissionIrr,
BnplCommissionIrr = amounts.BnplCommissionIrr,
@@ -0,0 +1,65 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Messaging;
using Mediator;
using Microsoft.EntityFrameworkCore;
namespace Baya.Application.Features.Messaging.Commands.AddParticipant;
/// <summary>
/// Adds a user to a ticket (staff on any ticket, or the ticket owner). Enforces <c>UNIQUE(ticket_id, user_id)</c>:
/// a duplicate active participant is a clean conflict; a previously-removed participant is resurrected (the same
/// unique row), never re-inserted. The unique index is the authoritative backstop against a race.
/// </summary>
internal sealed class AddParticipantCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork)
: IRequestHandler<AddParticipantCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(AddParticipantCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } callerId)
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
var header = await unitOfWork.TicketRepository.GetHeaderAsync(request.TicketId, cancellationToken);
if (header is null)
return OperationResult<bool>.NotFoundResult("Ticket not found.");
if (!StaffRoles.IsStaff(currentUser.Roles) && header.OpenedById != callerId)
return OperationResult<bool>.ForbiddenResult("Only staff or the ticket owner can manage participants.");
var existing = await unitOfWork.TicketRepository.GetParticipantAsync(request.TicketId, request.UserId, cancellationToken);
if (existing is not null)
{
if (existing.IsActive)
return OperationResult<bool>.ConflictResult("That user is already a participant on this ticket.");
existing.Restore(callerId);
}
else
{
await unitOfWork.TicketRepository.AddParticipantAsync(new TicketParticipant
{
TicketId = request.TicketId,
UserId = request.UserId,
AddedById = callerId
}, cancellationToken);
}
try
{
await unitOfWork.CommitAsync();
}
catch (DbUpdateException)
{
// Lost the UNIQUE(ticket_id, user_id) race — treat as the duplicate it is, not a 500.
await unitOfWork.RollBackAsync();
return OperationResult<bool>.ConflictResult("That user is already a participant on this ticket.");
}
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.Messaging.Commands.AddParticipant;
public sealed class AddParticipantCommandValidator : AbstractValidator<AddParticipantCommand>
{
public AddParticipantCommandValidator()
{
// TicketId is route-supplied (merged via `with`); only the body-supplied UserId is validated.
RuleFor(x => x.UserId).GreaterThan(0);
}
}
@@ -0,0 +1,9 @@
#nullable enable
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Messaging.Commands.AddParticipant;
/// <summary>Attaches a user to a ticket. Staff (any ticket) or the ticket owner may add. A duplicate add returns
/// a clean conflict, backed by the <c>UNIQUE(ticket_id, user_id)</c> index.</summary>
public record AddParticipantCommand(long TicketId, int UserId) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,55 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Messaging;
using Mediator;
namespace Baya.Application.Features.Messaging.Commands.AutoCreateCoordinationTicket;
/// <summary>
/// Creates the one booking-scoped coordination ticket + its nurse/customer participants, idempotently. The
/// idempotency guard is <c>CoordinationTicketExistsForBookingAsync</c> (a re-confirmation is a no-op). Not an
/// end-user path — there is no participant/tenancy gate; the participants are resolved from the booking itself.
/// </summary>
internal sealed class AutoCreateCoordinationTicketCommandHandler(
IUnitOfWork unitOfWork)
: IRequestHandler<AutoCreateCoordinationTicketCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(AutoCreateCoordinationTicketCommand request, CancellationToken cancellationToken)
{
if (await unitOfWork.TicketRepository.CoordinationTicketExistsForBookingAsync(request.BookingId, cancellationToken))
return OperationResult<bool>.SuccessResult(false);
var parties = await unitOfWork.TicketRepository.GetBookingPartyUserIdsAsync(request.BookingId, cancellationToken);
if (parties is not { } p)
return OperationResult<bool>.NotFoundResult("Booking not found.");
var referenceCode = await TicketReferenceCode.MintAsync(unitOfWork.TicketRepository, cancellationToken);
var ticket = new Ticket
{
ReferenceCode = referenceCode,
Category = TicketCategory.Coordination,
Subject = "Booking coordination",
BookingId = request.BookingId,
OpenedById = p.CustomerUserId
};
ticket.Participants.Add(new TicketParticipant { UserId = p.CustomerUserId, RoleOnTicket = TicketParticipantRole.Customer });
ticket.Participants.Add(new TicketParticipant { UserId = p.NurseUserId, RoleOnTicket = TicketParticipantRole.Nurse });
await unitOfWork.TicketRepository.AddAsync(ticket, cancellationToken);
try
{
await unitOfWork.CommitAsync();
}
catch (Microsoft.EntityFrameworkCore.DbUpdateException)
{
// A concurrent confirmation won the race — the one-per-booking rule holds; treat as no-op.
await unitOfWork.RollBackAsync();
return OperationResult<bool>.SuccessResult(false);
}
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,13 @@
#nullable enable
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Messaging.Commands.AutoCreateCoordinationTicket;
/// <summary>
/// On booking confirmation, auto-creates the <c>coordination</c> ticket linked to the booking and adds the nurse
/// + customer as participants. Idempotent — exactly one coordination ticket per booking (a re-confirmation must
/// not create a second). Invoked by the b9/b10/b12 confirmation flow, not by an end user. Returns <c>true</c>
/// when it created the ticket, <c>false</c> when one already existed.
/// </summary>
public record AutoCreateCoordinationTicketCommand(long BookingId) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,39 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Messaging.Commands.CloseTicket;
/// <summary>Closes a ticket (participant or staff). Idempotent — an already-closed ticket is a no-op success. The
/// owner trail comes from the audit fields.</summary>
internal sealed class CloseTicketCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<CloseTicketCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(CloseTicketCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
var ticket = await unitOfWork.TicketRepository.GetTrackedAsync(request.TicketId, cancellationToken);
if (ticket is null)
return OperationResult<bool>.NotFoundResult("Ticket not found.");
if (!StaffRoles.IsStaff(currentUser.Roles) &&
!await unitOfWork.TicketRepository.IsActiveParticipantAsync(request.TicketId, userId, cancellationToken))
return OperationResult<bool>.ForbiddenResult("You are not a participant on this ticket.");
if (ticket.IsOpen)
{
ticket.Close(userId, dateTimeProvider.UtcNow);
await unitOfWork.CommitAsync();
}
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,8 @@
#nullable enable
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Messaging.Commands.CloseTicket;
/// <summary>Closes an open ticket (open → closed), stamping who/when. A participant or staff may close.</summary>
public record CloseTicketCommand(long TicketId) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,71 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Baya.Domain.Entities.Messaging;
using Baya.Domain.Entities.SupportAlerts;
using Mediator;
namespace Baya.Application.Features.Messaging.Commands.LogEmergencyTicket;
/// <summary>
/// Opens the emergency ticket (assigned nurse or staff) and, when requested, raises an internal
/// <c>support_alerts</c> row via the b1 raise API. The ticket + alert are the durable record staff triage; the
/// emergency call itself is out-of-platform (a <c>tel:</c> link in the UI), so there is no telephony seam here.
/// </summary>
internal sealed class LogEmergencyTicketCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IDateTimeProvider dateTimeProvider,
ISupportAlertService supportAlerts)
: IRequestHandler<LogEmergencyTicketCommand, OperationResult<OpenTicketResult>>
{
public async ValueTask<OperationResult<OpenTicketResult>> Handle(LogEmergencyTicketCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<OpenTicketResult>.UnauthorizedResult("Not authenticated.");
var parties = await unitOfWork.TicketRepository.GetBookingPartyUserIdsAsync(request.BookingId, cancellationToken);
if (parties is not { } p)
return OperationResult<OpenTicketResult>.NotFoundResult("Booking not found.");
// Only the assigned nurse (or staff) may log an emergency on the booking.
if (!StaffRoles.IsStaff(currentUser.Roles) && p.NurseUserId != userId)
return OperationResult<OpenTicketResult>.ForbiddenResult("Only the assigned nurse can log an emergency on this booking.");
var now = dateTimeProvider.UtcNow;
var referenceCode = await TicketReferenceCode.MintAsync(unitOfWork.TicketRepository, cancellationToken);
var ticket = new Ticket
{
ReferenceCode = referenceCode,
Category = TicketCategory.Emergency,
Subject = "On-site emergency",
BookingId = request.BookingId,
OpenedById = userId
};
ticket.Participants.Add(new TicketParticipant
{
UserId = userId,
RoleOnTicket = TicketRoleResolver.Derive(currentUser.Roles),
AddedById = userId
});
if (!string.IsNullOrWhiteSpace(request.Body))
ticket.Messages.Add(new TicketMessage { SenderId = userId, Body = request.Body, SentAt = now });
await unitOfWork.TicketRepository.AddAsync(ticket, cancellationToken);
await unitOfWork.CommitAsync();
// Self-committing facade — runs after the ticket is committed so the alert references a real ticket id.
if (request.RaiseAlert)
await supportAlerts.RaiseAsync(
SupportAlertType.Emergency, entityType: "ticket", entityId: ticket.Id.ToString(),
severity: SupportAlertSeverity.High, bookingId: request.BookingId, cancellationToken: cancellationToken);
return OperationResult<OpenTicketResult>.SuccessResult(
new OpenTicketResult(ticket.Id, ticket.ReferenceCode, ticket.Status, ticket.Category));
}
}
@@ -0,0 +1,15 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Mediator;
namespace Baya.Application.Features.Messaging.Commands.LogEmergencyTicket;
/// <summary>
/// The <b>operational</b> side of the on-site emergency playbook: after the nurse has called the emergency
/// contact (surfaced from the encrypted care instructions, out of platform), this records the aftermath — a
/// <c>category=emergency</c> ticket on the booking, and optionally a <c>support_alerts</c> row. It does
/// <b>not</b> dial anyone and does <b>not</b> expose any phone number; it does not widen the clinical disclosure.
/// </summary>
public record LogEmergencyTicketCommand(long BookingId, string? Body, bool RaiseAlert = true)
: IRequest<OperationResult<OpenTicketResult>>;
@@ -0,0 +1,76 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Baya.Domain.Entities.Messaging;
using Mediator;
namespace Baya.Application.Features.Messaging.Commands.OpenTicket;
/// <summary>
/// Creates the ticket + the opener participant (+ an optional first message) in one commit. Enforces the link
/// tenancy: a booking link requires the opener to be a party to the booking (staff bypass — staff read/attach to
/// any ticket); a refund link is staff-only (the b11 dispute paper trail). The <c>reference_code</c> is minted
/// once, collision-checked, and backed by the UNIQUE index.
/// </summary>
internal sealed class OpenTicketCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<OpenTicketCommand, OperationResult<OpenTicketResult>>
{
public async ValueTask<OperationResult<OpenTicketResult>> Handle(OpenTicketCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<OpenTicketResult>.UnauthorizedResult("Not authenticated.");
var isStaff = StaffRoles.IsStaff(currentUser.Roles);
if (request.BookingId is { } bookingId && !isStaff)
{
// Tenancy: a non-staff opener must be the booking's customer or nurse. A mismatch is a clean 404.
var isParty = await unitOfWork.TicketRepository.IsUserPartyToBookingAsync(bookingId, userId, cancellationToken);
if (!isParty)
return OperationResult<OpenTicketResult>.NotFoundResult("Booking not found.");
}
if (request.RefundId is not null && !isStaff)
return OperationResult<OpenTicketResult>.ForbiddenResult("Only staff can anchor a ticket to a refund.");
var now = dateTimeProvider.UtcNow;
var referenceCode = await TicketReferenceCode.MintAsync(unitOfWork.TicketRepository, cancellationToken);
var ticket = new Ticket
{
ReferenceCode = referenceCode,
Subject = request.Subject,
Category = request.Category,
BookingId = request.BookingId,
RefundId = request.RefundId,
OpenedById = userId
};
ticket.Participants.Add(new TicketParticipant
{
UserId = userId,
RoleOnTicket = TicketRoleResolver.Derive(currentUser.Roles),
AddedById = userId
});
if (!string.IsNullOrWhiteSpace(request.Body))
ticket.Messages.Add(new TicketMessage
{
SenderId = userId,
Body = request.Body,
IsInternal = false,
SentAt = now
});
await unitOfWork.TicketRepository.AddAsync(ticket, cancellationToken);
await unitOfWork.CommitAsync();
return OperationResult<OpenTicketResult>.SuccessResult(
new OpenTicketResult(ticket.Id, ticket.ReferenceCode, ticket.Status, ticket.Category));
}
}
@@ -0,0 +1,18 @@
using Baya.Domain.Entities.Messaging;
using FluentValidation;
namespace Baya.Application.Features.Messaging.Commands.OpenTicket;
public sealed class OpenTicketCommandValidator : AbstractValidator<OpenTicketCommand>
{
public OpenTicketCommandValidator()
{
RuleFor(x => x.Category)
.NotEmpty()
.Must(TicketCategory.IsValid)
.WithMessage($"Category must be one of: {string.Join(", ", TicketCategory.All)}.");
RuleFor(x => x.Subject).MaximumLength(300);
RuleFor(x => x.Body).MaximumLength(4000);
}
}
@@ -0,0 +1,20 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Mediator;
namespace Baya.Application.Features.Messaging.Commands.OpenTicket;
/// <summary>
/// Opens a ticket, mints a stable unique <c>reference_code</c>, attaches the optional (both nullable)
/// <see cref="BookingId"/>/<see cref="RefundId"/> links, and adds the opener as the first participant. Used by
/// the customer/nurse "contact support" flow, the b11 refund flow (which anchors <c>refunds.ticket_id</c> here),
/// and <c>LogEmergencyTicket</c>. A booking link requires the opener to be a party to the booking (staff bypass);
/// a refund link is staff-only.
/// </summary>
public record OpenTicketCommand(
string Category,
string? Subject,
string? Body,
long? BookingId,
long? RefundId) : IRequest<OperationResult<OpenTicketResult>>;
@@ -0,0 +1,72 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Baya.Domain.Entities.Messaging;
using Mediator;
namespace Baya.Application.Features.Messaging.Commands.PostMessage;
/// <summary>
/// Appends a message to a ticket after enforcing the visibility + posting rules: only an active participant (or
/// staff) may post; <c>is_internal</c> is staff-only (the hard boundary); a non-staff caller cannot post to a
/// closed ticket. A non-internal message notifies the other active participants (in-app only at MVP).
/// </summary>
internal sealed class PostMessageCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IDateTimeProvider dateTimeProvider,
INotificationDispatcher notifications)
: IRequestHandler<PostMessageCommand, OperationResult<PostMessageResult>>
{
public async ValueTask<OperationResult<PostMessageResult>> Handle(PostMessageCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<PostMessageResult>.UnauthorizedResult("Not authenticated.");
var isStaff = StaffRoles.IsStaff(currentUser.Roles);
var header = await unitOfWork.TicketRepository.GetHeaderAsync(request.TicketId, cancellationToken);
if (header is null)
return OperationResult<PostMessageResult>.NotFoundResult("Ticket not found.");
if (!isStaff && !await unitOfWork.TicketRepository.IsActiveParticipantAsync(request.TicketId, userId, cancellationToken))
return OperationResult<PostMessageResult>.ForbiddenResult("You are not a participant on this ticket.");
if (request.IsInternal && !isStaff)
return OperationResult<PostMessageResult>.ForbiddenResult("Only staff can post an internal note.");
if (header.Status == TicketStatus.Closed && !isStaff)
return OperationResult<PostMessageResult>.ForbiddenResult("This ticket is closed.");
var now = dateTimeProvider.UtcNow;
var message = new TicketMessage
{
TicketId = request.TicketId,
SenderId = userId,
Body = request.Body,
IsInternal = request.IsInternal,
SentAt = now
};
await unitOfWork.TicketRepository.AddMessageAsync(message, cancellationToken);
await unitOfWork.CommitAsync();
// Notify the other active participants — but never for an internal note (it must not surface to users).
if (!message.IsInternal)
{
var recipients = await unitOfWork.TicketRepository.GetActiveParticipantUserIdsAsync(request.TicketId, cancellationToken);
foreach (var recipient in recipients.Where(r => r != userId))
await notifications.DispatchAsync(
new Notification(recipient, "ticket_message",
"New ticket message", $"New message on ticket {header.ReferenceCode}.",
$"{{\"ticketId\":{request.TicketId},\"referenceCode\":\"{header.ReferenceCode}\"}}"),
cancellationToken);
}
return OperationResult<PostMessageResult>.SuccessResult(
new PostMessageResult(message.Id, request.TicketId, message.SentAt));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.Messaging.Commands.PostMessage;
public sealed class PostMessageCommandValidator : AbstractValidator<PostMessageCommand>
{
public PostMessageCommandValidator()
{
// TicketId is route-supplied (merged via `with`), so it is not validated here.
RuleFor(x => x.Body).NotEmpty().MaximumLength(4000);
}
}
@@ -0,0 +1,12 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Mediator;
namespace Baya.Application.Features.Messaging.Commands.PostMessage;
/// <summary>Appends a message to a ticket. Only an active participant (or staff) may post. <see cref="IsInternal"/>
/// (an admin-only note) can be set <b>only</b> by staff; a non-staff caller can neither set it nor post to a
/// closed ticket.</summary>
public record PostMessageCommand(long TicketId, string Body, bool IsInternal = false)
: IRequest<OperationResult<PostMessageResult>>;
@@ -0,0 +1,39 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Messaging.Commands.RemoveParticipant;
/// <summary>Soft-removes a participant (staff on any ticket, or the ticket owner). The unique row survives so a
/// later re-add resurrects it.</summary>
internal sealed class RemoveParticipantCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<RemoveParticipantCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(RemoveParticipantCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } callerId)
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
var header = await unitOfWork.TicketRepository.GetHeaderAsync(request.TicketId, cancellationToken);
if (header is null)
return OperationResult<bool>.NotFoundResult("Ticket not found.");
if (!StaffRoles.IsStaff(currentUser.Roles) && header.OpenedById != callerId)
return OperationResult<bool>.ForbiddenResult("Only staff or the ticket owner can manage participants.");
var participant = await unitOfWork.TicketRepository.GetParticipantAsync(request.TicketId, request.UserId, cancellationToken);
if (participant is null || !participant.IsActive)
return OperationResult<bool>.NotFoundResult("That user is not an active participant on this ticket.");
participant.Remove(dateTimeProvider.UtcNow);
await unitOfWork.CommitAsync();
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,9 @@
#nullable enable
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Messaging.Commands.RemoveParticipant;
/// <summary>Detaches a user from a ticket (soft <c>removed_at</c> stamp). Staff (any ticket) or the ticket owner
/// may remove.</summary>
public record RemoveParticipantCommand(long TicketId, int UserId) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,37 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Messaging.Commands.ReopenTicket;
/// <summary>Reopens a ticket (participant or staff). Idempotent — an already-open ticket is a no-op success.</summary>
internal sealed class ReopenTicketCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork)
: IRequestHandler<ReopenTicketCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(ReopenTicketCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
var ticket = await unitOfWork.TicketRepository.GetTrackedAsync(request.TicketId, cancellationToken);
if (ticket is null)
return OperationResult<bool>.NotFoundResult("Ticket not found.");
if (!StaffRoles.IsStaff(currentUser.Roles) &&
!await unitOfWork.TicketRepository.IsActiveParticipantAsync(request.TicketId, userId, cancellationToken))
return OperationResult<bool>.ForbiddenResult("You are not a participant on this ticket.");
if (!ticket.IsOpen)
{
ticket.Reopen();
await unitOfWork.CommitAsync();
}
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,8 @@
#nullable enable
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Messaging.Commands.ReopenTicket;
/// <summary>Reopens a closed ticket (closed → open). A participant or staff may reopen.</summary>
public record ReopenTicketCommand(long TicketId) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,49 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Mediator;
namespace Baya.Application.Features.Messaging.Queries.GetTicketThread;
/// <summary>
/// Loads the header, active participants, and messages of a ticket the caller may read. Internal messages are
/// included only in the admin view (staff) — the user view's projection strips them. Authorization: the admin
/// view requires staff; the user view requires an active participant (staff may also read the user view).
/// </summary>
internal sealed class GetTicketThreadQueryHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork)
: IRequestHandler<GetTicketThreadQuery, OperationResult<TicketThreadDto>>
{
public async ValueTask<OperationResult<TicketThreadDto>> Handle(GetTicketThreadQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<TicketThreadDto>.UnauthorizedResult("Not authenticated.");
var isStaff = StaffRoles.IsStaff(currentUser.Roles);
if (request.AsAdmin && !isStaff)
return OperationResult<TicketThreadDto>.ForbiddenResult("Admin ticket view requires staff access.");
var header = await unitOfWork.TicketRepository.GetHeaderAsync(request.TicketId, cancellationToken);
if (header is null)
return OperationResult<TicketThreadDto>.NotFoundResult("Ticket not found.");
if (!request.AsAdmin && !isStaff &&
!await unitOfWork.TicketRepository.IsActiveParticipantAsync(request.TicketId, userId, cancellationToken))
return OperationResult<TicketThreadDto>.ForbiddenResult("You are not a participant on this ticket.");
// Internal notes surface ONLY in the admin view; the user view never receives them.
var includeInternal = request.AsAdmin && isStaff;
var participants = await unitOfWork.TicketRepository.GetActiveParticipantsAsync(request.TicketId, cancellationToken);
var messages = await unitOfWork.TicketRepository.GetMessagesAsync(request.TicketId, includeInternal, cancellationToken);
return OperationResult<TicketThreadDto>.SuccessResult(new TicketThreadDto(
header.Id, header.ReferenceCode, header.Subject, header.Status, header.Category,
header.BookingId, header.RefundId, header.OpenedById, header.ClosedAt, participants, messages));
}
}
@@ -0,0 +1,14 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Mediator;
namespace Baya.Application.Features.Messaging.Queries.GetTicketThread;
/// <summary>
/// Returns the ordered thread for a ticket the caller may see. <see cref="AsAdmin"/> selects the view: the
/// <b>user</b> view (<c>false</c>) strips every <c>is_internal</c> message in the projection; the <b>admin</b>
/// view (<c>true</c>, staff only) returns them. This is enforced at the query layer, never in the UI.
/// </summary>
public record GetTicketThreadQuery(long TicketId, bool AsAdmin = false)
: IRequest<OperationResult<TicketThreadDto>>;
@@ -0,0 +1,25 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Mediator;
namespace Baya.Application.Features.Messaging.Queries.ListMyTickets;
internal sealed class ListMyTicketsQueryHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork)
: IRequestHandler<ListMyTicketsQuery, OperationResult<PagedResult<TicketSummaryDto>>>
{
public async ValueTask<OperationResult<PagedResult<TicketSummaryDto>>> Handle(ListMyTicketsQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<PagedResult<TicketSummaryDto>>.UnauthorizedResult("Not authenticated.");
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var result = await unitOfWork.TicketRepository.ListMyTicketsAsync(userId, request.Status, request.ReferenceCode, page, pageSize, cancellationToken);
return OperationResult<PagedResult<TicketSummaryDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,13 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Mediator;
namespace Baya.Application.Features.Messaging.Queries.ListMyTickets;
/// <summary>The caller's tickets (active participation), paginated, filterable by status and reference code, newest first.</summary>
public record ListMyTicketsQuery(
string? Status = null,
string? ReferenceCode = null,
int Page = 1,
int PageSize = 50) : IRequest<OperationResult<PagedResult<TicketSummaryDto>>>;
@@ -0,0 +1,20 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Mediator;
namespace Baya.Application.Features.Messaging.Queries.ListTicketsForAdmin;
internal sealed class ListTicketsForAdminQueryHandler(IUnitOfWork unitOfWork)
: IRequestHandler<ListTicketsForAdminQuery, OperationResult<PagedResult<TicketSummaryDto>>>
{
public async ValueTask<OperationResult<PagedResult<TicketSummaryDto>>> Handle(ListTicketsForAdminQuery request, CancellationToken cancellationToken)
{
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var result = await unitOfWork.TicketRepository.ListForAdminAsync(
request.Status, request.Category, request.ReferenceCode, request.BookingId, request.RefundId, page, pageSize, cancellationToken);
return OperationResult<PagedResult<TicketSummaryDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,17 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Mediator;
namespace Baya.Application.Features.Messaging.Queries.ListTicketsForAdmin;
/// <summary>The admin global ticket queue, paginated, filter by status/category, search by reference code, optional
/// booking/refund link, newest first.</summary>
public record ListTicketsForAdminQuery(
string? Status = null,
string? Category = null,
string? ReferenceCode = null,
long? BookingId = null,
long? RefundId = null,
int Page = 1,
int PageSize = 50) : IRequest<OperationResult<PagedResult<TicketSummaryDto>>>;
@@ -0,0 +1,37 @@
using Baya.Application.Contracts.Persistence;
namespace Baya.Application.Features.Messaging;
/// <summary>
/// Mints the human-facing, UNIQUE, stable ticket <c>reference_code</c> (e.g. <c>TKT-9F3K2A7Q</c>). Collision is
/// astronomically unlikely, but the code is quoted to users and backed by a UNIQUE index, so we collision-check
/// against the store and retry — the index is the authoritative backstop, this is the friendly pre-check.
/// </summary>
public static class TicketReferenceCode
{
private const string Prefix = "TKT-";
private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no ambiguous 0/O/1/I
private const int Length = 8;
public static async Task<string> MintAsync(ITicketRepository tickets, CancellationToken cancellationToken)
{
for (var attempt = 0; attempt < 5; attempt++)
{
var code = Generate();
if (!await tickets.ReferenceCodeExistsAsync(code, cancellationToken))
return code;
}
// Extremely improbable; fall back to a longer, still-checked code rather than loop forever.
return Prefix + Guid.NewGuid().ToString("N")[..12].ToUpperInvariant();
}
private static string Generate()
{
var bytes = Guid.NewGuid().ToByteArray();
var chars = new char[Length];
for (var i = 0; i < Length; i++)
chars[i] = Alphabet[bytes[i] % Alphabet.Length];
return Prefix + new string(chars);
}
}
@@ -0,0 +1,19 @@
using Baya.Application.Common;
using Baya.Domain.Entities.Messaging;
using Baya.Domain.Entities.User;
namespace Baya.Application.Features.Messaging;
/// <summary>Derives the display <c>role_on_ticket</c> label from a user's platform roles (staff → admin, nurse
/// → nurse, else customer). Purely a label — never an authorization source.</summary>
public static class TicketRoleResolver
{
public static string Derive(IReadOnlyList<string> roles)
{
if (StaffRoles.IsStaff(roles))
return TicketParticipantRole.Admin;
if (roles.Contains(RoleNames.Nurse))
return TicketParticipantRole.Nurse;
return TicketParticipantRole.Customer;
}
}
@@ -0,0 +1,38 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Baya.Domain.Entities.PartnerCenters;
using Mediator;
namespace Baya.Application.Features.PartnerCenters.Commands.CreatePartnerCenter;
/// <summary>Persists a new partner center (inactive). <see cref="PartnerCenter.SettlementIban"/> is set in
/// plaintext on the entity and encrypted by the EF converter on save — it is never returned in plaintext (the
/// detail read masks it).</summary>
internal sealed class CreatePartnerCenterCommandHandler(IUnitOfWork unitOfWork)
: IRequestHandler<CreatePartnerCenterCommand, OperationResult<PartnerCenterDetailDto>>
{
public async ValueTask<OperationResult<PartnerCenterDetailDto>> Handle(CreatePartnerCenterCommand request, CancellationToken cancellationToken)
{
var center = new PartnerCenter
{
Name = request.Name,
LegalEntityType = request.LegalEntityType,
MohEstablishmentPermitNo = request.MohEstablishmentPermitNo,
TechnicalDirectorNurseUserId = request.TechnicalDirectorNurseUserId,
TechnicalDirectorLicenseNo = request.TechnicalDirectorLicenseNo,
EnamadCode = request.EnamadCode,
SettlementIban = string.IsNullOrWhiteSpace(request.SettlementIban) ? null : request.SettlementIban,
IsMerchantOfRecord = request.IsMerchantOfRecord,
CommissionRate = request.CommissionRate,
AdminUserId = request.AdminUserId
};
await unitOfWork.PartnerCenterRepository.AddAsync(center, cancellationToken);
await unitOfWork.CommitAsync();
var detail = await unitOfWork.PartnerCenterRepository.GetDetailAsync(center.Id, cancellationToken);
return OperationResult<PartnerCenterDetailDto>.SuccessResult(detail!);
}
}
@@ -0,0 +1,30 @@
using FluentValidation;
namespace Baya.Application.Features.PartnerCenters.Commands.CreatePartnerCenter;
public sealed class CreatePartnerCenterCommandValidator : AbstractValidator<CreatePartnerCenterCommand>
{
public CreatePartnerCenterCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(300);
RuleFor(x => x.LegalEntityType).MaximumLength(30);
RuleFor(x => x.MohEstablishmentPermitNo).NotEmpty().MaximumLength(100);
RuleFor(x => x.TechnicalDirectorLicenseNo).MaximumLength(100);
RuleFor(x => x.EnamadCode).MaximumLength(100);
RuleFor(x => x.AdminUserId).GreaterThan(0);
// The center's cut is a fraction in [0, 1) — never ≥ 1, never negative.
RuleFor(x => x.CommissionRate)
.InclusiveBetween(0m, 0.9999m)
.When(x => x.CommissionRate.HasValue);
// A merchant-of-record center is the settlement target, so it MUST carry a settlement IBAN.
RuleFor(x => x.SettlementIban)
.NotEmpty()
.MaximumLength(34)
.When(x => x.IsMerchantOfRecord)
.WithMessage("A merchant-of-record center requires a settlement IBAN.");
RuleFor(x => x.SettlementIban).MaximumLength(34).When(x => !x.IsMerchantOfRecord);
}
}
@@ -0,0 +1,24 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Mediator;
namespace Baya.Application.Features.PartnerCenters.Commands.CreatePartnerCenter;
/// <summary>
/// Registers a licensed partner center (admin-only). <see cref="SettlementIban"/> is required when
/// <see cref="IsMerchantOfRecord"/> is set and is encrypted at rest before persisting (never plaintext);
/// <see cref="CommissionRate"/> is the center's own cut (01), separate from <c>platform_fee_rate</c>. The
/// center is created inactive — <c>VerifyPartnerCenter</c> records the licensing approval and activates it.
/// </summary>
public record CreatePartnerCenterCommand(
string Name,
string? LegalEntityType,
string MohEstablishmentPermitNo,
int? TechnicalDirectorNurseUserId,
string? TechnicalDirectorLicenseNo,
string? EnamadCode,
string? SettlementIban,
bool IsMerchantOfRecord,
decimal? CommissionRate,
int AdminUserId) : IRequest<OperationResult<PartnerCenterDetailDto>>;
@@ -0,0 +1,41 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.PartnerCenters.Commands.SponsorNurse;
/// <summary>
/// Sets (or clears) the nurse's sponsoring center. Authorization: staff, or the center's own dashboard account
/// (a center may sponsor within itself). A link points <c>nurse_profiles.partner_center_id</c> at the center;
/// unlinking sets it back to <c>null</c> (once Balinyaar holds its own permit).
/// </summary>
internal sealed class SponsorNurseCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork)
: IRequestHandler<SponsorNurseCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(SponsorNurseCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
var centerAdminUserId = await unitOfWork.PartnerCenterRepository.GetAdminUserIdAsync(request.CenterId, cancellationToken);
if (centerAdminUserId is null)
return OperationResult<bool>.NotFoundResult("Partner center not found.");
if (!StaffRoles.IsStaff(currentUser.Roles) && centerAdminUserId != userId)
return OperationResult<bool>.ForbiddenResult("Only staff or the center's own account can sponsor nurses here.");
var nurse = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(request.NurseProfileId, cancellationToken);
if (nurse is null)
return OperationResult<bool>.NotFoundResult("Nurse profile not found.");
nurse.PartnerCenterId = request.Unlink ? null : request.CenterId;
await unitOfWork.CommitAsync();
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.PartnerCenters.Commands.SponsorNurse;
public sealed class SponsorNurseCommandValidator : AbstractValidator<SponsorNurseCommand>
{
public SponsorNurseCommandValidator()
{
// CenterId is route-supplied; only the body-supplied nurse id is validated.
RuleFor(x => x.NurseProfileId).GreaterThan(0);
}
}
@@ -0,0 +1,11 @@
#nullable enable
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.PartnerCenters.Commands.SponsorNurse;
/// <summary>Links (or, when <see cref="Unlink"/>, unlinks) a nurse to a sponsoring center by setting
/// <c>nurse_profiles.partner_center_id</c>. <see cref="CenterId"/> is route-supplied. Staff, or the center's own
/// dashboard account, may sponsor within that center.</summary>
public record SponsorNurseCommand(long NurseProfileId, bool Unlink = false, long CenterId = 0)
: IRequest<OperationResult<bool>>;
@@ -0,0 +1,34 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Mediator;
namespace Baya.Application.Features.PartnerCenters.Commands.UpdatePartnerCenter;
internal sealed class UpdatePartnerCenterCommandHandler(IUnitOfWork unitOfWork)
: IRequestHandler<UpdatePartnerCenterCommand, OperationResult<PartnerCenterDetailDto>>
{
public async ValueTask<OperationResult<PartnerCenterDetailDto>> Handle(UpdatePartnerCenterCommand request, CancellationToken cancellationToken)
{
var center = await unitOfWork.PartnerCenterRepository.GetTrackedAsync(request.Id, cancellationToken);
if (center is null)
return OperationResult<PartnerCenterDetailDto>.NotFoundResult("Partner center not found.");
center.Name = request.Name;
center.LegalEntityType = request.LegalEntityType;
center.MohEstablishmentPermitNo = request.MohEstablishmentPermitNo;
center.TechnicalDirectorNurseUserId = request.TechnicalDirectorNurseUserId;
center.TechnicalDirectorLicenseNo = request.TechnicalDirectorLicenseNo;
center.EnamadCode = request.EnamadCode;
center.SettlementIban = string.IsNullOrWhiteSpace(request.SettlementIban) ? null : request.SettlementIban;
center.IsMerchantOfRecord = request.IsMerchantOfRecord;
center.CommissionRate = request.CommissionRate;
center.AdminUserId = request.AdminUserId;
await unitOfWork.CommitAsync();
var detail = await unitOfWork.PartnerCenterRepository.GetDetailAsync(center.Id, cancellationToken);
return OperationResult<PartnerCenterDetailDto>.SuccessResult(detail!);
}
}
@@ -0,0 +1,29 @@
using FluentValidation;
namespace Baya.Application.Features.PartnerCenters.Commands.UpdatePartnerCenter;
public sealed class UpdatePartnerCenterCommandValidator : AbstractValidator<UpdatePartnerCenterCommand>
{
public UpdatePartnerCenterCommandValidator()
{
// Id is route-supplied (merged via `with`), so it is not validated here.
RuleFor(x => x.Name).NotEmpty().MaximumLength(300);
RuleFor(x => x.LegalEntityType).MaximumLength(30);
RuleFor(x => x.MohEstablishmentPermitNo).NotEmpty().MaximumLength(100);
RuleFor(x => x.TechnicalDirectorLicenseNo).MaximumLength(100);
RuleFor(x => x.EnamadCode).MaximumLength(100);
RuleFor(x => x.AdminUserId).GreaterThan(0);
RuleFor(x => x.CommissionRate)
.InclusiveBetween(0m, 0.9999m)
.When(x => x.CommissionRate.HasValue);
RuleFor(x => x.SettlementIban)
.NotEmpty()
.MaximumLength(34)
.When(x => x.IsMerchantOfRecord)
.WithMessage("A merchant-of-record center requires a settlement IBAN.");
RuleFor(x => x.SettlementIban).MaximumLength(34).When(x => !x.IsMerchantOfRecord);
}
}
@@ -0,0 +1,21 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Mediator;
namespace Baya.Application.Features.PartnerCenters.Commands.UpdatePartnerCenter;
/// <summary>Updates a partner center's editable fields (admin-only, replace semantics). <see cref="Id"/> is
/// route-supplied. A merchant-of-record center must still carry a settlement IBAN.</summary>
public record UpdatePartnerCenterCommand(
string Name,
string? LegalEntityType,
string MohEstablishmentPermitNo,
int? TechnicalDirectorNurseUserId,
string? TechnicalDirectorLicenseNo,
string? EnamadCode,
string? SettlementIban,
bool IsMerchantOfRecord,
decimal? CommissionRate,
int AdminUserId,
long Id = 0) : IRequest<OperationResult<PartnerCenterDetailDto>>;
@@ -0,0 +1,45 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Mediator;
namespace Baya.Application.Features.PartnerCenters.Commands.VerifyPartnerCenter;
/// <summary>
/// Runs the (mocked) licensing checks behind <see cref="ILicenseVerificationService"/> and records the admin's
/// approval. An explicit <c>Invalid</c> verdict on the establishment permit or eNamad blocks activation; a
/// <c>Valid</c> or <c>NeedsManualReview</c> verdict lets the human admin's decision stand (this command is that
/// decision). The seam call is the swap point for a real registry/API later — this handler is unchanged then.
/// </summary>
internal sealed class VerifyPartnerCenterCommandHandler(
IUnitOfWork unitOfWork,
ILicenseVerificationService licenseVerification,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<VerifyPartnerCenterCommand, OperationResult<PartnerCenterDetailDto>>
{
public async ValueTask<OperationResult<PartnerCenterDetailDto>> Handle(VerifyPartnerCenterCommand request, CancellationToken cancellationToken)
{
var center = await unitOfWork.PartnerCenterRepository.GetTrackedAsync(request.Id, cancellationToken);
if (center is null)
return OperationResult<PartnerCenterDetailDto>.NotFoundResult("Partner center not found.");
var permitVerdict = await licenseVerification.VerifyEstablishmentPermitAsync(center.MohEstablishmentPermitNo, cancellationToken);
if (permitVerdict.Status == LicenseVerificationStatus.Invalid)
return OperationResult<PartnerCenterDetailDto>.FailureResult("moh_establishment_permit_no", permitVerdict.Reason);
if (!string.IsNullOrWhiteSpace(center.EnamadCode))
{
var enamadVerdict = await licenseVerification.VerifyENamadAsync(center.EnamadCode, cancellationToken);
if (enamadVerdict.Status == LicenseVerificationStatus.Invalid)
return OperationResult<PartnerCenterDetailDto>.FailureResult("enamad_code", enamadVerdict.Reason);
}
center.Verify(dateTimeProvider.UtcNow);
await unitOfWork.CommitAsync();
var detail = await unitOfWork.PartnerCenterRepository.GetDetailAsync(center.Id, cancellationToken);
return OperationResult<PartnerCenterDetailDto>.SuccessResult(detail!);
}
}
@@ -0,0 +1,11 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Mediator;
namespace Baya.Application.Features.PartnerCenters.Commands.VerifyPartnerCenter;
/// <summary>Records the licensing approval for a center and activates it (sets <c>verified_at</c> + <c>is_active</c>).
/// At MVP the eNamad / MoH check runs behind <c>ILicenseVerificationService</c> and returns <c>NeedsManualReview</c>,
/// so this command IS the admin's recorded decision; an <c>Invalid</c> verdict blocks activation.</summary>
public record VerifyPartnerCenterCommand(long Id = 0) : IRequest<OperationResult<PartnerCenterDetailDto>>;
@@ -0,0 +1,32 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Mediator;
namespace Baya.Application.Features.PartnerCenters.Queries.GetCenterDashboard;
/// <summary>Authorizes the dashboard to the center's own account (or staff) and returns its read model.</summary>
internal sealed class GetCenterDashboardQueryHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork)
: IRequestHandler<GetCenterDashboardQuery, OperationResult<CenterDashboardDto>>
{
public async ValueTask<OperationResult<CenterDashboardDto>> Handle(GetCenterDashboardQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<CenterDashboardDto>.UnauthorizedResult("Not authenticated.");
var centerAdminUserId = await unitOfWork.PartnerCenterRepository.GetAdminUserIdAsync(request.CenterId, cancellationToken);
if (centerAdminUserId is null)
return OperationResult<CenterDashboardDto>.NotFoundResult("Partner center not found.");
if (!StaffRoles.IsStaff(currentUser.Roles) && centerAdminUserId != userId)
return OperationResult<CenterDashboardDto>.ForbiddenResult("This dashboard belongs to another center.");
var dashboard = await unitOfWork.PartnerCenterRepository.GetDashboardAsync(request.CenterId, cancellationToken);
return OperationResult<CenterDashboardDto>.SuccessResult(dashboard!);
}
}
@@ -0,0 +1,10 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Mediator;
namespace Baya.Application.Features.PartnerCenters.Queries.GetCenterDashboard;
/// <summary>The center dashboard, scoped to the center's own <c>admin_user_id</c> (staff may also read it):
/// sponsored nurses + booking/invoice counts + the (masked) settlement summary.</summary>
public record GetCenterDashboardQuery(long CenterId) : IRequest<OperationResult<CenterDashboardDto>>;
@@ -0,0 +1,19 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Mediator;
namespace Baya.Application.Features.PartnerCenters.Queries.GetCenterForBooking;
internal sealed class GetCenterForBookingQueryHandler(IUnitOfWork unitOfWork)
: IRequestHandler<GetCenterForBookingQuery, OperationResult<CenterForBookingDto>>
{
public async ValueTask<OperationResult<CenterForBookingDto>> Handle(GetCenterForBookingQuery request, CancellationToken cancellationToken)
{
var resolution = await unitOfWork.PartnerCenterRepository.ResolveCenterForBookingAsync(request.BookingId, cancellationToken);
return resolution is null
? OperationResult<CenterForBookingDto>.NotFoundResult("Booking not found.")
: OperationResult<CenterForBookingDto>.SuccessResult(resolution);
}
}
@@ -0,0 +1,11 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Mediator;
namespace Baya.Application.Features.PartnerCenters.Queries.GetCenterForBooking;
/// <summary>Resolves which center legally covers a booking and the resulting invoice-issuer / settlement decision.
/// This is the single resolver b11's commission-invoice issuance calls — invoices and settlement follow
/// <c>partner_centers</c>, never a hardcoded platform.</summary>
public record GetCenterForBookingQuery(long BookingId) : IRequest<OperationResult<CenterForBookingDto>>;
@@ -0,0 +1,19 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Mediator;
namespace Baya.Application.Features.PartnerCenters.Queries.GetPartnerCenterById;
internal sealed class GetPartnerCenterByIdQueryHandler(IUnitOfWork unitOfWork)
: IRequestHandler<GetPartnerCenterByIdQuery, OperationResult<PartnerCenterDetailDto>>
{
public async ValueTask<OperationResult<PartnerCenterDetailDto>> Handle(GetPartnerCenterByIdQuery request, CancellationToken cancellationToken)
{
var detail = await unitOfWork.PartnerCenterRepository.GetDetailAsync(request.Id, cancellationToken);
return detail is null
? OperationResult<PartnerCenterDetailDto>.NotFoundResult("Partner center not found.")
: OperationResult<PartnerCenterDetailDto>.SuccessResult(detail);
}
}
@@ -0,0 +1,9 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Mediator;
namespace Baya.Application.Features.PartnerCenters.Queries.GetPartnerCenterById;
/// <summary>Admin detail view of a partner center — the settlement IBAN is returned <b>masked</b> (last 4), never plaintext.</summary>
public record GetPartnerCenterByIdQuery(long Id) : IRequest<OperationResult<PartnerCenterDetailDto>>;
@@ -0,0 +1,19 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Mediator;
namespace Baya.Application.Features.PartnerCenters.Queries.ListPartnerCenters;
internal sealed class ListPartnerCentersQueryHandler(IUnitOfWork unitOfWork)
: IRequestHandler<ListPartnerCentersQuery, OperationResult<PagedResult<PartnerCenterListItemDto>>>
{
public async ValueTask<OperationResult<PagedResult<PartnerCenterListItemDto>>> Handle(ListPartnerCentersQuery request, CancellationToken cancellationToken)
{
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var result = await unitOfWork.PartnerCenterRepository.ListAsync(request.IsActive, page, pageSize, cancellationToken);
return OperationResult<PagedResult<PartnerCenterListItemDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,10 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Mediator;
namespace Baya.Application.Features.PartnerCenters.Queries.ListPartnerCenters;
/// <summary>Admin paginated list of partner centers (no IBAN), optional active filter, with sponsored-nurse counts.</summary>
public record ListPartnerCentersQuery(bool? IsActive = null, int Page = 1, int PageSize = 50)
: IRequest<OperationResult<PagedResult<PartnerCenterListItemDto>>>;
@@ -20,7 +20,8 @@ internal sealed class ConfirmPaymentAndPostLedgerCommandHandler(
IPaymentProvider paymentProvider,
ISettlementSplitProvider settlementSplitProvider,
IVariantSnapshotSerializer variantSnapshotSerializer,
INotificationDispatcher notifications)
INotificationDispatcher notifications,
ISender sender)
: IRequestHandler<ConfirmPaymentAndPostLedgerCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(ConfirmPaymentAndPostLedgerCommand request, CancellationToken cancellationToken)
@@ -86,6 +87,11 @@ internal sealed class ConfirmPaymentAndPostLedgerCommandHandler(
if (conversion.Created && conversion.CustomerUserId is { } customerUserId && conversion.NurseUserId is { } nurseUserId)
await NotifyConfirmedAsync(customerUserId, nurseUserId, booking, cancellationToken);
// Open the booking-coordination ticket (nurse + customer) once the booking is confirmed. Idempotent —
// one coordination ticket per booking; a replayed confirm is a no-op (b15).
if (conversion.Created)
await sender.Send(new Messaging.Commands.AutoCreateCoordinationTicket.AutoCreateCoordinationTicketCommand(booking), cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
@@ -6,8 +6,10 @@ using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Features.Messaging.Commands.OpenTicket;
using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Baya.Domain.Entities.Messaging;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Baya.Domain.Entities.SupportAlerts;
@@ -34,23 +36,31 @@ internal sealed class CreateRefundCommandHandler(
IBnplProvider bnplProvider,
INursePayoutStatus nursePayoutStatus,
ISupportAlertService supportAlerts,
INotificationDispatcher notifications)
INotificationDispatcher notifications,
ISender sender)
: IRequestHandler<CreateRefundCommand, OperationResult<CreateRefundResult>>
{
public async ValueTask<OperationResult<CreateRefundResult>> Handle(CreateRefundCommand request, CancellationToken cancellationToken)
{
// Ticket link is required, but config-gated off until b15 ships the tickets table (the FK is nullable
// now for exactly this forward-dep). Read the gate through the typed config accessor, never hardcoded.
var ticketRequired = await platformConfig.GetConfig<bool>("refund_ticket_required", cancellationToken);
if (ticketRequired && request.TicketId is null)
return OperationResult<CreateRefundResult>.FailureResult("ticket_id", "A support ticket is required to issue a refund.");
await using var _ = await distributedLock.AcquireAsync($"booking:{request.BookingId}:refund", cancellationToken);
var context = await unitOfWork.RefundRepository.GetRefundContextAsync(request.BookingId, cancellationToken);
if (context is null)
return OperationResult<CreateRefundResult>.NotFoundResult("No captured payment exists for this booking to refund.");
// Every admin refund hangs off a ticket (the dispute paper trail). b15 ships the ticket system this link
// targets: if the caller didn't supply one, open a category=refund ticket now (staff bypass on the booking
// link), so refunds.ticket_id is always non-null. Only done once the booking's captured payment exists.
var ticketId = request.TicketId;
if (ticketId is null)
{
var opened = await sender.Send(
new OpenTicketCommand(TicketCategory.Refund, "Refund", null, request.BookingId, null), cancellationToken);
if (!opened.IsSuccess)
return OperationResult<CreateRefundResult>.FailureResult("ticket", "Could not open a refund ticket to anchor this refund.");
ticketId = opened.Result.TicketId;
}
var decomposition = ResolveDecomposition(request, context);
if (decomposition is null)
return OperationResult<CreateRefundResult>.FailureResult(
@@ -79,7 +89,7 @@ internal sealed class CreateRefundCommandHandler(
PaymentTransactionId = context.PaymentTransactionId,
BookingId = context.BookingId,
RequestedByCustomerId = context.CustomerId,
TicketId = request.TicketId,
TicketId = ticketId,
Amount = amount,
PlatformFeeRefundedIrr = platformFeeRefunded,
NursePayoutRefundedIrr = nursePayoutRefunded,
@@ -0,0 +1,58 @@
#nullable enable
namespace Baya.Application.Models.Messaging;
/// <summary>A ticket row in a list (no messages). Used by <c>ListMyTickets</c> and the admin queue.</summary>
public record TicketSummaryDto(
long Id,
string ReferenceCode,
string? Subject,
string Status,
string Category,
long? BookingId,
long? RefundId,
DateTimeOffset CreatedAt);
/// <summary>One message in a thread. <c>IsInternal</c> is only ever <c>true</c> in the admin view — the user
/// view never contains an internal message (it is stripped in the query projection).</summary>
public record TicketMessageDto(
long Id,
int SenderId,
string Body,
bool IsInternal,
DateTimeOffset SentAt);
/// <summary>An active participant on a thread.</summary>
public record TicketParticipantDto(
int UserId,
string? RoleOnTicket);
/// <summary>The full ordered thread the caller may see. In the <b>user</b> view internal messages are absent;
/// the <b>admin</b> view returns them.</summary>
public record TicketThreadDto(
long Id,
string ReferenceCode,
string? Subject,
string Status,
string Category,
long? BookingId,
long? RefundId,
int OpenedById,
DateTimeOffset? ClosedAt,
IReadOnlyList<TicketParticipantDto> Participants,
IReadOnlyList<TicketMessageDto> Messages);
/// <summary>The header facts of a ticket (no messages) — used for authorization + the thread response header.</summary>
public record TicketHeaderDto(
long Id,
string ReferenceCode,
string? Subject,
string Status,
string Category,
long? BookingId,
long? RefundId,
int OpenedById,
DateTimeOffset? ClosedAt);
public record OpenTicketResult(long TicketId, string ReferenceCode, string Status, string Category);
public record PostMessageResult(long MessageId, long TicketId, DateTimeOffset SentAt);
@@ -0,0 +1,65 @@
#nullable enable
namespace Baya.Application.Models.PartnerCenters;
/// <summary>A partner center in the admin list (never carries the settlement IBAN), with its sponsored-nurse count.</summary>
public record PartnerCenterListItemDto(
long Id,
string Name,
string? LegalEntityType,
bool IsMerchantOfRecord,
bool IsActive,
DateTimeOffset? VerifiedAt,
int SponsoredNurseCount,
int AdminUserId);
/// <summary>The admin detail view of a partner center. <see cref="SettlementIbanMasked"/> is the last-4 mask —
/// the plaintext/full IBAN is <b>never</b> returned.</summary>
public record PartnerCenterDetailDto(
long Id,
string Name,
string? LegalEntityType,
string MohEstablishmentPermitNo,
int? TechnicalDirectorNurseUserId,
string? TechnicalDirectorLicenseNo,
string? EnamadCode,
string? SettlementIbanMasked,
bool IsMerchantOfRecord,
decimal? CommissionRate,
int AdminUserId,
bool IsActive,
DateTimeOffset? VerifiedAt,
int SponsoredNurseCount,
DateTimeOffset CreatedAt);
/// <summary>The issuer/settlement decision for a booking. <see cref="IssuingEntityType"/> is <c>platform</c> or
/// <c>partner_center</c>; when a merchant-of-record center legally covers the booking it is the invoice issuer
/// and settlement target. This is the single resolver b11's invoice issuance calls.</summary>
public record CenterForBookingDto(
long BookingId,
string IssuingEntityType,
long? PartnerCenterId,
string? PartnerCenterName,
bool IsMerchantOfRecord);
/// <summary>A nurse sponsored by a center (for the center dashboard).</summary>
public record SponsoredNurseDto(
long NurseProfileId,
int UserId,
bool IsVerified,
decimal AverageRating,
int TotalCompletedBookings);
/// <summary>The center dashboard read model — scoped to the center's <c>admin_user_id</c>: the sponsored nurses,
/// the count of bookings its nurses served, and the settlement/invoice summary (only meaningful when the center
/// is merchant-of-record). Surfaces b13 payout / b11 invoice read models filtered to the center; it does not
/// re-implement that logic.</summary>
public record CenterDashboardDto(
long CenterId,
string Name,
bool IsMerchantOfRecord,
bool IsActive,
string? SettlementIbanMasked,
int SponsoredNurseCount,
int SponsoredBookingCount,
int InvoiceCount,
IReadOnlyList<SponsoredNurseDto> SponsoredNurses);
@@ -0,0 +1,63 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Messaging;
/// <summary>
/// The root of <b>all</b> post-booking communication. There is deliberately no live chat and no direct
/// nurse↔customer side-channel: nurse and customer coordinate here, under full admin visibility, and admin-only
/// <c>is_internal</c> notes (on <see cref="TicketMessage"/>) never leak to users. A ticket optionally links a
/// <see cref="BookingId"/> and/or a <see cref="RefundId"/> — <b>both nullable</b>: a pure support ticket has
/// neither. <see cref="ReferenceCode"/> is the human-facing support id: minted once (collision-checked, UNIQUE
/// index backstop) and never mutated. Status transitions go through <see cref="Close"/>/<see cref="Reopen"/>.
/// </summary>
public class Ticket : BaseEntity<long>
{
/// <summary>Human-facing support id (e.g. <c>TKT-3F9K2A</c>). Minted once, UNIQUE, stable, quoted to users.</summary>
public string ReferenceCode { get; set; } = string.Empty;
public string? Subject { get; set; }
/// <summary>Guarded — mutated only through <see cref="Close"/>/<see cref="Reopen"/>. Defaults to
/// <see cref="TicketStatus.Open"/>.</summary>
public string Status { get; private set; } = TicketStatus.Open;
/// <summary>A <see cref="TicketCategory"/> code.</summary>
public string Category { get; set; } = TicketCategory.Support;
/// <summary>Optional link to the booking this ticket coordinates/anchors. NULL for a pure support ticket.</summary>
public long? BookingId { get; set; }
/// <summary>Optional link to the refund this ticket anchors (b11's dispute paper trail). NULL otherwise.</summary>
public long? RefundId { get; set; }
/// <summary>The <c>users.id</c> that opened the ticket (first participant).</summary>
public int OpenedById { get; set; }
public DateTimeOffset? ClosedAt { get; private set; }
public int? ClosedById { get; private set; }
public DateTimeOffset? DeletedAt { get; set; }
public ICollection<TicketParticipant> Participants { get; set; } = new List<TicketParticipant>();
public ICollection<TicketMessage> Messages { get; set; } = new List<TicketMessage>();
public bool IsOpen => Status == TicketStatus.Open;
/// <summary>Closes the thread and stamps who/when — the owner trail also comes from the audit fields.</summary>
public void Close(int closedById, DateTimeOffset now)
{
Status = TicketStatus.Closed;
ClosedAt = now;
ClosedById = closedById;
}
/// <summary>Reopens a closed thread, clearing the close stamp.</summary>
public void Reopen()
{
Status = TicketStatus.Open;
ClosedAt = null;
ClosedById = null;
}
}
@@ -0,0 +1,39 @@
namespace Baya.Domain.Entities.Messaging;
/// <summary>The lifecycle of a <see cref="Ticket"/>, persisted as these stable snake_case codes (never a C#
/// enum member name). A ticket is born <see cref="Open"/> and can be closed/reopened by a participant or admin.</summary>
public static class TicketStatus
{
public const string Open = "open";
public const string Closed = "closed";
public static readonly IReadOnlyList<string> All = [Open, Closed];
public static bool IsValid(string status) => All.Contains(status);
}
/// <summary>What a ticket is for. <see cref="Coordination"/> is the auto-created, booking-scoped logistics
/// thread; <see cref="Support"/> is a free-standing help request; <see cref="Refund"/> anchors an admin refund
/// (b11); <see cref="Emergency"/> records the aftermath of an on-site emergency call.</summary>
public static class TicketCategory
{
public const string Coordination = "coordination";
public const string Support = "support";
public const string Refund = "refund";
public const string Emergency = "emergency";
public static readonly IReadOnlyList<string> All = [Coordination, Support, Refund, Emergency];
public static bool IsValid(string category) => All.Contains(category);
}
/// <summary>The role a participant plays on a thread — derived from the user's platform role, used only to label
/// the thread in the UI. Not an authorization source (authorization is participation + admin).</summary>
public static class TicketParticipantRole
{
public const string Customer = "customer";
public const string Nurse = "nurse";
public const string Admin = "admin";
public static readonly IReadOnlyList<string> All = [Customer, Nurse, Admin];
}
@@ -0,0 +1,29 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Messaging;
/// <summary>
/// One message on a ticket thread. <see cref="IsInternal"/> is the <b>hard visibility boundary</b>: an
/// admin-only internal note that must never appear in any user-facing query, payload, or join — a non-admin can
/// neither set nor read it. This is enforced in the query projection (the user view of a thread strips every
/// internal message), not in the UI.
/// </summary>
public class TicketMessage : BaseEntity<long>
{
public long TicketId { get; set; }
public Ticket Ticket { get; set; } = null!;
/// <summary>The <c>users.id</c> that sent the message.</summary>
public int SenderId { get; set; }
public string Body { get; set; } = string.Empty;
/// <summary>Admin-only note. Default <c>false</c>. The hard visibility boundary — stripped from every
/// user-facing thread read; only the admin view returns it.</summary>
public bool IsInternal { get; set; }
public DateTimeOffset SentAt { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}
@@ -0,0 +1,38 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Messaging;
/// <summary>
/// A user on a ticket thread. Participation (plus admin) is the authorization boundary: only an <b>active</b>
/// participant (or an admin) may read or post. <c>UNIQUE(ticket_id, user_id)</c> is the authoritative backstop
/// against adding a user twice — a duplicate add is a clean <c>OperationResult</c> conflict, never a raw DB
/// exception. Removal is a soft <see cref="RemovedAt"/> stamp (so the unique row survives and a later re-add
/// resurrects it) rather than a hard delete.
/// </summary>
public class TicketParticipant : BaseEntity<long>
{
public long TicketId { get; set; }
public Ticket Ticket { get; set; } = null!;
public int UserId { get; set; }
/// <summary>A <see cref="TicketParticipantRole"/> label (derived), nullable.</summary>
public string? RoleOnTicket { get; set; }
public int? AddedById { get; set; }
/// <summary>Soft-remove stamp. <c>null</c> = an active participant; non-null = detached from the thread.</summary>
public DateTimeOffset? RemovedAt { get; private set; }
public bool IsActive => RemovedAt is null;
public void Remove(DateTimeOffset now) => RemovedAt = now;
/// <summary>Re-attaches a previously-removed participant (the same UNIQUE row), clearing the removal stamp.</summary>
public void Restore(int? addedById)
{
RemovedAt = null;
AddedById = addedById;
}
}
@@ -0,0 +1,62 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.PartnerCenters;
/// <summary>
/// A licensed home-nursing center (مرکز مشاوره و ارائه مراقبت‌های پرستاری در منزل) that <b>sponsors</b> nurses
/// and, at launch, is plausibly the <b>merchant-of-record</b> (the Asanism go-to-market model). When
/// <see cref="IsMerchantOfRecord"/> is set it — <b>not</b> Balinyaar and <b>not</b> the nurse — is the legal
/// invoice issuer and the IPG settlement target: invoices and settlement follow the center, resolved once via
/// <c>GetCenterForBooking</c>. Distinct from the future (DEFERRED) <c>organizations</c> employer model — a
/// center sponsors *for legality*, it does not *employ*. Marked <see cref="IAuditable"/> so every admin state
/// change appends an immutable <c>audit_logs</c> row (the encrypted <see cref="SettlementIban"/> is redacted in
/// the diff).
/// </summary>
public class PartnerCenter : BaseEntity<long>, IAuditable
{
public string Name { get; set; } = string.Empty;
public string? LegalEntityType { get; set; }
/// <summary>پروانه تأسیس — the MoH establishment permit number that makes the operation legal.</summary>
public string MohEstablishmentPermitNo { get; set; } = string.Empty;
/// <summary>مسئول فنی — the technical director nurse's <c>users.id</c>. Nullable.</summary>
public int? TechnicalDirectorNurseUserId { get; set; }
public string? TechnicalDirectorLicenseNo { get; set; }
/// <summary>نماد اعتماد الکترونیکی — the eNamad trust seal code (clears the BNPL onboarding gate).</summary>
public string? EnamadCode { get; set; }
/// <summary>The center's settlement IBAN — <b>only</b> when merchant-of-record. Encrypted at rest via
/// <c>IFieldEncryptor</c> (converter wired in <c>ApplicationDbContext</c>); never stored, logged, or
/// projected in plaintext; masked (last 4) in any read. Redacted from the audit diff.</summary>
[AuditRedacted]
public string? SettlementIban { get; set; }
public bool IsMerchantOfRecord { get; set; }
/// <summary>The center's own cut (fraction, 01), separate from <c>platform_fee_rate</c>. Nullable.</summary>
public decimal? CommissionRate { get; set; }
/// <summary>The <c>users.id</c> that owns the center's dashboard account.</summary>
public int AdminUserId { get; set; }
/// <summary>Guarded — flipped only through <see cref="Verify"/>/<see cref="SetActive"/>.</summary>
public bool IsActive { get; private set; }
public DateTimeOffset? VerifiedAt { get; private set; }
public DateTimeOffset? DeletedAt { get; set; }
/// <summary>Records the (manual, at MVP) licensing approval and activates the center.</summary>
public void Verify(DateTimeOffset now)
{
VerifiedAt = now;
IsActive = true;
}
public void SetActive(bool active) => IsActive = active;
}
@@ -49,9 +49,13 @@ public static class SupportAlertType
/// track recovery. Iranian IBAN transfers are irreversible, so this is always worth a human look.</summary>
public const string NurseClawback = "nurse_clawback";
/// <summary>An on-site emergency was logged by the assigned nurse (b15) — the aftermath of the emergency-call
/// playbook. Always worth a human review.</summary>
public const string Emergency = "emergency";
public static readonly IReadOnlyList<string> All =
[
LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, SharedSim, PaymentAnomaly, FraudSignal, NurseClawback
LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, SharedSim, PaymentAnomaly, FraudSignal, NurseClawback, Emergency
];
}