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