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:
+23
@@ -0,0 +1,23 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
|
||||
namespace Baya.Infrastructure.CrossCutting.Seams;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="ICredentialVerifier"/> — and the mock. MoH پروانه صلاحیت حرفهای and INO membership
|
||||
/// have <b>no public B2B API</b>, so verification is a manual admin review of the uploaded document against
|
||||
/// the official portal: every call returns <see cref="CredentialVerificationStatus.RequiresManualReview"/>
|
||||
/// with <c>verification_method = manual</c>. When an <c>api</c>/<c>portal</c> source becomes available, a
|
||||
/// real implementation replaces this registration and starts returning
|
||||
/// <see cref="CredentialVerificationStatus.Verified"/>/<see cref="CredentialVerificationStatus.Failed"/>
|
||||
/// with the matching method — callers are unchanged.
|
||||
/// </summary>
|
||||
public sealed class MockCredentialVerifier : ICredentialVerifier
|
||||
{
|
||||
public Task<CredentialVerificationResult> VerifyAsync(string credentialType, string? credentialNumber, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new CredentialVerificationResult(
|
||||
CredentialVerificationStatus.RequiresManualReview,
|
||||
VerificationMethods.Manual,
|
||||
ExternalResponseJson: null));
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#nullable enable
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Baya.Infrastructure.CrossCutting.Seams;
|
||||
|
||||
/// <summary>
|
||||
/// Mock <see cref="IIdentityKycProvider"/>: a deterministic fake identity + liveness check — no real
|
||||
/// OCR/liveness call. Passes every well-formed national id except the configured
|
||||
/// <see cref="IdentityKycOptions.FailNationalId"/>. On pass it reports a matched name and populates the
|
||||
/// verified identity. A real Iranian e-KYC vendor (Finnotech / U-ID / Jibbit / Farashensa / Verify /
|
||||
/// Kavoshak) swaps in by a registration change — callers are unchanged.
|
||||
/// </summary>
|
||||
public sealed class MockIdentityKycProvider(IOptions<SeamOptions> options) : IIdentityKycProvider
|
||||
{
|
||||
private readonly IdentityKycOptions _options = options.Value.IdentityKyc;
|
||||
|
||||
public Task<IdentityKycResult> VerifyAsync(string nationalId, string? livenessPayload, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var id = (nationalId ?? string.Empty).Trim();
|
||||
var vendorRef = $"MOCK-KYC-{Token(id)}";
|
||||
var wellFormed = id.Length == 10 && id.All(char.IsDigit);
|
||||
|
||||
if (!wellFormed || string.Equals(id, _options.FailNationalId, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult(new IdentityKycResult(
|
||||
Passed: false,
|
||||
MatchedName: null,
|
||||
VendorRef: vendorRef,
|
||||
ExternalResponseJson: Payload("fail", id, livenessPayload, passed: false),
|
||||
FailureReason: "Identity could not be verified against the civil registry."));
|
||||
}
|
||||
|
||||
return Task.FromResult(new IdentityKycResult(
|
||||
Passed: true,
|
||||
MatchedName: _options.MatchedName,
|
||||
VendorRef: vendorRef,
|
||||
ExternalResponseJson: Payload("pass", id, livenessPayload, passed: true),
|
||||
FailureReason: null));
|
||||
}
|
||||
|
||||
private static string Payload(string outcome, string nationalId, string? liveness, bool passed)
|
||||
=> JsonSerializer.Serialize(new
|
||||
{
|
||||
provider = "mock_identity_kyc",
|
||||
outcome,
|
||||
passed,
|
||||
national_id_present = !string.IsNullOrEmpty(nationalId),
|
||||
liveness_present = !string.IsNullOrEmpty(liveness)
|
||||
});
|
||||
|
||||
private static string Token(string value)
|
||||
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..12];
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
#nullable enable
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Baya.Infrastructure.CrossCutting.Seams;
|
||||
|
||||
/// <summary>
|
||||
/// Mock <see cref="IShahkarVerifier"/>: a deterministic fake شاهکار phone↔national-id inquiry — no real
|
||||
/// Shahkar/KYC call. Matches every pair except the configured <see cref="ShahkarOptions.SharedSimPhone"/>
|
||||
/// (returns the explicit shared-SIM failure state) and <see cref="ShahkarOptions.MismatchNationalId"/>
|
||||
/// (returns a plain mismatch). The vendor ref is derived from the inputs, so re-running is idempotent. A
|
||||
/// real Finnotech/KYC client swaps in by a registration change — callers are unchanged.
|
||||
/// </summary>
|
||||
public sealed class MockShahkarVerifier(IOptions<SeamOptions> options) : IShahkarVerifier
|
||||
{
|
||||
private readonly ShahkarOptions _options = options.Value.Shahkar;
|
||||
|
||||
public Task<ShahkarMatchResult> MatchAsync(string? phoneNumber, string? nationalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = (phoneNumber ?? string.Empty).Trim();
|
||||
var vendorRef = $"MOCK-SHAHKAR-{Token(phone + "|" + (nationalId ?? string.Empty))}";
|
||||
|
||||
if (string.Equals(phone, _options.SharedSimPhone, StringComparison.Ordinal))
|
||||
{
|
||||
var json = Payload("shared_sim", phone, nationalId, matched: false);
|
||||
return Task.FromResult(new ShahkarMatchResult(
|
||||
Matched: false,
|
||||
IsSharedSim: true,
|
||||
VendorRef: vendorRef,
|
||||
ExternalResponseJson: json,
|
||||
FailureReason: "This SIM appears to be registered to a family member. Please use a SIM registered in your own name."));
|
||||
}
|
||||
|
||||
if (string.Equals(nationalId, _options.MismatchNationalId, StringComparison.Ordinal))
|
||||
{
|
||||
var json = Payload("mismatch", phone, nationalId, matched: false);
|
||||
return Task.FromResult(new ShahkarMatchResult(
|
||||
Matched: false,
|
||||
IsSharedSim: false,
|
||||
VendorRef: vendorRef,
|
||||
ExternalResponseJson: json,
|
||||
FailureReason: "The phone number is not registered to your national ID."));
|
||||
}
|
||||
|
||||
return Task.FromResult(new ShahkarMatchResult(
|
||||
Matched: true,
|
||||
IsSharedSim: false,
|
||||
VendorRef: vendorRef,
|
||||
ExternalResponseJson: Payload("match", phone, nationalId, matched: true),
|
||||
FailureReason: null));
|
||||
}
|
||||
|
||||
private static string Payload(string outcome, string phone, string? nationalId, bool matched)
|
||||
=> JsonSerializer.Serialize(new
|
||||
{
|
||||
provider = "mock_shahkar",
|
||||
outcome,
|
||||
matched,
|
||||
phone_last4 = phone.Length >= 4 ? phone[^4..] : phone,
|
||||
national_id_present = !string.IsNullOrEmpty(nationalId)
|
||||
});
|
||||
|
||||
private static string Token(string value)
|
||||
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..12];
|
||||
}
|
||||
@@ -12,6 +12,38 @@ public sealed class SeamOptions
|
||||
public ObjectStorageOptions ObjectStorage { get; set; } = new();
|
||||
public BankOwnershipOptions BankOwnership { get; set; } = new();
|
||||
public GeocodingOptions Geocoding { get; set; } = new();
|
||||
public ShahkarOptions Shahkar { get; set; } = new();
|
||||
public IdentityKycOptions IdentityKyc { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tunes the mock <c>IShahkarVerifier</c> (phone↔national-id binding). A submitted phone equal to
|
||||
/// <see cref="SharedSimPhone"/> returns the explicit shared-SIM failure; a national id equal to
|
||||
/// <see cref="MismatchNationalId"/> returns a plain mismatch; every other pair matches. The real vendor
|
||||
/// implementation ignores these.
|
||||
/// </summary>
|
||||
public sealed class ShahkarOptions
|
||||
{
|
||||
/// <summary>The designated test phone that returns the shared-SIM failure state.</summary>
|
||||
public string SharedSimPhone { get; set; } = "09120000000";
|
||||
|
||||
/// <summary>The designated test national id that returns a plain phone↔national-id mismatch.</summary>
|
||||
public string MismatchNationalId { get; set; } = "1111111111";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tunes the mock <c>IIdentityKycProvider</c>. A national id equal to <see cref="FailNationalId"/> fails
|
||||
/// KYC; every other (well-formed) national id passes with <see cref="MatchedName"/>. The real e-KYC vendor
|
||||
/// implementation ignores these.
|
||||
/// </summary>
|
||||
public sealed class IdentityKycOptions
|
||||
{
|
||||
/// <summary>The designated test national id that fails identity KYC.</summary>
|
||||
public string FailNationalId { get; set; } = "0000000000";
|
||||
|
||||
/// <summary>The name the mock reports as matched on a passing KYC (informational; the authoritative
|
||||
/// identity name for credential cross-check comes from the users row).</summary>
|
||||
public string MatchedName { get; set; } = "Verified Nurse";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+7
@@ -36,6 +36,13 @@ public static class ServiceCollectionExtension
|
||||
// centroid with no network call; a real Neshan/Google geocoding client replaces this registration.
|
||||
services.AddSingleton<IGeocoder, MockGeocoder>();
|
||||
|
||||
// Nurse-verification vendors (backend-phase-6). All three are deterministic mocks; a real Iranian
|
||||
// e-KYC vendor / Shahkar bridge / (future) MoH-INO portal swaps in by a registration change only —
|
||||
// no mock behaviour is baked into any handler call site.
|
||||
services.AddSingleton<IShahkarVerifier, MockShahkarVerifier>();
|
||||
services.AddSingleton<IIdentityKycProvider, MockIdentityKycProvider>();
|
||||
services.AddSingleton<ICredentialVerifier, MockCredentialVerifier>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using Baya.Application.Contracts.Common;
|
||||
using Baya.Domain.Common;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using Baya.Infrastructure.Persistence.ValueConversion;
|
||||
using Baya.SharedKernel.Extensions;
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
@@ -131,5 +132,12 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
|
||||
builder.Property(a => a.RecipientName).HasConversion(encrypted);
|
||||
builder.Property(a => a.RecipientPhone).HasConversion(encrypted);
|
||||
});
|
||||
|
||||
// b6 credential PII: the license/membership number is encrypted at rest through the same seam and
|
||||
// is never serialized on the wire (the trust badge exposes credential types, never numbers).
|
||||
modelBuilder.Entity<NurseCredential>(builder =>
|
||||
{
|
||||
builder.Property(c => c.CredentialNumber).HasConversion(encrypted);
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
@@ -43,6 +43,7 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration<PlatformCo
|
||||
(13, "auth_otp_resend_seconds", "120", ConfigDataType.Int, "Seconds a phone must wait before another OTP can be requested."),
|
||||
(14, "auth_otp_max_attempts", "5", ConfigDataType.Int, "Wrong-code attempts allowed before OTP verification is refused until a fresh code."),
|
||||
(15, "auth_session_ttl_days", "30", ConfigDataType.Int, "Refresh-token session lifetime in days."),
|
||||
(16, "verification_expiry_scan_cadence_hours", "24", ConfigDataType.Int, "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today)."),
|
||||
];
|
||||
|
||||
return rows
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.VerificationConfig;
|
||||
|
||||
internal sealed class NurseCredentialConfig : IEntityTypeConfiguration<NurseCredential>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<NurseCredential> builder)
|
||||
{
|
||||
builder.ToTable("NurseCredentials", "verif");
|
||||
|
||||
builder.Property(c => c.CredentialType).HasMaxLength(50).IsRequired();
|
||||
// credential_number is encrypted at rest (converter wired in ApplicationDbContext) — never serialized.
|
||||
builder.Property(c => c.CredentialNumber).HasMaxLength(256).IsRequired();
|
||||
builder.Property(c => c.HolderNameSnapshot).HasMaxLength(200).IsRequired();
|
||||
builder.Property(c => c.IssuingAuthority).HasMaxLength(200).IsRequired();
|
||||
builder.Property(c => c.VerificationSource).HasMaxLength(300);
|
||||
builder.Property(c => c.VerificationMethod).HasMaxLength(20).IsRequired();
|
||||
|
||||
// Badge + renewal reads: the nurse's non-expired credentials by type.
|
||||
builder.HasIndex(c => new { c.NurseId, c.CredentialType });
|
||||
|
||||
builder.HasOne<NurseProfile>()
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.NurseId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne<User>()
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.VerifiedByAdminId)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasQueryFilter(c => c.DeletedAt == null);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.VerificationConfig;
|
||||
|
||||
internal sealed class NurseVerificationConfig : IEntityTypeConfiguration<NurseVerification>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<NurseVerification> builder)
|
||||
{
|
||||
builder.ToTable("NurseVerifications", "verif");
|
||||
|
||||
// Status persists as its stable snake_case code — readable and queryable (WHERE status = 'approved').
|
||||
builder.Property(v => v.Status)
|
||||
.HasConversion(s => s.ToCode(), s => VerificationCodes.ParseStatus(s))
|
||||
.HasMaxLength(20)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(v => v.RejectionReason).HasMaxLength(1000);
|
||||
builder.Property(v => v.InternalNotes).HasMaxLength(2000);
|
||||
|
||||
// 1:1 with the nurse profile — the sole verification header per nurse.
|
||||
builder.HasIndex(v => v.NurseId).IsUnique();
|
||||
builder.HasOne(v => v.Nurse)
|
||||
.WithMany()
|
||||
.HasForeignKey(v => v.NurseId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne<User>()
|
||||
.WithMany()
|
||||
.HasForeignKey(v => v.ReviewedByAdminId)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasQueryFilter(v => v.DeletedAt == null);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.VerificationConfig;
|
||||
|
||||
internal sealed class VerificationDocumentConfig : IEntityTypeConfiguration<VerificationDocument>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<VerificationDocument> builder)
|
||||
{
|
||||
builder.ToTable("VerificationDocuments", "verif");
|
||||
|
||||
// Metadata only — the bytes live in object storage behind a signed URL, never in the DB.
|
||||
builder.Property(d => d.ObjectStorageKey).HasMaxLength(400).IsRequired();
|
||||
builder.Property(d => d.IntegrityHash).HasMaxLength(64).IsRequired();
|
||||
builder.Property(d => d.ContentType).HasMaxLength(100).IsRequired();
|
||||
builder.Property(d => d.OriginalFileName).HasMaxLength(260);
|
||||
|
||||
builder.HasOne(d => d.Step)
|
||||
.WithMany(s => s.Documents)
|
||||
.HasForeignKey(d => d.StepId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne<User>()
|
||||
.WithMany()
|
||||
.HasForeignKey(d => d.UploadedByUserId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasQueryFilter(d => d.DeletedAt == null);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.VerificationConfig;
|
||||
|
||||
internal sealed class VerificationStepConfig : IEntityTypeConfiguration<VerificationStep>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<VerificationStep> builder)
|
||||
{
|
||||
builder.ToTable("VerificationSteps", "verif");
|
||||
|
||||
builder.Property(s => s.Status)
|
||||
.HasConversion(s => s.ToCode(), s => VerificationCodes.ParseStepStatus(s))
|
||||
.HasMaxLength(20)
|
||||
.IsRequired();
|
||||
|
||||
// Raw KYC-vendor response, kept for audit — no length cap (NVARCHAR(MAX)).
|
||||
builder.Property(s => s.ExternalResponseJson);
|
||||
builder.Property(s => s.FailureReason).HasMaxLength(500);
|
||||
builder.Property(s => s.IsAutomated).HasDefaultValue(false);
|
||||
|
||||
// One step per (verification, step-type) — the idempotent-submit backstop.
|
||||
builder.HasIndex(s => new { s.NurseVerificationId, s.StepTypeId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_VerificationSteps_Verification_StepType");
|
||||
|
||||
builder.HasOne(s => s.NurseVerification)
|
||||
.WithMany(v => v.Steps)
|
||||
.HasForeignKey(s => s.NurseVerificationId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne(s => s.StepType)
|
||||
.WithMany(t => t.Steps)
|
||||
.HasForeignKey(s => s.StepTypeId)
|
||||
.IsRequired();
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.VerificationConfig;
|
||||
|
||||
internal sealed class VerificationStepTypeConfig : IEntityTypeConfiguration<VerificationStepType>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<VerificationStepType> builder)
|
||||
{
|
||||
builder.ToTable("VerificationStepTypes", "verif");
|
||||
|
||||
builder.Property(t => t.Code).HasMaxLength(50).IsRequired();
|
||||
builder.Property(t => t.DisplayName).HasMaxLength(150).IsRequired();
|
||||
builder.Property(t => t.Description).HasMaxLength(500);
|
||||
builder.Property(t => t.AutomationProvider).HasMaxLength(50);
|
||||
builder.Property(t => t.IsRequired).HasDefaultValue(false);
|
||||
builder.Property(t => t.IsAutomated).HasDefaultValue(false);
|
||||
builder.Property(t => t.SortOrder).HasDefaultValue(0);
|
||||
builder.Property(t => t.IsActive).HasDefaultValue(true);
|
||||
|
||||
// Stable machine code — the data-driven pipeline's identity; one nurse-step joins on it.
|
||||
builder.HasIndex(t => t.Code).IsUnique();
|
||||
|
||||
// Ordered admin/nurse browse of the active catalog.
|
||||
builder.HasIndex(t => new { t.IsActive, t.SortOrder });
|
||||
|
||||
// The six step-types are seeded via HasData; the resulting InsertData is captured in a dedicated
|
||||
// seed migration (SeedVerificationStepTypes), kept separate from the table-creation migration.
|
||||
builder.HasData(VerificationStepTypeSeed.StepTypes());
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.VerificationConfig;
|
||||
|
||||
using Baya.Domain.Entities.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// The six MVP pipeline step-types, seeded via <c>HasData</c> so the data-driven pipeline exists on a fresh
|
||||
/// DB (the b1 seeding path). Ids are fixed and deterministic (1…6, sort_order = id) so re-running is
|
||||
/// idempotent and the model snapshot stays stable. A new regulatory step is one more row — never a code
|
||||
/// enum. Emitted in its own "seed" migration, separate from the table-creation migration.
|
||||
/// </summary>
|
||||
internal static class VerificationStepTypeSeed
|
||||
{
|
||||
private static readonly (long Id, string Code, string Display, string Description, bool Required, bool Automated, string Provider)[] Rows =
|
||||
[
|
||||
(1, VerificationStepTypeCodes.IdentityKyc, "Identity Verification (KYC)",
|
||||
"National-ID validity, name match and photo/video liveness via an Iranian e-KYC vendor.",
|
||||
true, true, AutomationProviders.IdentityKycVendor),
|
||||
(2, VerificationStepTypeCodes.ShahkarMatch, "Shahkar Phone Binding",
|
||||
"Confirms the login SIM is registered to the nurse's own national ID (شاهکار).",
|
||||
true, true, AutomationProviders.Shahkar),
|
||||
(3, VerificationStepTypeCodes.MohCompetencyLicense, "MoH Professional Competency License",
|
||||
"پروانه صلاحیت حرفهای — the MoH-mandated in-home nursing licence (bundles the criminal-record screen). Manual today.",
|
||||
true, false, null),
|
||||
(4, VerificationStepTypeCodes.InoMembership, "Nursing Organization (INO) Membership",
|
||||
"نظام پرستاری membership cross-check (ino.ir). Manual today.",
|
||||
true, false, null),
|
||||
(5, VerificationStepTypeCodes.CriminalRecord, "Criminal Record Certificate",
|
||||
"عدم سوء پیشینه — consent-gated, nurse-uploaded, time-limited (reverts on expiry).",
|
||||
true, false, null),
|
||||
(6, VerificationStepTypeCodes.BankAccountVerification, "Bank Account (IBAN) Ownership",
|
||||
"استعلام شبا — the payout IBAN owner's national ID must equal the verified nurse national ID.",
|
||||
true, true, AutomationProviders.Sheba),
|
||||
];
|
||||
|
||||
public static object[] StepTypes()
|
||||
{
|
||||
var ts = SeedConstants.Timestamp;
|
||||
return Rows
|
||||
.Select(r => (object)new
|
||||
{
|
||||
r.Id,
|
||||
r.Code,
|
||||
DisplayName = r.Display,
|
||||
Description = (string)r.Description,
|
||||
IsRequired = r.Required,
|
||||
IsAutomated = r.Automated,
|
||||
AutomationProvider = r.Provider,
|
||||
SortOrder = (int)r.Id,
|
||||
IsActive = true,
|
||||
CreatedAt = ts
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
+3346
File diff suppressed because it is too large
Load Diff
+302
@@ -0,0 +1,302 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class VerificationPipeline : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "verif");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NurseCredentials",
|
||||
schema: "verif",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
NurseId = table.Column<long>(type: "bigint", nullable: false),
|
||||
CredentialType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
CredentialNumber = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
HolderNameSnapshot = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
IssuingAuthority = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
IssuedAt = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
ExpiresAt = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
VerificationSource = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: true),
|
||||
VerificationMethod = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
VerifiedByAdminId = table.Column<int>(type: "int", nullable: true),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NurseCredentials", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_NurseCredentials_NurseProfiles_NurseId",
|
||||
column: x => x.NurseId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "NurseProfiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_NurseCredentials_Users_VerifiedByAdminId",
|
||||
column: x => x.VerifiedByAdminId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "Users",
|
||||
principalColumn: "UserId");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NurseVerifications",
|
||||
schema: "verif",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
NurseId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
SubmittedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
ApprovedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
RejectedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
SuspendedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
RejectionReason = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
|
||||
ReviewedByAdminId = table.Column<int>(type: "int", nullable: true),
|
||||
InternalNotes = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NurseVerifications", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_NurseVerifications_NurseProfiles_NurseId",
|
||||
column: x => x.NurseId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "NurseProfiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_NurseVerifications_Users_ReviewedByAdminId",
|
||||
column: x => x.ReviewedByAdminId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "Users",
|
||||
principalColumn: "UserId");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VerificationStepTypes",
|
||||
schema: "verif",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Code = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
DisplayName = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
IsRequired = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||
IsAutomated = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||
AutomationProvider = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VerificationStepTypes", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VerificationSteps",
|
||||
schema: "verif",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
NurseVerificationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
StepTypeId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
ExternalResponseJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
ExpiresAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
IsAutomated = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||
StartedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CompletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
FailureReason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VerificationSteps", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VerificationSteps_NurseVerifications_NurseVerificationId",
|
||||
column: x => x.NurseVerificationId,
|
||||
principalSchema: "verif",
|
||||
principalTable: "NurseVerifications",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VerificationSteps_VerificationStepTypes_StepTypeId",
|
||||
column: x => x.StepTypeId,
|
||||
principalSchema: "verif",
|
||||
principalTable: "VerificationStepTypes",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VerificationDocuments",
|
||||
schema: "verif",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
StepId = table.Column<long>(type: "bigint", nullable: false),
|
||||
ObjectStorageKey = table.Column<string>(type: "nvarchar(400)", maxLength: 400, nullable: false),
|
||||
IntegrityHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
ContentType = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
FileSizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
OriginalFileName = table.Column<string>(type: "nvarchar(260)", maxLength: 260, nullable: true),
|
||||
UploadedByUserId = table.Column<int>(type: "int", nullable: false),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VerificationDocuments", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VerificationDocuments_Users_UploadedByUserId",
|
||||
column: x => x.UploadedByUserId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "Users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VerificationDocuments_VerificationSteps_StepId",
|
||||
column: x => x.StepId,
|
||||
principalSchema: "verif",
|
||||
principalTable: "VerificationSteps",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
schema: "ops",
|
||||
table: "PlatformConfigs",
|
||||
columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" },
|
||||
values: new object[] { 16L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today).", "verification_expiry_scan_cadence_hours", null, null, "24" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NurseCredentials_NurseId_CredentialType",
|
||||
schema: "verif",
|
||||
table: "NurseCredentials",
|
||||
columns: new[] { "NurseId", "CredentialType" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NurseCredentials_VerifiedByAdminId",
|
||||
schema: "verif",
|
||||
table: "NurseCredentials",
|
||||
column: "VerifiedByAdminId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NurseVerifications_NurseId",
|
||||
schema: "verif",
|
||||
table: "NurseVerifications",
|
||||
column: "NurseId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NurseVerifications_ReviewedByAdminId",
|
||||
schema: "verif",
|
||||
table: "NurseVerifications",
|
||||
column: "ReviewedByAdminId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VerificationDocuments_StepId",
|
||||
schema: "verif",
|
||||
table: "VerificationDocuments",
|
||||
column: "StepId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VerificationDocuments_UploadedByUserId",
|
||||
schema: "verif",
|
||||
table: "VerificationDocuments",
|
||||
column: "UploadedByUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VerificationSteps_StepTypeId",
|
||||
schema: "verif",
|
||||
table: "VerificationSteps",
|
||||
column: "StepTypeId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_VerificationSteps_Verification_StepType",
|
||||
schema: "verif",
|
||||
table: "VerificationSteps",
|
||||
columns: new[] { "NurseVerificationId", "StepTypeId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VerificationStepTypes_Code",
|
||||
schema: "verif",
|
||||
table: "VerificationStepTypes",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VerificationStepTypes_IsActive_SortOrder",
|
||||
schema: "verif",
|
||||
table: "VerificationStepTypes",
|
||||
columns: new[] { "IsActive", "SortOrder" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "NurseCredentials",
|
||||
schema: "verif");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VerificationDocuments",
|
||||
schema: "verif");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VerificationSteps",
|
||||
schema: "verif");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NurseVerifications",
|
||||
schema: "verif");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VerificationStepTypes",
|
||||
schema: "verif");
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
schema: "ops",
|
||||
table: "PlatformConfigs",
|
||||
keyColumn: "Id",
|
||||
keyValue: 16L);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3423
File diff suppressed because it is too large
Load Diff
+84
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SeedVerificationStepTypes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.InsertData(
|
||||
schema: "verif",
|
||||
table: "VerificationStepTypes",
|
||||
columns: new[] { "Id", "AutomationProvider", "Code", "CreatedAt", "CreatedById", "Description", "DisplayName", "IsActive", "IsAutomated", "IsRequired", "ModifiedAt", "ModifiedById", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1L, "identity_kyc_vendor", "identity_kyc", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "National-ID validity, name match and photo/video liveness via an Iranian e-KYC vendor.", "Identity Verification (KYC)", true, true, true, null, null, 1 },
|
||||
{ 2L, "shahkar", "shahkar_match", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "Confirms the login SIM is registered to the nurse's own national ID (شاهکار).", "Shahkar Phone Binding", true, true, true, null, null, 2 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
schema: "verif",
|
||||
table: "VerificationStepTypes",
|
||||
columns: new[] { "Id", "AutomationProvider", "Code", "CreatedAt", "CreatedById", "Description", "DisplayName", "IsActive", "IsRequired", "ModifiedAt", "ModifiedById", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 3L, null, "moh_competency_license", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "پروانه صلاحیت حرفهای — the MoH-mandated in-home nursing licence (bundles the criminal-record screen). Manual today.", "MoH Professional Competency License", true, true, null, null, 3 },
|
||||
{ 4L, null, "ino_membership", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "نظام پرستاری membership cross-check (ino.ir). Manual today.", "Nursing Organization (INO) Membership", true, true, null, null, 4 },
|
||||
{ 5L, null, "criminal_record", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "عدم سوء پیشینه — consent-gated, nurse-uploaded, time-limited (reverts on expiry).", "Criminal Record Certificate", true, true, null, null, 5 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
schema: "verif",
|
||||
table: "VerificationStepTypes",
|
||||
columns: new[] { "Id", "AutomationProvider", "Code", "CreatedAt", "CreatedById", "Description", "DisplayName", "IsActive", "IsAutomated", "IsRequired", "ModifiedAt", "ModifiedById", "SortOrder" },
|
||||
values: new object[] { 6L, "sheba", "bank_account_verification", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "استعلام شبا — the payout IBAN owner's national ID must equal the verified nurse national ID.", "Bank Account (IBAN) Ownership", true, true, true, null, null, 6 });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
schema: "verif",
|
||||
table: "VerificationStepTypes",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1L);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
schema: "verif",
|
||||
table: "VerificationStepTypes",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2L);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
schema: "verif",
|
||||
table: "VerificationStepTypes",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3L);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
schema: "verif",
|
||||
table: "VerificationStepTypes",
|
||||
keyColumn: "Id",
|
||||
keyValue: 4L);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
schema: "verif",
|
||||
table: "VerificationStepTypes",
|
||||
keyColumn: "Id",
|
||||
keyValue: 5L);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
schema: "verif",
|
||||
table: "VerificationStepTypes",
|
||||
keyColumn: "Id",
|
||||
keyValue: 6L);
|
||||
}
|
||||
}
|
||||
}
|
||||
+493
@@ -609,6 +609,15 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
Description = "Refresh-token session lifetime in days.",
|
||||
Key = "auth_session_ttl_days",
|
||||
Value = "30"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 16L,
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
DataType = "int",
|
||||
Description = "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today).",
|
||||
Key = "verification_expiry_scan_cadence_hours",
|
||||
Value = "24"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2560,6 +2569,411 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("UserTokens", "usr");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseCredential", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("CredentialNumber")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("CredentialType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<DateOnly?>("ExpiresAt")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("HolderNameSnapshot")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateOnly?>("IssuedAt")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("IssuingAuthority")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<long>("NurseId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("VerificationMethod")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("VerificationSource")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("nvarchar(300)");
|
||||
|
||||
b.Property<int?>("VerifiedByAdminId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VerifiedByAdminId");
|
||||
|
||||
b.HasIndex("NurseId", "CredentialType");
|
||||
|
||||
b.ToTable("NurseCredentials", "verif");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset?>("ApprovedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("InternalNotes")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<long>("NurseId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset?>("RejectedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("RejectionReason")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<int?>("ReviewedByAdminId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<DateTimeOffset?>("SubmittedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<DateTimeOffset?>("SuspendedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NurseId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("ReviewedByAdminId");
|
||||
|
||||
b.ToTable("NurseVerifications", "verif");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<long>("FileSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("IntegrityHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ObjectStorageKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(400)
|
||||
.HasColumnType("nvarchar(400)");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(260)
|
||||
.HasColumnType("nvarchar(260)");
|
||||
|
||||
b.Property<long>("StepId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("UploadedByUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StepId");
|
||||
|
||||
b.HasIndex("UploadedByUserId");
|
||||
|
||||
b.ToTable("VerificationDocuments", "verif");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("ExternalResponseJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("FailureReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<bool>("IsAutomated")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<long>("NurseVerificationId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset?>("StartedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<long>("StepTypeId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StepTypeId");
|
||||
|
||||
b.HasIndex("NurseVerificationId", "StepTypeId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_VerificationSteps_Verification_StepType");
|
||||
|
||||
b.ToTable("VerificationSteps", "verif");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AutomationProvider")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("nvarchar(150)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("IsAutomated")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsRequired")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("IsActive", "SortOrder");
|
||||
|
||||
b.ToTable("VerificationStepTypes", "verif");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1L,
|
||||
AutomationProvider = "identity_kyc_vendor",
|
||||
Code = "identity_kyc",
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
Description = "National-ID validity, name match and photo/video liveness via an Iranian e-KYC vendor.",
|
||||
DisplayName = "Identity Verification (KYC)",
|
||||
IsActive = true,
|
||||
IsAutomated = true,
|
||||
IsRequired = true,
|
||||
SortOrder = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2L,
|
||||
AutomationProvider = "shahkar",
|
||||
Code = "shahkar_match",
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
Description = "Confirms the login SIM is registered to the nurse's own national ID (شاهکار).",
|
||||
DisplayName = "Shahkar Phone Binding",
|
||||
IsActive = true,
|
||||
IsAutomated = true,
|
||||
IsRequired = true,
|
||||
SortOrder = 2
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3L,
|
||||
Code = "moh_competency_license",
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
Description = "پروانه صلاحیت حرفهای — the MoH-mandated in-home nursing licence (bundles the criminal-record screen). Manual today.",
|
||||
DisplayName = "MoH Professional Competency License",
|
||||
IsActive = true,
|
||||
IsAutomated = false,
|
||||
IsRequired = true,
|
||||
SortOrder = 3
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4L,
|
||||
Code = "ino_membership",
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
Description = "نظام پرستاری membership cross-check (ino.ir). Manual today.",
|
||||
DisplayName = "Nursing Organization (INO) Membership",
|
||||
IsActive = true,
|
||||
IsAutomated = false,
|
||||
IsRequired = true,
|
||||
SortOrder = 4
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 5L,
|
||||
Code = "criminal_record",
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
Description = "عدم سوء پیشینه — consent-gated, nurse-uploaded, time-limited (reverts on expiry).",
|
||||
DisplayName = "Criminal Record Certificate",
|
||||
IsActive = true,
|
||||
IsAutomated = false,
|
||||
IsRequired = true,
|
||||
SortOrder = 5
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 6L,
|
||||
AutomationProvider = "sheba",
|
||||
Code = "bank_account_verification",
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
Description = "استعلام شبا — the payout IBAN owner's national ID must equal the verified nurse national ID.",
|
||||
DisplayName = "Bank Account (IBAN) Ownership",
|
||||
IsActive = true,
|
||||
IsAutomated = true,
|
||||
IsRequired = true,
|
||||
SortOrder = 6
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.User.User", null)
|
||||
@@ -2863,6 +3277,70 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseCredential", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("NurseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.User.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("VerifiedByAdminId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse")
|
||||
.WithMany()
|
||||
.HasForeignKey("NurseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.User.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ReviewedByAdminId");
|
||||
|
||||
b.Navigation("Nurse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Verification.VerificationStep", "Step")
|
||||
.WithMany("Documents")
|
||||
.HasForeignKey("StepId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.User.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UploadedByUserId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Step");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Verification.NurseVerification", "NurseVerification")
|
||||
.WithMany("Steps")
|
||||
.HasForeignKey("NurseVerificationId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Verification.VerificationStepType", "StepType")
|
||||
.WithMany("Steps")
|
||||
.HasForeignKey("StepTypeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("NurseVerification");
|
||||
|
||||
b.Navigation("StepType");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
|
||||
{
|
||||
b.Navigation("Options");
|
||||
@@ -2921,6 +3399,21 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.Navigation("UserRoles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b =>
|
||||
{
|
||||
b.Navigation("Steps");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b =>
|
||||
{
|
||||
b.Navigation("Documents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b =>
|
||||
{
|
||||
b.Navigation("Steps");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -18,6 +18,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
public ICustomerAddressRepository CustomerAddressRepository { get; }
|
||||
public ICatalogRepository CatalogRepository { get; }
|
||||
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
|
||||
public IVerificationRepository VerificationRepository { get; }
|
||||
|
||||
public UnitOfWork(ApplicationDbContext db)
|
||||
{
|
||||
@@ -34,6 +35,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
CustomerAddressRepository = new CustomerAddressRepository(_db);
|
||||
CatalogRepository = new CatalogRepository(_db);
|
||||
NurseServiceVariantRepository = new NurseServiceVariantRepository(_db);
|
||||
VerificationRepository = new VerificationRepository(_db);
|
||||
}
|
||||
|
||||
public Task CommitAsync()
|
||||
|
||||
+3
@@ -42,6 +42,9 @@ internal sealed class NurseBankAccountRepository : BaseAsyncRepository<NurseBank
|
||||
public Task<bool> HasAnyAsync(long nurseId, CancellationToken cancellationToken)
|
||||
=> TableNoTracking.AnyAsync(a => a.NurseId == nurseId, cancellationToken);
|
||||
|
||||
public Task<NurseBankAccount> GetPrimaryAsync(long nurseId, CancellationToken cancellationToken)
|
||||
=> Table.FirstOrDefaultAsync(a => a.NurseId == nurseId && a.IsPrimary, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<NurseBankAccountDto>> ListAsync(long nurseId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Decrypt the IBAN in memory, then mask to last-4 — the full value never leaves the repository.
|
||||
|
||||
+3
@@ -15,6 +15,9 @@ internal sealed class NurseProfileRepository : BaseAsyncRepository<NurseProfile>
|
||||
public Task<NurseProfile> GetByUserIdAsync(int userId, CancellationToken cancellationToken)
|
||||
=> Table.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
|
||||
|
||||
public Task<NurseProfile> GetTrackedByIdAsync(long nurseProfileId, CancellationToken cancellationToken)
|
||||
=> Table.FirstOrDefaultAsync(p => p.Id == nurseProfileId, cancellationToken);
|
||||
|
||||
public Task AddAsync(NurseProfile profile, CancellationToken cancellationToken)
|
||||
=> base.AddAsync(profile);
|
||||
|
||||
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Verification;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Repositories;
|
||||
|
||||
internal sealed class VerificationRepository : BaseAsyncRepository<NurseVerification>, IVerificationRepository
|
||||
{
|
||||
public VerificationRepository(ApplicationDbContext dbContext) : base(dbContext)
|
||||
{
|
||||
}
|
||||
|
||||
private IQueryable<VerificationStepType> StepTypes => DbContext.Set<VerificationStepType>();
|
||||
private IQueryable<VerificationStep> Steps => DbContext.Set<VerificationStep>();
|
||||
private IQueryable<VerificationDocument> Documents => DbContext.Set<VerificationDocument>();
|
||||
private IQueryable<NurseCredential> Credentials => DbContext.Set<NurseCredential>();
|
||||
private IQueryable<NurseProfile> Profiles => DbContext.Set<NurseProfile>();
|
||||
|
||||
// --- Step-type catalog ---
|
||||
|
||||
public Task<VerificationStepType?> GetStepTypeByIdAsync(long id, CancellationToken cancellationToken)
|
||||
=> StepTypes.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
|
||||
|
||||
public Task<bool> StepTypeCodeExistsAsync(string code, CancellationToken cancellationToken)
|
||||
=> StepTypes.AsNoTracking().AnyAsync(t => t.Code == code, cancellationToken);
|
||||
|
||||
public Task<bool> StepTypeInUseAsync(long stepTypeId, CancellationToken cancellationToken)
|
||||
=> Steps.AsNoTracking().AnyAsync(s => s.StepTypeId == stepTypeId, cancellationToken);
|
||||
|
||||
public async Task AddStepTypeAsync(VerificationStepType stepType, CancellationToken cancellationToken)
|
||||
=> await DbContext.Set<VerificationStepType>().AddAsync(stepType, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<VerificationStepTypeDto>> ListStepTypesAsync(bool includeInactive, CancellationToken cancellationToken)
|
||||
=> ListStepTypesInternalAsync(includeInactive, cancellationToken);
|
||||
|
||||
private async Task<IReadOnlyList<VerificationStepTypeDto>> ListStepTypesInternalAsync(bool includeInactive, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = StepTypes.AsNoTracking();
|
||||
if (!includeInactive)
|
||||
query = query.Where(t => t.IsActive);
|
||||
|
||||
return await query
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.Id)
|
||||
.Select(t => new VerificationStepTypeDto(
|
||||
t.Id, t.Code, t.DisplayName, t.Description, t.IsRequired, t.IsAutomated, t.AutomationProvider, t.SortOrder, t.IsActive))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<VerificationStepType>> GetActiveRequiredStepTypesAsync(CancellationToken cancellationToken)
|
||||
=> await StepTypes.AsNoTracking()
|
||||
.Where(t => t.IsActive && t.IsRequired)
|
||||
.OrderBy(t => t.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// --- Nurse verification aggregate (tracked, for mutation) ---
|
||||
|
||||
public Task<NurseVerification?> GetTrackedByNurseIdAsync(long nurseId, CancellationToken cancellationToken)
|
||||
=> Table
|
||||
.Include(v => v.Steps).ThenInclude(s => s.StepType)
|
||||
.FirstOrDefaultAsync(v => v.NurseId == nurseId, cancellationToken);
|
||||
|
||||
public Task<NurseVerification?> GetTrackedByIdAsync(long nurseVerificationId, CancellationToken cancellationToken)
|
||||
=> Table
|
||||
.Include(v => v.Steps)
|
||||
.FirstOrDefaultAsync(v => v.Id == nurseVerificationId, cancellationToken);
|
||||
|
||||
public async Task AddVerificationAsync(NurseVerification verification, CancellationToken cancellationToken)
|
||||
=> await base.AddAsync(verification);
|
||||
|
||||
public Task<VerificationStep?> GetTrackedStepForNurseAsync(long stepId, long nurseId, CancellationToken cancellationToken)
|
||||
=> Steps
|
||||
.Include(s => s.StepType)
|
||||
.Include(s => s.NurseVerification).ThenInclude(v => v.Steps)
|
||||
.FirstOrDefaultAsync(s => s.Id == stepId && s.NurseVerification.NurseId == nurseId, cancellationToken);
|
||||
|
||||
public Task<VerificationStep?> GetTrackedStepWithVerificationAsync(long stepId, CancellationToken cancellationToken)
|
||||
=> Steps
|
||||
.Include(s => s.StepType)
|
||||
.Include(s => s.NurseVerification).ThenInclude(v => v.Steps)
|
||||
.FirstOrDefaultAsync(s => s.Id == stepId, cancellationToken);
|
||||
|
||||
// --- Documents / credentials ---
|
||||
|
||||
public async Task AddDocumentAsync(VerificationDocument document, CancellationToken cancellationToken)
|
||||
=> await DbContext.Set<VerificationDocument>().AddAsync(document, cancellationToken);
|
||||
|
||||
public async Task AddCredentialAsync(NurseCredential credential, CancellationToken cancellationToken)
|
||||
=> await DbContext.Set<NurseCredential>().AddAsync(credential, cancellationToken);
|
||||
|
||||
// --- Projected reads ---
|
||||
|
||||
public async Task<VerificationStatusDto?> GetStatusForNurseAsync(long nurseId, CancellationToken cancellationToken)
|
||||
{
|
||||
var header = await Table.AsNoTracking()
|
||||
.Where(v => v.NurseId == nurseId)
|
||||
.Select(v => new { v.Id, v.Status })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (header is null)
|
||||
return null;
|
||||
|
||||
// Flat read + in-memory assembly: SQLite can't translate a nested collection projection.
|
||||
var steps = await Steps.AsNoTracking()
|
||||
.Where(s => s.NurseVerificationId == header.Id)
|
||||
.OrderBy(s => s.StepType.SortOrder).ThenBy(s => s.Id)
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
Code = s.StepType.Code,
|
||||
Display = s.StepType.DisplayName,
|
||||
s.Status,
|
||||
s.IsAutomated,
|
||||
s.ExpiresAt,
|
||||
s.FailureReason
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var isBookable = await Profiles.AsNoTracking()
|
||||
.Where(p => p.Id == nurseId)
|
||||
.Select(p => p.IsVerified)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
var stepDtos = steps
|
||||
.Select(s => new VerificationStepDto(s.Id, s.Code, s.Display, s.Status.ToCode(), s.IsAutomated, s.ExpiresAt, s.FailureReason))
|
||||
.ToList();
|
||||
|
||||
var blocking = steps
|
||||
.Where(s => s.Status != VerificationStepStatus.Passed)
|
||||
.Select(s => s.Code)
|
||||
.ToList();
|
||||
|
||||
return new VerificationStatusDto(header.Status.ToCode(), isBookable, blocking, stepDtos);
|
||||
}
|
||||
|
||||
public async Task<PagedResult<AdminPendingStepDto>> ListPendingStepsAsync(
|
||||
VerificationStepStatus? status, int page, int pageSize, Func<string, string> signUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
var effective = status ?? VerificationStepStatus.InReview;
|
||||
|
||||
var query = Steps.AsNoTracking().Where(s => s.Status == effective);
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
var rows = await query
|
||||
.OrderBy(s => s.NurseVerification.SubmittedAt).ThenBy(s => s.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(s => new
|
||||
{
|
||||
s.NurseVerificationId,
|
||||
NurseId = s.NurseVerification.NurseId,
|
||||
NurseName = (s.NurseVerification.Nurse.User.Name ?? "") + " " + (s.NurseVerification.Nurse.User.FamilyName ?? ""),
|
||||
StepId = s.Id,
|
||||
StepCode = s.StepType.Code,
|
||||
StepDisplay = s.StepType.DisplayName,
|
||||
s.Status,
|
||||
SubmittedAt = s.NurseVerification.SubmittedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var stepIds = rows.Select(r => r.StepId).ToList();
|
||||
var docs = await LoadDocumentsAsync(stepIds, signUrl, cancellationToken);
|
||||
|
||||
var items = rows
|
||||
.Select(r => new AdminPendingStepDto(
|
||||
r.NurseVerificationId,
|
||||
r.NurseId,
|
||||
r.NurseName.Trim(),
|
||||
r.StepId,
|
||||
r.StepCode,
|
||||
r.StepDisplay,
|
||||
r.Status.ToCode(),
|
||||
r.SubmittedAt,
|
||||
docs.TryGetValue(r.StepId, out var stepDocs) ? stepDocs : []))
|
||||
.ToList();
|
||||
|
||||
return new PagedResult<AdminPendingStepDto>(items, total, page, pageSize);
|
||||
}
|
||||
|
||||
public async Task<AdminVerificationDetailDto?> GetDetailAsync(long nurseVerificationId, Func<string, string> signUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
var header = await Table.AsNoTracking()
|
||||
.Where(v => v.Id == nurseVerificationId)
|
||||
.Select(v => new
|
||||
{
|
||||
v.Id,
|
||||
v.NurseId,
|
||||
v.Status,
|
||||
IdentityName = (v.Nurse.User.Name ?? "") + " " + (v.Nurse.User.FamilyName ?? "")
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (header is null)
|
||||
return null;
|
||||
|
||||
var steps = await Steps.AsNoTracking()
|
||||
.Where(s => s.NurseVerificationId == header.Id)
|
||||
.OrderBy(s => s.StepType.SortOrder).ThenBy(s => s.Id)
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
Code = s.StepType.Code,
|
||||
Display = s.StepType.DisplayName,
|
||||
s.Status,
|
||||
s.IsAutomated,
|
||||
s.ExpiresAt,
|
||||
s.FailureReason
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var stepIds = steps.Select(s => s.Id).ToList();
|
||||
var docs = await LoadDocumentsAsync(stepIds, signUrl, cancellationToken);
|
||||
|
||||
var credentials = await Credentials.AsNoTracking()
|
||||
.Where(c => c.NurseId == header.NurseId)
|
||||
.OrderByDescending(c => c.Id)
|
||||
.Select(c => new NurseCredentialDto(
|
||||
c.Id, c.CredentialType, c.HolderNameSnapshot, c.IssuingAuthority, c.IssuedAt, c.ExpiresAt, c.VerificationMethod))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var stepDtos = steps
|
||||
.Select(s => new AdminStepDetailDto(
|
||||
s.Id, s.Code, s.Display, s.Status.ToCode(), s.IsAutomated, s.ExpiresAt, s.FailureReason,
|
||||
docs.TryGetValue(s.Id, out var stepDocs) ? stepDocs : []))
|
||||
.ToList();
|
||||
|
||||
return new AdminVerificationDetailDto(header.Id, header.NurseId, header.IdentityName.Trim(), header.Status.ToCode(), stepDtos, credentials);
|
||||
}
|
||||
|
||||
public async Task<TrustBadgeDto?> GetTrustBadgeAsync(long nurseId, DateOnly today, CancellationToken cancellationToken)
|
||||
{
|
||||
var profile = await Profiles.AsNoTracking()
|
||||
.Where(p => p.Id == nurseId)
|
||||
.Select(p => new { p.IsVerified })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (profile is null)
|
||||
return null;
|
||||
|
||||
var approvedAt = await Table.AsNoTracking()
|
||||
.Where(v => v.NurseId == nurseId)
|
||||
.Select(v => v.ApprovedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
var credentialTypes = await Credentials.AsNoTracking()
|
||||
.Where(c => c.NurseId == nurseId && (c.ExpiresAt == null || c.ExpiresAt >= today))
|
||||
.Select(c => c.CredentialType)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
credentialTypes.Sort(StringComparer.Ordinal);
|
||||
|
||||
return new TrustBadgeDto(nurseId, profile.IsVerified, approvedAt, credentialTypes);
|
||||
}
|
||||
|
||||
public Task<string?> GetNurseIdentityNameAsync(long nurseId, CancellationToken cancellationToken)
|
||||
=> Profiles.AsNoTracking()
|
||||
.Where(p => p.Id == nurseId)
|
||||
.Select(p => (p.User.Name ?? "") + " " + (p.User.FamilyName ?? ""))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
public Task<User?> GetTrackedUserAsync(int userId, CancellationToken cancellationToken)
|
||||
=> DbContext.Set<User>().FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<ExpiringStepRow>> GetExpiredPassedStepsAsync(
|
||||
DateTimeOffset asOf, int page, int pageSize, CancellationToken cancellationToken)
|
||||
=> await Steps.AsNoTracking()
|
||||
.Where(s => s.Status == VerificationStepStatus.Passed && s.ExpiresAt != null && s.ExpiresAt < asOf)
|
||||
.OrderBy(s => s.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(s => new ExpiringStepRow(s.Id, s.NurseVerificationId, s.NurseVerification.NurseId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
private async Task<Dictionary<long, IReadOnlyList<VerificationDocumentDto>>> LoadDocumentsAsync(
|
||||
IReadOnlyList<long> stepIds, Func<string, string> signUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
if (stepIds.Count == 0)
|
||||
return new Dictionary<long, IReadOnlyList<VerificationDocumentDto>>();
|
||||
|
||||
var rows = await Documents.AsNoTracking()
|
||||
.Where(d => stepIds.Contains(d.StepId))
|
||||
.OrderBy(d => d.Id)
|
||||
.Select(d => new { d.Id, d.StepId, d.ContentType, d.FileSizeBytes, d.OriginalFileName, d.ObjectStorageKey })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return rows
|
||||
.GroupBy(d => d.StepId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<VerificationDocumentDto>)g
|
||||
.Select(d => new VerificationDocumentDto(d.Id, d.ContentType, d.FileSizeBytes, d.OriginalFileName, signUrl(d.ObjectStorageKey)))
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user