cleanup phases 6
This commit is contained in:
@@ -0,0 +1,437 @@
|
||||
# Backend status log (append-only)
|
||||
|
||||
One block per completed backend phase. Newest at the top. Backend lane writes here; frontend reads.
|
||||
|
||||
<!-- TEMPLATE — copy for each phase
|
||||
## backend-phase-N — <Title> — <YYYY-MM-DD>
|
||||
- **Shipped:** <entities/endpoints/seams in one or two lines>
|
||||
- **Contracts:** dev/contracts/domains/<domain>.md + openapi snapshot refreshed (yes/no)
|
||||
- **Mocked:** <which seams> (see reports/mocks-registry.md)
|
||||
- **Gate:** build clean / tests green
|
||||
- **Handoff:** backend/handoff/after-backend-phase-N.md
|
||||
- **Notes for frontend:** <anything load-bearing>
|
||||
-->
|
||||
|
||||
## refinement-phase-9 — Observability, ops hardening, docs honesty & scale-later — 2026-07-13
|
||||
- **Shipped:** **one OpenTelemetry stack** (metrics scraped at `/metrics` + **tracing** ASP.NET Core/EF, opt-in
|
||||
OTLP via `OpenTelemetry:Otlp:Endpoint`; prometheus-net removed) — `ApiResult.requestId` = W3C trace id.
|
||||
**Health split** `/healthz/live` vs `/healthz/ready` (app DB + logDb[deployed] + object-storage write probe);
|
||||
`/HealthCheck` aggregate kept. **Prod logs Information+** with **no PII** (OTP code no longer logged) + dead
|
||||
Elasticsearch sink/package removed. **`AuditLogRetentionJob`** (`IRecurringJob`, two-tier legal retention).
|
||||
**`TicketMessage.Body` encrypted at rest** (`IFieldEncryptor`; column → nvarchar(max)). **gRPC reflection
|
||||
Development-only.** Docs reconciled (mocks-registry stale rows pruned; deferrals 9.7–9.11 recorded with pull-
|
||||
triggers). Migration `RefinementPhase9TicketBodyEncryptionAndAuditRetention` (Body widen + 3 config seed rows).
|
||||
- **Contracts:** none changed — no wire/shape change (observability + docs + at-rest encryption only).
|
||||
- **Mocked:** none new. Deferred (recorded, not gaps): Elasticsearch `INurseSearch`, SMS/push
|
||||
`INotificationDispatcher`, analytics pipeline, holiday feed, 8 product tables — each with a written pull-trigger.
|
||||
- **Gate:** build clean (0 new warnings) / **407 tests pass** (402 prior + 5 new: audit-retention, ticket-body
|
||||
encryption, liveness).
|
||||
- **Handoff:** backend/handoff/after-refinement-phase-9.md
|
||||
- **Notes for frontend:** no client-facing change. Ticket message bodies are now encrypted at rest server-side
|
||||
(the thread read still returns plaintext — unchanged wire). A request's `requestId` is a real trace id useful
|
||||
for support correlation.
|
||||
|
||||
## refinement-phase-7 — Unattended ops: scheduler, locking & multi-instance readiness — 2026-07-13
|
||||
- **Shipped:** one in-process **`RecurringJobSchedulerHostedService`** + the **`IRecurringJob`** seam
|
||||
(`Persistence/Services/Scheduling/`) replacing the two `PeriodicTimer` hosted services and scheduling the
|
||||
previously admin-manual crons — `booking_request_expiry`, `notification_retention`, `verification_expiry_scan`,
|
||||
`no_show_sweep`, `weekly_payout_generation` — each reading its seeded cadence key; admin triggers stay overrides.
|
||||
Payout **generation** is scheduled (system-initiated `draft`); **processing** stays admin-only
|
||||
(`NursePayoutBatch.InitiatedByAdminId` now nullable = system; `SystemInitiated` flag is scheduler-only,
|
||||
controller-neutralized). **Migrations split from boot:** `dotnet run -- migrate` one-shot + deployed-boot schema
|
||||
*check* (`EnsureSchemaUpToDateAsync`); Dev keeps migrate-on-boot. **No Redis/Hangfire added** — documented as the
|
||||
>1-instance scale-out gate; the scheduler's per-tick `IDistributedLock` is that swap point.
|
||||
- **Contracts:** `PayoutBatchDto.initiatedByAdminId` nullable — `dev/contracts/domains/payouts.md` + openapi
|
||||
snapshot refreshed (yes).
|
||||
- **Mocked:** none new. Redis = scale gate; Moadian/refund-settlement poll = Phase 8 jobs (see mocks-registry).
|
||||
- **Gate:** build clean (0 new warnings) / **402 tests pass** (396 prior + 6 new scheduling tests).
|
||||
- **Handoff:** backend/handoff/after-refinement-phase-7.md
|
||||
- **Notes for frontend:** admin payout batch `initiatedByAdminId` can be `null` (system/scheduled batch) — render
|
||||
a "system"/"scheduled" label rather than assuming an admin id.
|
||||
|
||||
## refinement-phase-1 — Database: local-dev story, demo seed & migration hygiene — 2026-07-13
|
||||
- **Shipped (no migration, no endpoint, no contract change):** Development-gated **demo-world seeder** —
|
||||
`Persistence/Services/Seeding/DemoWorldSeeder.cs` + `DemoWorldDefinitions.cs`, scoped-registered in
|
||||
`AddPersistenceServices`, invoked from `Program.cs` via new `SeedDemoWorldAsync()` **only under
|
||||
`IsDevelopment()`**. Idempotently (guarded on phone) creates **3 nurses** (2 verified: `MarkVerified()` +
|
||||
`nurse_verifications` `approved` + credentials + `matched_national_id` primary bank + 2–3 IRR variants +
|
||||
Tehran areas incl. whole-city `district_id=NULL`; 1 unverified), **2 customers** (patients + coord
|
||||
addresses), and **1 cross-category required demo option group** (شیفت / Shift Type). Search rows are driven
|
||||
through the real `ISearchIndexMaintainer.RebuildAsync` — never hand-inserted, so `is_searchable` stays
|
||||
truthful (verified surface, unverified doesn't). Reference `HasData` seeds + the 17 migrations untouched.
|
||||
- **Local DB / migration hygiene:** verified Phase 0's compose + placeholder connstrings + boot-migrate;
|
||||
documented the explicit `dotnet ef database update` path + demo-reset flow in the RUNBOOK; confirmed no
|
||||
pending model changes. Forward-dep FKs (Phase 6) and the boot-migrate multi-instance split (Phase 7) are
|
||||
out of scope by design.
|
||||
- **Contracts:** none produced; swagger snapshot **not** regenerated (no route/shape change).
|
||||
- **Mocked:** none introduced (the seeder uses the real handlers/maintainer/converters).
|
||||
- **Gate:** build clean (0 new warnings) / tests green (**372**: +3 `DemoWorldSeederTests` proving over the
|
||||
real HTTP pipeline — verified nurse in search, unverified not, trust badge correct, idempotent). Added
|
||||
`InternalsVisibleTo("Baya.Test.Api")` on Persistence for the seeder test.
|
||||
- **Handoff:** backend/handoff/after-refinement-phase-1.md
|
||||
- **Notes for frontend:** demo accounts to log in as (phone-OTP) — nurses `09120000001` (verified) /
|
||||
`09120000002` (verified) / `09120000003` (unverified); customers `09120000010` / `09120000011`. Tehran
|
||||
`city_id=101`. `GET /search/nurses?service_category_id=1&city_id=101` now returns real verified nurses.
|
||||
**No `USE_*_MOCK` flag flipped — de-mocking is Phase 4.**
|
||||
|
||||
## refinement-phase-0 — Local end-to-end bring-up & the integration seam — 2026-07-12
|
||||
- **Shipped (integration/plumbing — no business logic):** **CORS** (`Baya.WebFramework/ServiceConfiguration/
|
||||
CorsServiceExtension.cs` → `AddCorsPolicies`, policy `BalinyaarWebClient` from `Cors:AllowedOrigins`, default
|
||||
`http://localhost:3000`; `app.UseCors` after `UseRouting` / before `UseRateLimiter`; no `AllowCredentials`).
|
||||
**Local DB story** — `server/docker-compose.yml` rewritten to SQL Server 2022 Developer on `localhost:1433`
|
||||
(dev-only SA password); committed `appsettings*.json` connection strings replaced with **non-working
|
||||
placeholders** (real value via `dotnet user-secrets`); added `<UserSecretsId>` to the API csproj.
|
||||
**Development-only OTP helper** `GET /api/v1/dev/last_otp/{phone}` (`DevController` + `DevOtpStore` +
|
||||
`DevCapturingSmsSender`, wired only in Development via `AddDevelopmentOtpCapture`; **404 outside
|
||||
Development**). Wrote `dev/post-phase/refinement/RUNBOOK.md`.
|
||||
- **Contracts:** none produced; swagger snapshot **not** regenerated (only new path is the dev-only helper).
|
||||
- **Mocked:** no new seam; `ISmsSender` (`LoggingSmsSender`) row updated in mocks-registry (interim OTP channel
|
||||
+ the Development-only capture affordance).
|
||||
- **Gate:** build clean (0 new warnings) / tests green (**369**: 4 identity + 248 foundation + 117 API, incl.
|
||||
3 new CORS/dev-otp integration tests + 8 dev-otp store/decorator unit tests).
|
||||
- **Handoff:** backend/handoff/after-refinement-phase-0.md
|
||||
- **Notes for frontend:** the client now really reaches the API cross-origin (CORS unblocked). **No mock flag
|
||||
was flipped — `auth` is still the only real domain** (that's Phase 4). `client/.env.development` already
|
||||
points at `https://localhost:5002`; the API speaks HTTP/2 (browsers negotiate h2-over-TLS automatically).
|
||||
Read the OTP from the server console or `GET /api/v1/dev/last_otp/{phone}` (Development only).
|
||||
|
||||
## backend-phase-15 — Messaging (tickets), partner centers & admin backoffice — 2026-07-10
|
||||
- **Shipped (FINAL backend phase):** new `messaging` schema — `Tickets` (`UNIQUE(reference_code)`, status/
|
||||
category, nullable `booking_id`/`refund_id`), `TicketParticipants` (`UNIQUE(ticket_id, user_id)`, soft-remove
|
||||
via `removed_at`), `TicketMessages` (`is_internal` hard boundary) — and new `partner` schema — `PartnerCenters`
|
||||
(`IAuditable`, encrypted+masked `settlement_iban`, `commission_rate` separate from `platform_fee_rate`). Added
|
||||
the `nurse_profiles.partner_center_id` FK in place. One migration (`MessagingAndPartnerCenters`). CQRS:
|
||||
OpenTicket / AutoCreateCoordinationTicket / PostMessage / Add+RemoveParticipant / Close+ReopenTicket /
|
||||
LogEmergencyTicket / GetTicketThread (user vs admin view) / ListMyTickets / ListTicketsForAdmin; CreatePartnerCenter
|
||||
/ UpdatePartnerCenter / VerifyPartnerCenter / SponsorNurse / GetCenterForBooking / ListPartnerCenters /
|
||||
GetPartnerCenterById / GetCenterDashboard. 5 new controllers (Tickets/AdminTickets/AdminPartnerCenters/Centers/
|
||||
InternalCenters). Wired: b11 `IssueInvoice` now resolves issuer/settlement via `GetCenterForBooking`; b11
|
||||
`CreateRefund` auto-opens a `refund` ticket (so `refunds.ticket_id` is always non-null); the card confirm + BNPL
|
||||
settle handlers auto-create the coordination ticket. Support-alert worklist + audit viewer reused from b1 (not
|
||||
rebuilt). The 4 DEFERRED tables were **not** created.
|
||||
- **Contracts:** `dev/contracts/domains/messaging-notifications-admin.md` + openapi snapshot refreshed (yes).
|
||||
- **Mocked:** `ILicenseVerificationService` (eNamad / MoH permit — manual-approve at MVP) → 🟡 (see reports/mocks-registry.md).
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (358 total: 4 identity + 240 foundation + 114 API,
|
||||
incl. 4 new merchant-of-record resolver tests + 8 new ticket/partner-center API tests).
|
||||
- **Handoff:** backend/handoff/after-backend-phase-15.md
|
||||
- **Notes for frontend:** `is_internal` is stripped from the user thread view server-side (never trust the UI);
|
||||
no direct nurse↔customer channel / no phone numbers; ticket↔booking/refund links are optional (nullable);
|
||||
duplicate participant add = 409; `settlement_iban` is only ever returned masked (last 4); merchant-of-record
|
||||
(invoice issuer + settlement) follows `partner_centers`, resolved by `GET /internal/bookings/{id}/center`.
|
||||
|
||||
## backend-phase-14 — Reviews, ratings & patient care records — 2026-07-09
|
||||
- **Shipped:** new `reviews` schema, 4 tables — `Reviews` (`UNIQUE(booking_id)`, `CHECK(rating 1–5)`, guarded
|
||||
`moderation_status`, `IAuditable`), `ReviewTagsMaster` (seeded 5-tag vocab, `UNIQUE(code)`), `ReviewTagLinks`
|
||||
(`UNIQUE(review_id, review_tag_master_id)`), `PatientCareRecords` (encrypted, patient-scoped, `(patient_id,
|
||||
recorded_at)` index). One migration (`ReviewsAndPatientCareRecords`). CQRS: SubmitReview / ModerateReview /
|
||||
AttachReviewTags / ListReviewsForNurse / GetReviewModerationQueue / GetTagAggregates / WritePatientCareRecord /
|
||||
GetPatientHistory + the `RecomputeNurseRating` from-source helper. 8 endpoints across 5 controllers. Added
|
||||
`NurseProfile.SetReviewAggregates` (guarded aggregate write) + `ICustomerProfileRepository.GetUserIdByProfileIdAsync`.
|
||||
- **Contracts:** `dev/contracts/domains/reviews-records.md` + openapi snapshot refreshed (yes).
|
||||
- **Mocked:** `IReviewModerationService` (AI pre-screen — keyword/pass-through) → 🟡 (see reports/mocks-registry.md).
|
||||
- **Gate:** build clean (0 new warnings) / tests green (346 total: 4 identity + 236 foundation + 106 API,
|
||||
incl. 13 new review/care-record handler tests + 4 new API tests).
|
||||
- **Handoff:** backend/handoff/after-backend-phase-14.md
|
||||
- **Notes for frontend:** publish gate — only `published` reviews are ever public/counted; the nurse aggregate
|
||||
recomputes from source on every transition. Care records are patient-scoped + encrypted + strict access
|
||||
(owner/assigned-nurse/admin). Money-free domain. Support alerts stay internal (only `lowRatingAlertId` on the
|
||||
admin queue).
|
||||
|
||||
## backend-phase-13 — Weekly nurse payouts (mocked PAYA/SATNA) — 2026-07-09
|
||||
- **Shipped:** new `payouts` schema, 3 tables — `NursePayoutBatches` (holiday-shifted period/processing dates),
|
||||
`NursePayouts` (net-split CHECK, encrypted `iban_snapshot`, forward-only `PayoutStatus`), `NursePayoutBookingLinks`
|
||||
(**unconditional `UNIQUE(booking_id)`** = one-payout-per-booking-ever). One migration (`NursePayoutEngine`).
|
||||
`Features/Payouts/*` (compute-eligible / generate-batch / process / retry / mark-failed + admin batch-detail/list +
|
||||
nurse history; shared `PayoutSettlement` step). `IPayoutRepository`. Controllers `AdminPayouts` (admin, rate-limited)
|
||||
+ `NursePayouts` (nurse, tenancy-scoped). New seam **`IBankTransferProvider`** (mock PAYA/SATNA). Swapped
|
||||
`INursePayoutStatus` to the authoritative link-based `NursePayoutLinkStatusService` (deleted the interim one). Added
|
||||
2 config keys (`payout_satna_threshold_irr`, `require_bnpl_settlement_for_payout`).
|
||||
- **Contracts:** `dev/contracts/domains/payouts.md` + openapi snapshot refreshed (yes — 7 payout paths).
|
||||
- **Mocked:** `IBankTransferProvider` → 🟡; `INursePayoutStatus` → 🟢 (real link lookup). Reuse `IHolidayCalendar`,
|
||||
`IFieldEncryptor`, `IDistributedLock`, `ICacheService`. See reports/mocks-registry.md.
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (329: 223 foundation + 102 api + 4 identity; +9 payout
|
||||
unit + 6 payout api). Migration builds; swagger serves all 7 payout paths.
|
||||
- **Handoff:** backend/handoff/after-backend-phase-13.md
|
||||
- **Notes for frontend:** f12-b13 = nurse `nurse_payouts/history` (own payouts, masked IBAN, digit-string money) +
|
||||
admin payout console (`admin_payouts/eligible|batches|batches/{id}|batches/{id}/process|{payoutId}/retry|mark_failed`).
|
||||
Query params camelCase (`page`/`pageSize`/`status`/`periodStart`/`periodEnd`). Money is a digit string.
|
||||
|
||||
## backend-phase-12 — BNPL: provider-financed installments (mocked) — 2026-07-09
|
||||
- **Shipped:** `payments.BnplTransactions` (1:1 with `payment_transaction`, `UNIQUE(payment_transaction_id)`,
|
||||
settle-split CHECK, forward-only `BnplStatus` machine); `Features/Bnpl/*` (eligibility/initiate/verify/settle/
|
||||
revert/callback/status); `CheckoutBnplController` (customer) + `WebhooksBnplController` (anon, signed) +
|
||||
`AdminBnplController` (admin), all rate-limited; `LedgerPosting.BnplSettle` (net-of-fee group w/
|
||||
`bnpl_fee_expense`); extracted shared `Features/Bookings/BookingConversion` (used by b10 card + b12 settle).
|
||||
- **Contracts:** dev/contracts/domains/bnpl.md + openapi snapshot refreshed (yes).
|
||||
- **Mocked:** `IBnplProvider` (per `provider_code` via `IBnplProviderResolver`) + `ICurrencyNormalizer` → 🟡
|
||||
(configurable mock commission %, non-instant `settled_at`). See reports/mocks-registry.md.
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (314 pass: 4 identity + 214 foundation + 96 api;
|
||||
+14 new). Migration `BnplTransactions` created (not applied to a live DB this session — SQLite
|
||||
`EnsureCreated` builds it for tests).
|
||||
- **Handoff:** backend/handoff/after-backend-phase-12.md
|
||||
- **Notes for frontend:** f11-b12 = the "pay with installments" checkout (`checkout_bnpl/eligibility` →
|
||||
`initiate` → provider redirect; declined → fall back to card), the customer order view (`checkout_bnpl/{id}`),
|
||||
and the admin BNPL revert path with the ~7–10-day ETA. Money is IRR digit-strings; `settledAt` is nullable
|
||||
(not instant). A BNPL revert opens a `refund_channel='bnpl_revert'` refund (read via `refunds/{id}/status`).
|
||||
|
||||
## backend-phase-11 — Refunds, invoices & nurse clawbacks — 2026-07-09
|
||||
- **Shipped:** the reversal leg via one migration in the **`payments`** schema — **3 tables** `Refunds`
|
||||
(fee-leg decomposition + `refund_channel` + `amount = fee_leg + payout_leg` CHECK + **nullable `ticket_id`, no FK**
|
||||
until b15) / `NurseClawbacks` (nullable `original_payout_id`/`recovered_in_payout_id` until b13) / `Invoices`
|
||||
(**UNIQUE `invoice_number`** sequential + UNIQUE `booking_id`), plus the `InvoiceNumberSequences` counter row.
|
||||
Features: `CreateRefund` (whole money-path under `lock(booking:{id}:refund)`; decompose → Σ≤captured → channel →
|
||||
balanced ledger reversal via b10's helper; pre-payout `nurse_payable` vs post-payout `nurse_clawback_receivable`
|
||||
fork + `pending` clawback + support alert), `WriteOffClawback`, `ListRefunds`, `GetRefundStatus`, `IssueInvoice`
|
||||
(VAT on commission only from config, sequential number, mocked مودیان), `GetInvoice`. Controllers: admin
|
||||
`AdminRefunds`/`AdminClawbacks`/`AdminInvoices` (rate-limited) + customer `Refunds`/`Invoices`.
|
||||
- **Contracts:** dev/contracts/domains/refunds-invoices.md + openapi snapshot refreshed — **yes**.
|
||||
- **Mocked:** `IMoadianClient` (new), `IBnplProvider` (thin pre-b12 stub), `INursePayoutStatus` (interim derivation;
|
||||
b13 owns real); reused `IPaymentProvider`/`IWebhookVerifier`/`IDistributedLock`/`INotificationDispatcher`. See
|
||||
reports/mocks-registry.md.
|
||||
- **Gate:** build clean (0 new warnings) / tests green (300 pass: 205 Foundation + 91 Api + 4 Identity). Migration
|
||||
applied to the dev DB; swagger snapshot republished.
|
||||
- **Handoff:** backend/handoff/after-backend-phase-11.md
|
||||
- **Notes for frontend:** refunds are **admin-only** (no self-service); the only customer surfaces are
|
||||
`GET refunds/{id}/status` (with the BNPL `expected_customer_refund_eta` date + masked reference) and
|
||||
`GET invoices/{booking_id}`. Money is IRR digit-strings. Card refund = immediate `succeeded`; BNPL = `processing`
|
||||
+ ETA.
|
||||
|
||||
## backend-phase-10 — Payments core: ledger, transactions, webhooks & card capture — 2026-07-06
|
||||
- **Shipped:** the money core via one migration in a new **`payments`** schema — **4 tables** `PaymentGateways`
|
||||
(encrypted `config_json`) / `PaymentTransactions` (the **two filtered uniques** — `gateway_reference_code` WHERE
|
||||
NOT NULL, `booking_id` WHERE status='succeeded') / `PaymentWebhookEvents` (**UNIQUE(provider_code,
|
||||
external_event_id)**) / **append-only** `LedgerEntries`. Features `InitiatePayment`, `HandlePaymentWebhook`,
|
||||
`ConfirmPaymentAndPostLedger` (internal), `GetNursePayableBalance`; controllers
|
||||
`POST bookings/{id}/payments`, public `POST webhooks/payments/{provider}`, `GET nurses/{id}/payable_balance`.
|
||||
Extracted **`BookingFactory`** so b10's real capture reuses b9's conversion (b9 unchanged).
|
||||
- **Contracts:** `dev/contracts/domains/payments.md` written; **openapi snapshot refreshed**
|
||||
(`dev/contracts/openapi/swagger.v1.json` — the three b10 paths + DTOs present).
|
||||
- **Mocked:** `IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock` → 🟡
|
||||
(see reports/mocks-registry.md). `IPaymentCaptureSimulator` (b9) retained for b9's Convert path/tests.
|
||||
- **Gate:** build clean (0 new warnings) / tests green (Foundation 198, Identity 4, Api 83; +12 Foundation +4 Api new).
|
||||
- **Handoff:** backend/handoff/after-backend-phase-10.md
|
||||
- **Notes for frontend:** money is an **IRR digit-string**; a booking exists only **on capture** (pay against the
|
||||
accepted **request** id; the booking appears `confirmed` after the webhook); **never expose internal
|
||||
`account_type`s** (checkout = gross + commission/VAT); payment idempotent end-to-end (`Idempotency-Key` header).
|
||||
|
||||
## backend-phase-9 — Bookings, sessions, care instructions & EVV — 2026-07-06
|
||||
- **Shipped:** the post-payment engine via one additive migration in the **`booking`** schema — **5 tables**
|
||||
`Bookings` / `BookingSessions` / `BookingCareInstructions` / `VisitVerifications` / `CancellationPolicies`
|
||||
(seeded 4 tiers) + 2 config rows (`no_show_threshold_minutes`, `no_show_scan_cadence_hours`). `Convert`
|
||||
(mock-capture → booking 1:1, three-amount split, snapshots, N sessions), care-instructions submit + **gated**
|
||||
read, EVV `check_in`/`check_out` (advisory address match, dispute window on completion), `transition`,
|
||||
`cancel` booking/session (policy snapshot + un-started refund), `detect_no_shows`, cancellation-policy CRUD.
|
||||
Controllers: `Bookings` / `BookingSessions` / `AdminEvv` / `AdminCancellationPolicies`.
|
||||
- **Contracts:** dev/contracts/domains/bookings-evv.md + openapi snapshot refreshed (yes).
|
||||
- **Mocked:** **`IPaymentCaptureSimulator`** introduced (🟡) — the temporary conversion trigger; reuses
|
||||
`IGeocoder`/`IFieldEncryptor`/`INotificationDispatcher`/`ISupportAlertService`. Real capture trigger = b10.
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (269: 186 foundation + 4 identity + 79 API).
|
||||
- **Handoff:** backend/handoff/after-backend-phase-9.md
|
||||
- **Notes for frontend (f8-b9):** money is an **IRR digit-string** (three amounts reconcile). The nurse view
|
||||
omits `addressSnapshotJson`; care-instruction clinical fields are **assigned-nurse/admin only, post-confirmation**
|
||||
(`GET bookings/care_instructions/{id}`); raw EVV GPS is gated to owning nurse + admin. Routes are action-style
|
||||
(`bookings/get/{id}`, `booking_sessions/check_in/{id}`, `booking_sessions/today`, `admin_evv/list`). Payout
|
||||
eligibility comes from `disputeWindowEndsAt`/`payoutEligibleAt`, never `completed` alone.
|
||||
|
||||
## backend-phase-8 — Booking requests (pre-payment intent) — 2026-07-06
|
||||
- **Shipped:** the money-free request lifecycle via one additive migration — new **`booking`** schema,
|
||||
**1 table** `BookingRequests`. The customer-side (`CreateBookingRequest` — tenancy invariant
|
||||
patient+address∈customer & variant∈nurse, bookability + **same-gender** match, response deadline frozen from
|
||||
`nurse_response_deadline_hours`; `CancelBookingRequest`), the nurse-side (`AcceptBookingRequest` — freezes
|
||||
`payment_deadline_at` from `booking_payment_deadline_minutes`=30, self-guards the response deadline;
|
||||
`RejectBookingRequest` — reason required), the reads (`ListBookingRequests` role-scoped inbox +
|
||||
`GetBookingRequest` party/admin, **nurse view masks the full address, exposes only `customer_notes`**), and
|
||||
the **forward-only status guard** (`BookingRequestTransitions`) + the idempotent, paginated
|
||||
`ExpireBookingRequests` sweep behind a recurring `BackgroundService` (`BookingRequestExpiryHostedService`,
|
||||
reuses the b1 `IJobScheduler` seam) + an admin manual trigger. **2 controllers:** `BookingRequestsController`
|
||||
(customer/nurse) + `AdminBookingRequestsController` (`POST admin_booking_requests/expire`). Inbox/expiry
|
||||
covering indexes `(nurse_id,status)`/`(customer_id,status)`/`(status,response_deadline)`/`(status,payment_deadline)`.
|
||||
**No money column, no `bookings` row, no snapshot, no price** anywhere.
|
||||
- **Contracts:** dev/contracts/domains/booking-requests.md + openapi snapshot refreshed (yes — the 7 booking
|
||||
paths + `BookingRequestDto`/`BookingRequestListItemDto`/`ExpireBookingRequestsResult`).
|
||||
- **Mocked:** **nothing new** — reuses `IPlatformConfig`/`INotificationDispatcher`/`IJobScheduler`/
|
||||
`IDateTimeProvider`/`ICurrentUser`. **No new `mocks-registry.md` row** (the `IJobScheduler` row was updated to
|
||||
note the second hosted job).
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (215 pass: +20 booking = 14 handler-unit + the
|
||||
transition machine + 4 DB-backed SQLite + 5 API integration). Migration applied to the dev DB; API boots with
|
||||
all booking paths in Swagger; the expiry sweep runs on startup (verified against SQL Server).
|
||||
- **Handoff:** backend/handoff/after-backend-phase-8.md
|
||||
- **Notes for frontend:** f7-b8 = the request form (C4 → `booking_requests/create`), the status tracker (C5 →
|
||||
`booking_requests/get/{id}` + customer inbox with the response + 30-min payment countdowns + `cancel`), and
|
||||
the nurse inbox (`booking_requests/list?role=nurse` + `accept`/`reject`). Gender required (`male`/`female`/
|
||||
`any`); deadlines are server-frozen UTC timestamps; the nurse sees only `customer_notes` + a masked address.
|
||||
|
||||
## backend-phase-7 — Search & matching (nurse search index) — 2026-07-05
|
||||
- **Shipped:** the discovery layer via one additive migration — new **`search`** schema, **1 table**
|
||||
`NurseSearchIndices` (the denormalized `nurse_search_index`): **one flat row per (bookable variant ×
|
||||
covered area)** with copied category/price/unit, `city_id`/`district_id` (NULL = whole city), `nurse_gender`
|
||||
+ rating aggregates, and the single **`is_searchable`** gate. It is a **read-only projection**, maintained
|
||||
**inline in each source write's own transaction** by **`ISearchIndexMaintainer`** (`SearchIndexMaintainer`)
|
||||
wired into the b3/b4/b5/b6 handlers (`ReindexVariant`/`ReindexNurse`/`FanOutServiceArea`/
|
||||
`RemoveServiceAreaRows` + `Rebuild`). Read side is the **`INurseSearch`** seam — real MVP impl
|
||||
`SqlNurseSearch` (reads only `is_searchable=1`; category/city/district(NULL-aware)/gender/price filters +
|
||||
rating sort + pagination). **2 controllers:** public `SearchController` (`GET search/nurses`) + admin
|
||||
`AdminSearchController` (`POST admin_search/rebuild_index`, idempotent convergence rebuild). Covering search
|
||||
index + filtered-unique `(variant_id, city_id, district_id)` pair (NULL participating) + `nurse_id` index.
|
||||
- **Contracts:** dev/contracts/domains/search.md + openapi snapshot refreshed (yes — `search/nurses` +
|
||||
`admin_search/rebuild_index` + DTOs).
|
||||
- **Mocked:** `INurseSearch` → 🟢 **SQL is real** (Elastic backend 🟡 deferred, config `Search:Backend`);
|
||||
`ISearchIndexMaintainer` inline path real, outbox/feeder 🟡 deferred (see reports/mocks-registry.md).
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (167 pass: +9 DB-backed search + 4 API integration;
|
||||
affected b3/b4/b5/b6 handler tests updated for the new dependency).
|
||||
- **Handoff:** backend/handoff/after-backend-phase-7.md
|
||||
- **Notes for frontend:** f6-b7 = `GET api/v1/search/nurses` (public; snake_case params
|
||||
`service_category_id`/`city_id` required, optional `district_id`/`nurse_gender`/`min_price`/`max_price`/
|
||||
`price_unit`; `page`/`page_size` default 1/50 max 100). Returns **only searchable** nurses;
|
||||
`districtId=null` result = whole city; `price` is an IRR **digit string**; sort is rating-desc only.
|
||||
`required_caregiver_gender` capture into booking is **b8**.
|
||||
|
||||
## backend-phase-6 — Nurse verification & credentials (mocked vendors) — 2026-07-02
|
||||
- **Shipped:** the trust engine via one additive migration — new **`verif`** schema, **5 tables**:
|
||||
`NurseVerifications` (`status` = the **single source of verification truth**), `VerificationStepTypes`
|
||||
(seeded catalog — six stable codes `identity_kyc`/`shahkar_match`/`moh_competency_license`/`ino_membership`/
|
||||
`criminal_record`/`bank_account_verification`), `VerificationSteps` (one per required step-type; snapshots
|
||||
`is_automated`), `VerificationDocuments` (**metadata only** — bytes never in the DB), `NurseCredentials`
|
||||
(`credential_number` **encrypted, never serialized**). **15 endpoints across 4 controllers** —
|
||||
`nurse_verification` (submit/get/upload_url/documents + automated `identity_kyc`/`shahkar_match`/
|
||||
`bank_account_verification` `/run`), `admin_verification_step_types` (list/upsert/deactivate, dup code →
|
||||
409), `admin_verifications` (queue/detail/decide/suspend/scan_expiring), public `nurses/{id}/trust_badge`.
|
||||
`nurse_profiles.is_verified` is the **only derived boolean**, flipped **only inside the finalize
|
||||
transaction** (reversed transactionally on suspend/expiry). Three **new mock vendor seams**
|
||||
(`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`); reuses b3
|
||||
`IBankAccountOwnershipVerifier` + b0 `IObjectStorage`/`IFieldEncryptor`. The expiry-scan logic ships as
|
||||
`ScanExpiringCredentialsCommand`; the **scheduled cron is deferred** (admin `scan_expiring` is the entry
|
||||
point; config `verification_expiry_scan_cadence_hours`, default 24).
|
||||
- **Contracts:** dev/contracts/domains/verification.md + openapi snapshot refreshed (yes — all 15 b6 paths).
|
||||
- **Mocked:** `IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier` → 🟡 (deterministic mocks;
|
||||
see reports/mocks-registry.md). All vendor/money calls are mocked.
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (**153 pass**). Swagger exposes all 15 b6 paths.
|
||||
- **Handoff:** backend/handoff/after-backend-phase-6.md
|
||||
- **Notes for frontend:** **`isBookable`/`isVerified` are read-only, server-derived** — never infer
|
||||
verification client-side. **Credential numbers never cross the wire** (trust badge = types only).
|
||||
`step.isAutomated` drives the UI (`/run` button vs upload flow). Prereqs enforced with **400** (Shahkar
|
||||
needs KYC; bank needs KYC + a primary b3 account). Shared-SIM / vendor fails are **200 with
|
||||
`stepStatus:"failed"` + `failureReason`**, not HTTP errors. Documents are short-lived signed URLs. Routes
|
||||
are action-style POST; refresh after `select_role`. Public trust badge (f6) is `[AllowAnonymous]`.
|
||||
|
||||
## backend-phase-5 — Service catalog & nurse pricing variants — 2026-07-02
|
||||
- **Shipped:** five tables via one additive migration (`ServiceCatalogAndNurseVariants`) — new **`catalog`**
|
||||
schema `ServiceCategories` / `ServiceOptionGroups` (nullable `service_category_id` = cross-category) /
|
||||
`ServiceOptionValues` / `NurseServiceVariants` (`Price` **BIGINT IRR**, `PriceUnit`, `SessionCount?`,
|
||||
`DisplayName`, `OptionSetHash`) / `NurseServiceVariantOptions` (`UNIQUE(variant_id, option_group_id)`);
|
||||
seed of **5 categories** (`nameFa`+`nameEn`) via `HasData`. 16 CQRS slices across 3 controllers
|
||||
(`catalog` public browse, `admin_catalog` CRUD + set-active, `nurse_variants` create/update/set-active/
|
||||
list/get). Duplicate-listing guard = `OptionSetHash` + filtered `UNIQUE(nurse_id, service_category_id,
|
||||
option_set_hash) WHERE deleted_at IS NULL` + 409 pre-check. Public catalog reads cached behind a
|
||||
`CatalogCache` generation token (invalidate on any admin write). Ships **`IVariantSnapshotSerializer`**
|
||||
(pure, for b8). **No new seam.**
|
||||
- **Contracts:** dev/contracts/domains/catalog.md + openapi snapshot refreshed (yes — 14 new
|
||||
catalog/admin_catalog/nurse_variants paths; 71 total).
|
||||
- **Mocked:** none — this phase mocks nothing and adds **no** `reports/mocks-registry.md` row.
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (122 pass: +10 handler/serializer unit,
|
||||
+11 `Baya.Test.Api` integration). Migration verified to apply on a real SQL Server; swagger exposes all
|
||||
b5 paths. Adversarial 4-dimension review: 0 confirmed findings.
|
||||
- **Handoff:** backend/handoff/after-backend-phase-5.md
|
||||
- **Notes for frontend:** the **variant is the bookable unit** (not the nurse). `price` is a **string of IRR
|
||||
digits**; the total is `price` + `priceUnit` + `sessionCount`, never price alone. A **NULL-category option
|
||||
group is cross-category** (render it under every category; required ones must be answered). Duplicate
|
||||
identical listing → **409**; missing required dimension → **400**. `displayName` auto-generates (editable).
|
||||
Deactivate, never delete. Routes are action-style POST (`admin_catalog/create_category`,
|
||||
`nurse_variants/create`, …); groups/values are admin-authored (only categories are seeded).
|
||||
|
||||
## backend-phase-4 — Geography, addresses & nurse service areas — 2026-07-02
|
||||
- **Shipped:** five tables via one migration (`GeographyAddressesServiceAreas`) — new **`geo`** schema
|
||||
`Provinces` 1:N `Cities` 1:N `Districts` (+ `NurseServiceAreas`) and `usr.CustomerAddresses`; seed
|
||||
(31 provinces + capital cities + Tehran's 22 مناطق via `HasData`); 20 CQRS slices across 4 controllers
|
||||
(`geo` public lookups incl. `/tree`, `admin_geo` CRUD + set_active, `nurse_service_areas`,
|
||||
`customer_addresses`); new **`IGeocoder`** seam (deterministic mock, `Seams:Geocoding`); per-domain
|
||||
repos on `IUnitOfWork`; enc value converters for the address PII columns; **`409 Conflict`** added to
|
||||
`OperationResult`/`BaseController`. Whole-city (`district_id NULL`) uniqueness via a **filtered-index
|
||||
pair**; single-primary address via filtered `UNIQUE(customer_id) WHERE is_primary=1`; geo reads cached
|
||||
behind a generation-token scheme with invalidate-on-admin-write.
|
||||
- **Contracts:** dev/contracts/domains/geography-addresses.md + openapi snapshot refreshed (yes — 20 new
|
||||
geo/service-area/address paths).
|
||||
- **Mocked:** `IGeocoder` → 🟡 (see reports/mocks-registry.md).
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (103 pass: +16 `Baya.Test.Api` integration,
|
||||
+12 handler unit tests). Migration `GeographyAddressesServiceAreas` applies on startup; swagger exposes
|
||||
all b4 paths.
|
||||
- **Handoff:** backend/handoff/after-backend-phase-4.md
|
||||
- **Notes for frontend:** `districtId=null` means **whole city** (a real choice) everywhere. Duplicate
|
||||
service area → **409**. Addresses come back **decrypted for the owner** with `latitude`/`longitude`
|
||||
(nullable when ungeocoded — geocoding is mocked). Routes are action-style (`admin_geo/create_city`,
|
||||
`nurse_service_areas/add`, `customer_addresses/create`, …). Admin geo needs an admin token.
|
||||
|
||||
## backend-phase-3 — Identity: profiles, patients & nurse bank accounts — 2026-07-02
|
||||
- **Shipped:** four `usr` tables via one migration (`IdentityProfilesPatientsBankAccounts`) —
|
||||
`NurseProfiles` (1:1 `Users`; guarded `is_verified` **no public setter**; read-only aggregates;
|
||||
soft-delete), `CustomerProfiles` (thin payer; enc emergency contact), `Patients` (care recipient,
|
||||
tenancy-scoped; `is_active` archive; enc `initial_medical_notes`), `NurseBankAccounts` (enc `iban` +
|
||||
`UNIQUE(iban_hash)` + filtered `UNIQUE(nurse_id) WHERE is_primary=1`; استعلام شبا inquiry fields);
|
||||
15 CQRS slices across 4 controllers (`nurse_profiles`, `customer_profiles`, `patients`,
|
||||
`nurse_bank_accounts`); new **`IBankAccountOwnershipVerifier`** seam (mock = deterministic شبا match);
|
||||
per-domain repositories on `IUnitOfWork`; enc value converters for the new PII columns. Also
|
||||
**activated FluentValidation** repo-wide (`AddApplicationServices` now registers every
|
||||
`AbstractValidator<T>` — the `ValidateCommandBehavior`/model-state filter were previously starved).
|
||||
- **Contracts:** dev/contracts/domains/identity-profiles.md + openapi snapshot refreshed (yes — new
|
||||
nurse/customer/patient/bank paths).
|
||||
- **Mocked:** `IBankAccountOwnershipVerifier` → 🟡 (see reports/mocks-registry.md).
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (75 pass: +13 `Baya.Test.Api` integration,
|
||||
+15 handler unit tests). Migration applied to the dev DB on startup; swagger exposes all b3 paths.
|
||||
- **Handoff:** backend/handoff/after-backend-phase-3.md
|
||||
- **Notes for frontend:** role scoping needs the **role claim in the token** — refresh after
|
||||
`select_role` before calling these. IBAN comes back **masked** (last-4). `isVerified`/aggregates are
|
||||
read-only. Patient `get/update` of another customer's id → **404**. Addresses/service-areas are
|
||||
**deferred to b4**.
|
||||
|
||||
## backend-phase-2 — Identity: phone-OTP auth, sessions & roles (REST) — 2026-07-02
|
||||
- **Shipped:** the six-endpoint REST auth surface (`auth/request_otp`, `auth/verify_otp`,
|
||||
`auth/refresh`, `auth/logout`, `me`, `me/select_role`) wrapping the existing JWE/TOTP/RBAC engine;
|
||||
new `usr.UserSessions` (refresh-token rotation + revoke-all on replayed token); `usr.Users` extended
|
||||
(`Gender`, `NationalId` enc NULL, `ShahkarVerifiedAt` auto-reset on phone change, `PhoneHash`
|
||||
UNIQUE, `IsActive`, `DeletedAt` + soft-delete filter); phone/email/national-id **encrypted at rest**
|
||||
(EF value converter over `IFieldEncryptor`); `usr.UserRoles` grant/revoke audit trail + revoked
|
||||
filter; 7 roles seeded; `ISmsSender` seam (mock logs the code); 3 auth config keys;
|
||||
`OperationResult`/`BaseController` learned 401/403.
|
||||
- **Contracts:** dev/contracts/domains/identity-auth.md + openapi snapshot refreshed (yes — 22 paths).
|
||||
- **Mocked:** `ISmsSender` → 🟡 (see reports/mocks-registry.md).
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (47 pass: 10 new `Baya.Test.Api`
|
||||
integration + 14 new handler unit tests). Migration `IdentitySessionsAndUserExtensions` applied to
|
||||
the dev DB; full §7 flow verified live (OTP in log, tokens, 401/403/429, rotation, replay-revoke,
|
||||
logout stamp-kill).
|
||||
- **Handoff:** backend/handoff/after-backend-phase-2.md
|
||||
- **Notes for frontend:** exact paths are `request_otp`/`verify_otp`/`select_role` (snake_case
|
||||
transformer — not the `otp/request` sketch). Bodies camelCase. Fresh users: `roles: []` → role
|
||||
router → `me/select_role` → **refresh tokens** to pick up role claims. `/me` phone is masked.
|
||||
SMS is mocked — read the OTP from the server log.
|
||||
|
||||
## backend-phase-1 — Config, reference & platform signals — 2026-07-02
|
||||
- **Shipped:** first marketplace migration baseline (`InitialMarketplaceBaseline`, new **`ops`** schema)
|
||||
with 6 tables (`PlatformConfigs`, `AuditLogs`, `SystemEvents`, `IranianHolidays`, `Notifications`,
|
||||
`SupportAlerts`) + seed (12 config keys, 7 holidays); platform-signal facades `IPlatformConfig` /
|
||||
`IHolidayCalendar` / `IAnalyticsSink` / `IAuditLogger` / `INotificationService` / `ISupportAlertService`
|
||||
(`Persistence/Services/`); `AuditFieldInterceptor` extended to write append-only `audit_logs` rows for
|
||||
`IAuditable` entities; real in-app `INotificationDispatcher` (b0 stub removed); notification-retention
|
||||
hosted service; 5 controllers (admin config/holidays/audit/support-alerts + current-user notifications).
|
||||
- **Contracts:** `dev/contracts/domains/config-reference.md` + openapi snapshot refreshed (yes — 16 paths).
|
||||
- **Mocked:** `IHolidayCalendar`, `IAnalyticsSink`, retention `IJobScheduler` → 🟡; `INotificationDispatcher`
|
||||
flipped to in-app-real 🟡 (SMS/push deferred). See reports/mocks-registry.md.
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (22 pass: 4 identity + 18 foundation). Migration
|
||||
applied to the dev DB; API boots with all 16 paths in Swagger; retention job runs on startup.
|
||||
- **Handoff:** backend/handoff/after-backend-phase-1.md
|
||||
- **Notes for frontend:** f14 = `notifications/*` (envelope unchanged; unread-first lists; `data_json` is a
|
||||
typed deep-link payload). f15 = admin `platform_config/*`, `holidays/*`, `audit/get_audit_trail`,
|
||||
`support_alerts/*` (DynamicPermission). Pagination `page`/`page_size` (default 50, max 100).
|
||||
|
||||
## backend-phase-0 — Foundation, cross-cutting seams & starter cleanup — 2026-06-28
|
||||
- **Shipped:** removed the `Order` demo (entity/feature/repo/config/gRPC) + 3 old migrations; fresh
|
||||
`InitialBaseline` migration; REST surface (`PingController` + `System/Ping` CQRS); `ICurrentUser` +
|
||||
`AuditFieldInterceptor`; five cross-cutting seams (`IDateTimeProvider`, `IFieldEncryptor`,
|
||||
`ICacheService`, `IObjectStorage`, `INotificationDispatcher`) with mocks; `LoggingBehavior` +
|
||||
rate limiter (per-IP global + `otp`/`auth`/`sensitive`).
|
||||
- **Contracts:** `dev/contracts/openapi/swagger.v1.json` published (envelope + ping schemas).
|
||||
- **Mocked:** the 5 seams above → 🟡 (see reports/mocks-registry.md).
|
||||
- **Gate:** build clean (0 new warnings) / tests green (10 pass). Live API verified vs `192.168.100.14`
|
||||
(migration applied + seeded; ping `200`; rate-limit `429`).
|
||||
- **Handoff:** backend/handoff/after-backend-phase-0.md
|
||||
- **Notes for frontend:** `ApiResult` envelope is fixed (camelCase body, snake_case URLs);
|
||||
`GET /api/v1/ping/get_status` is live to wire types against; `429` on over-limit.
|
||||
@@ -0,0 +1,45 @@
|
||||
# After backend-phase-0 — what f0/b1 can rely on
|
||||
|
||||
**The spine is clean and REST works.** The inherited `Order` demo (entity/feature/repo/config/gRPC) and
|
||||
the three pre-marketplace migrations are gone; Identity + JWE auth + dynamic permissions + the CQRS
|
||||
behaviors + observability are untouched and the existing tests stay green. A real REST controller is
|
||||
live and proves the full pipeline.
|
||||
|
||||
## What the frontend (f0) can rely on now
|
||||
- **The response envelope is fixed.** Every endpoint returns the server's `ApiResult` envelope, not a
|
||||
bare body. Success shape:
|
||||
```json
|
||||
{ "isSuccess": true, "statusCode": 0, "message": "Success", "requestId": "<trace>", "data": <T> }
|
||||
```
|
||||
Failure returns `400/401/403/404` with the same envelope (field-level errors for validation). This is
|
||||
what `clientFetch`/`serverFetch` must unwrap. JSON casing is the server default (camelCase for the
|
||||
envelope/body properties; **URL segments are snake_case**).
|
||||
- **A working endpoint to wire the type pipeline against:**
|
||||
`GET /api/v1/ping/get_status` → `ApiResult<{ service, status, serverTimeUtc }>`.
|
||||
- **Rate limiting exists.** Over-limit requests get `429`; `get_status_rate_limited` demonstrates it
|
||||
(first 5/10s OK, then 429).
|
||||
- **swagger.json is published:** `dev/contracts/openapi/swagger.v1.json` (envelope schemas
|
||||
`ApiResult`, `ApiResultStatusCode`, `PingQueryResult`). Generate frontend types from it.
|
||||
|
||||
## What b1 can rely on now
|
||||
- **Audit base type:** `BaseEntity`/`IAuditableEntity` (`Domain/Common/BaseEntity.cs`) with
|
||||
`CreatedAt`/`ModifiedAt` (`DateTimeOffset`) + `CreatedById`/`ModifiedById` (`int?`). New entities that
|
||||
derive `BaseEntity` get audit stamping for free.
|
||||
- **Audit interceptor:** `AuditFieldInterceptor` (`Infrastructure.Persistence/Interceptors/`) stamps
|
||||
those fields on save from `ICurrentUser` + `IDateTimeProvider`. **b1 extends this** to also write the
|
||||
append-only `audit_logs` rows — the stamping plumbing and the clean extension point are in place.
|
||||
- **Cross-cutting seams (DI-registered, mocked):** `IDateTimeProvider`, `IFieldEncryptor` (use it for
|
||||
every encrypted PII column — phone/national_id/IBAN/addresses/clinical notes; deterministic `Hash`
|
||||
for `*_hash` lookup columns), `ICacheService` (cache `platform_configs` / read-heavy data through it),
|
||||
`IObjectStorage`, `INotificationDispatcher`. Contracts in `Application/Contracts/Common`; mocks in
|
||||
`Infrastructure.CrossCutting/Seams/`; registered by `AddCrossCuttingSeams`.
|
||||
- **Money rule:** IRR is `long`/`BIGINT`, integer-only, no floats (CONVENTIONS §6).
|
||||
- **Migration baseline:** `20260628191947_InitialBaseline`. Add the marketplace schema on top of it;
|
||||
generate with the documented `dotnet ef migrations add` command.
|
||||
- **Rate-limit policies** `otp`/`auth`/`sensitive` are defined and ready to apply via
|
||||
`[EnableRateLimiting("...")]` on the relevant endpoints (b2 auth/OTP).
|
||||
|
||||
## Known caveat
|
||||
Non-Development environments route Serilog to the `logDb` connection (`Server=sql_server2022`); if that
|
||||
host isn't reachable, the API won't start there. Development logs to console/file and boots cleanly
|
||||
against the configured `SqlServer` DB. This is pre-existing infra config, unrelated to phase-0 code.
|
||||
@@ -0,0 +1,58 @@
|
||||
# After backend-phase-1 — what b2…b15 and the frontend can rely on
|
||||
|
||||
The **platform backbone** is live. Six cross-cutting tables exist in a new **`ops` schema** on top of
|
||||
b0's `InitialBaseline`, seeded with real config + a sample holiday calendar. The mechanisms every later
|
||||
phase needs — typed config, holiday math, audit trail, analytics, in-app notifications, support alerts —
|
||||
are built **once, here**, behind Application contracts. **Reuse them; do not re-create the tables.**
|
||||
|
||||
## Internal contracts b2…b15 must depend on (never reinvent)
|
||||
All are DI-registered (Scoped) and implemented in `Baya.Infrastructure.Persistence/Services/`:
|
||||
|
||||
- **`IPlatformConfig`** — `GetConfig<T>(key)` (cached, parsed by `data_type`), `SetConfig(key,value)`
|
||||
(audited, evicts cache), `ListAsync`, `GetConfigChangeHistory(key)`. **Read money-critical constants
|
||||
here at compute time — never hardcode.** Seeded keys: `platform_fee_rate`, `vat_rate` (0.10),
|
||||
`dispute_window_hours` (72), `booking_payment_deadline_minutes` (30), `nurse_response_deadline_hours`,
|
||||
`nurse_payout_interval_days`, `evv_location_tolerance_meters`, `min_rating_for_support_alert`,
|
||||
`bnpl_merchant_of_record`, `bnpl_provider_commission_rate`, `bnpl_settlement_timing`,
|
||||
`cancellation_tiers`. **Snapshot the rate you use onto the priced row** — a later config change must not
|
||||
re-price it.
|
||||
- **`IHolidayCalendar`** — `IsHoliday`, `IsBankClosed`, `NextBusinessDay` (skips bank-closed days + the
|
||||
Iranian banking weekend = Friday), plus admin CRUD. **b13 payout scheduling calls `NextBusinessDay`.**
|
||||
- **`IAuditLogger`** — `WriteAsync(entityType, entityId, action, changedFields?)` for state changes with
|
||||
no row diff, plus `GetTrailAsync`. Row-level diffs on **`IAuditable`** entities are written
|
||||
automatically by the extended `AuditFieldInterceptor` (mark an entity `IAuditable`; annotate encrypted
|
||||
props `[AuditRedacted]`). `platform_configs` is the first `IAuditable` entity. **`audit_logs` is
|
||||
append-only — never update/delete it.**
|
||||
- **`IAnalyticsSink`** — `EmitAsync(name, props)`; fire-and-forget (`system_events`). **Never** route
|
||||
compliance facts here — those go to `IAuditLogger`.
|
||||
- **`INotificationDispatcher`** — `DispatchAsync(Notification(userId, type, title, body?, dataJson?))`
|
||||
now writes a **real in-app `notifications` row** (b0 stub gone). This is how booking/payment/review
|
||||
domains mint a user notification. `data_json` is a **typed, versioned deep-link payload** — version it.
|
||||
- **`INotificationService`** — per-user reads/commands (list unread-first, unread count, mark read/all,
|
||||
purge). Always tenant-scoped to `ICurrentUser`.
|
||||
- **`ISupportAlertService`** — `RaiseAsync(type, entityType, entityId, severity, bookingId?, reviewId?)`
|
||||
for review/EVV/verification/payment flows to call, plus assign/resolve/list. **Support alerts are
|
||||
admin-only — never surface them on a user-facing route or in a user `notification`.**
|
||||
|
||||
## Live endpoints (contract: `dev/contracts/domains/config-reference.md`)
|
||||
Admin (`DynamicPermission`): `platform_config/*`, `holidays/*`, `audit/get_audit_trail`,
|
||||
`support_alerts/*`. Current-user (`Authorize`): `notifications/*` (f14 notification center;
|
||||
f15 admin config/holidays/audit/alerts). Envelope unchanged (camelCase body, snake_case URLs); lists
|
||||
paginated `page`/`page_size`.
|
||||
|
||||
## Migration / schema
|
||||
New migration **`20260701193257_InitialMarketplaceBaseline`** — the marketplace baseline every later
|
||||
phase adds onto. Applied cleanly to the dev DB (tables + indexes + seed present). Tables live in **`ops`**
|
||||
(keep new marketplace tables off `usr`).
|
||||
|
||||
## Follow-ups later phases must close
|
||||
- **FK constraints for `support_alerts.booking_id` / `review_id`.** The columns exist now (no FK yet).
|
||||
**b9** (bookings) adds `FK SupportAlerts.BookingId → Bookings`; **b14** (reviews) adds
|
||||
`FK SupportAlerts.ReviewId → Reviews`. Do it in the migration that creates those tables.
|
||||
- **Make-it-real seams (🟡):** `IHolidayCalendar` (real feed/sync), `IAnalyticsSink` (warehouse),
|
||||
`IJobScheduler` retention (Hangfire/Quartz), `INotificationDispatcher` SMS/push channels — see
|
||||
`reports/mocks-registry.md`.
|
||||
|
||||
## Caveat (unchanged from b0)
|
||||
Non-Development Serilog targets the `logDb` connection; Development boots against the configured
|
||||
`SqlServer`. A reachable SQL Server is required to run the API (it applies migrations + seeds on boot).
|
||||
@@ -0,0 +1,55 @@
|
||||
# Handoff — after backend phase 10 (Payments core: ledger, transactions, webhooks & card capture)
|
||||
|
||||
**The money core is live.** A family can now pay the gross price by card: **initiate → PSP webhook confirms →
|
||||
the balanced card-capture ledger group posts → the booking converts/confirms**. The PSP acquirer, تسهیم split,
|
||||
webhook signature verify, and the distributed lock are **mocked behind seams**; the DB constraints are the
|
||||
authoritative money-path backstops.
|
||||
|
||||
## What f9-b10 can now build
|
||||
- **Summary & pay (C6)** — after the nurse accepts, `POST api/v1/bookings/{bookingRequestId}/payments` (as the
|
||||
customer, with an `Idempotency-Key` header) → `{ transactionId, redirectUrl, gatewayReferenceCode }`. Show
|
||||
the checkout summary (gross + commission/VAT/escrow notice) and send the payer to `redirectUrl`. **Never show
|
||||
the internal `account_type`s** — gross + the commission/VAT breakdown only.
|
||||
- **Card payment redirect + confirmation** — the (mock) redirect completes; the PSP callback hits
|
||||
`POST api/v1/webhooks/payments/{provider}` and confirms. Poll the booking (b9 `bookings/list` /
|
||||
`bookings/get`) → it appears **`confirmed`** once the webhook captures. (A booking exists only on capture.)
|
||||
- **Nurse payable balance** — `GET api/v1/nurses/{nurseId}/payable_balance` (the nurse themself or admin) →
|
||||
`{ nurseId, balanceIrr }` as a digit-string, derived from the ledger.
|
||||
|
||||
## Live endpoints / contracts
|
||||
- Contract: [`dev/contracts/domains/payments.md`](../../contracts/domains/payments.md).
|
||||
- Enums: `payment` status (`pending`/`succeeded`/`failed`), `payment_gateways.type` (`standard`/`bnpl`),
|
||||
`processing_status` (`received`/`processed`/`failed`/`ignored`), the eight `account_type`s (internal).
|
||||
|
||||
## Load-bearing rules the client must honour
|
||||
- **Money is IRR integer, on the wire as a digit-string.** Never coerce to a JS number for math.
|
||||
- **The booking is created & confirmed on capture (the webhook), not on initiate.** After `initiate` you have a
|
||||
redirect only; the `bookings` row appears when the PSP callback confirms.
|
||||
- **Payment is idempotent end-to-end** — a retried `initiate` (same `Idempotency-Key`) reuses the attempt; a
|
||||
replayed webhook is a no-op; a repeat `initiate` after capture is a `409`.
|
||||
- **Internal account types are never exposed to the customer** — checkout shows gross + commission/VAT only.
|
||||
|
||||
## Design note (reconciling b10 with b9's as-built)
|
||||
b9 creates the `bookings` row **on capture** (it never persists a standalone `pending_payment` booking). So b10
|
||||
initiates payment against the `accepted_awaiting_payment` **request**; `payment_transactions.booking_id` is
|
||||
**nullable**, bound only at confirm. Confirm reuses b9's conversion/amount logic through the extracted
|
||||
**`BookingFactory`** (no duplication) and posts the ledger + registers the split. The mock
|
||||
`IPaymentCaptureSimulator` + the `POST bookings/convert` endpoint **stay** (b9's own tests use them); the real
|
||||
capture path uses `BookingFactory` directly, not that seam.
|
||||
|
||||
## Mocked here → make real later (see reports/mocks-registry.md)
|
||||
- **`IPaymentProvider`** (🟡) — deterministic ref + fake redirect; `VerifyAsync` instant-succeeds. Real:
|
||||
ZarinPal/Sadad/Vandar/Jibit acquirer-with-تسهیم, merchant id from encrypted `config_json`, server-side verify.
|
||||
- **`ISettlementSplitProvider`** (🟡) — records split intent, returns `Settled`. Real: split-by-ratio to
|
||||
registered SHEBAs; provider credits IBANs directly.
|
||||
- **`IWebhookVerifier`** (🟡) — signature valid unless a marker; parses a test JSON body. Real: per-provider
|
||||
HMAC / mandatory server-side verify.
|
||||
- **`IDistributedLock`** (🟡) — in-process semaphore. Real: StackExchange.Redis lease, key `booking:{id}:payment`.
|
||||
|
||||
## Consumed by later backend phases
|
||||
- **b11** — refunds/clawbacks/invoices post against the ledger (`refund_payable`/`nurse_clawback_receivable`
|
||||
account types are already defined); `IPaymentProvider.RefundAsync` is ready to call.
|
||||
- **b12** — BNPL settle routes callbacks through the **same** `payment_webhook_events` idempotency store and
|
||||
posts the BNPL-settle group (adds `bnpl_fee_expense`).
|
||||
- **b13** — payouts read `GetNursePayableBalance` (the signed `nurse_payable` ledger sum) and post
|
||||
DEBIT `nurse_payable` / CREDIT `escrow_held`, gated on b9's `dispute_window_ends_at`.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Handoff — after backend-phase-11 (Refunds, clawbacks & invoices)
|
||||
|
||||
**The reversal leg of the payments arc is live.** Money can now flow *backwards*: an admin reverses a captured
|
||||
booking payment across both fee legs, the balanced reversal posts to the append-only ledger, and the flow forks
|
||||
on whether the nurse was already paid (clean `nurse_payable` reversal vs a first-class `nurse_clawbacks`
|
||||
receivable). The minimal commission **invoice** (VAT on the commission line, sequential number, mocked مودیان)
|
||||
ships too.
|
||||
|
||||
## What the frontend (f10-b11) can now build
|
||||
- **Admin refund console** — create a refund (`POST admin_refunds`: full/partial by percentage or explicit legs),
|
||||
the refund worklist (`GET admin_refunds` — channel, decomposed legs, status, ETA, policy snapshot), and the
|
||||
clawback write-off (`POST admin_clawbacks/{id}/write_off`).
|
||||
- **Admin invoice issuance** — `POST admin_invoices` (sequential number, VAT on commission, مودیان pending).
|
||||
- **Customer cancellation/refund-status view** — `GET refunds/{id}/status` (tenancy-scoped; status, channel,
|
||||
amount, **`expected_customer_refund_eta`** for the BNPL 7–10-business-day window, masked reference). This is the
|
||||
**only** customer-visible refund surface — refunds are admin-initiated, there is no self-service initiation.
|
||||
- **Customer/admin invoice view** — `GET invoices/{booking_id}` (number, gross, commission, VAT, مودیان status,
|
||||
PDF URL when present).
|
||||
|
||||
## Live endpoints / contract
|
||||
- Contract: **`dev/contracts/domains/refunds-invoices.md`** (enums, DTO shapes, IRR digit-strings, masked
|
||||
references, failure codes, side-effects). Machine schema: `dev/contracts/openapi/swagger.v1.json` **refreshed**.
|
||||
- All money is IRR integer, on the wire as a **digit-string**. `expected_customer_refund_eta` is a **date**.
|
||||
- Admin endpoints are behind the admin policy and **rate-limited** (sensitive). Refunds are **never** customer
|
||||
self-service.
|
||||
|
||||
## What is mocked / waiting
|
||||
- **سامانه مودیان** is mocked behind **`IMoadianClient`** (pending/no-ref by default; a config switch forces a
|
||||
registered 22-digit ref). See `reports/mocks-registry.md`.
|
||||
- **BNPL revert** runs against **`IBnplProvider`** — a **thin local stub** now; **b12 owns the real seam** and its
|
||||
adapter. The `bnpl_revert` ledger legs are already identical to the card path.
|
||||
- **"Was the nurse paid?"** is derived from the booking's dispute-window close (`INursePayoutStatus`) until **b13**
|
||||
ships `nurse_payouts`; **clawback recovery/netting is b13** (this phase only opens the `pending` receivable +
|
||||
supports write-off).
|
||||
- **`tickets`** arrives in **b15**: `refunds.ticket_id` is a nullable column (no FK yet); the "ticket required"
|
||||
rule is config-gated off (`refund_ticket_required = false`).
|
||||
- **Deferred crons** (thin/manual today): the مودیان reconciliation job (`pending → registered`) and the
|
||||
BNPL-revert reconciliation job that clears `refund_payable ↔ escrow_held` for a `processing` refund.
|
||||
|
||||
## Notes for the next backend phases
|
||||
- **b12 (BNPL):** owns the real `IBnplProvider`; the refund `bnpl_revert` path already calls it through the seam.
|
||||
- **b13 (payouts):** implement the real `INursePayoutStatus` (`nurse_payout_booking_links`) and net `pending`
|
||||
`nurse_clawbacks` out of a payout batch (set `recovered_in_payout_id` / `original_payout_id`, post `DEBIT
|
||||
nurse_payable / CREDIT nurse_clawback_receivable`).
|
||||
- **b15 (tickets/partner centers):** wire the real `refunds.ticket_id` FK + flip `refund_ticket_required` on;
|
||||
wire `invoices.partner_center_id` + `nurse_clawbacks` payout FKs.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Handoff — after backend-phase-12 (BNPL: provider-financed installments)
|
||||
|
||||
**BNPL checkout is live.** A family can now pay for a booking with a provider-financed installment plan
|
||||
(SnappPay / Digipay / Tara / Torob Pay). The decisive, verified truth: an Iranian provider-financed BNPL order
|
||||
**settles the full booking amount to Balinyaar in one lump, net of the provider's merchant commission**, and the
|
||||
provider owns the customer's installments and **100% of default risk**. So in our books a BNPL order is **a card
|
||||
payment that lands net-of-fee** — one `bnpl_transactions` row (1:1 with its `payment_transaction`), a forward-only
|
||||
`eligible → token_issued → verified → settled → reverted` state machine, and a settle that posts the card-capture
|
||||
ledger legs **plus** a `bnpl_fee_expense` leg so escrow reflects the *net* cash. **The nurse's payout is invariant
|
||||
to payment method.** We do **not** model the customer's repayment schedule.
|
||||
|
||||
## What the frontend (f11-b12) can now build
|
||||
- **"Pay with installments" option at checkout** — `POST checkout_bnpl/eligibility` returns
|
||||
`eligible`/`not_eligible`/`ceiling_exceeded` + the plan summary ("4 interest-free installments, provider-financed",
|
||||
`installmentCount`, `creditCeilingIrr`). On anything but `eligible`, **fall back to card**.
|
||||
- **Start the plan + provider handoff** — `POST checkout_bnpl/initiate` → `token_issued` + `externalPaymentToken`
|
||||
+ `redirectUrl` (send the customer to the provider). Carries an `Idempotency-Key` header.
|
||||
- **Order status** — `GET checkout_bnpl/{id}` (customer, own order only): status, gross/net/commission, the
|
||||
**non-instant** `settledAt`, and the revert audit + async customer ETA.
|
||||
- **Admin BNPL console** — `POST admin_bnpl/{id}/verify|settle`, `POST admin_bnpl/{id}/revert` (full or partial),
|
||||
`GET admin_bnpl/{id}`. The revert surfaces `expectedCustomerRefundEta` (~7–10 business days) and opens a
|
||||
`refund_channel='bnpl_revert'` refund (visible via the b11 `GET refunds/{id}/status`).
|
||||
|
||||
## Live endpoints / contract
|
||||
- Contract: **`dev/contracts/domains/bnpl.md`** (enums, DTO shapes, IRR digit-strings, nullable `settled_at`,
|
||||
failure codes, the net-of-fee settle + customer↔provider↔Balinyaar refund routing). Machine schema:
|
||||
`dev/contracts/openapi/swagger.v1.json` **refreshed**.
|
||||
- All money is IRR integer, on the wire as a **digit-string**; `settled_at` is **nullable** (not instant);
|
||||
`expectedCustomerRefundEta` is a **date**.
|
||||
- Checkout is customer-scoped + **rate-limited**; the webhook is anonymous (signature-verified) + rate-limited;
|
||||
admin endpoints are behind the admin policy + rate-limited.
|
||||
|
||||
## What is mocked / waiting
|
||||
- **The provider + currency are mocked** behind **`IBnplProvider`** (per `provider_code` via
|
||||
**`IBnplProviderResolver`**) and **`ICurrencyNormalizer`** — deterministic, no network; the settle commission is
|
||||
a configurable mock % (`Seams:Bnpl:CommissionRate`, default 10%). See `reports/mocks-registry.md` for the exact
|
||||
real-provider steps (SnappPay/Digipay verb sets, encrypted creds).
|
||||
- **Settlement timing is not modelled as instant** — `settled_at` is nullable and read from the settlement
|
||||
(`Seams:Bnpl:SettlementInstant` toggles the mock). **b13 must not assume BNPL cash funds a payout.**
|
||||
- **`bnpl_settlement_entries`** (tranched settlement) is **DEFERRED — modeled-but-not-built**; adding it later is a
|
||||
purely additive migration.
|
||||
- **Multi-provider routing / failover** is DEFERRED — one active route, config-driven selection.
|
||||
- The revert reuses the **b11 refund path**; `provider_commission_reversed_amount` is left null there (reconciled
|
||||
from the provider response later).
|
||||
|
||||
## Notes for the next backend phases
|
||||
- **b13 (payouts):** the `settled_at`-gates-payout coupling lives here — add the
|
||||
`require_bnpl_settlement_for_payout` config flag and gate a BNPL booking's payout on `bnpl_transactions.settled_at`
|
||||
actually being set (never pay a nurse before Balinyaar holds the cash). The `nurse_payable` accrual is already
|
||||
identical to the card path, so payout amounts need no BNPL-specific logic.
|
||||
- **Shared conversion:** b10's booking-creation was extracted to `Features/Bookings/BookingConversion` — both the
|
||||
card capture and the BNPL settle use it. Reuse it, don't fork.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Handoff — after backend-phase-13 (Weekly nurse payouts)
|
||||
|
||||
**The payout engine is live — a nurse's earnings are now real money.** This is the last money-out leg of the
|
||||
payments arc (b10 ledger → b11 refunds/clawbacks → b12 BNPL → **b13 payouts**). An admin previews eligible
|
||||
earnings, opens a weekly batch, and submits it to a **mocked PAYA/SATNA bank rail**; each booking is paid in
|
||||
**exactly one** payout across all batches (a `nurse_payout_booking_links.booking_id` UNIQUE), pending clawbacks are
|
||||
netted, the verified primary IBAN is snapshotted, and the outbound `nurse_payable → escrow_held` ledger movement is
|
||||
posted. Everything is **holiday-aware** (a Nowruz-landing batch shifts off bank-closed days). No `payout_released`
|
||||
boolean exists — paid-ness is derived from a link row + the ledger.
|
||||
|
||||
## What the frontend (f12-b13) can now build
|
||||
- **Nurse earnings / payout history** — `GET nurse_payouts/history` (authenticated nurse, own payouts only,
|
||||
paginated): `status` (`pending|submitted|paid|failed`), `netAmountIrr`, `grossEarningsIrr`, `clawbackAppliedIrr`,
|
||||
the **masked** IBAN, `transferReference`, `paidAt`, and the batch window. Money is a **digit string**. Show the
|
||||
clawback line when `clawbackAppliedIrr > "0"` ("earnings held to recover a prior overpayment").
|
||||
- **Admin payout console:**
|
||||
- `GET admin_payouts/eligible?periodStart=&periodEnd=` — dry-run preview per nurse; flags any nurse with
|
||||
`hasVerifiedPrimaryIban=false` (they won't be paid until they register a verified primary account).
|
||||
- `POST admin_payouts/batches {periodStart, periodEnd}` — open a `draft` batch → returns the batch + payouts +
|
||||
the `skipped` nurses (with reasons). No money moves yet.
|
||||
- `POST admin_payouts/batches/{id}/process` — submit to the rail → `completed` / `partially_failed`.
|
||||
- `POST admin_payouts/{payoutId}/retry` and `POST admin_payouts/{payoutId}/mark_failed {failureReason}`.
|
||||
- `GET admin_payouts/batches/{id}` (header + payouts + linked bookings) and `GET admin_payouts/batches?status=`.
|
||||
|
||||
## Contracts
|
||||
- **`dev/contracts/domains/payouts.md`** — all 8 endpoints, the `PayoutBatchStatus`/`PayoutStatus` enums, and the
|
||||
batch/payout/link/history DTO shapes (IRR digit strings, **masked** IBAN). Query params are **camelCase**
|
||||
(`page`/`pageSize`/`status`/`periodStart`/`periodEnd`).
|
||||
- **`dev/contracts/openapi/swagger.v1.json`** refreshed — the 7 payout paths are in the snapshot.
|
||||
|
||||
## What is mocked (and how it becomes real)
|
||||
- **`IBankTransferProvider`** (new) — the PAYA/SATNA rail. `MockBankTransferProvider` moves no money: it returns a
|
||||
deterministic `transfer_reference` and settles every instruction `paid`, honouring the PAYA/SATNA method the
|
||||
handler picked by `payout_satna_threshold_irr`. Config forces failures for testing (`Seams:BankTransfer:ForceFailure`
|
||||
whole-batch, `Seams:BankTransfer:FailIban` one row). Make it real → a Jibit/Vandar/Sadad payout adapter with a
|
||||
registered source settlement account + the async reconciliation callback (see reports/mocks-registry.md).
|
||||
- **`INursePayoutStatus`** — b13 shipped the authoritative `NursePayoutLinkStatusService` (paid iff a link ties the
|
||||
booking to a `paid` payout); the interim dispute-window derivation was deleted. **The b11 refund fork now forks on
|
||||
the true paid-state** — no refund-side change needed.
|
||||
|
||||
## Load-bearing rules (don't regress)
|
||||
- **One payout per booking, ever** — the `booking_id` UNIQUE is unconditional (not soft-delete-filtered).
|
||||
- **Eligibility ≠ completed** — needs `dispute_window_ends_at < now`, no active refund, not already linked.
|
||||
- **Clawback netting recovers *whole* clawbacks up to earnings** (never a negative net, never a partial single row);
|
||||
the recovery is a real `DEBIT nurse_payable / CREDIT nurse_clawback_receivable` posting + `recovered` status.
|
||||
- **Process is idempotent** — forward-only `PayoutStatus` + a batch idempotency key + the ledger-exists guard.
|
||||
|
||||
## Deferred (flagged, not built)
|
||||
- The weekly **cron scheduler** — batches are admin-triggered; cadence in `nurse_payout_interval_days` (default 7).
|
||||
- **On-demand / instant withdrawal**, **per-nurse payout frequency**, **automated clawback recovery beyond netting**.
|
||||
- The **BNPL `settled_at` guard** — exposed as `require_bnpl_settlement_for_payout` (config, default off).
|
||||
@@ -0,0 +1,56 @@
|
||||
# Handoff — after backend-phase-14 (Reviews, ratings & patient care records)
|
||||
|
||||
**The trust loop and the continuity-of-care loop are live.** A customer leaves **one moderated review per
|
||||
completed booking**; an admin/moderator publishes/hides/rejects it; the nurse's public rating is recomputed
|
||||
**from source on every transition** (so hiding a 1-star lowers the count and re-derives the average — no
|
||||
inflated-after-hide drift); low ratings auto-raise an internal `support_alert`; and nurses author **encrypted,
|
||||
patient-scoped** clinical notes readable only under a strict clinical-access rule.
|
||||
|
||||
## What the frontend (f13-b14) can now build
|
||||
- **Leave a review** — `POST bookings/{bookingId}/review` `{ rating 1–5, body?, tagCodes? }` (customer who owns a
|
||||
completed booking). Returns `{ id, moderationStatus: "pending_moderation", lowRatingAlertRaised }`. The review is
|
||||
**not public** until an admin publishes it — build the "submitted, awaiting moderation" state.
|
||||
- **Public nurse reviews** — `GET nurses/{nurseProfileId}/reviews?page=&pageSize=` → `{ aggregate: { averageRating,
|
||||
publishedCount }, reviews: PagedResult<{ id, rating, body, tagCodes[], createdAt }> }`. **Published only.**
|
||||
- **Public tag rollup** — `GET nurses/{nurseProfileId}/review_tags` → `{ publishedReviewCount, tags: [{ code,
|
||||
labelFa, labelEn, count, percentage }] }` ("% punctual"). The seeded vocab (`punctual/professional/clean/kind/
|
||||
communicative`) is always returned.
|
||||
- **Tag your own review** — `POST reviews/{reviewId}/tags` `{ tagCodes }` (author or moderator; replaces the set).
|
||||
- **Admin moderation console** — `GET admin/reviews/moderation_queue?status=&page=&pageSize=` (default
|
||||
`pending_moderation`; each row carries the linked `lowRatingAlertId` for triage) and
|
||||
`PATCH reviews/{reviewId}/status` `{ action: publish|hide|reject|unpublish, reason? }` (hide/reject need a reason).
|
||||
The PATCH returns the recomputed `{ averageRating, totalReviews }`.
|
||||
- **Patient care records** — `POST patients/{patientId}/care_records` `{ bookingId?, body }` (nurse with a
|
||||
confirmed booking) and `GET patients/{patientId}/care_records?page=&pageSize=` (owning customer / nurse with a
|
||||
confirmed booking / admin) → decrypted `{ id, patientId, bookingId, nurseProfileId, nurseName, body, recordedAt }`
|
||||
newest first. The history is **patient-scoped** — a new nurse taking over reads the whole history.
|
||||
|
||||
## Contracts
|
||||
- **`dev/contracts/domains/reviews-records.md`** — all 8 endpoints, the `moderationStatus`/action enums, tag
|
||||
codes, DTO shapes, and the care-record **access matrix**.
|
||||
- **`dev/contracts/openapi/swagger.v1.json`** refreshed — the 8 review/care-record paths are in the snapshot.
|
||||
|
||||
## What is mocked (and how it becomes real)
|
||||
- **`IReviewModerationService`** (new) — AI review pre-screen. `MockReviewModerationService` is a keyword filter:
|
||||
clean text → a human-review **Flag** by default (so the publish gate holds — reviews land `pending_moderation`);
|
||||
a banned-word substring → **Reject** (auto-hidden). Config `Seams:ReviewModeration:{AutoApproveClean,BannedWords}`.
|
||||
Make it real → a text classifier / LLM endpoint (see reports/mocks-registry.md). `ModerateReviewCommand` keeps
|
||||
decision authority + the human override, so the real impl never touches the handler.
|
||||
|
||||
## Load-bearing rules (don't regress)
|
||||
- **Recompute from source, not delta** — every publish/hide/reject/unpublish re-derives `average_rating`/
|
||||
`total_reviews` over currently-published reviews (exclude-the-changed-review then fold in its new status), in the
|
||||
same transaction, then refreshes the search index.
|
||||
- **Publish gate** — `pending_moderation`/`hidden`/`rejected` are never in a public read and never counted.
|
||||
- **1:1 per completed booking** — `UNIQUE(booking_id)` + handler pre-check; cross-tenant is a 404.
|
||||
- **Low rating (≤ config `min_rating_for_support_alert`, default 2)** raises an internal `low_rating` alert —
|
||||
internal-only, never in a user response (only its id shows on the admin queue).
|
||||
- **Care records are patient-scoped, encrypted at rest, strict access** — a nurse without a confirmed booking for
|
||||
the patient is denied read + write.
|
||||
|
||||
## Deferred (flagged, not built)
|
||||
- Two-way (nurse-reviews-customer) double-blind reviews with timed reveal.
|
||||
- First-class `incidents` entity + ML fraud scoring (manual suspension + `support_alerts` cover it now).
|
||||
- The ticket system, partner centers, and the admin **support-alert worklist console** → **b15** (this phase only
|
||||
*raises* alerts).
|
||||
- `SuspendNurse` / `ResolveSupportAlert` / `FlagConcern` admin actions → b15.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Handoff — after backend phase 15 (Messaging, partner centers & admin backoffice)
|
||||
|
||||
**This is the final backend phase. The backend chain is complete.** Every domain the admin backoffice acts on
|
||||
now exists and is wired together.
|
||||
|
||||
## What is now live (frontend can build against it)
|
||||
|
||||
### Tickets — the post-booking channel (f14 messaging)
|
||||
- `POST /api/v1/tickets` — open a ticket (`category` ∈ `support|coordination|refund|emergency`; optional
|
||||
`bookingId`/`refundId`; body optional). Opener is auto-added as the first participant. Returns
|
||||
`{ ticketId, referenceCode, status, category }`.
|
||||
- `POST /api/v1/tickets/{id}/messages` — post a message. **`isInternal` is staff-only**; a non-staff caller
|
||||
sending `true` → `403`; posting to a closed ticket as non-staff → `403`.
|
||||
- `POST /api/v1/tickets/{id}/participants` (add) / `DELETE …/participants/{userId}` (soft-remove) — staff or
|
||||
ticket owner. A **duplicate add is `409`** (backed by `UNIQUE(ticket_id, user_id)`), never a 500.
|
||||
- `POST /api/v1/tickets/{id}/close` · `/reopen` — participant or staff (idempotent).
|
||||
- `POST /api/v1/tickets/emergency` — assigned nurse (or staff) logs an emergency (+ optional support alert).
|
||||
- `GET /api/v1/tickets` — my tickets (paginated, filter `status`, search `referenceCode`).
|
||||
- `GET /api/v1/tickets/{id}` — **user thread view: internal notes are stripped** in the projection.
|
||||
- `GET /api/v1/admin/tickets` + `GET /api/v1/admin/tickets/{id}` — admin queue + **admin thread view: internal
|
||||
notes included** (`support`/`admin`).
|
||||
|
||||
**The rule f14 must respect:** never build a direct nurse↔customer channel, never surface a phone number, and
|
||||
never rely on the UI to hide internal notes — the backend already strips them from the user payload. The
|
||||
coordination ticket for a booking is auto-created on confirmation (you don't create it).
|
||||
|
||||
### Partner centers + merchant-of-record (f15 admin + partner consoles)
|
||||
- `POST /api/v1/admin/partner-centers` (create, inactive) · `PATCH …/{id}` (update) ·
|
||||
`POST …/{id}/verify` (activate) · `POST …/{id}/sponsor-nurse` · `GET …` (list) · `GET …/{id}` (detail) —
|
||||
`admin`/`super_admin`. **`settlementIbanMasked` (last 4) is the only IBAN ever returned** — never plaintext.
|
||||
`commissionRate ∈ [0,1)`; a merchant-of-record center requires a `settlementIban`.
|
||||
- `GET /api/v1/centers/{id}/dashboard` — the center's own account (or staff): sponsored nurses + booking/invoice
|
||||
counts + masked settlement summary.
|
||||
- `GET /api/v1/internal/bookings/{bookingId}/center` — the issuer/settlement resolver
|
||||
(`platform` | `partner_center`).
|
||||
|
||||
### Admin backoffice (surfaced, not rebuilt)
|
||||
- Support-alert worklist: `GET support_alerts/get_support_alerts`, `POST …/assign_support_alert`,
|
||||
`POST …/resolve_support_alert` (built b1). Audit viewer: `GET audit/get_audit_trail` (built b1). Both
|
||||
`DynamicPermission`. Verification queue / refunds / payout dashboard / moderation queue are their own phases'
|
||||
routes — surface them under the admin console with the right RBAC scope.
|
||||
|
||||
## RBAC the frontend must respect (per route)
|
||||
- Authenticated (own) ticket routes: any logged-in user; participation is enforced server-side.
|
||||
- Admin ticket queue + admin thread: `support`/`admin`. Partner centers: `admin`/`super_admin`. Center
|
||||
dashboard: the center's `adminUserId` (or staff). Support alerts: `support`/`admin`. Audit: `super_admin`/`admin`.
|
||||
- The admin role passes every `DynamicPermission` check; narrower staff scopes (`support`/`finance`/`moderation`)
|
||||
are granted via seeded role claims.
|
||||
|
||||
## What's mocked
|
||||
- **`ILicenseVerificationService`** (eNamad / MoH establishment-permit) — `MockLicenseVerificationService`,
|
||||
manual-approve at MVP (`NeedsManualReview`); `VerifyPartnerCenter` records the human decision. Config
|
||||
`Seams:LicenseVerification:AutoApprove` forces `Valid`. See the mock registry (🟡). There is **no** telephony
|
||||
seam — the emergency call is an out-of-platform `tel:` link by design.
|
||||
|
||||
## Contracts
|
||||
- `dev/contracts/domains/messaging-notifications-admin.md` (this phase). `swagger.v1.json` refreshed (now includes
|
||||
`/tickets`, `/admin/tickets`, `/admin/partner-centers`, `/centers`, `/internal/bookings/{id}/center`).
|
||||
|
||||
## Types / wire notes
|
||||
- Envelope unchanged (camelCase body, snake_case URL tokens where `[action]`-based; the new controllers use
|
||||
explicit REST routes). Pagination `page`/`pageSize` (default 50, max 100). `sentAt`/`closedAt`/`verifiedAt` are
|
||||
UTC ISO-8601; ids are numbers; the settlement IBAN is a masked string (`"••••0001"`).
|
||||
@@ -0,0 +1,50 @@
|
||||
# After backend-phase-2 — auth is live over REST
|
||||
|
||||
The marketplace has its front door: **phone-OTP login, revocable sessions with refresh-token
|
||||
rotation + stolen-token detection, `/me`, and public role selection** — all over REST, wrapping the
|
||||
pre-existing JWE/TOTP/RBAC engine (nothing was rebuilt). Contract:
|
||||
[`dev/contracts/domains/identity-auth.md`](../../../contracts/domains/identity-auth.md); machine
|
||||
schema: `dev/contracts/openapi/swagger.v1.json` (refreshed — 22 paths).
|
||||
|
||||
## What the frontend (f1-b2) can now build
|
||||
- **Login flow:** `POST api/v1/auth/request_otp` → `POST api/v1/auth/verify_otp` (camelCase bodies;
|
||||
exact snake_case paths per the contract — note `request_otp`, **not** `otp/request`).
|
||||
- **Session handling:** store the token pair; `POST api/v1/auth/refresh` rotates it (never reuse an
|
||||
old refresh token — replay = 401 + logout-everywhere); `POST api/v1/auth/logout` (send `{}`).
|
||||
- **`AuthContext` roles + role router:** `GET api/v1/me` returns masked phone, `roles[]`,
|
||||
profile-completion flags (false until b3) and `nurseVerificationStatus` (`not_started` until b6).
|
||||
Fresh users have `roles: []` → route to `POST api/v1/me/select_role` (`customer`/`nurse`, both
|
||||
allowed, 403 for anything else). **Refresh tokens after role selection** — role claims are baked
|
||||
into the access token.
|
||||
- **Errors:** 400 invalid phone/code (safe, non-enumerating message), 401 with the standard
|
||||
envelope (also written by the auth stack itself), 403 admin self-assign, 429 over the OTP/auth
|
||||
per-IP limits. The envelope is unchanged (camelCase body, snake_case URLs).
|
||||
|
||||
## What's mocked
|
||||
- **SMS delivery (`ISmsSender` → 🟡).** The OTP code is written to the server log instead of a SIM.
|
||||
Local testing: call `request_otp`, read the code from the API console log, `verify_otp` with it.
|
||||
|
||||
## Rules baked into the API (don't fight them client-side)
|
||||
- Phone is the only public credential; email is optional and never a login key.
|
||||
- One resend per `auth_otp_resend_seconds` (response says `otpSent: false` + wait time).
|
||||
- After `auth_otp_max_attempts` wrong codes, verification refuses until a fresh OTP is requested.
|
||||
- Logout rotates the security stamp: **all** devices' access tokens die; they recover via refresh.
|
||||
|
||||
## Schema / migration
|
||||
Migration **`20260701222425_IdentitySessionsAndUserExtensions`** applied to the dev DB on top of
|
||||
b1's baseline: `usr.Users` gains `Gender`, `NationalId` (enc, NULL until b6 KYC),
|
||||
`NationalIdVerifiedAt`, `ShahkarVerifiedAt` (auto-reset on phone change), `PhoneHash` (UNIQUE),
|
||||
`PhoneVerifiedAt`, `IsActive`, `DeletedAt` (+ soft-delete filter); new `usr.UserSessions`;
|
||||
`usr.UserRoles` gains `GrantedById`/`GrantedAt`/`RevokedAt` (revoked grants filtered out globally).
|
||||
`PhoneNumber`/`Email`/`NationalId` are now **encrypted at rest** — never query them by equality;
|
||||
use `PhoneHash`. Roles seeded: `customer`, `nurse`, `admin`, `support`, `finance`, `moderation`,
|
||||
`super_admin`. Config keys added: `auth_otp_resend_seconds` (120), `auth_otp_max_attempts` (5),
|
||||
`auth_session_ttl_days` (30).
|
||||
|
||||
## Follow-ups later phases must close
|
||||
- **b3:** profiles/patients/addresses/bank accounts; `gender` + names become settable; the `/me`
|
||||
profile-completion flags start reading real tables.
|
||||
- **b6:** Shahkar + KYC set `NationalId`/`ShahkarVerifiedAt`; `nurseVerificationStatus` becomes real.
|
||||
- **Legacy `UserRefreshTokens`** still backs the gRPC path only; retire it when gRPC moves to
|
||||
sessions (or gRPC is dropped).
|
||||
- **`ISmsSender` → real gateway** (see mocks-registry row).
|
||||
@@ -0,0 +1,57 @@
|
||||
# After backend-phase-3 — profiles, patients & nurse bank accounts are live
|
||||
|
||||
On top of the b2 auth spine, the *people behind the accounts* now exist. A nurse has a seller profile
|
||||
(that b6 will verify and b5 will hang service variants off), a customer has a payer profile and their
|
||||
patients, and a nurse has payout bank accounts with an automated IBAN-ownership inquiry. Contract:
|
||||
[`dev/contracts/domains/identity-profiles.md`](../../../contracts/domains/identity-profiles.md); machine
|
||||
schema: `dev/contracts/openapi/swagger.v1.json` (refreshed).
|
||||
|
||||
## What the frontend (f2-b3) can now build
|
||||
- **Nurse profile bootstrap:** `POST api/v1/nurse_profiles/upsert` (bio, experience, education,
|
||||
`specializationsJson`), `GET api/v1/nurse_profiles/me`, and the pause/resume toggle
|
||||
`POST api/v1/nurse_profiles/set_accepting_bookings`. `isVerified` and the rating/booking aggregates are
|
||||
**read-only** (verification is b6) — render them, never send them.
|
||||
- **Customer profile:** `POST api/v1/customer_profiles/upsert` (emergency contact) + `GET …/me`.
|
||||
- **"Who is care for" (patients):** `create` / `list` (paginated) / `get/{id}` / `update/{id}` /
|
||||
`archive/{id}` under `api/v1/patients`. `gender` (`male`/`female`) is **required**. A customer only ever
|
||||
sees/edits their own patients — someone else's id returns **404**.
|
||||
- **Nurse bank-account settings:** `add` (returns the account with `matchedNationalId` set by the شبا
|
||||
inquiry), `list` (IBAN **masked**, last-4), `set_primary/{id}`, and `verify_ownership/{id}` to re-run
|
||||
the inquiry.
|
||||
|
||||
## Rules baked into the API (don't fight them client-side)
|
||||
- **Refresh after `select_role`.** These endpoints authorize on the **role claim in the access token**;
|
||||
the token minted before role selection lacks it. Login → `select_role` → **refresh** (or re-login) →
|
||||
then call profile/patient/bank endpoints. Otherwise you get `403`.
|
||||
- **Guarded verification** — no field/endpoint sets `isVerified`; a nurse is not bookable until b6.
|
||||
- **Tenancy** — patients and bank accounts are strictly owner-scoped; cross-tenant reads/writes are `404`.
|
||||
- **IBAN is masked on the wire** (last-4 only); the full value is encrypted at rest.
|
||||
- **`matchedNationalId`** is the money-mule-prevention gate for the first payout (enforced in b13). It is
|
||||
`null` until the inquiry runs; `add` runs it automatically. The bank rail is **mocked** at MVP.
|
||||
- Duplicate IBAN → a clean `400` (via `iban_hash` uniqueness), not a server error.
|
||||
|
||||
## What's mocked
|
||||
- **IBAN ownership (`IBankAccountOwnershipVerifier` → 🟡).** Deterministic fake استعلام شبا: every IBAN
|
||||
matches except the configured mismatch IBAN (`Seams:BankOwnership:MismatchIban`, default
|
||||
`IR000000000000000000000000`) which returns `matchedNationalId=false`. No real bank/KYC call.
|
||||
|
||||
## Schema / migration
|
||||
Migration **`20260702042131_IdentityProfilesPatientsBankAccounts`** (applied to the dev DB on startup):
|
||||
`usr.NurseProfiles`, `usr.CustomerProfiles`, `usr.Patients`, `usr.NurseBankAccounts` — with the 1:1
|
||||
uniques, `UNIQUE(iban_hash)`, filtered single-primary index, guarded `is_verified`, encrypted PII columns
|
||||
(`iban`, `account_holder_name`, emergency contacts, `initial_medical_notes`), and soft-delete on
|
||||
`NurseProfiles`. None of the CUT columns (`verification_status`, `response_rate`, … ,
|
||||
`customer_profiles.national_id_verified_at`) exist.
|
||||
|
||||
## Deferred to later phases (do not build against these yet)
|
||||
- **Addresses & nurse service areas → b4** (need province/city/district + geocoder).
|
||||
- **`is_verified` flip → b6** (verification pipeline).
|
||||
- **Payout gating on `matched_national_id` → b13.**
|
||||
- **Aggregate recompute (rating/reviews/completed) → b9/b14.**
|
||||
- **Customer national-ID KYC** — intentionally not collected; never gate browsing/booking on it.
|
||||
|
||||
## Note for the whole backend chain
|
||||
FluentValidation was previously inert (no validators registered). b3 activates it in
|
||||
`AddApplicationServices` — every `AbstractValidator<T>` now runs via `ValidateCommandBehavior` and the
|
||||
`ModelStateValidationAttribute` controller filter. **Consequence:** for route-supplied ids, don't add a
|
||||
body validator rule on that id (e.g. `patients/update/{id}` validates the body, whose `Id` is 0).
|
||||
@@ -0,0 +1,61 @@
|
||||
# After backend-phase-4 — geography, addresses & nurse service areas are live
|
||||
|
||||
The geographic spine the marketplace stands on now exists. There is a real province→city→district
|
||||
hierarchy (tables, not code lists), nurses can declare where they travel, and customers can save
|
||||
encrypted, geocoded service addresses. Contract:
|
||||
[`dev/contracts/domains/geography-addresses.md`](../../../contracts/domains/geography-addresses.md);
|
||||
machine schema: `dev/contracts/openapi/swagger.v1.json` (refreshed).
|
||||
|
||||
## What the frontend (f3-b4) can now build
|
||||
- **Cascading province/city/district dropdowns** — `GET api/v1/geo/provinces`,
|
||||
`…/geo/cities?province_id=`, `…/geo/districts?city_id=` (each active-only, ordered by `sortOrder`), or
|
||||
the whole active tree in one call via `GET api/v1/geo/tree`. **An empty district list is normal** — that
|
||||
city is whole-city-only; let the user pick "whole city".
|
||||
- **Nurse coverage-area editor** — `POST api/v1/nurse_service_areas/add` `{ cityId, districtId? }`
|
||||
(omit `districtId` = whole city), `DELETE …/nurse_service_areas/remove/{id}`,
|
||||
`GET …/nurse_service_areas/list`. Each row carries an `isWholeCity` flag. Requires a nurse profile first
|
||||
(b3 `nurse_profiles/upsert`).
|
||||
- **Address book + map-pin picker** — `POST api/v1/customer_addresses/create`
|
||||
`{ title, cityId, districtId?, addressLine, postalCode?, recipientName?, recipientPhone?, isPrimary? }`,
|
||||
`update/{id}`, `set_primary/{id}`, `delete/{id}`, `list` (primary first). The create/update response and
|
||||
the list return `latitude`/`longitude` for the map pin (**nullable** — render a "pin not set" state when
|
||||
null), and the address is **decrypted for the owner**.
|
||||
|
||||
## Rules baked into the API (don't fight them client-side)
|
||||
- **`districtId = null` means "the entire city"** — a deliberate coverage choice, not "unset". Show it as a
|
||||
first-class option; a whole-city service area matches every district in that city when search lands (b7).
|
||||
- **Duplicate service area → `409`** (including a duplicate whole-city row), never a `500`. Surface it as
|
||||
"you already cover this".
|
||||
- **Single primary address** — the first address is primary automatically; setting another primary clears
|
||||
the previous one. There is always exactly one.
|
||||
- **`is_active` hides, never deletes** — a deactivated region simply drops out of the dropdowns.
|
||||
- **Address PII is encrypted at rest** and only ever returned to the owning customer. Coordinates and the
|
||||
`title` label are not PII.
|
||||
- **Tenancy** — service areas and addresses are strictly owner-scoped; another owner's id returns `404`.
|
||||
- **Refresh after `select_role`** still applies (nurse/customer scoping reads the role claim in the token).
|
||||
- **Routes are action-style** (`admin_geo/create_city`, `nurse_service_areas/add`,
|
||||
`customer_addresses/create`, …) — see the contract for the full list.
|
||||
|
||||
## What's mocked
|
||||
- **Geocoding (`IGeocoder` → 🟡).** `MockGeocoder` returns deterministic coordinates around the city
|
||||
centroid with no network call. A config switch (`Seams:Geocoding:ReturnNullCoordinates`) or a `NO_GEO`
|
||||
marker in the address text forces the null-coordinate path so the "saved without a map pin" UI state is
|
||||
testable. Real Neshan/Google geocoding is a drop-in registration swap (see mocks-registry).
|
||||
|
||||
## Schema / migration
|
||||
Migration **`20260702093332_GeographyAddressesServiceAreas`** (applies on startup): `geo.Provinces`,
|
||||
`geo.Cities`, `geo.Districts`, `geo.NurseServiceAreas`, `usr.CustomerAddresses`. Whole-city uniqueness is a
|
||||
**filtered-index pair** (`UNIQUE(nurse_id, city_id) WHERE district_id IS NULL AND deleted_at IS NULL` +
|
||||
`UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL AND deleted_at IS NULL`); addresses
|
||||
carry a filtered `UNIQUE(customer_id) WHERE is_primary=1 AND deleted_at IS NULL`; coordinates are
|
||||
`decimal(9,6)`; address PII columns are encrypted. Seed: 31 provinces (Tehran id `1`), capital cities
|
||||
(Tehran city id `101`), Tehran districts `1001…1022`.
|
||||
|
||||
## Deferred to later phases (do not build against these yet)
|
||||
- **`nurse_search_index` fan-out** on service-area add/remove → **b7** (the add/remove handlers are the
|
||||
clean trigger point).
|
||||
- **GPS-radius / "nurses near me" map discovery** → not planned; coverage is named districts, full stop.
|
||||
- **EVV distance check** that *consumes* the address `latitude`/`longitude` → **b9** (this phase only
|
||||
*produces* the coordinates).
|
||||
- **Region bulk-import feed** (`IGeoDataImporter`) → deferred; the idempotent seed + admin CRUD is enough
|
||||
for MVP.
|
||||
@@ -0,0 +1,82 @@
|
||||
# After backend-phase-5 — the service catalog & nurse pricing variants are live
|
||||
|
||||
The two-tier service model the whole marketplace is priced and searched on now exists. Admins curate a
|
||||
catalog skeleton (categories → configurable option groups → option values) that ships as **data, not
|
||||
migrations**, and each nurse turns that skeleton into **variants** — the atomic bookable unit: a category +
|
||||
one chosen value per dimension, at the nurse's own price and price unit. Contract:
|
||||
[`dev/contracts/domains/catalog.md`](../../../contracts/domains/catalog.md); machine schema:
|
||||
`dev/contracts/openapi/swagger.v1.json` (refreshed for b5).
|
||||
|
||||
## What the frontend (f4-b5) can now build
|
||||
- **Category grid / browse** — `GET api/v1/catalog/categories` (active, ordered, **paginated**, cached).
|
||||
Five categories are seeded (Elderly, Post-Surgery, Infant, Chronic, Companionship) with `nameFa`+`nameEn`.
|
||||
- **The nurse service-builder** — for a chosen category, `GET api/v1/catalog/option_groups?category_id=`
|
||||
returns the **applicable** dimensions (the category's own groups **plus** every cross-category group),
|
||||
each with `isRequired` and its active values. The nurse then `POST api/v1/nurse_variants/create`
|
||||
`{ serviceCategoryId, options: [{ optionGroupId, optionValueId }], price, priceUnit, sessionCount?, displayName? }`.
|
||||
Manage with `update/{id}`, `set_active/{id}`, `list`, `get/{id}`. Requires a nurse profile first
|
||||
(b3 `nurse_profiles/upsert`).
|
||||
- **Admin catalog console** — `admin_catalog/create_category|update_category/{id}|set_category_active/{id}`,
|
||||
`create_option_group|update_option_group/{id}`, `create_option_value|update_option_value/{id}` (admin
|
||||
token / dynamic-permission).
|
||||
- **Public variant view** — `GET api/v1/nurse_variants/get/{id}` returns the public (active-only) projection
|
||||
for anyone, backing a nurse-profile offerings list.
|
||||
|
||||
## Rules baked into the API (don't fight them client-side)
|
||||
- **The bookable unit is the variant, not the nurse.** Price/book against a specific variant.
|
||||
- **`price` is a string of IRR-Rial digits** (e.g. `"8000000"`) — integer money, no floats, no Toman. The
|
||||
**total is `price` + `priceUnit` + `sessionCount`**; never compute it from `price` alone. Parse with a
|
||||
BigInt-safe helper, format for display, map `priceUnit` to an i18n label (never off the code).
|
||||
- **A NULL-category option group is cross-category** — it applies to every category. Render the cross-
|
||||
category groups in the builder for *every* category; the required ones must be answered.
|
||||
- **All required dimensions must be answered**, **one value per dimension**, a value must belong to its
|
||||
group. A missing required dimension → **400** (names it); a duplicate identical listing → **409**; surface
|
||||
both cleanly.
|
||||
- **`displayName` auto-generates** (category + chosen value labels) — show it, let the nurse override it.
|
||||
- **Deactivate, never delete.** A deactivated variant stays in the nurse's own `list` (flagged
|
||||
`isActive:false`) but is unbookable and 404s on the public `get`.
|
||||
- **Every catalog row has `nameFa` (primary) + `nameEn`** — pick by locale, never label off a code.
|
||||
- **Tenancy** — variants are strictly owner-scoped; another nurse's id on write → **404** (existence not
|
||||
leaked). Catalog skeleton writes are **admin-only**.
|
||||
- **Refresh after `select_role`** still applies (nurse scoping reads the role claim in the token).
|
||||
- **Routes are action-style** (`admin_catalog/create_category`, `nurse_variants/create`, …), POST for
|
||||
mutations, camelCase bodies — see the contract for the full list.
|
||||
|
||||
## What b7 (search & matching) must read from the variant
|
||||
The variant is a clean, projection-friendly source. b7 owns the denormalized `nurse_search_index` and the
|
||||
`INurseSearch` seam (**not built here**). Its fan-out reads, per active variant: `service_category_id`,
|
||||
`price`, `price_unit`, `is_active`, `nurse_id` — joined to the nurse's `nurse_service_areas` (b4) to emit one
|
||||
index row per covered city/district, and to `nurse_profiles` for `is_verified`/accepting/gender/rating. The
|
||||
**single write trigger points** to maintain that index are `CreateVariantCommand`, `SetVariantActiveCommand`
|
||||
(and later option/price edits) — hook the index maintenance there.
|
||||
|
||||
## What b8 (booking) consumes
|
||||
`IVariantSnapshotSerializer` (`Application/Contracts/Common`, single real impl in `Application/Common`) is
|
||||
ready: `string Serialize(VariantSnapshot)` emits the canonical `variant_snapshot_json` (category id + labels,
|
||||
each `(group label, value label)`, `price` as a digit string, `price_unit`, `session_count`, `display_name`,
|
||||
`variant_id`). b8 owns the `booking_requests.variant_snapshot_json` **column** and calls the serializer at
|
||||
booking time so later variant edits/deactivation never mutate past bookings. The snapshot column is **not**
|
||||
added here.
|
||||
|
||||
## Schema / migration
|
||||
Migration **`20260702132758_ServiceCatalogAndNurseVariants`** (applies on startup), new **`catalog`** schema:
|
||||
`ServiceCategories`, `ServiceOptionGroups` (nullable `ServiceCategoryId` = cross-category),
|
||||
`ServiceOptionValues`, `NurseServiceVariants` (`Price` **BIGINT**, `PriceUnit` code, `SessionCount?`,
|
||||
`DisplayName`, `OptionSetHash`), `NurseServiceVariantOptions`. Constraints: `UNIQUE(VariantId, OptionGroupId)`
|
||||
(one value per dimension); filtered `UNIQUE(NurseId, ServiceCategoryId, OptionSetHash) WHERE DeletedAt IS
|
||||
NULL` (duplicate-listing backstop); soft-delete query filters. Seed: five categories (`nameFa`+`nameEn`),
|
||||
ids 1–5. Option groups/values are admin-authored data (not seeded).
|
||||
|
||||
## Deferred to later phases (do not build against these yet)
|
||||
- **`nurse_search_index`, `INurseSearch`, the search query & index fan-out** → **b7** (variant writes are the
|
||||
clean trigger point; the variant shape is projection-ready).
|
||||
- **`variant_snapshot_json` persistence** → **b8** (the serializer is shipped and unit-tested here).
|
||||
- **`nurse_availability_slots` / `nurse_availability_exceptions`** → **deferred** (soft scheduling guidance,
|
||||
not on the money/safety path) — not built.
|
||||
- **Holiday/surge pricing, a Companionship *tier* pricing model, tiered per-category commission** →
|
||||
**deferred**. Companionship ships only as a seeded category (data), not a special pricing path.
|
||||
|
||||
## What's mocked
|
||||
**Nothing.** Catalog and variant data are fully owned by Balinyaar's DB — this phase introduces **no**
|
||||
cross-cutting seam and adds **no** row to `reports/mocks-registry.md`. It reuses `ICacheService` (b0) for the
|
||||
public catalog reads with invalidate-on-mutation.
|
||||
@@ -0,0 +1,90 @@
|
||||
# After backend-phase-6 — nurse verification & credentials are live (vendors mocked)
|
||||
|
||||
The trust engine now exists. A nurse opens a **verification checklist**, clears automated checks
|
||||
(identity-KYC, Shahkar, bank-ownership) and uploads documents for manual credential steps; an admin reviews,
|
||||
decides, and — when every required step passes — the platform flips `nurse_profiles.is_verified` **inside a
|
||||
single transaction**, making the nurse bookable. Credential **numbers are encrypted and never leave the API**;
|
||||
the public **trust badge** exposes only the credential **types** a nurse holds. Contract:
|
||||
[`dev/contracts/domains/verification.md`](../../../contracts/domains/verification.md); machine schema:
|
||||
`dev/contracts/openapi/swagger.v1.json` (refreshed for b6 — all 15 endpoints). **All vendor/money calls are
|
||||
mocked** behind deterministic DI seams.
|
||||
|
||||
## What the frontend (f5-b6) can now build
|
||||
- **The nurse verification checklist screen** — `GET api/v1/nurse_verification` returns `status`,
|
||||
`isBookable`, `blockingSteps[]`, and `steps[]` (each with `code`, `displayName`, `status`, `isAutomated`,
|
||||
`expiresAt?`, `failureReason?`). `POST api/v1/nurse_verification/submit` opens/seeds it (idempotent).
|
||||
Requires a nurse profile first (b3 `nurse_profiles/upsert`). Render the checklist off `steps`, gate the
|
||||
"you're bookable" state on `isBookable`, and surface `blockingSteps` as the to-do list.
|
||||
- **Identity submit (automated)** — `POST api/v1/nurse_verification/steps/identity_kyc/run`
|
||||
`{ nationalId, livenessPayload? }` → `RunStepResult` (`stepStatus`, `failureReason?`). A pass populates the
|
||||
nurse's national id; drive the next steps off it.
|
||||
- **Shahkar & bank runs (automated)** — `POST .../steps/shahkar_match/run` (no body; **needs KYC passed**)
|
||||
and `POST .../steps/bank_account_verification/run` (no body; **needs KYC + a primary bank account from b3**).
|
||||
Both return `RunStepResult`. Shared-SIM comes back as a clean `failed` + reason (not an error) — show it.
|
||||
- **Document upload for manual steps** — two-step: `POST .../steps/{stepId}/upload_url`
|
||||
`{ contentType, fileName? }` → `{ objectStorageKey, uploadUrl }`; PUT the file to `uploadUrl`; then
|
||||
`POST .../steps/{stepId}/documents` `{ objectStorageKey, integrityHash, contentType, fileSizeBytes, originalFileName? }`
|
||||
→ the step moves to `in_review`. Manual steps only (an automated step 400s the upload).
|
||||
- **The credentials / under-review / rejected states** — render each `step.status`
|
||||
(`not_started`/`pending`/`in_review`/`passed`/`failed`/`expired`) with the right affordance; `in_review`
|
||||
= "with our team", `failed`/`expired` = show `failureReason` + a redo path, `passed` = done.
|
||||
- **The public trust badge (f6)** — `GET api/v1/nurses/{nurseId}/trust_badge` (**anonymous**) → `isVerified`,
|
||||
`approvedAt?`, `credentialTypes[]`. Types only — never a credential number. Back the nurse-profile trust
|
||||
chip / verified marker with it.
|
||||
|
||||
## Live endpoints (all under `api/v1`, action-style, camelCase bodies)
|
||||
Nurse (`[Authorize]`, nurse-scoped): `nurse_verification/submit`, `GET nurse_verification`,
|
||||
`nurse_verification/steps/{stepId}/upload_url`, `nurse_verification/steps/{stepId}/documents`,
|
||||
`nurse_verification/steps/identity_kyc/run`, `nurse_verification/steps/shahkar_match/run`,
|
||||
`nurse_verification/steps/bank_account_verification/run`.
|
||||
Admin step-types (dynamic-permission): `GET admin_verification_step_types`, `POST admin_verification_step_types`,
|
||||
`DELETE admin_verification_step_types/{id}`.
|
||||
Admin review (dynamic-permission): `GET admin_verifications`, `GET admin_verifications/{nurseVerificationId}`,
|
||||
`admin_verifications/steps/{stepId}/decide`, `admin_verifications/{nurseVerificationId}/suspend`,
|
||||
`admin_verifications/scan_expiring`.
|
||||
Public (`[AllowAnonymous]`): `GET nurses/{nurseId}/trust_badge`.
|
||||
|
||||
## Rules baked into the API (don't fight them client-side)
|
||||
- **`isBookable` / `isVerified` are read-only, server-derived.** Never infer verification client-side —
|
||||
read `VerificationStatusDto.isBookable` (nurse view) or `TrustBadgeDto.isVerified` (public view). The
|
||||
aggregate `nurse_verifications.status` is the single source of truth; `is_verified` is flipped only inside
|
||||
the finalize transaction (and reversed on suspend/expiry).
|
||||
- **Credential numbers never cross the wire.** `NurseCredentialDto` / `TrustBadgeDto` carry **types** and
|
||||
metadata only. Don't build a UI that expects to display a number.
|
||||
- **Automated vs manual steps drive different UI.** `step.isAutomated=true` → a `/run` button; `false` → the
|
||||
upload flow (`upload_url` → PUT → `documents`). The list tells you which.
|
||||
- **Ordering / prerequisites** — Shahkar needs identity-KYC passed; bank verification needs KYC **and** a
|
||||
primary bank account (b3). Calling out of order → **400**; disable/guide accordingly.
|
||||
- **Shared-SIM and vendor fails are `200` with `stepStatus:"failed"` + `failureReason`**, not HTTP errors —
|
||||
surface the reason, don't treat as a crash. (Shared-SIM also raises an internal support alert.)
|
||||
- **Documents come back as short-lived signed URLs** (`VerificationDocumentDto.url`) — fetch/display fresh;
|
||||
don't cache the URL.
|
||||
- **Refresh after `select_role`** still applies (nurse scoping reads the role claim in the token).
|
||||
- **Routes are action-style POST** (`nurse_verification/submit`, `admin_verifications/steps/{id}/decide`, …),
|
||||
ids from the route, camelCase bodies — see the contract for the full list.
|
||||
|
||||
## Schema / migration
|
||||
New **`verif`** schema, one additive migration, **5 tables**: `nurse_verifications` (header; `status` = single
|
||||
source of truth), `verification_step_types` (seeded catalog — six stable codes), `verification_steps`
|
||||
(one per required step-type; snapshots `is_automated`), `verification_documents` (**metadata only** — bytes
|
||||
never in the DB), `nurse_credentials` (`credential_number` **encrypted**, never serialized). The only derived
|
||||
boolean is `nurse_profiles.is_verified`, flipped/reversed transactionally.
|
||||
|
||||
## What's mocked
|
||||
All vendor/money calls (deterministic seams; test values in the contract):
|
||||
- `IShahkarVerifier` → `MockShahkarVerifier` (pass unless shared-SIM `09120000000` / mismatch id `1111111111`).
|
||||
- `IIdentityKycProvider` → `MockIdentityKycProvider` (passes any well-formed 10-digit id except `0000000000`).
|
||||
- `ICredentialVerifier` → `MockCredentialVerifier` (manual-admin default; `verification_method=manual`).
|
||||
- **Reused:** `IBankAccountOwnershipVerifier` (b3; mismatch IBAN `IR000000000000000000000000`),
|
||||
`IObjectStorage` (b0; local-disk signed URLs), `IFieldEncryptor` (b0; encrypts `credential_number`).
|
||||
|
||||
See [`reports/mocks-registry.md`](../../reports/mocks-registry.md) for the make-it-real steps.
|
||||
|
||||
## Deferred to later phases (do not build against these yet)
|
||||
- **Scheduled expiry cron** (`CredentialExpiryScannerJob`) → the scan logic ships as
|
||||
`ScanExpiringCredentialsCommand`; today only the admin `scan_expiring` endpoint triggers it.
|
||||
- **Automated MoH/INO license lookup** (`ICredentialVerifier`, `verification_method=api`) → deferred.
|
||||
- **`fraud_flags` / ML fraud scoring** → deferred.
|
||||
- **Professional-liability-insurance step** → addable later as a step-type row (no schema change).
|
||||
- **b7 (search & matching)** reads `nurse_profiles.is_verified` for `nurse_search_index.is_searchable` — b6
|
||||
owns the flip, b7 owns the index (**not built here**).
|
||||
@@ -0,0 +1,50 @@
|
||||
# Handoff — after backend-phase-7 (Search & matching)
|
||||
|
||||
**Search is live.** Verified nurses are now discoverable through one public endpoint backed by a
|
||||
denormalized, maintained-on-write index. This unblocks **frontend f6-b7**: search + filters (C1), results
|
||||
list (C2), and the nurse profile (C3) can be built against a real API.
|
||||
|
||||
## What the frontend can now build (f6-b7)
|
||||
|
||||
- **Search + filters (C1)** → `GET api/v1/search/nurses` (public, no auth). Query params (snake_case):
|
||||
`service_category_id` (**required**), `city_id` (**required**), `district_id` (optional),
|
||||
`nurse_gender` (`male`/`female`, optional), `min_price`/`max_price` (IRR long, optional),
|
||||
`price_unit` (optional), `page`/`page_size` (default 1 / 50, max 100).
|
||||
- **Results list (C2)** → the `data` is a `PagedResult<NurseSearchResultDto>` (`items`, `total`, `page`,
|
||||
`pageSize`). Each item: `variantId`, `nurseId`, `serviceCategoryId`, `price` (IRR **digit string**),
|
||||
`priceUnit`, `nurseGender`, `averageRating`, `totalReviews`, `totalCompletedBookings`, `cityId`,
|
||||
`districtId` (null = whole city).
|
||||
- **Nurse profile (C3)** → reuse the b6 public trust badge (`GET api/v1/nurses/{id}/trust_badge`) and the b5
|
||||
public variant read (`GET api/v1/nurse_variants/get/{id}`) already live; b7 adds no new profile route.
|
||||
|
||||
Categories/cities/districts for the filter dropdowns come from the **b4** geo lookups (`geo/*`) and **b5**
|
||||
catalog (`catalog/*`) — unchanged.
|
||||
|
||||
## Rules the UI must respect
|
||||
|
||||
- **Only searchable nurses come back.** The backend returns a nurse **only** when verified + not suspended +
|
||||
accepting + variant active. No client-side re-check needed; an empty page is a valid result.
|
||||
- **`districtId = null` = whole city.** A city-only search returns both whole-city and district rows; a
|
||||
district search returns that district's rows **plus** whole-city rows. Show whole-city hits as covering the
|
||||
district the user searched.
|
||||
- **Same-gender filter is first-class.** Surface `nurse_gender` prominently; never default it silently.
|
||||
(Carrying the chosen gender into the booking request — `required_caregiver_gender` — is **b8**, not here.)
|
||||
- **`price` is an IRR digit string** — render with a formatter; never parse to a float. Combine with
|
||||
`priceUnit` (+ `sessionCount` from the variant, when booking) for the engagement total.
|
||||
- **Sort is rating-desc only** (MVP). No client sort options beyond what the API returns.
|
||||
|
||||
## Contracts
|
||||
|
||||
- New: [`dev/contracts/domains/search.md`](../../contracts/domains/search.md).
|
||||
- `swagger.v1.json` refreshed (adds `search/nurses` + `admin_search/rebuild_index` + the DTOs).
|
||||
|
||||
## Backend notes (not frontend-facing)
|
||||
|
||||
- The index is a **read-only projection** maintained inline inside each source write's transaction
|
||||
(`ISearchIndexMaintainer`, wired into the b3/b4/b5/b6 handlers). The read seam is **`INurseSearch`**
|
||||
(SQL now; Elasticsearch is a config-selected drop-in later, `Search:Backend`).
|
||||
- Admin `POST api/v1/admin_search/rebuild_index` (dynamic-permission) does an idempotent full rebuild — the
|
||||
reconciliation path; incremental maintenance and rebuild converge.
|
||||
- **Deferred to b8:** `booking_requests.required_caregiver_gender` capture (carry the chosen gender into the
|
||||
booking). **Deferred:** Elasticsearch backend + feeder, availability hard-filter, map/radius discovery,
|
||||
ranking beyond rating.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Handoff — after backend-phase-8 (Booking requests · pre-payment intent)
|
||||
|
||||
**The booking-request lifecycle is live end-to-end.** A customer can request a nurse; the nurse accepts
|
||||
(opening a config-driven 30-minute payment window) or rejects; both sides read their role-scoped inbox and a
|
||||
single request; unanswered/unpaid requests auto-expire on a recurring sweep (also an admin manual trigger).
|
||||
**No money and no `bookings` row exist yet** — that conversion is b9/b10.
|
||||
|
||||
## What b9 must consume (the next backend phase)
|
||||
|
||||
- **Convert an `accepted_awaiting_payment` request → a `bookings` row** once payment is captured (b10), then
|
||||
set the request to `converted` **through the `BookingRequestTransitions` guard** (`request.MarkConverted()`
|
||||
already exists and is the only sanctioned path; b8 never writes this edge itself). The request↔booking link
|
||||
is 1:1 and b9-owned.
|
||||
- **b8 deliberately exposes only `customer_notes` (stage 1).** The full **encrypted** clinical/care
|
||||
instructions are b9's **stage 2** (`booking_care_instructions`, readable only post-confirmation by the
|
||||
assigned nurse + admin). Do **not** add an encrypted clinical field to `booking_requests`.
|
||||
- **The money split, snapshots, sessions, EVV, dispute window** are all b9/b10 — b8 persists **no**
|
||||
`variant_snapshot_json`/`address_snapshot_json`, no price, no ledger entry. The b5 `IVariantSnapshotSerializer`
|
||||
is still the tool for the booking snapshot at conversion time.
|
||||
- **Reuse the forward-only status-machine pattern** for the `bookings` state machine (see CONVENTIONS §6 —
|
||||
"Forward-only status machine"). Same shape: const codes + a static `CanTransition` edge table + entity-owned
|
||||
transitions + handler pre-check → 409.
|
||||
|
||||
## What f7-b8 can now build (frontend)
|
||||
|
||||
All routes are **action-style** (`[controller]/[action]`, snake_case) with the standard
|
||||
`{ succeeded, statusCode, data }` envelope. Full shapes in
|
||||
[`dev/contracts/domains/booking-requests.md`](../../contracts/domains/booking-requests.md); types come from the
|
||||
refreshed `swagger.v1.json` — **do not guess**.
|
||||
|
||||
- **Request form (C4)** → `POST api/v1/booking_requests/create` (customer). Body: `nurseId`, `variantId`,
|
||||
`patientId`, `customerAddressId`, `requestedDate` (`YYYY-MM-DD`), `requestedTimeStart`/`requestedTimeEnd`
|
||||
(`HH:mm:ss`), `requiredCaregiverGender` (`male`|`female`|`any`, **required**), `customerNotes` (≤ 1000, the
|
||||
**only** clinical text the nurse sees). Inputs come from search (nurse+variant), patients (b3), addresses (b4).
|
||||
- **Awaiting-acceptance / status tracker (C5)** → `GET api/v1/booking_requests/get/{id}` + the customer inbox
|
||||
`GET api/v1/booking_requests/list?role=customer`. Show two countdowns: to `nurseResponseDeadlineAt` (pending)
|
||||
and to `paymentDeadlineAt` (accepted — the 30-min window). Customer can `POST …/cancel/{id}` while pending or
|
||||
accepted-awaiting-payment.
|
||||
- **Nurse incoming-requests inbox** → `GET api/v1/booking_requests/list?role=nurse` (+ `status=` filter) with
|
||||
`POST …/accept/{id}` and `POST …/reject/{id}` (reason required). The nurse row shows `counterpartyName` =
|
||||
patient name + `customerNotes` only, plus the response countdown — **never** a full address or any clinical
|
||||
field beyond `customerNotes`.
|
||||
|
||||
## Rules the UI must respect
|
||||
|
||||
- **Same-gender is first-class and required.** Send `requiredCaregiverGender` explicitly (never default it);
|
||||
`male`/`female` must match the nurse's gender or the create is a `400`. `any` matches either.
|
||||
- **Deadlines are server-frozen absolute UTC timestamps.** Render countdowns from them; never recompute a
|
||||
deadline client-side. `paymentDeadlineAt` is null until the nurse accepts.
|
||||
- **Two-stage disclosure.** The nurse detail view returns a **masked** address (city/district only,
|
||||
`addressLine`/`postalCode`/recipient are null); the customer/admin view returns the full address. Do not
|
||||
expect (or request) clinical instructions here — they don't exist until b9.
|
||||
- **Status drives the UI.** Terminal states (`converted`/`rejected_by_nurse`/`expired_no_response`/
|
||||
`payment_deadline_expired`/`cancelled_by_customer`) allow no further actions; a stale action returns `409`.
|
||||
- **`get/{id}` is party-scoped.** A non-party caller gets `404` (existence not leaked).
|
||||
|
||||
## Contracts
|
||||
|
||||
- New: [`dev/contracts/domains/booking-requests.md`](../../contracts/domains/booking-requests.md).
|
||||
- `swagger.v1.json` refreshed (adds `booking_requests/{create,accept,reject,cancel,list,get}` +
|
||||
`admin_booking_requests/expire` + `BookingRequestDto`/`BookingRequestListItemDto`/`ExpireBookingRequestsResult`).
|
||||
|
||||
## Nothing new is mocked
|
||||
|
||||
b8 introduces **no** external seam and adds **no** new `mocks-registry.md` row. It reuses `IPlatformConfig`,
|
||||
`INotificationDispatcher`, `IJobScheduler`/`BackgroundService`, `IDateTimeProvider`, `ICurrentUser`. The
|
||||
expiry sweep is an internal hosted service (the registry's `IJobScheduler` row was updated to note the second
|
||||
job), not an external integration.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Handoff — after backend phase 9 (Bookings, sessions, care instructions & EVV)
|
||||
|
||||
**The booking engine is live.** A paid request now becomes a real engagement: `bookings` + N `booking_sessions`,
|
||||
encrypted `booking_care_instructions`, per-session `visit_verifications` (EVV), and the dispute-window gate.
|
||||
Capture is **mocked** behind `IPaymentCaptureSimulator`; the real conversion trigger arrives with **b10 payments**.
|
||||
|
||||
## What f8-b9 can now build
|
||||
- **Booking detail & "My bookings"** — `GET bookings/get/{id}`, `GET bookings/list?role=customer|nurse|all&status=`.
|
||||
Header + money summary (three amounts as **digit-strings**, `platformFeeRate`, `pspFeeAmount`) + sessions +
|
||||
status timeline. The **nurse view omits `addressSnapshotJson`**.
|
||||
- **Nurse EVV** — `GET booking_sessions/today?date=` (today's visits + CTA state), `POST booking_sessions/check_in/{id}`
|
||||
and `POST booking_sessions/check_out/{id}` (send `{latitude, longitude}`; both nullable if GPS denied),
|
||||
`GET booking_sessions/evv/{id}` (raw GPS, owning nurse + admin only).
|
||||
- **Care instructions** — `POST bookings/submit_care_instructions/{id}` (customer/admin, booking must be confirmed+),
|
||||
`GET bookings/care_instructions/{id}` (**assigned nurse + admin only, post-confirmation** — the two-stage
|
||||
disclosure boundary; everyone else gets 404).
|
||||
- **Status timeline & cancel** — `POST bookings/cancel/{id}` (customer/nurse/admin; returns the policy snapshot +
|
||||
refundable amount), `POST booking_sessions/cancel/{id}` (single un-started session).
|
||||
- **Admin** — `GET admin_evv/list?type=mismatch|no_show`, `POST admin_evv/detect_no_shows`,
|
||||
`POST admin_cancellation_policies/upsert` + `GET admin_cancellation_policies/list`, `POST bookings/transition/{id}`.
|
||||
|
||||
## Live endpoints / contracts
|
||||
- Contract: [`dev/contracts/domains/bookings-evv.md`](../../contracts/domains/bookings-evv.md); machine schema in
|
||||
the refreshed [`swagger.v1.json`](../../contracts/openapi/swagger.v1.json).
|
||||
- Enums: `BookingStatus`, `BookingSessionStatus`, `VisitVerificationStatus`, `CancellationActor`.
|
||||
|
||||
## Load-bearing rules the client must honour
|
||||
- **Money is IRR integer, on the wire as a digit-string.** `grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`.
|
||||
Never coerce to a JS number for math.
|
||||
- **Payout eligibility is derived from `disputeWindowEndsAt` / per-session `payoutEligibleAt`, not from `completed`.**
|
||||
- **EVV address mismatch is advisory** — a flagged check-in still succeeds; surface it, don't block.
|
||||
- **Care-instruction clinical fields never appear in a list or the booking detail** — only via the gated read.
|
||||
|
||||
## Mocked here → make real later
|
||||
- **`IPaymentCaptureSimulator`** (🟡) — the temporary conversion trigger. In **b10**, the real card capture calls
|
||||
`ConvertRequestToBooking` directly on a `payment_transactions.succeeded`; this seam is then removed. A config
|
||||
switch (`Seams:PaymentCapture:ForceFailure`) exercises the "capture failed → no booking" path today.
|
||||
- The **no-show cron** is DEFERRED — `POST admin_evv/detect_no_shows` runs the same idempotent command a scheduler
|
||||
will later call (config `no_show_scan_cadence_hours`).
|
||||
|
||||
## Consumed by later backend phases
|
||||
- **b10** — real capture posts the ledger and calls the conversion; sets the real `psp_fee_amount`.
|
||||
- **b11** — refund execution consumes the frozen `cancellationPolicyCode` + `refundableAmountIrr` (no ledger posted here).
|
||||
- **b13** — payout batching consumes `disputeWindowEndsAt` / `payoutEligibleAt`.
|
||||
- **b14** — reviews on a completed booking.
|
||||
- **b15** — `partner_centers` wires the nullable `partner_center_id`.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Handoff — after refinement-phase-0 (Local end-to-end bring-up)
|
||||
|
||||
**Date:** 2026-07-12 · **Track:** integration (both projects) · **Unlocks:** every other refinement phase.
|
||||
|
||||
## What the frontend can now do
|
||||
- **Actually call the backend cross-origin.** The API now has a CORS policy (`BalinyaarWebClient`) allowing
|
||||
`http://localhost:3000` (config `Cors:AllowedOrigins`, default in Dev) with the four headers the client
|
||||
sends (`Authorization`, `Content-Type`, `Accept-Language`, `Idempotency-Key`). A browser at `:3000` calling
|
||||
`https://localhost:5002` is no longer blocked by same-origin policy.
|
||||
- **Stand the stack up in ~5 minutes** via `dev/post-phase/refinement/RUNBOOK.md` (dev-cert trust → local SQL
|
||||
Server via `docker compose` → connection string via `dotnet user-secrets` → `dotnet run` + `npm run dev`).
|
||||
- **Complete a real login end-to-end.** Request an OTP, read the 6-digit code from the **server console**
|
||||
(`MOCK SMS — OTP code … for phone ending in …`) or from the Development-only helper
|
||||
`GET /api/v1/dev/last_otp/{phone}`, verify → real tokens + `/me`.
|
||||
|
||||
## What did NOT change (important)
|
||||
- **No `USE_*_MOCK` flag was flipped.** `auth` is still the only real client domain; the home/search/bookings
|
||||
are still in-browser mocks until [Refinement Phase 4](../../../post-phase/refinement/refinement-phase-4-frontend-de-mock.md).
|
||||
- **No client app code changed** — only verified `client/.env.development` (already `https://localhost:5002`)
|
||||
and added the runbook. If you run the API elsewhere, override with `client/.env.local`.
|
||||
- **Auth crypto, the money path, and the `ApiResult` envelope are untouched.**
|
||||
|
||||
## New endpoint (Development only — not a contract)
|
||||
- `GET /api/v1/dev/last_otp/{phone}` → `{ data: { phone, code } }` when a code was issued, else 404. **404 in
|
||||
every non-Development environment** (the capture isn't even wired there). For manual/e2e login only;
|
||||
superseded by real SMS in [Refinement Phase 8](../../../post-phase/refinement/refinement-phase-8-external-rails.md).
|
||||
Do not build client features on it.
|
||||
|
||||
## Gotchas
|
||||
- The API binds **HTTP/2** (`Kestrel:Protocols = Http2`, for gRPC). Browsers negotiate h2-over-TLS via ALPN
|
||||
automatically, so `fetch` works; `curl` needs `--http2`.
|
||||
- The dev HTTPS cert **must be trusted** (`dotnet dev-certs https --trust`) or `fetch` to `:5002` fails with an
|
||||
opaque network error.
|
||||
- Committed `appsettings*.json` connection strings are **placeholders** — the API will not boot until you set
|
||||
`ConnectionStrings:SqlServer` via user-secrets (or the `ConnectionStrings__SqlServer` env var).
|
||||
@@ -0,0 +1,46 @@
|
||||
# Handoff — after refinement-phase-1 (Database: local-dev story, demo seed & migration hygiene)
|
||||
|
||||
**Date:** 2026-07-13 · **Track:** backend (+ DB) · **Unlocks:** real search/discovery/booking data for
|
||||
[Refinement Phase 4](../../../post-phase/refinement/refinement-phase-4-frontend-de-mock.md).
|
||||
|
||||
## What the frontend can now do
|
||||
- **A fresh Development DB is a populated marketplace, not an empty shell.** On boot (Development only) the API
|
||||
now seeds a coherent demo world *after* the reference/lookup seeds. Every real-path discovery/search/booking
|
||||
screen has data on the real path — no frontend change required to see it.
|
||||
- **Log in as real demo accounts** (phone-OTP flow; read the code from the server console or
|
||||
`GET /api/v1/dev/last_otp/{phone}`). The accounts already hold the right role, so the role router lands them
|
||||
on the correct shell:
|
||||
|
||||
| Phone | Role | Who | State |
|
||||
| --- | --- | --- | --- |
|
||||
| `09120000001` | nurse | زهرا عزیزی (female) | **verified**, accepting, 3 variants, whole-city + 2 districts |
|
||||
| `09120000002` | nurse | علی کریمی (male) | **verified**, accepting, 2 variants, 3 districts |
|
||||
| `09120000003` | nurse | مریم احمدی (female) | **unverified** (pending) — never surfaces in search |
|
||||
| `09120000010` | customer | سارا محمدی (female) | 2 patients, 1 Tehran address (coords) |
|
||||
| `09120000011` | customer | رضا حسینی (male) | 1 patient, 1 Tehran address (coords) |
|
||||
|
||||
- **Search returns real nurses.** `GET /api/v1/search/nurses?service_category_id=1&city_id=101` (Elderly Care in
|
||||
Tehran) returns the two verified nurses' priced variants. Categories offered only by the unverified nurse
|
||||
(Infant Care, `service_category_id=3`) return an empty page — the `is_searchable` invariant holds.
|
||||
- **Trust badges are real.** `GET /api/v1/nurses/{id}/trust_badge` returns `isVerified=true` + credential types
|
||||
for the verified nurses, `false` for the unverified one.
|
||||
- **The variant builder's required-option step renders on the real path.** The demo seeder adds one
|
||||
cross-category **required** option group — شیفت / *Shift Type* (`Daytime`/`Night`/`Live-in`) — so
|
||||
`GET /api/v1/catalog/...` option-group reads are non-empty in Development (the "categories but no option
|
||||
groups" data gap). This group is **Development-only demo data**, not a production catalog decision.
|
||||
|
||||
## What did NOT change (important)
|
||||
- **No `USE_*_MOCK` flag was flipped** — de-mocking the client is still [Phase 4](../../../post-phase/refinement/refinement-phase-4-frontend-de-mock.md).
|
||||
This phase only makes the *backend* real path return data.
|
||||
- **No new endpoints, no contract changes, no new migration.** The 17 migrations are unchanged and current;
|
||||
the reference `HasData` seeds are untouched. The demo seeder writes through the existing entities/handlers.
|
||||
- **Nothing runs in Production/Staging.** The seeder is gated on `IsDevelopment()`.
|
||||
|
||||
## How to reset / re-seed
|
||||
- `docker compose down -v` (wipe the DB volume) then `dotnet run` → migrate + reference seed + demo seed from
|
||||
scratch. Re-running `dotnet run` against an already-seeded DB is a **no-op** (guarded on each persona's phone).
|
||||
|
||||
## Gotchas
|
||||
- **Money is IRR Rials** (e.g. per-24h live-in = `3500000`); on the wire variant prices are digit strings.
|
||||
- The unverified nurse **has** a variant and a covered area but `is_verified=0`, so the search maintainer
|
||||
computes `is_searchable=0`. Do not treat "has a variant" as "is discoverable".
|
||||
@@ -0,0 +1,31 @@
|
||||
# After refinement-phase-6 — Money-path correctness completion
|
||||
|
||||
**Track:** backend (money path). Gate: `dotnet build` 0 new warnings · `dotnet test` 396 pass (+13). Migration
|
||||
`RefinementPhase6MoneyFks` scaffolded (additive: 3 FK sets + invoice index + delete of a dead config seed row).
|
||||
|
||||
## What's now live (new endpoints — admin)
|
||||
- **`POST api/v1/admin_refunds/{id}/confirm_settlement`** — settle a `processing` BNPL/manual refund
|
||||
(`processing → succeeded`, posts the deferred `refund_payable ↔ escrow_held` clearing). Idempotent.
|
||||
- **`POST api/v1/admin_refunds/{id}/mark_failed`** — fail a `processing` refund (no ledger). Body
|
||||
`{ "reason": "..." }` (optional). Idempotent.
|
||||
- Both return `RefundSettlement` `{ refundId, bookingId, status, completedAt }`. Contract:
|
||||
`dev/contracts/domains/refunds-invoices.md`; snapshot `swagger.v1.json` refreshed.
|
||||
|
||||
## What changed under the hood (no client-visible shape change)
|
||||
- The **BNPL provider cash-back callback** now auto-settles the matching `processing` refund (a
|
||||
`refund/revert completed|confirmed|settled` / `cashback` event) — so a real BNPL revert reaches `succeeded`
|
||||
without an admin click.
|
||||
- **Refund create** persists the row (`approved`) before the external channel call (crash-window fix) — an
|
||||
interrupted refund is now a reconcilable `approved` row, not a lost execution.
|
||||
- **Forward-dep FKs** added: `refunds.ticket_id`, `nurse_clawbacks.original_payout_id`/`recovered_in_payout_id`,
|
||||
`invoices.partner_center_id` (+ index). All nullable, `NO ACTION`. No behavior change; integrity backstop only.
|
||||
- **Audit:** `Refund`/`NurseClawback`/`NursePayout`/`NursePayoutBatch`/`NurseVerification` are now `IAuditable`
|
||||
→ admin decisions leave an `audit_logs` diff row (IBAN redacted).
|
||||
- **Retired** the orphaned `refund_ticket_required` config key (a refund ticket is always auto-opened).
|
||||
|
||||
## Frontend notes
|
||||
- The customer refund-status flow is **unchanged** — a BNPL refund still shows `processing` with the ETA, and now
|
||||
actually flips to `succeeded` once settled (via admin confirm or the provider callback). No new client work
|
||||
required for the customer side.
|
||||
- If/when an admin refund console surfaces settlement, the two new endpoints are the actions (staff-gated,
|
||||
`sensitive` rate policy).
|
||||
@@ -0,0 +1,38 @@
|
||||
# After refinement-phase-7 — Unattended operation (scheduler, locking, migrations-from-boot)
|
||||
|
||||
**For the frontend / next backend phase. Backend-owned; frontend reads.**
|
||||
|
||||
## What changed for a client
|
||||
|
||||
Almost nothing user-facing — this is infrastructure. One wire delta:
|
||||
|
||||
- **`PayoutBatchDto.initiatedByAdminId` is now nullable.** `null` = a **system-initiated / scheduled** payout
|
||||
batch (the weekly cron generated it, no human initiator). Admin payout UIs should render a "system"/"scheduled"
|
||||
label instead of assuming an admin id. Contract + `swagger.v1.json` updated.
|
||||
|
||||
## What the platform now does on its own
|
||||
|
||||
The four previously admin-click-only sweeps run on schedule (each reading its `platform_configs` cadence key):
|
||||
credential-expiry scan, EVV no-show sweep, and **weekly payout-batch generation** — plus the two re-homed sweeps
|
||||
(booking-request expiry, notification retention). **Admin manual triggers are unchanged and remain overrides.**
|
||||
|
||||
- **Payout generation only.** The cron opens a `draft` batch; **processing (money movement) is still an explicit
|
||||
admin action** (`POST admin_payouts/batches/{id}/process`). Do not build a client flow that auto-processes.
|
||||
|
||||
## For the next backend phase (Phase 8 — external rails)
|
||||
|
||||
- **Register new crons via the seam, not a new host.** Implement `IRecurringJob`
|
||||
(`Persistence/Services/Scheduling/`) + one `services.AddSingleton<IRecurringJob, YourJob>()` in
|
||||
`AddPersistenceServices`. Phase 8 owns the **Moadian reconciliation poll** and the **refund-settlement
|
||||
reconciliation** this way (each reads/adds its own cadence key). The scheduler already provides the per-tick
|
||||
scope, the `scheduler:{name}` lock, and error isolation.
|
||||
- **Jobs must stay idempotent** — a retry (or a second instance once the lock is Redis-backed) must never
|
||||
double-pay/double-post; the DB uniques/state-machines are the backstop.
|
||||
|
||||
## Ops / deployment
|
||||
|
||||
- **DDL is a deploy step now:** run `dotnet run -- migrate` (applies migrations + idempotent seeders, then exits)
|
||||
before starting the API in a deployed environment. A normal deployed boot only *checks* the schema and **fails
|
||||
fast** if a migration is pending. Development still migrates + seeds on boot.
|
||||
- **Redis is the >1-instance gate** (shared cache + the cross-instance scheduler/money lock). Single-instance MVP
|
||||
does not need it; the in-proc seams are correct for one instance. Elasticsearch is never MVP.
|
||||
@@ -0,0 +1,56 @@
|
||||
# After refinement-phase-8 — External rails go real (config-selected vendor adapters)
|
||||
|
||||
**For the frontend / next backend phase. Backend-owned; frontend reads.**
|
||||
|
||||
## What changed for a client
|
||||
|
||||
**Nothing user-facing changed by default** — the mocks stay the default registration, so every existing flow
|
||||
behaves exactly as before. This phase makes each vendor rail *swappable to real by config*, not on by default.
|
||||
|
||||
One **new endpoint** (backend-to-backend, not for the browser): `POST /api/v1/webhooks/payouts/{provider}` — the
|
||||
async PAYA/SATNA reconciliation callback (signature-authenticated, anonymous, `webhook` rate policy). It flips a
|
||||
`submitted` payout to `paid`/`failed`. No client calls it.
|
||||
|
||||
## How a rail goes real (ops)
|
||||
|
||||
Set the rail's **`Seams:{rail}:Provider`** + its credentials (user-secrets/env) and restart — no code change,
|
||||
no deploy of new binaries. Provider tokens (mock stays default; a typo falls closed to mock):
|
||||
|
||||
| Rail | Key | Real value | Also needs |
|
||||
| --- | --- | --- | --- |
|
||||
| SMS (**launch-critical**) | `Seams:Sms:Provider` | `kavenegar` | `Seams:Sms:{ApiKey,SenderLine,OtpTemplate}` |
|
||||
| Shahkar / e-KYC / شبا | `Seams:{Shahkar,IdentityKyc,BankOwnership}:Provider` | `finnotech` | `Seams:Finnotech:{BaseUrl,ClientId,AccessToken}` |
|
||||
| Geocoding | `Seams:Geocoding:Provider` | `neshan` | `Seams:Geocoding:{ApiKey}` |
|
||||
| Object storage | `Seams:ObjectStorage:Provider` | `s3` | `Seams:ObjectStorage:{ServiceUrl,Bucket,Region,AccessKey,SecretKey}` |
|
||||
| Card PSP (+ HMAC webhook + تسهیم) | `Seams:Payments:Provider` | `zarinpal` | `Seams:Payments:{MerchantId,CallbackUrl,WebhookSigningSecrets}` |
|
||||
| BNPL | `Seams:Bnpl:Provider` | `real` | `Seams:Bnpl:Providers:{snapppay,digipay}:*` (creds via gateway config) |
|
||||
| Payout rail | `Seams:BankTransfer:Provider` | `jibit` | `Seams:BankTransfer:{ApiKey,SourceSettlementAccount}` + webhook secret |
|
||||
| Moadian | `Seams:Moadian:Provider` | `moadian` | `Seams:Moadian:{MemoryId,AccessToken}` + signing cert |
|
||||
|
||||
**Two hard rules baked in:**
|
||||
- **SMS real ⇒ the OTP is never logged.** The Development OTP-in-logs bridge (`GET dev/last_otp`) runs **only while
|
||||
the mock SMS sender is selected**. Once `Seams:Sms:Provider=kavenegar`, the code only leaves the process over the
|
||||
SMS wire.
|
||||
- **Money callbacks fail closed.** The payout reconciliation + PSP webhook verify a per-provider HMAC over the raw
|
||||
body; an invalid signature mutates nothing. The confirm path still re-verifies the amount server-side.
|
||||
|
||||
## Behavioural notes the next phase should know
|
||||
|
||||
- **Payout rail is async now (when real).** A real `JibitBankTransferProvider` accepts a transfer as `submitted`;
|
||||
the ledger posts only when the reconciliation callback confirms `paid`. The `ExecutePayoutBatch` handler already
|
||||
handled this (`MarkSubmitted` first, ledger on `paid`) — it was unchanged.
|
||||
- **`bookings/convert` is Dev/Testing only.** `IPaymentCaptureSimulator` is out of the production registration
|
||||
(prod = fail-closed `DisabledPaymentCaptureSimulator`). Production converts via the b10 payment **webhook confirm**
|
||||
calling `ConvertRequestToBooking` directly — do not build a client convert flow.
|
||||
- **`balinyaar` BNPL = in-house.** In real BNPL mode `provider_code=balinyaar` resolves to the deterministic
|
||||
net-of-fee model (no external API); `tara`/`torobpay` are unbuilt and rejected cleanly.
|
||||
- **New cron:** `MoadianReconciliationJob` (6 h) walks `pending/submitted` invoices to `registered` — registered
|
||||
the phase-7 way (`IRecurringJob` + one `AddSingleton`), no migration.
|
||||
|
||||
## Follow-ups carried forward
|
||||
|
||||
- Per-code BNPL revert (the b11 refund path uses the SnappPay default); SMS.ir/Ghasedak adapters; Finnotech/Moadian
|
||||
token exchange + Moadian signing cert; the **refund-settlement poll** (BNPL `processing → succeeded`, pairs with
|
||||
Moadian — the confirm command exists, the poll job is the remaining wiring); a dedicated **center-settlement
|
||||
payout** (deferred per 6.6 — MoR centers settle via a تسهیم split leg, non-MoR have no separate money path).
|
||||
- **Redis** stays the >1-instance gate; **Elasticsearch** is never MVP.
|
||||
@@ -0,0 +1,43 @@
|
||||
# After refinement-phase-9 — Observability, ops hardening, docs honesty & scale-later
|
||||
|
||||
**For the frontend / next backend phase. Backend-owned; frontend reads.**
|
||||
|
||||
## What changed for a client
|
||||
|
||||
**Nothing user-facing.** No route, envelope, shape, or enum changed. Two things worth knowing:
|
||||
|
||||
- **`ApiResult.requestId` is now a real W3C trace id** (the request's OpenTelemetry trace). It's the id to quote in
|
||||
a support ticket — it maps 1:1 to the server-side trace once an OTLP collector is wired.
|
||||
- **Ticket message bodies are encrypted at rest** server-side. The thread read still returns **plaintext** (the wire
|
||||
is unchanged); the change is purely storage-side (the refund/dispute paper trail is no longer plaintext in the DB).
|
||||
|
||||
## What the platform now does / exposes (ops)
|
||||
|
||||
- **One OpenTelemetry stack.** Metrics scrape at `/metrics`; distributed **tracing** (ASP.NET Core + EF Core) is
|
||||
wired. **OTLP export is opt-in** — set `OpenTelemetry:Otlp:Endpoint` (Grafana Tempo / Jaeger / OTEL Collector) to
|
||||
turn trace + metric export on. Prometheus-scrape-only is an acceptable MVP; prometheus-net was removed.
|
||||
- **Health endpoints:** `/healthz/live` (process only — safe liveness), `/healthz/ready` (app DB + log DB [deployed]
|
||||
+ an object-storage write probe — pull an instance out of rotation when a dependency is down), `/HealthCheck`
|
||||
(aggregate, kept for compat). Point the orchestrator's liveness probe at `/healthz/live`, readiness at
|
||||
`/healthz/ready`.
|
||||
- **Prod logs are Information+ with no PII/secrets.** The OTP code is no longer logged in any environment. Log-table
|
||||
retention on `Baya_Logs` is an ops/DBA task (or ship logs to the OTLP collector).
|
||||
- **Audit-log retention** runs as a scheduled `IRecurringJob` — two-tier (financial/verification rows kept ~7 yr,
|
||||
everyday rows ~2 yr) via the `audit_retention_*` config keys.
|
||||
- **gRPC reflection is Development-only** (the plugin itself is unchanged; it shares the mixed-protocol listener).
|
||||
|
||||
## For the next backend phase / deploy
|
||||
|
||||
- **Turn tracing on in deployed envs** by provisioning an OTLP collector and setting `OpenTelemetry:Otlp:Endpoint`.
|
||||
- **When Redis lands (>1 instance)**, add a `redis` readiness check (tagged `ready`) to `ConfigureHealthChecks`.
|
||||
- **Register any new retention/cron via `IRecurringJob`** (unchanged from phase 7).
|
||||
|
||||
## Deferred — recorded, NOT gaps (each has a written pull-trigger; see the phase report)
|
||||
|
||||
- **Elasticsearch `INurseSearch` backend + outbox feeder** — pull when SQL search shows strain. SQL search is the
|
||||
real MVP (`Search:Backend=sql`; any other value fails fast).
|
||||
- **SMS/push channels of `INotificationDispatcher`** — pull when the notification UX demands out-of-app reach.
|
||||
In-app notifications are real now.
|
||||
- **Analytics warehouse/stream, holiday-calendar feed, 8 deferred product tables** (`organizations`,
|
||||
`organization_nurses`, `fraud_flags`, `recurring_booking_schedules`, `bnpl_settlement_entries`, availability
|
||||
slots, customer national-ID KYC, geo bulk import) — each a pure additive step when product pulls it.
|
||||
Reference in New Issue
Block a user