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
@@ -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";
}