backend phase 6: nurse verification & credentials (mocked vendors)

The trust engine. New `verif` schema (5 tables) + a data-driven verification
pipeline: steps are rows (6 seeded step-types), not a code enum.

- nurse_verifications.status is the single source of verification truth;
  nurse_profiles.is_verified is flipped ONLY inside the finalize transaction
  (VerificationAggregator: tracked verification + tracked profile -> one commit)
  and reversed on suspension/expiry — no in-between state.
- is_automated snapshotted onto each step at submit; steps seeded from active
  required step-types; automated runs (identity-KYC, Shahkar, IBAN ownership)
  find their step by code.
- users.national_id populated only on identity-KYC pass; Shahkar + IBAN owner
  compare against it (money-mule guard); shared-SIM -> shared_sim support alert.
- Documents are metadata-only behind signed URLs; credential_number encrypted
  and never serialized; public trust badge exposes credential TYPES, not numbers;
  holder-name cross-checked against the verified identity before recording.
- Admin-triggered credential-expiry scan reverts lapsed steps, re-gates
  bookability, raises a verification_expired alert + verification_expiry_prompt
  notification (scheduled cron deferred; config key
  verification_expiry_scan_cadence_hours).

Three new mock vendor seams (IShahkarVerifier / IIdentityKycProvider /
ICredentialVerifier) behind DI; reuses b3 IBankAccountOwnershipVerifier and
b0 IObjectStorage/IFieldEncryptor. 15 endpoints across 4 controllers.

