@
backend phase 3: identity profiles, patients & nurse bank accounts Add the role-attached identity layer on top of the b2 auth spine: nurse seller profiles (guarded is_verified, read-only aggregates), thin customer payer profiles, first-class patients (tenancy-scoped), and nurse payout bank accounts hardened with an iban_hash uniqueness guard and an automated استعلام شبا IBAN-ownership inquiry. - Four usr tables via one migration (1:1 uniques, UNIQUE(iban_hash), filtered UNIQUE(nurse_id) WHERE is_primary=1, guarded is_verified, encrypted PII, soft-delete on nurse_profiles) - 15 CQRS slices + 4 role-scoped controllers; reads projected + paginated, IBAN masked (last-4); ownership-inquiry endpoints rate-limited - New IBankAccountOwnershipVerifier seam (mock deterministic شبا match) + per-domain repositories on IUnitOfWork + encrypted-PII value converters - Activate FluentValidation repo-wide (validators were never registered) - Handler unit tests + WebApplicationFactory integration tests (76 pass); contract identity-profiles.md + swagger snapshot; docs, handoff & report Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> @
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
namespace Baya.Application.Common;
|
||||
|
||||
/// <summary>Wire-masking for sensitive-but-displayable identifiers.</summary>
|
||||
public static class Mask
|
||||
{
|
||||
private const string MaskPrefix = "••••";
|
||||
|
||||
/// <summary>Masks an IBAN to its last four characters (e.g. <c>••••3456</c>) so lists never carry the
|
||||
/// full value. Returns the input unchanged when it is null/empty or already ≤4 chars.</summary>
|
||||
public static string IbanTail(string iban)
|
||||
=> string.IsNullOrEmpty(iban) || iban.Length <= 4 ? iban : MaskPrefix + iban[^4..];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Seam for the استعلام شبا IBAN-owner ↔ national-id inquiry — the automated check that the bank account
|
||||
/// an IBAN belongs to is registered to the same national id as the nurse. This replaces the forgeable
|
||||
/// "an admin eyeballs the IBAN" step and is the money-mule-prevention gate for the first payout (b13).
|
||||
/// The mock returns a deterministic fake match; the real implementation calls a Finnotech/banking-bridge
|
||||
/// vendor. No money moves through this seam.
|
||||
/// </summary>
|
||||
public interface IBankAccountOwnershipVerifier
|
||||
{
|
||||
/// <summary>Runs the ownership inquiry for the given IBAN against the nurse's national id.</summary>
|
||||
Task<OwnershipInquiryResult> VerifyOwnershipAsync(string iban, string? nurseNationalId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of an <see cref="IBankAccountOwnershipVerifier"/> inquiry.
|
||||
/// </summary>
|
||||
/// <param name="MatchedNationalId">Whether the IBAN owner's national id matches the nurse's.</param>
|
||||
/// <param name="AccountHolderFromBank">The account-holder name the bank returned (snapshot).</param>
|
||||
/// <param name="VendorRef">The vendor transaction id, kept for audit.</param>
|
||||
public readonly record struct OwnershipInquiryResult(bool MatchedNationalId, string AccountHolderFromBank, string VendorRef);
|
||||
@@ -0,0 +1,21 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
public interface ICustomerProfileRepository
|
||||
{
|
||||
/// <summary>Tracked lookup of the customer's profile by owning user id (for upsert).</summary>
|
||||
Task<CustomerProfile?> GetByUserIdAsync(int userId, CancellationToken cancellationToken);
|
||||
|
||||
Task AddAsync(CustomerProfile profile, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>No-tracking projection of the signed-in customer's profile (emergency contact decrypted,
|
||||
/// full, for the owner).</summary>
|
||||
Task<CustomerProfileDto?> GetMineAsync(int userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The customer's <c>customer_profiles.id</c> from their user id — the tenancy anchor for
|
||||
/// patient operations. NULL when the user has no customer profile yet.</summary>
|
||||
Task<long?> GetProfileIdByUserIdAsync(int userId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
public interface INurseBankAccountRepository
|
||||
{
|
||||
Task AddAsync(NurseBankAccount account, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked, tenancy-scoped lookup — returns the account only if it belongs to
|
||||
/// <paramref name="nurseId"/>, else null.</summary>
|
||||
Task<NurseBankAccount?> GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Atomically makes <paramref name="accountId"/> the nurse's primary account and clears any
|
||||
/// prior primary, in a single transaction (clear-then-set order) so the filtered
|
||||
/// <c>UNIQUE(nurse_id) WHERE is_primary = 1</c> index never trips. The account must already be owned
|
||||
/// by the nurse — the caller verifies tenancy first.</summary>
|
||||
Task SetPrimaryAsync(long nurseId, long accountId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Whether any account already carries this deterministic IBAN hash — the clean duplicate
|
||||
/// guard (the <c>UNIQUE(iban_hash)</c> index is the authoritative backstop).</summary>
|
||||
Task<bool> IbanHashExistsAsync(string ibanHash, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Whether the nurse already has at least one account (decides whether a new one defaults to
|
||||
/// primary).</summary>
|
||||
Task<bool> HasAnyAsync(long nurseId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>No-tracking projection of the nurse's accounts with the IBAN masked (last 4 only).</summary>
|
||||
Task<IReadOnlyList<NurseBankAccountDto>> ListAsync(long nurseId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
public interface INurseProfileRepository
|
||||
{
|
||||
/// <summary>Tracked lookup of the nurse's profile by owning user id (for upsert/toggle).</summary>
|
||||
Task<NurseProfile?> GetByUserIdAsync(int userId, CancellationToken cancellationToken);
|
||||
|
||||
Task AddAsync(NurseProfile profile, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>No-tracking projection of the signed-in nurse's profile, incl. read-only verified flag
|
||||
/// and aggregates.</summary>
|
||||
Task<NurseProfileDto?> GetMineAsync(int userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The nurse's <c>nurse_profiles.id</c> from their user id — the tenancy anchor for
|
||||
/// bank-account operations. NULL when the user has no nurse profile yet.</summary>
|
||||
Task<long?> GetProfileIdByUserIdAsync(int userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The nurse's <c>nurse_profiles.id</c> + decrypted national id — what the bank-account
|
||||
/// ownership inquiry needs. NULL when the user has no nurse profile yet.</summary>
|
||||
Task<NurseIdentityContext?> GetIdentityContextByUserIdAsync(int userId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
public interface IPatientRepository
|
||||
{
|
||||
Task AddAsync(Patient patient, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked, tenancy-scoped lookup — returns the patient only if it belongs to
|
||||
/// <paramref name="customerId"/>, else null (so callers surface a not-found, never a cross-tenant
|
||||
/// mutation).</summary>
|
||||
Task<Patient?> GetOwnedAsync(long id, long customerId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Paginated, no-tracking projection of the customer's own patients.</summary>
|
||||
Task<PagedResult<PatientDto>> ListAsync(long customerId, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>No-tracking, tenancy-scoped projection of a single owned patient; null if not owned.</summary>
|
||||
Task<PatientDto?> GetOwnedProjectedAsync(long id, long customerId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -5,6 +5,10 @@ public interface IUnitOfWork
|
||||
public IUserRefreshTokenRepository UserRefreshTokenRepository { get; }
|
||||
public IUserSessionRepository UserSessionRepository { get; }
|
||||
public IUserAccountRepository UserAccountRepository { get; }
|
||||
public INurseProfileRepository NurseProfileRepository { get; }
|
||||
public ICustomerProfileRepository CustomerProfileRepository { get; }
|
||||
public IPatientRepository PatientRepository { get; }
|
||||
public INurseBankAccountRepository NurseBankAccountRepository { get; }
|
||||
Task CommitAsync();
|
||||
ValueTask RollBackAsync();
|
||||
}
|
||||
}
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
#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.AddNurseBankAccount;
|
||||
|
||||
internal sealed class AddNurseBankAccountCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IFieldEncryptor fieldEncryptor,
|
||||
IBankAccountOwnershipVerifier ownershipVerifier)
|
||||
: IRequestHandler<AddNurseBankAccountCommand, OperationResult<NurseBankAccountDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<NurseBankAccountDto>> Handle(AddNurseBankAccountCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<NurseBankAccountDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<NurseBankAccountDto>.ForbiddenResult("Only a nurse can add a payout account.");
|
||||
|
||||
var iban = Sheba.Normalize(request.Iban);
|
||||
if (iban is null)
|
||||
return OperationResult<NurseBankAccountDto>.FailureResult(nameof(request.Iban), "A valid Iranian IBAN (شبا) is required.");
|
||||
|
||||
var context = await unitOfWork.NurseProfileRepository.GetIdentityContextByUserIdAsync(userId, cancellationToken);
|
||||
if (context is null)
|
||||
return OperationResult<NurseBankAccountDto>.FailureResult("No nurse profile exists yet. Create your profile first.");
|
||||
|
||||
// Deterministic hash is the duplicate guard — the UNIQUE(iban_hash) index is the DB backstop.
|
||||
var ibanHash = fieldEncryptor.Hash(iban);
|
||||
if (await unitOfWork.NurseBankAccountRepository.IbanHashExistsAsync(ibanHash, cancellationToken))
|
||||
return OperationResult<NurseBankAccountDto>.FailureResult(nameof(request.Iban), "This IBAN is already registered.");
|
||||
|
||||
var isFirst = !await unitOfWork.NurseBankAccountRepository.HasAnyAsync(context.NurseProfileId, cancellationToken);
|
||||
|
||||
var account = new NurseBankAccount
|
||||
{
|
||||
NurseId = context.NurseProfileId,
|
||||
BankName = request.BankName,
|
||||
AccountHolderName = request.AccountHolderName,
|
||||
Iban = iban,
|
||||
IbanHash = ibanHash,
|
||||
IsPrimary = isFirst
|
||||
};
|
||||
|
||||
var inquiry = await ownershipVerifier.VerifyOwnershipAsync(iban, context.NationalId, cancellationToken);
|
||||
account.ApplyOwnershipInquiry(inquiry.MatchedNationalId, inquiry.AccountHolderFromBank, inquiry.VendorRef);
|
||||
|
||||
await unitOfWork.NurseBankAccountRepository.AddAsync(account, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<NurseBankAccountDto>.SuccessResult(new NurseBankAccountDto(
|
||||
account.Id,
|
||||
account.BankName,
|
||||
Mask.IbanTail(iban),
|
||||
account.IsPrimary,
|
||||
account.IsVerified,
|
||||
account.MatchedNationalId));
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.AddNurseBankAccount;
|
||||
|
||||
public sealed class AddNurseBankAccountCommandValidator : AbstractValidator<AddNurseBankAccountCommand>
|
||||
{
|
||||
public AddNurseBankAccountCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.BankName).NotEmpty().MaximumLength(100);
|
||||
RuleFor(x => x.AccountHolderName).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.Iban)
|
||||
.NotEmpty()
|
||||
.Must(Sheba.IsValid)
|
||||
.WithMessage("A valid Iranian IBAN (شبا) is required: IR followed by 24 digits.");
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.AddNurseBankAccount;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a payout account for the signed-in nurse. The IBAN and account-holder name are encrypted at rest;
|
||||
/// a deterministic <c>iban_hash</c> guards against duplicates and the استعلام شبا ownership inquiry runs
|
||||
/// immediately. If the nurse has no other account this one becomes primary.
|
||||
/// </summary>
|
||||
public record AddNurseBankAccountCommand(
|
||||
string BankName,
|
||||
string AccountHolderName,
|
||||
string Iban) : IRequest<OperationResult<NurseBankAccountDto>>;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.ArchivePatient;
|
||||
|
||||
internal sealed class ArchivePatientCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<ArchivePatientCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(ArchivePatientCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<bool>.ForbiddenResult("Only a customer can manage patients.");
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is not { } cid)
|
||||
return OperationResult<bool>.NotFoundResult("Patient not found.");
|
||||
|
||||
var patient = await unitOfWork.PatientRepository.GetOwnedAsync(request.Id, cid, cancellationToken);
|
||||
if (patient is null)
|
||||
return OperationResult<bool>.NotFoundResult("Patient not found.");
|
||||
|
||||
patient.IsActive = false;
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.ArchivePatient;
|
||||
|
||||
/// <summary>Soft-archives a patient (<c>is_active = false</c>) the signed-in customer owns — not a hard
|
||||
/// delete, so the longitudinal care record (b14) is preserved.</summary>
|
||||
public record ArchivePatientCommand(long Id) : IRequest<OperationResult<bool>>;
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
#nullable enable
|
||||
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.CreatePatient;
|
||||
|
||||
internal sealed class CreatePatientCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<CreatePatientCommand, OperationResult<PatientDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PatientDto>> Handle(CreatePatientCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<PatientDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<PatientDto>.ForbiddenResult("Only a customer can register a patient.");
|
||||
|
||||
var patient = new Patient
|
||||
{
|
||||
DisplayName = request.DisplayName,
|
||||
FirstName = request.FirstName,
|
||||
LastName = request.LastName,
|
||||
BirthDate = request.BirthDate,
|
||||
Gender = request.Gender,
|
||||
BloodType = request.BloodType,
|
||||
InitialMedicalNotes = request.InitialMedicalNotes,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is { } existingCustomerId)
|
||||
{
|
||||
patient.CustomerId = existingCustomerId;
|
||||
}
|
||||
else
|
||||
{
|
||||
// First patient before any customer-profile save — provision the thin payer row so the
|
||||
// customer/patient split works without a separate profile step. The FK is fixed up on commit.
|
||||
var profile = new CustomerProfile { UserId = userId };
|
||||
await unitOfWork.CustomerProfileRepository.AddAsync(profile, cancellationToken);
|
||||
patient.Customer = profile;
|
||||
}
|
||||
|
||||
await unitOfWork.PatientRepository.AddAsync(patient, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<PatientDto>.SuccessResult(new PatientDto(
|
||||
patient.Id,
|
||||
patient.DisplayName,
|
||||
patient.FirstName,
|
||||
patient.LastName,
|
||||
patient.BirthDate,
|
||||
patient.Gender,
|
||||
patient.BloodType,
|
||||
patient.InitialMedicalNotes,
|
||||
patient.IsActive));
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.CreatePatient;
|
||||
|
||||
public sealed class CreatePatientCommandValidator : AbstractValidator<CreatePatientCommand>
|
||||
{
|
||||
public CreatePatientCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.FirstName).MaximumLength(100);
|
||||
RuleFor(x => x.LastName).MaximumLength(100);
|
||||
RuleFor(x => x.BloodType).MaximumLength(10);
|
||||
RuleFor(x => x.Gender)
|
||||
.Must(PatientRules.IsValidGender)
|
||||
.WithMessage("Gender is required and must be 'male' or 'female'.");
|
||||
RuleFor(x => x.BirthDate)
|
||||
.NotEqual(default(DateOnly))
|
||||
.Must(PatientRules.IsNotFuture)
|
||||
.WithMessage("Birth date cannot be in the future.");
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.CreatePatient;
|
||||
|
||||
/// <summary>
|
||||
/// Registers a care recipient under the signed-in customer. The owning customer is derived from the
|
||||
/// caller — never from the request body. <c>Gender</c> is required (same-gender matching signal) and
|
||||
/// <c>InitialMedicalNotes</c> is encrypted at rest.
|
||||
/// </summary>
|
||||
public record CreatePatientCommand(
|
||||
string DisplayName,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
DateOnly BirthDate,
|
||||
string Gender,
|
||||
string BloodType,
|
||||
string InitialMedicalNotes) : IRequest<OperationResult<PatientDto>>;
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings;
|
||||
|
||||
internal sealed class SetNurseAcceptingBookingsCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<SetNurseAcceptingBookingsCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(SetNurseAcceptingBookingsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<bool>.ForbiddenResult("Only a nurse can manage a nurse profile.");
|
||||
|
||||
var profile = await unitOfWork.NurseProfileRepository.GetByUserIdAsync(userId, cancellationToken);
|
||||
if (profile is null)
|
||||
return OperationResult<bool>.NotFoundResult("No nurse profile exists yet. Create your profile first.");
|
||||
|
||||
profile.SetAcceptingBookings(request.Accepting);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings;
|
||||
|
||||
/// <summary>Pauses or resumes the signed-in nurse's bookability without touching verified status.</summary>
|
||||
public record SetNurseAcceptingBookingsCommand(bool Accepting) : IRequest<OperationResult<bool>>;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.SetPrimaryBankAccount;
|
||||
|
||||
internal sealed class SetPrimaryBankAccountCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<SetPrimaryBankAccountCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(SetPrimaryBankAccountCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<bool>.ForbiddenResult("Only a nurse can manage payout accounts.");
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<bool>.NotFoundResult("Bank account not found.");
|
||||
|
||||
// Verify tenancy before touching any row — a non-owned/nonexistent id must never clear the
|
||||
// existing primary.
|
||||
var account = await unitOfWork.NurseBankAccountRepository.GetOwnedAsync(request.Id, nid, cancellationToken);
|
||||
if (account is null)
|
||||
return OperationResult<bool>.NotFoundResult("Bank account not found.");
|
||||
|
||||
if (!account.IsPrimary)
|
||||
await unitOfWork.NurseBankAccountRepository.SetPrimaryAsync(nid, request.Id, cancellationToken);
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.SetPrimaryBankAccount;
|
||||
|
||||
/// <summary>Makes one of the signed-in nurse's accounts primary, clearing the prior primary atomically.</summary>
|
||||
public record SetPrimaryBankAccountCommand(long Id) : IRequest<OperationResult<bool>>;
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.TriggerBankAccountOwnershipInquiry;
|
||||
|
||||
internal sealed class TriggerBankAccountOwnershipInquiryCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IBankAccountOwnershipVerifier ownershipVerifier)
|
||||
: IRequestHandler<TriggerBankAccountOwnershipInquiryCommand, OperationResult<NurseBankAccountDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<NurseBankAccountDto>> Handle(TriggerBankAccountOwnershipInquiryCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<NurseBankAccountDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<NurseBankAccountDto>.ForbiddenResult("Only a nurse can run an ownership inquiry.");
|
||||
|
||||
var context = await unitOfWork.NurseProfileRepository.GetIdentityContextByUserIdAsync(userId, cancellationToken);
|
||||
if (context is null)
|
||||
return OperationResult<NurseBankAccountDto>.NotFoundResult("Bank account not found.");
|
||||
|
||||
var account = await unitOfWork.NurseBankAccountRepository.GetOwnedAsync(request.Id, context.NurseProfileId, cancellationToken);
|
||||
if (account is null)
|
||||
return OperationResult<NurseBankAccountDto>.NotFoundResult("Bank account not found.");
|
||||
|
||||
// The tracked entity decrypts the IBAN on materialization, so we can re-run the inquiry on it.
|
||||
var inquiry = await ownershipVerifier.VerifyOwnershipAsync(account.Iban, context.NationalId, cancellationToken);
|
||||
account.ApplyOwnershipInquiry(inquiry.MatchedNationalId, inquiry.AccountHolderFromBank, inquiry.VendorRef);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<NurseBankAccountDto>.SuccessResult(new NurseBankAccountDto(
|
||||
account.Id,
|
||||
account.BankName,
|
||||
Mask.IbanTail(account.Iban),
|
||||
account.IsPrimary,
|
||||
account.IsVerified,
|
||||
account.MatchedNationalId));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.TriggerBankAccountOwnershipInquiry;
|
||||
|
||||
/// <summary>
|
||||
/// Re-runs the استعلام شبا ownership inquiry for an existing account (e.g. after a NULL/failed first
|
||||
/// attempt) and updates <c>matched_national_id</c>/<c>account_holder_from_bank</c>/
|
||||
/// <c>ownership_vendor_ref</c>. Idempotent — the same input yields the same vendor ref from the mock.
|
||||
/// </summary>
|
||||
public record TriggerBankAccountOwnershipInquiryCommand(long Id) : IRequest<OperationResult<NurseBankAccountDto>>;
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.UpdatePatient;
|
||||
|
||||
internal sealed class UpdatePatientCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<UpdatePatientCommand, OperationResult<PatientDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PatientDto>> Handle(UpdatePatientCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<PatientDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<PatientDto>.ForbiddenResult("Only a customer can manage patients.");
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is not { } cid)
|
||||
return OperationResult<PatientDto>.NotFoundResult("Patient not found.");
|
||||
|
||||
var patient = await unitOfWork.PatientRepository.GetOwnedAsync(request.Id, cid, cancellationToken);
|
||||
if (patient is null)
|
||||
return OperationResult<PatientDto>.NotFoundResult("Patient not found.");
|
||||
|
||||
patient.DisplayName = request.DisplayName;
|
||||
patient.FirstName = request.FirstName;
|
||||
patient.LastName = request.LastName;
|
||||
patient.BirthDate = request.BirthDate;
|
||||
patient.Gender = request.Gender;
|
||||
patient.BloodType = request.BloodType;
|
||||
patient.InitialMedicalNotes = request.InitialMedicalNotes;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<PatientDto>.SuccessResult(new PatientDto(
|
||||
patient.Id,
|
||||
patient.DisplayName,
|
||||
patient.FirstName,
|
||||
patient.LastName,
|
||||
patient.BirthDate,
|
||||
patient.Gender,
|
||||
patient.BloodType,
|
||||
patient.InitialMedicalNotes,
|
||||
patient.IsActive));
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.UpdatePatient;
|
||||
|
||||
public sealed class UpdatePatientCommandValidator : AbstractValidator<UpdatePatientCommand>
|
||||
{
|
||||
public UpdatePatientCommandValidator()
|
||||
{
|
||||
// Id is supplied by the route, not the request body, so it is not validated here.
|
||||
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.FirstName).MaximumLength(100);
|
||||
RuleFor(x => x.LastName).MaximumLength(100);
|
||||
RuleFor(x => x.BloodType).MaximumLength(10);
|
||||
RuleFor(x => x.Gender)
|
||||
.Must(PatientRules.IsValidGender)
|
||||
.WithMessage("Gender is required and must be 'male' or 'female'.");
|
||||
RuleFor(x => x.BirthDate)
|
||||
.NotEqual(default(DateOnly))
|
||||
.Must(PatientRules.IsNotFuture)
|
||||
.WithMessage("Birth date cannot be in the future.");
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.UpdatePatient;
|
||||
|
||||
/// <summary>Updates a patient the signed-in customer owns; tenancy-checked, re-encrypts changed PII.</summary>
|
||||
public record UpdatePatientCommand(
|
||||
long Id,
|
||||
string DisplayName,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
DateOnly BirthDate,
|
||||
string Gender,
|
||||
string BloodType,
|
||||
string InitialMedicalNotes) : IRequest<OperationResult<PatientDto>>;
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
#nullable enable
|
||||
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.UpsertCustomerProfile;
|
||||
|
||||
internal sealed class UpsertCustomerProfileCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<UpsertCustomerProfileCommand, OperationResult<CustomerProfileDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<CustomerProfileDto>> Handle(UpsertCustomerProfileCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<CustomerProfileDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<CustomerProfileDto>.ForbiddenResult("Only a customer can manage a customer profile.");
|
||||
|
||||
var phone = IranianPhone.Normalize(request.DefaultEmergencyContactPhone) ?? request.DefaultEmergencyContactPhone;
|
||||
|
||||
var profile = await unitOfWork.CustomerProfileRepository.GetByUserIdAsync(userId, cancellationToken);
|
||||
if (profile is null)
|
||||
{
|
||||
profile = new CustomerProfile
|
||||
{
|
||||
UserId = userId,
|
||||
DefaultEmergencyContactName = request.DefaultEmergencyContactName,
|
||||
DefaultEmergencyContactPhone = phone
|
||||
};
|
||||
await unitOfWork.CustomerProfileRepository.AddAsync(profile, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
profile.DefaultEmergencyContactName = request.DefaultEmergencyContactName;
|
||||
profile.DefaultEmergencyContactPhone = phone;
|
||||
}
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
var dto = await unitOfWork.CustomerProfileRepository.GetMineAsync(userId, cancellationToken);
|
||||
return OperationResult<CustomerProfileDto>.SuccessResult(dto!);
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.UpsertCustomerProfile;
|
||||
|
||||
public sealed class UpsertCustomerProfileCommandValidator : AbstractValidator<UpsertCustomerProfileCommand>
|
||||
{
|
||||
public UpsertCustomerProfileCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.DefaultEmergencyContactName).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.DefaultEmergencyContactPhone)
|
||||
.NotEmpty()
|
||||
.Must(IranianPhone.IsValid)
|
||||
.WithMessage("A valid Iranian mobile number is required (09xxxxxxxxx).");
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Mediator;
|
||||
|
||||
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.
|
||||
/// </summary>
|
||||
public record UpsertCustomerProfileCommand(
|
||||
string DefaultEmergencyContactName,
|
||||
string DefaultEmergencyContactPhone) : IRequest<OperationResult<CustomerProfileDto>>;
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.UpsertNurseProfile;
|
||||
|
||||
internal sealed class UpsertNurseProfileCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<UpsertNurseProfileCommand, OperationResult<NurseProfileDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<NurseProfileDto>> Handle(UpsertNurseProfileCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<NurseProfileDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<NurseProfileDto>.ForbiddenResult("Only a nurse can manage a nurse profile.");
|
||||
|
||||
var profile = await unitOfWork.NurseProfileRepository.GetByUserIdAsync(userId, cancellationToken);
|
||||
if (profile is null)
|
||||
{
|
||||
// Created unverified and not accepting bookings — verification (b6) is the only path to
|
||||
// is_verified; the nurse opts into bookability separately.
|
||||
profile = new NurseProfile { UserId = userId };
|
||||
Apply(profile, request);
|
||||
await unitOfWork.NurseProfileRepository.AddAsync(profile, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
Apply(profile, request);
|
||||
}
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
var dto = await unitOfWork.NurseProfileRepository.GetMineAsync(userId, cancellationToken);
|
||||
return OperationResult<NurseProfileDto>.SuccessResult(dto!);
|
||||
}
|
||||
|
||||
private static void Apply(NurseProfile profile, UpsertNurseProfileCommand request)
|
||||
{
|
||||
profile.Bio = request.Bio;
|
||||
profile.YearsOfExperience = request.YearsOfExperience;
|
||||
profile.EducationLevel = request.EducationLevel;
|
||||
profile.EducationField = request.EducationField;
|
||||
profile.SpecializationsJson = request.SpecializationsJson;
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.UpsertNurseProfile;
|
||||
|
||||
public sealed class UpsertNurseProfileCommandValidator : AbstractValidator<UpsertNurseProfileCommand>
|
||||
{
|
||||
public UpsertNurseProfileCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Bio).MaximumLength(2000);
|
||||
RuleFor(x => x.YearsOfExperience).InclusiveBetween(0, 80);
|
||||
RuleFor(x => x.EducationLevel).MaximumLength(100);
|
||||
RuleFor(x => x.EducationField).MaximumLength(150);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.UpsertNurseProfile;
|
||||
|
||||
/// <summary>
|
||||
/// Creates (first call) or updates the signed-in nurse's seller profile. Never accepts the guarded
|
||||
/// <c>is_verified</c> flag or the read-only aggregates. Idempotent on the owning user.
|
||||
/// </summary>
|
||||
public record UpsertNurseProfileCommand(
|
||||
string Bio,
|
||||
int YearsOfExperience,
|
||||
string EducationLevel,
|
||||
string EducationField,
|
||||
string SpecializationsJson) : IRequest<OperationResult<NurseProfileDto>>;
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Baya.Application.Features.Identity;
|
||||
|
||||
/// <summary>Shared patient input rules used by the create/update validators.</summary>
|
||||
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);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Queries.GetMyCustomerProfile;
|
||||
|
||||
internal sealed class GetMyCustomerProfileQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetMyCustomerProfileQuery, OperationResult<CustomerProfileDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<CustomerProfileDto>> Handle(GetMyCustomerProfileQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<CustomerProfileDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var dto = await unitOfWork.CustomerProfileRepository.GetMineAsync(userId, cancellationToken);
|
||||
return dto is null
|
||||
? OperationResult<CustomerProfileDto>.NotFoundResult("No customer profile exists yet.")
|
||||
: OperationResult<CustomerProfileDto>.SuccessResult(dto);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Queries.GetMyCustomerProfile;
|
||||
|
||||
/// <summary>Projects the signed-in customer's profile (emergency contact returned in full to the owner).</summary>
|
||||
public record GetMyCustomerProfileQuery : IRequest<OperationResult<CustomerProfileDto>>;
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Queries.GetMyNurseProfile;
|
||||
|
||||
internal sealed class GetMyNurseProfileQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetMyNurseProfileQuery, OperationResult<NurseProfileDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<NurseProfileDto>> Handle(GetMyNurseProfileQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<NurseProfileDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var dto = await unitOfWork.NurseProfileRepository.GetMineAsync(userId, cancellationToken);
|
||||
return dto is null
|
||||
? OperationResult<NurseProfileDto>.NotFoundResult("No nurse profile exists yet.")
|
||||
: OperationResult<NurseProfileDto>.SuccessResult(dto);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Queries.GetMyNurseProfile;
|
||||
|
||||
/// <summary>Projects the signed-in nurse's profile, incl. read-only verified flag and aggregates.</summary>
|
||||
public record GetMyNurseProfileQuery : IRequest<OperationResult<NurseProfileDto>>;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Queries.GetPatient;
|
||||
|
||||
internal sealed class GetPatientQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetPatientQuery, OperationResult<PatientDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PatientDto>> Handle(GetPatientQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<PatientDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
// A patient outside the caller's tenancy is indistinguishable from a non-existent one.
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is not { } cid)
|
||||
return OperationResult<PatientDto>.NotFoundResult("Patient not found.");
|
||||
|
||||
var dto = await unitOfWork.PatientRepository.GetOwnedProjectedAsync(request.Id, cid, cancellationToken);
|
||||
return dto is null
|
||||
? OperationResult<PatientDto>.NotFoundResult("Patient not found.")
|
||||
: OperationResult<PatientDto>.SuccessResult(dto);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Queries.GetPatient;
|
||||
|
||||
/// <summary>Returns one patient only if it belongs to the signed-in customer, else not-found.</summary>
|
||||
public record GetPatientQuery(long Id) : IRequest<OperationResult<PatientDto>>;
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Queries.ListNurseBankAccounts;
|
||||
|
||||
internal sealed class ListNurseBankAccountsQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<ListNurseBankAccountsQuery, OperationResult<IReadOnlyList<NurseBankAccountDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<IReadOnlyList<NurseBankAccountDto>>> Handle(ListNurseBankAccountsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<IReadOnlyList<NurseBankAccountDto>>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<IReadOnlyList<NurseBankAccountDto>>.ForbiddenResult("Only a nurse can view payout accounts.");
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<IReadOnlyList<NurseBankAccountDto>>.SuccessResult([]);
|
||||
|
||||
var accounts = await unitOfWork.NurseBankAccountRepository.ListAsync(nid, cancellationToken);
|
||||
return OperationResult<IReadOnlyList<NurseBankAccountDto>>.SuccessResult(accounts);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Queries.ListNurseBankAccounts;
|
||||
|
||||
/// <summary>Lists the signed-in nurse's payout accounts with the IBAN masked (last 4 only).</summary>
|
||||
public record ListNurseBankAccountsQuery : IRequest<OperationResult<IReadOnlyList<NurseBankAccountDto>>>;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
#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 Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Queries.ListPatients;
|
||||
|
||||
internal sealed class ListPatientsQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<ListPatientsQuery, OperationResult<PagedResult<PatientDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<PatientDto>>> Handle(ListPatientsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<PagedResult<PatientDto>>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is not { } cid)
|
||||
return OperationResult<PagedResult<PatientDto>>.SuccessResult(new PagedResult<PatientDto>([], 0, page, pageSize));
|
||||
|
||||
var result = await unitOfWork.PatientRepository.ListAsync(cid, page, pageSize, cancellationToken);
|
||||
return OperationResult<PagedResult<PatientDto>>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Queries.ListPatients;
|
||||
|
||||
/// <summary>Lists the signed-in customer's own patients only (tenancy-scoped, paginated).</summary>
|
||||
public record ListPatientsQuery(int Page = 1, int PageSize = 50)
|
||||
: IRequest<OperationResult<PagedResult<PatientDto>>>;
|
||||
@@ -0,0 +1,27 @@
|
||||
#nullable enable
|
||||
using System.Text.RegularExpressions;
|
||||
using Baya.SharedKernel.Extensions;
|
||||
|
||||
namespace Baya.Application.Features.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes and validates an Iranian IBAN (شبا): the canonical <c>IR</c> + 24 digits, uppercase, no
|
||||
/// spaces (Persian digits translated). The canonical form is what gets stored, encrypted, and hashed —
|
||||
/// so the deterministic <c>iban_hash</c> uniqueness holds regardless of how the nurse typed it.
|
||||
/// </summary>
|
||||
internal static partial class Sheba
|
||||
{
|
||||
[GeneratedRegex(@"^IR\d{24}$")]
|
||||
private static partial Regex ShebaPattern();
|
||||
|
||||
public static string? Normalize(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return null;
|
||||
|
||||
var candidate = raw.Trim().Fa2En().Replace(" ", string.Empty).Replace("-", string.Empty).ToUpperInvariant();
|
||||
return ShebaPattern().IsMatch(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
public static bool IsValid(string? raw) => Normalize(raw) is not null;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Baya.Application.Models.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// The signed-in customer's payer profile. The emergency-contact fields are decrypted and returned in
|
||||
/// full because the endpoint only ever serves the owning customer (self).
|
||||
/// </summary>
|
||||
public record CustomerProfileDto(
|
||||
long Id,
|
||||
string DefaultEmergencyContactName,
|
||||
string DefaultEmergencyContactPhone);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Baya.Application.Models.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// A nurse payout account. The IBAN is returned <b>masked</b> (last 4 only) — the full value is never
|
||||
/// sent on the wire. <c>MatchedNationalId</c> is NULL until the استعلام شبا ownership inquiry has run.
|
||||
/// </summary>
|
||||
public record NurseBankAccountDto(
|
||||
long Id,
|
||||
string BankName,
|
||||
string IbanMasked,
|
||||
bool IsPrimary,
|
||||
bool IsVerified,
|
||||
bool? MatchedNationalId);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Baya.Application.Models.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// The minimal nurse facts a bank-account command needs: the owning <c>nurse_profiles</c> id and the
|
||||
/// nurse's national id (decrypted) for the استعلام شبا ownership inquiry. National id is NULL until the
|
||||
/// b6 KYC pipeline populates it.
|
||||
/// </summary>
|
||||
/// <param name="NurseProfileId">The signed-in nurse's <c>nurse_profiles.id</c>.</param>
|
||||
/// <param name="NationalId">The nurse's national id, decrypted; NULL before KYC.</param>
|
||||
public record NurseIdentityContext(long NurseProfileId, string NationalId);
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Baya.Application.Models.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// The signed-in nurse's seller profile. <c>IsVerified</c> and the aggregates are read-only — they are
|
||||
/// never set through a profile command in this phase.
|
||||
/// </summary>
|
||||
public record NurseProfileDto(
|
||||
long Id,
|
||||
string Bio,
|
||||
int YearsOfExperience,
|
||||
string EducationLevel,
|
||||
string EducationField,
|
||||
string SpecializationsJson,
|
||||
bool IsVerified,
|
||||
bool IsAcceptingBookings,
|
||||
decimal AverageRating,
|
||||
int TotalReviews,
|
||||
int TotalCompletedBookings);
|
||||
@@ -0,0 +1,16 @@
|
||||
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.
|
||||
/// </summary>
|
||||
public record PatientDto(
|
||||
long Id,
|
||||
string DisplayName,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
DateOnly BirthDate,
|
||||
string Gender,
|
||||
string BloodType,
|
||||
string InitialMedicalNotes,
|
||||
bool IsActive);
|
||||
+23
-5
@@ -1,6 +1,5 @@
|
||||
using System.Reflection;
|
||||
using Baya.Application.Common;
|
||||
using Mapster;
|
||||
using Baya.Application.Common;
|
||||
using FluentValidation;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -21,11 +20,30 @@ public static class ServiceCollectionExtension
|
||||
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(MetricsBehaviour<,>));
|
||||
|
||||
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidateCommandBehavior<,>));
|
||||
|
||||
|
||||
RegisterCommandValidators(services);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
// Registers every FluentValidation AbstractValidator<T> in this assembly as IValidator<T> so the
|
||||
// ValidateCommandBehavior can resolve and run them. Done by hand (rather than pulling in the
|
||||
// FluentValidation.DependencyInjectionExtensions package) to keep the dependency surface minimal.
|
||||
private static void RegisterCommandValidators(IServiceCollection services)
|
||||
{
|
||||
var assembly = typeof(ValidateCommandBehavior<,>).Assembly;
|
||||
|
||||
foreach (var type in assembly.GetTypes())
|
||||
{
|
||||
if (type.IsAbstract || type.IsInterface)
|
||||
continue;
|
||||
|
||||
var validatorInterface = Array.Find(
|
||||
type.GetInterfaces(),
|
||||
i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IValidator<>));
|
||||
|
||||
if (validatorInterface is not null)
|
||||
services.AddScoped(validatorInterface, type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// The thin payer extension for a customer. Intentionally lightweight — most customer reality lives in
|
||||
/// their <c>patients</c>, addresses and bookings. Customer national-ID KYC is deferred at launch, so no
|
||||
/// verification columns exist here.
|
||||
/// </summary>
|
||||
public class CustomerProfile : BaseEntity<long>
|
||||
{
|
||||
public int UserId { get; set; }
|
||||
public User.User User { get; set; }
|
||||
|
||||
/// <summary>Encrypted at rest.</summary>
|
||||
public string DefaultEmergencyContactName { get; set; }
|
||||
|
||||
/// <summary>Encrypted at rest.</summary>
|
||||
public string DefaultEmergencyContactPhone { get; set; }
|
||||
|
||||
public ICollection<Patient> Patients { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// A nurse's payout destination (IBAN/Sheba) — the single place real money will one day leave the
|
||||
/// platform. Hardened: the IBAN is encrypted, a deterministic <see cref="IbanHash"/> carries a UNIQUE
|
||||
/// constraint so one IBAN can't silently serve two nurses, and an automated استعلام شبا ownership
|
||||
/// inquiry records whether the IBAN owner matches the nurse's national id
|
||||
/// (<see cref="MatchedNationalId"/>) — the first-payout gate (b13), not an admin's eyeballs.
|
||||
/// </summary>
|
||||
public class NurseBankAccount : BaseEntity<long>
|
||||
{
|
||||
public long NurseId { get; set; }
|
||||
public NurseProfile Nurse { get; set; }
|
||||
|
||||
public string BankName { get; set; }
|
||||
|
||||
/// <summary>Encrypted at rest.</summary>
|
||||
public string AccountHolderName { get; set; }
|
||||
|
||||
/// <summary>Encrypted at rest. Non-deterministic ciphertext — never equality-queried; lookups go
|
||||
/// through <see cref="IbanHash"/>.</summary>
|
||||
public string Iban { get; set; }
|
||||
|
||||
/// <summary>Deterministic keyed hash of the normalized IBAN (via <c>IFieldEncryptor.Hash</c>) —
|
||||
/// carries the UNIQUE index, since the ciphertext itself can't be uniquely indexed.</summary>
|
||||
public string IbanHash { get; set; }
|
||||
|
||||
public bool IsPrimary { get; set; }
|
||||
|
||||
/// <summary>Result of the استعلام شبا IBAN-owner ↔ national-id inquiry. NULL until the inquiry runs;
|
||||
/// the first payout (b13) is gated on <c>true</c>.</summary>
|
||||
public bool? MatchedNationalId { get; set; }
|
||||
|
||||
/// <summary>The account-holder name the bank returned by the ownership inquiry — a snapshot.</summary>
|
||||
public string AccountHolderFromBank { get; set; }
|
||||
|
||||
/// <summary>The ownership-inquiry vendor transaction id, kept for audit.</summary>
|
||||
public string OwnershipVendorRef { get; set; }
|
||||
|
||||
public bool IsVerified { get; set; }
|
||||
public int? VerifiedByAdminId { get; set; }
|
||||
public DateTimeOffset? VerifiedAt { get; set; }
|
||||
|
||||
/// <summary>Records the outcome of an استعلام شبا ownership inquiry against this account.</summary>
|
||||
public void ApplyOwnershipInquiry(bool matchedNationalId, string accountHolderFromBank, string vendorRef)
|
||||
{
|
||||
MatchedNationalId = matchedNationalId;
|
||||
AccountHolderFromBank = accountHolderFromBank;
|
||||
OwnershipVendorRef = vendorRef;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// A nurse's seller profile plus the denormalized search/quality aggregates. Separated from
|
||||
/// <c>users</c> so the base identity row stays lean and the nurse-only attributes (and the aggregates
|
||||
/// search reads on every query) live together. A profile is created unverified and not accepting
|
||||
/// bookings — a nurse is not bookable until the b6 verification pipeline flips <see cref="IsVerified"/>.
|
||||
/// </summary>
|
||||
public class NurseProfile : BaseEntity<long>
|
||||
{
|
||||
public int UserId { get; set; }
|
||||
public User.User User { get; set; }
|
||||
|
||||
/// <summary>The licensed center legally sponsoring this nurse at launch (Asanism model). NULL once
|
||||
/// Balinyaar holds its own permit. Forward dependency on <c>partner_centers</c> (b15) — nullable,
|
||||
/// no FK target is enforced in this phase.</summary>
|
||||
public long? PartnerCenterId { get; set; }
|
||||
|
||||
public string Bio { get; set; }
|
||||
public int YearsOfExperience { get; set; }
|
||||
public string EducationLevel { get; set; }
|
||||
public string EducationField { get; set; }
|
||||
public string SpecializationsJson { get; set; }
|
||||
|
||||
/// <summary>Write-guarded. Flipped ONLY inside the b6 verification-confirm transaction once every
|
||||
/// required verification step has passed — never from a profile command in this phase. A nurse is
|
||||
/// not bookable until this is true.</summary>
|
||||
public bool IsVerified { get; private set; }
|
||||
|
||||
/// <summary>Whether the nurse currently accepts new bookings. A nurse can pause without losing
|
||||
/// verified status — toggled via <see cref="SetAcceptingBookings"/>.</summary>
|
||||
public bool IsAcceptingBookings { get; private set; }
|
||||
|
||||
/// <summary>Denormalized read-only aggregate. Defaults to 0; recomputed by the reviews/bookings
|
||||
/// phases (b9/b14) — never accepted from a request in this phase.</summary>
|
||||
public decimal AverageRating { get; private set; }
|
||||
|
||||
public int TotalReviews { get; private set; }
|
||||
public int TotalCompletedBookings { get; private set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
public ICollection<NurseBankAccount> BankAccounts { get; set; }
|
||||
|
||||
/// <summary>The single sanctioned write path for the guarded verified flag — called only by the b6
|
||||
/// verification-confirm transaction.</summary>
|
||||
public void MarkVerified() => IsVerified = true;
|
||||
|
||||
public void MarkUnverified() => IsVerified = false;
|
||||
|
||||
public void SetAcceptingBookings(bool accepting) => IsAcceptingBookings = accepting;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// The person receiving care — a first-class entity separate from the payer, because the customer
|
||||
/// (an adult child, a spouse) is frequently not the patient (an elderly parent, a newborn, a
|
||||
/// post-surgical adult). One customer registers many patients. Every read/write is tenancy-scoped to the
|
||||
/// owning <see cref="CustomerId"/>.
|
||||
/// </summary>
|
||||
public class Patient : BaseEntity<long>
|
||||
{
|
||||
public long CustomerId { get; set; }
|
||||
public CustomerProfile Customer { get; set; }
|
||||
|
||||
public string DisplayName { get; set; }
|
||||
public string FirstName { get; set; }
|
||||
public string LastName { get; set; }
|
||||
public DateOnly BirthDate { get; set; }
|
||||
|
||||
/// <summary>"male" / "female". Required — load-bearing for same-gender caregiver matching.</summary>
|
||||
public string Gender { get; set; }
|
||||
|
||||
public string BloodType { get; set; }
|
||||
|
||||
/// <summary>Encrypted at rest.</summary>
|
||||
public string InitialMedicalNotes { get; set; }
|
||||
|
||||
/// <summary>Archive flag — a patient is soft-archived (not hard-deleted) so the longitudinal care
|
||||
/// record (b14) is preserved.</summary>
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user