383 lines
22 KiB
Markdown
383 lines
22 KiB
Markdown
# Server persistence
|
||
|
||
EF Core rules, money, state machines, snapshots, the scheduler, and the domain invariants that live in the
|
||
database.
|
||
|
||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||
|
||
---
|
||
|
||
## 1. EF Core basics
|
||
|
||
```csharp
|
||
// ✅ project to a DTO in the query
|
||
var dto = await _db.Orders
|
||
.AsNoTracking()
|
||
.Where(o => o.UserId == userId)
|
||
.Select(o => new OrderResult(o.Id, o.Status, o.CreatedAt))
|
||
.ToListAsync(ct);
|
||
|
||
// ❌ loads the entity graph then maps in memory — N+1 risk
|
||
var orders = await _db.Orders.Include(o => o.Lines).ToListAsync();
|
||
var dtos = _mapper.Map<List<OrderResult>>(orders);
|
||
```
|
||
|
||
- **Always `AsNoTracking()`** on a read-only query.
|
||
- **Always project with `.Select()`** in a query — never hydrate full entities just to map them, and **never
|
||
return an entity from a handler**.
|
||
- **Pagination is mandatory** on any unbounded list (`Skip`/`Take`). No unbounded `ToListAsync()`.
|
||
- Use `Include` **only** in a command handler that needs navigation properties loaded to mutate the aggregate.
|
||
- **Access the DB through `IUnitOfWork`** in Application handlers. `ApplicationDbContext` is referenced
|
||
directly only inside Infrastructure.
|
||
- **Commit once per command**, at the end: `await unitOfWork.CommitAsync(ct)`.
|
||
- **One `IEntityTypeConfiguration<T>` per entity**, in `Persistence/Configuration/<Area>Config/`.
|
||
- **Mapster maps in the handler after the query**, never in the repository. Only write a custom
|
||
`TypeAdapterConfig` when shapes genuinely diverge; register scans in `Program.cs`.
|
||
- **Never concatenate raw SQL.** EF parameterizes automatically. If you must drop to SQL, use
|
||
`FromSqlInterpolated`, never `FromSqlRaw` with user data.
|
||
|
||
**Migrations:**
|
||
|
||
```bash
|
||
dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api
|
||
dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api
|
||
```
|
||
|
||
### Migrations are split from boot
|
||
|
||
`dotnet run -- migrate` (the deploy-time one-shot, or a CI `dotnet ef database update`) applies migrations
|
||
plus the idempotent seeders, then exits — so multi-instance boots never race on DDL and the runtime login
|
||
needs no permanent DDL rights.
|
||
|
||
| Environment | What boot does |
|
||
| --- | --- |
|
||
| Development | Migrates + seeds (roles always; a bootstrap admin **only if** `Seed:AdminUsername`/`Seed:AdminPassword` are configured), plus the Development-only gateway, demo-world and demo-lifecycle seeders |
|
||
| Deployed | Only **checks** the schema is current (`EnsureSchemaUpToDateAsync` — fail fast on a pending migration) and seeds roles / the break-glass admin, idempotently |
|
||
|
||
A reachable SQL Server is required to start.
|
||
|
||
### Soft delete
|
||
|
||
Every soft-deletable entity **must** declare a global query filter in its configuration:
|
||
|
||
```csharp
|
||
builder.HasQueryFilter(o => !o.IsDeleted);
|
||
```
|
||
|
||
Without it, soft-deleted rows appear in every query that doesn't explicitly exclude them — a silent data
|
||
leak. **Never add `Where(x => !x.IsDeleted)` per query**; the filter makes it automatic and auditable.
|
||
|
||
**Deactivate, never hard-delete.** `user_roles.revoked_at` has the same treatment, so a revoked grant
|
||
disappears from every role read automatically.
|
||
|
||
---
|
||
|
||
## 2. Audit
|
||
|
||
| Field | Type | Set by |
|
||
| --- | --- | --- |
|
||
| `CreatedAt` / `ModifiedAt` | `DateTimeOffset` | `AuditFieldInterceptor` |
|
||
| `CreatedById` / `ModifiedById` | `int?` | `AuditFieldInterceptor`, via `ICurrentUser` |
|
||
|
||
The base type is `BaseEntity` / `IAuditableEntity` (`Baya.Domain/Common/`). Stamping happens in
|
||
`AuditFieldInterceptor` (a `SaveChangesInterceptor` in `Persistence/Interceptors/`) which reads time from
|
||
`IDateTimeProvider` and the user from `ICurrentUser` — **not** in the `DbContext`, and **not** in a handler.
|
||
|
||
Audit fields cannot be backfilled retroactively, so design them in from the start.
|
||
|
||
### The append-only audit trail
|
||
|
||
Mark a compliance-sensitive entity with **`IAuditable`** and the interceptor writes an old/new diff row into
|
||
`ops.AuditLogs` **in the same transaction as the change**. Annotate any encrypted or PII property with
|
||
**`[AuditRedacted]`** so the diff records a marker, never plaintext.
|
||
|
||
`audit_logs` is **immutable — there is no update or delete path in app code.** Current `IAuditable` entities:
|
||
`PlatformConfig`, `PartnerCenter`, `Review`, and the admin-decided money and trust entities `Refund`,
|
||
`NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification`.
|
||
|
||
Retention is a two-tier sweep via `IAuditLogger.PurgeExpiredAsync`: financial and verification entity types
|
||
keep `audit_retention_financial_days` (default 2555 ≈ 7 years), everyday rows `audit_retention_general_days`
|
||
(default 730 ≈ 2 years). Oldest-first, capped, id-keyed delete, idempotent.
|
||
|
||
---
|
||
|
||
## 3. Config is rows, read at compute time
|
||
|
||
Money-critical constants — commission percentage, VAT, deadlines, EVV tolerance, cancellation tiers, job
|
||
cadences — live in `ops.PlatformConfigs` and are read via **`IPlatformConfig.GetConfig<T>`** (cached, parsed by
|
||
the row's `data_type`). **Never hardcode one.**
|
||
|
||
And the corollary, which is the part that actually matters:
|
||
|
||
> **Changing a rate must never retroactively alter an already-computed amount.** A rate is **snapshotted onto
|
||
> the booking or invoice at compute time**. Do not live-re-read a rate for an already-priced row.
|
||
|
||
The DB-backed platform facades — `IPlatformConfig`, `IHolidayCalendar`, `IAnalyticsSink`, `IAuditLogger`,
|
||
`INotificationService`, `ISupportAlertService` — live in `Persistence/Services/` and are the contracts other
|
||
domains reuse. **Don't re-query those tables directly.** `IAnalyticsSink` is fire-and-forget and never fails
|
||
the caller; `INotificationService` is always tenant-scoped to `ICurrentUser`; `support_alerts` are admin-only
|
||
and must never appear on a user-facing route.
|
||
|
||
### Self-committing facades come *after* the atomic commit
|
||
|
||
`ISupportAlertService.RaiseAsync`, `INotificationDispatcher.DispatchAsync`, `IAuditLogger.WriteAsync` and
|
||
`IPlatformConfig.SetConfig` each call `SaveChanges` on the **shared scoped** `DbContext`. Calling one
|
||
mid-build flushes your partial tracked changes. **Invoke them only after `unitOfWork.CommitAsync()`.**
|
||
|
||
In a batch loop that commits per item: load and guard **every** dependency *before* mutating tracked state, or
|
||
an early `continue` leaks a dirty entity that a later iteration's commit will flush.
|
||
|
||
---
|
||
|
||
## 4. Money
|
||
|
||
**Money has its own file: [money.md](money.md).** IRR `BIGINT` integers, the append-only balanced ledger, the
|
||
three-amount reconciliation, webhook idempotency, and the refund / BNPL / payout / invoice invariants all live
|
||
there. Read it before touching anything under `Features/{Payments,Refunds,Invoices,Bnpl,Payouts}`.
|
||
|
||
The one line to carry in your head meanwhile: **money is an integer number of IRR Rials, and there is no float
|
||
path on it anywhere.**
|
||
|
||
---
|
||
|
||
## 5. Forward-only status machines
|
||
|
||
When an entity has a lifecycle `status` with a fixed set of allowed transitions, model the machine as a
|
||
**static allowed-edges table** and route **every** write through it. Never assign `status` ad hoc.
|
||
|
||
- **Statuses are `const string` codes**, persisted as the stable snake_case string — no C# enum, no value
|
||
converter needed.
|
||
- **Edges live in a static `CanTransition(from, to)`** built from a
|
||
`Dictionary<string, IReadOnlyCollection<string>>`; a terminal state maps to an empty set.
|
||
- **The entity owns the transition.** `status` has a **private setter**, and the only mutators are cohesive
|
||
domain methods (`Accept`/`Reject`/`Cancel…`) calling a private `Transition(target)` that asserts the edge is
|
||
legal — throwing on an illegal edge, because that is a programming error, since the handler pre-checks.
|
||
Side-effect fields are set in the same method.
|
||
- **The handler pre-checks and returns a clean 409**:
|
||
`if (!entity.CanTransitionTo(target)) return OperationResult.ConflictResult(...)`. Never throw for the
|
||
expected "already moved / terminal" case.
|
||
- **A replayed transition that is already complete is an idempotent no-op**, not a failure.
|
||
|
||
Machines in the codebase: `BookingRequestTransitions`, the `bookings` machine, `BnplTransitions`,
|
||
`PayoutBatchStatus`/`PayoutStatus` transitions, `VerificationStatus`, `ReviewModerationStatus`.
|
||
|
||
### When the enum is a C# enum
|
||
|
||
Persist it as its **stable snake_case code** via `HasConversion(e => e.ToCode(), s => Parse(s))` (see
|
||
`VerificationCodes`) so the DB and the wire carry `in_review`, not `InReview`. Enum→code mapping in a
|
||
projected read happens **in memory after materialization** — `.ToCode()` is not LINQ-translatable. DTOs expose
|
||
the code string.
|
||
|
||
### Guarded cross-aggregate flips
|
||
|
||
When one write must atomically change a header row's state **and** a derived boolean on a *different*
|
||
aggregate (`nurse_verifications.status` → `nurse_profiles.is_verified`): load **both** as tracked entities,
|
||
mutate them through a single pure domain helper (`VerificationAggregator.Finalize`), then `CommitAsync`
|
||
**once**. Never flip the derived flag from a controller, a partial write, or an out-of-band update, and never
|
||
leave an in-between state.
|
||
|
||
`NurseProfile.is_verified` has **no public setter** for this reason.
|
||
|
||
### Two SQL Server / SQLite portability rules
|
||
|
||
- **A deadline column that is compared or sorted uses `DateTime` (UTC `datetime2`), not `DateTimeOffset`** —
|
||
the SQLite test provider cannot translate `DateTimeOffset` comparison or `ORDER BY`. Order lists and sweeps
|
||
by `Id` for the same reason.
|
||
- **Sequential numbers come from a counter row, not a DB sequence** (`InvoiceNumberSequences`), locked and
|
||
committed with the row it numbers, so it is portable and gap-free.
|
||
|
||
---
|
||
|
||
## 6. Uniqueness patterns
|
||
|
||
| Need | Pattern |
|
||
| --- | --- |
|
||
| A nullable column must participate in uniqueness | **The filtered-index pair.** SQL Server treats NULLs as distinct, so `district_id = NULL` needs `UNIQUE(nurse_id, city_id) WHERE district_id IS NULL` **plus** `UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL`, both `AND deleted_at IS NULL` |
|
||
| "No two rows may share the same *set* of child rows" | **A deterministic set-hash.** `Baya.Application.Common.OptionSetHash.Compute(pairs)` sorts the `(long, long)` pairs and SHA-256s them into a stable, **order-independent** 64-char hex hash. Persist `NVARCHAR(64)` and back it with a filtered unique index as the race-safe backstop, plus a handler pre-check for a friendly 409. **Do not reuse `IFieldEncryptor.Hash`** — that is for PII equality lookups |
|
||
| One-per-parent, forever | An **unconditional** UNIQUE, not filtered on soft-delete — `nurse_payout_booking_links.booking_id` |
|
||
| One flagged row per parent | A filtered UNIQUE plus clear-then-set in one transaction — `UNIQUE(customer_id) WHERE is_primary=1 AND deleted_at IS NULL` |
|
||
| PII equality lookup | A deterministic hash column, UNIQUE, synced on `SaveChanges` — `users.PhoneHash`. See [identity.md](identity.md) |
|
||
|
||
A duplicate returns **409** through `OperationResult.ConflictResult` → `BaseController`'s 409 mapping.
|
||
|
||
---
|
||
|
||
## 7. Snapshots freeze history
|
||
|
||
A row that represents a past agreement must not change when its sources are edited later. Frozen at their
|
||
moment, and never mutated afterwards:
|
||
|
||
- `variant_snapshot_json` (via `IVariantSnapshotSerializer`) and the **encrypted** `address_snapshot_json`
|
||
- `platform_fee_rate` on the booking
|
||
- The resolved cancellation policy `code` + `refund_percentage`
|
||
- `iban_snapshot` on a payout (**encrypted**, `[AuditRedacted]`), frozen from the verified primary account
|
||
- Deadlines: `nurse_response_deadline_at` = `now + config`, `payment_deadline_at` = `now + config` — both
|
||
stored as **absolute UTC**, so a later config change cannot move them
|
||
|
||
A later edit to the source variant, address, or policy **never** mutates an existing booking.
|
||
|
||
---
|
||
|
||
## 8. The search projection
|
||
|
||
`search.NurseSearchIndices` is **one flat row per (bookable variant × covered service area)** — a fan-out
|
||
denormalization carrying the variant's category/price/unit, the covered `city_id`/`district_id`, the nurse's
|
||
gender and rating aggregates, and one visibility gate. It is a **read-only projection**, written only by
|
||
`ISearchIndexMaintainer`.
|
||
|
||
Three invariants:
|
||
|
||
- **`is_searchable = 1` only when** the nurse `is_verified = 1` **AND** `nurse_verifications.status !=
|
||
'suspended'` **AND** `is_accepting_bookings = 1` **AND** the variant `is_active = 1` — recomputed on **every**
|
||
relevant source write. An unverified, paused, suspended, or deactivated nurse or variant must **never**
|
||
surface.
|
||
- **`district_id = NULL` means whole city, both directions.** A city search matches every row in the city; a
|
||
district search matches that district's rows **plus** the NULL-district rows.
|
||
- **Incremental maintenance and a full rebuild must converge.** The index is fully re-derivable from source;
|
||
`RebuildAsync` is idempotent.
|
||
|
||
The maintainer keeps the index consistent **inline, inside the source write's own unit of work** — it shares
|
||
the request-scoped `DbContext`, so it only *stages* changes and the handler's single `CommitAsync` flushes
|
||
source and projection atomically. It reads the facts a trigger does not change from the DB, and takes the
|
||
facts it *does* change as **tracked arguments**, so it never reads a stale pre-commit value. It resurrects a
|
||
soft-deleted row on re-upsert, so each (variant × area) has exactly one live row.
|
||
|
||
`INurseSearch` (read) reads **only `is_searchable = 1`** rows. Callers depend on the interface, so a later
|
||
Elasticsearch backend is a config-selected drop-in.
|
||
|
||
**Coverage is named districts, not GPS radii.** Address lat/lng exists only for the EVV distance check; it is
|
||
never used for coverage matching.
|
||
|
||
---
|
||
|
||
## 9. Reference-data caching
|
||
|
||
Public and reference reads are cached through `ICacheService` behind a **generation-token key scheme** —
|
||
`GeoCache`, `CatalogCache`, `ReviewCache`. Any admin write to that area **bumps the token**, which invalidates
|
||
the whole namespace at once rather than enumerating keys.
|
||
|
||
The catalog is **EAV/data, not code**: an admin adds a category or a pricing dimension as *rows*, never a
|
||
migration. The only closed code enum in the area is `PriceUnits`. A `ServiceOptionGroups.ServiceCategoryId =
|
||
NULL` marks a **cross-category** dimension that applies to every category — and "applicable groups" means the
|
||
category's own groups **plus** every NULL group, everywhere: public browse, required-group validation, and the
|
||
duplicate guard. All required groups must be answered; one value per dimension.
|
||
|
||
**The bookable unit is the variant, not the nurse.** Keep it a clean projectable source. The engagement total
|
||
is `price` + `price_unit` + `session_count` — never `price` alone.
|
||
|
||
---
|
||
|
||
## 10. Domain invariants that live here
|
||
|
||
The rules a change in these areas must not break. Each is enforced in code *and* by a constraint.
|
||
|
||
**Bookings and EVV**
|
||
|
||
- A `bookings` row exists **only** when the nurse accepted **and** payment was captured. So a payment is
|
||
initiated against the `accepted_awaiting_payment` *request*, and `payment_transactions.booking_id` is
|
||
**nullable**, bound only when the confirm creates or loads the booking.
|
||
- Conversion goes through the shared `BookingFactory` / `BookingConversion` helper — the card confirm and the
|
||
BNPL settle both call it rather than re-implementing the split.
|
||
- A booking request carries **no money and no `bookings` row**; accept only opens the payment window.
|
||
- **EVV is per session, and a mismatch is advisory.** Check-in computes the distance to the *frozen* booking
|
||
address against `evv_location_tolerance_meters`; a mismatch raises a `location_mismatch` support alert and
|
||
notifies **without blocking**. GPS-denied still checks in, flagged null.
|
||
- **`SetDisputeWindow` is the only payout-eligibility trigger.** Completion sets
|
||
`dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)` and each completed session's
|
||
`payout_eligible_at`.
|
||
- **Cancellation refunds only un-started sessions**; the applicable policy tier is resolved by
|
||
`(actor, lead-time bucket)` and frozen onto the booking.
|
||
|
||
**Refunds, clawbacks, invoices, BNPL, payouts** → all in [money.md](money.md).
|
||
|
||
**Reviews**
|
||
|
||
- Reviews are for **completed/closed bookings only, owned by the caller, 1:1** (`UNIQUE(booking_id)` is the
|
||
backstop; the handler pre-checks for a clean 409). A cross-tenant booking is a **404**, never a leak.
|
||
- **Recompute the nurse aggregate from source on EVERY transition — not a delta.** Read
|
||
`COUNT`/`SUM(rating)` over the nurse's currently-`published` reviews *excluding* the transitioning review,
|
||
fold that review's *new* status in memory, set the guarded aggregates, and stage the reindex — all in the
|
||
**same transaction** as the status change. The exclude-and-fold avoids a stale pre-commit re-query. This is
|
||
the fix for inflated-rating-after-hide drift.
|
||
- **`pending_moderation` is never public** — list and aggregate filter to `published` at the query layer.
|
||
- `rating <= min_rating_for_support_alert` (config, default 2) raises a support alert **reliably** — after the
|
||
main commit, never silently swallowed.
|
||
|
||
**Partner centres**
|
||
|
||
- Merchant-of-record resolution follows `partner_centers` through the single resolver, **not a hardcoded
|
||
platform**: booking → nurse → `partner_center_id`, and the issuer/settlement target is the centre **only**
|
||
when it `is_merchant_of_record`, else `platform`.
|
||
- `partner_centers` (the licensing *sponsor*) **≠** `organizations` (the future *employer*, deferred).
|
||
`settlement_iban` is encrypted, `[AuditRedacted]`, and **masked to the last 4 in every read**. The centre's
|
||
`commission_rate` is separate from `platform_fee_rate`.
|
||
|
||
**Deferred by design — do not create these tables:** `bnpl_settlement_entries`, `organizations`,
|
||
`organization_nurses`, `fraud_flags`, `recurring_booking_schedules`.
|
||
|
||
---
|
||
|
||
## 11. The recurring-job scheduler
|
||
|
||
A single in-process scheduler, `Persistence/Services/Scheduling/RecurringJobSchedulerHostedService`, drives
|
||
every registered `IRecurringJob` on its own cadence — using **no new infrastructure**, so SQL Server stays the
|
||
only external dependency.
|
||
|
||
| Job | Cadence |
|
||
| --- | --- |
|
||
| `booking_request_expiry` | 1 min (const) |
|
||
| `notification_retention` | 24 h (const) — the predicate is exactly `is_read = 1 AND age > 90d`; **unread is never auto-deleted** |
|
||
| `verification_expiry_scan` | `verification_expiry_scan_cadence_hours` |
|
||
| `no_show_sweep` | `no_show_scan_cadence_hours` |
|
||
| `weekly_payout_generation` | `nurse_payout_interval_days` |
|
||
| `MoadianReconciliationJob` | 6 h |
|
||
| `audit_log_retention` | `audit_retention_scan_cadence_hours` |
|
||
|
||
- **Adding a cron = implement `IRecurringJob` + one `AddSingleton<IRecurringJob, …>()`** in
|
||
`AddPersistenceServices`. The scheduler owns the per-tick DI scope, error isolation (a throwing tick never
|
||
kills the loop), and the lock. A job says only *how often* and *what one idempotent run does*.
|
||
- **Jobs must be idempotent.** A retry — or a second instance, once the lock is Redis-backed — must never
|
||
double-pay or double-post. The DB uniques and state machines are the backstop. Each tick runs under
|
||
`IDistributedLock("scheduler:{name}")`, which is in-process today and is **the >1-instance scale-out gate**:
|
||
swap the seam to Redis to serialize ticks across nodes. A single-instance MVP needs neither Redis nor
|
||
Hangfire/Quartz.
|
||
- **Money movement stays human-approved.** The payout job schedules *generation* only — a `draft` batch,
|
||
recorded system-initiated (`InitiatedByAdminId` nullable = "no human initiator"). The irreversible `process`
|
||
step remains an explicit admin action, and `AdminPayoutsController` **neutralizes any request-supplied
|
||
`SystemInitiated` value**.
|
||
- **Admin manual triggers are overrides**, running the same idempotent commands.
|
||
- **The scheduler is dormant under the `Testing` environment**, so integration tests stay deterministic. Each
|
||
job and command is unit-tested directly.
|
||
- A time-sensitive command **self-guards** against a passed deadline via `IDateTimeProvider` rather than
|
||
trusting that a sweep has run; a sweep's re-queried `WHERE status = …` predicate **is** the concurrency guard
|
||
— a row a racing action moved is simply not reloaded.
|
||
|
||
---
|
||
|
||
## 12. Development seeders
|
||
|
||
Both are **Development-only** and idempotent.
|
||
|
||
- **`DemoWorldSeeder`** — a coherent demo marketplace on top of the reference `HasData` seeds: 3 nurses (2
|
||
verified with variants, Tehran coverage, `approved` verification, credentials and a `matched_national_id`
|
||
bank account; 1 unverified), 2 customers with patients and addresses, **2 phone-OTP admins** (a `super_admin`
|
||
plus a scoped `finance` operator, so the console is reachable through the normal login and capability gating
|
||
is demonstrable), and one cross-category required option group.
|
||
- **`DemoLifecycleSeeder`** (+ `.Money.cs` / `.Social.cs` partials) — a full lifecycle world layered on those
|
||
personas so every flow is manually testable: booking requests in every status, 8 bookings across every
|
||
reachable state, the balanced payment ledger behind each, refunds on all three forks, a paid and a draft
|
||
payout batch, moderated reviews with recomputed aggregates, tickets (including an `is_internal` note),
|
||
notifications, patient care records, a merchant-of-record partner centre, and a mid-pipeline verification
|
||
case.
|
||
|
||
Three rules they establish:
|
||
|
||
1. **Write through the real entities and commands** — the guarded transition methods, `BookingFactory`,
|
||
`GeneratePayoutBatch`/`ExecutePayoutBatch`, `LedgerPosting`, `OpenTicketCommand`. Business timestamps are
|
||
backdated explicitly. (Application grants `InternalsVisibleTo` to Persistence for this.)
|
||
2. **Drive the search projection through `ISearchIndexMaintainer.RebuildAsync`** — never hand-insert index
|
||
rows.
|
||
3. **Never guard idempotency on a Persian string.** The `ApplicationDbContext` save hook normalizes Persian
|
||
digits and ZWNJ in every stored string, so a Persian literal **never round-trips equal**. Guard on a phone
|
||
number, a code, or another natural key.
|