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:
@@ -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 & 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);
|
||||
+68
@@ -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()));
|
||||
}
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
}
|
||||
+18
@@ -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>>;
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
@@ -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>>;
|
||||
+43
@@ -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));
|
||||
}
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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>>;
|
||||
+144
@@ -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.";
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
}
|
||||
+23
@@ -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>>;
|
||||
+84
@@ -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));
|
||||
}
|
||||
}
|
||||
+10
@@ -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>>;
|
||||
+75
@@ -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));
|
||||
}
|
||||
}
|
||||
+14
@@ -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.");
|
||||
}
|
||||
}
|
||||
+12
@@ -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>>;
|
||||
+87
@@ -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));
|
||||
}
|
||||
}
|
||||
+10
@@ -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>>;
|
||||
+86
@@ -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));
|
||||
}
|
||||
}
|
||||
+11
@@ -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>>;
|
||||
+75
@@ -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!);
|
||||
}
|
||||
}
|
||||
+12
@@ -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>>;
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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>>;
|
||||
+74
@@ -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));
|
||||
}
|
||||
}
|
||||
+20
@@ -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 (a–z, 0–9, 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);
|
||||
}
|
||||
}
|
||||
+22
@@ -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>>;
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
@@ -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>>;
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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>>;
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.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);
|
||||
}
|
||||
}
|
||||
+10
@@ -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>>;
|
||||
+26
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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>>>;
|
||||
+26
@@ -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);
|
||||
}
|
||||
}
|
||||
+10
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user