cleanup phases 6

This commit is contained in:
hamid
2026-08-02 18:48:32 +03:30
parent e2db97392a
commit 51e86a1e5f
239 changed files with 118 additions and 70 deletions
@@ -0,0 +1,32 @@
# Phase reports — template
Every phase writes a report here when it finishes (operating-rules §7, Definition of Done). The report
is the durable answer to *"what did this phase do, what can I test now, and what's still fake?"* — saved
to a file, not left in chat.
File name: `backend-phase-N-report.md` or `frontend-phase-N-bM-report.md`.
```markdown
# <Backend|Frontend> Phase N — <Title> — Report (<YYYY-MM-DD>)
## What was built
- Bullet list of concrete deliverables (entities/migrations/endpoints, or screens/services/components).
## What is now testable (and exactly how)
- Step-by-step for a human: e.g. "Swagger → POST /api/v1/auth/otp/request with {phone} → 200; the OTP is
logged to the console; POST /api/v1/auth/otp/verify with that code → returns access+refresh tokens."
- For the frontend: which screen, which route, what to click, expected result, how mock data shows up.
## What is mocked / waiting on a real service
- Each seam touched: interface + file, what's faked, and a link to its entry in `mocks-registry.md`.
## Contracts
- Produced (backend): which `contracts/domains/*.md` + openapi snapshot.
- Consumed (frontend): which contract/version; any request filed in `frontend/requests/for-backend.md`.
## Docs updated
- Which CLAUDE.md / product doc / conventions were updated and why.
## Follow-ups for later phases
- Anything intentionally deferred, with the phase that should pick it up.
```
@@ -0,0 +1,93 @@
# Backend Phase 0 — Foundation, cross-cutting seams & starter cleanup — Report (2026-06-28)
## What was built
- **Starter cleanup.** Removed the `Order` demo end-to-end: entity (`Domain/Entities/Order`), feature
folder (`Application/Features/Order`), `IOrderRepository`/`OrderRepository`, `OrderConfig`,
`User.Orders` nav, `IUnitOfWork.OrderRepository`, the gRPC `OrderGrpcServices` + proto + wiring +
`/GrpcUserOrder` map. Deleted the three pre-marketplace migrations (`2021/2022/2023`) + snapshot.
- **Fresh baseline migration** `Migrations/20260628191947_InitialBaseline` — Identity (`usr` schema) +
`UserRefreshTokens`, with audit columns `CreatedAt`/`ModifiedAt` (`datetimeoffset`),
`CreatedById`/`ModifiedById` (`int`). No `Orders` table.
- **Audit base type.** `BaseEntity`/`IAuditableEntity` in `Domain/Common/BaseEntity.cs`:
`CreatedAt`/`ModifiedAt` as `DateTimeOffset`, `CreatedById`/`ModifiedById` as `int?`
(renamed from the old `CreatedTime`/`ModifiedDate` `DateTime` pair, to match `CONVENTIONS.md` §6).
- **Current-user plumbing.** `ICurrentUser` (`Application/Contracts/Common`) with
`HttpContextCurrentUser` + `NullCurrentUser` (`Infrastructure.Identity/Identity/CurrentUser/`),
registered Scoped; `AddHttpContextAccessor()` wired.
- **Audit interceptor.** `AuditFieldInterceptor` (`Infrastructure.Persistence/Interceptors/`), a
`SaveChangesInterceptor` stamping audit fields from `ICurrentUser` + `IDateTimeProvider`. Replaces
the old `DateTime.Now` date hook in `ApplicationDbContext` (the `_cleanString` Persian-normalisation
hook stays). Registered via `AddInterceptors` in `AddPersistenceServices`.
- **Five cross-cutting seams** (interfaces in `Application/Contracts/Common`, mocks in
`Infrastructure.CrossCutting/Seams/`, registered by `AddCrossCuttingSeams(config)`):
`IDateTimeProvider``SystemDateTimeProvider`, `IFieldEncryptor``SymmetricFieldEncryptor`,
`ICacheService``MemoryCacheService`, `IObjectStorage``LocalDiskObjectStorage`,
`INotificationDispatcher``LogNotificationDispatcher`.
- **Pipeline + rate limiting.** Registered `LoggingBehavior<,>` as the outermost Mediator behavior.
Added `AddRateLimitingPolicies()` (`WebFramework/ServiceConfiguration`) — built-in rate limiter with
a per-IP global limit + named policies `otp`/`auth`/`sensitive`; `app.UseRateLimiter()` placed
**before** `app.UseAuthentication()`.
- **REST surface.** `Controllers/V1/PingController` (sealed, `BaseController`, `ISender`) with
`GetStatus` and a rate-limited `GetStatusRateLimited`, backed by the `Features/System/Queries/Ping`
CQRS feature — proves REST → Mediator → behaviors → `OperationResult``ApiResult` envelope.
- **Tests.** New `Baya.Test.Foundation` project: `SymmetricFieldEncryptor` round-trip + deterministic
hash, `AuditFieldInterceptor` add/update stamping (SQLite), `PingQueryHandler` happy path.
## What is now testable (and exactly how)
- **Build/test gate:** `dotnet build Baya.sln` → 0 errors, 0 new warnings; `dotnet test Baya.sln`
10 pass (4 existing identity + 6 new foundation). ✅ verified.
- **Live API:** ✅ verified end-to-end against SQL Server `192.168.100.14` (Development env). On boot the
`InitialBaseline` migration applied and the default users seeded. `dotnet run --project
src/API/Baya.Web.Api/...` → open `/swagger`:
- `GET /api/v1/ping/get_status``200` with body
`{ "data": { "service": "Baya.Web.Api", "status": "ok", "serverTimeUtc": "<utc>" },
"isSuccess": true, "statusCode": 200, "message": "Success", "requestId": "<trace>" }`
(the standard `ApiResult<T>` envelope). ✅
- `GET /api/v1/ping/get_status_rate_limited` from one IP → first 5 = `200`, then `429`. ✅
- The Order REST/gRPC endpoints are gone; the swagger doc shows only the two ping paths. ✅
> Run note: in non-Development the Serilog `MSSqlServer` sink targets the `logDb` connection
> (`Server=sql_server2022`), which must resolve or the host fails to build (pre-existing config,
> unrelated to this phase). Development logs to console/file, so verify there.
## What is mocked / waiting on a real service
All five seams are 🟡 (mock behind DI seam) — see `reports/mocks-registry.md` rows for
`IFieldEncryptor`, `ICacheService`, `IObjectStorage`, `INotificationDispatcher` (and `IDateTimeProvider`,
not external). Each mock lives in `Baya.Infrastructure.CrossCutting/Seams/`; swapping to a real provider
is a registration change in `AddCrossCuttingSeams`. `INotificationDispatcher` does **not** write yet —
the in-app `notifications` write lands in b15.
## Contracts
- **Produced:** the `ApiResult`/`OperationResult` envelope shape. The first machine `swagger.json`
snapshot is published at `dev/contracts/openapi/swagger.v1.json` (paths `ping/get_status` +
`ping/get_status_rate_limited`; schemas `ApiResult`, `ApiResultStatusCode`,
`ApiResultOfPingQueryResult`, `PingQueryResult`). Casing on the wire: **camelCase** body/envelope
properties, **snake_case** URL segments.
- **Consumed:** none.
## Docs updated
- `server/CLAUDE.md`*Project map* (Order removed; CrossCutting `Seams/`, Persistence `Interceptors/`,
Identity `CurrentUser/`, `Baya.Test.Foundation`, new seams note), *Startup wiring* (new registrations
+ rate-limiter-before-auth pipeline order), CQRS example (Order → generic + Ping).
- `server/CONVENTIONS.md` — §6 "as built" note (audit base type + interceptor) and a new
"Money is IRR `BIGINT` — integer-only, no floats" rule.
- `dev/shared-working-context/reports/mocks-registry.md` — five seam rows → 🟡 with files + config keys.
## Follow-ups for later phases
- **b1:** extend `AuditFieldInterceptor` to also write append-only `audit_logs` rows; evolve the
baseline migration with the marketplace schema; `IHolidayCalendar` seed.
- **b2:** `ISmsSender` seam + auth/OTP REST surface; apply the `otp`/`auth` rate-limit policies to those
endpoints.
- **Integration tests:** a `WebApplicationFactory<Program>` test project (CONVENTIONS §10) was not
scaffolded this phase; add it when the first real feature area lands so the HTTP pipeline (routing,
auth, envelope translation, 429) is covered automatically.
- **Logging config (pre-existing):** the non-Development Serilog `MSSqlServer` sink points at
`logDb` = `Server=sql_server2022`. If that host isn't reachable in an environment, the API won't
start there. Out of scope for this phase — flag for whoever owns environment/infra config.
## Status
All Definition-of-Done items met: build clean (0 new warnings), 10 tests green, REST controller live
through Swagger with the `OperationResult` envelope, `LoggingBehavior` + rate limiter wired,
`ICurrentUser` + audit interceptor + the five seams in place, migration applied + DB seeded, docs/
contracts/handoff/registry updated, swagger snapshot published. Live verification done against
`192.168.100.14`.
@@ -0,0 +1,78 @@
# Backend Phase 1 — Config, reference & platform signals — Report (2026-07-02)
## What was built
- **First marketplace migration baseline** `20260701193257_InitialMarketplaceBaseline` (on top of b0's
`InitialBaseline`) creating a new **`ops` schema** with six tables:
- `PlatformConfigs` (unique `Key`, audit fields, `IAuditable`), `AuditLogs` (append-only; indexes on
`(EntityType,EntityId)` + `OccurredAt`; nullable FK → `usr.Users`), `SystemEvents` (append-only;
indexes on `Name`/`OccurredAt`/`UserId`), `IranianHolidays` (unique `HolidayDate`), `Notifications`
(index `(UserId,IsRead,CreatedAt)`; FK → Users), `SupportAlerts` (indexes on `Status`/`Type`;
nullable `BookingId`/`ReviewId` columns **without FK yet**; FK → Users on `OwnerUserId`).
- Seeded via `HasData`: 12 `platform_configs` keys and 7 sample holidays (Nowruz block + Revolution
Day + Nature Day + a religious day).
- **Domain:** entities under `Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts`; the
`IAuditable` marker + `[AuditRedacted]` attribute (`Domain/Common`); string-code constant holders
(`ConfigDataType`, `AuditAction`, `HolidayType`, `SupportAlertType/Severity/Status`).
- **Application:** facade contracts `IPlatformConfig`/`IHolidayCalendar`/`IAnalyticsSink`/`IAuditLogger`/
`INotificationService`/`ISupportAlertService`; DTOs + `PagedResult<T>`; evolved the
`INotificationDispatcher.Notification` record to carry `Type` + `DataJson`; a `Pagination.Normalize`
helper; **14 CQRS commands/queries** (+ validators) wiring the endpoints to the facades.
- **Persistence (`Services/`):** DB-backed implementations of every facade; the real
`InAppNotificationDispatcher` (supersedes and **removes** the b0 `LogNotificationDispatcher`);
`NotificationRetentionHostedService` (interval `BackgroundService`). The `AuditFieldInterceptor` was
**extended** (not duplicated) to append an `audit_logs` row with an old/new diff for every `IAuditable`
change, in the same transaction, redacting `[AuditRedacted]` properties. Registered all facades +
hosted service in `AddPersistenceServices`; removed the `INotificationDispatcher` registration from
`AddCrossCuttingSeams`.
- **API:** 5 sealed `BaseController` controllers — admin `PlatformConfigController`/`HolidaysController`/
`AuditController`/`SupportAlertsController` (`[Authorize(DynamicPermission)]`) and current-user
`NotificationsController` (`[Authorize]`).
## What is now testable (and exactly how)
`dotnet test Baya.sln`**22 pass** (4 identity + 18 new foundation), build clean (0 new code warnings;
only pre-existing NU1903/NU1510 package advisories remain). Foundation tests run against a real
`ApplicationDbContext` over in-memory SQLite (`OpsTestHost`, seed applied via `EnsureCreated`):
1. **Typed config**`GetConfig<decimal>("vat_rate")==0.10`, `<int>("dispute_window_hours")==72`,
`<int>("booking_payment_deadline_minutes")==30`; cache hit on re-read.
2. **Config change is audited**`SetConfig("platform_fee_rate","0.18")` → new value read back (cache
evicted) + one `audit_logs` row (`updated`, actor, old `0.15`/new `0.18`); missing key → `false`.
3. **Holidays**`IsBankClosed(2026-03-21)==true`, `IsHoliday(non-holiday)==false`,
`NextBusinessDay(2026-03-21)` → a later open, non-Friday day; upsert/delete round-trip.
4. **Notifications** — dispatch → list unread-first → unread count 1 → mark read → 0; tenancy (another
user sees nothing and cannot mark your row); **retention** deletes only read >90d (unread >90d and
read <90d survive).
5. **Support alerts** — raise (`low_rating`,`review`,`42`) → list open → assign → resolve; resolved is
terminal.
6. **Analytics**`EmitAsync` inserts a `system_events` row.
**Live:** the migration was applied to the dev DB (`Server=87.107.152.16`); the API boots with all 16
Swagger paths present, and the notification-retention hosted service runs its purge query on startup.
Swagger snapshot refreshed at `dev/contracts/openapi/swagger.v1.json` (fetched over HTTP/2 — the gRPC
plugin makes the REST port HTTP/2-only, so a browser or HTTP/2 client is needed to hit Swagger by hand).
## What is mocked / waiting on a real service
See `reports/mocks-registry.md`. New/changed 🟡: `IHolidayCalendar` (seeded table → real feed/sync),
`IAnalyticsSink` (system_events row → warehouse), retention `IJobScheduler`
(`NotificationRetentionHostedService` interval runner → Hangfire/Quartz), `INotificationDispatcher`
(now **real in-app write**; SMS/push channels deferred). Selection is by registration, never `if(mock)`.
## Contracts produced
`dev/contracts/domains/config-reference.md` (live as of b1; consumers f14/f15) + refreshed
`openapi/swagger.v1.json`.
## Decisions recorded (were not pinned by product docs)
Seeded config defaults marked _provisional_ in `product/data-model/12-audit-config-and-reference.md`:
`platform_fee_rate` 0.15, `nurse_response_deadline_hours` 24, `evv_location_tolerance_meters` 200,
`min_rating_for_support_alert` 2, `bnpl_provider_commission_rate` 0.07, `bnpl_settlement_timing`
immediate, and a 3-tier `cancellation_tiers` default. Confirm before launch — all are config-driven.
## Follow-ups for later phases
- **b9 / b14:** add the FK constraints `SupportAlerts.BookingId → Bookings` and
`SupportAlerts.ReviewId → Reviews` when those tables land (columns already exist).
- List orderings use the monotonic `Id` (newest-first, deterministic, cross-provider) rather than the
`DateTimeOffset` column — SQLite can't `ORDER BY`/compare `DateTimeOffset`; equivalent on SQL Server.
- Notification retention filters the age cutoff in memory (after a server-side `IsRead` filter) so the
bulk delete translates on every provider; fine for a bounded, off-peak job.
- **Integration tests** (`WebApplicationFactory<Program>`, CONVENTIONS §10) still not scaffolded — the
HTTP pipeline (auth 401/403, envelope) is covered by the live Swagger boot but not automated; add the
project when convenient.
@@ -0,0 +1,69 @@
# Backend Phase 10 report — Payments core: ledger, transactions, webhooks & card capture
## What was built
- **`payments` schema, four tables, one migration (`PaymentsCoreLedger`):**
- `PaymentGateways` — PSP config; **encrypted `config_json`** (`IFieldEncryptor`); selection index on
`(type, is_active, priority)`.
- `PaymentTransactions` — every attempt; the **two filtered uniques**:
`UNIQUE(gateway_reference_code) WHERE NOT NULL` and `UNIQUE(booking_id) WHERE status='succeeded' AND
booking_id IS NOT NULL`. `booking_id` is **nullable** (bound at confirm — a b9 booking exists only on capture).
- `PaymentWebhookEvents` — the idempotency store; **`UNIQUE(provider_code, external_event_id)`**.
- `LedgerEntries` — the append-only double-entry source of truth. Implements `IEntity` **only** (no
`ITimeModification`/audit-modify, no soft-delete) so the audit interceptor never stamps it; `created_at`
set explicitly from `IDateTimeProvider`.
- **Domain:** `LedgerPosting.CardCapture` (the balanced-group builder — throws if `gross ≠ commission + payout`);
`LedgerAccountType` (all 8 codes), `LedgerDirection`/`LedgerSourceRefType`, `PaymentTransactionStatus`,
`WebhookProcessingStatus`, `PaymentGatewayType`.
- **Features (`Baya.Application/Features/Payments/`):** `InitiatePayment`, `HandlePaymentWebhook`,
`ConfirmPaymentAndPostLedger` (internal — dispatched only from the webhook + tests), `GetNursePayableBalance`.
- **Seams (`Application/Contracts/Payments/`) + mocks (`CrossCutting/Seams/`):** `IPaymentProvider`,
`ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock`; registered in `AddCrossCuttingSeams`.
- **Persistence:** `IPaymentRepository` + `PaymentRepository` on `IUnitOfWork`; `config_json` encryption wired
in `ApplicationDbContext`.
- **Controllers:** `PaymentsController` (`POST bookings/{bookingRequestId}/payments`), `WebhooksController`
(public `POST webhooks/payments/{provider}`), `NursePayableBalanceController` (`GET nurses/{id}/payable_balance`).
- **Shared conversion:** extracted **`BookingFactory`** from b9's `ConvertRequestToBooking` handler so b9 (mock
capture) and b10 (real webhook capture) share the conversion/amount logic — b9 behaviour unchanged.
## What is now testable and exactly how (mirrors phase §7)
1. **Initiate** `POST api/v1/bookings/{requestId}/payments` (as the customer) → `200` with a redirect URL + a
`pending` transaction carrying the deterministic `gatewayReferenceCode`; no ledger rows, no booking yet.
2. **Webhook confirms** `POST api/v1/webhooks/payments/{provider}` with a `succeeded` event for that reference →
the transaction flips `succeeded`; **one balanced ledger group** posts (DEBIT `escrow_held` 23300000 =
CREDIT `platform_revenue` 3495000 + `nurse_payable` 19805000); the booking is created & **confirmed**.
3. **Replay** the same `external_event_id``200` with `duplicate: true`; no second confirm, no second ledger
group; still one webhook-event row.
4. **`GET api/v1/nurses/{nurseId}/payable_balance`** → `19805000` (signed ledger sum; a debit reduces it).
5. **Second `succeeded` transaction for a booking is blocked** by the filtered `UNIQUE(booking_id) WHERE
status='succeeded'` — an idempotent no-op success, never a second capture.
6. **Unverified callback** (mock verifier marks it invalid) → stored `ignored`, no transaction flip, no ledger.
7. **Encrypted gateway config** — `payment_gateways.config_json` is ciphertext at rest.
Tests: **12 Foundation** (`Baya.Test.Foundation/Payments/`: `LedgerPostingTests`, `PaymentConfirmTests`,
`InitiatePaymentTests`, `NursePayableBalanceTests`, `PaymentWebhookTests`) + **4 Api integration**
(`PaymentsApiTests`: 401, validation 400, full initiate→webhook→ledger flow, duplicate-replay). Whole suite
green (Foundation 198, Identity 4, Api 83); `dotnet build Baya.sln` 0 new warnings. Filtered uniques + the
balanced ledger are exercised against the real EF model on SQLite (partial indexes work there).
## What is mocked + how to make it real
The four money-path seams — `IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`,
`IDistributedLock` — see `reports/mocks-registry.md` (each row → 🟡 with step-by-step make-it-real). The mock
`IPaymentCaptureSimulator` (b9) is **retained** (b9's `bookings/convert` endpoint + tests use it); b10's real
capture uses `BookingFactory` directly.
## Account types reserved for b11b13
`refund_payable`, `nurse_clawback_receivable` (b11); `bnpl_fee_expense` (b12); `psp_fee_expense`, `bad_debt`
(reserved). All defined now so later phases only post against them.
## Contracts produced
- `dev/contracts/domains/payments.md` (human contract) + **`dev/contracts/openapi/swagger.v1.json` refreshed**
(the three b10 paths — `bookings/{bookingRequestId}/payments`, `webhooks/payments/{provider}`,
`nurses/{nurseId}/payable_balance` — and the `InitiatePaymentResult`/`WebhookIngestResult`/
`NursePayableBalanceDto` schemas are present).
## Follow-ups
- b11 removes the last coupling to `IPaymentCaptureSimulator` once the refund path exercises the real rail end
to end; consider retiring the `bookings/convert` mock endpoint then.
- The webhook confirm crosses two commits (booking creation, then txn+ledger) because the ledger legs need the
DB-generated `booking_id`; the webhook dedup + forward-only request guard + the succeeded-unique keep it
idempotent. A single explicit transaction is a future hardening if `IUnitOfWork` grows a transaction scope.
@@ -0,0 +1,90 @@
# Backend Phase 11 Report — Refunds, invoices & nurse clawbacks
**Mission:** make money flow *backwards* correctly — the admin-only refund engine that reverses a captured
booking payment across both fee legs, posts the balanced reversal into the append-only ledger, forks on whether
the nurse was already paid (clean reversal vs a `nurse_clawbacks` receivable), and issues the minimal commission
invoice with VAT.
## What was built
**Three tables (one migration `RefundsClawbacksInvoices`, `payments` schema) + a counter row:**
- `Refunds` — 1:N per `payment_transaction`; `amount = platform_fee_refunded_irr + nurse_payout_refunded_irr`
(DB CHECK); `refund_channel` (`psp_card`|`bnpl_revert`|`manual`), `external_revert_reference`,
`expected_customer_refund_eta` (DATE), `cancellation_policy_code` + `refund_percentage_applied` snapshot,
forward-only `status`. **`ticket_id` is a nullable column with NO FK** (tickets → b15).
- `NurseClawbacks``status` (`pending`|`recovered`|`written_off`; only `pending`/`written_off` here),
`amount_irr`, `refund_id` (1:1 UNIQUE), nullable `original_payout_id`/`recovered_in_payout_id` (no FK, → b13).
- `Invoices` — UNIQUE `invoice_number` (sequential) + UNIQUE `booking_id`; `vat_rate`/`vat_irr` on the commission
line; `moadian_reference_number`/`moadian_status`; nullable `partner_center_id` (no FK, → b15).
- `InvoiceNumberSequences` — single seeded counter row (id 1, next 1); locked + committed with the invoice so
numbers are gap-free and portable across SQL Server / SQLite (no DB sequence).
**Features (CQRS, `OperationResult`, validators):** `CreateRefundCommand`, `WriteOffClawbackCommand`,
`ListRefundsQuery`, `GetRefundStatusQuery`, `IssueInvoiceCommand`, `GetInvoiceQuery`. The phase's
channel-execution / ledger-posting / clawback "internal step" commands are realized as cohesive **private steps**
inside `CreateRefundCommandHandler` under one lock + transaction (mirroring b10's `ConfirmPaymentAndPostLedger`) so
they stay atomic — this is the correct shape; separate dispatched commands would each commit and break atomicity.
**Ledger postings** added to b10's `LedgerPosting` (balanced, append-only): `RefundReversalPrePayout`,
`ClawbackReversalPostPayout`, `RefundPayableClearing`, `ClawbackWriteOff`.
**Controllers:** `AdminRefundsController` (`POST`/`GET admin_refunds`), `AdminClawbacksController`
(`POST admin_clawbacks/{id}/write_off`), `AdminInvoicesController` (`POST admin_invoices`) — all admin policy +
rate-limited; `RefundsController` (`GET refunds/{id}/status`), `InvoicesController` (`GET invoices/{bookingId}`) —
authenticated, tenancy-scoped.
**Seams:** introduced `IMoadianClient` (+ `MockMoadianClient`) and, because b12 isn't merged, a thin
`IBnplProvider` (+ `MockBnplProvider`) stub; interim `INursePayoutStatus` (`NursePayoutStatusService`, Persistence).
Extended `IPaymentProvider.RefundAsync` to carry an `idempotencyKey` (updated the mock + b10 call sites — no b10
behaviour change). Reused `IWebhookVerifier`/`IDistributedLock`/`INotificationDispatcher`/`ISupportAlertService`/
`IObjectStorage`/`IPlatformConfig`. Added config rows `refund_ticket_required` (false), `bnpl_refund_eta_business_days`
(10), `refund_assume_nurse_paid` (false).
## What is now testable and exactly how (mirrors the phase §7)
Seed a confirmed booking with a captured card transaction (the `RefundsTestHost`/`AdminRefundsApiTests.SeedCapturedBookingAsync`
helpers do this; gross 10,000,000 / commission 1,500,000 / payout 8,500,000).
1. **Pre-payout full refund**`POST admin_refunds {bookingId, refundPercentage:1}``refund_channel psp_card`,
legs `1,500,000` + `8,500,000` summing to `10,000,000`; ledger shows a balanced `DEBIT platform_revenue` +
`DEBIT nurse_payable` / `CREDIT refund_payable`, and a `DEBIT refund_payable` / `CREDIT escrow_held` clearing
leg (5 legs, Σdebit=Σcredit); status `succeeded`.
2. **Partial + over-refund guard** — a 50% refund decomposes to `750,000` + `4,250,000`; a second refund that would
exceed the captured amount → **409**, no ledger posted.
3. **Invoice**`POST admin_invoices {bookingId}` → sequential `INV-0000000001`,
`vat_irr = round(1,500,000 × 0.10) = 150,000` (on the commission, `0` when `vat_rate = 0`), `moadian_status
pending` / null ref; a second booking → `INV-0000000002` (gap-free). Re-issue returns the same invoice.
4. **Post-payout clawback** — with the nurse flagged paid (seed a past `dispute_window_ends_at`, or
`refund_assume_nurse_paid = true`) → the payout leg debits `nurse_clawback_receivable` (not `nurse_payable`), a
`pending` `nurse_clawbacks` row (`amount_irr = 8,500,000`) is created, and a `nurse_clawback` support alert is
raised. Not auto-recovered (b13).
5. **BNPL revert** — seed a `bnpl` gateway → `refund_channel bnpl_revert`, `IBnplProvider.RevertAsync` called,
`external_revert_reference` stored, `expected_customer_refund_eta ≈ now + 10 business days`, status `processing`;
the reversal ledger legs are identical to the card case. `GET refunds/{id}/status` shows the ETA.
6. **Write-off**`POST admin_clawbacks/{id}/write_off``written_off` + `DEBIT bad_debt` / `CREDIT
nurse_clawback_receivable` + `resolved_at`.
7. **Worklist + tenancy** — `GET admin_refunds?status=…` lists channel/legs/ETA; `GET refunds/{id}/status` as a
different customer → **404**.
**Tests:** 8 Foundation handler tests (`Refunds/RefundHandlerTests` + `InvoiceHandlerTests`) + 8 Api integration
tests (`AdminRefundsApiTests`, `AdminInvoicesApiTests`, `RefundStatusApiTests`). Full suite green (300).
`dotnet build Baya.sln` 0 new warnings.
## What is mocked / how to make it real
See `reports/mocks-registry.md` — `IMoadianClient` (سامانه مودیان enrollment + submission API + 22-digit ref +
reconciliation callback), `IBnplProvider` (b12 owns the real adapter), `INursePayoutStatus` (b13 real lookup).
## Contracts produced/consumed
- **Produced:** `dev/contracts/domains/refunds-invoices.md`; `dev/contracts/openapi/swagger.v1.json` refreshed.
- **Consumed:** b9 booking/cancellation snapshot, b10 ledger/transaction/gateway, b1 VAT config + notifications +
support alerts.
## Follow-ups for later phases
- **مودیان reconciliation cron** — flip `moadian_status pending → registered` + fill the 22-digit ref (thin/manual
today).
- **BNPL-revert reconciliation cron** — clear `refund_payable ↔ escrow_held` for a `processing` refund when the
provider confirms the customer cash-back (webhook via `IWebhookVerifier`; deferred/manual today).
- **b12** — real `IBnplProvider`.
- **b13** — clawback netting/recovery + real `INursePayoutStatus`.
- **b15** — `tickets` FK on `refunds` (+ flip `refund_ticket_required` on), `partner_centers` on `invoices`,
`nurse_payouts` FKs on `nurse_clawbacks`.
@@ -0,0 +1,97 @@
# Backend phase 12 — BNPL: provider-financed installments (mocked) — report
**Mission:** let a family pay for a booking with a provider-financed BNPL plan, and record it correctly — a BNPL
order is, in Balinyaar's books, **a card payment that lands net-of-fee**.
## What was built
### Domain (`Baya.Domain/Entities/Bnpl/`)
- **`BnplTransaction`** — one row per order, **1:1 with its `payment_transaction`**. Guarded `Status` mutated only
through cohesive `MarkTokenIssued`/`MarkVerified`/`MarkSettled`/`MarkReverted`/`MarkCancelled`/`MarkFailed`;
`MarkSettled` enforces `settled = order commission` (≥0). Money is IRR `long`; `settled_at`,
`provider_commission_reversed_amount`, and all settle/revert amounts are nullable.
- **`BnplStatus`** + **`BnplTransitions`** — the forward-only state machine (`eligible → token_issued → verified →
settled → reverted/cancelled/failed`), the idempotency spine.
- **`BnplEligibilityStatus`** (`eligible`/`not_eligible`/`ceiling_exceeded`), **`BnplProviderCodes`**
(`snapppay`/`digipay`/`tara`/`torobpay`).
- **`LedgerPosting.BnplSettle`** — the net-of-fee group (card-capture legs **plus** `DEBIT bnpl_fee_expense /
CREDIT escrow_held`), one balanced `transaction_group_id`, `SourceRefType = bnpl_transaction`. Throws if the
capture legs don't reconcile.
### Application (`Baya.Application/Features/Bnpl/`)
- Queries: **`CheckBnplEligibilityQuery`** (records `eligibility_status` on a created/updated row),
**`GetBnplOrderStatusQuery`** (admin/customer, tenancy-scoped).
- Commands: **`InitiateBnplOrderCommand`** (token + `eligible → token_issued`, under
`lock(booking-request:{id}:payment)`, idempotency-keyed), **`VerifyBnplOrderCommand`**, **`SettleBnplOrderCommand`**
(net-of-fee ledger + booking conversion, under `lock(bnpl:{id}:settle)`), **`RevertBnplOrderCommand`** (reuses
b11 `CreateRefundCommand`), **`HandleBnplCallbackCommand`** (webhook dedup + dispatch by event type).
- **`BnplOrderInitializer`** (shared find-or-create of the pending `payment_transaction` + 1:1 BNPL row) and the
extracted **`BookingConversion`** helper (shared by b10 card capture + b12 settle — b10 was refactored to use it).
- New seams in `Contracts/Payments/`: **`IBnplProvider`** (full verb set, supersedes the b11 revert-only stub),
**`IBnplProviderResolver`**, **`ICurrencyNormalizer`**; `IBnplRepository` on `IUnitOfWork`.
### Infrastructure
- **`Persistence`**: `BnplTransactionConfig` (`payments.BnplTransactions`, `UNIQUE(payment_transaction_id)`,
`CK_BnplTransactions_SettleSplit`, filtered token index), `BnplRepository`, `UnitOfWork` wiring, one migration
**`BnplTransactions`**. `IRefundRepository.GetExternalRevertReferenceAsync` added for the revert audit.
- **`CrossCutting/Seams`**: `MockBnplProvider` (deterministic full state machine), `MockBnplProviderResolver`,
`MockCurrencyNormalizer`; `SeamOptions` extended (`BnplOptions` + `CurrencyOptions`); DI registration in
`AddCrossCuttingSeams`.
- **`API`**: `CheckoutBnplController`, `WebhooksBnplController`, `AdminBnplController`.
## What is now testable and exactly how (per phase §7)
Seed a `pending_payment`/accepted booking request with a known three-amount split and a `payment_gateways` row
`type='bnpl', provider_code='snapppay'`; mock commission % via `Seams:Bnpl:CommissionRate` (default 10%).
1. **Eligibility** — `POST api/v1/checkout_bnpl/eligibility` → `eligible` + plan summary; a `bnpl_transactions`
row exists with `eligibility_status` set and `status='eligible'`. *(Covered:
`BnplHandlerTests.Eligibility_creates_row_eligible_and_returns_plan`, `BnplApiTests` eligibility.)*
2. **Initiate** — `POST api/v1/checkout_bnpl/initiate` → `status='token_issued'`, deterministic token + redirect;
1:1 with the `payment_transaction`; a second initiate reuses the same row.
*(`BnplHandlerTests.Initiate_issues_token_and_is_strictly_one_to_one`.)*
3. **Verify → settle (the ledger)** — drive `POST api/v1/webhooks_bnpl/snapppay` (`order.verified` then
`order.settled`) or the admin settle → `verified → settled`; `settled_amount = order commission`, ledger shows
the balanced net-of-fee group and net `escrow_held = settled_amount`.
*(`BnplHandlerTests.Settle_posts_net_of_fee_group_…`, `BnplApiTests.Full_flow_…`, `BnplLedgerAndStateTests`.)*
4. **Payout invariance** — `nurse_payable` credited = `gross balinyaar_commission`, **identical to the card path**
and independent of the BNPL commission. *(Asserted in both the handler + api full-flow tests, compared against
`LedgerPosting.CardCapture`.)*
5. **Replayed settle is a no-op** — re-deliver the same settle callback → webhook dedup + state guard reject it;
no second ledger group. *(`BnplApiTests.Replayed_settle_callback_is_idempotent_no_second_ledger`,
`BnplHandlerTests.Replayed_settle_is_a_noop_…`.)*
6. **Revert** — `POST api/v1/admin_bnpl/{id}/revert` → `status='reverted'`, revert audit set; a `refunds` row with
`refund_channel='bnpl_revert'` + `expected_customer_refund_eta`; reversal ledger posts.
*(`BnplApiTests.Admin_revert_reverses_a_settled_order_…`.)*
7. **Status** — `GET api/v1/admin_bnpl/{id}` (admin) / `GET api/v1/checkout_bnpl/{id}` (customer, own only).
8. **Guards** — settle-before-verify is a 409; the state machine rejects illegal edges.
*(`BnplHandlerTests.Settle_before_verify_…`, `BnplLedgerAndStateTests.State_machine_…`.)*
Full suite: **314 pass** (4 identity + 214 foundation + 96 api). Build clean, 0 new code warnings.
## What is mocked + how to make it real
- **`IBnplProvider`** (per `provider_code` via **`IBnplProviderResolver`**) — deterministic mock, no network;
settle returns `order round(order × Seams:Bnpl:CommissionRate)` with the commission read from the response.
Real: one adapter per code — **SnappPay** OAuth `api/online/v1/oauth/token` + `offer/v1/eligible` +
`payment/v1/token|verify|settle|revert|cancel|update|status`, or **Digipay** UPG `tickets/business?type=13` +
`purchases/verify` + `purchases/deliver?type=13` + `refunds`/`reverse`; creds from the encrypted
`payment_gateways.config_json`; per-contract commission read from the settle response. **Do not use the unrelated
Canadian `SnapPayInc/open-api-java-sdk`.**
- **`ICurrencyNormalizer`** — mock ×10 Toman→IRR (`Seams:Currency:TomanToIrrMultiplier`). Real: read the
multiplier/unit from provider config at the boundary.
- **`settled_at` is nullable / non-instant** (`Seams:Bnpl:SettlementInstant`) — the mock models the
daily/T+13/weekly reality. **b13 must not assume BNPL cash funds a payout.**
## Contracts produced / consumed
- **Produced:** `dev/contracts/domains/bnpl.md`; `dev/contracts/openapi/swagger.v1.json` refreshed
(386K → 411K, all BNPL paths present).
- **Consumed:** b10 `payment_transactions`/`ledger_entries`/`payment_webhook_events`/`IWebhookVerifier`/
`IDistributedLock`/`LedgerPosting`; b11 `refunds`/`CreateRefundCommand`/`refund_channel`; b9 booking split +
`BookingFactory`; b1 typed config accessor.
## Follow-ups
- **b13:** the `settled_at`-gates-payout coupling — add `require_bnpl_settlement_for_payout` and gate a BNPL
booking's payout on `bnpl_transactions.settled_at`.
- **DEFERRED:** `bnpl_settlement_entries` (tranched settlement — modeled-but-not-built, additive migration later);
multi-provider routing/failover (one active route today); `provider_commission_reversed_amount` reconciliation
on revert (left null when the b11 refund path drives it).
- The `BnplTransactions` migration was **not applied to a live DB** this session (no reachable SQL Server in the
agent env); apply with `dotnet ef database update` before the next live run, and seed a `type='bnpl'` gateway.
@@ -0,0 +1,76 @@
# Backend Phase 13 report — Weekly nurse payouts (mocked bank transfer)
**Date:** 2026-07-09 · **Track:** backend · **Status:** complete, gate green.
## What was built
- **`payouts` schema, 3 tables** (one migration `NursePayoutEngine`):
- `NursePayoutBatches` — weekly aggregation; `period_end`/`processing_date` holiday-shifted; `total_amount` /
`payout_count`; `status` (`draft|processing|partially_failed|completed|failed`); `initiated_by_admin_id` FK.
- `NursePayouts` — one per nurse per batch; DB CHECK `net = gross clawback` + all ≥ 0; **encrypted
`iban_snapshot`** (EF converter) frozen from the verified primary account; `status` (`pending|submitted|paid|failed`,
forward-only); `transfer_reference`, `paid_at`, `failure_reason`.
- `NursePayoutBookingLinks`**unconditional `UNIQUE(booking_id)`** (the one-payout-per-booking-ever guard);
nullable `session_id` for a future per-session model.
- **Domain:** `PayoutBatchStatus`/`PayoutStatus` + `*Transitions`; `LedgerPosting.NursePayout` (DEBIT nurse_payable /
CREDIT escrow_held) + `LedgerPosting.ClawbackRecovery` (DEBIT nurse_payable / CREDIT nurse_clawback_receivable);
`NurseClawback.Recover(payoutId, now)`.
- **Application:** `Features/Payouts/{Commands|Queries}``ComputeEligibleEarnings`, `GeneratePayoutBatch`
(build+link inline), `ExecutePayoutBatch`, `RetryFailedPayout`, `MarkPayoutFailed`, `GetBatchDetail`,
`ListPayoutBatches`, `GetNursePayoutHistory`; shared `PayoutSettlement` (ledger post + clawback netting).
`IPayoutRepository` on `IUnitOfWork`.
- **Infrastructure:** `PayoutRepository`, `PayoutsConfig/*`, `MockBankTransferProvider` (+ `BankTransferOptions`),
the authoritative `NursePayoutLinkStatusService` (swapped for the deleted interim `NursePayoutStatusService`),
`iban_snapshot` encryption wired in `ApplicationDbContext`, 2 new `platform_configs` seeds.
- **API:** `AdminPayoutsController` (admin, rate-limited) + `NursePayoutsController` (nurse, tenancy-scoped).
## What is now testable and exactly how (per §7 of the phase)
Seed a few **completed** bookings via the admin flow: some with `dispute_window_ends_at` in the past (eligible), some
future (not yet), one disputed, one with a pending clawback; one nurse with a verified primary IBAN, one without.
1. **Eligibility preview**`GET admin_payouts/eligible?periodStart=&periodEnd=` → only completed + dispute-window-
closed, unpaid bookings appear, grouped by nurse; future + disputed excluded; the no-IBAN nurse flagged
(`hasVerifiedPrimaryIban=false`). *(unit: `Preview_includes_only_closed_window_and_flags_missing_iban`)*
2. **Generate a batch**`POST admin_payouts/batches` → a `draft` batch, one payout per eligible nurse; the nurse
with a pending clawback shows `clawbackAppliedIrr>0` and `net = gross clawback`; `total_amount = Σ net`;
`iban_snapshot` populated (encrypted, served masked). *(unit: `Generate_materializes_one_payout_per_nurse_and_nets_clawback`,
`Generate_skips_nurse_without_verified_primary_iban_with_reason`)*
3. **Double-pay guard** — a second generate over the same window doesn't re-select the linked bookings.
*(unit: `Double_pay_guard_second_generate_does_not_reselect_linked_bookings`)*
4. **Holiday shift**`period_end`/`processing_date` shift off a seeded bank-closed day.
*(unit: `Holiday_shifts_period_end_and_processing_date`)*
5. **Execute**`POST admin_payouts/batches/{id}/process` → payouts go `paid` with a `transfer_reference`; the ledger
shows balanced `DEBIT nurse_payable / CREDIT escrow_held` per payout (the payable balance drops by the paid amount);
a netted clawback is marked `recovered` with `recovered_in_payout_id`. *(unit:
`Execute_posts_balanced_payout_ledger_and_drains_payable`, `Execute_recovers_clawback_and_posts_recovery_leg`)*
6. **Idempotency** — re-process → no second transfer / ledger group. *(unit: `Reprocess_is_idempotent_no_second_ledger_group`)*
7. **Failure / retry** — force a rail failure → `partially_failed`; `retry` (rail back to success) → `paid`, batch
`completed`. *(unit: `Partial_failure_then_retry_completes_the_batch`)*
8. **Nurse history**`GET nurse_payouts/history` as the nurse → their payouts (masked IBAN, net, reference);
another nurse's are invisible. *(api: `NursePayoutsApiTests`)*
API happy-path/401/400: `AdminPayoutsApiTests` (generate→process pays the nurse; 401 unauth; 400 bad period; list)
and `NursePayoutsApiTests` (401 unauth; own paid payout with masked IBAN).
## What is mocked + how to make it real
- **`IBankTransferProvider`** (🟡) — PAYA/SATNA rail. Make real = a Jibit/Vandar/Sadad payout adapter with a
registered source settlement account, per-nurse verified Sheba, PAYA-vs-SATNA selection, batch caps/minimums, and
the async reconciliation callback that flips `submitted → paid/failed`. Config keys `Seams:BankTransfer:*`.
- **`INursePayoutStatus`** (🟢) — now the real link-based lookup (`NursePayoutLinkStatusService`).
- Reused mocks: `IHolidayCalendar`, `IFieldEncryptor`, `IDistributedLock`, `ICacheService`.
## Contracts produced
- `dev/contracts/domains/payouts.md` (new) · `dev/contracts/openapi/swagger.v1.json` refreshed (7 payout paths).
## Confirmed rules recorded (product/business/10-payouts.md §d1)
- Clawback netting recovers **whole** clawbacks up to a batch's earnings (never negative net, never a partial single
clawback); a clawback larger than a batch's earnings waits for a later batch. Recovery is a real ledger movement.
- A booking with an active refund is held out of payouts (the operational reading of "no open dispute").
- `payout_satna_threshold_irr` picks PAYA vs SATNA; `require_bnpl_settlement_for_payout` (default off) gates BNPL.
## Follow-ups (deferred)
- The weekly **cron scheduler** (PAYA-aligned) — entry point is `GeneratePayoutBatchCommand`; cadence in
`nurse_payout_interval_days`. On-demand/instant withdrawal; per-nurse payout frequency; automated clawback recovery
beyond next-batch netting; the BNPL `settled_at` timing guard (flag shipped, off).
## Gate
`dotnet build Baya.sln` — 0 errors, 0 new code warnings (only pre-existing NU1510/NETSDK1057/NU1903).
`dotnet test Baya.sln` — 329 pass (223 foundation + 102 api + 4 identity), 0 fail.
@@ -0,0 +1,91 @@
# Backend Phase 14 — Reviews, ratings & patient care records — Report
**Date:** 2026-07-09 · **Track:** backend · **Status:** complete, gate green.
Closes the trust loop (moderated reviews + an honest, recomputed-from-source public rating + low-rating safety
alerts) and the continuity-of-care loop (encrypted, patient-scoped clinical notes under strict access).
## What was built
### Schema (one additive migration `ReviewsAndPatientCareRecords`, `reviews` schema)
- **`Reviews`** — one per completed booking. `UNIQUE(booking_id)` (1:1), `CHECK(rating BETWEEN 1 AND 5)`, guarded
`moderation_status` (+ reason/moderator/time), soft-delete filter. Indexes: unique `booking_id`,
`(nurse_profile_id, moderation_status)`, `moderation_status`. Marked `IAuditable` → the SaveChanges interceptor
writes an append-only `audit_logs` diff on creation and every moderation transition.
- **`ReviewTagsMaster`** — seeded vocabulary (`punctual/professional/clean/kind/communicative`), `UNIQUE(code)`.
- **`ReviewTagLinks`** — N:N join, `UNIQUE(review_id, review_tag_master_id)`.
- **`PatientCareRecords`** — nurse-authored, **patient-scoped** (`patient_id`, not booking), nullable `booking_id`
provenance, `body_encrypted` (ciphertext, no EF converter), `(patient_id, recorded_at)` index, soft-delete.
- **`NurseProfile`** gained `SetReviewAggregates(avg, count)` — the single sanctioned write for the existing
`AverageRating`/`TotalReviews` columns (no new aggregate table).
### Features (CQRS, `Baya.Application/Features/`)
- **Reviews/**: `SubmitReviewCommand`, `ModerateReviewCommand`, `AttachReviewTagsCommand`,
`ListReviewsForNurseQuery`, `GetReviewModerationQueueQuery`, `GetTagAggregatesQuery`; plus the internal
`RecomputeNurseRating` from-source helper and `ReviewCache` (cached public aggregate + eviction).
- **PatientCareRecords/**: `WritePatientCareRecordCommand`, `GetPatientHistoryQuery`.
### Endpoints (5 controllers)
| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `POST /v1/bookings/{bookingId}/review` | SubmitReview | customer (owns booking) |
| `POST /v1/reviews/{reviewId}/tags` | AttachReviewTags | author / moderator |
| `PATCH /v1/reviews/{reviewId}/status` | ModerateReview | admin / moderator |
| `GET /v1/nurses/{nurseProfileId}/reviews` | ListReviewsForNurse | public |
| `GET /v1/nurses/{nurseProfileId}/review_tags` | GetTagAggregates | public |
| `GET /v1/admin/reviews/moderation_queue` | GetReviewModerationQueue | admin |
| `POST /v1/patients/{patientId}/care_records` | WritePatientCareRecord | nurse (confirmed booking) |
| `GET /v1/patients/{patientId}/care_records` | GetPatientHistory | owner / nurse / admin |
### Key design decisions (non-obvious)
- **Recompute is exclude-and-fold, in one transaction.** A fresh LINQ query can't see the tracked, uncommitted
status change, so `RecomputeNurseRating` reads `COUNT`/`SUM(rating)` over the nurse's published reviews
**excluding the transitioning review** (id `0` for a brand-new one), then folds in that review's *new* status in
memory. Correct-from-source and single-commit — no `+delta`/`-delta`, no stale re-query, no second commit.
- **Care records encrypt manually (no EF value converter).** Unlike the b3/b9 converter-backed columns,
`body_encrypted` stores ciphertext directly; the handler `Encrypt`s on write and `Decrypt`s only after the access
check passes — so no query path (list, projection) can ever surface plaintext. `recorded_at` is `DateTime` (UTC),
not `DateTimeOffset`, so the SQLite test provider can `ORDER BY` it.
- **AI verdict → initial disposition.** `SubmitReview` calls `IReviewModerationService.ScreenAsync`; the verdict
maps to the initial status (`Approve`→published, `Reject`→hidden, else pending). The mock defaults clean text to
a human-review **Flag** so the **publish gate holds by default** (reviews land `pending_moderation`);
`Seams:ReviewModeration:AutoApproveClean` opts into auto-publish. Human `ModerateReview` always overrides.
## What is now testable and exactly how (the phase §7 steps)
Run the API against SQL Server; use Swagger/curl.
1. **Submit on a completed booking → accepted.** `POST bookings/{completedId}/review {rating:5}` as the owner →
`200`, `moderationStatus: pending_moderation`. It does **not** appear in `GET nurses/{id}/reviews` (publish gate).
2. **Submit on a cancelled booking → rejected;** a second submit on an already-reviewed booking → conflict (1:1).
3. **Moderate publish recomputes up.** `PATCH reviews/{id}/status {action:"publish"}` → the review appears in the
public list; the aggregate `publishedCount` increments and `averageRating` reflects it.
4. **Moderate hide recomputes down.** Publish a 5★ and a 1★, then `hide` the 1★ → the public list drops it and the
aggregate rises / count decrements (re-derived from source).
5. **Low rating raises an alert.** `POST … {rating:1}` → a `low_rating` `support_alerts` row exists (visible only on
the admin/internal path — never in a user response; its id shows on the moderation queue row).
6. **Write + read a care record.** As a nurse with a confirmed booking, `POST patients/{P}/care_records``200`;
the stored column is ciphertext. As the owning customer or that nurse, `GET …` → decrypted note, newest first.
7. **Unauthorized nurse denied.** A nurse with no confirmed booking for P → `403` on both write and read.
**Automated coverage:** 13 Foundation handler tests (`Baya.Test.Foundation/Reviews/`) cover steps 17 incl. the
recompute-from-source math (hide lowers count **and** average) and ciphertext-at-rest; 4 API tests
(`Baya.Test.Api/ReviewsApiTests.cs`) cover the HTTP pipeline (public reads anonymous, admin/patient reads 401
without a token). Full suite: **346 pass**, build clean (0 new warnings).
## What is mocked / waiting on a real service
- **`IReviewModerationService`** (introduced here) — `MockReviewModerationService` (keyword filter / pass-through),
config `Seams:ReviewModeration:{AutoApproveClean,BannedWords}`. Make it real → a text classifier / LLM endpoint,
registration-only swap (see reports/mocks-registry.md, row → 🟡). `ModerateReviewCommand` keeps decision authority.
- Reused seams: `IFieldEncryptor` (clinical notes), `ISearchIndexMaintainer` (aggregate refresh),
`ISupportAlertService` (low-rating alert), `INotificationDispatcher` (review-outcome notice), `IPlatformConfig`
(`min_rating_for_support_alert`), `ICacheService` (aggregate cache).
## Contracts produced
- `dev/contracts/domains/reviews-records.md` (8 endpoints, enums, DTOs, care-record access matrix).
- `dev/contracts/openapi/swagger.v1.json` refreshed (all 8 paths present).
## Follow-ups for later phases (b15)
- Ticket system, partner centers, and the admin **support-alert worklist console** (this phase only *raises*
`low_rating` alerts).
- `SuspendNurse` / `ResolveSupportAlert` / `FlagConcern` admin actions.
- Deferred by design: two-way double-blind reviews with timed reveal; a first-class `incidents` entity + ML fraud
scoring; optional structured `vitals_encrypted` on care records.
@@ -0,0 +1,86 @@
# Backend Phase 15 report — Messaging (tickets), partner centers & admin backoffice
**The final backend phase.** It closes the operational loop: the ticket system, the licensed partner centers
(merchant-of-record), and the consolidated admin backoffice. The backend chain is now complete.
## What was built
### Messaging (tickets) — new `messaging` schema
- Entities `Domain/Entities/Messaging/`: `Ticket`, `TicketParticipant`, `TicketMessage` + `TicketStatus` /
`TicketCategory` / `TicketParticipantRole` code sets. Configs in `Persistence/Configuration/MessagingConfig/`.
- `ITicketRepository` (+ `TicketRepository`) on `IUnitOfWork`.
- Features `Application/Features/Messaging/`: `OpenTicket`, `AutoCreateCoordinationTicket`, `PostMessage`,
`AddParticipant`, `RemoveParticipant`, `CloseTicket`, `ReopenTicket`, `LogEmergencyTicket`, `GetTicketThread`
(role-aware user/admin view), `ListMyTickets`, `ListTicketsForAdmin`. Shared helpers `TicketReferenceCode`
(collision-checked mint) + `TicketRoleResolver` + `StaffRoles` (Application/Common).
- Controllers `TicketsController` (authenticated) + `AdminTicketsController` (`support`/`admin`).
### Partner centers — new `partner` schema
- Entity `Domain/Entities/PartnerCenters/PartnerCenter` (`IAuditable`; `settlement_iban` `[AuditRedacted]` +
encrypted converter in `ApplicationDbContext`). Config in `Persistence/Configuration/PartnerCentersConfig/`
(also adds the `nurse_profiles.partner_center_id` FK in place). `IPartnerCenterRepository` (+ impl).
- Features: `CreatePartnerCenter`, `UpdatePartnerCenter`, `VerifyPartnerCenter`, `SponsorNurse`,
`GetCenterForBooking` (the merchant-of-record resolver), `ListPartnerCenters`, `GetPartnerCenterById`,
`GetCenterDashboard`. Controllers `AdminPartnerCentersController`, `CentersController` (portal),
`InternalCentersController` (resolver).
### Seam
- **`ILicenseVerificationService`** (`Application/Contracts/Common`) + `MockLicenseVerificationService`
(`CrossCutting/Seams/`, registered in `AddCrossCuttingSeams`, config `Seams:LicenseVerification:AutoApprove`).
### Cross-phase wiring
- b11 `IssueInvoiceCommand` now sets `invoices.issuing_entity_type` + `partner_center_id` from
`ResolveCenterForBookingAsync` (the single merchant-of-record resolver).
- b11 `CreateRefundCommand` auto-opens a `category=refund` ticket via `OpenTicketCommand` when the caller passes
none, so `refunds.ticket_id` is always non-null (replaces the old config-gated "ticket required" check).
- The card `ConfirmPaymentAndPostLedger` and BNPL `SettleBnplOrder` handlers dispatch
`AutoCreateCoordinationTicketCommand` after a booking is confirmed (idempotent, one per booking).
### Reused, not rebuilt (admin backoffice consolidation)
- Support-alert worklist (`ISupportAlertService` List/Assign/Resolve — `SupportAlertsController`) and the audit
viewer (`GetAuditTrail``AuditController`) already existed since b1; verified as the backoffice surface.
Verification/refund/payout/moderation queues are their own phases' endpoints.
## What is now testable and exactly how (the §7 steps)
1. **Open + message:** `POST /api/v1/tickets` (no links) → 200 with a `TKT-…` `referenceCode` + opener as
participant; `POST /api/v1/tickets/{id}/messages` → the message appears in the thread.
2. **Internal boundary (proven by a test):** admin `POST …/messages {isInternal:true}` → 200; user
`GET /api/v1/tickets/{id}` omits it; admin `GET /api/v1/admin/tickets/{id}` includes it; a non-staff
`isInternal:true` → 403. (`MessagingApiTests.InternalNote_IsHiddenInUserView_ShownInAdminView` + `NonAdmin_CannotSetInternal`.)
3. **Participant uniqueness:** add a user → 200; add again → **409** (not 500); delete → 200.
(`MessagingApiTests.AddParticipant_DuplicateIsConflict_NotServerError`.)
4. **Partner center + masked IBAN:** `POST /api/v1/admin/partner-centers {isMerchantOfRecord:true, settlementIban}`
→ 200 with `settlementIbanMasked` (last 4), never plaintext; `GET …/{id}` masks it too; created inactive.
(`PartnerCentersApiTests.CreateMerchantOfRecord_MasksSettlementIban`, `Verify_ActivatesTheCenter`.)
5. **Merchant-of-record resolution:** `GET /api/v1/internal/bookings/{id}/center``partner_center` (+ id) for a
nurse sponsored by a merchant-of-record center, `platform` otherwise. (`CenterForBookingTests`, 4 cases.)
6. **Refund anchors a ticket:** `CreateRefund` yields a non-null `refunds.ticket_id` (foundation refund tests
pass with the auto-open wired via `TestSenders.WithTicketHooks()`).
7. **Admin worklists / RBAC:** support alerts + audit reachable under admin scope; a non-admin token on an admin
route → 403 (`PartnerCentersApiTests.NonAdmin_IsForbidden`), unauthenticated → 401.
8. **Audit:** admin state changes (e.g. `VerifyPartnerCenter`) append an `audit_logs` row (`PartnerCenter` is
`IAuditable`; `settlement_iban` is redacted in the diff).
## What is mocked / waiting on a real service
- `ILicenseVerificationService` → manual-approve at MVP (no public eNamad/MoH B2B API). Make-it-real steps in
`reports/mocks-registry.md` (🟡). No telephony seam — the emergency call is out-of-platform by design.
## Contracts produced
- `dev/contracts/domains/messaging-notifications-admin.md`; `dev/contracts/openapi/swagger.v1.json` refreshed
(now includes tickets, partner centers, the center resolver).
## Gate
- `dotnet build Baya.sln` — 0 new code warnings. `dotnet test Baya.sln` — green: 4 identity + 240 foundation +
114 API (12 new tests this phase). Migration `MessagingAndPartnerCenters` scaffolds cleanly.
## Decisions / notes for the future
- **Merchant-of-record** = `partner_center` issuer only when the sponsoring center `is_merchant_of_record`; a
non-MoR sponsor leaves the platform as issuer (so a sponsored-but-platform-billed nurse is representable).
- **Participant removal** is a soft `removed_at` stamp (not a hard delete / not `deleted_at`), so the
`UNIQUE(ticket_id, user_id)` row survives and a re-add resurrects it.
- **SQLite gotcha (again):** messages are ordered by the monotonic `Id` (== send order), never `ORDER BY sent_at`
(`DateTimeOffset`), which the SQLite test provider can't translate.
- **Follow-ups:** the invoice-issuer wire sets the columns but the downstream settlement rail (paying a center's
IBAN when it is MoR) is not exercised end-to-end here; the center dashboard caps the sponsored-nurse list at 50
(count is exact) — paginate it if a center grows large. Bookings/invoices `partner_center_id` columns exist
without a DB FK (only `nurse_profiles` got the FK, per the DoD).
@@ -0,0 +1,114 @@
# Backend phase 2 report — Identity: phone-OTP auth, sessions & roles (REST)
**Date:** 2026-07-02 · **Gate:** `dotnet build` clean (0 new code warnings) · `dotnet test` green
(47 pass — 33 Foundation, 4 Identity, 10 new Api) · migration applied to the dev DB · full manual
flow verified against a live run.
## What was built
**REST auth surface** (wraps the existing JWE/TOTP/RBAC engine — nothing rebuilt):
- `Features/Identity/``RequestOtpCommand`, `VerifyOtpCommand`, `RefreshTokenCommand`,
`LogoutCommand`, `GetMeQuery`, `SelectRoleCommand` (records + `internal sealed` handlers +
FluentValidation validators; expected failures via `OperationResult`).
- `Controllers/V1/AuthController` (`request_otp`/`verify_otp` on the `otp` rate-limit policy,
`refresh` on `auth` + `RequireTokenWithoutAuthorization`, `logout` authenticated) and
`MeController` (`GET me`, `POST me/select_role`, `[Authorize]`).
- OTP delivery through the new **`ISmsSender`** seam (`LoggingSmsSender` mock in CrossCutting —
logs the code, phone masked to last-4; registered in `AddCrossCuttingSeams`). The old
`//TODO Send Code Via Sms Provider` log lines in `UserCreateCommand`/`UserTokenRequestQuery`
handlers are gone — they call the seam too.
**Schema (migration `20260701222425_IdentitySessionsAndUserExtensions`, onto b1's baseline):**
- `usr.Users` + `Gender` (NVARCHAR(10) NULL, load-bearing, not collected at signup), `NationalId`
(enc, NULL until b6 KYC), `NationalIdVerifiedAt`, `ShahkarVerifiedAt`, `PhoneHash` (NVARCHAR(64),
filtered UNIQUE), `PhoneVerifiedAt`, `IsActive` (default 0), `DeletedAt` + global soft-delete
filter.
- New `usr.UserSessions` (`RefreshTokenHash` unique-indexed, `DeviceInfo`, `IpAddress`, `IsRevoked`,
`RevokedAt`, `ExpiresAt`, audit fields; index `(UserId, IsRevoked)`).
- `usr.UserRoles` + `GrantedById` (FK→Users NULL) / `GrantedAt` / `RevokedAt`, with a global
`RevokedAt IS NULL` filter so every role read (Identity store, JWT claims factory, `/me`) respects
revocation automatically.
- Data-fix SQL clears pre-b2 plaintext phone/email values (encrypted from now on) and re-activates
the seeded admin.
- Roles seeded at startup (`SeedDataBase`): `customer`, `nurse`, `admin`, `support`, `finance`,
`moderation`, `super_admin`.
- `platform_configs` + `auth_otp_resend_seconds` (120), `auth_otp_max_attempts` (5),
`auth_session_ttl_days` (30) — read via `IPlatformConfig` at compute time.
**Cross-cutting changes (in place, noted per operating-rules §0.3):**
- `ApplicationDbContext` now takes `IFieldEncryptor`; `PhoneNumber`/`Email`/`NormalizedEmail`/
`NationalId` are encrypted at rest via `ValueConversion/EncryptedStringConverter`; a SaveChanges
hook syncs `PhoneHash` and **resets `ShahkarVerifiedAt` on any real phone change** (rule enforced
centrally, no handler can forget it).
- `AppUserManagerImplementation.IsExistUser/GetUserByPhoneNumber` and
`JwtService.GenerateByPhoneNumberAsync` now look up by `PhoneHash` (encrypted columns are
equality-unqueryable by design).
- `IJwtService.GenerateAccessTokenAsync` added — access token only; the legacy `GenerateAsync`
(which also writes a `UserRefreshTokens` row) still feeds the gRPC path. I deliberately did **not**
use `GenerateByPhoneNumberAsync` for the REST flow (the phase sketch suggested it) because it
would double-book a legacy refresh row next to the new session.
- `ICurrentUser` gained `IpAddress` (session bookkeeping), implemented in `HttpContextCurrentUser`
/ null in `NullCurrentUser`.
- `OperationResult<T>` gained `IsUnauthorized`/`IsForbidden` (+ factories) and `BaseController`
maps them to enveloped 401/403 — the phase requires handler-driven 401 (reuse detection) and 403
(admin self-assign), which the envelope previously couldn't express.
- `Program.cs` skips migrations/seed in the **Testing** environment and exposes
`public partial class Program` for `WebApplicationFactory`; Serilog logs to console for Testing.
## Design decisions (the "why")
- **Rotation + reuse detection:** refresh looks the session up by `Hash(token)` (raw token never
stored). Active → revoke + issue a new pair/session. Already-revoked → stolen-token signal →
revoke **all** the user's sessions, 401. Expired → revoke, 401.
- **Logout:** revokes the presented session (or all, when `everywhere`/no token) **and** rotates the
security stamp (the existing `RequestLogout` mechanism), so the JWE `OnTokenValidated` stamp check
kills outstanding access tokens on every device; other devices recover via refresh.
- **No enumeration:** `request_otp` returns the same shape for known/unknown phones; `verify_otp`
returns one safe message for unknown-phone and wrong-code.
- **Per-phone resend window** lives in the handler behind `ICacheService` (keyed by phone hash) —
the per-IP `otp` rate-limit policy can't see request bodies. Repeated requests inside the window
return `200 { otpSent: false, resendAvailableInSeconds }`; hammering the endpoint returns `429`.
- **Attempt limiting:** wrong codes increment Identity's `AccessFailedCount`; at
`auth_otp_max_attempts` verification refuses. Requesting a fresh OTP resets the counter (bounded
by the per-IP limit + the ~3-min TOTP window).
- **New-user accounts** are created at `request_otp` with a surrogate `UserName` (`u_<guid>`) so the
plaintext phone never lands in `UserName`/`NormalizedUserName`; `IsActive` stays false until the
first successful verify.
- **`isNewUser`** = "phone was not previously confirmed".
## What is now testable (and how)
Run the API (Development, reachable SQL Server), then:
1. `POST api/v1/auth/request_otp` `{"phone":"09120000000"}``200 {otpSent:true}`; the code appears
in the server log (mock SMS). Immediate repeat → `200 {otpSent:false}`; >5/min per IP → `429`.
2. `POST api/v1/auth/verify_otp` with the logged code → `200` access/refresh pair, `isNewUser:true`,
`roles:[]`; a `usr.UserSessions` row exists (`IsRevoked=0`).
3. `GET api/v1/me` with the bearer → `200` masked phone, empty roles, flags false,
`nurseVerificationStatus:"not_started"`; without it → `401`.
4. `POST api/v1/me/select_role` `{"role":"customer"}``200` (roles now `["customer"]`); `"nurse"`
also succeeds (both held); `"super_admin"``403`.
5. `POST api/v1/auth/refresh``200` new pair; **replaying the old refresh token → `401`** and all
of the user's sessions are revoked.
6. `POST api/v1/auth/logout``200`; the prior access token now fails `/me` with `401`.
7. `verify_otp` with a wrong code → `400` (safe message).
All seven were executed against a live run on 2026-07-02 (plus the `429`) — exact behaviour
confirmed. The same flows are automated in `Baya.Test.Api` (WebApplicationFactory over in-memory
SQLite) and 14 NSubstitute handler unit tests in `Baya.Test.Foundation/Identity/`.
## Mocked / waiting on a real service
- **`ISmsSender` → 🟡** (see `mocks-registry.md` for the make-it-real steps: gateway client package,
`Seams:Sms` config keys, swap the registration).
- OTP TTL is the TOTP provider's window (~3 min), provider-fixed — the config keys govern resend,
attempts, and session TTL.
## Contracts produced
- `dev/contracts/domains/identity-auth.md` (routes, shapes, enums, failure semantics, masked-phone
note, gender note).
- `dev/contracts/openapi/swagger.v1.json` refreshed (22 paths).
## Follow-ups
- **b3:** profiles/patients/addresses/nurse bank accounts; gender/names become settable; `/me`
completion flags read real tables.
- **b6:** Shahkar/KYC populate `NationalId`/`ShahkarVerifiedAt`; real `nurseVerificationStatus`.
- Retire legacy `UserRefreshTokens` once the gRPC path moves to sessions (or is dropped).
- Model-validation WRN logs about the `Users` soft-delete filter vs required Identity navs
(`UserClaim`/`UserLogin`/`UserToken`/`UserRefreshToken`) are benign (log-only); tidy if they annoy.
@@ -0,0 +1,69 @@
# Backend phase 3 report — Identity: profiles, patients & nurse bank accounts
## What was built
- **Four domain entities** (`Baya.Domain/Entities/Identity/`): `NurseProfile`, `CustomerProfile`,
`Patient`, `NurseBankAccount`. `NurseProfile.is_verified` is write-guarded (private setter +
`MarkVerified()`/`MarkUnverified()` — only b6 calls it); `is_accepting_bookings` toggled via a domain
method; the search aggregates are read-only.
- **One EF migration** `IdentityProfilesPatientsBankAccounts` (schema `usr`): 1:1 uniques on
`user_id`, `UNIQUE(iban_hash)`, filtered `UNIQUE(nurse_id) WHERE is_primary=1`, soft-delete on
`NurseProfiles`, encrypted PII columns, audit fields. No CUT columns.
- **15 CQRS slices** under `Features/Identity/{Commands|Queries}/` + 4 `sealed : BaseController`
controllers (`NurseProfilesController`, `CustomerProfilesController`, `PatientsController`,
`NurseBankAccountsController`). Reads project to DTOs; lists paginate; the ownership-inquiry endpoints
are rate-limited (`sensitive` policy).
- **New seam `IBankAccountOwnershipVerifier`** (Application `Contracts/Common`) + mock
`MockBankAccountOwnershipVerifier` (CrossCutting), registered in `AddCrossCuttingSeams`, config-selected.
- **Persistence:** four per-domain repositories on `IUnitOfWork` (`NurseProfileRepository`,
`CustomerProfileRepository`, `PatientRepository`, `NurseBankAccountRepository`); encrypted-PII value
converters for the new columns wired in `ApplicationDbContext.OnModelCreating`; an atomic
`SetPrimaryAsync` (clear-then-set in one transaction) so the single-primary index never trips.
- **Infra fix:** `AddApplicationServices` now registers every `AbstractValidator<T>` as `IValidator<T>`
so the pre-existing `ValidateCommandBehavior` and `ModelStateValidationAttribute` filter actually
validate (they had **no** validators registered before this phase — validation was silently inert).
## What is now testable, and exactly how (mirrors the phase §7)
Log in as a nurse and a customer (b2 OTP flow), **refreshing the token after `select_role`** so the
role claim is present. Then:
1. **Nurse profile**`POST api/v1/nurse_profiles/upsert` → row created `is_verified=0`,
`is_accepting_bookings=0`; `GET …/me` shows aggregates at 0. No path sets `is_verified`.
2. **Accepting-bookings**`POST …/set_accepting_bookings` flips it; verified untouched.
3. **Customer profile**`POST api/v1/customer_profiles/upsert` with emergency contact → `GET …/me`
round-trips it through the encrypted column.
4. **Patient CRUD**`create` (gender required) / `list` / `get/{id}` / `update/{id}` / `archive/{id}`.
5. **Tenancy** — customer B calling `get`/`update` on customer A's patient id → **404**.
6. **Bank account + inquiry**`POST api/v1/nurse_bank_accounts/add` (normal IBAN) → `matched_national_id=true`,
vendor ref recorded; `list` shows the IBAN **masked**.
7. **Mismatch** — add the mismatch IBAN → `matched_national_id=false`.
8. **Duplicate IBAN** — re-add the same IBAN → clean `400` via `iban_hash` uniqueness.
9. **Primary flip** — add a 2nd account, `set_primary/{id2}` → account 2 primary, account 1 not; never two primaries.
Automated coverage: 15 handler unit tests (NSubstitute) covering profile upsert, role forbidden, patient
CRUD + cross-customer 404, bank add match/mismatch/duplicate + set-primary flip/not-owned; 13
`WebApplicationFactory` integration tests (one+ per controller: happy path, 401, validation 400, tenancy
404, mask, duplicate, primary flip, mismatch). `dotnet build` clean (0 new code warnings); `dotnet test`
green (75 pass).
## What is mocked / waiting on a real service
- **`IBankAccountOwnershipVerifier` (🟡)** — deterministic fake استعلام شبا. Make-it-real steps in
`reports/mocks-registry.md`. Reused seams: `IFieldEncryptor`, `ICurrentUser`, `IDateTimeProvider`.
## Contracts produced / consumed
- **Produced:** `dev/contracts/domains/identity-profiles.md`; `dev/contracts/openapi/swagger.v1.json`
refreshed (adds all nurse-profile / customer-profile / patient / bank-account paths).
- **Consumed:** b2 auth (login/roles), b0 seams (`IFieldEncryptor`/`ICurrentUser`/`IDateTimeProvider`),
b1 `IPlatformConfig` (available; not needed this phase).
## Follow-ups for later phases
- **b4:** `customer_addresses` + `nurse_service_areas` (need geography + geocoder) — deferred here.
- **b6:** the `is_verified` flip (verification-confirm transaction); Shahkar/KYC populate `national_id`;
the `bank_account_verification` step couples to `NurseBankAccounts`.
- **b13:** first-payout gate on `matched_national_id = true`.
- **b9/b14:** recompute `average_rating`/`total_reviews`/`total_completed_bookings` (read-only here).
- **Chain-wide:** validators are now active — new phases must ensure route-supplied ids aren't validated
in the body command, and can rely on FluentValidation for input rejection.
## Decisions taken (flagged for confirmation)
- A thin `customer_profiles` row is **auto-provisioned** on a customer's first patient (so patient
registration needs no separate profile step). Recorded in `product/data-model/01-identity-and-access.md`.
- IBAN is returned **masked (last-4)** on every read; first account added is primary by default.
@@ -0,0 +1,79 @@
# Backend phase 4 report — Geography, addresses & nurse service areas
## What was built
- **New `geo` schema + 5 tables (one migration `GeographyAddressesServiceAreas`):** `Provinces` 1:N
`Cities` 1:N `Districts` (reference hierarchy), `NurseServiceAreas` (nurse coverage), and
`usr.CustomerAddresses` (identity-domain saved locations). Each has an `IEntityTypeConfiguration<T>`, a
`deleted_at IS NULL` soft-delete filter, and audit-field wiring via the b0 interceptor.
- **Idempotent seed (b1 `HasData` path):** all 31 Iranian provinces (Tehran first, deterministic
`sort_order`), each province's capital city (covers the product's white-space targets — Tehran, Karaj,
Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom), and Tehran's 22 municipal مناطق. Fixed ids
(city = `100 + provinceId`; Tehran city `101`; Tehran districts `1001…1022`).
- **20 CQRS slices across 4 controllers:**
- `GeoController` (public): `provinces`, `cities?province_id=`, `districts?city_id=`, `tree` — projected,
cached, active-only with parent-active honoured.
- `AdminGeoController` (dynamic-permission): create/update/set_active for province/city/district; every
write invalidates the geo cache.
- `NurseServiceAreasController` (nurse): `add` (whole-city or city+district), `remove/{id}`, `list`.
- `CustomerAddressesController` (customer): `create`, `update/{id}`, `set_primary/{id}`, `delete/{id}`,
`list`.
- **New `IGeocoder` seam** (Application `Contracts/Common`; `MockGeocoder` in CrossCutting; DI in
`AddCrossCuttingSeams`; config `Seams:Geocoding`). Address create/update sets coordinates from it.
- **`409 Conflict`** added to the result envelope (`OperationResult.ConflictResult` / `IsConflict` /
`BaseController` → 409) — used for duplicate service areas.
- Per-domain repositories (`IGeoRepository`, `INurseServiceAreaRepository`, `ICustomerAddressRepository`)
on `IUnitOfWork`; encrypted value converters for the address PII columns.
## What is now testable and exactly how (per phase §7)
1. **Seed**`GET api/v1/geo/provinces` → 31 (Tehran first); `…/geo/cities?province_id=1` includes Tehran
(city 101); `…/geo/districts?city_id=101` → 22; `…/geo/districts?city_id=105` (Mashhad) → **empty**.
2. **Cascading dropdown / tree** — the three lazy lookups or `GET api/v1/geo/tree` (one payload).
3. **Admin toggle**`POST api/v1/admin_geo/set_city_active/{id}` `{isActive:false}` → the city
disappears from `geo/cities`; `{isActive:true}` → it returns (not deleted).
4. **Nurse whole-city area**`POST api/v1/nurse_service_areas/add {cityId:101}``isWholeCity:true`.
5. **Duplicate rejected** — repeat the same add → **`409`**; add `{cityId:101, districtId:1001}` → ok;
repeat → **`409`**.
6. **Geocoded address**`POST api/v1/customer_addresses/create {..., isPrimary:true}``latitude`/
`longitude` populated; `list` shows it primary-first with the address decrypted for the owner.
7. **Single primary** — a second `isPrimary:true` create (or `set_primary/{id}`) clears the previous;
exactly one `isPrimary` row remains.
8. **PII not leaked**`address_line`/`postal_code`/recipient fields are encrypted at rest; only the
owner's own read decrypts them.
Covered by tests: **+16 `Baya.Test.Api` integration** (Geo/AdminGeo/NurseServiceAreas/CustomerAddresses —
happy path, 401, validation 400, 409 duplicate, single-primary, geocode, is_active hide/show) and **+12
handler unit tests** (NSubstitute — duplicate→conflict, geocode wiring, single-primary clear, tenancy 404,
role checks). Full suite: **103 pass**, `dotnet build Baya.sln` with 0 new code warnings.
## Decisions fixed here (recorded in product docs / CLAUDE.md)
- **Whole-city (`district_id NULL`) uniqueness = a filtered-index pair** (not a plain unique index, which
SQL Server would let duplicate NULLs through). Both filters also exclude soft-deleted rows so a removed
area can be re-declared.
- **`district_id NULL` is a meaningful "entire city"** coverage value, never "unset".
- **Named districts, not GPS radii** — address lat/lng is only for the later EVV distance check.
- **Single-primary address** = filtered unique index + clear-then-set in one transaction; first address is
primary by default.
- **Address PII columns** (`address_line`, `postal_code`, `recipient_name`, `recipient_phone`) encrypted
through `IFieldEncryptor`.
- **Action-style routes** kept (over the phase's resource-style sketch) for codebase + dynamic-permission
consistency.
## Mocked + how to make it real
- **`IGeocoder` → 🟡.** `MockGeocoder` = deterministic point around the city centroid (FNV-1a jitter), no
network. Config `Seams:Geocoding:{ReturnNullCoordinates, LowConfidenceMarker, ResolvedConfidence}`; the
`NO_GEO` marker or the switch forces null coordinates. **Make it real:** add a Neshan (or Google)
geocoding client package, add `Seams:Geocoding:{ApiKey,BaseUrl}`, implement `IGeocoder.GeocodeAsync`
mapping the vendor response to `(lat, lng, formatted_address, confidence)` (decimal coords), add
rate-limit/retry, swap the registration in `AddCrossCuttingSeams` — handlers unchanged; test a known
Tehran address resolves within expected bounds.
## Contract produced
`dev/contracts/domains/geography-addresses.md` + refreshed `dev/contracts/openapi/swagger.v1.json` (20 new
paths). This is what **f3-b4** consumes.
## Follow-ups for later phases
- **b7:** wire the `nurse_search_index` fan-out into the (already-isolated) service-area add/remove trigger
points.
- **b9:** the EVV distance check that consumes `customer_addresses.latitude/longitude`.
- **Region bulk-import feed** (`IGeoDataImporter`): deferred; the idempotent seed + admin CRUD suffices for
MVP.
@@ -0,0 +1,103 @@
# Backend Phase 5 — Service catalog & nurse pricing variants — report
**Status:** complete · build clean (0 new code warnings) · `dotnet test Baya.sln` green (122 pass:
+21 over b4's 101 — 10 new handler/serializer unit tests, +11 API integration & adversarially reviewed).
**Nothing is mocked** in this phase.
## What was built
The two-tier service model the whole marketplace is priced/searched on — one additive migration
(`20260702132758_ServiceCatalogAndNurseVariants`, new **`catalog`** schema), five tables, 14 endpoints across
3 controllers, plus the b8 snapshot serializer.
- **Entities** (`Domain/Entities/Catalog/`): `ServiceCategory`, `ServiceOptionGroup` (nullable
`ServiceCategoryId` = cross-category), `ServiceOptionValue`, `NurseServiceVariant` (`Price` **BIGINT IRR**,
`PriceUnit`, `SessionCount?`, `DisplayName`, `OptionSetHash`), `NurseServiceVariantOption`, and the closed
`PriceUnits` set (`per_hour`/`per_session`/`per_half_day`/`per_day`/`per_24h`).
- **EF configs + seed** (`Persistence/Configuration/CatalogConfig/`): soft-delete query filters;
`UNIQUE(variant_id, option_group_id)`; filtered `UNIQUE(nurse_id, service_category_id, option_set_hash)
WHERE deleted_at IS NULL`; `(is_active, sort_order)` / `(service_category_id, sort_order)` /
`(option_group_id, sort_order)` / `(nurse_id, is_active)` / `(service_category_id)` indexes. **Five seed
categories** (`nameFa`+`nameEn`, ids 15) via `HasData`.
- **Admin catalog CQRS** (`Features/Catalog/`): `CreateServiceCategory`/`UpdateServiceCategory`/
`SetServiceCategoryActive`, `CreateServiceOptionGroup`/`UpdateServiceOptionGroup`,
`CreateServiceOptionValue`/`UpdateServiceOptionValue`, and the public cached `GetCatalogCategories`
(paginated) / `GetCategoryOptionGroups` (applicable = own + cross-category). Every mutation invalidates the
`CatalogCache` generation token.
- **Nurse variant CQRS** (`Features/Variants/`): `CreateVariant` (required-group enforcement incl.
cross-category, one-value-per-dimension, value-in-group, duplicate-listing pre-check + auto display-name),
`UpdateVariant` (price/unit/session/display; option-set immutable), `SetVariantActive` (deactivate, never
delete), `ListMyVariants` (active + inactive), `GetVariant` (owner/admin full · public active-only).
- **`OptionSetHash`** helper (deterministic, order-independent SHA-256) + **`IVariantSnapshotSerializer`**
(pure, singleton; canonical `variant_snapshot_json` for b8).
- **Controllers** (`Controllers/V1/`): `AdminCatalogController` (dynamic-permission), `CatalogController`
(public), `NurseVariantsController` (`[Authorize]`; `get/{id}` is `[AllowAnonymous]`).
## What is now testable — and exactly how (the phase §7 steps)
Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger/curl.
1. **Catalog seeded**`GET /api/v1/catalog/categories``200`, five categories, `nameFa`+`nameEn`, ordered
by `sortOrder`, active only.
2. **Admin builds a dimension** — as admin, `POST /api/v1/admin_catalog/create_option_group`
`{ serviceCategoryId: 1, nameFa: "نوع شیفت", nameEn: "Shift type", isRequired: true, sortOrder: 1 }``200`;
`POST /api/v1/admin_catalog/create_option_value` twice → `200`;
`GET /api/v1/catalog/option_groups?category_id=1` → the required group **plus any cross-category groups**,
each with its values.
3. **Nurse builds a valid variant**`POST /api/v1/nurse_variants/create`
`{ serviceCategoryId: 1, options: [{ optionGroupId, optionValueId }], price: "8000000", priceUnit: "per_24h" }`
`200`, `isActive: true`, auto `displayName` = category + value labels.
4. **Duplicate identical listing** — repeat the exact create → clean **`409`** (not a `500`).
5. **Missing required dimension** — create with `options: []`**`400`** naming the missing group.
6. **One value per dimension** — two values for the same group in one create → rejected (handler + the
`UNIQUE(variant_id, option_group_id)` backstop).
7. **List active + inactive**`GET /api/v1/nurse_variants/list`; then
`POST /api/v1/nurse_variants/set_active/{id}` `{ isActive: false }` → the deactivated variant still appears,
flagged `isActive: false` and unbookable; the row is **never** hard-deleted.
8. **Tenancy** — a *different* nurse `POST /api/v1/nurse_variants/update/{id}` on the first nurse's variant →
**`404`** (existence not leaked).
9. **Snapshot serializer** — unit-tested: the JSON carries the category labels, each option label, `price`
(as a digit string), `priceUnit`, and `sessionCount`.
Automated coverage: `Baya.Test.Foundation/Catalog/` (CreateVariant valid/missing-required/duplicate/
one-value-per-dimension/value-not-in-group/inactive-category/non-nurse/override; admin category cache
invalidation + parent checks + cross-category null group; serializer) and `Baya.Test.Api/`
(`CatalogPublicApiTests`, `NurseVariantsApiTests` — full lifecycle incl. 409/400/tenancy-404/public-get/401).
## Adversarial review
A 4-dimension review (tenancy/authorization, EAV/cross-category/required-group, money integrity, EF-translation/
caching/soft-delete) with independent per-finding verification ran over the diff (159 tool-uses, ~382k tokens):
**0 confirmed findings**. Spot-checked by hand: cross-nurse `get/{id}` returns the public (never full) view;
the duplicate hash includes cross-category selections; missing a required cross-category group → 400.
## Contracts produced / consumed
- **Produced:** [`dev/contracts/domains/catalog.md`](../../contracts/domains/catalog.md) (routes, shapes,
`price_unit` enum, IRR-string money, `409`/`400` failure cases, examples). `swagger.v1.json` **refreshed**
(71 paths total; +14 catalog/variant paths) — verified the API boots and applies the migration on a real
SQL Server.
- **Consumed:** `nurse_profiles` (b3), `ICacheService`/CQRS/`OperationResult`/`BaseController` (b0),
admin dynamic-permission policy (b1/b2). No geography coupling (b7 joins the two later).
## Mocks
**None.** This phase introduces no cross-cutting seam and adds **no** row to
[`mocks-registry.md`](mocks-registry.md) — stated here so the next agent doesn't go looking.
`IVariantSnapshotSerializer` is an internal application contract with a single real implementation (not a
mock seam). `ICacheService` is reused (already 🟡 from b0), not redefined.
## Follow-ups for later phases
- **b7 (search & matching)** — owns `nurse_search_index`, `INurseSearch`, the search query, and index
fan-out. Reads per active variant: `service_category_id`, `price`, `price_unit`, `is_active`, `nurse_id`,
fanned across the nurse's `nurse_service_areas` (b4). The maintenance trigger points are
`CreateVariantCommand` / `SetVariantActiveCommand` (and future option/price edits).
- **b8 (booking)** — owns `booking_requests.variant_snapshot_json`; calls `IVariantSnapshotSerializer` at
booking time. The serializer is shipped and unit-tested here.
- **Deferred (not built):** `nurse_availability_slots`/`_exceptions` (soft guidance); holiday/surge pricing;
a Companionship *tier* pricing model (ships only as a seeded category); tiered per-category commission.
## Notable decisions (recorded in product/eng docs, not invented)
- Routes are **action-style POST** with camelCase bodies (matching b3/b4), not the PUT/PATCH the phase table
sketched — documented in the contract's routing note.
- `update_option_value` does **not** re-parent a value to another group (would silently change the meaning of
variants that already answered with it).
- `price` crosses the wire as a **string of digits** (`money-and-types.md`); the DTO/command use `string`.
- The `OptionSetHash` + filtered-unique duplicate-listing strategy is noted as a reusable pattern in
`server/CONVENTIONS.md` §6.
No new *business* rules were discovered — the product docs (`business/03`, `data-model/03`) already matched;
left unchanged.
@@ -0,0 +1,126 @@
# Backend Phase 6 — Nurse verification & credentials (mocked vendors) — report
**Status:** complete · build clean (0 new code warnings) · `dotnet test Baya.sln` green (**153 pass**).
**Vendors are mocked** (three new DI seams + reused b3/b0 seams) — see [What is mocked](#what-is-mocked).
## What was built
The trust engine the whole marketplace gates on — a data-driven verification pipeline, the admin review
queue, the structured credential registry, the transactional `nurse_profiles.is_verified` flip, an
admin-triggered credential-expiry scanner, and the public trust badge. One additive migration (new **`verif`**
schema, **5 tables**), **15 endpoints across 4 controllers**, and **3 new mock vendor seams**.
- **Schema** (new **`verif`** schema): `nurse_verifications` (the header; `status` = the **single source of
verification truth**), `verification_step_types` (the seeded catalog of possible steps),
`verification_steps` (one per required step-type per nurse; snapshots `is_automated`),
`verification_documents` (metadata only — **bytes never in the DB**), `nurse_credentials`
(`credential_number` **encrypted**, never serialized). `nurse_profiles.is_verified` is the **only derived
boolean**, flipped **only inside the finalize transaction**.
- **Six seeded, stable step-type codes:** `identity_kyc`, `shahkar_match`, `moh_competency_license`,
`ino_membership`, `criminal_record`, `bank_account_verification` (three of them credential-bearing).
- **Nurse pipeline** (`NurseVerificationController`, `[Authorize]` + nurse role in handler, tenancy-scoped):
`submit` (upsert header + seed one step per active required step-type; **idempotent**), `GET` status (the
checklist + aggregate + `blockingSteps` + `isBookable`), `upload_url` / `documents` (manual steps →
`in_review`), and three automated `/run` endpoints — `identity_kyc` (populates `users.national_id` +
`national_id_verified_at`), `shahkar_match` (requires KYC; **shared-SIM = explicit handled failure**
`shared_sim` alert; sets `shahkar_verified_at`), `bank_account_verification` (reuses the b3
`IBankAccountOwnershipVerifier`; **money-mule guard** — holder national id must equal the verified nurse
national id; on match sets `matched_national_id=1`).
- **Admin step-type catalog** (`AdminVerificationStepTypesController`, dynamic-permission, `sensitive`
rate-limit): cached list (generation token), upsert (snake_case `code`, immutable once in use, dup →
**409**), deactivate (`is_active=false`, **never hard delete**).
- **Admin review** (`AdminVerificationsController`, dynamic-permission, `sensitive` rate-limit): queue
(`in_review` default; docs carry **signed GET URLs**), case detail (steps + docs + credentials + identity
name), `decide` (manual steps; on approving a credential-bearing step records an **encrypted**
`nurse_credentials` row, **holder-name cross-check** vs identity → mismatch **400** with no credential,
`criminal_record` requires `expiresAt`; writes an `audit_logs` record; **re-aggregates**, may flip
`is_verified`), `suspend` (`status=suspended` + **reverse `is_verified=0` in one transaction** + audit),
and `scan_expiring` (reverts lapsed steps → `expired`, `verification_expired` alert +
`verification_expiry_prompt` notification, re-gates bookability).
- **Public trust badge** (`NursesController`, `[AllowAnonymous]`): `GET nurses/{id}/trust_badge` returns
`isVerified` + `approvedAt` + the **credential TYPES held** (never numbers); cached (short TTL, evicted on
suspension/expiry/decision); `404` unknown nurse.
- **The scan logic ships** as `ScanExpiringCredentialsCommand`; the **scheduled cron is deferred** — the
admin `scan_expiring` endpoint is the entry point (config `verification_expiry_scan_cadence_hours`, int,
default `24`).
## What is now testable — and exactly how (the phase §7 steps)
Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger/curl.
The seam mocks are **deterministic** — use the test national ids / IBAN / phone below.
1. **Open the checklist** — as a nurse (with a b3 nurse profile), `POST /api/v1/nurse_verification/submit`
`200`; `GET /api/v1/nurse_verification``status: "pending"`, `isBookable: false`, one seeded step per
active required step-type, each with its `isAutomated`.
2. **Identity KYC passes**`POST /api/v1/nurse_verification/steps/identity_kyc/run`
`{ "nationalId": "1234567891" }``stepStatus: "passed"`; the nurse's `users.national_id` +
`national_id_verified_at` are populated. The configured fail id `0000000000``stepStatus: "failed"` +
`failureReason` (still `200`).
3. **Shahkar match**`POST /api/v1/nurse_verification/steps/shahkar_match/run` (no body) → `passed`;
`shahkar_verified_at` set. A nurse on the shared-SIM phone `09120000000`**handled failure** + a
`shared_sim` support alert row. Running it **before** KYC → **400**.
4. **Bank ownership** — with a **primary** `nurse_bank_accounts` row (b3),
`POST /api/v1/nurse_verification/steps/bank_account_verification/run` → on match sets
`matched_national_id=1`; the mismatch IBAN `IR000000000000000000000000` → failed. No primary account → **400**.
5. **Manual document upload** — for a credential step, `POST steps/{stepId}/upload_url`
`{ "contentType": "application/pdf" }``{ objectStorageKey, uploadUrl }`; PUT the bytes to `uploadUrl`;
`POST steps/{stepId}/documents` `{ objectStorageKey, integrityHash, contentType, fileSizeBytes }` → the
step moves to `in_review` and a metadata row is stored (no bytes in the DB). An automated step rejects the
upload → **400**.
6. **Admin review queue** — as admin, `GET /api/v1/admin_verifications` (default `status=in_review`) →
the pending step with **signed GET URLs** on its documents; `GET /api/v1/admin_verifications/{id}` → the
full case incl. the identity name for cross-check.
7. **Approve a credential**`POST /api/v1/admin_verifications/steps/{stepId}/decide`
`{ approve: true, credentialNumber, holderName: "<verified identity name>", issuingAuthority, issuedAt }`
→ records an **encrypted** credential (`credentialId` returned; the number is never serialized back),
writes an audit record, re-aggregates. A **holder-name mismatch → 400** with **no** credential recorded;
a `criminal_record` approval **without `expiresAt` → 400**; `approve:false` without `rejectionReason`
**400**.
8. **is_verified flips** — once every required step is `passed`/`approved`, the re-aggregate flips
`nurse_profiles.is_verified=1` **in one transaction**; `GET /api/v1/nurse_verification` now reads
`status: "approved"`, `isBookable: true`.
9. **Public trust badge**`GET /api/v1/nurses/{nurseId}/trust_badge``isVerified: true`, `approvedAt`,
`credentialTypes: ["moh_competency_license", …]` (**types only**, never numbers). Unknown nurse → **404**.
10. **Suspend & expiry**`POST /api/v1/admin_verifications/{id}/suspend` `{ reason }` sets
`status=suspended` and **reverses `is_verified=0`** in one transaction (+ audit); the trust badge
updates. `POST /api/v1/admin_verifications/scan_expiring` reverts lapsed time-limited steps to `expired`,
raises a `verification_expired` alert + `verification_expiry_prompt` notification, and re-gates
bookability (`ScanExpiringResult{ scannedSteps, revertedNurses }`).
## What is mocked / waiting on a real service
Three **new** seams (all deterministic, all mocked here) + three **reused** seams:
- `IShahkarVerifier``MockShahkarVerifier` — phone↔national-id match: **pass** unless the shared-SIM phone
`09120000000` (→ shared-SIM handled failure) or the mismatch national id `1111111111`. Registry row:
`IShahkarVerifier` in [`mocks-registry.md`](mocks-registry.md).
- `IIdentityKycProvider``MockIdentityKycProvider` — national-id + liveness: passes any well-formed 10-digit
national id **except** the configured fail id `0000000000`. Registry row: `IIdentityKycProvider`.
- `ICredentialVerifier``MockCredentialVerifier` — MoH/INO/criminal-record: the manual-admin default
(always `RequiresManualReview` / `verification_method=manual`). Registry row: `ICredentialVerifier`.
- **Reused:** `IBankAccountOwnershipVerifier` (b3 — mismatch IBAN `IR000000000000000000000000`),
`IObjectStorage` (b0 — local-disk signed PUT/GET URLs), `IFieldEncryptor` (b0 — encrypts
`credential_number`).
## Contracts
- **Produced:** [`dev/contracts/domains/verification.md`](../../contracts/domains/verification.md) (all 15
routes, DTO shapes, the four enums, failure cases, side effects, mock test values, worked example).
`swagger.v1.json` **refreshed** — [`dev/contracts/openapi/swagger.v1.json`](../../contracts/openapi/swagger.v1.json)
includes all 15 b6 endpoints.
- **Consumed:** `nurse_profiles` + `IBankAccountOwnershipVerifier` (b3), `users.national_id` /
`shahkar_verified_at` (b2), `IObjectStorage` / `IFieldEncryptor` / `ICacheService` / CQRS /
`OperationResult` / `BaseController` (b0), dynamic-permission + `support_alerts` / `notifications` /
`audit_logs` + `sensitive` rate-limit (b1).
## Docs updated
- New contract `dev/contracts/domains/verification.md`; openapi snapshot refreshed.
- Handoff `backend/handoff/after-backend-phase-6.md`; status log `backend/STATUS.md` (append).
- The three b6 seams (`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`) are the
pre-listed rows in `reports/mocks-registry.md` — now realized as seam + fake impl.
## Follow-ups for later phases
- **Scheduled expiry cron** — a `CredentialExpiryScannerJob` hosted scheduler that calls the shipped
`ScanExpiringCredentialsCommand` on `verification_expiry_scan_cadence_hours` cadence. **Deferred** — the
admin `scan_expiring` endpoint is the manual entry point today.
- **Automated MoH/INO license lookup** — behind `ICredentialVerifier` (`verification_method=api`) once a
portal exists. **Deferred.**
- **`fraud_flags` / ML fraud scoring** — **deferred.**
- **Professional-liability-insurance step** — addable later as a `verification_step_types` row (no schema
change). **Deferred.**
- **b7 (search & matching)** — reads `nurse_profiles.is_verified` for `nurse_search_index.is_searchable`;
b6 owns the flip, b7 owns the index.
@@ -0,0 +1,83 @@
# Backend Phase 7 report — Search & matching (nurse search index)
## What was built
- **`nurse_search_index` read model** — `Domain/Entities/Search/NurseSearchIndex` (table `search.NurseSearchIndices`),
one flat row per **(bookable variant × covered service area)**: copied `variant_id`/`nurse_id`/
`service_category_id`/`price`/`price_unit`, the covered `city_id`/`district_id` (NULL = whole city), the
nurse's `nurse_gender` + `average_rating`/`total_reviews`/`total_completed_bookings`, the single
`is_searchable` gate, `updated_at`, soft-delete `deleted_at`. EF config in
`Persistence/Configuration/SearchConfig/`; one migration `NurseSearchIndex`. Indexes: a **covering** search
index `(is_searchable, service_category_id, city_id, district_id) INCLUDE (price, nurse_gender,
average_rating, total_reviews, nurse_id, variant_id)`; the **filtered-unique pair** on `(variant_id, city_id,
district_id) WHERE deleted_at IS NULL` (NULL-district participating, via the `nurse_service_areas` trick); a
`nurse_id` secondary index; soft-delete query filter.
- **`ISearchIndexMaintainer` (write seam) + `SearchIndexMaintainer`** — `Persistence/Services/Search/`. Keeps
the index consistent **inline, in the source write's own unit of work**. Methods: `ReindexVariantAsync`
(variant create/edit/toggle — inserts a new variant's rows in the same graph via the `Variant` navigation),
`ReindexNurseAsync` (verification flip / suspend / accepting-toggle / rating recompute), `FanOutServiceAreaAsync`
+ `RemoveServiceAreaRowsAsync` (area add/remove), `RebuildAsync` (idempotent full rebuild). Resurrects a
soft-deleted (variant × area) row on re-upsert so each pair has exactly one live row.
- **`INurseSearch` (read seam) + `SqlNurseSearch`** — `Persistence/Services/Search/`. Reads **only
`is_searchable = 1`** rows, applies category/city/district(NULL-aware)/gender/price filters + rating sort +
pagination, `AsNoTracking` + `.Select` projection; `price` formatted to a digit string in memory.
- **`SearchNursesQuery`** (`Features/Search/Queries/`) + FluentValidation validator, delegating to `INurseSearch`;
**`RebuildSearchIndexCommand`** (`Features/Search/Commands/`) → `RebuildAsync` + audit log.
- **Controllers:** public `SearchController` (`GET api/v1/search/nurses`, snake_case query params, per-IP
global rate limit) and `AdminSearchController` (`POST api/v1/admin_search/rebuild_index`, dynamic-permission +
`sensitive` rate limit).
- **Wiring into source handlers** (same-transaction maintenance): b5 `CreateVariant`/`UpdateVariant`/
`SetVariantActive`; b4 `AddNurseServiceArea`/`RemoveNurseServiceArea`; b3 `SetNurseAcceptingBookings`; b6
`AdminReviewStep`/`AdminSuspendVerification`/`ScanExpiringCredentials`/`RunIdentityKyc`/`RunShahkarMatch`/
`RunBankAccountVerification`.
- **DI:** `AddPersistenceServices` registers `ISearchIndexMaintainer` + (config-selected) `INurseSearch`
(`Search:Backend`, default `sql`).
## What is now testable and exactly how (per phase §7)
Seed fixtures via `SearchIndexTestHost` (Foundation) or drive the live API. Verified against tests:
1. **Predicate** — a verified+accepting+not-suspended+active nurse is searchable; each missing condition
(unverified / not accepting / suspended / inactive variant) makes it not searchable, but the row is kept.
2. **Geography** — district-3 search returns the district-3 nurse **and** the whole-city (NULL) nurse; a
different district returns only the whole-city nurse; a city-only search returns both.
3. **Same-gender**`nurse_gender=female`/`male` narrows to that gender.
4. **Price range**`min_price`/`max_price` filter on the copied IRR `price`; result `price` is a digit string.
5. **Rating sort** — higher `average_rating` sorts first; deterministic paging.
6. **Verification flip** — suspend/un-verify → the nurse disappears from search in the same transaction;
reinstating brings them back (row resurrected, not duplicated).
7. **Service-area fan-out/remove** — adding an area adds its rows; removing it drops exactly those rows.
8. **Variant deactivate** — the variant stops appearing (`is_searchable=0`) without deleting its rows.
9. **Rebuild convergence**`RebuildAsync` reproduces the incrementally-maintained live/searchable row set,
no duplicate (variant × area) rows.
**Tests:** `Baya.Test.Foundation/Search/SearchIndexTests` (9 DB-backed over real EF/SQLite) +
`Baya.Test.Api/SearchApiTests` (4 WebApplicationFactory: public paged happy path, 400 missing category, 400
invalid gender, 401 rebuild-unauth). Affected b3/b4/b5/b6 handler unit tests updated for the new dependency.
**Gate:** `dotnet build Baya.sln` 0 new warnings; `dotnet test Baya.sln` green (167 pass).
Manual: `GET /api/v1/search/nurses?service_category_id=…&city_id=…` (public) returns the paged envelope;
`POST /api/v1/admin_search/rebuild_index` (admin) returns `{ nursesProcessed, rowsWritten }`.
## Contracts produced / consumed
- **Produced:** `dev/contracts/domains/search.md`; `dev/contracts/openapi/swagger.v1.json` refreshed.
- **Consumed:** b3 (profiles/gender/aggregates), b4 (service areas / geo), b5 (variants), b6 (verification status).
## What is mocked / deferred + how to make it real
- **Elasticsearch backend (`ElasticNurseSearch`) + outbox feeder** — DEFERRED. The SQL index is the real MVP
backend and stays the projection/fallback. Seam ready (`INurseSearch`, config `Search:Backend`;
`ISearchIndexMaintainer` change-event shape). Steps in `reports/mocks-registry.md` (both rows).
- **`booking_requests.required_caregiver_gender` capture** — owned by **b8** (carry the chosen gender into the
booking). b7 makes `nurse_gender` a first-class search facet and stops there.
- **Availability hard-filter, map/radius discovery, ranking beyond rating, preferred-nurse continuity** —
DEFERRED per the product doc.
## Follow-ups for later phases
- **b8** — consume `search/nurses` results into the booking flow; capture `required_caregiver_gender`.
- **Optional** — a short-TTL `ICacheService` decorator over hot (category, city, gender) result pages,
invalidated on index writes for the affected city/category (shipped no-cache at MVP).
- **Perf** — `RebuildAsync` does per-nurse reads (N+1); fine for the batched admin/nightly job, worth a
set-based rewrite if the nurse count grows large.
- **Elastic** — build the outbox + feeder when search scale demands it (both registry rows).
@@ -0,0 +1,96 @@
# Backend phase 8 report — Booking requests (pre-payment intent)
**Date:** 2026-07-06 · **Track:** backend · **Depends on:** b1 (config/jobs/notifications), b3 (profiles/
patients/tenancy), b4 (addresses), b5 (variants), b7 (search/gender). **Unlocks:** b9 (bookings/sessions/care)
and frontend f7-b8.
## What was built
The **money-free** first half of the engagement lifecycle — the `booking_requests` table and its full state
machine (request → accept → pay-window → expire/reject/cancel). **One additive migration** adds a new
**`booking`** schema with a single table `BookingRequests`. **No money, no `bookings` row, no snapshot, no
price** anywhere in this phase.
- **Domain** (`Baya.Domain/Entities/Booking/`): `BookingRequest` (guarded `status` with a private setter, both
deadlines as UTC `DateTime`, unencrypted `CustomerNotes`), `BookingRequestStatus` (7 const codes),
`BookingRequestTransitions` (forward-only edge table + `CanTransition`), `CaregiverGender` (`male`/`female`/
`any` + `Matches`).
- **Application** (`Features/Booking/`): commands `CreateBookingRequest`, `AcceptBookingRequest`,
`RejectBookingRequest`, `CancelBookingRequest`, `ExpireBookingRequests`; queries `ListBookingRequests`
(role-scoped) + `GetBookingRequest` (party/admin); `BookingRequestMapper` (stage-1 address masking);
DTOs in `Models/Booking/`; `NurseBookingContext` in `Models/Identity/`.
- **Infrastructure**: `BookingRequestConfig` (EF config + the 4 covering indexes + soft-delete filter),
`BookingRequestRepository` on `IUnitOfWork`, `INurseProfileRepository.GetBookingContextByIdAsync`, and
`BookingRequestExpiryHostedService` (recurring sweep, reuses the b1 `IJobScheduler`/`BackgroundService` seam),
registered in `AddPersistenceServices`.
- **API** (`Controllers/V1/`): `BookingRequestsController` (`create`/`accept/{id}`/`reject/{id}`/`cancel/{id}`/
`list`/`get/{id}`, `[Authorize]`, role/ownership enforced in handlers) + `AdminBookingRequestsController`
(`expire`, `DynamicPermission`).
## The critical rules, as enforced
- **No money / no booking.** A request has no price column; accept only sets `payment_deadline_at`. Conversion
to a `bookings` row is b9 (`MarkConverted()` exists but b8 never calls it).
- **Two-stage disclosure (stage 1).** `customer_notes` is unencrypted and the only clinical text the nurse
sees; the nurse view/inbox mask the encrypted full address to a coarse city/district.
- **Tenancy invariant** resolved from `ICurrentUser` (never the body): patient + address ∈ the caller's
customer, variant ∈ the requested nurse — a mismatch is a clean 404.
- **Same-gender match** at request time against `User.Gender`; required, never defaulted.
- **Deadlines frozen from config** (`nurse_response_deadline_hours` at create, `booking_payment_deadline_minutes`
= 30 at accept) as absolute UTC timestamps; a later config change can't move them.
- **Forward-only guard**: every write pre-checks `CanTransition` → 409 on an illegal edge; terminal states have
no outgoing edge. Accept self-guards against a passed response deadline. The expiry sweep is bounded,
paginated, time-injected, and idempotent (the `WHERE status = …` predicate is the concurrency guard).
## What is now testable, and exactly how
Run the API against a reachable SQL Server (`dotnet run --project src/API/Baya.Web.Api/...`), sign in per role.
The §7 scenarios all pass:
1. **Create (happy path)** — customer `POST /v1/booking_requests/create` with own patient/address, a
verified+accepting nurse's active variant, a future date, `requiredCaregiverGender: any``200`,
`pending_nurse_response`, `nurseResponseDeadlineAt = now + nurse_response_deadline_hours`, `paymentDeadlineAt`
null; the nurse gets a `booking_request_received` notification.
2. **Cross-customer patient/address/variant** → clean `404`, no row created.
3. **Same-gender mismatch** (`female` vs a `male` nurse) → `400`; `any` succeeds.
4. **Accept**`200`, `accepted_awaiting_payment`, `paymentDeadlineAt = now + 30 min`, customer notified; no
bookings row.
5. **Reject** with reason → `200`, `rejected_by_nurse`; rejecting a non-pending request → `409`.
6. **Nurse inbox** (`list?role=nurse`) shows `customerNotes` + the response countdown, no clinical/encrypted field.
7. **Customer cancels** an accepted request → `200`, `cancelled_by_customer`; cancelling a terminal one → `409`.
8. **Expiry** — admin `POST /v1/admin_booking_requests/expire` (or the recurring job) moves stale pending →
`expired_no_response` and stale accepted → `payment_deadline_expired`, notifies the customer; re-running is a
no-op.
9. **Read tenancy** — a third party `GET /v1/booking_requests/get/{id}``404`.
**Automated tests (215 total pass):** 14 handler-unit (`CreateBookingRequestHandlerTests`,
`RespondBookingRequestHandlerTests`) + the transition-machine tests (`BookingRequestTransitionsTests`) + 4
DB-backed SQLite tests over the real EF model (`BookingRequestExpiryTests`, `BookingRequestQueryTests` via
`BookingTestHost`) + 5 API integration tests (`BookingRequestsApiTests`: 401/400/empty-inbox/admin-expire).
`dotnet build Baya.sln` = 0 new warnings; `dotnet test Baya.sln` green. Migration applied to the dev DB and the
sweep verified running against SQL Server on boot.
## Contracts produced / consumed
- **Produced:** `dev/contracts/domains/booking-requests.md`; `swagger.v1.json` refreshed (the 7 booking paths +
the 3 DTOs).
- **Consumed:** b1 config keys (`nurse_response_deadline_hours`, `booking_payment_deadline_minutes`), b3
profiles/patients + tenancy, b4 addresses, b5 variants (bookable unit; `GetOwnedAsync` tenancy), b7 nurse
gender/matching data.
## Nothing is mocked here
This phase owns **no** third-party integration and introduces **no** DI seam. It reuses `IPlatformConfig`,
`INotificationDispatcher`, `IJobScheduler`/`BackgroundService`, `IDateTimeProvider`, `ICurrentUser`. There is
**no new `mocks-registry.md` row** — the existing `IJobScheduler` row was updated to note the second hosted job
(the internal expiry sweep is not an external seam).
## Follow-ups for b9 (+ b10)
- Consume an `accepted_awaiting_payment` request → create the `bookings` row on payment capture (b10), then
`request.MarkConverted()` through the guard. The request↔booking link is 1:1 and b9-owned.
- Stage 2: the encrypted `booking_care_instructions` (post-confirmation, assigned nurse + admin only). Do not
add a clinical field to `booking_requests`.
- The three-amount money split, `variant_snapshot_json`/`address_snapshot_json`, `booking_sessions`, EVV, and
`dispute_window_ends_at` are all b9/b10.
- Reuse the forward-only status-machine pattern (CONVENTIONS §6) for the `bookings` state machine.
@@ -0,0 +1,78 @@
# Backend phase 9 report — Bookings, sessions, care instructions & EVV
## What was built
- **Domain (`Baya.Domain/Entities/Booking/`):** `Booking`, `BookingSession`, `BookingCareInstruction`,
`VisitVerification`, `CancellationPolicy` (+ `CancellationPolicyCode` seed codes), the `BookingStatus` /
`BookingSessionStatus` / `VisitVerificationStatus` / `CancellationActor` code sets, the `BookingTransitions`
/ `BookingSessionTransitions` guards, and `BookingAmounts` (the pure integer-money split/reconciliation).
- **Persistence:** one migration `BookingsSessionsEvvCancellation` — the five `booking`-schema tables with the
`gross = commission + payout` (all ≥ 0) **DB CHECK**, the `booking_request_id` / `booking_id` (care) /
`booking_session_id` (EVV) UNIQUE 1:1 indexes, encrypted `address_snapshot_json` + care columns, seeded
cancellation tiers + 2 new `platform_configs` rows. `BookingConfig/` configs; `BookingRepository` +
`CancellationPolicyRepository` on `IUnitOfWork`; `IBookingRequestRepository` gained
`GetTrackedByIdAsync` + `GetConversionSourceAsync`.
- **Application (`Features/Bookings/`):** `ConvertRequestToBooking`, `SubmitCareInstructions`, `CheckInVisit`,
`CheckOutVisit`, `TransitionBookingStatus`, `CancelBooking`, `CancelSession`, `DetectNoShowSessions`,
`UpsertCancellationPolicy` (commands) + `GetBookingDetail`, `ListBookings`, `ListSessionsForNurse`,
`GetCareInstructions`, `GetVisitVerification`, `ListAdminEvv`, `ListCancellationPolicies` (queries), plus
`BookingMapper`, `CancellationHelper`, `GeoDistance`.
- **Seam:** `IPaymentCaptureSimulator` (Application `Contracts/Common`) + `MockPaymentCaptureSimulator`
(CrossCutting), registered in `AddCrossCuttingSeams`, config `Seams:PaymentCapture`.
- **API:** `BookingsController`, `BookingSessionsController`, `AdminEvvController`,
`AdminCancellationPoliciesController` (convert/cancel + admin EVV/policies are rate-limited).
## What is now testable, and exactly how (per §7 of the phase)
1. **Convert**`POST bookings/convert` (as the owning customer) with an `accepted_awaiting_payment` request →
a `confirmed` booking whose three amounts sum, snapshots are populated (address encrypted at rest), N
sessions reconcile (`Σ visit_payout = nurse_payout`), request → `converted`. Re-convert → same booking.
2. **Single-visit** — a `session_count=1` request → exactly one session.
3. **Care disclosure** — submit as customer, then `GET bookings/care_instructions/{id}`: assigned nurse + admin
get the decrypted fields; customer / unassigned nurse / pre-confirmation → 404. Never in list/detail.
4. **EVV**`check_in` (in-range GPS) → session + booking `in_progress`; `check_out` → session `completed`,
EVV `completed`.
5. **Mismatch**`check_in` out-of-range → still succeeds, `check_in_address_match=false`, a
`evv_location_mismatch` support alert + notification; visible in `admin_evv/list?type=mismatch`.
6. **Completion** — last `check_out` → booking `completed`, `dispute_window_ends_at = completed_at + 72h`
(config), each completed session's `payout_eligible_at` set; not payout-eligible before that.
7. **Cancellation**`bookings/cancel/{id}` resolves the tier by lead-time + actor, snapshots `code` +
`refund_percentage`, refunds only un-started sessions; a later policy edit leaves the snapshot unchanged.
8. **Transition guard** — an illegal/EVV-contradicting transition → `OperationResult` failure, no state change.
9. **No-show**`admin_evv/detect_no_shows` for an overdue scheduled session → `missed` + `no_show` alert +
family notification.
Automated coverage: 42 booking foundation tests (SQLite host over the real EF model + real handlers) — the
three-amount split + session reconciliation, the transition guards, the two-stage disclosure gate, the
advisory-mismatch-raises-alert-without-blocking path, `SetDisputeWindow` on completion, and the
policy-snapshot immutability — plus one WebApplicationFactory integration test per controller (happy path /
401 / 400 / disclosure not-found). Full suite green (269 tests). `dotnet build` zero new code warnings.
## What is mocked, and how to make it real
- **`IPaymentCaptureSimulator`** — see `reports/mocks-registry.md`. In b10 the real card capture calls
`ConvertRequestToBooking` directly on a `payment_transactions.succeeded`; remove the seam + mock. A config
switch forces a failed capture today so the "no booking on failure" path is covered.
## Decisions recorded (not in the product docs before)
- The `visit_payout_amount` split places the remainder of integer division on the **last** session so
`Σ = nurse_payout_amount` exactly.
- The EVV-state ↔ booking-state mapping (`checked_in` ↔ session `in_progress` ↔ booking `in_progress`; all
sessions settled ↔ booking `completed`).
- Seeded cancellation tiers: `standard_24h` (customer ≥24h → 100%), `standard_inside_24h` (customer <24h →
50%, open lower bound so an already-started cancel still resolves), `nurse_no_show` (nurse → 100% + a
modelled penalty whose posting is deferred to b13), `admin_cancellation` (admin → 100%).
- `no_show_threshold_minutes` default **60**; `no_show_scan_cadence_hours` default **1**.
- The cancellation snapshot (`cancellation_policy_code` / `cancellation_refund_percentage` /
`refundable_amount_irr`) lives on the `bookings` row for MVP — the typed per-event/refund record lands in b11;
`booking_sessions.cancellation_event_id` is a nullable column left unset until then.
## Contracts produced / consumed
- Produced: `dev/contracts/domains/bookings-evv.md` + refreshed `swagger.v1.json`. Consumes b8's
`booking_requests`, b5's `IVariantSnapshotSerializer`, b4's `IGeocoder` + address coords, b1's config /
`support_alerts` / `INotificationDispatcher`, b0's `IFieldEncryptor` / `ICurrentUser` / `OperationResult`.
## Follow-ups
- **b10** — real card capture (`payment_transactions`, ledger) → replaces the `IPaymentCaptureSimulator` trigger.
- **b11** — refund execution consumes the frozen policy snapshot + `refundable_amount_irr`; adds the
cancellation-event/refund records that `booking_sessions.cancellation_event_id` will reference.
- **b13** — payout batching consumes `dispute_window_ends_at` / `payout_eligible_at`; posts the nurse penalty.
- **b14** — reviews on a completed booking. **b15**`partner_centers` wires `partner_center_id`.
- The **no-show cron** and a **recurring dispute-window/close sweep** remain DEFERRED (hosted-scheduler pattern).
@@ -0,0 +1,97 @@
# Frontend Phase 0 — Foundations: app shells, design system & data/contract patterns — Report (2026-07-02)
## What was built
**Cleanup (3.1)**
- Removed the `toastDemo` i18n namespace (both locales) and the placeholder home page.
- Deleted the two dead icons (`AppIcon/icons/CurrencyIcon.tsx`, `YellowPlanIcon.tsx`).
- Fixed `BottomBar` to read the route via `usePathname()` (was the global `location`) and made it
locale-aware (highlights the active tab, pushes locale-prefixed routes).
- Audit note "AppLoading missing from the `@/components` barrel" — verified it is already exported
(`components/common/index.tsx`); no change needed.
**Actor shells + routing (3.2)** — three role-scoped experiences under `(private-routes)`, no layout
added above `[locale]`:
- **Customer (family)** — `(customer)` route group (no URL segment) at `/`, `/bookings`, `/patients`,
`/wallet`, `/profile`; `CustomerLayout` = slim TopBar + the 5-tab `BottomBar` from the wireframe.
- **Nurse** — `/nurse`, `/nurse/verification`, `/nurse/visits`; `NurseLayout` on the shared
`TopBarAndSideBarLayout` engine.
- **Admin** — `/admin`, `/admin/users`, `/admin/notifications`; `AdminLayout`, persistent desktop sidebar.
- Role model: `constants/roles.ts` (`AppRole`), optional `User.roles`, and `useActorRole()` (defaults to
`customer` until the server seeds roles in f1-b2). Nav is built per shell from `useTranslations('nav')`.
**Data pattern + utils (3.3)**
- Reference domain `services/patients/` mirroring `auth`: `types.ts` (+ the `PatientsApi` seam interface),
`keys.ts` (hierarchical factory), `constants.ts` (mock toggle + staleTime), `apis/` (`clientApi` real,
`mockApi` in-memory, `index` selects by config), `hooks/` (`usePatients` with `staleTime`,
`useAddPatient` invalidates the list), barrel exporting hooks only.
- Shared wire types `lib/api/types.ts`: `ApiEnvelope<T>` + `unwrap()`, `Paginated<T>`, `PageParams`.
- Money/date utils in `@/utils`: `parseIrr`/`rialToToman`/`formatIrr`/`formatIrrToToman` (integer-safe
BigInt) and `formatShamsiDate`/`formatShamsiDateTime` (Intl Persian calendar — no date lib). Plus
`toEnglishDigits`/`digitsOnly` in `utils/text.ts`.
**Shared composites (3.4)** — each in `src/components/<Name>/` with a co-located `.test.tsx`, composed
from MUI/`App*` primitives, i18n-agnostic (labels passed by caller):
- `OtpInput` (auto-advance, backspace-to-previous, paste distribution, digit-normalizing, LTR-in-RTL),
- `PhoneNumberField` (Iranian mobile, normalizes Persian/Arabic digits, caps at 11, `isIranianMobile`),
- `StepperHeader` (MUI Stepper, RTL-aware), `StatusChip` (verified/pending/rejected/… off `--bal-*` tokens),
- `PlaceholderScreen` (empty-state used by every not-yet-built screen).
- Nurse/result card and price-breakdown were **deferred** to their feature phases (per the phase's "your call").
**i18n (3.5)** — seeded `nav`, `common`, `shell`, `patients` in both `en.json`/`fa.json` (in sync,
RTL-first). Documented the future namespace conventions in `client/CLAUDE.md`.
## What is now testable (and exactly how)
1. `cd client && npm run dev` → open `http://localhost:3000` (redirects to `/fa`).
- Customer shell: mobile 5-tab bottom nav (خانه/رزروها/بیماران/کیف‌پول/پروفایل); tapping switches routes
and highlights the active tab.
- Nurse shell: `/fa/nurse` — TopBar + sidebar (داشبورد/احراز هویت/ویزیت‌ها).
- Admin shell: `/fa/admin` — persistent sidebar on desktop (نمای کلی/کاربران/اعلان‌ها).
- Switch locale to `/en``dir` flips to LTR and all strings translate; dark-mode toggle still works.
2. **Reference data pattern:** `/fa/patients` shows the mocked list (~400 ms latency), an add form
(name + gender). Submitting adds the patient and the list updates **without a refetch** — open React
Query Devtools to watch `['patients','list',…]` cache + the invalidation on mutation success.
3. `npm run check` (type + lint) and `npm run test:ci` (72 tests, 12 suites) both pass. `npm run build`
passes when `NEXT_PUBLIC_API_URL` is set (see Follow-ups).
## What is mocked / waiting on a real service
- **Patients domain — client-side mock.** `services/patients/apis/mockApi.ts` (`patientsMockApi`)
implements the `PatientsApi` interface (`services/patients/types.ts`) in memory. Selected by
`USE_PATIENTS_MOCK = true` in `services/patients/constants.ts`. The real `clientApi.ts` is written
against `/patients` (GET list + POST create) and already unwraps the `ApiEnvelope`. **To make real:**
publish the `patients` contract + endpoints, set `USE_PATIENTS_MOCK = false` — no hook/component change.
(This is a frontend client-side mock, not a backend DI seam, so it is recorded here rather than in the
backend-owned `mocks-registry.md`.)
- This is the template f1+ copy for any domain whose backend phase hasn't merged.
## Contracts
- **Produced:** none (frontend consumes).
- **Consumed:** `dev/contracts/conventions/{api-conventions,money-and-types}.md` and the b0
`openapi/swagger.v1.json` (only `ping` endpoints exist yet). Types-from-contract step is wired for the
`patients` reference (shapes mirror the intended wire; `ApiEnvelope`/`Paginated` in `lib/api/types.ts`).
- **Request filed:** `frontend/requests/for-backend.md` REQ-001 (confirm envelope unwrapping, wire casing,
pagination payload shape).
## Docs updated
- `client/CLAUDE.md`: *Project Structure* tree (new route groups, actor layouts, shared composites,
`services/patients`, `lib/api/types.ts`, `constants/roles.ts`, money/date utils); i18n namespaces +
future-namespace conventions; a new *services/{domain} reference pattern* subsection (caching, mock
seam, envelope, money/dates). Corrected the `ColorSchemeScript` doc drift in the two structure lines
that named it (it is neither exported from `@/theme` nor rendered).
## Follow-ups for later phases
- **Envelope unwrapping (REQ-001):** `clientFetch`/`serverFetch` currently return the raw body, so domain
`clientApi`s call `unwrap()`. If the team prefers central unwrapping, that touches the auth plumbing —
coordinate before changing. Wire casing observed is **camelCase**, not the snake_case api-conventions
implies; confirm and update the convention doc.
- **Role guards:** shells read `useActorRole()` but do not yet *guard* cross-actor access (any authed user
can open `/nurse`, `/admin`). Add real guards once roles land in **f1-b2**.
- **Login is username/password** today; phone-OTP arrives in **f1-b2** (use `OtpInput`/`PhoneNumberField`).
- **`npm run build` needs `NEXT_PUBLIC_API_URL`:** `@/config` uses `envRequired`, which throws at import.
Dev works via the committed `.env.development`; a production build must supply the var (as it always would
once any page imports the fetch layer). Not a code defect — an env expectation to note in CI.
@@ -0,0 +1,95 @@
# Frontend Phase 1 (b2) — Auth: phone-OTP login & role routing — Report (2026-07-02)
## What was built
**`services/auth` — rewritten for phone-OTP (the username/password stub is removed, not left dangling)**
- `types.ts` — contract-mirrored (camelCase) shapes: `OtpRequest`, `RequestOtpResult`, `OtpVerify`,
`RefreshRequest`, `LogoutRequest`, `AuthTokens` (= `AuthTokensResult`), `SelectRoleDto`, `Me` (= `MeResult`),
`RoleCode`/`PublicRole`/`AdminRole`, `Gender`, `NurseVerificationStatus`, and the `AuthApi` seam interface.
- `keys.ts``authKeys.me()` (+ `authKeys.all`).
- `constants.ts``USE_AUTH_MOCK` (default **false**), `AUTH_API_BASE = '/api/v1'`, `AUTH_ME_STALE_TIME`,
`OTP_CODE_LENGTH = 6`, `OTP_RESEND_FALLBACK_SECONDS`, `OTP_LOCKED_CODE`.
- `routing.ts`**pure** `resolveRoleDestination(me, intendedRole)` (the role router's core), `toAppRoles`
(fine-grained role codes → the 3 actor roles; all admin sub-roles → `admin`), `isAdminRole`.
- `apis/clientApi.ts` (real, over `clientFetch` + `unwrap`, exact snake_case routes), `apis/mockApi.ts`
(in-memory, dev code `123456`, 3-try lockout, `MOCK_SCENARIO` toggle), `apis/index.ts` (seam selector).
- hooks (one per file): `useRequestOtp`, `useVerifyOtp`, `useMe`, `useRefresh`, `useLogout`, `useSelectRole`,
`useSessionRoleSync`; barrel `index.ts` re-exports **hooks only**.
**Screens (branded, RTL-first, both locales, via the f0 OTP/phone composites + the `App*` library)**
- **A1/B1** `PhoneStep` — one phone step for both actors, copy + role-switch link driven by `intendedRole`;
inline invalid-number and rate-limit states.
- **A2/B2** `OtpStep``OtpInput` (6 boxes), masked-phone echo, single resend countdown (`useCountdown`,
cleaned up on unmount), auto-verify on last digit, and explicit **wrong-code / expired / max-attempts
lockout** states; CTA copy differs by actor.
- `LoginFlow` orchestrates phone→otp→routing (one stack, `?role=nurse` seeds nurse intent); `AuthCard`,
`BrandMark`, `AuthSplash` are the shared branded shells.
- **Role router** `RoleRouter` — consumes `useMe`, shows `AuthSplash` while `/me` is in flight (never flashes
the wrong shell), then `router.replace`s to the resolved app; carries nurse intent into `/select-role`.
- **SelectRole** — first-use picker (خانواده / پرستار only; admin never selectable), pre-selects the login
intent; on select it calls `me/select_role`, rotates the token (role claim lives in the access token), and
routes into the chosen app.
- Pages: `(public-routes)/login/page.tsx`, `(private-routes)/select-role/page.tsx` (both wrap the
search-param reader in `<Suspense>`). `ROUTES.SELECT_ROLE = '/select-role'` (authenticated, not public).
**Session / auth plumbing**
- Widened `AuthState`: `currentUser` is now `SessionUser { id?, phone, roles: AppRole[] }`. The server still
seeds **`isAuthenticated` only** (the access token is an opaque JWE — roles aren't derivable edge/server-side);
`useSessionRoleSync` (mounted in the private-routes layout) hydrates roles from `/me` so the f0 shells pick
chrome from the real role — one source of truth, no second role store.
- **Silent refresh in the fetch layer** (`lib/api/refresh.ts` + a 401 branch in `clientFetch`): on a 401 it
attempts one single-flight refresh and retries the original request once; on refresh failure it clears
tokens and redirects to login (matches the server's rotation + reuse-detection → sign-in-again). Token
cookie writes are centralised in `lib/auth/session.ts` (`persistAuthTokens`/`clearAuthTokens`), shared by
the hooks and the fetch layer. `useRefresh` is the explicit/on-demand path (used after `select_role`).
- `invalidateQueries(authKeys.me())` on login; `removeQueries(authKeys.all)` on logout.
## What is now testable (and exactly how)
`cd client && npm run dev` (b2 backend running, or `USE_AUTH_MOCK = true` for offline with dev code `123456`):
- **Customer happy path:** `/fa/login` → valid mobile → «دریافت کد تایید» → A2 shows masked phone + counting
resend → enter code (auto-verifies) → tokens in cookies (DevTools → Application → Cookies; **nothing** in
localStorage) → redirected to the **customer** home; `/me` in the Query cache.
- **Nurse switch:** A1 → «پرستار هستید؟ ورود پرستاران ←» → B1 nurse copy → verify («تایید و ورود») → routed
to the **nurse** app (B3 status is f5; routed to `/nurse` for now).
- **No-role user:** verify a user whose `/me.roles` is empty → **`/select-role`** → pick a role → routed in.
- **OTP edges:** wrong code → inline error + boxes clear; resend re-enabled when the countdown hits 0; 3 wrong
tries (mock) → input locks with the lockout message.
- **Session:** logout → both cookies cleared, `/me` dropped, back to `/login`; an expired access token is
silently refreshed on the next call (or a clean redirect to `/login` if the session is gone).
- **i18n/RTL:** `/en` translates and stays LTR-correct; `/fa` is RTL.
- Gate: `npm run check` green · `npm run test:ci` green (95 tests) · `npm run build` green (with
`NEXT_PUBLIC_API_URL` set — see follow-ups).
## Tests added
- `services/auth/routing.test.ts` — all role-router branches + `toAppRoles`/`isAdminRole`.
- `components/auth/useCountdown.test.ts` — countdown lifecycle (fake timers; no leaked interval).
- `components/auth/OtpStep.test.tsx` — box count, auto-verify, success→onVerified, wrong-code clears boxes.
- `components/auth/RoleRouter.test.tsx` — loading (no nav), customer/nurse/no-role/error navigation targets.
- Fixed the jest `@/` alias (`<rootDir>/$1``<rootDir>/src/$1`) so `jest.mock('@/…')` resolves.
## What is mocked / waiting on a real service
- **`services/auth` client-side mock** (`authMockApi`) behind `USE_AUTH_MOCK` (default **false**; the real
client is wired to the live b2 routes). Recorded in `mocks-registry.md`. OTP/SMS delivery itself is the
**backend's** `ISmsSender` seam — the frontend never sends SMS.
## Contracts
- **Produced:** none (frontend consumes).
- **Consumed:** `dev/contracts/domains/identity-auth.md` + `openapi/swagger.v1.json` (b2). Reconciliations
vs the phase's sketch: wire is **camelCase** (not snake_case body); `intended_role` is **not** sent to the
server (client-only routing state); `Me` has **no** `activeRole`/`profileCompleted` (router uses intended
role; profile state = `hasCustomerProfile`/`hasNurseProfile`); OTP length isn't in the contract (defaulted
to 6).
- **Requests filed:** REQ-002 (OTP `codeLength`/`expiresInSeconds`), REQ-003 (verify error codes + lockout
`retryAfterSeconds`), REQ-004 (multi-role `activeRole?`; verify returns no `id`).
## Follow-ups for later phases
- **B3 verification landing** (unverified nurse) is **f5** — the router sends nurses to `/nurse`; the
persistent "verification in progress" banner is f5's job.
- **A3/A4 onboarding & profiles** are **f2-b3** (their own `onboarding` namespace); SelectRole only picks the
role.
- **Admin screens** are **f15** — the router routes admins correctly but builds no admin UI here.
- **Refresh-cookie TTL:** the client cookie is 7d (f0 design) while the contract's session default is 30d;
aligning the cookie `maxAge` to `refreshExpiresAt` is a small follow-up (kept the f0 constants for now).
- **`npm run build` needs `NEXT_PUBLIC_API_URL`** (pre-existing f0 note — `@/config` throws at prerender
without it; `next build` doesn't read `.env.development`).
@@ -0,0 +1,117 @@
# Frontend Phase 10 — Cancellation & refund status (customer) — report
**Consumes:** [`dev/contracts/domains/refunds-invoices.md`](../../contracts/domains/refunds-invoices.md) (b11).
**Depends on:** f8 (`services/bookings`, booking detail), f9 (`services/payment`, checkout/invoice, the
`PriceBreakdown`/`EscrowNotice` composites, the money util). **Gate:** `npm run check` green ·
`npm run test:ci` green (**214 tests, +10**). **Mock-primary** behind `USE_REFUNDS_MOCK` (default `true`).
---
## What was built
A vertical slice — the **customer** half of the refund story: *what cancelling costs* (disclosed before
confirm) and *where the money is* (a read-only refund status that tells the truth about the BNPL window).
### `services/refunds` domain (new)
`types.ts` / `constants.ts` / `keys.ts` / `invalidations.ts` / `apis/{clientApi,mockApi,index}.ts` /
`hooks/{useCancellationPolicyPreview,useCancelBooking,useRefundStatus}.ts` / `index.ts`, mirroring the
`services/payment` + `services/bookings` shape exactly.
- **Contract-derived enums:** `RefundStatus` = `requested|approved|processing|succeeded|failed|rejected`
(the b11 set, forward-only), `RefundChannel` = `psp_card|bnpl_revert|manual`. The six statuses collapse
onto three customer steps via `refundCustomerStep` (*submitted → on its way → completed*, `failed`/
`rejected` → a distinct error state). `isTerminalRefundStatus` gates the poll.
- **Money:** the refund is the decomposition of `gross = commission + payout`. All IRR math is **BigInt**
(integer parts-per-10000), never a float; `refundAmount + fee = refundableGross` and
`platformFeeRefunded + nursePayoutRefunded = totalRefunded` reconcile to the rial (so `PriceBreakdown`'s
dev-guard never trips).
- **Caching:** `useRefundStatus` polls (`refetchInterval`) **only while non-terminal** and stops at
`succeeded`/`failed`/`rejected` (and never polls the `null` no-refund empty state); the cancel mutation
`setQueryData`-primes the fresh refund into `refundKeys.byBooking` and invalidates the booking detail +
lists + the policy preview (`invalidations.ts`) — no refetch storm.
### Screens
- **Cancellation flow** `/bookings/[id]/cancel` — step 1 **discloses** the resolved tier (label off the
`cancellation_policy_code` i18n key, never the raw code), the **refund % + fee %**, the concrete Toman
split (refunded vs kept, via the money util), and the multi-session refundable/locked breakdown; the
confirm button is gated behind an explicit acknowledgement. Step 2 restates the amounts and submits via
`useCancelBooking` → routes to the refund status. Copy makes the **admin-approved, never-self-issued**
reality explicit.
- **Refund status** `/bookings/[id]/refund_status` — the three-step stepper, the refunded amount, the
per-channel ETA (BNPL's honest ~710-business-day window from `expected_customer_refund_eta`), the
optional fee-leg split, and a `failed`/`rejected` **contact-support** state (**no retry** — retry is
admin-only, DEFERRED to f15). An empty state renders when the booking has no refund.
- **Booking detail** `/bookings/[id]` now composes `CustomerBookingActions` (page-only glue): the **Cancel
booking** CTA while cancellable, or the **refund section** once cancelled — reusing the cached booking
query key (no extra fetch); the refund read is enabled only after the booking is cancelled.
### Shared composites (each with a co-located `*.test.tsx`)
- **`CancellationPolicyDisclosure`** — the pre-confirm disclosure block (policy tier + %/fee + reconciling
`PriceBreakdown` refund-vs-fee split + per-session refundable/locked + admin-approval explainer + ETA).
- **`RefundStatusCard`** — the 3-step stepper + amount + masked reference + optional fee-leg split +
failed/contact-support state. Reused on the refund-status page and the booking-detail refund section.
- **`RefundEtaBanner`** — one branch on `refund_channel`: `bnpl_revert` surfaces the ~710-business-day
window honestly (never instant), `psp_card`/`manual` their wording.
### i18n
A new **`refunds`** namespace (69 keys) added to **both** `messages/en.json` and `messages/fa.json` in
sync (fa brand `بالین‌یار`, RTL-first). Policy-tier, refund-status, per-channel-ETA, per-session-reason,
and failed/contact-support strings are all i18n keys off the enum codes — never hardcoded off a raw code.
---
## What is now testable and exactly how
Run `npm run dev` (the `services/refunds` mock is primary). Seeded bookings live in the f8 store:
| Booking | State | Demonstrates |
| --- | --- | --- |
| **5001** | confirmed, 3 sessions (today) | multi-session cancel at the **partial** tier (<24h) |
| **5002** | confirmed, single (today) | **BNPL** cancel — `processing` refund + the ~710-day ETA banner, walks to completed over polls |
| **5003** | in_progress, 3 sessions (1 completed, 2 un-started >24h) | the **mixed refundable/locked** breakdown at the **free** tier |
| **5004** | already cancelled | the **failed** refund → contact-support state (no retry) |
1. **Disclosure before confirm.** Open booking 5003 → *Cancel booking* → the resolved tier label, refund %
+ fee %, and the Toman refund-vs-fee split render **before** confirm is enabled (acknowledgement gate).
2. **Multi-session breakdown.** 5003 shows session 1 **locked** (completed) with a reason chip and sessions
23 **refundable**. 5001 shows all three refundable (partial tier).
3. **Refund progression.** Cancel 5002 (BNPL) → refund status walks *submitted → on its way → completed*;
polling stops at completed. Cancel 5001/5003 (card) → **completed** immediately.
4. **BNPL ETA.** 5002's refund shows the ~710-business-day window + a Shamsi ETA + provider-routed wording.
5. **No self-refund.** There is **no** issue/approve/retry control anywhere; 5004 shows contact-support copy.
6. **Locale + RTL.** Toggle `fa`/`en` → every string flips and is present in both files.
7. **Caching (Devtools).** The cancel mutation invalidates `bookingKeys.bookingDetail`/`lists` and primes
`refundKeys.byBooking`; the refund poll is active only while non-terminal; re-entry doesn't refetch.
---
## What is mocked (and how to make it real)
The whole customer cancel + refund surface is mocked behind `RefundsApi` (`USE_REFUNDS_MOCK = true`) because
b11 shipped refunds **admin-only** — there is **no** customer cancel command, policy preview,
refund-by-booking lookup, or fee-leg decomposition on the customer status. The mock reads the shared f8
bookings store (tier by lead time + per-session refundability), flips the booking to `cancelled`, and drives
card-immediate / BNPL-`processing` refunds. See the [mock registry](./mocks-registry.md) `RefundsApi` row.
The real `refundsClientApi` maps the published `refunds/{id}/status` 1:1 and targets the proposed slugs for
the gaps; when REQ-019/020/021 land, flip the flag — no hook/component change.
**Contract gaps filed** ([`for-backend.md`](../frontend/requests/for-backend.md)):
- **REQ-019** — customer-initiated cancellation command (`POST bookings/{id}/cancel`).
- **REQ-020** — pre-cancel cancellation-policy preview + per-session refundability + the canonical
`cancellation_policy_code` set (the tier codes `free_24h`/`partial_under_24h`/`customer_no_show` are
client placeholders mapped to i18n keys).
- **REQ-021** — `GET refunds/by_booking/{id}` + the fee-leg decomposition / policy / timestamps on the
customer `refunds/{id}/status` (today admin-only); confirm `provider_commission_reversed_amount` nullable.
## Prior-phase files touched (in place, noted per operating-rules §0.3)
- `services/bookings/apis/mockApi.ts` — added two **non-seam** exports (`mockGetBookingForRefund`,
`mockMarkBookingCancelled`) alongside `mockInsertConvertedBooking`, and two additive seeds (5003
mid-engagement, 5004 cancelled). Dated so they don't disturb the f8 *today* check-in demo; f8 tests build
their own fixtures and don't read the store, so unaffected.
- `constants/routes.ts``bookingCancelPath` / `bookingRefundStatusPath` helpers (id-keyed, like
`bookingInvoicePath`).
- `app/.../bookings/[id]/page.tsx` — composes `CustomerBookingActions`.
## Follow-ups for f15-b15 (admin)
The admin refund console (create/approve, leg-split editor, ticket linkage, the clawback "nurse already
paid" banner, **refund retry**), self-service *partial* refund UI, and holiday-specific policy overrides
remain DEFERRED. This phase is strictly the customer read + cancel-request surface.
@@ -0,0 +1,108 @@
# Frontend Phase 11 — BNPL installment checkout (D1D5) — report
**Track:** frontend · **Depends on:** f9 (checkout & payment) + the b12 BNPL contract · **Date:** 2026-07-10
**Gate:** `npm run check` green · `npm run test:ci` green (223 tests, +9) · `npm run build` green with
`NEXT_PUBLIC_API_URL` set (the build's missing-`.env` prerender failure is pre-existing/environmental and
affects unrelated pages).
## What was built
The **second exit off C6**: instead of paying the full card amount, the family chooses **اقساط** and is taken
through a provider BNPL flow (pick provider → pick plan → credit check → accept schedule → pay down-payment),
after which **the booking confirms exactly as the card path** (the provider paid Balinyaar in full). Repayment
is then tracked from the **کیف‌پول (Wallet)** tab.
### `services/bnpl` (new domain, f0 pattern)
- **`types.ts`** — the b12 enums (`BnplStatus`/`BnplEligibilityStatus`/`ProviderCode`), `BnplProvider` +
`BnplPlanOption` (per-plan served monthly/down-payment/total IRR strings), `BnplEligibilityResult`,
`BnplSchedule` + `BnplInstallmentRow`, `BnplOrderStatus`, `WalletInstallmentPlan` (D5 provider-reported),
the `BnplApi` seam, and helpers (`isTerminalBnplStatus`, `isBnplSettlementSuccess`, `installmentStatusKind`).
**All money is the IRR digit-string type.**
- **`keys.ts`** — `bnplKeys.options/eligibility/schedule/order/walletInstallments`.
- **`apis/`** — `clientApi.ts` (real, maps b12 `eligibility`/`initiate`/order-by-id 1:1; options/schedule/
wallet/by-request-order are REQ-022/024 gaps on proposed slugs), `mockApi.ts` (**PRIMARY**), selecting
`index.ts` (`USE_BNPL_MOCK`).
- **`hooks/`** — `useBnplOptions`/`useCheckEligibility`/`useBnplSchedule`/`useIssueBnplToken`/
`useAcceptBnplSchedule`(invalidates booking+checkout+wallet)/`useBnplOrder`(bounded backoff poll)/
`useWalletInstallments`. `invalidations.ts` reuses the f9 `invalidateAfterPaymentSuccess` + the wallet key.
### Screens (customer shell, terracotta financial accent = `--bal-secondary`)
- **D1 روش پرداخت** (`checkout/bnpl/page.tsx` + `MethodStep.tsx`) — payable amount + full-card option
(returns to the f9 card flow) + provider option cards **from `useBnplOptions`** (never hardcoded) + an
ownership disclosure at the point of choice. A stateful wizard (`StepperHeader`) drives D1→D4.
- **D2 انتخاب طرح** (`PlanStep.tsx`) — single-select `BnplPlanCard` group (term/fee/served monthly/down-payment).
- **D3 اعتبارسنجی** (`EligibilityStep.tsx`) — کد ملی (10-digit client format) + prefilled موبایل (session) +
**consent-gated** submit → `useCheckEligibility` → approved (credit ceiling) / declined+ceiling-exceeded
(declined panel **+ card fall-back**).
- **D4 تایید طرح و قرارداد** (`ScheduleStep.tsx`) — served repayment table (`InstallmentScheduleRow`) +
the ownership-truth note + a **contract-acceptance-gated** final action → `useIssueBnplToken` handoff.
- **Provider handoff** (`bnpl/gateway/page.tsx` harness → `bnpl/return/page.tsx`) — settle (down-payment
cleared) → invalidate → **the reused f9 confirmation** (`?method=bnpl` adds «پرداخت‌شده با اقساط»).
- **D5 پیگیری اقساط** (`wallet/page.tsx``WalletInstallments.tsx`) — provider-reported outstanding
balance (terracotta card), next-installment + **early-pay provider hand-off** (opens the provider URL, not
a Balinyaar payment), per-installment due list with status chips, and the provider-owned ownership note.
### Shared composites (tested)
- **`BnplPlanCard`** — D2 plan option (terracotta selectable).
- **`InstallmentScheduleRow`** — one repayment row, reused by D4 schedule + D5 wallet due list.
### Wiring / changes
- C6 (`checkout/page.tsx`): the «پرداخت اقساطی» button is enabled (`BNPL_ENABLED = true`) and navigates into
the wizard with `?request_id=`.
- The f9 confirmation renders the installments line on `?method=bnpl` (no parallel confirmation built).
- New `ROUTES.CHECKOUT_BNPL[_GATEWAY|_RETURN]`, the `installments` AppIcon, and the `bnpl` i18n namespace
(87 keys, both locales in sync, RTL-first, ICU-`number` params for Persian digits).
## What is now testable, and exactly how
Run `npm run dev`, sign in as a customer, reach a **confirmed** booking's checkout (C6) via the f9 flow (the
seam mock closes the loop):
1. **C6 → اقساط → D1**: payable amount + دیجی‌پی/اسنپ‌پی/اقساط بالین‌یار (terracotta). Pick → «ادامه با …».
2. **D2**: plans (۳/۶/۱۲ ماهه or ۴ قسط) with monthly + down-payment; amounts render in Toman via the money util.
3. **D3**: enter کد ملی, tick consent (submit stays disabled until consent) → **mock approves** with a ceiling.
**Declined path:** a national ID ending in `0``not_eligible` (declined panel + card fall-back);
an order above the ceiling → `ceiling_exceeded`.
4. **D4**: پیش‌پرداخت (امروز) + قسط rows with **Shamsi** due dates; accept the contract (final action disabled
until ticked) → handoff harness → **the booking confirms**, landing on the f9 confirmation marked اقساطی.
5. **کیف‌پول (Wallet) → D5**: provider-reported outstanding balance + next due + per-installment due list
(پرداخت‌شده / سررسید نزدیک / آینده), the ownership note, and an **early-pay hand-off** (opens the provider).
6. Switch to `en` → all BNPL strings translate, RTL⇄LTR flips; dark mode intact.
## What is mocked / waiting on a real service
`services/bnpl` is **mock-primary** (`USE_BNPL_MOCK = true`) — see the `BnplApi` row in
[`mocks-registry.md`](./mocks-registry.md). The mock reads the frozen request gross from the f7 store, plays
the provider (eligibility verdict, plan math, token/redirect), and on settle **reuses the f9 conversion
bridge** (`mockInsertConvertedBooking` + `mockMarkBookingRequestConverted`) — a settled BNPL order is a card
payment net-of-fee — then seeds a provider-reported Wallet plan. All money is served IRR digit-strings (the
mock computes plan/schedule with integer BigInt math; components only format). Swap is a one-line flip once
the upstream is real and the REQs land.
## Contracts consumed + gaps filed
Consumed [`dev/contracts/domains/bnpl.md`](../../contracts/domains/bnpl.md) (b12) for all types/routes.
Filed to [`for-backend.md`](../frontend/requests/for-backend.md):
- **REQ-022** — provider/plan options (D1/D2) + repayment schedule (D4); the contract serves neither and
explicitly does not model the repayment schedule. Also: add `balinyaar` to the `provider_code` enum.
- **REQ-023** — BNPL eligibility should accept the D3 KYC inputs (national ID / mobile / consent).
- **REQ-024** — provider-reported Wallet installment status (D5), a customer-readable `bookingId` on the
settled order, and a by-request order lookup for the return poll (`GET checkout_bnpl/{id}` is order-id-keyed).
## Adversarial review (multi-agent workflow)
Ran a 6-dimension review (money · ownership · contract · reuse/caching · gates/flow · i18n/RTL/theme) with an
adversarial verify pass. Of the raised findings, **5 were confirmed and all fixed**; the rest were refuted
(mock-allowed, non-reachable, or f9-parity conventions):
1. Return-page **window-expired** branch had a mislabeled CTA + wrong copy → now reuses the f9
`window_expired_*` + `back_to_request` keys (label matches the destination).
2. Real `getBnplOrder` keyed by request id vs the contract's order-id path → the settle-on-return now reads
the order **by its own id** (contract-correct); the by-request poll is honestly flagged as a REQ-024 gap.
3. Return-page order poll fired one needless fetch on immediate success → gated on the late-settle case only.
4. Dead i18n keys (`col_*`) deleted; the strongest ownership disclosure (`ownership_note`) is now rendered at D1.
5. D1 provider-glyph text was near-illegible in dark mode → switched to the `--bal-secondary-dark` text token.
## Follow-ups for later phases
- **f15 admin BNPL** — the admin revert/cancel console (b12 `admin_bnpl/*`) is out of scope here.
- **BNPL refund/revert** — the customer cancellation + BNPL revert ETA surface is **f10** (not duplicated here).
- When REQ-022/023/024 land, flip `USE_BNPL_MOCK = false` (one line); if `f12` nurse-earnings Wallet content
lands, it sits beside the self-contained D5 section under `/wallet`.
@@ -0,0 +1,105 @@
# Frontend Phase 12 — Nurse earnings & payout history — report
**Date:** 2026-07-10 · **Track:** frontend · **Depends on:** f8 (nurse booking detail) + the b13 payouts
contract · **Status:** complete (mock-primary) · **Gate:** `npm run check` green · `npm run test:ci` green
(242 tests, +3 suites) · `npm run build` green with `NEXT_PUBLIC_API_URL` set.
This is the **last money-path frontend phase** — it closes the loop on the nurse side ("I did the work,
where is my money?") as a strictly **read-only** surface.
## What was built
### `services/payouts` domain (nurse read; the `auth`-service shape)
- **`types.ts`** — string-literal unions + shapes derived from the b13 contract:
- `EarningsState` = `pending | eligible | paid | clawback_applied` — a **client display model** (there is no
single wire enum; it is derived server-side from `bookings.status` + `dispute_window_ends_at` + the payout
link + clawbacks). `PayoutStatus` = the **contract's** `pending | submitted | paid | failed` (NB `submitted`,
not "processing"). `PayoutBatchStatus` = `draft | processing | partially_failed | completed | failed`.
- `NurseEarningsSummary` (four buckets + a **signed** `netPayableBalanceIrr` that may be negative),
`NurseEarningsItem` (per-booking, all four states), `NursePayoutHistoryItem`, `NursePayoutBookingLink`,
`NursePayoutBatchContext`, `NursePayoutDetail`, `EarningsListParams`, and the `PayoutsApi` seam (**all reads,
no mutations**).
- **`keys.ts`** — `payoutKeys` with the **state filter + page baked into the key** (`earningsList(state, page)`,
`history(page)`, `detail(id)`, `earningsSummary()`) so each tab/page caches independently.
- **`constants.ts`** — `USE_PAYOUTS_MOCK = true` (mock-primary), a `MOCK_SCENARIO` toggle
(`standard` | `clawback_heavy`) for the negative-balance demo, generous per-read `staleTime`s (earnings move
weekly), `PAYOUTS_PAGE_SIZE`.
- **`apis/clientApi.ts`** — real HTTP impl: `getNursePayoutHistory` → the **live** `GET api/v1/nurse_payouts/
history`; `getNurseEarningsBalance` / `getNurseEarnings` / `getNursePayoutDetail` target the proposed
REQ-025 slugs. **`apis/mockApi.ts`** — the primary impl (self-contained, money-correct fixtures — see below).
**`apis/index.ts`** — one-line seam selector.
- **`hooks/`** — `useNurseEarningsBalance` / `useNurseEarnings(state, page)` / `useNursePayoutHistory(page)` /
`useNursePayoutDetail(id)` — all read-only `useQuery`, `keepPreviousData` on the paged lists, detail disabled
on an invalid id. **`index.ts`** re-exports hooks only.
### Three shared, tested composites (`src/components/`)
- **`EarningsBalanceHeader`** — the net payable balance + four buckets (pending/eligible/paid-lifetime/clawback
off `--bal-{warning,info,success,error}`). A **negative** net renders an explicit **"owed back"** state
(error-toned card, the magnitude only — never a bare minus).
- **`EarningsRow`** — the three-amount breakdown framed for the nurse (`gross commission = your payout`,
reconciled by `PriceBreakdown`), one of four **visually-distinct** state chips, and the state affordance:
pending → a **display-only** dispute-window countdown (reuses `CountdownTimer`); eligible → awaiting-batch;
paid → `paid_at` + `transferReference` + a payout-detail link; clawback_applied → the `original clawback =
net` explanation. Every row deep-links to the f8 booking detail.
- **`PayoutHistoryRow`** — net transferred + payout-status chip + period + masked IBAN (last-4, `dir="ltr"`) +
transfer ref; a `failed` payout shows its reason as a **read-only** banner (no nurse retry).
### Three nurse screens (nurse shell)
- **`/nurse/earnings`** — balance header + collapsible explainer + a `Tabs` state segment (All + the four states)
→ the earnings list (skeleton / empty / error-with-retry) + a prev/next pager.
- **`/nurse/earnings/payouts`** — the payout history list (same state treatments) → payout detail.
- **`/nurse/earnings/payouts/[id]`** — the reconciliation detail: batch window (Shamsi) + status chips, the money
decomposition (`gross_earnings clawback_applied = net_amount`, plus the amount transferred), masked IBAN +
transfer ref, a failure banner, and the list of covered bookings (each deep-linking to `/nurse/visits/[id]`).
### Wiring
- `earnings` nav item + a `PaidOutlined` icon; `NURSE_EARNINGS` / `NURSE_EARNINGS_PAYOUTS` routes +
`nursePayoutDetailPath` / `nurseBookingDetailPath` helpers; the three components in the `@/components` barrel;
a `payouts` i18n namespace (82 keys, **`en.json` + `fa.json` in sync**) + `nav.earnings`.
## What is now testable and exactly how (phase §7)
Prereq: `npm run dev` (mock is primary, `USE_PAYOUTS_MOCK=true`), sign in as a nurse, open **درآمدها**.
1. **Pending** — booking 5001 shows under **pending / "in escrow · dispute window open"** with a live countdown
off `disputeWindowEndsAt`; it counts into the **pending** bucket, never as paid.
2. **Eligible → paid** — booking 5002 shows **eligible / "awaiting the weekly batch"**; booking 5003 shows
**paid** with `paid_at` (Shamsi) + a `transferReference`, links to the payout detail, and appears in payout
history; the detail lists the exact booking(s) covered.
3. **Clawback nets the total** — booking 5004 shows **clawback_applied** with the `original clawback = net`
explanation (1,700,000 1,700,000 = 0). To see the **negative net balance** ("owed back"), set
`MOCK_SCENARIO = 'clawback_heavy'` in `services/payouts/constants.ts` → the header renders the explicit
owed-back state (magnitude only).
4. **Failed payout** — payout 9003 (in history + at `/nurse/earnings/payouts/9003`) shows `failure_reason`
`invalid_sheba` as a read-only banner; **no retry control** exists for the nurse.
5. **Money correctness** — every row satisfies `gross commission = your payout`; Toman = IRR ÷ 10; no BNPL
provider commission appears; the amount is identical for a card- vs BNPL-funded booking of the same gross.
6. **i18n / RTL / caching** — `fa`↔`en` translates + mirrors; switching state tabs / paging shows separate
cache entries (React Query Devtools) and **no refetch** of already-loaded data.
7. **Gate** — `npm run check` + `npm run test:ci` pass.
## What is mocked, and how it swaps
`services/payouts` is **mock-primary** (`payoutsMockApi`) because b13 serves the nurse **only**
`GET api/v1/nurse_payouts/history`. The four-bucket **earnings summary**, the per-booking **earnings list +
money-state**, the **nurse-readable payout detail** (batch context + booking links), and `failureReason` on the
history DTO are contract gaps filed as **REQ-025**. `payoutsClientApi` already maps the live history route 1:1
and targets the proposed slugs for the rest — when REQ-025 lands, the swap is a single `USE_PAYOUTS_MOCK=false`
flip with **no hook/component change**. Recorded in `reports/mocks-registry.md` (`PayoutsApi` row).
The mock fixtures are engineered to be **money-correct** (`gross = commission + payout`; `net = gross clawback`;
Σ booking-link amounts = `grossEarnings`; the signed net balance computed with BigInt, not clamped) and to
exercise **every** UI state (all four earnings states, all four payout statuses incl. a `failed` one, a negative
net balance). Booking ids 50015004 align with the f8 bookings-store seeds so "view booking" deep-links land on
real mock detail screens.
## Contracts consumed
- `dev/contracts/domains/payouts.md` (b13) — `GET api/v1/nurse_payouts/history` (live) + the enum codes
(`PayoutStatus`, `PayoutBatchStatus`) and the `PayoutDto`/`PayoutBookingLinkDto`/`PayoutBatchDto` shapes the
nurse-read analogues mirror. `api-conventions.md` (`page`/`pageSize`, envelope) + `money-and-types.md` (IRR
integer digit-strings, Toman display-only, UTC → Shamsi).
## Follow-ups (deferred, not built here)
- **REQ-025** (this phase's filing) — the three nurse-read endpoints + `failureReason` on the history DTO.
- **Admin payout console** (create/process/retry batch, eligible-earnings preview, clawback write-off queue) →
DEFERRED to f15.
- **Nurse bank-account add/verify (استعلام شبا) UI** → already the nurse onboarding/profile phase (f2/`/nurse/
bank`). This phase only *displays* the masked `iban_snapshot`; it never edits bank accounts.
- **On-demand / instant withdrawal**, per-nurse payout-frequency settings → DEFERRED product-side (MVP is weekly).
@@ -0,0 +1,156 @@
# Frontend Phase 13 (b14) — Reviews & Patient Care Records — report
**Track:** frontend · **Consumes:** [`reviews-records.md`](../../contracts/domains/reviews-records.md) (b14) +
`openapi/swagger.v1.json` · **Status:** complete, gate green.
The last feature-domain frontend phase: the moderated **review** loop and the **patient care-record** viewer/
authoring. Two new `services/{domain}` domains, four screens (+ a tab added to C3 + a CTA on the customer
booking detail), four new shared composites.
---
## 1. What was built
### Services
- **`services/reviews`** (`types`/`keys`/`constants`/`apis{index,clientApi,mockApi}`/`hooks`/`index`) — the
moderated-review domain. Hooks: `useNurseReviews` (infinite, published-only aggregate + list),
`useReviewEligibility(bookingId)`, `useMyReviewForBooking(bookingId)`, `useCreateReview` (invalidates
eligibility + my-review, **never** the public list/aggregate).
- **`services/patientRecords`** (same shape) — the continuity-of-care domain, **patient-scoped**. Hooks:
`usePatientCareRecord` (family record), `useRecordAccess` (gates before any clinical fetch),
`usePatientHistory` (paged visit-note history), `useUpdateCareRecord` (**customer-only** edit → `setQueryData`),
`useCreateVisitNote` (**nurse-only** append → invalidates history).
- **`services/patients`** — added `usePatient(id)` (E2 identity header; the seam already had `get(id)`).
### Screens & flows
- **Leave-a-review** — `/bookings/[id]/review` (`RatingInput` + body + `ReviewTagSelector`), gated on
completed/closed + server `can_review` + 1:1; on submit → persistent **"under review"** state (never public
here); already-reviewed shows the review's moderation state, never a second form. Entry: **`LeaveReviewCta`**
on the customer booking detail (page-only sibling, reads the cached booking + my-review).
- **Nurse-profile reviews tab (C3)** — added a «خدمات» / «نظرات» tab strip to the existing profile; the reviews
panel renders the **published-only** aggregate (rating + count) + an infinite list. The old single-review
snippet is subsumed.
- **E2 patient record viewer** — `/patients/[id]/record`: reused `PatientHeader` + ownership banner + four tabs
(داروها/روتین/سوابق/وظایف). The customer edits medications/routine/tasks; **سوابق** is the read-only nurse
visit-note history; a first-class, non-leaking **access-denied** card gates before any clinical fetch. Tapping
a `PatientCard` opens it.
- **Nurse visit-note authoring (E3)** — `NurseVisitNotesPanel` (co-located at `nurse/visits/[id]/`), mounted
**below** the f8 EVV banner: today's task checklist + a free-text note composer + the read-only continuity
history. **Append-only** — it exposes no record editing and never wires `useUpdateCareRecord`.
- **Longitudinal history** — patient-scoped, paged (Prev/Next when >1 page), read-only, newest-first, rendered
via the shared `VisitNoteCard` in both the E2 سوابق tab and the nurse continuity view; persists across nurse
changes (the mock seeds a multi-nurse default).
### Shared composites (each with a co-located `*.test.tsx`)
- **`RatingInput`** — 15 star input/display (custom, on the registered `star` `AppIcon`; filled
`var(--bal-warning)` / empty `var(--bal-divider)`; interactive = radiogroup, readOnly = static).
- **`ReviewTagSelector`** — multi-select review-tag chip group (i18n-free; caller passes `labelFor(code)`).
- **`VisitNoteCard`** — one read-only visit note (nurse name + Shamsi date + body + done/not-done task chips).
- **`PatientHeader`** — extracted from `PatientCard` so E1 + E2 share the identity header; `PatientCard` now
composes it + gained an optional `onOpen` tap handler.
- New icons registered: `notes`, `routine`, `tasks`, `history`, `family`.
- i18n: new top-level `reviews` + `records` namespaces (both locales, in sync) + `search.tab_{services,reviews}`
+ `patients.open_record`.
---
## 2. What is testable and exactly how (per phase §7)
Run `npm run dev` (with `NEXT_PUBLIC_API_URL` set; the b14 backend reachable or the seam mocks active — both
default on).
1. **Leave a review on a completed booking → pending → appears after moderation.** As a customer, open the
completed **booking 5005** (seeded in the f8 mock) → its detail shows **«ثبت نظر»**. Open it, give 4 stars +
body + a tag chip, submit → the screen shows **«در حال بررسی / under review»** and the CTA no longer offers a
second review. The review does **not** appear on the nurse's profile. To watch it publish (the f15 admin path
is deferred), in the browser console call the reviews mock's dev helper
`__mockPublishSubmittedReview(5005)` (exported from `services/reviews/apis/mockApi.ts`) → it appears on the
**nurse 1** profile reviews tab and the aggregate updates on the next fetch. A **cancelled** booking (5004)
shows no review CTA (not completed).
2. **View a patient record with tabs + ownership banner.** Patients tab → tap a patient → E2 shows the four
tabs, medication cards under داروها, the **«این پرونده متعلق به خانواده است …»** banner, and the customer can
edit a medication/routine/task (**ویرایش** → change → **ذخیره**) and see it persist (cache updates via
`setQueryData`, no reload). Navigating to **`/patients/8888/record`** (the `MOCK_FOREIGN_PATIENT_ID`) shows
the **access-denied** card, not a crash.
3. **A nurse appends a visit note (cannot edit the record).** As the nurse, open a visit (`/nurse/visits/5005`)
→ below the EVV banner, tick the task checklist, write a note, **«ثبت یادداشت»** → the note saves and shows in
the history. There is **no** medication/routine/task edit control anywhere in the nurse view.
4. **History persists across nurse changes.** The سوابق tab (customer) and the nurse continuity view show the
patient-scoped, newest-first timeline — including seeded notes from a **different** nurse (سارا محمدی) —
paginated.
5. **Gate checks:** `npm run check` green; `npm run test:ci` green (all suites); the reviews tab/list + record
edit show query caching + invalidation in React Query Devtools (no needless refetch).
---
## 3. What is mocked client-side + how it swaps
Both domains are **mock-primary** behind their `services/{domain}` seams (`USE_REVIEWS_MOCK`,
`USE_PATIENT_RECORDS_MOCK`, default `true`). See [`mocks-registry.md`](./mocks-registry.md) for the full rows.
- **Reviews:** `reviewsClientApi.getNurseReviews`/`createReview` map the live b14 routes **1:1**; the mock adds
what b14 doesn't serve — review-eligibility + my-review-for-booking (**REQ-026**) — and stands in for the
admin-only moderation (f15) via `__mockPublishSubmittedReview`. Swap = flip `USE_REVIEWS_MOCK` once REQ-026 lands.
- **Patient records:** `patientRecordsClientApi.getPatientHistory`/`createVisitNote` map the **real** b14
`care_records` GET/POST 1:1; the mock adds the family-owned record + access check (**REQ-027**, no backend
entity exists). Swap = flip `USE_PATIENT_RECORDS_MOCK` once REQ-027 lands (only the family-record/access
methods change).
- **f8 bookings mock (non-seam):** added **booking 5005** (`completed`) so a review is demoable + the cross-mock
read helper `mockGetBookingForReview` (one-way edge into bookings, no cycle).
---
## 4. Contracts consumed / requests filed
- **Consumed (real, mapped 1:1):** `POST bookings/{id}/review`, `GET nurses/{id}/reviews`,
`GET`/`POST patients/{id}/care_records`.
- **Filed** to [`for-backend.md`](../frontend/requests/for-backend.md):
- **REQ-026** — review-eligibility read + my-review-for-booking read + confirm the masked-author omission on
`ReviewListItemDto`.
- **REQ-027** — the family-owned `care_record` (medications/routine/tasks) GET/PUT + a `record_access` read
(or derive from the 403) + structured `taskResults` on a visit note. Flags that the wireframe's four-tab E2
record has **no data-model entity** — needs a product decision.
---
## 5. Follow-ups for later phases
- **⚠ Discovered pre-existing infra defect (repo-wide, NOT in the f13 diff): the ESLint unused-vars gate is a
no-op.** `client/eslint.config.mjs` tries to raise `@typescript-eslint/no-unused-vars` to `error` by
`.map()`-patching `eslint-config-next`'s default export — but that export never carries the rule (it lives in
the `eslint-config-next/typescript` subpath, which the config doesn't spread), so the patch matches nothing
and the rule is **never enabled** (verified via `eslint --print-config` — zero active `@typescript-eslint`
rules). `tsconfig.json` also lacks `noUnusedLocals`. Net effect: **unused imports/vars pass `npm run check`
silently**, contradicting `client/CLAUDE.md` golden rule #11 and the config's own comment. This is why the
adversarial review (not the gate) caught three dead imports in this diff — now removed; **the f13 diff is
verified 0 unused via `tsc --noEmit --noUnusedLocals --noUnusedParameters`.** The fix (spread
`eslint-config-next/typescript`, or re-register the rule in an explicit `**/*.{ts,tsx}` override, + fix the
false CLAUDE.md/config comments, + optionally add `noUnusedLocals`) is **deliberately deferred** here: it
surfaces ~6 pre-existing violations in earlier-phase files, so it wants a focused repo-wide cleanup + a
team decision, not a f13-scoped change. **Recommend a dedicated infra task.**
- **Review moderation UI** (admin approve/hide/reject queue) is **DEFERRED → f15** (`frontend-phase-15-b15`).
The client is publish-only; the dev helper stands in until then.
- **Tag-aggregation dashboards** ("% punctual") are deferred (the `GET nurses/{id}/review_tags` endpoint exists
and could back them). This phase renders per-review tag chips only.
- The **in-app "raise a concern" flag + emergency banner**`frontend-phase-14-b15`.
- Once REQ-026/027 land, flip the two mock flags; no hook/component change.
---
## 6. Gate
`npm run check` green (tsc + eslint). `npm run test:ci` green (257 tests, 58 suites; +17 new across
`RatingInput`/`ReviewTagSelector`/`VisitNoteCard`/`PatientHeader`/`PatientCard`). `npm run build` green with
`NEXT_PUBLIC_API_URL` set (all new routes compile; the env-required build failure without it is pre-existing and
unrelated). The f13 diff is verified **0 unused** via `tsc --noEmit --noUnusedLocals --noUnusedParameters`.
A **6-dimension adversarial review** (contract-fidelity, caching, security/access, i18n/RTL/tokens,
React-correctness, flow-integrity) ran with per-finding adversarial verification. Outcome:
- **Confirmed & fixed:** 3 dead imports (`Box` + `RECORD_HISTORY_PAGE_SIZE` in the E2 page,
`mockListBookingsForReview` in the reviews mock — the latter's orphaned bookings-mock export also removed);
the now-inaccurate history-only invalidation doc comment; the orphaned `search` i18n keys my C3 refactor left;
an unreachable task-checklist skeleton branch.
- **Confirmed but deferred:** the pre-existing ESLint-gate no-op (§5, repo-wide infra).
- **Verified false-positive (no change):** the `409`-for-not-completed (the contract specifies no status for
that case, only for the 1:1 `409`); the security/access dimension raised **nothing** (published-only,
append-only, and the non-leaking access gate all hold); the caching dimension raised **nothing**.
@@ -0,0 +1,168 @@
# Frontend Phase 14 — Messaging (tickets) & notifications — report
**Track:** frontend · **Depends on:** frontend-phase-8-b9 (booking detail) · **Consumes:** b15 tickets
([messaging-notifications-admin.md](../../contracts/domains/messaging-notifications-admin.md)) + b1 notifications
([config-reference.md](../../contracts/domains/config-reference.md)) · **Unlocks:** frontend-phase-15-b15 (admin
& partner consoles — reuses these services with the admin lens). Date: 2026-07-10.
The mission: give families and nurses the **only** sanctioned way to talk after a booking — the admin-readable
**ticket** system — plus the in-app **notification center** + polled **bell**, and the **emergency playbook
banner** on booking/support entry. No live chat by design; structured, auditable, anti-disintermediation.
---
## 1. What was built
### Two new domain services (copied the `auth`/`reviews` skeleton exactly)
- **`services/tickets`** — `types.ts` (client model, deliberately **no `isInternal`**), `keys.ts` (one
`detail(id)` = the whole thread; there is no message pagination in the contract — a `select`-based
`useTicketThread` derives the messages, mirroring f8 `useBookingSessions = select over detail`), `constants.ts`
(`USE_TICKETS_MOCK`, stale times, `MOCK_SEND_FAIL_SENTINEL`, per-role `MOCK_VIEWER_USER_ID`), `apis/`
(`clientApi` maps b15 1:1 + **drops any internal message defensively**, `mockApi` PRIMARY, selecting `index`),
hooks `useMyTickets` / `useTicket` / `useTicketThread` / `useOpenTicket` / `usePostMessage` (+ internal
`useTicketViewer`), barrel.
- **`services/notifications`** — `types.ts` (`AppNotification` + discriminated `NotificationData` union),
`parse.ts` (`parseNotificationData` — snake/camel-tolerant, degrades to `{kind:'none'}`), `deepLink.ts`
(`notificationDeepLink(n, role)` — role-aware, `null` when nothing to open), `keys.ts`, `constants.ts`
(`USE_NOTIFICATIONS_MOCK`, poll interval/staleTime), `apis/` (`clientApi` maps b1 1:1, `mockApi` PRIMARY),
hooks `useNotifications` / `useUnreadCount` (**the poller**) / `useMarkNotificationRead` / `useMarkAllRead`,
barrel (+ re-exports `notificationDeepLink`).
### Screens (shared by the customer **and** nurse apps — role decides chrome, not components)
- **My Tickets inbox** — `/support/tickets` (customer) + `/nurse/support/tickets` (nurse), both thin wrappers
over `<TicketInboxScreen role=…>`: prominent `referenceCode`, status chip (reused `StatusChip`), unread
indicator, null-safe linked-entity hint, relative Shamsi time; empty/loading-skeleton/error→retry; a
**Contact support** dialog (category → subject → message → submit → shows the new `referenceCode`).
- **Ticket thread** — `/…/tickets/[id]`: role-aware bubbles (mine vs theirs, **RTL-mirrored**), `referenceCode`
in the header, linked-booking chip, sticky **optimistic** composer; thread skeleton / empty / error states;
**never any internal-note content or affordance**.
- **Notification center** — `/notifications` + `/nurse/notifications` (`<NotificationCenter role=…>`):
unread-first list, each row **marks read on open** (optimistic) and **deep-links via `notificationDeepLink`**;
**Mark all read**; empty ("به‌روز هستید") / loading / error→retry; a growing-limit "load more".
- **Notification bell** — `<NotificationBell role=…>` in the customer TopBar (+ a support icon) and the nurse
shell (via a new `headerActions` slot on `TopBarAndSideBarLayout`; a Support item added to the nurse sidebar).
It subscribes to the polling count so **only the bell re-renders** on a change, never the shell.
- **Emergency banner + support entry on the f8 booking detail** — `<BookingSupportEntry bookingId role>`
(page-local glue, mounted below `BookingDetailView` on both the customer `/bookings/[id]` and nurse
`/nurse/visits/[id]` pages). It **reuses the cached booking query** (same key/viewer — no refetch) and, for
the **nurse on a post-confirmation booking**, the cached care-instructions read to surface the emergency
banner's `tel:` contact. Pre-confirmation → nothing. Customer → the support CTA only (no clinical phone).
### Shared composites (co-located `*.test.tsx`)
`MessageBubble` (mine/theirs, RTL, no internal styling), `TicketListCard` (prominent ref code + unread + null-safe
link), `EmergencyBanner` (post-confirmation `tel:` playbook, no VoIP seam), `NotificationRow` (unread emphasis +
server title/body), `NotificationBellView` (pure badge), `ContactSupportDialog`. Plus unit tests for
`notificationDeepLink` and `parseNotificationData`. **+32 test cases; the full suite is 66 suites / 289 tests.**
### i18n + icons + routes
`tickets` + `notifications` namespaces added to **both** `fa.json` and `en.json` (in sync, RTL-first) + `nav.support`;
`support` + `send` icons; route constants + role-aware path helpers (`ticketThreadPath`, `notificationsPath`, …).
---
## 2. Critical rules honoured (phase §5)
- **`is_internal` never reaches the user app.** No `isInternal` field in the user-app types, no internal styling,
no internal-note affordance anywhere. **Both** API mappers (`ticketsClientApi.mapThread` and the mock's
`toDetail`) **drop** any message flagged internal — the mock even stores an internal admin note it never
returns, so the no-leak behaviour is demonstrable.
- **No out-of-band channel except the post-confirmation emergency `tel:`** — drawn from the nurse-gated f8 care
read; the customer never sees a phone; no VoIP/calling seam; no SLA timers.
- **`referenceCode` shown prominently** in the inbox card + thread header.
- **Polite polling** — only `useUnreadCount` polls (60s `refetchInterval` + 45s `staleTime` + refetch-on-focus,
auth-gated); the notification **list is never polled**.
- **Optimistic send is draft-preserving** — `onMutate` appends a pending bubble; `onError` rolls back to the
snapshot; the composer keeps the draft (cleared **only** on server confirm) and retries; reconcile by
`clientMessageId` (no double-render); submit disabled while sending. The composer is **keyed on `ticketId`**
so a draft / in-flight send never crosses a thread→thread navigation.
- **`data_json` is a typed contract** — parsed into the discriminated union, deep-linked off that, degrades to
no-deep-link for an unknown type / missing id, never trusts a blob.
- **Tenancy / null links** — reads are server-scoped; ticket↔booking/refund chips render only when present.
---
## 3. How to test (what a human can verify) — the phase §7 steps
Run `npm run dev` (mocks are primary — `USE_TICKETS_MOCK`/`USE_NOTIFICATIONS_MOCK` default `true`):
1. **Open a ticket from a booking.** Confirmed booking detail → **"Get support / Open ticket"** → a coordination
ticket opens (booking 5001 already has one → it **jumps to the existing thread**, else creates) and lands in
**My Tickets** with its `referenceCode`. *Expected:* it appears at the top of the inbox with no manual refresh.
2. **Post a message (optimistic).** Open a thread, type, send → the bubble appears **immediately** ("sending") then
resolves to "sent". Send the dev sentinel body **`/fail`** → the bubble rolls back, **the text stays in the
composer**, and the "send failed" hint shows; edit + resend succeeds. *Expected:* no duplicate bubble, no lost draft.
3. **No internal notes leak.** Ticket 1201 (coordination, booking 5001) has a **seeded internal admin note** — the
user thread shows **none of it**, no styling, no affordance.
4. **Notification bell.** From the console call `window`-reachable dev helper (or import) `__mockPushNotification('ticket_message','پیام جدید','{"ticket_id":1201}')` → the **bell badge increments** within ~60s. Open the center,
open a notification → it **marks read**, the **badge decrements**, and it **deep-links** (booking → `/bookings/{id}`,
ticket → `/support/tickets/{id}`, etc.). **Mark all read** clears the badge. *Expected:* the count is served from
cache instantly and revalidates in the background; the endpoint isn't hit more often than the interval.
5. **Emergency banner.** On a **confirmed** booking in the **nurse** app, the banner shows with a `tel:` link
(booking 5001/5002 have a seeded contact) + the playbook copy; on an **unconfirmed** booking it is **absent**.
The support entry (inbox) shows the playbook without a specific phone.
6. **RTL + locales.** Switch `fa`/`en`: bubbles mirror, the badge sits correctly, every string is translated.
`npm run check` + `npm run test:ci` pass.
---
## 4. Mocks behind the two seams (swap = one flag)
Both domains are **mock-primary** — recorded in [mocks-registry.md](./mocks-registry.md):
- **`services/tickets` (`USE_TICKETS_MOCK = true`)** — `ticketsClientApi` maps every live b15 route 1:1
(`POST/GET /tickets`, `GET /tickets/{id}`, `POST /tickets/{id}/messages`) and defensively drops any leaked
internal message. The mock is primary because the linked bookings are themselves mock-primary and the wire
summary lacks `unreadCount`/`lastMessageAt` (**REQ-028**). It seeds 3 tickets (incl. the no-leak internal note
+ a booking-linked coordination ticket with idempotent open), a clears-on-open unread count, an optimistic
append attributed to the current viewer (`openTicket` now takes the viewer id; `postMessage` tracks the
last-viewed thread's viewer), a closed-ticket `403`, and the `/fail` failure trigger.
- **`services/notifications` (`USE_NOTIFICATIONS_MOCK = true`)** — `notificationsClientApi` maps every live b1
route 1:1. The mock is primary because nothing dispatches notifications client-side yet. It seeds an
unread-first feed spanning every deep-link class (each mapped through the **real** `parseNotificationData`) and
exposes `__mockPushNotification` for the bell-increment demo.
Flip either flag to `false` (one line in `apis/index.ts` selection via `constants.ts`) — no hook/component change.
---
## 5. Contract consumed + gaps filed
Consumed: `messaging-notifications-admin.md` (b15) + `config-reference.md` (b1) + `openapi/swagger.v1.json`
(`TicketSummaryDto`/`TicketThreadDto`/`TicketMessageDto`/`OpenTicketCommand`/`OpenTicketResult`/`PostMessageCommand`/
`PostMessageResult`; `NotificationDto`/`UnreadCountResult`/`MarkNotificationReadCommand`).
Gap filed — **REQ-028** in [for-backend.md](../frontend/requests/for-backend.md): (1) `unreadCount` + `lastMessageAt`
on `TicketSummaryDto`; (2) a message author label / confirm masked-by-design (the client uses the participant
**role** label, never a raw name); (3) a **user-facing by-booking ticket lookup** (only the admin list filters by
`bookingId`) so "Get support" can jump to the existing coordination thread on the real path; (4) an optional
`clientMessageId`/idempotency field on `POST …/messages`. Business-rule drift check: the contract does **not**
expose `is_internal` to users (the user `GET /tickets/{id}` is server-stripped) — no drift; the client models it
accordingly.
---
## 6. Review + gate
- **6-dimension adversarial review** (leak-and-tenancy, optimistic-send, notifications-cache-poll,
conventions-rtl-i18n, contract-fidelity, reuse-bugs) with per-finding adversarial verification. **1 finding
survived verification** — the mock `openTicket` mis-attributing a nurse-opened ticket's first message to the
customer (module-global `lastViewerUserId` defaulted to the customer). **Fixed** by threading the viewer id
through the `openTicket` seam (real path ignores it; server infers the sender) and adding the opener as a
participant. A second flagged item (composer state crossing a thread→thread navigation) did **not** survive
verification, but the composer was **keyed on `ticketId`** anyway as a correct-by-construction safeguard.
- **Gate:** `npm run check` green; `npm run test:ci` green (66 suites / 289 tests, +32); production build
**compiles + type-checks clean**. A pre-existing "Missing .env variable" prerender guard fails SSG on
`/en/addresses` + `/en/nurse/verification/identity` only — unrelated to this phase (sibling pages under the
same modified shells prerender fine); not introduced here.
---
## 7. Follow-ups for frontend-phase-15-b15 (the admin lens)
- The admin **global ticket queue** (`GET /admin/tickets` + `GET /admin/tickets/{id}` with internal messages) and
the **internal-note composer** layer on top of `services/tickets` — reuse the domain, add an admin-only
`isInternal` toggle **only in the admin view** (never in the user-app types).
- The **support-alert worklist** (`support_alerts/*`) + **partner-center** console + **audit viewer** are b15/b1
admin surfaces (DEFERRED here).
- When REQ-028 lands, flip `USE_TICKETS_MOCK = false`; when upstream domains dispatch real notifications, flip
`USE_NOTIFICATIONS_MOCK = false`.
@@ -0,0 +1,133 @@
# Frontend Phase 15 (b15) — Admin backoffice & partner-center consoles — report
**Date:** 2026-07-10 · **Track:** frontend · **Status:** ✅ complete — **this is the final frontend phase; MVP is complete.**
**Gate:** `npm run check` green (0 errors / 0 warnings) · `npm run test:ci` green (**325 tests, +36**) · `npm run build` green (all 18 new routes compile) · `admin`/`partner` i18n in sync · i18n key audit clean.
---
## 1. What was built
The internal **operational cockpit** that runs the marketplace: a role-gated **admin backoffice** in the f0 desktop
sidebar shell, plus a separately-scoped **partner-center portal**. All of it is a *read-and-act* surface over data
other domains own — the client renders the contract's values and issues the sanctioned commands; it **never**
computes eligibility, money decomposition, holiday shifts, the `is_verified` flip, or the review aggregate (§5).
### Two new domain services (the `auth`-service shape)
- **`services/admin`** — config, holidays, audit, support-alerts, RBAC. 12 hooks; filters + page in every key so
each worklist filter/page caches independently. Mock-primary.
- **`services/partnerCenter`** — admin-side center management + the center-scoped portal reads. 12 hooks. Mock-primary.
### Admin-endpoint additions to 5 existing domains (the staff lens — *not* new domains)
- **verification** (b6): `useVerificationQueue` / `useVerificationCase` / `useVerificationDocumentUrl` (on-demand
signed URL) / `useDecideStep` / `useApproveVerification` / `useRejectVerification`.
- **refunds** (b11): `useRefundPreview` / `useInitiateRefund` / `useApproveRefund` / `useRejectRefund` (ticket-linked).
- **payouts** (b13): `usePayoutBatches` / `usePayoutBatchDetail` / `usePreviewPayoutBatch` / `useRunPayoutBatch`
(idempotency-keyed) / `useRetryPayout` / `useRecordTransferReference`.
- **reviews** (b14): `useModerationQueue` / `useModerateReview`.
- **tickets** (b15): `useAdminTickets` / `useAdminTicket` / `useAdminTicketThread` / `usePostAdminMessage`. The **admin**
ticket types carry `isInternal`; the **user-app** types deliberately do not — the two surfaces stay distinct so an
internal note can never bleed into a user view (§5).
### Shared composites — `@/components/admin` (13, each with a co-located `*.test.tsx`; 36 tests)
`AdminPageHeader`, `AdminEmptyState`, `AdminErrorState`, `AdminPager`, `ConfirmDialog` (optional required-reason),
`AdminDataTable` (generic dense worklist table, horizontal-scroll-contained), `ConfigRow`, `AuditLogRow` (expandable
diff), `SupportAlertCard`, `PartnerSettlementRow` (reconciling commission/VAT breakdown via the f0 `PriceBreakdown`),
`DocumentViewer` (signed-URL on-demand + expired→re-request), `RefundPanel` (server-computed preview → initiate →
provider-failure retry / reject), `AdminMessageBubble` (internal-note styled distinctly).
### Screens (18 new routes)
- **Admin** (`/admin/*`, role-gated): overview landing · verification (queue + `[nurseId]` case) · tickets (queue +
`[id]` thread w/ internal-note composer + RefundPanel) · payouts (dashboard + `[batchId]` detail) · reviews
(moderation) · config · holidays · alerts · audit · partners (list + `[id]` detail) · roles (**DEFERRED-IF-MISSING**).
- **Partner portal** (`/partner/*`, separate scope): home · nurses · bookings · settlement (MoR-gated).
### Foundation
- **`useAdminCapabilities()`** (`@/hooks`) — a memoized selector over the session's fine-grained `roleCodes`
(added to `SessionUser`, hydrated from `/me` by `useSessionRoleSync`). Drives per-console `can*` booleans that
hide/disable controls a role can't use (server still authorizes every command).
- **`PartnerLayout`** + the `(private-routes)/partner/` route group (a separate authz scope).
- The `admin` + `partner` i18n namespaces in both locales (incl. پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی /
سامانه مودیان and the enum-label prefixes keyed off stable codes).
- New `AppIcon` registry entries; new `ROUTES.ADMIN_*` / `ROUTES.PARTNER_*` + path helpers.
---
## 2. What is now testable, and exactly how
Run `npm run dev`, sign in as an **admin** (mock-primary, so no live backend needed). The §7 acceptance steps:
1. **Verify a nurse.** `/admin/verification` → pick a pending nurse → the case loads each document via a **signed URL**
(`DocumentViewer`; sentinel `documentId 9999` exercises the error→re-request path). Pass the steps (reject one with a
required reason), enter a **structured credential** on a credential-bearing step; **Approve** is disabled until every
step is `passed` → confirm → the nurse leaves the queue. The client never wrote `is_verified`.
2. **Process a refund.** `/admin/tickets` → open the **refund** ticket (bookingId + refundId seeded) → **RefundPanel**
shows the **server-computed** tiered % + fee/payout decomposition (reconciles to the rial) + channel + BNPL ETA;
the post-payout booking shows the read-only **clawback notice**; the decline **sentinel** shows **retry**.
3. **Preview + run a payout batch.** `/admin/payouts`**preview next batch** → eligibility breakdown (eligible +
no-IBAN-skipped + clawback line + **holiday-shifted** processing date) → **run batch** (confirm requires an
**idempotency key** — a double-click can't double-pay) → `processing`. `[batchId]` detail → a **failed** payout →
**retry**; reconcile a `transfer_reference`.
4. **Moderate a review.** `/admin/reviews` → a `pending_moderation` review (low-rating flag) → **publish/hide/reject**
(reject/hide require a reason) → it leaves the queue; the client never computed the aggregate.
5. **Edit a config value.** `/admin/config` → edit `vat_rate` → the input validates **01** (`1.5` blocked) → save →
the dialog states it is **audited, immediate, non-retroactive** → the **history drawer** shows old→new + actor + Shamsi time.
6. **Resolve a support alert.** `/admin/alerts` → filter to **open****assign to me****resolve** with a note.
It appears in **no** customer/nurse/partner view.
7. **Holiday manager.** `/admin/holidays` → add a holiday with **`is_bank_closed` on** → listed (the client never
computes the shift; the payout preview in step 3 shows the server-shifted date).
8. **Audit viewer.** `/admin/audit` → filter by entity/actor/date → paginated; expand a row for the `changedFields`
diff. **No** edit/delete control exists.
9. **Partner portal.** Sign in mapping to a center admin → `/partner` shows onboarding state + license + MoR;
`/partner/nurses` + `/partner/bookings` show only that center's data; `/partner/settlement` renders the commission/VAT
invoices + signed-URL PDF + **masked IBAN** **only when merchant-of-record** (a non-MoR center shows the "via
Balinyaar" state). Back as an admin → `/admin/partners` → create/verify/activate + assign a nurse; IBAN is
write-then-masked (submit full, only last-4 ever shows).
10. **RBAC + i18n + RTL + caching.** A `support` role sees no payout/refund controls; a `moderation` role sees only
moderation (drive via `useAdminCapabilities`). `fa``en` translates every label (incl. the Persian legal terms) and
the sidebar mirrors. Switching worklist filters/pages caches separately (filters+page in the key) — no refetch of
loaded data.
---
## 3. What is mocked, and how to make it real
All f15 data is **mock-primary** (the real `clientApi` in each domain maps the live routes and targets proposed slugs
for the gaps; the swap is a one-line flag flip per domain — no hook/component change). Full detail in
`reports/mocks-registry.md` (three new/updated rows). Seams + flags:
- `services/admin``USE_ADMIN_MOCK` (config/holidays/audit/support-alerts live at b1; RBAC + config audit columns +
rich audit filters are gaps → REQ-029/030/031).
- `services/partnerCenter``USE_PARTNER_MOCK` (admin CRUD/verify/sponsor live at b15; portal split reads +
activate/suspend + invoice total are gaps → REQ-032/033).
- verification/refunds/payouts/reviews/tickets admin methods — the owning domain's flag (REQ-034/035/036/037).
**No backend seams were introduced**`IObjectStorage`/`IBankTransferProvider`/`IBnplProvider`/`IMoadianClient`/
`ILicenseVerificationService`/the audit interceptor/the notification dispatcher are all server-side (b1/b6/b11/b13/b14/b15);
the frontend never touches them.
---
## 4. Contracts consumed
`messaging-notifications-admin.md` (b15, primary) + `verification.md` (b6) + `refunds-invoices.md` (b11) +
`payouts.md` (b13) + `reviews-records.md` (b14) + `config-reference.md` (b1). All types derived from these — none guessed.
## 5. Requests filed (`frontend/requests/for-backend.md`)
**REQ-029** config `updatedAt`/`updatedBy` · **REQ-030** audit actor/action/date filters · **REQ-031** RBAC role endpoints ·
**REQ-032** partner-portal split reads + activate/suspend + IBAN write-then-masked · **REQ-033** center-scoped invoice list
+ invoice `totalIrr` · **REQ-034** verification nurse-level queue + on-demand doc URL + whole-verification approve/reject ·
**REQ-035** refund preview + explicit approve/reject · **REQ-036** payout single-preview + `holidayShifted` +
record-transfer-reference · **REQ-037** moderation `tagCodes`.
---
## 6. MVP-complete closeout
This is the **final frontend phase** — every customer, nurse, admin, and partner surface in the build plan now exists.
- **Deferred-if-missing:** the **RBAC roles console** (`/admin/roles`) is built against the mock and tagged
DEFERRED-IF-MISSING; it activates when the b15 role endpoints land (REQ-031). It is not on the testable acceptance path.
- **Modeled-but-inactive, no UI (as specified):** `organizations` / `organization_nurses` (employer model),
`fraud_flags` (ML console), `recurring_booking_schedules` (recurrence) — data-model/13. Also **no UI** for: full
سامانه مودیان e-invoice automation / digital-signature (the portal only *views* the issued invoice ref + PDF),
push/SMS channels, an analytics-warehouse dashboard, on-demand/instant payout, per-nurse payout-frequency settings.
- **Minor i18n substitutions the screen agents flagged** (all use existing keys; cosmetic, non-blocking): the payout
retry-success toast reuses `admin.saved`; the partner nurse verified/unverified column borrows `center_state_*`
labels; partner booking-status renders the raw stable status code. Add dedicated keys later if desired.
@@ -0,0 +1,82 @@
# Frontend phase 2 (f2-b3) — Onboarding & profiles — report
**Track:** frontend · **Consumes:** [`dev/contracts/domains/identity-profiles.md`](../../contracts/domains/identity-profiles.md) (backend-phase-3) · **Date:** 2026-07-02
## What was built
### Domain services (f0 `services/{domain}` pattern: types ← contract, keys factory, apis[client+mock+seam], one-hook-per-file, hooks-only barrel)
- **`services/patients`** — rewritten from the f0 reference stub to the b3 `PatientDto`. Full CRUD seam
(`list`/`get`/`create`/`update`/`archive`) + real `patientsClientApi` (action routes `patients/{list,get,create,
update,archive}`) + `patientsMockApi`. Hooks: `usePatients` (staleTime), `useCreatePatient`, `useUpdatePatient`
(both invalidate lists), `useArchivePatient` (**optimistic** remove + rollback + settle-invalidate). Helpers
`age.ts` (age↔birthDate) and the client-augmented `relation`/`conditions` (REQ-005, mock-persisted).
- **`services/profiles`** — customer + nurse profile. Seam `getCustomerProfile`/`upsertCustomerProfile`/
`getNurseProfile`/`upsertNurseProfile`/`uploadAvatar`; real client maps 404→`null` (no profile yet). Hooks
`useCustomerProfile`/`useNurseProfile` (queries) and `useUpsertCustomerProfile`/`useUpsertNurseProfile`
(setQueryData + invalidate `/me` for profile-completion) and `useUploadAvatar`.
- **`services/nurse`** — payout bank accounts (kept separate). Seam `list`/`add`/`setPrimary`/`verifyOwnership`;
`iban.ts` (Sheba `IR`+24 validate/normalize + bank-name from the 3-digit code). Hooks `useNurseBankAccounts`
(poll via `refetchInterval` **only while any account is pending**), `useAddNurseBankAccount`,
`useSetPrimaryBankAccount`. `deriveBankStatus` maps `matchedNationalId` (null→pending, false→mismatch,
true→verified).
### Shared composites (`src/components/…`, each with a co-located `*.test.tsx`)
`GenderToggle` (required male/female, never defaulted, can't deselect), `ConditionChips` (multi-select codes),
`RelationSelect` (radio cards), `PatientForm` (the A4 form — name/age/gender/conditions/relation, reused
create+edit), `PatientCard` (E1 card), `BankStatusPanel` (the three ownership states, masked IBAN, non-accusatory
mismatch). Reused the f0 `StepperHeader`/`StatusChip`/`PhoneNumberField` (not re-implemented). Added `--bal-primary-soft`
token (both schemes) and 5 AppIcon registry names (edit/archive/bank/camera/warning).
### Screens
- **A3→A4 onboarding** (`(customer)/onboarding/page.tsx`) — 2-step stepper: relation → patient form; creates the
first patient and lands on Home.
- **A5 Home** (`(customer)/page.tsx`, now a client component) — first-login gate: a customer with 0 patients is
redirected into onboarding (waits for a settled list so a post-create refetch never bounces back); otherwise the
"complete patient record" nudge (+ a profile nudge until `hasCustomerProfile`).
- **E1 patients** (`(customer)/patients/page.tsx`) — cached list, skeleton + empty states, add/edit dialog (A4 form),
soft-archive with confirm.
- **Customer profile** (`(customer)/profile/page.tsx`) — name + preferred language + emergency contact (reused
phone field). **No national-ID field.**
- **Nurse profile** (`nurse/profile/page.tsx`) — avatar upload + bio + years; unverified/not-bookable placeholder
→ verification; services/availability correctly deferred (a caption, not a stub).
- **Nurse bank** (`nurse/bank/page.tsx`) — IBAN + holder form; renders each account via `BankStatusPanel` in its
pending/verified/mismatch state; mismatch offers re-enter.
- **NurseLayout** sidebar gains Profile + Bank.
## What is now testable and exactly how (`npm run dev`, mocks default on — no backend needed)
- **Onboarding:** log in as customer → land on **A3** (step 1) → pick a relation → **A4**; submit **without gender**
→ blocked + "gender required"; fill it + submit → lands on **Home (A5)** with the nudge; the flow doesn't
re-trigger (a patient now exists).
- **Patients (E1):** Patients tab shows the new patient card (relation/name/age·gender/conditions). "+ Add patient"
→ dialog (A4 form) → appears without a full reload (invalidate). Edit → persists. Archive (confirm) → card
disappears (soft, `isActive=false`), not hard-deleted. Fresh session → empty state with add CTA. React Query
Devtools shows the list cached + invalidated on mutation.
- **Customer profile:** edit name + emergency contact → save → Home profile nudge clears. No national-ID field.
- **Nurse profile + bank:** log in as nurse → bootstrap profile (avatar + bio) → saves, shows **unverified /
not-bookable**. Bank settings → enter an IBAN → **pending** "در حال استعلام" panel → (after ~1 poll) **verified**
green with **masked** IBAN (last-4). Enter `IR000000000000000000000000`**mismatch** with re-enter CTA.
- **i18n / RTL:** toggle locale → strings flip fa↔en, `dir` flips, gender toggle / chips / stepper mirror.
## What is mocked client-side + how to make it real
All three services default to `USE_*_MOCK = true` (real HTTP clients fully wired for a one-line flip). See the
[mock registry](./mocks-registry.md) rows for `PatientsApi`, `ProfilesApi`, `NurseBankAccountsApi`. The b3
endpoints are live; the mocks stay on because of the three filed gaps:
- **REQ-005** — `PatientDto.relation` + `conditions` (client-augmented meanwhile).
- **REQ-006** — avatar/object-storage upload route + `avatarUrl` (real `uploadAvatar` throws `501`).
- **REQ-007** — customer `firstName`/`lastName`/`preferredLanguage` home (name is `/me`-only, read-only today).
Once each lands, flip the corresponding flag — no hook/component/call-site change.
## Contracts consumed
`identity-profiles.md` (b3) as the type source: `NurseProfileDto`, `CustomerProfileDto`, `PatientDto`,
`NurseBankAccountDto`, the enums (`gender` load-bearing), IBAN masking (last-4), guarded `isVerified`, tenancy-404.
Gaps filed in `requests/for-backend.md` (REQ-005/006/007) — no shapes guessed; augmented fields are clearly marked.
## Follow-ups for later phases
- **f3 (addresses & geo):** reuse this profile shell + the `services/{domain}` pattern; the A4/E1 sibling address
book slots in.
- **f4 (catalog & service builder):** the nurse **services-and-prices** builder and **available-days** picker slot
onto the B7 profile (both deferred here; a caption marks them).
- **f5 (verification):** replaces the neutral unverified placeholder on the nurse profile with the real
"not bookable until verified" banner; flips `isVerified` inside the backend transaction.
- **f7 (booking):** consumes the patient (needs a known `gender`) created here.
- When REQ-005/006/007 land, flip the three mock flags.
@@ -0,0 +1,108 @@
# Frontend Phase 3 — Addresses, map picker & nurse coverage areas (b4)
**Track:** frontend · **Consumes:** [`dev/contracts/domains/geography-addresses.md`](../../contracts/domains/geography-addresses.md) (backend-phase-4)
· **Unlocks:** f7 booking request (needs a chosen address) + f6 search (needs nurse coverage areas)
· **Date:** 2026-07-03 · **Gate:** `npm run check` green · `npm run test:ci` green (129 tests, +17 across 5 new suites) · `npm run build` green (routes `/[locale]/addresses`, `/[locale]/nurse/coverage` generated)
## What shipped
Both actors get their *place* on the map — pure geography, no money, no clinical data.
### Three domain services (mirroring the `patients`/`nurse` template)
- **`services/geography`** — the cached province → city → district reference lookups. `types.ts`
(`Province`/`City`/`District` + the `GeographyApi` seam), `keys.ts` (`geographyKeys.{provinces,cities,districts}`),
`constants.ts` (`USE_GEOGRAPHY_MOCK`, **`GEO_STALE_TIME = Infinity`** + `GEO_GC_TIME`, `CITY_CENTROIDS` +
`cityCentroid()`), `names.ts` (`pickRegionName`), `apis/{clientApi,mockApi,seed,index}.ts`, hooks
`useProvinces`/`useCities`(enabled on province)/`useDistricts`(enabled on city). **Aggressively cached**: each
level is fetched once per session and shared across both editors (and later f6 search).
- **`services/addresses`** — the customer address book. `types.ts` (`CustomerAddress` = wire `CustomerAddressDto`
+ client-augmented `provinceId`; `CreateAddressInput`; `AddressesApi`), `keys.ts`, `constants.ts`, full
seam+mock+client, hooks `useAddresses`/`useCreateAddress`/`useUpdateAddress`/`useDeleteAddress`/
`useSetPrimaryAddress`**every mutation invalidates `addressKeys.lists()`** (set-primary flips two rows, so
invalidate, don't hand-patch).
- **`services/serviceAreas`** — the nurse coverage areas. `types.ts` (`NurseServiceArea`; `AddServiceAreaInput`;
`ServiceAreasApi`; the pure **`areaExists`** dup-guard), `keys.ts`, `constants.ts`, seam+mock+client, hooks
`useServiceAreas`/`useAddServiceArea`/`useRemoveServiceArea` (add/remove invalidate the list).
### Two shared composites + two form/card composites (`src/components/geography/`, each tested)
- **`CascadingRegionSelect`** — province → city → district dependent MUI selects, **driving the cached geography
queries itself** so both editors drop it in with only `value`/`onChange`. Each level enables on its parent,
resets its children on change; a whole-city-only city surfaces the **whole-city** affordance; district is
optional. Guards edit-prefill against out-of-range values before options load.
- **`AddressMapPicker`** — the **map-pin stand-in** (see Mocks). A tappable/draggable marker canvas that emits
real `{ latitude, longitude }`; marker positioned with inline `style` (physical left/top) + `dir="ltr"` so the
RTL stylis plugin can't mirror the pin off its click point. Centres on the chosen city's centroid.
- **`AddressForm`** — the add/edit body: cascade + map pin + title + street + set-primary toggle; validates
**city required, pin required**, title + street required, district optional. Emits `CreateAddressInput`.
- **`AddressCard`** — presentational list card: title + primary badge (reuses the f0 `StatusChip`), region label,
street line, edit/delete/set-primary; set-primary shows only on non-primary cards (never two primaries).
### Screens
- **Customer address book** (`/addresses`, reached from a profile-hub link) — cards with primary badge, add/edit
dialog (cascade + map pin), set-primary, soft-delete confirm, empty + skeleton states.
- **Nurse coverage editor** (`/nurse/coverage`, new sidebar tab) — area chips (whole-city shown explicitly), an
add control (cascade + whole-city/specific-districts scope toggle), **inline duplicate block** (client
`areaExists` fast path + the server 409 mapped to the same message), remove confirm, and the empty-state
**"won't appear in search"** warning.
### Wiring
- `constants/routes.ts`: `ADDRESSES`, `NURSE_COVERAGE`. `AppIcon` registry: `location`, `delete`, `coverage`.
- i18n: new `geo`/`address`/`coverage` namespaces + `nav.coverage` in **both** `en.json` and `fa.json` (identical
key sets, RTL-first). Colours from `tokens.css` only.
- `client/CLAUDE.md` Project Structure + i18n namespaces + the reference-data caching convention updated.
## What is now testable, and exactly how
Run `cd client && npm run dev`, sign in (f1-b2 OTP). All three services default to their client mock, so the
flows work **without the backend running**.
1. **Cascading dropdowns + caching.** Customer → Profile → *Manage addresses**Add address*. Province → city →
district cascade; a whole-city-only city (Mashhad/Isfahan/…) shows the whole-city affordance. Re-open *Add
address*: the lists come **from cache** (React Query Devtools shows no refetch).
2. **Add with a map pin + set primary.** Pick city (+ optional district), **drop a pin**, enter title + street,
toggle primary, save → the card shows a primary badge. Add a second, set *it* primary → exactly one badge
moves. Save without a city or pin → inline errors.
3. **Nurse coverage + duplicate block.** Nurse → *Coverage*. Empty → the "won't appear in search" warning. Add a
**whole-city** area (a chip); add a **city + district** area; add the **same** pair again → inline "already
covered", no request fired; remove → the chip disappears.
4. **i18n / RTL.** Flip `fa``en`: every label/empty/error/duplicate string translates; the cascade, chips, and
map controls mirror correctly; colours match the brand tokens.
5. `npm run check`, `npm run test:ci`, `npm run build` all pass.
## What is mocked client-side (and how f-next swaps it)
All three services are behind a `services/{domain}` seam with a `USE_*_MOCK` flag (**default `true`**) and a
real `clientApi` already wired to the contract routes — the swap is a one-line flag flip per service. The
**`IGeocoder`** seam is backend-owned (backend-phase-4); the client only sends the picked coordinates. The
`AddressMapPicker` is a **stand-in** (no Neshan/Google tiles) behind a component boundary — a real map drops in
without touching `AddressForm`. See the **mock registry** for the exact rows + make-it-real steps.
## Contract consumed + gaps filed
Consumed `dev/contracts/domains/geography-addresses.md` (camelCase wire, snake_case query params, action-style
routes, 409 on duplicate coverage, single-primary address). Two gaps filed in
[`for-backend.md`](../frontend/requests/for-backend.md):
- **REQ-008** — accept the client-picked `latitude`/`longitude` on address create/update (the contract geocodes
server-side today; f3 requires the user's dropped pin for EVV precision). The client sends them + echoes locally.
- **REQ-009** — add `provinceId` to `CustomerAddressDto` so the edit form can prefill the province→city cascade
(the city list is fetched per-province). Client-augmented behind the seam meanwhile.
## Adversarial review (pre-merge)
Ran a 5-dimension multi-agent review (contract fidelity · conventions/i18n · single-primary · coverage
dedup/cascade · map/RTL/caching) with an adversarial verify pass. **3 findings confirmed and fixed:**
1. **(high, RTL)** `AddressMapPicker` marker's centering `transform` was in `sx`, so stylis-plugin-rtl
flipped its X on the default `fa` locale — the pin rendered ~one icon-width off the tap point. Moved the
transform into the inline `style` alongside the left/top offsets.
2. **(med, contract)** `addresses`/`serviceAreas` `list` sent `page_size` (snake_case), which the server's
`PageSize` binder ignores (only the geo lookups are explicitly snake_cased) → truncated lists once the real
endpoints are used. Changed both to `pageSize`, matching the patients template.
3. **(med, UX)** The coverage "specific districts" scope dead-ended on a whole-city-only city (district-less)
with contradictory `no_districts``district_required` messages. The page now reads the cached districts to
force whole-city for such cities (disables the "districts" toggle), so the add never dead-ends.
## Follow-ups for later phases
- **f7 booking request** consumes the chosen customer address (id + coordinates).
- **f6 search** consumes nurse coverage areas (whole-city rows match every district; reuse the `geography` cache
+ `geographyKeys`, don't reinvent). The same-gender filter is f6, not here.
- When REQ-008/REQ-009 land, flip `USE_ADDRESSES_MOCK` to `false`.
- Swap the `AddressMapPicker` stand-in for a real map (mock registry row).
@@ -0,0 +1,117 @@
# Frontend Phase 4 — Catalog browse (Home A5) & nurse service builder (B7) — Report (2026-07-05)
Lights up the two faces of the configurable catalog: the **customer Home (A5)** front door and the
**nurse "add a service" builder + offerings list (B7 services half)**, over a new cached `services/catalog`
domain. Consumes the b5 `catalog` contract. Unlocks search & discovery (f6).
## What was built
### `services/catalog` domain (`client/src/services/catalog/`)
- **`types.ts`** — DTOs mirrored from [`catalog.md`](../../contracts/domains/catalog.md) (**camelCase** wire):
`ServiceCategory`, `ServiceOptionGroup` (`serviceCategoryId: number|null` = cross-category, `isRequired`),
`ServiceOptionValue`, `NurseServiceVariant` (`price` = **IRR digit-string**, `priceUnit`, `sessionCount`,
`options[]`), `CreateVariantInput`/`UpdateVariantInput`, the `CatalogApi` seam, and helpers
`isGroupApplicable` / `optionSetSignature` (the duplicate-listing signature). `PriceUnit` +the `PRICE_UNITS`
array are the **only** closed enum — categories/groups/values are data-driven.
- **`keys.ts`** — `catalogKeys`: `categories()`, `categoryOptionGroups(id)`, `myVariants(params)` /
`myVariantsLists()` (invalidation prefix), `variant(id)`.
- **`constants.ts`** — `USE_CATALOG_MOCK` (default `true`); `CATALOG_REFERENCE_STALE_TIME = Infinity` +
`CATALOG_REFERENCE_GC_TIME` (reference data cached session-long, like geography); `MY_VARIANTS_STALE_TIME`;
page sizes.
- **`apis/`** — `clientApi.ts` (real, action-style routes, camelCase bodies, `pageSize` pagination),
`mockApi.ts` + `seed.ts` (in-memory, behind the seam), `index.ts` (config-selected seam).
- **`hooks/`** (one per file) — `useServiceCategories`, `useCategoryOptionGroups` (cached reference data);
`useMyVariants` (paginated, auth-gated, self-scoped); `useCreateVariant`, `useUpdateVariant`
(`setQueryData` the row + invalidate), `useSetVariantActive` (deactivate/reactivate). `index.ts` barrels hooks.
- **`names.ts`** — `pickCatalogName(row, locale)` locale-label helper (fa primary).
### Shared components (`client/src/components/`, each with a co-located `*.test.tsx`)
- **`CategoryTile`** — data-driven, tappable category tile (icon disc + localised label; `selected` state for
the builder; robust `iconKey`→icon fallback to a generic `category` icon).
- **`PriceDisplay`** — renders `{Toman} {unit}` via the f0 money util + an i18n unit label off `price_unit`, and
the **unit-aware estimated total** (`price × sessionCount`, integer-safe) only when a duration is present.
- **`VariantCard`** — a nurse offering: `display_name`, `PriceDisplay`, active/deactivated distinction (dimmed +
neutral chip + "can't be booked" hint), Edit + Deactivate/Reactivate actions — **no delete affordance**.
### Money util (`client/src/utils/money.ts`)
- `tomanToRial(value)` — the sanctioned **Toman→IRR digit-string** conversion at the field boundary.
- `multiplyIrr(value, count)` — integer-safe `price × count` for the estimated total (never from price alone).
- Both unit-tested (`money.test.ts`).
### Screens
- **Customer Home (A5)** — `app/[locale]/(private-routes)/(customer)/page.tsx` (rewritten): greeting + avatar
(`useMe` firstName / initial), **search bar** (navigates toward `/search?q=…`; execution deferred to f6),
**data-driven category grid** (`useServiceCategories`; loading skeletons / empty / error+retry; tiles carry
`service_category_id``/search?category_id=`), the complete-patient-record nudge (reuses the **cached f2
`usePatients`** — no new fetch), and the preserved first-login onboarding gate.
- **`/search`** — deferred `PlaceholderScreen` stub so the Home CTAs don't dead-end (echoes the q/category).
- **Nurse Services & prices (B7)** — `app/[locale]/(private-routes)/nurse/services/` (new sidebar tab). `page.tsx`
switches between `MyServicesList` (offerings — active/inactive, edit, soft-deactivate w/ confirm, reactivate,
empty/skeleton) and `VariantBuilder` (create/edit).
### Builder (`VariantBuilder.tsx`)
- **Create** = 3-step stepper (reuses the f0 `StepperHeader`): category (CategoryTile grid) → options
(single-select `ToggleButtonGroup` per group; required badge; **blocks advancing until every required group is
answered**, cross-category groups included) → price+unit+duration. Price entered in **Toman** → submitted as an
**IRR digit-string** (`tomanToRial`, no float on the path); **live unit-aware estimated total** via
`PriceDisplay`; editable auto-generated `display_name`. The duplicate-listing **`409`** shows a friendly inline
warning. **Edit** locks the category + option-set and edits only price/unit/duration/display.
### Wiring
- i18n: `catalog` / `services` / `search` namespaces + `home` additions + `nav.services` (both locales, in sync,
313 keys). Icons: `services`, `category`, `elderly`, `post_surgery`, `infant`, `chronic`, `companionship`.
Routes: `SEARCH`, `NURSE_SERVICES`. Nurse sidebar gains **Services**.
## What is now testable (and exactly how)
Run `npm run dev` (mock is on by default — no backend needed). Follow the phase §7 steps:
1. **Home** — sign in as a customer → greeting + avatar, search bar, and the **category grid** (5 seeded
categories ordered by `sortOrder`); the patient nudge shows/hides off the cached patient state (React Query
Devtools: the `patients` query is **reused**, not refetched).
2. **Locale + RTL** — toggle `fa``en`: labels translate, `dir` flips, tiles/grid mirror; Persian unit labels
(ساعتی/روزانه/شبانه‌روزی) read correctly.
3. **Build a variant** — sign in as a nurse → `/nurse/services``+ افزودن خدمت` → pick **مراقبت از سالمند**;
in options, try **Next** without answering **نوع شیفت** (required) → blocked, the required badge turns red;
answer it → Next; enter a **Toman** price + unit (ساعتی) + a duration → the **estimated total** updates from
`price × duration`; edit the auto `display_name`; submit → the card appears (`… تومان ساعتی`).
4. **Duplicate** — create a second variant with the **same category + same option-set** → the friendly
`409` duplicate warning shows inline; no crash / no generic toast.
5. **Edit + deactivate** — edit a variant's price/name → the list reflects it without a full refetch (Devtools:
`setQueryData` + single invalidation). Deactivate → confirm dialog → the row dims to the deactivated state
with the "can't be booked" hint; **no delete** option. Reactivate from the inactive row.
6. **Caching** — Devtools: `catalogKeys.categories()` / `categoryOptionGroups` are served from cache across Home
and every builder step (no per-step refetch); a variant mutation invalidates only `myVariants`.
7. **Gate**`npm run check` + `npm run test:ci` pass.
## What is mocked / waiting on a real service
- **`CatalogApi`** — `client/src/services/catalog/apis/mockApi.ts` (+ `seed.ts`), behind `USE_CATALOG_MOCK`
(default `true`). Faithfully reproduces the b5 create validation (`400` missing-required / bad-price) and the
`(nurse, category, option-set)` duplicate **`409`**; categories mirror the real seed; the variant store starts
**empty** (so the empty state demos). See [`mocks-registry.md`](./mocks-registry.md) (`CatalogApi` row).
**Swap = one line** (`USE_CATALOG_MOCK = false`); `catalogClientApi` is already wired to the action-style routes.
Caveat recorded there: the mock seeds representative **option groups the fresh backend does not** (an admin
authors them per category) — after the swap, categories have no groups until seeded server-side.
## Contracts
- **Consumed:** [`dev/contracts/domains/catalog.md`](../../contracts/domains/catalog.md) (backend-phase-b5) —
types/services derive from it; no shape guessed.
- **Requested:** `REQ-010` in [`for-backend.md`](../frontend/requests/for-backend.md) — confirm/align the list
pagination query-param name (`pageSize`, per the proven b4 binding, vs the doc's `page_size`).
- **Produced:** none (frontend produces no contract).
## Docs updated
- [`client/CLAUDE.md`](../../../client/CLAUDE.md) — *Project Structure* (customer `search` stub, nurse `services`
route, `services/catalog` domain, `CategoryTile`/`PriceDisplay`/`VariantCard`); the reference-data caching note
(catalog = second long-lived cached domain); the i18n namespaces list (`catalog`/`services`/`search`, `home`).
- This report + `STATUS.md` + `mocks-registry.md` + `for-backend.md` (REQ-010).
- No product-doc rule change was needed; the estimated-total presentation rule (total only from
price × session_count, never price alone) is captured here and enforced in `PriceDisplay`/the builder.
## Follow-ups for later phases
- **f6 (search & discovery)** — the Home **search bar** hands a `q` / `service_category_id` to `/search` (today a
placeholder); f6 builds the results, filters, and nurse cards, and can **reuse** `CategoryTile`, `PriceDisplay`,
the cached `useServiceCategories`/`useCategoryOptionGroups`, and the `catalog`/`search` namespaces. The **variant
builder is what populates the index f6 reads** (a nurse must have ≥1 active variant + coverage to appear).
- **Backend** — deliver `REQ-010` (pagination param confirmation), then f6 can flip `USE_CATALOG_MOCK=false`.
- **Deferred (unchanged):** admin catalog manager (→ f15), nurse availability slots, public nurse-profile service
rows (→ f6 C3), holiday/surge pricing.
@@ -0,0 +1,111 @@
# Frontend Phase 5 — Nurse verification flow (trust engine) — Report (2026-07-09)
Builds the trust engine's front end: the staged, platform-owned verification a nurse walks before any
service can go live. A nurse lands on a **status checklist (B3)**, submits **identity (B4)**, submits
**professional credentials (B5)**, and waits on **under-review (B6)** until an admin decides — then a
**trust badge** renders and the **publish gate** unlocks. Consumes the b6 `verification` contract. Unlocks
a bookable verified nurse + the `<TrustBadge>` f6 reuses.
## What was built
### `services/verification` domain (`client/src/services/verification/`)
- **`types.ts`** — DTOs mirrored from [`verification.md`](../../contracts/domains/verification.md)
(**camelCase** wire): `VerificationStatus` (aggregate `status` + `isBookable` + `blockingSteps` +
ordered `steps[]`), `VerificationStep`, `RunStepResult`, `UploadUrlResult`, `DocumentConfirmedResult`,
`VerificationDocument` (metadata only), `NurseCredential`, `TrustBadge`, the `VerificationApi` seam, and
the string-literal enums (aggregate/per-step status, the six step codes, credential type, verification
method, `BadgeState`). Helpers: `isApproved`, `ownBadgeState` (own-profile, computes `expired`),
`publicBadgeState` (public/search — verified/unverified only), `SPECIALTY_PRESETS`.
- **`validation.ts`** — `isValidNationalId` (10-digit mod-11 checksum; rejects all-same-digit).
- **`keys.ts`** — `verificationKeys.status()` (the single cached source B3+B6 read), `.documents(code)`,
`.badge(nurseId)`.
- **`constants.ts`** — `USE_VERIFICATION_MOCK` (**default true**), the status `staleTime` (30 s) + badge
`staleTime`/`gcTime`, the document type/size caps (jpg/png/pdf, 5 MB), `NATIONAL_ID_LENGTH`.
- **`apis/`** — `clientApi.ts` (real HTTP, action-style routes, XHR signed-URL PUT for upload progress +
SHA-256 integrity hash), `mockApi.ts` (**primary**, full journey + dev-only admin sim), selecting
`index.ts`. Both implement `VerificationApi`; swap is one line.
- **`hooks/`** — one per file: `useVerificationStatus` (query), `useStartVerification` (setQueryData),
`useSubmitIdentity` (runs KYC → chained Shahkar), `useRunBankVerification`, `useUploadVerificationDocument`
(progress via vars), `useSubmitCredentials`, `useNurseTrustBadge`. **Every mutation invalidates
`status()`** (badge where relevant), so the checklist re-renders from cache with no manual refetch.
### Screens — nurse verification route subtree (`app/[locale]/(private-routes)/nurse/verification/`)
- **B3 `page.tsx`** — the hub: loading skeleton, error+retry, `not_started` start-CTA, the in-progress
**"X از Y" meter + data-driven checklist** (`VerificationChecklist` reusing the shared `StatusChip`), a
**blocking summary**, a single **continue CTA** that routes to the next actionable step (calls
`useStartVerification` first when `not_started`), and the terminal **`approved`** state (publish link).
- **B4 `identity/page.tsx`** — national-ID field (checksum) + national-ID card + liveness selfie **local
captures** (identity stores no server document — they feed the automated KYC), the honest auto-registry
note, submit → `useSubmitIdentity`. Handles national-ID mismatch (`failed`) and the **non-accusatory
shared-SIM** Shahkar failure distinctly.
- **B5 `credentials/page.tsx`** — INO number + specialty chips (presets + add-your-own) + **a
`<DocumentUpload>` per manual credential step (data-driven from `status`)** each moving its step to
`in_review`, + an optional education-cert local upload + optional registry fields; submit →
`useSubmitCredentials`, lands on B6. Copy reflects **manual review**, never an automated authority check.
- **B6 `review/page.tsx`** — the under-review resting screen: "در حال بررسی" + the 2448h note + a
**condensed mini-checklist** (reusing `StatusChip`) — a focused **second view of the same cached
`status()` query**, not a second fetch. CTA back to B3.
- **`verificationSteps.ts`** — the data-driven glue: `displaySteps` (prepends a synthetic passed **mobile**
step), `progressCounts`, `stepLabelKey`/`stepDescriptionKey`, `stepStatusChip` (the green/amber/grey/red
legend), `routeForStep`, `nextActionRoute`/`nextActionableIndex`.
- **Journey stepper:** B4/B5/B6 reuse the shared `StepperHeader` (identity → credentials → review).
### Shared components (with co-located tests)
- **`<DocumentUpload>`** — the reusable uploader: client type/size validation **before** upload, the full
idle → uploading(%) → success(✓ + name / local image preview) → error(retry) machine, re-upload on
reject, server-metadata as the "uploaded" truth, and a **local-capture mode** (B4). Its state chrome uses
the `verification` namespace; the caller passes the field label/hint.
- **`<TrustBadge state=…>`** — verified / unverified / expired off `--bal-*` tokens. Rendered on the nurse's
own profile; **exported so f6 reuses it** in search results + the public profile.
### Wiring
- **Publish gate** — `nurse/services/PublishGate.tsx` (in `MyServicesList`): the go-live CTA is **disabled
with a blocked-until-verified explanation** (+ link to B3) until the aggregate is `approved`. Mirrors the
server's guarded `is_verified` flip.
- **Trust badge on the nurse profile** — sourced from the own `VerificationStatus` via `ownBadgeState`; the
unverified banner now shows only until `verified`.
- **6 AppIcons** (`upload/document/refresh/identity/license/publish`), **4 route constants**, the
`verification` **i18n namespace (123 keys)** in both locales in sync.
## What is now testable, and exactly how
Run `npm run dev`, sign in as a **nurse** (mock auth code `123456` if `USE_AUTH_MOCK`), open
**/nurse/verification**:
1. **Checklist:** B3 shows "X از Y" with the mobile step already green, the rest `not_started`. The continue
CTA calls `start` (seeds steps) then routes to B4.
2. **Identity (B4):** a valid کد ملی + card image (watch the uploader progress → ✓) + selfie → submit → the
`identity_kyc` + `shahkar_match` steps update on B3 **without a refresh**. National-ID `0000000000`
failed KYC; national-ID `1111111111` → the non-accusatory **shared-SIM** Shahkar message.
3. **Credentials (B5):** INO number + upload each manual document (→ `in_review`) + specialty chips → submit
→ lands on **B6** ("در حال بررسی", 2448h, mini-checklist).
4. **Approval flips verified:** the **dev-only "Simulate admin review" panel** on B3/B6 (mock-flag-gated —
stands in for the deferred f15 admin queue) → *Approve all* → B3 shows `approved`, the **trust badge**
on the nurse profile shows **verified**, and the **publish CTA on /nurse/services is enabled**.
5. **Rejected step:** *Reject a document* → the step shows its **rejection reason** + a working re-upload
path; the publish CTA stays blocked.
6. React Query Devtools shows **one `verification.status` query** feeding B3 + B6 and invalidating on each
mutation. Toggle `/en``/fa` (RTL) and dark mode — strings + layout flip.
## What is mocked / waiting on a real service
`services/verification` runs behind **`verificationMockApi`** (`USE_VERIFICATION_MOCK = true`) — b6 isn't
reachable in this environment, so the mock drives the full journey (identity/Shahkar/bank runs, document
uploads, admin-decision sim). The **swap is one line** (`USE_VERIFICATION_MOCK = false`): `verificationClientApi`
is wired to every b6 route (`nurse_verification/*`, `nurses/{id}/trust_badge`), same hook signatures + query
keys, no call-site change. See the mocks-registry row for the deterministic test triggers. Vendor calls
(KYC/Shahkar/credential/IBAN/object-storage) are mocked **server-side** by b6 — the front end consumes them
as if real.
## Contracts consumed + gaps filed
- **Consumed:** [`verification.md`](../../contracts/domains/verification.md) + `openapi/swagger.v1.json` (b6).
- **Filed:** **REQ-011** — a nurse-facing endpoint for the structured credential details (INO number,
specialties, license fields) B5 collects; the contract records these only on the admin `decide`, so the
real `submitCredentialDetails` no-ops until it lands (the document uploads are contract-backed). Also
flagged: `VerificationStepDto` has no `isRequired` (the client treats every seeded step as required).
## Follow-ups for later phases
- **f6** reuses `<TrustBadge>` (import from `@/components`) on search results (C2) + the public nurse profile
(C3), sourced from `useNurseTrustBadge(nurseId)` / `publicBadgeState`. The `expired` state is own-profile
only (the public badge payload carries `isVerified` only).
- **f12 payout** depends on the `bank_account_verification` step (this phase deep-links it to the f2 bank screen).
- The **admin verification review queue** (pass/reject, doc viewer, credential entry) is **f15** — the
dev-only mock sim here is a stand-in, gated on `USE_VERIFICATION_MOCK`.
- Deliver **REQ-011** then wire `submitCredentialDetails` + flip `USE_VERIFICATION_MOCK=false`.
@@ -0,0 +1,88 @@
# Frontend Phase 6 — Search & discovery (C1/C2/C3) — report
**Date:** 2026-07-09 · **Track:** frontend · **Depends on:** f4 (catalog/category grid), f5
(TrustBadge), f3 (geo picker), f0 (money util, services pattern) · **Consumes:** b7 `search.md` +
b6 trust badge / b5 variant reads · **Unlocks:** f7 booking request.
## What was built
A vertical discovery slice — the trust funnel where a family picks a real, verified nurse.
### `services/search/` (the domain, copies the f0/auth shape)
- **`types.ts`** — `NurseSearchFilters` (the cache key/URL shape), `NurseSearchResult` (C2 card row),
`NurseProfile` + `NurseProfileServiceRow` + `NurseReviewSnippet` (C3), the `SearchApi` seam. Derived
from the b7 contract; the fields b7 doesn't expose are documented inline + filed (REQ-012).
- **`keys.ts`** — `searchKeys.results(filters)` / `searchKeys.profile(id)` + `canonicalizeSearchFilters`
(stable key order, absent optionals omitted) — the filter-object-as-query-key caching contract.
- **`constants.ts`** — `USE_SEARCH_MOCK` (true, primary), stale/gc times, page size, debounce ms.
- **`filterParams.ts`** — the single C1↔C2 URL (de)serializer (snake_case, matching b7 params) so the
screen that writes the URL and the screen that reads it never drift.
- **`apis/`** — `mockApi.ts` (primary; real-shaped verified fixtures in `seed.ts`, reproduces b7
filter + whole-city geography + rating-sort semantics), `clientApi.ts` (real b7/b6 mapping scaffold,
gaps left blank), `index.ts` (seam selection by `USE_SEARCH_MOCK`).
- **`hooks/`** — `useNurseSearch` (keepPreviousData, enabled on category+city), `useNurseProfile`
(enabled on id), `useDebouncedValue` (generic, used by the C1 controller). `index.ts` re-exports hooks.
### Screens (all RTL/i18n/dark-mode, under the customer bottom-tab shell)
- **C1** `/search` — reused f4 category grid (selectable) + f3 `CascadingRegionSelect` (district
optional = whole city) + **prominent same-gender toggle** (خانم/آقا/فرقی ندارد) with a why-line +
intent-only date + Toman price range; a **live result count** drives the "مشاهده N پرستار" CTA into
C2. Fast-changing filter state in the colocated `useSearchFilters` controller (debounced price).
- **C2** `/search/results` — result count + rating sort control (one option; other sorts DEFERRED),
rating-sorted `NurseResultCard` list, **all four states** (skeleton / empty "relax filters" with
concrete suggestions / error-retry / populated), load-more. Filters live in the URL.
- **C3** `/search/nurse/[nurseId]` — avatar/name/rating, ✓ تاییدشده (reused TrustBadge) + نظام پرستاری
(rendered only when `inoMembership`), attribute chips (specialty codes → i18n + years-experience),
`ServicePriceRow` services list, latest-review snippet (+ "no reviews" empty), loading/not-found/error
states, and the **"درخواست رزرو"** CTA that hands off to `/bookings/request`.
### Shared components (tested)
- **`NurseResultCard`** — presentational + memoized; avatar, name, reused verified badge, rating +
review count, optional distance chip, "from X تومان/unit" via `PriceDisplay`.
- **`ServicePriceRow`** — service name + `PriceDisplay` (money util + i18n unit label); reused by the
booking summary in f7+.
### Other
- Added `star` + `tune` icons to the AppIcon registry. Routes: `SEARCH_RESULTS`, `SEARCH_NURSE`,
`BOOKING_REQUEST`. i18n: `search` filled + `booking` seeded, both locales in sync.
## Now testable, and exactly how (§7 of the phase)
Run `npm run dev` (mock is primary — no backend needed; or point `NEXT_PUBLIC_API_URL` at a b7 server
and flip `USE_SEARCH_MOCK=false`, noting the REQ-012 gaps).
- **Discovery E2E:** Home → tap a category (e.g. مراقبت سالمند) → C1 preselects it → set city (تهران),
gender (خانم) → CTA shows a real count → tap → C2 lists only verified nurses, rating-sorted, each with
photo/initials, name, ✓ تاییدشده, rating + review count, distance, "from X تومان/ساعت".
- **Profile:** tap a card → C3 shows badges, attribute chips, services + Persian unit labels, latest
review → "درخواست رزرو" → `/bookings/request` echoing nurse + variant + gender intent.
- **Empty state:** search Mashhad/Isfahan/Shiraz (seeded empty) → "relax your filters" with suggestions.
- **Caching (headline):** React Query Devtools → filter set A → set B (one fetch) → **revert to A**
instant, **zero** new requests. Type in the price field → one debounced request, not one per keystroke.
- **i18n/RTL:** flip fa↔en — all labels/badges/units/empty copy translate + mirror; dark mode holds.
## Mocked behind the seam (how f-next swaps it)
`services/search` is **mock-primary** (`USE_SEARCH_MOCK = true`) because b7's `NurseSearchResultDto`
row omits the nurse **name/avatar/distance**, and there is **no aggregated public nurse-profile
endpoint** (only the b6 trust badge + b5 single-variant read). `searchMockApi` supplies real-shaped
fixtures so C1/C2/C3 fully demo. The real `searchClientApi` already maps everything b7/b6 provide and
leaves the missing fields blank; once **REQ-012** lands (enrich the search row + add
`GET nurses/{id}/profile`), the swap is flipping one flag — no hook/component change. (This is a
client-side mock; it is recorded here, not in `mocks-registry.md`, which tracks backend DI seams.)
## Contract consumed / requests filed
- **Consumed (not edited):** `dev/contracts/domains/search.md` (b7) — `services/search/types.ts` derives
from it; b6 trust badge + b5 variant read for the profile scaffold.
- **Filed:** `frontend/requests/for-backend.md` **REQ-012** — search-row `nurseName`/`avatarUrl`/
`distanceKm` + an aggregated `GET api/v1/nurses/{id}/profile`.
## Follow-ups
- **f7 booking:** the "درخواست رزرو" handoff carries `nurse_id`, `variant_id`, `required_gender`
(the C1 same-gender intent → `required_caregiver_gender`/b8), `city_id`, `service_category_id`, `date`
as query params to `/bookings/request` (currently a DEFERRED stub). f7 builds the form + captures the
gender into the booking request.
- **C3 reviews tab:** DEFERRED → f13 (only the latest-review snippet ships now).
- **DEFERRED (per contract):** availability-window hard filter, sorts beyond rating, map/radius discovery.
## Gate
`npm run check` green · `npm run test:ci` green (165 tests, +8). `npm run build` compiles and
type-checks clean; the only prerender failure is the **pre-existing** f5 `/nurse/verification`
"Missing .env variable!" (needs env set) — unrelated to this phase's routes.
@@ -0,0 +1,118 @@
# Frontend Phase 7 — Booking request flow (C4 + C5 + nurse inbox) — report
**Date:** 2026-07-09 · **Track:** frontend · **Depends on:** f6 (search/nurse profile → the CTA + the
nurse's variants), f3 (addresses + map preview), f2 (patients), f0 (money/date utils, services pattern,
stepper/status composites) · **Consumes:** b8 `booking-requests.md` · **Unlocks:** f8 (booking detail →
the `converted` handoff) + f9 (checkout → the payment CTA).
## What was built
The money-free **request phase** of the engagement lifecycle — a customer turns a nurse profile into a
sent request and both sides close the loop, with server-frozen deadlines and two-stage clinical disclosure
made visible.
### `services/bookingRequests/` (the domain, copies the f0/auth shape)
- **`types.ts`** — `RequiredCaregiverGender`, the full `BookingRequestStatus` union + `TERMINAL_*` set +
`isTerminalBookingRequestStatus`, `BookingRequestDto` (customer/admin full-address vs nurse masked view),
`BookingRequestListItem`, `CreateBookingRequestPayload`, `RejectBookingRequestPayload`, the paginated
list params, `BookingRequestDisplayContext` (mock-only display aid), and the `BookingRequestsApi` seam.
Derived from the b8 contract; `variantPrice` is documented **client-augmented** (REQ-013).
- **`keys.ts`** — `bookingRequestKeys.list(role,status,page)` / `.nurseInbox(...)` / `.detail(id)`.
- **`constants.ts`** — `USE_BOOKING_REQUESTS_MOCK` (true, primary), poll/stale/gc times, page size, the
notes/reason limits, and the mock-only deadline windows.
- **`apis/`** — `mockApi.ts` (**primary**; a shared in-memory state machine, see below), `clientApi.ts`
(real b8 1:1 mapping — action-style snake_case routes, camelCase bodies, ids from the route), `index.ts`
(seam selection by the flag).
- **`hooks/`** — `useCreateBookingRequest` (mutation → seeds the detail cache + invalidates the customer
list), `useBookingRequest(id, role)` (query; **polls while non-terminal, stops at terminal/`converted`**
via a `refetchInterval` guard; `role` drives the mock's nurse-view masking), `useNurseRequestInbox`
(query; light polling), `useCustomerRequests` (query; for f8 reuse + create/cancel invalidation),
`useAccept`/`useReject`/`useCancel` (mutations → invalidate inbox list + detail). Barrel re-exports hooks.
### Screens (all RTL/i18n/dark-mode)
- **C4** `/bookings/request` (customer) — the request form. Patient dropdown (f2; empty → link to
`/patients`), service-variant dropdown (the chosen nurse's `services` from the f6 profile) + a
`PriceDisplay` of the picked rate, address dropdown (f3) + a read-only **map-pin preview** of the saved
address's coordinates, native date + time-window fields, a **first-class 3-way caregiver-gender toggle**
(خانم/آقا/فرقی ندارد) with a why-line, and a stage-1 notes field with a counter + honesty copy. Defaults
are **derived during render** (no setState-in-effect): the variant to the carried/first, the address to
the primary/first. The CTA is gated on all required fields + a client-side same-gender-mismatch block;
domain 400 codes map to field/form errors. On success → C5.
- **C5** `/bookings/request/[id]` (customer) — the awaiting screen. `BookingRequestSummaryCard` + the
3-step `StepperHeader` tracker + a status-driven body: **pending** shows a `CountdownTimer` to
`nurseResponseDeadlineAt`; **accepted** swaps the tracker, shows a ✓ badge + a terracotta 30-min
`CountdownTimer` to `paymentDeadlineAt` + the **ادامه پرداخت** CTA → `/bookings/checkout`; **rejected /
expired / payment-expired / cancelled** are terminal cards with a re-request CTA into search;
**converted** routes to booking (f8). Cancel-with-confirm while pending/accepted. Polls until terminal.
- **Nurse inbox** `/nurse/requests` — pending requests, each a card with patient name, Shamsi time, the
**required-gender chip**, a `customerNotes` preview, and a per-request `CountdownTimer`. Empty state +
light polling. New nurse-shell nav item.
- **Nurse detail** `/nurse/requests/[id]` — the summary + **only `customerNotes`** (masked coarse
city/district, a disclosure note) + accept / reject-with-reason (a dialog capturing the reason). A stale
`409` surfaces a warning + refetch. Both actions invalidate the inbox + detail.
- **`/bookings/checkout`** — an f9 DEFERRED stub (PlaceholderScreen) so the accept CTA doesn't dead-end.
### Shared composites (tested)
- **`CountdownTimer`** — a pure countdown to a **server-frozen** UTC instant; owns its own 1-second tick so
only it re-renders (never the page), stops at zero and shows an elapsed label + fires `onElapsed` once,
renders locale digits forced LTR. 4 tests (MM:SS / HH:MM:SS / tick / elapsed+onElapsed via fake timers).
- **`BookingRequestSummaryCard`** — nurse identity + rating, patient, priced service (`PriceDisplay` when a
price is present), address label, Shamsi date/time. Reused by C5 + nurse detail; f8 reuses it too. 4 tests.
### Other
- Routes: `BOOKING_REQUEST` (now the C4 form), `BOOKING_REQUEST_STATUS`, `CHECKOUT`, `NURSE_REQUESTS`.
Icons: `requests`, `payment`. i18n: `booking` namespace fully fleshed + `nav.requests`, both locales in
sync. The prior `/bookings/request` f6-handoff **stub was replaced** by the real C4 form.
## Now testable, and exactly how (§7 of the phase)
Run `npm run dev` (mock is primary — no backend needed). Within one browser tab (the mock store is a shared
module singleton):
1. **Submit:** from a nurse profile (C3) tap **درخواست رزرو** → C4 pick patient/variant/address/date/time +
a gender → **ارسال درخواست** → land on **C5** (tracker step 2 active, response countdown ticking).
2. **Nurse sees it:** open `/nurse/requests` → the new request appears with the gender chip + notes +
countdown; open the detail → **only the notes** (no address/clinical fields).
3. **Accept:** tap accept → it leaves the pending inbox (invalidated); navigate back to **C5** → step 2
done, step 3 active, **✓ پرستار تایید کرد**, the **30-min payment countdown**, and **ادامه پرداخت →**
(routes to the checkout stub with `request_id`).
4. **Reject:** on another request, reject with a reason → C5 shows the **rejected** terminal card + reason.
5. **Expiry:** the mock shortens the response window (see below) — once it lapses, a C5 poll (or reopening)
shows **expired_no_response**; after accept, the 30-min window lapsing shows **payment_deadline_expired**.
6. **Quality:** flip fa↔en (dir + strings), dark mode holds; Devtools shows the inbox/detail invalidating on
accept/reject and the C5 poll stopping at a terminal status.
## What is mocked (and how it swaps to real)
`services/bookingRequests` is **mock-primary** (`USE_BOOKING_REQUESTS_MOCK = true`). b8 is live server-side,
but this is a **client-side** mock for two reasons: (a) every *input* id — the nurse (f6 search), the
patient (f2), the address (f3) — comes from a **mock-primary** upstream domain, so a real
`booking_requests/create` would reference ids that don't exist in a real DB; (b) the contract DTO omits the
variant price the summary renders (REQ-013). The mock is a **shared in-memory state machine**: one
module-level store drives create → the nurse inbox → accept/reject/cancel → the customer C5 poll, with a
**lazy expiry sweep** on every read (the client stand-in for the server's background job), the nurse-view
**address masking**, and the forward-only status/deadline rules. Mock-only deadline windows: the payment
window is contract-accurate (30 min); the **response** window is shortened to 30 min (a stand-in for the
server's 24h) so a session can observe the expiry path. The real `bookingRequestsClientApi` maps the b8
contract 1:1 (the `context`/`role` args are mock-only and ignored). **Swapping to real is a one-line flag
flip** once the upstream domains are live and REQ-013 lands — no hook/component change. (Client-side mock;
recorded here, not in `mocks-registry.md`, which tracks backend DI seams.)
## Contracts consumed / requests filed
- **Consumed (not edited):** `dev/contracts/domains/booking-requests.md` (b8) — types derive from it. Note:
the actual routes are `booking_requests/{create,accept/{id},reject/{id},cancel/{id},list,get/{id}}` (the
phase file's illustrative `create_booking_request`-style names were superseded by the contract), and the
wire uses `requestedDate` + `requestedTimeStart/End` (not a single `scheduled_start_at`).
- **Filed:** `frontend/requests/for-backend.md`**REQ-013** (`variantPrice` + `nurseAvatarUrl` on
`BookingRequestDto`) and **REQ-014** (`variantLabel` + `patientAge` on `BookingRequestListItemDto`).
## Follow-ups for later phases
- **f8 (booking detail):** the C5 `converted` state routes to `/bookings` today — wire it to the real
booking-detail route when f8 lands. f8 should reuse `BookingRequestSummaryCard`.
- **f9 (checkout):** the C5 accept CTA → `/bookings/checkout?request_id=…` (a stub). f9 builds the C6
summary + escrow + card/BNPL and consumes the accepted request id.
- **Swap to real b8:** flip `USE_BOOKING_REQUESTS_MOCK=false` once search/patients/addresses are live and
REQ-013/014 land; verify the nurse-view masking + deadline freezing against the server.
## Gate
`npm run check` green · `npm run test:ci` green (173 tests, +8) · `npm run build` green with
`NEXT_PUBLIC_API_URL` set (all five new routes compile + prerender). Without the env var the build fails on
the **pre-existing** "Missing .env variable!" from `@/config` — it hits the existing `/fa/search` route
identically, so it is environmental, not an f7 defect.
@@ -0,0 +1,122 @@
# Frontend Phase 8 (f8-b9) — Booking detail, sessions & nurse EVV — report
**Date:** 2026-07-10 · **Lane:** frontend · **Consumes:** [bookings-evv.md](../../contracts/domains/bookings-evv.md) (b9)
· **Unlocks:** f9 (checkout/pay), f13 (reviews & patient records)
## What was built
The post-payment engagement — the hinge between "I asked for a nurse" and "a nurse is delivering care."
### Data layer — a **new** `services/bookings` domain
The **sibling** of `services/bookingRequests` (b8), **not** a rename — a distinct b9 contract, distinct
routes (`/api/v1/bookings/*` + `/api/v1/booking_sessions/*`), distinct shapes. Same `services/{domain}`
shape as every other domain:
- `types.ts` — derived 1:1 from the b9 swagger (camelCase): `BookingDetailDto`, `BookingSessionDto`
(`BookingSessionSummaryDto`), `VisitVerificationDto`, `CareInstructionsDto`, `BookingListItemDto`,
`BookingSessionListItemDto`, `CheckInVisitInput`/`CheckOutVisitInput`, the `BookingsApi` seam, the three
enum unions (`BookingStatus`/`BookingSessionStatus`/`VisitVerificationStatus`), and pure helpers
(`isBookingConfirmedOrBeyond`, `isBookingTerminalBranch`, `bookingTimelineActiveIndex`, `BOOKING_TIMELINE_ORDER`).
- `keys.ts``bookingDetail(id)`, `bookingSessions(id)` (alias of `bookingDetail` — sessions are embedded),
`today(params)`, `sessionEvv(id)`, `careInstructions(id)`, `list(params)`.
- `apis/` — real `clientApi` (maps the routes 1:1), `mockApi` (the seeded state machine, **primary**),
`serverApi.getBookingDetail` (the RSC-prefetch seam for the real path), and a config-selecting `index`.
- `evv/locationProvider.ts` — the **`ILocationProvider`** GPS seam (real `navigator.geolocation` vs a canned
mock; `getCurrentPosition()` resolves `null` on denial, never rejects).
- `hooks/` (one per file): `useBookingDetail`, `useBookingSessions` (a `select` over the detail query, no
second fetch), `useBookingList`, `useTodaySessions`, `useSessionEvv`, `useCareInstructions` (**enabled-gated**),
`useCheckInVisit`, `useCheckOutVisit` (both invalidate detail+sessionEvv+today+list on success).
### Shared composites — `src/components/booking/` (each with a co-located `*.test.tsx`)
- `BookingDetailView` — the both-roles smart container (role-conditioned EVV + gated care).
- `BookingStatusTimeline` — the server-truth 7-status timeline over the f0 `StepperHeader` + status chip.
- `SessionList``SessionCard` — per-session Shamsi schedule, status chip, EVV CTA, elapsed/payout.
- `EvvStatusBanner` — advisory banner (in-range success / out-of-range warning / no-gps neutral).
- `CareInstructionsCard` — the decrypted clinical read (conditions/meds/allergies/instructions/emergency).
- `BookingMoneySummary` — gross / commission (کارمزد) / payout, display-only via the money util.
- `useEvvController` — GPS-capture + check-in/out orchestration (one instance per surface, per-session busy).
- `format.ts` (clock/duration) + `statusKind.ts` (status → StatusChip kind) helpers.
### Screens
- **Customer:** `/bookings` (رزروها list) → `/bookings/[id]` (detail, customer view: timeline + sessions +
money; the care record shows the "visible to your nurse only" affordance and the query never fires).
- **Nurse:** `/nurse/visits` (ویزیت امروز — today's sessions with inline EVV check-in/out) →
`/nurse/visits/[id]` (detail, nurse view: EVV controls + the gated care card).
### Cross-cutting
- i18n `booking` namespace **extended** (both locales, key-synced): `bstatus_*`, `sstatus_*`, `evv_*`
(banner variants + CTAs + GPS copy), `care_*` (+ the customer lock copy), `money_*`, `list_*`, dispute note.
- 8 new registry icons (`check_in`/`check_out`/`gps`/`schedule`/`clinical`/`medication`/`emergency`/`lock`)
and one new token `--bal-secondary-soft` (the نمای پرستار chip / EVV affordance), both schemes.
## What is now testable, and exactly how
Run `npm run dev` (mock is primary — no backend needed). The b9 endpoints are also live if you flip the flag.
1. **Confirmed booking (customer):** open the رزروها tab → the list shows the two seeded bookings; open one
**status timeline** at `confirmed`, the **session schedule** (booking #5002 shows exactly **one**
session; #5001 shows **3**), and the **money summary** in Toman. Toggle `/en``/fa` → strings + `dir`
flip; the timeline reads RTL.
2. **Care gate:** open a booking **as the nurse** (`/nurse/visits` → a session → view booking) → the
**care-instructions card** is visible (conditions/meds/allergies/instructions/emergency). As the
**customer**, the card is absent and the Network tab shows the care request **was never made** (proven by
the `BookingDetailView` test too).
3. **Nurse check-in:** on `/nurse/visits` (or in the nurse booking detail), tap **«ثبت ورود (EVV)»** →
"در حال دریافت موقعیت…" → the **«ورود ثبت شد … موقعیت تایید شد (EVV)»** banner; the session chip →
`in_progress`; the timeline → `in_progress`. Set `NEXT_PUBLIC_EVV_MOCK_GPS=out_of_range` → the **advisory**
«موقعیت خارج از محدوده (در حال بررسی)» banner and the check-in **still succeeds**. `=denied` → the nurse
still checks in (no block; advisory toast + no-gps banner).
4. **Nurse check-out:** tap **«ثبت خروج (EVV)»** → the session chip → `completed` with elapsed duration; for
the single-visit booking (#5002) the timeline advances to `completed` + the dispute-window note appears —
all from the server response, no client-side step jump.
5. **Caching:** in React Query Devtools, an EVV mutation invalidates `bookingDetail`/`sessionEvv`/`today`/
`list` and the UI re-renders from the refetch; revisiting within `staleTime` does not refetch.
6. `npm run check` green · `npm run test:ci` green (195 tests, +22).
## What is mocked / waiting on a real service
- **`services/bookings` — mock-primary** (`USE_BOOKINGS_MOCK=true`). A booking only exists after
`bookings/convert` runs on a **paid** request, and both upstreams (`bookingRequests` mock, card capture
b10) aren't real client-side yet, so a real `bookings/list` returns nothing. The mock seeds confirmed
bookings + sessions + care + the EVV state machine. Real `bookingsClientApi` maps the routes 1:1; the swap
is one flag (see mocks-registry). `serverApi.getBookingDetail` is ready for the RSC prefetch on the real path.
- **`ILocationProvider`** (`NEXT_PUBLIC_EVV_MOCK_GPS`) — GPS capture seam; the real path is
`navigator.geolocation`. Server-side address-match math stays behind the backend geocoding seam.
- Both are recorded in [mocks-registry.md](./mocks-registry.md).
## Contract consumed + gaps filed
- **Consumed:** [bookings-evv.md](../../contracts/domains/bookings-evv.md) + the b9 swagger shapes — types
derive 1:1. No shape was guessed.
- **Filed:** **REQ-015** — confirm the booking/session/EVV enum **string codes** (bare `string` in swagger)
match the client unions, and the **`checkInAddressMatch` tri-state** (`true`/`false`/`null`) so the banner
can distinguish an advisory mismatch from "no GPS captured." Low-risk (mock-primary now); worth locking
before f9/f13 reuse the shapes.
## Deliberate design decisions (non-obvious)
- **Two-stage disclosure is a UI gate, not just a server check.** `useCareInstructions` is `enabled` only for
the assigned-nurse view on a `confirmed`+ booking; the customer/unassigned viewer **never fires** the
request (a 403/404 is treated as a defect path). The `BookingDetailView` test asserts the customer never
calls `getCareInstructions` and the nurse does.
- **EVV mismatch/denial is advisory, never a block.** Out-of-range check-in succeeds with a **warning**-tokened
banner (never the error token); GPS denial still submits. Check-out is never gated on the match.
- **Server-truth timeline + display-only money.** The timeline renders `BookingDetailDto.status` exactly (no
client step advance); money is rendered as-sent (no sum/derive/re-split); `payoutEligibleAt` is never
recomputed. Sessions are **embedded** in the detail (no standalone list endpoint) — `useBookingSessions`
is a `select` over the one detail query, so invalidating `bookingDetail(id)` refreshes both.
- **`ILocationProvider`** is the single new client seam; the `NEXT_PUBLIC_EVV_MOCK_GPS` default is `in_range`
while mock-primary so the happy path is demoable without a device (real GPS would never fall near the
seeded Tehran address).
## Follow-ups for later phases
- **f9 (checkout/pay):** the money summary here shows the confirmed **split only**; the **tax (مالیات) line**,
escrow notice, and invoice are the checkout surface — b9's `BookingDetailDto` has **no tax field** (flagged;
f9 owns it). The C5 accept CTA still lands on `/bookings/checkout?request_id=…` (f7 stub).
- **f13 (reviews & records):** the E3 **visit-note authoring** (bottom half) and the full **E2 patient-record
viewer** are deferred here; the booking-detail/EVV/care pattern (timeline + sessions + gated care + EVV
banner) is the template they extend. The customer-side **care-details authoring** (`submit_care_instructions`)
write form is also f13 — f8 only reads the gated record.
- **f15 (admin):** the EVV-review queue (mismatch / no-show worklist) is the admin console; f8 raises no
alerts client-side (no-show detection is a server job).
- **Swap to real:** flip `USE_BOOKINGS_MOCK=false` once `bookings/convert` is reachable client-side (b10
card capture) — `bookingsClientApi` + `bookingsServerApi` are wired; no hook/component change.
@@ -0,0 +1,165 @@
# Frontend phase 9 report — Checkout, card payment & invoice (consumes b10 + the invoice slice of b11)
**Status:** complete · gate green (`npm run check`, `npm run test:ci` — 204 tests, production build) · 2026-07-10
**Scope shipped:** the C6 خلاصه و پرداخت checkout, the card-payment state machine (initiate → redirect →
pending-callback → succeeded→confirmed / failed→retry), the confirmation screen, the invoice view, the
`services/payment` domain, and three shared money composites.
---
## 1. What was built
### `services/payment` (new domain — mirrors the `auth`/`bookings` shape)
| File | What it is |
| --- | --- |
| `types.ts` | Contract-derived DTOs + the `PaymentApi` seam. `PaymentTransactionStatus = pending\|succeeded\|failed` (the b10 enum — the phase file's illustrative `initiated/cancelled` states do **not** exist on the wire and were not used). `CheckoutSummaryDto` (REQ-016 shape), `InitiatePaymentResult` (b10 swagger), `PaymentOutcomeDto` (poll target), `InvoiceDto` (b11 swagger, flat totals — **no line-items array exists in the contract**). |
| `keys.ts` | `paymentKeys.summary(requestId)` / `.outcome(requestId)` / `.invoice(bookingId)`. |
| `constants.ts` | `USE_PAYMENT_MOCK=true` (why documented in-file), `BNPL_ENABLED=false` (the f11 seam gate), poll backoff tuning, checkout query-param names, mock money rates. |
| `apis/clientApi.ts` | Real impl: **initiate** = `POST api/v1/bookings/{id}/payments` + **`Idempotency-Key` header** (no body — contract-exact); **invoice** = `GET api/v1/invoices/{bookingId}`; **summary** targets the REQ-016 proposed slug; **outcome** maps `GET booking_requests/get/{id}` (`converted`→succeeded, `payment_deadline_expired`→failed, else pending — REQ-017). |
| `apis/mockApi.ts` | Mock-primary state machine — see §3. |
| `invalidations.ts` | `invalidateAfterPaymentSuccess` — the one post-capture cache transition (request detail/lists + bookings lists/detail + this request's summary/outcome). Exact keys, never a blanket refetch. |
| `hooks/` | `useCheckoutSummary` (short stale), `useInitiatePayment` (caller owns the per-attempt key), `useConfirmGatewayReturn` (primes the outcome key; invalidates on immediate success), `usePaymentOutcome` (**geometric-backoff poll**: 2s → ×1.5 → cap 15s, stops on terminal outcome or 40 attempts; exports `isTerminalPaymentOutcome`), `useInvoice` (immutable → long stale; **404 = "not issued", not retried**). |
### Screens (customer shell)
- **C6** `/bookings/checkout?request_id=` — «✓ پرستار تایید کرد» badge (reuses `booking.accepted_badge`),
nurse/service/schedule mini-summary, the payment-window `CountdownTimer`, the **served reconciling
breakdown** (هزینه خدمت / کارمزد بالین‌یار / مالیات بر ارزش افزوده / **مبلغ کل**) via `PriceBreakdown`,
the verbatim `EscrowNotice`, «ادامه پرداخت ←» (terracotta `secondary`), and the **BNPL seam** — a
disabled outlined «یا پرداخت اقساطی» + "coming soon" caption gated by `BNPL_ENABLED` for f11 to wire to D1.
Non-payable statuses render convergence cards (already-paid → outcome; window-expired / other terminal → back to C5).
- **Gateway harness** `/bookings/checkout/gateway`**test-only** fake PSP (labelled آزمایشی): success +
failure buttons so both return branches are drivable without a gateway. The phase file suggested
auto-success; buttons were chosen instead so §7 step 5 (failed attempt → new idempotency key) is testable by a human.
- **Return** `/bookings/checkout/return` — fires `useConfirmGatewayReturn` once per mount (ref-guarded; a
refresh replays it and converges idempotently), then the pending-callback poll. Succeeded → invalidate +
`router.replace` to confirmation (once, ref-guarded, no double invalidation between mutation and poll
paths); failed → retry (back to a fresh C6 mount = **new attempt, new key**); window lapsed → back to C5.
A manual «بررسی دوباره» covers the bounded poll giving up.
- **Confirmation** `/bookings/checkout/confirmation` — success state, amount-paid card, «مشاهده رزرو» →
`/bookings/{bookingId}` (falls back to the list without a bookingId — REQ-017), «دانلود فاکتور» →
`/bookings/{bookingId}/invoice` (hidden without a bookingId).
- **Invoice** `/bookings/[id]/invoice``invoiceNumber` + Shamsi issue date, `PriceBreakdown` rows where
the **service line is the exact integer remainder** (gross commission VAT, via `parseIrr` BigInt) so
the lines reconcile by construction, the VAT line labelled **«مالیات بر ارزش افزوده (بر کارمزد
بالین‌یار)»** (product rule: the nurse is never implied to be taxed), read-only **مودیان** state chip
(`moadian_*`), and `pdfUrl` download **or** a print receipt (`window.print()` + a print-scoped
visibility rule isolating the invoice card). 404 renders «فاکتور هنوز صادر نشده است» (REQ-018).
### Shared composites (each with a co-located test)
- **`PriceBreakdown`** — typed rows + total, all IRR digit-strings through `formatIrrToToman`; dev-guard
`console.error`s if rows ≠ total (test proves rows render, total = Σ rows, and the guard fires on mismatch).
- **`EscrowNotice`** — wraps `AppAlert` (info severity, `--bal-primary` text on `--bal-primary-soft`, lock icon). Its test mocks next-intl to
read **the real `fa.json`**, pinning the mandated copy verbatim: a rewording fails the suite.
- **`PaymentStatusBadge`** — full `PaymentTransactionStatus``StatusChip` kind map (`Record` typed, so an
enum change breaks the build); labels from `payment.pstatus_*`.
### Extensions to prior phases (in place, per operating rules)
- `services/bookingRequests`: client-augmented **`bookingId: number | null`** on `BookingRequestDto`
(REQ-017 twin of REQ-013's `variantPrice`; real client maps it to `null`), and the mock-only
`mockMarkBookingRequestConverted(id, bookingId)` capture bridge.
- `services/bookings/apis/mockApi.ts`: mock-only `mockInsertConvertedBooking(seed)` — inserts a confirmed
single-session booking (ids 6001+/80001+, distinct from the 5001/5002 seeds).
- C5 (`bookings/request/[id]`): the `converted` terminal card now deep-links the booking when
`bookingId` is present (list fallback otherwise).
- `constants/routes.ts`: `CHECKOUT_GATEWAY/RETURN/CONFIRMATION` + `bookingInvoicePath()`.
- i18n: new **`payment`** namespace — 53 keys, both locales, inserted textually (no reformat of existing lines).
## 2. Contract deltas the implementation honors (vs the phase file's illustrative design)
The phase file sketched `getCheckoutSummary`/`verifyPayment`/`getTransaction` endpoints and an
`initiated…cancelled` status enum. The **published contract wins**:
1. **Status enum is `pending|succeeded|failed`** (b10 `payment_transactions.status`). Client unions match.
2. **There is no client verify/transaction endpoint.** The server re-verifies inside the webhook handler;
"verify on return" is therefore modelled as `confirmGatewayReturn` (real impl = an outcome *read* — the
PSP already hit the webhook before redirecting) + the outcome poll. Filed as REQ-017.
3. **There is no checkout-summary endpoint** and the b8 request DTO is money-free — the C6 breakdown
cannot be served today. Filed as REQ-016; mocked behind the seam; the real client targets the proposed slug.
4. **The invoice is flat totals** (`grossIrr`/`platformCommissionIrr`/`vatRate`/`vatIrr`…, no line-items
array) and only exists after the **admin-only** issue action. Filed as REQ-018; the UI has a not-issued state.
5. **Idempotency is a header** (`Idempotency-Key`), not a body field — the contract's exact casing.
## 3. Mocks in this phase (recorded in mocks-registry.md)
- **`paymentMockApi` (mock-primary)** — the missing **conversion trigger between the f7 and f8 mock
stores** (their `converted`/seeded-booking states were previously unconnected): capture flips the f7
request to `converted` + stamps `bookingId`, inserts a **confirmed** f8 booking, and auto-issues the
b11-shaped invoice. Money split uses integer parts-per-10000 BigInt math (12% fee, 10% VAT,
`vat = commission_net × rate` per b11) so `service + commission + vat = total` **exactly** and
`gross = balinyaarCommission + payout` holds. Idempotency mirrors b10: same-key retry reuses the
attempt, post-capture initiate → `409`, replayed returns converge.
- **The mock-gateway page** — test harness only (see registry row for the deletion story).
- **Why mock-primary:** upstream ids are mock-primary (f7), REQ-016/017 are unserved, and nothing fires
the PSP webhook in dev (b10's "webhook simulator" is a manual server-side POST — after a real initiate,
nothing would ever confirm). Swap = deliver REQ-016/017/018 + real upstreams, then `USE_PAYMENT_MOCK=false`.
## 4. What is now testable, exactly (mock path — `npm run dev`)
Prereq: an `accepted_awaiting_payment` request — either seed-driven (open `/fa/nurse/requests`, accept a
seeded pending request) or full-flow (C4 create → nurse accept). Same browser tab throughout (module-singleton mocks).
1. **C6:** from C5's «ادامه پرداخت» (or `/fa/bookings/checkout?request_id={id}`) — badge, mini-summary,
30-min countdown, breakdown that sums to the rial (2,800,000×1 IRR gross → service 2,436,000 +
commission 336,000 VAT split), escrow notice in info tone. `/en` flips `dir`, translates, still Toman.
2. **Pay:** «ادامه پرداخت ←» → spinner → the آزمایشی gateway → «پرداخت موفق» → return surface briefly shows
«در حال تایید پرداخت…» → confirmation screen.
3. **Booking flips:** «مشاهده رزرو» lands on `/bookings/{id}` showing **confirmed** (React Query Devtools:
only request-detail/lists, bookings lists/detail, and this request's payment keys invalidated). C5 now
shows the converted card deep-linking the same booking; the bookings list has the new row.
4. **Invoice:** «دانلود فاکتور» → number `INV-…`, Shamsi date, service/commission/VAT-on-commission/total
reconciling to C6, مودیان «در انتظار ثبت», print button (mock serves no `pdfUrl` so the print path runs).
5. **Idempotency/retry:** double-tap pay (one attempt, same key — mock returns the same transaction);
re-initiate after success → `409` → converges to confirmation, no error toast. Gateway «شبیه‌سازی
پرداخت ناموفق» → failed card → «تلاش دوباره» → fresh C6 mount issues a **new** key (observable in the
mock's `gatewayReferenceCode` suffix).
6. **Window expiry:** wait out the 30-min window (or re-enter later) → C6/return show «مهلت پرداخت به
پایان رسید» and C5 shows its terminal card; initiate after expiry → `409` handled as state, not error.
7. **Invoice not-issued state:** `/fa/bookings/5001/invoice` (a seeded booking that never went through
checkout) → «فاکتور هنوز صادر نشده است».
## 5. Follow-ups for the next phases
- **f10 (refunds/cancellation):** reuse `PriceBreakdown` (fee disclosure), `EscrowNotice` (identical trust
copy), `PaymentStatusBadge`; `RefundStatusDto`/`refunds/{id}/status` is live in b11 and unconsumed;
`refundableAmountIrr`/`cancellationRefundPercentage` already ride on `BookingDetailDto`.
- **f11 (BNPL):** flip `BNPL_ENABLED` and wire the C6 secondary to D1. The b12 contract
(`checkout_bnpl/eligibility|initiate|{id}` + `Idempotency-Key` header) parallels this domain's shapes;
`InvoiceDto.bnplCommissionIrr` is already typed. The gateway-harness pattern extends to the BNPL redirect.
- **Backend:** REQ-016 (checkout summary), REQ-017 (outcome/bookingId — until then the real poll can't
distinguish *declined* from *slow*, and the confirmation can't deep-link), REQ-018 (invoice reachable
post-capture). Also note: the PSP's return-URL config must deep-link `/{locale}/bookings/checkout/return`.
## 6. Post-review hardening (multi-agent adversarial review before close)
A 26-agent review/verify pass over the diff confirmed and fixed, pre-merge:
- **Stale-outcome guard (major):** the return surface now trusts the outcome cache only after *this*
mount's return report settles — a previous attempt's cached `failed` outcome can no longer flash a
false «پرداخت ناموفق» (with a live retry) while the current attempt's capture is in flight.
- **No dead-end retries:** malformed `request_id`/booking-id links render a navigation card instead of a
`refetch()` that bypasses `enabled` and would request `checkout_summary/undefined`.
- **Unpriced request fails loudly:** the mock throws `409 unpriced_request` instead of silently serving a
reconciling 0-rial checkout when `variantPrice` is null (REQ-013 edge).
- **i18n/UX:** inline initiate errors always use the localized copy (never raw `ApiError.message`); en
`cta_pay` arrow points → (fa keeps ←); fa `error_body` matches the app's «بارگذاری … ممکن نشد» pattern;
the C6 service-cost row carries the quantity (`row_service_cost_with_count`); the invoice issuer line
uses the product spelling «بالین‌یار» (note: fa `common.brand` reads «بالین یار» — a pre-existing
wordmark/product-spelling divergence worth a product decision).
- **Dark scheme:** EscrowNotice text/border use `--bal-primary` (the info token is an alert *background*
and is illegible as dark-mode text); the print button temporarily flips `data-mui-color-scheme` to
light around `window.print()` (restored on `afterprint`) so a dark-mode user prints paper colors.
- **Contract hygiene:** the domain barrel is hooks-only again (`isTerminalPaymentOutcome` moved to
`types.ts`, mirroring `isTerminalBookingRequestStatus`); the gateway harness scopes `dir="ltr"` to the
reference code, not the Persian label; the invoice VAT percent formats fractional rates
(`maximumFractionDigits: 2`).
## 7. Gate
`npm run check` green · `npm run test:ci` green (46 suites, 204 tests — +9: PriceBreakdown 4, EscrowNotice 2,
PaymentStatusBadge 3) · `npm run build` green. `en.json`/`fa.json` in sync (53-key `payment` namespace).
`client/CLAUDE.md` Project Structure updated (checkout subtree, invoice route, `services/payment`, three
components, `payment` namespace entry). Gotcha for future phases: **BigInt literals (`0n`) don't compile**
(tsconfig target ES2017) — use the `BigInt(...)` constructor like `utils/money.ts`.
@@ -0,0 +1,145 @@
# Mock & integration registry
> **The frontend half of this file is stale (checked 2026-08-02).** Its "Config flag … default `true`"
> column was never updated after the refinement-phase-4 de-mock, so **8 rows name the wrong default** and
> three are wrong about behaviour, not just configuration. 17 disagreements with the code are listed, with
> file:line evidence, in [`docs/flows/index.md`](../../../docs/flows/index.md#corrections-to-mocks-registrymd).
> **Read the code-derived map there, not the flag column here.** The backend seam rows (the top of this
> file) were not re-audited; the current server seam picture is in
> [`docs/flows/index.md`](../../../docs/flows/index.md#mock-vs-real-map--server-seams). Kept as a record.
The master list of every external dependency that is **mocked behind a DI seam** in this build, and the
exact steps to make each one real. Backend lane owns this file; every phase that introduces or touches a
seam updates its row. This is the checklist the team works through to go from "MVP with mocks" to
"production with real providers".
Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 real integration live · 🟢◐ **real
adapter shipped, config-selected (mock remains the default/fallback)** — the refinement-phase-8 state.
> **Refinement-phase-9 — docs honesty + observability (2026-07-13).** §7.6: pruned the **stale duplicate 🔴 rows**
> (`IDistributedLock`/`INurseSearch`/`IPaymentProvider`/`ISettlementSplitProvider`/`IWebhookVerifier`/`IMoadianClient`/
> `ILicenseVerificationService`) that the detailed rows below already correct; the recurring-jobs row is the real
> in-process scheduler; `IPaymentCaptureSimulator` now reflects its 6.4 prod removal. **No seam was un-mocked** — the
> deferred **Elasticsearch `INurseSearch` backend** (row) and the **SMS/push channels of `INotificationDispatcher`**
> (row) stay explicitly out of MVP with the pull-triggers in their "Make it real →" columns (SQL search / in-app
> notifications are the real MVP). Also removed the unused `Serilog.Sinks.Elasticsearch` package (dead sink block);
> tracing/metrics unified on OpenTelemetry; audit-log retention is now a scheduled `IRecurringJob`; `TicketMessage.Body`
> is encrypted at rest (§9.5). See `backend-phase-9-report.md` (refinement) for the full observability + deferral list.
> **Refinement-phase-8 — external rails go real (2026-07-13).** Every vendor rail below now has a **real HTTP
> adapter behind the same seam**, config-selected via a per-rail **`Seams:*:Provider`** selector (default = the
> mock, so an unconfigured environment is unchanged; a typo falls closed to the mock). Selecting a real provider
> swaps the adapter by a **registration change only — no handler changed**. All adapters are built on
> `HttpClient` + `System.Text.Json` + BCL crypto (**zero new NuGet packages**); credentials come from `Seams:*`
> (user-secrets/env). `dotnet build` 0 new warnings · `dotnet test` **402 pass**. The rails made real, with their
> provider token and adapter (`CrossCutting/Seams/Real/`):
>
> | Seam | `Seams:*:Provider` | Real adapter | Notes |
> | --- | --- | --- | --- |
> | `ISmsSender` | `Sms:Provider=kavenegar` | `KavenegarSmsSender` | **launch-critical.** OTP via verify/lookup template; the Dev OTP-in-logs bridge is **disabled** when a real provider is selected (OTP never logged). |
> | `IShahkarVerifier` | `Shahkar:Provider=finnotech` | `FinnotechShahkarVerifier` | shared `Seams:Finnotech` creds; شاهکار can't distinguish shared-SIM from mismatch (reported as plain mismatch). |
> | `IIdentityKycProvider` | `IdentityKyc:Provider=finnotech` | `FinnotechIdentityKycProvider` | nid+name inquiry; liveness extends the same adapter. |
> | `IBankAccountOwnershipVerifier` | `BankOwnership:Provider=finnotech` | `FinnotechBankAccountOwnershipVerifier` | استعلام شبا owner↔nid; fails closed (no nid returned = no match) — it is the first-payout gate. |
> | `IGeocoder` | `Geocoding:Provider=neshan` | `NeshanGeocoder` | outage degrades to the null-pin state, never blocks saving an address. |
> | `IObjectStorage` | `ObjectStorage:Provider=s3` | `S3ObjectStorage` | MinIO/S3/ArvanCloud; **manual AWS SigV4** (no SDK) — presigned GET = the real b6 signed-URL contract. |
> | `IPaymentProvider` | `Payments:Provider=zarinpal` | `ZarinPalPaymentProvider` | v4 request/verify/refund; mandatory server-side verify. |
> | `IWebhookVerifier` | (with `Payments:Provider`) | `HmacWebhookVerifier` | per-provider HMAC over the raw body; no-secret ⇒ the server-side verify re-check is the guard. |
> | `ISettlementSplitProvider` | (with `Payments:Provider`) | `ProviderSettlementSplitProvider` | تسهیم split-by-ratio to registered IBANs. |
> | `IBnplProvider`/`IBnplProviderResolver` | `Bnpl:Provider=real` | `SnappPayBnplProvider` + `DigipayBnplProvider` + `ConfiguredBnplProviderResolver` | one adapter per code; **`balinyaar` = in-house (no external API), resolves to the net-of-fee model**; `tara`/`torobpay` → null (unbuilt). |
> | `ICurrencyNormalizer` | `Currency:TomanToIrrMultiplier` | `MockCurrencyNormalizer` (config-driven = **the real impl**) | conversion only at the adapter boundary. |
> | `IBankTransferProvider` | `BankTransfer:Provider=jibit` | `JibitBankTransferProvider` | **async rail** — accepts as `submitted`; the reconciliation callback (`POST webhooks/payouts/{provider}`, `ReconcilePayoutBatchCommand`, HMAC-verified) flips `submitted → paid/failed`. |
> | `IMoadianClient` | `Moadian:Provider=moadian` | `MoadianClient` | submit + the `MoadianReconciliationJob` (`IRecurringJob`, 6 h) walks `pending/submitted → registered`. |
> | `IPaymentCaptureSimulator` | — | `DisabledPaymentCaptureSimulator` (prod) / `MockPaymentCaptureSimulator` (Dev/Testing) | **6.4:** removed from prod; the `bookings/convert` path is a Dev/Testing affordance (b10's webhook confirm is the real conversion). |
> | `ICredentialVerifier`, `ILicenseVerificationService` | — | mock (unchanged) | **5.6: manual = intended MVP** — MoH/INO/eNamad have no public B2B API; the manual admin review is the mechanism, not debt. |
| Seam (interface) | Introduced in | What it fakes | Config keys | Make it real → | Status |
| --- | --- | --- | --- | --- | --- |
| `ISmsSender` | backend-phase-2 | OTP/SMS delivery — `LoggingSmsSender` (`Baya.Infrastructure.CrossCutting/Seams/`) logs the OTP code (phone shown as last-4 only) and returns success; registered singleton in `AddCrossCuttingSeams`. **refinement-phase-0:** in **Development only**, `DevCapturingSmsSender` decorates it (via `AddDevelopmentOtpCapture`, called from `Program.cs` inside `IsDevelopment()`) to also capture the code in `DevOtpStore` for the `GET /api/v1/dev/last_otp/{phone}` bring-up helper — not wired / 404 outside Development | none today; real client will need `Seams:Sms:ApiKey` + `Seams:Sms:SenderLine` (+ gateway base URL) | 1) pick a gateway (Kavenegar/Ghasedak/SMS.ir), add its client package to `Directory.Packages.props`; 2) implement `ISmsSender.SendOtpAsync`/`SendAsync` against it (template/pattern-based OTP send); 3) bind the new `Seams:Sms` options; 4) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 5) keep the per-phone resend window + `otp` rate-limit policy exactly as-is; test with a real SIM | 🟡 |
| `IObjectStorage` | backend-phase-0/6 | File storage — local-disk store under a scratch root (`LocalDiskObjectStorage`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:ObjectStorage:RootPath` (default: temp dir) | Point at MinIO/S3/ArvanCloud; presigned upload/download; bucket + creds | 🟡 |
| `ICacheService` | backend-phase-0 | Caching — in-memory `IMemoryCache` (`MemoryCacheService`, `Baya.Infrastructure.CrossCutting/Seams/`) | _none_ | Swap to Redis (`StackExchange.Redis`); keep key/TTL scheme. **refinement-phase-7: this is the >1-instance scale-out gate** — a single-instance MVP intentionally keeps the in-proc cache (its generation-token invalidation is process-local); add Redis only when a second API instance runs | 🟡 (in-proc is correct single-instance) |
| `IBnplProvider` | backend-phase-12 | BNPL — `MockBnplProvider` drives the full state machine (eligible→settled→reverted), settle returns `order commission%` | `Seams:Bnpl:{CommissionRate,SettlementInstant,CreditCeilingIrr,NotEligibleMobile,ForceFailure,ReverseProviderCommission}` | SnappPay/Digipay OAuth + verb set; encrypted creds in `payment_gateways.config_json` | 🟡 |
| `IBnplProviderResolver` | backend-phase-12 | Per-`provider_code` selection — maps every known code to the one mock | _none_ | One concrete adapter per code; resolver returns the right one | 🟡 |
| `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR — ×10 at the boundary | `Seams:Currency:TomanToIrrMultiplier` (default `10`) | Config-driven per provider boundary | 🟡 |
| `IBankTransferProvider` | backend-phase-13 | PAYA/SATNA payout rail — `MockBankTransferProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call, no money moves**: `SubmitPayoutBatchAsync(batchId, instructions, idempotencyKey)` returns a deterministic `externalBatchRef` + a per-instruction `transfer_reference` and settles every row `Paid` (collapsing the real `submitted → paid` reconciliation); it **honours** the PAYA/SATNA `method` the handler chose by the `payout_satna_threshold_irr` config and echoes it. A config switch forces deterministic failures so `partially_failed`/retry are testable: `ForceFailure` fails the whole batch, `FailIban` fails one destination. `GetPayoutStatusAsync` echoes `Paid`. Registered singleton in `AddCrossCuttingSeams` | `Seams:BankTransfer:ForceFailure` (default `false`), `Seams:BankTransfer:FailIban` (default empty) | 1) pick a transferor (Jibit/Vandar/Sadad payout API), add its client package to `Directory.Packages.props`; 2) add `Seams:BankTransfer:{ApiKey,BaseUrl,SourceSettlementAccount}`; 3) implement `SubmitPayoutBatchAsync` to register the batch against the registered **source settlement account** and route each transfer PAYA (batch, low-value) vs SATNA (real-time, above the threshold) to each nurse's **verified Sheba** (the b3 `matched_national_id` gate), honouring batch caps/minimums; 4) implement the async **reconciliation callback** that flips a payout `submitted → paid/failed` (the mock collapses this — the real rail is async); 5) swap the registration (config-selected) — the payout status machine + `nurse_payout_booking_links` UNIQUE remain the irreversible-transfer backstop; 6) test PAYA/SATNA selection, whole-batch + single-row failure → retry | 🟡 |
| `IHolidayCalendar` | backend-phase-1 | Bank holidays — reads the seeded `ops.IranianHolidays` table; lookups cached (`HolidayCalendarService`, `Persistence/Services/Holidays/`); Iranian banking weekend = Friday | _none_ | Add a sync job/feed that maintains the (partly lunar-Hijri) calendar table; the read interface stays | 🟡 |
| `IAnalyticsSink` | backend-phase-1 | Behavioural events — inserts an `ops.SystemEvents` row, fire-and-forget (`AnalyticsSink`, `Persistence/Services/Analytics/`) | _none_ | Pipe to a warehouse/stream (e.g. Kafka→ClickHouse); keep fire-and-forget semantics | 🟡 |
| Recurring jobs (in-process scheduler) | backend-phase-1 · **re-homed refinement-phase-7** | Scheduling — **REAL** single in-process scheduler `RecurringJobSchedulerHostedService` (`Persistence/Services/Scheduling/`) drives every `IRecurringJob` on its own (config-read) cadence: `notification_retention` (24 h) + `booking_request_expiry` (1 min) + `verification_expiry_scan` (`verification_expiry_scan_cadence_hours`) + `no_show_sweep` (`no_show_scan_cadence_hours`) + `weekly_payout_generation` (`nurse_payout_interval_days`, system-initiated draft only — processing stays admin). Each tick runs under `IDistributedLock(scheduler:{name})`; dormant under `Testing`. Admin manual triggers remain overrides. **No new infra** (SQL Server only). | the cadence keys above (via `IPlatformConfig`) | This is the intended MVP shape — **not** debt. Hangfire/Quartz only buys durable/cross-restart scheduling and is the >1-instance option alongside Redis (§7.2) — swap by re-registering the jobs behind it. **Phase 8** adds the Moadian reconciliation + refund-settlement poll as new `IRecurringJob`s here. | 🟢 real (single-instance) |
| `IShahkarVerifier` | backend-phase-6 | شاهکار phone↔national-id binding — `MockShahkarVerifier` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic result + fake vendor ref + `external_response_json`: matches every pair except the configured shared-SIM phone (→ the explicit shared-SIM failure state, which the handler turns into a `shared_sim` support alert) and the mismatch national id (→ plain mismatch); registered singleton in `AddCrossCuttingSeams`. No real Shahkar call | `Seams:Shahkar:SharedSimPhone` (default `09120000000`), `Seams:Shahkar:MismatchNationalId` (default `1111111111`) | 1) pick a Finnotech / KYC Shahkar-bridge vendor, add its client package to `Directory.Packages.props`; 2) add `Seams:Shahkar:{ApiKey,BaseUrl}` options; 3) implement `MatchAsync(phone, nationalId)` against the real استعلام شاهکار, mapping to `ShahkarMatchResult` and persisting the raw response into the step's `external_response_json`; 4) keep shared-SIM as the explicit handled failure (`IsSharedSim=true`); 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test match / shared-SIM / mismatch + that a phone change re-runs it (`shahkar_verified_at` resets upstream on phone change) | 🟡 |
| `IIdentityKycProvider` | backend-phase-6 | Identity KYC (national-id validity + name match + liveness) — `MockIdentityKycProvider` (`.../Seams/`) passes any well-formed 10-digit national id except the configured fail id, returning a matched name + fake vendor ref + `external_response_json`; on pass the handler populates `users.national_id` + `national_id_verified_at`. No real OCR/liveness; registered singleton | `Seams:IdentityKyc:FailNationalId` (default `0000000000`), `Seams:IdentityKyc:MatchedName` (default `Verified Nurse`) | 1) pick an Iranian e-KYC vendor (Finnotech / U-ID / Jibbit / Farashensa / Verify / Kavoshak), add its client package to `Directory.Packages.props`; 2) add `Seams:IdentityKyc:{ApiKey,BaseUrl}` options; 3) implement `VerifyAsync(nationalId, livenessPayload)` → national-id validity + name match + photo/video liveness against ثبت احوال, mapping to `IdentityKycResult` and persisting `external_response_json`; 4) swap the registration (config-selected) — handlers unchanged; 5) test pass/fail by national id + that `national_id` is populated **only** on pass | 🟡 |
| `ICredentialVerifier` | backend-phase-6 | MoH پروانه صلاحیت حرفه‌ای / INO / عدم سوء پیشینه verification — `MockCredentialVerifier` (`.../Seams/`) **is** the manual-admin default: every call returns `RequiresManualReview` with `verification_method=manual` (an admin verifies the uploaded document against the official portal in `AdminReviewStep`). No portal call; registered singleton. There is **no public B2B API** for MoH/INO, so this stays manual until one appears | _none_ | 1) when an MoH/INO portal or API becomes available, implement `VerifyAsync(credentialType, credentialNumber)` to return `Verified`/`Failed` with `verification_method=portal|api` (+ `external_response_json`); 2) swap the registration (config-selected) for those credential types — the manual path stays the fallback; 3) the structured `nurse_credentials` registry already stores number/authority/expiry so cross-check + renewal survive the swap. **MoH/INO have no public B2B API today** | 🟡 |
| `IBankAccountOwnershipVerifier` | backend-phase-3 | استعلام شبا IBAN-owner ↔ national-id inquiry — `MockBankAccountOwnershipVerifier` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic fake: every IBAN matches (`matched_national_id=true`, echoes a holder name + `MOCK-SHEBA-{sha}` vendor ref) except the configured mismatch IBAN which returns `false`; registered singleton in `AddCrossCuttingSeams`. No real bank/KYC call, no money moves | `Seams:BankOwnership:MismatchIban` (default `IR000000000000000000000000`), `Seams:BankOwnership:MatchedHolderName`, `Seams:BankOwnership:MismatchHolderName` | 1) pick a Finnotech / banking-bridge استعلام شبا provider, add its client package to `Directory.Packages.props`; 2) add `Seams:BankOwnership:{ApiKey,BaseUrl}` options; 3) implement `VerifyOwnershipAsync(iban, nurseNationalId)` against the real Sheba-owner inquiry, mapping to `OwnershipInquiryResult`; 4) persist the real `ownership_vendor_ref` (+ raw response if a column is added); 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test match/mismatch + that the b13 first-payout gate honours `matched_national_id=true` | 🟡 |
| `IGeocoder` | backend-phase-4 | Address→lat/lng — `MockGeocoder` (`Baya.Infrastructure.CrossCutting/Seams/`) returns deterministic `decimal` coordinates jittered (FNV-1a, ~±5 km) around the known city centroid (unknown city → Iran centroid) plus `formatted_address` + `confidence`; **no network call**. A global switch or a per-address marker forces the null-coordinate ("no map pin") path; registered singleton in `AddCrossCuttingSeams` | `Seams:Geocoding:ReturnNullCoordinates` (default `false`), `Seams:Geocoding:LowConfidenceMarker` (default `NO_GEO`), `Seams:Geocoding:ResolvedConfidence` (default `0.9`) | 1) pick Neshan (or Google) geocoding, add its client package to `Directory.Packages.props`; 2) add `Seams:Geocoding:{ApiKey,BaseUrl}` options; 3) implement `IGeocoder.GeocodeAsync(addressText, cityName, districtName?)` against it, mapping to `(lat, lng, formatted_address, confidence)` with `decimal` coords; 4) add rate-limit/retry; 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test a known Tehran address resolves within expected bounds | 🟡 |
| `IReviewModerationService` | backend-phase-14 | AI review pre-screen — `MockReviewModerationService` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `ScreenAsync(reviewText)` returns a `ModerationVerdict(Decision, Reason)` — a banned-word substring hit → `Reject` (`banned_word:{w}`); otherwise clean text → a human-review `Flag` by default (so the publish gate holds), or `Approve` when `AutoApproveClean` is set. The `SubmitReview` handler maps the verdict to the initial status (`Approve`→published, `Reject`→hidden, else pending) — **decision authority stays with `ModerateReviewCommand` (human override)**. Registered singleton in `AddCrossCuttingSeams` | `Seams:ReviewModeration:AutoApproveClean` (default `false`), `Seams:ReviewModeration:BannedWords` (default `scam,fraud,کلاهبردار`) | 1) pick a text classifier / LLM moderation endpoint, add its client package to `Directory.Packages.props`; 2) add `Seams:ReviewModeration:{ApiKey,BaseUrl}` options; 3) implement `ScreenAsync(reviewText)` → map the provider's toxicity/spam scores to `Approve`/`Flag`/`Reject` + a reason; 4) swap the registration in `AddCrossCuttingSeams` (config-selected) — `SubmitReviewCommand`/`ModerateReviewCommand` unchanged, and the human moderation path always overrides; 5) test clean/flagged/rejected dispositions + that the publish gate still holds for a `Flag` | 🟡 |
| `IFieldEncryptor` | backend-phase-0 | PII encryption — AES-256-CBC + HMAC hash from a local symmetric key (`SymmetricFieldEncryptor`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:FieldEncryption:Key`, `Seams:FieldEncryption:HashKey` | KMS / column encryption / Key Vault / HSM | 🟡 |
| `INotificationDispatcher` | backend-phase-0/**1** | Notification channels — **in-app write is now real** (`InAppNotificationDispatcher`, `Persistence/Services/Notifications/`, writes an `ops.Notifications` row); b0 log stub removed. SMS/push channels still deferred (no-op) behind the same seam | _none_ | Add SMS (`ISmsSender`) / push (FCM) channels; polling → Redis pub/sub or SignalR later | 🟡 |
| `IPaymentCaptureSimulator` | backend-phase-9 → **removed from prod refinement-phase-8 §6.4** | The **temporary conversion trigger** that stood in for b10's real card capture. **Prod now gets the fail-closed `DisabledPaymentCaptureSimulator`**; only Dev/Testing re-register the succeeding `MockPaymentCaptureSimulator` (the `bookings/convert` path is a Dev/Testing affordance — prod converts via the b10 webhook confirm calling `ConvertRequestToBooking` directly). Registered in `AddCrossCuttingSeams` (prod) + re-registered in Dev/Testing from `Program.cs` | `Seams:PaymentCapture:ForceFailure` (default `false`), `Seams:PaymentCapture:PspFeeAmount` (default unset) | Nothing further for prod — the real conversion trigger is the b10 webhook confirm. The Dev/Testing mock stays as a test affordance; drop it only if/when `bookings/convert` is retired | 🟢 (prod fail-closed; Dev/Testing mock is a test affordance) |
| `INurseSearch` | backend-phase-7 | The search-service seam (read side). **The MVP impl `SqlNurseSearch` (`Persistence/Services/Search/`) is REAL, not a mock** — it reads the maintained `nurse_search_index WHERE is_searchable=1`, applies the category/city/district (NULL=whole-city)/gender/price filters + rating sort + pagination, projected & `AsNoTracking`. Registered by `AddPersistenceServices`, config-selected. Only the DEFERRED Elasticsearch backend is unbuilt | `Search:Backend` (default `sql`; any other value throws until Elastic ships) | 1) add an Elasticsearch client package (`Elastic.Clients.Elasticsearch`) to `Directory.Packages.props`; 2) define the index mapping (the `NurseSearchResultDto` fields + `is_searchable`); 3) implement `ElasticNurseSearch : INurseSearch` (same filters/sort/paging) reading the ES index; 4) build the feeder that consumes the `ISearchIndexMaintainer` change events via an **outbox/CDC** stream into ES (see the next row); 5) point `Search:Backend=elastic` in config — **callers unchanged**; 6) keep the SQL index as the projection/fallback + the reconciliation source (`RebuildAsync`); 7) test filter/sort/paging parity vs `SqlNurseSearch` | 🟢 SQL real; Elastic 🟡 |
| `IPaymentProvider` | backend-phase-10 | Card PSP acquirer — `MockPaymentProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `InitPaymentAsync` → a deterministic `gatewayReferenceCode` (`mock-ref-{requestId}-{key}`) + a fake redirect URL; `VerifyAsync` → instant `Succeeded` echoing the expected amount (the server-side re-check); `RefundAsync(ref, amount, idempotencyKey, ct)` → always `Succeeded`, echoes a deterministic refund ref (b11 refunds carry the booking+refund idempotency key so a retry never double-refunds). Registered singleton in `AddCrossCuttingSeams` | none today; real client needs merchant id + terminal/IBAN registration + sandbox flag from `payment_gateways.config_json` (encrypted), not appsettings | 1) pick ZarinPal/Sadad/Vandar/Jibit as an acquirer-with-تسهیم, add its client package to `Directory.Packages.props`; 2) implement `InitPaymentAsync` (open the IPG session, return the Shaparak-routed redirect + reference), `VerifyAsync` (the mandatory server-side `verify` re-check of amount + reference — **never trust the callback alone**), `RefundAsync`; 3) read merchant id/terminal from the encrypted `payment_gateways.config_json`; 4) a config-driven `IProviderRegistry`/factory selects the concrete provider per gateway so a cut-off provider swaps without code change; 5) persist the full gateway response into `gateway_response_json`; 6) swap the registration (config-selected) — handlers unchanged | 🟡 |
| `ISettlementSplitProvider` | backend-phase-10 | تسهیم settlement-sharing — `MockSettlementSplitProvider` (`.../Seams/`) records the split intent and returns `Settled` for any legs whose sum is positive; the platform never moves money. Registered singleton in `AddCrossCuttingSeams` | none today; real client needs each beneficiary's registered SHEBA + split-by-ratio config | 1) pick the acquirer's تسهیم API, implement `RegisterSplitAsync(bookingId, legs)` to register the split-by-ratio to each beneficiary's **registered IBAN** (nurse payout + platform commission), honouring the ~100,000 IRR min-amount caveat; 2) resolve each nurse's SHEBA from `nurse_bank_accounts` (the b3 `matched_national_id` gate) and the platform SHEBA from config; 3) `GetSplitStatusAsync` polls the provider; the provider credits IBANs directly — the ledger only mirrors it; 4) swap the registration (config-selected) | 🟡 |
| `IWebhookVerifier` | backend-phase-10 | PSP callback signature verify — `MockWebhookVerifier` (`.../Seams/`) treats the signature as valid unless the body carries `Seams:Payments:InvalidSignatureMarker`, and extracts `external_event_id`/`event_type`/`gateway_reference_code` from a small JSON body (so tests can replay duplicates + exercise the invalid-signature path). Registered singleton in `AddCrossCuttingSeams` | `Seams:Payments:InvalidSignatureMarker` (default `INVALID_SIGNATURE`) | 1) implement the per-provider HMAC/signature scheme (verify the raw body against the provider's signing key from the gateway config); 2) where a provider offers no signature, fall back to the mandatory server-side `verify` re-check (amount + reference) via `IPaymentProvider.VerifyAsync`; 3) parse the real provider event shape into `WebhookVerification`; 4) swap the registration (config-selected) — the `HandlePaymentWebhook` upsert-first/no-op-on-duplicate ordering is unchanged | 🟡 |
| `IDistributedLock` | backend-phase-10 | Money-path mutex — `InProcessDistributedLock` (`.../Seams/`): a per-key `SemaphoreSlim` so the capture path runs the same acquire/release shape it will with real Redis, **within one process only**. **Not** a cross-instance correctness guarantee — the DB uniques/state-machine are the authoritative backstop. Registered singleton in `AddCrossCuttingSeams` | none today; real client needs a Redis connection string | 1) add `StackExchange.Redis` to `Directory.Packages.props`; 2) implement `AcquireAsync(key)` with a lease/expiry (RedLock-style SET NX PX + a token-checked release), key convention `booking:{id}:payment`; 3) bind `Seams:Payments:Redis` (or reuse the `ICacheService` Redis swap); 4) swap the registration (config-selected) — handlers unchanged, and correctness still rests on the DB uniques if Redis is down/expired. **refinement-phase-7: also the scheduler's per-tick lock** (`scheduler:{job}`) uses this seam — once Redis-backed it serializes recurring-job ticks across instances (idempotency + DB uniques cover a double-run either way). Required only for >1 instance. | 🟡 (in-proc is correct single-instance) |
| `ISearchIndexMaintainer` (the "`ISearchIndexWriter`" event shape) | backend-phase-7 | The index-maintenance seam (write side). **The inline SQL path is REAL**`SearchIndexMaintainer` (`Persistence/Services/Search/`) re-derives `nurse_search_index` from source and **stages** it inside the owning source write's unit of work (single `CommitAsync`), invoked from the b3/b4/b5/b6 handlers (`ReindexVariantAsync`/`ReindexNurseAsync`/`FanOutServiceAreaAsync`/`RemoveServiceAreaRowsAsync`/`RebuildAsync`). Only the **outbox/queue routing** for an async Elastic feeder is deferred — the seam is shaped so the same change events can later be emitted to an outbox instead of an inline upsert | _none_ | 1) introduce an `outbox` table + a SaveChanges interceptor that captures each maintainer change as an event row in the same transaction; 2) a background feeder (Hangfire/Quartz or a hosted service) reads the outbox and applies to `ElasticNurseSearch`; 3) keep the inline SQL upsert as the projection/fallback so `RebuildAsync` stays the reconciliation path; 4) test that an outbox replay converges to the same rows as the inline path | 🟡 outbox deferred (inline real) |
| `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoicing — `MockMoadianClient` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `SubmitAsync` leaves a new invoice `moadian_status = pending` with `moadian_reference_number = null`; a config switch forces a deterministic `registered` result with a fake 22-digit reference so the reconciliation/registered path is testable. Registered singleton in `AddCrossCuttingSeams` | `Seams:Moadian:ForceRegistered` (default `false`) | 1) enroll the platform in سامانه مودیان (memory/economic code + signing certificate); 2) implement `SubmitAsync` to POST the معاملات/invoice (`صورتحساب`) to the مودیان API, sign the payload, map the 22-digit `reference_number`; 3) walk the async `pending → submitted → registered`/`failed` states via a reconciliation callback/poll (**cron deferred/manual today** — a job flips `moadian_status` + fills the ref); 4) swap the registration (config-selected) — the `IssueInvoice` handler is unchanged | 🟡 |
| `IBnplProvider` | **backend-phase-12** (superset of the b11 revert-only stub) | BNPL provider — `MockBnplProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**, drives the full SnappPay-superset verb set `CheckEligibilityAsync`/`CreatePaymentTokenAsync`/`VerifyAsync`/`SettleAsync`/`GetStatusAsync`/`CancelAsync`/`RevertAsync`/`UpdateAsync` and the `eligible → token_issued → verified → settled → reverted/cancelled` machine. Eligibility is `eligible` unless the mobile = `NotEligibleMobile` (→`not_eligible`) or the order exceeds `CreditCeilingIrr` (→`ceiling_exceeded`); token/redirect are deterministic; **settle returns `settledAmountIrr = order round(order × CommissionRate)` + the commission read from the response (never hardcoded) + a nullable `settledAt`** (null when `SettlementInstant=false`, modelling non-instant settlement); revert echoes a deterministic `external_revert_reference` + nullable `provider_commission_reversed_amount`. Selected per `provider_code` by **`IBnplProviderResolver`** (`MockBnplProviderResolver` → the one mock for every known code); the b11 refund `bnpl_revert` path still injects `IBnplProvider` directly. Registered singleton in `AddCrossCuttingSeams` | `Seams:Bnpl:CommissionRate` (default `0.10`), `Seams:Bnpl:SettlementInstant` (default `true`), `Seams:Bnpl:CreditCeilingIrr` (default `2000000000`), `Seams:Bnpl:NotEligibleMobile` (default `09120000099`), `Seams:Bnpl:ForceFailure` (default `false`), `Seams:Bnpl:ReverseProviderCommission` (default `false`) | 1) implement one concrete adapter per `provider_code` (**SnappPay** OAuth `api/online/v1/oauth/token` + `offer/v1/eligible` + `payment/v1/token\|verify\|settle\|revert\|cancel\|update\|status`, or **Digipay** UPG `tickets/business?type=13` + `purchases/verify` + `purchases/deliver?type=13` + `refunds`/`reverse`); 2) read credentials from the **encrypted** `payment_gateways.config_json`; 3) do Toman↔Rial via `ICurrencyNormalizer` at the adapter boundary; 4) read the **per-contract commission from the settle response**, never hardcode; 5) map the provider event shape into the callback so `HandleBnplCallback` dispatch is unchanged; 6) register per-code in `IBnplProviderResolver` (config-selected) — handlers unchanged. **Warn: do NOT use the unrelated Canadian `SnapPayInc/open-api-java-sdk`.** | 🟡 |
| `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR at the provider boundary — `MockCurrencyNormalizer` (`Baya.Infrastructure.CrossCutting/Seams/`): `ToIrr(amount,"TOMAN")` = `amount × TomanToIrrMultiplier`, IRR passes through; `ToDisplayToman` divides back. **Conversion happens ONLY here, never internally.** Registered singleton in `AddCrossCuttingSeams` | `Seams:Currency:TomanToIrrMultiplier` (default `10`) | Read the multiplier (or a per-provider unit) from provider config; the interface stays — a currency redenomination is a config change | 🟡 |
| `INursePayoutStatus` | backend-phase-11 (interim) → **backend-phase-13 (authoritative)** | "Was the nurse already paid for this booking?" — **b13 shipped the real `NursePayoutLinkStatusService`** (`Persistence/Services/Payments/`): a booking is paid iff a `nurse_payout_booking_links` row ties it to a `nurse_payouts` row in status `paid`. This **supersedes** the interim `NursePayoutStatusService` (dispute-window derivation, now deleted); the `refund_assume_nurse_paid` config override still forces the paid answer for ops/testing. Not a mock of an external — a real ledger-backed derivation. Registered scoped in `AddPersistenceServices`. The refund pre-payout/clawback fork is unchanged | `refund_assume_nurse_paid` (`platform_configs`, default `false`) | Nothing further — this is the real implementation. (A future on-demand-withdrawal model would extend the "paid?" definition, not replace it.) | 🟢 |
| `ILicenseVerificationService` | **backend-phase-15** | Partner-center licensing (eNamad / MoH establishment-permit پروانه تأسیس) — `MockLicenseVerificationService` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `VerifyEstablishmentPermitAsync`/`VerifyENamadAsync` return `NeedsManualReview` (no automated registry → a human admin decides), so `VerifyPartnerCenterCommand` records the manual approval and activates the center. A config toggle makes clean checks return `Valid` (auto-approve path); an explicit `Invalid` verdict blocks activation. Registered singleton in `AddCrossCuttingSeams` | `Seams:LicenseVerification:AutoApprove` (default `false`) | 1) obtain access to a real eNamad status endpoint and/or the MoH establishment-permit registry (no public B2B API today — likely a manual/partner data feed at launch); 2) implement the two methods to look up the permit/eNamad code and return `Valid`/`Invalid` + a reason; 3) swap the registration (config-selected) — `VerifyPartnerCenter` is unchanged (it keeps the human-override decision authority) | 🟡 |
> Exact config keys and file paths get filled in by the phase that builds each seam. Keep the
> "Make it real →" column actionable enough that a developer can pick up any single row and ship it.
## Frontend client-side mocks (not backend DI seams)
These are in-browser mocks behind a `services/{domain}` interface, selected by a config flag. They exist so
the frontend can build before the backend phase merges, and swap to the real HTTP client in one line.
> **Refinement-phase-4 de-mock (2026-07-13) — 14 domains flipped to REAL** (`USE_*_MOCK = false`), verified
> against the regenerated swagger + `npm run check`/`test:ci` green: **geography, patients, profiles,
> nurse (bank), addresses, serviceAreas, catalog, search, bookingRequests, bookings, payment, reviews,
> tickets, notifications** (auth was already real). The flip was **not** a pure flag flip for most: Phase-3
> delivered fields the `clientApi.ts` mappers were written to null-override, so each mapper was updated to
> consume/send them (search name/avatar/distance + `nurses/{id}/profile`; patient relation/conditions;
> address `provinceId`; booking-request `variantPrice`/`bookingId`; ticket `unreadCount`/`lastMessageAt` +
> `clientMessageId`; review `my_review`; profile `avatarUrl`/`preferredLanguage` + a real **multipart avatar
> upload** — `clientFetch` now passes `FormData` through; the customer profile sources name from `/me`). The
> **payment mock-gateway harness page was deleted**; `EVV_GPS_MODE` auto-selects `off` (real geolocation).
>
> **7 domains stay 🟡 mocked — precondition REQ deferred/unsafe (documented, not forgotten):**
> `verification` (REQ-034 admin queue/doc-URL/approve — nurse flow ready, admin half blocks the shared flag),
> `refunds` (REQ-035 admin preview/approve — customer cancel/policy ready), `payouts` (REQ-036 admin
> preview/holidayShifted/transfer-ref — nurse earnings ready), `admin` (REQ-031 RBAC roles — config/audit/
> holidays/alerts ready), `bnpl` (REQ-022 options/schedule + REQ-024 wallet_installments deferred),
> `partnerCenter` (REQ-032/033 portal split reads + REQ-038 `/me` signal deferred), `patientRecords` (REQ-027
> endpoints exist but the client family-record `id` model is `string` vs the wire's `int` → the customer-edit
> PUT is write-unsafe until the id types are reconciled; the nurse visit-note history half is contract-real).
| Seam (interface) | File | What it fakes | Config flag | Make it real → | Status |
| --- | --- | --- | --- | --- | --- |
| `PatientsApi` | `client/src/services/patients/apis/mockApi.ts` | In-memory patient CRUD (list/get/create/update/soft-archive), **seeded empty** so onboarding + the empty state both demo; persists the client-augmented `relation`/`conditions` the wire `PatientDto` lacks (REQ-005) | `USE_PATIENTS_MOCK` (`services/patients/constants.ts`, default `true`) | Deliver REQ-005 (relation/conditions on `PatientDto` + create/update), then set flag `false``patientsClientApi` is already wired to the b3 `patients/*` routes | 🟢 (real, refinement-phase-4) |
| `ProfilesApi` | `client/src/services/profiles/apis/mockApi.ts` | Customer + nurse profile get/upsert and **avatar upload** (echoes an object-URL). Keeps guarded read-only fields (`isVerified=false`, zero aggregates). Augments customer name/language (REQ-007) + nurse `avatarUrl` (REQ-006) the wire DTOs lack. **ui-phase-8:** `setAcceptingBookings(accepting)` now mirrors the flip in-memory (`isAcceptingBookings`, never touching `isVerified`) — this is the **real** endpoint (`POST nurse_profiles/set_accepting_bookings`, previously unwired), not a mock-only gap; the mock exists only so `USE_PROFILES_MOCK=true` local dev still demos the go-live toggle | `USE_PROFILES_MOCK` (`services/profiles/constants.ts`, default `true`) | b3 `customer_profiles/*` + `nurse_profiles/*` + `nurse_profiles/set_accepting_bookings` are all live; deliver REQ-006 (avatar route/field) + REQ-007 (customer name/language) then set flag `false``profilesClientApi` is wired (its `uploadAvatar` throws `501` until REQ-006; `setAcceptingBookings` is fully wired already) | 🟢 (real, refinement-phase-4) |
| `NurseBankAccountsApi` | `client/src/services/nurse/apis/mockApi.ts` | Bank-account list/add/set-primary/verify-ownership. Drives the استعلام شبا **pending→verified/mismatch** transition over 2 list reads (so the poll shows it), single-primary enforcement, masked-IBAN (last-4); the configured mismatch IBAN (`IR000000000000000000000000`, matches backend default) resolves to `matchedNationalId=false` | `USE_NURSE_BANK_MOCK` (`services/nurse/constants.ts`, default `true`) | b3 `nurse_bank_accounts/*` are live (the real `add` resolves the inquiry synchronously — no client poll needed); set flag `false``nurseBankClientApi` is wired | 🟢 (real, refinement-phase-4) |
| `AuthApi` | `client/src/services/auth/apis/mockApi.ts` (`authMockApi`) | Phone-OTP login offline: `requestOtp``{otpSent,resendAvailableInSeconds:120}`; `verifyOtp` accepts dev code **`123456`** and locks after 3 wrong tries (`otp_locked`); `getMe`/`selectRole`/`refresh` from a `MOCK_SCENARIO` toggle (`customer`/`nurse_unverified`/`no_role`) to exercise all router branches | `USE_AUTH_MOCK` (`services/auth/constants.ts`, default **false** — b2 is live) + `MOCK_SCENARIO` in `mockApi.ts` | The real `authClientApi` is already wired to the live b2 routes; set `USE_AUTH_MOCK = false` (already the default) — no hook/screen change | 🟢 real by default, 🟡 mock available |
| `GeographyApi` | `client/src/services/geography/apis/mockApi.ts` (+ `apis/seed.ts`) | The province→city→district reference hierarchy — a faithful subset of the b4 seed: 8 provinces, Tehran (city 101) with its 22 مناطق (1001…1022), and the white-space cities Mashhad/Isfahan/Shiraz/Tabriz/Ahvaz/Qom/Karaj as whole-city-only. Active-only, `sortOrder`-ordered. `seed.ts` also resolves a saved `cityId`/`districtId` back to names for the addresses & serviceAreas mocks | `USE_GEOGRAPHY_MOCK` (`services/geography/constants.ts`, default `true`) | b4 `geo/{provinces,cities,districts}` are live; set flag `false``geographyClientApi` is wired to the snake_case-param lookups. No hook/component change | 🟢 (real, refinement-phase-4) |
| `AddressesApi` | `client/src/services/addresses/apis/mockApi.ts` | Customer address CRUD (list primary-first / create / update / set-primary / soft-delete) with the **exactly-one-primary** invariant enforced in-memory (first address auto-primary; promoting clears the prior; deleting the primary promotes the next). Persists the client-augmented `provinceId` (REQ-009) and the picked `latitude`/`longitude` (REQ-008) the wire DTO/create-body lack | `USE_ADDRESSES_MOCK` (`services/addresses/constants.ts`, default `true`) | b4 `customer_addresses/*` are live; deliver REQ-008 (accept the pin) + REQ-009 (`provinceId` on the DTO), then set flag `false``addressesClientApi` is wired (sends the pin + `pageSize`, echoes `provinceId` locally) | 🟢 (real, refinement-phase-4) |
| `ServiceAreasApi` | `client/src/services/serviceAreas/apis/mockApi.ts` | Nurse coverage areas (list whole-city-first / add / remove). Enforces `UNIQUE(cityId, districtId)` exactly as the server — a duplicate (incl. a second whole-city row) throws the same **`409`** (`area_duplicate`) so the coverage editor's inline dup handling is demonstrable | `USE_SERVICE_AREAS_MOCK` (`services/serviceAreas/constants.ts`, default `true`) | b4 `nurse_service_areas/*` are live; set flag `false``serviceAreasClientApi` is wired (maps the server 409 to the same inline message). No hook/component change | 🟢 (real, refinement-phase-4) |
| `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange``AddressForm` and every caller stay unchanged | 🟡 |
| `CatalogApi` | `client/src/services/catalog/apis/mockApi.ts` (+ `apis/seed.ts`) | The catalog skeleton + nurse pricing layer. **Categories mirror the b5 seed exactly** (5 categories, ids 15, `sortOrder` 04). Seeds representative **option groups/values** the fresh backend does **not** (an admin authors them per category) — incl. required + optional groups and one **cross-category** (`serviceCategoryId=null`) group — so the builder's required-option gate + cross-category rendering demo. Enforces the server's create validation in-memory: `400` missing required dimension / bad price, and the `(nurse, category, option-set)` duplicate **`409`** (via `optionSetSignature`). Variant store seeded **empty** so the offerings empty-state demos; the nurse builds variants live (across price units). `create`/`update`/`set_active`/`list`(active-first, paginated)/`get`. Money stays an **IRR digit-string** end-to-end | `USE_CATALOG_MOCK` (`services/catalog/constants.ts`, default `true`) | b5 `catalog/*` + `nurse_variants/*` are live; set flag `false``catalogClientApi` is wired to the action-style routes (camelCase bodies, `pageSize` pagination per REQ-010, `category_id` snake_case filter). **When swapped, categories will have NO option groups until an admin authors them** (the mock's groups were illustrative). No hook/component change | 🟢 (real, refinement-phase-4) |
| `VerificationApi` | `client/src/services/verification/apis/mockApi.ts` | The whole nurse trust journey (b6). Seeds the six required steps on `start` (idempotent); `runIdentityKyc` passes any well-formed 10-digit id **except** `0000000000` (→ `failed`/`kyc_no_match`, matches backend `MockIdentityKycProvider`); `runShahkarMatch` requires identity passed, fails **shared-SIM** when the bound national id is `1111111111` (→ `failed`/`shared_sim`); `runBankVerification` passes (assumes a primary bank account); `uploadStepDocument` simulates signed-URL PUT progress then moves the step to `in_review` (metadata only); `submitCredentialDetails` validates the INO number. Re-aggregates like the server (`approved` only when every step passes). **Dev-only** `__mockApproveAll()`/`__mockRejectStep(code,reason)` stand in for the deferred (f15) admin review queue so a human can watch `is_verified`/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true. **ui-phase-8:** `VerificationStatus` gained two mock-tolerant fields the nurse-facing wire doesn't serve yet — `submittedAt` (stamped once when `start()` first seeds the steps, REQ-055) and `credentialSubmission` (stamped by `submitCredentialDetails``inoNumberSubmitted` boolean + specialties/registry fields, **never** the raw INO number, REQ-056) — so B6's timestamp and B5's hydrate-on-return both demo pre-REQ. **ui-phase-11 addition:** `listVerificationQueue` gained `search` (name/phone substring match against the mock's own fixture set) + a `counts: {pending, in_review}` computed over the **whole unfiltered** queue (REQ-062) — backs the admin verification desk's search field + status-tab badge counts | `USE_VERIFICATION_MOCK` (`services/verification/constants.ts`, default `true`) | b6 `nurse_verification/*` + `nurses/{id}/trust_badge` are live; set flag `false``verificationClientApi` is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). **Caveats:** the real `submitCredentialDetails` no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet — the document uploads it accompanies are contract-backed); `submittedAt` (REQ-055) and `credentialSubmission` (REQ-056) are both undefined until served, degrading gracefully (B6 omits the timestamp line; B5 falls back to blank fields); `search`/`counts` (REQ-062) — the real client already sends `search` as `q` (currently a no-op, ignored by the server) and `counts` stays `undefined`, so the queue's status tabs render without badge counts until delivered. No hook/component change | 🟡 |
| `BookingsApi` | `client/src/services/bookings/apis/mockApi.ts` | The post-payment engagement (b9). Seeds **2 confirmed bookings** (one 3-session multi-day, one single-visit) + `booking_care_instructions` + a per-session **EVV state machine**`checkInVisit` flips the session→`in_progress`/`checked_in` (booking→`in_progress`) and computes the **advisory** `checkInAddressMatch` (haversine vs the seeded address ± `MOCK_EVV_TOLERANCE_METERS`, `null` when GPS was absent); `checkOutVisit` requires an open check-in (**`400 no_open_check_in`** otherwise), completes the session (stamps `payoutEligibleAt`), and completes the booking + opens the dispute window once **all** sessions settle. `getCareInstructions` **404s any viewer but the assigned nurse** (the two-stage-disclosure boundary; the UI `enabled` gate means the customer never even calls it). Money stays IRR digit-strings with `gross = commission + payout` and `Σ visitPayout = payout`. **ui-phase-7:** `forViewer` now unmasks `addressSnapshotJson` for the nurse once the booking is `confirmed`+ (simulating REQ-051, still delivered) instead of unconditionally nulling it — `addr5001` gained `latitude`/`longitude` matching the EVV reference point so the new address-card map link is demoable; `listTodaySessions` stamps a `variantLabel` off the booking's frozen variant snapshot (REQ-052) | `USE_BOOKINGS_MOCK` (`services/bookings/constants.ts`, default `false`) | b9 `bookings/*` + `booking_sessions/*` are live, but a booking only exists after `bookings/convert` runs on a **paid** request — both upstreams (`bookingRequests` mock, card capture b10) aren't real client-side yet. Once conversion is live, set flag `false``bookingsClientApi` maps the routes 1:1 (+ `bookingsServerApi` for the RSC prefetch). No hook/component change. Deliver **REQ-051** (nurse-view address post-confirmation) + **REQ-052** (today-feed `variantLabel`) | 🟢 (real, refinement-phase-4) |
| `ILocationProvider` | `client/src/services/bookings/evv/locationProvider.ts` | **EVV GPS capture** — the only client seam f8 introduces. `getCurrentPosition()` never rejects (denied/unavailable → `null`, so a GPS problem is **advisory, never a block**). The **real** provider wraps `navigator.geolocation.getCurrentPosition`; the **mock** returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock `BookingsApi` computes the match against the same seeded reference point) | `NEXT_PUBLIC_EVV_MOCK_GPS` = `in_range` \| `out_of_range` \| `denied` \| `off` (default `in_range` while `USE_BOOKINGS_MOCK`, else `off`) | Set `NEXT_PUBLIC_EVV_MOCK_GPS=off` (or flip `USE_BOOKINGS_MOCK`) → the real `navigator.geolocation` provider is selected. Real **address-match math** stays server-side (backend geocoding seam), not here — this seam only *captures* the position | 🟢 (real, refinement-phase-4) |
| `PaymentApi` | `client/src/services/payment/apis/mockApi.ts` | **The f9 checkout money path** — plays the PSP + webhook roles the client can't reach: `getCheckoutSummary` serves the unserved C6 breakdown (REQ-016; commission-net/VAT/service split via **integer parts-per-10000 BigInt math**, 12% fee / 10% VAT, reconciles to the rial); `initiatePayment` enforces b10 idempotency (same `Idempotency-Key` → same attempt; repeat after capture / lapsed window → **`409`**) and returns a `redirectUrl` into the local mock-gateway harness; `confirmGatewayReturn` on success is the **webhook-confirm stand-in and the missing f7↔f8 bridge** — flips the request `converted` (+ client-augmented `bookingId`, via `mockMarkBookingRequestConverted` in the f7 mock), inserts a **confirmed** booking into the f8 store (`mockInsertConvertedBooking`), and auto-issues the b11-shaped invoice (`moadianStatus: pending`, `pdfUrl: null` so the print path exercises); replayed returns converge idempotently; `getInvoice` 404s until issued. **ui-phase-6:** `initiatePayment`'s `redirectUrl` is now `null` (was a stale pointer to the deleted card-gateway harness page — a latent bug, since the harness itself was already removed in refinement-phase-4; the checkout page's `!redirectUrl` branch already reads the outcome directly, no behavior change); `getCheckoutSummary` adds mock `nurseAvatarUrl: null`/`nurseVerified: true` (REQ-046, the C6 identity moment); the capture path stamps `capturedAt`/`createdAt` on the transaction so `PaymentOutcomeDto` serves `trackingCode`/`paidAt` (REQ-046, the confirmation receipt) and the new `getPaymentHistory` reads the same transaction list (REQ-047, wallet «پرداخت‌ها»); invoice creation adds mock `paymentMethod: 'card'`/`transactionReference`/`sellerFiscalIdentity` (REQ-049, fiscal-grade invoice) | `USE_PAYMENT_MOCK` (`services/payment/constants.ts`, default `true`) | b10 initiate + b11 invoice are live and `paymentClientApi` maps them 1:1 (`Idempotency-Key` header, `GET invoices/{bookingId}`); deliver **REQ-016** (checkout summary — the real client already targets the proposed `booking_requests/checkout_summary/{id}` slug) + **REQ-017** (transaction status / `bookingId`; until then the real outcome poll maps `booking_requests/get` statuses and can't distinguish declined from slow) + **REQ-018** (invoice reachable post-capture) + **REQ-046** (nurse identity + tracking code/paid-at) + **REQ-047** (payment history) + **REQ-049** (invoice fiscal fields), make the upstream `bookingRequests` flow real, then set flag `false`. No hook/component change | 🟢 (real, refinement-phase-4) |
| Mock-gateway page (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the PSP's hosted payment page so the initiate → redirect → return round-trip is exercisable without a gateway: the mock `redirectUrl` points here, and its success/failure buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «درگاه پرداخت آزمایشی», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b10's `redirectUrl` is the PSP's **absolute** URL (the checkout does a full `window.location.assign` for `http(s)` URLs), so this page is simply never linked; delete it when `USE_PAYMENT_MOCK` retires. The PSP's return deep-link into `/bookings/checkout/return` is backend/PSP config | 🗑 removed in refinement-phase-4 (payment flipped real) |
| `RefundsApi` | `client/src/services/refunds/apis/mockApi.ts` | **The f10 customer cancel + refund surface** b11 doesn't serve (refunds are admin-only; no customer cancel command, no policy preview, no refund-by-booking, no fee-leg decomposition on the customer status → REQ-019/020/021). Reads the shared **f8 bookings store** (`mockGetBookingForRefund`) to resolve the tier by lead time (`free_24h` >24h / `partial_under_24h` <24h / `customer_no_show` started — client-invented codes → i18n keys) and the per-session refundable(un-started)/locked(completed-and-verified) breakdown, decomposing the refund across the two fee legs via **integer parts-per-10000 BigInt math** (`refundAmount + fee = refundableGross` to the rial). `cancelBooking` flips the booking → `cancelled` (`mockMarkBookingCancelled` stamps the b9 snapshot + cancels only un-started sessions) and creates a refund: **card → `succeeded`** immediately (no ETA); **BNPL → `approved`→`processing`→`succeeded`** over status polls with a `expected_customer_refund_eta` ~10 business days out (Fridays skipped) so the ~710-day banner renders. Enforces the outside-policy **`409`** (already-cancelled / nothing-refundable / non-refundable session). Seeds a **`failed`** refund on the cancelled booking 5004 so the contact-support state demos; booking 5002 is pinned to the BNPL channel; booking 5003 (new, mid-engagement) demos the mixed refundable/locked breakdown. Also adds bookings-store seeds 5003/5004 + the two non-seam exports. **ui-phase-6:** `getMyRefunds` (REQ-048) returns every in-memory refund newest-first — the wallet «استردادها» tab | `USE_REFUNDS_MOCK` (`services/refunds/constants.ts`, default `true`) | Deliver **REQ-019** (customer cancel command — the real `refundsClientApi.cancelBooking` already targets `POST bookings/{id}/cancel`) + **REQ-020** (cancellation-policy preview → `GET bookings/{id}/cancellation_policy`, incl. the canonical `cancellation_policy_code` set) + **REQ-021** (`GET refunds/by_booking/{id}` + the decomposition fields on the customer `refunds/{id}/status`) + **REQ-048** (`GET refunds/my`), then set flag `false` — the real client maps the published `refunds/{id}/status` 1:1 and targets the proposed slugs for the rest. No hook/component change | 🟡 |
| `BnplApi` | `client/src/services/bnpl/apis/mockApi.ts` | **The f11 BNPL installment checkout (D1D5)** b12 doesn't serve client-side (b12 is order-centric — eligibility/initiate/status/webhook — and **explicitly does not model the repayment schedule**; no provider/plan options, no wallet installment status → REQ-022/023/024). Reads the frozen request gross from the shared **f7 store** and plays the provider: `getBnplOptions` builds the provider set as **data** (دیجی‌پی 3/6/12 · اسنپ‌پی ۴ · اقساط بالین‌یار; per-plan monthly/down-payment/total via **integer parts-per-10000 BigInt math**, never a hardcoded fee in the UI); `checkEligibility` returns `eligible` unless the national-id last digit is `0` (→`not_eligible`) or the order exceeds `MOCK_CREDIT_CEILING_IRR` (→`ceiling_exceeded`) so both declined paths demo; `getBnplSchedule` serves the down-payment + N-installment rows (last absorbs the remainder → rows sum to total); `issueBnplToken` enforces b12 idempotency (same key → same token; repeat after settle / lapsed window → **`409`**) + a `redirectUrl` into the local provider-handoff harness; `acceptBnplSchedule` on success is the **settle stand-in and reuses the f9 conversion bridge** — flips the request `converted` (`mockMarkBookingRequestConverted`), inserts a **confirmed** booking (`mockInsertConvertedBooking`; a settled BNPL order = a card payment net-of-fee, payout invariant to method), and **seeds a provider-reported Wallet plan**; `getWalletInstallments` serves D5 (seeded active دیجی‌پی ۶-ماهه with paid/due-soon/upcoming rows + each settled checkout's plan). Money = served IRR digit-strings end-to-end (components only format) | `USE_BNPL_MOCK` (`services/bnpl/constants.ts`, default `true`) | Deliver **REQ-022** (options + schedule — real `bnplClientApi` targets `checkout_bnpl/options/{id}` + `checkout_bnpl/schedule/{id}`), **REQ-023** (eligibility accepts the D3 national-id/mobile/consent), **REQ-024** (`checkout_bnpl/wallet_installments` provider-reported status + a customer `bookingId` on the settled order), and make the upstream `bookingRequests` flow real, then set flag `false``checkEligibility`/`issueBnplToken`(`Idempotency-Key`)/`getBnplOrder` already map the live b12 routes 1:1; the settle-on-return reads the order (the real settle is the provider webhook). No hook/component change | 🟡 |
| BNPL provider-handoff harness (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the provider's hosted BNPL page so the initiate → redirect → return round-trip is exercisable without a provider: the mock `redirectUrl` points here, and its pay/cancel buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «در حال انتقال به ارائه‌دهنده», dashed border. **ui-phase-6:** was reachable by direct URL in a production build (the equivalent card-gateway harness was already deleted, this one never got the same guard since BNPL stays mock-primary) — now `notFound()`-gated outside `NODE_ENV=development`, still fully reachable in `next dev` | _none — only reachable via the mock's `redirectUrl`_ | On the real path b12's `redirectUrl` is the provider's **absolute** URL (the wizard does a full `window.location.assign` for `http(s)`), so this page is never linked; delete it when `USE_BNPL_MOCK` retires. The provider's return deep-link into `/bookings/checkout/bnpl/return` is backend/provider config | 🟡 |
| `PayoutsApi` | `client/src/services/payouts/apis/mockApi.ts` | **The f12 nurse earnings surface** b13 doesn't serve read-side for a nurse (b13's only nurse route is `GET nurse_payouts/history`; the four-bucket **earnings summary**, the per-booking **earnings list + money-state**, and a **nurse-readable payout detail** with batch context + booking links are gaps → **REQ-025**). Self-contained, money-correct fixtures exercising **every** UI state: all four earnings states (`pending`/`eligible`/`paid`/`clawback_applied`; booking ids 50015004 align with the f8 bookings-store seeds so "view booking" deep-links land), all four `PayoutStatus` values in history (`pending`/`submitted`/`paid`/`failed`, incl. a `failed` payout with `failureReason: 'invalid_sheba'` for the read-only failure banner), payout **details that reconcile** (`gross clawback = net = amount`, Σ booking-link amounts = `grossEarnings`), and a **signed net balance** computed with BigInt via a `MOCK_SCENARIO` toggle (`standard` = positive; **`clawback_heavy` = negative "owed back"** for phase §7 step 3). Timestamps are relative to `now` so the pending dispute-window countdown always ticks; money stays IRR digit-strings end-to-end (components only format). `getNurseEarnings` filters by `state` + paginates. **ui-phase-7:** `buildSummary()` adds `nextPayoutDate` (+3 days) + `nextPayoutEligibleAmountIrr` (= the `eligible` bucket) for the earnings/dashboard «برداشت بعدی» forecast line (REQ-053); the client also gained a `failureReasonLabelKey()` helper (`services/payouts/failureReasons.ts`) mapping known `failureReason` codes (today: `invalid_sheba`) to i18n labels so `PayoutHistoryRow`/the payout detail never show the raw vendor string as the headline | `USE_PAYOUTS_MOCK` (`services/payouts/constants.ts`, default `true`) + `MOCK_SCENARIO` in `constants.ts` | Deliver **REQ-025** (earnings_balance + earnings list + nurse `nurse_payouts/{id}` detail + `failureReason` on the history DTO) + **REQ-053** (`nextPayoutDate`/`nextPayoutEligibleAmountIrr`), then set flag `false``payoutsClientApi` already maps the live `GET nurse_payouts/history` 1:1 and targets the proposed slugs for the rest. No hook/component change | 🟡 |
| `ReviewsApi` | `client/src/services/reviews/apis/mockApi.ts` | **The f13 moderated-review trust loop.** b14 serves the review **submit** (`POST bookings/{id}/review`), the public **nurse reviews** page (`GET nurses/{id}/reviews`), and the tag rollup — those are mapped 1:1 in `reviewsClientApi`. But there is **no review-eligibility read** and **no my-review-for-booking read** (**REQ-026**), and the whole moderation transition (`pending_moderation → published`) is **admin-only (f15)**. The mock reads a booking from the shared **f8 bookings store** (`mockGetBookingForReview`) to gate eligibility on a **completed/closed** booking (aligns with the new completed seed 5005 / nurse 1 / patient 905), tracks the customer's submission as `pending_moderation` so eligibility flips `already_reviewed` + `getMyReviewForBooking` returns the persistent "under review" state, and seeds a **published list per nurse** (nurse 1 has 7 → the profile tab paginates; nurses 5/6 empty → empty state). The aggregate is **recomputed from the published list** (never a stored sum). A submitted review **never** enters any public list. Dev-only `__mockPublishSubmittedReview(bookingId)` stands in for the deferred (f15) admin queue so a human can watch a review appear on the profile. Money-free | `USE_REVIEWS_MOCK` (`services/reviews/constants.ts`, default `true`) | Deliver **REQ-026** (`review_eligibility` + `my_review` reads; confirm masked-author omission), then set flag `false``reviewsClientApi.getNurseReviews`/`createReview` already map the live b14 routes 1:1 and target the two proposed slugs for the gaps. Moderation UI itself is **f15** (admin). No hook/component change | 🟢 (real, refinement-phase-4) |
| `PatientRecordsApi` | `client/src/services/patientRecords/apis/mockApi.ts` | **The f13 continuity-of-care surface.** Two very different things: (1) the **nurse-authored visit-note history** (`getPatientHistory`/`createVisitNote`) is **REAL b14** (`GET`/`POST patients/{id}/care_records`), mapped 1:1 in `patientRecordsClientApi` (the append composes the ticked task checklist into the note `body` since the wire has no structured task field); (2) the **family-owned editable record** (medications/routine/tasks — the داروها/روتین/وظایف tabs) and the **access check** have **NO backend at all** (neither the b14 contract nor `data-model/10-reviews-and-records.md` model them → **REQ-027**). The mock is **patient-scoped** and lazily seeds a coherent default per patient: a default family record (customer edits it), a **multi-nurse continuity history** (two prior notes from *different* nurses, proving the history persists across nurse changes; a nurse append prepends to the same patient's history), and a **foreign-patient access-denied** path (`MOCK_FOREIGN_PATIENT_ID = 8888``canView:false` + a `403` on every read) so the non-leaking access-denied card is demoable. Clinical text is fixture data (never logged) | `USE_PATIENT_RECORDS_MOCK` (`services/patientRecords/constants.ts`, default `true`) | Deliver **REQ-027** (family-owned `care_record` GET/PUT + `record_access` + structured `taskResults`), then set flag `false` — the history/append methods already map the real b14 routes; only the family-record/access methods flip. Confirm whether the family-owned record is a real MVP entity | 🟡 |
| f8 bookings mock — completed-booking seed 5005 + f13 cross-mock reads | `client/src/services/bookings/apis/mockApi.ts` | **Non-seam additions (mirrors the f10 refunds precedent).** The f8 seeds had **no `completed` booking** (only `confirmed`/`in_progress`/`cancelled`), so f13's review flow needs one: added **booking 5005** (`status: 'completed'`, nurse 1, patient 905, one completed EVV session) so the customer can open a completed booking and leave a review. Also added a **cross-mock read helper**`mockGetBookingForReview(id)` (single booking, clone) — imported by the reviews mock to gate eligibility and read the patient/nurse snapshot for a submission (the `listBookings` seam row omits `patientId`/`nurseId`). One-way edge INTO bookings (the bookings mock never imports f13), so no cycle | — (part of `USE_BOOKINGS_MOCK`) | When the bookings flow goes real (b9/b10 conversion live), 5005 stops being a static seed and the cross-mock helpers retire with the reviews/records mocks | 🟢 (real, refinement-phase-4) |
| `TicketsApi` | `client/src/services/tickets/apis/mockApi.ts` | **The f14 ticket channel (b15).** b15 serves open/list/thread/message and `ticketsClientApi` maps them 1:1; **REQ-028 is now delivered** (`unreadCount`/`lastMessageAt` + `clientMessageId` idempotency are real on the wire), so `USE_TICKETS_MOCK = false` by default — the mock stays available for offline/demo use. It seeds 3 tickets (a booking-5001 **coordination** ticket with a **stored internal admin note the user view NEVER returns** — the no-leak demo — plus a support + a closed refund ticket), returns them newest-activity first with a per-ticket unread count that **clears on open**; `openTicket` is **idempotent for `coordination + bookingId`**; `postMessage` appends as the current viewer, throws `403` on a **closed** ticket, and throws `500` on the dev sentinel body `'/fail'` (the optimistic failure→retry path — **ui-phase-10:** a failed send now flips the bubble to `sendStatus:'failed'` in place instead of rolling it back, and a retry re-mutates the same `clientMessageId`). `MOCK_VIEWER_USER_ID` (per-role "me") drives `isMine`; **`isInternal` is never modelled in the user-app types**. **ui-phase-10 additions:** `toSummary()` now also computes `lastMessagePreview` (first ~80 chars of the last non-internal message) + `lastAuthorRole` (REQ-059 gap — real path maps both `null`, card degrades gracefully); a new `getUnreadTotal()` sums `unread` across every seeded ticket for the chrome support-badge (REQ-059 — real path returns `null`, badge doesn't render). **ui-phase-11 additions:** `closeTicket`/`reopenTicket`/`assignTicket` (REQ-063) — the admin thread's close/reopen/"assign to me" controls, gated client-side behind `TICKET_LIFECYCLE_ENABLED` (default `false`, `services/tickets/constants.ts`) so nothing points at the still-404ing proposed routes in production; `StoredTicket` gained `assigneeUserId` | `USE_TICKETS_MOCK` (`services/tickets/constants.ts`, default **false** — REQ-028 delivered) | Deliver **REQ-059** (`lastMessagePreview`/`lastAuthorRole` on the summary + a cheap unread-total read) to fully retire the mock's enrichment role; **REQ-060** (message photo attachments) gates the composer's designed-but-off attachment affordance (`TICKETS_ATTACHMENTS_ENABLED`, default `false`); **REQ-063** (close/reopen/assign routes + `assigneeUserId` on the admin DTOs) — once delivered, flip `TICKET_LIFECYCLE_ENABLED` to `true` (the hooks/UI are already built against the mock). `ticketsClientApi` already maps the live b15 routes 1:1 (drops any leaked internal message defensively) | 🟢 real by default (REQ-028); 🟡 REQ-059/060/063 gaps remain mock-only |
| `NotificationsApi` | `client/src/services/notifications/apis/mockApi.ts` | **The f14 notification center + polled bell (b1).** The b1 endpoints are live and `notificationsClientApi` maps them 1:1, but a notification only exists once some other backend domain **dispatches** one (`INotificationDispatcher`) — none run client-side while the upstream flows are mock-primary — so there'd be nothing to show. The mock seeds a realistic **unread-first** feed spanning **every deep-link class** (ticket_message/booking_confirmed/refund_processed/payment_captured/payout_paid/review_published + one unknown-type/no-payload row that degrades to no deep-link), each with a snake_case `dataJson` string the list maps through the **real** `parseNotificationData`; `getUnreadCount`/`markRead`/`markAllRead` mutate the in-memory feed. **Dev-only `__mockPushNotification(type,title,dataJson?,body?)`** prepends a fresh **unread** row so a human can watch the bell badge increment within the poll interval (phase §7 step 4). Ids align with the f8 bookings + tickets mocks so a deep-link lands on a real screen | `USE_NOTIFICATIONS_MOCK` (`services/notifications/constants.ts`, default `true`) | When the upstream domains dispatch real notifications, set flag `false``notificationsClientApi` already maps the live b1 `notifications/*` routes 1:1 (`page`/`pageSize`, `{count}`, `{notificationId}`). No hook/component change | 🟢 (real, refinement-phase-4) |
| `AdminApi` | `client/src/services/admin/apis/mockApi.ts` | **The f15 backoffice-owned data (b1 + b15).** Fixtures engineered to exercise every console state: **one config per `data_type`** (decimal/int/bool/json/string — so the typed inputs + the 01 rate validation are all reachable) with a **change-history** trail; **holidays** with bank-closed days; a **paged audit log** with `changedFields` diffs (one row `<redacted>` for a PII field); a **support-alert** list spanning **every** `type` (`low_rating`/`evv_no_show`/`evv_location_mismatch`/`verification_expired`/`shared_sim`/`payment_anomaly`/`fraud_signal`/`nurse_clawback`/`emergency`) and all three statuses so the worklist filters are testable; and **RBAC** grants. Mutations mutate the in-memory arrays (a config save writes a history row; assign/resolve advance an alert; grant/revoke flip a role). Timestamps relative to `now`. **ui-phase-11 addition:** `searchUsers(query, roleFilter?)`/`lookupUsers(userIds)` (REQ-061) — a seeded ~13-entry user directory (admin/support/finance staff, nurses with `nurseProfileId`, customers, one partner contact) backing `UserPicker`/`NursePicker` (name+masked-phone+id search, replacing every raw numeric-id `TextField` on an audited action) and `AuditLogRow`'s batch actor-name resolve; `AdminUserSummary.maskedPhone` never exposes the mock's internal full-number field | `USE_ADMIN_MOCK` (`services/admin/constants.ts`, default `true`) | b1 config/holiday/audit/support-alert routes are live and `adminClientApi` maps them 1:1 — deliver **REQ-029** (config `updatedAt`/`updatedBy`) + **REQ-030** (audit actor/action/date filters) + **REQ-031** (the RBAC `admin_roles/*` endpoints, which don't exist yet) + **REQ-061** (the user-directory search/lookup endpoints — `client/src/services/admin/apis/clientApi.ts` already maps them to a proposed `admin_users/search`+`admin_users/lookup` route pair), then set flag `false`. No hook/component change | 🟡 |
| `PartnerCenterApi` | `client/src/services/partnerCenter/apis/mockApi.ts` | **The f15 partner centers (b15) — admin management + the center-scoped portal.** Returns **center #1 = merchant-of-record** (the settlement/invoice view renders) **and** #2 = non-MoR (the "settlement runs through Balinyaar" state) **and** a **draft** #3 (unverified banner); sponsored nurses (verified + unverified), sponsored bookings, and commission invoices whose **platform commission + BNPL commission + VAT = total** (VAT on the commission line only) with a fake 22-digit `moadianReferenceNumber` + a stub PDF url. `settlementIbanMasked` is **last-4 only** (write-then-masked: create/edit submit a full IBAN, only last-4 ever returns). Admin CRUD/verify/set-active/assign-nurse + the portal "my center" reads all mutate/read the in-memory world; "my center" resolves to `MOCK_MY_CENTER_ID` (=1, MoR). **ui-phase-11 addition:** `getMySponsoredBookingDetail(bookingId)` (REQ-064) — a synthetic 24-step status timeline consistent with the booking's current status, backing the portal's new scoped read-only booking-detail page (dates + status timeline + patient display name only, no clinical content) | `USE_PARTNER_MOCK` (`services/partnerCenter/constants.ts`, default `true`) + `MOCK_MY_CENTER_ID` | b15 admin partner-center CRUD/verify/sponsor are live; deliver **REQ-032** (portal split reads `centers/me[/nurses|/bookings|/settlement]` + the activate/suspend toggle + confirm the write-then-masked IBAN) + **REQ-033** (center-scoped invoice list + invoice `totalIrr`) + **REQ-064** (the single-booking read + timeline), then set flag `false``partnerCenterClientApi` maps the live admin routes and targets the proposed portal slugs. No hook/component change | 🟡 |
| Admin-endpoint additions to existing domain mocks (`verification`/`refunds`/`payouts`/`reviews`/`tickets`) | the same `apis/mockApi.ts` files (+ their `clientApi.ts`) | **The f15 staff lens over prior domains** — new admin methods added behind the existing seams (no new seam, no hook/component change on swap). **verification:** a nurse-level review queue (`pending`/`in_review`, one with an expiring credential) + a per-nurse case whose manual credential steps carry a document, and `getDocumentSignedUrl` that returns a **fresh short-lived URL each call** (sentinel `documentId 9999` throws → viewer error/re-request path); `decideStep`/`approve`/`reject` re-aggregate. **refunds:** a `getRefundPreview` with the fee/payout split reconciling to the rial per booking (a normal card, a BNPL w/ ETA, a post-payout w/ clawback notice, and a provider-decline **sentinel that fails then retries succeeds**). **payouts:** batches spanning `completed`/`partially_failed`/`processing` (one holiday-shifted), a preview w/ eligible + skipped(no-IBAN) + clawback line + holiday-shifted date, an **idempotency-keyed** run/retry (same key → same result, never double-pays), a `failed` payout to retry, and record-transfer-reference. **reviews:** a moderation queue incl. a low-rating flagged review; `moderateReview` returns a plausible recomputed aggregate. **tickets:** a global admin queue + a thread that **includes** the seeded internal note (the no-leak *inverse* demo) + `postAdminMessage` w/ `isInternal`; a refund-linked ticket (bookingId+refundId) so the RefundPanel opens from it | the owning domain's flag (`USE_VERIFICATION_MOCK` / `USE_REFUNDS_MOCK` / `USE_PAYOUTS_MOCK` / `USE_REVIEWS_MOCK` / `USE_TICKETS_MOCK`, all default `true`) | Deliver the per-domain admin gaps — **REQ-034** (verification nurse-queue + on-demand doc URL + whole-verification approve/reject), **REQ-035** (refund preview + explicit approve/reject), **REQ-036** (payout single-preview + `holidayShifted` + record-transfer-reference), **REQ-037** (moderation `tagCodes`) — then flip the owning domain's flag. The real `clientApi` methods already map the live admin routes 1:1 and target the proposed slugs for the gaps | 🟢 (real, refinement-phase-4) |
@@ -0,0 +1,78 @@
# Refinement Phase 0 — Local end-to-end bring-up & the integration seam — Report (2026-07-12)
## What was built
Removed the three hard integration blockers so the client and server actually talk on one machine, and
proved it with one real authenticated round-trip. **Plumbing only — no business logic, money path, auth
crypto, or envelope shape changed.**
- **CORS (§3.1).** New `Baya.WebFramework/ServiceConfiguration/CorsServiceExtension.cs`
`AddCorsPolicies(configuration)` builds the named policy `BalinyaarWebClient` from
`Cors:AllowedOrigins` (string array), defaulting to `http://localhost:3000` when unset. Allows exactly the
four headers the client sends (`Authorization`, `Content-Type`, `Accept-Language`, `Idempotency-Key`) +
`AllowAnyMethod()`; **no `AllowCredentials()`** (the client uses a bearer header, not a cookie). Registered
in the service chain and `app.UseCors(CorsServiceExtension.PolicyName)` placed **after `UseRouting()` and
before `UseRateLimiter()`** so pre-flight `OPTIONS` is answered before the limiter/auth run.
- **Local database story (§3.2).** `server/docker-compose.yml` rewritten to a single-purpose SQL Server 2022
(Developer edition) on `localhost:1433` with a **dev-only** `MSSQL_SA_PASSWORD` + a named volume +
healthcheck. (The old compose referenced an unbuildable `bobby-baya` app image and exposed `1435`.) The
committed `appsettings.json` / `appsettings.Development.json` connection strings (`SqlServer` + `logDb`) are
now **non-working placeholders** (`Password=SET_VIA_USER_SECRETS_OR_ENV`); the real local value comes from
`dotnet user-secrets`. Added `<UserSecretsId>baya-web-api</UserSecretsId>` to the API csproj (it was
missing — user-secrets weren't actually wired before).
- **Development-only OTP retrieval (§3.3).** `GET /api/v1/dev/last_otp/{phone}` (`DevController`) returns the
most recent OTP for a phone so a browser / e2e flow can log in without an SMS gateway. Backed by a new
Development-only `DevOtpStore` + `DevCapturingSmsSender` (an `ISmsSender` decorator that captures the code
then delegates to the log-only `LoggingSmsSender`), wired **only** in Development by
`AddDevelopmentOtpCapture()`. The endpoint **404s in every non-Development environment** and the capture is
not even registered there — two independent guarantees it can't leak a code. It does **not** weaken the
`otp` rate-limit or the per-phone resend window.
- **Client env & runbook (§3.4).** Verified `client/.env.development` already has
`NEXT_PUBLIC_API_URL = https://localhost:5002` (matches the API's bound URL) — **no client code changed, no
mock flag flipped.** Wrote `dev/post-phase/refinement/RUNBOOK.md` — the copy-pasteable "run the whole app
locally" procedure (dev-cert trust, compose, user-secrets, both run commands, OTP-from-logs / dev-endpoint,
DevTools verification, troubleshooting).
## What is now testable (and exactly how)
- **Automated (in the suite, +11 tests → 369 total):** 3 new `Baya.Test.Api` integration tests
(`CorsAndDevBringUpTests`): a pre-flight `OPTIONS /api/v1/auth/request_otp` from `http://localhost:3000`
reflects `Access-Control-Allow-Origin: http://localhost:3000`; the same from a foreign origin gets **no**
allow-origin header; `GET /api/v1/dev/last_otp/...` returns **404** in the (non-Development) test host. Plus
8 `Baya.Test.Foundation` unit tests (`DevOtpBringUpTests`) covering the store's capture/latest/
spelling-insensitive lookup + the decorator's capture-and-still-delegate behaviour.
- **Manual (the §7 proof):** follow `RUNBOOK.md``docker compose up -d`, set the user-secret, `dotnet run`,
`npm run dev`, open `/fa/login`, request an OTP, read the code from the server console (or
`GET /api/v1/dev/last_otp/{phone}`), submit → land on the customer home with **200s** on
`/auth/request_otp`, `/auth/verify_otp`, `/api/v1/me` and **no CORS error**. Negative check: remove
`app.UseCors(...)` → the same flow fails with a CORS error.
## What is mocked / waiting on a real service
- No new seam. The existing **`ISmsSender`** (`LoggingSmsSender`) stays the interim OTP channel (logs the
code); its `mocks-registry.md` row is updated to note the Development-only `DevCapturingSmsSender` +
`/dev/last_otp` affordance. Real SMS is [Refinement Phase 8](refinement-phase-8-external-rails.md).
- The `DevOtpStore` / `DevCapturingSmsSender` / `/dev/last_otp` endpoint are a **Development-only dev
affordance, not a seam** (per the phase §4) — superseded by real SMS in Phase 8.
## Contracts
- **None produced.** This phase ships plumbing (CORS middleware) + a Development-only diagnostic endpoint the
frontend does not consume as a contract, so no `dev/contracts/domains/*.md` was written and the
`swagger.v1.json` snapshot was **not** regenerated (the only new path is the dev-only helper Phase 8
removes — regenerating would add churn for a path no client binds to). Auth remains the one real domain
the frontend consumes, unchanged.
## Docs updated
- `server/CLAUDE.md` "Startup wiring" — added `AddCorsPolicies(config)` to the registration list, the
Development-only `AddDevelopmentOtpCapture()` note, and `CORS` in the pipeline order (after routing, before
the rate limiter). Project map — noted the Development-only `Dev` controller.
- `dev/post-phase/refinement/RUNBOOK.md` — new local-run runbook.
- `dev/shared-working-context/reports/mocks-registry.md``ISmsSender` row updated.
## Follow-ups for later phases
- **Phase 1** — local-dev demo seed (nurses/variants/search rows) so discovery/booking aren't empty on the
real path.
- **Phase 4** — flip the 21 `USE_*_MOCK` flags (this phase changed none).
- **Phase 5** — rotate the leaked remote `sa` credentials + the dev-grade `IdentitySettings` keys (still
committed as dev placeholders here); set real production `Cors:AllowedOrigins`.
- **Phase 8** — real SMS gateway; removes the `/dev/last_otp` helper + the `DevCapturingSmsSender` decorator.
- Note for deployed envs: `Cors:AllowedOrigins` must list the real web origin(s); an empty array falls back
to the localhost dev origin (safe — a real user's origin won't match, so cross-origin is effectively denied
until configured).
@@ -0,0 +1,92 @@
# Refinement Phase 1 — Database: local-dev story, demo seed & migration hygiene — Report (2026-07-13)
## What was built
Turned a freshly-migrated database from an empty shell (reference/lookup rows + one admin + one gateway) into
a **populated demo marketplace**, so every real-path discovery/search/booking screen has data. **No new
migration, no new endpoint, no contract change, and the reference `HasData` seeds are untouched** — the demo
seeder writes *alongside* them through the existing entities and the real search maintainer.
- **Development-gated demo seeder (§3.2).** New `Persistence/Services/Seeding/DemoWorldSeeder.cs` (scoped,
registered in `AddPersistenceServices`) + `DemoWorldDefinitions.cs` (the static, deterministic persona
data). Invoked from `Program.cs` **only under `app.Environment.IsDevelopment()`**, after
`SeedDefaultUsersAsync` + `SeedPaymentGatewaysAsync`, via the new `SeedDemoWorldAsync()` extension. It
creates:
- **3 nurses** (phone users + `nurse` role via `IAppUserManager`, each with a `nurse_profiles` row):
two **fully verified** (`is_verified` flipped through the guarded `MarkVerified()`, a `nurse_verifications`
row `approved`, credentials, a primary bank account with `matched_national_id=1`), one **unverified**
(`pending`, no credentials/bank). Each verified nurse gets **23 `nurse_service_variants`** across price
units (IRR `BIGINT`) + **`nurse_service_areas`** in Tehran (one whole-city `district_id=NULL`, some
specific districts).
- **2 customers** (phone users + `customer` role) each with a `customer_profiles` row, **12 `patients`**
(with gender for same-gender matching + encrypted `initial_medical_notes`), and a **`customer_addresses`**
row with coordinates.
- **One cross-category, required option group** — شیفت / *Shift Type* (`Daytime`/`Night`/`Live-in`) — so the
variant builder's required-option step renders on the real path in Development (the "categories but no
option groups" data gap). Each variant answers it; the `OptionSetHash` is computed the same way the real
`CreateVariant` handler does.
- **The search index is driven through the real `ISearchIndexMaintainer.RebuildAsync`** (never hand-inserted)
— the single place `is_searchable` is computed, so the seeded world is identical to one built by real usage.
- **Idempotency (§5).** Every persona is guarded on its **phone number** (`GetUserByPhoneNumber`); the option
group on its name. Re-running the seeder on an already-seeded DB is a no-op (it still re-derives the search
index, which is itself idempotent).
- **Local DB story finished (§3.1).** Phase 0 already delivered `docker-compose.yml` (SQL Server 2022 on
1433), placeholder `appsettings*.json` connection strings, the `UserSecretsId`, and boot-time
`MigrateAsync`. This phase **verified** those and **documented the explicit `dotnet ef database update` path**
(for the migrations-off-boot / deploy direction Phase 7 formalises) plus the demo-seed reset flow in the
RUNBOOK.
- **Migration hygiene (§3.3).** Confirmed the 17 migrations are current and there are no pending model changes
(the demo seeder adds runtime data, not schema — it changed no entity config and no migration). The three
promised-but-missing forward-dep FKs and the boot-migrate multi-instance race are **explicitly out of scope**
(Phase 6 / Phase 7 respectively), as the phase directs.
## The exact demo world (for the frontend de-mock phase to log in as)
| Phone | Role | Who | Gender | State |
| --- | --- | --- | --- | --- |
| `09120000001` | nurse | زهرا عزیزی | female | **verified**, accepting · variants: Elderly per-24h `3500000`, Elderly per-hour `250000`, Post-Surgery per-day `2000000` · areas: whole-city Tehran + منطقه ۱ + منطقه ۳ |
| `09120000002` | nurse | علی کریمی | male | **verified**, accepting · variants: Chronic per-day `1800000`, Post-Surgery per-24h `3200000` · areas: منطقه ۳/۶/۱۲ |
| `09120000003` | nurse | مریم احمدی | female | **unverified** (pending) · Infant per-session `800000` · منطقه ۲ — never in search |
| `09120000010` | customer | سارا محمدی | female | patients: حسن (male), فاطمه (female) · address in منطقه ۳ (coords) |
| `09120000011` | customer | رضا حسینی | male | patient: امیرعلی (male, infant) · address in منطقه ۶ (coords) |
Tehran `city_id = 101`; districts are `1000+n`. Category ids: Elderly `1`, Post-Surgery `2`, Infant `3`,
Chronic `4`.
## What is now testable (and exactly how)
- **Automated (in the suite, +3 tests → 372 total):** `Baya.Test.Api/DemoWorldSeederTests` runs the seeder
through real DI over the SQLite harness and asserts, over the real HTTP pipeline:
1. `GET /api/v1/search/nurses?service_category_id=1&city_id=101` (Elderly, verified nurses) → `total > 0`;
`service_category_id=3` (Infant, only the unverified nurse) → `total == 0`.
2. The verified nurse's `trust_badge``isVerified=true` with non-empty `credentialTypes`; the unverified
nurse's → `false`.
3. Running the seeder **twice** leaves exactly `Nurses.Length` / `Customers.Length` rows (idempotent).
- **Manual (the §7 proof, requires a reachable SQL Server):** wipe + `dotnet run` (Development) → the console
logs `Demo world seeded: 3 nurse(s), 2 customer(s)…`; hit the two search routes above and the trust-badge
routes in Swagger; re-run → `Demo world already seeded — no-op`. *(Not runnable in this environment: the SQL
Server on `localhost:1433` here rejects the dev `sa` credential, so the automated HTTP-level tests above are
the reproducible proof.)*
## What is mocked / waiting on a real service
- **None introduced.** The seeder goes through the real handlers/maintainer where an invariant is at stake
(verification flip, search reindex, `OptionSetHash`, encrypted-PII converters, `iban_hash`), so no seam and
no `mocks-registry.md` entry. The bank account's `matched_national_id` is set directly to model a completed
استعلام شبا inquiry (the mock `IBankAccountOwnershipVerifier` isn't invoked from the seeder).
## Contracts
- **None produced / changed.** No new route or shape; `swagger.v1.json` not regenerated.
## Docs updated
- `server/CLAUDE.md` — Persistence section notes the Development demo seeder and what it creates; Startup
wiring notes the Development-only `SeedDemoWorldAsync()`.
- `dev/post-phase/refinement/RUNBOOK.md` — added the demo-seed description, the demo-account table, the explicit
`dotnet ef database update` path, and the reset/re-seed flow.
- Handoff: `backend/handoff/after-refinement-phase-1.md`.
## Follow-ups for later phases
- **Phase 4** — flip the client `USE_*_MOCK` flags; log in as the demo accounts above and de-mock home/search/
booking against this real data.
- **Phase 6** — the additive forward-dep FKs (`refunds.ticket_id`, `nurse_clawbacks.*_payout_id`,
`invoices.partner_center_id`) + money-correctness; *not* in this phase.
- **Phase 7** — split boot-time `MigrateAsync` out for the multi-instance / least-privilege deploy path.
- **Optional later:** a couple of confirmed bookings + sessions for the demo pairs (ids 50015005) so the
post-payment screens demo without running the funnel — deliberately deferred (the cleaner demo is to create
bookings by running the real funnel once Phase 4 lands).
@@ -0,0 +1,85 @@
# Refinement Phase 2 — Auth & role-aware navigation (the "only customer side" fix) — Report (2026-07-13)
## The symptom, and the actual root cause
"There are nurse and admin pages, but running the frontend only ever shows the customer side." Auth was
already the one real domain (`USE_AUTH_MOCK = false`); the app only *looked* customer-only because of two
things, now fixed:
1. **Role hydration conflated "loading" with "no role."** A fresh `/me` in-flight fell through
`useActorRole()`'s `DEFAULT_ROLE = customer` fallback, so a nurse/admin was shown the customer shell for a
beat — or forever, if `/me` failed. **This was the core bug.**
2. **The admin console was unreachable through the web login.** Admin sub-roles are server-granted (never
self-selectable via `me/select_role`), and no *phone* user held one — the only admin was the
username/password `admin`/`qw123321` the phone-OTP frontend can't use.
## What was built
### Frontend (client/ — the bulk)
- **`useRoleHydration()`** (`services/auth/hooks/useRoleHydration.ts`) — a discriminated
`loading | error | ready` over `useMe`. This is the resolved-vs-pending distinction the phase demands:
`ready` only once `/me` resolves (carrying the collapsed `appRoles`); `error` only when `/me` has **no**
data (a background refetch that fails while a cached identity exists stays `ready` — don't downgrade a known
nurse on a blip). Exported from the `services/auth` barrel.
- **`RoleGuard`** (`components/auth/RoleGuard.tsx`, **tested**) — wraps every private shell. On `loading`
neutral brand `AuthSplash` (never the customer shell as a stand-in); on `error``AuthAccountError` with
retry (never a silent customer fallback); on **role mismatch**`router.replace(resolveRoleDestination(me))`
+ a `guard_denied` toast. Takes `expected?: AppRole`; the partner portal passes none (hydration-only —
partner isn't an `AppRole`, it self-gates via `useMyPartnerCenter`). It is **UX/chrome, not security** — the
server still authorizes every endpoint; a dual customer+nurse session holds both roles and moves freely.
- **`AuthAccountError`** (`components/auth/AuthAccountError.tsx`) — the `/me`-failed recovery card (brand mark
+ warning + retry). Distinct from `RoleRouter`'s login-time error branch (which sends to `/login`).
- **Wired the four shells**`(customer)`/`nurse`/`admin` layouts wrap in `RoleGuard expected={APP_ROLES.*}`;
`partner` wraps in a role-less `RoleGuard`. The guard sits **outside** the shell component so its nav chrome
never renders during load/redirect.
- **Doc hardening**`useActorRole()`'s `DEFAULT_ROLE` fallback is now documented as a last resort (the guard
ensures hydration before a shell renders), never the loading state. No behavior change there (f15's
`useAdminCapabilities` still reads the same session roleCodes).
- **i18n**`auth.guard_denied` / `account_error_title` / `account_error_body` / `account_error_retry` in
both `en.json` + `fa.json`.
### Backend (server/ — a little, per §3.4)
- **Two phone-OTP admins added to the Development demo seeder** (`DemoWorldSeeder` + `DemoWorldDefinitions`):
`09120000020` (`super_admin`) and `09120000021` (`finance`). An admin persona is just a phone user + a
server-granted admin role (no profile) via the existing `CreateUserAsync`; idempotent (phone-guarded) like
every other persona, Development-only. This is the sanctioned path to `/admin` through the normal phone-OTP
login. Seeding **two** roles makes `useAdminCapabilities` gating demonstrable — the `finance` operator's
sidebar shows only the money consoles.
## What's now testable, and exactly how (DoD)
Run the app per the RUNBOOK, then:
1. **Nurse → `/nurse`:** log in as `09120000001` (verified nurse) → nurse shell + dashboard.
2. **Customer → `/`:** log in as `09120000010` → family app. Tap "become a nurse" (SelectRole) → `POST
me/select_role` in Network → after the `/me` refetch you're routed to `/nurse`.
3. **Admin → `/admin`:** log in as `09120000020` → admin console (all consoles incl. RBAC). Log in as
`09120000021``/admin` with only the finance consoles in the sidebar (`useAdminCapabilities`).
4. **Mis-role redirect:** as a pure customer, visit `/nurse` → redirected to `/` with the `guard_denied` toast.
5. **Backend-down resilience:** stop the API, reload a nurse session → `AuthAccountError` (loading→error), **not**
the customer app; restart + retry → recovers to `/nurse`.
Automated: `RoleGuard.test.tsx` (8 cases — loading/error/retry/allowed/dual-role/mismatch-redirect/role-less/
partner-no-expected). `DemoWorldSeederTests` +1 (admins reachable with their granted roles; total 4).
## What's mocked / deferred (honest gaps)
- **Partner login-routing is deferred.** `/partner` is a separate authz scope **not derivable from `me.roles`**,
so `resolveRoleDestination` can't route a partner admin there on login. `/partner` **is** reachable by direct
navigation (the `services/partnerCenter` mock resolves a center for `useMyPartnerCenter`, so the shell renders
rather than access-denied), and the `RoleGuard` doesn't block it. The real login→`/partner` needs a `/me`
signal — filed as **REQ-038** (`administersPartnerCenterId`) + a paired demo-seed association. No partner
center was seeded this phase (would need real b15 partner↔user wiring that isn't runtime-verifiable here).
- **No new mock seam.** Auth stays 100% real (`USE_AUTH_MOCK = false` untouched) — deliberately, per §4: using
the auth mock to fake roles would hide the very hydration bug this phase fixes.
## Contracts / tracker
- **REQ-004 resolved** — "the client owns the active-role choice"; `MeResult` gains no `activeRole`. A dual
customer+nurse session is disambiguated by the client-carried intended role (A1/B1 switch), defaulting to the
family app; `RoleGuard` lets a dual-role user move between shells.
- **REQ-038 filed** — a `/me` partner-center-admin signal for partner login-routing (see above).
## Gate
- **client:** `npm run check` green; `npm run test:ci -- RoleGuard` green (8/8); `en.json`/`fa.json` in sync.
- **server:** `dotnet build Baya.sln` 0 errors (warnings all pre-existing NuGet advisories / a migration's
CS8632); `DemoWorldSeederTests` 4/4 pass over the SQLite harness. (A real SQL Server still couldn't boot in
this env — same constraint as phase 1 — so the seeder DoD is proved through the test harness.)
## Follow-ups for later phases
- REQ-038 (partner `/me` signal + seed) — likely a small backend refinement phase.
- Cross-actor **hard** route guarding is still server-side only; `RoleGuard` is deliberately chrome-level UX.
@@ -0,0 +1,95 @@
# Refinement Phase 6 — Money-path correctness completion — Report (2026-07-13)
**Track:** backend (money path) · **Depends on:** nothing hard (do before real BNPL/manual refunds — phase 8)
· **Gate:** `dotnet build` 0 new warnings · `dotnet test` 396 pass (383 prior + 13 new).
## The headline fix (6.1) — the unreachable BNPL/manual refund settlement is now wired
Before this phase, a card refund cleared its `refund_payable ↔ escrow_held` leg immediately, but a
BNPL-revert / manual-bank refund was left in `processing` with the clearing "deferred to reconciliation" — and
**no reconciliation path existed**: `Refund.MarkSucceededAsync` had zero callers, and nothing performed
`processing → succeeded`. Every BNPL/manual refund permanently overstated `escrow_held` and stranded
`refund_payable`; the ledger could never reconcile with the bank.
Now:
- **`ConfirmRefundSettlementCommand`** (`Features/Refunds/Commands/ConfirmRefundSettlement/`) transitions
`processing → succeeded`, stamps the settled instant, and posts `LedgerPosting.RefundPayableClearing` in the
**same commit**. It runs under the same `booking:{id}:refund` lock as `CreateRefundCommand` and **re-reads the
tracked refund inside the lock**, so a racing/replayed confirm sees committed truth and no-ops (never
double-clears). Idempotent: an already-`succeeded` refund is a no-op success.
- **`MarkRefundSettlementFailedCommand`** (`.../MarkRefundSettlementFailed/`) is the counterpart —
`processing → failed`, no ledger moves.
- **Admin surface:** `POST admin_refunds/{id}/confirm_settlement` + `.../mark_failed` on `AdminRefundsController`.
- **BNPL callback branch:** `HandleBnplCallback` gained a `RefundConfirmed` action. A provider event whose type
says the revert/refund cash-back **completed/confirmed/settled** (or "cashback") resolves the `processing`
refund created against that order's `payment_transaction` (`GetProcessingRefundIdForTransactionAsync`) and
dispatches `ConfirmRefundSettlementCommand`. `ResolveAction` checks this **before** the order-level `settle`
branch so `revert_settled` doesn't fall through.
- Domain: the misnamed `Refund.MarkSucceededAsync` (not async, uncalled) was renamed `MarkSucceededReconciled`.
**Proof:** `RefundSettlementTests` — a BNPL refund lands `processing` (3 reversal legs, no clearing) → confirm →
`succeeded`, clearing posts, the ledger reconciles (Σdebit = Σcredit, `refund_payable` fully drained,
`escrow_held` credited back); a replayed confirm stays at 5 legs (no double-clear); `mark_failed` leaves 3 legs
and blocks a later confirm (409).
## 6.4 — crash-window closed
`CreateRefundCommand` now persists the refund row (`approved`) **and commits** *before* calling the external
channel; then executes the channel against the persisted row and commits the outcome (succeeded/processing/failed
+ ledger). Same claim-first / execute-second shape the webhook handler uses — a crash between provider success
and our commit now leaves a reconcilable `approved` row instead of a silently-executed refund with no record.
## 6.2 — forward-dep FKs added (additive migration `RefinementPhase6MoneyFks`)
Real FKs (all nullable, `ON DELETE NO ACTION`) on the columns b11 shipped FK-less "until the target ships"
(the targets all shipped in b13/b15): `refunds.ticket_id → messaging.Tickets`,
`nurse_clawbacks.original_payout_id`/`recovered_in_payout_id → payouts.NursePayouts`,
`invoices.partner_center_id → partner.PartnerCenters` (**+ index**). The three now-false config-doc comments were
corrected. Referential integrity no longer rests on application discipline alone.
## 6.3 — IAuditable extended to the admin-decided money & trust entities
`Refund`, `NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification` are now `IAuditable`, so the
`AuditFieldInterceptor` writes an append-only `audit_logs` diff row on create + every admin decision (approve /
reject / process / settle, and the verification `is_verified` decision). `NursePayout.IbanSnapshot` (encrypted)
carries `[AuditRedacted]` so the diff records a marker, never the plaintext IBAN.
## 6.6 — orphaned config key retired
`refund_ticket_required` (seed row id 19) had no consumer left (b15 unconditionally auto-opens a refund ticket).
The seed row is deleted by the migration and the false description removed; the test-host stub was dropped.
## 6.5 — the previously-untested admin money paths now have tests (+13 tests)
- **`ClawbackWriteOffTests`** — write-off posts a balanced `DEBIT bad_debt / CREDIT nurse_clawback_receivable`
group and resolves the clawback; 404 unknown; 409 second write-off. (Was zero-coverage.)
- **`Racing_same_key_insert_is_caught_as_an_idempotent_no_op`** (added to `PaymentWebhookTests`) — a provider
that omits `external_event_id` skips the read-dedup, so the `(provider_code, external_event_id)` UNIQUE is the
sole backstop (the exact state a true concurrent insert reaches); a colliding insert hits `DbUpdateException`
and is treated as an idempotent duplicate no-op (no confirm, no ledger).
- **`MessagingInternalBoundaryTests`** (Foundation, handler-level) — the `is_internal` boundary: user thread view
strips internal notes; admin view returns them; non-staff admin-view request is forbidden; non-staff can't
post an internal note; a staff internal note never surfaces in the user view.
- **`RefundSettlementTests`** — the 6.1 settlement (both channels) + idempotency + mark_failed.
## Test-infra note (why a refund test host changed)
Adding the `refunds.ticket_id` FK means SQLite (which EF enables FK enforcement on) rejects a refund whose
`ticket_id` points at a non-existent ticket. `RefundsTestHost` now **seeds a real `Ticket`** and exposes
`Senders()` whose `OpenTicket` hook returns that real id (replacing the `TestSenders.WithTicketHooks()` fake id 1
in the refund tests). `PaymentsTestHost`/`PayoutsTestHost` were unaffected (they leave the new FK columns null).
## Contracts / docs updated (same change)
- `dev/contracts/domains/refunds-invoices.md` — the two new endpoints + `RefundSettlement` shape + changelog +
corrected the `refund_ticket_required` note.
- `dev/contracts/openapi/swagger.v1.json` — refreshed (additive: the two routes + `RefundSettlementResult`).
- `server/CLAUDE.md` — refunds/payments section (settlement wiring + crash-window + FKs + retired config), the
audit-interceptor note (expanded IAuditable set + `[AuditRedacted]`), and the feature map.
## Follow-ups (out of scope, for later phases)
- **A `mark_failed` after a successful provider revert** leaves the reversal ledger posted with no clearing (the
money is genuinely in limbo — an ops reconciliation case). Deliberate: the reversal is not un-posted.
- The BNPL/PSP mocks stay until phase 8 (external rails); the settlement path is now complete behind them.
- `audit_logs` growth (2.3 grows it faster) — retention/archival is phase 9 (§7.4).
@@ -0,0 +1,92 @@
# Refinement Phase 7 — Unattended operation: scheduler, locking & multi-instance readiness — Report (2026-07-13)
**Track:** backend (infra) · **Depends on:** phase 6 (its settlement reconciliation is a future job here) ·
**Gate:** `dotnet build` 0 new warnings · `dotnet test` **402 pass** (396 prior + 6 new scheduling tests).
## The headline (7.1) — the platform now runs itself
Before this phase only two hand-written `PeriodicTimer` hosted services existed; the credential-expiry scan, EVV
no-show sweep, and **weekly payout-batch generation** were admin-click-only while their seeded cadence keys sat
unread — so **nurses were paid only when an operator clicked**. Now a single in-process scheduler drives every job
on its own cadence.
**`RecurringJobSchedulerHostedService`** (`Persistence/Services/Scheduling/`) + the **`IRecurringJob`** seam:
- The scheduler owns one independent loop per job (their cadences don't couple; a crash in one never stops the
others), the per-tick DI scope, error isolation (a throwing tick logs and the next tick retries on schedule),
and a per-tick `IDistributedLock("scheduler:{name}")`. A job says only *how often* (usually a `platform_configs`
cadence key, re-read each tick so an admin change applies without a restart) and *what one idempotent run does*.
- **No new infrastructure.** SQL Server stays the only external dependency — a single-instance MVP needs neither
Hangfire/Quartz (durable/cross-restart scheduling is the only thing they add for idempotent periodic sweeps) nor
Redis. Adding a cron = implement `IRecurringJob` + one `AddSingleton<IRecurringJob, …>()`.
Jobs registered (`Services/Scheduling/Jobs/`), each dispatching the **same idempotent command the admin trigger
sends** (the admin endpoints are unchanged and remain overrides):
| Job (`Name`) | Cadence source | Re-homed / new |
| --- | --- | --- |
| `booking_request_expiry` | 1 min const | re-homed from `BookingRequestExpiryHostedService` (deleted) |
| `notification_retention` | 24 h const | re-homed from `NotificationRetentionHostedService` (deleted) |
| `verification_expiry_scan` | `verification_expiry_scan_cadence_hours` (24) | **new**`ScanExpiringCredentialsCommand` |
| `no_show_sweep` | `no_show_scan_cadence_hours` (1) | **new**`DetectNoShowSessionsCommand` |
| `weekly_payout_generation` | `nurse_payout_interval_days` (7) | **new**`GeneratePayoutBatchCommand` |
## Money movement stays human-approved (critical rule)
The payout job schedules **generation only** — it opens a `draft` batch over the trailing window; the irreversible
`process` (money-moving) step remains an explicit admin action until trust is earned. To let an unattended run
record a batch with no human initiator, `NursePayoutBatch.InitiatedByAdminId` is now **nullable** (`null` =
system-initiated) — migration `RefinementPhase7SystemPayoutBatch` (alters the column + FK to nullable; the FK, the
`PayoutBatchDto` projection, and `swagger.v1.json` were updated to match). The command's `SystemInitiated` flag is
**scheduler-only**: `AdminPayoutsController.Generate` neutralizes any request-supplied value (`command with {
SystemInitiated = false }`), so an API caller can never bypass the authenticated-admin requirement. A quiet week
(no eligible bookings) is a benign no-op; a re-run over an overlapping window is safe — the
`nurse_payout_booking_links.booking_id` UNIQUE prevents re-selecting an already-paid booking.
## 7.2 — Redis is the scale-out gate, NOT added
Per the phase's "don't add Redis because", the in-process `ICacheService`/`IDistributedLock` stay. They are the
documented **>1-instance scale-out gate**: the moment a second API instance runs, swap the lock seam to Redis and
the scheduler's per-tick lock serializes ticks across nodes (idempotency + the DB uniques cover a double-run
either way). Nothing speaks Redis today; no package added. (Registry rows `ICacheService`/`IDistributedLock`
updated with the framing.)
## 7.3 — Migrations split from boot
`dotnet run -- migrate` is a deploy-time one-shot: it applies EF migrations + the idempotent seeders, then exits —
so concurrent multi-instance start-ups never race on DDL and the runtime login needs no permanent DDL rights.
**Development** boot still migrates + seeds (incl. the Development-only sandbox gateway + demo world) for
convenience; **deployed** boot only *checks* the schema is current (`EnsureSchemaUpToDateAsync` — fail-fast on a
pending migration) and seeds roles/break-glass admin. Env-gated in `Program.cs`.
## What is now testable and exactly how
- **6 new Foundation tests** (`Tests/Baya.Test.Foundation/Scheduling/`): each cadence job reads the right config
key and dispatches the right command (incl. the payout job asserting `SystemInitiated=true` + the trailing
window); the scheduler runs a job at startup under `scheduler:{name}`, keeps siblings alive when one throws, and
stays **dormant under the `Testing` environment**.
- **Live cadence check:** set a short `no_show_scan_cadence_hours` / `verification_expiry_scan_cadence_hours` (or
a short interval) in `platform_configs`, run the API (Development), and watch the job fire on schedule in the
logs, producing the same result as the admin manual trigger.
- **Migration path:** `dotnet run -- migrate` applies + seeds and exits; a deployed-env boot with a pending
migration fails fast with the list of pending migrations.
## What is mocked / deferred (follow-ups)
- **Moadian reconciliation + refund-settlement poll** are **Phase 8's** jobs — they have no command/cadence key
today and Phase 8 explicitly owns registering them. They slot in as new `IRecurringJob`s with one `AddSingleton`
— no scheduler change. Documented in the mocks-registry row.
- **Redis** — the scale-out gate above (only when >1 instance).
## Contracts produced/consumed
- `PayoutBatchDto.initiatedByAdminId` is now nullable (`null` = system/scheduled batch). Updated
`dev/contracts/domains/payouts.md` + `dev/contracts/openapi/swagger.v1.json`. No other wire change.
## Files
New: `Services/Scheduling/{IRecurringJob, RecurringJobSchedulerHostedService}.cs` +
`Services/Scheduling/Jobs/{BookingRequestExpiry, NotificationRetention, CredentialExpiryScan, NoShowSweep,
WeeklyPayoutGeneration}Job.cs`; migration `RefinementPhase7SystemPayoutBatch`; 2 test files. Deleted: the two old
hosted services. Changed: `AddPersistenceServices` (registration) + `EnsureSchemaUpToDateAsync`; `Program.cs`
(migrate one-shot + env-gated boot); `NursePayoutBatch`/`NursePayoutBatchConfig`/`PayoutBatchDto` (nullable
initiator); `GeneratePayoutBatchCommand`(+Handler)/`AdminPayoutsController` (system-initiated path).
@@ -0,0 +1,136 @@
# Refinement Phase 8 — External rails go real (SMS → trust/identity → money) — Report (2026-07-13)
**Track:** backend (integrations) · **Depends on:** phase 6 (money-correctness), phase 7 (scheduler) ·
**Gate:** `dotnet build` **0 new warnings** · `dotnet test` **402 pass** (unchanged — the mocks stay the default,
so no existing test changed behaviour).
## The shape of the phase — an adapter behind every seam, config-selected
Every vendor dependency was a deterministic in-process mock. This phase ships a **real HTTP adapter behind each
seam**, selected by a per-rail **`Seams:*:Provider`** selector. The mock is the **default** (an unconfigured or
typo'd provider falls closed to it), so a **partial rollout is the normal case** — real SMS + real geocoder while
payments stay mocked in a pre-launch environment is three config keys. Swapping is a **registration change in
`AddCrossCuttingSeams`; no handler changed** (the DoD's "handler is unchanged" holds for every rail).
**Zero new NuGet packages.** The CrossCutting project already framework-references `Microsoft.AspNetCore.App`, so
every adapter is `HttpClient` (typed via `IHttpClientFactory`) + `System.Text.Json` + BCL crypto — no vendor SDK,
no restore risk. Credentials come from `Seams:*` (user-secrets/env), never committed. New adapters live in
`Baya.Infrastructure.CrossCutting/Seams/Real/`.
## 3.1 Trust & identity rails
- **5.1 SMS — `KavenegarSmsSender` (launch-critical).** OTP via Kavenegar's `verify/lookup` template API;
free-form via `sms/send`. A non-`200` `return.status` is surfaced as a delivery failure (the OTP command reports
it, never a silent "success"). **The OTP is never logged:** `Program.cs` now runs the Development OTP-in-logs
capture bridge **only while the mock SMS sender is selected** (`Seams:Sms:Provider` empty/`mock`) — the moment a
real gateway is configured the code leaves the process only over the SMS wire.
- **5.2 Shahkar + e-KYC — `FinnotechShahkarVerifier`, `FinnotechIdentityKycProvider`.** A shared `FinnotechClient`
(base URL, bearer auth, per-call `trackId`) fronts both; creds in `Seams:Finnotech`. Shahkar can't distinguish a
shared-SIM from a plain mismatch (the registry only asserts bound/not-bound), so a real no-match is reported as a
plain mismatch — the explicit shared-SIM branch stays reachable through the mock. The raw vendor response is
persisted as `external_response_json`.
- **5.3 استعلام شبا — `FinnotechBankAccountOwnershipVerifier`** (the b13 first-payout money-mule gate). Matches the
IBAN's registered national code against the nurse's; **fails closed** (no national code returned ⇒ no match).
- **5.4 Geocoder — `NeshanGeocoder`.** `x`=lng/`y`=lat parsed to `decimal` (exact EVV haversine downstream). A
Neshan outage **degrades to the null-pin state** — it never blocks saving an address.
- **5.5 Object storage — `S3ObjectStorage`.** MinIO / S3 / ArvanCloud with **manual AWS SigV4** (HMAC-SHA256, all
BCL — no AWS SDK). Server-side put/get/delete are SigV4-header-authed (`UNSIGNED-PAYLOAD` so a blob stream is
never buffered to hash it); `GetUrl` returns a **presigned GET** = the real form of the b6 signed-URL contract.
Path-style default (MinIO/ArvanCloud); virtual-host supported.
- **5.6 MoH/INO/eNamad — kept manual (intended MVP).** `ICredentialVerifier` / `ILicenseVerificationService` stay
mock — there is **no public B2B API**, so the manual admin review *is* the mechanism, not debt. The registry rows
are marked "manual = intended MVP".
## 3.2 Money rails
- **6.1 PSP + webhook signature + تسهیم — `ZarinPalPaymentProvider` + `HmacWebhookVerifier` +
`ProviderSettlementSplitProvider`** (swap together on `Payments:Provider`). ZarinPal v4 request/verify/refund;
the **mandatory server-side verify** re-checks amount + reference (never trusts the callback). The webhook
verifier does **per-provider HMAC over the raw body** (`Seams:Payments:WebhookSigningSecrets[{provider}]`,
constant-time compare, tolerates a `sha256=` prefix); **no secret ⇒ the handler's server-side verify re-check is
the guard** (the contract's signatureless fallback). تسهیم registers a split-by-ratio to registered IBANs.
- **6.2 BNPL — `SnappPayBnplProvider` + `DigipayBnplProvider` + `ConfiguredBnplProviderResolver`**
(`Bnpl:Provider=real`). One adapter per `provider_code`; the SnappPay verb set is the canonical superset the seam
was designed around (OAuth-token cached → eligible → token → verify → settle → status → cancel/revert/update).
**Currency crosses the wire only at the adapter boundary** via a shared `HttpBnplProviderBase.ToWire/FromWire`
over `ICurrencyNormalizer` (`Seams:Bnpl:WireCurrency`, Rial pass-through by default). The **merchant commission
is read from the settle response, never hardcoded.** **REQ-022 / balinyaar decision:** `balinyaar` is the
in-house plan — no external API — so it **resolves to the deterministic net-of-fee model** (the distinction is
the financing entity, not the money mechanics); `tara`/`torobpay` resolve to `null` (unbuilt) so the handler
rejects them cleanly. The b11 `bnpl_revert` refund path injects `IBnplProvider` directly (not per-code) →
SnappPay is the default revert provider (per-code revert resolution is a documented follow-up).
- **6.3 PAYA/SATNA payout — `JibitBankTransferProvider` + the async reconciliation callback.** The real rail is
**async**: an accepted transfer comes back `submitted` (a track id, money not yet confirmed). The existing
`ExecutePayoutBatch` handler already `MarkSubmitted`s first and posts **no ledger** until paid, so it needed no
change. New: **`ReconcilePayoutBatchCommand`** + **`WebhooksPayoutsController` (`POST webhooks/payouts/{provider}`,
anonymous, `webhook` rate policy)** — HMAC-verified (an invalid signature mutates nothing), parses the
per-transfer outcomes, matches `submitted` payouts by `transfer_reference`, and flips `paid` (posts the payout
ledger + nets clawbacks via `PayoutSettlement`) / `failed`. Idempotent by the forward-only status machine + the
ledger-exists guard — a replayed callback is a no-op.
- **6.4 `IPaymentCaptureSimulator` out of production.** Prod registers the fail-closed
`DisabledPaymentCaptureSimulator` (never fabricates a capture); Dev/Testing re-register the succeeding
`MockPaymentCaptureSimulator` via `AddDevelopmentPaymentCapture` (last-wins). The `bookings/convert` path is a
Dev/Testing affordance — production converts via the b10 webhook confirm calling `ConvertRequestToBooking`
directly. (Testing must keep the mock: Mediator constructs the handler before validation runs, so the API tests
that expect `400`/`401` on `bookings/convert` would otherwise `500`.)
- **6.5 Moadian — `MoadianClient` + `MoadianReconciliationJob`.** Submit posts the invoice and maps the outcome
(22-digit ref ⇒ `registered`; accepted-not-yet ⇒ `submitted`; reject ⇒ `failed`; a transient error stays
`submitted` so the next tick retries — never permanently failed on a transient fault). The **reconciliation poll**
is a new `IRecurringJob` (fixed **6 h** cadence — no seeded config key, so **no migration**) running
`ReconcileMoadianInvoicesCommand`, which re-submits every `pending`/`submitted` invoice until it registers
(Moadian dedups on the invoice number, so a re-submit doubles as the status poll — the seam keeps its one verb).
New repo read: `IInvoiceRepository.GetUnregisteredMoadianInvoicesAsync`.
- **6.6 Partner-center settlement rail — decision (product).** **No new center-payout money path is built this
phase.** The MoR resolver already routes the invoice issuer; the settlement decision is: a **merchant-of-record**
center is settled at capture time by **adding its registered `settlement_iban` as a تسهیم split leg** (the
acquirer credits it directly — reusing 6.1, no new batch), and a **non-MoR** center has **no separate money
path** (the nurse is paid via the normal b13 payout; the center's cut is an off-platform arrangement). A
dedicated center-settlement ledger account + payout reusing the b13 machinery is **deferred** until center volume
justifies it. Documented; no code beyond the existing تسهیم leg.
## Config-selection mechanics (how the swap works)
`AddCrossCuttingSeams` reads the bound `SeamOptions` once and, per rail, registers the real adapter **or** the mock.
Real HTTP adapters get a **named `IHttpClientFactory` client**; because the seams are singletons injected into
scoped handlers (and the BNPL resolver holds its adapters), the adapters are singletons resolving one client — the
standard minor SigV4/handler-rotation caveat against these stable vendor hosts is acceptable for the MVP. A
`SeamProviders` token class keeps the selectors typo-safe. `SeamOptions` gained a `Provider` selector on every rail
+ credential blocks (`Sms`, `Finnotech`, `ObjectStorage` S3, `Payments`, `Bnpl.Providers`, `BankTransfer`,
`Moadian`).
## What is testable and how (no live vendors here)
The adapters can't be exercised against live Iranian vendors in this environment; that is deploy-time
credentialing/certification (Shaparak lead time for the PSP especially). What **is** verified now: build + the full
402-test suite stay green with the mocks as default (proving the config-selection default preserves every existing
behaviour). To exercise a real rail: provision the vendor account + credential, set `Seams:{rail}:Provider` +
creds, and run the flow (request OTP → real SMS → login; sandbox card → verify + signed webhook; payout batch →
`submitted``POST webhooks/payouts/jibit``paid`; invoice → `MoadianReconciliationJob``registered`).
## Follow-ups (documented, not forgotten)
- **Per-code BNPL revert** — the b11 refund path injects `IBnplProvider` directly; SnappPay is the default. Route
the revert through `IBnplProviderResolver` by the transaction's `provider_code`.
- **SMS.ir / Ghasedak** adapters — only Kavenegar is implemented; selecting the others throws a clear
`NotSupportedException` at registration (fail fast, never a silent mock).
- **Finnotech token exchange** — the adapters use a pre-issued `AccessToken`; the client-credential refresh is a
deploy-time concern. Same for the Moadian signing certificate.
- **Refund-settlement poll** (BNPL `processing → succeeded`) — the phase-7 note paired it with Moadian; the
settlement-confirm command exists (phase 6 `ConfirmRefundSettlement`), the poll job over "processing refunds" is
the remaining wiring (needs a repo read of pending settlements).
- **Center-settlement payout** — deferred per 6.6.
- **Redis / Elasticsearch** — unchanged scale-out gates, no adapter (correctly single-instance today).
## Files
New (`CrossCutting/Seams/Real/`): `KavenegarSmsSender`, `FinnotechClient`, `FinnotechShahkarVerifier`,
`FinnotechIdentityKycProvider`, `FinnotechBankAccountOwnershipVerifier`, `NeshanGeocoder`, `S3ObjectStorage`,
`ZarinPalPaymentProvider`, `HmacWebhookVerifier`, `ProviderSettlementSplitProvider`, `HttpBnplProviderBase`,
`SnappPayBnplProvider`, `DigipayBnplProvider`, `ConfiguredBnplProviderResolver`, `JibitBankTransferProvider`,
`MoadianClient`. Plus `CrossCutting/Seams/DisabledPaymentCaptureSimulator`;
`Features/Payouts/Commands/ReconcilePayoutBatch/*`; `Features/Invoices/Commands/ReconcileMoadianInvoices/*`;
`Persistence/Services/Scheduling/Jobs/MoadianReconciliationJob`; `Controllers/V1/WebhooksPayoutsController`.
Changed: `SeamOptions` (+ provider selectors/creds), `AddCrossCuttingSeams` (config-selected rewrite),
`DevelopmentSeamExtensions` (+ `AddDevelopmentPaymentCapture`), `Program.cs` (OTP-capture gated on mock SMS +
Dev/Testing payment-capture), `AddPersistenceServices` (register `MoadianReconciliationJob`),
`IInvoiceRepository`/`InvoiceRepository` (+ `GetUnregisteredMoadianInvoicesAsync`).
@@ -0,0 +1,110 @@
# Refinement Phase 9 — Observability, ops hardening, docs honesty & scale-later — Report (2026-07-13)
**Track:** backend (observability/docs) + explicit deferrals · **Depends on:** nothing hard ·
**Gate:** `dotnet build` **0 new warnings** · `dotnet test` **407 pass** (402 prior + 5 new: audit-retention ×2,
ticket-body encryption ×2, liveness ×1; the existing messaging suite now also exercises the encrypted body).
This phase makes the running platform **diagnosable and honest**, and records the explicitly-deferred scale work so
nobody mistakes it for missing MVP scope. No feature behaviour changed; the money/trust rules are untouched.
## Observability & ops hardening (finish before launch)
### 9.1 — Tracing added; one metrics stack; `requestId` = trace id
- **One metrics stack.** Removed the duplicate **prometheus-net** stack (`UseMetricServer`/`UseHttpMetrics`/
`ForwardToPrometheus` + the three `prometheus-net*` packages). **OpenTelemetry is now the only metrics source**,
scraped at `/metrics` via `UseOpenTelemetryPrometheusScrapingEndpoint()`. HTTP request metrics now come from the
OTel ASP.NET Core instrumentation; the `mediator_meter` request-duration histogram (`MetricsBehaviour`) is now
actually exported (added to the meter list — the old prometheus-net stack never captured it).
- **Tracing.** Added `WithTracing` (ASP.NET Core + **EF Core** instrumentation) sharing one resource
(`service.name = Baya.Web.Api`), so a cross-service money flow (webhook → confirm → ledger) is one trace.
- **OTLP export is opt-in.** Traces + metrics export to an OTLP collector **only when `OpenTelemetry:Otlp:Endpoint`
is set** — an MVP with Prometheus alone runs unchanged and no exporter spams an absent collector.
- **`requestId` already carries the trace id** (`ApiResult.RequestId = Activity.Current.TraceId`) with
`Activity.DefaultIdFormat = W3C` — a support ticket maps 1:1 to a trace with no extra wiring.
- **New packages** (all cached, no restore risk): `OpenTelemetry.Exporter.OpenTelemetryProtocol` (1.15.3),
`OpenTelemetry.Instrumentation.EntityFrameworkCore` (1.15.1-beta.1).
### 9.2 — Health checks broadened; liveness/readiness split
- `/healthz/live` — process only (a dependency-free `self` check), so a dependency outage never restart-loops.
- `/healthz/ready` — the app DB, the log DB (**deployed only** — its conn string is a placeholder in Dev/Testing),
and a real **object-storage write round-trip** (`ObjectStorageWriteHealthCheck`: put → get → delete a probe blob).
- `/HealthCheck` — the aggregate, retained for backward compatibility. The dead `currentUrl` line is gone.
- **Redis** is noted as the next readiness check to add *when* it becomes a real dependency (>1 instance); not now.
- `Baya.Infrastructure.Monitoring` now references `Baya.Application` (for the `IObjectStorage` probe) — a legitimate
Infrastructure→Application edge, noted in the server Project map.
### 9.3 — Prod log level raised to Information+; no PII; dead ES sink removed
- Deployed envs now log **Information+** (was Warning+, which dropped every Information-level audit trail), with
framework categories held at Warning so the floor raise doesn't flood the sink.
- **No secrets/PII in logs.** `LoggingSmsSender` **no longer logs the OTP code** (a login secret) in any
environment — a developer gets it from the Development-only `GET /api/v1/dev/last_otp`. Clinical text / IBANs /
phone numbers are already encrypted or masked before any handler logs. The `columnOptions` (previously built but
never applied) are now wired to the SQL sink.
- **Dead Elasticsearch sink resolved by deletion:** removed the commented ES sink block *and* the unused
`Serilog.Sinks.Elasticsearch` package (this also revealed `Serilog.Sinks.File` was only a transitive of the ES
package — added it explicitly). Log-table retention is documented as an ops/DBA responsibility (or ship logs to
the OTLP collector).
### 9.4 — Audit-log retention as a scheduled job
- New `AuditLogRetentionJob` (`IRecurringJob`, registered like the others) runs a **two-tier** retention sweep over
the append-only `ops.AuditLogs`: **financial/verification** entity types (`Refund`, `NurseClawback`,
`NursePayout`, `NursePayoutBatch`, `NurseVerification`, `PlatformConfig`, `PartnerCenter`) keep a long legal window
(`audit_retention_financial_days`, default **2555** ≈ 7 years); everyday rows a shorter one
(`audit_retention_general_days`, default **730** ≈ 2 years). Cadence key `audit_retention_scan_cadence_hours` (24).
- `IAuditLogger.PurgeExpiredAsync(...)` does the delete: oldest-first (Id is monotonic with `OccurredAt`), capped at
20 000 rows/run so a backlog drains across runs; age compared in memory (SQLite can't translate a `DateTimeOffset`
predicate), delete is a single id-keyed `ExecuteDeleteAsync`. Idempotent.
- Migration `RefinementPhase9TicketBodyEncryptionAndAuditRetention` seeds the three config keys (ids 2426).
### 9.5 — `TicketMessage.Body` encrypted; gRPC reflection gated to Development
- **Ticket bodies are the refund/dispute paper trail** (users type phone numbers, addresses, clinical detail) — now
**encrypted at rest** through the existing `IFieldEncryptor` converter (wired in `ApplicationDbContext`, like every
other PII column). The stored column is widened to `nvarchar(max)` (ciphertext is longer than plaintext); the 4000-
char plaintext limit stays a boundary-validation rule (Open/PostMessage validators). Body is never a SQL search/
filter predicate (the admin thread read decrypts per row), so losing SQL-searchability is an accepted trade-off.
- **gRPC decision — keep the plugin, gate reflection to Development.** The plugin exposes only the User service and
the client is HTTP/JSON, but removing it is more invasive than the risk warrants. gRPC **reflection** (which
advertises the full schema) is now registered/mapped **only in Development**. The HTTP/2-posture concern is already
mitigated (refinement-phase-5 set Kestrel `Http1AndHttp2`), so the plugin shares the mixed-protocol listener (ALPN
negotiates h2 for gRPC clients) — no dedicated port needed.
### 9.6 — Docs made honest
- **Mocks-registry:** pruned the 7 **stale duplicate 🔴 rows** (`IDistributedLock`/`INurseSearch`/`IPaymentProvider`/
`ISettlementSplitProvider`/`IWebhookVerifier`/`IMoadianClient`/`ILicenseVerificationService`) the detailed rows
already correct; the recurring-jobs row is the real in-process scheduler; the `IPaymentCaptureSimulator` row now
reflects its 6.4 prod removal (fail-closed in prod; Dev/Testing mock is a test affordance). Added a phase-9 banner.
- **REQ tracker:** already honest — refinement-phase-3 marked every delivered/deferred/resolved REQ; this phase ships
no new contract, so no REQ status changed. (The pre-phase-3 "all 15 open" state the audit flagged is long fixed.)
- **Architecture maps:** `server/CLAUDE.md` updated (observability wiring, the audit-retention cron, ticket-body
encryption, the gRPC decision, the new Monitoring→Application edge); `runtime-services.md` updated (OTel
consolidation, tracing, health split).
## Scale & later — explicitly NOT MVP (recorded, not built). Each has a written pull-trigger.
| # | Deferred item | Where it lives today (the real MVP) | **Pull it when…** |
| --- | --- | --- | --- |
| 9.7 | **Elasticsearch read backend + outbox feeder**`ElasticNurseSearch` + the CDC/outbox stream | `SqlNurseSearch` is real & correct; `Search:Backend` fails fast on any non-`sql` value | **SQL search shows strain** (latency/throughput on `nurse_search_index`). Build `ElasticNurseSearch` (same filters/sort/paging) + the outbox feeder off `ISearchIndexMaintainer`; keep SQL as the reconciliation source (`RebuildAsync`). |
| 9.8 | **Analytics pipeline** — warehouse/stream sink | `IAnalyticsSink` writes `ops.SystemEvents` fire-and-forget (real, queryable) | **Product needs cross-event analytics** beyond SQL queries. Pipe `SystemEvents` to a warehouse/stream (e.g. Kafka→ClickHouse), keeping fire-and-forget semantics. |
| 9.9 | **Holiday-calendar feed** — automated lunar-Hijri drift feed | `IHolidayCalendar` reads the seeded, manually-maintained `ops.IranianHolidays` table (real) | **The manual yearly refresh becomes a burden.** A **yearly ops-checklist item to top up the table is an acceptable MVP alternative** to a feed — the read interface stays. |
| 9.10 | **Push/SMS notification channels** — SMS/FCM fan-out | `InAppNotificationDispatcher` writes real in-app `ops.Notifications`; non-InApp channels are dropped by design | **The notification UX demands** out-of-app reach. Fan out to SMS (via the phase-8 `ISmsSender`) and FCM push behind the same `INotificationDispatcher`. |
| 9.11 | **Deferred product tables**`organizations`, `organization_nurses`, `fraud_flags`, `recurring_booking_schedules`, `bnpl_settlement_entries`, availability slots, customer national-ID KYC, geo bulk import | All **verified absent**; each is a **pure additive migration** when product pulls it | **Product pulls the feature.** No structural blocker — additive migration + feature slice; nothing in the current schema needs to change first. |
**These are decisions, not gaps.** SQL search, in-app notifications, and the manual holiday table are the real,
correct MVP; Elasticsearch/analytics/push each has a concrete trigger above and stays out until then.
## How it was verified
- **Build:** `dotnet build Baya.sln` — 0 new warnings (the pre-existing NU1510 + NU1903 transitive-dependency audit
warnings are unrelated to this phase).
- **Tests:** `dotnet test Baya.sln` — all green. New: `AuditLogRetentionTests` (two-tier purge + idempotency),
`TicketMessageEncryptionTests` (encrypted at rest + round-trips on read), `HealthCheckApiTests` (liveness healthy
without dependencies).
- **Trace/requestId:** a request's `ApiResult.requestId` is `Activity.Current.TraceId` (W3C) — the same id a
configured OTLP collector records.
- **No PII in logs:** the OTP code is no longer logged in any environment; clinical text/IBANs are encrypted/masked.
## Follow-ups for later phases
- Wire an OTLP collector (Grafana Tempo / Jaeger / OTEL Collector) in the deploy topology and set
`OpenTelemetry:Otlp:Endpoint` to turn tracing export on.
- When the first `redis` dependency lands (>1 instance), add its readiness check to `/healthz/ready`.
- The NU1903 transitive-dependency vulnerability warnings (`Microsoft.OpenApi`, `SQLitePCLRaw`) are a separate
dependency-bump task, out of this phase's scope.
@@ -0,0 +1,197 @@
# UI Phase 0 — Design language & theme foundation — Report (2026-07-17)
## What was built
### `theme.components` pass (the highest-leverage change)
`client/src/theme/theme.ts` now carries a `components` block covering `MuiCssBaseline`
(global `:focus-visible` → 2px `var(--bal-focus-ring)`), `MuiButton` (`disableElevation`,
`--bal-radius-sm`, comfortable padding), `MuiPaper` (hairline `var(--bal-divider)` border,
`backgroundImage: none`), `MuiAppBar` (cream/paper surface + hairline bottom border,
`color="transparent"` default — no more solid teal slab), `MuiOutlinedInput` (house radius +
calmer resting border + primary hover), `MuiChip` (soft `--bal-primary-soft` fill for
default-color filled chips only — `ownerState`-scoped so `color="success"`/`"error"` chips
are untouched), `MuiToggleButton`/`MuiListItemButton` (`.Mui-selected` → primary-soft fill +
primary text), `MuiDialog` (`--bal-radius-lg`, tighter mobile margins), `MuiTabs`/`MuiTab`
(3px indicator, 48px min-height), `MuiAlert` (house radius — severities now come from the
palette, see below), `MuiStepIcon` (brand active/completed), `MuiSkeleton` (warm
`primary-soft` tint), `MuiTooltip` (ink/cream inversion). Every value is `var(--bal-*)` or a
logical CSS property — zero raw hex, zero physical direction props.
A `theme.shadows` array (`TEAL_SHADOWS`, 25 entries tiered onto `--bal-shadow-1/2/3`)
replaces MUI's default grey stack **globally** — every component that reads
`theme.shadows[n]` (Dialog, Menu, Popover, AppBar elevation, Autocomplete…) gets teal-tinted
shadows for free, not just the ones this file overrides directly. Both themes are wrapped in
`responsiveFontSizes()` for per-breakpoint heading scaling.
### Semantic palette
`LIGHT_PALETTE`/`DARK_PALETTE` (`colors.ts`) now define `success`/`error`/`warning`/`info`
(main + contrastText), sourced from the same values as the `--bal-*` feedback tokens — an
inline `<Alert severity="success">` and a success toast are now the same color family in
both schemes. `AppAlert`'s defaults flipped from the footgun (`severity="error"
variant="filled"`) to a calm baseline (`severity="info" variant="standard"`) — every
existing call-site already passed an explicit severity+variant, so this was a safe default
change with zero call-site migration needed (verified by grep before changing).
### Persian type scale
`typography.ts` rewritten: `letterSpacing: 0` on every `TYPOGRAPHY_RTL` variant, a shared
size/line-height scale (`SIZE_SCALE` — body line-height 1.7, headings 1.41.5, no more
6rem default h1), and the weight decision: **700 for headings/buttons, 500 for in-text
emphasis, 400 body — never 600** (neither Mikhak nor Space Grotesk loads a 600 face; a
requested 600 silently rendered full Bold). Swept **64** `fontWeight: 600` sx call-sites
across 42 files to 500/700 by this rule (9→700, 55→500; judgment calls documented in the
commit — e.g. selection-card titles → 700, chip/row labels → 500). LTR keeps its original
split (Space Grotesk headings, system-font body); RTL keeps Mikhak everywhere (full glyph
coverage). Space Grotesk is now genuinely wired via `next/font/google` in
`app/[locale]/layout.tsx` (self-hosted at build time, `preload: false`, attached only on
`en` — mirrors the Mikhak/`fa` discipline exactly); the dead "not currently wired" comment
is gone.
### Token extension (`tokens.css` + `colors.ts` mirror where palette-level)
Added, both scheme blocks: `--bal-radius-sm/md/lg` (4/10/16 — controls/cards/dialogs),
`--bal-motion-fast/base/slow` + `--bal-easing-standard` (for phase 12), `--bal-shadow-1/2/3`
(teal-tinted light, black-teal dark), `--bal-focus-ring`, `--bal-rating`/`--bal-rating-empty`
(retiring the muddy `--bal-warning` star fill — phase 1 wires this into `RatingInput`),
`--bal-trust`/`--bal-trust-soft` (a distinct blue identity for verified marks, separate from
primary/success — phases 1/4/8 consume this), `--bal-money-emphasis` (AA-contrast-safe;
`--bal-secondary` fails AA at small sizes on white, ~2:1). `tokens.css`'s header now points
at the frontend-designer skill instead of the deleted `product/balinyaar.html`.
### Brand mark — the pencil is gone
Two new SVGs under `AppIcon/icons/`: **`LogoMark.tsx`** (monochrome `currentColor` "b" glyph
— stem + a true evenodd-ring bowl, no background square — registered as `ICONS.logo`, used
anywhere via `<AppIcon icon="logo">`, recolors for free in dark mode) and **`LogoLockup.tsx`**
(the full-color mark: deep-teal rounded-square ground, cream glyph, terracotta dot, all
`var(--bal-*)`-driven) used directly by `BrandMark.tsx` next to the (still translated,
never-baked-into-SVG) wordmark. `PencilIcon.tsx` (the Twemoji starter pencil, the app's last
hard-coded-hex SVG) is deleted. Favicon + webmanifest regenerated from the same construction
via a one-off `sharp` script (fixed brand hex — the one place a literal hex is correct, since
it's a static binary asset, not app code): `src/app/favicon.ico` (real multi-size PNG-in-ICO,
16/32/48) and `public/img/favicon/{16,32,48,180,192,512}.png` all now render the actual mark;
`site.webmanifest`'s `theme_color`/`background_color` are the real brand teal/cream (were
starter `#000000`/`#ffffff`), and every referenced icon file now genuinely exists.
### Icon system
- **Size bug fixed**: `AppIcon.tsx` drives size via `style.fontSize` (which MUI SvgIcon's
`1em`-based CSS actually respects) instead of `width`/`height` *attributes* (which that
same CSS silently beat) — all ~40 existing `size={14..56}` call-sites now render at the
requested size. The invalid `size` DOM attribute is no longer spread onto the SVG; the
unknown-icon `console.warn` is dev-only.
- **One visual family**: the ~90-entry registry (`AppIcon/config.ts`) is now 100% MUI
`*Rounded` — every filled/outlined starter icon was swapped. The 8 dead starter entries
(`daynight`/`night`/`day`/`visibilityon`/`visibilityoff`/`signup`/`login`/`settings`) are
deleted (re-verified zero usages before deleting).
- **New vocabulary**: `back`/`chevron_start`, `share`, `copy`, `phone`, `navigate`, `sort`,
`attachment`, `star_half`.
- **Directional mirroring**: `back`/`chevron_start` are registered in a new
`DIRECTIONAL_ICONS` set; `AppIcon` stamps `data-icon-directional` on those, and one CSS
rule (`app/globals.css`) does the flip (`[dir='rtl'] [data-icon-directional] { transform:
scaleX(-1) }`) — adding a future directional icon is a one-line registry addition, never a
per-component flip.
### AppButton de-startering
Removed `DEFAULT_SX_VALUES = { margin: 1 }` and the dead `underline`/`label`/`text` prop
cruft (`text`/`label` duplicate props stay — 238 call-sites use them — only the invalid
`underline` spread onto non-link buttons and the false "Box around to specify margins" JSDoc
were removed). Swept the now-redundant `sx={{ m: 0 }}` neutralization at **192** call sites
across ~68 files (6 parallel batches, verified against the original 213-occurrence/82-file
audit count — the ~21 difference is re-count timing, not missed sites); **3** genuine
non-AppButton `m: 0` uses (on `FormControlLabel`, an unrelated real layout decision) were
correctly left alone. Outer spacing is now the parent's job (Stack/Box gaps), as intended.
### Starter residue purge
Deleted `theme/light.ts` + `theme/dark.ts` (+ their `theme/index.ts` exports:
`APP_THEME`/`LIGHT_THEME`/`DARK_THEME`/`LIGHT_THEME as default` — nothing imported them) and
the deprecated `TYPOGRAPHY` alias. Deleted the unused `AppImage` component + its test +
barrel exports (zero product usages, verified). Rewrote `globals.css` as an intentional
minimal base: kept the `box-sizing` reset, dropped `max-height: 100vh` and the
`max-width: 100vw; overflow-x: hidden` mask, added `::selection` in brand colors + the
directional-icon CSS rule, and a commented decision on the `a { color: inherit }` reset.
Fixed the false `CONTENT_MIN_WIDTH` comment and the starter commented-alternatives style in
`components/config.ts`.
### No-flash color-scheme boot — CSS only, no boot script
The original phase spec called for an inline pre-paint `<script>` (a `ColorSchemeScript`,
matching what `client/CLAUDE.md`'s Theme System section had — inaccurately — already
documented). **Built, then deliberately reverted per explicit user direction mid-session**:
a hand-rolled script (cookie parsing + `Storage.prototype` patching) didn't fit a codebase
where every other theme decision is CSS-variable-driven. The shipped mechanism is pure CSS:
- Returning visitor (cookie present): unchanged — server stamps `data-mui-color-scheme`,
the explicit `tokens.css` blocks match immediately.
- First-ever visitor (no cookie): `getThemeMode()` now returns `colorScheme: undefined`, so
the server renders `<html>` **without** the attribute at all. `tokens.css` gained a
`@media (prefers-color-scheme: dark)` block scoped to `:root:not([data-mui-color-scheme])`
that paints the OS-preferred scheme immediately, zero JS. Once React hydrates,
`defaultMode="system"` resolves the same media query and MUI stamps the attribute itself —
the CSS values already match, so nothing visibly flips.
- **Known trade-off** (documented in `client/CLAUDE.md`): this covers every `--bal-*` token
(the dominant visual surface — page/paper background, text, dividers, all brand colors).
MUI's own `--mui-palette-*` variables don't get the same free fallback (MUI's
`colorSchemeSelector` supports attribute-based *or* `'media'`-based generation, not both),
so a bare `color="primary"` fill (e.g. a contained Button) can very briefly show the light
value on a cookie-less dark-OS first visit before hydration — self-corrects same frame,
never animates (`disableTransitionOnChange`). Acceptable trade-off given the alternative
was a hand-rolled script the codebase's own conventions argue against.
`viewport.themeColor` is now the media-query array form (light → `BRAND.teal`, dark →
`BRAND.tealDeep`) so browser chrome matches the page in both schemes.
## What is now testable (and exactly how)
1. `npm run dev`, open `/fa/login` in a private window with OS dark on and no cookies: dark
paint from first frame (no light flash on the `--bal-*` surface), the new Balinyaar mark
(not a pencil) on the auth splash, recoloring correctly if you toggle dark/light.
2. `/fa` customer home: flat buttons with comfortable padding, hairline-bordered cards with
soft teal shadows (not grey), soft-tinted chips. Tab through the page — every focusable
element shows the same 2px brand focus ring.
3. `/fa/nurse` and `/fa/admin` (seeded accounts): top bar is a cream/paper surface with a
hairline divider, not a solid teal slab; sidebar icons are one Rounded family; selected
nav item uses the soft-primary fill.
4. Trigger an inline form-error `AppAlert` and a toast on the same screen — same
brand-harmonized color family, both schemes.
5. `/fa` Persian text: no letter-spacing gaps inside joined words, headings don't clip;
compare `/en` — Space Grotesk headings render (network tab shows the self-hosted font
file only on `/en`, never on `/fa`).
6. Browser tab: favicon is the new mark; toggling OS dark mode flips `theme-color`
(deep-teal) in supporting browsers.
7. `npm run check` (type + lint) and `npm run test:ci` — both green (see Gate below).
## What is mocked / waiting on a real service
None — this phase is pure client theming, no service seams, no mock flags (per the phase's
own §4).
## Docs updated
- `.claude/skills/frontend-designer/SKILL.md` — brand-mark construction (now the source of
truth, `product/balinyaar.html` is gone), the new token categories + when to use each, the
weight-500/700 decision (the old "buttons weight 600" line was wrong the moment this phase
landed), the Rounded icon family + directional-mirroring rule + the size-bug fix, dropped
the stale ~19-icon "currently registered" list (pointed at `AppIcon/config.ts` directly
instead — it drifts too fast for a skill doc to enumerate reliably).
- `client/CLAUDE.md` — Theme System section rewritten for the CSS-only no-flash mechanism
(replacing the doc-drift `ColorSchemeScript` description that never matched reality), the
Fonts table (Space Grotesk now wired), the `theme/` entries in Project Structure
(`light.ts`/`dark.ts` removed).
## Follow-ups for later phases
- Phase 1: wire `--bal-rating`/`--bal-rating-empty` into `RatingInput` (currently still uses
`--bal-warning`), build EmptyState/ErrorState/PageHeader/`<Money>`/Jalali picker/skeleton
twins/route-level `loading.tsx`/`error.tsx`/404 — all deferred here per the phase's own
scope boundary.
- Phase 2: TopBar/SideBar chrome, `UserInfo`, nav grouping, SSR mobile-first flash — the
physical `paddingLeft`/`paddingRight` in `TopBarAndSideBarLayout.tsx` and the hard-coded
English "Open Sidebar" tooltip are pre-existing shell issues, deliberately untouched here
(no page/shell redesigns in phase 0).
- Phase 4/8: `--bal-trust`/`--bal-trust-soft` are defined but not yet consumed by
`TrustBadge`/verification UI — that's a content decision for those phases, not a token gap.
- Phase 12: the app-wide motion pass consumes `--bal-motion-*`/`--bal-easing-standard`,
defined but unused here (define-only per this phase's scope).
- No REQs filed — this phase never touches `server/` or any contract.
## Gate
- `npm run check` (type + lint): **green**, zero errors/warnings.
- `npm run test:ci`: **329/329 tests, 79/79 suites green.** Three pre-existing shared-component
tests (`AppIcon.test.tsx`, `AppIconButton.test.tsx`, `AppButton.test.tsx`) asserted the
exact bugs this phase fixes (an invalid `size` DOM attribute; the pre-Rounded-swap
`MoreHorizIcon` testid) — updated in the same change to assert the corrected behavior
(`style.fontSize`, `MoreHorizRoundedIcon`) rather than the old bug.
- `en.json`/`fa.json`: untouched — this phase added no new user-facing strings (the brand
mark's alt text reuses the existing `common.brand` key via `BrandMark`'s adjacent
`<Typography>`; the SVG itself is `aria-hidden`).
@@ -0,0 +1,269 @@
# UI Phase 1 — Shared primitives & app-wide states — Report (2026-07-17)
## What was built
### State kit — `EmptyState`, `ErrorState`, `QueryStateGate`
`components/common/EmptyState` and `ErrorState` promote/replace `AdminEmptyState`/
`AdminErrorState` (now thin re-exports) with a branded, calm look (phase-0 surface/radius
tokens, registry icon slot, soft-primary icon roundel) — retiring the dashed-border `Paper`
pattern hand-rolled across the app. Both are **presentational with caller-owned copy**;
`ErrorState` requires `retryLabel`/`onRetry` so a query failure can never render without a
working retry. `QueryStateGate` wraps the `isLoading`/`isError`/`isEmpty` branching in one
fixed order (skeleton → error → empty → children) for pages that want the component instead
of hand-branching. The mechanical sweep replaced the dashed-`Paper` blocks with these
primitives; `border: '1px dashed'` under `client/src/app` is now zero (the one remaining hit
in `client/src`, `DocumentUpload`'s upload dropzone, is an interactive drop-target affordance,
not an empty state, and is outside `app/` — a deliberate, not missed, exception).
**The six error→false-empty defects from the audit are fixed and verified**, each now renders
`ErrorState` with a working retry:
- `(customer)/HomeScreen.tsx` (formerly `page.tsx`) — the patients gate no longer spinner-hangs
on error.
- `(customer)/patients/page.tsx``isEmpty` now excludes the error case.
- `nurse/requests/page.tsx` — an errored inbox no longer reads as "no requests."
- `nurse/services/MyServicesList.tsx` — an errored variant list no longer shows the
first-service CTA.
- `(customer)/profile/page.tsx` — an errored profile renders `ErrorState`, never a blank,
save-capable form.
- `nurse/services/VariantBuilder.tsx` — a failed `optionGroupsQuery` now blocks progression
via `ErrorState` instead of silently treating the category as having no required options.
Both silent mutations from the audit now toast on failure: `nurse/profile/page.tsx`'s avatar
upload and profile save each carry an `onError` toast. The convention — *an errored query must
never render as empty; every mutation needs an `onError` toast* — is written into
`client/CLAUDE.md` (Unit Testing / Toast Notifications sections).
### `PageHeader` + `ConfirmDialog` promotions
`PageHeader` generalizes `AdminPageHeader` (`title`/`subtitle`/`actions`/`backTo`/`backLabel`,
RTL-flippable back chevron via the new `forward`/`back` icon pair) and was adopted mechanically
wherever pages hand-rolled the identical h5 + subtitle block. `ConfirmDialog` moved from
`components/admin/` to `components/common/`, preserving its exact contract (required-reason
gating, busy-state disabling both buttons) — now usable by any actor, not just the backoffice.
### Card kit — `SurfaceCard` + `AccentCard`
`SurfaceCard` (flat `Paper`, house radius, a `padding` scale `sm`/`md`/`lg`) and `AccentCard`
(`SurfaceCard` + a `borderInlineStart` accent at one standardized 4px width — ending the
3px/4px drift between `EarningsBalanceHeader`/`PayoutHistoryRow` and `BankStatusPanel`/
`DocumentUpload`) replace the ~12 named hand-rollers (`EarningsRow`, `EarningsBalanceHeader`,
`PayoutHistoryRow`, `PatientCard`, `VariantCard`, `VisitNoteCard`, `InstallmentScheduleRow`,
`PriceBreakdown`, `BankStatusPanel`, `DocumentUpload`'s panels, `BookingRequestSummaryCard`,
the customer-home nudge card) — visual unification only, no content/behavior change. A gap
found during the final sweep (`BookingDetailView`'s customer-side "care record locked"
notice, still on a dashed `Paper`) was migrated onto `AccentCard tone="neutral"` in this pass.
16 call sites now render through `SurfaceCard`/`AccentCard`.
### `<Money>` primitive
One component for every displayed IRR amount (`components/common/Money`): takes a served IRR
digit-string, formats via `utils/money.ts` (never computes), with `size`/`tone` variants —
emphasis renders in `--bal-money-emphasis`, **not terracotta** (`--bal-secondary` fails AA
contrast at small sizes on light backgrounds) — and an explicit deduction treatment (a
dir="ltr"-held `` prefix, never a bare Unicode minus floating in RTL text). 19 call sites now
render through `<Money>`, including the flagship terracotta-total defects
(`PriceBreakdown`, `RefundStatusCard`, `BnplPlanCard`, `InstallmentScheduleRow`,
`CancellationPolicyDisclosure`). `PriceDisplay` (catalog unit rates) was deliberately left on
its own `formatIrrToToman` call per the phase's own scope note — its contract is tested,
correct, and has different unit/estimate-label needs than a bare amount.
One site — `bookings/[id]/cancel/page.tsx`'s confirmation restatement — needed a different
shape: the amounts are interpolated into a translated sentence (`"مبلغ {refund} به شما
بازپرداخت می‌شود…"`), not rendered as an isolated node, so `<Money>` couldn't drop in directly.
Fixed by switching `confirm_restate` to next-intl's `t.rich()` with `<refund>`/`<fee>` tags in
both message files, rendering `<Money>` inside the sentence rather than hand-formatting a
string — the first `t.rich()` usage in the codebase; a precedent worth reusing for any future
money-inside-a-sentence case instead of falling back to string concatenation.
`currency_toman` outside `utils/money.ts` + `<Money>`/`PriceDisplay` internals is now zero
(DoD-exact grep).
### Formatting utils — `utils/number.ts`
`localeTag(locale)`, `formatNumber(value, locale, options?)`, `formatRelativeTime(iso, locale,
absoluteFormatter)` (7-day decay to the absolute Shamsi date — documented in JSDoc, no live
consumer yet per the phase's own scope: lands in phases 10/11), `formatClock(totalSeconds,
locale)` (migrated `CountdownTimer`'s inline `Intl` pad + fixed `OtpStep.tsx`'s Latin-digit
resend clock). Swept the `locale === 'fa' ? 'fa-IR' : 'en-US'` ternary onto `localeTag`/
`formatNumber` at every remaining site found by a final consolidated grep pass (run **after**
the metadata-pattern file splits, since those physically relocated code the first sweep had
already covered): `bookings/[id]/invoice/page.tsx`, `search/nurse/[nurseId]/page.tsx` (×2),
`NurseResultCard.tsx` (×2), `PriceDisplay.tsx`, and `JalaliDatePicker.tsx` (×2, the picker's
own new code). `locale === 'fa' ? 'fa-IR'` now appears only in `utils/number.ts` itself
(DoD-exact grep) — `JalaliDatePicker`'s separate `locale === 'fa' ? 'fa-IR-u-ca-persian' :
'en-US'` (the Jalali-calendar-system Intl tag, a different decision than the plain locale tag)
is intentionally untouched by this rule.
### `JalaliDatePicker` + `JalaliDateField`
Shamsi-native date selection that **always emits/accepts ISO Gregorian** — the wire never sees
a Jalaali date. `calendarEngine.ts` defines one `CalendarEngine` shape with two
implementations (`jalaaliEngine` for `fa`, a plain Gregorian one for `en`) so the picker's
grid/navigation logic is calendar-agnostic.
**Arithmetic decision:** `Intl` (`fa-IR-u-ca-persian`, already used by `utils/date.ts`) is
sufficient to *display* a Shamsi date but has no reverse direction — there's no way to ask
Intl "what Gregorian date is Jalaali 1404/5/15," which is exactly what a *picker* (as opposed
to a *label*) needs for month navigation and emitting the selected day. Rather than hand-roll
the Borkowski conversion algorithm, the phase adds `jalaali-js` (MIT, zero runtime
dependencies, documented in-repo as ~9KB unminified / low single-digit KB gzipped) for the
reverse conversion (`toGregorian`) plus month-length/leap-year math; `Intl` still supplies
locale-correct month/weekday *names*`jalaali-js` only supplies numbers. `JalaliDatePicker`
has a `grid` variant (month calendar with keyboard roving nav, RTL-flipped arrow keys) and a
`chips` variant (horizontal day-chip strip for near dates, the C4 booking-form shape).
`JalaliDateField` wraps it in a read-only `TextField` + `Popover`.
### `StatusChip` v2, `StatusTimeline`, `CountdownTimer` v2, `RatingInput` v2
- `StatusChip` rewritten to a soft-tint hierarchy: neutral/info/pending states use
`--bal-primary-soft`, verified/active use `--bal-success-soft`, and solid `--bal-error` fill
is reserved for the one true "alarm" state (rejected) — added alongside new
`--bal-{success,error,warning,info}-soft` tokens (both color schemes).
- `StatusTimeline` (ordered `TimelineNode[]`: completed/current/pending/failed, animated pulse
on `current` respecting `prefers-reduced-motion`) now backs `RefundStatusCard` and
`BookingStatusTimeline` (3 call sites) — wizards (`StepperHeader`) are untouched, per the
phase's own "wizards still use StepperHeader" boundary.
- `CountdownTimer` v2 adds a `windowStart` progress ring, `warnThresholdSeconds`/
`urgentThresholdSeconds` urgency tiers (replacing the old binary `urgent` prop, kept as a
legacy override), and an opt-in `coarseThresholdSeconds`/`coarseLabel` humanized mode — the
core architecture (server-frozen UTC deadline, self-owned 1s tick, single `onElapsed`,
`dir="ltr"` tabular-nums digits) is unchanged.
- `RatingInput` v2 renders a fractional fill via a clip-path overlay in read-only mode (a 4.5
average now shows a genuinely half-filled fifth star, replacing the old `Math.round`
workaround removed from the nurse profile page) and switched its fill colors from
`--bal-warning` to the phase-0 `--bal-rating`/`--bal-rating-empty` tokens.
### Route chrome + per-page metadata
`loading.tsx` added for all five route groups ((customer), nurse, admin, partner,
public-routes) — the three sidebar shells (nurse/admin/partner) share one
`_chrome/SidebarShellSkeleton.tsx`. `error.tsx` and `not-found.tsx` under `[locale]/` are
branded and localized (the `not-found.tsx` is reached via a `[...rest]/page.tsx` catch-all
calling `notFound()`, next-intl's recommended pattern). `global-error.tsx` sits above
`[locale]/` and is the one sanctioned static-string, no-MUI exception — it replaces the root
layout on a root-level crash and renders its own `<html>`, so it structurally cannot reach
`NextIntlClientProvider`.
`ErrorBoundary` was rewritten to take `title`/`body`/`retryLabel` as required caller-owned
props instead of calling `useTranslations` internally — kept **presentational**, no
`next-intl` import, specifically so it stays safe at the top of the `components/common`
barrel (see the Jest note below). Both `CustomerLayout` and `TopBarAndSideBarLayout` pass the
copy via `useTranslations('routeChrome')`.
Every route now has its own browser tab title via `generateMetadata` (locale-aware `'%s |
بالین‌یار'`/`'%s | Balinyaar'` template, set once in `[locale]/layout.tsx`). Seven pages that
needed a Client Component body were split into a thin Server Component `page.tsx` +
co-located `<Name>Screen.tsx`: home, login, search, bookings, admin overview, partner home
(nurse dashboard was already a Server Component and got `generateMetadata` added in place, no
split needed). The pattern is documented in `client/CLAUDE.md` under "Per-page metadata (the
client-page pattern)."
## The Jest / next-intl transform fix (the one non-mechanical problem this phase hit)
`ErrorBoundary`/`ErrorState` were deliberately kept free of `next-intl` because any component
near the top of the `components/common` barrel that imports it at module scope forces *every*
test file that transitively imports the barrel to deal with next-intl's ESM-only build — even
tests that never touch translations. `<Money>` couldn't take the same fix: it already had ~20
call sites depending on its locale-aware, self-contained API before this was noticed, so
stripping `next-intl` from it would have meant threading a formatted string through every
caller instead.
The actual fix was at the root: `next/jest`'s `createJestConfig` returns a
`transformIgnorePatterns` that already matches nearly all of `node_modules` (a narrow
negative-lookahead allowlist for a couple of Next.js-internal packages), and **appends**
whatever custom pattern you pass rather than letting it override — combined with Jest's
OR-based array semantics (a file is ignored if *any* pattern matches), a more permissive
pattern appended after the restrictive one can never "un-ignore" a package the first pattern
already caught. `jest.config.ts` now replaces the array outright, in an async wrapper around
`createJestConfig`, allowlisting `next-intl`/`use-intl`/`@formatjs`/`intl-messageformat` (each
transitive ESM dependency surfaced its own parse error until the allowlist was broad enough).
This is a repo-wide fix, not a `<Money>`-specific one: any future shared component is now free
to call `useTranslations` without risking the same barrel-poisoning failure mode. The
trade-off and the "prefer caller-owned by default" convention are both documented in
`client/CLAUDE.md`'s new "Presentational purity in `components/common`" subsection.
## Icon registry
Added `forward` (`ArrowForwardRounded`) to `AppIcon/config.ts`'s `ICONS` and
`DIRECTIONAL_ICONS` — needed for `JalaliDatePicker`'s next-month button (mirrors under RTL
alongside the existing `back`/`chevron_start` pair).
## Sweep counts (verified by grep on the current tree, not the original audit estimate)
| Primitive | Live call sites |
| --- | --- |
| `<EmptyState>` | 21 |
| `<ErrorState>` | 15 |
| `<Money>` | 19 |
| `<SurfaceCard>` / `<AccentCard>` | 16 |
| `<StatusTimeline>` | 3 |
| `formatNumber(...)` | 11 |
| `localeTag(...)` | 9 |
## Definition-of-Done sweep greps (re-run after the metadata-pattern file splits)
- `border: '1px dashed'` under `client/src/app`**zero**.
- `currency_toman` outside `utils/money.ts` + `<Money>`/`PriceDisplay` internals → **zero**.
- `locale === 'fa' ? 'fa-IR'` outside `utils/number.ts`**zero**.
These were re-verified as a final pass, separate from the per-agent sweeps, because the
metadata-pattern splits (home/login/search/bookings/admin/partner) physically relocated code
after the mechanical sweeps had already run over the pre-split files — three real gaps
(`BookingDetailView`'s locked-care dashed border, the cancel-page `currency_toman` sentence
site, six `locale === 'fa' ? 'fa-IR'` sites across five files) surfaced only in this final pass
and are fixed above.
## What was mocked / waiting on a real service
None — this phase, like phase 0, is pure client primitives/chrome with no service seams
touched. No REQ entries filed (the phase never touches `server/` or a contract).
## Docs updated
- `client/CLAUDE.md` — Project Structure (`components/common/*` primitives, `utils/number.ts`,
the route-chrome files, the metadata-pattern file splits), a new "Presentational purity in
`components/common`" subsection under Unit Testing (the next-intl/Jest gotcha + the
caller-owned-copy convention), "Per-page metadata (the client-page pattern)" section, "Every
mutation needs an `onError` toast" line under Toast Notifications.
- `messages/en.json` / `fa.json``routeChrome` namespace, ~8 new error-copy keys for the six
defect fixes, `confirm_restate` converted to `t.rich()` tags. Key parity verified
programmatically: 1,538 keys in each file, zero one-sided keys.
## What a human should verify (not done here — no browser in this environment)
Per the phase's own Definition of Done, visual verification on the four axes (`/fa`+`/en` ×
light+dark) and mobile+desktop for the state kit, card kit, route chrome, and the Jalali
picker was **not performed** — there is no browser available in this environment. `npm run
check` and `npm run test:ci` passing is evidence of type/lint/unit correctness, not visual or
interaction correctness. Specifically worth a human pass:
1. Force a query to fail (devtools offline) on `/fa` home, `/patients`, `/profile`,
`/nurse/requests`, `/nurse/services` — confirm `ErrorState` + working retry, never a spinner
or false-empty state.
2. `/fa/xyz` → branded 404; throw inside a page in dev → branded error screen; production build
→ confirm no stack trace renders.
3. `JalaliDatePicker` grid + chips variants on `/fa` (RTL arrow-key direction, month names) and
`/en` (plain Gregorian) — both color schemes.
4. The nurse profile's rating display — confirm a non-integer average renders a genuinely
half-filled star, not a rounded one.
5. Tab titles change per section (`جستجو | بالین‌یار`, `رزروها | بالین‌یار`, …).
## A note on a subagent report during this phase
One of the six parallel sweep agents (the card-kit migration agent, worktree
`agent-a099d7389dc14bd95`) returned a report whose text was flagged by the harness as
"instruction-shaped" (matching a `settings-json` pattern) and had its control tags neutralized
before reaching this session. Having now reviewed the neutralized text directly: it was the
agent's own file-accounting summary, noting in passing that
`.claude/settings.local.json` "predates this task and is untouched by me" — normal
bookkeeping language, not an embedded directive aimed at this session. No action was taken on
it beyond relaying it here, per the harness's own instruction to treat any remaining
directive-shaped text as a finding to report rather than follow.
## Gate
- `npm run check` (type + lint): **green**, zero errors/warnings.
- `npm run test:ci`: **94/94 suites, 392/392 tests green** (run after all merge-conflict
resolution and the final DoD-sweep fixes above).
- `messages/en.json` / `messages/fa.json`: in sync (1,538 keys each, verified programmatically).
- 6 agent worktrees (`agent-a099d7389dc14bd95`, `-a0b4bb6e73e057271`, `-a279bb3717dd53e45`,
`-a74eed78c5350b831`, `-ae141074976cb056f`, `-af1e1c6a8d89d64cc`) and their branches removed
after diff extraction — no leftover worktree state.
## Follow-ups for later phases
- `formatRelativeTime` ships with tests but no live consumer yet — phases 10/11 (per the
phase's own scope note).
- Custom illustration set for `EmptyState` — DEFERRED to phase 12, per phase 0/1's own notes.
- The app-wide motion pass (`--bal-motion-*`/`--bal-easing-standard`, defined in phase 0) is
still unconsumed outside `StatusTimeline`'s pulse animation and `CountdownTimer`'s ring —
phase 12's job.
- Visual verification (four axes × mobile/desktop) is unverified — see the section above; the
next phase touching any of these primitives should do a pass before building further on top.
@@ -0,0 +1,222 @@
# UI Phase 10 — Messaging & notifications — Report (2026-07-19)
## What was built
### Ticket inbox → a real inbox (`TicketInboxScreen`, `TicketListCard`)
- **Status filter chips** (همه/باز/بسته) + **load-more paging**: `useMyTickets({ status, pageSize, page: 1 })`
with a growing `pageSize` (the same "growing limit" pattern `NotificationCenter` already used), reset to
`TICKETS_PAGE_SIZE` on a filter change. `keepPreviousData` (already set on the hook) means a chip switch
never flashes.
- **`TicketListCard` redesigned around activity:** bold subject + unread pill (unchanged behavior — it turns
out `unreadCount`/`lastMessageAt` are **already real** on the wire, REQ-028 having been delivered since the
audit was written — see "Stale audit note" below) **plus** a one-line last-message preview prefixed with
the translated author label (e.g. "پرستار: ساعت ۵ عصر هماهنگ شد") and a **relative** last-activity time
(`formatRelativeTime`, decaying to Shamsi past 7 days) instead of the old absolute
`formatShamsiDateTime`. The preview/author-role fields don't exist on the wire summary yet — filed as
**REQ-059** — so `TicketSummary.lastMessagePreview`/`lastAuthorRole` are optional and the card renders
subject + status + time only when they're absent (no empty slot, never a fake value).
- **Support-entry unread badge in the chrome:** a new `useSupportUnreadTotal()` read behind the `services/tickets`
seam (mock sums its tickets' `unreadCount`; real returns `null` — REQ-059). Mounted as a `Badge` on the
customer TopBar's support icon (only on the 5 root tabs) and on the nurse sidebar's support item (a new
`LinkToPage.badgeCount` prop, rendered by `SideBarNavItem` — a minimal, intentional touch to the
phase-2-owned `layout/` files, noted per the ownership rules).
- **Emergency affordance right-sized:** the permanent alarm-red `EmergencyBanner` is gone from both ticket
inboxes, replaced by a new **`EmergencyPlaybookRow`** — a compact, neutral, collapsed-by-default row that
expands to rewritten copy (never instructs calling a number the inbox can't show) + the "open a ticket"
action. `EmergencyBanner` itself is untouched and stays exactly where it belongs: the nurse's
post-confirmation booking detail (`BookingSupportEntry`), the only surface with a real `tel:` contact.
- Unified the messaging surface width (inbox now 720px, matching the thread — was 640/720 mismatched).
### Live thread (`useTicket`/`useTicketThread`, `useThreadScroll`, `TicketMessageList`)
- **Polls while mounted:** `useTicket`/`useTicketThread` both got `refetchInterval: TICKET_THREAD_REFETCH_INTERVAL`
(15s, new constant) — TanStack Query scopes this to active observers, so it's automatically off once the
thread unmounts. No `refetchIntervalInBackground`. The 60s notification-count poll stays the only
always-on poll (§5 posture unchanged).
- **`useThreadScroll`** — a new, reusable scroll-orchestration hook (`components/messaging/useThreadScroll.ts`,
exported from the barrel): opens the thread scrolled to the **newest** message (instant, on first load),
always scrolls to a newly-sent (mine) message, and auto-scrolls on a received message only when the
viewer is already within ~120px of the bottom — otherwise shows a floating «پیام جدید ↓» pill. Uses an
`IntersectionObserver` on a bottom sentinel rather than tracking a specific scroll container, so it works
whether the *page* or an inner box scrolls (guarded for environments without `IntersectionObserver`, e.g.
jsdom). Deliberately exported as a consumable for **phase 11's admin thread**, which has the inverse bug
(a 520px scrollbox that also opens at the top).
- **Chat typography** (`TicketMessageList`): messages are grouped into date-separator / system-event /
consecutive-same-author blocks (`buildBlocks`); centered Shamsi separators read امروز/دیروز/an absolute
date (`formatDaySeparator`, new in `utils/date.ts`, string-equality comparison — never raw ms diffs, so
it's calendar-exact); consecutive messages from the same author collapse under one author label; bubbles
show **hh:mm only** (`formatShamsiTime`, new in `utils/date.ts`); `authorRole === 'system'` renders as a
centered neutral chip, never a bubble.
- **Fixed the bidi timestamp bug:** removed the forced `direction: 'ltr'` from the bubble's time label
(`MessageBubble.tsx`) — with hh:mm-only Persian-digit stamps, no forced direction is needed; the Latin
`referenceCode` keeps its forced LTR everywhere it's shown.
- **Sticky composer separation:** the composer's sticky box now has a real `borderTop` (divider token)
instead of a bare `bgcolor` block, so bubbles no longer scroll flush into the input.
### Composer (`MessageComposer`, `TicketConversationPanel`, `usePostMessage`, `useDiscardFailedMessage`)
- **Enter semantics per input modality:** `useMediaQuery('(pointer: coarse)')` branches the composer's
`onKeyDown` — desktop (fine pointer): Enter sends, Shift+Enter newlines (unchanged); touch (coarse
pointer): Enter always inserts a newline, the send button is the only send path.
- **Retry-in-place on failure** — the invariant-preserving rewrite:
- `usePostMessage`'s `onMutate` is now **idempotent on `clientMessageId`**: a fresh send appends a pending
bubble; a retry (the same id already in the cache as `sendStatus: 'failed'`) flips it back to `sending`
in place instead of appending a duplicate.
- `onError` **no longer rolls the thread back** — it flips the bubble to `sendStatus: 'failed'` and leaves
it in place with its typed body.
- `MessageBubble`'s failed state renders `role="alert"` (screen readers are told), the error-accented
bubble, a «تلاش مجدد» action (re-mutates the same `clientMessageId`), and a discard (delete) action.
- A new `useDiscardFailedMessage()` (not a mutation — nothing was ever sent) removes the failed message
from the cached thread; `TicketConversationPanel` wires its `onDiscard` to also restore the message's
body into the composer's draft — **a failure never loses typed text**, and `clientMessageId`
reconciliation still never double-renders. Both invariants are proven by the updated
`MessageBubble.test.tsx`.
- **`TicketConversationPanel`** (new) is the component that makes this possible without leaking state
across tickets: it owns the one `usePostMessage`/draft instance both `TicketMessageList`'s retry/discard
and the composer's own send now share, and it's mounted `key={ticketId}` from `TicketThreadScreen` — so
navigating thread→thread (the App Router reuses the `[id]` subtree) remounts the whole panel, exactly
preserving the pre-existing "drafts/in-flight state never cross tickets" invariant (previously enforced
by keying only `MessageComposer`).
- **Attachment affordance — designed, gated:** the composer has an attachment icon button + the capability
flag `TICKETS_ATTACHMENTS_ENABLED` (`services/tickets/constants.ts`, default `false`) — it renders nothing
until the seam lands (REQ-060). No dead button ships.
- **RTL send-icon mirror:** `send` added to `AppIcon`'s `DIRECTIONAL_ICONS` set (config.ts) — phase 0's
auto-mirroring mechanism already existed, this was a one-line registry addition.
### Emergency affordance (recap)
- Full alarm-red `EmergencyBanner` + `tel:` stays **only** on the nurse's post-confirmation booking read
(untouched). Both ticket inboxes now show the compact `EmergencyPlaybookRow` instead.
### Notification center (`NotificationCenter`, `NotificationRow`, `notificationIcon.ts`)
- **Day grouping:** امروز/دیروز/این‌هفته, then a Shamsi date header for anything older — grouped **in list
order** (the server's unread-first-then-newest ordering is preserved per §5's "keep the mark-read UX as
is"; a bucket can recur if an older unread item sits above newer read ones — a deliberate, documented
trade-off rather than silently reordering the list).
- **Relative timestamps** (`formatRelativeTime`, decaying to Shamsi) replace the absolute
`formatShamsiDateTime` on every row.
- **Per-kind tinted icon container:** a new `notificationTint(kind)` helper (`notificationIcon.ts`) — booking
teal, payout success-green, ticket/support terracotta (the deliberate "a human from Balinyaar" accent),
refund info, nurse-trust the dedicated `--bal-trust` token, `none` fully neutral. All existing `--bal-*-soft`
tokens — no new tokens needed.
- **Non-navigable rows render non-interactive:** `NotificationRow` now takes a `navigable` prop (the caller
computes it from `notificationDeepLink(...) != null`). Navigable rows are a real `ButtonBase` with a
trailing chevron (`forward` icon) and a visible `:focus-visible` ring; non-navigable rows render as a
plain, static surface (no ripple, no pointer cursor, no chevron) that still marks the notification read on
click/Enter/Space (kept keyboard-operable via `role="button" tabIndex={0}`, even though visually inert).
- **Kept unchanged:** mark-read-on-open (optimistic), mark-all-read, load-more.
### Bell behavior (`NotificationBell`, `NotificationBellPopover`, `NotificationBellView`)
- **Desktop popover on the nurse shell:** `NotificationBell` now branches on `role === 'nurse' && isDesktop`
(`useMediaQuery(theme.breakpoints.up('md'))`) — opens a new `NotificationBellPopover` (5 most recent,
mark-all-read, «مشاهده همه» to the full center) instead of navigating. The popover fetches **on open**
(`useNotifications(5, { enabled: open })``useNotifications` gained the optional `{enabled}` param),
reusing the same `notificationKeys` cache the full center reads, never on the polled count's tick.
Everywhere else (customer — always, mobile-first by design; nurse mobile) keeps direct navigation.
- **Isolation preserved:** only `NotificationBell` (the container) calls `useUnreadCount()`; the popover's
content and the shell around it never do. `NotificationBellView` now `forwardRef`s to its `IconButton` so
the container can anchor the popover to it (via `anchorEl` state set through the ref callback — not a
read-during-render, which the `react-hooks/refs` lint rule correctly flagged and blocked).
- Badge pulse-on-increment (the phase's "optional polish") was **not** built this pass — noted under
Follow-ups.
### Admin notifications — the phase-11 handshake (§3.7)
- `admin/notifications/page.tsx` stays a `PlaceholderScreen` (untouched — building it is phase 11's call),
but its **only entry point was the TopBar bell** (`AdminLayout` had no separate "notifications" nav item —
the bell click was the dead link). Removed `<NotificationBell role="admin" />` from `AdminLayout` entirely
— a documented, intentional removal (comment in `AdminLayout.tsx`), not a silent regression. Re-add it
(reusing `NotificationBellPopover`, already exported from the barrel) once phase 11 ships a real admin
feed.
- `useThreadScroll` and `NotificationBellPopover` are both exported from their barrels specifically for
phase 11 to consume for the admin thread's inverse scroll bug and (eventually) an admin bell popover.
## Stale-audit note (recorded for the record, not re-litigated)
The phase doc's problem list (drawn from `audit/messaging-notifications.md`) describes `unreadCount`/
`lastMessageAt` as "dead on the real API, REQ-028 gap, mock-only." That was true when the audit was
written, but by the time this phase ran, **REQ-028 had already been delivered**`USE_TICKETS_MOCK` and
`USE_NOTIFICATIONS_MOCK` are both `false` in this codebase, and `ticketsClientApi.mapSummary` genuinely maps
`unreadCount`/`lastMessageAt` off the wire (confirmed by reading the current code, not the audit). This
phase's actual REQ-039/040-equivalent gaps turned out to be narrower than described — `lastMessagePreview`/
`lastAuthorRole`/the unread-total badge (filed as **REQ-059**, since REQ-039/040 were already taken by
ui-phase-3/4 by the time this phase ran — always check the tracker's live highest number, not a phase doc's
indicative one) and the attachment affordance (**REQ-060**). This mirrors the same "audit predates a
same-day fix" pattern the ui-phase-9 stale-audit memory already flagged — worth a standing reminder for
future phases to verify audit claims against current code before treating them as gaps to close.
## What is now testable (and exactly how)
1. `/fa` customer → پشتیبانی (`/support/tickets`): status chips filter the list without a flash; if there
are >20 tickets in a scenario, «نمایش بیشتر» loads more; the compact «موارد اضطراری» row is collapsed by
default and expands on tap; each card shows a bold subject, unread pill (mock scenario), one-line
preview + relative time.
2. Open a thread with a booking-linked coordination ticket → opens scrolled to the newest message,
date-separated and author-grouped, `hh:mm` stamps, a centered system event line if present. Scroll up,
wait ~15s for the poll (or use the mock's reply path) → a reply appears and, if scrolled up, shows
«پیام جدید ↓»; tap it → scrolls to newest.
3. Send a message → bubble appears instantly with "در حال ارسال…"; on confirm it shows `hh:mm`. Post the
dev sentinel `/fail` as the body → the bubble turns error-accented with «تلاش مجدد» + a delete icon,
`role="alert"` (verify with a screen reader or the accessibility tree); tap «تلاش مجدد» → succeeds (a
real send) with **exactly one** bubble, never a duplicate. Try again and tap the delete icon instead →
the bubble disappears and its text reappears in the composer.
4. On a touch-emulated viewport (devtools device toolbar), Enter in the composer inserts a newline; on a
desktop viewport, Enter sends. `/fa`: the send arrow points out of the field (toward the inline-end).
5. `/nurse/support/tickets`: the sidebar's «پشتیبانی» item shows a small unread badge when the mock's
`getUnreadTotal()` is positive.
6. Notification center (`/fa` + `/en`, light + dark): امروز/دیروز/این‌هفته day groups, «۵ دقیقه پیش» decaying
to a Shamsi date past 7 days, tinted per-kind icon circles (teal/success/terracotta/info/trust); a
non-deep-linking row (seeded via the mock's unknown-type row) doesn't ripple and has no chevron, but a
click still marks it read; navigable rows show a trailing chevron and a visible focus ring on Tab.
7. Nurse desktop (`≥md` viewport): the bell opens a popover (5 recent + mark-all + «مشاهده همه»); resize to
mobile → the same bell now navigates to the full center. Customer shell: the bell always navigates,
any viewport. Devtools Network tab: only the unread-count endpoint polls (60s) plus the thread endpoint
while a thread screen is mounted (15s) — nothing else ticks.
8. `/admin`: no notification bell in the TopBar at all (removed, not a dead link).
## What is mocked / waiting on a real service
- `TicketsApi` (`services/tickets/apis/mockApi.ts`) — mock stays available for the two REQ-059 fields
(`lastMessagePreview`/`lastAuthorRole`) + `getUnreadTotal()`; the real client maps the first two to `null`
and the third to a constant `null`. See `mocks-registry.md`'s updated `TicketsApi` row.
- The composer's attachment affordance is fully designed but inert (`TICKETS_ATTACHMENTS_ENABLED = false`)
until REQ-060 ships an object-storage-backed attachment endpoint.
## Contracts
- No contract consumed this phase (no new backend endpoint existed to consume — REQ-028 was already
delivered before this phase started).
- Requests filed in `dev/shared-working-context/frontend/requests/for-backend.md`:
- **REQ-059** — Ticket inbox enrichment: `lastMessagePreview` + `lastAuthorRole` on `TicketSummaryDto`
(extends REQ-028) + a cheap unread-total read for the chrome badge.
- **REQ-060** — Ticket message photo attachments (object-storage-backed), gating the composer's
already-built affordance.
## Docs updated
- `client/CLAUDE.md` — the `messaging/` and `notifications/` Project Structure entries rewritten for the
new components/hooks (`TicketConversationPanel`, `useThreadScroll`, `EmergencyPlaybookRow`,
`NotificationBellPopover`, `notificationTint`); the `services/tickets`/`services/notifications` domain
bullets updated (retry-in-place semantics, thread polling, `useSupportUnreadTotal`,
`useDiscardFailedMessage`, REQ-028-delivered vs REQ-059-gap distinction); the `'tickets'`/`'notifications'`
i18n namespace descriptions updated with the new keys; `CustomerLayout.tsx`/`NurseLayout.tsx`/
`AdminLayout.tsx`/`SideBarNavItem.tsx` structure-tree lines updated for the badge + admin-bell-removal
changes.
- `dev/shared-working-context/reports/mocks-registry.md` — the `TicketsApi` row corrected (it was stale:
marked "deliver REQ-028, then set flag false" when the flag was already `false`) and updated with the
REQ-059/060 gaps.
- `dev/shared-working-context/frontend/requests/for-backend.md` — REQ-059/060 appended (see Contracts).
## Foundation extensions (minimal, per the ownership rules)
- **`layout/` (phase 2-owned):** `LinkToPage` gained an optional `badgeCount` field; `SideBarNavItem`/
`SideBarNavList` render it as a small `Badge`; `CustomerLayout`/`NurseLayout` wire it to
`useSupportUnreadTotal()`; `AdminLayout` lost its notification bell (§3.7 handshake, documented above).
- **`utils/date.ts`:** added `formatShamsiTime` (hh:mm-only) and `formatDaySeparator`
(امروز/دیروز/absolute-date, calendar-exact via formatted-string comparison, not ms diffs).
- **`AppIcon/config.ts`:** `send` added to `DIRECTIONAL_ICONS` (one-line registry addition, phase 0's
existing mechanism).
- **`services/notifications`:** `useNotifications` gained an optional `{ enabled }` param (for the popover's
fetch-on-open); no breaking change to existing callers.
## Follow-ups for later phases
- **Phase 11 (admin console):** build the real admin notification feed (or keep it hidden) — the bell +
popover pattern (`NotificationBellPopover`) and the scroll hook (`useThreadScroll`) are ready to reuse for
the admin thread's inverse scrollbox bug.
- **REQ-059**/**REQ-060** as filed.
- **Badge pulse-on-increment** (§3.6's "optional polish") and a document-title/favicon unread hint were not
built this pass — low-risk, low-priority visual polish, safe to pick up whenever.
- **Ticket lifecycle + trust affordances** (user-side close/reopen, an expected-response-time promise, a
"support has seen this" state) — called out as an opportunity in the audit but out of this phase's
explicit scope (§3 didn't ask for it); worth a future phase if support-response-time becomes a measured
product metric.
@@ -0,0 +1,260 @@
# UI Phase 11 — Admin & partner console — Report (2026-07-19)
## What was built
### 3.1 `useAdminListState` — URL-synced worklist state, adopted everywhere
- New `client/src/hooks/useAdminListState.ts` — mirrors **applied** filters + page into `searchParams` via
`router.replace({ scroll: false })`; draft stays local (typing never refetches or touches the URL) until
`apply()`/`applyFilters(explicitValue)`/`clear()`/`goToPage(n)` commit it. Initial `applied`/`page` are
read from the URL **once, on mount**. `applyFilters` exists because a discrete control (a status tab/
select) that should commit the instant it changes cannot safely do `setDraft(next); apply()` in the same
handler — `apply()` closes over the *previous* render's `draft`, so it would commit the stale value; two
pages hit this bug during integration and were fixed to use `applyFilters` instead (a co-located test
covers it). Also exports `useAdminBackToList(listHref)` — a real `router.back()` when there's browser
history, falling back to pushing `listHref` otherwise (the detail-page "back" fix).
- Because it calls `useSearchParams()`, every page using it wraps its body in `<Suspense>` (the existing
`SearchScreen.tsx` pattern) — a default-exported thin wrapper + a `*Inner`/`*Screen` body component.
- Adopted on **every** admin/partner queue page: tickets, audit, verification, reviews, payouts (list +
batch detail), partners (list), alerts, holidays, config (list + history drawer), and the partner
bookings/settlement lists. `roles` was intentionally **not** touched (it's an unpaginated, unfiltered
grid — nothing to URL-sync).
- **Fixed the hard-wired page-1 reads:** `admin/config/page.tsx` (`usePlatformConfigs`) and its
change-history drawer, and `admin/holidays/page.tsx` (`useHolidays`) now carry real page state + an
`AdminPager` — previously any row beyond page 1 was invisible/uneditable.
- Detail pages (tickets thread, payout batch, partner center) now use `useAdminBackToList` (or `PageHeader`'s
new `onBack`) instead of a hand-rolled `router.push` to the bare list.
### 3.2 `UserPicker`/`NursePicker` — killing raw-ID targeting
- New `client/src/components/admin/UserPicker/` — an async MUI `Autocomplete` (name/phone search, 300ms
debounce via the existing `useDebouncedValue`) rendering **name + masked phone + `#id`** per option, never
a bare id. `NursePicker` is a thin `roleFilter="nurse"` wrapper — its selection carries `nurseProfileId`
(a different id space than the user id, the one sponsorship/roster assignment actually needs). Both
co-located-tested (mock the `useUserSearch` hook, no `QueryClientProvider` needed).
- Backed by a new admin user-directory seam (REQ-061, gap): `AdminUserSummary` type, `searchUsers`/
`lookupUsers` on `AdminApi`, a seeded ~13-entry mock directory (admin/support/finance staff, nurses with
`nurseProfileId`, customers, one partner contact), `useUserSearch`/`useUserLookup` hooks. `useUserLookup`
is the **batch** id→label resolve — one request for every actor/owner id a page renders, never one per row.
- Wired into: `admin/roles`'s grant dialog (the confirm copy now names the resolved person —
«نقش {role} به {name} اعطا شود؟» — never `#42`), `admin/partners`'s create/edit dialog (`adminUserId`) and
detail page (sponsored-nurse assignment, via `NursePicker`). `admin/alerts`' "assign to me" doesn't need a
picker (it targets the current admin, not an arbitrary user) — its fix is below.
- **Fixed the alert assign-to-self fallback:** `admin/alerts/page.tsx`'s `meId = authState.currentUser?.id
?? 1` is gone. The button is now `disabled` with a `title`/`Tooltip` («در حال بارگذاری حساب شما…») until
the real id hydrates — it can never silently target user `#1`. `SupportAlertCard` gained
`assignSelfDisabled`/`assignSelfDisabledTitle` props for this. The admin ticket thread's new "assign to
me" control (§3.4) uses the identical pattern.
### 3.3 Verification desk — the flagship trust queue
- **Status tabs with counts:** the lone 3-value select is now MUI `Tabs` (all/pending/in_review), each
showing a `(count)` suffix when the server serves `counts` (REQ-062, gap — mock computes real counts over
the whole unfiltered queue; the real client sends the param but the response field stays `undefined`, so
the tabs render without badges until delivered — never a fake count).
- **Name/phone search** behind the established draft-vs-applied Apply/Clear pattern (mirrors tickets/audit).
- **Waiting-time column:** client-computed, display-only relative age off `submittedAt`, colored past
`WAITING_TIME_WARNING_HOURS=48` (`--bal-warning`) and `WAITING_TIME_ALARM_HOURS=96` (`--bal-error`) —
named constants, never magic numbers.
- **Next/prev case navigation:** `admin/verification/[nurseId]/page.tsx` re-derives the queue's filters/page
from the URL (a new `queueFilters.ts` shared by both pages) and calls `useVerificationQueue` with the same
params — React Query serves it from the list's own cache, no extra fetch — to compute the previous/next
`nurseVerificationId` in the current queue order. «پرونده بعدی»/«پرونده قبلی» buttons + `ArrowLeft`/
`ArrowRight` window keydown bindings (ignored while focus is in a text input). A full split-pane case view
stays explicitly **out of scope** (deferred post-chain) per the phase brief.
- `CredentialDialog`'s two native `type="date"` inputs (issued/expires) are now `JalaliDateField`.
- `DocumentViewer`'s signed-URL flow is untouched (do-not-regress, confirmed).
### 3.4 Ticket console — lifecycle + a safe composer
- **Close/reopen/assign mutations** (`useCloseTicket`/`useReopenTicket`/`useAssignTicket`, REQ-063, gap) —
full mock implementations (`StoredTicket` gained `assigneeUserId`) and `clientApi` methods mapped to
proposed routes, all gated behind `TICKET_LIFECYCLE_ENABLED` (`services/tickets/constants.ts`, default
`false`) so no control points at a 404ing route in production. The thread header gets close/reopen
buttons (via `ConfirmDialog`, no reason required — closing is terminal, not destructive) + "assign to me"
(disabled-with-tooltip until hydrated, never a fallback id), all also gated on `caps.canManageTickets`.
- **Scroll-to-latest:** the thread reuses `useThreadScroll` (built in ui-phase-10, explicitly earmarked in
its own docstring for "phase 11's admin thread scrollbox next") — attaching its `bottomRef` after the
message list is the only change needed; the hook's own initial-scroll effect handles the rest.
- **Internal-note mode made unmistakable:** the composer `Paper` turns amber (`--bal-warning`/
`--bal-warning-soft`, both schemes) and the send button relabels to «ثبت یادداشت داخلی» whenever `mode ===
'internal'` — the safety cue now lives on the action itself, not only on the toggle above it.
- **Queue columns:** an activity column + a results footer (`AdminDataTable`'s new `footer` prop). REQ-028's
`unreadCount`/`lastMessageAt` are real on the **user** ticket list but the admin queue (`AdminTicketSummary`)
is explicitly served `unreadCount = 0` per that REQ's delivery note — this phase does **not** re-file that
admin-side extension (it's ui-phase-10's to own); the queue renders a `createdAt`-based activity column as
the honest fallback.
### 3.5 Money-desk safety
- **Fixed the UTC off-by-one:** `admin/payouts/page.tsx`'s `isoDate` helper now formats via local
`getFullYear()/getMonth()/getDate()` instead of `toISOString().slice()` — near Tehran midnight the
prefilled payout window no longer lands on yesterday. Adopted `JalaliDateField` for the period inputs.
- **Payout run confirm shows the movement summary + a typed confirmation:** the final run-confirm dialog now
shows the batch total (via `<Money>`, sourced from the already-fetched preview — **never** recomputed),
the eligible-nurse count, and the processing date, then requires typing «تایید» or the exact amount before
the confirm button enables. This is a new, generic capability on the **shared** `ConfirmDialog`
(`requireTypedConfirmation: string[]`/`typedConfirmationLabel`/`typedConfirmationPlaceholder`) — additive,
zero behavior change for every other caller, co-located test coverage added.
- **Reconcile/retry polish:** transfer-reference entry stays `dir="ltr"` (confirmed unchanged); failed-payout
rows keep their visible failure reason + `useRetryPayout` retry. `SkippedNurse.reason` was checked against
the real path (`previewPayoutBatch` currently always returns `skipped: []` — a REQ-036 gap; only the mock
invents string reasons) and correctly left `dir="ltr"` free text, per the phase note, rather than inventing
a translation map for something that isn't a documented stable code today.
- The payout-batch detail page is unified onto `PageHeader` and adopts the page-only slice of
`useAdminListState`.
### 3.6 Primitives v2
- **`AdminDataTable`:** optional per-column `sortable` + a `sort`/`onSortChange` pair (renders MUI's native
`TableSortLabel`, the caller owns the 3-state cycle), an opt-in `stickyHeader` (bounded scroll viewport,
`stickyMaxHeight`, MUI's own `stickyHeader` mechanics — a self-contained viewport rather than depending on
window-scroll math or a `layout/` import), per-column `minWidth`, and a `footer?: string` line (callers
pass the existing `t('showing_range', {from,to,total})` i18n key — it already existed in the `admin`
namespace, just unused until now). `align: 'inherit'` and the horizontal-scroll container are unchanged.
- **`AdminPager`:** the `admin.page_indicator` i18n key regained its `{total}` («صفحه {page} از {total}» —
it had regressed to `"صفحه {page}"` while the non-admin `common.page_indicator` kept the full form). The
component's own API is unchanged (it still takes a caller-composed `indicator` string, matching its
established pattern); every caller across the whole admin/partner surface now passes
`t('page_indicator', { page, total: pageCount })`.
- **Detail headers unified onto the shared `PageHeader`:** the four divergent patterns (verification case,
ticket thread, payout batch, partner-center detail) all now render through `PageHeader`. It gained two
small, additive props during integration: `meta?: ReactNode` (a chip-row slot below the title, distinct
from the button-oriented `actions` — the ticket thread's category/status/linked-record chips) and
`onBack?: () => void` (an alternative to `backTo` for `useAdminBackToList`-style back navigation, takes
precedence when both are given). Both are additive/optional; no existing caller changed behavior.
- **Jalali date inputs everywhere:** `JalaliDateField` replaces every native `type="date"` under `/admin`
audit from/to, the payout window, the holiday date, and the credential issued/expires fields. No Gregorian
native input remains anywhere in the backoffice.
- **`AuditLogRow`:** the expand chevron now rotates on open (CSS `transform`, `--bal-motion-fast`), the
header carries `role="button"`/`tabIndex`/`aria-expanded` + `Enter`/`Space` keyboard support (kept as a
`Stack` with ARIA semantics rather than a real `<button>`, avoiding a polymorphic-`component` TS friction
point). A new `actorLabel` prop (+ exported `actorDisplay`/`actorLabelFrom` helpers) renders the resolved
actor name, falling back to `#id` until the REQ-061 lookup lands; the audit page and the roles grid both
batch-resolve every visible actor id in one `useUserLookup` call.
### 3.7 Partner portal — professional, light-touch
- **Localized booking statuses:** the 7 wire codes (`pending_payment``cancelled`) now render through
`StatusChip` + translated labels (`bstatus_*`) in **both** the table and the filter menu — the worst
partner-facing defect the audit found (a raw English code shown to a Persian-speaking center admin) is
gone. `partner/bookings/page.tsx` also adopts `useAdminListState`.
- **Scoped read-only booking detail:** a new `partner/bookings/[id]/page.tsx` — dates, a status timeline
(the shared `StatusTimeline`), and the patient's display name only, **no** clinical content, address, or
money. Backed by a new `getMySponsoredBookingDetail` on `PartnerCenterApi` (REQ-064, gap — mock-backed
with a synthetic timeline consistent with the booking's current status).
- **CSV export on settlement:** a new dependency-free `client/src/utils/toCsv.ts` (CRLF line endings, proper
comma/quote escaping, co-located tests) drives a «خروجی CSV» button on `partner/settlement/page.tsx` — a
client-side, current-result-set export via a UTF-8-BOM-prefixed `Blob` download, so Persian text renders
correctly when opened in Excel.
- **Portal identity in the chrome:** `PartnerLayout`'s TopBar identity slot (already showing the center's
name) gained a compact merchant-of-record `StatusChip` beside it, so the MoR state is now visible on
every portal page, not only the home screen. Additive change to shared layout chrome; `ProfileSummary`
itself is untouched.
### 3.8 Dead ends & honest nav
- **`admin/users`** is now a real read-first directory (was a `PlaceholderScreen`) — search by name/phone
over the same REQ-061 seam `UserPicker` uses, role chips, and a per-row link into the audit log
(`/admin/audit?entityType=User&entityId={id}` — the one console that already supports an entity filter).
A ticket-queue deep link was deliberately **not** offered: the admin ticket queue has no actor/user filter
to land on, and a fake-looking link would be dishonest.
- **`admin/notifications`** stays a placeholder, but it was never in `AdminLayout`'s nav to begin with
(removed in ui-phase-10 — no real admin feed exists yet) — so "no placeholder screen reachable from the
admin nav" is already satisfied without further action this phase.
- Small verified cleanups: the dead `dataType==='int'||'decimal'?'text':'text'` ternary in
`admin/config/page.tsx` is gone (the `TextField` just has no `type` prop now — `text` was always the only
outcome); `admin/holidays/page.tsx`'s lying `TODAY_ISO = ''` constant is replaced by a real
`todayLocalIso()` helper (local date, not UTC — the same class of fix as the payout window).
## How this was built (process note)
Given the scope (8 independent subsystems touching ~50 files), four slices ran as **parallel background
agents** with disjoint file ownership (ticket console, verification desk, money desk, partner portal), each
briefed with the shared primitives' exact APIs up front; the remaining pages (roles, partners, alerts,
config, holidays, audit, reviews, users) and every shared primitive (`useAdminListState`, `UserPicker`/
`NursePicker`, `AdminDataTable`/`AdminPager` v2, `AuditLogRow` v2) were built directly. Two real integration
bugs surfaced only once all slices landed together: `useAdminListState<F extends Record<string, unknown>>`'s
generic constraint rejected every `interface`-declared filter type across five separate pages (TS quirk —
`interface`s don't structurally satisfy a `Record<string, unknown>` constraint the way object-literal types
do); fixed by dropping the constraint entirely (the hook never needed it). And the `setDraft(next); apply()`
same-handler stale-closure bug (§3.1) appeared independently in two pages; fixed once in the hook
(`applyFilters`) and both call sites updated. `PageHeader` was extended twice by two different agents in the
same window (`meta` and `onBack`) without collision — both compose cleanly. Full `npm run check` (0 errors)
and `npm run test:ci` (114 suites / 522 tests, all passing) were run only after every slice landed, to avoid
transient noise from concurrent in-flight edits.
## What is now testable (and exactly how)
1. `/fa/admin/tickets`: apply a status filter + go to page 2 → open a ticket → browser back → filter and
page intact; paste the list URL into a new tab → same view.
2. `/fa/admin/config`: page past page 1 (mock has >20 configs across data types once seeded further, or
verify the pager renders correctly at 1 page today — the mechanism is real either way) → edit a row →
saves; the history drawer pages too (`vat_rate`/`platform_fee_rate` have seeded history).
3. `/fa/admin/roles` → «اعطای نقش»: type a partial name → options show name + masked phone + id → pick one
→ the confirm text names the person, not a number.
4. `/fa/admin/alerts` before `/me` hydrates (throttle the network in devtools): "assign to me" is disabled
with a tooltip.
5. `/fa/admin/verification`: search a seeded nurse by name → row found; waiting-time column shows amber/red
for old cases; open a case → «پرونده بعدی» walks the queue without returning to the list; arrow keys work.
6. `/fa/admin/tickets/[id]`: open a long thread → scrolled to the newest message; toggle internal mode →
composer turns amber, send button reads «ثبت یادداشت داخلی»; close (behind `TICKET_LIFECYCLE_ENABLED`
flip it locally to demo) → leaves the open queue; reopen restores it.
7. `/fa/admin/payouts`: window defaults match today's local date (test near midnight or spoof the clock);
preview → run → the confirm shows total/count/date and stays disabled until «تایید» (or the exact amount)
is typed.
8. `/fa/admin/audit`: pick from/to with the Jalali picker (no native date input anywhere); expand a row →
chevron rotates, `aria-expanded` toggles, the actor shows a resolved name (or `#id` until resolved).
9. `/fa/partner/bookings`: statuses render as Persian `StatusChip`s in the table and the filter menu; click
a row → the scoped detail (dates/timeline/patient name only); `/fa/partner/settlement` → «خروجی CSV» opens
in Excel with correct Persian text.
10. `/fa/admin/users`: search a seeded name/phone (min 2 chars) → results show role chips + a working audit
link.
11. Every pager/footer reads «صفحه ۲ از ۷» / «نمایش ۱–۲۰ از ۱۲۴» with locale digits across all four axes
(`/fa`+`/en` × light+dark); no admin nav item leads to a placeholder.
## What is mocked / waiting on a real service
See `mocks-registry.md`'s updated `AdminApi`/`TicketsApi`/`VerificationApi`/`PartnerCenterApi` rows for the
full detail. Summary: **REQ-061** (admin user directory — `searchUsers`/`lookupUsers`) backs `UserPicker`/
`NursePicker`/`AuditLogRow`'s name resolve; **REQ-062** (verification queue `search` + whole-desk `counts`);
**REQ-063** (ticket close/reopen/assign + `assigneeUserId`, gated behind `TICKET_LIFECYCLE_ENABLED`);
**REQ-064** (partner scoped single-booking read + timeline). All four are built UI-complete against their
domain's mock, with `clientApi` already mapped to the proposed route — flipping the seam is a one-line
change per domain once each lands, no hook/component change.
## Contracts
No backend contract was consumed this phase (frontend-only phase). Requests filed in
`dev/shared-working-context/frontend/requests/for-backend.md`:
- **REQ-061** — Admin user lookup: name/phone search + batch id→label resolve.
- **REQ-062** — Verification queue enrichment: name/phone search + per-status counts.
- **REQ-063** — Ticket lifecycle mutations (close/reopen/assign).
- **REQ-064** — Partner scoped booking detail (read-only summary).
Not re-filed (referenced instead, per the phase's explicit instruction): ui-phase-10's REQ-059 (admin ticket
queue unread/last-activity enrichment).
## Docs updated
- `client/CLAUDE.md` — the `admin/`/`partner/` Project Structure subtrees rewritten for every page's
ui-phase-11 changes; a new `components/admin/` line describing `AdminDataTable` v2/`AdminPager`/
`AuditLogRow` v2/`UserPicker`/`NursePicker`; `PageHeader`/`ConfirmDialog` entries updated for their new
props; `hooks/` and `utils/` entries updated (`useAdminListState`/`useAdminBackToList`, `toCsv.ts`);
`PartnerLayout.tsx`'s line updated for the MoR chip; the `services/admin` domain bullet updated for the
user-directory hooks.
- `dev/shared-working-context/reports/mocks-registry.md``AdminApi`/`TicketsApi`/`VerificationApi`/
`PartnerCenterApi` rows updated with this phase's additions and the new REQ numbers.
- `dev/shared-working-context/frontend/requests/for-backend.md` — REQ-061…064 appended (see Contracts).
## Foundation extensions (minimal, per the ownership rules)
- **`components/common/PageHeader/PageHeader.tsx`** (phase-1-owned): additive `meta?: ReactNode` (chip-row
slot) and `onBack?: () => void` (callback back-nav, takes precedence over `backTo`) — both optional, zero
behavior change for existing callers, tests updated.
- **`components/common/ConfirmDialog/ConfirmDialog.tsx`** (phase-1-owned): additive
`requireTypedConfirmation?: string[]`/`typedConfirmationLabel?`/`typedConfirmationPlaceholder?` — the
typed "type X to proceed" guard for an irreversible action, generalized from the payout-run use case so
any future money-moving confirm can reuse it. Optional, zero behavior change when absent, tests updated.
- **`components/admin/SupportAlertCard.tsx`**: additive `assignSelfDisabled`/`assignSelfDisabledTitle` props.
- **`components/messaging/useThreadScroll.ts`** (ui-phase-10-owned, pre-earmarked for this handoff):
consumed as-is by the admin ticket thread — no changes needed.
## Follow-ups for later phases
- **REQ-061/062/063/064** as filed — each domain's real `clientApi` is already written and route-shaped;
flipping `USE_*_MOCK` (or `TICKET_LIFECYCLE_ENABLED` for REQ-063 specifically) is the only change needed
once the backend lands.
- **ui-phase-12 (copy/motion)** sweeps these surfaces per the standard handoff — the URL-synced list state,
the picker pattern, and the four new REQ numbers are the load-bearing decisions to carry forward (also
saved to persistent memory, see below).
- A full split-pane verification case view (queue rail + case detail) was explicitly deferred — the
next/prev affordance delivers the throughput win this phase asked for at a fraction of the layout risk;
worth revisiting if reviewer throughput is still a bottleneck after this ships.
- Admin ticket-queue `unreadCount`/`lastMessageAt` enrichment (the REQ-028 admin-side gap) stays
ui-phase-10's to file — referenced, not duplicated, here.
@@ -0,0 +1,269 @@
# UI Phase 12 — Copy, motion & final polish — Report (2026-07-19)
This is the closing phase of the UI build chain (phases 012). Scope: a checked-in Persian style guide
+ lint, the verified copy defects from the microcopy/cross-cutting audits, ICU plurals, arrows out of
strings, a trust-copy pass, config-served policy numbers, a restrained app-wide motion language behind a
single reduced-motion gate, an a11y sweep, two desktop-aware layouts, and this closing QA pass.
## What was built
### 3.1 — Persian style guide + enforcement
- `client/messages/STYLE.md` — the binding one-pager: brand spelling (ZWNJ), تأیید hamza, one جستجو
form, ZWNJ rules, punctuation, domain glossary (بیمار not مددجو), the shell-naming system
(اپلیکیشن for end-user shells, کنسول for back-office), the verification-pipeline-vs-KYC-step naming
split, a nurse-facing/admin-facing status-vocabulary rule, digits policy, register, and the
policy-number-interpolation rule.
- `client/scripts/check-copy.mjs` — a ~100-line dependency-free Node script that flattens `fa.json` and
greps every leaf string against 5 banned-variant rules (plain-space brand, hamza-less تایید, جست‌وجو/جست
و جو, the «بازی» word-boundary trap, the archaic می‌گردد passive — with a leading-space anchor so the
legitimate verb «برمی‌گردد» is never a false positive). Wired as `npm run lint:copy`, folded into
`npm run check`. Currently: **1919 strings checked, 0 banned variants.**
### 3.2/hamza/جستجو/می‌گردد sweep — ~90 keys swept across `fa.json`
- Brand name: 5 keys (`common.brand`, `auth.customer_title`/`select_role_title`/`account_error_body`,
`verification.start_body`) — plain-space → ZWNJ.
- تأیید hamza: ~65 occurrences normalized to the hamza form across `home`, `profile`, `nurseProfile`,
`bank`, `activation`, `search`, `booking`, `payment`, `auth`, `verification`, `refunds`, `bnpl`,
`payouts`, `admin`, `partner`, `legal`. `bank.status_verified_chip`'s typo («تاییدشد» missing the final
ه) is fixed to «تأییدشده» in the same edit.
- جستجو: 3 keys folded (`coverage.empty_warning`, `booking.missing_nurse_body`, one `legal` section body).
- Archaic می‌گردد → می‌شود: `refunds.confirm_restate`, `admin.mod_confirm_publish`,
`admin.cfg_save_confirm_body` (3 occurrences, matching the audit's own recount, not the "1" a naive grep
would find).
- Grammar bugs: `booking.evv_no_open_check_in` («ورود بازی» → «ورودِ ثبت‌شده‌ای برای این ویزیت وجود
ندارد؛ ابتدا ورود را ثبت کنید.») and `admin.alert_empty` («هشدار بازی» → «هشداری برای رسیدگی نیست.»).
- `tickets.thread_empty_body` comma splice → two sentences.
- `address.line_hint`**already fixed by ui-phase-9** (verified in its report); not re-touched.
- EVV introduced once: `booking.evv_visits_subtitle` now spells out «ثبت حضور الکترونیکی (EVV) — ورود و
خروج شما ثبت می‌شود تا ویزیت بدون اختلاف تأیید شود» (this subtitle renders once per visits-page
load); the short chips (`evv_check_in`/`evv_check_out`) stay abbreviated.
- en: `auth.nurse_subtitle` "licence" → "license"; 6 curly-apostrophe holdouts in the `admin` namespace
(`no_permission`, `doc_error`, `payout_run_confirm_body`, `mod_confirm_publish`, `access_denied`,
`invoice_pdf_error`) → straight quotes. Zero curly quotes remain (verified by grep).
### Shell naming + verification-pipeline naming (fa only — en had no overload)
- `shell.nurse_app`/`switch_to_nurse`: «نمای پرستار» → «اپلیکیشن پرستار» (end-user shells share one
metaphor); `shell.partner_console`: «پرتال همکار» → «کنسول همکار» (back-office shells share the other).
`booking.evv_nurse_view` ("نمای پرستار" as a perspective chip label, not a shell name) is untouched —
it's a different concept and STYLE.md calls out the distinction explicitly.
- «احراز هویت» renamed to «تأیید صلاحیت» for the **pipeline** (11 keys: `nav.verification`,
`nurseProfile.unverified_body`/`unverified_cta`, `verification.title`/`load_error`/`start_title`/
`start_cta`/`progress_title`/`approved_title`/`credentials_needs_start`/`review_approved_title`,
`admin.ver_title`/`ver_case_title`); `verification.explainer_not_verified` rephrased to «صلاحیت این
پرستار هنوز تأیید نشده است.» for the same reason. The **KYC step** keeps «احراز هویت» everywhere
(`verification.step_identity_kyc`, `admin.step_identity_kyc`, `activation.row_identity`) — the two no
longer share a name, so a nurse who passed KYC no longer sees the pipeline nav item read as
contradictorily incomplete under the same word.
- Status vocabulary: `admin.step_failed` («ناموفق» → «ردشده») now matches admin's own
`agg_rejected`/`rstatus_rejected`/`mstatus_rejected` pattern (all «ردشده»); nurse-facing
`verification.status_failed` keeps «رد شد» (STYLE.md §9 — one nurse-facing form, one admin-facing
form). Money-failure vocabulary (`ناموفق` for payouts/refunds/batches) is untouched — a payment
*failing* is a different concept from a document being *rejected*.
### 3.3 — ICU plurals with designed `=0` cases
- fa gained ICU plural + a real zero-state on the 4 keys the audit flagged:
`search.cta_view_results`/`results_count`/`reviews_count`, `booking.session_count`. Zero renders
«پرستاری یافت نشد» / «بدون نظر» / «ویزیتی نیست», never «۰ پرستار».
`search/SearchScreen.tsx` doesn't need a call-site change — `cta_view_results` is only reached
when `count > 0` (the sibling `cta_zero_title` branch handles the zero state), so the ICU `=0` case is
a defensive designed fallback, not the primary render path.
- en's own `cta_view_results`/`results_count` `=0` case fixed from the audit's flagged "View no nurses" /
"No nurses" to "No nurses found" (`cta_view_results` restructured so "View" only prefixes the non-zero
cases — `{count, plural, =0 {No nurses found} one {View # nurse} other {View # nurses}}` — rather than
gluing "View" onto a full sentence).
### 3.4 — Arrows out of strings
- 4 keys per catalog had a literal ←/→ (not 5 — `payment.cta_pay` already lost its arrow before this
phase): `booking.continue_payment`, `auth.nurse_switch`/`customer_switch`, `admin.cfg_history_change`.
All four are now plain text; direction moves into a mirrored `AppIcon icon="forward"` (registered in
`DIRECTIONAL_ICONS`, auto-flips under `[dir='rtl']` regardless of host component — verified by reading
`AppIcon.tsx`, no per-call-site mirroring logic needed):
- `bookings/request/[id]/page.tsx`'s continue-to-payment button: `endIcon="payment"``endIcon="forward"`
(fixes the en wrong-direction arrow **by construction** — the icon mirrors, the string never carried
direction to begin with).
- `PhoneStep.tsx`'s role-switch link: an inline `<AppIcon icon="forward" size={14}>` after the text.
- `admin/config/page.tsx`'s config-history diff: `cfg_history_change` split into
`cfg_history_change_old`/`_new` (each holding one interpolation), rendered as two `Typography`s joined
by a mirrored `AppIcon icon="forward"` — the old defect (a translator hand-mirroring `{old} ← {new}`)
can't recur.
- Grep for `←|→` across both catalogs → zero hits (STYLE.md prose itself has none either).
### 3.5 — Trust-moments copy pass
- **BNPL de-jargon**: `bnpl.ownership_note` rewritten reader-first, «نکول» removed entirely — "you pay
installments directly to the provider; Balinyaar receives the full amount up front, and if an
installment goes unpaid, that risk sits with the provider — never you or the nurse" (both catalogs).
Kept provider-agnostic (no `{provider}` interpolation) because the call site (`MethodStep.tsx`) shows
this line **before** a provider is chosen, ahead of the provider list.
- **OTP-screen reassurance** and **checkout "why is this safe"**: found **already delivered**
`AuthCard`/`TrustBullets` (ui-phase-3) already renders three trust bullets (verified nurses, escrow
payment, support) directly under the phone-entry card on both the customer and nurse login steps, and
`EscrowExplainer` (ui-phase-6) already supplements `EscrowNotice` with a 3-step visual + the
cancellation-policy implication at checkout. Neither needed new copy; verified by reading `AuthCard.tsx`/
`PhoneStep.tsx`/`EscrowExplainer.tsx` rather than assumed from the audit's older snapshot.
- Verification pipeline naming + status vocabulary: see above.
### 3.6 — Config-served policy numbers
- New `client/src/constants/policy.ts`: `DISPUTE_WINDOW_HOURS=72`, `CANCELLATION_LEAD_HOURS=24`,
`REFUND_ETA_MIN_BUSINESS_DAYS=7`, `REFUND_ETA_MAX_BUSINESS_DAYS=10` — re-exported via
`src/constants/index.ts`.
- `payouts.explainer_point_2` takes `{hours}`; `refunds.lead_gt_24h`/`lead_lt_24h` take `{hours}`;
`refunds.eta_business_days` takes `{minDays}`/`{maxDays}` (both catalogs). Wired at the 3 call sites:
`nurse/earnings/page.tsx`'s `ExplainerCard`, `CancellationPolicyDisclosure.tsx`, `RefundEtaBanner.tsx`.
`services/refunds/constants.ts`'s `BNPL_REFUND_ETA_BUSINESS_DAYS` now sources from
`REFUND_ETA_MAX_BUSINESS_DAYS` instead of its own hardcoded `10`, so the mock's projected refund date and
the displayed ETA window can never drift apart.
- **REQ-065** filed (`dev/shared-working-context/frontend/requests/for-backend.md`) — a
public/authenticated policy-config read; verified first that only admin-scoped `platform_config/*`
exists (per the contract docs), so this is a genuine gap, not a guess.
### 3.7 — Honest search empty state
- **Already fixed by ui-phase-4**`search.empty_suggest_city` (the Mashhad/Isfahan/Shiraz nonsense
suggestion) was already deleted and replaced with `empty_suggest_date` in both catalogs. Verified by
grep (`مشهد|اصفهان|شیراز` → no matches) rather than re-implemented.
### 3.8 — Motion pass
- `globals.css`: one `bal-fade-in` keyframe (150200ms fade + 4px slide, `var(--bal-motion-base)` +
`var(--bal-easing-standard)`) applied via a `data-bal-route-fade` attribute selector, and **the single
reduced-motion gate for the whole app** — a universal `*, *::before, *::after { animation-duration:
0.01ms !important; transition-duration: 0.01ms !important; … }` under
`@media (prefers-reduced-motion: reduce)`. This is the one rule that makes MUI's own JS-driven
Dialog/Drawer/Menu/Collapse/Fade transitions collapse too (they don't read CSS custom properties, so a
token-only gate wouldn't reach them) — no component anywhere needs its own reduced-motion branch.
- `tokens.css` additionally zeroes `--bal-motion-fast/base/slow` under the same media feature, for any
future consumer that reads the token value directly.
- New shared component `components/common/RouteFadeIn/` (tested) — wraps `{children}`, keyed on the
locale-stripped pathname (`@/i18n/navigation`'s `usePathname`) so it remounts (replaying the fade) on
navigation but never on an in-place re-render. Mounted inside the `ErrorBoundary` in **all five** shells
(`CustomerLayout`, `TopBarAndSideBarLayout` — nurse/admin/partner, `FocusedLayout`, `PublicLayout`) —
every route in the app gets the fade, not a hand-picked subset.
- `theme.ts`: `MuiDialog`/`MuiDrawer`/`MuiPopover`/`MuiMenu` all get an explicit
`defaultProps.transitionDuration: { enter: 200, exit: 120 }` in **one place** (matching
`--bal-motion-base`/`-fast`) instead of MUI's per-variant defaults, so every dialog/bottom-sheet/menu in
the app now shares one calm timing.
- Skeleton→content crossfade: the `data-bal-route-fade` primitive is reusable for this (any screen can
attach it to its populated-state branch); demonstrated on `search/results/page.tsx`'s results grid
(§3.10) as a worked example rather than retrofitted to every list screen in the app — flagged as a
mechanical follow-up below, not a silent scope cut.
### 3.9 — A11y sweep
- **`AppIconButton`**: the underlying `IconButton` now always receives `aria-label={title}` directly (not
only via the `Tooltip` wrap), so a **disabled** icon-only button — where the Tooltip isn't rendered at
all — keeps an accessible name. A caller-supplied `aria-label` still wins (spread order). New test:
"keeps an accessible name from `.title` even when disabled". `useMemo` dependency array fixed
(`react-hooks/exhaustive-deps`) as part of the change.
- **`aria-live`**: `CountdownTimer`'s one-time elapsed transition now carries `aria-live="polite"` (the
coarse-mode label already had it from an earlier phase; the fine per-second clock deliberately still
doesn't — spam risk, unchanged). `ErrorState` (the one "query failed" pattern, ~55 sites) gets
`role="alert"` — one change covers every error/retry region in the app. `ErrorBoundary` gets the same.
`PaymentStateCard` (the one terminal/wait-state card for card + BNPL checkout, including the
pending→succeeded/failed poll) gets `aria-live="polite"`.
- **`aria-expanded`/`aria-controls`**: `EscrowExplainer`'s toggle gained `aria-controls` (it already had
`aria-expanded`); the admin ticket thread's refund-panel toggle (`admin/tickets/[id]/page.tsx`) gained
both (it had neither). `nurse/earnings`'s `ExplainerCard` and `AuditLogRow`/`EmergencyPlaybookRow`
already had both from earlier phases — verified, not re-touched.
- **Contrast**: `NurseDashboardScreen.tsx`'s unread-notifications label was the one real terracotta-on-text
finding (`color: 'var(--bal-secondary)'` on a `body2` unread count) — swapped to
`--bal-secondary-dark`. Every other `--bal-secondary` text usage found by grep was already
`--bal-secondary-dark` (BNPL screens) or an icon/background (non-text contrast rules, not the AA text
threshold the audit flagged).
### 3.10 — Desktop-aware layouts (scope-boxed to the two named surfaces)
- **Checkout** (`bookings/checkout/page.tsx`): above `md` (~900px), a two-column layout — the
summary/total/countdown/breakdown/escrow content on the reading side (flex `62%`), a `position: sticky`
order-summary panel with the pay CTA on the other. The pay-action JSX (total, CTA, secure-gateway note,
BNPL button, inline error) is a single `payActions` value rendered in **both** the desktop panel and the
mobile `StickyActionBar` — same handlers, same state, no duplicated logic; only one is visible at a time
via `sx={{ display: { xs: …, md: … } }}`, the same pattern the codebase already uses for
`BottomBar`/`CustomerDesktopNav`.
- **Search results** (`search/results/page.tsx`): the populated/skeleton lists switch from a single-column
`Stack` to a `display: grid` with `gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }` — two columns above
`md`. Bounded by the customer shell's existing `CONTENT_MAX_WIDTH` (800px, unchanged, shared across the
whole customer app) — each card gets meaningfully more width than the old full-width single column, but
a true full-bleed desktop layout or list+detail split would need a per-route shell override, which is
exactly the "full responsive pass" the phase doc explicitly defers post-chain. Flagged, not silently cut.
### 3.11 — Final QA walkthrough
**Static verification performed** (all green): `npm run check` (type + lint + the new `lint:copy`),
`npm run test:ci` (115 suites / 526 tests, including 2 new/extended ones — `RouteFadeIn.test.tsx`,
`AppIconButton.test.tsx`'s new disabled-name case), a key-parity script (`fa.json`/`en.json` — 1891 leaf
keys each, zero one-sided keys, matching array lengths on `legal.terms_sections`/`privacy_sections`), and
a manual grep pass for stray hex literals in every changed file (none found — every new color reference is
a `var(--bal-*)` token).
**Could not perform a live in-browser visual pass.** This sandbox has no headless-browser tool
(`chromium-cli` is not installed) and direct HTTP access to the dev server's `localhost` port returns a
proxy `502` rather than reaching Next.js (confirmed: the dev server itself started cleanly — `✓ Ready in
5.8s` in its log — the request never reached it). Per this repo's own verification guidance, I'm stating
this explicitly rather than claiming a visual check that didn't happen. **A human should run `npm run dev`
and spot-check, on `/fa` and `/en` × light and dark × a narrow and a ≥1100px viewport:**
- [ ] Login (`/login`): brand reads «بالین‌یار» identically everywhere it appears (header, trust bullets,
account-error retry copy); trust bullets render under the phone-entry card.
- [ ] Search (`/search` → results): filters producing zero results show «پرستاری یافت نشد» (fa) / "No
nurses found" (en) — never «۰ پرستار»/"View no nurses"; at ≥900px, result cards render two-up.
- [ ] Nurse EVV (`/nurse/visits`): the visits subtitle spells out EVV once; attempting a check-out with no
open check-in shows the corrected sentence (no «ورود بازی»).
- [ ] Bank (`/nurse/bank`): the verified chip reads «تأییدشده» (not «تاییدشد»).
- [ ] Checkout (`/bookings/checkout?request_id=…`): at ≥900px, a two-column layout with a sticky right-side
pay panel; at <900px, the original bottom sticky bar; the "continue to payment" button's chevron
points the reading-forward direction on both locales (mirrored, not baked into the string).
- Currently only reachable via the request-flow with a real/mocked `accepted_awaiting_payment` request.
- [ ] BNPL comparison (`/bookings/checkout/bnpl`): the ownership note reads plainly, no «نکول».
- [ ] Any route transition: a calm ~150200ms fade/slide plays once; with DevTools' "Emulate CSS media
feature `prefers-reduced-motion: reduce`" enabled, everything appears instantly (no fade, no MUI
dialog/menu transition either).
- [ ] Any icon-only disabled button (e.g. a paused/loading admin action): inspect the accessibility tree —
it still has a name.
- [ ] Config history (`/admin/config`, open a row's history drawer): the old→new diff shows a mirrored
arrow icon between two values, not a baked-in `←`/`→` character.
## What is now testable (and exactly how)
1. `cd client && npm run check` — passes, including `lint:copy`. Temporarily add «بالین یار» (plain
space) to any `fa.json` value → `npm run lint:copy` fails with the exact key path; revert.
2. `npm run test:ci` — 115 suites / 526 tests pass, including the two touched/added shared-component tests.
3. Grep both catalogs for `←|→` → zero hits. Grep for `تایید` (hamza-less) → zero hits (only `تأیید` and
its compounds remain). Grep for `جست‌وجو`/`جست و جو` → zero hits.
4. The four visual-axis items above, once a human can reach a browser.
## What is mocked / waiting on a real service
- **REQ-065** (new, this phase) — public/authenticated policy-config read (dispute-window hours,
cancellation lead hours, refund ETA days). Until delivered, `client/src/constants/policy.ts` is the
single source; every message key that used to hardcode a policy number now takes it as an ICU param from
this file. No mock-registry entry needed — this isn't a seam behind a DI interface, it's a constants file
standing in for a config read that doesn't exist yet for a non-admin caller.
- No other new mocks this phase; no `services/{domain}` seam was touched.
## Contracts
- Consumed: none new. REQ-065 filed against the existing `platform_config` admin-only surface
(`dev/contracts/domains/` — verified no public projection exists before filing, per the phase's own
instruction not to file speculatively).
## Docs updated
- `client/messages/STYLE.md` — new, the Persian style guide (this phase's own deliverable).
- `client/CLAUDE.md` — see the diff in this change: mentions `messages/STYLE.md` + `npm run lint:copy` in
the i18n section, the reduced-motion gate location (`globals.css`), `RouteFadeIn` in the component-library
table, and `constants/policy.ts` in the constants section.
- `dev/shared-working-context/frontend/requests/for-backend.md` — REQ-065 appended.
## Follow-ups for later phases (post-chain)
- **Skeleton→content crossfade beyond the one worked example.** `data-bal-route-fade` is reusable
(a one-line addition per screen) but wasn't retrofitted onto every list/detail page in the app — a
mechanical, low-risk follow-up, not a defect.
- **A true desktop search layout** (list+detail split, or breaking out of the 800px `CONTENT_MAX_WIDTH`
for this one route) — the phase doc explicitly defers "a full responsive pass" post-chain; the 2-column
grid shipped here is the scope-boxed interim step.
- **REQ-065** — once delivered, replace `constants/policy.ts`'s hardcoded numbers with a fetched config
read (the ICU-param call sites don't change, only where the numbers come from).
- A live in-browser visual confirmation of the walkthrough checklist above — this environment couldn't run
one; flag to the human reviewer.
## Memory
A `project`-type memory note was saved (`ui_phase_12_copy_motion_polish.md`) summarizing the STYLE.md
decisions, the reduced-motion gate's location and mechanism, and the sandbox's browser-verification gap, so
a future agent doesn't have to rediscover any of it.
@@ -0,0 +1,162 @@
# UI Phase 13 — Public Front Door — Report (2026-07-19)
## The §3.1 decision (recorded first, per the phase's own gate)
Framed the three guest-browse tiers and decided **ship (a) landing + (b) static public pages now;
defer (c) guest search + public nurse profiles as a REQ-gated follow-up**. Full rationale recorded in
[product/notes/open-questions.md](../../../product/notes/open-questions.md) ("Decided — public
guest-browse depth" section). Tier (c)'s two endpoints are filed as REQ-066/REQ-067 (see Contracts
below) — nothing in this phase calls them; no guest search/profile screen was built.
## What was built
- **The guest front door itself**`client/middleware.ts`: after the existing next-intl 307/308
early-return, an **unauthenticated** exact match on `pathWithoutLocale === '/'` is
`NextResponse.rewrite()`d to `/{locale}/welcome` (never a redirect — the browser URL and SEO
canonical stay `/`). An **authenticated** hit on `/welcome` redirects to `/`. Every other path's
behavior (locale detection, the private-route → `/login?next=` redirect, the `returnUrl` capture,
the copied next-intl hreflang headers) is untouched — confirmed by direct testing, not just code
reading (see "What is now testable").
- **`ROUTES.WELCOME` (`/welcome`)** added to `src/constants/routes.ts` and to `PUBLIC_PATHS`. A
comment on `PUBLIC_PATHS` (and in the middleware) calls out the `startsWith`/`'/'` trap explicitly
for the next agent who touches either file.
- **The landing** at `client/src/app/[locale]/(public-routes)/welcome/`:
- `page.tsx` — thin RSC, `generateMetadata` (description, `alternates.canonical``/{locale}`,
`openGraph`). Deliberately does **not** override `title` — it inherits the root layout's default
(`"بالین‌یار"` / `"Balinyaar"`), which is also how view-source tells it apart from the customer
home's own `shell.customer_app` title when both serve at the same `/{locale}` URL depending on
auth state.
- `WelcomeScreen.tsx` — an **async Server Component**, not a `'use client'` screen (no page in this
app has needed that treatment before; here it's the point — zero query hooks anywhere in the
tree). Sections, in order: hero (`BrandMark` + tagline + subtitle + CTA to `/login`), a static
5-tile category grid (`CategoryTile`, static i18n labels — see the CategoryTile change below), a
3-step how-it-works (step 2 renders the actual `<EscrowNotice />` component, never a paraphrase),
a static trust/verification explainer (identity/license/INO/bank — written from
`product/business/02-nurse-verification.md`, since `VerificationPanel` requires a live
`nurseId`-keyed badge fetch and can't be reused on a zero-data page), a nurse-recruitment CTA
linking to `/login?role=nurse`, and a footer (terms/privacy links, a static contact note, no
duplicate locale switcher — `PublicLayout`'s corner strip already provides one for every public
route including this one).
- `opengraph-image.tsx``next/og`'s `ImageResponse`, a brand-mark composition (teal background,
the logo shape reproduced in plain divs, terracotta accent dot, "Balinyaar" wordmark) at 1200×630.
**Latin-only**`ImageResponse`'s bundled fallback font doesn't cover Persian glyphs, and
embedding a Mikhak font buffer wasn't verified working in the time available; flagged as a
follow-up, not silently skipped.
- **`CategoryTile` gained an `href` prop** (`client/src/components/CategoryTile/CategoryTile.tsx`):
when given, the tile renders via `ButtonBase`'s `component` swap to `AppLink` (the tile itself
*becomes* the anchor) instead of a click handler — the same polymorphic pattern `AppButton` already
uses, so there's no button nested inside a link (invalid HTML) and no new client wrapper component
needed just to make a static tile navigate. Existing `onClick`/`selected` callers are unaffected.
Test coverage added (`CategoryTile.test.tsx`): renders as a link with the given `href`, and no
`button` role is present when `href` is set.
- **SEO/metadata infrastructure:**
- `SITE_URL` constant (`client/src/config.ts`, from `NEXT_PUBLIC_SITE_URL`, falls back to
`http://localhost:3000`) — the root layout's `generateMetadata` now sets
`metadataBase: new URL(SITE_URL)`, so every child page's relative OG/canonical URLs resolve
absolutely.
- `src/app/robots.ts` **replaces** the old contradictory static `public/robots.txt` (deleted):
allows the public surface, disallows every private route root (`nurse`, `admin`, `partner`,
`bookings`, `patients`, `addresses`, `wallet`, `profile`, `support`, `notifications`,
`select-role`, `onboarding`, `search`) for both locales via a wildcard pattern, points at
`sitemap.xml`.
- `src/app/sitemap.ts` — public routes only (`/`, `/login`, `/terms`, `/privacy`) × both locales,
with `hreflang` alternates.
## What is now testable (and exactly how)
**Important caveat first:** the **dev server** (`npm run dev`, Turbopack) in this sandbox did not
reliably invoke middleware for the *literal* root path `/` — verified down to a minimal
`matcher: ['/']` control middleware that unconditionally returned a JSON body and still never fired
for `/` (a sibling `/ping` path with the identical matcher also silently fell back to a stale cached
render instead of hitting the handler). This reproduced even after full `.next` wipes and process
restarts, and is **not specific to this phase's logic** — a **production build** (`npm run build` +
`npm run start`) exhibits none of it and confirms the intended behavior end-to-end (below). Treat this
as a known dev-server quirk in this Next 16.2.9/Turbopack build, not a defect in the shipped code; a
human should re-confirm with `npm run dev` on their own machine before assuming it's universal.
Verified against a **production** server (`NEXT_PUBLIC_API_URL=... npm run build && npm run start`):
1. `GET /` (no cookies) → `307``Location: /fa` (next-intl's own redirect, untouched).
2. `GET /fa` (no cookies) → `200`, response header `x-middleware-rewrite: /fa/welcome`; body's
`<title>` is the bare default `"بالین‌یار"` (not the customer app's `"اپلیکیشن خانواده | بالین‌یار"`)
and contains the hero copy/CTA — the landing, not the customer home.
3. `GET /fa/welcome` directly → `200`, same landing content.
4. `GET /en` (no cookies) → `200`, `lang="en" dir="ltr"`, no Mikhak reference, title `"Balinyaar"`.
5. With a **valid, unexpired `access_token` cookie** (forged payload for testing — `isTokenAlive` only
checks `exp`, never the signature, by design): `GET /fa``200` with `<title>` =
`"اپلیکیشن خانواده | بالین‌یار"` — the **customer home**, confirming an authenticated `/` is
untouched. `GET /fa/welcome``307``/fa/` (redirected away from the marketing page). `GET
/fa/bookings` → `200` (private route now reachable).
6. Without the cookie: `GET /fa/bookings``307``/fa/login?next=%2Fbookings` — the private-route
gate and `returnUrl` capture are unchanged.
7. `GET /robots.txt` → the new allow/disallow rules + `Sitemap: .../sitemap.xml`.
8. `GET /sitemap.xml` → the 4 public paths × 2 locales with `hreflang` alternates.
9. `GET /fa` view-source → `og:title`, `og:description`, `og:image` (an **absolute** URL),
`og:image:width/height` (1200×630), `canonical``/fa`.
10. `GET` the `opengraph-image` route directly → `200`, `Content-Type: image/png`; verified the bytes
are a real 1200×630 PNG (`PNG image data, 1200 x 630, 8-bit/color RGBA`).
`npm run check` (type + lint + `lint:copy`) is green. `npm run test:ci` — all 115 suites / 527 tests
pass, including the extended `CategoryTile.test.tsx`.
Not independently verified in this session (no running backend, no browser): the actual OTP
login→`RoleRouter` round trip after tapping the hero CTA (the login screen itself is unchanged by
this phase); visual dark-mode/RTL inspection of the landing (code follows the same token/RTL
conventions as every other screen, but wasn't eyeballed in a real browser).
## What is mocked / waiting on a real service
Nothing new. Tiers (a)+(b) are 100% static content — no service calls, no mock flags, no
`services/{domain}` seam touched.
## Contracts
- **Consumed:** none (no data fetching on this page).
- **Filed (proposals only, not built against):**
- **REQ-066** — public (anonymous), rate-limited nurse-search read, for a future tier-(c) guest
search screen.
- **REQ-067** — public (anonymous), privacy-reviewed nurse-profile read, for a future tier-(c)
guest profile screen.
- Both in
[for-backend.md](../frontend/requests/for-backend.md), status `open`; each explicitly says the
frontend has not built anything against it.
## Docs updated
- `product/notes/open-questions.md` — the §3.1 decision + rationale (and the docs HTML was
regenerated: `cd product && node build-docs.mjs`).
- `client/CLAUDE.md`:
- Project Structure — `middleware.ts`'s description (the rewrite + the bare-root matcher-entry
gotcha), `src/app/robots.ts`/`sitemap.ts`, the whole `(public-routes)/welcome/` subtree, and
`CategoryTile`'s new `href` mode.
- Per-page metadata section — `metadataBase`/`SITE_URL` and the `opengraph-image.tsx` convention.
- i18n section — the `welcome` namespace description.
- Route Constants section — the `PUBLIC_PATHS`/`'/'` trap, spelled out again at the point a future
agent is most likely to be editing.
## Follow-ups for later phases
- **Tier (c)** (guest search + public nurse profiles) — REQ-066/067 need a backend phase plus an
explicit privacy sign-off on the nurse-profile field list before any guest-facing search/profile
screen is built. Do not build against a guessed shape.
- **OG image Persian variant** — currently Latin-only by deliberate scope cut (see `opengraph-image.tsx`'s
own comment). A follow-up could embed a Mikhak font buffer (`fs.readFile` the existing
`src/app/fonts/Mikhak-Bold.woff2`) and verify Satori/`next/og` renders Persian glyphs correctly in a
real running server before shipping it — not verified in this session.
- **Dev-server root-path quirk** — if a future phase also needs middleware to act on the literal `/`
segment, re-confirm on a real (non-sandboxed) `npm run dev` whether the matcher gotcha reproduces
there too; if so, the `matcher: ['/', '/((?!_next|_vercel|api|.*\\..*).*)']` fix already applied here
covers it, but worth a second look outside this session's environment.
- **Footer legal links** — phase 3 had already shipped `/terms`/`/privacy` by the time this phase ran,
so the footer links to both (no graceful-omission gap to report).
- **Verification-explainer reuse** — phase 4/8 had already shipped, but their `VerificationPanel`
needs a live `nurseId` badge query and can't be reused as-is on a zero-fetch landing; the trust
section here is independently written static copy sourced from the same product doc. A future pass
could extract a shared *static* label set (`step_identity_kyc`, `step_moh_competency_license`, …
already exist in the `verification` namespace) if keeping the two copies in sync becomes a problem.
## Save memory note
See `MEMORY.md``ui_phase_13_public_front_door.md` for the durable cross-session note (the
rewrite-not-redirect mechanic, the `PUBLIC_PATHS`/`'/'` trap, the dev-server root-path quirk, tier (c)
status).
@@ -0,0 +1,192 @@
# UI Phase 2 — Shells & navigation — Report (2026-07-17)
## What was built
### Locale-aware navigation — one wrapper, both bugs fixed
`src/i18n/navigation.ts` wraps `createNavigation(routing)` (`Link`/`usePathname`/`useRouter`/
`redirect`/`getPathname`). **All chrome navigation now goes through it**: `SideBarNavItem`
renders via the wrapper's `Link` (unprefixed `href`, locale added automatically — one
navigation, no middleware 307 hop), `BottomBar` dropped its `withLocale` helper and `router.push`
via the wrapper, `NotificationBell` and `CustomerLayout`'s support/back buttons route via the
wrapper's `useRouter`. `grep -r "\$\{locale\}" src/layout` and the rewritten chrome components
now returns nothing. A new shared helper, `src/layout/matchActivePath.ts` (unit-tested,
longest-prefix winner-takes-all), backs both the sidebar and every bottom bar's active-item
detection — the sidebar's `pathname.startsWith(unprefixedPath)` bug (always-false against the
locale-prefixed `next/navigation` pathname) is gone; the active item now highlights on every
nurse/admin/partner route for the first time.
**Deliberately not touched:** `AppLink`'s `activeClassName` comparison (same underlying bug,
listed in the phase's suggested fixes). It has zero live consumers (`grep activeClassName` hits
only its own definition + test) and no CSS ever targets the `.active` class it would add —
purely inert. `AppLink` is also used for locale-prefixed navigation at ~10 non-chrome call sites
app-wide (`` `/${locale}${ROUTES.X}` ``); switching its internal `usePathname` to the locale-
stripped wrapper would flip that dead code from harmlessly-wrong to actively-wrong for those
sites without fixing anything visible. Left as-is; not a regression from this phase.
### Customer shell — contextual header + desktop treatment
`CustomerLayout` now renders a contextual `TopBar`: the brand lockup (`BrandLockup`, new — a
compact horizontal logo+wordmark reused in every sidebar shell's drawer header) on the 5 root
tabs, a page title + RTL-mirrored back chevron (`router.back()`) on pushed routes. Titles come
from `src/layout/routeTitle.tsx` — a static longest-prefix route→title map off the `nav`
namespace, plus a `PageTitleProvider`/`usePageTitleOverride` context slot for dynamic titles
area phases will wire in later (ships with static titles only, per scope). **Desktop decision
(≥`md`)**: the mobile `BottomBar` hides entirely in favor of an inline top-nav (`CustomerDesktopNav`,
MUI `Tabs`, same 5 items + `matchActivePath`) rendered as a second row inside the same fixed
`AppBar` (`TopBar` gained a `secondaryRow` slot for this). `BottomBar` also gained
`env(safe-area-inset-bottom)` padding for the iOS home-indicator overlap.
### Nurse shell — a workspace
`NurseLayout`'s sidebar is now sectioned (امروز / حرفهٔ من / مالی / پشتیبانی) via a new
`group` field on `LinkToPage``SideBarNavList` renders a `ListSubheader` whenever consecutive
items' `group` changes. The identity block is a real `ProfileSummary` card (new shared
component, `src/components/ProfileSummary/`): avatar, name (falls back to phone), masked phone
(`dir="ltr"`), and the nurse's own `TrustBadge` (`ownBadgeState(useVerificationStatus())`) —
skeleton while `/me` resolves, never an English "Current User"/"Loading...". Mobile gets a 5-tab
`BottomBar` (امروز/درخواست‌ها/ویزیت‌ها/درآمد/بیشتر); «بیشتر» opens the **same** sidebar drawer
(`TopBarAndSideBarLayout` exposes a `mobileBottomBar` render-prop that hands the drawer's
open-callback down — no second drawer). `ActorSwitcher` (new, dual-role only) sits under the
identity card.
### Admin + Partner shells — a dense console
`AdminLayout`'s sidebar is sectioned (اعتماد / مالی / پشتیبانی / سیستم) with **every
`useAdminCapabilities` gate preserved exactly** — grouping only changes presentation. Added the
missing `ROUTES.ADMIN_USERS` entry, gated on `caps.canManageRoles` like Roles. The
notifications sidebar item is gone, replaced by a header bell (`NotificationBell`'s `role` union
widened to include `'admin'`; `notificationsPath('admin')``ROUTES.ADMIN_NOTIFICATIONS` — both
minimal, pre-existing-seam extensions, not new endpoints). The TopBar carries a compact
`ProfileSummary` identity chip showing the admin's fine-grained role label (`admin.role_*`,
already existed). `PartnerLayout` gets the same TopBar identity slot showing the center's own
name (`useMyPartnerCenter`, skeleton while resolving) — the page-level access-denied state is
untouched.
### Public shell — stripped to a brand frame
`PublicLayout` no longer wraps `/login` in `TopBarAndSideBarLayout` at all: no hard-coded
`'Unauthorized - Balinyaar'` title, no pencil-icon drawer, no empty `BottomBar` strip. It's a
slim corner header (small `logo` icon + `LocaleSwitcher` + dark toggle) over the content —
deliberately not a second big brand lockup, since `AuthCard` already renders `BrandMark` inside
the login card. The dead `BOTTOM_BAR_DESKTOP_VISIBLE` flag and the commented-out anchor
alternates in `layout/config.ts` are gone.
### Cross-cutting engine fixes
- **SSR flash killed structurally, not patched.** `TopBarAndSideBarLayout` + `SideBar` no
longer derive sidebar *structure* from `useIsMobile()`. `SideBar` renders **two Drawers over
one content tree** — `variant="temporary"` (mobile) and `variant="permanent"` (desktop) —
switched purely by `sx` breakpoint `display`. The permanent Drawer is a normal flex sibling of
the main column (`TopBarAndSideBarLayout`'s content row is `direction="row"`), so desktop
reserves its own width as part of native flex layout — first paint already has the sidebar,
and RTL flexbox puts it at the reading-start side with **no manual offset math at all** (the
old `paddingLeft/Right` keyed off `anchor.includes('left')` double-flip hack is deleted
outright, not replaced with a logical-property equivalent — there was nothing left to offset).
The mobile temporary Drawer's `anchor` is still physical (an MUI API constraint) but is now
derived from `theme.direction` at render time, not a hardcoded per-breakpoint constant.
- **Drawer-close scoping fixed.** The close handler moved off the whole content `Stack` onto
`SideBarNavList`'s `onClick` only — toggling dark mode, switching locale, or tapping a divider
inside the mobile drawer no longer closes it; tapping a nav link does.
- **Chrome strings translated.** `'Open Sidebar'``common.open_sidebar`; the starter
`'Logout Current User'` icon-only button is now a labeled, translated (`nav.logout`) full-width
row in the sidebar footer, alongside the new `LocaleSwitcher`.
- **`TopBar` overflow fixed** (`noWrap` + `minWidth: 0` instead of manual `whiteSpace:'nowrap'`),
starter comment residue deleted, and it gained `align`/`titleNode`/`secondaryRow` so one
component now serves both the customer's centered brand header and the console shells'
start-anchored breadcrumb-style title.
- Two small, justified `theme.ts` additions (the phase context's claim that `Drawer`/
`BottomNavigation` were already restyled in phase 0 didn't hold — neither had overrides):
`MuiDrawer` paper (background.default canvas + inset border) and `MuiBottomNavigationAction`
(selected color + bold label weight), both off existing `--bal-*` tokens.
### Session affordances
- **Sign-out** now reachable in the customer shell for the first time: one labeled row
(`profile.sign_out`) on the `/profile` hub, per the phase's explicit "one row, not a redesign"
scope (full hub redesign deferred to phase 9). Sidebar shells keep it in the drawer footer.
- **`ActorSwitcher`** (new, `src/layout/components/`): renders nothing for a single-role
session; for a dual customer+nurse session shows «نمای پرستار» in the nurse sidebar and
«اپلیکیشن خانواده» on the customer profile hub, navigating only — `RoleGuard`/
`resolveRoleDestination` remain the sole "which app" authority.
- **`LocaleSwitcher`** (new, `src/components/common/`): `router.replace(pathname, { locale })`
via the wrapper, so it preserves the current route. In every sidebar footer, the customer
profile hub, and the public shell. `DarkModeToggleButton`/`DarkModeFormSwitch` remain the only
`useColorScheme()` subscribers — neither switcher adds a scheme subscription.
### `UserInfo` deletion
`src/components/UserInfo/` is deleted outright (no rename/shim). Its one call site
(`SideBar.tsx`) now renders the `identity` slot passed by the caller; the top-level `@/components`
barrel and `common/index.tsx` re-exports were swapped for `ProfileSummary`. No other file imported
it (`grep UserInfo` was clean before deletion beyond the component's own two files).
## What is now testable (and exactly how)
1. Nurse (`/nurse`): sidebar shows four labeled sections; the identity card shows name/phone/
TrustBadge, not "Current User". Click «ویزیت‌ها» — it highlights, the TopBar shows the page
title, one navigation in the Network tab (no 307).
2. Resize to mobile: nurse shell shows the 5-tab bar; «بیشتر» opens the drawer with the full
grouped list + sign-out; toggling dark mode inside doesn't close it; a nav link does.
3. Customer `/`: brand lockup in the header. Open a nurse profile from search → header flips to
title + back chevron; back returns to results; mirrors correctly on `/en`.
4. Customer ≥900px: no bottom tab bar; the inline top-nav is present and highlights the right tab
on nested routes (e.g. a booking detail still lights up "Bookings").
5. `/profile`: sign-out row works; with a dual-role session, the actor switcher appears there and
in the nurse sidebar.
6. Admin: TopBar shows page title + bell + role chip (e.g. «مالی» for finance); sidebar is
sectioned and still capability-filtered — a finance-only admin sees no new items.
7. `/login` on `/fa`: no English text, no drawer, no bottom strip — just the corner logo +
switchers + the `AuthCard`'s own brand mark.
8. Hard-reload `/nurse` on desktop: sidebar present at first paint (verified via `npm run build`
not run here, but structurally guaranteed — see "SSR flash killed structurally" above).
## Verification performed this session
- `npm run check` (type + lint) — clean.
- `npm run test:ci` — 98 suites / 408 tests pass, including new tests for `matchActivePath`,
`ProfileSummary`, `LocaleSwitcher`, `ActorSwitcher`.
- `npm run dev` + SSR smoke curls (via PowerShell, not Bash — this sandbox's Bash tool proxies
loopback HTTP and returns a bare 502 regardless of the server's actual state):
`/fa/login`, `/fa`, `/en`, `/fa/nurse`, `/fa/admin` all return 200 with no error-boundary/
Next error markers in the HTML; `/fa/login` confirms `dir="rtl"`, the Mikhak font class, and
the brand string, with the old `'Unauthorized - Balinyaar'` title gone (the one "Unauthorized"
string left in the payload is Next's own internal App Router boundary metadata, unrelated).
- **Not performed**: a full four-locale × two-scheme × two-viewport visual pass in an actual
browser (no browser/screenshot tool available in this session) — the structural/SSR checks
above give high confidence, but visual polish (spacing, the desktop top-nav's exact look,
dark-mode contrast on the new `ProfileSummary`/`ActorSwitcher`) has not been eyeballed. Flagging
this explicitly per the "don't claim UI success you can't see" rule — recommend a human pass
before merging, especially on the customer desktop treatment (the newest, least-precedented
piece of this phase).
## What is mocked / waiting on a real service
None introduced — this phase is chrome over data that already flows (`useMe`, `useNurseProfile`,
`useVerificationStatus`, `useMyPartnerCenter`, `useUnreadCount`) behind existing `services/{domain}`
seams. No new mock/seam registered.
## Contracts
None produced or consumed. `/me` already returns `firstName`/`lastName`/`phone`, which was enough
for `ProfileSummary` — the anticipated gap ("`/me` lacking a display name") did not materialize,
so **no REQ was filed** (REQ-039+ still unused).
## Docs updated
- `client/CLAUDE.md` — Project Structure: `i18n/navigation.ts`, the full `layout/` tree (new
`routeTitle.tsx`, `matchActivePath.ts`, `BrandLockup.tsx`, `ActorSwitcher.tsx`, the rewritten
shell files), `components/ProfileSummary/`, `components/common/LocaleSwitcher/`.
- `.claude/skills/frontend-designer/SKILL.md` — §4 component table (`UserInfo``ProfileSummary`),
§5 "Layout & page shells" rewritten for the four-shell architecture, the dual-Drawer engine, and
`matchActivePath`.
## Follow-ups for later phases
- Full visual four-axes pass (see "Verification performed" above) — recommend before/alongside
the next UI phase that touches customer screens.
- Dynamic per-page titles (nurse name, booking reference) via `usePageTitleOverride` — the slot
exists; phases 4-6 and 9 (per the original scope note) should wire real titles in as they touch
those screens.
- `AppLink`'s inert `activeClassName` bug (see "Deliberately not touched" above) — low priority,
no known consumer; worth a real fix only if a future feature actually starts using it.
## Notable session correction
`RoleGuard.tsx` briefly had an unconditional `return children;` (added outside this phase, by the
user, for unrelated reasons) that broke `npm run check` — the dead code below it failed to
type-check because TypeScript doesn't apply control-flow narrowing inside unreachable code.
Confirmed via `git stash` that this predated my changes; the user removed the bypass mid-session.
`RoleGuard.tsx` was otherwise never touched by this phase, per scope.
@@ -0,0 +1,194 @@
# UI Phase 3 — Auth & first-run — Report (2026-07-17)
## What was built
### 3.1 Login hero — a branded, trust-forward front door
`AuthCard` is redesigned as a hero: on `≥md` a calm abstract illustration (`AuthIllustration`,
new — layered soft-tint circles in `--bal-primary-soft`/`--bal-secondary-soft` with a centered
`family` glyph and a floating `verified` trust badge, all CSS/SVG in brand tokens, no stock art)
sits beside the card; below the card, `TrustBullets` (new) renders three facts the platform
actually implements — licensed + identity-verified nurses, escrow held until a confirmed
check-out, support — each with a registry icon (`verification`/`lock`/`support`, all pre-existing).
The card stays a single `AUTH_CARD_MAX_WIDTH` (420px) column at `xs`, readable at 320px; the
illustration is `display:none` below `md`. `LoginFlow`'s single stack (`intendedRole` parameter,
never a forked tree) and the `' '` helperText placeholder are untouched.
### 3.2 OTP ergonomics
- `OtpInput`: `autoComplete="one-time-code"` on every digit box; backspace on an empty box now
clears the **previous** box and moves focus there in one keypress (was: focus-only, needing two
presses to erase a digit).
- `components/auth/useWebOtp.ts` (new): feature-detects `'OTPCredential' in window`, calls
`navigator.credentials.get({ otp: { transport: ['sms'] }, signal })` with an `AbortController`,
aborted on unmount and whenever verification starts (`active` flips false). Wired into `OtpStep`
so a WebOTP read feeds the same `setCode`/`verify` path manual entry uses. Unsupported browsers
(desktop, Firefox, Safari) silently no-op.
- `OtpStep`: the masked-phone echo and the resend countdown are now rendered via `t.rich` with a
`<Box component="bdi" dir="ltr">` wrapper — bidi-isolated so the RTL sentence's bidi algorithm
can't reorder the digit/bullet runs around them. The countdown uses the Phase-1 `formatClock`
helper (Persian digits on `/fa`, Latin on `/en`) instead of the old raw `String`/`padStart`.
- `PhoneStep`: `error={invalid || rateLimited}` — the 429 rate-limit message now renders in error
styling on the field instead of grey helper text.
### 3.3 Consent + legal pages
`PhoneStep` renders a consent line under the CTA («با ورود، شرایط استفاده و حریم خصوصی را
می‌پذیرید») via `t.rich` with `<terms>`/`<privacy>` tag functions rendering `AppLink`s. New
`ROUTES.TERMS`/`ROUTES.PRIVACY` (`/terms`, `/privacy`), both appended to `PUBLIC_PATHS`. New pages
`(public-routes)/terms/page.tsx` and `.../privacy/page.tsx` — Server Components (no `'use client'`
needed; matches the existing `not-found.tsx` pattern of RSCs rendering MUI/`BrandMark` directly),
data-driven from a new `legal` i18n namespace (`terms_sections`/`privacy_sections` are arrays of
`{title, body}` read via `t.raw` — the one namespace using structured JSON, everywhere else stays
flat keys). Each page opens with an `AppAlert severity="info"` **draft-copy banner**.
**⚠️ Draft legal copy — human/legal review required before launch.** The Terms of Service and
Privacy Policy text (8 sections + 7 sections, both locales) was written by this agent from the
product docs (`platform-summary.md`, `02-nurse-verification.md`, `08-payments-and-escrow.md`) to
be factually accurate to what the platform does today, but it is **not** attorney-reviewed and
must not be treated as binding before a legal pass.
### 3.4 Select-role — illustrated fork
`ROLE_OPTIONS` icons changed from `{customer: 'account', nurse: 'home'}` (a house for "I am a
nurse" was the defect) to `{customer: 'family', nurse: 'visits'}` (`FamilyRestroomRounded`/
`MedicalServicesRounded`, both pre-existing registry entries). Each card gets a 56px circular icon
badge (paper background + `--bal-shadow-1`, echoing the login hero's illustration language).
Selected state = `--bal-primary-soft` fill **and** a `verified` check glyph at the row's end (a
fixed-width placeholder Box holds its position when unselected, so nothing shifts) — color is no
longer the only signal. Added a reassurance line (`role_add_later_note`) below the cards. Radio
a11y semantics (`role="radio"`, `aria-checked`, `tabIndex`, Enter/Space) are unchanged.
### 3.5 Onboarding as a focused journey
Relocated `/onboarding` from `(private-routes)/(customer)/onboarding/` to a new sibling route
group `(private-routes)/(customer-focused)/onboarding/` (the old file is deleted outright, not
shimmed — two `onboarding/page.tsx` at the same URL would conflict). The new route group's
`layout.tsx` keeps `RoleGuard(expected=customer)` and wraps children in a new `FocusedLayout`
(`src/layout/`) — a slim logo-only header, no BottomBar/bell/sidebar, in the spirit of
`AuthCard`/`PublicLayout`. Route groups add no URL segment, so `/onboarding`, `ROUTES.ONBOARDING`,
and the home redirect gate (`(customer)/page.tsx` `isEmpty` effect) all keep working untouched.
`OnboardingScreen.tsx` (new, replaces the old inline `onboarding/page.tsx` body) adds a `'welcome'`
phase before `'relation'`/`'patient'` — brand mark, «خوش آمدید — مراقبت برای چه کسی است؟» framing,
one CTA. `StepperHeader` only renders for the two counted steps (welcome doesn't count). The four
relation options get distinct icons instead of all sharing `'account'`: `parent→elderly`,
`spouse→favorite` (**new registry icon**, `FavoriteRounded` — noted per the Phase-0
ownership rule), `child→infant`, `self→account` (the latter three already registered). The
relation-pre-shapes-patient-form behavior and the settled-list redirect gate are untouched.
**Deferred, per phase scope:** a «بعداً تکمیل می‌کنم» skip path (→ Phase 4, changes the zero-patient
home gate) and OTP voice-call fallback (→ Phase 12, needs a second delivery channel server-side).
### 3.6 returnUrl — deep links survive login
`middleware.ts`: on the redirect-to-login it now appends `?next=<pathWithoutLocale + search>` (new
`RETURN_URL_PARAM = 'next'` constant) — e.g. `/fa/bookings/42``/fa/login?next=%2Fbookings%2F42`.
The token check, locale detection, header propagation, and next-intl handling are untouched;
only the redirect branch gained the extra `searchParams.set`.
`services/auth/routing.ts` adds `resolvePostLoginDestination(me, intendedRole, next)` (pure,
exported, unit-tested) beside the existing `resolveRoleDestination`: returns `next` only when (a)
`isSafeRelativePath` — starts with `/`, not `//`, not `/\` (rejects protocol-relative and
absolute-URL values; an absolute URL like `https://evil.com` already fails the `startsWith('/')`
check) — and (b) `appRoleForPath(next)` resolves to a role the session's `toAppRoles(me.roles)`
actually holds (a customer's `next=/nurse/...` falls through; the partner portal, not an
`AppRole`, always falls through). Otherwise defers to `resolveRoleDestination`, which stays the
single "which app" source of truth. `LoginFlow` reads `next` from `useSearchParams()` and passes
it to `RoleRouter`, which now calls `resolvePostLoginDestination` instead of
`resolveRoleDestination` directly (the select-role-with-nurse-intent carry-through logic is
unchanged, layered on top of the resolved destination).
### 3.7 PhoneNumberField autofill
`autoComplete="tel"` added to the default `slotProps.htmlInput`; LTR forcing and digit
normalization untouched.
## What is now testable (and exactly how)
1. `/fa` logged-out → `/fa/login`: branded hero (logotype, tagline, illustration on desktop, 3
trust bullets, consent line with working «شرایط استفاده»/«حریم خصوصی» links). Toggle dark mode
and `/en` — tracks.
2. Enter a phone, request the code → OTP screen: masked number renders un-scrambled inside the
Persian sentence; the resend countdown ticks in Persian digits on `/fa`, Latin on `/en`.
3. Wrong digit + backspace twice on the OTP boxes — each press erases one digit. Paste a 6-digit
code — auto-verifies (unchanged).
4. Spam request-OTP to trigger a 429 → the phone field renders in error styling, not grey helper
text.
5. `/terms` and `/privacy` load logged-out, both locales — draft-copy banner + 7-8 sections each.
6. Fresh phone → `/select-role`: two illustrated cards (family/nurse icons, not house); selecting
shows soft fill + check glyph; reassurance line present; Tab/Enter/Space still work.
7. Zero-patient customer → `/onboarding`: welcome screen (brand mark + CTA, no stepper) → relation
(4 distinct icons) → patient form (relation hidden) → save → Home. No BottomBar/bell visible at
any point in the wizard.
8. A seeded nurse still lands on `/nurse`; `?role=nurse` still pre-selects nurse copy through to
select-role (both unchanged branches, exercised by the existing + new `RoleRouter` tests).
## Verification performed this session
- **`npm run check`** (type + lint) — clean.
- **`npm run test:ci`** — **98 suites / 420 tests pass** (was 98/408 going in — this phase added a
`resolvePostLoginDestination` test block to `routing.test.ts`, two `RoleRouter` tests, an
`autoComplete`/backspace test to `OtpInput.test.tsx`, and an `autoComplete` test to
`PhoneNumberField.test.tsx`; `OtpStep.test.tsx`'s `next-intl` mock was extended with `useLocale`
and a `t.rich` stub so the existing suite kept passing against the rewritten component).
- **`npm run dev` + SSR smoke** (via `curl --noproxy "*"`, working around the same loopback-proxy
502 Phase 2's report already documented): `/fa/login`, `/en/login`, `/fa/terms`, `/fa/privacy`
all return 200 with no error-boundary/`MISSING_MESSAGE` markers in the HTML; `/fa/terms` was
grepped for its own distinctive section title («ماهیت خدمت») and the draft banner
(«این متن پیش‌نویس است») to confirm real translated content renders, not just the hydration
payload.
- **Not conclusively verified this session: the `?next=` middleware redirect itself.** Requesting
a private route with no cookie (`curl`, and independently a .NET `HttpClient` from PowerShell
with `UseProxy=$false` — two unrelated HTTP stacks, ruling out the proxy explanation) returned
**200 with the page's own content** instead of a 3xx to `/login`, for both routes this phase
never touches (`/fa/nurse/profile`, `/fa/bookings/999` — the latter a dynamic segment, so not a
static-cache artifact either) and routes it does. A `console.log` placed at the very top of
`middleware.ts` never printed for any of these requests, even after a full dev-server restart —
the middleware bundle compiles (`.next/dev/server/middleware/middleware-manifest.json` lists it
with a correct matcher regex) but does not appear to run for a bare HTTP GET in this
`next dev`/Turbopack session. Since the exact same non-redirect happens on **untouched**
pre-existing private routes with **byte-identical, unmodified** redirect logic (confirmed via
`git show HEAD:client/middleware.ts`), this reads as an environment/toolchain anomaly specific
to this Next.js 16.2.9-canary + Turbopack dev session, not a regression from this phase's diff —
but I could not get a real browser in front of it to confirm the alternate hypothesis (that
actual browser navigation, unlike a cold HTTP client request, does trigger it correctly). A
production build (`npm run build`) also could not confirm this independently: it fails during
static-page prerendering on `/fa/addresses`, `/fa/partner/bookings`, `/fa/nurse/earnings` with
`Error: Missing .env variable!` inside `src/components/notifications/index.ts` — a pre-existing
production-build/env-config gap unrelated to this phase (not investigated further, out of
scope). **Confidence in the `next`-handling code itself comes from `resolvePostLoginDestination`'s
unit tests** (open-redirect guards for `//evil.com` and `https://evil.com`, role-forbidden-path
fallback, role-less-user fallback, happy path) and `RoleRouter`'s two new tests exercising the
same function through the component — not from an end-to-end browser confirmation. **Flagging
this explicitly per the "don't claim UI success you can't see" rule** — recommend a human
re-verify item 8 in "How to test" (§7 of the phase doc) in an actual browser before merging.
## What is mocked / waiting on a real service
None introduced. Auth stays real (`USE_AUTH_MOCK = false`); every deliverable here is
presentation, ergonomics, or client-side routing — no mock flag was flipped.
## Contracts
None produced or consumed as an endpoint change. One request filed:
- **REQ-039** — WebOTP-conformant OTP SMS template (`@<domain> #<code>` origin-bound last line) —
a server/Kavenegar-adapter SMS-template change, zero API-shape impact. The client-side WebOTP
wiring (`useWebOtp` + `autoComplete="one-time-code"`) ships regardless and degrades to manual
entry until the template lands.
## Docs updated
- `client/CLAUDE.md` — Project Structure: the new `(customer-focused)/onboarding/` route group,
the deleted `(customer)/onboarding/`, the new `terms/`/`privacy/` pages, `FocusedLayout.tsx`,
the `components/auth/` bullet (added `useWebOtp`, `AuthIllustration`, `TrustBullets`); the i18n
namespace list (`auth` additions, new `legal` namespace, `onboarding`'s `welcome_*`); the
Middleware section (returnUrl behavior + `resolvePostLoginDestination`).
## Follow-ups for later phases
- **Human/legal review of `/terms` and `/privacy`** before launch — flagged in-page (draft banner)
and here; not a code follow-up, a legal one.
- **Re-verify the `?next=` middleware redirect in an actual browser** (see "Verification performed"
above) — the code is unit-tested and unchanged in its core logic, but this session's tooling
couldn't get a conclusive live HTTP confirmation.
- The production-build failure (`Missing .env variable!` in `src/components/notifications/index.ts`
during static prerendering of `/fa/addresses`, `/fa/partner/bookings`, `/fa/nurse/earnings`) is
pre-existing and unrelated to this phase — worth a ticket so `npm run build` is verifiable
end-to-end by a future phase.
- Skip-onboarding («بعداً تکمیل می‌کنم») → Phase 4 (changes the zero-patient home gate, storefront
territory). OTP voice-call fallback → Phase 12 (needs a second server-side delivery channel).
@@ -0,0 +1,152 @@
# UI Phase 4 — Customer Storefront — Report (2026-07-18)
## What was built
**Home (A5)** — `HomeScreen.tsx`:
- The dead free-text search bar (`?q=` silently discarded by C1) replaced with a tappable faux-input
(`ButtonBase`) that routes straight to C1. Decision: the search index has no text column, variant names
aren't client-queryable, and the only matchable dataset client-side (56 cached category names) is
already better served by the category grid directly below — a half-working text field over-promises
where trust matters most. The upgrade path is documented in the component's JSDoc and filed as REQ-041.
- A compact one-line `TrustStrip` (escrow · verified nurses · support) under the greeting.
- The patient-record `NudgeCard` is now gated on a derived completeness signal (a patient with no
recorded conditions) instead of rendering forever, and is dismissible for the session (a module-scoped
flag — survives client-side navigation, resets on a hard reload; deliberately not a cookie/localStorage
write, since it's ephemeral UI state, not app/auth state).
- A `RebookRow` — up to 2 "رزرو دوباره با …" cards sourced from `useBookingList('customer')`, deduplicated
by nurse, each resolving its `nurseId` via a per-card `useBookingDetail` call (the list row doesn't carry
`nurseId`) and deep-linking to the nurse's C3 profile. Renders nothing when there's no booking history.
- The pre-existing `isError` retry branch on `usePatients()` was found **already correct** in the current
code (the audit's finding predates it) — verified, not re-fixed.
**Search (C1)** — `SearchScreen.tsx` + `useSearchFilters.ts`:
- The native `<input type="date">` replaced by a Jalali day-chip strip (`JalaliDatePicker` `chips` variant,
extended with optional `todayLabel`/`tomorrowLabel` props for «امروز»/«فردا») plus a calendar-icon entry
into the full Jalali grid (a `Popover`) for later dates — intent-only semantics unchanged.
- The inline `ToggleButtonGroup` gender facet replaced by the shared `GenderToggle`, extended with an
opt-in `allowAny` mode (a discriminated-union prop shape — the default booking-context contract is
byte-for-byte unchanged for every other caller).
- The live-count CTA is now a `StickyActionBar` (new shared primitive) — never a tappable "مشاهده ۰
پرستار": at zero results it shows a non-CTA message with a relaxation hint instead of the button.
- `useSearchFilters` now hydrates **every** field from the initial URL (`searchParamsToFilters`), not just
`category_id` — a full filter set carried back from a C2 recap chip rehydrates C1 completely. A
client-only `province_id` query param (not part of `NurseSearchFilters`/the search cache key) carries the
province id so `CascadingRegionSelect`'s city dropdown can prefill without a server round trip.
**Results (C2)** — `results/page.tsx`:
- A tappable filter-recap chip row (category · region · gender · price) under the count — every chip
performs the same navigation: back to C1 with the **entire current query string** (C2's URL is already a
superset of everything C1 set, including `province_id`/`date`), so hydration is exact.
- The dead single-option sort `<TextField select>` replaced with a static "مرتب‌شده بر اساس امتیاز" caption.
- Skeleton twins (`NurseResultCard.Skeleton`) were already wired in the current code; updated to match the
v2 card anatomy.
**`NurseResultCard` v2** (this phase owns it) — rebuilt as the four-second decision unit:
- Added: a service/variant label (falls back to the category name via a page-supplied `serviceLabel` prop
until REQ-040's `variantDisplayName` lands), a quiet nurse-gender chip, the completed-visits count
(«N ویزیت موفق», already-served data that was fetched and never rendered), an optional one-line top-review
tag (`topReviewTag`, REQ-040), and a tappable `TrustBadge` (now passes `nurseId`).
- `NurseResultCardSkeleton` updated to the new anatomy (an extra meta-row).
**Nurse profile (C3)** — `nurse/[nurseId]/page.tsx`:
- Header now shows the completed-visits count (served, previously unrendered). No gender chip — the
public profile DTO doesn't serve `nurseGender` (the client's `'female'` stub is explicitly a
placeholder); filed as REQ-042 rather than rendering it.
- New `VerificationSection` renders the shared `VerificationPanel`, fed by `useNurseTrustBadge(nurseId)`.
- `TrustBadge` now passes `nurseId`, making the badge tappable everywhere it appears (C2 cards + C3).
- The primary CTA is now a `StickyActionBar` (price-from beside "درخواست رزرو") — survives the infinite
reviews list.
- An optional latest-review snippet renders on the services tab when `profile.latestReview` is present.
- The reviews tab's fractional `RatingInput` (no `Math.round`) was found **already correct** in the current
code — verified, not re-fixed (phase 1's RatingInput v2 already replaced the rounding).
**New shared components** (both co-located-tested, both reused by phase 8 per the phase's cross-reference):
- `VerificationPanel` (`src/components/VerificationPanel/`) — "what Balinyaar verified": one row per
`TrustBadge.credentialTypes[]` (i18n off the `verification` namespace's `step_*` codes, never a raw wire
value) + the approval date. Renders only what is served — no invented steps, no fake dates.
- `TrustBadge`'s new opt-in `nurseId` prop — tappable, opens a bottom-sheet (mobile)/dialog (desktop)
rendering `VerificationPanel`, fed by a **lazily-enabled** `useNurseTrustBadge` fetch (only queries once
the explainer is actually opened). Implemented as an inner `InteractiveTrustBadge` subcomponent so the
**default** (no `nurseId`) badge — used everywhere else in the app (`ProfileSummary`, etc.) — calls no
query hook at all and needs no `QueryClientProvider` in its callers' tests.
- `StickyActionBar` (`src/components/common/StickyActionBar/`) — the bottom-pinned action-bar shell shared
by C1's live-count CTA and C3's booking CTA. Composes with the shell's existing `BottomBar` safe-area
handling (a flex sibling below the scrolling `main`) rather than reimplementing
`env(safe-area-inset-bottom)`.
**Copy**: `search.empty_suggest_city` («شهر نزدیک دیگری مانند مشهد، اصفهان یا شیراز») deleted from both
`en.json`/`fa.json`, replaced with `empty_suggest_date` ("try a different date" — an honest relaxation the
family can actually act on). Reused in the C1 zero-count sticky-bar hint too.
## What is now testable (and exactly how)
1. **Home**: log in as the seeded customer (`0912000000x`). Tap the search field on Home → lands on
`/search` with no `?q=` in the URL. Stop the API and reload → an error card with «تلاش مجدد», not an
eternal spinner. Restart → home recovers; the trust strip shows under the greeting; a customer with a
completed booking sees a «رزرو دوباره با …» card that opens the nurse's profile.
2. **C1**: `/search` — the date filter shows «امروز»/«فردا» + Shamsi day chips (no native browser
calendar anywhere); tapping the calendar icon opens the full Jalali grid in a popover. Pick a category +
city → the count CTA is pinned at the bottom while scrolling the page. Pick filters matching nothing
(e.g. an unusually high min price) → the sticky bar shows the non-CTA "no matches" message, not a
tappable button.
3. **C2**: run a search → recap chips show category/region/gender/price; tap any chip → C1 opens with
every filter pre-filled (category, region incl. province prefill, gender, price, date); browser-back
returns to identical results with zero network (cache hit, unchanged from before this phase). The
header reads «مرتب‌شده بر اساس امتیاز» as static text, not a dropdown.
4. **Cards**: a nurse with multiple variants shows distinguishable cards (category/variant label + price);
every card shows the gender chip and «N ویزیت موفق»; tapping the ✓ badge opens the verification
bottom-sheet/dialog (fetches `nurses/{id}/trust_badge` lazily on open).
5. **C3**: open a profile → header shows completed visits + rating; the `VerificationPanel` section lists
the served credential types + approval date (Shamsi); open the reviews tab and scroll deep → «درخواست
رزرو» stays pinned at the bottom; a 4.5 average renders as a fractional star row. Tap the CTA → the C4
request form receives the same nurse/variant/gender/date params as before.
6. Repeat 15 on `/en` (LTR) and in dark mode; on a mobile viewport confirm both sticky bars sit above the
`BottomBar` and the home-indicator safe area (the `BottomBar` already owns
`env(safe-area-inset-bottom)`; `StickyActionBar` is a separate flex sibling above it, inside the
scrolling `main`, so it never overlaps).
## What is mocked / waiting on a real service
Nothing new mocked this phase — search stays real (`USE_SEARCH_MOCK = false`, unchanged) and verification
stays mock-primary (`USE_VERIFICATION_MOCK`, unchanged) exactly as before; this phase only consumes the
existing `useNurseTrustBadge` hook (previously built, never wired into a page) and adds no new fetch code
outside existing service hooks. No mock registry entries added/changed.
## Contracts
- Consumed: `services/search` (b7 + REQ-012, unchanged), `services/verification`'s public trust-badge read
(b6, unchanged — this phase is the first to actually call `useNurseTrustBadge` from a page/component).
- Requested (`dev/shared-working-context/frontend/requests/for-backend.md`, next free numbers 040043):
- **REQ-040**`variantDisplayName` (required) + optional `topReviewTag` on `NurseSearchResultDto`.
- **REQ-041** — free-text `q` search over nurse/variant/category names on `GET search/nurses` (the
Home search bar's upgrade path).
- **REQ-042**`nurseGender` on `NursePublicProfileDto` (the C3 header gender chip, currently omitted).
- **REQ-043** — public per-step verification detail (step codes + decision dates) so `VerificationPanel`
can list identity/Shahkar/license individually instead of folding to `credentialTypes[]`.
## Docs updated
- `client/CLAUDE.md` "Project Structure": the C1/C2/C3 line items (Jalali chips, sticky CTA, recap chips,
dossier layout), the new `VerificationPanel`/`StickyActionBar` entries, and updated `TrustBadge`/
`GenderToggle`/`NurseResultCard`/`JalaliDatePicker` lines noting their new modes.
## Follow-ups for later phases
- **Multi-variant collapse.** Collapsing a nurse's several variant rows into one card with a price range +
"N خدمت" disclosure needs the `variantDisplayName` REQ (040) served first — noted as a design follow-up,
not built this phase (the category-name fallback keeps rows distinguishable meanwhile).
- **Public/guest storefront and landing page** — deferred to phase 13 (unchanged from the phase brief).
- **Save/favorite/share nurses** — post-MVP product decision (unchanged from the phase brief).
- **Full ICU zero-case copy sweep** (ratings/counts pluralization polish across the whole app) — deferred
to phase 12 per the phase brief; this phase only ensured the C1 zero-count state is never a tappable CTA.
- Phase 8's public-profile preview should reuse `VerificationPanel` and `TrustBadge`'s `nurseId` explainer
mode unchanged, per the phase's explicit cross-reference.
## Memory
Saved a `project`-type memory (`ui_phase_4_customer_storefront.md`, indexed in `MEMORY.md`) covering: the
`NurseResultCard`/`TrustBadge` ownership split with phase 8, the `VerificationPanel` shared-component
contract, the `TrustBadge` split-component gotcha (query hooks must live in a conditionally-**mounted**
subcomponent, not a conditionally-**enabled** hook call, to avoid forcing `QueryClientProvider` on every
caller's tests), the C3 profile DTO's missing `nurseGender` (REQ-042, never render the placeholder stub),
and the `StickyActionBar`/`province_id`-carry patterns for future sticky-CTA or region-prefill needs.
@@ -0,0 +1,189 @@
# UI Phase 5 — Booking Lifecycle — Report (2026-07-18)
## What was built
**C4 request form** — `bookings/request/page.tsx`:
- A sticky nurse-identity bar (`NurseIdentityBar`, page-local) pinned to the top of the scroll: avatar,
name, rating + review count, tappable `TrustBadge`, gender chip — off the already-fetched
`useNurseProfile`. The family always sees who they're inviting home before filling anything in.
- A «چه اتفاقی می‌افتد؟» strip reusing C5's own `StepperHeader` three-step labels (`step_submitted` /
`step_awaiting` / `step_payment`) at `activeStep={0}` — no new one-off stepper, and the copy stays in
sync with the tracker the family lands on next.
- The native `type="date"` field replaced by the new shared `JalaliDateIntentPicker` (extracted from C1's
local date-intent widget — see below) and the free start/end time fields replaced by tappable
morning/afternoon/evening window chips + a «زمان دلخواه» custom option that reveals the time fields —
kills the end≤start error class for the common case.
- **Fixed the dead-validation defect**: `attempted` (a single flag only ever set inside the submit handler
the disabled button could never reach) replaced with a per-field `touched` map set `onBlur`; inline
errors are now reachable. The submit button stays disabled while required fields are missing (unchanged
behavior) but a caption underneath now lists exactly what's missing («برای ادامه: انتخاب بیمار، تاریخ»),
built from `cta_missing_*` keys.
- The fake-map preview (`AddressMapPicker` wrapped in `pointerEvents:'none'`) replaced with a compact
address row (icon + "title · city · district" + street line) and a «تغییر» affordance that swaps the row
back for the select; choosing a new address collapses it back to the compact row automatically.
- Negative-margin stitching (`mt: -1.5`, `mt: -2`) removed — price-under-select and counter-under-notes are
now grouped with real `Stack` containers.
- C4 now also reads `patient_id`/`address_id` query params (extending the existing `nurse_id`/`variant_id`/
`required_gender` handoff from C3) so C5's terminal-state "request again" can reopen it fully prefilled.
**New shared component** — `components/common/JalaliDateIntentPicker/`: extracted from C1's local
`DateIntentFilter` (near-day chip strip + a calendar-icon `Popover` entry into the full Jalali grid) so C4's
**real, required** date field could reuse the identical widget instead of forking a second copy. C1's
`SearchScreen.tsx` was refactored to delegate to it (behavior byte-identical — verified via `npm run
test:ci`); the new component ships its own co-located test.
**C5 tracker** — `bookings/request/[id]/page.tsx`:
- The response countdown now renders `CountdownTimer`'s progress ring (`windowStart={request.createdAt}`,
the exact server-frozen pair — no client-side deadline math) with a humanized coarse label above the
10-minute threshold («حدود ۳ ساعت» / «حدود ۲۵ دقیقه», new `countdown_about_hours`/`countdown_about_minutes`
keys) that switches to the ticking clock in the final minutes. A one-line «نتیجه را به شما اطلاع می‌دهیم»
note sits under it. The payment countdown is unchanged (no accurate `windowStart` exists on the DTO for
it — never fabricated client-side).
- **Fixed the cancel-dialog label defect**: the hand-rolled `Dialog` replaced with the shared
`ConfirmDialog`. Dismiss now reads «نه، نگه دار» (`cancel_confirm_keep`, neutral text button); the
destructive action reads «بله، انصراف از درخواست» (`cancel_confirm_destructive`, error/contained) — the
dismiss button no longer carries the destructive action's own label. The now-unused `cancel_confirm_yes`
key was removed from both message files.
- **Terminal-state recovery** for `rejected_by_nurse`/`expired_no_response`: a «درخواست دوباره با زمان
دیگر» button reopens C4 prefilled with the same nurse/variant/patient/address, and a «پرستاران مشابه»
button opens `/search` carrying the request's city/district/gender. For rejections, the same-nurse retry
is suppressed when `nurseRejectionReason` (freeform text — no structured code exists, REQ-044) matches a
gender/coverage keyword heuristic (fa+en), in which case only "similar nurses" is offered. Other terminal
states (`payment_deadline_expired`, `cancelled_by_customer`, `converted`) are unchanged.
**Bookings list** — `bookings/page.tsx` + new `BookingsScreen.tsx`:
- Three segmented tabs: «در انتظار پاسخ» / «فعال» / «گذشته». The pending tab wires the
previously-exported-but-unused `useCustomerRequests` (filtered to `pending_nurse_response` +
`accepted_awaiting_payment`), with a live mini `CountdownTimer` per row deep-linking to C5 — the "orphaned
request" defect (money-adjacent deadline the customer could no longer find) is fixed. A badge on the tab
label shows the pending count.
- Active/past split `useBookingList('customer')` client-side by status over **one** query with a growing
`pageSize` ("load more", the exact C2 results pattern) — booking #21+ is reachable via the button.
- Rows are `AccentCard`s with a status-toned `borderInlineStart` + a soft `StatusChip`, `role="button"` +
keyboard-activatable, fully tappable (not just a small nested button).
- A completed/closed row without a review shows a compact star-strip CTA (`RatingInput` read-only decor +
«ثبت نظر» text) via a gated `useReviewEligibility(bookingId, { enabled: isCompleted })` — no extra query
fires for non-completed rows.
**Booking detail** — `BookingDetailView.tsx`:
- The header is now a hero: a next-upcoming-session headline («ویزیت ۲ · فردا ۰۹:۰۰», via
`formatRelativeTime` + a Shamsi fallback past 7 days, bidi-isolated clock digits), the frozen visit
address (best-effort parsed off `addressSnapshotJson` — see REQ-045), a nurse-identity row, and a
client-side **`.ics` add-to-calendar download** (new `components/booking/ics.ts`, no backend seam —
Gregorian UTC in the file, Shamsi in the UI).
- An EVV **presence headline** («پرستار در محل است · ورود ۰۹:۰۲», success-toned) renders above the address
whenever a session is currently checked in — elevates the existing advisory EVV data instead of leaving
it buried per-session. (The equivalent **compact list-row indicator** from the phase brief was **not**
built: `BookingListItemDto` carries no per-row EVV/session state, and fetching it via an extra query per
in-progress row would be an N+1 anti-pattern — filed as a note, not a REQ, since no clean single-field
addition was obvious; flagged for a human product/API call.)
- The vertical `StatusTimeline` swap the phase asked for (in place of `StepperHeader`) was **already done**
in ui-phase-1 (`BookingStatusTimeline` already renders `StatusTimeline`) — verified, not re-built; the
stale in-code comment claiming otherwise is fixed in this pass.
- Fixed the `unnamed_nurse` key-misuse defect: a new `bd_nurse_label` ("پرستار") key is now the nurse
header-fact **label**; `unnamed_nurse` stays exactly the no-name **fallback value** it was written for.
- `SessionCard`'s outer `Paper` swapped for `SurfaceCard` (identical `padding="sm"` visual, now on the
shared radius token) — the "align to phase-1 card anatomy" ask.
**Cancel flow** — `bookings/[id]/cancel/page.tsx`:
- Two off-ramps above the disclosure: «تغییر زمان» and «گفتگو با پشتیبانی», both opening the existing
`ContactSupportDialog` (pre-linked to the booking, categories `coordination`/`support` respectively) plus
a one-line nurse-impact note. Real rescheduling stays DEFERRED (product decision + backend, per the phase
brief — no REQ filed, it was already a known/flagged gap).
- The reason `useState` no longer pre-defaults to `'changed_mind'` — it starts empty with a disabled
placeholder `MenuItem`, and the continue CTA stays disabled until a reason is chosen.
- `CancellationPolicyDisclosure` is untouched (byte-identical behavior, per the keep-list).
**Review flow** — `bookings/[id]/review/page.tsx` + the list row:
- A context-recap card (service name off the variant snapshot, nurse avatar + name, Shamsi visit date —
all off the already-cached `useBookingDetail`, no new fetch) renders above both the eligible-form and the
already-reviewed states.
- The moderation-expectation note («نظر شما پس از بررسی منتشر می‌شود») now renders **before** submit, not
only in the post-submit "under review" state.
- **Fixed the ungated-hook defect**: `useMyReviewForBooking(bookingId)` now passes `{ enabled: reviewable }`
(`booking?.status === 'completed' || 'closed'`), matching the exact gate the booking-detail page already
used for the same hook.
- The list-row star-strip CTA (above) is the "post-completion review nudge" half of this deliverable.
**Misc verified defects**:
- `BookingRequestSummaryCard`'s `whenLabel` bidi-isolated: the date·time-range now wraps the clock-digits
segment in a `dir="ltr"` `tabular-nums` span, matching `SessionCard.tsx`'s existing precedent exactly. A
new co-located test asserts the isolation.
- The «ادامه پرداخت ←» arrow-in-string CTA (`booking.continue_payment`) was **not** touched — confirmed
it's phase 12's catalog-wide sweep, per the phase brief's own scope note; only strings genuinely edited
this phase had their arrows reconsidered (none needed it).
## What is now testable (and exactly how)
1. From a nurse profile (C3) tap «درخواست رزرو» → C4 shows the sticky nurse card and the 3-step strip.
Blur the empty patient select → inline error; the disabled submit lists what's still missing.
2. Pick a date from the Jalali chip strip (or the calendar-icon popover) and tap «صبح ۸–۱۲» → the time
fields fill silently; tap «زمان دلخواه» → the free time fields appear. The address shows a compact text
row with a «تغییر» link, never a grid-canvas stand-in.
3. Submit → C5 shows the countdown ring with «حدود …» framing (switches to ticking digits under 10 min).
Tap «انصراف از درخواست» → the dialog's neutral button reads «نه، نگه دار» and keeps the request; the
red «بله، انصراف از درخواست» button cancels it.
4. Leave C5 → `/bookings` «در انتظار پاسخ» tab shows the pending request with a live mini-countdown and a
tab badge; the row deep-links back to C5. Reject a request with a non-gender/coverage reason (dev nurse
inbox) → C5's terminal card offers both «درخواست دوباره با زمان دیگر» (C4 reopens fully prefilled,
including patient/address) and «پرستاران مشابه» (search prefilled with the same city/gender).
5. Seed >20 bookings → the active/past tabs load 20, then «نمایش بیشتر» reveals the rest; every row is
keyboard-reachable (Tab + Enter) and carries a status-colored accent stripe.
6. Open an active booking with a session scheduled → the hero reads «ویزیت N · <relative/Shamsi> <time>»,
the frozen address, a nurse avatar, and a working «افزودن به تقویم» `.ics` download. Check a nurse in
(dev EVV sim) → the hero shows «پرستار در محل است · ورود …».
7. Start a cancellation → the reason select is empty (continue disabled until chosen); «تغییر زمان»/«گفتگو
با پشتیبانی» open the support dialog pre-linked to the booking; completing the flow shows the unchanged
policy disclosure.
8. Open a completed booking's `/bookings` row → a star-strip CTA (only when un-reviewed) deep-links to the
review page, which shows the service/nurse/date recap and the moderation note before any input.
9. `npm run check` and `npm run test:ci` are green (101/101 suites, 437/437 tests, including the new
`JalaliDateIntentPicker` and `BookingRequestSummaryCard` bidi tests).
## What is mocked / waiting on a real service
Nothing new mocked — `bookingRequests`/`bookings`/`reviews`/`refunds`/`tickets` all run real, unchanged
from refinement-phase-4, per the phase's own "None introduced" note. Every deliverable is client-side over
existing seams; the `.ics` file is generated entirely in the browser.
## Contracts
- Consumed: `services/bookingRequests` (b8), `services/bookings` (b9), `services/refunds` (b11),
`services/reviews` (b14), `services/tickets` (b15) — all unchanged.
- Requested (`dev/shared-working-context/frontend/requests/for-backend.md`, next free numbers 044045):
- **REQ-044** — a structured `nurseRejectionReasonCode` on `BookingRequestDto`. C5's same-nurse-retry
gate currently approximates this with a keyword heuristic over the freeform `nurseRejectionReason`
text (documented in-code, `rejectionAllowsSameNurseRetry()`).
- **REQ-045** — a typed `addressSnapshot`/`variantSnapshot` shape on `BookingDetailDto` in place of the
opaque `*SnapshotJson` strings, whose field names were confirmed to drift across the seed fixtures
(`city`/`cityName`/`cityNameFa`, `line`/`addressLine`). The client's `addressSnapshotLabel()` tries
every candidate key defensively; no user-facing defect today, just an unenforced contract.
## Docs updated
- `client/CLAUDE.md` "Project Structure": the `/bookings` tree (tabs + wired `useCustomerRequests` +
load-more, C4/C5 redesign notes, the cancel/review off-ramp and gating fixes), the new
`JalaliDateIntentPicker` entry, the `booking/` composite line (hero, `.ics`, EVV presence, the
`StatusTimeline`-not-`StepperHeader` correction — the prior line was already stale before this phase),
`CountdownTimer`'s ring now having a live consumer, and `BookingRequestSummaryCard`'s bidi fix.
## Follow-ups for later phases
- **List-row EVV presence indicator** (phase brief §3.4) was not built — `BookingListItemDto` has no
per-row EVV/session field and an N+1 per-row fetch would be the wrong shape; needs a product/API
decision (a lightweight `currentlyCheckedIn` boolean on the list DTO, or accept the gap) before a future
phase attempts it.
- **REQ-044/045** (rejection-reason code, snapshot typing) — no phase currently owns picking these up;
flagged for whichever backend phase next touches b8/b9.
- The arrow-in-string `continue_payment` CTA sweep stays phase 12's, as scoped.
## Memory
Saved a `project`-type memory (`ui_phase_5_booking_lifecycle.md`, indexed in `MEMORY.md`) covering: the
lifecycle tabs model (client-side status bucketing over one growing-pageSize query, not per-tab server
filters — a deliberate trade-off since the list API takes only a single `status`), the terminal-state
recovery decision tree (same-nurse retry vs similar-nurses, gated by a keyword heuristic pending REQ-044),
the confirm-dialog labeling convention (`ConfirmDialog` cancelLabel=keep/neutral,
confirmLabel=destructive/error — apply to every future confirm dialog), the presence-headline pattern, and
what phase 6 (checkout) should know about the accepted-request → payment handoff surfaces touched here (C5's
payment countdown + `continue_payment` CTA, unchanged in shape).
@@ -0,0 +1,208 @@
# UI Phase 6 — Checkout & Money — Report (2026-07-18)
## What was built
**Three new shared components** (`src/components/`, each tested):
- **`PaymentStateCard/`** — the one terminal/wait-state card (icon/tone/title/body + an actions slot)
replacing the four copy-pasted private `MessageCard` (checkout/page.tsx, bnpl/page.tsx) and `StateCard`
(checkout/return/page.tsx, bnpl/return/page.tsx) functions. All eight call sites now render through it;
the card and BNPL flows can no longer visually drift.
- **`BnplProviderLogo/`** — `providerCode` → a bundled SVG when a real asset exists (none licensed yet,
registry left empty on purpose) falling back to a *designed* tinted-monogram roundel, replacing the old
two-letter text-glyph stand-in (`DG`/`SP`/…) in D1's `MethodStep`. Asset-tolerant: dropping in a real logo
later touches only the internal registry map, no call-site changes.
- **`EscrowExplainer/`** — wraps the product-mandated `EscrowNotice` (never edited) with an optional
«چطور کار می‌کند؟» expander: a 3-step visual (پرداخت ← امانت نزد بالین‌یار ← آزادسازی پس از تایید پایان
ویزیت, grounded in `product/payments/escrow-ledger.md`) plus the cancellation/refund implication. Used on
checkout and the confirmation receipt.
**Minimal foundation extension**: `Money` gained a fourth size, `xl` → MUI `h4`, for the checkout/
confirmation "prominent total" hero figure that phase 1 didn't need. Test added.
**C6 checkout** (`bookings/checkout/page.tsx`) — real hierarchy:
- Identity moment: `EngagementSummary` now shows the nurse's avatar + a `TrustBadge` (verified/unverified
off the new `nurseVerified` field) next to the service/patient/time details.
- A prominent `<Money size="xl">` total sits above the countdown/breakdown — the same served `totalIrr`
`PriceBreakdown` reconciles below, never recomputed.
- A safe-area-aware sticky pay bar (`StickyActionBar`, reusing phase-4's shell-composed safe-area padding —
`layout/` untouched): total + pay CTA co-located, a lock-icon «پرداخت امن از طریق درگاه بانکی» trust line,
and the BNPL branch button — **both** CTAs now `disabled={busy}` during initiate (the race the audit
flagged: only the card button used to disable).
- An explicit «بازگشت به درخواست» text link above the identity card (beyond the shell's header chrome).
- Un-baked the arrow: `cta_pay` no longer bakes «←»/«→» into the translated string; the button now uses
`endIcon="forward"` (already a registered directional icon — mirrors under RTL automatically).
**Confirmation rebuilt as a receipt** (`bookings/checkout/confirmation/page.tsx`):
- A copyable, `dir="ltr"` کد پیگیری row with a copy button (clipboard + a "copied" toast) — sourced from
`usePaymentOutcome`'s new `trackingCode` (card path) or the settled `BnplOrderStatus.id` (BNPL path, no
new field needed).
- Shamsi paid-at (`formatShamsiDateTime`), payment method («کارت بانکی» / «اقساطی — {provider}»), and the
booking reference — every row conditionally rendered so an unserved real-path field just disappears,
never a fabricated value.
- `EscrowExplainer` (was a bare `EscrowNotice`) and a "what happens next" 2-step `StatusTimeline`
(اطلاع‌رسانی به پرستار ← ویزیت و ثبت ورود).
- **Real loading/error states**: the old `{summary ? (...) : null}` (a failed fetch silently erased the
paid amount) is now a skeleton → `ErrorState` (with retry) → receipt sequence.
**checkout/return staged wait state** — the pending-callback branch's spinner+`PaymentStatusBadge`+title
triple is replaced with a 2-node `StatusTimeline` («بازگشت از درگاه ✓» → «در انتظار تایید بانک», reusing the
timeline's existing `current`-state animated pulse for the "calm animated indicator" ask) plus an
expected-duration caption. The manual «بررسی دوباره» escape hatch and the bounded backoff poll are
untouched. The failed/window-expired/invalid-id branches now render through `PaymentStateCard` and no
longer double an icon with a redundant status chip (the failed state's `PaymentStatusBadge` was dropped —
audit's own complaint about the doubled signal).
**Wallet rebuilt as the money hub** (`wallet/`) — `page.tsx` → new `WalletScreen.tsx` (MUI `Tabs`, one
shared `CONTENT_MAX_WIDTH`, no local width override — the old `maxWidth: 560` is gone):
- **«پرداخت‌ها»** (`WalletPaymentHistory.tsx`) — every card + BNPL payment, newest first, `PaymentStatusBadge`
+ deep-link to the booking. Card rows come from the new `usePaymentHistory()` (REQ-047); BNPL rows are
derived from each settled wallet plan's own down-payment leg. A co-located `useWalletHistoryRows.ts` hook
merges the two independent seams once, reused by the receipts tab too. Degrades gracefully: either source
failing alone still renders the other's rows.
- **«اقساط»** — the unchanged f11 D5 `WalletInstallments` body (own heading/width stripped; it's a section
now, not a page).
- **«استردادها»** (`WalletRefunds.tsx`) — every refund via the new `useMyRefunds()` (REQ-048), each rendered
through the existing `RefundStatusCard` with a link to `bookingRefundStatusPath`.
- **«رسیدها»** (`WalletReceipts.tsx`) — invoice deep-links derived client-side from the same merged history
rows (succeeded + a known `bookingId`) — no endpoint, no money math, just a filter + `bookingInvoicePath`.
- A card-paying customer (the default path) now sees payment history instead of a permanently-empty
installments-only tab — the audit's top-severity finding.
**BNPL honesty + polish** (`checkout/bnpl/`):
- `BnplPlanCard` no longer shows a percent + `LinearProgress` bar for the down payment (a static fact
styled as a loading indicator). It now shows three plain Toman rows: پیش‌پرداخت (امروز), قسط ماهانه, and
**مجموع بازپرداخت** with the fee delta spelled out («+۴۵۰٬۰۰۰ تومان کارمزد» in `--bal-money-emphasis`) or
«بدون سود» for interest-free plans. The delta is `totalIrr orderAmountIrr` — the exact BigInt difference
of two already-served amounts (the same "exact remainder of served amounts" pattern the invoice page uses
for its service line), never a computed rate.
- `PlanStep`'s «مبلغ کل» header no longer defaults to `plans[0]` before any selection (the silently-morphing
number the audit flagged) — it renders only once a plan is selected, **names** the plan
(«مبلغ کل با طرح {plan}»), and shows the same fee delta.
- `EligibilityStep`: the credit-check button now swaps to a spinner + «در حال استعلام اعتبار…» while
pending (mirrors C6's `state_initiating` pattern); the prefilled mobile field is `readOnly` (normal
contrast, screen-reader-reachable) instead of `disabled`.
- `bnpl/return`'s invalid-link state CTA was promising a card payment it couldn't perform (labelled
«پرداخت با کارت», navigated to the bookings list) — relabelled «رزروهای من» (`bd_my_bookings`, C6's own
invalid-link pattern) so the label matches the destination; no recoverable request id exists at that
point to route to an actual card checkout instead.
- `checkout/bnpl/gateway/page.tsx` (the dev provider-handoff harness) is now `notFound()`-gated outside
`NODE_ENV=development` — it was reachable by direct URL in a production build. The equivalent card-gateway
harness was already deleted in refinement-phase-4; this one stays (BNPL is still mock-primary) but is no
longer reachable in prod. The dead `ROUTES.CHECKOUT_GATEWAY` constant (pointed at that already-deleted card
harness page) is removed, and the one place still referencing it — the payment mock's
`initiatePayment` — now returns `redirectUrl: null` (a latent bug: it was building a URL to a page that no
longer exists; the checkout page's `!redirectUrl` branch already reads the outcome directly, so behavior
is unaffected).
**Invoice: fiscal-grade** (`bookings/[id]/invoice/page.tsx`):
- Buyer name and a service+visit-date recap are composed **client-side** (a UI join, not money math) from
`useCustomerProfile()` and `useBookingDetail(bookingId, 'customer')` — no new fields needed.
- Payment method, transaction reference, and a seller fiscal-identity block (legal name / economic code /
address) render when `InvoiceDto` serves them (new `paymentMethod`/`transactionReference`/
`sellerFiscalIdentity` fields, REQ-049) — `null` on the real path hides the row rather than fake it.
- An A4 print pass: a `@page { size: A4; margin: 16mm }` rule alongside the existing print-visibility rule,
and a print-only footer (invoice number + issue date + مودیان reference when present) shown only inside
`@media print`. The existing print mechanics (visibility-scoped area, `insetInlineStart` anchoring,
dark→light token flip) are untouched.
- Deleted the stale comment claiming fa `common.brand` reads «بلینیار» — it actually reads «بالین یار»
(plain space); the brand-spelling unification itself stays phase 12's, per this phase's scope note.
**Money-display sweep**: `PriceBreakdown` rows now render through `<Money>` (every row carries «تومان», not
just the total — the exact Toman/Rial ambiguity the audit flagged); `InstallmentScheduleRow`'s `hideUnit`
was dropped so every installment amount carries the unit too.
## What is now testable (and exactly how)
1. Login as a seeded customer with an accepted request → `/fa/bookings/checkout?request_id=…` at 375px: the
nurse's avatar + verified badge sit above a large total figure; the sticky bar (total + «پرداخت») pins
above the bottom nav. Tap «پرداخت» → both CTAs (card + BNPL) disable, label swaps to «در حال شروع…».
2. Complete the mock capture round-trip → `checkout/return` shows «بازگشت از درگاه ✓» → «در انتظار تایید
بانک» with an animated current-node pulse and a duration hint — no bare spinner+chip stack.
3. Land on the confirmation: کد پیگیری renders LTR with a working copy-to-clipboard (toast confirms);
Shamsi paid-at, method, booking reference, and the escrow line are present; tap «چطور کار می‌کند؟» → the
3-step explainer expands. Block the network and reload → skeleton then a retryable error, never a
silently-missing amount.
4. Open «کیف‌پول» → four tabs. «پرداخت‌ها» lists the just-made payment linking to the booking; «اقساط» is
the unchanged installment tracker; «استردادها» shows a `RefundStatusCard` after cancelling a paid
booking; «رسیدها» links to the invoice. All four read at the same content width as checkout/invoice.
5. Back on checkout, tap «پرداخت اقساطی» → provider rows show a tinted monogram (no two-letter glyph); plan
cards show پیش‌پرداخت/قسط ماهانه/مجموع بازپرداخت in Toman with the fee delta on fee plans and no
`LinearProgress`; selecting a plan names the header total; the eligibility check shows a spinner + label
change while pending; an invalid BNPL return link offers «رزروهای من», not a dead "pay with card" promise.
6. `/fa/bookings/checkout/bnpl/gateway` in a production build (`npm run build && npm start`) → 404; `npm run
dev` → still reachable. Open a paid booking's invoice → buyer/service/visit-date/reference rows present
(mock); print preview shows an A4 page with a footer; from dark mode, the print dialog shows paper colors.
7. `npm run check` is green (`tsc` + `eslint`, zero errors) and `npm run test:ci` is green — **104/104 test
suites, 447/447 tests** (3 new suites: `PaymentStateCard`, `BnplProviderLogo`, `EscrowExplainer`; existing
`Money`/`PriceBreakdown`/`BnplPlanCard` suites extended for the new size/behavior).
8. Repeat 15 on `/en` (LTR) and in dark mode: no clipped RTL/LTR islands, tokens resolve in both schemes,
«تومان» (fa) / "Toman" (en) on every amount including breakdown rows and installment amounts.
## What is mocked / waiting on a real service
No new seams — everything stays behind the existing `PaymentApi`/`BnplApi`/`RefundsApi` seams
(`USE_PAYMENT_MOCK=false`/`USE_BNPL_MOCK=true`/`USE_REFUNDS_MOCK=true`, all unchanged). Extended, not
replaced:
- `PaymentApi.getCheckoutSummary`/`getPaymentOutcome` gained mock-only `nurseAvatarUrl`/`nurseVerified`/
`trackingCode`/`paidAt`; a new `PaymentApi.getPaymentHistory` reads the mock's transaction list.
- `PaymentApi.getInvoice`'s mock-issued invoices gained `paymentMethod`/`transactionReference`/
`sellerFiscalIdentity`.
- `RefundsApi` gained `getMyRefunds`, reading the same in-memory refund store `getRefundByBooking` uses.
- The BNPL provider-handoff harness page is now env-gated (see above) — not a seam change.
See `mocks-registry.md`'s updated `PaymentApi`/`RefundsApi`/BNPL-harness rows for the exact deltas and the
"make it real" steps (REQ-046/047/048/049).
## Contracts
- Consumed: `services/payment` (b10/b11), `services/bnpl` (b12), `services/refunds` (b11) — all unchanged
contract-wise; the mock/real seam split is untouched.
- Requested (`dev/shared-working-context/frontend/requests/for-backend.md`, next free numbers 046049):
- **REQ-046**`nurseAvatarUrl`/`nurseVerified` on `CheckoutSummaryDto` (the C6 identity moment) +
`trackingCode`/`paidAt` on `PaymentOutcomeDto` (the confirmation receipt). Extends REQ-016/017.
- **REQ-047** — a customer payment-transactions list (`GET bookings/payment_history` proposed) for the
wallet «پرداخت‌ها» tab.
- **REQ-048** — a customer "all my refunds" list (`GET refunds/my` proposed) for the wallet «استردادها»
tab — confirmed b11 truly has only by-booking/by-id customer reads. Extends REQ-021.
- **REQ-049**`paymentMethod`/`transactionReference`/`sellerFiscalIdentity` on `InvoiceDto` for the
fiscal-grade invoice. Extends REQ-018.
## Docs updated
- `client/CLAUDE.md` "Project Structure": the checkout/return/confirmation/bnpl subtree (identity moment,
sticky bar, staged wait state, receipt fields, provider-logo/eligibility/plan-naming polish, the gateway
harness env-gate), the invoice line (fiscal fields + A4 print), the wallet line (4-tab hub replacing the
installments-only shell), the `Money`/`PriceBreakdown`/`EscrowNotice`/`InstallmentScheduleRow`/
`BnplPlanCard` component lines, and three new component entries (`PaymentStateCard`, `BnplProviderLogo`,
`EscrowExplainer`). The `services/payment`/`services/refunds`/`services/bnpl` domain-summary lines note
the new hooks/DTO fields.
## Follow-ups for later phases
- **Buyer name on the invoice is a live client-side join** (`useCustomerProfile()`), not a snapshot on
`InvoiceDto` — correct for MVP but not strictly immutable (a later profile-name change would reflect on
an old invoice). If invoice immutability becomes a real concern, a future REQ should ask for a
server-snapshotted `buyerName` at issue time.
- **`variantName()`'s best-effort `variantSnapshotJson` parse is now duplicated a third time**
(`BookingDetailView.tsx`, the review page, and this phase's invoice page) — REQ-045 (typed
`variantSnapshot`) would let all three collapse to one read; not extracted to a shared helper this phase
to avoid touching files outside this phase's lane speculatively.
- **Wallet «پرداخت‌ها»/«رسیدها» merge two independent seams client-side** (card `PaymentApi` history + BNPL
`WalletInstallmentPlan` down-payment rows) rather than one unified "customer transactions" read — kept
the domains separate per existing architecture; if a future phase adds a true unified ledger read, the
merge in `useWalletHistoryRows.ts` can retire.
- The BNPL card's fee-delta math (`totalIrr orderAmountIrr`) needs the order amount threaded down through
`PlanStep`/`BnplPlanCard` — fine today (both wizard steps already have it in scope) but worth folding into
a single served `feeIrr` field per plan if REQ-022 (options/schedule) ever lands, so the client stops
doing even this exact-remainder subtraction.
- Real gateway/Shaparak logos near the pay CTA are still DEFERRED (no licensed assets) — `secure_gateway_notice`
(lock icon + text) is the interim trust signal, per the phase's own scope note.
## Memory
Saved a `project`-type memory (`ui_phase_6_checkout_and_money.md`, indexed in `MEMORY.md`) covering: the
`PaymentStateCard`/`BnplProviderLogo`/`EscrowExplainer` extraction decisions, the provider-logo (monogram
roundel, no brand colors) and money-width (one `CONTENT_MAX_WIDTH`) decisions, the fee-delta-as-exact-
remainder pattern, the wallet history/receipts merge-two-seams-client-side decision, the sticky-bar-vs-
bottom-nav layering (reuses `StickyActionBar`'s existing safe-area composition, never re-implemented), the
print-stylesheet `@page` addition, and REQ-046..049 for whichever backend phase next touches b10/b11/b12.
@@ -0,0 +1,185 @@
# UI Phase 7 — Nurse Daily Ops — Report (2026-07-19)
## What was built
**The nurse "امروز" dashboard** (`nurse/page.tsx``NurseDashboardScreen.tsx`, new) — replaces the
`PlaceholderScreen` with pure assembly over already-cached queries:
- Greeting header (name off `useMe`) + own `TrustBadge` (`ownBadgeState`, one cached `useVerificationStatus`
query, not two).
- `NextVisitCard` — first actionable session from `useTodaySessions`, a display-only "starts in" line
(`formatRelativeTime`), a check-in shortcut to `/nurse/visits`; a calm «امروز ویزیتی ندارید» empty state.
- `RequestsStrip` — the most time-critical widget (sorts above earnings): pending count, the most urgent
request's urgency-tinted countdown, an inline open into the request detail.
- `EarningsSnapshotCard` — a compact two-stat row (signed net payable + eligible) via `useNurseEarningsBalance`
+ `<Money>`; never clamps a negative balance.
- `DashboardActivationSlot` (new, exported, page-local) — the **named composition point** for Phase 8. Filled
for now with only the existing verification-status banner when not yet approved; renders nothing once
approved. **Phase 8: extend `client/src/app/[locale]/(private-routes)/nurse/DashboardActivationSlot.tsx`
in place — don't add a second slot.**
- `NotificationsEntryRow` — unread count (`useUnreadCount`) linking to `ROUTES.NURSE_NOTIFICATIONS`.
- Every widget: skeleton → error-with-retry → empty → data, in that order.
**Visits day surface** (`visits/page.tsx`):
- Shamsi «امروز، {day month}» date anchor via `PageHeader` (replaces the static title).
- `useTodaySessions` gained a 60s `refetchInterval` (`TODAY_SESSIONS_REFETCH_MS`) so a same-day schedule
change surfaces without re-navigation; the EVV-mutation invalidation is untouched.
- `SessionCard` gained an optional `serviceLabel` prop, rendered under the title; the day surface passes
`item.variantLabel` (REQ-052, mock-tolerant — `undefined` on the real path just omits the line).
- A real error state (`ErrorState` + retry) replaces the old two-field `{ data, isLoading }` destructure that
rendered "no visits today" on a failed query.
**`SessionCard` EVV hero** (shared, both roles — gated on `showEvvControls`):
- Check-in/check-out are now the **full-width, ≥48px** primary action (`width:'100%', minHeight:48`),
replacing the small `alignSelf:'flex-start'` button visually equal to the "view booking" text link beside
it.
- Check-out now goes through the new shared `CheckOutConfirmButton` (`components/booking/`, tested) — a
lightweight `ConfirmDialog` before firing («اتمام ویزیت؟» — it ends the visit and starts the payout clock).
Reused by `BookingDetailView`'s in-visit banner so both entry points confirm the same way.
**Visit detail workspace** (`BookingDetailView.tsx`, shared both roles):
- **Address card** (new, standalone — replaces the old inline hero address line): renders the frozen
`addressSnapshotJson` when present (title/city/district/line, best-effort parse — REQ-045 still open), a
`geo:{lat},{lng}` map deep-link + a Neshan web-map fallback link when the snapshot carries coordinates
(REQ-051), and a quiet nurse-only "available after confirmation" note when masked. Never sourced from the
b8 request stage.
- **In-visit mode** (new, nurse-only): when a session is `checked_in`, an `AccentCard` state header —
«در حال ویزیت» + elapsed on-site time (`formatElapsed` against the server `checkInAt`, never a guessed
start) + the check-out CTA **promoted to the top**, via the same `CheckOutConfirmButton`.
- **Contact affordance — already delivered, verified, no changes needed.** `BookingSupportEntry`/
`EmergencyBanner` (messaging composites, already mounted on `visits/[id]/page.tsx`) already surface a
`tel:` click-to-call from the gated care-instructions read for the nurse on a confirmed+ booking. The
`phone`/`navigate` icons the phase doc flagged as missing are also already registered
(`AppIcon/config.ts`) — both audit findings were stale against current code.
- Notes placement: `NurseVisitNotesPanel`/`BookingSupportEntry`/`BookingDetailView` were already each
`maxWidth:640, mx:'auto', width:'100%'` and stacked under one `gap:3` — verified consistent, no change
needed.
**Request inbox redesign** (`requests/page.tsx`, `requests/[id]/page.tsx`):
- Decision-first `InboxCard`: service + price headline when the list item carries `variantLabel`/
`variantPrice` (REQ-050, mock-tolerant — degrades to the patient-name headline on the real path), patient/
time/gender as secondary facts. The whole card is now a tappable link (`AppLink`), matching the "fully
tappable AccentCard row" convention from the customer bookings screen.
- Urgency-tinted countdown pill: `CountdownTimer`'s existing v2 tier API (`warnThresholdSeconds`/
`urgentThresholdSeconds`) at teal >2h / amber <2h / terracotta <30min, a label, and `coarseLabel` (which
opts into `aria-live="polite"` humanized copy) — no extension to `CountdownTimer` was needed, its ui-phase-1
API already covers this.
- Tabs + pager: «در انتظار» (`pending_nurse_response`) / «پاسخ‌داده» (client-merged `accepted_awaiting_payment`
+ `converted` + `rejected_by_nurse`, **page-1-only — documented limitation**, filed as part of REQ-050's
status-group-filter ask) / «منقضی» (`expired_no_response`). `useNurseRequestInbox` gained an optional
`enabled` param so the four non-active tab queries don't poll in the background.
- Accept now requires a `ConfirmDialog` («با پذیرش، خانواده برای پرداخت دعوت می‌شود؛ پس از پرداخت، رزرو قطعی
می‌شود.») before firing — the payment-window duration is never hard-coded; after acceptance the detail page
renders a `CountdownTimer` against the server `paymentDeadlineAt`.
- The inbox's error handling (`isError``ErrorState`) was **already correct** in the current code — the
phase doc's cited line numbers were stale (a prior phase had already fixed the false-empty defect).
**Earnings clarity** (`earnings/page.tsx`, `PayoutHistoryRow`, payout detail page):
- «برداشت بعدی» `ForecastLine` above the tabs — server-served only (`nextPayoutDate`/
`nextPayoutEligibleAmountIrr`, both optional on `NurseEarningsSummary`), renders nothing until served
(REQ-053, never computed client-side).
- Failure-reason mapping (`services/payouts/failureReasons.ts`, new): known `failureReason` codes (today:
`invalid_sheba`) render a mapped Persian/English label as the headline; unknown codes get a generic message;
the raw code is always demoted to a secondary `dir="ltr"` caption — never the raw vendor string as the
headline. Applied to both `PayoutHistoryRow` and the payout detail page.
- `ExplainerCard` a11y: the bare `onClick` `Stack` is now a real `ButtonBase` (`aria-expanded`, `aria-controls`)
and the eye icons (`visibilityon`/`visibilityoff`) are replaced by the registered `expand` chevron (rotates
180° when open).
- Width normalization: adopted one page-level convention — `CONTENT_MAX_WIDTH` (800) + `mx:'auto'` — across
every page this phase touches (`requests/page.tsx`, `requests/[id]/page.tsx`, `visits/page.tsx`,
`earnings/page.tsx`, `earnings/payouts/page.tsx`, `earnings/payouts/[id]/page.tsx`). The dashboard uses a
wider `DASHBOARD_MAX_WIDTH` (960) per the phase's "the dashboard may go wider" allowance. `BookingDetailView`
(640) is a component-level width, untouched — a separate decision from the page-level convention.
**New shared component:** `Pager` (`components/common/Pager/`, tested) — the prev/next "page X of Y" control,
replacing three near-identical inline pagers (earnings, payout history, and the new inbox tabs). New `common`
namespace i18n keys (`page_prev`/`page_next`/`page_indicator`) so it doesn't depend on the `payouts`
namespace's copies (left in place, unused by the new code, but not deleted — out of scope to chase down every
call site).
**REQ-039…043 renumbered to REQ-050…054** — the phase doc was written assuming REQ-001…038 were taken, but
ui-phase-3 through ui-phase-6 have since filed REQ-039…049. Filed as REQ-050 (inbox decision data +
status-group filter), REQ-051 (nurse-view address post-confirmation), REQ-052 (today-feed service label),
REQ-053 (payout forecast), REQ-054 (web-push, deferred/non-blocking) — see `for-backend.md`.
## What is now testable (and exactly how)
1. Log in as the seeded verified nurse (refinement-phase-1 demo accounts) → `/nurse` shows a real dashboard:
greeting + trust badge, next visit (or the calm empty state), a pending-requests strip, an earnings
snapshot, and (if not yet approved) the activation banner.
2. `/nurse/visits` (mobile, `/fa`): a Shamsi «امروز، …» header; check-in is a full-width primary CTA; check-out
opens a confirm dialog first. Deny browser GPS → check-in still succeeds with the advisory warning toast.
3. `/nurse/requests`: three tabs; a pending card leads with service + price **only when
`USE_BOOKING_REQUESTS_MOCK=true`** (flip the flag in `services/bookingRequests/constants.ts` — the real
path degrades to the patient-name headline); the countdown pill is teal/amber/terracotta by remaining time
(seed/adjust a request's deadline to see the escalation). Open a request → «پذیرش» → confirm dialog →
accept → the detail shows a payment-window countdown.
4. Stop the API (or force a query error) on `/nurse/requests` or `/nurse/visits` → an `ErrorState` panel with
retry, never the empty state.
5. `/nurse/visits/[id]` with **`USE_BOOKINGS_MOCK=true`** on a confirmed booking (e.g. id `5001` or the
completed `5005`) → an address card with a working `geo:`/Neshan-web map link (booking 5001's seed now
carries lat/lng matching the EVV reference point); on the real (masked) path → the quiet "available after
confirmation" note, no crash. Check in on a today session, then open its booking detail → the «در حال
ویزیت» banner with elapsed time + a promoted check-out CTA.
6. `/nurse/earnings`: the forecast line always appears (payouts stays mock-primary,
`USE_PAYOUTS_MOCK=true` by default); a failed payout (seeded `invalid_sheba`) shows a mapped Persian/
English label as the headline with the raw code as a small LTR caption; the explainer header opens with
Enter/Space and reports `aria-expanded`.
7. Repeat 16 on `/en` and dark mode — no stock-MUI colors, no Latin digits in `fa` timers.
**Verification performed:** `npm run check` (tsc + eslint) is green; `npm run test:ci` is green — 106 suites /
455 tests, including the 2 new suites (`Pager`, `CheckOutConfirmButton`) and every touched shared component's
existing suite (`SessionCard`, `BookingDetailView`, `PayoutHistoryRow`). A dev-server smoke pass confirmed
every touched route (`/nurse`, `/nurse/requests[/1]`, `/nurse/visits[/5001]`, `/nurse/earnings[/payouts[/9001]]`,
`/en/nurse`) compiles and responds 200 with no server errors. **Not performed:** a full authenticated
click-through in a browser (no browser-automation tool available in this session, and exercising the OTP login
flow end-to-end needs the backend API running) — the "How to test" steps above are written for a human to run
that pass manually.
## What is mocked / waiting on a real service
- `services/bookingRequests`**real** by default (`USE_BOOKING_REQUESTS_MOCK=false`); the mock's
`toListItem` now also stamps `variantLabel`/`variantPrice`/`variantPriceUnit` (REQ-050) so the redesigned
inbox card is demonstrable when the flag is flipped for local testing. No registry row (bookingRequests was
already de-mocked in refinement-phase-4 and has none — consistent with its siblings).
- `services/bookings`**real** by default (`USE_BOOKINGS_MOCK=false`); mock changes: `forViewer` now
unmasks `addressSnapshotJson` for the nurse once `isBookingConfirmedOrBeyond` (simulating REQ-051 ahead of
the real endpoint), `addr5001` gained matching `latitude`/`longitude`, and `listTodaySessions` stamps
`variantLabel` (REQ-052). See the updated `mocks-registry.md` row.
- `services/payouts`**mock-primary** (`USE_PAYOUTS_MOCK=true`, unchanged — REQ-025 is still the root gap);
`buildSummary()` now also serves `nextPayoutDate`/`nextPayoutEligibleAmountIrr` (REQ-053). See the updated
`mocks-registry.md` row.
- No new seams introduced this phase — every change extends an existing `services/{domain}` mock behind its
existing seam.
## Contracts
- Consumed: `dev/contracts/domains/{booking-requests,bookings-evv,payouts}.md` (unchanged this phase — no
contract landed to consume).
- Filed to `for-backend.md`: **REQ-050** (nurse inbox `variantLabel`/`variantPrice`/`variantPriceUnit` + an
`answered` status-group filter), **REQ-051** (nurse-view address on confirmed+ bookings), **REQ-052**
(service label on the today feed), **REQ-053** (payout forecast), **REQ-054** (web-push, deferred).
## Docs updated
- `client/CLAUDE.md` "Project Structure": the nurse route tree (`page.tsx``NurseDashboardScreen.tsx` +
`DashboardActivationSlot.tsx`, `requests/`, `visits/`, `earnings/`) and the shared component tree
(`components/common/Pager/`, `components/booking/`'s `CheckOutConfirmButton` + the `BookingDetailView`/
`SessionCard` additions) updated in the same change.
- `dev/shared-working-context/reports/mocks-registry.md`: `BookingsApi` and `PayoutsApi` rows updated in place
with this phase's mock additions (also corrected `BookingsApi`'s stale "default `true`" config-flag note —
it's `false`, real-primary, since refinement-phase-4).
## Follow-ups for later phases
- **Phase 8** owns `DashboardActivationSlot`'s content — the fuller "go live" checklist (profile/services/
coverage/bank all done), per the phase doc's own hand-off note.
- REQ-050's status-group filter (`status=answered`) would collapse the inbox's three-query «پاسخ‌داده» merge
into one real paginated query — currently page-1-only, documented in the REQ and in code.
- REQ-051 (nurse-view address) is the highest-value of this phase's REQs — until delivered, the address card
is demoable only via the mock; REQ-045 (a typed address-snapshot shape) is still open from ui-phase-5 and
would let the address card + `.ics` export drop their best-effort JSON-key parsing.
- The `payouts` namespace's now-unused `failure_reason_label`/`page_prev`/`page_next`/`page_indicator` i18n
keys were left in place (superseded by `failure_code_*` and the shared `common.page_*` keys respectively) —
a future cleanup pass could remove them if nothing else references them.
- Web-push (REQ-054) remains genuinely deferred — the 15s poll is the only freshness mechanism for new
requests until a service-worker + backend push rail is built.
@@ -0,0 +1,216 @@
# UI Phase 8 — Nurse Business & Verification — Report (2026-07-19)
## What was built
**Real go-live switch + one activation checklist** (`src/components/ActivationChecklist/`, new shared
component + `useActivationChecklist` hook):
- The profiles seam gained `setAcceptingBookings` (types + `clientApi` + `mockApi` + a new
`useSetAcceptingBookings` hook) — wiring the real, previously-unwired
`POST nurse_profiles/set_accepting_bookings` endpoint the audit found. Invalidates the nurse-profile
query on success; the mock mirrors the flip (never touching `isVerified`).
- `ActivationChecklist` folds five already-cached queries (verification, nurse profile, `useMyVariants`,
`useServiceAreas`, `useNurseBankAccounts`) into rows with **two-tier honesty**: identity/profile
complete/≥1 active service/≥1 coverage area drive **search visibility** (`is_searchable`); the verified
bank row is labelled separately as **"برای دریافت درآمد"** and never gates search. Collapses to a
compact «فعال در جستجو» confirmation once every row passes *and* the nurse is actively accepting
bookings. Mounted on `/nurse/services` (above the offerings list, inside `MyServicesList`) and in
`DashboardActivationSlot` (replacing ui-phase-7's single-row placeholder) — one shared component.
- `PublishGate` rewritten from scratch: consumes the same `useActivationChecklist` state (no duplicated
condition logic) and renders one of three real states — unmet conditions → guidance naming exactly
what's missing (`publish_unmet_intro`); met but paused → «شروع پذیرش رزرو» calling the real mutation;
live → the on state + «توقف موقت پذیرش». Success/error toasts fire only after the mutation resolves —
the old `enqueueSnackbar('published')` no-op is gone (grep confirms no reachable `publish_done`/
`publish_cta` string remains).
**Unified vertical verification journey** (`nurse/verification/`):
- `verificationSteps.ts` gained the presentation-grouping layer (`StepGroupKey`, `GROUP_ORDER`,
`stepGroup`/`groupLabelKey`/`groupRoute`/`groupedDisplaySteps`/`groupStatus`) — folds the existing
data-driven step catalog into three journey groups (هویت / مدارک حرفه‌ای / بانک) without touching the
catalog itself.
- `VerificationChecklist.tsx` rebuilt as ONE vertical spine of grouped step cards (replacing the flat
"X از Y" meter + 7-row list) — each card shows its steps, per-step reason/auto-note, and a single
"برو"/"رفع مشکل" CTA to the screen that owns that group.
- `TrustBadgePreviewPanel.tsx` (new) — the hub's payoff: a live `TrustBadge` (`ownBadgeState`, never
invents a state) + a per-group fill indicator, framed as «این نشان را خانواده‌ها می‌بینند».
- `VerificationJourneyHeader.tsx` (new, shared across B4/B5/B6) — replaces the competing bare 3-step
`StepperHeader` those three screens rendered alongside the hub's own meter. One progress answer now:
the hub's grouped spine is the only place progress is shown; B4/B5/B6 get a group name + «بازگشت به
مسیر تأیید» back link.
- B4 (`identity/page.tsx`): added `CaptureGuideFrame` — a cheap, dependency-free CSS viewfinder
(corner brackets for the ID card, an oval for the selfie) + a static hint line above each
`DocumentUpload`, per §3.3's capture-guidance ask. No client-side blur/darkness heuristic — judged not
"trivially cheap" without a canvas-pixel pass, so left out (documented, not silently skipped).
- B5 (`credentials/page.tsx`): hydrates INO/specialties/registry fields from a new mock-tolerant
`status.credentialSubmission` field (REQ-056, filed). Once the INO number is on file it locks into a
"شمارهٔ نظام ثبت شد" confirmation row (a «تغییر» link re-opens it) — never re-prompted as if lost, never
silently re-sent blank (the raw number is never read back by design, and the server's
`CredentialDetailsInput.inoNumber` is required, so re-submitting without re-entering it isn't possible).
The two native `type="date"` fields are now `JalaliDateField`s. The submit gate now considers
server-side document state (`in_review`/`passed`), not just this session's uploads, so a returning
nurse is never dead-ended on a disabled button with no explanation; when genuinely nothing is on file
yet, an explanatory caption replaces the silent disable.
- B6 (`review/page.tsx`): a Shamsi submitted timestamp from a new mock-tolerant `status.submittedAt`
field (REQ-055, filed) + a `StatusTimeline` what-happens-next (مدارک ثبت شد → بررسی توسط کارشناس →
فعال‌سازی نشان) + the same `VerificationJourneyHeader` as its siblings.
- Verification + B4/B5/B6 pages adopted the ui-phase-7 `CONTENT_MAX_WIDTH` + `mx:'auto'` + `PageHeader`
convention (closing the trust-ops audit's "verification (620), no `mx:auto`" width-chaos finding that
ui-phase-7 didn't reach).
**`DocumentUpload` precedence fix + capture guidance** (`src/components/DocumentUpload/`):
- Fixed the render-branch ordering: `state === 'uploading'` **and** the success flash now win over the
still-true `rejected` prop, so a re-upload shows live progress (with the original rejection reason kept
visible above the bar) instead of staying frozen on the red rejected card; the re-upload button is
never rendered mid-flight (no double-submit). New final order: uploading → success → rejected (resting)
→ local error → idle.
- Test suite gained a scripted rejected→re-upload→progress→success case asserting the reason stays
visible and the re-upload button disappears during the upload.
**Services & variant builder** (`nurse/services/`):
- `VariantCard` gained an `interactive` prop (default `true`; `false` drops the Edit/Deactivate row) —
used for read-only preview contexts without forking the component.
- `VariantBuilder` step 3 now renders the real `VariantCard` (`interactive={false}`) as «این‌گونه در
جستجو دیده می‌شوید», composing the entered name/category/price, replacing the old price-only estimate
panel (`PriceDisplay`'s estimate line is still shown — it's inside `VariantCard` — nothing was lost).
- Step 2's wrapped `ToggleButtonGroup` (broken borders on wrap) replaced with a chip group, matching the
house pattern used elsewhere (B5 specialties, the profile's specializations).
- The duplicate-listing warning restyled as an `AccentCard` (`tone="warning"`) with `text.primary` body
and warning reserved for the edge/icon (was amber-on-paper, a light-mode contrast fail) — plus a new
"ویرایش خدمت موجود" affordance that resolves the colliding existing variant via `optionSetSignature`
against the already-cached `useMyVariants()` list and jumps straight into editing it
(`onEditExisting`, wired through `NurseServicesPage`).
**Coverage — one control owns whole-city** (`nurse/coverage/page.tsx`):
- Dropped the separate whole-city/districts scope `ToggleButtonGroup` entirely. `CascadingRegionSelect`'s
own district level (its «کل شهر» empty option) is now the **only** control — `districtId = null` is a
complete, valid whole-city submission, never an error state. The "district required" error class the
audit flagged can no longer be triggered by picking a UI-offered option, because there is no longer a
second control that could disagree with the select. `CascadingRegionSelect` itself is untouched — no
prop-gating was needed, so addresses/search are provably unaffected.
- `removeArea.mutate` gained an `onError` toast (was silent).
- The optional map visualization was **not built** — deliberately deferred; the phase doc marks it
optional ("(if built)" in the testing script) and real tile rendering is explicitly deferred to Phase 9.
**Bank — an accounts section** (`nurse/bank/page.tsx`):
- Restructured around a persistent «افزودن حساب دیگر» CTA once ≥1 account exists (replacing the
form-only-when-`accounts.length===0` gate) with a cancel affordance on the now-optional form. A failed
`useNurseBankAccounts` query now renders `ErrorState` + retry — never the empty-state form (which
invited a duplicate-IBAN submission blind). The pending-inquiry copy is now explicit about the wait
(«در حال استعلام صحت شبا؛ معمولاً چند دقیقه طول می‌کشد…»). `setPrimary` gained an `onError` toast.
`BankStatusPanel`'s three-state design is untouched.
**Profile — qualifications editable + public preview** (`nurse/profile/`):
- Education level/field are now real `select` fields (a curated preset list + a «سایر» free-text
fallback — there is no server-side enum, so the client stores stable internal codes, never a
locale-baked label, matching how every other coded field in this app works) submitted through the
existing upsert (no REQ, no server change — confirmed the server already accepts+persists them).
Specializations are chips off the shared `SPECIALTY_PRESETS` vocabulary (same codes B5 uses).
- Avatar upload + profile save already had `onError` toasts (the audit's citation was stale — confirmed
by reading the current code before touching it); added a `beforeunload` guard so a staged-but-unsaved
avatar is never silently discarded on a reload/tab-close. (In-app route-away interception was judged
out of scope for a page-local fix — no cross-app unsaved-changes framework exists yet; noted below.)
- New route `/nurse/profile/preview` («نمایهٔ عمومی من») — composes the C3 trust-dossier pieces
(`TrustBadge`, `VerificationPanel`, `ServicePriceRow`) **entirely from the nurse's own cached data**
(own profile + `useMyVariants` + `useServiceAreas` + own badge via `useNurseTrustBadge(me.id)`) — no
dependency on the search index, so it renders truthfully pre-publish. Linked from both the profile page
and the services list (`MyServicesList`).
## What is now testable (and exactly how)
1. As the seeded **unverified** nurse → `/nurse/services`: `ActivationChecklist` shows the unmet rows
(identity/profile/services/coverage) each with a «تکمیل» deep link, plus the separately-labelled bank
row; `PublishGate` below it shows the blocked guidance naming the same unmet items — no button that
fakes success.
2. Complete verification via the dev admin sim (`__mockApproveAll`, B3's mock controls) → the identity
row flips; once profile/services/coverage are also done, `PublishGate` shows «شروع پذیرش رزرو» → click
→ Network tab shows `POST nurse_profiles/set_accepting_bookings` → the panel flips to the live state +
«توقف موقت پذیرش»; add a verified bank account too → `ActivationChecklist` collapses to «فعال در
جستجو».
3. `/nurse/verification`: one vertical spine (هویت / مدارک حرفه‌ای / بانک cards) + the `TrustBadgePreviewPanel`
above it. Open B4/B5 → no 3-step `StepperHeader` anywhere, just the group header + back link. B4 shows
a dashed viewfinder + hint above each capture. B6 shows the Shamsi submitted date + the 3-node
what-happens-next timeline.
4. In B5, upload a manual-step doc, reject it via the B3 mock admin control
(`__mockRejectStep('moh_competency_license', 'blurry_scan')`), then re-upload from B5 → the progress
bar animates while the rejection reason stays visible above it; the re-upload button is not rendered
mid-flight. Leave (`/nurse/verification`) and return to B5 → the INO number shows the "on file"
confirmation row (never blank), specialties/dates are pre-filled, dates open the Jalali picker.
5. `/nurse/services` → add a variant: step 2 renders chip groups (no broken borders on wrap); step 3
shows the live `VariantCard` preview; submitting the exact same category+options twice shows the
restyled (readable, non-amber-body) duplicate warning with a "ویرایش خدمت موجود" link that opens the
builder already editing the colliding listing.
6. `/nurse/coverage`: only one control (the district select) offers whole-city; picking «کل شهر» there
and submitting never errors. Kill the `removeArea` mutation (e.g. force a network error) → a toast
appears instead of silent failure.
7. `/nurse/bank` with a verified account → «افزودن حساب دیگر» opens the form (with a cancel action);
submitting shows the explicit multi-minute inquiry copy while pending; kill the API and reload →
`ErrorState` + retry, never the empty-state form.
8. `/nurse/profile`: pick an education level/field (or «سایر» + free text), toggle specialization chips,
save, reload → values persist. Stage an avatar (don't save), then try to reload the tab → the browser's
native "leave site?" prompt appears. Open «پیش‌نمایش نمایهٔ عمومی من» (from both `/nurse/profile` and
`/nurse/services`) → the own-data listing renders with TrustBadge + `VerificationPanel` + priced
services + coverage chips, reachable even before the nurse is search-visible.
9. Repeat the key screens on `/en`, dark mode, and a ~390px viewport.
**Verification performed:** `npm run check` (tsc + eslint) is green. `npm run test:ci` is green — 107
suites / 462 tests, including the new `ActivationChecklist` suite, the extended `DocumentUpload` and
`VariantCard` suites, and every other pre-existing suite (no regressions). i18n key parity between
`en.json`/`fa.json` was verified programmatically (0 keys only-in-one-file). **Not performed:** a live
authenticated click-through in a browser (no browser-automation tool available in this session, same
constraint ui-phase-7 noted) — the "How to test" steps above are written for a human to run that pass
manually.
## What is mocked / waiting on a real service
- `services/profiles`**real** by default (`USE_PROFILES_MOCK=false`); `setAcceptingBookings` targets
the real, already-live `POST nurse_profiles/set_accepting_bookings` route — this is not a new mock, it
is finishing the wiring of an endpoint that already existed server-side. The mock implementation was
still added (mirrors the flip) so local dev with `USE_PROFILES_MOCK=true` keeps working. See the
updated `mocks-registry.md` row.
- `services/verification`**mock-primary** (`USE_VERIFICATION_MOCK=true`, unchanged, per the phase
doc's explicit instruction not to flip it). Two new mock-tolerant fields added to `VerificationStatus`:
`submittedAt` (REQ-055) and `credentialSubmission` (REQ-056) — both `undefined` on the real path,
degrading gracefully (B6 omits the timestamp; B5 falls back to blank fields, same as before this
phase). See the updated `mocks-registry.md` row.
- No other domain's mock flag changed. No new seams introduced.
## Contracts
- Consumed: `dev/contracts/domains/{identity-profiles,verification}.md` (unchanged this phase — no new
contract landed to consume; `set_accepting_bookings` was already documented server-side, just unwired
client-side).
- Filed to `for-backend.md`: **REQ-055** (`submittedAt` on the nurse-facing `VerificationStatusDto`),
**REQ-056** (nurse-facing read-back of submitted credential details — INO-on-file boolean +
specialties/registry fields, never the raw number).
## Docs updated
- `client/CLAUDE.md` "Project Structure": the nurse route tree (`profile/``preview/page.tsx`,
`services/` PublishGate/VariantBuilder changes, `coverage/page.tsx`, `bank/page.tsx`, the `verification/`
subtree's new `TrustBadgePreviewPanel.tsx`/`VerificationJourneyHeader.tsx` + regrouped
`VerificationChecklist.tsx`/`verificationSteps.ts`), the shared component tree (`ActivationChecklist/`,
`VariantCard`'s `interactive` prop), the `services/profiles` entry (`setAcceptingBookings`), and the
i18n namespace list (`nurseProfile`/`bank`/`coverage`/`services`/`verification` deltas + the new
`activation` namespace).
- `dev/shared-working-context/reports/mocks-registry.md`: `ProfilesApi` and `VerificationApi` rows
updated in place with this phase's additions.
- `dev/constants/routes.ts` (client): added `ROUTES.NURSE_PROFILE_PREVIEW`.
## Follow-ups for later phases
- **REQ-055/REQ-056** are genuinely useful, low-effort backend additions (both are pure reads over data
the server likely already has, or can trivially derive) — good candidates for an early slice of a
future backend refinement pass.
- The optional coverage map visualization (§3.6) was not built — deferred to Phase 9, which owns the real
map-picker work this would reuse.
- In-app (client-side-routed) unsaved-avatar interception was scoped down to the `beforeunload` guard
(covers reload/tab-close, the highest-value real risk). Intercepting an in-app `Link` navigation away
from the profile page would need a small cross-app "confirm navigation" primitive that doesn't exist
yet — worth building once a second page needs the same guard, not invented single-purpose here.
- The education-level/field select-with-"سایر"-fallback pattern stores a stable internal code (not a
wire enum — `NurseProfileDto.educationLevel`/`educationField` are plain strings with no server-side
vocabulary). If a future phase wants these queryable/filterable server-side, that would need a real
enum contract — flagged here as a design note, not filed as a REQ (no current feature needs it).
- `VariantCard`'s `visibilityon`/`visibilityoff` deactivate/reactivate icons are not registered in
`AppIcon/config.ts` (pre-existing gap predating this phase, confirmed via `git blame`-equivalent code
read — not introduced here; surfaces as a harmless dev-only console warning in tests). Out of this
phase's scope; a one-line `AppIcon/config.ts` registration whenever someone's next in that file.
@@ -0,0 +1,176 @@
# UI Phase 9 — Customer account & care circle — Report (2026-07-19)
## What was built
### Account hub (`profile/page.tsx`)
- Rebuilt from a flat settings form into an account hub: `ProfileSummary` identity header (avatar/initials
via the new `initialsFallback` prop + name + server-masked phone), grouped tappable rows (اطلاعات شخصی /
مخاطب اضطراری / نشانی‌ها / زبان / اعلان‌ها / پشتیبانی / خروج), an emergency-contact status card (complete →
success check + `tel:`-only link; incomplete → a warm nudge), and a working خروج row (`ConfirmDialog`
`useLogout()`, the single logout path).
- `/profile` stays the single route — اطلاعات شخصی/زبان/مخاطب اضطراری open `FormDialogShell` sheets, not
sub-routes. زبان's sheet hosts the phase-2 `LocaleSwitcher` (the actual UI locale switch) alongside the
server-stored `preferredLanguage` field — two distinct concerns, no second locale mechanism built.
- The silent-load-error/blank-form defect (§3.1.4 of the phase doc) was **already fixed** before this phase
started (confirmed against the current code, not the audit's snapshot — see "Docs updated" below); this
phase's profile work is the hub redesign, not that fix.
- `ActorSwitcher` preserved verbatim (renders nothing for a single-role session).
### Care-circle reframe (`patients/page.tsx`, `PatientCard`, `PatientHeader`)
- **Naming decision:** «بیماران» → **«حلقهٔ مراقبت»** for the list/page title, nav label, and empty
states (the safer default per the audit, given the «خودم» self-care relation). Individual-action copy
(the add button, archive confirm) uses the neutral **«فرد»** ("person") rather than «عزیز» ("loved one"),
which reads oddly for self-care. This is a **copy-only** rename — the `/patients` route, `services/patients`
module, and every `patients.*`/`nav.patients` i18n **key name** are unchanged; only the translated
**values** changed (both `en.json` and `fa.json`). Applied consistently: page title, nav tab, empty
states, and the Home screen's `nudge_patient_*`/`patients_error` copy.
- **Avatars:** a new `InitialsAvatar` component (warm auto-colored initials, deterministic per-name hash →
one of 6 new `--bal-avatar-*` token pairs added to `tokens.css`, both scheme blocks) is now a slot on the
shared `PatientHeader`, so both the E1 card and the E2 record viewer got a face in one change.
`ProfileSummary` also gained an `initialsFallback` prop (same util) for the account hub's identity header.
- **Visible record affordance:** `PatientCard`'s invisible `component="button"` area is now a pressable
`ButtonBase` surface (hover/press + a trailing «forward» chevron) with an optional `lastVisitLabel` prop —
**never populated** (the cached customer bookings list, `BookingListItemDto`, carries no `patientId` at
all, so no client-side join is possible without an N+1 fetch per row; filed as REQ-057).
- Soft-archive semantics + copy kept (generalized wording, same behavior); `isError`/empty-state handling
was already correct (see note above) and is unchanged.
### Care-record editing (`patients/[id]/record/page.tsx`)
- Replaced the whole-list free-text edit mode with **per-item bottom sheets** (`Drawer anchor="bottom"` on
mobile, `Dialog` on desktop, each with its own dirty-gated discard-confirm): medications get name +
structured **dose amount + unit** (قرص/کپسول/قطره/سی‌سی/واحد) + **frequency presets**
(روزی ۱/۲/۳ بار، هر ۸ ساعت، در صورت نیاز + a free-text fallback when no preset fits) + **time-of-day
chips** (stable codes `morning|noon|evening|night`); routine items get the same time-of-day chip row
instead of the old free-text `routine_time`; tasks get a per-item sheet too (label + done), with a quick
inline checkbox for toggling done without opening a sheet. Killing the whole-list mode removes the
silent tab-switch draft loss by construction (each sheet is its own short-lived mount).
- **REQ-027 addendum** recorded in `requests/for-backend.md` (not a new REQ number): the structured shape
(dose amount/unit, frequency preset codes, time-of-day codes) so the eventual real table matches what the
UI now collects. `services/patientRecords/types.ts` + `apis/mockApi.ts` updated to the new shape.
- **سوابق (history)** is now a Shamsi month-grouped timeline (a subtle rail), each `VisitNoteCard` gains a
done/undone task-summary line (`{done} از {total} انجام شد`) and, when `VisitNote.bookingId` is non-null, a
«مشاهده رزرو» link to `/bookings/[id]` — the service name is shown only when best-effort derivable from an
**already-cached** booking snapshot (`queryClient.getQueryData`, never fetched per-note — that would be an
N+1). Paging kept (grouped within a page).
- The access gate (`useRecordAccess` before any clinical fetch, the access-denied card, the ownership
banner) is untouched, verbatim.
### Addresses + the real map (`addresses/page.tsx`, `components/geography/`)
- `isError` was **not** handled before this phase (confirmed against current code) — fixed: `ErrorState` +
retry, no more false-empty.
- **Real Neshan map behind the existing `AddressMapPicker` boundary.** `{ latitude, longitude }` in/out is
unchanged, so `AddressForm` needed zero changes. `AddressMapPicker` now branches on `NESHAN_WEB_KEY`
(`@/config`, from `NEXT_PUBLIC_NESHAN_KEY`):
- **Set:** dynamically imports (`next/dynamic`, `ssr:false`) a new `NeshanMap` component — Leaflet
(`leaflet` + `@types/leaflet`, newly added `npm` dependencies) with a Neshan raster tile layer, an
address search box (`services/geography/neshan.ts`'s `searchNeshan`), a locate-me button (browser
Geolocation), a draggable/tappable pin (a brand-colored `L.divIcon`, never Leaflet's default marker
images), and a reverse-geocoded preview line (`reverseGeocodeNeshan`) — raw coordinates are never
rendered. The container stays `dir="ltr"` (same RTL hazard `AddressMapPicker`'s own doc comment already
called out for the grid stand-in's marker).
- **Unset:** falls back to the original bounded-canvas grid stand-in, **kept verbatim** (including its
raw lat/lng captions — this is the preserved dev/CI/jsdom path, not the primary experience) except the
hard-coded `rgba()` pin drop-shadow is now the new `--bal-pin-shadow` token.
- **Honest flag (operating-rules §9 — external unknown, not stalled on):** the Neshan tile URL template
(`services/geography/constants.ts`'s `NESHAN_TILE_URL_TEMPLATE`) and the Search/Reverse response field
names (`services/geography/neshan.ts`) were set from Neshan's public documentation **without live
verification** — this sandbox has no network route to `platform.neshan.org`/`api.neshan.org` for a
fetch tool, only for `WebSearch`. Endpoints (`api.neshan.org/v1/search`, `api.neshan.org/v5/reverse`)
and the `Api-Key` header are confirmed via search-result snippets; the exact JSON field names
(`items[].location.x/y`, `formatted_address`) and the raster tile host/path are best-effort. Every read
is defensive (optional-chained, try/catch), so a shape drift degrades to "no results"/"no tiles" rather
than a crash. **Re-verify against the current Neshan developer portal before relying on this with a
real key.**
- `AddressCard` gained the pin-quality cue: «پین ثبت شده» / «پین ندارد» (warm warning tone) from
`Address.latitude`/`longitude` being non-null.
- Fixed the `line_hint` fa copy bug (`messages/fa.json`) — «برای یافتن در نیاز دارد» → «برای یافتن درِ منزل
نیاز دارد» — flagging it here so phase 12's global copy sweep doesn't double-edit it.
- Patient (`patients/page.tsx`) and address (`addresses/page.tsx`) forms now open in the new shared
`FormDialogShell` (`components/common/FormDialogShell/`): full-screen below the `sm` breakpoint (a single
scroll region, fixing the old double-scroll-against-keyboard defect), an app-bar header (title + close),
and a dirty-gated discard-confirm on close/backdrop/escape — `PatientForm`/`AddressForm` report `dirty`
via a new `onDirtyChange` prop (compares current fields to the `initial` prop).
### Form quality
- `PatientForm`: the single guessed full-name field is now **نام** / **نام خانوادگی** (structured, the wire
already carries `firstName`/`lastName`); `lastName` falls back to `firstName` only when left blank (the
wire's `lastName` is a required string) — never a client-side guess-split.
- Verified no surface renders the fabricated Jan-1 `birthDate` — every display path already went through
`birthDateToAge` before this phase; age-only, as required.
- `GenderToggle` gained `width: '100%'` so its `flex: 1` children actually split evenly.
- `RelationSelect`'s selected state gained a check icon (+ a soft-tint background), no longer color-only
(WCAG 1.4.1).
- Profile's `AppLoading` full-page spinner replaced with a form-shaped `Skeleton` (identity header + row
placeholders), matching the loading language of patients/addresses/record.
## What is now testable (and exactly how)
1. Log in as a seeded customer (`0912000000x`) on a mobile viewport at `/fa`. Open the Profile tab → identity
header (initials + name + masked LTR phone), rows for اطلاعات شخصی/مخاطب اضطراری/نشانی‌ها/زبان/اعلان‌ها/
پشتیبانی/خروج. Tap خروج → confirm → lands on `/login`, session revoked.
2. Clear the emergency contact (via the row's edit) → the incomplete-state warm nudge; fill it in → a
success check + `tel:` link.
3. Stop the API, reload `/profile`, `/patients`, `/addresses` → each shows `ErrorState` with retry (no
blank form, no false-empty). Start the API, tap retry → data returns without a full reload.
4. Open the care circle (`/patients`) → every person has a colored-initials avatar and a visible chevron
affordance; tap the card body → the record opens; the renamed «حلقهٔ مراقبت» title appears in the tab
bar and the page header.
5. In the record, tap "افزودن دارو" → the bottom sheet (mobile) with dose amount + unit, frequency preset
chips (+ free-text fallback when none is chosen), and صبح/ظهر/عصر/شب chips; save → the row shows the
structured summary. Switch tabs mid-edit and come back → nothing lost (the sheet only exists while open).
Try closing a dirty sheet → discard-confirm.
6. Open سوابق → notes grouped by Shamsi month on a rail; a note whose `bookingId` is set shows «مشاهده رزرو»
linking to `/bookings/[id]`.
7. Add an address on mobile → the form opens full-screen with an app-bar header. Without
`NEXT_PUBLIC_NESHAN_KEY` set (the default in this environment), the grid stand-in still works exactly as
before (raw lat/lng shown — the documented fallback). With a real key set, the real map (search box,
locate-me, draggable pin, reverse-geocoded preview) should appear instead — **not verified end-to-end
in this sandbox** (no real Neshan key available; see the honest flag above). Back on the list, an
address without a pin shows «پین ندارد»; one with a pin shows «پین ثبت شده».
8. Repeat 1, 4, 5, 7 on `/en` (LTR) and in dark mode.
## What is mocked / waiting on a real service
- `patientRecords` stays **mock-primary** for the family-owned record (REQ-027 — unchanged domain
boundary); this phase only changed the **shape** collected/persisted by the mock (see the REQ-027
addendum above), not the mock/real seam itself.
- The Neshan web map is **client config**, not a backend seam (per the phase doc: "not a backend seam —
with a grid-stand-in fallback when unset") — no `mocks-registry.md` entry filed; the fallback grid *is*
the registered dev/CI behavior, already documented in `AddressMapPicker`'s own doc comment.
- `services/geography/neshan.ts`'s tile URL and response-shape assumptions are unverified against live
Neshan documentation (see the honest flag above) — this is a **content-accuracy risk to re-check before
production use with a real key**, not a mock.
## Contracts
- Consumed: no new backend contract this phase (the touched domains — profiles/patients/addresses/
patientRecords — already had their contracts from earlier phases).
- Filed to `dev/shared-working-context/frontend/requests/for-backend.md`:
- **REQ-027 addendum** (not a new number) — the structured medication/routine field shape.
- **REQ-057**`lastVisitAt` (+ optional `visitCount`) on the patient read model (`PatientDto`).
- **REQ-058** (deferred, non-blocking) — optional patient photo upload; the shipped solution is
initials-only and needs no backend.
## Docs updated
- `client/CLAUDE.md` "Project Structure": `profile/`, `patients/`, `patients/[id]/record/`, `addresses/`
route notes; the `components/common/` list (new `InitialsAvatar/`, `FormDialogShell/`); `PatientForm`,
`PatientCard`, `PatientHeader`, `ProfileSummary`, and `geography/` component bullets.
- `client/.env.sample`: documented `NEXT_PUBLIC_NESHAN_KEY` (the client web key — separate from the
server's `NeshanGeocoder` key, which lives in server config and is never touched here).
- **Correction to the phase's own premise:** the audit (`dev/post-phase/ui/audit/customer-account.md`,
authored 2026-07-17 13:22) predates a same-day `ui phase 2` commit (2026-07-17 19:05) that already added
the `isError`/`ErrorState` handling the audit describes as missing in `profile/page.tsx` and
`patients/page.tsx`. Verified against the current code (not re-derived from the stale audit text) before
starting — `addresses/page.tsx` genuinely still lacked it and was fixed in this phase. Flagging this so a
future phase doesn't re-"fix" an already-fixed defect based on the audit's original wording.
## Follow-ups for later phases
- Re-verify `services/geography/neshan.ts` + `NESHAN_TILE_URL_TEMPLATE` against the current
`platform.neshan.org` developer docs once a real web key is available to test against.
- REQ-057 (`lastVisitAt`) — once served, wire `PatientCard`'s existing `lastVisitLabel` prop in
`patients/page.tsx`.
- Phase 12's global copy sweep: the `address.line_hint` fa fix already landed here — don't re-edit it.
- A read-mode "daily schedule" view (meds grouped صبح/ظهر/شب) and an `AddressCard` static-map thumbnail are
both explicitly deferred per the phase doc (§3.3.3, §3.4.3) — not started.