refinement phase 3
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Shared validation + storage-key rules for avatar (profile photo) uploads. Keeps the nurse and customer
|
||||
/// upload handlers in lock-step on the accepted content types, the size cap, and the key scheme.
|
||||
/// </summary>
|
||||
public static class AvatarUpload
|
||||
{
|
||||
public const long MaxSizeBytes = 5 * 1024 * 1024;
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> AllowedTypes = new Dictionary<string, string>
|
||||
{
|
||||
["image/jpeg"] = ".jpg",
|
||||
["image/png"] = ".png",
|
||||
["image/webp"] = ".webp"
|
||||
};
|
||||
|
||||
public static bool IsAllowedContentType(string? contentType)
|
||||
=> contentType is not null && AllowedTypes.ContainsKey(contentType.ToLowerInvariant());
|
||||
|
||||
public static bool IsWithinSizeLimit(long length) => length > 0 && length <= MaxSizeBytes;
|
||||
|
||||
/// <summary>Opaque, collision-free storage key: <c>avatars/{scope}/{ownerId}/{token}{ext}</c>.</summary>
|
||||
public static string BuildKey(string scope, long ownerId, string contentType, string token)
|
||||
{
|
||||
var ext = AllowedTypes.TryGetValue(contentType.ToLowerInvariant(), out var e) ? e : ".bin";
|
||||
return $"avatars/{scope}/{ownerId}/{token}{ext}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Baya.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Shared (de)serialization for a small list of stable string codes persisted as a JSON string array
|
||||
/// (e.g. patient <c>conditions_json</c>, nurse <c>specializations_json</c>). A malformed or empty value
|
||||
/// parses to an empty list; an empty list serializes back to <c>null</c> (a clean NULL column).
|
||||
/// </summary>
|
||||
public static class JsonCodeList
|
||||
{
|
||||
public static IReadOnlyList<string> Parse(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return [];
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public static string Serialize(IReadOnlyList<string> codes)
|
||||
=> codes is null || codes.Count == 0 ? null : JsonSerializer.Serialize(codes);
|
||||
}
|
||||
@@ -19,8 +19,12 @@ public interface IAuditLogger
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<PagedResult<AuditLogDto>> GetTrailAsync(
|
||||
string entityType,
|
||||
string entityId,
|
||||
string? entityType,
|
||||
string? entityId,
|
||||
int? actorUserId,
|
||||
string? action,
|
||||
System.DateTimeOffset? from,
|
||||
System.DateTimeOffset? to,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -40,4 +40,8 @@ public interface IBnplRepository
|
||||
/// <summary>The BNPL order view + the owning customer's user id for tenancy, and the linked refund's ETA
|
||||
/// when reverted. Null when absent.</summary>
|
||||
Task<BnplOrderStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The BNPL order for a booking request (the id the return-poll surface holds), latest first, with the
|
||||
/// owning customer's user id for tenancy. Null when the request has no BNPL order.</summary>
|
||||
Task<BnplOrderStatusProjection?> GetStatusByRequestAsync(long bookingRequestId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -54,4 +54,9 @@ public interface IBookingRequestRepository
|
||||
/// <summary>The facts b10's <c>InitiatePayment</c> needs to validate a card attempt (owning customer,
|
||||
/// status, frozen payment window, gross to charge). NULL when absent.</summary>
|
||||
Task<BookingPaymentContext?> GetPaymentContextAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Owner-scoped read for the checkout summary — the request's schedule/participant labels + the
|
||||
/// variant price/session-count needed to compute the money decomposition. NULL when the request is absent
|
||||
/// or not the caller's (existence not leaked).</summary>
|
||||
Task<CheckoutContext?> GetCheckoutContextAsync(long id, long customerId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Application.Models.Nurses;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
@@ -31,4 +32,9 @@ public interface INurseProfileRepository
|
||||
/// read — what a booking-request create needs to notify the nurse and run the bookability + same-gender
|
||||
/// checks. NULL when no such nurse profile exists.</summary>
|
||||
Task<NurseBookingContext?> GetBookingContextByIdAsync(long nurseProfileId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The aggregated public nurse profile for the discovery detail (C3): identity + aggregates +
|
||||
/// verification signal + specialty chips + active bookable services + latest published review. NULL when
|
||||
/// no such nurse profile exists. Exposes no encrypted credential number.</summary>
|
||||
Task<NursePublicProfileDto?> GetPublicProfileAsync(long nurseProfileId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
@@ -25,4 +26,10 @@ public interface IPatientCareRecordRepository
|
||||
/// <summary>Patient-scoped longitudinal history, paginated, newest first — <b>ciphertext</b> bodies; the
|
||||
/// handler decrypts post-check.</summary>
|
||||
Task<PagedResult<CareRecordCipherRow>> GetPatientHistoryAsync(long patientId, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The tracked family-owned care plan for a patient (for read + upsert). Null when none exists yet.</summary>
|
||||
Task<PatientCarePlan?> GetCarePlanAsync(long patientId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Adds a new family-owned care plan (first PUT for a patient).</summary>
|
||||
Task AddCarePlanAsync(PatientCarePlan plan, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,18 @@ public interface IPayoutRepository
|
||||
|
||||
/// <summary>The nurse's own payouts (tenancy-scoped), most recent first, projected + paginated. Masked IBAN.</summary>
|
||||
Task<PagedResult<NursePayoutHistoryDto>> GetNurseHistoryAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Every completed/closed booking the nurse earned, each with its <b>server-derived</b> money-state
|
||||
/// (<c>pending|eligible|paid|clawback_applied</c>) from <c>bookings.status</c> + <c>dispute_window_ends_at</c>
|
||||
/// + the payout link + any clawback. The handler filters by state, sums the buckets, and paginates.</summary>
|
||||
Task<IReadOnlyList<NurseEarningsItemDto>> GetNurseEarningsAsync(long nurseId, DateTime now, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Lifetime total (IRR) of the nurse's <c>paid</c> payouts' net — the <c>paidTotalIrr</c> bucket.</summary>
|
||||
Task<long> GetPaidNetTotalAsync(long nurseId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>A nurse-scoped payout detail (payout + batch window + covered bookings). Null when the payout is
|
||||
/// absent or not the nurse's — never leak another nurse's payout.</summary>
|
||||
Task<NursePayoutDetailDto?> GetNursePayoutDetailAsync(long payoutId, long nurseId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>The verified primary account a payout snapshots — the account id and the decrypted IBAN (frozen into
|
||||
|
||||
@@ -35,6 +35,10 @@ public interface IRefundRepository
|
||||
/// Null when absent.</summary>
|
||||
Task<RefundStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The customer-facing status of a booking's (latest) refund, with the owning customer's user id
|
||||
/// for tenancy. Null when the booking has no refund. Lets the customer reach a refund from its booking.</summary>
|
||||
Task<RefundStatusProjection?> GetStatusByBookingAsync(long bookingId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The provider revert reference on a <c>bnpl_revert</c> refund — the BNPL revert path records it
|
||||
/// as <c>revert_transaction_id</c> on the <c>bnpl_transactions</c> row. Null when absent.</summary>
|
||||
Task<string?> GetExternalRevertReferenceAsync(long refundId, CancellationToken cancellationToken);
|
||||
|
||||
@@ -20,6 +20,10 @@ public interface IReviewRepository
|
||||
/// <summary>True if a (non-deleted) review already exists for the booking — the 1:1 pre-check.</summary>
|
||||
Task<bool> ExistsForBookingAsync(long bookingId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The caller's own review for a booking (with tags + moderation status) plus the owning
|
||||
/// customer's user id for tenancy. Null when the booking has no review.</summary>
|
||||
Task<MyReviewProjection?> GetMyReviewForBookingAsync(long bookingId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked review for a moderation transition. Null if absent.</summary>
|
||||
Task<Review?> GetTrackedAsync(long reviewId, CancellationToken cancellationToken);
|
||||
|
||||
|
||||
@@ -59,7 +59,11 @@ public interface ITicketRepository
|
||||
|
||||
/// <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);
|
||||
Task<PagedResult<TicketSummaryDto>> ListMyTicketsAsync(int userId, string? status, string? referenceCode, long? bookingId, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>An existing message on the ticket carrying this client message id — the optimistic-send
|
||||
/// idempotency lookup. Null when none (a fresh send). Ignores internal notes.</summary>
|
||||
Task<TicketMessageDto?> GetMessageByClientIdAsync(long ticketId, string clientMessageId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The admin global queue — paginated, filter by status/category, search by reference code, optional
|
||||
/// booking/refund link, newest first.</summary>
|
||||
|
||||
@@ -10,6 +10,9 @@ public interface IUserAccountRepository
|
||||
/// The phone comes back decrypted and unmasked — masking is the handler's job.</summary>
|
||||
Task<UserAccountSnapshot?> GetAccountSnapshotAsync(int userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked user row for an in-place edit of the base identity (e.g. name).</summary>
|
||||
Task<User?> GetTrackedByIdAsync(int userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<Role?> GetRoleByNameAsync(string roleName, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked user-role lookup that bypasses the revoked-filter, so a revoked grant can be
|
||||
|
||||
@@ -38,6 +38,10 @@ public interface IVerificationRepository
|
||||
Task AddDocumentAsync(VerificationDocument document, CancellationToken cancellationToken);
|
||||
Task AddCredentialAsync(NurseCredential credential, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked credential for the nurse of a given type — lets the nurse-facing credential-details
|
||||
/// capture upsert (rather than duplicate) its registry row. Null when none exists yet.</summary>
|
||||
Task<NurseCredential?> GetTrackedCredentialAsync(long nurseId, string credentialType, CancellationToken cancellationToken);
|
||||
|
||||
// --- Projected reads ---
|
||||
Task<VerificationStatusDto?> GetStatusForNurseAsync(long nurseId, CancellationToken cancellationToken);
|
||||
Task<PagedResult<AdminPendingStepDto>> ListPendingStepsAsync(
|
||||
|
||||
+22
-3
@@ -42,7 +42,24 @@ internal sealed class CreateAddressCommandHandler(
|
||||
var isFirst = customerId is not { } existing || !await unitOfWork.CustomerAddressRepository.HasAnyAsync(existing, cancellationToken);
|
||||
var isPrimary = request.IsPrimary || isFirst;
|
||||
|
||||
var geo = await geocoder.GeocodeAsync(request.AddressLine, city.NameEn, district?.NameEn, cancellationToken);
|
||||
// Prefer the client's dropped pin (the customer's exact door — best for the EVV distance check);
|
||||
// fall back to the server geocode only when no pin was sent.
|
||||
decimal? latitude;
|
||||
decimal? longitude;
|
||||
string geocodeSource;
|
||||
if (request is { Latitude: { } pinLat, Longitude: { } pinLng })
|
||||
{
|
||||
latitude = pinLat;
|
||||
longitude = pinLng;
|
||||
geocodeSource = GeocodeSources.UserPin;
|
||||
}
|
||||
else
|
||||
{
|
||||
var geo = await geocoder.GeocodeAsync(request.AddressLine, city.NameEn, district?.NameEn, cancellationToken);
|
||||
latitude = geo.Latitude;
|
||||
longitude = geo.Longitude;
|
||||
geocodeSource = GeocodeSources.Geocoder;
|
||||
}
|
||||
|
||||
// Values are set as plaintext; the EF value converter encrypts the PII columns at rest.
|
||||
var address = new CustomerAddress
|
||||
@@ -54,8 +71,9 @@ internal sealed class CreateAddressCommandHandler(
|
||||
PostalCode = request.PostalCode,
|
||||
RecipientName = request.RecipientName,
|
||||
RecipientPhone = request.RecipientPhone,
|
||||
Latitude = geo.Latitude,
|
||||
Longitude = geo.Longitude,
|
||||
Latitude = latitude,
|
||||
Longitude = longitude,
|
||||
GeocodeSource = geocodeSource,
|
||||
IsPrimary = isPrimary
|
||||
};
|
||||
|
||||
@@ -84,6 +102,7 @@ internal sealed class CreateAddressCommandHandler(
|
||||
new(
|
||||
address.Id,
|
||||
address.Title,
|
||||
city.ProvinceId,
|
||||
city.Id,
|
||||
city.NameFa,
|
||||
city.NameEn,
|
||||
|
||||
+5
@@ -14,5 +14,10 @@ public sealed class CreateAddressCommandValidator : AbstractValidator<CreateAddr
|
||||
.Matches("^[0-9۰-۹]{10}$")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.PostalCode))
|
||||
.WithMessage("Postal code must be 10 digits.");
|
||||
RuleFor(x => x.Latitude).InclusiveBetween(-90m, 90m).When(x => x.Latitude.HasValue);
|
||||
RuleFor(x => x.Longitude).InclusiveBetween(-180m, 180m).When(x => x.Longitude.HasValue);
|
||||
RuleFor(x => x)
|
||||
.Must(x => x.Latitude.HasValue == x.Longitude.HasValue)
|
||||
.WithMessage("Latitude and longitude must be supplied together.");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -17,4 +17,6 @@ public record CreateAddressCommand(
|
||||
string PostalCode,
|
||||
string RecipientName,
|
||||
string RecipientPhone,
|
||||
bool IsPrimary) : IRequest<OperationResult<CustomerAddressDto>>;
|
||||
bool IsPrimary,
|
||||
decimal? Latitude = null,
|
||||
decimal? Longitude = null) : IRequest<OperationResult<CustomerAddressDto>>;
|
||||
|
||||
+10
-1
@@ -58,11 +58,19 @@ internal sealed class UpdateAddressCommandHandler(
|
||||
address.RecipientName = request.RecipientName;
|
||||
address.RecipientPhone = request.RecipientPhone;
|
||||
|
||||
if (locationChanged)
|
||||
// A freshly-dropped pin always wins; otherwise re-geocode only when the location text changed.
|
||||
if (request is { Latitude: { } pinLat, Longitude: { } pinLng })
|
||||
{
|
||||
address.Latitude = pinLat;
|
||||
address.Longitude = pinLng;
|
||||
address.GeocodeSource = GeocodeSources.UserPin;
|
||||
}
|
||||
else if (locationChanged)
|
||||
{
|
||||
var geo = await geocoder.GeocodeAsync(request.AddressLine, city.NameEn, district?.NameEn, cancellationToken);
|
||||
address.Latitude = geo.Latitude;
|
||||
address.Longitude = geo.Longitude;
|
||||
address.GeocodeSource = GeocodeSources.Geocoder;
|
||||
}
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
@@ -70,6 +78,7 @@ internal sealed class UpdateAddressCommandHandler(
|
||||
return OperationResult<CustomerAddressDto>.SuccessResult(new CustomerAddressDto(
|
||||
address.Id,
|
||||
address.Title,
|
||||
city.ProvinceId,
|
||||
city.Id,
|
||||
city.NameFa,
|
||||
city.NameEn,
|
||||
|
||||
+5
@@ -14,5 +14,10 @@ public sealed class UpdateAddressCommandValidator : AbstractValidator<UpdateAddr
|
||||
.Matches("^[0-9۰-۹]{10}$")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.PostalCode))
|
||||
.WithMessage("Postal code must be 10 digits.");
|
||||
RuleFor(x => x.Latitude).InclusiveBetween(-90m, 90m).When(x => x.Latitude.HasValue);
|
||||
RuleFor(x => x.Longitude).InclusiveBetween(-180m, 180m).When(x => x.Longitude.HasValue);
|
||||
RuleFor(x => x)
|
||||
.Must(x => x.Latitude.HasValue == x.Longitude.HasValue)
|
||||
.WithMessage("Latitude and longitude must be supplied together.");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -14,4 +14,6 @@ public record UpdateAddressCommand(
|
||||
string AddressLine,
|
||||
string PostalCode,
|
||||
string RecipientName,
|
||||
string RecipientPhone) : IRequest<OperationResult<CustomerAddressDto>>;
|
||||
string RecipientPhone,
|
||||
decimal? Latitude = null,
|
||||
decimal? Longitude = null) : IRequest<OperationResult<CustomerAddressDto>>;
|
||||
|
||||
+3
-1
@@ -12,7 +12,9 @@ internal sealed class GetAuditTrailQueryHandler(IAuditLogger auditLogger)
|
||||
public async ValueTask<OperationResult<PagedResult<AuditLogDto>>> Handle(GetAuditTrailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
|
||||
var result = await auditLogger.GetTrailAsync(request.EntityType, request.EntityId, page, pageSize, cancellationToken);
|
||||
var result = await auditLogger.GetTrailAsync(
|
||||
request.EntityType, request.EntityId, request.ActorId, request.Action, request.From, request.To,
|
||||
page, pageSize, cancellationToken);
|
||||
return OperationResult<PagedResult<AuditLogDto>>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -1,8 +1,16 @@
|
||||
using System;
|
||||
using Baya.Application.Models.Audit;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Audit.Queries.GetAuditTrail;
|
||||
|
||||
public record GetAuditTrailQuery(string EntityType, string EntityId, int Page = 1, int PageSize = 50)
|
||||
: IRequest<OperationResult<PagedResult<AuditLogDto>>>;
|
||||
public record GetAuditTrailQuery(
|
||||
string? EntityType = null,
|
||||
string? EntityId = null,
|
||||
int? ActorId = null,
|
||||
string? Action = null,
|
||||
DateTimeOffset? From = null,
|
||||
DateTimeOffset? To = null,
|
||||
int Page = 1,
|
||||
int PageSize = 50) : IRequest<OperationResult<PagedResult<AuditLogDto>>>;
|
||||
|
||||
+5
@@ -124,8 +124,13 @@ internal sealed class SettleBnplOrderCommandHandler(
|
||||
|
||||
// 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);
|
||||
|
||||
// Auto-issue the commission invoice so the customer can reach it right after settlement (idempotent).
|
||||
await sender.Send(new Invoices.Commands.IssueInvoice.IssueInvoiceCommand(conversion.BookingId), cancellationToken);
|
||||
}
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -46,7 +46,10 @@ internal sealed class CheckBnplEligibilityQueryHandler(
|
||||
if (gatewayId is null)
|
||||
return OperationResult<BnplEligibilityDto>.FailureResult("No active BNPL gateway is configured.");
|
||||
|
||||
var eligibility = await provider.CheckEligibilityAsync(ctx.CustomerMobile, ctx.GrossIrr, cancellationToken);
|
||||
// The D3 inputs (national id / mobile / consent) feed the provider credit inquiry; the mock uses only the
|
||||
// mobile today (the KYC step is deferred). Prefer the supplied mobile, else the account mobile.
|
||||
var inquiryMobile = string.IsNullOrWhiteSpace(request.Mobile) ? ctx.CustomerMobile : request.Mobile;
|
||||
var eligibility = await provider.CheckEligibilityAsync(inquiryMobile, ctx.GrossIrr, cancellationToken);
|
||||
|
||||
var merchantOfRecord = await platformConfig.GetConfig<string>("bnpl_merchant_of_record", cancellationToken);
|
||||
var bnpl = await BnplOrderInitializer.EnsureAsync(
|
||||
|
||||
+6
-1
@@ -11,6 +11,11 @@ public sealed class CheckBnplEligibilityQueryValidator : AbstractValidator<Check
|
||||
RuleFor(x => x.ProviderCode)
|
||||
.NotEmpty()
|
||||
.Must(BnplProviderCodes.IsKnown)
|
||||
.WithMessage("provider_code must be one of snapppay, digipay, tara, torobpay.");
|
||||
.WithMessage("provider_code must be one of snapppay, digipay, tara, torobpay, balinyaar.");
|
||||
// The D3 credit inquiry is consented: when national id / mobile are supplied, consent must be granted.
|
||||
RuleFor(x => x.Consent)
|
||||
.Equal(true)
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.NationalId) || !string.IsNullOrWhiteSpace(x.Mobile))
|
||||
.WithMessage("Consent is required to run the credit inquiry.");
|
||||
}
|
||||
}
|
||||
|
||||
+10
-3
@@ -11,6 +11,13 @@ namespace Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility;
|
||||
/// client shows the plan summary on <c>eligible</c> or falls back to card. Owned by the requesting customer.
|
||||
/// </summary>
|
||||
/// <param name="BookingRequestId">The accepted request to finance (a b9 <c>bookings</c> row exists only on settle).</param>
|
||||
/// <param name="ProviderCode">Which BNPL provider to check (<c>snapppay</c>/<c>digipay</c>/<c>tara</c>/<c>torobpay</c>).</param>
|
||||
public record CheckBnplEligibilityQuery(long BookingRequestId, string ProviderCode)
|
||||
: IRequest<OperationResult<BnplEligibilityDto>>;
|
||||
/// <param name="ProviderCode">Which BNPL provider to check (<c>snapppay</c>/<c>digipay</c>/<c>tara</c>/<c>torobpay</c>/<c>balinyaar</c>).</param>
|
||||
/// <param name="NationalId">The D3 credit-inquiry national id (optional; used for the provider KYC inquiry).</param>
|
||||
/// <param name="Mobile">The D3 credit-inquiry mobile (optional; falls back to the account mobile).</param>
|
||||
/// <param name="Consent">The D3 consent checkbox — must be true when the KYC inputs are supplied.</param>
|
||||
public record CheckBnplEligibilityQuery(
|
||||
long BookingRequestId,
|
||||
string ProviderCode,
|
||||
string NationalId = null,
|
||||
string Mobile = null,
|
||||
bool? Consent = null) : IRequest<OperationResult<BnplEligibilityDto>>;
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Bnpl;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Queries.GetBnplOrderByRequest;
|
||||
|
||||
internal sealed class GetBnplOrderByRequestQueryHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetBnplOrderByRequestQuery, OperationResult<BnplOrderStatusDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<BnplOrderStatusDto>> Handle(GetBnplOrderByRequestQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var projection = await unitOfWork.BnplRepository.GetStatusByRequestAsync(request.BookingRequestId, cancellationToken);
|
||||
|
||||
// Cross-customer access is indistinguishable from "no order" — never leak another customer's order.
|
||||
if (projection is null || projection.CustomerUserId != currentUser.UserId)
|
||||
return OperationResult<BnplOrderStatusDto>.NotFoundResult("BNPL order not found.");
|
||||
|
||||
return OperationResult<BnplOrderStatusDto>.SuccessResult(projection.Order);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Bnpl;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Bnpl.Queries.GetBnplOrderByRequest;
|
||||
|
||||
/// <summary>Reads the BNPL order for a booking request (the id the return-poll surface holds, not the order id),
|
||||
/// owner-scoped. 404 when the request has no BNPL order.</summary>
|
||||
public record GetBnplOrderByRequestQuery(long BookingRequestId) : IRequest<OperationResult<BnplOrderStatusDto>>;
|
||||
@@ -1,4 +1,5 @@
|
||||
#nullable enable
|
||||
using System.Globalization;
|
||||
using Baya.Application.Models.Booking;
|
||||
|
||||
namespace Baya.Application.Features.Booking;
|
||||
@@ -43,5 +44,8 @@ internal static class BookingRequestMapper
|
||||
p.NurseResponseDeadlineAt,
|
||||
p.PaymentDeadlineAt,
|
||||
p.NurseRejectionReason,
|
||||
p.CreatedAt);
|
||||
p.CreatedAt,
|
||||
p.VariantPrice.ToString(CultureInfo.InvariantCulture),
|
||||
p.NurseAvatarUrl,
|
||||
p.BookingId);
|
||||
}
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
#nullable enable
|
||||
using System.Globalization;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Queries.GetCheckoutSummary;
|
||||
|
||||
internal sealed class GetCheckoutSummaryQueryHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IPlatformConfig platformConfig)
|
||||
: IRequestHandler<GetCheckoutSummaryQuery, OperationResult<CheckoutSummaryDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<CheckoutSummaryDto>> Handle(GetCheckoutSummaryQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<CheckoutSummaryDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<CheckoutSummaryDto>.ForbiddenResult("Only a customer can read a checkout summary.");
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is not { } cid)
|
||||
return OperationResult<CheckoutSummaryDto>.NotFoundResult("Booking request not found.");
|
||||
|
||||
var ctx = await unitOfWork.BookingRequestRepository.GetCheckoutContextAsync(request.BookingRequestId, cid, cancellationToken);
|
||||
if (ctx is null)
|
||||
return OperationResult<CheckoutSummaryDto>.NotFoundResult("Booking request not found.");
|
||||
|
||||
var sessions = ctx.SessionCount is > 0 ? ctx.SessionCount.Value : 1;
|
||||
var gross = ctx.VariantPrice * sessions;
|
||||
|
||||
var feeRate = await platformConfig.GetConfig<decimal>("platform_fee_rate", cancellationToken);
|
||||
var vatRate = await platformConfig.GetConfig<decimal>("vat_rate", cancellationToken);
|
||||
|
||||
// b10 split (VAT-inclusive commission), then carve VAT out of the commission so the display
|
||||
// decomposition reconciles to the captured total: serviceCost + commissionNet + vat = gross.
|
||||
var (commission, payout) = BookingAmounts.Split(gross, feeRate);
|
||||
var commissionNet = (long)decimal.Round(commission / (1 + vatRate), MidpointRounding.AwayFromZero);
|
||||
var vat = commission - commissionNet;
|
||||
|
||||
var dto = new CheckoutSummaryDto(
|
||||
ctx.Id,
|
||||
ctx.Status,
|
||||
ctx.NurseName,
|
||||
ctx.PatientName,
|
||||
ctx.VariantLabel,
|
||||
ctx.VariantPriceUnit,
|
||||
ctx.SessionCount,
|
||||
ctx.RequestedDate,
|
||||
ctx.RequestedTimeStart,
|
||||
ctx.RequestedTimeEnd,
|
||||
ctx.PaymentDeadlineAt,
|
||||
Str(payout),
|
||||
Str(commissionNet),
|
||||
Str(vat),
|
||||
vatRate,
|
||||
Str(gross),
|
||||
Str(gross),
|
||||
Str(commission),
|
||||
Str(payout));
|
||||
|
||||
return OperationResult<CheckoutSummaryDto>.SuccessResult(dto);
|
||||
}
|
||||
|
||||
private static string Str(long value) => value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Queries.GetCheckoutSummary;
|
||||
|
||||
/// <summary>
|
||||
/// Owner-scoped checkout money summary for a booking request (C6). Serves the reconciling gross / commission
|
||||
/// (net of VAT) / VAT / total decomposition computed server-side from config; the client renders, never
|
||||
/// derives. A foreign or absent request is a clean not-found.
|
||||
/// </summary>
|
||||
public record GetCheckoutSummaryQuery(long BookingRequestId) : IRequest<OperationResult<CheckoutSummaryDto>>;
|
||||
+6
-1
@@ -1,4 +1,5 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
@@ -29,6 +30,8 @@ internal sealed class CreatePatientCommandHandler(ICurrentUser currentUser, IUni
|
||||
Gender = request.Gender,
|
||||
BloodType = request.BloodType,
|
||||
InitialMedicalNotes = request.InitialMedicalNotes,
|
||||
Relation = request.Relation,
|
||||
ConditionsJson = JsonCodeList.Serialize(request.Conditions),
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
@@ -58,6 +61,8 @@ internal sealed class CreatePatientCommandHandler(ICurrentUser currentUser, IUni
|
||||
patient.Gender,
|
||||
patient.BloodType,
|
||||
patient.InitialMedicalNotes,
|
||||
patient.IsActive));
|
||||
patient.IsActive,
|
||||
patient.Relation,
|
||||
JsonCodeList.Parse(patient.ConditionsJson)));
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -17,5 +17,11 @@ public sealed class CreatePatientCommandValidator : AbstractValidator<CreatePati
|
||||
.NotEqual(default(DateOnly))
|
||||
.Must(PatientRules.IsNotFuture)
|
||||
.WithMessage("Birth date cannot be in the future.");
|
||||
RuleFor(x => x.Relation)
|
||||
.Must(PatientRules.IsValidRelation)
|
||||
.WithMessage("Relation must be one of parent, spouse, child, self.");
|
||||
RuleFor(x => x.Conditions)
|
||||
.Must(PatientRules.AreValidConditions)
|
||||
.WithMessage("Each condition must be a non-empty code up to 40 characters.");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -16,4 +16,6 @@ public record CreatePatientCommand(
|
||||
DateOnly BirthDate,
|
||||
string Gender,
|
||||
string BloodType,
|
||||
string InitialMedicalNotes) : IRequest<OperationResult<PatientDto>>;
|
||||
string InitialMedicalNotes,
|
||||
string Relation = null,
|
||||
IReadOnlyList<string> Conditions = null) : IRequest<OperationResult<PatientDto>>;
|
||||
|
||||
+4
-2
@@ -33,7 +33,8 @@ internal sealed class RequestOtpCommandHandler(
|
||||
var now = clock.UtcNow;
|
||||
if (windowEndsAt > now)
|
||||
return OperationResult<RequestOtpResult>.SuccessResult(
|
||||
new RequestOtpResult(false, (int)Math.Ceiling((windowEndsAt - now).TotalSeconds)));
|
||||
new RequestOtpResult(false, (int)Math.Ceiling((windowEndsAt - now).TotalSeconds),
|
||||
IdentityDefaults.OtpCodeLength, IdentityDefaults.OtpExpirySeconds));
|
||||
|
||||
var user = await userManager.GetUserByPhoneNumber(phone);
|
||||
if (user is null)
|
||||
@@ -64,6 +65,7 @@ internal sealed class RequestOtpCommandHandler(
|
||||
|
||||
await cache.SetAsync(resendKey, now.AddSeconds(resendSeconds), TimeSpan.FromSeconds(resendSeconds), cancellationToken);
|
||||
|
||||
return OperationResult<RequestOtpResult>.SuccessResult(new RequestOtpResult(true, resendSeconds));
|
||||
return OperationResult<RequestOtpResult>.SuccessResult(
|
||||
new RequestOtpResult(true, resendSeconds, IdentityDefaults.OtpCodeLength, IdentityDefaults.OtpExpirySeconds));
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -1,4 +1,5 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
@@ -34,6 +35,8 @@ internal sealed class UpdatePatientCommandHandler(ICurrentUser currentUser, IUni
|
||||
patient.Gender = request.Gender;
|
||||
patient.BloodType = request.BloodType;
|
||||
patient.InitialMedicalNotes = request.InitialMedicalNotes;
|
||||
patient.Relation = request.Relation;
|
||||
patient.ConditionsJson = JsonCodeList.Serialize(request.Conditions);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
@@ -46,6 +49,8 @@ internal sealed class UpdatePatientCommandHandler(ICurrentUser currentUser, IUni
|
||||
patient.Gender,
|
||||
patient.BloodType,
|
||||
patient.InitialMedicalNotes,
|
||||
patient.IsActive));
|
||||
patient.IsActive,
|
||||
patient.Relation,
|
||||
JsonCodeList.Parse(patient.ConditionsJson)));
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -18,5 +18,11 @@ public sealed class UpdatePatientCommandValidator : AbstractValidator<UpdatePati
|
||||
.NotEqual(default(DateOnly))
|
||||
.Must(PatientRules.IsNotFuture)
|
||||
.WithMessage("Birth date cannot be in the future.");
|
||||
RuleFor(x => x.Relation)
|
||||
.Must(PatientRules.IsValidRelation)
|
||||
.WithMessage("Relation must be one of parent, spouse, child, self.");
|
||||
RuleFor(x => x.Conditions)
|
||||
.Must(PatientRules.AreValidConditions)
|
||||
.WithMessage("Each condition must be a non-empty code up to 40 characters.");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -13,4 +13,6 @@ public record UpdatePatientCommand(
|
||||
DateOnly BirthDate,
|
||||
string Gender,
|
||||
string BloodType,
|
||||
string InitialMedicalNotes) : IRequest<OperationResult<PatientDto>>;
|
||||
string InitialMedicalNotes,
|
||||
string Relation = null,
|
||||
IReadOnlyList<string> Conditions = null) : IRequest<OperationResult<PatientDto>>;
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
#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.Identity;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.UploadCustomerAvatar;
|
||||
|
||||
internal sealed class UploadCustomerAvatarCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IObjectStorage objectStorage)
|
||||
: IRequestHandler<UploadCustomerAvatarCommand, OperationResult<AvatarUploadResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<AvatarUploadResult>> Handle(UploadCustomerAvatarCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<AvatarUploadResult>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<AvatarUploadResult>.ForbiddenResult("Only a customer can upload a customer avatar.");
|
||||
|
||||
if (!AvatarUpload.IsAllowedContentType(request.ContentType))
|
||||
return OperationResult<AvatarUploadResult>.FailureResult("Unsupported image type. Use JPEG, PNG, or WebP.");
|
||||
|
||||
if (!AvatarUpload.IsWithinSizeLimit(request.Length))
|
||||
return OperationResult<AvatarUploadResult>.FailureResult("Image is empty or exceeds the 5 MB limit.");
|
||||
|
||||
var profile = await unitOfWork.CustomerProfileRepository.GetByUserIdAsync(userId, cancellationToken);
|
||||
if (profile is null)
|
||||
{
|
||||
// Mirror the customer-profile upsert: provision the thin payer row so an avatar-first customer
|
||||
// still gets a profile to attach the URL to.
|
||||
profile = new CustomerProfile { UserId = userId };
|
||||
await unitOfWork.CustomerProfileRepository.AddAsync(profile, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
var key = AvatarUpload.BuildKey("customer", profile.Id, request.ContentType, Guid.NewGuid().ToString("N"));
|
||||
using (var stream = new MemoryStream(request.Content))
|
||||
await objectStorage.PutAsync(key, stream, request.ContentType, cancellationToken);
|
||||
|
||||
var url = objectStorage.GetUrl(key);
|
||||
profile.AvatarUrl = url;
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<AvatarUploadResult>.SuccessResult(new AvatarUploadResult(url));
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.UploadCustomerAvatar;
|
||||
|
||||
/// <summary>
|
||||
/// Stores the signed-in customer's profile photo via <c>IObjectStorage</c> and persists the returned URL on
|
||||
/// the customer profile. The controller reads the multipart file; the command carries the bytes + metadata.
|
||||
/// </summary>
|
||||
public record UploadCustomerAvatarCommand(
|
||||
byte[] Content,
|
||||
string ContentType,
|
||||
long Length) : IRequest<OperationResult<AvatarUploadResult>>;
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
#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.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.UploadNurseAvatar;
|
||||
|
||||
internal sealed class UploadNurseAvatarCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IObjectStorage objectStorage)
|
||||
: IRequestHandler<UploadNurseAvatarCommand, OperationResult<AvatarUploadResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<AvatarUploadResult>> Handle(UploadNurseAvatarCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<AvatarUploadResult>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<AvatarUploadResult>.ForbiddenResult("Only a nurse can upload a nurse avatar.");
|
||||
|
||||
if (!AvatarUpload.IsAllowedContentType(request.ContentType))
|
||||
return OperationResult<AvatarUploadResult>.FailureResult("Unsupported image type. Use JPEG, PNG, or WebP.");
|
||||
|
||||
if (!AvatarUpload.IsWithinSizeLimit(request.Length))
|
||||
return OperationResult<AvatarUploadResult>.FailureResult("Image is empty or exceeds the 5 MB limit.");
|
||||
|
||||
var profile = await unitOfWork.NurseProfileRepository.GetByUserIdAsync(userId, cancellationToken);
|
||||
if (profile is null)
|
||||
return OperationResult<AvatarUploadResult>.NotFoundResult("No nurse profile exists yet.");
|
||||
|
||||
var key = AvatarUpload.BuildKey("nurse", profile.Id, request.ContentType, Guid.NewGuid().ToString("N"));
|
||||
using (var stream = new MemoryStream(request.Content))
|
||||
await objectStorage.PutAsync(key, stream, request.ContentType, cancellationToken);
|
||||
|
||||
var url = objectStorage.GetUrl(key);
|
||||
profile.AvatarUrl = url;
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<AvatarUploadResult>.SuccessResult(new AvatarUploadResult(url));
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.UploadNurseAvatar;
|
||||
|
||||
/// <summary>
|
||||
/// Stores the signed-in nurse's profile photo via <c>IObjectStorage</c> and persists the returned URL on
|
||||
/// the nurse profile. The controller reads the multipart file; the command carries the bytes + metadata.
|
||||
/// </summary>
|
||||
public record UploadNurseAvatarCommand(
|
||||
byte[] Content,
|
||||
string ContentType,
|
||||
long Length) : IRequest<OperationResult<AvatarUploadResult>>;
|
||||
+18
-1
@@ -29,7 +29,8 @@ internal sealed class UpsertCustomerProfileCommandHandler(ICurrentUser currentUs
|
||||
{
|
||||
UserId = userId,
|
||||
DefaultEmergencyContactName = request.DefaultEmergencyContactName,
|
||||
DefaultEmergencyContactPhone = phone
|
||||
DefaultEmergencyContactPhone = phone,
|
||||
PreferredLanguage = request.PreferredLanguage
|
||||
};
|
||||
await unitOfWork.CustomerProfileRepository.AddAsync(profile, cancellationToken);
|
||||
}
|
||||
@@ -37,6 +38,22 @@ internal sealed class UpsertCustomerProfileCommandHandler(ICurrentUser currentUs
|
||||
{
|
||||
profile.DefaultEmergencyContactName = request.DefaultEmergencyContactName;
|
||||
profile.DefaultEmergencyContactPhone = phone;
|
||||
if (request.PreferredLanguage is not null)
|
||||
profile.PreferredLanguage = request.PreferredLanguage;
|
||||
}
|
||||
|
||||
// The customer's display name lives on the base identity row, not the profile — update it in the
|
||||
// same unit of work when supplied.
|
||||
if (request.FirstName is not null || request.LastName is not null)
|
||||
{
|
||||
var user = await unitOfWork.UserAccountRepository.GetTrackedByIdAsync(userId, cancellationToken);
|
||||
if (user is not null)
|
||||
{
|
||||
if (request.FirstName is not null)
|
||||
user.Name = request.FirstName;
|
||||
if (request.LastName is not null)
|
||||
user.FamilyName = request.LastName;
|
||||
}
|
||||
}
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
+6
-2
@@ -6,8 +6,12 @@ namespace Baya.Application.Features.Identity.Commands.UpsertCustomerProfile;
|
||||
|
||||
/// <summary>
|
||||
/// Creates (first call) or updates the signed-in customer's payer profile and its default emergency
|
||||
/// contact (encrypted at rest). Idempotent on the owning user.
|
||||
/// contact (encrypted at rest). Optionally updates the customer's display name (persisted on the base
|
||||
/// <c>users</c> row) and preferred UI language (persisted on the profile). Idempotent on the owning user.
|
||||
/// </summary>
|
||||
public record UpsertCustomerProfileCommand(
|
||||
string DefaultEmergencyContactName,
|
||||
string DefaultEmergencyContactPhone) : IRequest<OperationResult<CustomerProfileDto>>;
|
||||
string DefaultEmergencyContactPhone,
|
||||
string FirstName = null,
|
||||
string LastName = null,
|
||||
string PreferredLanguage = null) : IRequest<OperationResult<CustomerProfileDto>>;
|
||||
|
||||
+10
-2
@@ -35,7 +35,13 @@ internal sealed class VerifyOtpCommandHandler(
|
||||
|
||||
var maxAttempts = await platformConfig.GetConfig<int>(IdentityDefaults.OtpMaxAttemptsKey, cancellationToken);
|
||||
if (user.AccessFailedCount >= maxAttempts)
|
||||
return OperationResult<AuthTokensResult>.FailureResult("Too many failed attempts. Request a new code.");
|
||||
{
|
||||
// Lockout is the one machine-distinguishable state (safe — it reveals nothing about the code or
|
||||
// account). The client shows the unlock countdown; a fresh code is gated by the resend window.
|
||||
var retryAfterSeconds = await platformConfig.GetConfig<int>(IdentityDefaults.OtpResendSecondsKey, cancellationToken);
|
||||
return OperationResult<AuthTokensResult>.CodedFailureResult(
|
||||
"otp_locked", "Too many failed attempts. Request a new code.", new { retryAfterSeconds });
|
||||
}
|
||||
|
||||
// First-ever verify confirms the phone (ChangePhoneNumber to the same number); afterwards the
|
||||
// passwordless TOTP path applies. Both rotate the security stamp, so the token is minted after.
|
||||
@@ -47,7 +53,9 @@ internal sealed class VerifyOtpCommandHandler(
|
||||
if (!verifyResult.Succeeded)
|
||||
{
|
||||
await userManager.IncrementAccessFailedCountAsync(user);
|
||||
return OperationResult<AuthTokensResult>.FailureResult(InvalidCodeMessage);
|
||||
// Wrong and expired stay collapsed behind one code + message (anti-enumeration); only lockout is
|
||||
// distinguished (above).
|
||||
return OperationResult<AuthTokensResult>.CodedFailureResult("otp_invalid", InvalidCodeMessage);
|
||||
}
|
||||
|
||||
var now = clock.UtcNow;
|
||||
|
||||
@@ -21,6 +21,14 @@ internal static class IdentityDefaults
|
||||
/// <summary>Refresh-token session lifetime, in days.</summary>
|
||||
public const string SessionTtlDaysKey = "auth_session_ttl_days";
|
||||
|
||||
/// <summary>Number of digits in the OTP code — mirrors the TOTP token provider (6). Surfaced on
|
||||
/// <c>RequestOtpResult</c> so the client renders the correct number of input boxes contract-driven.</summary>
|
||||
public const int OtpCodeLength = 6;
|
||||
|
||||
/// <summary>How long an OTP stays valid, in seconds — mirrors the passwordless TOTP
|
||||
/// <c>TokenLifespan</c> (1 minute). Surfaced so the client can show a "code expires in …" hint.</summary>
|
||||
public const int OtpExpirySeconds = 60;
|
||||
|
||||
/// <summary>Cache key of the per-phone resend window (keyed by phone hash, never the raw phone).</summary>
|
||||
public static string OtpResendCacheKey(string phoneHash) => $"auth:otp:resend:{phoneHash}";
|
||||
|
||||
|
||||
@@ -6,4 +6,12 @@ internal static class PatientRules
|
||||
public static bool IsValidGender(string gender) => gender is "male" or "female";
|
||||
|
||||
public static bool IsNotFuture(DateOnly birthDate) => birthDate <= DateOnly.FromDateTime(DateTime.UtcNow.Date);
|
||||
|
||||
/// <summary>The care-recipient relation-to-payer code set. Null/empty means "unspecified".</summary>
|
||||
public static bool IsValidRelation(string relation)
|
||||
=> string.IsNullOrEmpty(relation) || relation is "parent" or "spouse" or "child" or "self";
|
||||
|
||||
/// <summary>Each condition is a short stable code; the list itself may be null/empty.</summary>
|
||||
public static bool AreValidConditions(IReadOnlyList<string> conditions)
|
||||
=> conditions is null || conditions.All(c => !string.IsNullOrWhiteSpace(c) && c.Length <= 40);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ internal static class InvoiceDtoFactory
|
||||
invoice.BnplCommissionIrr?.ToString(),
|
||||
invoice.VatRate,
|
||||
invoice.VatIrr.ToString(),
|
||||
(invoice.PlatformCommissionIrr + (invoice.BnplCommissionIrr ?? 0) + invoice.VatIrr).ToString(),
|
||||
invoice.MoadianReferenceNumber,
|
||||
invoice.MoadianStatus,
|
||||
pdfUrl,
|
||||
|
||||
+12
-1
@@ -41,6 +41,16 @@ internal sealed class PostMessageCommandHandler(
|
||||
if (header.Status == TicketStatus.Closed && !isStaff)
|
||||
return OperationResult<PostMessageResult>.ForbiddenResult("This ticket is closed.");
|
||||
|
||||
// Optimistic-send idempotency: a retried post with the same client message id returns the original
|
||||
// message instead of a duplicate.
|
||||
if (!string.IsNullOrWhiteSpace(request.ClientMessageId))
|
||||
{
|
||||
var existing = await unitOfWork.TicketRepository.GetMessageByClientIdAsync(request.TicketId, request.ClientMessageId, cancellationToken);
|
||||
if (existing is not null)
|
||||
return OperationResult<PostMessageResult>.SuccessResult(
|
||||
new PostMessageResult(existing.Id, request.TicketId, existing.SentAt, request.ClientMessageId));
|
||||
}
|
||||
|
||||
var now = dateTimeProvider.UtcNow;
|
||||
var message = new TicketMessage
|
||||
{
|
||||
@@ -48,6 +58,7 @@ internal sealed class PostMessageCommandHandler(
|
||||
SenderId = userId,
|
||||
Body = request.Body,
|
||||
IsInternal = request.IsInternal,
|
||||
ClientMessageId = string.IsNullOrWhiteSpace(request.ClientMessageId) ? null : request.ClientMessageId,
|
||||
SentAt = now
|
||||
};
|
||||
|
||||
@@ -67,6 +78,6 @@ internal sealed class PostMessageCommandHandler(
|
||||
}
|
||||
|
||||
return OperationResult<PostMessageResult>.SuccessResult(
|
||||
new PostMessageResult(message.Id, request.TicketId, message.SentAt));
|
||||
new PostMessageResult(message.Id, request.TicketId, message.SentAt, message.ClientMessageId));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -8,5 +8,5 @@ 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)
|
||||
public record PostMessageCommand(long TicketId, string Body, bool IsInternal = false, string? ClientMessageId = null)
|
||||
: IRequest<OperationResult<PostMessageResult>>;
|
||||
|
||||
+14
-1
@@ -15,7 +15,8 @@ namespace Baya.Application.Features.Messaging.Queries.GetTicketThread;
|
||||
/// </summary>
|
||||
internal sealed class GetTicketThreadQueryHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork)
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<GetTicketThreadQuery, OperationResult<TicketThreadDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<TicketThreadDto>> Handle(GetTicketThreadQuery request, CancellationToken cancellationToken)
|
||||
@@ -42,6 +43,18 @@ internal sealed class GetTicketThreadQueryHandler(
|
||||
var participants = await unitOfWork.TicketRepository.GetActiveParticipantsAsync(request.TicketId, cancellationToken);
|
||||
var messages = await unitOfWork.TicketRepository.GetMessagesAsync(request.TicketId, includeInternal, cancellationToken);
|
||||
|
||||
// Fetching the user-facing thread marks it read for the caller (drives the inbox unread count) — admins
|
||||
// reading the staff view do not consume a participant's read state.
|
||||
if (!request.AsAdmin)
|
||||
{
|
||||
var participant = await unitOfWork.TicketRepository.GetParticipantAsync(request.TicketId, userId, cancellationToken);
|
||||
if (participant is { IsActive: true })
|
||||
{
|
||||
participant.MarkRead(dateTimeProvider.UtcNow);
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ internal sealed class ListMyTicketsQueryHandler(
|
||||
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);
|
||||
var result = await unitOfWork.TicketRepository.ListMyTicketsAsync(userId, request.Status, request.ReferenceCode, request.BookingId, page, pageSize, cancellationToken);
|
||||
return OperationResult<PagedResult<TicketSummaryDto>>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -9,5 +9,6 @@ namespace Baya.Application.Features.Messaging.Queries.ListMyTickets;
|
||||
public record ListMyTicketsQuery(
|
||||
string? Status = null,
|
||||
string? ReferenceCode = null,
|
||||
long? BookingId = null,
|
||||
int Page = 1,
|
||||
int PageSize = 50) : IRequest<OperationResult<PagedResult<TicketSummaryDto>>>;
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Nurses;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Nurses.Queries.GetNursePublicProfile;
|
||||
|
||||
internal sealed class GetNursePublicProfileQueryHandler(IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetNursePublicProfileQuery, OperationResult<NursePublicProfileDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<NursePublicProfileDto>> Handle(GetNursePublicProfileQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var dto = await unitOfWork.NurseProfileRepository.GetPublicProfileAsync(request.NurseId, cancellationToken);
|
||||
return dto is null
|
||||
? OperationResult<NursePublicProfileDto>.NotFoundResult("Nurse not found.")
|
||||
: OperationResult<NursePublicProfileDto>.SuccessResult(dto);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Nurses;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Nurses.Queries.GetNursePublicProfile;
|
||||
|
||||
/// <summary>
|
||||
/// The aggregated public nurse profile for the C3 discovery detail. Anonymous — it exposes only public,
|
||||
/// non-PII facts (identity name/avatar/bio, aggregates, verification signal, specialty chips, active
|
||||
/// services, the latest published review). No encrypted credential number is ever returned.
|
||||
/// </summary>
|
||||
public record GetNursePublicProfileQuery(long NurseId) : IRequest<OperationResult<NursePublicProfileDto>>;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
#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.SetPartnerCenterActive;
|
||||
|
||||
internal sealed class SetPartnerCenterActiveCommandHandler(IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<SetPartnerCenterActiveCommand, OperationResult<PartnerCenterDetailDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PartnerCenterDetailDto>> Handle(SetPartnerCenterActiveCommand 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.SetActive(request.IsActive);
|
||||
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.SetPartnerCenterActive;
|
||||
|
||||
/// <summary>Activates or suspends a partner center (admin). Distinct from verify (verify records the licensing
|
||||
/// approval + activates); this is the standalone activate/suspend toggle. The id comes from the route.</summary>
|
||||
public record SetPartnerCenterActiveCommand(bool IsActive, long Id = 0)
|
||||
: IRequest<OperationResult<PartnerCenterDetailDto>>;
|
||||
@@ -0,0 +1,61 @@
|
||||
#nullable enable
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Models.Patients;
|
||||
|
||||
namespace Baya.Application.Features.PatientCareRecords;
|
||||
|
||||
/// <summary>(De)serializes the family care plan's three JSON lists and assigns stable ids on write: a
|
||||
/// caller-supplied id (> 0) is preserved, a new item (id 0) gets the next free id — so ids stay stable
|
||||
/// across edits within a plan.</summary>
|
||||
internal static class CarePlanSerialization
|
||||
{
|
||||
public static IReadOnlyList<MedicationDto> ParseMedications(string? json)
|
||||
=> Parse<MedicationDto>(json);
|
||||
|
||||
public static IReadOnlyList<RoutineItemDto> ParseRoutine(string? json)
|
||||
=> Parse<RoutineItemDto>(json);
|
||||
|
||||
public static IReadOnlyList<CareTaskDto> ParseTasks(string? json)
|
||||
=> Parse<CareTaskDto>(json);
|
||||
|
||||
public static (string Json, IReadOnlyList<MedicationDto> Items) AssignMedications(IReadOnlyList<MedicationDto>? items)
|
||||
{
|
||||
var assigned = AssignIds(items, (m, id) => m with { Id = id }, m => m.Id);
|
||||
return (JsonSerializer.Serialize(assigned), assigned);
|
||||
}
|
||||
|
||||
public static (string Json, IReadOnlyList<RoutineItemDto> Items) AssignRoutine(IReadOnlyList<RoutineItemDto>? items)
|
||||
{
|
||||
var assigned = AssignIds(items, (r, id) => r with { Id = id }, r => r.Id);
|
||||
return (JsonSerializer.Serialize(assigned), assigned);
|
||||
}
|
||||
|
||||
public static (string Json, IReadOnlyList<CareTaskDto> Items) AssignTasks(IReadOnlyList<CareTaskDto>? items)
|
||||
{
|
||||
var assigned = AssignIds(items, (t, id) => t with { Id = id }, t => t.Id);
|
||||
return (JsonSerializer.Serialize(assigned), assigned);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<T> Parse<T>(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return [];
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<T>>(json) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<T> AssignIds<T>(IReadOnlyList<T>? items, Func<T, long, T> withId, Func<T, long> getId)
|
||||
{
|
||||
if (items is null || items.Count == 0)
|
||||
return [];
|
||||
|
||||
var next = items.Select(getId).DefaultIfEmpty(0).Max() + 1;
|
||||
return items.Select(i => getId(i) > 0 ? i : withId(i, next++)).ToList();
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Patients;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.PatientCareRecords.Commands.UpsertCarePlan;
|
||||
|
||||
internal sealed class UpsertCarePlanCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<UpsertCarePlanCommand, OperationResult<CarePlanDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<CarePlanDto>> Handle(UpsertCarePlanCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is null)
|
||||
return OperationResult<CarePlanDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var (owner, access) = await PatientAccess.ResolveAsync(currentUser, unitOfWork, request.PatientId, cancellationToken);
|
||||
if (owner is null)
|
||||
return OperationResult<CarePlanDto>.NotFoundResult("Patient not found.");
|
||||
if (!access.CanEdit)
|
||||
return OperationResult<CarePlanDto>.ForbiddenResult("Only the owning customer can edit the care plan.");
|
||||
|
||||
var (medsJson, meds) = CarePlanSerialization.AssignMedications(request.Medications);
|
||||
var (routineJson, routine) = CarePlanSerialization.AssignRoutine(request.Routine);
|
||||
var (tasksJson, tasks) = CarePlanSerialization.AssignTasks(request.Tasks);
|
||||
|
||||
var plan = await unitOfWork.PatientCareRecordRepository.GetCarePlanAsync(request.PatientId, cancellationToken);
|
||||
if (plan is null)
|
||||
{
|
||||
plan = new PatientCarePlan { PatientId = request.PatientId };
|
||||
plan.MedicationsJson = medsJson;
|
||||
plan.RoutineJson = routineJson;
|
||||
plan.TasksJson = tasksJson;
|
||||
await unitOfWork.PatientCareRecordRepository.AddCarePlanAsync(plan, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
plan.MedicationsJson = medsJson;
|
||||
plan.RoutineJson = routineJson;
|
||||
plan.TasksJson = tasksJson;
|
||||
}
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<CarePlanDto>.SuccessResult(new CarePlanDto(request.PatientId, meds, routine, tasks));
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Patients;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.PatientCareRecords.Commands.UpsertCarePlan;
|
||||
|
||||
/// <summary>Replaces the family-owned care plan for a patient (owning customer only). New items (id 0) are
|
||||
/// assigned stable ids on save. The patient id comes from the route.</summary>
|
||||
public record UpsertCarePlanCommand(
|
||||
long PatientId,
|
||||
IReadOnlyList<MedicationDto>? Medications,
|
||||
IReadOnlyList<RoutineItemDto>? Routine,
|
||||
IReadOnlyList<CareTaskDto>? Tasks) : IRequest<OperationResult<CarePlanDto>>;
|
||||
+2
@@ -1,4 +1,5 @@
|
||||
#nullable enable
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
@@ -47,6 +48,7 @@ internal sealed class WritePatientCareRecordCommandHandler(
|
||||
BookingId = request.BookingId,
|
||||
NurseProfileId = nurseId,
|
||||
BodyEncrypted = fieldEncryptor.Encrypt(request.Body.Trim()),
|
||||
TaskResultsJson = request.TaskResults is { Count: > 0 } tr ? JsonSerializer.Serialize(tr) : null,
|
||||
RecordedAt = dateTimeProvider.UtcNow.UtcDateTime
|
||||
};
|
||||
|
||||
|
||||
+8
-3
@@ -1,11 +1,16 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Patients;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.PatientCareRecords.Commands.WritePatientCareRecord;
|
||||
|
||||
/// <summary>A nurse authors a clinical note for a patient (optionally tagged with the booking that produced
|
||||
/// it). The patient id comes from the route; the body is encrypted at rest before persisting.</summary>
|
||||
public record WritePatientCareRecordCommand(long PatientId, long? BookingId, string Body)
|
||||
: IRequest<OperationResult<WriteCareRecordResult>>;
|
||||
/// it) plus the visit's ticked task checklist. The patient id comes from the route; the body is encrypted at
|
||||
/// rest before persisting.</summary>
|
||||
public record WritePatientCareRecordCommand(
|
||||
long PatientId,
|
||||
long? BookingId,
|
||||
string Body,
|
||||
IReadOnlyList<TaskResultDto>? TaskResults = null) : IRequest<OperationResult<WriteCareRecordResult>>;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Patients;
|
||||
using Baya.Domain.Entities.User;
|
||||
|
||||
namespace Baya.Application.Features.PatientCareRecords;
|
||||
|
||||
/// <summary>
|
||||
/// The single resolver for a caller's access to a patient's records: <b>edit</b> = the owning customer,
|
||||
/// <b>append-note</b> = a nurse with a confirmed booking for the patient, <b>view</b> = either of those or an
|
||||
/// admin. Centralized so the family care-plan get/put, the visit-note write/history, and the explicit
|
||||
/// <c>record_access</c> read all apply the exact same rule.
|
||||
/// </summary>
|
||||
internal static class PatientAccess
|
||||
{
|
||||
private static readonly string[] AdminRoles = [RoleNames.Admin, RoleNames.SuperAdmin];
|
||||
|
||||
public const string DeniedNotFound = "not_found";
|
||||
public const string DeniedNotAuthorized = "not_authorized";
|
||||
|
||||
/// <summary>Resolves access. <c>OwnerCustomerId</c> is null when the patient does not exist (a
|
||||
/// <see cref="DeniedNotFound"/> access with everything false).</summary>
|
||||
public static async Task<(long? OwnerCustomerId, RecordAccessDto Access)> ResolveAsync(
|
||||
ICurrentUser currentUser, IUnitOfWork unitOfWork, long patientId, CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerCustomerId = await unitOfWork.PatientCareRecordRepository.GetPatientOwnerCustomerIdAsync(patientId, cancellationToken);
|
||||
if (ownerCustomerId is null)
|
||||
return (null, new RecordAccessDto(false, false, false, DeniedNotFound));
|
||||
|
||||
var isAdmin = currentUser.Roles?.Any(AdminRoles.Contains) == true;
|
||||
|
||||
var userId = currentUser.UserId;
|
||||
var customerProfileId = userId is null
|
||||
? null
|
||||
: await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId.Value, cancellationToken);
|
||||
var canEdit = customerProfileId is { } cid && cid == ownerCustomerId;
|
||||
|
||||
var canAppendNote = false;
|
||||
if (userId is not null)
|
||||
{
|
||||
var nurseProfileId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId.Value, cancellationToken);
|
||||
if (nurseProfileId is { } nurseId)
|
||||
canAppendNote = await unitOfWork.PatientCareRecordRepository
|
||||
.NurseHasQualifyingBookingForPatientAsync(nurseId, patientId, cancellationToken);
|
||||
}
|
||||
|
||||
var canView = canEdit || canAppendNote || isAdmin;
|
||||
return (ownerCustomerId, new RecordAccessDto(canView, canEdit, canAppendNote, canView ? null : DeniedNotAuthorized));
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Patients;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.PatientCareRecords.Queries.GetCarePlan;
|
||||
|
||||
internal sealed class GetCarePlanQueryHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetCarePlanQuery, OperationResult<CarePlanDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<CarePlanDto>> Handle(GetCarePlanQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is null)
|
||||
return OperationResult<CarePlanDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var (owner, access) = await PatientAccess.ResolveAsync(currentUser, unitOfWork, request.PatientId, cancellationToken);
|
||||
if (owner is null)
|
||||
return OperationResult<CarePlanDto>.NotFoundResult("Patient not found.");
|
||||
if (!access.CanView)
|
||||
return OperationResult<CarePlanDto>.ForbiddenResult("You do not have access to this patient's care plan.");
|
||||
|
||||
var plan = await unitOfWork.PatientCareRecordRepository.GetCarePlanAsync(request.PatientId, cancellationToken);
|
||||
return OperationResult<CarePlanDto>.SuccessResult(plan is null
|
||||
? new CarePlanDto(request.PatientId, [], [], [])
|
||||
: new CarePlanDto(
|
||||
request.PatientId,
|
||||
CarePlanSerialization.ParseMedications(plan.MedicationsJson),
|
||||
CarePlanSerialization.ParseRoutine(plan.RoutineJson),
|
||||
CarePlanSerialization.ParseTasks(plan.TasksJson)));
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Patients;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.PatientCareRecords.Queries.GetCarePlan;
|
||||
|
||||
/// <summary>Reads the family-owned care plan for a patient, under the same clinical access rule as the history
|
||||
/// (owner / nurse-with-booking / admin). The patient id comes from the route.</summary>
|
||||
public record GetCarePlanQuery(long PatientId) : IRequest<OperationResult<CarePlanDto>>;
|
||||
+17
-1
@@ -1,7 +1,9 @@
|
||||
#nullable enable
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Patients;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
@@ -60,10 +62,24 @@ internal sealed class GetPatientHistoryQueryHandler(
|
||||
var items = cipher.Items
|
||||
.Select(r => new CareRecordDto(
|
||||
r.Id, r.PatientId, r.BookingId, r.NurseProfileId, r.NurseName,
|
||||
fieldEncryptor.Decrypt(r.BodyEncrypted), r.RecordedAt))
|
||||
fieldEncryptor.Decrypt(r.BodyEncrypted), ParseTaskResults(r.TaskResultsJson), r.RecordedAt))
|
||||
.ToList();
|
||||
|
||||
return OperationResult<PagedResult<CareRecordDto>>.SuccessResult(
|
||||
new PagedResult<CareRecordDto>(items, cipher.Total, cipher.Page, cipher.PageSize));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<TaskResultDto> ParseTaskResults(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return [];
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<TaskResultDto>>(json) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Patients;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.PatientCareRecords.Queries.GetRecordAccess;
|
||||
|
||||
internal sealed class GetRecordAccessQueryHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetRecordAccessQuery, OperationResult<RecordAccessDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<RecordAccessDto>> Handle(GetRecordAccessQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is null)
|
||||
return OperationResult<RecordAccessDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
// Always 200 with the access flags (incl. the non-leaking not_found / not_authorized denied states) so
|
||||
// the client can render the access-denied card without probing a 403/404.
|
||||
var (_, access) = await PatientAccess.ResolveAsync(currentUser, unitOfWork, request.PatientId, cancellationToken);
|
||||
return OperationResult<RecordAccessDto>.SuccessResult(access);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Patients;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.PatientCareRecords.Queries.GetRecordAccess;
|
||||
|
||||
/// <summary>The caller's access to a patient's records (view/edit/append-note) — so the UI shows the right
|
||||
/// affordances + a non-leaking access-denied state. The patient id comes from the route.</summary>
|
||||
public record GetRecordAccessQuery(long PatientId) : IRequest<OperationResult<RecordAccessDto>>;
|
||||
+6
@@ -90,8 +90,14 @@ internal sealed class ConfirmPaymentAndPostLedgerCommandHandler(
|
||||
// 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);
|
||||
|
||||
// Auto-issue the commission invoice so the customer can reach «دانلود فاکتور» right after capture
|
||||
// (was admin-only). Idempotent per booking — a replayed confirm returns the existing invoice.
|
||||
await sender.Send(new Invoices.Commands.IssueInvoice.IssueInvoiceCommand(booking), cancellationToken);
|
||||
}
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
#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.Payouts;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Queries.GetNurseEarnings;
|
||||
|
||||
internal sealed class GetNurseEarningsQueryHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<GetNurseEarningsQuery, OperationResult<PagedResult<NurseEarningsItemDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<NurseEarningsItemDto>>> Handle(GetNurseEarningsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<PagedResult<NurseEarningsItemDto>>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<PagedResult<NurseEarningsItemDto>>.ForbiddenResult("Only a nurse can read their earnings.");
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<PagedResult<NurseEarningsItemDto>>.NotFoundResult("No nurse profile exists yet.");
|
||||
|
||||
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
|
||||
var all = await unitOfWork.PayoutRepository.GetNurseEarningsAsync(nid, now, cancellationToken);
|
||||
var filtered = string.IsNullOrWhiteSpace(request.State)
|
||||
? all
|
||||
: all.Where(e => e.State == request.State).ToList();
|
||||
|
||||
var total = filtered.Count;
|
||||
var items = filtered
|
||||
.OrderByDescending(e => e.ScheduledDate).ThenByDescending(e => e.BookingId)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
|
||||
return OperationResult<PagedResult<NurseEarningsItemDto>>.SuccessResult(
|
||||
new PagedResult<NurseEarningsItemDto>(items, total, page, pageSize));
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Queries.GetNurseEarnings;
|
||||
|
||||
/// <summary>The signed-in nurse's per-booking earnings list, optionally filtered by money-state, paginated.</summary>
|
||||
public record GetNurseEarningsQuery(string State = null, int Page = 1, int PageSize = 20)
|
||||
: IRequest<OperationResult<PagedResult<NurseEarningsItemDto>>>;
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
#nullable enable
|
||||
using System.Globalization;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Queries.GetNurseEarningsBalance;
|
||||
|
||||
internal sealed class GetNurseEarningsBalanceQueryHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<GetNurseEarningsBalanceQuery, OperationResult<NurseEarningsBalanceDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<NurseEarningsBalanceDto>> Handle(GetNurseEarningsBalanceQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<NurseEarningsBalanceDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<NurseEarningsBalanceDto>.ForbiddenResult("Only a nurse can read their earnings.");
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<NurseEarningsBalanceDto>.NotFoundResult("No nurse profile exists yet.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
var earnings = await unitOfWork.PayoutRepository.GetNurseEarningsAsync(nid, now, cancellationToken);
|
||||
|
||||
long pending = 0, eligible = 0;
|
||||
foreach (var e in earnings)
|
||||
{
|
||||
var amount = long.Parse(e.NursePayoutAmount, CultureInfo.InvariantCulture);
|
||||
if (e.State == NurseEarningsState.Pending) pending += amount;
|
||||
else if (e.State == NurseEarningsState.Eligible) eligible += amount;
|
||||
}
|
||||
|
||||
var paidTotal = await unitOfWork.PayoutRepository.GetPaidNetTotalAsync(nid, cancellationToken);
|
||||
var clawbackOutstanding = await unitOfWork.PayoutRepository.GetPendingClawbackSumAsync(nid, cancellationToken);
|
||||
// The authoritative, SIGNED net payable — the ledger sum over nurse_payable (may be negative). Never clamped.
|
||||
var net = await unitOfWork.PaymentRepository.GetNursePayableBalanceAsync(nid, cancellationToken);
|
||||
|
||||
return OperationResult<NurseEarningsBalanceDto>.SuccessResult(new NurseEarningsBalanceDto(
|
||||
pending.ToString(CultureInfo.InvariantCulture),
|
||||
eligible.ToString(CultureInfo.InvariantCulture),
|
||||
paidTotal.ToString(CultureInfo.InvariantCulture),
|
||||
clawbackOutstanding.ToString(CultureInfo.InvariantCulture),
|
||||
net.ToString(CultureInfo.InvariantCulture)));
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Queries.GetNurseEarningsBalance;
|
||||
|
||||
/// <summary>The signed-in nurse's four-bucket earnings balance + the ledger-derived signed net payable.</summary>
|
||||
public record GetNurseEarningsBalanceQuery : IRequest<OperationResult<NurseEarningsBalanceDto>>;
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Queries.GetNursePayoutDetail;
|
||||
|
||||
internal sealed class GetNursePayoutDetailQueryHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetNursePayoutDetailQuery, OperationResult<NursePayoutDetailDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<NursePayoutDetailDto>> Handle(GetNursePayoutDetailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<NursePayoutDetailDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<NursePayoutDetailDto>.ForbiddenResult("Only a nurse can read their payout.");
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<NursePayoutDetailDto>.NotFoundResult("Payout not found.");
|
||||
|
||||
var detail = await unitOfWork.PayoutRepository.GetNursePayoutDetailAsync(request.PayoutId, nid, cancellationToken);
|
||||
return detail is null
|
||||
? OperationResult<NursePayoutDetailDto>.NotFoundResult("Payout not found.")
|
||||
: OperationResult<NursePayoutDetailDto>.SuccessResult(detail);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Queries.GetNursePayoutDetail;
|
||||
|
||||
/// <summary>The signed-in nurse's own payout detail (batch window + covered bookings). A payout that isn't the
|
||||
/// nurse's is a clean not-found. The payout id comes from the route.</summary>
|
||||
public record GetNursePayoutDetailQuery(long PayoutId) : IRequest<OperationResult<NursePayoutDetailDto>>;
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Features.Bookings.Commands.CancelBooking;
|
||||
using Baya.Application.Features.Refunds.Commands.CreateRefund;
|
||||
using Baya.Application.Features.Refunds.Queries.GetRefundStatus;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Refunds;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Commands.CancelBookingAndRefund;
|
||||
|
||||
internal sealed class CancelBookingAndRefundCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
ISender sender)
|
||||
: IRequestHandler<CancelBookingAndRefundCommand, OperationResult<RefundStatusDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<RefundStatusDto>> Handle(CancelBookingAndRefundCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is null)
|
||||
return OperationResult<RefundStatusDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<RefundStatusDto>.ForbiddenResult("Only a customer can cancel their own booking.");
|
||||
|
||||
var reason = string.IsNullOrWhiteSpace(request.ReasonNotes)
|
||||
? request.ReasonCategory
|
||||
: $"{request.ReasonCategory}: {request.ReasonNotes}";
|
||||
|
||||
// 1) Cancel the booking — resolves + freezes the customer cancellation policy snapshot (owner-scoped
|
||||
// inside the handler; a foreign booking is a clean 404, a terminal one a 409).
|
||||
var cancel = await sender.Send(new CancelBookingCommand(reason, request.BookingId), cancellationToken);
|
||||
if (!cancel.IsSuccess)
|
||||
return Propagate(cancel);
|
||||
|
||||
// 2) Open the refund off the just-frozen snapshot (decomposes both fee legs, posts the reversal,
|
||||
// auto-opens a refund ticket). The customer never sets the money — the snapshot drives it.
|
||||
var refund = await sender.Send(
|
||||
new CreateRefundCommand(
|
||||
BookingId: request.BookingId,
|
||||
TicketId: null,
|
||||
RefundPercentage: null,
|
||||
PlatformFeeRefundedIrr: null,
|
||||
NursePayoutRefundedIrr: null,
|
||||
ReasonCategory: request.ReasonCategory,
|
||||
ReasonNotes: request.ReasonNotes,
|
||||
AdminNotes: null,
|
||||
ManualBankReference: null),
|
||||
cancellationToken);
|
||||
if (!refund.IsSuccess)
|
||||
return Propagate(refund);
|
||||
|
||||
// Return the full customer status (incl. the decomposition + policy snapshot) — owner-scoped read.
|
||||
return await sender.Send(new GetRefundStatusQuery(refund.Result.RefundId), cancellationToken);
|
||||
}
|
||||
|
||||
private static OperationResult<RefundStatusDto> Propagate<T>(OperationResult<T> failed)
|
||||
{
|
||||
var message = failed.ErrorMessages.Count > 0 ? failed.ErrorMessages[0].Value : "The cancellation could not be completed.";
|
||||
if (failed.IsNotFound)
|
||||
return OperationResult<RefundStatusDto>.NotFoundResult(message);
|
||||
if (failed.IsConflict)
|
||||
return OperationResult<RefundStatusDto>.ConflictResult(message);
|
||||
if (failed.IsForbidden)
|
||||
return OperationResult<RefundStatusDto>.ForbiddenResult(message);
|
||||
if (failed.IsUnauthorized)
|
||||
return OperationResult<RefundStatusDto>.UnauthorizedResult(message);
|
||||
return OperationResult<RefundStatusDto>.FailureResult(message);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Refunds;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Commands.CancelBookingAndRefund;
|
||||
|
||||
/// <summary>
|
||||
/// The customer-initiated cancel: cancels the booking (freezing the resolved cancellation policy snapshot)
|
||||
/// and opens the resulting refund in one call, returning the refund status. The customer *requests*; an
|
||||
/// admin/ticket step still governs the money (the refund auto-opens a <c>refund</c> ticket). MVP cancels all
|
||||
/// un-started sessions (the whole remaining engagement); <see cref="SessionIds"/> is accepted for
|
||||
/// forward-compatibility. The booking id comes from the route.
|
||||
/// </summary>
|
||||
public record CancelBookingAndRefundCommand(
|
||||
string ReasonCategory,
|
||||
string ReasonNotes = null,
|
||||
IReadOnlyList<long> SessionIds = null,
|
||||
long BookingId = 0) : IRequest<OperationResult<RefundStatusDto>>;
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
#nullable enable
|
||||
using System.Globalization;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Bookings;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Refunds;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Payments;
|
||||
using Baya.Domain.Entities.Refunds;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Queries.GetCancellationPolicyPreview;
|
||||
|
||||
internal sealed class GetCancellationPolicyPreviewQueryHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<GetCancellationPolicyPreviewQuery, OperationResult<CancellationPolicyPreviewDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<CancellationPolicyPreviewDto>> Handle(GetCancellationPolicyPreviewQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<CancellationPolicyPreviewDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<CancellationPolicyPreviewDto>.ForbiddenResult("Only a customer can preview their own cancellation.");
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
var booking = await unitOfWork.BookingRepository.GetTrackedWithSessionsAsync(request.BookingId, cancellationToken);
|
||||
if (booking is null || customerId is not { } cid || booking.CustomerId != cid)
|
||||
return OperationResult<CancellationPolicyPreviewDto>.NotFoundResult("Booking not found.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
var cancellable = booking.CanTransitionTo(BookingStatus.Cancelled);
|
||||
|
||||
var grossShares = BookingAmounts.SplitPayout(booking.GrossPriceIrr, booking.SessionCount);
|
||||
long refundableBase = 0;
|
||||
var sessions = booking.Sessions
|
||||
.OrderBy(s => s.SessionIndex)
|
||||
.Select(s =>
|
||||
{
|
||||
var refundable = s.Status == BookingSessionStatus.Scheduled;
|
||||
if (refundable)
|
||||
refundableBase += grossShares[s.SessionIndex - 1];
|
||||
return new CancellationPreviewSessionDto(
|
||||
s.Id, s.SessionIndex, s.ScheduledDate, refundable, refundable ? "un_started" : s.Status);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var policies = await unitOfWork.CancellationPolicyRepository.GetActiveForActorAsync(CancellationActor.Customer, cancellationToken);
|
||||
var hoursBefore = CancellationHelper.HoursBeforeStart(booking.ScheduledDate, booking.ScheduledTimeStart, now);
|
||||
var policy = CancellationHelper.ResolvePolicy(policies, hoursBefore);
|
||||
|
||||
var refundAmount = policy is null ? 0 : CancellationHelper.Refundable(refundableBase, policy.RefundPercentage);
|
||||
var feeAmount = refundableBase - refundAmount;
|
||||
|
||||
// Split the refunded amount across the booking's frozen fee legs pro-rata (never client-derived).
|
||||
long platformFeeRefunded = 0;
|
||||
if (refundAmount > 0 && booking.GrossPriceIrr > 0)
|
||||
platformFeeRefunded = (long)decimal.Round(
|
||||
(decimal)refundAmount * booking.BalinyaarCommissionIrr / booking.GrossPriceIrr, MidpointRounding.AwayFromZero);
|
||||
var nursePayoutRefunded = refundAmount - platformFeeRefunded;
|
||||
|
||||
var channelContext = await unitOfWork.RefundRepository.GetRefundContextAsync(request.BookingId, cancellationToken);
|
||||
var channel = channelContext?.GatewayType == PaymentGatewayType.Bnpl ? RefundChannel.BnplRevert : RefundChannel.PspCard;
|
||||
|
||||
var dto = new CancellationPolicyPreviewDto(
|
||||
booking.Id,
|
||||
cancellable,
|
||||
policy?.Code,
|
||||
policy?.RefundPercentage,
|
||||
policy is null ? null : 100m - policy.RefundPercentage,
|
||||
Str(refundAmount),
|
||||
Str(feeAmount),
|
||||
Str(refundableBase),
|
||||
Str(platformFeeRefunded),
|
||||
Str(nursePayoutRefunded),
|
||||
CancellationActor.Customer,
|
||||
hoursBefore >= 24 ? "at_least_24h" : "less_than_24h",
|
||||
channel,
|
||||
// The BNPL ~7–10-business-day customer ETA is stamped on the actual refund; the preview leaves it null.
|
||||
null,
|
||||
sessions);
|
||||
|
||||
return OperationResult<CancellationPolicyPreviewDto>.SuccessResult(dto);
|
||||
}
|
||||
|
||||
private static string Str(long value) => value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Refunds;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Queries.GetCancellationPolicyPreview;
|
||||
|
||||
/// <summary>Owner-scoped pre-cancel preview: resolves the applicable customer policy by current lead time and
|
||||
/// the per-session refundability, without mutating anything. The booking id comes from the route.</summary>
|
||||
public record GetCancellationPolicyPreviewQuery(long BookingId) : IRequest<OperationResult<CancellationPolicyPreviewDto>>;
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Refunds;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Queries.GetRefundByBooking;
|
||||
|
||||
internal sealed class GetRefundByBookingQueryHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
ICurrentUser currentUser)
|
||||
: IRequestHandler<GetRefundByBookingQuery, OperationResult<RefundStatusDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<RefundStatusDto>> Handle(GetRefundByBookingQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var projection = await unitOfWork.RefundRepository.GetStatusByBookingAsync(request.BookingId, cancellationToken);
|
||||
|
||||
// Cross-customer access is indistinguishable from "no refund" — never confirm existence.
|
||||
if (projection is null || projection.CustomerUserId != currentUser.UserId)
|
||||
return OperationResult<RefundStatusDto>.NotFoundResult("Refund not found.");
|
||||
|
||||
return OperationResult<RefundStatusDto>.SuccessResult(projection.Refund);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Refunds;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Refunds.Queries.GetRefundByBooking;
|
||||
|
||||
/// <summary>Reaches the customer's own refund from its booking id (owner-scoped). 404 when the booking has no
|
||||
/// refund — the customer holds the booking id, not the refund id.</summary>
|
||||
public record GetRefundByBookingQuery(long BookingId) : IRequest<OperationResult<RefundStatusDto>>;
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Queries.GetMyReview;
|
||||
|
||||
internal sealed class GetMyReviewQueryHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetMyReviewQuery, OperationResult<MyReviewDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<MyReviewDto>> Handle(GetMyReviewQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<MyReviewDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var projection = await unitOfWork.ReviewRepository.GetMyReviewForBookingAsync(request.BookingId, cancellationToken);
|
||||
|
||||
// No review yet → a clean "none" state (only for a booking the caller could own). A review owned by a
|
||||
// different customer is indistinguishable from "none" — never leak another customer's review.
|
||||
if (projection is null || projection.CustomerUserId != userId)
|
||||
return OperationResult<MyReviewDto>.SuccessResult(new MyReviewDto(MyReviewDto.StatusNone, null, null, [], null));
|
||||
|
||||
return OperationResult<MyReviewDto>.SuccessResult(projection.Review);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Queries.GetMyReview;
|
||||
|
||||
/// <summary>Owner-scoped: the caller's own review for a booking (its persistent moderation state), or a
|
||||
/// <c>none</c> status when they have not reviewed it. The booking id comes from the route.</summary>
|
||||
public record GetMyReviewQuery(long BookingId) : IRequest<OperationResult<MyReviewDto>>;
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Queries.GetReviewEligibility;
|
||||
|
||||
internal sealed class GetReviewEligibilityQueryHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetReviewEligibilityQuery, OperationResult<ReviewEligibilityDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<ReviewEligibilityDto>> Handle(GetReviewEligibilityQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<ReviewEligibilityDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
var booking = await unitOfWork.ReviewRepository.GetReviewableBookingAsync(request.BookingId, cancellationToken);
|
||||
|
||||
if (booking is null)
|
||||
return Result(false, ReviewEligibilityDto.ReasonNotFound);
|
||||
if (customerId is not { } cid || booking.CustomerProfileId != cid)
|
||||
return Result(false, ReviewEligibilityDto.ReasonNotOwner);
|
||||
if (booking.Status is not (BookingStatus.Completed or BookingStatus.Closed))
|
||||
return Result(false, ReviewEligibilityDto.ReasonNotCompleted);
|
||||
if (await unitOfWork.ReviewRepository.ExistsForBookingAsync(request.BookingId, cancellationToken))
|
||||
return Result(false, ReviewEligibilityDto.ReasonAlreadyReviewed);
|
||||
|
||||
return Result(true, null);
|
||||
}
|
||||
|
||||
private static OperationResult<ReviewEligibilityDto> Result(bool canReview, string? reason)
|
||||
=> OperationResult<ReviewEligibilityDto>.SuccessResult(new ReviewEligibilityDto(canReview, reason));
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Queries.GetReviewEligibility;
|
||||
|
||||
/// <summary>Owner-scoped: can the caller review this booking? Returns a stable reason when not, so the CTA/form
|
||||
/// gates without probing the 1:1 conflict. The booking id comes from the route.</summary>
|
||||
public record GetReviewEligibilityQuery(long BookingId) : IRequest<OperationResult<ReviewEligibilityDto>>;
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
#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.Verification;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Verification.Commands.SubmitCredentialDetails;
|
||||
|
||||
internal sealed class SubmitCredentialDetailsCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<SubmitCredentialDetailsCommand, OperationResult<VerificationStatusDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<VerificationStatusDto>> Handle(SubmitCredentialDetailsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<VerificationStatusDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<VerificationStatusDto>.ForbiddenResult("Only a nurse can submit credential details.");
|
||||
|
||||
var profile = await unitOfWork.NurseProfileRepository.GetByUserIdAsync(userId, cancellationToken);
|
||||
if (profile is null)
|
||||
return OperationResult<VerificationStatusDto>.NotFoundResult("No nurse profile exists yet.");
|
||||
|
||||
var nurseId = profile.Id;
|
||||
var holderName = string.IsNullOrWhiteSpace(request.HolderName)
|
||||
? await unitOfWork.VerificationRepository.GetNurseIdentityNameAsync(nurseId, cancellationToken) ?? string.Empty
|
||||
: request.HolderName;
|
||||
|
||||
// Specialties are the nurse's professional facets — persisted on the profile (they feed the public
|
||||
// profile + search facets), not on the credential registry.
|
||||
if (request.Specialties is not null)
|
||||
profile.SpecializationsJson = JsonCodeList.Serialize(request.Specialties) ?? "[]";
|
||||
|
||||
await UpsertCredentialAsync(nurseId, CredentialTypes.InoMembership, request.InoNumber, holderName, request, cancellationToken);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.LicenseNumber))
|
||||
await UpsertCredentialAsync(nurseId, CredentialTypes.MohCompetencyLicense, request.LicenseNumber, holderName, request, cancellationToken);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
var status = await unitOfWork.VerificationRepository.GetStatusForNurseAsync(nurseId, cancellationToken);
|
||||
return status is null
|
||||
? OperationResult<VerificationStatusDto>.NotFoundResult("No verification exists yet.")
|
||||
: OperationResult<VerificationStatusDto>.SuccessResult(status);
|
||||
}
|
||||
|
||||
private async Task UpsertCredentialAsync(
|
||||
long nurseId, string credentialType, string number, string holderName,
|
||||
SubmitCredentialDetailsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await unitOfWork.VerificationRepository.GetTrackedCredentialAsync(nurseId, credentialType, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
await unitOfWork.VerificationRepository.AddCredentialAsync(new NurseCredential
|
||||
{
|
||||
NurseId = nurseId,
|
||||
CredentialType = credentialType,
|
||||
CredentialNumber = number,
|
||||
HolderNameSnapshot = holderName,
|
||||
IssuingAuthority = request.IssuingAuthority ?? string.Empty,
|
||||
IssuedAt = request.IssuedAt,
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
VerificationMethod = VerificationMethods.Manual
|
||||
}, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// The nurse resubmitted — refresh the structured details; admin verification is unchanged.
|
||||
existing.CredentialNumber = number;
|
||||
existing.HolderNameSnapshot = holderName;
|
||||
if (request.IssuingAuthority is not null)
|
||||
existing.IssuingAuthority = request.IssuingAuthority;
|
||||
if (request.IssuedAt is not null)
|
||||
existing.IssuedAt = request.IssuedAt;
|
||||
if (request.ExpiresAt is not null)
|
||||
existing.ExpiresAt = request.ExpiresAt;
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Verification.Commands.SubmitCredentialDetails;
|
||||
|
||||
public sealed class SubmitCredentialDetailsCommandValidator : AbstractValidator<SubmitCredentialDetailsCommand>
|
||||
{
|
||||
public SubmitCredentialDetailsCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.InoNumber).NotEmpty().MaximumLength(50);
|
||||
RuleFor(x => x.LicenseNumber).MaximumLength(50);
|
||||
RuleFor(x => x.IssuingAuthority).MaximumLength(150);
|
||||
RuleFor(x => x.HolderName).MaximumLength(200);
|
||||
RuleFor(x => x.Specialties)
|
||||
.Must(s => s is null || s.All(c => !string.IsNullOrWhiteSpace(c) && c.Length <= 50))
|
||||
.WithMessage("Each specialty must be a non-empty code up to 50 characters.");
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Verification;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Verification.Commands.SubmitCredentialDetails;
|
||||
|
||||
/// <summary>
|
||||
/// The nurse-facing capture of the <b>structured</b> credential fields collected alongside the document
|
||||
/// uploads (B5): the INO (nursing-system) number, professional specialties, and optional
|
||||
/// license/authority/holder/date details. Without this the real path silently drops the nurse's INO number
|
||||
/// and specialties until an admin re-enters them. The submitted credential is <b>unverified</b> — an admin
|
||||
/// still decides it; the specialties feed the nurse's public profile/search facets.
|
||||
/// </summary>
|
||||
public record SubmitCredentialDetailsCommand(
|
||||
string InoNumber,
|
||||
IReadOnlyList<string> Specialties,
|
||||
string LicenseNumber = null,
|
||||
string IssuingAuthority = null,
|
||||
string HolderName = null,
|
||||
DateOnly? IssuedAt = null,
|
||||
DateOnly? ExpiresAt = null) : IRequest<OperationResult<VerificationStatusDto>>;
|
||||
@@ -9,6 +9,7 @@ namespace Baya.Application.Models.Addresses;
|
||||
public record CustomerAddressDto(
|
||||
long Id,
|
||||
string Title,
|
||||
long ProvinceId,
|
||||
long CityId,
|
||||
string CityNameFa,
|
||||
string CityNameEn,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json.Serialization;
|
||||
using Baya.SharedKernel.Extensions;
|
||||
|
||||
namespace Baya.Application.Models.ApiResult;
|
||||
@@ -11,6 +12,11 @@ public class ApiResult
|
||||
public string Message { get; set; }
|
||||
public string RequestId { get; }
|
||||
|
||||
/// <summary>Optional stable machine-readable error code (e.g. <c>otp_locked</c>) for a failure the client
|
||||
/// must branch on. Omitted from the wire when null, so success/normal responses are unchanged.</summary>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string Code { get; set; }
|
||||
|
||||
public ApiResult(bool isSuccess, ApiResultStatusCode statusCode, string message = null)
|
||||
{
|
||||
IsSuccess = isSuccess;
|
||||
|
||||
@@ -41,4 +41,7 @@ public record BookingRequestDetailProjection(
|
||||
DateTime NurseResponseDeadlineAt,
|
||||
DateTime? PaymentDeadlineAt,
|
||||
string? NurseRejectionReason,
|
||||
DateTimeOffset CreatedAt);
|
||||
DateTimeOffset CreatedAt,
|
||||
long VariantPrice,
|
||||
string? NurseAvatarUrl,
|
||||
long? BookingId);
|
||||
|
||||
@@ -39,4 +39,11 @@ public record BookingRequestDto(
|
||||
DateTime NurseResponseDeadlineAt,
|
||||
DateTime? PaymentDeadlineAt,
|
||||
string? NurseRejectionReason,
|
||||
DateTimeOffset CreatedAt);
|
||||
DateTimeOffset CreatedAt,
|
||||
// The chosen variant's display rate (IRR digit-string) — for the summary card. This is the variant's
|
||||
// rate, NOT an engagement total: a booking_request stays money-free.
|
||||
string VariantPrice,
|
||||
string? NurseAvatarUrl,
|
||||
// The booking created once this request is converted (paid); null until then. Lets the confirmation
|
||||
// deep-link the booking + invoice.
|
||||
long? BookingId);
|
||||
|
||||
@@ -18,4 +18,8 @@ public record BookingRequestListItemDto(
|
||||
TimeOnly RequestedTimeEnd,
|
||||
DateTime NurseResponseDeadlineAt,
|
||||
DateTime? PaymentDeadlineAt,
|
||||
string? CustomerNotes);
|
||||
string? CustomerNotes,
|
||||
// The requested service variant — makes the inbox row self-describing without opening the detail.
|
||||
string VariantLabel,
|
||||
// Coarse patient age for triage (nurse inbox); null when the birth date is unset.
|
||||
int? PatientAge);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Models.Booking;
|
||||
|
||||
/// <summary>The owner-scoped facts the checkout-summary handler needs: participant labels, the schedule,
|
||||
/// the frozen payment window, and the variant price + session count to compute the money split.</summary>
|
||||
public record CheckoutContext(
|
||||
long Id,
|
||||
string Status,
|
||||
string NurseName,
|
||||
string PatientName,
|
||||
string VariantLabel,
|
||||
string VariantPriceUnit,
|
||||
int? SessionCount,
|
||||
long VariantPrice,
|
||||
DateOnly RequestedDate,
|
||||
TimeOnly RequestedTimeStart,
|
||||
TimeOnly RequestedTimeEnd,
|
||||
DateTime? PaymentDeadlineAt);
|
||||
@@ -0,0 +1,32 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Models.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// The served money breakdown for the C6 checkout of an <c>accepted_awaiting_payment</c> request. All money
|
||||
/// is IRR digit-strings, computed server-side from config (the client renders, never derives). The display
|
||||
/// decomposition reconciles to the captured total: <c>serviceCostIrr + commissionIrr + vatIrr = totalIrr</c>.
|
||||
/// VAT is <b>carved out of the platform commission</b> (VAT-on-commission-only), so <c>commissionIrr</c> is
|
||||
/// the commission <i>net of VAT</i> and the customer's total equals <c>grossPriceIrr</c> — the amount
|
||||
/// captured. The raw b10 amounts (<c>grossPriceIrr</c>/<c>balinyaarCommissionIrr</c>/<c>nursePayoutAmount</c>)
|
||||
/// are surfaced alongside for reference: <c>grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount</c>.
|
||||
/// </summary>
|
||||
public record CheckoutSummaryDto(
|
||||
long BookingRequestId,
|
||||
string RequestStatus,
|
||||
string NurseName,
|
||||
string PatientName,
|
||||
string VariantLabel,
|
||||
string VariantPriceUnit,
|
||||
int? SessionCount,
|
||||
DateOnly RequestedDate,
|
||||
TimeOnly RequestedTimeStart,
|
||||
TimeOnly RequestedTimeEnd,
|
||||
DateTime? PaymentDeadlineAt,
|
||||
string ServiceCostIrr,
|
||||
string CommissionIrr,
|
||||
string VatIrr,
|
||||
decimal VatRate,
|
||||
string TotalIrr,
|
||||
string GrossPriceIrr,
|
||||
string BalinyaarCommissionIrr,
|
||||
string NursePayoutAmount);
|
||||
@@ -30,6 +30,14 @@ public class OperationResult<TResult> : IOperationResult
|
||||
/// (backend-phase-4). Distinct from a validation 400 so callers can react to the collision.</summary>
|
||||
public bool IsConflict { get; set; }
|
||||
|
||||
/// <summary>Optional stable machine-readable error code (e.g. <c>otp_locked</c>) surfaced on the failure
|
||||
/// envelope so the client can branch on the exact condition. Null for ordinary failures.</summary>
|
||||
public string ErrorCode { get; set; }
|
||||
|
||||
/// <summary>Optional structured payload that accompanies <see cref="ErrorCode"/> (e.g. a lockout's
|
||||
/// <c>retryAfterSeconds</c>). Serialized under the envelope's <c>data</c> when present.</summary>
|
||||
public object ErrorData { get; set; }
|
||||
|
||||
public static OperationResult<TResult> SuccessResult(TResult result)
|
||||
{
|
||||
return new OperationResult<TResult> { Result = result, IsSuccess = true };
|
||||
@@ -87,6 +95,18 @@ public class OperationResult<TResult> : IOperationResult
|
||||
return operationResult;
|
||||
}
|
||||
|
||||
/// <summary>A validation-style failure (HTTP 400) that also carries a stable machine <paramref name="code"/>
|
||||
/// (and optional structured <paramref name="data"/>) on the envelope — for a condition the client must
|
||||
/// distinguish, e.g. an OTP lockout.</summary>
|
||||
public static OperationResult<TResult> CodedFailureResult(string code, string message, object data = null)
|
||||
{
|
||||
var operationResult = new OperationResult<TResult> { IsSuccess = false, ErrorCode = code, ErrorData = data };
|
||||
|
||||
operationResult.ErrorMessages.Add(new("GeneralError", message));
|
||||
|
||||
return operationResult;
|
||||
}
|
||||
|
||||
public void AddError(string propertyName, string message)
|
||||
{
|
||||
IsSuccess = false;
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
namespace Baya.Application.Models.Configuration;
|
||||
|
||||
/// <summary>A runtime config row as returned to admins. <c>Value</c> is the raw string; parse per <c>DataType</c>.</summary>
|
||||
public record PlatformConfigDto(string Key, string Value, string DataType, string? Description);
|
||||
public record PlatformConfigDto(
|
||||
string Key,
|
||||
string Value,
|
||||
string DataType,
|
||||
string? Description,
|
||||
// Last-changed meta for the editor row (from the entity's audit fields; falls back to creation).
|
||||
System.DateTimeOffset UpdatedAt,
|
||||
int? UpdatedBy);
|
||||
|
||||
/// <summary>One audited change to a config key (from the append-only audit trail).</summary>
|
||||
public record ConfigChangeDto(
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Baya.Application.Models.Identity;
|
||||
|
||||
/// <summary>The stored, retrievable URL of an uploaded avatar. Also persisted on the owning profile.</summary>
|
||||
public record AvatarUploadResult(string Url);
|
||||
@@ -7,4 +7,6 @@ namespace Baya.Application.Models.Identity;
|
||||
public record CustomerProfileDto(
|
||||
long Id,
|
||||
string DefaultEmergencyContactName,
|
||||
string DefaultEmergencyContactPhone);
|
||||
string DefaultEmergencyContactPhone,
|
||||
string AvatarUrl,
|
||||
string PreferredLanguage);
|
||||
|
||||
@@ -15,4 +15,5 @@ public record NurseProfileDto(
|
||||
bool IsAcceptingBookings,
|
||||
decimal AverageRating,
|
||||
int TotalReviews,
|
||||
int TotalCompletedBookings);
|
||||
int TotalCompletedBookings,
|
||||
string AvatarUrl);
|
||||
|
||||
@@ -2,7 +2,8 @@ namespace Baya.Application.Models.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// A care recipient owned by the signed-in customer. <c>InitialMedicalNotes</c> is decrypted and
|
||||
/// returned only to the owning customer.
|
||||
/// returned only to the owning customer. <c>Relation</c> is a stable code (nullable);
|
||||
/// <c>Conditions</c> is the patient's care-condition code list (empty, never null).
|
||||
/// </summary>
|
||||
public record PatientDto(
|
||||
long Id,
|
||||
@@ -13,4 +14,6 @@ public record PatientDto(
|
||||
string Gender,
|
||||
string BloodType,
|
||||
string InitialMedicalNotes,
|
||||
bool IsActive);
|
||||
bool IsActive,
|
||||
string Relation,
|
||||
IReadOnlyList<string> Conditions);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user