backend phase 15 & frontend phase 8
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
+6
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
+65
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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>>;
|
||||
+55
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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>>;
|
||||
+39
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
@@ -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>>;
|
||||
+71
@@ -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));
|
||||
}
|
||||
}
|
||||
+15
@@ -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>>;
|
||||
+76
@@ -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));
|
||||
}
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
+20
@@ -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>>;
|
||||
+72
@@ -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));
|
||||
}
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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>>;
|
||||
+39
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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>>;
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
@@ -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>>;
|
||||
+49
@@ -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));
|
||||
}
|
||||
}
|
||||
+14
@@ -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>>;
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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>>>;
|
||||
+20
@@ -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);
|
||||
}
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
}
|
||||
+38
@@ -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!);
|
||||
}
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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 (0–1), 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>>;
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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>>;
|
||||
+34
@@ -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!);
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
+21
@@ -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>>;
|
||||
+45
@@ -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!);
|
||||
}
|
||||
}
|
||||
+11
@@ -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>>;
|
||||
+32
@@ -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!);
|
||||
}
|
||||
}
|
||||
+10
@@ -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>>;
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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>>;
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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>>;
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+10
@@ -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>>>;
|
||||
+7
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+18
-8
@@ -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, 0–1), 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
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user