@
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,30 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Identity.Commands.UpsertCustomerProfile;
|
||||
using Baya.Application.Features.Identity.Queries.GetMyCustomerProfile;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Authorize]
|
||||
[Display(Description = "The signed-in customer's payer profile")]
|
||||
public sealed class CustomerProfilesController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpPost("[action]")]
|
||||
[ProducesOkApiResponseType<CustomerProfileDto>]
|
||||
public async Task<IActionResult> Upsert(UpsertCustomerProfileCommand command, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(command, cancellationToken));
|
||||
|
||||
[HttpGet("[action]")]
|
||||
[ProducesOkApiResponseType<CustomerProfileDto>]
|
||||
public async Task<IActionResult> Me(CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new GetMyCustomerProfileQuery(), cancellationToken));
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Identity.Commands.AddNurseBankAccount;
|
||||
using Baya.Application.Features.Identity.Commands.SetPrimaryBankAccount;
|
||||
using Baya.Application.Features.Identity.Commands.TriggerBankAccountOwnershipInquiry;
|
||||
using Baya.Application.Features.Identity.Queries.ListNurseBankAccounts;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Baya.WebFramework.ServiceConfiguration;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Authorize]
|
||||
[Display(Description = "The signed-in nurse's payout bank accounts")]
|
||||
public sealed class NurseBankAccountsController(ISender sender) : BaseController
|
||||
{
|
||||
// Rate-limited: adding an account triggers the استعلام شبا vendor inquiry.
|
||||
[HttpPost("[action]")]
|
||||
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
|
||||
[ProducesOkApiResponseType<NurseBankAccountDto>]
|
||||
public async Task<IActionResult> Add(AddNurseBankAccountCommand command, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(command, cancellationToken));
|
||||
|
||||
[HttpPost("[action]/{id}")]
|
||||
[ProducesOkApiResponseType]
|
||||
public async Task<IActionResult> SetPrimary(long id, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new SetPrimaryBankAccountCommand(id), cancellationToken));
|
||||
|
||||
[HttpGet("[action]")]
|
||||
[ProducesOkApiResponseType<IReadOnlyList<NurseBankAccountDto>>]
|
||||
public async Task<IActionResult> List(CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new ListNurseBankAccountsQuery(), cancellationToken));
|
||||
|
||||
// Rate-limited: re-runs the استعلام شبا vendor inquiry.
|
||||
[HttpPost("[action]/{id}")]
|
||||
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
|
||||
[ProducesOkApiResponseType<NurseBankAccountDto>]
|
||||
public async Task<IActionResult> VerifyOwnership(long id, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new TriggerBankAccountOwnershipInquiryCommand(id), cancellationToken));
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings;
|
||||
using Baya.Application.Features.Identity.Commands.UpsertNurseProfile;
|
||||
using Baya.Application.Features.Identity.Queries.GetMyNurseProfile;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Authorize]
|
||||
[Display(Description = "The signed-in nurse's seller profile")]
|
||||
public sealed class NurseProfilesController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpPost("[action]")]
|
||||
[ProducesOkApiResponseType<NurseProfileDto>]
|
||||
public async Task<IActionResult> Upsert(UpsertNurseProfileCommand command, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(command, cancellationToken));
|
||||
|
||||
[HttpPost("[action]")]
|
||||
[ProducesOkApiResponseType]
|
||||
public async Task<IActionResult> SetAcceptingBookings(SetNurseAcceptingBookingsCommand command, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(command, cancellationToken));
|
||||
|
||||
[HttpGet("[action]")]
|
||||
[ProducesOkApiResponseType<NurseProfileDto>]
|
||||
public async Task<IActionResult> Me(CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new GetMyNurseProfileQuery(), cancellationToken));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Identity.Commands.ArchivePatient;
|
||||
using Baya.Application.Features.Identity.Commands.CreatePatient;
|
||||
using Baya.Application.Features.Identity.Commands.UpdatePatient;
|
||||
using Baya.Application.Features.Identity.Queries.GetPatient;
|
||||
using Baya.Application.Features.Identity.Queries.ListPatients;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Authorize]
|
||||
[Display(Description = "The signed-in customer's patients (care recipients)")]
|
||||
public sealed class PatientsController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpPost("[action]")]
|
||||
[ProducesOkApiResponseType<PatientDto>]
|
||||
public async Task<IActionResult> Create(CreatePatientCommand command, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(command, cancellationToken));
|
||||
|
||||
[HttpGet("[action]")]
|
||||
[ProducesOkApiResponseType<PagedResult<PatientDto>>]
|
||||
public async Task<IActionResult> List([FromQuery] ListPatientsQuery query, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(query, cancellationToken));
|
||||
|
||||
[HttpGet("[action]/{id}")]
|
||||
[ProducesOkApiResponseType<PatientDto>]
|
||||
public async Task<IActionResult> Get(long id, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new GetPatientQuery(id), cancellationToken));
|
||||
|
||||
[HttpPost("[action]/{id}")]
|
||||
[ProducesOkApiResponseType<PatientDto>]
|
||||
public async Task<IActionResult> Update(long id, UpdatePatientCommand command, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
|
||||
|
||||
[HttpPost("[action]/{id}")]
|
||||
[ProducesOkApiResponseType]
|
||||
public async Task<IActionResult> Archive(long id, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new ArchivePatientCommand(id), cancellationToken));
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
#nullable enable
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Baya.Infrastructure.CrossCutting.Seams;
|
||||
|
||||
/// <summary>
|
||||
/// Mock <see cref="IBankAccountOwnershipVerifier"/>: a deterministic fake استعلام شبا inquiry — no real
|
||||
/// bank/KYC call and no money moves. Every IBAN returns a match except the configured
|
||||
/// <see cref="BankOwnershipOptions.MismatchIban"/>, which returns <c>MatchedNationalId = false</c> so the
|
||||
/// ownership-mismatch path is testable. The vendor ref is derived from the IBAN, so re-running the same
|
||||
/// inquiry is idempotent. The real implementation (Finnotech/banking-bridge) swaps in via a registration
|
||||
/// change — callers are unchanged.
|
||||
/// </summary>
|
||||
public sealed class MockBankAccountOwnershipVerifier(IOptions<SeamOptions> options) : IBankAccountOwnershipVerifier
|
||||
{
|
||||
private readonly BankOwnershipOptions _options = options.Value.BankOwnership;
|
||||
|
||||
// nurseNationalId is part of the real vendor contract (owner ↔ national-id match); the mock decides
|
||||
// the outcome from the IBAN alone so both paths are deterministically testable.
|
||||
public Task<OwnershipInquiryResult> VerifyOwnershipAsync(string iban, string? nurseNationalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = Normalize(iban);
|
||||
var matched = !string.Equals(normalized, Normalize(_options.MismatchIban), StringComparison.OrdinalIgnoreCase);
|
||||
var holder = matched ? _options.MatchedHolderName : _options.MismatchHolderName;
|
||||
var vendorRef = $"MOCK-SHEBA-{Token(normalized)}";
|
||||
|
||||
return Task.FromResult(new OwnershipInquiryResult(matched, holder, vendorRef));
|
||||
}
|
||||
|
||||
private static string Normalize(string iban)
|
||||
=> string.IsNullOrEmpty(iban) ? string.Empty : iban.Replace(" ", string.Empty).ToUpperInvariant();
|
||||
|
||||
private static string Token(string value)
|
||||
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..12];
|
||||
}
|
||||
@@ -10,6 +10,24 @@ public sealed class SeamOptions
|
||||
|
||||
public FieldEncryptionOptions FieldEncryption { get; set; } = new();
|
||||
public ObjectStorageOptions ObjectStorage { get; set; } = new();
|
||||
public BankOwnershipOptions BankOwnership { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tunes the mock <c>IBankAccountOwnershipVerifier</c> (استعلام شبا). A submitted IBAN equal to
|
||||
/// <see cref="MismatchIban"/> returns an ownership mismatch so the payout-gating path is testable; every
|
||||
/// other IBAN returns a match. The real vendor implementation ignores these.
|
||||
/// </summary>
|
||||
public sealed class BankOwnershipOptions
|
||||
{
|
||||
/// <summary>The designated test IBAN that returns <c>matched_national_id = false</c>.</summary>
|
||||
public string MismatchIban { get; set; } = "IR000000000000000000000000";
|
||||
|
||||
/// <summary>The account-holder name the mock echoes back for a matching inquiry.</summary>
|
||||
public string MatchedHolderName { get; set; } = "Verified Account Holder";
|
||||
|
||||
/// <summary>The account-holder name the mock returns for the mismatch IBAN.</summary>
|
||||
public string MismatchHolderName { get; set; } = "Unmatched Account Holder";
|
||||
}
|
||||
|
||||
public sealed class FieldEncryptionOptions
|
||||
|
||||
+4
@@ -28,6 +28,10 @@ public static class ServiceCollectionExtension
|
||||
// (Kavenegar/Ghasedak/SMS.ir) replaces this registration only.
|
||||
services.AddSingleton<ISmsSender, LoggingSmsSender>();
|
||||
|
||||
// استعلام شبا IBAN-owner ↔ national-id inquiry (backend-phase-3). The mock returns a deterministic
|
||||
// fake match; a real Finnotech/banking-bridge client replaces this registration only.
|
||||
services.AddSingleton<IBankAccountOwnershipVerifier, MockBankAccountOwnershipVerifier>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Reflection;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Domain.Common;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Infrastructure.Persistence.ValueConversion;
|
||||
using Baya.SharedKernel.Extensions;
|
||||
@@ -103,5 +104,22 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
|
||||
builder.Property(u => u.NormalizedEmail).HasConversion(encrypted);
|
||||
builder.Property(u => u.NationalId).HasConversion(encrypted);
|
||||
});
|
||||
|
||||
// b3 PII: emergency contacts, clinical notes, IBAN and the account-holder name are encrypted at
|
||||
// rest through the same seam. The IBAN's deterministic lookup uses the iban_hash column instead.
|
||||
modelBuilder.Entity<CustomerProfile>(builder =>
|
||||
{
|
||||
builder.Property(c => c.DefaultEmergencyContactName).HasConversion(encrypted);
|
||||
builder.Property(c => c.DefaultEmergencyContactPhone).HasConversion(encrypted);
|
||||
});
|
||||
modelBuilder.Entity<Patient>(builder =>
|
||||
{
|
||||
builder.Property(p => p.InitialMedicalNotes).HasConversion(encrypted);
|
||||
});
|
||||
modelBuilder.Entity<NurseBankAccount>(builder =>
|
||||
{
|
||||
builder.Property(a => a.AccountHolderName).HasConversion(encrypted);
|
||||
builder.Property(a => a.Iban).HasConversion(encrypted);
|
||||
});
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.IdentityConfig;
|
||||
|
||||
internal sealed class CustomerProfileConfig : IEntityTypeConfiguration<CustomerProfile>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<CustomerProfile> builder)
|
||||
{
|
||||
builder.ToTable("CustomerProfiles", "usr");
|
||||
|
||||
// Emergency-contact columns are encrypted at rest (converter wired in ApplicationDbContext) —
|
||||
// left as nvarchar(max) since ciphertext is longer than the plaintext it carries.
|
||||
|
||||
builder.HasIndex(c => c.UserId).IsUnique();
|
||||
builder.HasOne(c => c.User)
|
||||
.WithOne()
|
||||
.HasForeignKey<CustomerProfile>(c => c.UserId)
|
||||
.IsRequired();
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.IdentityConfig;
|
||||
|
||||
internal sealed class NurseBankAccountConfig : IEntityTypeConfiguration<NurseBankAccount>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<NurseBankAccount> builder)
|
||||
{
|
||||
builder.ToTable("NurseBankAccounts", "usr");
|
||||
|
||||
builder.Property(a => a.BankName).HasMaxLength(100);
|
||||
builder.Property(a => a.IbanHash).HasMaxLength(64).IsRequired();
|
||||
builder.Property(a => a.AccountHolderFromBank).HasMaxLength(200);
|
||||
builder.Property(a => a.OwnershipVendorRef).HasMaxLength(200);
|
||||
builder.Property(a => a.IsPrimary).HasDefaultValue(false);
|
||||
builder.Property(a => a.IsVerified).HasDefaultValue(false);
|
||||
|
||||
// account_holder_name and iban are encrypted at rest (converters wired in ApplicationDbContext).
|
||||
|
||||
// One IBAN can't silently serve two nurses — the authoritative duplicate backstop.
|
||||
builder.HasIndex(a => a.IbanHash).IsUnique();
|
||||
|
||||
// Exactly one primary account per nurse — the filtered-unique backstop the set-primary
|
||||
// transaction must never trip.
|
||||
builder.HasIndex(a => a.NurseId)
|
||||
.IsUnique()
|
||||
.HasFilter("[IsPrimary] = 1")
|
||||
.HasDatabaseName("UX_NurseBankAccounts_NurseId_Primary");
|
||||
|
||||
builder.HasOne(a => a.Nurse)
|
||||
.WithMany(n => n.BankAccounts)
|
||||
.HasForeignKey(a => a.NurseId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne<User>()
|
||||
.WithMany()
|
||||
.HasForeignKey(a => a.VerifiedByAdminId)
|
||||
.IsRequired(false);
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.IdentityConfig;
|
||||
|
||||
internal sealed class NurseProfileConfig : IEntityTypeConfiguration<NurseProfile>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<NurseProfile> builder)
|
||||
{
|
||||
builder.ToTable("NurseProfiles", "usr");
|
||||
|
||||
builder.Property(p => p.Bio).HasMaxLength(2000);
|
||||
builder.Property(p => p.EducationLevel).HasMaxLength(100);
|
||||
builder.Property(p => p.EducationField).HasMaxLength(150);
|
||||
builder.Property(p => p.IsVerified).HasDefaultValue(false);
|
||||
builder.Property(p => p.IsAcceptingBookings).HasDefaultValue(false);
|
||||
|
||||
// Read-only quality aggregates — default 0, recomputed by reviews/bookings phases.
|
||||
builder.Property(p => p.AverageRating).HasPrecision(3, 2).HasDefaultValue(0m);
|
||||
builder.Property(p => p.TotalReviews).HasDefaultValue(0);
|
||||
builder.Property(p => p.TotalCompletedBookings).HasDefaultValue(0);
|
||||
|
||||
// 1:1 with the owning user.
|
||||
builder.HasIndex(p => p.UserId).IsUnique();
|
||||
builder.HasOne(p => p.User)
|
||||
.WithOne()
|
||||
.HasForeignKey<NurseProfile>(p => p.UserId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasQueryFilter(p => p.DeletedAt == null);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.IdentityConfig;
|
||||
|
||||
internal sealed class PatientConfig : IEntityTypeConfiguration<Patient>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Patient> builder)
|
||||
{
|
||||
builder.ToTable("Patients", "usr");
|
||||
|
||||
builder.Property(p => p.DisplayName).HasMaxLength(200);
|
||||
builder.Property(p => p.FirstName).HasMaxLength(100);
|
||||
builder.Property(p => p.LastName).HasMaxLength(100);
|
||||
builder.Property(p => p.Gender).HasMaxLength(10).IsRequired();
|
||||
builder.Property(p => p.BloodType).HasMaxLength(10);
|
||||
builder.Property(p => p.IsActive).HasDefaultValue(true);
|
||||
|
||||
// initial_medical_notes is encrypted at rest (converter wired in ApplicationDbContext).
|
||||
|
||||
// Tenancy anchor: every list/get is scoped by CustomerId.
|
||||
builder.HasIndex(p => p.CustomerId);
|
||||
builder.HasOne(p => p.Customer)
|
||||
.WithMany(c => c.Patients)
|
||||
.HasForeignKey(p => p.CustomerId)
|
||||
.IsRequired();
|
||||
}
|
||||
}
|
||||
+1333
File diff suppressed because it is too large
Load Diff
+215
@@ -0,0 +1,215 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class IdentityProfilesPatientsBankAccounts : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CustomerProfiles",
|
||||
schema: "usr",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
UserId = table.Column<int>(type: "int", nullable: false),
|
||||
DefaultEmergencyContactName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DefaultEmergencyContactPhone = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CustomerProfiles", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_CustomerProfiles_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "Users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NurseProfiles",
|
||||
schema: "usr",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
UserId = table.Column<int>(type: "int", nullable: false),
|
||||
PartnerCenterId = table.Column<long>(type: "bigint", nullable: true),
|
||||
Bio = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
YearsOfExperience = table.Column<int>(type: "int", nullable: false),
|
||||
EducationLevel = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
EducationField = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: true),
|
||||
SpecializationsJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsVerified = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||
IsAcceptingBookings = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||
AverageRating = table.Column<decimal>(type: "decimal(3,2)", precision: 3, scale: 2, nullable: false, defaultValue: 0m),
|
||||
TotalReviews = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
|
||||
TotalCompletedBookings = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NurseProfiles", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_NurseProfiles_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "Users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Patients",
|
||||
schema: "usr",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
CustomerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
DisplayName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
FirstName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
LastName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
BirthDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
Gender = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: false),
|
||||
BloodType = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
|
||||
InitialMedicalNotes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Patients", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Patients_CustomerProfiles_CustomerId",
|
||||
column: x => x.CustomerId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "CustomerProfiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NurseBankAccounts",
|
||||
schema: "usr",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
NurseId = table.Column<long>(type: "bigint", nullable: false),
|
||||
BankName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
AccountHolderName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Iban = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IbanHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
IsPrimary = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||
MatchedNationalId = table.Column<bool>(type: "bit", nullable: true),
|
||||
AccountHolderFromBank = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
OwnershipVendorRef = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
IsVerified = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||
VerifiedByAdminId = table.Column<int>(type: "int", nullable: true),
|
||||
VerifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NurseBankAccounts", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_NurseBankAccounts_NurseProfiles_NurseId",
|
||||
column: x => x.NurseId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "NurseProfiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_NurseBankAccounts_Users_VerifiedByAdminId",
|
||||
column: x => x.VerifiedByAdminId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "Users",
|
||||
principalColumn: "UserId");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CustomerProfiles_UserId",
|
||||
schema: "usr",
|
||||
table: "CustomerProfiles",
|
||||
column: "UserId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NurseBankAccounts_IbanHash",
|
||||
schema: "usr",
|
||||
table: "NurseBankAccounts",
|
||||
column: "IbanHash",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NurseBankAccounts_VerifiedByAdminId",
|
||||
schema: "usr",
|
||||
table: "NurseBankAccounts",
|
||||
column: "VerifiedByAdminId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_NurseBankAccounts_NurseId_Primary",
|
||||
schema: "usr",
|
||||
table: "NurseBankAccounts",
|
||||
column: "NurseId",
|
||||
unique: true,
|
||||
filter: "[IsPrimary] = 1");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NurseProfiles_UserId",
|
||||
schema: "usr",
|
||||
table: "NurseProfiles",
|
||||
column: "UserId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Patients_CustomerId",
|
||||
schema: "usr",
|
||||
table: "Patients",
|
||||
column: "CustomerId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "NurseBankAccounts",
|
||||
schema: "usr");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Patients",
|
||||
schema: "usr");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NurseProfiles",
|
||||
schema: "usr");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "CustomerProfiles",
|
||||
schema: "usr");
|
||||
}
|
||||
}
|
||||
}
|
||||
+318
@@ -390,6 +390,266 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("DefaultEmergencyContactName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("DefaultEmergencyContactPhone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CustomerProfiles", "usr");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AccountHolderFromBank")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("AccountHolderName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("BankName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Iban")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("IbanHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<bool>("IsPrimary")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsVerified")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool?>("MatchedNationalId")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<long>("NurseId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("OwnershipVendorRef")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateTimeOffset?>("VerifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("VerifiedByAdminId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IbanHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("NurseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_NurseBankAccounts_NurseId_Primary")
|
||||
.HasFilter("[IsPrimary] = 1");
|
||||
|
||||
b.HasIndex("VerifiedByAdminId");
|
||||
|
||||
b.ToTable("NurseBankAccounts", "usr");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<decimal>("AverageRating")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasPrecision(3, 2)
|
||||
.HasColumnType("decimal(3,2)")
|
||||
.HasDefaultValue(0m);
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("EducationField")
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("nvarchar(150)");
|
||||
|
||||
b.Property<string>("EducationLevel")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<bool>("IsAcceptingBookings")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsVerified")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<long?>("PartnerCenterId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("SpecializationsJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("TotalCompletedBookings")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<int>("TotalReviews")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("YearsOfExperience")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("NurseProfiles", "usr");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateOnly>("BirthDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("BloodType")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<long>("CustomerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Gender")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<string>("InitialMedicalNotes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CustomerId");
|
||||
|
||||
b.ToTable("Patients", "usr");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -880,6 +1140,54 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
.HasForeignKey("ActorUserId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.User.User", "User")
|
||||
.WithOne()
|
||||
.HasForeignKey("Baya.Domain.Entities.Identity.CustomerProfile", "UserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse")
|
||||
.WithMany("BankAccounts")
|
||||
.HasForeignKey("NurseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.User.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("VerifiedByAdminId");
|
||||
|
||||
b.Navigation("Nurse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.User.User", "User")
|
||||
.WithOne()
|
||||
.HasForeignKey("Baya.Domain.Entities.Identity.NurseProfile", "UserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer")
|
||||
.WithMany("Patients")
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Customer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.User.User", null)
|
||||
@@ -985,6 +1293,16 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b =>
|
||||
{
|
||||
b.Navigation("Patients");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b =>
|
||||
{
|
||||
b.Navigation("BankAccounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.User.Role", b =>
|
||||
{
|
||||
b.Navigation("Claims");
|
||||
|
||||
+10
-2
@@ -9,6 +9,10 @@ public class UnitOfWork : 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; }
|
||||
|
||||
public UnitOfWork(ApplicationDbContext db)
|
||||
{
|
||||
@@ -16,9 +20,13 @@ public class UnitOfWork : IUnitOfWork
|
||||
UserRefreshTokenRepository = new UserRefreshTokenRepository(_db);
|
||||
UserSessionRepository = new UserSessionRepository(_db);
|
||||
UserAccountRepository = new UserAccountRepository(_db);
|
||||
NurseProfileRepository = new NurseProfileRepository(_db);
|
||||
CustomerProfileRepository = new CustomerProfileRepository(_db);
|
||||
PatientRepository = new PatientRepository(_db);
|
||||
NurseBankAccountRepository = new NurseBankAccountRepository(_db);
|
||||
}
|
||||
|
||||
public Task CommitAsync()
|
||||
public Task CommitAsync()
|
||||
{
|
||||
return _db.SaveChangesAsync();
|
||||
}
|
||||
@@ -28,4 +36,4 @@ public class UnitOfWork : IUnitOfWork
|
||||
_db.ChangeTracker.Clear();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Repositories;
|
||||
|
||||
internal sealed class CustomerProfileRepository : BaseAsyncRepository<CustomerProfile>, ICustomerProfileRepository
|
||||
{
|
||||
public CustomerProfileRepository(ApplicationDbContext dbContext) : base(dbContext)
|
||||
{
|
||||
}
|
||||
|
||||
public Task<CustomerProfile> GetByUserIdAsync(int userId, CancellationToken cancellationToken)
|
||||
=> Table.FirstOrDefaultAsync(c => c.UserId == userId, cancellationToken);
|
||||
|
||||
public Task AddAsync(CustomerProfile profile, CancellationToken cancellationToken)
|
||||
=> base.AddAsync(profile);
|
||||
|
||||
public Task<CustomerProfileDto> GetMineAsync(int userId, CancellationToken cancellationToken)
|
||||
=> TableNoTracking
|
||||
.Where(c => c.UserId == userId)
|
||||
.Select(c => new CustomerProfileDto(c.Id, c.DefaultEmergencyContactName, c.DefaultEmergencyContactPhone))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
public Task<long?> GetProfileIdByUserIdAsync(int userId, CancellationToken cancellationToken)
|
||||
=> TableNoTracking
|
||||
.Where(c => c.UserId == userId)
|
||||
.Select(c => (long?)c.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Repositories;
|
||||
|
||||
internal sealed class NurseBankAccountRepository : BaseAsyncRepository<NurseBankAccount>, INurseBankAccountRepository
|
||||
{
|
||||
public NurseBankAccountRepository(ApplicationDbContext dbContext) : base(dbContext)
|
||||
{
|
||||
}
|
||||
|
||||
public Task AddAsync(NurseBankAccount account, CancellationToken cancellationToken)
|
||||
=> base.AddAsync(account);
|
||||
|
||||
public Task<NurseBankAccount> GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken)
|
||||
=> Table.FirstOrDefaultAsync(a => a.Id == id && a.NurseId == nurseId, cancellationToken);
|
||||
|
||||
public async Task SetPrimaryAsync(long nurseId, long accountId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Clear-then-set inside one transaction: two ordered statements so the filtered unique index is
|
||||
// never momentarily violated (setting the new primary while the old one is still primary).
|
||||
await using var transaction = await DbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
await Entities
|
||||
.Where(a => a.NurseId == nurseId && a.IsPrimary)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(a => a.IsPrimary, false), cancellationToken);
|
||||
|
||||
await Entities
|
||||
.Where(a => a.Id == accountId && a.NurseId == nurseId)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(a => a.IsPrimary, true), cancellationToken);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<bool> IbanHashExistsAsync(string ibanHash, CancellationToken cancellationToken)
|
||||
=> TableNoTracking.AnyAsync(a => a.IbanHash == ibanHash, cancellationToken);
|
||||
|
||||
public Task<bool> HasAnyAsync(long nurseId, CancellationToken cancellationToken)
|
||||
=> TableNoTracking.AnyAsync(a => a.NurseId == nurseId, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<NurseBankAccountDto>> ListAsync(long nurseId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Decrypt the IBAN in memory, then mask to last-4 — the full value never leaves the repository.
|
||||
var rows = await TableNoTracking
|
||||
.Where(a => a.NurseId == nurseId)
|
||||
.OrderByDescending(a => a.IsPrimary)
|
||||
.ThenByDescending(a => a.Id)
|
||||
.Select(a => new { a.Id, a.BankName, a.Iban, a.IsPrimary, a.IsVerified, a.MatchedNationalId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return rows
|
||||
.Select(a => new NurseBankAccountDto(a.Id, a.BankName, Mask.IbanTail(a.Iban), a.IsPrimary, a.IsVerified, a.MatchedNationalId))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Repositories;
|
||||
|
||||
internal sealed class NurseProfileRepository : BaseAsyncRepository<NurseProfile>, INurseProfileRepository
|
||||
{
|
||||
public NurseProfileRepository(ApplicationDbContext dbContext) : base(dbContext)
|
||||
{
|
||||
}
|
||||
|
||||
public Task<NurseProfile> GetByUserIdAsync(int userId, CancellationToken cancellationToken)
|
||||
=> Table.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
|
||||
|
||||
public Task AddAsync(NurseProfile profile, CancellationToken cancellationToken)
|
||||
=> base.AddAsync(profile);
|
||||
|
||||
public Task<NurseProfileDto> GetMineAsync(int userId, CancellationToken cancellationToken)
|
||||
=> TableNoTracking
|
||||
.Where(p => p.UserId == userId)
|
||||
.Select(p => new NurseProfileDto(
|
||||
p.Id,
|
||||
p.Bio,
|
||||
p.YearsOfExperience,
|
||||
p.EducationLevel,
|
||||
p.EducationField,
|
||||
p.SpecializationsJson,
|
||||
p.IsVerified,
|
||||
p.IsAcceptingBookings,
|
||||
p.AverageRating,
|
||||
p.TotalReviews,
|
||||
p.TotalCompletedBookings))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
public Task<long?> GetProfileIdByUserIdAsync(int userId, CancellationToken cancellationToken)
|
||||
=> TableNoTracking
|
||||
.Where(p => p.UserId == userId)
|
||||
.Select(p => (long?)p.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
public Task<NurseIdentityContext> GetIdentityContextByUserIdAsync(int userId, CancellationToken cancellationToken)
|
||||
=> TableNoTracking
|
||||
.Where(p => p.UserId == userId)
|
||||
.Select(p => new NurseIdentityContext(p.Id, p.User.NationalId))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Repositories;
|
||||
|
||||
internal sealed class PatientRepository : BaseAsyncRepository<Patient>, IPatientRepository
|
||||
{
|
||||
public PatientRepository(ApplicationDbContext dbContext) : base(dbContext)
|
||||
{
|
||||
}
|
||||
|
||||
public Task AddAsync(Patient patient, CancellationToken cancellationToken)
|
||||
=> base.AddAsync(patient);
|
||||
|
||||
public Task<Patient> GetOwnedAsync(long id, long customerId, CancellationToken cancellationToken)
|
||||
=> Table.FirstOrDefaultAsync(p => p.Id == id && p.CustomerId == customerId, cancellationToken);
|
||||
|
||||
public async Task<PagedResult<PatientDto>> ListAsync(long customerId, int page, int pageSize, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = TableNoTracking.Where(p => p.CustomerId == customerId);
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(p => p.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(p => new PatientDto(
|
||||
p.Id,
|
||||
p.DisplayName,
|
||||
p.FirstName,
|
||||
p.LastName,
|
||||
p.BirthDate,
|
||||
p.Gender,
|
||||
p.BloodType,
|
||||
p.InitialMedicalNotes,
|
||||
p.IsActive))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new PagedResult<PatientDto>(items, total, page, pageSize);
|
||||
}
|
||||
|
||||
public Task<PatientDto> GetOwnedProjectedAsync(long id, long customerId, CancellationToken cancellationToken)
|
||||
=> TableNoTracking
|
||||
.Where(p => p.Id == id && p.CustomerId == customerId)
|
||||
.Select(p => new PatientDto(
|
||||
p.Id,
|
||||
p.DisplayName,
|
||||
p.FirstName,
|
||||
p.LastName,
|
||||
p.BirthDate,
|
||||
p.Gender,
|
||||
p.BloodType,
|
||||
p.InitialMedicalNotes,
|
||||
p.IsActive))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace Baya.Test.Api;
|
||||
|
||||
public class CustomerProfilesApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
|
||||
{
|
||||
[Fact]
|
||||
public async Task Upsert_ThenMe_RoundTripsThroughTheEncryptedColumn()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
await ProfileTestClient.AuthenticateAsync(factory, client, "09122000001", "customer");
|
||||
|
||||
var upsert = await client.PostAsJsonAsync("/api/v1/customer_profiles/upsert",
|
||||
new { defaultEmergencyContactName = "Ali", defaultEmergencyContactPhone = "09120000099" });
|
||||
Assert.Equal(HttpStatusCode.OK, upsert.StatusCode);
|
||||
|
||||
// The value survives the encrypt-on-write / decrypt-on-read converter round-trip.
|
||||
var me = await client.GetAsync("/api/v1/customer_profiles/me");
|
||||
var data = await AuthTestClient.ReadDataAsync(me);
|
||||
Assert.Equal("Ali", data.GetProperty("defaultEmergencyContactName").GetString());
|
||||
Assert.Equal("09120000099", data.GetProperty("defaultEmergencyContactPhone").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Me_Unauthenticated_Returns401()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v1/customer_profiles/me");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_InvalidEmergencyPhone_Returns400()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
await ProfileTestClient.AuthenticateAsync(factory, client, "09122000002", "customer");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/customer_profiles/upsert",
|
||||
new { defaultEmergencyContactName = "Ali", defaultEmergencyContactPhone = "not-a-phone" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Baya.Test.Api;
|
||||
|
||||
public class NurseBankAccountsApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
|
||||
{
|
||||
private const string Iban1 = "IR000000000000000000000001";
|
||||
private const string Iban2 = "IR000000000000000000000002";
|
||||
private const string MismatchIban = "IR000000000000000000000000";
|
||||
|
||||
private static object AccountBody(string iban) => new { bankName = "Bank Melli", accountHolderName = "Nurse Name", iban };
|
||||
|
||||
[Fact]
|
||||
public async Task Add_List_Duplicate_And_PrimaryFlip()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
await ProfileTestClient.AuthenticateAsync(factory, client, "09124000001", "nurse");
|
||||
await client.PostAsJsonAsync("/api/v1/nurse_profiles/upsert",
|
||||
new { bio = "b", yearsOfExperience = 5, educationLevel = "", educationField = "", specializationsJson = "[]" });
|
||||
|
||||
// First account: ownership inquiry matches, becomes primary, IBAN masked on the wire.
|
||||
var add1 = await client.PostAsJsonAsync("/api/v1/nurse_bank_accounts/add", AccountBody(Iban1));
|
||||
Assert.Equal(HttpStatusCode.OK, add1.StatusCode);
|
||||
var acc1 = await AuthTestClient.ReadDataAsync(add1);
|
||||
var id1 = acc1.GetProperty("id").GetInt64();
|
||||
Assert.True(acc1.GetProperty("matchedNationalId").GetBoolean());
|
||||
Assert.True(acc1.GetProperty("isPrimary").GetBoolean());
|
||||
var masked = acc1.GetProperty("ibanMasked").GetString()!;
|
||||
Assert.DoesNotContain(Iban1, masked);
|
||||
Assert.EndsWith("0001", masked);
|
||||
|
||||
// Duplicate IBAN is a clean failure via the iban_hash uniqueness — not an unhandled exception.
|
||||
var duplicate = await client.PostAsJsonAsync("/api/v1/nurse_bank_accounts/add", AccountBody(Iban1));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode);
|
||||
|
||||
// Second account: not primary.
|
||||
var add2 = await client.PostAsJsonAsync("/api/v1/nurse_bank_accounts/add", AccountBody(Iban2));
|
||||
var acc2 = await AuthTestClient.ReadDataAsync(add2);
|
||||
var id2 = acc2.GetProperty("id").GetInt64();
|
||||
Assert.False(acc2.GetProperty("isPrimary").GetBoolean());
|
||||
|
||||
// Flip primary to the second account.
|
||||
var setPrimary = await client.PostAsJsonAsync($"/api/v1/nurse_bank_accounts/set_primary/{id2}", new { });
|
||||
Assert.Equal(HttpStatusCode.OK, setPrimary.StatusCode);
|
||||
|
||||
var list = await client.GetAsync("/api/v1/nurse_bank_accounts/list");
|
||||
var accounts = (await AuthTestClient.ReadDataAsync(list)).EnumerateArray().ToList();
|
||||
Assert.Equal(2, accounts.Count);
|
||||
Assert.True(Primary(accounts, id2));
|
||||
Assert.False(Primary(accounts, id1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_MismatchIban_RecordsMatchedFalse()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
await ProfileTestClient.AuthenticateAsync(factory, client, "09124000002", "nurse");
|
||||
await client.PostAsJsonAsync("/api/v1/nurse_profiles/upsert",
|
||||
new { bio = "b", yearsOfExperience = 5, educationLevel = "", educationField = "", specializationsJson = "[]" });
|
||||
|
||||
var add = await client.PostAsJsonAsync("/api/v1/nurse_bank_accounts/add", AccountBody(MismatchIban));
|
||||
Assert.Equal(HttpStatusCode.OK, add.StatusCode);
|
||||
var data = await AuthTestClient.ReadDataAsync(add);
|
||||
Assert.False(data.GetProperty("matchedNationalId").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_Unauthenticated_Returns401()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v1/nurse_bank_accounts/list");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
private static bool Primary(IEnumerable<JsonElement> accounts, long id) =>
|
||||
accounts.Single(a => a.GetProperty("id").GetInt64() == id).GetProperty("isPrimary").GetBoolean();
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace Baya.Test.Api;
|
||||
|
||||
public class NurseProfilesApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
|
||||
{
|
||||
[Fact]
|
||||
public async Task Upsert_ThenMe_CreatesUnverifiedProfileWithZeroAggregates()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
await ProfileTestClient.AuthenticateAsync(factory, client, "09121000001", "nurse");
|
||||
|
||||
var upsert = await client.PostAsJsonAsync("/api/v1/nurse_profiles/upsert",
|
||||
new { bio = "20 years in geriatric care", yearsOfExperience = 20, educationLevel = "BSc", educationField = "Nursing", specializationsJson = "[\"elderly\"]" });
|
||||
Assert.Equal(HttpStatusCode.OK, upsert.StatusCode);
|
||||
|
||||
var me = await client.GetAsync("/api/v1/nurse_profiles/me");
|
||||
Assert.Equal(HttpStatusCode.OK, me.StatusCode);
|
||||
var data = await AuthTestClient.ReadDataAsync(me);
|
||||
|
||||
Assert.False(data.GetProperty("isVerified").GetBoolean());
|
||||
Assert.False(data.GetProperty("isAcceptingBookings").GetBoolean());
|
||||
Assert.Equal(0, data.GetProperty("totalReviews").GetInt32());
|
||||
Assert.Equal(20, data.GetProperty("yearsOfExperience").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAcceptingBookings_TogglesWithoutVerifying()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
await ProfileTestClient.AuthenticateAsync(factory, client, "09121000002", "nurse");
|
||||
await client.PostAsJsonAsync("/api/v1/nurse_profiles/upsert",
|
||||
new { bio = "b", yearsOfExperience = 3, educationLevel = "", educationField = "", specializationsJson = "[]" });
|
||||
|
||||
var toggle = await client.PostAsJsonAsync("/api/v1/nurse_profiles/set_accepting_bookings", new { accepting = true });
|
||||
Assert.Equal(HttpStatusCode.OK, toggle.StatusCode);
|
||||
|
||||
var me = await client.GetAsync("/api/v1/nurse_profiles/me");
|
||||
var data = await AuthTestClient.ReadDataAsync(me);
|
||||
Assert.True(data.GetProperty("isAcceptingBookings").GetBoolean());
|
||||
Assert.False(data.GetProperty("isVerified").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Me_Unauthenticated_Returns401()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v1/nurse_profiles/me");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_InvalidExperience_Returns400()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
await ProfileTestClient.AuthenticateAsync(factory, client, "09121000003", "nurse");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/nurse_profiles/upsert",
|
||||
new { bio = "b", yearsOfExperience = -5, educationLevel = "", educationField = "", specializationsJson = "[]" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace Baya.Test.Api;
|
||||
|
||||
public class PatientsApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
|
||||
{
|
||||
private static object PatientBody(string name, string gender = "female") => new
|
||||
{
|
||||
displayName = name,
|
||||
firstName = "F",
|
||||
lastName = "L",
|
||||
birthDate = "1950-03-01",
|
||||
gender,
|
||||
bloodType = "O+",
|
||||
initialMedicalNotes = "diabetic"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Create_List_Get_Update_Archive_Lifecycle()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
await ProfileTestClient.AuthenticateAsync(factory, client, "09123000001", "customer");
|
||||
|
||||
var create = await client.PostAsJsonAsync("/api/v1/patients/create", PatientBody("Mother"));
|
||||
Assert.Equal(HttpStatusCode.OK, create.StatusCode);
|
||||
var created = await AuthTestClient.ReadDataAsync(create);
|
||||
var id = created.GetProperty("id").GetInt64();
|
||||
Assert.Equal("female", created.GetProperty("gender").GetString());
|
||||
Assert.Equal("diabetic", created.GetProperty("initialMedicalNotes").GetString());
|
||||
|
||||
var list = await client.GetAsync("/api/v1/patients/list");
|
||||
var listData = await AuthTestClient.ReadDataAsync(list);
|
||||
Assert.Equal(1, listData.GetProperty("total").GetInt32());
|
||||
|
||||
var get = await client.GetAsync($"/api/v1/patients/get/{id}");
|
||||
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
|
||||
|
||||
var update = await client.PostAsJsonAsync($"/api/v1/patients/update/{id}", PatientBody("Mother Renamed", "female"));
|
||||
Assert.Equal(HttpStatusCode.OK, update.StatusCode);
|
||||
var updated = await AuthTestClient.ReadDataAsync(update);
|
||||
Assert.Equal("Mother Renamed", updated.GetProperty("displayName").GetString());
|
||||
|
||||
var archive = await client.PostAsJsonAsync($"/api/v1/patients/archive/{id}", new { });
|
||||
Assert.Equal(HttpStatusCode.OK, archive.StatusCode);
|
||||
|
||||
var afterArchive = await client.GetAsync($"/api/v1/patients/get/{id}");
|
||||
var archivedData = await AuthTestClient.ReadDataAsync(afterArchive);
|
||||
Assert.False(archivedData.GetProperty("isActive").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_And_Update_OfAnotherCustomersPatient_Return404()
|
||||
{
|
||||
// Customer A creates a patient.
|
||||
var clientA = factory.CreateClient();
|
||||
await ProfileTestClient.AuthenticateAsync(factory, clientA, "09123000002", "customer");
|
||||
var create = await clientA.PostAsJsonAsync("/api/v1/patients/create", PatientBody("A's patient"));
|
||||
var aPatientId = (await AuthTestClient.ReadDataAsync(create)).GetProperty("id").GetInt64();
|
||||
|
||||
// Customer B can neither read nor mutate A's patient — existence is not leaked.
|
||||
var clientB = factory.CreateClient();
|
||||
await ProfileTestClient.AuthenticateAsync(factory, clientB, "09123000003", "customer");
|
||||
|
||||
var get = await clientB.GetAsync($"/api/v1/patients/get/{aPatientId}");
|
||||
Assert.Equal(HttpStatusCode.NotFound, get.StatusCode);
|
||||
|
||||
var update = await clientB.PostAsJsonAsync($"/api/v1/patients/update/{aPatientId}", PatientBody("hijack"));
|
||||
Assert.Equal(HttpStatusCode.NotFound, update.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_Unauthenticated_Returns401()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v1/patients/list");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_MissingGender_Returns400()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
await ProfileTestClient.AuthenticateAsync(factory, client, "09123000004", "customer");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/patients/create", PatientBody("No gender", gender: ""));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace Baya.Test.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Logs a user in, grants a public role, then refreshes so the bearer token carries the new role claim
|
||||
/// (role claims are baked into the access token at mint time — see backend-phase-2). Uses one OTP verify
|
||||
/// plus one refresh per call to stay inside the OTP endpoint's per-IP rate-limit budget.
|
||||
/// </summary>
|
||||
internal static class ProfileTestClient
|
||||
{
|
||||
public static async Task AuthenticateAsync(BayaApiFactory factory, HttpClient client, string phone, string role)
|
||||
{
|
||||
var tokens = await AuthTestClient.LoginAsync(factory, client, phone);
|
||||
|
||||
AuthTestClient.UseBearer(client, tokens.GetProperty("accessToken").GetString()!);
|
||||
var select = await client.PostAsJsonAsync("/api/v1/me/select_role", new { role });
|
||||
select.EnsureSuccessStatusCode();
|
||||
|
||||
var refreshToken = tokens.GetProperty("refreshToken").GetString()!;
|
||||
var refreshed = await client.PostAsJsonAsync("/api/v1/auth/refresh", new { refreshToken });
|
||||
refreshed.EnsureSuccessStatusCode();
|
||||
|
||||
var data = await AuthTestClient.ReadDataAsync(refreshed);
|
||||
AuthTestClient.UseBearer(client, data.GetProperty("accessToken").GetString()!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Identity.Commands.AddNurseBankAccount;
|
||||
using Baya.Application.Features.Identity.Commands.SetPrimaryBankAccount;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
|
||||
namespace Baya.Test.Foundation.Identity;
|
||||
|
||||
public class NurseBankAccountHandlersTests
|
||||
{
|
||||
private const string ValidIban = "IR062960000000100324200001";
|
||||
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
|
||||
private readonly INurseBankAccountRepository _accounts = Substitute.For<INurseBankAccountRepository>();
|
||||
private readonly IFieldEncryptor _encryptor = Substitute.For<IFieldEncryptor>();
|
||||
private readonly IBankAccountOwnershipVerifier _verifier = Substitute.For<IBankAccountOwnershipVerifier>();
|
||||
|
||||
public NurseBankAccountHandlersTests()
|
||||
{
|
||||
_currentUser.UserId.Returns(7);
|
||||
_currentUser.Roles.Returns([RoleNames.Nurse]);
|
||||
_unitOfWork.NurseProfileRepository.Returns(_nurses);
|
||||
_unitOfWork.NurseBankAccountRepository.Returns(_accounts);
|
||||
_nurses.GetIdentityContextByUserIdAsync(7, Arg.Any<CancellationToken>())
|
||||
.Returns(new NurseIdentityContext(42L, "0012345678"));
|
||||
_encryptor.Hash(Arg.Any<string>()).Returns(ci => "HASH-" + ci.Arg<string>());
|
||||
}
|
||||
|
||||
private AddNurseBankAccountCommandHandler CreateAddHandler() =>
|
||||
new(_currentUser, _unitOfWork, _encryptor, _verifier);
|
||||
|
||||
[Fact]
|
||||
public async Task Add_MatchingIban_RunsInquiryAndSetsMatchedTrueAndPrimary()
|
||||
{
|
||||
_accounts.IbanHashExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(false);
|
||||
_accounts.HasAnyAsync(42L, Arg.Any<CancellationToken>()).Returns(false);
|
||||
_verifier.VerifyOwnershipAsync(Arg.Any<string>(), "0012345678", Arg.Any<CancellationToken>())
|
||||
.Returns(new OwnershipInquiryResult(true, "Verified Holder", "MOCK-SHEBA-ABC"));
|
||||
var handler = CreateAddHandler();
|
||||
|
||||
var result = await handler.Handle(new AddNurseBankAccountCommand("Bank Melli", "Nurse Name", ValidIban), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.Result.MatchedNationalId);
|
||||
Assert.True(result.Result.IsPrimary);
|
||||
Assert.DoesNotContain(ValidIban, result.Result.IbanMasked);
|
||||
await _verifier.Received(1).VerifyOwnershipAsync(ValidIban, "0012345678", Arg.Any<CancellationToken>());
|
||||
await _accounts.Received(1).AddAsync(
|
||||
Arg.Is<NurseBankAccount>(a => a.NurseId == 42L && a.MatchedNationalId == true && a.OwnershipVendorRef == "MOCK-SHEBA-ABC" && a.IbanHash == "HASH-" + ValidIban),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _unitOfWork.Received(1).CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_MismatchIban_RecordsMatchedFalse()
|
||||
{
|
||||
_accounts.IbanHashExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(false);
|
||||
_accounts.HasAnyAsync(42L, Arg.Any<CancellationToken>()).Returns(true);
|
||||
_verifier.VerifyOwnershipAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new OwnershipInquiryResult(false, "Someone Else", "MOCK-SHEBA-XYZ"));
|
||||
var handler = CreateAddHandler();
|
||||
|
||||
var result = await handler.Handle(new AddNurseBankAccountCommand("Bank Melli", "Nurse Name", ValidIban), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.Result.MatchedNationalId);
|
||||
Assert.False(result.Result.IsPrimary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_DuplicateIban_IsRejectedBeforeInsert()
|
||||
{
|
||||
_accounts.IbanHashExistsAsync("HASH-" + ValidIban, Arg.Any<CancellationToken>()).Returns(true);
|
||||
var handler = CreateAddHandler();
|
||||
|
||||
var result = await handler.Handle(new AddNurseBankAccountCommand("Bank Melli", "Nurse Name", ValidIban), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
await _accounts.DidNotReceive().AddAsync(Arg.Any<NurseBankAccount>(), Arg.Any<CancellationToken>());
|
||||
await _verifier.DidNotReceive().VerifyOwnershipAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||
await _unitOfWork.DidNotReceive().CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_NoNurseProfile_IsFailure()
|
||||
{
|
||||
_nurses.GetIdentityContextByUserIdAsync(7, Arg.Any<CancellationToken>()).ReturnsNull();
|
||||
var handler = CreateAddHandler();
|
||||
|
||||
var result = await handler.Handle(new AddNurseBankAccountCommand("Bank Melli", "Nurse Name", ValidIban), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
await _accounts.DidNotReceive().AddAsync(Arg.Any<NurseBankAccount>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetPrimary_OwnedNonPrimary_FlipsAtomically()
|
||||
{
|
||||
_nurses.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
|
||||
_accounts.GetOwnedAsync(5L, 42L, Arg.Any<CancellationToken>()).Returns(new NurseBankAccount { NurseId = 42L, IsPrimary = false });
|
||||
var handler = new SetPrimaryBankAccountCommandHandler(_currentUser, _unitOfWork);
|
||||
|
||||
var result = await handler.Handle(new SetPrimaryBankAccountCommand(5L), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await _accounts.Received(1).SetPrimaryAsync(42L, 5L, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetPrimary_NotOwned_IsNotFound()
|
||||
{
|
||||
_nurses.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
|
||||
_accounts.GetOwnedAsync(5L, 42L, Arg.Any<CancellationToken>()).ReturnsNull();
|
||||
var handler = new SetPrimaryBankAccountCommandHandler(_currentUser, _unitOfWork);
|
||||
|
||||
var result = await handler.Handle(new SetPrimaryBankAccountCommand(5L), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsNotFound);
|
||||
await _accounts.DidNotReceive().SetPrimaryAsync(Arg.Any<long>(), Arg.Any<long>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings;
|
||||
using Baya.Application.Features.Identity.Commands.UpsertNurseProfile;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
|
||||
namespace Baya.Test.Foundation.Identity;
|
||||
|
||||
public class NurseProfileHandlersTests
|
||||
{
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
private readonly INurseProfileRepository _repo = Substitute.For<INurseProfileRepository>();
|
||||
|
||||
public NurseProfileHandlersTests()
|
||||
{
|
||||
_currentUser.UserId.Returns(7);
|
||||
_currentUser.Roles.Returns([RoleNames.Nurse]);
|
||||
_unitOfWork.NurseProfileRepository.Returns(_repo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_NoExistingProfile_CreatesUnverifiedAndCommits()
|
||||
{
|
||||
_repo.GetByUserIdAsync(7, Arg.Any<CancellationToken>()).ReturnsNull();
|
||||
_repo.GetMineAsync(7, Arg.Any<CancellationToken>())
|
||||
.Returns(new NurseProfileDto(1, "bio", 3, "BSc", "Nursing", "[]", false, false, 0m, 0, 0));
|
||||
var handler = new UpsertNurseProfileCommandHandler(_currentUser, _unitOfWork);
|
||||
|
||||
var result = await handler.Handle(new UpsertNurseProfileCommand("bio", 3, "BSc", "Nursing", "[]"), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.Result.IsVerified);
|
||||
await _repo.Received(1).AddAsync(
|
||||
Arg.Is<NurseProfile>(p => p.UserId == 7 && !p.IsVerified && !p.IsAcceptingBookings),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _unitOfWork.Received(1).CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_NonNurseRole_IsForbidden()
|
||||
{
|
||||
_currentUser.Roles.Returns([RoleNames.Customer]);
|
||||
var handler = new UpsertNurseProfileCommandHandler(_currentUser, _unitOfWork);
|
||||
|
||||
var result = await handler.Handle(new UpsertNurseProfileCommand("bio", 3, "BSc", "Nursing", "[]"), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsForbidden);
|
||||
await _repo.DidNotReceive().AddAsync(Arg.Any<NurseProfile>(), Arg.Any<CancellationToken>());
|
||||
await _unitOfWork.DidNotReceive().CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAcceptingBookings_NoProfile_IsNotFound()
|
||||
{
|
||||
_repo.GetByUserIdAsync(7, Arg.Any<CancellationToken>()).ReturnsNull();
|
||||
var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork);
|
||||
|
||||
var result = await handler.Handle(new SetNurseAcceptingBookingsCommand(true), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsNotFound);
|
||||
await _unitOfWork.DidNotReceive().CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAcceptingBookings_ExistingProfile_TogglesWithoutTouchingVerified()
|
||||
{
|
||||
var profile = new NurseProfile { UserId = 7 };
|
||||
_repo.GetByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(profile);
|
||||
var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork);
|
||||
|
||||
var result = await handler.Handle(new SetNurseAcceptingBookingsCommand(true), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(profile.IsAcceptingBookings);
|
||||
Assert.False(profile.IsVerified);
|
||||
await _unitOfWork.Received(1).CommitAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Identity.Commands.CreatePatient;
|
||||
using Baya.Application.Features.Identity.Commands.UpdatePatient;
|
||||
using Baya.Application.Features.Identity.Queries.GetPatient;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
|
||||
namespace Baya.Test.Foundation.Identity;
|
||||
|
||||
public class PatientHandlersTests
|
||||
{
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
private readonly ICustomerProfileRepository _customers = Substitute.For<ICustomerProfileRepository>();
|
||||
private readonly IPatientRepository _patients = Substitute.For<IPatientRepository>();
|
||||
|
||||
public PatientHandlersTests()
|
||||
{
|
||||
_currentUser.UserId.Returns(7);
|
||||
_currentUser.Roles.Returns([RoleNames.Customer]);
|
||||
_unitOfWork.CustomerProfileRepository.Returns(_customers);
|
||||
_unitOfWork.PatientRepository.Returns(_patients);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_UnderExistingCustomer_UsesResolvedCustomerId()
|
||||
{
|
||||
_customers.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
|
||||
var handler = new CreatePatientCommandHandler(_currentUser, _unitOfWork);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new CreatePatientCommand("Mother", "A", "B", new DateOnly(1950, 1, 1), "female", "O+", "notes"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("female", result.Result.Gender);
|
||||
await _patients.Received(1).AddAsync(Arg.Is<Patient>(p => p.CustomerId == 42L && p.IsActive), Arg.Any<CancellationToken>());
|
||||
await _customers.DidNotReceive().AddAsync(Arg.Any<CustomerProfile>(), Arg.Any<CancellationToken>());
|
||||
await _unitOfWork.Received(1).CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_NoCustomerProfileYet_AutoProvisionsProfile()
|
||||
{
|
||||
_customers.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns((long?)null);
|
||||
var handler = new CreatePatientCommandHandler(_currentUser, _unitOfWork);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new CreatePatientCommand("Mother", "A", "B", new DateOnly(1950, 1, 1), "female", "O+", "notes"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await _customers.Received(1).AddAsync(Arg.Is<CustomerProfile>(c => c.UserId == 7), Arg.Any<CancellationToken>());
|
||||
await _patients.Received(1).AddAsync(Arg.Any<Patient>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_NonCustomerRole_IsForbidden()
|
||||
{
|
||||
_currentUser.Roles.Returns([RoleNames.Nurse]);
|
||||
var handler = new CreatePatientCommandHandler(_currentUser, _unitOfWork);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new CreatePatientCommand("Mother", "A", "B", new DateOnly(1950, 1, 1), "female", "O+", "notes"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsForbidden);
|
||||
await _patients.DidNotReceive().AddAsync(Arg.Any<Patient>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_OtherCustomersPatient_IsNotFound()
|
||||
{
|
||||
// Tenancy: the repo scopes by customerId, so a non-owned patient resolves to null → not-found.
|
||||
_customers.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
|
||||
_patients.GetOwnedAsync(99L, 42L, Arg.Any<CancellationToken>()).ReturnsNull();
|
||||
var handler = new UpdatePatientCommandHandler(_currentUser, _unitOfWork);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new UpdatePatientCommand(99L, "X", "A", "B", new DateOnly(1960, 5, 5), "male", null, null),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsNotFound);
|
||||
await _unitOfWork.DidNotReceive().CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_OtherCustomersPatient_IsNotFound()
|
||||
{
|
||||
_customers.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
|
||||
_patients.GetOwnedProjectedAsync(99L, 42L, Arg.Any<CancellationToken>()).ReturnsNull();
|
||||
var handler = new GetPatientQueryHandler(_currentUser, _unitOfWork);
|
||||
|
||||
var result = await handler.Handle(new GetPatientQuery(99L), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsNotFound);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user