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,60 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.PartnerCenters.Commands.CreatePartnerCenter;
using Baya.Application.Features.PartnerCenters.Commands.SponsorNurse;
using Baya.Application.Features.PartnerCenters.Commands.UpdatePartnerCenter;
using Baya.Application.Features.PartnerCenters.Commands.VerifyPartnerCenter;
using Baya.Application.Features.PartnerCenters.Queries.GetPartnerCenterById;
using Baya.Application.Features.PartnerCenters.Queries.ListPartnerCenters;
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>
/// Admin partner-center console (admin/super_admin). Creates/updates/verifies licensed centers and sponsors
/// nurses. The settlement IBAN is encrypted at rest and returned <b>masked</b> (last 4). Internal-only, audited.
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/admin/partner-centers")]
[Authorize(ConstantPolicies.DynamicPermission)]
[Display(Description = "Admin partner-center management (licensed sponsor / merchant-of-record)")]
public sealed class AdminPartnerCentersController(ISender sender) : BaseController
{
[HttpPost]
[ProducesOkApiResponseType<PartnerCenterDetailDto>]
public async Task<IActionResult> Create(CreatePartnerCenterCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPatch("{id}")]
[ProducesOkApiResponseType<PartnerCenterDetailDto>]
public async Task<IActionResult> Update(long id, UpdatePartnerCenterCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpPost("{id}/verify")]
[ProducesOkApiResponseType<PartnerCenterDetailDto>]
public async Task<IActionResult> Verify(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new VerifyPartnerCenterCommand(id), cancellationToken));
[HttpPost("{id}/sponsor-nurse")]
[ProducesOkApiResponseType]
public async Task<IActionResult> SponsorNurse(long id, SponsorNurseCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { CenterId = id }, cancellationToken));
[HttpGet]
[ProducesOkApiResponseType<PagedResult<PartnerCenterListItemDto>>]
public async Task<IActionResult> List([FromQuery] ListPartnerCentersQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
[HttpGet("{id}")]
[ProducesOkApiResponseType<PartnerCenterDetailDto>]
public async Task<IActionResult> GetById(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetPartnerCenterByIdQuery(id), cancellationToken));
}
@@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Messaging.Queries.GetTicketThread;
using Baya.Application.Features.Messaging.Queries.ListTicketsForAdmin;
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>Admin ticket console (support/admin): the global queue and the <b>admin</b> thread view (internal
/// notes included). Internal-only, RBAC-gated, audited.</summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/admin/tickets")]
[Authorize(ConstantPolicies.DynamicPermission)]
[Display(Description = "Admin ticket queue + full (internal-inclusive) thread view")]
public sealed class AdminTicketsController(ISender sender) : BaseController
{
[HttpGet]
[ProducesOkApiResponseType<PagedResult<TicketSummaryDto>>]
public async Task<IActionResult> List([FromQuery] ListTicketsForAdminQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
// Admin view — internal notes ARE returned.
[HttpGet("{id}")]
[ProducesOkApiResponseType<TicketThreadDto>]
public async Task<IActionResult> Thread(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetTicketThreadQuery(id, AsAdmin: true), cancellationToken));
}
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.PartnerCenters.Queries.GetCenterDashboard;
using Baya.Application.Models.PartnerCenters;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>The partner-center portal — scoped to the center's own dashboard account (or staff). Surfaces the
/// sponsored nurses + booking/invoice counts + the masked settlement summary.</summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/centers")]
[Authorize]
[Display(Description = "Partner-center dashboard (center account scoped)")]
public sealed class CentersController(ISender sender) : BaseController
{
[HttpGet("{id}/dashboard")]
[ProducesOkApiResponseType<CenterDashboardDto>]
public async Task<IActionResult> Dashboard(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetCenterDashboardQuery(id), cancellationToken));
}
@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.PartnerCenters.Queries.GetCenterForBooking;
using Baya.Application.Models.PartnerCenters;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>Internal/admin resolver: which center legally covers a booking (invoice issuer + settlement target).
/// The single merchant-of-record decision — invoices and settlement follow <c>partner_centers</c>.</summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/internal/bookings")]
[Authorize(ConstantPolicies.DynamicPermission)]
[Display(Description = "Internal: resolve the invoice issuer / settlement center for a booking")]
public sealed class InternalCentersController(ISender sender) : BaseController
{
[HttpGet("{bookingId}/center")]
[ProducesOkApiResponseType<CenterForBookingDto>]
public async Task<IActionResult> CenterForBooking(long bookingId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetCenterForBookingQuery(bookingId), cancellationToken));
}
@@ -0,0 +1,78 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Messaging.Commands.AddParticipant;
using Baya.Application.Features.Messaging.Commands.CloseTicket;
using Baya.Application.Features.Messaging.Commands.LogEmergencyTicket;
using Baya.Application.Features.Messaging.Commands.OpenTicket;
using Baya.Application.Features.Messaging.Commands.PostMessage;
using Baya.Application.Features.Messaging.Commands.RemoveParticipant;
using Baya.Application.Features.Messaging.Commands.ReopenTicket;
using Baya.Application.Features.Messaging.Queries.GetTicketThread;
using Baya.Application.Features.Messaging.Queries.ListMyTickets;
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>
/// The ticket system — the only sanctioned post-booking communication channel. All reads/writes are
/// participation-gated (staff may attach to any ticket). The user thread view never contains an internal note.
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/tickets")]
[Authorize]
[Display(Description = "Post-booking ticket communication (participant-scoped)")]
public sealed class TicketsController(ISender sender) : BaseController
{
[HttpPost]
[ProducesOkApiResponseType<OpenTicketResult>]
public async Task<IActionResult> Open(OpenTicketCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("emergency")]
[ProducesOkApiResponseType<OpenTicketResult>]
public async Task<IActionResult> Emergency(LogEmergencyTicketCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("{id}/messages")]
[ProducesOkApiResponseType<PostMessageResult>]
public async Task<IActionResult> PostMessage(long id, PostMessageCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { TicketId = id }, cancellationToken));
[HttpPost("{id}/participants")]
[ProducesOkApiResponseType]
public async Task<IActionResult> AddParticipant(long id, AddParticipantCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { TicketId = id }, cancellationToken));
[HttpDelete("{id}/participants/{userId}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> RemoveParticipant(long id, int userId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new RemoveParticipantCommand(id, userId), cancellationToken));
[HttpPost("{id}/close")]
[ProducesOkApiResponseType]
public async Task<IActionResult> Close(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new CloseTicketCommand(id), cancellationToken));
[HttpPost("{id}/reopen")]
[ProducesOkApiResponseType]
public async Task<IActionResult> Reopen(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new ReopenTicketCommand(id), cancellationToken));
[HttpGet]
[ProducesOkApiResponseType<PagedResult<TicketSummaryDto>>]
public async Task<IActionResult> ListMine([FromQuery] ListMyTicketsQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
// User view — internal notes are stripped in the query projection.
[HttpGet("{id}")]
[ProducesOkApiResponseType<TicketThreadDto>]
public async Task<IActionResult> Thread(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetTicketThreadQuery(id, AsAdmin: false), cancellationToken));
}
@@ -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
];
}
@@ -0,0 +1,31 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Default <see cref="ILicenseVerificationService"/> — and the mock. The MoH establishment-permit registry and
/// eNamad have <b>no public B2B API</b>, so at MVP licensing is a manual admin approval: every call returns
/// <see cref="LicenseVerificationStatus.NeedsManualReview"/>, and <c>VerifyPartnerCenter</c> records the human
/// decision. Set <see cref="LicenseVerificationOptions.AutoApprove"/> to have the mock return
/// <see cref="LicenseVerificationStatus.Valid"/> (test the auto-approve path). When a real registry/API becomes
/// available, a real implementation replaces this registration and starts returning real verdicts — callers are
/// unchanged.
/// </summary>
public sealed class MockLicenseVerificationService(IOptions<SeamOptions> options) : ILicenseVerificationService
{
private LicenseVerificationOptions Options => options.Value.LicenseVerification;
public Task<LicenseVerdict> VerifyEstablishmentPermitAsync(string permitNo, CancellationToken cancellationToken = default)
=> Task.FromResult(Verdict($"establishment permit '{permitNo}'"));
public Task<LicenseVerdict> VerifyENamadAsync(string enamadCode, CancellationToken cancellationToken = default)
=> Task.FromResult(Verdict($"eNamad '{enamadCode}'"));
private LicenseVerdict Verdict(string subject)
=> Options.AutoApprove
? new LicenseVerdict(LicenseVerificationStatus.Valid, $"Auto-approved (mock) for {subject}.")
: new LicenseVerdict(LicenseVerificationStatus.NeedsManualReview,
$"No automated registry for {subject}; requires a manual admin decision.");
}
@@ -21,6 +21,19 @@ public sealed class SeamOptions
public CurrencyOptions Currency { get; set; } = new();
public BankTransferOptions BankTransfer { get; set; } = new();
public ReviewModerationOptions ReviewModeration { get; set; } = new();
public LicenseVerificationOptions LicenseVerification { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>ILicenseVerificationService</c> (b15 partner-center eNamad / MoH establishment-permit
/// check). By default every check returns <c>NeedsManualReview</c> so <c>VerifyPartnerCenter</c> records the
/// human admin decision. Set <see cref="AutoApprove"/> to have the mock return <c>Valid</c> (test the
/// auto-approve path). The real eNamad / MoH registry adapter ignores these knobs.
/// </summary>
public sealed class LicenseVerificationOptions
{
/// <summary>When true, permit/eNamad checks auto-approve (return <c>Valid</c>) instead of requiring a manual decision.</summary>
public bool AutoApprove { get; set; }
}
/// <summary>
@@ -87,6 +87,12 @@ public static class ServiceCollectionExtension
// keeps decision authority + the human override, so the real impl never touches the handler.
services.AddSingleton<IReviewModerationService, MockReviewModerationService>();
// Partner-center licensing (backend-phase-15). eNamad / MoH establishment-permit registries have no
// public B2B API, so the mock returns NeedsManualReview (manual admin approval at MVP; config can force
// auto-approve for tests). A real registry/API client swaps in by a registration change only —
// VerifyPartnerCenter records the decision and is never touched.
services.AddSingleton<ILicenseVerificationService, MockLicenseVerificationService>();
return services;
}
}
@@ -172,5 +172,12 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
{
builder.Property(p => p.IbanSnapshot).HasConversion(encrypted);
});
// b15 partner-center settlement account: the center's IBAN (only when merchant-of-record) is encrypted
// at rest through the same seam and never serialized in plaintext — reads mask it to the last 4 digits.
modelBuilder.Entity<Baya.Domain.Entities.PartnerCenters.PartnerCenter>(builder =>
{
builder.Property(c => c.SettlementIban).HasConversion(encrypted);
});
}
}
@@ -0,0 +1,43 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Messaging;
using Baya.Domain.Entities.Refunds;
using Baya.Domain.Entities.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.MessagingConfig;
/// <summary>
/// <c>tickets</c> — the root of all post-booking communication. <c>UNIQUE(reference_code)</c> backs the stable
/// human-facing support id; the <c>status</c> and <c>(status, created_at)</c> indexes serve the admin queue;
/// the <c>booking_id</c>/<c>refund_id</c> indexes serve the "tickets for this booking/refund" lookups. Both
/// links are optional (nullable FK) — a pure support ticket has neither.
/// </summary>
internal sealed class TicketConfig : IEntityTypeConfiguration<Ticket>
{
public void Configure(EntityTypeBuilder<Ticket> builder)
{
builder.ToTable("Tickets", "messaging");
builder.Property(t => t.ReferenceCode).HasMaxLength(40).IsRequired();
builder.Property(t => t.Subject).HasMaxLength(300);
builder.Property(t => t.Status).HasMaxLength(20).IsRequired();
builder.Property(t => t.Category).HasMaxLength(30).IsRequired();
builder.HasIndex(t => t.ReferenceCode).IsUnique();
builder.HasIndex(t => t.Status);
builder.HasIndex(t => t.BookingId);
builder.HasIndex(t => t.RefundId);
builder.HasIndex(t => new { t.Status, t.CreatedAt });
builder.HasOne<User>().WithMany().HasForeignKey(t => t.OpenedById).IsRequired();
builder.HasOne<User>().WithMany().HasForeignKey(t => t.ClosedById).IsRequired(false);
builder.HasOne<Booking>().WithMany().HasForeignKey(t => t.BookingId).IsRequired(false);
builder.HasOne<Refund>().WithMany().HasForeignKey(t => t.RefundId).IsRequired(false);
builder.HasMany(t => t.Participants).WithOne(p => p.Ticket).HasForeignKey(p => p.TicketId).IsRequired();
builder.HasMany(t => t.Messages).WithOne(m => m.Ticket).HasForeignKey(m => m.TicketId).IsRequired();
builder.HasQueryFilter(t => t.DeletedAt == null);
}
}
@@ -0,0 +1,27 @@
using Baya.Domain.Entities.Messaging;
using Baya.Domain.Entities.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.MessagingConfig;
/// <summary>
/// <c>ticket_messages</c> — individual messages. <c>is_internal</c> (default 0) is the hard visibility boundary
/// (admin-only note); the <c>(ticket_id, sent_at)</c> index serves the ordered thread read.
/// </summary>
internal sealed class TicketMessageConfig : IEntityTypeConfiguration<TicketMessage>
{
public void Configure(EntityTypeBuilder<TicketMessage> builder)
{
builder.ToTable("TicketMessages", "messaging");
builder.Property(m => m.Body).HasMaxLength(4000).IsRequired();
builder.Property(m => m.IsInternal).HasDefaultValue(false);
builder.HasIndex(m => new { m.TicketId, m.SentAt });
builder.HasOne<User>().WithMany().HasForeignKey(m => m.SenderId).IsRequired();
builder.HasQueryFilter(m => m.DeletedAt == null);
}
}
@@ -0,0 +1,28 @@
using Baya.Domain.Entities.Messaging;
using Baya.Domain.Entities.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.MessagingConfig;
/// <summary>
/// <c>ticket_participants</c> — who is on a thread. <c>UNIQUE(ticket_id, user_id)</c> is the authoritative
/// backstop against adding a user twice (a duplicate add is a clean conflict, never a raw DB error); removal is
/// a soft <c>removed_at</c> stamp, so the unique row survives and a re-add resurrects it. The
/// <c>(user_id, ticket_id)</c> index serves <c>ListMyTickets</c>.
/// </summary>
internal sealed class TicketParticipantConfig : IEntityTypeConfiguration<TicketParticipant>
{
public void Configure(EntityTypeBuilder<TicketParticipant> builder)
{
builder.ToTable("TicketParticipants", "messaging");
builder.Property(p => p.RoleOnTicket).HasMaxLength(20);
builder.HasIndex(p => new { p.TicketId, p.UserId }).IsUnique();
builder.HasIndex(p => new { p.UserId, p.TicketId });
builder.HasOne<User>().WithMany().HasForeignKey(p => p.UserId).IsRequired();
builder.HasOne<User>().WithMany().HasForeignKey(p => p.AddedById).IsRequired(false);
}
}
@@ -0,0 +1,45 @@
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.PartnerCenters;
using Baya.Domain.Entities.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.PartnerCentersConfig;
/// <summary>
/// <c>partner_centers</c> — the licensed sponsor center. <c>settlement_iban</c> is encrypted at rest (converter
/// wired in <c>ApplicationDbContext</c>) and masked in reads; <c>commission_rate</c> is the center's own cut
/// (separate from <c>platform_fee_rate</c>). Indexes on <c>is_active</c> (the active-center list) and
/// <c>admin_user_id</c> (the center portal scope). The 1:N sponsorship to <c>nurse_profiles</c> is configured
/// here (adds the <c>nurse_profiles.partner_center_id</c> FK in place, without forking a parallel table).
/// </summary>
internal sealed class PartnerCenterConfig : IEntityTypeConfiguration<PartnerCenter>
{
public void Configure(EntityTypeBuilder<PartnerCenter> builder)
{
builder.ToTable("PartnerCenters", "partner");
builder.Property(c => c.Name).HasMaxLength(300).IsRequired();
builder.Property(c => c.LegalEntityType).HasMaxLength(30);
builder.Property(c => c.MohEstablishmentPermitNo).HasMaxLength(100).IsRequired();
builder.Property(c => c.TechnicalDirectorLicenseNo).HasMaxLength(100);
builder.Property(c => c.EnamadCode).HasMaxLength(100);
builder.Property(c => c.SettlementIban).HasMaxLength(256); // ciphertext is longer than the 34-char plaintext
builder.Property(c => c.CommissionRate).HasPrecision(5, 4);
builder.HasIndex(c => c.IsActive);
builder.HasIndex(c => c.AdminUserId);
builder.HasOne<User>().WithMany().HasForeignKey(c => c.AdminUserId).IsRequired();
builder.HasOne<User>().WithMany().HasForeignKey(c => c.TechnicalDirectorNurseUserId).IsRequired(false);
// 1:N sponsorship — adds nurse_profiles.partner_center_id FK in place (nullable; NULL once Balinyaar
// holds its own permit). No inverse navigation on NurseProfile (kept lean).
builder.HasMany<NurseProfile>()
.WithOne()
.HasForeignKey(n => n.PartnerCenterId)
.IsRequired(false);
builder.HasQueryFilter(c => c.DeletedAt == null);
}
}
@@ -0,0 +1,332 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class MessagingAndPartnerCenters : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "partner");
migrationBuilder.EnsureSchema(
name: "messaging");
migrationBuilder.CreateTable(
name: "PartnerCenters",
schema: "partner",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
LegalEntityType = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: true),
MohEstablishmentPermitNo = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
TechnicalDirectorNurseUserId = table.Column<int>(type: "int", nullable: true),
TechnicalDirectorLicenseNo = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
EnamadCode = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
SettlementIban = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
IsMerchantOfRecord = table.Column<bool>(type: "bit", nullable: false),
CommissionRate = table.Column<decimal>(type: "decimal(5,4)", precision: 5, scale: 4, nullable: true),
AdminUserId = table.Column<int>(type: "int", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
VerifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PartnerCenters", x => x.Id);
table.ForeignKey(
name: "FK_PartnerCenters_Users_AdminUserId",
column: x => x.AdminUserId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_PartnerCenters_Users_TechnicalDirectorNurseUserId",
column: x => x.TechnicalDirectorNurseUserId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId");
});
migrationBuilder.CreateTable(
name: "Tickets",
schema: "messaging",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ReferenceCode = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
Subject = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: true),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
Category = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false),
BookingId = table.Column<long>(type: "bigint", nullable: true),
RefundId = table.Column<long>(type: "bigint", nullable: true),
OpenedById = table.Column<int>(type: "int", nullable: false),
ClosedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
ClosedById = table.Column<int>(type: "int", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Tickets", x => x.Id);
table.ForeignKey(
name: "FK_Tickets_Bookings_BookingId",
column: x => x.BookingId,
principalSchema: "booking",
principalTable: "Bookings",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Tickets_Refunds_RefundId",
column: x => x.RefundId,
principalSchema: "payments",
principalTable: "Refunds",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Tickets_Users_ClosedById",
column: x => x.ClosedById,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId");
table.ForeignKey(
name: "FK_Tickets_Users_OpenedById",
column: x => x.OpenedById,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "TicketMessages",
schema: "messaging",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
TicketId = table.Column<long>(type: "bigint", nullable: false),
SenderId = table.Column<int>(type: "int", nullable: false),
Body = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: false),
IsInternal = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
SentAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TicketMessages", x => x.Id);
table.ForeignKey(
name: "FK_TicketMessages_Tickets_TicketId",
column: x => x.TicketId,
principalSchema: "messaging",
principalTable: "Tickets",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TicketMessages_Users_SenderId",
column: x => x.SenderId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "TicketParticipants",
schema: "messaging",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
TicketId = table.Column<long>(type: "bigint", nullable: false),
UserId = table.Column<int>(type: "int", nullable: false),
RoleOnTicket = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
AddedById = table.Column<int>(type: "int", nullable: true),
RemovedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TicketParticipants", x => x.Id);
table.ForeignKey(
name: "FK_TicketParticipants_Tickets_TicketId",
column: x => x.TicketId,
principalSchema: "messaging",
principalTable: "Tickets",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TicketParticipants_Users_AddedById",
column: x => x.AddedById,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId");
table.ForeignKey(
name: "FK_TicketParticipants_Users_UserId",
column: x => x.UserId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_NurseProfiles_PartnerCenterId",
schema: "usr",
table: "NurseProfiles",
column: "PartnerCenterId");
migrationBuilder.CreateIndex(
name: "IX_PartnerCenters_AdminUserId",
schema: "partner",
table: "PartnerCenters",
column: "AdminUserId");
migrationBuilder.CreateIndex(
name: "IX_PartnerCenters_IsActive",
schema: "partner",
table: "PartnerCenters",
column: "IsActive");
migrationBuilder.CreateIndex(
name: "IX_PartnerCenters_TechnicalDirectorNurseUserId",
schema: "partner",
table: "PartnerCenters",
column: "TechnicalDirectorNurseUserId");
migrationBuilder.CreateIndex(
name: "IX_TicketMessages_SenderId",
schema: "messaging",
table: "TicketMessages",
column: "SenderId");
migrationBuilder.CreateIndex(
name: "IX_TicketMessages_TicketId_SentAt",
schema: "messaging",
table: "TicketMessages",
columns: new[] { "TicketId", "SentAt" });
migrationBuilder.CreateIndex(
name: "IX_TicketParticipants_AddedById",
schema: "messaging",
table: "TicketParticipants",
column: "AddedById");
migrationBuilder.CreateIndex(
name: "IX_TicketParticipants_TicketId_UserId",
schema: "messaging",
table: "TicketParticipants",
columns: new[] { "TicketId", "UserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TicketParticipants_UserId_TicketId",
schema: "messaging",
table: "TicketParticipants",
columns: new[] { "UserId", "TicketId" });
migrationBuilder.CreateIndex(
name: "IX_Tickets_BookingId",
schema: "messaging",
table: "Tickets",
column: "BookingId");
migrationBuilder.CreateIndex(
name: "IX_Tickets_ClosedById",
schema: "messaging",
table: "Tickets",
column: "ClosedById");
migrationBuilder.CreateIndex(
name: "IX_Tickets_OpenedById",
schema: "messaging",
table: "Tickets",
column: "OpenedById");
migrationBuilder.CreateIndex(
name: "IX_Tickets_ReferenceCode",
schema: "messaging",
table: "Tickets",
column: "ReferenceCode",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Tickets_RefundId",
schema: "messaging",
table: "Tickets",
column: "RefundId");
migrationBuilder.CreateIndex(
name: "IX_Tickets_Status",
schema: "messaging",
table: "Tickets",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_Tickets_Status_CreatedAt",
schema: "messaging",
table: "Tickets",
columns: new[] { "Status", "CreatedAt" });
migrationBuilder.AddForeignKey(
name: "FK_NurseProfiles_PartnerCenters_PartnerCenterId",
schema: "usr",
table: "NurseProfiles",
column: "PartnerCenterId",
principalSchema: "partner",
principalTable: "PartnerCenters",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_NurseProfiles_PartnerCenters_PartnerCenterId",
schema: "usr",
table: "NurseProfiles");
migrationBuilder.DropTable(
name: "PartnerCenters",
schema: "partner");
migrationBuilder.DropTable(
name: "TicketMessages",
schema: "messaging");
migrationBuilder.DropTable(
name: "TicketParticipants",
schema: "messaging");
migrationBuilder.DropTable(
name: "Tickets",
schema: "messaging");
migrationBuilder.DropIndex(
name: "IX_NurseProfiles_PartnerCenterId",
schema: "usr",
table: "NurseProfiles");
}
}
}
@@ -2716,6 +2716,8 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("PartnerCenterId");
b.HasIndex("UserId")
.IsUnique();
@@ -2885,6 +2887,182 @@ namespace Baya.Infrastructure.Persistence.Migrations
});
});
modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long?>("BookingId")
.HasColumnType("bigint");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.Property<DateTimeOffset?>("ClosedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ClosedById")
.HasColumnType("int");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int>("OpenedById")
.HasColumnType("int");
b.Property<string>("ReferenceCode")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<long?>("RefundId")
.HasColumnType("bigint");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Subject")
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.HasKey("Id");
b.HasIndex("BookingId");
b.HasIndex("ClosedById");
b.HasIndex("OpenedById");
b.HasIndex("ReferenceCode")
.IsUnique();
b.HasIndex("RefundId");
b.HasIndex("Status");
b.HasIndex("Status", "CreatedAt");
b.ToTable("Tickets", "messaging");
});
modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketMessage", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<bool>("IsInternal")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int>("SenderId")
.HasColumnType("int");
b.Property<DateTimeOffset>("SentAt")
.HasColumnType("datetimeoffset");
b.Property<long>("TicketId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("SenderId");
b.HasIndex("TicketId", "SentAt");
b.ToTable("TicketMessages", "messaging");
});
modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketParticipant", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<int?>("AddedById")
.HasColumnType("int");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("RemovedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("RoleOnTicket")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<long>("TicketId")
.HasColumnType("bigint");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("AddedById");
b.HasIndex("TicketId", "UserId")
.IsUnique();
b.HasIndex("UserId", "TicketId");
b.ToTable("TicketParticipants", "messaging");
});
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
{
b.Property<long>("Id")
@@ -2930,6 +3108,85 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("Notifications", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.PartnerCenters.PartnerCenter", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AdminUserId")
.HasColumnType("int");
b.Property<decimal?>("CommissionRate")
.HasPrecision(5, 4)
.HasColumnType("decimal(5,4)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("EnamadCode")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsMerchantOfRecord")
.HasColumnType("bit");
b.Property<string>("LegalEntityType")
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("MohEstablishmentPermitNo")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<string>("SettlementIban")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("TechnicalDirectorLicenseNo")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int?>("TechnicalDirectorNurseUserId")
.HasColumnType("int");
b.Property<DateTimeOffset?>("VerifiedAt")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("AdminUserId");
b.HasIndex("IsActive");
b.HasIndex("TechnicalDirectorNurseUserId");
b.ToTable("PartnerCenters", "partner");
});
modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b =>
{
b.Property<long>("Id")
@@ -5078,6 +5335,10 @@ namespace Baya.Infrastructure.Persistence.Migrations
modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b =>
{
b.HasOne("Baya.Domain.Entities.PartnerCenters.PartnerCenter", null)
.WithMany()
.HasForeignKey("PartnerCenterId");
b.HasOne("Baya.Domain.Entities.User.User", "User")
.WithOne()
.HasForeignKey("Baya.Domain.Entities.Identity.NurseProfile", "UserId")
@@ -5107,6 +5368,65 @@ namespace Baya.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
.WithMany()
.HasForeignKey("BookingId");
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("ClosedById");
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("OpenedById")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Refunds.Refund", null)
.WithMany()
.HasForeignKey("RefundId");
});
modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketMessage", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Messaging.Ticket", "Ticket")
.WithMany("Messages")
.HasForeignKey("TicketId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Ticket");
});
modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketParticipant", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("AddedById");
b.HasOne("Baya.Domain.Entities.Messaging.Ticket", "Ticket")
.WithMany("Participants")
.HasForeignKey("TicketId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Ticket");
});
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
@@ -5116,6 +5436,19 @@ namespace Baya.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.PartnerCenters.PartnerCenter", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("AdminUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("TechnicalDirectorNurseUserId");
});
modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
@@ -5530,6 +5863,13 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.Navigation("BankAccounts");
});
modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b =>
{
b.Navigation("Messages");
b.Navigation("Participants");
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b =>
{
b.Navigation("BookingLinks");
@@ -29,6 +29,8 @@ public class UnitOfWork : IUnitOfWork
public IPayoutRepository PayoutRepository { get; }
public IReviewRepository ReviewRepository { get; }
public IPatientCareRecordRepository PatientCareRecordRepository { get; }
public ITicketRepository TicketRepository { get; }
public IPartnerCenterRepository PartnerCenterRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -56,6 +58,8 @@ public class UnitOfWork : IUnitOfWork
PayoutRepository = new PayoutRepository(_db);
ReviewRepository = new ReviewRepository(_db);
PatientCareRecordRepository = new PatientCareRecordRepository(_db);
TicketRepository = new TicketRepository(_db);
PartnerCenterRepository = new PartnerCenterRepository(_db);
}
public Task CommitAsync()
@@ -0,0 +1,145 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Invoices;
using Baya.Domain.Entities.PartnerCenters;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class PartnerCenterRepository : BaseAsyncRepository<PartnerCenter>, IPartnerCenterRepository
{
private const int DashboardNurseCap = 50;
public PartnerCenterRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task AddAsync(PartnerCenter center, CancellationToken cancellationToken) => base.AddAsync(center);
public Task<PartnerCenter?> GetTrackedAsync(long centerId, CancellationToken cancellationToken)
=> Entities.FirstOrDefaultAsync(c => c.Id == centerId, cancellationToken);
public Task<bool> ExistsAsync(long centerId, CancellationToken cancellationToken)
=> Entities.AnyAsync(c => c.Id == centerId, cancellationToken);
public async Task<PartnerCenterDetailDto?> GetDetailAsync(long centerId, CancellationToken cancellationToken)
{
// Project (the converter decrypts settlement_iban here); the plaintext IBAN is masked in memory below and
// never leaves this method.
var raw = await Entities.AsNoTracking()
.Where(c => c.Id == centerId)
.Select(c => new
{
c.Id, c.Name, c.LegalEntityType, c.MohEstablishmentPermitNo, c.TechnicalDirectorNurseUserId,
c.TechnicalDirectorLicenseNo, c.EnamadCode, c.SettlementIban, c.IsMerchantOfRecord, c.CommissionRate,
c.AdminUserId, c.IsActive, c.VerifiedAt, c.CreatedAt,
SponsoredCount = DbContext.Set<NurseProfile>().Count(n => n.PartnerCenterId == c.Id)
})
.FirstOrDefaultAsync(cancellationToken);
if (raw is null)
return null;
return new PartnerCenterDetailDto(
raw.Id, raw.Name, raw.LegalEntityType, raw.MohEstablishmentPermitNo, raw.TechnicalDirectorNurseUserId,
raw.TechnicalDirectorLicenseNo, raw.EnamadCode,
string.IsNullOrEmpty(raw.SettlementIban) ? null : Mask.IbanTail(raw.SettlementIban),
raw.IsMerchantOfRecord, raw.CommissionRate, raw.AdminUserId, raw.IsActive, raw.VerifiedAt,
raw.SponsoredCount, raw.CreatedAt);
}
public async Task<PagedResult<PartnerCenterListItemDto>> ListAsync(bool? isActive, int page, int pageSize, CancellationToken cancellationToken)
{
var query = Entities.AsNoTracking().AsQueryable();
if (isActive is { } active)
query = query.Where(c => c.IsActive == active);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(c => c.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(c => new PartnerCenterListItemDto(
c.Id, c.Name, c.LegalEntityType, c.IsMerchantOfRecord, c.IsActive, c.VerifiedAt,
DbContext.Set<NurseProfile>().Count(n => n.PartnerCenterId == c.Id),
c.AdminUserId))
.ToListAsync(cancellationToken);
return new PagedResult<PartnerCenterListItemDto>(items, total, page, pageSize);
}
public async Task<CenterForBookingDto?> ResolveCenterForBookingAsync(long bookingId, CancellationToken cancellationToken)
{
var booking = await DbContext.Set<Booking>().AsNoTracking()
.Where(b => b.Id == bookingId)
.Select(b => new { b.NurseId })
.FirstOrDefaultAsync(cancellationToken);
if (booking is null)
return null;
var centerId = await DbContext.Set<NurseProfile>().AsNoTracking()
.Where(n => n.Id == booking.NurseId)
.Select(n => n.PartnerCenterId)
.FirstOrDefaultAsync(cancellationToken);
if (centerId is { } id)
{
var center = await Entities.AsNoTracking()
.Where(c => c.Id == id)
.Select(c => new { c.Id, c.Name, c.IsMerchantOfRecord })
.FirstOrDefaultAsync(cancellationToken);
// Merchant-of-record center → it is the invoice issuer + settlement target. Otherwise the platform
// issues (a non-MoR sponsor does not change the issuer).
if (center is { IsMerchantOfRecord: true })
return new CenterForBookingDto(bookingId, InvoiceIssuingEntityType.PartnerCenter, center.Id, center.Name, true);
}
return new CenterForBookingDto(bookingId, InvoiceIssuingEntityType.Platform, null, null, false);
}
public Task<int?> GetAdminUserIdAsync(long centerId, CancellationToken cancellationToken)
=> Entities.AsNoTracking()
.Where(c => c.Id == centerId)
.Select(c => (int?)c.AdminUserId)
.FirstOrDefaultAsync(cancellationToken);
public async Task<CenterDashboardDto?> GetDashboardAsync(long centerId, CancellationToken cancellationToken)
{
var center = await Entities.AsNoTracking()
.Where(c => c.Id == centerId)
.Select(c => new { c.Id, c.Name, c.IsMerchantOfRecord, c.IsActive, c.SettlementIban })
.FirstOrDefaultAsync(cancellationToken);
if (center is null)
return null;
var sponsoredNurseCount = await DbContext.Set<NurseProfile>().AsNoTracking()
.CountAsync(n => n.PartnerCenterId == centerId, cancellationToken);
var sponsoredNurses = await DbContext.Set<NurseProfile>().AsNoTracking()
.Where(n => n.PartnerCenterId == centerId)
.OrderBy(n => n.Id)
.Take(DashboardNurseCap)
.Select(n => new SponsoredNurseDto(n.Id, n.UserId, n.IsVerified, n.AverageRating, n.TotalCompletedBookings))
.ToListAsync(cancellationToken);
var sponsoredBookingCount = await DbContext.Set<Booking>().AsNoTracking()
.CountAsync(b => DbContext.Set<NurseProfile>().Any(n => n.Id == b.NurseId && n.PartnerCenterId == centerId), cancellationToken);
var invoiceCount = await DbContext.Set<Invoice>().AsNoTracking()
.CountAsync(i => i.PartnerCenterId == centerId, cancellationToken);
return new CenterDashboardDto(
center.Id, center.Name, center.IsMerchantOfRecord, center.IsActive,
string.IsNullOrEmpty(center.SettlementIban) ? null : Mask.IbanTail(center.SettlementIban),
sponsoredNurseCount, sponsoredBookingCount, invoiceCount, sponsoredNurses);
}
}
@@ -0,0 +1,158 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Messaging;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class TicketRepository : BaseAsyncRepository<Ticket>, ITicketRepository
{
public TicketRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task AddAsync(Ticket ticket, CancellationToken cancellationToken) => base.AddAsync(ticket);
public async Task AddMessageAsync(TicketMessage message, CancellationToken cancellationToken)
=> await DbContext.Set<TicketMessage>().AddAsync(message, cancellationToken);
public async Task AddParticipantAsync(TicketParticipant participant, CancellationToken cancellationToken)
=> await DbContext.Set<TicketParticipant>().AddAsync(participant, cancellationToken);
public Task<Ticket?> GetTrackedAsync(long ticketId, CancellationToken cancellationToken)
=> Entities.FirstOrDefaultAsync(t => t.Id == ticketId, cancellationToken);
public Task<bool> ReferenceCodeExistsAsync(string referenceCode, CancellationToken cancellationToken)
=> Entities.AnyAsync(t => t.ReferenceCode == referenceCode, cancellationToken);
public Task<TicketParticipant?> GetParticipantAsync(long ticketId, int userId, CancellationToken cancellationToken)
=> DbContext.Set<TicketParticipant>().FirstOrDefaultAsync(p => p.TicketId == ticketId && p.UserId == userId, cancellationToken);
public Task<bool> IsActiveParticipantAsync(long ticketId, int userId, CancellationToken cancellationToken)
=> DbContext.Set<TicketParticipant>().AsNoTracking()
.AnyAsync(p => p.TicketId == ticketId && p.UserId == userId && p.RemovedAt == null, cancellationToken);
public Task<bool> CoordinationTicketExistsForBookingAsync(long bookingId, CancellationToken cancellationToken)
=> Entities.AnyAsync(t => t.BookingId == bookingId && t.Category == TicketCategory.Coordination, cancellationToken);
public async Task<BookingPartyUserIds?> GetBookingPartyUserIdsAsync(long bookingId, CancellationToken cancellationToken)
{
var parties = await DbContext.Set<Booking>().AsNoTracking()
.Where(b => b.Id == bookingId)
.Select(b => new
{
CustomerUserId = DbContext.Set<Domain.Entities.Identity.CustomerProfile>()
.Where(c => c.Id == b.CustomerId).Select(c => (int?)c.UserId).FirstOrDefault(),
NurseUserId = DbContext.Set<Domain.Entities.Identity.NurseProfile>()
.Where(n => n.Id == b.NurseId).Select(n => (int?)n.UserId).FirstOrDefault()
})
.FirstOrDefaultAsync(cancellationToken);
if (parties is null || parties.CustomerUserId is not { } customerUserId || parties.NurseUserId is not { } nurseUserId)
return null;
return new BookingPartyUserIds(customerUserId, nurseUserId);
}
public Task<TicketHeaderDto?> GetHeaderAsync(long ticketId, CancellationToken cancellationToken)
=> Entities.AsNoTracking()
.Where(t => t.Id == ticketId)
.Select(t => new TicketHeaderDto(
t.Id, t.ReferenceCode, t.Subject, t.Status, t.Category, t.BookingId, t.RefundId, t.OpenedById, t.ClosedAt))
.FirstOrDefaultAsync(cancellationToken);
public Task<bool> IsUserPartyToBookingAsync(long bookingId, int userId, CancellationToken cancellationToken)
=> DbContext.Set<Booking>().AsNoTracking()
.Where(b => b.Id == bookingId)
.AnyAsync(b =>
DbContext.Set<Domain.Entities.Identity.CustomerProfile>().Any(c => c.Id == b.CustomerId && c.UserId == userId) ||
DbContext.Set<Domain.Entities.Identity.NurseProfile>().Any(n => n.Id == b.NurseId && n.UserId == userId),
cancellationToken);
public async Task<IReadOnlyList<TicketParticipantDto>> GetActiveParticipantsAsync(long ticketId, CancellationToken cancellationToken)
=> await DbContext.Set<TicketParticipant>().AsNoTracking()
.Where(p => p.TicketId == ticketId && p.RemovedAt == null)
.OrderBy(p => p.Id)
.Select(p => new TicketParticipantDto(p.UserId, p.RoleOnTicket))
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<int>> GetActiveParticipantUserIdsAsync(long ticketId, CancellationToken cancellationToken)
=> await DbContext.Set<TicketParticipant>().AsNoTracking()
.Where(p => p.TicketId == ticketId && p.RemovedAt == null)
.Select(p => p.UserId)
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<TicketMessageDto>> GetMessagesAsync(long ticketId, bool includeInternal, CancellationToken cancellationToken)
{
var query = DbContext.Set<TicketMessage>().AsNoTracking().Where(m => m.TicketId == ticketId);
// The hard visibility boundary: the user view strips every internal message in the projection.
if (!includeInternal)
query = query.Where(m => !m.IsInternal);
// Order by the monotonic identity, not sent_at: it matches send order and, unlike a DateTimeOffset
// ORDER BY, the SQLite test provider can translate it.
return await query
.OrderBy(m => m.Id)
.Select(m => new TicketMessageDto(m.Id, m.SenderId, m.Body, m.IsInternal, m.SentAt))
.ToListAsync(cancellationToken);
}
public async Task<PagedResult<TicketSummaryDto>> ListMyTicketsAsync(
int userId, string? status, string? referenceCode, int page, int pageSize, CancellationToken cancellationToken)
{
var participantTickets = DbContext.Set<TicketParticipant>().AsNoTracking()
.Where(p => p.UserId == userId && p.RemovedAt == null)
.Select(p => p.TicketId);
var query = Entities.AsNoTracking().Where(t => participantTickets.Contains(t.Id));
query = ApplyTicketFilters(query, status, referenceCode);
return await PageAsync(query, page, pageSize, cancellationToken);
}
public async Task<PagedResult<TicketSummaryDto>> ListForAdminAsync(
string? status, string? category, string? referenceCode, long? bookingId, long? refundId,
int page, int pageSize, CancellationToken cancellationToken)
{
var query = Entities.AsNoTracking().AsQueryable();
query = ApplyTicketFilters(query, status, referenceCode);
if (!string.IsNullOrWhiteSpace(category))
query = query.Where(t => t.Category == category);
if (bookingId is { } b)
query = query.Where(t => t.BookingId == b);
if (refundId is { } r)
query = query.Where(t => t.RefundId == r);
return await PageAsync(query, page, pageSize, cancellationToken);
}
private static IQueryable<Ticket> ApplyTicketFilters(IQueryable<Ticket> query, string? status, string? referenceCode)
{
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(t => t.Status == status);
if (!string.IsNullOrWhiteSpace(referenceCode))
query = query.Where(t => t.ReferenceCode == referenceCode);
return query;
}
private static async Task<PagedResult<TicketSummaryDto>> PageAsync(
IQueryable<Ticket> query, int page, int pageSize, CancellationToken cancellationToken)
{
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(t => t.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(t => new TicketSummaryDto(
t.Id, t.ReferenceCode, t.Subject, t.Status, t.Category, t.BookingId, t.RefundId, t.CreatedAt))
.ToListAsync(cancellationToken);
return new PagedResult<TicketSummaryDto>(items, total, page, pageSize);
}
}
@@ -0,0 +1,107 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Baya.Application.Contracts.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Baya.Test.Api;
/// <summary>
/// HTTP-pipeline coverage for the b15 ticket system. The load-bearing assertion is the <c>is_internal</c> hard
/// boundary: an admin internal note is stripped from the user thread view and present in the admin thread view.
/// Also covers participant uniqueness (a duplicate add is a 409, not a 500) and auth gates.
/// </summary>
public class MessagingApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
private static async Task<int> UserIdAsync(BayaApiFactory factory, string phone)
{
using var scope = factory.Services.CreateScope();
var users = scope.ServiceProvider.GetRequiredService<IAppUserManager>();
var user = await users.GetUserByPhoneNumber(phone);
return user!.Id;
}
[Fact]
public async Task Tickets_Unauthenticated_Returns401()
{
var anon = factory.CreateClient();
var response = await anon.GetAsync("/api/v1/tickets");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task InternalNote_IsHiddenInUserView_ShownInAdminView()
{
var user = factory.CreateClient();
var login = await AuthTestClient.LoginAsync(factory, user, "09120010001");
AuthTestClient.UseBearer(user, login.GetProperty("accessToken").GetString()!);
// Open a pure support ticket (no booking/refund link) and post a normal message.
var open = await user.PostAsJsonAsync("/api/v1/tickets", new { category = "support", subject = "Help", body = "My first message" });
Assert.Equal(HttpStatusCode.OK, open.StatusCode);
var opened = await AuthTestClient.ReadDataAsync(open);
var ticketId = opened.GetProperty("ticketId").GetInt64();
Assert.StartsWith("TKT-", opened.GetProperty("referenceCode").GetString());
// Admin (staff) posts an internal note.
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09120010009");
var internalPost = await admin.PostAsJsonAsync($"/api/v1/tickets/{ticketId}/messages", new { body = "internal only", isInternal = true });
Assert.Equal(HttpStatusCode.OK, internalPost.StatusCode);
// User view — the internal note is absent (only the original message).
var userThread = await AuthTestClient.ReadDataAsync(await user.GetAsync($"/api/v1/tickets/{ticketId}"));
var userMessages = userThread.GetProperty("messages").EnumerateArray().ToList();
Assert.Single(userMessages);
Assert.All(userMessages, m => Assert.False(m.GetProperty("isInternal").GetBoolean()));
// Admin view — the internal note IS present.
var adminThread = await AuthTestClient.ReadDataAsync(await admin.GetAsync($"/api/v1/admin/tickets/{ticketId}"));
var adminMessages = adminThread.GetProperty("messages").EnumerateArray().ToList();
Assert.Equal(2, adminMessages.Count);
Assert.Contains(adminMessages, m => m.GetProperty("isInternal").GetBoolean());
}
[Fact]
public async Task NonAdmin_CannotSetInternal()
{
var user = factory.CreateClient();
var login = await AuthTestClient.LoginAsync(factory, user, "09120020001");
AuthTestClient.UseBearer(user, login.GetProperty("accessToken").GetString()!);
var opened = await AuthTestClient.ReadDataAsync(
await user.PostAsJsonAsync("/api/v1/tickets", new { category = "support", subject = "x", body = "hi" }));
var ticketId = opened.GetProperty("ticketId").GetInt64();
var response = await user.PostAsJsonAsync($"/api/v1/tickets/{ticketId}/messages", new { body = "sneaky", isInternal = true });
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task AddParticipant_DuplicateIsConflict_NotServerError()
{
var user = factory.CreateClient();
var login = await AuthTestClient.LoginAsync(factory, user, "09120030001");
AuthTestClient.UseBearer(user, login.GetProperty("accessToken").GetString()!);
var opened = await AuthTestClient.ReadDataAsync(
await user.PostAsJsonAsync("/api/v1/tickets", new { category = "support", subject = "x", body = "hi" }));
var ticketId = opened.GetProperty("ticketId").GetInt64();
// A second user to attach.
await AuthTestClient.CreateUserWithOtpCodeAsync(factory, "09120030002");
var otherUserId = await UserIdAsync(factory, "09120030002");
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09120030009");
var first = await admin.PostAsJsonAsync($"/api/v1/tickets/{ticketId}/participants", new { userId = otherUserId });
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
var duplicate = await admin.PostAsJsonAsync($"/api/v1/tickets/{ticketId}/participants", new { userId = otherUserId });
Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode);
var removed = await admin.DeleteAsync($"/api/v1/tickets/{ticketId}/participants/{otherUserId}");
Assert.Equal(HttpStatusCode.OK, removed.StatusCode);
}
}
@@ -0,0 +1,99 @@
using System.Net;
using System.Net.Http.Json;
using Baya.Application.Contracts.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Baya.Test.Api;
/// <summary>
/// HTTP-pipeline coverage for the b15 partner-center console. The load-bearing assertion is that the settlement
/// IBAN is returned <b>masked</b> (last 4), never plaintext, on both the create response and the detail read.
/// Also covers the admin RBAC gate.
/// </summary>
public class PartnerCentersApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
private const string FullIban = "IR062960000000100324200001";
private static async Task<int> UserIdAsync(BayaApiFactory factory, string phone)
{
using var scope = factory.Services.CreateScope();
var users = scope.ServiceProvider.GetRequiredService<IAppUserManager>();
return (await users.GetUserByPhoneNumber(phone))!.Id;
}
[Fact]
public async Task List_Unauthenticated_Returns401()
{
var anon = factory.CreateClient();
var response = await anon.GetAsync("/api/v1/admin/partner-centers");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task NonAdmin_IsForbidden()
{
var customer = factory.CreateClient();
var login = await AuthTestClient.LoginAsync(factory, customer, "09120040001");
AuthTestClient.UseBearer(customer, login.GetProperty("accessToken").GetString()!);
var response = await customer.GetAsync("/api/v1/admin/partner-centers");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task CreateMerchantOfRecord_MasksSettlementIban()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09120040009");
var create = await admin.PostAsJsonAsync("/api/v1/admin/partner-centers", new
{
name = "Asanism Center",
legalEntityType = "llc",
mohEstablishmentPermitNo = "MOH-12345",
enamadCode = "EN-999",
settlementIban = FullIban,
isMerchantOfRecord = true,
commissionRate = 0.05m,
adminUserId = await UserIdAsync(factory, "09120040009")
});
Assert.Equal(HttpStatusCode.OK, create.StatusCode);
var created = await AuthTestClient.ReadDataAsync(create);
var centerId = created.GetProperty("id").GetInt64();
var masked = created.GetProperty("settlementIbanMasked").GetString();
Assert.NotNull(masked);
Assert.DoesNotContain("0032420", masked); // no interior digits
Assert.EndsWith("0001", masked);
Assert.NotEqual(FullIban, masked);
// The detail read masks it too — the plaintext IBAN is never serialized.
var detail = await AuthTestClient.ReadDataAsync(await admin.GetAsync($"/api/v1/admin/partner-centers/{centerId}"));
Assert.EndsWith("0001", detail.GetProperty("settlementIbanMasked").GetString());
Assert.False(detail.GetProperty("isActive").GetBoolean()); // created inactive until verified
}
[Fact]
public async Task Verify_ActivatesTheCenter()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09120050009");
var created = await AuthTestClient.ReadDataAsync(await admin.PostAsJsonAsync("/api/v1/admin/partner-centers", new
{
name = "Center B",
mohEstablishmentPermitNo = "MOH-55555",
isMerchantOfRecord = false,
adminUserId = await UserIdAsync(factory, "09120050009")
}));
var centerId = created.GetProperty("id").GetInt64();
var verify = await admin.PostAsync($"/api/v1/admin/partner-centers/{centerId}/verify", null);
Assert.Equal(HttpStatusCode.OK, verify.StatusCode);
var detail = await AuthTestClient.ReadDataAsync(verify);
Assert.True(detail.GetProperty("isActive").GetBoolean());
Assert.True(detail.GetProperty("verifiedAt").ValueKind is System.Text.Json.JsonValueKind.String);
}
}
@@ -129,7 +129,7 @@ public class BnplHandlerTests
private static SettleBnplOrderCommandHandler SettleHandler(BnplTestHost host, decimal commissionRate)
=> new(host.UnitOfWork, host.Resolver(commissionRate), host.Config(), host.Serializer,
host.Settlement, host.Lock, host.Clock(Now), host.Notifications());
host.Settlement, host.Lock, host.Clock(Now), host.Notifications(), TestSenders.WithTicketHooks());
private static long Leg(IReadOnlyList<LedgerEntry> legs, string account, string direction)
=> legs.Where(l => l.AccountType == account && l.Direction == direction).Sum(l => l.AmountIrr);
@@ -0,0 +1,139 @@
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Invoices;
using Baya.Domain.Entities.PartnerCenters;
using Baya.Infrastructure.Persistence;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Baya.Tests.Setup.Setups;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Test.Foundation.PartnerCenters;
/// <summary>
/// The merchant-of-record resolver: <c>GetCenterForBooking</c> must return the sponsoring center as the invoice
/// issuer + settlement target ONLY when that center is merchant-of-record, and <c>platform</c> otherwise (incl.
/// an unsponsored nurse). Foreign keys are disabled so the test can seed just the three rows the join touches.
/// </summary>
public sealed class CenterForBookingTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly ApplicationDbContext _db;
private readonly UnitOfWork _unitOfWork;
public CenterForBookingTests()
{
_connection = new SqliteConnection("DataSource=:memory:;Foreign Keys=False");
_connection.Open();
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(_connection).Options;
_db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
_db.Database.EnsureCreated();
_unitOfWork = new UnitOfWork(_db);
}
private long SeedNurse(long? partnerCenterId)
{
var nurse = new NurseProfile
{
UserId = 0,
Bio = string.Empty,
EducationLevel = string.Empty,
EducationField = string.Empty,
SpecializationsJson = "[]",
PartnerCenterId = partnerCenterId
};
_db.Set<NurseProfile>().Add(nurse);
_db.SaveChanges();
return nurse.Id;
}
private long SeedBooking(long nurseId)
{
var booking = new BookingEntity
{
BookingRequestId = 0,
CustomerId = 0,
NurseId = nurseId,
PatientId = 0,
VariantId = 0,
CustomerAddressId = 0,
VariantSnapshotJson = "{}",
AddressSnapshotJson = "{}",
GrossPriceIrr = 0,
BalinyaarCommissionIrr = 0,
NursePayoutAmount = 0
};
_db.Set<BookingEntity>().Add(booking);
_db.SaveChanges();
return booking.Id;
}
private long SeedCenter(bool merchantOfRecord)
{
var center = new PartnerCenter
{
Name = "Center",
MohEstablishmentPermitNo = "MOH-1",
AdminUserId = 0,
IsMerchantOfRecord = merchantOfRecord
};
_db.Set<PartnerCenter>().Add(center);
_db.SaveChanges();
return center.Id;
}
[Fact]
public async Task MerchantOfRecordCenter_IsTheIssuerAndSettlementTarget()
{
var centerId = SeedCenter(merchantOfRecord: true);
var nurseId = SeedNurse(centerId);
var bookingId = SeedBooking(nurseId);
var result = await _unitOfWork.PartnerCenterRepository.ResolveCenterForBookingAsync(bookingId, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(InvoiceIssuingEntityType.PartnerCenter, result!.IssuingEntityType);
Assert.Equal(centerId, result.PartnerCenterId);
Assert.True(result.IsMerchantOfRecord);
}
[Fact]
public async Task NonMerchantSponsor_FallsBackToPlatform()
{
var centerId = SeedCenter(merchantOfRecord: false);
var nurseId = SeedNurse(centerId);
var bookingId = SeedBooking(nurseId);
var result = await _unitOfWork.PartnerCenterRepository.ResolveCenterForBookingAsync(bookingId, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(InvoiceIssuingEntityType.Platform, result!.IssuingEntityType);
Assert.Null(result.PartnerCenterId);
}
[Fact]
public async Task UnsponsoredNurse_IsPlatform()
{
var nurseId = SeedNurse(partnerCenterId: null);
var bookingId = SeedBooking(nurseId);
var result = await _unitOfWork.PartnerCenterRepository.ResolveCenterForBookingAsync(bookingId, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(InvoiceIssuingEntityType.Platform, result!.IssuingEntityType);
Assert.Null(result.PartnerCenterId);
}
[Fact]
public async Task MissingBooking_ReturnsNull()
{
var result = await _unitOfWork.PartnerCenterRepository.ResolveCenterForBookingAsync(999, CancellationToken.None);
Assert.Null(result);
}
public void Dispose()
{
_db.Dispose();
_connection.Dispose();
}
}
@@ -43,7 +43,7 @@ public class InitiatePaymentTests
var first = await Initiate(host).Handle(new InitiatePaymentCommand(requestId, null), CancellationToken.None);
var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications());
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks());
await confirm.Handle(new ConfirmPaymentAndPostLedgerCommand(first.Result.TransactionId), CancellationToken.None);
// A repeat initiate for an already-paid booking is a 409, not a second attempt.
@@ -21,7 +21,7 @@ public class NursePayableBalanceTests
var init = await initiate.Handle(new InitiatePaymentCommand(requestId, null), CancellationToken.None);
var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications());
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks());
await confirm.Handle(new ConfirmPaymentAndPostLedgerCommand(init.Result.TransactionId), CancellationToken.None);
var query = new GetNursePayableBalanceQueryHandler(host.AsNurse(), host.UnitOfWork);
@@ -18,7 +18,7 @@ public class PaymentConfirmTests
=> new(host.AsCustomer(), host.UnitOfWork, host.PaymentProvider, host.Clock(Now));
private static ConfirmPaymentAndPostLedgerCommandHandler Confirm(PaymentsTestHost host)
=> new(host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications());
=> new(host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks());
private static async Task<long> InitiatePendingAsync(PaymentsTestHost host, long requestId)
{
@@ -43,7 +43,7 @@ public class PaymentWebhookTests
var (_, reference) = await SeedPendingAsync(host);
var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications());
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks());
var handler = new HandlePaymentWebhookCommandHandler(
SenderRoutingConfirmTo(confirm), host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now));
@@ -67,7 +67,7 @@ public class PaymentWebhookTests
var (_, reference) = await SeedPendingAsync(host);
var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications());
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks());
var sender = SenderRoutingConfirmTo(confirm);
var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now));
@@ -91,7 +91,7 @@ public class PaymentWebhookTests
var (_, reference) = await SeedPendingAsync(host);
var confirm = new ConfirmPaymentAndPostLedgerCommandHandler(
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications());
host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks());
var sender = SenderRoutingConfirmTo(confirm);
var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now));
@@ -16,7 +16,7 @@ public class RefundHandlerTests
=> new(
host.UnitOfWork, host.Lock(), host.Config(), host.Clock(Now), host.AsAdmin(),
host.Card(), host.Bnpl(), host.PayoutStatus(nursePaid),
Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>());
Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), TestSenders.WithTicketHooks());
private static CreateRefundCommand FullRefund(long bookingId)
=> new(bookingId, TicketId: null, RefundPercentage: 1m, null, null, "customer_request", null, null, null);
@@ -0,0 +1,27 @@
using Baya.Application.Features.Messaging.Commands.AutoCreateCoordinationTicket;
using Baya.Application.Features.Messaging.Commands.OpenTicket;
using Baya.Application.Models.Common;
using Baya.Application.Models.Messaging;
using Mediator;
using NSubstitute;
namespace Baya.Test.Foundation;
/// <summary>
/// Builds an <see cref="ISender"/> substitute that satisfies the b15 ticket hooks the money-path handlers now
/// dispatch — <c>OpenTicket</c> (the refund's auto-anchored ticket) and <c>AutoCreateCoordinationTicket</c> (on
/// booking confirmation) — so foundation unit tests can construct those handlers without wiring the real mediator.
/// </summary>
public static class TestSenders
{
public static ISender WithTicketHooks()
{
var sender = Substitute.For<ISender>();
sender.Send(Arg.Any<OpenTicketCommand>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(OperationResult<OpenTicketResult>.SuccessResult(
new OpenTicketResult(1, "TKT-TEST01", "open", "refund"))));
sender.Send(Arg.Any<AutoCreateCoordinationTicketCommand>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(OperationResult<bool>.SuccessResult(true)));
return sender;
}
}