Two migrations (tables + step-type seed). 154 tests pass, zero new warnings.
Contract dev/contracts/domains/verification.md + swagger snapshot refreshed;
handoff/report/mocks-registry updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-05 14:39:32 +03:30
parent 687fbfc6d9
commit 1c266523bc
105 changed files with 13938 additions and 10 deletions
+6 -5
View File
@@ -81,15 +81,15 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
```
src/
├── Core/
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + the platform-signal facade contracts + Contracts/Persistence per-domain repositories on IUnitOfWork), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly)
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service)
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier) + AddCrossCuttingSeams
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier) + AddCrossCuttingSeams
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
├── API/
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants), appsettings*.json
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge)), appsettings*.json
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
├── Shared/Baya.SharedKernel Extensions + validation base
@@ -106,7 +106,8 @@ Application reference Infrastructure or the API — this is a hard rule.
**Cross-cutting seams.** Application defines mock-able external dependencies as interfaces in
`Contracts/Common/` (`IDateTimeProvider`, `IFieldEncryptor`, `ICacheService`, `IObjectStorage`,
`INotificationDispatcher`, `IGeocoder`, plus `ICurrentUser`). Their in-memory/local mock implementations live in
`INotificationDispatcher`, `IGeocoder`, `IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`,
plus `ICurrentUser`). Their in-memory/local mock implementations live in
`Baya.Infrastructure.CrossCutting/Seams/` and are registered by `AddCrossCuttingSeams(configuration)`
(config section `Seams`); `ICurrentUser` is registered in the Identity layer. Swapping a mock for a
real provider is a registration change — handlers depend only on the contract. Audit fields are
+20
View File
@@ -312,6 +312,26 @@ collide). Persist it (`NVARCHAR(64)`) and back it with a **filtered unique index
with a handler pre-check for the friendly `409`. Reuse this helper for any future "same set of ids" guard;
do **not** reuse `IFieldEncryptor.Hash` (that is for PII-column equality lookups).
### Guarded cross-aggregate state flip (backend-phase-6)
When one write must atomically change a header row's state **and** a derived boolean on a *different*
aggregate (e.g. `nurse_verifications.status``nurse_profiles.is_verified`), do it in one transaction:
load **both** as tracked entities, mutate them through a single pure domain helper
(`VerificationAggregator.Finalize`), then `CommitAsync` **once** — never flip the derived flag from a
controller, a partial write, or an out-of-band update, and never leave an in-between state. Two follow-on
rules this establishes:
- **Self-committing facades come after the atomic commit.** `ISupportAlertService.RaiseAsync`,
`INotificationDispatcher.DispatchAsync`, `IAuditLogger.WriteAsync` and `IPlatformConfig.SetConfig` each call
`SaveChanges` on the *shared scoped* `DbContext`. Calling one mid-build flushes your partial tracked changes —
invoke them only **after** `unitOfWork.CommitAsync()`. In a batch loop that commits per item, load and guard
every dependency **before** mutating tracked state, or an early `continue` leaks a dirty entity that a later
iteration's commit will flush.
- **Persist a status enum as its stable snake_case code, not the member name.** Define the C# enum, then map
it with a `HasConversion(e => e.ToCode(), s => Parse(s))` value converter (see `VerificationCodes`) so the DB
and the wire carry `in_review`, not `InReview`. Enum→code mapping in a projected read happens **in memory
after materialization** (`.ToCode()` is not LINQ-translatable); DTOs expose the code string.
---
## 7. Validation
@@ -0,0 +1,40 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Verification.Commands.DeactivateStepType;
using Baya.Application.Features.Verification.Commands.UpsertStepType;
using Baya.Application.Features.Verification.Queries.ListStepTypes;
using Baya.Application.Models.Verification;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Baya.WebFramework.ServiceConfiguration;
namespace Baya.Web.Api.Controllers.V1;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize(ConstantPolicies.DynamicPermission)]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[Display(Description = "Admin catalog of verification step-types (the data-driven pipeline)")]
public sealed class AdminVerificationStepTypesController(ISender sender) : BaseController
{
[HttpGet]
[ProducesOkApiResponseType<IReadOnlyList<VerificationStepTypeDto>>]
public async Task<IActionResult> List([FromQuery] bool includeInactive, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new AdminListStepTypesQuery(includeInactive), cancellationToken));
[HttpPost]
[ProducesOkApiResponseType<VerificationStepTypeDto>]
public async Task<IActionResult> Upsert(AdminUpsertStepTypeCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpDelete("{id}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> Deactivate(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new AdminDeactivateStepTypeCommand(id), cancellationToken));
}
@@ -0,0 +1,53 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Verification.Commands.ReviewStep;
using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
using Baya.Application.Features.Verification.Commands.SuspendVerification;
using Baya.Application.Features.Verification.Queries.GetVerificationDetail;
using Baya.Application.Features.Verification.Queries.ListPendingSteps;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Baya.WebFramework.ServiceConfiguration;
namespace Baya.Web.Api.Controllers.V1;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize(ConstantPolicies.DynamicPermission)]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[Display(Description = "Admin verification review queue, decisions, suspension and expiry scan")]
public sealed class AdminVerificationsController(ISender sender) : BaseController
{
[HttpGet]
[ProducesOkApiResponseType<PagedResult<AdminPendingStepDto>>]
public async Task<IActionResult> List([FromQuery] AdminListPendingStepsQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
[HttpGet("{nurseVerificationId}")]
[ProducesOkApiResponseType<AdminVerificationDetailDto>]
public async Task<IActionResult> Get(long nurseVerificationId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new AdminGetVerificationDetailQuery(nurseVerificationId), cancellationToken));
[HttpPost("steps/{stepId}/decide")]
[ProducesOkApiResponseType<ReviewStepResult>]
public async Task<IActionResult> Decide(long stepId, AdminReviewStepCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { StepId = stepId }, cancellationToken));
[HttpPost("{nurseVerificationId}/suspend")]
[ProducesOkApiResponseType]
public async Task<IActionResult> Suspend(long nurseVerificationId, AdminSuspendVerificationCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { NurseVerificationId = nurseVerificationId }, cancellationToken));
[HttpPost("scan_expiring")]
[ProducesOkApiResponseType<ScanExpiringResult>]
public async Task<IActionResult> ScanExpiring(ScanExpiringCredentialsCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
}
@@ -0,0 +1,60 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Verification.Commands.ConfirmDocumentUpload;
using Baya.Application.Features.Verification.Commands.RequestDocumentUploadUrl;
using Baya.Application.Features.Verification.Commands.RunBankAccountVerification;
using Baya.Application.Features.Verification.Commands.RunIdentityKyc;
using Baya.Application.Features.Verification.Commands.RunShahkarMatch;
using Baya.Application.Features.Verification.Commands.SubmitVerification;
using Baya.Application.Features.Verification.Queries.GetStatus;
using Baya.Application.Models.Verification;
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 verification pipeline (checklist, uploads, automated runs)")]
public sealed class NurseVerificationController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<VerificationStatusDto>]
public async Task<IActionResult> Submit(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new SubmitNurseVerificationCommand(), cancellationToken));
[HttpGet]
[ProducesOkApiResponseType<VerificationStatusDto>]
public async Task<IActionResult> Get(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetNurseVerificationStatusQuery(), cancellationToken));
[HttpPost("steps/{stepId}/[action]")]
[ProducesOkApiResponseType<UploadUrlResult>]
public async Task<IActionResult> UploadUrl(long stepId, RequestDocumentUploadUrlCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { StepId = stepId }, cancellationToken));
[HttpPost("steps/{stepId}/documents")]
[ProducesOkApiResponseType<DocumentConfirmedResult>]
public async Task<IActionResult> ConfirmDocument(long stepId, ConfirmDocumentUploadCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { StepId = stepId }, cancellationToken));
[HttpPost("steps/identity_kyc/run")]
[ProducesOkApiResponseType<RunStepResult>]
public async Task<IActionResult> RunIdentityKyc(RunIdentityKycCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("steps/shahkar_match/run")]
[ProducesOkApiResponseType<RunStepResult>]
public async Task<IActionResult> RunShahkarMatch(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new RunShahkarMatchCommand(), cancellationToken));
[HttpPost("steps/bank_account_verification/run")]
[ProducesOkApiResponseType<RunStepResult>]
public async Task<IActionResult> RunBankAccountVerification(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new RunBankAccountVerificationCommand(), cancellationToken));
}
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Verification.Queries.GetTrustBadge;
using Baya.Application.Models.Verification;
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]")]
[AllowAnonymous]
[Display(Description = "Public nurse read surface (the verified trust badge)")]
public sealed class NursesController(ISender sender) : BaseController
{
// Public: the verified badge exposes credential *types* held, never the encrypted numbers.
[HttpGet("{nurseId}/[action]")]
[ProducesOkApiResponseType<TrustBadgeDto>]
public async Task<IActionResult> TrustBadge(long nurseId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetVerifiedTrustBadgeQuery(nurseId), cancellationToken));
}
@@ -0,0 +1,45 @@
#nullable enable
using System.Globalization;
namespace Baya.Application.Common;
/// <summary>
/// Cross-checks a credential's printed holder name against the nurse's verified identity name before the
/// credential is recorded — the documented defence against the "imposter nurse" forgery, where a real
/// license belonging to someone else is uploaded. Comparison is normalization-tolerant (ZWNJ, Arabic/Persian
/// yeh &amp; kaf variants, spacing, case) and order-insensitive on name tokens, but still rejects a genuinely
/// different name. An empty identity name fails closed (cannot cross-check ⇒ do not record).
/// </summary>
public static class IdentityNameMatch
{
public static bool Matches(string? identityName, string? holderName)
{
var identityTokens = Tokenize(identityName);
var holderTokens = Tokenize(holderName);
if (identityTokens.Count == 0 || holderTokens.Count == 0)
return false;
return identityTokens.SetEquals(holderTokens);
}
private static HashSet<string> Tokenize(string? value)
{
var set = new HashSet<string>(StringComparer.Ordinal);
if (string.IsNullOrWhiteSpace(value))
return set;
foreach (var token in Normalize(value).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
set.Add(token);
return set;
}
private static string Normalize(string value)
=> value
.Replace('', ' ') // ZWNJ → space so "علی‌رضا" and "علی رضا" tokenize the same
.Replace('ي', 'ی') // Arabic yeh → Persian yeh
.Replace('ك', 'ک') // Arabic kaf → Persian kaf
.Trim()
.ToLower(CultureInfo.InvariantCulture);
}
@@ -0,0 +1,77 @@
#nullable enable
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Verification;
namespace Baya.Application.Common;
/// <summary>
/// The single place that rolls per-step outcomes into <see cref="NurseVerification.Status"/> and drives the
/// guarded <c>nurse_profiles.is_verified</c> flip. It mutates the <b>tracked</b> verification + profile
/// in place; the caller commits once, so the status change and the <c>is_verified</c> flip land in the same
/// transaction — there is never an in-between state where the verification is approved but the nurse is not
/// yet bookable (or vice-versa).
///
/// Rules (steps seeded on submit are exactly the active required steps, so "all steps" == "all required"):
/// approved only when every step passed; rejected if any step failed; in_review if any step awaits an admin;
/// else pending. Suspension is terminal here — an already-suspended verification is not auto-recovered.
/// </summary>
public static class VerificationAggregator
{
public static VerificationStatus Finalize(NurseVerification verification, NurseProfile profile, DateTimeOffset now)
{
// Suspension is an explicit admin state; the scanner/automation must not silently un-suspend a nurse.
if (verification.Status == VerificationStatus.Suspended)
{
profile.MarkUnverified();
return verification.Status;
}
var steps = verification.Steps;
var hasSteps = steps.Count > 0;
var anyFailed = steps.Any(s => s.Status == VerificationStepStatus.Failed);
var anyInReview = steps.Any(s => s.Status == VerificationStepStatus.InReview);
var allPassed = hasSteps && steps.All(s => s.Status == VerificationStepStatus.Passed);
if (allPassed)
{
if (verification.Status != VerificationStatus.Approved)
{
verification.Status = VerificationStatus.Approved;
verification.ApprovedAt = now;
verification.RejectedAt = null;
verification.RejectionReason = null;
}
profile.MarkVerified();
return VerificationStatus.Approved;
}
// Not all required steps passed → the nurse is not bookable. Reverse the flip in the same transaction.
profile.MarkUnverified();
verification.ApprovedAt = null;
if (anyFailed)
{
verification.Status = VerificationStatus.Rejected;
verification.RejectedAt = now;
}
else if (anyInReview)
{
verification.Status = VerificationStatus.InReview;
}
else
{
verification.Status = VerificationStatus.Pending;
}
return verification.Status;
}
/// <summary>The codes of required steps not yet passed — the "what's blocking bookability" summary.</summary>
public static IReadOnlyList<string> BlockingStepCodes(IEnumerable<VerificationStep> steps)
=> steps
.Where(s => s.Status != VerificationStepStatus.Passed)
.Select(s => s.StepType?.Code ?? string.Empty)
.Where(c => c.Length > 0)
.ToList();
}
@@ -0,0 +1,32 @@
#nullable enable
namespace Baya.Application.Contracts.Common;
/// <summary>
/// Seam for verifying a professional credential (MoH پروانه صلاحیت حرفه‌ای, INO membership,
/// عدم سوء پیشینه) against its authoritative source. There is <b>no public B2B API</b> for MoH/INO today,
/// so the default implementation returns <see cref="CredentialVerificationStatus.RequiresManualReview"/>
/// (<c>verification_method = manual</c>) — the admin verifies the uploaded document against the official
/// portal. The interface is shaped so an <c>api</c>/<c>portal</c> implementation drops in later without
/// touching callers, keeping the audit-defensible <c>verification_method</c> honest.
/// </summary>
public interface ICredentialVerifier
{
Task<CredentialVerificationResult> VerifyAsync(string credentialType, string? credentialNumber, CancellationToken cancellationToken = default);
}
/// <summary>Whether a credential can be verified automatically or needs a manual admin decision.</summary>
public enum CredentialVerificationStatus
{
RequiresManualReview,
Verified,
Failed
}
/// <summary>Outcome of an <see cref="ICredentialVerifier"/> check.</summary>
/// <param name="Status">Manual today; automated once a portal/API is available.</param>
/// <param name="Method">The <c>verification_method</c> to record (<c>manual</c>/<c>portal</c>/<c>api</c>).</param>
/// <param name="ExternalResponseJson">Any raw source response, persisted for audit (null for manual).</param>
public readonly record struct CredentialVerificationResult(
CredentialVerificationStatus Status,
string Method,
string? ExternalResponseJson);
@@ -0,0 +1,27 @@
#nullable enable
namespace Baya.Application.Contracts.Common;
/// <summary>
/// Seam for the identity KYC vendor — national-id validity + name match + photo/video liveness against the
/// civil-registry (ثبت احوال) record. On pass it yields the matched name used to populate the verified
/// identity and cross-check later credentials. Buy this, don't build it: the mock returns a deterministic
/// pass/fail keyed off a test national-id; the real implementation swaps in an Iranian e-KYC vendor
/// (Finnotech / U-ID / Jibbit / Farashensa / Verify / Kavoshak) by a registration change only.
/// </summary>
public interface IIdentityKycProvider
{
Task<IdentityKycResult> VerifyAsync(string nationalId, string? livenessPayload, CancellationToken cancellationToken = default);
}
/// <summary>Outcome of an <see cref="IIdentityKycProvider"/> verification.</summary>
/// <param name="Passed">Whether identity + liveness passed.</param>
/// <param name="MatchedName">The full name the vendor matched (for the credential cross-check), when passed.</param>
/// <param name="VendorRef">The vendor transaction id, kept for audit.</param>
/// <param name="ExternalResponseJson">The raw vendor response blob, persisted for audit.</param>
/// <param name="FailureReason">A reason when <see cref="Passed"/> is false.</param>
public readonly record struct IdentityKycResult(
bool Passed,
string? MatchedName,
string VendorRef,
string ExternalResponseJson,
string? FailureReason);
@@ -0,0 +1,27 @@
#nullable enable
namespace Baya.Application.Contracts.Common;
/// <summary>
/// Seam for the Shahkar (شاهکار) phone↔national-id binding inquiry — confirms the login SIM is registered
/// to the nurse's own national id. The shared-SIM failure mode (a SIM owned by a family member) is an
/// explicit, handled state, never an undefined edge. The mock returns a deterministic result keyed off a
/// test phone/national-id; the real implementation calls a Finnotech/KYC vendor. No real Shahkar call and
/// no money moves through this seam.
/// </summary>
public interface IShahkarVerifier
{
Task<ShahkarMatchResult> MatchAsync(string? phoneNumber, string? nationalId, CancellationToken cancellationToken = default);
}
/// <summary>Outcome of a <see cref="IShahkarVerifier"/> inquiry.</summary>
/// <param name="Matched">Whether the SIM is bound to the given national id.</param>
/// <param name="IsSharedSim">The explicit shared-SIM failure state (raises a support alert).</param>
/// <param name="VendorRef">The vendor transaction id, kept for audit.</param>
/// <param name="ExternalResponseJson">The raw vendor response blob, persisted for audit.</param>
/// <param name="FailureReason">A non-accusatory reason when <see cref="Matched"/> is false.</param>
public readonly record struct ShahkarMatchResult(
bool Matched,
bool IsSharedSim,
string VendorRef,
string ExternalResponseJson,
string? FailureReason);
@@ -26,6 +26,10 @@ public interface INurseBankAccountRepository
/// primary).</summary>
Task<bool> HasAnyAsync(long nurseId, CancellationToken cancellationToken);
/// <summary>Tracked primary account for the nurse (the payout destination the verification bank-ownership
/// step re-checks) — null if the nurse has no primary account yet.</summary>
Task<NurseBankAccount?> GetPrimaryAsync(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);
}
@@ -9,6 +9,10 @@ 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);
/// <summary>Tracked lookup of the nurse's profile by its own id — the verification finalize/suspend
/// transaction loads it to flip the guarded <c>is_verified</c> flag in the same commit.</summary>
Task<NurseProfile?> GetTrackedByIdAsync(long nurseProfileId, CancellationToken cancellationToken);
Task AddAsync(NurseProfile profile, CancellationToken cancellationToken);
/// <summary>No-tracking projection of the signed-in nurse's profile, incl. read-only verified flag
@@ -14,6 +14,7 @@ public interface IUnitOfWork
public ICustomerAddressRepository CustomerAddressRepository { get; }
public ICatalogRepository CatalogRepository { get; }
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
public IVerificationRepository VerificationRepository { get; }
Task CommitAsync();
ValueTask RollBackAsync();
}
@@ -0,0 +1,61 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Baya.Domain.Entities.Verification;
namespace Baya.Application.Contracts.Persistence;
/// <summary>
/// Persistence for the verification pipeline. Tracked getters (for the state-changing handlers) load the
/// aggregate <see cref="NurseVerification"/> with its steps so the finalize transaction can flip
/// <c>nurse_profiles.is_verified</c> in one <c>SaveChanges</c>. Read getters are projected + paginated;
/// document-bearing reads take a <paramref name="signUrl"/> so signed GET URLs are minted without the
/// repository depending on the storage seam.
/// </summary>
public interface IVerificationRepository
{
// --- Step-type catalog ---
Task<VerificationStepType?> GetStepTypeByIdAsync(long id, CancellationToken cancellationToken);
Task<bool> StepTypeCodeExistsAsync(string code, CancellationToken cancellationToken);
Task<bool> StepTypeInUseAsync(long stepTypeId, CancellationToken cancellationToken);
Task AddStepTypeAsync(VerificationStepType stepType, CancellationToken cancellationToken);
Task<IReadOnlyList<VerificationStepTypeDto>> ListStepTypesAsync(bool includeInactive, CancellationToken cancellationToken);
Task<IReadOnlyList<VerificationStepType>> GetActiveRequiredStepTypesAsync(CancellationToken cancellationToken);
// --- Nurse verification aggregate (tracked, for mutation) ---
Task<NurseVerification?> GetTrackedByNurseIdAsync(long nurseId, CancellationToken cancellationToken);
Task<NurseVerification?> GetTrackedByIdAsync(long nurseVerificationId, CancellationToken cancellationToken);
Task AddVerificationAsync(NurseVerification verification, CancellationToken cancellationToken);
/// <summary>Tracked step scoped to the nurse (tenancy) with its step-type loaded; null if not owned.</summary>
Task<VerificationStep?> GetTrackedStepForNurseAsync(long stepId, long nurseId, CancellationToken cancellationToken);
/// <summary>Tracked step with its parent verification (and all sibling steps) + step-type, for an admin
/// decision that must re-aggregate the whole verification.</summary>
Task<VerificationStep?> GetTrackedStepWithVerificationAsync(long stepId, CancellationToken cancellationToken);
// --- Documents / credentials ---
Task AddDocumentAsync(VerificationDocument document, CancellationToken cancellationToken);
Task AddCredentialAsync(NurseCredential credential, CancellationToken cancellationToken);
// --- Projected reads ---
Task<VerificationStatusDto?> GetStatusForNurseAsync(long nurseId, CancellationToken cancellationToken);
Task<PagedResult<AdminPendingStepDto>> ListPendingStepsAsync(
VerificationStepStatus? status, int page, int pageSize, Func<string, string> signUrl, CancellationToken cancellationToken);
Task<AdminVerificationDetailDto?> GetDetailAsync(long nurseVerificationId, Func<string, string> signUrl, CancellationToken cancellationToken);
Task<TrustBadgeDto?> GetTrustBadgeAsync(long nurseId, DateOnly today, CancellationToken cancellationToken);
/// <summary>The nurse's verified identity name ("Name FamilyName") for the credential holder cross-check.</summary>
Task<string?> GetNurseIdentityNameAsync(long nurseId, CancellationToken cancellationToken);
/// <summary>Tracked <c>users</c> row for the signed-in nurse — the identity-KYC step populates
/// <c>national_id</c> + <c>national_id_verified_at</c> and Shahkar sets <c>shahkar_verified_at</c> on it.</summary>
Task<Baya.Domain.Entities.User.User?> GetTrackedUserAsync(int userId, CancellationToken cancellationToken);
/// <summary>Passed, time-limited steps whose expiry has lapsed — the expiry-scan worklist (paginated).</summary>
Task<IReadOnlyList<ExpiringStepRow>> GetExpiredPassedStepsAsync(
DateTimeOffset asOf, int page, int pageSize, CancellationToken cancellationToken);
}
/// <summary>An expired, previously-passed step that the scanner must revert + re-gate.</summary>
public readonly record struct ExpiringStepRow(long StepId, long NurseVerificationId, long NurseId);
@@ -0,0 +1,68 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.ConfirmDocumentUpload;
internal sealed class ConfirmDocumentUploadCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<ConfirmDocumentUploadCommand, OperationResult<DocumentConfirmedResult>>
{
public async ValueTask<OperationResult<DocumentConfirmedResult>> Handle(
ConfirmDocumentUploadCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<DocumentConfirmedResult>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<DocumentConfirmedResult>.ForbiddenResult("Only a nurse can upload verification documents.");
var context = await unitOfWork.NurseProfileRepository.GetIdentityContextByUserIdAsync(userId, cancellationToken);
if (context is null)
return OperationResult<DocumentConfirmedResult>.NotFoundResult("No nurse profile exists yet.");
var nurseId = context.NurseProfileId;
var step = await unitOfWork.VerificationRepository.GetTrackedStepForNurseAsync(request.StepId, nurseId, cancellationToken);
if (step is null)
return OperationResult<DocumentConfirmedResult>.NotFoundResult("Verification step not found.");
if (step.IsAutomated)
return OperationResult<DocumentConfirmedResult>.FailureResult("This step is verified automatically and does not accept document uploads.");
var now = dateTimeProvider.UtcNow;
var document = new VerificationDocument
{
StepId = step.Id,
ObjectStorageKey = request.ObjectStorageKey,
IntegrityHash = request.IntegrityHash,
ContentType = request.ContentType,
FileSizeBytes = request.FileSizeBytes,
OriginalFileName = request.OriginalFileName,
UploadedByUserId = userId
};
await unitOfWork.VerificationRepository.AddDocumentAsync(document, cancellationToken);
// A manual step with evidence attached moves into the admin review queue.
step.Status = VerificationStepStatus.InReview;
step.StartedAt ??= now;
var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(nurseId, cancellationToken);
if (profile is null)
return OperationResult<DocumentConfirmedResult>.NotFoundResult("Nurse profile not found.");
VerificationAggregator.Finalize(step.NurseVerification, profile, now);
await unitOfWork.CommitAsync();
return OperationResult<DocumentConfirmedResult>.SuccessResult(new DocumentConfirmedResult(document.Id, step.Status.ToCode()));
}
}
@@ -0,0 +1,16 @@
using FluentValidation;
namespace Baya.Application.Features.Verification.Commands.ConfirmDocumentUpload;
// StepId is route-supplied (set via `command with { StepId = ... }`), so it is not validated here.
public sealed class ConfirmDocumentUploadCommandValidator : AbstractValidator<ConfirmDocumentUploadCommand>
{
public ConfirmDocumentUploadCommandValidator()
{
RuleFor(x => x.ObjectStorageKey).NotEmpty().MaximumLength(400);
RuleFor(x => x.IntegrityHash).NotEmpty().MaximumLength(64);
RuleFor(x => x.ContentType).NotEmpty().MaximumLength(100);
RuleFor(x => x.FileSizeBytes).GreaterThan(0);
RuleFor(x => x.OriginalFileName).MaximumLength(260);
}
}
@@ -0,0 +1,18 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.ConfirmDocumentUpload;
/// <summary>
/// Persists the metadata row for a document the client already uploaded to the signed URL — bytes never
/// enter the DB. Moves a manual step to <c>in_review</c>. <c>StepId</c> is route-supplied.
/// </summary>
public record ConfirmDocumentUploadCommand(
long StepId,
string ObjectStorageKey,
string IntegrityHash,
string ContentType,
long FileSizeBytes,
string? OriginalFileName) : IRequest<OperationResult<DocumentConfirmedResult>>;
@@ -0,0 +1,24 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.DeactivateStepType;
internal sealed class AdminDeactivateStepTypeCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
: IRequestHandler<AdminDeactivateStepTypeCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(AdminDeactivateStepTypeCommand request, CancellationToken cancellationToken)
{
var type = await unitOfWork.VerificationRepository.GetStepTypeByIdAsync(request.Id, cancellationToken);
if (type is null)
return OperationResult<bool>.NotFoundResult("Step type not found.");
type.IsActive = false;
await unitOfWork.CommitAsync();
await VerificationCache.InvalidateStepTypesAsync(cache, cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.DeactivateStepType;
/// <summary>Deactivates a step-type (sets <c>is_active = false</c>) — never a hard delete, so historical
/// verifications that seeded a step from it keep their meaning.</summary>
public record AdminDeactivateStepTypeCommand(long Id) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,43 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.RequestDocumentUploadUrl;
internal sealed class RequestDocumentUploadUrlCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IObjectStorage objectStorage)
: IRequestHandler<RequestDocumentUploadUrlCommand, OperationResult<UploadUrlResult>>
{
public async ValueTask<OperationResult<UploadUrlResult>> Handle(
RequestDocumentUploadUrlCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<UploadUrlResult>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<UploadUrlResult>.ForbiddenResult("Only a nurse can upload verification documents.");
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } id)
return OperationResult<UploadUrlResult>.NotFoundResult("No nurse profile exists yet.");
var step = await unitOfWork.VerificationRepository.GetTrackedStepForNurseAsync(request.StepId, id, cancellationToken);
if (step is null)
return OperationResult<UploadUrlResult>.NotFoundResult("Verification step not found.");
if (step.IsAutomated)
return OperationResult<UploadUrlResult>.FailureResult("This step is verified automatically and does not accept document uploads.");
// Opaque, tenant-scoped key; the mock stores on local disk, a real provider presigns a PUT URL.
var key = $"verification/{id}/{step.Id}/{Guid.NewGuid():N}";
var uploadUrl = objectStorage.GetUrl(key);
return OperationResult<UploadUrlResult>.SuccessResult(new UploadUrlResult(key, uploadUrl));
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace Baya.Application.Features.Verification.Commands.RequestDocumentUploadUrl;
// StepId is route-supplied (set via `command with { StepId = ... }`), so it is not validated here.
public sealed class RequestDocumentUploadUrlCommandValidator : AbstractValidator<RequestDocumentUploadUrlCommand>
{
public RequestDocumentUploadUrlCommandValidator()
{
RuleFor(x => x.ContentType).NotEmpty().MaximumLength(100);
RuleFor(x => x.FileName).MaximumLength(260);
}
}
@@ -0,0 +1,11 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.RequestDocumentUploadUrl;
/// <summary>Returns a signed PUT URL for a manual-evidence upload on the nurse's own step. <c>StepId</c> is
/// route-supplied.</summary>
public record RequestDocumentUploadUrlCommand(long StepId, string ContentType, string? FileName)
: IRequest<OperationResult<UploadUrlResult>>;
@@ -0,0 +1,144 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Audit;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Baya.Domain.Entities.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.ReviewStep;
internal sealed class AdminReviewStepCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
ICredentialVerifier credentialVerifier,
IAuditLogger auditLogger,
ICacheService cache,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<AdminReviewStepCommand, OperationResult<ReviewStepResult>>
{
public async ValueTask<OperationResult<ReviewStepResult>> Handle(AdminReviewStepCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } adminId)
return OperationResult<ReviewStepResult>.UnauthorizedResult("Not authenticated.");
var repo = unitOfWork.VerificationRepository;
var step = await repo.GetTrackedStepWithVerificationAsync(request.StepId, cancellationToken);
if (step is null)
return OperationResult<ReviewStepResult>.NotFoundResult("Verification step not found.");
if (step.IsAutomated)
return OperationResult<ReviewStepResult>.FailureResult("Automated steps are decided by their vendor run, not manual review.");
var verification = step.NurseVerification;
var nurseId = verification.NurseId;
var now = dateTimeProvider.UtcNow;
NurseCredential? recordedCredential = null;
if (request.Approve)
{
if (VerificationStepTypeCodes.CredentialBearing.Contains(step.StepType.Code))
{
var credentialResult = await RecordCredentialAsync(request, step, nurseId, adminId, repo, cancellationToken);
if (!credentialResult.IsSuccess)
return OperationResult<ReviewStepResult>.FailureResult(FirstError(credentialResult));
recordedCredential = credentialResult.Result;
}
step.Status = VerificationStepStatus.Passed;
step.FailureReason = null;
step.CompletedAt = now;
}
else
{
step.Status = VerificationStepStatus.Failed;
step.FailureReason = request.RejectionReason;
step.CompletedAt = now;
verification.RejectionReason = request.RejectionReason;
}
verification.ReviewedByAdminId = adminId;
var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(nurseId, cancellationToken);
if (profile is null)
return OperationResult<ReviewStepResult>.NotFoundResult("Nurse profile not found.");
VerificationAggregator.Finalize(verification, profile, now);
// The step decision, the recorded credential, and any is_verified flip land in one transaction.
await unitOfWork.CommitAsync();
await auditLogger.WriteAsync(
"verification_step",
step.Id.ToString(),
request.Approve ? "approve" : "reject",
new Dictionary<string, object?>
{
["step_code"] = step.StepType.Code,
["decision"] = request.Approve ? "passed" : "failed",
["admin_id"] = adminId,
["reason"] = request.Approve ? null : request.RejectionReason
},
cancellationToken);
await VerificationCache.InvalidateBadgeAsync(cache, nurseId, cancellationToken);
return OperationResult<ReviewStepResult>.SuccessResult(new ReviewStepResult(step.Id, step.Status.ToCode(), recordedCredential?.Id));
}
private async ValueTask<OperationResult<NurseCredential>> RecordCredentialAsync(
AdminReviewStepCommand request,
VerificationStep step,
long nurseId,
int adminId,
IVerificationRepository repo,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.CredentialNumber)
|| string.IsNullOrWhiteSpace(request.HolderName)
|| string.IsNullOrWhiteSpace(request.IssuingAuthority))
{
return OperationResult<NurseCredential>.FailureResult("Credential number, holder name and issuing authority are required to approve this step.");
}
// The criminal-record certificate is time-limited — an expiry is mandatory and drives the re-scan.
if (step.StepType.Code == VerificationStepTypeCodes.CriminalRecord && request.ExpiresAt is null)
return OperationResult<NurseCredential>.FailureResult("An expiry date is required for the criminal-record certificate.");
// Anti-forgery gate: the printed holder name must match the verified identity before we record it.
var identityName = await repo.GetNurseIdentityNameAsync(nurseId, cancellationToken);
if (!IdentityNameMatch.Matches(identityName, request.HolderName))
return OperationResult<NurseCredential>.FailureResult("The credential holder name does not match the verified identity — it cannot be recorded.");
var check = await credentialVerifier.VerifyAsync(step.StepType.Code, request.CredentialNumber, cancellationToken);
var credential = new NurseCredential
{
NurseId = nurseId,
CredentialType = step.StepType.Code,
CredentialNumber = request.CredentialNumber!.Trim(),
HolderNameSnapshot = request.HolderName!.Trim(),
IssuingAuthority = request.IssuingAuthority!.Trim(),
IssuedAt = request.IssuedAt,
ExpiresAt = request.ExpiresAt,
VerificationSource = request.VerificationSource,
VerificationMethod = check.Method,
VerifiedByAdminId = adminId
};
await repo.AddCredentialAsync(credential, cancellationToken);
// Mirror the credential expiry onto the (time-limited) step so the scanner can re-gate on it. Valid
// through the whole expiry day → the step lapses at the start of the following day.
if (request.ExpiresAt is { } expires)
step.ExpiresAt = new DateTimeOffset(expires.AddDays(1).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
return OperationResult<NurseCredential>.SuccessResult(credential);
}
private static string FirstError(IOperationResult result)
=> result.ErrorMessages.Count > 0 ? result.ErrorMessages[0].Value : "Credential could not be recorded.";
}
@@ -0,0 +1,22 @@
using FluentValidation;
namespace Baya.Application.Features.Verification.Commands.ReviewStep;
// StepId is route-supplied. Credential-field requirements that depend on the step's code (e.g. criminal
// record requires expires_at) are enforced in the handler, where the step is known.
public sealed class AdminReviewStepCommandValidator : AbstractValidator<AdminReviewStepCommand>
{
public AdminReviewStepCommandValidator()
{
RuleFor(x => x.RejectionReason)
.NotEmpty()
.MaximumLength(1000)
.When(x => !x.Approve)
.WithMessage("A rejection reason is required when rejecting a step.");
RuleFor(x => x.CredentialNumber).MaximumLength(100);
RuleFor(x => x.HolderName).MaximumLength(200);
RuleFor(x => x.IssuingAuthority).MaximumLength(200);
RuleFor(x => x.VerificationSource).MaximumLength(300);
}
}
@@ -0,0 +1,23 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.ReviewStep;
/// <summary>
/// Admin manual decision on a step. <c>Approve=false</c> requires a <c>RejectionReason</c>. Approving a
/// credential-bearing step (MoH / INO / criminal-record) records a <c>nurse_credentials</c> row — the
/// encrypted number plus a holder name that must cross-check against the verified identity. <c>StepId</c>
/// is route-supplied.
/// </summary>
public record AdminReviewStepCommand(
long StepId,
bool Approve,
string? RejectionReason,
string? CredentialNumber,
string? HolderName,
string? IssuingAuthority,
DateOnly? IssuedAt,
DateOnly? ExpiresAt,
string? VerificationSource) : IRequest<OperationResult<ReviewStepResult>>;
@@ -0,0 +1,84 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.RunBankAccountVerification;
internal sealed class RunBankAccountVerificationCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IBankAccountOwnershipVerifier ownershipVerifier,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<RunBankAccountVerificationCommand, OperationResult<RunStepResult>>
{
public async ValueTask<OperationResult<RunStepResult>> Handle(RunBankAccountVerificationCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<RunStepResult>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<RunStepResult>.ForbiddenResult("Only a nurse can run verification.");
var repo = unitOfWork.VerificationRepository;
var context = await unitOfWork.NurseProfileRepository.GetIdentityContextByUserIdAsync(userId, cancellationToken);
if (context is null)
return OperationResult<RunStepResult>.NotFoundResult("No nurse profile exists yet.");
if (string.IsNullOrEmpty(context.NationalId))
return OperationResult<RunStepResult>.FailureResult("Complete identity verification (KYC) before running the bank-account check.");
var nurseId = context.NurseProfileId;
var verification = await repo.GetTrackedByNurseIdAsync(nurseId, cancellationToken);
var step = verification?.Steps.FirstOrDefault(s => s.StepType.Code == VerificationStepTypeCodes.BankAccountVerification);
if (verification is null || step is null)
return OperationResult<RunStepResult>.NotFoundResult("Submit your verification first — the bank-account step is not in your checklist.");
var account = await unitOfWork.NurseBankAccountRepository.GetPrimaryAsync(nurseId, cancellationToken);
if (account is null)
return OperationResult<RunStepResult>.FailureResult("Add a primary payout account before running the bank-account check.");
var now = dateTimeProvider.UtcNow;
step.StartedAt ??= now;
var inquiry = await ownershipVerifier.VerifyOwnershipAsync(account.Iban, context.NationalId, cancellationToken);
account.ApplyOwnershipInquiry(inquiry.MatchedNationalId, inquiry.AccountHolderFromBank, inquiry.VendorRef);
step.ExternalResponseJson = JsonSerializer.Serialize(new
{
provider = "mock_sheba",
matched = inquiry.MatchedNationalId,
vendor_ref = inquiry.VendorRef
});
if (inquiry.MatchedNationalId)
{
step.Status = VerificationStepStatus.Passed;
step.FailureReason = null;
step.CompletedAt = now;
}
else
{
step.Status = VerificationStepStatus.Failed;
step.FailureReason = "The payout account holder's national ID does not match your verified national ID.";
step.CompletedAt = now;
}
var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(nurseId, cancellationToken);
if (profile is null)
return OperationResult<RunStepResult>.NotFoundResult("Nurse profile not found.");
VerificationAggregator.Finalize(verification, profile, now);
await unitOfWork.CommitAsync();
return OperationResult<RunStepResult>.SuccessResult(new RunStepResult(step.Id, step.Status.ToCode(), step.FailureReason));
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.RunBankAccountVerification;
/// <summary>Runs the automated IBAN-ownership (استعلام شبا) step on the nurse's primary payout account —
/// the holder national id must equal the verified nurse national id (money-mule guard). Requires identity
/// KYC to have passed and a primary account to exist.</summary>
public record RunBankAccountVerificationCommand : IRequest<OperationResult<RunStepResult>>;
@@ -0,0 +1,75 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.RunIdentityKyc;
internal sealed class RunIdentityKycCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IIdentityKycProvider identityKyc,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<RunIdentityKycCommand, OperationResult<RunStepResult>>
{
public async ValueTask<OperationResult<RunStepResult>> Handle(RunIdentityKycCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<RunStepResult>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<RunStepResult>.ForbiddenResult("Only a nurse can run verification.");
var repo = unitOfWork.VerificationRepository;
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } id)
return OperationResult<RunStepResult>.NotFoundResult("No nurse profile exists yet.");
var verification = await repo.GetTrackedByNurseIdAsync(id, cancellationToken);
var step = verification?.Steps.FirstOrDefault(s => s.StepType.Code == VerificationStepTypeCodes.IdentityKyc);
if (verification is null || step is null)
return OperationResult<RunStepResult>.NotFoundResult("Submit your verification first — the identity step is not in your checklist.");
var now = dateTimeProvider.UtcNow;
step.StartedAt ??= now;
var kyc = await identityKyc.VerifyAsync(request.NationalId, request.LivenessPayload, cancellationToken);
step.ExternalResponseJson = kyc.ExternalResponseJson;
if (kyc.Passed)
{
var user = await repo.GetTrackedUserAsync(userId, cancellationToken);
if (user is null)
return OperationResult<RunStepResult>.NotFoundResult("User not found.");
// national_id is populated only after this step passes — every downstream comparison uses it.
user.NationalId = request.NationalId;
user.NationalIdVerifiedAt = now;
step.Status = VerificationStepStatus.Passed;
step.FailureReason = null;
step.CompletedAt = now;
}
else
{
step.Status = VerificationStepStatus.Failed;
step.FailureReason = kyc.FailureReason;
step.CompletedAt = now;
}
var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(id, cancellationToken);
if (profile is null)
return OperationResult<RunStepResult>.NotFoundResult("Nurse profile not found.");
VerificationAggregator.Finalize(verification, profile, now);
await unitOfWork.CommitAsync();
return OperationResult<RunStepResult>.SuccessResult(new RunStepResult(step.Id, step.Status.ToCode(), step.FailureReason));
}
}
@@ -0,0 +1,14 @@
using FluentValidation;
namespace Baya.Application.Features.Verification.Commands.RunIdentityKyc;
public sealed class RunIdentityKycCommandValidator : AbstractValidator<RunIdentityKycCommand>
{
public RunIdentityKycCommandValidator()
{
RuleFor(x => x.NationalId)
.NotEmpty()
.Matches(@"^\d{10}$")
.WithMessage("National ID must be exactly 10 digits.");
}
}
@@ -0,0 +1,12 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.RunIdentityKyc;
/// <summary>Runs the automated identity-KYC step for the signed-in nurse. On pass it populates the verified
/// <c>users.national_id</c> — the anchor every downstream comparison (Shahkar, IBAN, credential cross-check)
/// uses.</summary>
public record RunIdentityKycCommand(string NationalId, string? LivenessPayload)
: IRequest<OperationResult<RunStepResult>>;
@@ -0,0 +1,87 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Baya.Domain.Entities.SupportAlerts;
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.RunShahkarMatch;
internal sealed class RunShahkarMatchCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IShahkarVerifier shahkarVerifier,
ISupportAlertService supportAlerts,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<RunShahkarMatchCommand, OperationResult<RunStepResult>>
{
public async ValueTask<OperationResult<RunStepResult>> Handle(RunShahkarMatchCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<RunStepResult>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<RunStepResult>.ForbiddenResult("Only a nurse can run verification.");
var repo = unitOfWork.VerificationRepository;
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } id)
return OperationResult<RunStepResult>.NotFoundResult("No nurse profile exists yet.");
var verification = await repo.GetTrackedByNurseIdAsync(id, cancellationToken);
var step = verification?.Steps.FirstOrDefault(s => s.StepType.Code == VerificationStepTypeCodes.ShahkarMatch);
if (verification is null || step is null)
return OperationResult<RunStepResult>.NotFoundResult("Submit your verification first — the Shahkar step is not in your checklist.");
var user = await repo.GetTrackedUserAsync(userId, cancellationToken);
if (user is null)
return OperationResult<RunStepResult>.NotFoundResult("User not found.");
if (string.IsNullOrEmpty(user.NationalId))
return OperationResult<RunStepResult>.FailureResult("Complete identity verification (KYC) before running the Shahkar check.");
var now = dateTimeProvider.UtcNow;
step.StartedAt ??= now;
var match = await shahkarVerifier.MatchAsync(user.PhoneNumber, user.NationalId, cancellationToken);
step.ExternalResponseJson = match.ExternalResponseJson;
if (match.Matched)
{
user.ShahkarVerifiedAt = now;
step.Status = VerificationStepStatus.Passed;
step.FailureReason = null;
step.CompletedAt = now;
}
else
{
step.Status = VerificationStepStatus.Failed;
step.FailureReason = match.FailureReason;
step.CompletedAt = now;
}
var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(id, cancellationToken);
if (profile is null)
return OperationResult<RunStepResult>.NotFoundResult("Nurse profile not found.");
VerificationAggregator.Finalize(verification, profile, now);
await unitOfWork.CommitAsync();
// Shared-SIM is a distinct, non-accusatory handled state — flag it for staff follow-up. Raised
// after the atomic step write (RaiseAsync self-commits its own alert row).
if (match is { Matched: false, IsSharedSim: true })
{
await supportAlerts.RaiseAsync(
SupportAlertType.SharedSim, "nurse_profile", id.ToString(), SupportAlertSeverity.High,
cancellationToken: cancellationToken);
}
return OperationResult<RunStepResult>.SuccessResult(new RunStepResult(step.Id, step.Status.ToCode(), step.FailureReason));
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.RunShahkarMatch;
/// <summary>Runs the automated Shahkar phone↔national-id binding for the signed-in nurse. Requires identity
/// KYC to have passed (national id present). Shared-SIM is an explicit handled failure that raises a support
/// alert.</summary>
public record RunShahkarMatchCommand : IRequest<OperationResult<RunStepResult>>;
@@ -0,0 +1,86 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Baya.Domain.Entities.SupportAlerts;
using Baya.Domain.Entities.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
internal sealed class ScanExpiringCredentialsCommandHandler(
IUnitOfWork unitOfWork,
ISupportAlertService supportAlerts,
INotificationDispatcher notifications,
ICacheService cache,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<ScanExpiringCredentialsCommand, OperationResult<ScanExpiringResult>>
{
public async ValueTask<OperationResult<ScanExpiringResult>> Handle(ScanExpiringCredentialsCommand request, CancellationToken cancellationToken)
{
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var now = dateTimeProvider.UtcNow;
var repo = unitOfWork.VerificationRepository;
var expired = await repo.GetExpiredPassedStepsAsync(now, page, pageSize, cancellationToken);
var scannedSteps = expired.Count;
var revertedNurses = 0;
foreach (var group in expired.GroupBy(e => e.NurseVerificationId))
{
var verification = await repo.GetTrackedByIdAsync(group.Key, cancellationToken);
if (verification is null)
continue;
var nurseId = verification.NurseId;
// Load and guard the tracked profile BEFORE mutating any step: an early `continue` must never
// leave a dirty (Expired) step in the shared scoped DbContext for a later nurse's commit to
// flush — that would persist the step-expiry without its atomic is_verified re-gate.
var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(nurseId, cancellationToken);
if (profile is null)
continue;
var revertedAny = false;
foreach (var row in group)
{
var step = verification.Steps.FirstOrDefault(s => s.Id == row.StepId);
if (step is { Status: VerificationStepStatus.Passed, ExpiresAt: not null } && step.ExpiresAt < now)
{
step.Status = VerificationStepStatus.Expired;
step.FailureReason = "Credential expired; please re-upload a current certificate.";
step.CompletedAt = now;
revertedAny = true;
}
}
if (!revertedAny)
continue;
// A lapsed required credential must never silently keep a nurse verified — re-gate atomically.
VerificationAggregator.Finalize(verification, profile, now);
await unitOfWork.CommitAsync();
revertedNurses++;
// Side effects (each self-commits its own row): staff worklist + renewal prompt + badge eviction.
await supportAlerts.RaiseAsync(
SupportAlertType.VerificationExpired, "nurse_profile", nurseId.ToString(), SupportAlertSeverity.Medium,
cancellationToken: cancellationToken);
await notifications.DispatchAsync(
new Notification(
profile.UserId,
"verification_expiry_prompt",
"A verification credential has expired",
"A required credential has expired. Please renew it to remain bookable."),
cancellationToken);
await VerificationCache.InvalidateBadgeAsync(cache, nurseId, cancellationToken);
}
return OperationResult<ScanExpiringResult>.SuccessResult(new ScanExpiringResult(scannedSteps, revertedNurses));
}
}
@@ -0,0 +1,11 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
/// <summary>Admin-triggered scan for lapsed time-limited steps (criminal-record especially). Reverts each
/// to expired, raises a support alert + renewal notification, and re-gates bookability. The scheduled cron
/// is deferred — this is the clean entry point it will call. Batched/paginated.</summary>
public record ScanExpiringCredentialsCommand(int Page = 1, int PageSize = 50)
: IRequest<OperationResult<ScanExpiringResult>>;
@@ -0,0 +1,75 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.SubmitVerification;
internal sealed class SubmitNurseVerificationCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<SubmitNurseVerificationCommand, OperationResult<VerificationStatusDto>>
{
public async ValueTask<OperationResult<VerificationStatusDto>> Handle(
SubmitNurseVerificationCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<VerificationStatusDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<VerificationStatusDto>.ForbiddenResult("Only a nurse can submit a verification.");
var repo = unitOfWork.VerificationRepository;
var context = await unitOfWork.NurseProfileRepository.GetIdentityContextByUserIdAsync(userId, cancellationToken);
if (context is null)
return OperationResult<VerificationStatusDto>.FailureResult("No nurse profile exists yet. Create your profile first.");
var nurseId = context.NurseProfileId;
var now = dateTimeProvider.UtcNow;
var verification = await repo.GetTrackedByNurseIdAsync(nurseId, cancellationToken);
if (verification is null)
{
verification = new NurseVerification { NurseId = nurseId, Status = VerificationStatus.Pending, SubmittedAt = now };
await repo.AddVerificationAsync(verification, cancellationToken);
}
else if (verification.SubmittedAt is null)
{
verification.SubmittedAt = now;
}
// Seed one step per active *required* step-type, snapshotting is_automated. Idempotent: only the
// step-types not already seeded are added, so re-submitting never duplicates a step.
var requiredTypes = await repo.GetActiveRequiredStepTypesAsync(cancellationToken);
var existingTypeIds = verification.Steps.Select(s => s.StepTypeId).ToHashSet();
foreach (var stepType in requiredTypes.Where(t => !existingTypeIds.Contains(t.Id)))
{
verification.Steps.Add(new VerificationStep
{
NurseVerification = verification,
StepTypeId = stepType.Id,
Status = VerificationStepStatus.Pending,
IsAutomated = stepType.IsAutomated
});
}
var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(nurseId, cancellationToken);
if (profile is null)
return OperationResult<VerificationStatusDto>.NotFoundResult("Nurse profile not found.");
VerificationAggregator.Finalize(verification, profile, now);
await unitOfWork.CommitAsync();
var status = await repo.GetStatusForNurseAsync(nurseId, cancellationToken);
return OperationResult<VerificationStatusDto>.SuccessResult(status!);
}
}
@@ -0,0 +1,12 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.SubmitVerification;
/// <summary>
/// Starts (or re-syncs) the signed-in nurse's verification: upserts the header and seeds one step per active
/// required step-type, snapshotting each step-type's automation flag. Idempotent — re-submitting never
/// duplicates a step and only adds steps for newly-required step-types.
/// </summary>
public record SubmitNurseVerificationCommand : IRequest<OperationResult<VerificationStatusDto>>;
@@ -0,0 +1,58 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Audit;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.SuspendVerification;
internal sealed class AdminSuspendVerificationCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IAuditLogger auditLogger,
ICacheService cache,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<AdminSuspendVerificationCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(AdminSuspendVerificationCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } adminId)
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
var verification = await unitOfWork.VerificationRepository.GetTrackedByIdAsync(request.NurseVerificationId, cancellationToken);
if (verification is null)
return OperationResult<bool>.NotFoundResult("Verification not found.");
var now = dateTimeProvider.UtcNow;
var nurseId = verification.NurseId;
verification.Status = VerificationStatus.Suspended;
verification.SuspendedAt = now;
verification.ReviewedByAdminId = adminId;
verification.InternalNotes = request.Reason;
var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(nurseId, cancellationToken);
if (profile is null)
return OperationResult<bool>.NotFoundResult("Nurse profile not found.");
// Suspended status → the aggregator reverses is_verified in the same transaction.
VerificationAggregator.Finalize(verification, profile, now);
await unitOfWork.CommitAsync();
await auditLogger.WriteAsync(
"nurse_verification",
verification.Id.ToString(),
"suspend",
new Dictionary<string, object?> { ["admin_id"] = adminId, ["reason"] = request.Reason },
cancellationToken);
// Safety-critical: a suspended nurse must not read as verified — evict the badge immediately.
await VerificationCache.InvalidateBadgeAsync(cache, nurseId, cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.Verification.Commands.SuspendVerification;
// NurseVerificationId is route-supplied (set via `command with { ... }`), so it is not validated here.
public sealed class AdminSuspendVerificationCommandValidator : AbstractValidator<AdminSuspendVerificationCommand>
{
public AdminSuspendVerificationCommandValidator()
{
RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.SuspendVerification;
/// <summary>Suspends a nurse's verification and reverses the <c>is_verified</c> flip in the same
/// transaction (un-publishing the nurse from search). <c>NurseVerificationId</c> is route-supplied.</summary>
public record AdminSuspendVerificationCommand(long NurseVerificationId, string Reason)
: IRequest<OperationResult<bool>>;
@@ -0,0 +1,74 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Baya.Domain.Entities.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.UpsertStepType;
internal sealed class AdminUpsertStepTypeCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
: IRequestHandler<AdminUpsertStepTypeCommand, OperationResult<VerificationStepTypeDto>>
{
public async ValueTask<OperationResult<VerificationStepTypeDto>> Handle(
AdminUpsertStepTypeCommand request, CancellationToken cancellationToken)
{
var repo = unitOfWork.VerificationRepository;
VerificationStepType type;
if (request.Id is { } id)
{
var existing = await repo.GetStepTypeByIdAsync(id, cancellationToken);
if (existing is null)
return OperationResult<VerificationStepTypeDto>.NotFoundResult("Step type not found.");
if (!string.Equals(existing.Code, request.Code, StringComparison.Ordinal))
{
// The stable machine code is what steps snapshot against — it must not change once any
// nurse verification already seeded a step from this type.
if (await repo.StepTypeInUseAsync(id, cancellationToken))
return OperationResult<VerificationStepTypeDto>.FailureResult(nameof(request.Code), "Code cannot change once the step type is in use.");
if (await repo.StepTypeCodeExistsAsync(request.Code, cancellationToken))
return OperationResult<VerificationStepTypeDto>.ConflictResult("A step type with this code already exists.");
existing.Code = request.Code;
}
existing.DisplayName = request.DisplayName;
existing.Description = request.Description;
existing.IsRequired = request.IsRequired;
existing.IsAutomated = request.IsAutomated;
existing.AutomationProvider = request.AutomationProvider;
existing.SortOrder = request.SortOrder;
existing.IsActive = request.IsActive;
type = existing;
}
else
{
if (await repo.StepTypeCodeExistsAsync(request.Code, cancellationToken))
return OperationResult<VerificationStepTypeDto>.ConflictResult("A step type with this code already exists.");
type = new VerificationStepType
{
Code = request.Code,
DisplayName = request.DisplayName,
Description = request.Description,
IsRequired = request.IsRequired,
IsAutomated = request.IsAutomated,
AutomationProvider = request.AutomationProvider,
SortOrder = request.SortOrder,
IsActive = request.IsActive
};
await repo.AddStepTypeAsync(type, cancellationToken);
}
await unitOfWork.CommitAsync();
await VerificationCache.InvalidateStepTypesAsync(cache, cancellationToken);
return OperationResult<VerificationStepTypeDto>.SuccessResult(new VerificationStepTypeDto(
type.Id, type.Code, type.DisplayName, type.Description, type.IsRequired, type.IsAutomated,
type.AutomationProvider, type.SortOrder, type.IsActive));
}
}
@@ -0,0 +1,20 @@
using FluentValidation;
namespace Baya.Application.Features.Verification.Commands.UpsertStepType;
public sealed class AdminUpsertStepTypeCommandValidator : AbstractValidator<AdminUpsertStepTypeCommand>
{
public AdminUpsertStepTypeCommandValidator()
{
RuleFor(x => x.Code)
.NotEmpty()
.MaximumLength(50)
.Matches("^[a-z][a-z0-9_]*$")
.WithMessage("Code must be a stable snake_case machine key (az, 09, underscore).");
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(150);
RuleFor(x => x.Description).MaximumLength(500);
RuleFor(x => x.AutomationProvider).MaximumLength(50);
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
}
}
@@ -0,0 +1,22 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.UpsertStepType;
/// <summary>
/// Admin create/update of a pipeline step-type. Adding a step is one row (the pipeline is data-driven).
/// <paramref name="Id"/> null creates; otherwise updates. <c>Code</c> is immutable once the step-type is in
/// use by a nurse verification.
/// </summary>
public record AdminUpsertStepTypeCommand(
long? Id,
string Code,
string DisplayName,
string? Description,
bool IsRequired,
bool IsAutomated,
string? AutomationProvider,
int SortOrder,
bool IsActive) : IRequest<OperationResult<VerificationStepTypeDto>>;
@@ -0,0 +1,35 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Queries.GetStatus;
internal sealed class GetNurseVerificationStatusQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<GetNurseVerificationStatusQuery, OperationResult<VerificationStatusDto>>
{
public async ValueTask<OperationResult<VerificationStatusDto>> Handle(
GetNurseVerificationStatusQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<VerificationStatusDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<VerificationStatusDto>.ForbiddenResult("Only a nurse can read their verification.");
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } id)
return OperationResult<VerificationStatusDto>.NotFoundResult("No nurse profile exists yet.");
var status = await unitOfWork.VerificationRepository.GetStatusForNurseAsync(id, cancellationToken);
// No verification row yet — return the empty "not started" checklist so the client can prompt submit.
status ??= new VerificationStatusDto(VerificationStatus.NotStarted.ToCode(), false, [], []);
return OperationResult<VerificationStatusDto>.SuccessResult(status);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Queries.GetStatus;
/// <summary>The signed-in nurse's verification checklist + aggregate status + "what's blocking bookability".</summary>
public record GetNurseVerificationStatusQuery : IRequest<OperationResult<VerificationStatusDto>>;
@@ -0,0 +1,33 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Queries.GetTrustBadge;
internal sealed class GetVerifiedTrustBadgeQueryHandler(
IUnitOfWork unitOfWork,
ICacheService cache,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<GetVerifiedTrustBadgeQuery, OperationResult<TrustBadgeDto>>
{
public async ValueTask<OperationResult<TrustBadgeDto>> Handle(GetVerifiedTrustBadgeQuery request, CancellationToken cancellationToken)
{
var key = VerificationCache.BadgeKey(request.NurseId);
var cached = await cache.GetAsync<TrustBadgeDto>(key, cancellationToken);
if (cached is not null)
return OperationResult<TrustBadgeDto>.SuccessResult(cached);
var today = DateOnly.FromDateTime(dateTimeProvider.UtcNow.UtcDateTime);
var badge = await unitOfWork.VerificationRepository.GetTrustBadgeAsync(request.NurseId, today, cancellationToken);
if (badge is null)
return OperationResult<TrustBadgeDto>.NotFoundResult("Nurse not found.");
// Only found badges are cached; a suspension/expiry evicts this key so it is never stale-verified.
await cache.SetAsync(key, badge, VerificationCache.BadgeTtl, cancellationToken);
return OperationResult<TrustBadgeDto>.SuccessResult(badge);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Queries.GetTrustBadge;
/// <summary>The public "verified" trust badge for a nurse — verified status + credential <b>types</b> held
/// (never the numbers). Cached.</summary>
public record GetVerifiedTrustBadgeQuery(long NurseId) : IRequest<OperationResult<TrustBadgeDto>>;
@@ -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.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Queries.GetVerificationDetail;
internal sealed class AdminGetVerificationDetailQueryHandler(IUnitOfWork unitOfWork, IObjectStorage objectStorage)
: IRequestHandler<AdminGetVerificationDetailQuery, OperationResult<AdminVerificationDetailDto>>
{
public async ValueTask<OperationResult<AdminVerificationDetailDto>> Handle(
AdminGetVerificationDetailQuery request, CancellationToken cancellationToken)
{
var detail = await unitOfWork.VerificationRepository.GetDetailAsync(
request.NurseVerificationId, key => objectStorage.GetUrl(key), cancellationToken);
return detail is null
? OperationResult<AdminVerificationDetailDto>.NotFoundResult("Verification not found.")
: OperationResult<AdminVerificationDetailDto>.SuccessResult(detail);
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Queries.GetVerificationDetail;
/// <summary>Full per-nurse verification detail for the admin doc-viewer — all steps, documents (signed
/// URLs), existing credentials, and the identity name for the holder cross-check.</summary>
public record AdminGetVerificationDetailQuery(long NurseVerificationId)
: IRequest<OperationResult<AdminVerificationDetailDto>>;
@@ -0,0 +1,26 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Baya.Domain.Entities.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Queries.ListPendingSteps;
internal sealed class AdminListPendingStepsQueryHandler(IUnitOfWork unitOfWork, IObjectStorage objectStorage)
: IRequestHandler<AdminListPendingStepsQuery, OperationResult<PagedResult<AdminPendingStepDto>>>
{
public async ValueTask<OperationResult<PagedResult<AdminPendingStepDto>>> Handle(
AdminListPendingStepsQuery request, CancellationToken cancellationToken)
{
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var status = VerificationCodes.TryParseStepStatus(request.Status);
var result = await unitOfWork.VerificationRepository.ListPendingStepsAsync(
status, page, pageSize, key => objectStorage.GetUrl(key), cancellationToken);
return OperationResult<PagedResult<AdminPendingStepDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,11 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Queries.ListPendingSteps;
/// <summary>The admin manual-review worklist — steps in the given status (default <c>in_review</c>) with
/// their submitted documents (signed GET URLs). Projected + paginated.</summary>
public record AdminListPendingStepsQuery(string? Status = null, int Page = 1, int PageSize = 50)
: IRequest<OperationResult<PagedResult<AdminPendingStepDto>>>;
@@ -0,0 +1,26 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Queries.ListStepTypes;
internal sealed class AdminListStepTypesQueryHandler(IUnitOfWork unitOfWork, ICacheService cache)
: IRequestHandler<AdminListStepTypesQuery, OperationResult<IReadOnlyList<VerificationStepTypeDto>>>
{
public async ValueTask<OperationResult<IReadOnlyList<VerificationStepTypeDto>>> Handle(
AdminListStepTypesQuery request, CancellationToken cancellationToken)
{
var version = await VerificationCache.VersionAsync(cache, cancellationToken);
var result = await cache.GetOrCreateAsync(
VerificationCache.StepTypesKey(version, request.IncludeInactive),
async ct => await unitOfWork.VerificationRepository.ListStepTypesAsync(request.IncludeInactive, ct),
VerificationCache.Ttl,
cancellationToken);
return OperationResult<IReadOnlyList<VerificationStepTypeDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Queries.ListStepTypes;
/// <summary>Admin catalog of pipeline step-types (read-heavy reference data, cached). Set
/// <paramref name="IncludeInactive"/> to include deactivated rows.</summary>
public record AdminListStepTypesQuery(bool IncludeInactive = false)
: IRequest<OperationResult<IReadOnlyList<VerificationStepTypeDto>>>;
@@ -0,0 +1,37 @@
#nullable enable
using Baya.Application.Contracts.Common;
namespace Baya.Application.Features.Verification;
/// <summary>
/// Cache-key scheme for verification reads. The step-type catalog is read-heavy reference data behind a
/// generation token (any admin step-type write bumps it, orphaning the whole namespace in one move — the
/// geo/catalog pattern). The public trust badge is cached per nurse with a short TTL and is explicitly
/// evicted on any state change that must reflect immediately (suspension, expiry re-gate, an admin decision).
/// </summary>
internal static class VerificationCache
{
private const string VersionKey = "verification:step_types:version";
public static readonly TimeSpan Ttl = TimeSpan.FromHours(1);
/// <summary>Short TTL: a suspended/expired nurse is also evicted explicitly, so the badge is never
/// stale-verified for long.</summary>
public static readonly TimeSpan BadgeTtl = TimeSpan.FromSeconds(60);
public static ValueTask<string> VersionAsync(ICacheService cache, CancellationToken cancellationToken)
=> cache.GetOrCreateAsync(VersionKey, _ => ValueTask.FromResult(NewToken()), null, cancellationToken);
public static ValueTask InvalidateStepTypesAsync(ICacheService cache, CancellationToken cancellationToken)
=> cache.SetAsync(VersionKey, NewToken(), null, cancellationToken);
public static string StepTypesKey(string version, bool includeInactive)
=> $"verification:{version}:step_types:{includeInactive}";
public static string BadgeKey(long nurseId) => $"verification:badge:{nurseId}";
public static ValueTask InvalidateBadgeAsync(ICacheService cache, long nurseId, CancellationToken cancellationToken)
=> cache.RemoveAsync(BadgeKey(nurseId), cancellationToken);
private static string NewToken() => Guid.NewGuid().ToString("N");
}
@@ -0,0 +1,36 @@
#nullable enable
namespace Baya.Application.Models.Verification;
/// <summary>A row in the admin manual-review worklist — one pending step with its submitted documents
/// (signed GET URLs).</summary>
public record AdminPendingStepDto(
long NurseVerificationId,
long NurseId,
string NurseName,
long StepId,
string StepCode,
string StepDisplayName,
string Status,
DateTimeOffset? SubmittedAt,
IReadOnlyList<VerificationDocumentDto> Documents);
/// <summary>A step inside the admin per-nurse detail view.</summary>
public record AdminStepDetailDto(
long StepId,
string Code,
string DisplayName,
string Status,
bool IsAutomated,
DateTimeOffset? ExpiresAt,
string? FailureReason,
IReadOnlyList<VerificationDocumentDto> Documents);
/// <summary>Full per-nurse verification detail for the admin doc-viewer, incl. the identity name for the
/// holder-name cross-check and the credentials already on file.</summary>
public record AdminVerificationDetailDto(
long NurseVerificationId,
long NurseId,
string IdentityName,
string Status,
IReadOnlyList<AdminStepDetailDto> Steps,
IReadOnlyList<NurseCredentialDto> Credentials);
@@ -0,0 +1,60 @@
#nullable enable
namespace Baya.Application.Models.Verification;
/// <summary>An admin step-type catalog row.</summary>
public record VerificationStepTypeDto(
long Id,
string Code,
string DisplayName,
string? Description,
bool IsRequired,
bool IsAutomated,
string? AutomationProvider,
int SortOrder,
bool IsActive);
/// <summary>One step in the nurse's checklist. <c>Status</c> is a snake_case code.</summary>
public record VerificationStepDto(
long Id,
string Code,
string DisplayName,
string Status,
bool IsAutomated,
DateTimeOffset? ExpiresAt,
string? FailureReason);
/// <summary>
/// The nurse's aggregate verification state + per-step checklist. <c>IsBookable</c> mirrors
/// <c>nurse_profiles.is_verified</c>; <c>BlockingSteps</c> lists the codes of required steps not yet passed
/// (what the nurse must still complete to become bookable).
/// </summary>
public record VerificationStatusDto(
string Status,
bool IsBookable,
IReadOnlyList<string> BlockingSteps,
IReadOnlyList<VerificationStepDto> Steps);
/// <summary>Uploaded-evidence metadata + a short-lived signed URL. Bytes never touch the DB.</summary>
public record VerificationDocumentDto(
long Id,
string ContentType,
long FileSizeBytes,
string? OriginalFileName,
string Url);
/// <summary>A structured credential — the number is <b>never</b> serialized.</summary>
public record NurseCredentialDto(
long Id,
string CredentialType,
string HolderNameSnapshot,
string IssuingAuthority,
DateOnly? IssuedAt,
DateOnly? ExpiresAt,
string VerificationMethod);
/// <summary>The public "verified" badge — credential <b>types</b> held, never the numbers.</summary>
public record TrustBadgeDto(
long NurseId,
bool IsVerified,
DateTimeOffset? ApprovedAt,
IReadOnlyList<string> CredentialTypes);
@@ -0,0 +1,17 @@
#nullable enable
namespace Baya.Application.Models.Verification;
/// <summary>The signed PUT URL for a manual-evidence upload + the storage key the client echoes on confirm.</summary>
public record UploadUrlResult(string ObjectStorageKey, string UploadUrl);
/// <summary>The persisted document-metadata row + the step's resulting status.</summary>
public record DocumentConfirmedResult(long DocumentId, string StepStatus);
/// <summary>The outcome of an automated step run (identity KYC / Shahkar / bank ownership).</summary>
public record RunStepResult(long StepId, string StepStatus, string? FailureReason);
/// <summary>The outcome of an admin manual decision on a step.</summary>
public record ReviewStepResult(long StepId, string StepStatus, long? CredentialId);
/// <summary>The result of an admin-triggered expiry scan.</summary>
public record ScanExpiringResult(int ScannedSteps, int RevertedNurses);
@@ -41,12 +41,13 @@ public static class SupportAlertType
public const string EvvNoShow = "evv_no_show";
public const string EvvLocationMismatch = "evv_location_mismatch";
public const string VerificationExpired = "verification_expired";
public const string SharedSim = "shared_sim";
public const string PaymentAnomaly = "payment_anomaly";
public const string FraudSignal = "fraud_signal";
public static readonly IReadOnlyList<string> All =
[
LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, PaymentAnomaly, FraudSignal
LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, SharedSim, PaymentAnomaly, FraudSignal
];
}
@@ -0,0 +1,42 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Verification;
/// <summary>
/// The structured, queryable credential registry — the actual license/membership numbers, authority,
/// holder-name-as-printed and issue/expiry dates behind the opaque uploads. Powers the public trust badge
/// (types held, never numbers), renewal/expiry alerts and cross-checking. <see cref="CredentialNumber"/> is
/// encrypted PII (through <c>IFieldEncryptor</c>); <see cref="HolderNameSnapshot"/> is cross-checked against
/// the nurse's verified identity name before the credential is recorded — never trust an uploaded file alone.
/// </summary>
public class NurseCredential : BaseEntity<long>
{
public long NurseId { get; set; }
/// <summary>One of <see cref="CredentialTypes"/>.</summary>
public string CredentialType { get; set; } = string.Empty;
/// <summary>Encrypted at rest — never serialized on the wire.</summary>
public string CredentialNumber { get; set; } = string.Empty;
/// <summary>Name as printed on the credential, snapshotted for the identity cross-check.</summary>
public string HolderNameSnapshot { get; set; } = string.Empty;
public string IssuingAuthority { get; set; } = string.Empty;
public DateOnly? IssuedAt { get; set; }
/// <summary>Drives renewal alerts and the expiry scanner re-gate.</summary>
public DateOnly? ExpiresAt { get; set; }
/// <summary>Portal URL / method used to verify (audit defensibility).</summary>
public string? VerificationSource { get; set; }
/// <summary>One of <see cref="VerificationMethods"/> (<c>manual</c> today for MoH/INO/criminal).</summary>
public string VerificationMethod { get; set; } = VerificationMethods.Manual;
public int? VerifiedByAdminId { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}
@@ -0,0 +1,39 @@
#nullable enable
using Baya.Domain.Common;
using Baya.Domain.Entities.Identity;
namespace Baya.Domain.Entities.Verification;
/// <summary>
/// The master per-nurse verification record and the <b>single source of verification truth</b>. Its
/// <see cref="Status"/> rolls up the per-step outcomes; the derived <c>nurse_profiles.is_verified</c>
/// boolean is flipped only inside the finalize transaction when every required step has passed (and
/// reversed on suspension). The legacy <c>nurse_profiles.verification_status</c> column was deliberately
/// cut — never reintroduce a second copy of this state.
/// </summary>
public class NurseVerification : BaseEntity<long>
{
public long NurseId { get; set; }
/// <summary>Read-projection navigation (nurse name / identity for the admin queue). The finalize/suspend
/// flip loads the tracked profile separately, so this is not the write path for <c>is_verified</c>.</summary>
public NurseProfile Nurse { get; set; } = null!;
public VerificationStatus Status { get; set; } = VerificationStatus.NotStarted;
public DateTimeOffset? SubmittedAt { get; set; }
public DateTimeOffset? ApprovedAt { get; set; }
public DateTimeOffset? RejectedAt { get; set; }
public DateTimeOffset? SuspendedAt { get; set; }
public string? RejectionReason { get; set; }
/// <summary>The admin who last drove a manual decision (review/suspend).</summary>
public int? ReviewedByAdminId { get; set; }
public string? InternalNotes { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public ICollection<VerificationStep> Steps { get; set; } = new List<VerificationStep>();
}
@@ -0,0 +1,66 @@
#nullable enable
namespace Baya.Domain.Entities.Verification;
/// <summary>
/// Maps the verification status enums to/from the stable snake_case codes stored in the DB and sent on the
/// wire. Kept explicit (no reflection) so the persisted/contract vocabulary is greppable and can never
/// drift from an accidental enum rename.
/// </summary>
public static class VerificationCodes
{
public static string ToCode(this VerificationStatus status) => status switch
{
VerificationStatus.NotStarted => "not_started",
VerificationStatus.Pending => "pending",
VerificationStatus.InReview => "in_review",
VerificationStatus.Approved => "approved",
VerificationStatus.Rejected => "rejected",
VerificationStatus.Suspended => "suspended",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
};
public static string ToCode(this VerificationStepStatus status) => status switch
{
VerificationStepStatus.NotStarted => "not_started",
VerificationStepStatus.Pending => "pending",
VerificationStepStatus.InReview => "in_review",
VerificationStepStatus.Passed => "passed",
VerificationStepStatus.Failed => "failed",
VerificationStepStatus.Expired => "expired",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
};
public static VerificationStatus ParseStatus(string code) => code switch
{
"not_started" => VerificationStatus.NotStarted,
"pending" => VerificationStatus.Pending,
"in_review" => VerificationStatus.InReview,
"approved" => VerificationStatus.Approved,
"rejected" => VerificationStatus.Rejected,
"suspended" => VerificationStatus.Suspended,
_ => throw new ArgumentOutOfRangeException(nameof(code), code, "Unknown verification status code.")
};
public static VerificationStepStatus ParseStepStatus(string code) => code switch
{
"not_started" => VerificationStepStatus.NotStarted,
"pending" => VerificationStepStatus.Pending,
"in_review" => VerificationStepStatus.InReview,
"passed" => VerificationStepStatus.Passed,
"failed" => VerificationStepStatus.Failed,
"expired" => VerificationStepStatus.Expired,
_ => throw new ArgumentOutOfRangeException(nameof(code), code, "Unknown verification step status code.")
};
/// <summary>Lenient parse for a query-string filter; returns null for a missing/unknown value.</summary>
public static VerificationStepStatus? TryParseStepStatus(string? code) => code switch
{
"not_started" => VerificationStepStatus.NotStarted,
"pending" => VerificationStepStatus.Pending,
"in_review" => VerificationStepStatus.InReview,
"passed" => VerificationStepStatus.Passed,
"failed" => VerificationStepStatus.Failed,
"expired" => VerificationStepStatus.Expired,
_ => null
};
}
@@ -0,0 +1,29 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Verification;
/// <summary>
/// Metadata for an uploaded evidence file — <b>bytes never touch the DB</b>. The file lives in object
/// storage behind a short-lived signed URL keyed by <see cref="ObjectStorageKey"/>; the
/// <see cref="IntegrityHash"/> detects tampering/swap after upload. Never public.
/// </summary>
public class VerificationDocument : BaseEntity<long>
{
public long StepId { get; set; }
public VerificationStep Step { get; set; } = null!;
/// <summary>The opaque <c>IObjectStorage</c> key the bytes live under.</summary>
public string ObjectStorageKey { get; set; } = string.Empty;
/// <summary>Content hash (hex) to detect a later tamper/swap of the stored object.</summary>
public string IntegrityHash { get; set; } = string.Empty;
public string ContentType { get; set; } = string.Empty;
public long FileSizeBytes { get; set; }
public string? OriginalFileName { get; set; }
public int UploadedByUserId { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}
@@ -0,0 +1,16 @@
namespace Baya.Domain.Entities.Verification;
/// <summary>
/// The aggregate state of a nurse's verification (the single source of verification truth). Persisted as
/// its snake_case code (see <see cref="VerificationCodes"/>). The derived <c>nurse_profiles.is_verified</c>
/// boolean is written solely by the finalize transaction when this reaches <see cref="Approved"/>.
/// </summary>
public enum VerificationStatus
{
NotStarted,
Pending,
InReview,
Approved,
Rejected,
Suspended
}
@@ -0,0 +1,37 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Verification;
/// <summary>
/// One step per nurse per pipeline step-type. Carries the raw KYC-vendor payload
/// (<see cref="ExternalResponseJson"/>) for audit, an optional <see cref="ExpiresAt"/> for time-limited
/// steps, and a <b>snapshot</b> of the step-type's automation flag (<see cref="IsAutomated"/>) — read the
/// snapshot, never the live step-type, so historical records survive later catalog edits.
/// </summary>
public class VerificationStep : BaseEntity<long>
{
public long NurseVerificationId { get; set; }
public NurseVerification NurseVerification { get; set; } = null!;
public long StepTypeId { get; set; }
public VerificationStepType StepType { get; set; } = null!;
public VerificationStepStatus Status { get; set; } = VerificationStepStatus.Pending;
/// <summary>Raw KYC-vendor response, kept so the audit trail survives the mock→real swap.</summary>
public string? ExternalResponseJson { get; set; }
/// <summary>When a time-limited step lapses (criminal-record especially); the scanner reverts on it.</summary>
public DateTimeOffset? ExpiresAt { get; set; }
/// <summary>Snapshotted from the step-type at seed time — never re-read from the live step-type.</summary>
public bool IsAutomated { get; set; }
public DateTimeOffset? StartedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public string? FailureReason { get; set; }
public ICollection<VerificationDocument> Documents { get; set; } = new List<VerificationDocument>();
}
@@ -0,0 +1,16 @@
namespace Baya.Domain.Entities.Verification;
/// <summary>
/// The state of a single verification step. <see cref="Pending"/> = awaiting submission/automation;
/// <see cref="InReview"/> = awaiting a manual admin decision; <see cref="Expired"/> = a time-limited
/// credential lapsed (the scanner reverts it and re-gates bookability). Persisted as its snake_case code.
/// </summary>
public enum VerificationStepStatus
{
NotStarted,
Pending,
InReview,
Passed,
Failed,
Expired
}
@@ -0,0 +1,29 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Verification;
/// <summary>
/// The admin catalog of pipeline steps — data, not a code enum. Adding a regulatory requirement is one
/// row (<see cref="Code"/> is the stable machine key). <see cref="IsAutomated"/> is snapshotted onto each
/// <see cref="VerificationStep"/> at seed time, so toggling it here never rewrites the meaning of past
/// verifications. Deactivate (<see cref="IsActive"/>) rather than delete — no soft-delete here.
/// </summary>
public class VerificationStepType : BaseEntity<long>
{
public string Code { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsRequired { get; set; }
public bool IsAutomated { get; set; }
/// <summary>Informational vendor tag (e.g. <c>shahkar</c>, <c>identity_kyc_vendor</c>). The real seam
/// is config-selected; nothing branches on this string.</summary>
public string? AutomationProvider { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
public ICollection<VerificationStep> Steps { get; set; } = new List<VerificationStep>();
}
@@ -0,0 +1,48 @@
namespace Baya.Domain.Entities.Verification;
/// <summary>
/// The six stable machine codes of the seeded pipeline steps. The pipeline is data-driven — these live as
/// rows in <c>verification_step_types</c>, not as a switch the automated-run endpoints branch on. A new
/// regulatory step (e.g. professional-liability insurance) is one INSERT, never a new constant here.
/// </summary>
public static class VerificationStepTypeCodes
{
public const string IdentityKyc = "identity_kyc";
public const string ShahkarMatch = "shahkar_match";
public const string MohCompetencyLicense = "moh_competency_license";
public const string InoMembership = "ino_membership";
public const string CriminalRecord = "criminal_record";
public const string BankAccountVerification = "bank_account_verification";
/// <summary>Steps that, on admin pass, record a row in <c>nurse_credentials</c>.</summary>
public static readonly IReadOnlyList<string> CredentialBearing =
[MohCompetencyLicense, InoMembership, CriminalRecord];
/// <summary>The time-limited step whose certificate expires (drives the expiry scanner re-gate).</summary>
public const string TimeLimited = CriminalRecord;
}
/// <summary>Stable codes for <c>nurse_credentials.credential_type</c> (aligned with the step codes).</summary>
public static class CredentialTypes
{
public const string MohCompetencyLicense = VerificationStepTypeCodes.MohCompetencyLicense;
public const string InoMembership = VerificationStepTypeCodes.InoMembership;
public const string CriminalRecord = VerificationStepTypeCodes.CriminalRecord;
}
/// <summary>Stable codes for <c>nurse_credentials.verification_method</c>.</summary>
public static class VerificationMethods
{
public const string Manual = "manual";
public const string Portal = "portal";
public const string Api = "api";
}
/// <summary>Stable codes for <c>verification_step_types.automation_provider</c> (informational; the mock
/// swap is config-selected, never branched on here).</summary>
public static class AutomationProviders
{
public const string IdentityKycVendor = "identity_kyc_vendor";
public const string Shahkar = "shahkar";
public const string Sheba = "sheba";
}
@@ -0,0 +1,23 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Domain.Entities.Verification;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Default <see cref="ICredentialVerifier"/> — and the mock. MoH پروانه صلاحیت حرفه‌ای and INO membership
/// have <b>no public B2B API</b>, so verification is a manual admin review of the uploaded document against
/// the official portal: every call returns <see cref="CredentialVerificationStatus.RequiresManualReview"/>
/// with <c>verification_method = manual</c>. When an <c>api</c>/<c>portal</c> source becomes available, a
/// real implementation replaces this registration and starts returning
/// <see cref="CredentialVerificationStatus.Verified"/>/<see cref="CredentialVerificationStatus.Failed"/>
/// with the matching method — callers are unchanged.
/// </summary>
public sealed class MockCredentialVerifier : ICredentialVerifier
{
public Task<CredentialVerificationResult> VerifyAsync(string credentialType, string? credentialNumber, CancellationToken cancellationToken = default)
=> Task.FromResult(new CredentialVerificationResult(
CredentialVerificationStatus.RequiresManualReview,
VerificationMethods.Manual,
ExternalResponseJson: null));
}
@@ -0,0 +1,57 @@
#nullable enable
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Mock <see cref="IIdentityKycProvider"/>: a deterministic fake identity + liveness check — no real
/// OCR/liveness call. Passes every well-formed national id except the configured
/// <see cref="IdentityKycOptions.FailNationalId"/>. On pass it reports a matched name and populates the
/// verified identity. A real Iranian e-KYC vendor (Finnotech / U-ID / Jibbit / Farashensa / Verify /
/// Kavoshak) swaps in by a registration change — callers are unchanged.
/// </summary>
public sealed class MockIdentityKycProvider(IOptions<SeamOptions> options) : IIdentityKycProvider
{
private readonly IdentityKycOptions _options = options.Value.IdentityKyc;
public Task<IdentityKycResult> VerifyAsync(string nationalId, string? livenessPayload, CancellationToken cancellationToken = default)
{
var id = (nationalId ?? string.Empty).Trim();
var vendorRef = $"MOCK-KYC-{Token(id)}";
var wellFormed = id.Length == 10 && id.All(char.IsDigit);
if (!wellFormed || string.Equals(id, _options.FailNationalId, StringComparison.Ordinal))
{
return Task.FromResult(new IdentityKycResult(
Passed: false,
MatchedName: null,
VendorRef: vendorRef,
ExternalResponseJson: Payload("fail", id, livenessPayload, passed: false),
FailureReason: "Identity could not be verified against the civil registry."));
}
return Task.FromResult(new IdentityKycResult(
Passed: true,
MatchedName: _options.MatchedName,
VendorRef: vendorRef,
ExternalResponseJson: Payload("pass", id, livenessPayload, passed: true),
FailureReason: null));
}
private static string Payload(string outcome, string nationalId, string? liveness, bool passed)
=> JsonSerializer.Serialize(new
{
provider = "mock_identity_kyc",
outcome,
passed,
national_id_present = !string.IsNullOrEmpty(nationalId),
liveness_present = !string.IsNullOrEmpty(liveness)
});
private static string Token(string value)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..12];
}
@@ -0,0 +1,68 @@
#nullable enable
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Mock <see cref="IShahkarVerifier"/>: a deterministic fake شاهکار phone↔national-id inquiry — no real
/// Shahkar/KYC call. Matches every pair except the configured <see cref="ShahkarOptions.SharedSimPhone"/>
/// (returns the explicit shared-SIM failure state) and <see cref="ShahkarOptions.MismatchNationalId"/>
/// (returns a plain mismatch). The vendor ref is derived from the inputs, so re-running is idempotent. A
/// real Finnotech/KYC client swaps in by a registration change — callers are unchanged.
/// </summary>
public sealed class MockShahkarVerifier(IOptions<SeamOptions> options) : IShahkarVerifier
{
private readonly ShahkarOptions _options = options.Value.Shahkar;
public Task<ShahkarMatchResult> MatchAsync(string? phoneNumber, string? nationalId, CancellationToken cancellationToken = default)
{
var phone = (phoneNumber ?? string.Empty).Trim();
var vendorRef = $"MOCK-SHAHKAR-{Token(phone + "|" + (nationalId ?? string.Empty))}";
if (string.Equals(phone, _options.SharedSimPhone, StringComparison.Ordinal))
{
var json = Payload("shared_sim", phone, nationalId, matched: false);
return Task.FromResult(new ShahkarMatchResult(
Matched: false,
IsSharedSim: true,
VendorRef: vendorRef,
ExternalResponseJson: json,
FailureReason: "This SIM appears to be registered to a family member. Please use a SIM registered in your own name."));
}
if (string.Equals(nationalId, _options.MismatchNationalId, StringComparison.Ordinal))
{
var json = Payload("mismatch", phone, nationalId, matched: false);
return Task.FromResult(new ShahkarMatchResult(
Matched: false,
IsSharedSim: false,
VendorRef: vendorRef,
ExternalResponseJson: json,
FailureReason: "The phone number is not registered to your national ID."));
}
return Task.FromResult(new ShahkarMatchResult(
Matched: true,
IsSharedSim: false,
VendorRef: vendorRef,
ExternalResponseJson: Payload("match", phone, nationalId, matched: true),
FailureReason: null));
}
private static string Payload(string outcome, string phone, string? nationalId, bool matched)
=> JsonSerializer.Serialize(new
{
provider = "mock_shahkar",
outcome,
matched,
phone_last4 = phone.Length >= 4 ? phone[^4..] : phone,
national_id_present = !string.IsNullOrEmpty(nationalId)
});
private static string Token(string value)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..12];
}
@@ -12,6 +12,38 @@ public sealed class SeamOptions
public ObjectStorageOptions ObjectStorage { get; set; } = new();
public BankOwnershipOptions BankOwnership { get; set; } = new();
public GeocodingOptions Geocoding { get; set; } = new();
public ShahkarOptions Shahkar { get; set; } = new();
public IdentityKycOptions IdentityKyc { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>IShahkarVerifier</c> (phone↔national-id binding). A submitted phone equal to
/// <see cref="SharedSimPhone"/> returns the explicit shared-SIM failure; a national id equal to
/// <see cref="MismatchNationalId"/> returns a plain mismatch; every other pair matches. The real vendor
/// implementation ignores these.
/// </summary>
public sealed class ShahkarOptions
{
/// <summary>The designated test phone that returns the shared-SIM failure state.</summary>
public string SharedSimPhone { get; set; } = "09120000000";
/// <summary>The designated test national id that returns a plain phone↔national-id mismatch.</summary>
public string MismatchNationalId { get; set; } = "1111111111";
}
/// <summary>
/// Tunes the mock <c>IIdentityKycProvider</c>. A national id equal to <see cref="FailNationalId"/> fails
/// KYC; every other (well-formed) national id passes with <see cref="MatchedName"/>. The real e-KYC vendor
/// implementation ignores these.
/// </summary>
public sealed class IdentityKycOptions
{
/// <summary>The designated test national id that fails identity KYC.</summary>
public string FailNationalId { get; set; } = "0000000000";
/// <summary>The name the mock reports as matched on a passing KYC (informational; the authoritative
/// identity name for credential cross-check comes from the users row).</summary>
public string MatchedName { get; set; } = "Verified Nurse";
}
/// <summary>
@@ -36,6 +36,13 @@ public static class ServiceCollectionExtension
// centroid with no network call; a real Neshan/Google geocoding client replaces this registration.
services.AddSingleton<IGeocoder, MockGeocoder>();
// Nurse-verification vendors (backend-phase-6). All three are deterministic mocks; a real Iranian
// e-KYC vendor / Shahkar bridge / (future) MoH-INO portal swaps in by a registration change only —
// no mock behaviour is baked into any handler call site.
services.AddSingleton<IShahkarVerifier, MockShahkarVerifier>();
services.AddSingleton<IIdentityKycProvider, MockIdentityKycProvider>();
services.AddSingleton<ICredentialVerifier, MockCredentialVerifier>();
return services;
}
}
@@ -3,6 +3,7 @@ using Baya.Application.Contracts.Common;
using Baya.Domain.Common;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
using Baya.Infrastructure.Persistence.ValueConversion;
using Baya.SharedKernel.Extensions;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
@@ -131,5 +132,12 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
builder.Property(a => a.RecipientName).HasConversion(encrypted);
builder.Property(a => a.RecipientPhone).HasConversion(encrypted);
});
// b6 credential PII: the license/membership number is encrypted at rest through the same seam and
// is never serialized on the wire (the trust badge exposes credential types, never numbers).
modelBuilder.Entity<NurseCredential>(builder =>
{
builder.Property(c => c.CredentialNumber).HasConversion(encrypted);
});
}
}
@@ -43,6 +43,7 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration<PlatformCo
(13, "auth_otp_resend_seconds", "120", ConfigDataType.Int, "Seconds a phone must wait before another OTP can be requested."),
(14, "auth_otp_max_attempts", "5", ConfigDataType.Int, "Wrong-code attempts allowed before OTP verification is refused until a fresh code."),
(15, "auth_session_ttl_days", "30", ConfigDataType.Int, "Refresh-token session lifetime in days."),
(16, "verification_expiry_scan_cadence_hours", "24", ConfigDataType.Int, "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today)."),
];
return rows
@@ -0,0 +1,38 @@
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.VerificationConfig;
internal sealed class NurseCredentialConfig : IEntityTypeConfiguration<NurseCredential>
{
public void Configure(EntityTypeBuilder<NurseCredential> builder)
{
builder.ToTable("NurseCredentials", "verif");
builder.Property(c => c.CredentialType).HasMaxLength(50).IsRequired();
// credential_number is encrypted at rest (converter wired in ApplicationDbContext) — never serialized.
builder.Property(c => c.CredentialNumber).HasMaxLength(256).IsRequired();
builder.Property(c => c.HolderNameSnapshot).HasMaxLength(200).IsRequired();
builder.Property(c => c.IssuingAuthority).HasMaxLength(200).IsRequired();
builder.Property(c => c.VerificationSource).HasMaxLength(300);
builder.Property(c => c.VerificationMethod).HasMaxLength(20).IsRequired();
// Badge + renewal reads: the nurse's non-expired credentials by type.
builder.HasIndex(c => new { c.NurseId, c.CredentialType });
builder.HasOne<NurseProfile>()
.WithMany()
.HasForeignKey(c => c.NurseId)
.IsRequired();
builder.HasOne<User>()
.WithMany()
.HasForeignKey(c => c.VerifiedByAdminId)
.IsRequired(false);
builder.HasQueryFilter(c => c.DeletedAt == null);
}
}
@@ -0,0 +1,38 @@
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.VerificationConfig;
internal sealed class NurseVerificationConfig : IEntityTypeConfiguration<NurseVerification>
{
public void Configure(EntityTypeBuilder<NurseVerification> builder)
{
builder.ToTable("NurseVerifications", "verif");
// Status persists as its stable snake_case code — readable and queryable (WHERE status = 'approved').
builder.Property(v => v.Status)
.HasConversion(s => s.ToCode(), s => VerificationCodes.ParseStatus(s))
.HasMaxLength(20)
.IsRequired();
builder.Property(v => v.RejectionReason).HasMaxLength(1000);
builder.Property(v => v.InternalNotes).HasMaxLength(2000);
// 1:1 with the nurse profile — the sole verification header per nurse.
builder.HasIndex(v => v.NurseId).IsUnique();
builder.HasOne(v => v.Nurse)
.WithMany()
.HasForeignKey(v => v.NurseId)
.IsRequired();
builder.HasOne<User>()
.WithMany()
.HasForeignKey(v => v.ReviewedByAdminId)
.IsRequired(false);
builder.HasQueryFilter(v => v.DeletedAt == null);
}
}
@@ -0,0 +1,32 @@
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.VerificationConfig;
internal sealed class VerificationDocumentConfig : IEntityTypeConfiguration<VerificationDocument>
{
public void Configure(EntityTypeBuilder<VerificationDocument> builder)
{
builder.ToTable("VerificationDocuments", "verif");
// Metadata only — the bytes live in object storage behind a signed URL, never in the DB.
builder.Property(d => d.ObjectStorageKey).HasMaxLength(400).IsRequired();
builder.Property(d => d.IntegrityHash).HasMaxLength(64).IsRequired();
builder.Property(d => d.ContentType).HasMaxLength(100).IsRequired();
builder.Property(d => d.OriginalFileName).HasMaxLength(260);
builder.HasOne(d => d.Step)
.WithMany(s => s.Documents)
.HasForeignKey(d => d.StepId)
.IsRequired();
builder.HasOne<User>()
.WithMany()
.HasForeignKey(d => d.UploadedByUserId)
.IsRequired();
builder.HasQueryFilter(d => d.DeletedAt == null);
}
}
@@ -0,0 +1,38 @@
using Baya.Domain.Entities.Verification;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.VerificationConfig;
internal sealed class VerificationStepConfig : IEntityTypeConfiguration<VerificationStep>
{
public void Configure(EntityTypeBuilder<VerificationStep> builder)
{
builder.ToTable("VerificationSteps", "verif");
builder.Property(s => s.Status)
.HasConversion(s => s.ToCode(), s => VerificationCodes.ParseStepStatus(s))
.HasMaxLength(20)
.IsRequired();
// Raw KYC-vendor response, kept for audit — no length cap (NVARCHAR(MAX)).
builder.Property(s => s.ExternalResponseJson);
builder.Property(s => s.FailureReason).HasMaxLength(500);
builder.Property(s => s.IsAutomated).HasDefaultValue(false);
// One step per (verification, step-type) — the idempotent-submit backstop.
builder.HasIndex(s => new { s.NurseVerificationId, s.StepTypeId })
.IsUnique()
.HasDatabaseName("UX_VerificationSteps_Verification_StepType");
builder.HasOne(s => s.NurseVerification)
.WithMany(v => v.Steps)
.HasForeignKey(s => s.NurseVerificationId)
.IsRequired();
builder.HasOne(s => s.StepType)
.WithMany(t => t.Steps)
.HasForeignKey(s => s.StepTypeId)
.IsRequired();
}
}
@@ -0,0 +1,32 @@
using Baya.Domain.Entities.Verification;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.VerificationConfig;
internal sealed class VerificationStepTypeConfig : IEntityTypeConfiguration<VerificationStepType>
{
public void Configure(EntityTypeBuilder<VerificationStepType> builder)
{
builder.ToTable("VerificationStepTypes", "verif");
builder.Property(t => t.Code).HasMaxLength(50).IsRequired();
builder.Property(t => t.DisplayName).HasMaxLength(150).IsRequired();
builder.Property(t => t.Description).HasMaxLength(500);
builder.Property(t => t.AutomationProvider).HasMaxLength(50);
builder.Property(t => t.IsRequired).HasDefaultValue(false);
builder.Property(t => t.IsAutomated).HasDefaultValue(false);
builder.Property(t => t.SortOrder).HasDefaultValue(0);
builder.Property(t => t.IsActive).HasDefaultValue(true);
// Stable machine code — the data-driven pipeline's identity; one nurse-step joins on it.
builder.HasIndex(t => t.Code).IsUnique();
// Ordered admin/nurse browse of the active catalog.
builder.HasIndex(t => new { t.IsActive, t.SortOrder });
// The six step-types are seeded via HasData; the resulting InsertData is captured in a dedicated
// seed migration (SeedVerificationStepTypes), kept separate from the table-creation migration.
builder.HasData(VerificationStepTypeSeed.StepTypes());
}
}
@@ -0,0 +1,54 @@
namespace Baya.Infrastructure.Persistence.Configuration.VerificationConfig;
using Baya.Domain.Entities.Verification;
/// <summary>
/// The six MVP pipeline step-types, seeded via <c>HasData</c> so the data-driven pipeline exists on a fresh
/// DB (the b1 seeding path). Ids are fixed and deterministic (1…6, sort_order = id) so re-running is
/// idempotent and the model snapshot stays stable. A new regulatory step is one more row — never a code
/// enum. Emitted in its own "seed" migration, separate from the table-creation migration.
/// </summary>
internal static class VerificationStepTypeSeed
{
private static readonly (long Id, string Code, string Display, string Description, bool Required, bool Automated, string Provider)[] Rows =
[
(1, VerificationStepTypeCodes.IdentityKyc, "Identity Verification (KYC)",
"National-ID validity, name match and photo/video liveness via an Iranian e-KYC vendor.",
true, true, AutomationProviders.IdentityKycVendor),
(2, VerificationStepTypeCodes.ShahkarMatch, "Shahkar Phone Binding",
"Confirms the login SIM is registered to the nurse's own national ID (شاهکار).",
true, true, AutomationProviders.Shahkar),
(3, VerificationStepTypeCodes.MohCompetencyLicense, "MoH Professional Competency License",
"پروانه صلاحیت حرفه‌ای — the MoH-mandated in-home nursing licence (bundles the criminal-record screen). Manual today.",
true, false, null),
(4, VerificationStepTypeCodes.InoMembership, "Nursing Organization (INO) Membership",
"نظام پرستاری membership cross-check (ino.ir). Manual today.",
true, false, null),
(5, VerificationStepTypeCodes.CriminalRecord, "Criminal Record Certificate",
"عدم سوء پیشینه — consent-gated, nurse-uploaded, time-limited (reverts on expiry).",
true, false, null),
(6, VerificationStepTypeCodes.BankAccountVerification, "Bank Account (IBAN) Ownership",
"استعلام شبا — the payout IBAN owner's national ID must equal the verified nurse national ID.",
true, true, AutomationProviders.Sheba),
];
public static object[] StepTypes()
{
var ts = SeedConstants.Timestamp;
return Rows
.Select(r => (object)new
{
r.Id,
r.Code,
DisplayName = r.Display,
Description = (string)r.Description,
IsRequired = r.Required,
IsAutomated = r.Automated,
AutomationProvider = r.Provider,
SortOrder = (int)r.Id,
IsActive = true,
CreatedAt = ts
})
.ToArray();
}
}
@@ -0,0 +1,302 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class VerificationPipeline : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "verif");
migrationBuilder.CreateTable(
name: "NurseCredentials",
schema: "verif",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
NurseId = table.Column<long>(type: "bigint", nullable: false),
CredentialType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
CredentialNumber = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
HolderNameSnapshot = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
IssuingAuthority = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
IssuedAt = table.Column<DateOnly>(type: "date", nullable: true),
ExpiresAt = table.Column<DateOnly>(type: "date", nullable: true),
VerificationSource = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: true),
VerificationMethod = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
VerifiedByAdminId = table.Column<int>(type: "int", nullable: true),
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_NurseCredentials", x => x.Id);
table.ForeignKey(
name: "FK_NurseCredentials_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NurseCredentials_Users_VerifiedByAdminId",
column: x => x.VerifiedByAdminId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId");
});
migrationBuilder.CreateTable(
name: "NurseVerifications",
schema: "verif",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
NurseId = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
SubmittedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
ApprovedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
RejectedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
SuspendedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
RejectionReason = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
ReviewedByAdminId = table.Column<int>(type: "int", nullable: true),
InternalNotes = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
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_NurseVerifications", x => x.Id);
table.ForeignKey(
name: "FK_NurseVerifications_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NurseVerifications_Users_ReviewedByAdminId",
column: x => x.ReviewedByAdminId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId");
});
migrationBuilder.CreateTable(
name: "VerificationStepTypes",
schema: "verif",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Code = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
DisplayName = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
IsRequired = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
IsAutomated = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
AutomationProvider = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
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_VerificationStepTypes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "VerificationSteps",
schema: "verif",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
NurseVerificationId = table.Column<long>(type: "bigint", nullable: false),
StepTypeId = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
ExternalResponseJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
ExpiresAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
IsAutomated = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
StartedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CompletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
FailureReason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, 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_VerificationSteps", x => x.Id);
table.ForeignKey(
name: "FK_VerificationSteps_NurseVerifications_NurseVerificationId",
column: x => x.NurseVerificationId,
principalSchema: "verif",
principalTable: "NurseVerifications",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_VerificationSteps_VerificationStepTypes_StepTypeId",
column: x => x.StepTypeId,
principalSchema: "verif",
principalTable: "VerificationStepTypes",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "VerificationDocuments",
schema: "verif",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
StepId = table.Column<long>(type: "bigint", nullable: false),
ObjectStorageKey = table.Column<string>(type: "nvarchar(400)", maxLength: 400, nullable: false),
IntegrityHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ContentType = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
FileSizeBytes = table.Column<long>(type: "bigint", nullable: false),
OriginalFileName = table.Column<string>(type: "nvarchar(260)", maxLength: 260, nullable: true),
UploadedByUserId = table.Column<int>(type: "int", nullable: false),
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_VerificationDocuments", x => x.Id);
table.ForeignKey(
name: "FK_VerificationDocuments_Users_UploadedByUserId",
column: x => x.UploadedByUserId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_VerificationDocuments_VerificationSteps_StepId",
column: x => x.StepId,
principalSchema: "verif",
principalTable: "VerificationSteps",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.InsertData(
schema: "ops",
table: "PlatformConfigs",
columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" },
values: new object[] { 16L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today).", "verification_expiry_scan_cadence_hours", null, null, "24" });
migrationBuilder.CreateIndex(
name: "IX_NurseCredentials_NurseId_CredentialType",
schema: "verif",
table: "NurseCredentials",
columns: new[] { "NurseId", "CredentialType" });
migrationBuilder.CreateIndex(
name: "IX_NurseCredentials_VerifiedByAdminId",
schema: "verif",
table: "NurseCredentials",
column: "VerifiedByAdminId");
migrationBuilder.CreateIndex(
name: "IX_NurseVerifications_NurseId",
schema: "verif",
table: "NurseVerifications",
column: "NurseId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NurseVerifications_ReviewedByAdminId",
schema: "verif",
table: "NurseVerifications",
column: "ReviewedByAdminId");
migrationBuilder.CreateIndex(
name: "IX_VerificationDocuments_StepId",
schema: "verif",
table: "VerificationDocuments",
column: "StepId");
migrationBuilder.CreateIndex(
name: "IX_VerificationDocuments_UploadedByUserId",
schema: "verif",
table: "VerificationDocuments",
column: "UploadedByUserId");
migrationBuilder.CreateIndex(
name: "IX_VerificationSteps_StepTypeId",
schema: "verif",
table: "VerificationSteps",
column: "StepTypeId");
migrationBuilder.CreateIndex(
name: "UX_VerificationSteps_Verification_StepType",
schema: "verif",
table: "VerificationSteps",
columns: new[] { "NurseVerificationId", "StepTypeId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_VerificationStepTypes_Code",
schema: "verif",
table: "VerificationStepTypes",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_VerificationStepTypes_IsActive_SortOrder",
schema: "verif",
table: "VerificationStepTypes",
columns: new[] { "IsActive", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NurseCredentials",
schema: "verif");
migrationBuilder.DropTable(
name: "VerificationDocuments",
schema: "verif");
migrationBuilder.DropTable(
name: "VerificationSteps",
schema: "verif");
migrationBuilder.DropTable(
name: "NurseVerifications",
schema: "verif");
migrationBuilder.DropTable(
name: "VerificationStepTypes",
schema: "verif");
migrationBuilder.DeleteData(
schema: "ops",
table: "PlatformConfigs",
keyColumn: "Id",
keyValue: 16L);
}
}
}
@@ -0,0 +1,84 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class SeedVerificationStepTypes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
schema: "verif",
table: "VerificationStepTypes",
columns: new[] { "Id", "AutomationProvider", "Code", "CreatedAt", "CreatedById", "Description", "DisplayName", "IsActive", "IsAutomated", "IsRequired", "ModifiedAt", "ModifiedById", "SortOrder" },
values: new object[,]
{
{ 1L, "identity_kyc_vendor", "identity_kyc", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "National-ID validity, name match and photo/video liveness via an Iranian e-KYC vendor.", "Identity Verification (KYC)", true, true, true, null, null, 1 },
{ 2L, "shahkar", "shahkar_match", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "Confirms the login SIM is registered to the nurse's own national ID (شاهکار).", "Shahkar Phone Binding", true, true, true, null, null, 2 }
});
migrationBuilder.InsertData(
schema: "verif",
table: "VerificationStepTypes",
columns: new[] { "Id", "AutomationProvider", "Code", "CreatedAt", "CreatedById", "Description", "DisplayName", "IsActive", "IsRequired", "ModifiedAt", "ModifiedById", "SortOrder" },
values: new object[,]
{
{ 3L, null, "moh_competency_license", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "پروانه صلاحیت حرفه‌ای — the MoH-mandated in-home nursing licence (bundles the criminal-record screen). Manual today.", "MoH Professional Competency License", true, true, null, null, 3 },
{ 4L, null, "ino_membership", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "نظام پرستاری membership cross-check (ino.ir). Manual today.", "Nursing Organization (INO) Membership", true, true, null, null, 4 },
{ 5L, null, "criminal_record", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "عدم سوء پیشینه — consent-gated, nurse-uploaded, time-limited (reverts on expiry).", "Criminal Record Certificate", true, true, null, null, 5 }
});
migrationBuilder.InsertData(
schema: "verif",
table: "VerificationStepTypes",
columns: new[] { "Id", "AutomationProvider", "Code", "CreatedAt", "CreatedById", "Description", "DisplayName", "IsActive", "IsAutomated", "IsRequired", "ModifiedAt", "ModifiedById", "SortOrder" },
values: new object[] { 6L, "sheba", "bank_account_verification", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "استعلام شبا — the payout IBAN owner's national ID must equal the verified nurse national ID.", "Bank Account (IBAN) Ownership", true, true, true, null, null, 6 });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
schema: "verif",
table: "VerificationStepTypes",
keyColumn: "Id",
keyValue: 1L);
migrationBuilder.DeleteData(
schema: "verif",
table: "VerificationStepTypes",
keyColumn: "Id",
keyValue: 2L);
migrationBuilder.DeleteData(
schema: "verif",
table: "VerificationStepTypes",
keyColumn: "Id",
keyValue: 3L);
migrationBuilder.DeleteData(
schema: "verif",
table: "VerificationStepTypes",
keyColumn: "Id",
keyValue: 4L);
migrationBuilder.DeleteData(
schema: "verif",
table: "VerificationStepTypes",
keyColumn: "Id",
keyValue: 5L);
migrationBuilder.DeleteData(
schema: "verif",
table: "VerificationStepTypes",
keyColumn: "Id",
keyValue: 6L);
}
}
}
@@ -609,6 +609,15 @@ namespace Baya.Infrastructure.Persistence.Migrations
Description = "Refresh-token session lifetime in days.",
Key = "auth_session_ttl_days",
Value = "30"
},
new
{
Id = 16L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "int",
Description = "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today).",
Key = "verification_expiry_scan_cadence_hours",
Value = "24"
});
});
@@ -2560,6 +2569,411 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("UserTokens", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseCredential", 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>("CredentialNumber")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("CredentialType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateOnly?>("ExpiresAt")
.HasColumnType("date");
b.Property<string>("HolderNameSnapshot")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateOnly?>("IssuedAt")
.HasColumnType("date");
b.Property<string>("IssuingAuthority")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NurseId")
.HasColumnType("bigint");
b.Property<string>("VerificationMethod")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("VerificationSource")
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<int?>("VerifiedByAdminId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("VerifiedByAdminId");
b.HasIndex("NurseId", "CredentialType");
b.ToTable("NurseCredentials", "verif");
});
modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset?>("ApprovedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("InternalNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NurseId")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("RejectedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("RejectionReason")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int?>("ReviewedByAdminId")
.HasColumnType("int");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("SuspendedAt")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("NurseId")
.IsUnique();
b.HasIndex("ReviewedByAdminId");
b.ToTable("NurseVerifications", "verif");
});
modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<long>("FileSizeBytes")
.HasColumnType("bigint");
b.Property<string>("IntegrityHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("ObjectStorageKey")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("nvarchar(400)");
b.Property<string>("OriginalFileName")
.HasMaxLength(260)
.HasColumnType("nvarchar(260)");
b.Property<long>("StepId")
.HasColumnType("bigint");
b.Property<int>("UploadedByUserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("StepId");
b.HasIndex("UploadedByUserId");
b.ToTable("VerificationDocuments", "verif");
});
modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("ExpiresAt")
.HasColumnType("datetimeoffset");
b.Property<string>("ExternalResponseJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("FailureReason")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<bool>("IsAutomated")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NurseVerificationId")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<long>("StepTypeId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("StepTypeId");
b.HasIndex("NurseVerificationId", "StepTypeId")
.IsUnique()
.HasDatabaseName("UX_VerificationSteps_Verification_StepType");
b.ToTable("VerificationSteps", "verif");
});
modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AutomationProvider")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<bool>("IsAutomated")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("IsRequired")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("IsActive", "SortOrder");
b.ToTable("VerificationStepTypes", "verif");
b.HasData(
new
{
Id = 1L,
AutomationProvider = "identity_kyc_vendor",
Code = "identity_kyc",
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
Description = "National-ID validity, name match and photo/video liveness via an Iranian e-KYC vendor.",
DisplayName = "Identity Verification (KYC)",
IsActive = true,
IsAutomated = true,
IsRequired = true,
SortOrder = 1
},
new
{
Id = 2L,
AutomationProvider = "shahkar",
Code = "shahkar_match",
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
Description = "Confirms the login SIM is registered to the nurse's own national ID (شاهکار).",
DisplayName = "Shahkar Phone Binding",
IsActive = true,
IsAutomated = true,
IsRequired = true,
SortOrder = 2
},
new
{
Id = 3L,
Code = "moh_competency_license",
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
Description = "پروانه صلاحیت حرفه‌ای — the MoH-mandated in-home nursing licence (bundles the criminal-record screen). Manual today.",
DisplayName = "MoH Professional Competency License",
IsActive = true,
IsAutomated = false,
IsRequired = true,
SortOrder = 3
},
new
{
Id = 4L,
Code = "ino_membership",
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
Description = "نظام پرستاری membership cross-check (ino.ir). Manual today.",
DisplayName = "Nursing Organization (INO) Membership",
IsActive = true,
IsAutomated = false,
IsRequired = true,
SortOrder = 4
},
new
{
Id = 5L,
Code = "criminal_record",
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
Description = "عدم سوء پیشینه — consent-gated, nurse-uploaded, time-limited (reverts on expiry).",
DisplayName = "Criminal Record Certificate",
IsActive = true,
IsAutomated = false,
IsRequired = true,
SortOrder = 5
},
new
{
Id = 6L,
AutomationProvider = "sheba",
Code = "bank_account_verification",
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
Description = "استعلام شبا — the payout IBAN owner's national ID must equal the verified nurse national ID.",
DisplayName = "Bank Account (IBAN) Ownership",
IsActive = true,
IsAutomated = true,
IsRequired = true,
SortOrder = 6
});
});
modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
@@ -2863,6 +3277,70 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.Navigation("User");
});
modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseCredential", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
.WithMany()
.HasForeignKey("NurseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("VerifiedByAdminId");
});
modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse")
.WithMany()
.HasForeignKey("NurseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("ReviewedByAdminId");
b.Navigation("Nurse");
});
modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b =>
{
b.HasOne("Baya.Domain.Entities.Verification.VerificationStep", "Step")
.WithMany("Documents")
.HasForeignKey("StepId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("UploadedByUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Step");
});
modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b =>
{
b.HasOne("Baya.Domain.Entities.Verification.NurseVerification", "NurseVerification")
.WithMany("Steps")
.HasForeignKey("NurseVerificationId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Verification.VerificationStepType", "StepType")
.WithMany("Steps")
.HasForeignKey("StepTypeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("NurseVerification");
b.Navigation("StepType");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
{
b.Navigation("Options");
@@ -2921,6 +3399,21 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.Navigation("UserRoles");
});
modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b =>
{
b.Navigation("Steps");
});
modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b =>
{
b.Navigation("Documents");
});
modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b =>
{
b.Navigation("Steps");
});
#pragma warning restore 612, 618
}
}
@@ -18,6 +18,7 @@ public class UnitOfWork : IUnitOfWork
public ICustomerAddressRepository CustomerAddressRepository { get; }
public ICatalogRepository CatalogRepository { get; }
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
public IVerificationRepository VerificationRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -34,6 +35,7 @@ public class UnitOfWork : IUnitOfWork
CustomerAddressRepository = new CustomerAddressRepository(_db);
CatalogRepository = new CatalogRepository(_db);
NurseServiceVariantRepository = new NurseServiceVariantRepository(_db);
VerificationRepository = new VerificationRepository(_db);
}
public Task CommitAsync()
@@ -42,6 +42,9 @@ internal sealed class NurseBankAccountRepository : BaseAsyncRepository<NurseBank
public Task<bool> HasAnyAsync(long nurseId, CancellationToken cancellationToken)
=> TableNoTracking.AnyAsync(a => a.NurseId == nurseId, cancellationToken);
public Task<NurseBankAccount> GetPrimaryAsync(long nurseId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(a => a.NurseId == nurseId && a.IsPrimary, 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.
@@ -15,6 +15,9 @@ internal sealed class NurseProfileRepository : BaseAsyncRepository<NurseProfile>
public Task<NurseProfile> GetByUserIdAsync(int userId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
public Task<NurseProfile> GetTrackedByIdAsync(long nurseProfileId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(p => p.Id == nurseProfileId, cancellationToken);
public Task AddAsync(NurseProfile profile, CancellationToken cancellationToken)
=> base.AddAsync(profile);
@@ -0,0 +1,301 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class VerificationRepository : BaseAsyncRepository<NurseVerification>, IVerificationRepository
{
public VerificationRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
private IQueryable<VerificationStepType> StepTypes => DbContext.Set<VerificationStepType>();
private IQueryable<VerificationStep> Steps => DbContext.Set<VerificationStep>();
private IQueryable<VerificationDocument> Documents => DbContext.Set<VerificationDocument>();
private IQueryable<NurseCredential> Credentials => DbContext.Set<NurseCredential>();
private IQueryable<NurseProfile> Profiles => DbContext.Set<NurseProfile>();
// --- Step-type catalog ---
public Task<VerificationStepType?> GetStepTypeByIdAsync(long id, CancellationToken cancellationToken)
=> StepTypes.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
public Task<bool> StepTypeCodeExistsAsync(string code, CancellationToken cancellationToken)
=> StepTypes.AsNoTracking().AnyAsync(t => t.Code == code, cancellationToken);
public Task<bool> StepTypeInUseAsync(long stepTypeId, CancellationToken cancellationToken)
=> Steps.AsNoTracking().AnyAsync(s => s.StepTypeId == stepTypeId, cancellationToken);
public async Task AddStepTypeAsync(VerificationStepType stepType, CancellationToken cancellationToken)
=> await DbContext.Set<VerificationStepType>().AddAsync(stepType, cancellationToken);
public Task<IReadOnlyList<VerificationStepTypeDto>> ListStepTypesAsync(bool includeInactive, CancellationToken cancellationToken)
=> ListStepTypesInternalAsync(includeInactive, cancellationToken);
private async Task<IReadOnlyList<VerificationStepTypeDto>> ListStepTypesInternalAsync(bool includeInactive, CancellationToken cancellationToken)
{
var query = StepTypes.AsNoTracking();
if (!includeInactive)
query = query.Where(t => t.IsActive);
return await query
.OrderBy(t => t.SortOrder).ThenBy(t => t.Id)
.Select(t => new VerificationStepTypeDto(
t.Id, t.Code, t.DisplayName, t.Description, t.IsRequired, t.IsAutomated, t.AutomationProvider, t.SortOrder, t.IsActive))
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<VerificationStepType>> GetActiveRequiredStepTypesAsync(CancellationToken cancellationToken)
=> await StepTypes.AsNoTracking()
.Where(t => t.IsActive && t.IsRequired)
.OrderBy(t => t.SortOrder)
.ToListAsync(cancellationToken);
// --- Nurse verification aggregate (tracked, for mutation) ---
public Task<NurseVerification?> GetTrackedByNurseIdAsync(long nurseId, CancellationToken cancellationToken)
=> Table
.Include(v => v.Steps).ThenInclude(s => s.StepType)
.FirstOrDefaultAsync(v => v.NurseId == nurseId, cancellationToken);
public Task<NurseVerification?> GetTrackedByIdAsync(long nurseVerificationId, CancellationToken cancellationToken)
=> Table
.Include(v => v.Steps)
.FirstOrDefaultAsync(v => v.Id == nurseVerificationId, cancellationToken);
public async Task AddVerificationAsync(NurseVerification verification, CancellationToken cancellationToken)
=> await base.AddAsync(verification);
public Task<VerificationStep?> GetTrackedStepForNurseAsync(long stepId, long nurseId, CancellationToken cancellationToken)
=> Steps
.Include(s => s.StepType)
.Include(s => s.NurseVerification).ThenInclude(v => v.Steps)
.FirstOrDefaultAsync(s => s.Id == stepId && s.NurseVerification.NurseId == nurseId, cancellationToken);
public Task<VerificationStep?> GetTrackedStepWithVerificationAsync(long stepId, CancellationToken cancellationToken)
=> Steps
.Include(s => s.StepType)
.Include(s => s.NurseVerification).ThenInclude(v => v.Steps)
.FirstOrDefaultAsync(s => s.Id == stepId, cancellationToken);
// --- Documents / credentials ---
public async Task AddDocumentAsync(VerificationDocument document, CancellationToken cancellationToken)
=> await DbContext.Set<VerificationDocument>().AddAsync(document, cancellationToken);
public async Task AddCredentialAsync(NurseCredential credential, CancellationToken cancellationToken)
=> await DbContext.Set<NurseCredential>().AddAsync(credential, cancellationToken);
// --- Projected reads ---
public async Task<VerificationStatusDto?> GetStatusForNurseAsync(long nurseId, CancellationToken cancellationToken)
{
var header = await Table.AsNoTracking()
.Where(v => v.NurseId == nurseId)
.Select(v => new { v.Id, v.Status })
.FirstOrDefaultAsync(cancellationToken);
if (header is null)
return null;
// Flat read + in-memory assembly: SQLite can't translate a nested collection projection.
var steps = await Steps.AsNoTracking()
.Where(s => s.NurseVerificationId == header.Id)
.OrderBy(s => s.StepType.SortOrder).ThenBy(s => s.Id)
.Select(s => new
{
s.Id,
Code = s.StepType.Code,
Display = s.StepType.DisplayName,
s.Status,
s.IsAutomated,
s.ExpiresAt,
s.FailureReason
})
.ToListAsync(cancellationToken);
var isBookable = await Profiles.AsNoTracking()
.Where(p => p.Id == nurseId)
.Select(p => p.IsVerified)
.FirstOrDefaultAsync(cancellationToken);
var stepDtos = steps
.Select(s => new VerificationStepDto(s.Id, s.Code, s.Display, s.Status.ToCode(), s.IsAutomated, s.ExpiresAt, s.FailureReason))
.ToList();
var blocking = steps
.Where(s => s.Status != VerificationStepStatus.Passed)
.Select(s => s.Code)
.ToList();
return new VerificationStatusDto(header.Status.ToCode(), isBookable, blocking, stepDtos);
}
public async Task<PagedResult<AdminPendingStepDto>> ListPendingStepsAsync(
VerificationStepStatus? status, int page, int pageSize, Func<string, string> signUrl, CancellationToken cancellationToken)
{
var effective = status ?? VerificationStepStatus.InReview;
var query = Steps.AsNoTracking().Where(s => s.Status == effective);
var total = await query.CountAsync(cancellationToken);
var rows = await query
.OrderBy(s => s.NurseVerification.SubmittedAt).ThenBy(s => s.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(s => new
{
s.NurseVerificationId,
NurseId = s.NurseVerification.NurseId,
NurseName = (s.NurseVerification.Nurse.User.Name ?? "") + " " + (s.NurseVerification.Nurse.User.FamilyName ?? ""),
StepId = s.Id,
StepCode = s.StepType.Code,
StepDisplay = s.StepType.DisplayName,
s.Status,
SubmittedAt = s.NurseVerification.SubmittedAt
})
.ToListAsync(cancellationToken);
var stepIds = rows.Select(r => r.StepId).ToList();
var docs = await LoadDocumentsAsync(stepIds, signUrl, cancellationToken);
var items = rows
.Select(r => new AdminPendingStepDto(
r.NurseVerificationId,
r.NurseId,
r.NurseName.Trim(),
r.StepId,
r.StepCode,
r.StepDisplay,
r.Status.ToCode(),
r.SubmittedAt,
docs.TryGetValue(r.StepId, out var stepDocs) ? stepDocs : []))
.ToList();
return new PagedResult<AdminPendingStepDto>(items, total, page, pageSize);
}
public async Task<AdminVerificationDetailDto?> GetDetailAsync(long nurseVerificationId, Func<string, string> signUrl, CancellationToken cancellationToken)
{
var header = await Table.AsNoTracking()
.Where(v => v.Id == nurseVerificationId)
.Select(v => new
{
v.Id,
v.NurseId,
v.Status,
IdentityName = (v.Nurse.User.Name ?? "") + " " + (v.Nurse.User.FamilyName ?? "")
})
.FirstOrDefaultAsync(cancellationToken);
if (header is null)
return null;
var steps = await Steps.AsNoTracking()
.Where(s => s.NurseVerificationId == header.Id)
.OrderBy(s => s.StepType.SortOrder).ThenBy(s => s.Id)
.Select(s => new
{
s.Id,
Code = s.StepType.Code,
Display = s.StepType.DisplayName,
s.Status,
s.IsAutomated,
s.ExpiresAt,
s.FailureReason
})
.ToListAsync(cancellationToken);
var stepIds = steps.Select(s => s.Id).ToList();
var docs = await LoadDocumentsAsync(stepIds, signUrl, cancellationToken);
var credentials = await Credentials.AsNoTracking()
.Where(c => c.NurseId == header.NurseId)
.OrderByDescending(c => c.Id)
.Select(c => new NurseCredentialDto(
c.Id, c.CredentialType, c.HolderNameSnapshot, c.IssuingAuthority, c.IssuedAt, c.ExpiresAt, c.VerificationMethod))
.ToListAsync(cancellationToken);
var stepDtos = steps
.Select(s => new AdminStepDetailDto(
s.Id, s.Code, s.Display, s.Status.ToCode(), s.IsAutomated, s.ExpiresAt, s.FailureReason,
docs.TryGetValue(s.Id, out var stepDocs) ? stepDocs : []))
.ToList();
return new AdminVerificationDetailDto(header.Id, header.NurseId, header.IdentityName.Trim(), header.Status.ToCode(), stepDtos, credentials);
}
public async Task<TrustBadgeDto?> GetTrustBadgeAsync(long nurseId, DateOnly today, CancellationToken cancellationToken)
{
var profile = await Profiles.AsNoTracking()
.Where(p => p.Id == nurseId)
.Select(p => new { p.IsVerified })
.FirstOrDefaultAsync(cancellationToken);
if (profile is null)
return null;
var approvedAt = await Table.AsNoTracking()
.Where(v => v.NurseId == nurseId)
.Select(v => v.ApprovedAt)
.FirstOrDefaultAsync(cancellationToken);
var credentialTypes = await Credentials.AsNoTracking()
.Where(c => c.NurseId == nurseId && (c.ExpiresAt == null || c.ExpiresAt >= today))
.Select(c => c.CredentialType)
.Distinct()
.ToListAsync(cancellationToken);
credentialTypes.Sort(StringComparer.Ordinal);
return new TrustBadgeDto(nurseId, profile.IsVerified, approvedAt, credentialTypes);
}
public Task<string?> GetNurseIdentityNameAsync(long nurseId, CancellationToken cancellationToken)
=> Profiles.AsNoTracking()
.Where(p => p.Id == nurseId)
.Select(p => (p.User.Name ?? "") + " " + (p.User.FamilyName ?? ""))
.FirstOrDefaultAsync(cancellationToken);
public Task<User?> GetTrackedUserAsync(int userId, CancellationToken cancellationToken)
=> DbContext.Set<User>().FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
public async Task<IReadOnlyList<ExpiringStepRow>> GetExpiredPassedStepsAsync(
DateTimeOffset asOf, int page, int pageSize, CancellationToken cancellationToken)
=> await Steps.AsNoTracking()
.Where(s => s.Status == VerificationStepStatus.Passed && s.ExpiresAt != null && s.ExpiresAt < asOf)
.OrderBy(s => s.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(s => new ExpiringStepRow(s.Id, s.NurseVerificationId, s.NurseVerification.NurseId))
.ToListAsync(cancellationToken);
private async Task<Dictionary<long, IReadOnlyList<VerificationDocumentDto>>> LoadDocumentsAsync(
IReadOnlyList<long> stepIds, Func<string, string> signUrl, CancellationToken cancellationToken)
{
if (stepIds.Count == 0)
return new Dictionary<long, IReadOnlyList<VerificationDocumentDto>>();
var rows = await Documents.AsNoTracking()
.Where(d => stepIds.Contains(d.StepId))
.OrderBy(d => d.Id)
.Select(d => new { d.Id, d.StepId, d.ContentType, d.FileSizeBytes, d.OriginalFileName, d.ObjectStorageKey })
.ToListAsync(cancellationToken);
return rows
.GroupBy(d => d.StepId)
.ToDictionary(
g => g.Key,
g => (IReadOnlyList<VerificationDocumentDto>)g
.Select(d => new VerificationDocumentDto(d.Id, d.ContentType, d.FileSizeBytes, d.OriginalFileName, signUrl(d.ObjectStorageKey)))
.ToList());
}
}
@@ -0,0 +1,98 @@
using System.Net;
using System.Net.Http.Json;
using System.Linq;
namespace Baya.Test.Api;
public class AdminVerificationApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
private static readonly string[] SeededCodes =
[
"identity_kyc", "shahkar_match", "moh_competency_license",
"ino_membership", "criminal_record", "bank_account_verification"
];
[Fact]
public async Task ListStepTypes_AsAdmin_ContainsTheSixSeededCodes()
{
var client = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, client, "09124200001");
var response = await client.GetAsync("/api/v1/admin_verification_step_types");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var codes = (await AuthTestClient.ReadDataAsync(response)).EnumerateArray()
.Select(s => s.GetProperty("code").GetString())
.ToHashSet();
foreach (var code in SeededCodes)
Assert.Contains(code, codes);
}
[Fact]
public async Task UpsertStepType_NewRow_Persists_ProvesDataDriven()
{
var client = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, client, "09124200002");
var body = new
{
id = (long?)null,
code = "liability_insurance",
displayName = "Professional Liability Insurance",
description = (string?)null,
isRequired = false,
isAutomated = false,
automationProvider = (string?)null,
sortOrder = 7,
isActive = true
};
var created = await client.PostAsJsonAsync("/api/v1/admin_verification_step_types", body);
Assert.Equal(HttpStatusCode.OK, created.StatusCode);
var list = await client.GetAsync("/api/v1/admin_verification_step_types?includeInactive=true");
var codes = (await AuthTestClient.ReadDataAsync(list)).EnumerateArray()
.Select(s => s.GetProperty("code").GetString())
.ToList();
Assert.Contains("liability_insurance", codes);
}
[Fact]
public async Task UpsertStepType_InvalidCode_Returns400()
{
var client = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, client, "09124200003");
var body = new
{
id = (long?)null,
code = "Bad Code!",
displayName = "X",
description = (string?)null,
isRequired = false,
isAutomated = false,
automationProvider = (string?)null,
sortOrder = 8,
isActive = true
};
var response = await client.PostAsJsonAsync("/api/v1/admin_verification_step_types", body);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task StepTypes_AsNurse_Returns403()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09124200004", "nurse");
var response = await client.GetAsync("/api/v1/admin_verification_step_types");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task StepTypes_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/admin_verification_step_types");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
@@ -0,0 +1,69 @@
using System.Net;
using System.Net.Http.Json;
using System.Linq;
namespace Baya.Test.Api;
public class NurseVerificationApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
private static object NurseProfileBody => new
{
bio = "Experienced home nurse",
yearsOfExperience = 5,
educationLevel = "BSc",
educationField = "Nursing",
specializationsJson = "[]"
};
[Fact]
public async Task Submit_SeedsChecklist_ThenIdentityKycPasses()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09124100001", "nurse");
await client.PostAsJsonAsync("/api/v1/nurse_profiles/upsert", NurseProfileBody);
var submit = await client.PostAsJsonAsync("/api/v1/nurse_verification/submit", new { });
Assert.Equal(HttpStatusCode.OK, submit.StatusCode);
var status = await AuthTestClient.ReadDataAsync(submit);
Assert.Equal("pending", status.GetProperty("status").GetString());
Assert.False(status.GetProperty("isBookable").GetBoolean());
var steps = status.GetProperty("steps").EnumerateArray().ToList();
Assert.Equal(6, steps.Count);
// is_automated is snapshotted onto each step.
Assert.True(steps.Single(s => s.GetProperty("code").GetString() == "identity_kyc").GetProperty("isAutomated").GetBoolean());
Assert.False(steps.Single(s => s.GetProperty("code").GetString() == "moh_competency_license").GetProperty("isAutomated").GetBoolean());
var run = await client.PostAsJsonAsync("/api/v1/nurse_verification/steps/identity_kyc/run",
new { nationalId = "0012345678", livenessPayload = (string?)null });
Assert.Equal(HttpStatusCode.OK, run.StatusCode);
Assert.Equal("passed", (await AuthTestClient.ReadDataAsync(run)).GetProperty("stepStatus").GetString());
var get = await client.GetAsync("/api/v1/nurse_verification");
var getData = await AuthTestClient.ReadDataAsync(get);
var identityStep = getData.GetProperty("steps").EnumerateArray()
.Single(s => s.GetProperty("code").GetString() == "identity_kyc");
Assert.Equal("passed", identityStep.GetProperty("status").GetString());
}
[Fact]
public async Task RunIdentityKyc_InvalidNationalId_Returns400()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09124100002", "nurse");
await client.PostAsJsonAsync("/api/v1/nurse_profiles/upsert", NurseProfileBody);
await client.PostAsJsonAsync("/api/v1/nurse_verification/submit", new { });
var run = await client.PostAsJsonAsync("/api/v1/nurse_verification/steps/identity_kyc/run",
new { nationalId = "123", livenessPayload = (string?)null });
Assert.Equal(HttpStatusCode.BadRequest, run.StatusCode);
}
[Fact]
public async Task Get_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/nurse_verification");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
@@ -0,0 +1,34 @@
using System.Net;
using System.Net.Http.Json;
namespace Baya.Test.Api;
public class PublicTrustBadgeApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[Fact]
public async Task TrustBadge_ExistingNurse_IsAnonymousAndShowsUnverified()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09124300001", "nurse");
var upsert = await client.PostAsJsonAsync("/api/v1/nurse_profiles/upsert",
new { bio = "b", yearsOfExperience = 3, educationLevel = "", educationField = "", specializationsJson = "[]" });
var nurseId = (await AuthTestClient.ReadDataAsync(upsert)).GetProperty("id").GetInt64();
// Anonymous client — the badge is public.
var anon = factory.CreateClient();
var response = await anon.GetAsync($"/api/v1/nurses/{nurseId}/trust_badge");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var badge = await AuthTestClient.ReadDataAsync(response);
Assert.False(badge.GetProperty("isVerified").GetBoolean());
Assert.Empty(badge.GetProperty("credentialTypes").EnumerateArray());
}
[Fact]
public async Task TrustBadge_UnknownNurse_Returns404()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/nurses/999999/trust_badge");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
@@ -0,0 +1,206 @@
using Baya.Application.Contracts.Audit;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Features.Verification.Commands.ReviewStep;
using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
using Baya.Application.Features.Verification.Commands.SuspendVerification;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.SupportAlerts;
using Baya.Domain.Entities.Verification;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using static Baya.Test.Foundation.Verification.VerificationTestSupport;
namespace Baya.Test.Foundation.Verification;
public class AdminVerificationHandlersTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero);
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly IVerificationRepository _verif = Substitute.For<IVerificationRepository>();
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
private readonly ICacheService _cache = Substitute.For<ICacheService>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
public AdminVerificationHandlersTests()
{
_currentUser.UserId.Returns(99);
_unitOfWork.VerificationRepository.Returns(_verif);
_unitOfWork.NurseProfileRepository.Returns(_nurses);
_clock.UtcNow.Returns(Now);
}
private static (VerificationStep Step, NurseVerification Verification) MohStepInReview()
{
var step = Step(5, StepType(3, VerificationStepTypeCodes.MohCompetencyLicense, automated: false), VerificationStepStatus.InReview);
var verification = new NurseVerification { NurseId = 42, Status = VerificationStatus.InReview };
verification.Steps.Add(step);
step.NurseVerification = verification;
return (step, verification);
}
private AdminReviewStepCommandHandler ReviewHandler(ICredentialVerifier credentialVerifier)
=> new(_currentUser, _unitOfWork, credentialVerifier, _audit, _cache, _clock);
[Fact]
public async Task Review_ApproveCredentialStep_RecordsCredentialAndFlipsVerified()
{
var (step, _) = MohStepInReview();
_verif.GetTrackedStepWithVerificationAsync(5, Arg.Any<CancellationToken>()).Returns(step);
_verif.GetNurseIdentityNameAsync(42, Arg.Any<CancellationToken>()).Returns("Ali Ahmadi");
var profile = new NurseProfile();
_nurses.GetTrackedByIdAsync(42, Arg.Any<CancellationToken>()).Returns(profile);
var credVerifier = Substitute.For<ICredentialVerifier>();
credVerifier.VerifyAsync(VerificationStepTypeCodes.MohCompetencyLicense, "LIC-1", Arg.Any<CancellationToken>())
.Returns(new CredentialVerificationResult(CredentialVerificationStatus.RequiresManualReview, "manual", null));
var result = await ReviewHandler(credVerifier).Handle(
new AdminReviewStepCommand(5, true, null, "LIC-1", "Ali Ahmadi", "Ministry of Health", null, null, null),
CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(VerificationStepStatus.Passed, step.Status);
Assert.True(profile.IsVerified);
await _verif.Received(1).AddCredentialAsync(
Arg.Is<NurseCredential>(c =>
c.NurseId == 42
&& c.CredentialType == VerificationStepTypeCodes.MohCompetencyLicense
&& c.HolderNameSnapshot == "Ali Ahmadi"
&& c.VerificationMethod == "manual"
&& c.VerifiedByAdminId == 99),
Arg.Any<CancellationToken>());
await _audit.Received(1).WriteAsync("verification_step", "5", "approve", Arg.Any<IReadOnlyDictionary<string, object?>>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Review_HolderNameMismatch_IsRejectedAndRecordsNoCredential()
{
var (step, _) = MohStepInReview();
_verif.GetTrackedStepWithVerificationAsync(5, Arg.Any<CancellationToken>()).Returns(step);
_verif.GetNurseIdentityNameAsync(42, Arg.Any<CancellationToken>()).Returns("Ali Ahmadi");
var credVerifier = Substitute.For<ICredentialVerifier>();
var result = await ReviewHandler(credVerifier).Handle(
new AdminReviewStepCommand(5, true, null, "LIC-1", "Someone Else", "Ministry of Health", null, null, null),
CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.Equal(VerificationStepStatus.InReview, step.Status);
await _verif.DidNotReceive().AddCredentialAsync(Arg.Any<NurseCredential>(), Arg.Any<CancellationToken>());
await _unitOfWork.DidNotReceive().CommitAsync();
}
[Fact]
public async Task Review_Reject_FailsStepWithReason()
{
var (step, verification) = MohStepInReview();
_verif.GetTrackedStepWithVerificationAsync(5, Arg.Any<CancellationToken>()).Returns(step);
_nurses.GetTrackedByIdAsync(42, Arg.Any<CancellationToken>()).Returns(new NurseProfile());
var credVerifier = Substitute.For<ICredentialVerifier>();
var result = await ReviewHandler(credVerifier).Handle(
new AdminReviewStepCommand(5, false, "Document is illegible", null, null, null, null, null, null),
CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(VerificationStepStatus.Failed, step.Status);
Assert.Equal("Document is illegible", step.FailureReason);
Assert.Equal("Document is illegible", verification.RejectionReason);
}
[Fact]
public async Task Suspend_ReversesVerifiedInSameTransaction()
{
var verification = new NurseVerification { NurseId = 42, Status = VerificationStatus.Approved };
verification.Steps.Add(Step(1, StepType(1, VerificationStepTypeCodes.IdentityKyc, automated: true), VerificationStepStatus.Passed));
_verif.GetTrackedByIdAsync(10, Arg.Any<CancellationToken>()).Returns(verification);
var profile = new NurseProfile();
profile.MarkVerified();
_nurses.GetTrackedByIdAsync(42, Arg.Any<CancellationToken>()).Returns(profile);
var handler = new AdminSuspendVerificationCommandHandler(_currentUser, _unitOfWork, _audit, _cache, _clock);
var result = await handler.Handle(new AdminSuspendVerificationCommand(10, "Fraud reported"), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(VerificationStatus.Suspended, verification.Status);
Assert.Equal(Now, verification.SuspendedAt);
Assert.False(profile.IsVerified);
await _cache.Received(1).RemoveAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Scan_ExpiredCriminalRecord_RevertsStepRaisesAlertAndNotifies()
{
var bank = Step(4, StepType(4, VerificationStepTypeCodes.BankAccountVerification, automated: true), VerificationStepStatus.Passed);
var criminal = Step(5, StepType(5, VerificationStepTypeCodes.CriminalRecord, automated: false), VerificationStepStatus.Passed);
criminal.ExpiresAt = Now.AddDays(-1);
var verification = new NurseVerification { NurseId = 42, Status = VerificationStatus.Approved };
verification.Steps.Add(bank);
verification.Steps.Add(criminal);
_verif.GetExpiredPassedStepsAsync(Now, 1, 50, Arg.Any<CancellationToken>())
.Returns(new List<ExpiringStepRow> { new(5, 10, 42) });
_verif.GetTrackedByIdAsync(10, Arg.Any<CancellationToken>()).Returns(verification);
var profile = new NurseProfile { UserId = 7 };
profile.MarkVerified();
_nurses.GetTrackedByIdAsync(42, Arg.Any<CancellationToken>()).Returns(profile);
var alerts = Substitute.For<ISupportAlertService>();
var notifications = Substitute.For<INotificationDispatcher>();
var handler = new ScanExpiringCredentialsCommandHandler(_unitOfWork, alerts, notifications, _cache, _clock);
var result = await handler.Handle(new ScanExpiringCredentialsCommand(), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Result.RevertedNurses);
Assert.Equal(VerificationStepStatus.Expired, criminal.Status);
Assert.False(profile.IsVerified);
await alerts.Received(1).RaiseAsync(
SupportAlertType.VerificationExpired, "nurse_profile", "42", SupportAlertSeverity.Medium,
Arg.Any<long?>(), Arg.Any<long?>(), Arg.Any<CancellationToken>());
await notifications.Received(1).DispatchAsync(
Arg.Is<Notification>(n => n.RecipientUserId == 7 && n.Type == "verification_expiry_prompt"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Scan_NullProfileNurse_LeavesNoDirtyStepForNextNursesCommit()
{
// Nurse A's profile is missing (e.g. soft-deleted) while their verification still has an expired
// step; nurse B is valid. The guard must run before mutating A's step, so A's step is never left
// dirty for B's commit to flush without the atomic re-gate.
var stepA = Step(5, StepType(5, VerificationStepTypeCodes.CriminalRecord, automated: false), VerificationStepStatus.Passed);
stepA.ExpiresAt = Now.AddDays(-1);
var verificationA = new NurseVerification { NurseId = 42, Status = VerificationStatus.Approved };
verificationA.Steps.Add(stepA);
var stepB = Step(6, StepType(5, VerificationStepTypeCodes.CriminalRecord, automated: false), VerificationStepStatus.Passed);
stepB.ExpiresAt = Now.AddDays(-1);
var verificationB = new NurseVerification { NurseId = 99, Status = VerificationStatus.Approved };
verificationB.Steps.Add(stepB);
_verif.GetExpiredPassedStepsAsync(Now, 1, 50, Arg.Any<CancellationToken>())
.Returns(new List<ExpiringStepRow> { new(5, 10, 42), new(6, 20, 99) });
_verif.GetTrackedByIdAsync(10, Arg.Any<CancellationToken>()).Returns(verificationA);
_verif.GetTrackedByIdAsync(20, Arg.Any<CancellationToken>()).Returns(verificationB);
_nurses.GetTrackedByIdAsync(42, Arg.Any<CancellationToken>()).ReturnsNull();
var profileB = new NurseProfile { UserId = 8 };
profileB.MarkVerified();
_nurses.GetTrackedByIdAsync(99, Arg.Any<CancellationToken>()).Returns(profileB);
var handler = new ScanExpiringCredentialsCommandHandler(
_unitOfWork, Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), _cache, _clock);
var result = await handler.Handle(new ScanExpiringCredentialsCommand(), CancellationToken.None);
Assert.Equal(1, result.Result.RevertedNurses);
Assert.Equal(VerificationStepStatus.Passed, stepA.Status); // never mutated
Assert.Equal(VerificationStepStatus.Expired, stepB.Status);
Assert.False(profileB.IsVerified);
await _unitOfWork.Received(1).CommitAsync();
}
}
@@ -0,0 +1,146 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Features.Verification.Commands.RunBankAccountVerification;
using Baya.Application.Features.Verification.Commands.RunIdentityKyc;
using Baya.Application.Features.Verification.Commands.RunShahkarMatch;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.SupportAlerts;
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
using NSubstitute;
using static Baya.Test.Foundation.Verification.VerificationTestSupport;
namespace Baya.Test.Foundation.Verification;
public class RunStepHandlersTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero);
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly IVerificationRepository _verif = Substitute.For<IVerificationRepository>();
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
private readonly INurseBankAccountRepository _accounts = Substitute.For<INurseBankAccountRepository>();
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
public RunStepHandlersTests()
{
_currentUser.UserId.Returns(7);
_currentUser.Roles.Returns([RoleNames.Nurse]);
_unitOfWork.VerificationRepository.Returns(_verif);
_unitOfWork.NurseProfileRepository.Returns(_nurses);
_unitOfWork.NurseBankAccountRepository.Returns(_accounts);
_clock.UtcNow.Returns(Now);
_nurses.GetProfileIdByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(42L);
_nurses.GetTrackedByIdAsync(42, Arg.Any<CancellationToken>()).Returns(new NurseProfile());
}
private static NurseVerification VerificationWith(VerificationStep step)
{
var verification = new NurseVerification { NurseId = 42, Status = VerificationStatus.Pending };
verification.Steps.Add(step);
return verification;
}
[Fact]
public async Task RunIdentityKyc_Pass_PopulatesNationalIdAndPassesStep()
{
var step = Step(1, StepType(1, VerificationStepTypeCodes.IdentityKyc, automated: true), VerificationStepStatus.Pending);
_verif.GetTrackedByNurseIdAsync(42, Arg.Any<CancellationToken>()).Returns(VerificationWith(step));
var user = new User();
_verif.GetTrackedUserAsync(7, Arg.Any<CancellationToken>()).Returns(user);
var identityKyc = Substitute.For<IIdentityKycProvider>();
identityKyc.VerifyAsync("0012345678", null, Arg.Any<CancellationToken>())
.Returns(new IdentityKycResult(true, "Verified Nurse", "ref", "{}", null));
var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock);
var result = await handler.Handle(new RunIdentityKycCommand("0012345678", null), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(VerificationStepStatus.Passed, step.Status);
Assert.Equal("0012345678", user.NationalId);
Assert.Equal(Now, user.NationalIdVerifiedAt);
await _unitOfWork.Received(1).CommitAsync();
}
[Fact]
public async Task RunIdentityKyc_Fail_MarksStepFailedAndLeavesNationalIdNull()
{
var step = Step(1, StepType(1, VerificationStepTypeCodes.IdentityKyc, automated: true), VerificationStepStatus.Pending);
_verif.GetTrackedByNurseIdAsync(42, Arg.Any<CancellationToken>()).Returns(VerificationWith(step));
var user = new User();
_verif.GetTrackedUserAsync(7, Arg.Any<CancellationToken>()).Returns(user);
var identityKyc = Substitute.For<IIdentityKycProvider>();
identityKyc.VerifyAsync("0000000000", null, Arg.Any<CancellationToken>())
.Returns(new IdentityKycResult(false, null, "ref", "{}", "could not verify"));
var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock);
var result = await handler.Handle(new RunIdentityKycCommand("0000000000", null), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(VerificationStepStatus.Failed, step.Status);
Assert.Equal("could not verify", step.FailureReason);
Assert.Null(user.NationalId);
}
[Fact]
public async Task RunShahkar_SharedSim_FailsStepAndRaisesAlert()
{
var step = Step(1, StepType(1, VerificationStepTypeCodes.ShahkarMatch, automated: true), VerificationStepStatus.Pending);
_verif.GetTrackedByNurseIdAsync(42, Arg.Any<CancellationToken>()).Returns(VerificationWith(step));
var user = new User { PhoneNumber = "09120000000", NationalId = "0012345678" };
_verif.GetTrackedUserAsync(7, Arg.Any<CancellationToken>()).Returns(user);
var shahkar = Substitute.For<IShahkarVerifier>();
shahkar.MatchAsync("09120000000", "0012345678", Arg.Any<CancellationToken>())
.Returns(new ShahkarMatchResult(false, true, "ref", "{}", "shared sim"));
var alerts = Substitute.For<ISupportAlertService>();
var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock);
var result = await handler.Handle(new RunShahkarMatchCommand(), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(VerificationStepStatus.Failed, step.Status);
await alerts.Received(1).RaiseAsync(
SupportAlertType.SharedSim, "nurse_profile", "42", SupportAlertSeverity.High,
Arg.Any<long?>(), Arg.Any<long?>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task RunShahkar_BeforeIdentityKyc_IsRejected()
{
var step = Step(1, StepType(1, VerificationStepTypeCodes.ShahkarMatch, automated: true), VerificationStepStatus.Pending);
_verif.GetTrackedByNurseIdAsync(42, Arg.Any<CancellationToken>()).Returns(VerificationWith(step));
_verif.GetTrackedUserAsync(7, Arg.Any<CancellationToken>()).Returns(new User { PhoneNumber = "09121112233" });
var shahkar = Substitute.For<IShahkarVerifier>();
var alerts = Substitute.For<ISupportAlertService>();
var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock);
var result = await handler.Handle(new RunShahkarMatchCommand(), CancellationToken.None);
Assert.False(result.IsSuccess);
await shahkar.DidNotReceive().MatchAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task RunBankAccount_Mismatch_FailsStepAndRecordsMismatch()
{
_nurses.GetIdentityContextByUserIdAsync(7, Arg.Any<CancellationToken>())
.Returns(new NurseIdentityContext(42, "0012345678"));
var step = Step(1, StepType(1, VerificationStepTypeCodes.BankAccountVerification, automated: true), VerificationStepStatus.Pending);
_verif.GetTrackedByNurseIdAsync(42, Arg.Any<CancellationToken>()).Returns(VerificationWith(step));
var account = new NurseBankAccount { NurseId = 42, Iban = "IR000000000000000000000000" };
_accounts.GetPrimaryAsync(42, Arg.Any<CancellationToken>()).Returns(account);
var verifier = Substitute.For<IBankAccountOwnershipVerifier>();
verifier.VerifyOwnershipAsync(account.Iban, "0012345678", Arg.Any<CancellationToken>())
.Returns(new OwnershipInquiryResult(false, "Someone Else", "ref"));
var handler = new RunBankAccountVerificationCommandHandler(_currentUser, _unitOfWork, verifier, _clock);
var result = await handler.Handle(new RunBankAccountVerificationCommand(), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(VerificationStepStatus.Failed, step.Status);
Assert.False(account.MatchedNationalId);
}
}
@@ -0,0 +1,90 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Verification.Commands.SubmitVerification;
using Baya.Application.Models.Identity;
using Baya.Application.Models.Verification;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using static Baya.Test.Foundation.Verification.VerificationTestSupport;
namespace Baya.Test.Foundation.Verification;
public class SubmitNurseVerificationHandlerTests
{
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly IVerificationRepository _verif = Substitute.For<IVerificationRepository>();
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
public SubmitNurseVerificationHandlerTests()
{
_currentUser.UserId.Returns(7);
_currentUser.Roles.Returns([RoleNames.Nurse]);
_unitOfWork.VerificationRepository.Returns(_verif);
_unitOfWork.NurseProfileRepository.Returns(_nurses);
_clock.UtcNow.Returns(new DateTimeOffset(2026, 7, 1, 12, 0, 0, TimeSpan.Zero));
_nurses.GetIdentityContextByUserIdAsync(7, Arg.Any<CancellationToken>())
.Returns(new NurseIdentityContext(42, "0012345678"));
_nurses.GetTrackedByIdAsync(42, Arg.Any<CancellationToken>()).Returns(new NurseProfile());
_verif.GetActiveRequiredStepTypesAsync(Arg.Any<CancellationToken>())
.Returns(new List<VerificationStepType>
{
StepType(1, VerificationStepTypeCodes.IdentityKyc, automated: true),
StepType(2, VerificationStepTypeCodes.MohCompetencyLicense, automated: false)
});
_verif.GetStatusForNurseAsync(42, Arg.Any<CancellationToken>())
.Returns(new VerificationStatusDto("pending", false, ["identity_kyc", "moh_competency_license"], []));
}
private SubmitNurseVerificationCommandHandler Handler() => new(_currentUser, _unitOfWork, _clock);
[Fact]
public async Task Submit_NewVerification_SeedsOneStepPerRequiredType_WithAutomationSnapshot()
{
_verif.GetTrackedByNurseIdAsync(42, Arg.Any<CancellationToken>()).ReturnsNull();
var result = await Handler().Handle(new SubmitNurseVerificationCommand(), CancellationToken.None);
Assert.True(result.IsSuccess);
await _verif.Received(1).AddVerificationAsync(
Arg.Is<NurseVerification>(v =>
v.NurseId == 42
&& v.Status == VerificationStatus.Pending
&& v.Steps.Count == 2
&& v.Steps.Any(s => s.StepTypeId == 1 && s.IsAutomated)
&& v.Steps.Any(s => s.StepTypeId == 2 && !s.IsAutomated)),
Arg.Any<CancellationToken>());
await _unitOfWork.Received(1).CommitAsync();
}
[Fact]
public async Task Submit_ExistingVerification_IsIdempotent_OnlyAddsMissingSteps()
{
var existing = new NurseVerification { NurseId = 42, Status = VerificationStatus.Pending, SubmittedAt = _clock.UtcNow };
existing.Steps.Add(Step(1, StepType(1, VerificationStepTypeCodes.IdentityKyc, automated: true), VerificationStepStatus.Pending));
_verif.GetTrackedByNurseIdAsync(42, Arg.Any<CancellationToken>()).Returns(existing);
var result = await Handler().Handle(new SubmitNurseVerificationCommand(), CancellationToken.None);
Assert.True(result.IsSuccess);
// Only the missing MoH step is added; the identity step is not duplicated.
Assert.Equal(2, existing.Steps.Count);
Assert.Single(existing.Steps, s => s.StepTypeId == 2);
await _verif.DidNotReceive().AddVerificationAsync(Arg.Any<NurseVerification>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Submit_NonNurse_IsForbidden()
{
_currentUser.Roles.Returns([RoleNames.Customer]);
var result = await Handler().Handle(new SubmitNurseVerificationCommand(), CancellationToken.None);
Assert.True(result.IsForbidden);
await _unitOfWork.DidNotReceive().CommitAsync();
}
}
@@ -0,0 +1,120 @@
using Baya.Application.Common;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Verification;
using static Baya.Test.Foundation.Verification.VerificationTestSupport;
namespace Baya.Test.Foundation.Verification;
public class VerificationAggregatorTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero);
private static (NurseVerification Verification, NurseProfile Profile) Build(params VerificationStepStatus[] statuses)
{
var verification = new NurseVerification { NurseId = 42, Status = VerificationStatus.Pending };
for (var i = 0; i < statuses.Length; i++)
verification.Steps.Add(Step(i + 1, StepType(i + 1, $"step_{i}", automated: false), statuses[i]));
return (verification, new NurseProfile());
}
[Fact]
public void Finalize_AllPassed_ApprovesAndFlipsVerified()
{
var (verification, profile) = Build(VerificationStepStatus.Passed, VerificationStepStatus.Passed);
var status = VerificationAggregator.Finalize(verification, profile, Now);
Assert.Equal(VerificationStatus.Approved, status);
Assert.Equal(VerificationStatus.Approved, verification.Status);
Assert.Equal(Now, verification.ApprovedAt);
Assert.True(profile.IsVerified);
}
[Fact]
public void Finalize_OneFailed_RejectsAndKeepsUnverified()
{
var (verification, profile) = Build(VerificationStepStatus.Passed, VerificationStepStatus.Failed);
VerificationAggregator.Finalize(verification, profile, Now);
Assert.Equal(VerificationStatus.Rejected, verification.Status);
Assert.Equal(Now, verification.RejectedAt);
Assert.Null(verification.ApprovedAt);
Assert.False(profile.IsVerified);
}
[Fact]
public void Finalize_OneInReview_SetsInReview()
{
var (verification, profile) = Build(VerificationStepStatus.Passed, VerificationStepStatus.InReview);
VerificationAggregator.Finalize(verification, profile, Now);
Assert.Equal(VerificationStatus.InReview, verification.Status);
Assert.False(profile.IsVerified);
}
[Fact]
public void Finalize_AllPending_StaysPending()
{
var (verification, profile) = Build(VerificationStepStatus.Pending, VerificationStepStatus.Pending);
VerificationAggregator.Finalize(verification, profile, Now);
Assert.Equal(VerificationStatus.Pending, verification.Status);
Assert.False(profile.IsVerified);
}
[Fact]
public void Finalize_PreviouslyApproved_ThenExpires_ReversesVerified()
{
var (verification, profile) = Build(VerificationStepStatus.Passed, VerificationStepStatus.Passed);
VerificationAggregator.Finalize(verification, profile, Now);
Assert.True(profile.IsVerified);
// A required step lapses (the expiry scan) → re-gate.
verification.Steps.Last().Status = VerificationStepStatus.Expired;
VerificationAggregator.Finalize(verification, profile, Now);
Assert.False(profile.IsVerified);
Assert.Equal(VerificationStatus.Pending, verification.Status);
Assert.Null(verification.ApprovedAt);
}
[Fact]
public void Finalize_Suspended_StaysSuspendedAndUnverified()
{
var (verification, profile) = Build(VerificationStepStatus.Passed, VerificationStepStatus.Passed);
profile.MarkVerified();
verification.Status = VerificationStatus.Suspended;
var status = VerificationAggregator.Finalize(verification, profile, Now);
Assert.Equal(VerificationStatus.Suspended, status);
Assert.False(profile.IsVerified);
}
[Fact]
public void Finalize_NoSteps_DoesNotApprove()
{
var verification = new NurseVerification { NurseId = 42, Status = VerificationStatus.Pending };
var profile = new NurseProfile();
VerificationAggregator.Finalize(verification, profile, Now);
Assert.False(profile.IsVerified);
Assert.Equal(VerificationStatus.Pending, verification.Status);
}
[Fact]
public void BlockingStepCodes_ReturnsCodesOfNonPassedSteps()
{
var (verification, _) = Build(VerificationStepStatus.Passed, VerificationStepStatus.Pending);
var blocking = VerificationAggregator.BlockingStepCodes(verification.Steps);
Assert.Single(blocking);
Assert.Equal("step_1", blocking[0]);
}
}
@@ -0,0 +1,38 @@
using Baya.Domain.Entities.Verification;
namespace Baya.Test.Foundation.Verification;
/// <summary>
/// Helpers for the verification handler/aggregator tests. Entity ids have a protected setter (they are
/// assigned by EF on save); these set them via reflection so a mocked repository can return graphs with
/// stable ids without a real DbContext.
/// </summary>
internal static class VerificationTestSupport
{
public static T WithId<T>(this T entity, long id)
{
var setter = typeof(T).GetProperty("Id")!.GetSetMethod(nonPublic: true)!;
setter.Invoke(entity, [id]);
return entity;
}
public static VerificationStepType StepType(long id, string code, bool automated, bool required = true)
=> new VerificationStepType
{
Code = code,
DisplayName = code,
IsAutomated = automated,
IsRequired = required,
IsActive = true,
SortOrder = (int)id
}.WithId(id);
public static VerificationStep Step(long id, VerificationStepType type, VerificationStepStatus status)
=> new VerificationStep
{
StepTypeId = type.Id,
StepType = type,
Status = status,
IsAutomated = type.IsAutomated
}.WithId(id);
}