cleanup phases 6
This commit is contained in:
@@ -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 b11–b13
|
||||
`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+1–3/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 1–7 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 1–5) 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 ~7–10-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 ~7–10-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 ~7–10-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
|
||||
2–3 **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 ~7–10-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 (D1–D5) — 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 5001–5004 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`** — 1–5 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 **0–1** (`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 24–48h 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** ("در حال بررسی", 24–48h, 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 1–5, `sortOrder` 0–4). 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 ~7–10-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 (D1–D5)** 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 5001–5004 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 0–1 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 2–4-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 **2–3 `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, **1–2 `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 5001–5005) 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 24–26).
|
||||
|
||||
### 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.4–1.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 0–12). 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 (150–200ms 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 ~150–200ms 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 (5–6 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 1–5 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 040–043):
|
||||
- **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 044–045):
|
||||
- **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 1–5 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 046–049):
|
||||
- **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 1–6 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.
|
||||
Reference in New Issue
Block a user