refinement phase 3

This commit is contained in:
hamid
2026-07-13 11:26:39 +03:30
parent 1ce36f9414
commit 314763f764
194 changed files with 24211 additions and 274 deletions
@@ -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(
@@ -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,
@@ -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.");
}
}
@@ -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>>;
@@ -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,
@@ -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.");
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>>;
@@ -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);
}
@@ -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(
@@ -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.");
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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);
}
@@ -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);
}
@@ -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>>;
@@ -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)));
}
}
@@ -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.");
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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)));
}
}
@@ -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.");
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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>>;
@@ -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,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>>;
@@ -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,
@@ -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));
}
}
@@ -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>>;
@@ -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));
@@ -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);
}
}
@@ -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>>>;
@@ -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);
}
}
@@ -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>>;
@@ -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!);
}
}
@@ -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 (&gt; 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();
}
}
@@ -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));
}
}
@@ -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>>;
@@ -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
};
@@ -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));
}
}
@@ -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)));
}
}
@@ -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>>;
@@ -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 [];
}
}
}
@@ -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);
}
}
@@ -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>>;
@@ -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);
}
@@ -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));
}
}
@@ -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>>>;
@@ -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)));
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>;
@@ -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 ~710-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);
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>;
@@ -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));
}
@@ -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>>;
@@ -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;
}
}
@@ -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.");
}
}
@@ -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