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:
hamid
2026-07-02 12:03:15 +03:30
parent 17a82832ab
commit 39a979b1a7
89 changed files with 6060 additions and 13 deletions
@@ -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();
}
}
@@ -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));
}
}
@@ -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.");
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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.");
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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.");
}
}
@@ -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>>;
@@ -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!);
}
}
@@ -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).");
}
}
@@ -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>>;
@@ -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;
}
}
@@ -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);
}
}
@@ -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);
}
@@ -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);
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>>>;
@@ -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);
}
}
@@ -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);
@@ -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; }
}