Compare commits
8 Commits
0b45ec51f4
...
70fb0a9202
| Author | SHA1 | Date | |
|---|---|---|---|
| 70fb0a9202 | |||
| ef3024ef2f | |||
| 7edadadea1 | |||
| 70268ecc06 | |||
| d4147342da | |||
| 64f6aa45c9 | |||
| 314763f764 | |||
| 1ce36f9414 |
@@ -0,0 +1,26 @@
|
|||||||
|
# Git hooks
|
||||||
|
|
||||||
|
Repo-managed git hooks (they live in version control, unlike `.git/hooks`).
|
||||||
|
|
||||||
|
## Enable (once per clone)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
```
|
||||||
|
|
||||||
|
## `pre-commit` — secret scan
|
||||||
|
|
||||||
|
A fast, dependency-free backstop for the root `CLAUDE.md` rule **"Never commit secrets"**
|
||||||
|
(refinement-phase-5). It rejects a commit that stages:
|
||||||
|
|
||||||
|
- the historically-leaked SQL Server host `87.107.152.16`,
|
||||||
|
- the retired hardcoded admin password `qw123321`,
|
||||||
|
- a **real** connection-string password in any `appsettings*.json` (only the `SET_VIA_USER_SECRETS_OR_ENV`
|
||||||
|
placeholder is allowed — real values belong in user-secrets / environment variables),
|
||||||
|
- private-key material or an AWS access-key id, anywhere.
|
||||||
|
|
||||||
|
It scans only staged additions, so it is quick. It is **not** a replacement for a full scanner
|
||||||
|
(gitleaks / trufflehog) in CI — it is the local first line of defence.
|
||||||
|
|
||||||
|
Bypass a false positive with `git commit --no-verify` (use sparingly, and only when you are certain the
|
||||||
|
flagged line is not a secret).
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Balinyaar secret-scanning pre-commit hook (refinement-phase-5).
|
||||||
|
# Blocks a commit that stages an obvious credential. This is a fast, dependency-free backstop for the
|
||||||
|
# root CLAUDE.md rule "Never commit secrets" — not a replacement for gitleaks/trufflehog in CI.
|
||||||
|
#
|
||||||
|
# Enable once per clone: git config core.hooksPath .githooks
|
||||||
|
# Bypass a false positive: git commit --no-verify (use sparingly, and only when you are certain)
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Committed placeholders are allowed — real values are not. Keep in sync with StartupSecretsGuard.
|
||||||
|
PLACEHOLDER='SET_VIA_USER_SECRETS_OR_ENV'
|
||||||
|
|
||||||
|
# Only scan added/changed lines in text files that are staged.
|
||||||
|
staged=$(git diff --cached --name-only --diff-filter=ACM)
|
||||||
|
[ -z "$staged" ] && exit 0
|
||||||
|
|
||||||
|
violations=0
|
||||||
|
report() { printf ' ✖ %s\n' "$1"; violations=$((violations + 1)); }
|
||||||
|
|
||||||
|
while IFS= read -r file; do
|
||||||
|
# Skip this hook, lockfiles, and binaries.
|
||||||
|
case "$file" in
|
||||||
|
.githooks/*) continue ;;
|
||||||
|
*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.dll|*.exe|*.snk) continue ;;
|
||||||
|
esac
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
|
||||||
|
added=$(git diff --cached -U0 -- "$file" | grep '^+' | grep -v '^+++' || true)
|
||||||
|
[ -z "$added" ] && continue
|
||||||
|
|
||||||
|
# The historically-leaked SQL Server host — must never reappear.
|
||||||
|
echo "$added" | grep -Eq '87\.107\.152\.16' && report "$file: leaked SQL Server host 87.107.152.16"
|
||||||
|
|
||||||
|
# The retired hardcoded admin password.
|
||||||
|
echo "$added" | grep -Eq 'qw123321' && report "$file: hardcoded admin password 'qw123321'"
|
||||||
|
|
||||||
|
# A real (non-placeholder) connection-string password in a committed appsettings file.
|
||||||
|
case "$file" in
|
||||||
|
*appsettings*.json)
|
||||||
|
echo "$added" \
|
||||||
|
| grep -Ei 'Password=[^;"'"'"' ]+' \
|
||||||
|
| grep -viq "Password=${PLACEHOLDER}" \
|
||||||
|
&& report "$file: connection-string password must be '${PLACEHOLDER}' (real value belongs in user-secrets/env)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Private keys and common cloud tokens, anywhere.
|
||||||
|
echo "$added" | grep -Eq -- '-----BEGIN (RSA|EC|OPENSSH|PRIVATE) .*PRIVATE KEY-----' && report "$file: private key material"
|
||||||
|
echo "$added" | grep -Eq 'AKIA[0-9A-Z]{16}' && report "$file: AWS access key id"
|
||||||
|
done <<< "$staged"
|
||||||
|
|
||||||
|
if [ "$violations" -gt 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "Commit blocked: $violations potential secret(s) staged. Move the real value to user-secrets"
|
||||||
|
echo "(Development) or an environment variable (deploy) and commit only the '${PLACEHOLDER}' placeholder."
|
||||||
|
echo "See dev/post-phase/refinement/RUNBOOK.md. To override a false positive: git commit --no-verify"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
+39
-9
@@ -117,7 +117,7 @@ client/
|
|||||||
│ │ ├── layout.tsx # 'use client' — wraps PrivateLayout; mounts useSessionRoleSync (hydrates AuthContext roles from /me)
|
│ │ ├── layout.tsx # 'use client' — wraps PrivateLayout; mounts useSessionRoleSync (hydrates AuthContext roles from /me)
|
||||||
│ │ ├── select-role/page.tsx # /select-role — first-use role picker (no public role yet); role router lands here
|
│ │ ├── select-role/page.tsx # /select-role — first-use role picker (no public role yet); role router lands here
|
||||||
│ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment
|
│ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment
|
||||||
│ │ │ ├── layout.tsx # 'use client' — wraps CustomerLayout
|
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=customer) → CustomerLayout
|
||||||
│ │ │ ├── page.tsx # / (A5 home — 'use client'; greeting+avatar, search bar, data-driven category grid, first-login onboarding gate + record/profile nudges)
|
│ │ │ ├── page.tsx # / (A5 home — 'use client'; greeting+avatar, search bar, data-driven category grid, first-login onboarding gate + record/profile nudges)
|
||||||
│ │ │ ├── search/ # /search — f6 discovery: C1 filter screen (page.tsx: reused category grid + f3 region picker + prominent same-gender facet + Toman price + live-count CTA; useSearchFilters colocated controller) → results/ (C2) → nurse/[nurseId]/ (C3)
|
│ │ │ ├── search/ # /search — f6 discovery: C1 filter screen (page.tsx: reused category grid + f3 region picker + prominent same-gender facet + Toman price + live-count CTA; useSearchFilters colocated controller) → results/ (C2) → nurse/[nurseId]/ (C3)
|
||||||
│ │ │ │ ├── page.tsx # C1 search & filter; reads ?category_id preselect; pushes filter set to C2 as URL query params
|
│ │ │ │ ├── page.tsx # C1 search & filter; reads ?category_id preselect; pushes filter set to C2 as URL query params
|
||||||
@@ -136,8 +136,7 @@ client/
|
|||||||
│ │ │ │ ├── [id]/review/page.tsx # /bookings/[id]/review — f13 leave-a-review (b14): RatingInput + body + ReviewTagSelector; gated on completed/closed + server can_review + 1:1; on submit → persistent "under review" (pending_moderation, never public here); already-reviewed shows the review state, never a 2nd form (services/reviews)
|
│ │ │ │ ├── [id]/review/page.tsx # /bookings/[id]/review — f13 leave-a-review (b14): RatingInput + body + ReviewTagSelector; gated on completed/closed + server can_review + 1:1; on submit → persistent "under review" (pending_moderation, never public here); already-reviewed shows the review state, never a 2nd form (services/reviews)
|
||||||
│ │ │ │ └── checkout/ # f9 checkout flow (C5 accept CTA lands on page.tsx with ?request_id=)
|
│ │ │ │ └── checkout/ # f9 checkout flow (C5 accept CTA lands on page.tsx with ?request_id=)
|
||||||
│ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — acceptance badge, served reconciling breakdown (PriceBreakdown), EscrowNotice, payment-window countdown, «ادامه پرداخت ←» (idempotency-key-per-attempt) + «پرداخت اقساطی» → f11 BNPL wizard
|
│ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — acceptance badge, served reconciling breakdown (PriceBreakdown), EscrowNotice, payment-window countdown, «ادامه پرداخت ←» (idempotency-key-per-attempt) + «پرداخت اقساطی» → f11 BNPL wizard
|
||||||
│ │ │ │ ├── gateway/page.tsx # dev mock-gateway page — TEST HARNESS standing in for the PSP redirect (mock redirectUrl points here; success/failure buttons drive both return branches)
|
│ │ │ │ ├── return/page.tsx # return-from-gateway — confirm return → pending-callback poll (backoff, stops on terminal) → succeeded (invalidate + hand off) / failed retry / window-expired (the payment mock-gateway harness was removed in refinement-phase-4 when USE_PAYMENT_MOCK flipped; on the real path the PSP redirectUrl is absolute)
|
||||||
│ │ │ │ ├── return/page.tsx # return-from-gateway — confirm return → pending-callback poll (backoff, stops on terminal) → succeeded (invalidate + hand off) / failed retry / window-expired
|
|
||||||
│ │ │ │ ├── confirmation/page.tsx # payment success — «مشاهده رزرو» (booking detail) + «دانلود فاکتور» (invoice); REUSED by f11 (?method=bnpl adds «پرداختشده با اقساط» — a settled BNPL order is a card payment net-of-fee)
|
│ │ │ │ ├── confirmation/page.tsx # payment success — «مشاهده رزرو» (booking detail) + «دانلود فاکتور» (invoice); REUSED by f11 (?method=bnpl adds «پرداختشده با اقساط» — a settled BNPL order is a card payment net-of-fee)
|
||||||
│ │ │ │ └── bnpl/ # f11 BNPL installment checkout (the alternate branch off C6, reached with ?request_id=)
|
│ │ │ │ └── bnpl/ # f11 BNPL installment checkout (the alternate branch off C6, reached with ?request_id=)
|
||||||
│ │ │ │ ├── page.tsx # D1→D4 stateful wizard (StepperHeader): D1 method/provider · D2 plan · D3 eligibility · D4 schedule+contract → provider handoff; card fall-back → C6 everywhere
|
│ │ │ │ ├── page.tsx # D1→D4 stateful wizard (StepperHeader): D1 method/provider · D2 plan · D3 eligibility · D4 schedule+contract → provider handoff; card fall-back → C6 everywhere
|
||||||
@@ -155,7 +154,7 @@ client/
|
|||||||
│ │ │ ├── support/tickets/ # /support/tickets — f14 "My Tickets" inbox (TicketInboxScreen role="customer") ↔ support/tickets/[id]/page.tsx thread (TicketThreadScreen); thin role-passing wrappers over @/components/messaging
|
│ │ │ ├── support/tickets/ # /support/tickets — f14 "My Tickets" inbox (TicketInboxScreen role="customer") ↔ support/tickets/[id]/page.tsx thread (TicketThreadScreen); thin role-passing wrappers over @/components/messaging
|
||||||
│ │ │ └── notifications/page.tsx # /notifications — f14 notification center (NotificationCenter role="customer"); the TopBar bell deep-links here
|
│ │ │ └── notifications/page.tsx # /notifications — f14 notification center (NotificationCenter role="customer"); the TopBar bell deep-links here
|
||||||
│ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell
|
│ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell
|
||||||
│ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout
|
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=nurse) → NurseLayout
|
||||||
│ │ │ ├── page.tsx # /nurse (dashboard)
|
│ │ │ ├── page.tsx # /nurse (dashboard)
|
||||||
│ │ │ ├── requests/ # /nurse/requests — f7 incoming booking-requests inbox (page.tsx: pending list, per-request countdown + gender chip + notes preview) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept/reject-with-reason invalidate inbox+detail)
|
│ │ │ ├── requests/ # /nurse/requests — f7 incoming booking-requests inbox (page.tsx: pending list, per-request countdown + gender chip + notes preview) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept/reject-with-reason invalidate inbox+detail)
|
||||||
│ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder)
|
│ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder)
|
||||||
@@ -174,7 +173,7 @@ client/
|
|||||||
│ │ │ ├── support/tickets/ # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx
|
│ │ │ ├── support/tickets/ # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx
|
||||||
│ │ │ └── notifications/page.tsx # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here
|
│ │ │ └── notifications/page.tsx # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here
|
||||||
│ │ ├── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces).
|
│ │ ├── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces).
|
||||||
│ │ │ ├── layout.tsx # 'use client' — wraps AdminLayout (capability-gated nav)
|
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=admin) → AdminLayout (capability-gated nav)
|
||||||
│ │ │ ├── page.tsx # /admin — f15 overview landing: a capability-gated grid of console cards
|
│ │ │ ├── page.tsx # /admin — f15 overview landing: a capability-gated grid of console cards
|
||||||
│ │ │ ├── verification/ # /admin/verification — f15 review queue (page.tsx: status-filtered nurse worklist) ↔ [nurseId]/page.tsx per-nurse case (DocumentViewer signed-URL docs, pass/reject+reason per step, structured credential entry, Approve enabled only when all steps pass — client never writes is_verified)
|
│ │ │ ├── verification/ # /admin/verification — f15 review queue (page.tsx: status-filtered nurse worklist) ↔ [nurseId]/page.tsx per-nurse case (DocumentViewer signed-URL docs, pass/reject+reason per step, structured credential entry, Approve enabled only when all steps pass — client never writes is_verified)
|
||||||
│ │ │ ├── tickets/ # /admin/tickets — f15 global ticket queue (page.tsx: filter status/category/referenceCode) ↔ [id]/page.tsx admin thread (AdminMessageBubble renders isInternal notes distinctly; internal-note composer; RefundPanel opens from a refund ticket)
|
│ │ │ ├── tickets/ # /admin/tickets — f15 global ticket queue (page.tsx: filter status/category/referenceCode) ↔ [id]/page.tsx admin thread (AdminMessageBubble renders isInternal notes distinctly; internal-note composer; RefundPanel opens from a refund ticket)
|
||||||
@@ -189,7 +188,7 @@ client/
|
|||||||
│ │ │ ├── users/page.tsx # /admin/users
|
│ │ │ ├── users/page.tsx # /admin/users
|
||||||
│ │ │ └── notifications/page.tsx # /admin/notifications
|
│ │ │ └── notifications/page.tsx # /admin/notifications
|
||||||
│ │ └── partner/ # Partner-center portal (/partner/…) — a SEPARATE authz scope (f15). A center admin is not a Balinyaar admin; each page resolves the caller's OWN center (useMyPartnerCenter → access-denied on 403/404).
|
│ │ └── partner/ # Partner-center portal (/partner/…) — a SEPARATE authz scope (f15). A center admin is not a Balinyaar admin; each page resolves the caller's OWN center (useMyPartnerCenter → access-denied on 403/404).
|
||||||
│ │ ├── layout.tsx # 'use client' — wraps PartnerLayout (own partner nav)
|
│ │ ├── layout.tsx # 'use client' — RoleGuard (no expected role — hydration-only) → PartnerLayout (own partner nav; self-gates via useMyPartnerCenter)
|
||||||
│ │ ├── page.tsx # /partner — center home: onboarding/verification state banner + license fields + is_merchant_of_record indicator
|
│ │ ├── page.tsx # /partner — center home: onboarding/verification state banner + license fields + is_merchant_of_record indicator
|
||||||
│ │ ├── nurses/page.tsx # /partner/nurses — the center's sponsored nurses (verification badge)
|
│ │ ├── nurses/page.tsx # /partner/nurses — the center's sponsored nurses (verification badge)
|
||||||
│ │ ├── bookings/page.tsx # /partner/bookings — the bookings the center legally covers (read-only summaries)
|
│ │ ├── bookings/page.tsx # /partner/bookings — the bookings the center legally covers (read-only summaries)
|
||||||
@@ -237,7 +236,7 @@ client/
|
|||||||
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
|
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
|
||||||
│ ├── messaging/ # f14 tickets composites (import from @/components/messaging). Screens shared by the customer+nurse pages (role decides chrome): TicketInboxScreen, TicketThreadScreen (+ TicketMessageList), ContactSupportDialog (new-ticket → shows referenceCode), MessageComposer (optimistic send, draft-preserving), BookingSupportEntry (page-local glue on f8 booking detail — reuses the cached booking + care query, no refetch). Pure/tested: MessageBubble (mine/theirs, RTL-mirrored, never any internal-note styling), TicketListCard (prominent referenceCode + unread indicator + null-safe link), EmergencyBanner (post-confirmation tel: playbook, no VoIP seam). Helpers: statusKind.ts, authorLabel.ts
|
│ ├── messaging/ # f14 tickets composites (import from @/components/messaging). Screens shared by the customer+nurse pages (role decides chrome): TicketInboxScreen, TicketThreadScreen (+ TicketMessageList), ContactSupportDialog (new-ticket → shows referenceCode), MessageComposer (optimistic send, draft-preserving), BookingSupportEntry (page-local glue on f8 booking detail — reuses the cached booking + care query, no refetch). Pure/tested: MessageBubble (mine/theirs, RTL-mirrored, never any internal-note styling), TicketListCard (prominent referenceCode + unread indicator + null-safe link), EmergencyBanner (post-confirmation tel: playbook, no VoIP seam). Helpers: statusKind.ts, authorLabel.ts
|
||||||
│ ├── notifications/ # f14 notification composites (import from @/components/notifications). NotificationBell (chrome container — subscribes to the polling count so only it re-renders) → NotificationBellView (pure, tested), NotificationRow (pure, tested: unread emphasis + server title/body), NotificationCenter (shared page body: unread-first, mark-read-on-open + mark-all, deep-links via notificationDeepLink). Helper: notificationIcon.ts
|
│ ├── notifications/ # f14 notification composites (import from @/components/notifications). NotificationBell (chrome container — subscribes to the polling count so only it re-renders) → NotificationBellView (pure, tested), NotificationRow (pure, tested: unread emphasis + server title/body), NotificationCenter (shared page body: unread-first, mark-read-on-open + mark-all, deep-links via notificationDeepLink). Helper: notificationIcon.ts
|
||||||
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
|
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, RoleGuard (role-aware shell guard, tested), AuthAccountError (/me-failed recovery), useCountdown
|
||||||
├── i18n/
|
├── i18n/
|
||||||
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
|
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
|
||||||
│ └── request.ts # getRequestConfig — loads messages/${locale}.json
|
│ └── request.ts # getRequestConfig — loads messages/${locale}.json
|
||||||
@@ -277,7 +276,7 @@ client/
|
|||||||
│ ├── client.ts # getClientCookie, setClientCookie, deleteClientCookie
|
│ ├── client.ts # getClientCookie, setClientCookie, deleteClientCookie
|
||||||
│ └── index.ts # Re-exports constants ONLY (never server/client)
|
│ └── index.ts # Re-exports constants ONLY (never server/client)
|
||||||
├── services/ # Domain services — no top-level barrel; import directly from the file
|
├── services/ # Domain services — no top-level barrel; import directly from the file
|
||||||
│ ├── auth/ # Phone-OTP auth: requestOtp/verifyOtp/refresh/logout/me/selectRole + role router (routing.ts) + useSessionRoleSync
|
│ ├── auth/ # Phone-OTP auth: requestOtp/verifyOtp/refresh/logout/me/selectRole + role router (routing.ts) + useSessionRoleSync + useRoleHydration (resolved-vs-pending role state for RoleGuard)
|
||||||
│ ├── patients/ # Care-recipient CRUD (b3 PatientDto + client-augmented relation/conditions), soft-archive; age.ts helper
|
│ ├── patients/ # Care-recipient CRUD (b3 PatientDto + client-augmented relation/conditions), soft-archive; age.ts helper
|
||||||
│ ├── profiles/ # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam)
|
│ ├── profiles/ # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam)
|
||||||
│ ├── nurse/ # Nurse payout bank accounts + IBAN(Sheba) util (iban.ts) + ownership-inquiry states
|
│ ├── nurse/ # Nurse payout bank accounts + IBAN(Sheba) util (iban.ts) + ownership-inquiry states
|
||||||
@@ -408,7 +407,7 @@ async function MyServerComponent() {
|
|||||||
- `'records'` — the f13 E2 care-record viewer + the nurse visit-note panel: the ownership banner, the four tab labels (`tab_{medications,routine,history,tasks}`), the access-denied + not-found cards, the editable-record field labels (`med_*`/`routine_*`/`task_*`) + empty states, the paged-history controls (`prev`/`next`/`page_of`) + visit-note author fallback, and the nurse composer copy (`notes_title`/`tasks_checklist_title`/`note_*`/`continuity_title`); shared enum labels (relation/gender/condition) are REUSED from `onboarding`/`patients`, never re-keyed; consumed by the E2 record page + `NurseVisitNotesPanel` + `VisitNoteCard`
|
- `'records'` — the f13 E2 care-record viewer + the nurse visit-note panel: the ownership banner, the four tab labels (`tab_{medications,routine,history,tasks}`), the access-denied + not-found cards, the editable-record field labels (`med_*`/`routine_*`/`task_*`) + empty states, the paged-history controls (`prev`/`next`/`page_of`) + visit-note author fallback, and the nurse composer copy (`notes_title`/`tasks_checklist_title`/`note_*`/`continuity_title`); shared enum labels (relation/gender/condition) are REUSED from `onboarding`/`patients`, never re-keyed; consumed by the E2 record page + `NurseVisitNotesPanel` + `VisitNoteCard`
|
||||||
- `'tickets'` — the f14 messaging surface (tickets are the only post-booking channel): the inbox (`title`/`contact_support`/`empty_*`/`error_body`), the category + status labels keyed off the code (`category_{support,coordination,refund,emergency}`/`status_{open,closed}`), the linked-entity hints (`linked_booking`/`linked_refund` with `{id}`), `ref_code_label`, the new-ticket dialog (`new_ticket_title`/`category_label`/`subject_label`/`message_label`/`submit`/`created_*`/`view_thread`), the thread (`back_to_tickets`/`thread_*`/`closed_notice`), the composer (`sending`/`send`/`send_failed`/`composer_placeholder`), the author-role labels (`author_{customer,nurse,support,system}` — `admin`→support), and the **emergency playbook** (`emergency_title`/`emergency_body`/`emergency_call {name}`/`emergency_call_generic`/`emergency_open_ticket`) + `open_from_booking`; consumed by the ticket screens, `MessageBubble`/`TicketListCard`/`EmergencyBanner`/`ContactSupportDialog`/`MessageComposer`/`BookingSupportEntry`
|
- `'tickets'` — the f14 messaging surface (tickets are the only post-booking channel): the inbox (`title`/`contact_support`/`empty_*`/`error_body`), the category + status labels keyed off the code (`category_{support,coordination,refund,emergency}`/`status_{open,closed}`), the linked-entity hints (`linked_booking`/`linked_refund` with `{id}`), `ref_code_label`, the new-ticket dialog (`new_ticket_title`/`category_label`/`subject_label`/`message_label`/`submit`/`created_*`/`view_thread`), the thread (`back_to_tickets`/`thread_*`/`closed_notice`), the composer (`sending`/`send`/`send_failed`/`composer_placeholder`), the author-role labels (`author_{customer,nurse,support,system}` — `admin`→support), and the **emergency playbook** (`emergency_title`/`emergency_body`/`emergency_call {name}`/`emergency_call_generic`/`emergency_open_ticket`) + `open_from_booking`; consumed by the ticket screens, `MessageBubble`/`TicketListCard`/`EmergencyBanner`/`ContactSupportDialog`/`MessageComposer`/`BookingSupportEntry`
|
||||||
- `'notifications'` — the f14 notification center + bell: `title`, `empty_*`, `error_body`, `retry`, `mark_all_read`, `load_more`, and the polled-bell aria (`bell_aria` with `{count, number}`); the row `title`/`body` are **server-rendered** copy, not keys. Consumed by `NotificationCenter` + `NotificationBell`
|
- `'notifications'` — the f14 notification center + bell: `title`, `empty_*`, `error_body`, `retry`, `mark_all_read`, `load_more`, and the polled-bell aria (`bell_aria` with `{count, number}`); the row `title`/`body` are **server-rendered** copy, not keys. Consumed by `NotificationCenter` + `NotificationBell`
|
||||||
- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
|
- `'auth'` — the phone-OTP login flow, role router, RoleGuard (loading/`account_error_*`/`guard_denied`), and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
|
||||||
- `'admin'` — the f15 backoffice consoles: verification queue/case, refund panel, payout dashboard/detail, review moderation, config editor + change-history, holiday manager, support-alert board, audit viewer, admin ticket queue/thread, RBAC grid, and admin-side partner management. Includes the **Persian legal terms** (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی) and the enum-label prefixes keyed off the stable code (`step_*`/`agg_*`/`atype_*`/`astatus_*`/`sev_*`/`htype_*`/`dtype_*`/`batch_status_*`/`pstatus_*`/`channel_*`/`rstatus_*`/`mstatus_*`/`center_state_*`/`role_*`/`tcat_*`/`tstatus_*`). Consumed by the `/admin/*` screens + the `@/components/admin` composites
|
- `'admin'` — the f15 backoffice consoles: verification queue/case, refund panel, payout dashboard/detail, review moderation, config editor + change-history, holiday manager, support-alert board, audit viewer, admin ticket queue/thread, RBAC grid, and admin-side partner management. Includes the **Persian legal terms** (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی) and the enum-label prefixes keyed off the stable code (`step_*`/`agg_*`/`atype_*`/`astatus_*`/`sev_*`/`htype_*`/`dtype_*`/`batch_status_*`/`pstatus_*`/`channel_*`/`rstatus_*`/`mstatus_*`/`center_state_*`/`role_*`/`tcat_*`/`tstatus_*`). Consumed by the `/admin/*` screens + the `@/components/admin` composites
|
||||||
- `'partner'` — the f15 partner-center portal (a separate authz scope): center home/onboarding-state, sponsored nurses/bookings, and the merchant-of-record settlement/invoice view (سامانه مودیان, commission/VAT decomposition). Consumed by the `/partner/*` screens + `PartnerSettlementRow`
|
- `'partner'` — the f15 partner-center portal (a separate authz scope): center home/onboarding-state, sponsored nurses/bookings, and the merchant-of-record settlement/invoice view (سامانه مودیان, commission/VAT decomposition). Consumed by the `/partner/*` screens + `PartnerSettlementRow`
|
||||||
|
|
||||||
@@ -659,6 +658,19 @@ Every domain follows the same shape: `types.ts` (wire types + the domain's `Api`
|
|||||||
twice — a real `clientApi.ts` and an in-memory `mockApi.ts` — and select in `apis/index.ts` by a config
|
twice — a real `clientApi.ts` and an in-memory `mockApi.ts` — and select in `apis/index.ts` by a config
|
||||||
flag (`USE_{DOMAIN}_MOCK`). Hooks import the selected `api`; the swap is one line. Record every mock in
|
flag (`USE_{DOMAIN}_MOCK`). Hooks import the selected `api`; the swap is one line. Record every mock in
|
||||||
`dev/shared-working-context/reports/mocks-registry.md`.
|
`dev/shared-working-context/reports/mocks-registry.md`.
|
||||||
|
- **De-mock status (refinement-phase-4):** **14 domains are now REAL** (`USE_*_MOCK = false`): `auth`,
|
||||||
|
`geography`, `patients`, `profiles`, `nurse` (bank), `addresses`, `serviceAreas`, `catalog`, `search`,
|
||||||
|
`bookingRequests`, `bookings`, `payment`, `reviews`, `notifications`, `tickets`. Flipping them required
|
||||||
|
updating each `clientApi.ts` to **consume the fields Phase-3 delivered** (search name/avatar/distance +
|
||||||
|
`nurses/{id}/profile`; patient relation/conditions; address `provinceId`; booking-request
|
||||||
|
`variantPrice`/`bookingId`; ticket `unreadCount`/`lastMessageAt`/`clientMessageId`; review `my_review`
|
||||||
|
mapper; profile `avatarUrl`/`preferredLanguage` + a **multipart avatar upload** now that `clientFetch`
|
||||||
|
passes `FormData` bodies through). **7 domains stay mocked** because a precondition REQ is deferred/unsafe:
|
||||||
|
`verification` (REQ-034 admin queue), `refunds` (REQ-035 admin preview), `payouts` (REQ-036 admin preview),
|
||||||
|
`admin` (REQ-031 RBAC roles), `bnpl` (REQ-022/024 options/schedule/wallet), `partnerCenter` (REQ-032/033/038
|
||||||
|
portal reads + `/me` signal), `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 reconciled). Note:
|
||||||
|
the `EVV_GPS_MODE` seam auto-selects `off` (real `navigator.geolocation`) once `USE_BOOKINGS_MOCK=false`.
|
||||||
- **The wire envelope:** the server wraps responses in `ApiEnvelope<T>` (`{ isSuccess, statusCode,
|
- **The wire envelope:** the server wraps responses in `ApiEnvelope<T>` (`{ isSuccess, statusCode,
|
||||||
message, requestId, data }`, camelCase — see `lib/api/types.ts`). `clientFetch` returns the raw body, so
|
message, requestId, data }`, camelCase — see `lib/api/types.ts`). `clientFetch` returns the raw body, so
|
||||||
a real `clientApi` reads the payload via `unwrap()`. Types are derived from `dev/contracts/` +
|
a real `clientApi` reads the payload via `unwrap()`. Types are derived from `dev/contracts/` +
|
||||||
@@ -685,6 +697,24 @@ splash while `/me` loads so the wrong shell never flashes. The routing decision
|
|||||||
`resolveRoleDestination(me, intendedRole)` in `src/services/auth/routing.ts` (unit-tested). The middleware
|
`resolveRoleDestination(me, intendedRole)` in `src/services/auth/routing.ts` (unit-tested). The middleware
|
||||||
still owns the auth gate; the router only decides *which app*.
|
still owns the auth gate; the router only decides *which app*.
|
||||||
|
|
||||||
|
**Role-aware shell guard (resolved-vs-pending hydration).** Every private shell — `(customer)`, `nurse`,
|
||||||
|
`admin`, `partner` — wraps its layout in **`RoleGuard`** (`src/components/auth/RoleGuard.tsx`). This exists
|
||||||
|
because the *core* role bug is conflating **"`/me` hasn't resolved yet"** with **"the user has no
|
||||||
|
nurse/admin role"**: a fresh `/me` in-flight used to fall through the `DEFAULT_ROLE = customer` fallback and
|
||||||
|
flash a nurse the customer app (or strand them there if `/me` failed). `RoleGuard` reads
|
||||||
|
**`useRoleHydration()`** (`services/auth`, a discriminated `loading | error | ready` over `useMe`) and:
|
||||||
|
- **loading** → a neutral brand splash (never the customer shell as a stand-in);
|
||||||
|
- **error** (`/me` failed, e.g. API down) → `AuthAccountError` with retry (never a silent customer fallback —
|
||||||
|
a transient error must not downgrade a nurse/admin);
|
||||||
|
- **role mismatch** → redirect to the caller's real app via `resolveRoleDestination` (the single "which app"
|
||||||
|
source) with a `guard_denied` toast, instead of rendering a shell they lack the role for.
|
||||||
|
|
||||||
|
A shell passes `expected={APP_ROLES.*}`; the partner portal passes **no** `expected` (it isn't an `AppRole`
|
||||||
|
— it self-gates on `useMyPartnerCenter`, so `RoleGuard` there only hardens hydration). The guard is **UX/chrome,
|
||||||
|
not security** — the server authorizes every endpoint; a dual customer+nurse session holds both roles and moves
|
||||||
|
freely between the family and nurse apps. `useActorRole()`'s `DEFAULT_ROLE` fallback is now only a last resort
|
||||||
|
(the guard ensures roles are hydrated before a shell renders), never the loading state.
|
||||||
|
|
||||||
**Session state lives in `AuthContext`** (`src/context/auth/`), now carrying `SessionUser { id?, phone,
|
**Session state lives in `AuthContext`** (`src/context/auth/`), now carrying `SessionUser { id?, phone,
|
||||||
roles: AppRole[] }`. The root layout resolves the session on the server with `getServerAuthState()`
|
roles: AppRole[] }`. The root layout resolves the session on the server with `getServerAuthState()`
|
||||||
(`src/lib/auth/server.ts`) — which reads the `access_token` cookie and checks the JWT `exp` via the shared
|
(`src/lib/auth/server.ts`) — which reads the `access_token` cookie and checks the JWT `exp` via the shared
|
||||||
|
|||||||
@@ -648,7 +648,11 @@
|
|||||||
"role_customer_desc": "Book nurses and home care",
|
"role_customer_desc": "Book nurses and home care",
|
||||||
"role_nurse": "Nurse",
|
"role_nurse": "Nurse",
|
||||||
"role_nurse_desc": "Offer nursing services",
|
"role_nurse_desc": "Offer nursing services",
|
||||||
"continue": "Continue"
|
"continue": "Continue",
|
||||||
|
"guard_denied": "You don't have access to that area.",
|
||||||
|
"account_error_title": "Couldn't load your account",
|
||||||
|
"account_error_body": "We couldn't reach Balinyaar to load your account. Check your connection and try again.",
|
||||||
|
"account_error_retry": "Try again"
|
||||||
},
|
},
|
||||||
"verification": {
|
"verification": {
|
||||||
"title": "Verification",
|
"title": "Verification",
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
"close": "بستن",
|
"close": "بستن",
|
||||||
"optional": "اختیاری",
|
"optional": "اختیاری",
|
||||||
"currency_toman": "تومان",
|
"currency_toman": "تومان",
|
||||||
"brand": "بلینیار",
|
"brand": "بالین یار",
|
||||||
"brand_tagline": "مراقبت مطمئن در خانه"
|
"brand_tagline": "مراقبت مطمئن در خانه"
|
||||||
},
|
},
|
||||||
"shell": {
|
"shell": {
|
||||||
@@ -622,7 +622,7 @@
|
|||||||
"issuer_platform": "بالینیار"
|
"issuer_platform": "بالینیار"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"customer_title": "ورود به بلینیار",
|
"customer_title": "ورود به بالین یار",
|
||||||
"customer_subtitle": "با شماره موبایل خود وارد شوید",
|
"customer_subtitle": "با شماره موبایل خود وارد شوید",
|
||||||
"nurse_title": "ورود پرستاران",
|
"nurse_title": "ورود پرستاران",
|
||||||
"nurse_subtitle": "ویژه پرستاران دارای پروانه نظام پرستاری",
|
"nurse_subtitle": "ویژه پرستاران دارای پروانه نظام پرستاری",
|
||||||
@@ -642,13 +642,17 @@
|
|||||||
"resend": "ارسال مجدد کد",
|
"resend": "ارسال مجدد کد",
|
||||||
"change_number": "تغییر شماره",
|
"change_number": "تغییر شماره",
|
||||||
"routing_title": "در حال ورود…",
|
"routing_title": "در حال ورود…",
|
||||||
"select_role_title": "به بلینیار خوش آمدید",
|
"select_role_title": "به بالین یار خوش آمدید",
|
||||||
"select_role_subtitle": "برای شروع، نقش خود را انتخاب کنید",
|
"select_role_subtitle": "برای شروع، نقش خود را انتخاب کنید",
|
||||||
"role_customer": "خانواده",
|
"role_customer": "خانواده",
|
||||||
"role_customer_desc": "برای رزرو پرستار و مراقبت در منزل",
|
"role_customer_desc": "برای رزرو پرستار و مراقبت در منزل",
|
||||||
"role_nurse": "پرستار",
|
"role_nurse": "پرستار",
|
||||||
"role_nurse_desc": "برای ارائه خدمات پرستاری",
|
"role_nurse_desc": "برای ارائه خدمات پرستاری",
|
||||||
"continue": "ادامه"
|
"continue": "ادامه",
|
||||||
|
"guard_denied": "شما به این بخش دسترسی ندارید.",
|
||||||
|
"account_error_title": "حساب شما بارگذاری نشد",
|
||||||
|
"account_error_body": "در ارتباط با بالین یار برای بارگذاری حساب شما مشکلی پیش آمد. اتصال خود را بررسی کنید و دوباره تلاش کنید.",
|
||||||
|
"account_error_retry": "تلاش مجدد"
|
||||||
},
|
},
|
||||||
"verification": {
|
"verification": {
|
||||||
"title": "احراز هویت",
|
"title": "احراز هویت",
|
||||||
@@ -656,7 +660,7 @@
|
|||||||
"retry": "تلاش مجدد",
|
"retry": "تلاش مجدد",
|
||||||
"load_error": "بارگذاری وضعیت احراز هویت ممکن نشد.",
|
"load_error": "بارگذاری وضعیت احراز هویت ممکن نشد.",
|
||||||
"start_title": "احراز هویت را آغاز کنید",
|
"start_title": "احراز هویت را آغاز کنید",
|
||||||
"start_body": "اعتماد، بنیان بلینیار است. با تکمیل این مراحل، خانوادهها با اطمینان شما را انتخاب میکنند.",
|
"start_body": "اعتماد، بنیان بالین یار است. با تکمیل این مراحل، خانوادهها با اطمینان شما را انتخاب میکنند.",
|
||||||
"start_cta": "شروع احراز هویت",
|
"start_cta": "شروع احراز هویت",
|
||||||
"starting": "در حال آمادهسازی…",
|
"starting": "در حال آمادهسازی…",
|
||||||
"progress_title": "پیشرفت احراز هویت",
|
"progress_title": "پیشرفت احراز هویت",
|
||||||
|
|||||||
-76
@@ -1,76 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import { Suspense } from 'react';
|
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import { Box, Paper, Stack, Typography } from '@mui/material';
|
|
||||||
import { AppButton, AppIcon, AppLoading } from '@/components';
|
|
||||||
import { ROUTES } from '@/constants';
|
|
||||||
import {
|
|
||||||
CHECKOUT_QUERY_OUTCOME,
|
|
||||||
CHECKOUT_QUERY_REQUEST_ID,
|
|
||||||
CHECKOUT_QUERY_TRANSACTION_ID,
|
|
||||||
} from '@/services/payment/constants';
|
|
||||||
import type { GatewayReturnOutcome } from '@/services/payment/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dev mock-gateway page — a **test harness, not a product feature**. It stands in for the PSP so the
|
|
||||||
* initiate → redirect → return round-trip is exercisable without a real gateway: the payment mock's
|
|
||||||
* `redirectUrl` points here, and the success/failure buttons drive both outcome branches of the return
|
|
||||||
* surface (a real PSP redirects back after the cardholder pays or cancels). On the real path the
|
|
||||||
* `redirectUrl` is the PSP's absolute URL and this page is never reached.
|
|
||||||
*/
|
|
||||||
export default function MockGatewayPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<AppLoading />}>
|
|
||||||
<MockGatewayScreen />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MockGatewayScreen() {
|
|
||||||
const t = useTranslations('payment');
|
|
||||||
const locale = useLocale();
|
|
||||||
const router = useRouter();
|
|
||||||
const params = useSearchParams();
|
|
||||||
|
|
||||||
const requestId = params.get(CHECKOUT_QUERY_REQUEST_ID) ?? '';
|
|
||||||
const transactionId = params.get(CHECKOUT_QUERY_TRANSACTION_ID) ?? '';
|
|
||||||
|
|
||||||
const returnWith = (outcome: GatewayReturnOutcome) => {
|
|
||||||
const query = new URLSearchParams({
|
|
||||||
[CHECKOUT_QUERY_REQUEST_ID]: requestId,
|
|
||||||
[CHECKOUT_QUERY_TRANSACTION_ID]: transactionId,
|
|
||||||
[CHECKOUT_QUERY_OUTCOME]: outcome,
|
|
||||||
});
|
|
||||||
router.replace(`/${locale}${ROUTES.CHECKOUT_RETURN}?${query.toString()}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
|
||||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
|
||||||
<AppIcon icon="payment" size={44} color="var(--bal-secondary)" />
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
{t('gateway_title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{t('gateway_hint')}
|
|
||||||
</Typography>
|
|
||||||
{transactionId ? (
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
||||||
{/* dir scoped to the code only — the label is Persian and must keep the RTL base direction. */}
|
|
||||||
{t('gateway_reference_label')}:{' '}
|
|
||||||
<Box component="span" dir="ltr">
|
|
||||||
#{transactionId}
|
|
||||||
</Box>
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')} sx={{ m: 0 }}>
|
|
||||||
{t('gateway_pay_success')}
|
|
||||||
</AppButton>
|
|
||||||
<AppButton variant="text" color="error" onClick={() => returnWith('failure')} sx={{ m: 0 }}>
|
|
||||||
{t('gateway_pay_fail')}
|
|
||||||
</AppButton>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { CustomerLayout } from '@/layout';
|
import { CustomerLayout } from '@/layout';
|
||||||
|
import { RoleGuard } from '@/components/auth';
|
||||||
|
import { APP_ROLES } from '@/constants';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Customer (family) route group — the primary mobile-first experience with the
|
* Customer (family) route group — the primary mobile-first experience with the
|
||||||
* 5-tab bottom nav. A route group `(customer)` adds chrome without adding a URL
|
* 5-tab bottom nav. A route group `(customer)` adds chrome without adding a URL
|
||||||
* segment, so these screens live at the app root (/, /bookings, /patients, …).
|
* segment, so these screens live at the app root (/, /bookings, /patients, …).
|
||||||
|
* RoleGuard gates it on a resolved customer role: a pure nurse lands on /nurse,
|
||||||
|
* a role-less user on /select-role — never the family app as a loading stand-in.
|
||||||
*/
|
*/
|
||||||
export default function CustomerRouteLayout({ children }: { children: ReactNode }) {
|
export default function CustomerRouteLayout({ children }: { children: ReactNode }) {
|
||||||
return <CustomerLayout>{children}</CustomerLayout>;
|
return (
|
||||||
|
<RoleGuard expected={APP_ROLES.CUSTOMER}>
|
||||||
|
<CustomerLayout>{children}</CustomerLayout>
|
||||||
|
</RoleGuard>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,16 +8,25 @@ import { isIranianMobile } from '@/components/PhoneNumberField';
|
|||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { digitsOnly } from '@/utils';
|
import { digitsOnly } from '@/utils';
|
||||||
import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles';
|
import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles';
|
||||||
|
import { useMe } from '@/services/auth';
|
||||||
import type { CustomerProfile } from '@/services/profiles/types';
|
import type { CustomerProfile } from '@/services/profiles/types';
|
||||||
|
|
||||||
/** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */
|
/** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */
|
||||||
export default function CustomerProfilePage() {
|
export default function CustomerProfilePage() {
|
||||||
const { data: profile, isLoading } = useCustomerProfile();
|
const { data: profile, isLoading } = useCustomerProfile();
|
||||||
|
const { data: me } = useMe();
|
||||||
if (isLoading) return <AppLoading />;
|
if (isLoading) return <AppLoading />;
|
||||||
return <CustomerProfileForm initial={profile ?? null} />;
|
// The customer name is owned by `/me` (REQ-007), not `CustomerProfileDto` — prefill it from there so
|
||||||
|
// editing the emergency contact never blanks (and re-saves as null) the existing name.
|
||||||
|
return (
|
||||||
|
<CustomerProfileForm initial={profile ?? null} nameFallback={{ firstName: me?.firstName ?? null, lastName: me?.lastName ?? null }} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CustomerProfileForm: FunctionComponent<{ initial: CustomerProfile | null }> = ({ initial }) => {
|
const CustomerProfileForm: FunctionComponent<{
|
||||||
|
initial: CustomerProfile | null;
|
||||||
|
nameFallback: { firstName: string | null; lastName: string | null };
|
||||||
|
}> = ({ initial, nameFallback }) => {
|
||||||
const t = useTranslations('profile');
|
const t = useTranslations('profile');
|
||||||
const ta = useTranslations('address');
|
const ta = useTranslations('address');
|
||||||
const tc = useTranslations('common');
|
const tc = useTranslations('common');
|
||||||
@@ -25,8 +34,8 @@ const CustomerProfileForm: FunctionComponent<{ initial: CustomerProfile | null }
|
|||||||
const { enqueueSnackbar } = useSnackbar();
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
const upsert = useUpsertCustomerProfile();
|
const upsert = useUpsertCustomerProfile();
|
||||||
|
|
||||||
const [firstName, setFirstName] = useState(initial?.firstName ?? '');
|
const [firstName, setFirstName] = useState(initial?.firstName ?? nameFallback.firstName ?? '');
|
||||||
const [lastName, setLastName] = useState(initial?.lastName ?? '');
|
const [lastName, setLastName] = useState(initial?.lastName ?? nameFallback.lastName ?? '');
|
||||||
const [language, setLanguage] = useState(initial?.preferredLanguage ?? 'fa');
|
const [language, setLanguage] = useState(initial?.preferredLanguage ?? 'fa');
|
||||||
const [emergencyName, setEmergencyName] = useState(initial?.defaultEmergencyContactName ?? '');
|
const [emergencyName, setEmergencyName] = useState(initial?.defaultEmergencyContactName ?? '');
|
||||||
const [emergencyPhone, setEmergencyPhone] = useState(digitsOnly(initial?.defaultEmergencyContactPhone ?? ''));
|
const [emergencyPhone, setEmergencyPhone] = useState(digitsOnly(initial?.defaultEmergencyContactPhone ?? ''));
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { AdminLayout } from '@/layout';
|
import { AdminLayout } from '@/layout';
|
||||||
|
import { RoleGuard } from '@/components/auth';
|
||||||
|
import { APP_ROLES } from '@/constants';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Admin / backoffice route group (/admin/…) — desktop-oriented ops console (f15)
|
* Admin / backoffice route group (/admin/…) — desktop-oriented ops console (f15)
|
||||||
* with a persistent sidebar.
|
* with a persistent sidebar. RoleGuard gates the shell on the (collapsed) admin actor
|
||||||
|
* role; the per-console fine-grained gating stays with useAdminCapabilities inside.
|
||||||
*/
|
*/
|
||||||
export default function AdminRouteLayout({ children }: { children: ReactNode }) {
|
export default function AdminRouteLayout({ children }: { children: ReactNode }) {
|
||||||
return <AdminLayout>{children}</AdminLayout>;
|
return (
|
||||||
|
<RoleGuard expected={APP_ROLES.ADMIN}>
|
||||||
|
<AdminLayout>{children}</AdminLayout>
|
||||||
|
</RoleGuard>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { NurseLayout } from '@/layout';
|
import { NurseLayout } from '@/layout';
|
||||||
|
import { RoleGuard } from '@/components/auth';
|
||||||
|
import { APP_ROLES } from '@/constants';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Nurse route group (/nurse/…) — its own shell (dashboard, verification, EVV visits).
|
* Nurse route group (/nurse/…) — its own shell (dashboard, verification, EVV visits).
|
||||||
* A real path segment keeps nurse screens namespaced under /nurse.
|
* A real path segment keeps nurse screens namespaced under /nurse. RoleGuard redirects
|
||||||
|
* a caller without the nurse role home (with a toast); a nurse never flashes the wrong app.
|
||||||
*/
|
*/
|
||||||
export default function NurseRouteLayout({ children }: { children: ReactNode }) {
|
export default function NurseRouteLayout({ children }: { children: ReactNode }) {
|
||||||
return <NurseLayout>{children}</NurseLayout>;
|
return (
|
||||||
|
<RoleGuard expected={APP_ROLES.NURSE}>
|
||||||
|
<NurseLayout>{children}</NurseLayout>
|
||||||
|
</RoleGuard>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { PartnerLayout } from '@/layout';
|
import { PartnerLayout } from '@/layout';
|
||||||
|
import { RoleGuard } from '@/components/auth';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Partner-center portal route group (/partner/…) — a separate authz scope from /admin (f15). A center
|
* Partner-center portal route group (/partner/…) — a separate authz scope from /admin (f15). A center
|
||||||
* admin sees only their own center; tenancy is server-enforced and each portal page resolves the caller's
|
* admin sees only their own center; tenancy is server-enforced and each portal page resolves the caller's
|
||||||
* own center via `useMyPartnerCenter` (a 403/404 renders the access-denied state).
|
* own center via `useMyPartnerCenter` (a 403/404 renders the access-denied state). The RoleGuard here takes
|
||||||
|
* no `expected` role — partner scope isn't an `AppRole`, so the guard only hardens `/me` hydration (neutral
|
||||||
|
* loading/error instead of the raw shell); the center-resolution gate stays with `useMyPartnerCenter`.
|
||||||
*/
|
*/
|
||||||
export default function PartnerRouteLayout({ children }: { children: ReactNode }) {
|
export default function PartnerRouteLayout({ children }: { children: ReactNode }) {
|
||||||
return <PartnerLayout>{children}</PartnerLayout>;
|
return (
|
||||||
|
<RoleGuard>
|
||||||
|
<PartnerLayout>{children}</PartnerLayout>
|
||||||
|
</RoleGuard>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
import { FunctionComponent } from 'react';
|
||||||
|
import { CircularProgress, Stack, Typography } from '@mui/material';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { AppButton } from '@/components';
|
||||||
|
import AppIcon from '@/components/common/AppIcon';
|
||||||
|
import BrandMark from './BrandMark';
|
||||||
|
|
||||||
|
interface AuthAccountErrorProps {
|
||||||
|
onRetry: () => void;
|
||||||
|
isRetrying: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown by `RoleGuard` when `/me` fails on a private route (e.g. the API is unreachable). Surfacing an
|
||||||
|
* explicit "couldn't load your account" recovery is the deliberate alternative to silently defaulting to
|
||||||
|
* the customer shell — a transient error must never downgrade a nurse/admin to the family app.
|
||||||
|
* @component AuthAccountError
|
||||||
|
*/
|
||||||
|
const AuthAccountError: FunctionComponent<AuthAccountErrorProps> = ({ onRetry, isRetrying }) => {
|
||||||
|
const t = useTranslations('auth');
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', gap: 2, px: 2, textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
<BrandMark />
|
||||||
|
<AppIcon icon="warning" size={40} color="var(--bal-warning)" />
|
||||||
|
<Typography variant="h6" component="h1">
|
||||||
|
{t('account_error_title')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}>
|
||||||
|
{t('account_error_body')}
|
||||||
|
</Typography>
|
||||||
|
<AppButton
|
||||||
|
color="primary"
|
||||||
|
variant="contained"
|
||||||
|
onClick={onRetry}
|
||||||
|
disabled={isRetrying}
|
||||||
|
startIcon={isRetrying ? <CircularProgress size={18} color="inherit" /> : undefined}
|
||||||
|
>
|
||||||
|
{t('account_error_retry')}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthAccountError;
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ThemeProvider } from '../../theme';
|
||||||
|
import type { RoleHydration } from '@/services/auth';
|
||||||
|
|
||||||
|
const mockReplace = jest.fn();
|
||||||
|
const mockEnqueue = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('next/navigation', () => ({
|
||||||
|
...jest.requireActual('next/navigation'),
|
||||||
|
useRouter: () => ({ replace: mockReplace }),
|
||||||
|
}));
|
||||||
|
jest.mock('next-intl', () => ({ useLocale: () => 'fa', useTranslations: () => (key: string) => key }));
|
||||||
|
jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: mockEnqueue }) }));
|
||||||
|
|
||||||
|
let hydration: RoleHydration;
|
||||||
|
jest.mock('@/services/auth', () => ({ useRoleHydration: () => hydration }));
|
||||||
|
|
||||||
|
import RoleGuard from './RoleGuard';
|
||||||
|
|
||||||
|
const CHILD = <div data-testid="shell">shell content</div>;
|
||||||
|
|
||||||
|
function renderGuard(expected?: 'customer' | 'nurse' | 'admin') {
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<RoleGuard expected={expected}>{CHILD}</RoleGuard>
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('<RoleGuard/>', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockReplace.mockReset();
|
||||||
|
mockEnqueue.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a neutral splash (not the shell) while /me is loading', () => {
|
||||||
|
hydration = { status: 'loading' };
|
||||||
|
renderGuard('nurse');
|
||||||
|
expect(screen.queryByTestId('shell')).not.toBeInTheDocument();
|
||||||
|
expect(mockReplace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the account-error state (not the shell) when /me failed', () => {
|
||||||
|
hydration = { status: 'error', retry: jest.fn(), isRetrying: false };
|
||||||
|
renderGuard('nurse');
|
||||||
|
expect(screen.queryByTestId('shell')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('account_error_title')).toBeInTheDocument();
|
||||||
|
expect(mockReplace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls retry when the account-error button is pressed', async () => {
|
||||||
|
const retry = jest.fn();
|
||||||
|
hydration = { status: 'error', retry, isRetrying: false };
|
||||||
|
renderGuard('nurse');
|
||||||
|
await userEvent.click(screen.getByText('account_error_retry'));
|
||||||
|
expect(retry).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the shell when the session holds the expected role', () => {
|
||||||
|
hydration = { status: 'ready', me: { roles: ['nurse'] } as never, appRoles: ['nurse'] };
|
||||||
|
renderGuard('nurse');
|
||||||
|
expect(screen.getByTestId('shell')).toBeInTheDocument();
|
||||||
|
expect(mockReplace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a dual customer+nurse session into the nurse shell', () => {
|
||||||
|
hydration = { status: 'ready', me: { roles: ['customer', 'nurse'] } as never, appRoles: ['customer', 'nurse'] };
|
||||||
|
renderGuard('nurse');
|
||||||
|
expect(screen.getByTestId('shell')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects (with a toast) a pure customer away from the nurse shell', async () => {
|
||||||
|
hydration = { status: 'ready', me: { roles: ['customer'] } as never, appRoles: ['customer'] };
|
||||||
|
renderGuard('nurse');
|
||||||
|
expect(screen.queryByTestId('shell')).not.toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/'));
|
||||||
|
expect(mockEnqueue).toHaveBeenCalledWith('guard_denied', { variant: 'warning' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects a role-less user to select-role', async () => {
|
||||||
|
hydration = { status: 'ready', me: { roles: [] } as never, appRoles: [] };
|
||||||
|
renderGuard('customer');
|
||||||
|
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/select-role'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with no expected role (partner scope) renders once /me resolves without redirecting', () => {
|
||||||
|
hydration = { status: 'ready', me: { roles: ['customer'] } as never, appRoles: ['customer'] };
|
||||||
|
renderGuard(undefined);
|
||||||
|
expect(screen.getByTestId('shell')).toBeInTheDocument();
|
||||||
|
expect(mockReplace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
import { FunctionComponent, ReactNode, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { useSnackbar } from 'notistack';
|
||||||
|
|
||||||
|
import { type AppRole } from '@/constants';
|
||||||
|
import { useRoleHydration } from '@/services/auth';
|
||||||
|
import { resolveRoleDestination } from '@/services/auth/routing';
|
||||||
|
import AuthSplash from './AuthSplash';
|
||||||
|
import AuthAccountError from './AuthAccountError';
|
||||||
|
|
||||||
|
interface RoleGuardProps {
|
||||||
|
/**
|
||||||
|
* The actor shell being entered. When set, a session that lacks the role is **redirected** to its real
|
||||||
|
* destination (chrome UX, not security — the server still authorizes every endpoint). Omit for a scope
|
||||||
|
* not keyed on `AppRole` (the partner portal, which self-gates via `useMyPartnerCenter`); the guard then
|
||||||
|
* only hardens hydration — a neutral loading/error state instead of the raw shell while `/me` resolves.
|
||||||
|
*/
|
||||||
|
expected?: AppRole;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client-side role-aware navigation guard for the private shells (refinement-phase-2). It gates a shell
|
||||||
|
* on the resolved-vs-pending role state (`useRoleHydration`) so the wrong actor app never renders:
|
||||||
|
* - **loading** — a brand splash while `/me` is in flight (never the customer shell as a stand-in);
|
||||||
|
* - **error** — an explicit account-error recovery when `/me` failed (never a silent customer fallback);
|
||||||
|
* - **role mismatch** — redirect to the caller's real app (`resolveRoleDestination`, the single source of
|
||||||
|
* "which app") with a toast, rather than rendering a shell they lack the role for.
|
||||||
|
*
|
||||||
|
* A dual customer+nurse session holds both roles, so it passes either shell's guard and can move freely
|
||||||
|
* between the family and nurse apps.
|
||||||
|
* @component RoleGuard
|
||||||
|
*/
|
||||||
|
const RoleGuard: FunctionComponent<RoleGuardProps> = ({ expected, children }) => {
|
||||||
|
const t = useTranslations('auth');
|
||||||
|
const router = useRouter();
|
||||||
|
const locale = useLocale();
|
||||||
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
const hydration = useRoleHydration();
|
||||||
|
|
||||||
|
const me = hydration.status === 'ready' ? hydration.me : null;
|
||||||
|
const appRoles = hydration.status === 'ready' ? hydration.appRoles : null;
|
||||||
|
const allowed = !expected || (appRoles?.includes(expected) ?? false);
|
||||||
|
// Stable across renders for a given identity (a string), so the redirect effect fires once, not per render.
|
||||||
|
const redirectTo = me && !allowed ? `/${locale}${resolveRoleDestination(me)}` : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!redirectTo) return;
|
||||||
|
enqueueSnackbar(t('guard_denied'), { variant: 'warning' });
|
||||||
|
router.replace(redirectTo);
|
||||||
|
}, [redirectTo, enqueueSnackbar, t, router]);
|
||||||
|
|
||||||
|
if (hydration.status === 'loading') return <AuthSplash message={t('routing_title')} />;
|
||||||
|
if (hydration.status === 'error')
|
||||||
|
return <AuthAccountError onRetry={hydration.retry} isRetrying={hydration.isRetrying} />;
|
||||||
|
// Role mismatch — hold the neutral splash while the redirect above navigates away.
|
||||||
|
if (!allowed) return <AuthSplash message={t('routing_title')} />;
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RoleGuard;
|
||||||
@@ -2,3 +2,5 @@ export { default as LoginFlow } from './LoginFlow';
|
|||||||
export { default as RoleRouter } from './RoleRouter';
|
export { default as RoleRouter } from './RoleRouter';
|
||||||
export { default as SelectRole } from './SelectRole';
|
export { default as SelectRole } from './SelectRole';
|
||||||
export { default as AuthSplash } from './AuthSplash';
|
export { default as AuthSplash } from './AuthSplash';
|
||||||
|
export { default as RoleGuard } from './RoleGuard';
|
||||||
|
export { default as AuthAccountError } from './AuthAccountError';
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ jest.mock('next-intl', () => ({
|
|||||||
}));
|
}));
|
||||||
jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: jest.fn() }) }));
|
jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: jest.fn() }) }));
|
||||||
|
|
||||||
|
// This is a behavioural test of the two-stage-disclosure gate; it uses the in-memory bookings mock as
|
||||||
|
// its data fixture (seeded booking 5001 + care instructions). Pin the seam to the mock so the test is
|
||||||
|
// independent of the production `USE_BOOKINGS_MOCK` flag (flipped to real in refinement-phase-4).
|
||||||
|
jest.mock('@/services/bookings/apis', () => ({
|
||||||
|
bookingsApi: jest.requireActual('@/services/bookings/apis/mockApi').bookingsMockApi,
|
||||||
|
}));
|
||||||
|
|
||||||
import BookingDetailView from './BookingDetailView';
|
import BookingDetailView from './BookingDetailView';
|
||||||
import { bookingsApi } from '@/services/bookings/apis';
|
import { bookingsApi } from '@/services/bookings/apis';
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ const ROLE_PRECEDENCE: AppRole[] = [APP_ROLES.ADMIN, APP_ROLES.NURSE, APP_ROLES.
|
|||||||
/**
|
/**
|
||||||
* The actor experience the current session should see, read from the session roles.
|
* The actor experience the current session should see, read from the session roles.
|
||||||
*
|
*
|
||||||
* Roles are seeded by the server in f1-b2; until then sessions carry no roles and
|
* Chrome-only signal: which nav/shell the shells build from the collapsed session roles. The DEFAULT_ROLE
|
||||||
* this returns DEFAULT_ROLE (customer) so the shells degrade gracefully. Route-group
|
* (customer) fallback is a **last resort**, not the loading state — the shells are wrapped in `RoleGuard`
|
||||||
* layouts use it to drive role-aware navigation and (later) access guards.
|
* (refinement-phase-2), which holds a neutral splash until `/me` resolves and redirects a role mismatch, so
|
||||||
|
* this is only ever read once the roles are hydrated. Never gate "which app" on this fallback; that decision
|
||||||
|
* is `resolveRoleDestination` (via `RoleGuard`/`RoleRouter`), the single source of truth.
|
||||||
*/
|
*/
|
||||||
export function useActorRole(): AppRole {
|
export function useActorRole(): AppRole {
|
||||||
const [state] = useAuth();
|
const [state] = useAuth();
|
||||||
|
|||||||
@@ -36,9 +36,13 @@ export async function clientFetch<T>(path: string, options?: RequestInit, isRetr
|
|||||||
const locale = window.location.pathname.split('/')[1] || 'fa';
|
const locale = window.location.pathname.split('/')[1] || 'fa';
|
||||||
|
|
||||||
const reqHeaders: Record<string, string> = {
|
const reqHeaders: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept-Language': locale,
|
'Accept-Language': locale,
|
||||||
};
|
};
|
||||||
|
// Let the browser set `Content-Type` (with the multipart boundary) for FormData bodies — a manual
|
||||||
|
// JSON content-type there breaks the upload. JSON bodies still declare it explicitly.
|
||||||
|
if (!(options?.body instanceof FormData)) {
|
||||||
|
reqHeaders['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
if (token) {
|
if (token) {
|
||||||
reqHeaders['Authorization'] = `Bearer ${token}`;
|
reqHeaders['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import type { CreateAddressInput, CustomerAddress, CustomerAddressDto, Addresses
|
|||||||
|
|
||||||
const BASE = '/api/v1/customer_addresses';
|
const BASE = '/api/v1/customer_addresses';
|
||||||
|
|
||||||
// The wire `CustomerAddressDto` has no `provinceId` (REQ-009). Reads default it to null; writes
|
// REQ-009 (delivered): the wire `CustomerAddressDto` now carries `provinceId` (joined from
|
||||||
// echo the caller's chosen province onto the returned row so the just-saved address can be
|
// `cities.province_id`), so a freshly-fetched address prefills the cascade. The caller's chosen
|
||||||
// re-edited with its cascade prefilled (not yet persisted server-side).
|
// province is kept as a fallback for the optimistic just-saved echo.
|
||||||
function toAddress(dto: CustomerAddressDto, provinceId?: number | null): CustomerAddress {
|
interface AddressWire extends CustomerAddressDto {
|
||||||
return { ...dto, provinceId: provinceId ?? null };
|
provinceId: number;
|
||||||
|
}
|
||||||
|
function toAddress(dto: AddressWire, provinceId?: number | null): CustomerAddress {
|
||||||
|
return { ...dto, provinceId: dto.provinceId ?? provinceId ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the contract fields cross the wire. `latitude`/`longitude` are the picked pin (REQ-008 —
|
// Only the contract fields cross the wire. `latitude`/`longitude` are the picked pin (REQ-008 —
|
||||||
@@ -43,7 +46,7 @@ export const addressesClientApi: AddressesApi = {
|
|||||||
// geo lookups' explicit `province_id`/`city_id`); match the patients template's `pageSize`.
|
// geo lookups' explicit `province_id`/`city_id`); match the patients template's `pageSize`.
|
||||||
query.set('pageSize', String(params?.pageSize ?? ADDRESSES_PAGE_SIZE));
|
query.set('pageSize', String(params?.pageSize ?? ADDRESSES_PAGE_SIZE));
|
||||||
const page = unwrap(
|
const page = unwrap(
|
||||||
await clientFetch<ApiEnvelope<Paginated<CustomerAddressDto>>>(`${BASE}/list?${query.toString()}`),
|
await clientFetch<ApiEnvelope<Paginated<AddressWire>>>(`${BASE}/list?${query.toString()}`),
|
||||||
);
|
);
|
||||||
return { ...page, items: page.items.map((dto) => toAddress(dto)) };
|
return { ...page, items: page.items.map((dto) => toAddress(dto)) };
|
||||||
},
|
},
|
||||||
@@ -51,7 +54,7 @@ export const addressesClientApi: AddressesApi = {
|
|||||||
create: async (input) =>
|
create: async (input) =>
|
||||||
toAddress(
|
toAddress(
|
||||||
unwrap(
|
unwrap(
|
||||||
await clientFetch<ApiEnvelope<CustomerAddressDto>>(`${BASE}/create`, {
|
await clientFetch<ApiEnvelope<AddressWire>>(`${BASE}/create`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(toBody(input)),
|
body: JSON.stringify(toBody(input)),
|
||||||
}),
|
}),
|
||||||
@@ -62,7 +65,7 @@ export const addressesClientApi: AddressesApi = {
|
|||||||
update: async (id, input) =>
|
update: async (id, input) =>
|
||||||
toAddress(
|
toAddress(
|
||||||
unwrap(
|
unwrap(
|
||||||
await clientFetch<ApiEnvelope<CustomerAddressDto>>(`${BASE}/update/${id}`, {
|
await clientFetch<ApiEnvelope<AddressWire>>(`${BASE}/update/${id}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(toBody(input)),
|
body: JSON.stringify(toBody(input)),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* `api/v1/customer_addresses/*` routes — no hook/component changes
|
* `api/v1/customer_addresses/*` routes — no hook/component changes
|
||||||
* (see dev/shared-working-context/reports/mocks-registry.md).
|
* (see dev/shared-working-context/reports/mocks-registry.md).
|
||||||
*/
|
*/
|
||||||
export const USE_ADDRESSES_MOCK = true;
|
export const USE_ADDRESSES_MOCK = false;
|
||||||
|
|
||||||
/** Address lists change only on mutation; keep them warm across screen visits. */
|
/** Address lists change only on mutation; keep them warm across screen visits. */
|
||||||
export const ADDRESSES_STALE_TIME = 60_000;
|
export const ADDRESSES_STALE_TIME = 60_000;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useMe } from './useMe';
|
||||||
|
import { toAppRoles } from '../routing';
|
||||||
|
import type { Me } from '../types';
|
||||||
|
import type { AppRole } from '@/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The resolved-vs-pending role state for a private route. The core refinement-phase-2 fix: a shell must
|
||||||
|
* distinguish **"/me hasn't resolved yet"** from **"the user has no nurse/admin role"** — conflating the
|
||||||
|
* two is what silently showed a nurse the customer app (a fresh `/me` in-flight fell through the
|
||||||
|
* `DEFAULT_ROLE = customer` fallback). This exposes that distinction so `RoleGuard` can render a neutral
|
||||||
|
* loading state while pending, an explicit error state when `/me` failed, and only route on a resolved
|
||||||
|
* role set.
|
||||||
|
*
|
||||||
|
* `error` fires only when `/me` has no data at all; a background refetch that fails while we still hold a
|
||||||
|
* cached identity keeps serving `ready` (don't downgrade a known nurse on a transient blip).
|
||||||
|
*/
|
||||||
|
export type RoleHydration =
|
||||||
|
| { status: 'loading' }
|
||||||
|
| { status: 'error'; retry: () => void; isRetrying: boolean }
|
||||||
|
| { status: 'ready'; me: Me; appRoles: AppRole[] };
|
||||||
|
|
||||||
|
export function useRoleHydration(): RoleHydration {
|
||||||
|
const { data: me, isError, isFetching, refetch } = useMe();
|
||||||
|
|
||||||
|
if (me) return { status: 'ready', me, appRoles: toAppRoles(me.roles) };
|
||||||
|
if (isError) return { status: 'error', retry: () => void refetch(), isRetrying: isFetching };
|
||||||
|
return { status: 'loading' };
|
||||||
|
}
|
||||||
@@ -5,3 +5,5 @@ export { useRefresh } from './hooks/useRefresh';
|
|||||||
export { useLogout } from './hooks/useLogout';
|
export { useLogout } from './hooks/useLogout';
|
||||||
export { useSelectRole } from './hooks/useSelectRole';
|
export { useSelectRole } from './hooks/useSelectRole';
|
||||||
export { useSessionRoleSync } from './hooks/useSessionRoleSync';
|
export { useSessionRoleSync } from './hooks/useSessionRoleSync';
|
||||||
|
export { useRoleHydration } from './hooks/useRoleHydration';
|
||||||
|
export type { RoleHydration } from './hooks/useRoleHydration';
|
||||||
|
|||||||
@@ -12,43 +12,27 @@ import type {
|
|||||||
|
|
||||||
const BASE = '/api/v1/booking_requests';
|
const BASE = '/api/v1/booking_requests';
|
||||||
|
|
||||||
/**
|
|
||||||
* The b8 wire `BookingRequestDto` — identical to our app DTO minus the client-augmented `variantPrice`
|
|
||||||
* (REQ-013: the contract returns `variantLabel` + `variantPriceUnit` but no price) and `bookingId`
|
|
||||||
* (REQ-017: a `converted` request gives no way to reach the booking it became).
|
|
||||||
*/
|
|
||||||
type BookingRequestWireDto = Omit<BookingRequestDto, 'variantPrice' | 'bookingId'>;
|
|
||||||
|
|
||||||
/** Map the wire DTO to the app DTO, defaulting the not-yet-contracted fields to `null`. */
|
|
||||||
function toDto(wire: BookingRequestWireDto): BookingRequestDto {
|
|
||||||
return { ...wire, variantPrice: null, bookingId: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real HTTP implementation of the `BookingRequestsApi` seam (b8 contract
|
* Real HTTP implementation of the `BookingRequestsApi` seam (b8 contract
|
||||||
* `dev/contracts/domains/booking-requests.md`). Routes are action-style + snake_case; ids for
|
* `dev/contracts/domains/booking-requests.md`). Routes are action-style + snake_case; ids for
|
||||||
* accept/reject/cancel/get come from the **route**, never the body; JSON bodies/fields are camelCase and
|
* accept/reject/cancel/get come from the **route**, never the body; JSON bodies/fields are camelCase and
|
||||||
* `clientFetch` returns the raw envelope, so we `unwrap()`. Mutations use POST.
|
* `clientFetch` returns the raw envelope, so we `unwrap()`. Mutations use POST.
|
||||||
*
|
*
|
||||||
* NOT the primary implementation this phase (`USE_BOOKING_REQUESTS_MOCK = true`): every input id (nurse,
|
* PRIMARY once `USE_BOOKING_REQUESTS_MOCK = false` (refinement-phase-4; REQ-013/014/017 delivered): the
|
||||||
* patient, address) comes from a mock-primary upstream domain today, and the DTO omits `variantPrice`
|
* DTO now carries `variantPrice` + `nurseAvatarUrl` (REQ-013) + `bookingId` (REQ-017), and the list item
|
||||||
* (REQ-013). This client maps everything b8 provides — the `context` arg (a mock-only display aid) is
|
* carries `variantLabel` + `patientAge` (REQ-014), so the wire maps 1:1 to the app DTO. The `context` arg
|
||||||
* ignored here, and the nurse-view masking is done server-side (so `role` is ignored too). Selected once
|
* (a mock-only display aid) is ignored here, and the nurse-view masking is done server-side.
|
||||||
* the upstream domains are live and REQ-013 lands (a single config flip; no hook/component change).
|
|
||||||
*/
|
*/
|
||||||
export const bookingRequestsClientApi: BookingRequestsApi = {
|
export const bookingRequestsClientApi: BookingRequestsApi = {
|
||||||
create: async (payload: CreateBookingRequestPayload) =>
|
create: async (payload: CreateBookingRequestPayload) =>
|
||||||
toDto(
|
unwrap(
|
||||||
unwrap(
|
await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/create`, {
|
||||||
await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/create`, {
|
method: 'POST',
|
||||||
method: 'POST',
|
body: JSON.stringify(payload),
|
||||||
body: JSON.stringify(payload),
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
get: async (id: number) =>
|
get: async (id: number) => unwrap(await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/get/${id}`)),
|
||||||
toDto(unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/get/${id}`))),
|
|
||||||
|
|
||||||
list: async (params: BookingRequestListParams): Promise<Paginated<BookingRequestListItem>> => {
|
list: async (params: BookingRequestListParams): Promise<Paginated<BookingRequestListItem>> => {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
@@ -62,22 +46,16 @@ export const bookingRequestsClientApi: BookingRequestsApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
accept: async (id: number) =>
|
accept: async (id: number) =>
|
||||||
toDto(
|
unwrap(await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/accept/${id}`, { method: 'POST' })),
|
||||||
unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/accept/${id}`, { method: 'POST' })),
|
|
||||||
),
|
|
||||||
|
|
||||||
reject: async (id: number, payload: RejectBookingRequestPayload) =>
|
reject: async (id: number, payload: RejectBookingRequestPayload) =>
|
||||||
toDto(
|
unwrap(
|
||||||
unwrap(
|
await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/reject/${id}`, {
|
||||||
await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/reject/${id}`, {
|
method: 'POST',
|
||||||
method: 'POST',
|
body: JSON.stringify(payload),
|
||||||
body: JSON.stringify(payload),
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
cancel: async (id: number) =>
|
cancel: async (id: number) =>
|
||||||
toDto(
|
unwrap(await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/cancel/${id}`, { method: 'POST' })),
|
||||||
unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/cancel/${id}`, { method: 'POST' })),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* (REQ-013). Flip to `false` once the upstream domains are live and REQ-013 lands — no hook/component
|
* (REQ-013). Flip to `false` once the upstream domains are live and REQ-013 lands — no hook/component
|
||||||
* change (see `dev/shared-working-context/reports/frontend-phase-7-report.md`).
|
* change (see `dev/shared-working-context/reports/frontend-phase-7-report.md`).
|
||||||
*/
|
*/
|
||||||
export const USE_BOOKING_REQUESTS_MOCK = true;
|
export const USE_BOOKING_REQUESTS_MOCK = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The customer's C5 and the nurse inbox **poll** while a request is non-terminal so a transition
|
* The customer's C5 and the nurse inbox **poll** while a request is non-terminal so a transition
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* `clientApi` maps the routes 1:1; flip to `false` once conversion (b10) is live client-side — a single
|
* `clientApi` maps the routes 1:1; flip to `false` once conversion (b10) is live client-side — a single
|
||||||
* config change, no hook/component edits (see `dev/shared-working-context/reports/frontend-phase-8-report.md`).
|
* config change, no hook/component edits (see `dev/shared-working-context/reports/frontend-phase-8-report.md`).
|
||||||
*/
|
*/
|
||||||
export const USE_BOOKINGS_MOCK = true;
|
export const USE_BOOKINGS_MOCK = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The booking detail changes on status transitions (payment → confirmed → in_progress → completed) and
|
* The booking detail changes on status transitions (payment → confirmed → in_progress → completed) and
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* demo standalone before the backend is reachable in this environment. Flip to false to hit the
|
* demo standalone before the backend is reachable in this environment. Flip to false to hit the
|
||||||
* live endpoints — no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md).
|
* live endpoints — no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md).
|
||||||
*/
|
*/
|
||||||
export const USE_CATALOG_MOCK = true;
|
export const USE_CATALOG_MOCK = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Categories and a category's option groups/values are **admin-seeded reference data** that changes
|
* Categories and a category's option groups/values are **admin-seeded reference data** that changes
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Flip to false to hit the live `api/v1/geo/*` lookups — no hook/component changes
|
* Flip to false to hit the live `api/v1/geo/*` lookups — no hook/component changes
|
||||||
* (see dev/shared-working-context/reports/mocks-registry.md).
|
* (see dev/shared-working-context/reports/mocks-registry.md).
|
||||||
*/
|
*/
|
||||||
export const USE_GEOGRAPHY_MOCK = true;
|
export const USE_GEOGRAPHY_MOCK = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reference data almost never changes, so it is cached **for the whole session**: an Infinite
|
* Reference data almost never changes, so it is cached **for the whole session**: an Infinite
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* `__mockPushNotification` to simulate a fresh notification arriving (the bell increments within the poll
|
* `__mockPushNotification` to simulate a fresh notification arriving (the bell increments within the poll
|
||||||
* interval — phase §7 step 4). Flip to `false` once the upstreams are real — no hook/component change.
|
* interval — phase §7 step 4). Flip to `false` once the upstreams are real — no hook/component change.
|
||||||
*/
|
*/
|
||||||
export const USE_NOTIFICATIONS_MOCK = true;
|
export const USE_NOTIFICATIONS_MOCK = false;
|
||||||
|
|
||||||
/** Notification-center page size (api-conventions `pageSize`). */
|
/** Notification-center page size (api-conventions `pageSize`). */
|
||||||
export const NOTIFICATIONS_PAGE_SIZE = 20;
|
export const NOTIFICATIONS_PAGE_SIZE = 20;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* the pending→verified/mismatch UI transition behind the client mock. Flip to false to use
|
* the pending→verified/mismatch UI transition behind the client mock. Flip to false to use
|
||||||
* the real endpoints — no hook/component changes (mocks-registry.md).
|
* the real endpoints — no hook/component changes (mocks-registry.md).
|
||||||
*/
|
*/
|
||||||
export const USE_NURSE_BANK_MOCK = true;
|
export const USE_NURSE_BANK_MOCK = false;
|
||||||
|
|
||||||
/** Bank accounts change rarely; keep them warm across screen visits. */
|
/** Bank accounts change rarely; keep them warm across screen visits. */
|
||||||
export const BANK_STALE_TIME = 30_000;
|
export const BANK_STALE_TIME = 30_000;
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
import { clientFetch } from '@/lib/api/client';
|
import { clientFetch } from '@/lib/api/client';
|
||||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||||
import type { CreatePatientInput, Patient, PatientDto, PatientsApi } from '../types';
|
import type {
|
||||||
|
ConditionCode,
|
||||||
|
CreatePatientInput,
|
||||||
|
Patient,
|
||||||
|
PatientDto,
|
||||||
|
PatientsApi,
|
||||||
|
Relation,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
const BASE = '/api/v1/patients';
|
const BASE = '/api/v1/patients';
|
||||||
|
|
||||||
// The wire `PatientDto` has no relation/conditions yet (REQ-005). Reads default them; writes
|
/** REQ-005 (delivered): the wire `PatientDto` now carries `relation`/`conditions` (both nullable). */
|
||||||
// echo the caller's choice onto the returned row so the just-edited card reflects it (not
|
interface PatientWire extends PatientDto {
|
||||||
// yet persisted server-side).
|
relation: Relation | null;
|
||||||
function toPatient(dto: PatientDto, augment?: Pick<CreatePatientInput, 'relation' | 'conditions'>): Patient {
|
conditions: ConditionCode[] | null;
|
||||||
return { ...dto, relation: augment?.relation ?? null, conditions: augment?.conditions ?? [] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the wire fields cross the boundary — relation/conditions are client-augmented (REQ-005).
|
function toPatient(dto: PatientWire): Patient {
|
||||||
|
return { ...dto, relation: dto.relation ?? null, conditions: dto.conditions ?? [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// REQ-005 (delivered): relation/conditions now cross the wire and are persisted server-side.
|
||||||
function toBody(input: CreatePatientInput) {
|
function toBody(input: CreatePatientInput) {
|
||||||
const { displayName, firstName, lastName, birthDate, gender } = input;
|
const { displayName, firstName, lastName, birthDate, gender } = input;
|
||||||
return {
|
return {
|
||||||
@@ -22,13 +32,15 @@ function toBody(input: CreatePatientInput) {
|
|||||||
gender,
|
gender,
|
||||||
bloodType: input.bloodType ?? null,
|
bloodType: input.bloodType ?? null,
|
||||||
initialMedicalNotes: input.initialMedicalNotes ?? null,
|
initialMedicalNotes: input.initialMedicalNotes ?? null,
|
||||||
|
relation: input.relation,
|
||||||
|
conditions: input.conditions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real HTTP implementation of the PatientsApi seam (b3 action-style routes). `clientFetch`
|
* Real HTTP implementation of the PatientsApi seam (b3 action-style routes). `clientFetch`
|
||||||
* returns the raw envelope, so each call reads its payload via `unwrap`. Selected once
|
* returns the raw envelope, so each call reads its payload via `unwrap`. Primary once
|
||||||
* USE_PATIENTS_MOCK is false and the relation/conditions fields land.
|
* USE_PATIENTS_MOCK is false (refinement-phase-4; REQ-005 delivered).
|
||||||
*/
|
*/
|
||||||
export const patientsClientApi: PatientsApi = {
|
export const patientsClientApi: PatientsApi = {
|
||||||
list: async (params) => {
|
list: async (params) => {
|
||||||
@@ -36,32 +48,30 @@ export const patientsClientApi: PatientsApi = {
|
|||||||
if (params?.page) query.set('page', String(params.page));
|
if (params?.page) query.set('page', String(params.page));
|
||||||
if (params?.pageSize) query.set('pageSize', String(params.pageSize));
|
if (params?.pageSize) query.set('pageSize', String(params.pageSize));
|
||||||
const qs = query.toString();
|
const qs = query.toString();
|
||||||
const page = unwrap(await clientFetch<ApiEnvelope<Paginated<PatientDto>>>(`${BASE}/list${qs ? `?${qs}` : ''}`));
|
const page = unwrap(await clientFetch<ApiEnvelope<Paginated<PatientWire>>>(`${BASE}/list${qs ? `?${qs}` : ''}`));
|
||||||
return { ...page, items: page.items.map((dto) => toPatient(dto)) };
|
return { ...page, items: page.items.map((dto) => toPatient(dto)) };
|
||||||
},
|
},
|
||||||
|
|
||||||
get: async (id) => toPatient(unwrap(await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/get/${id}`))),
|
get: async (id) => toPatient(unwrap(await clientFetch<ApiEnvelope<PatientWire>>(`${BASE}/get/${id}`))),
|
||||||
|
|
||||||
create: async (input) =>
|
create: async (input) =>
|
||||||
toPatient(
|
toPatient(
|
||||||
unwrap(
|
unwrap(
|
||||||
await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/create`, {
|
await clientFetch<ApiEnvelope<PatientWire>>(`${BASE}/create`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(toBody(input)),
|
body: JSON.stringify(toBody(input)),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
input,
|
|
||||||
),
|
),
|
||||||
|
|
||||||
update: async (id, input) =>
|
update: async (id, input) =>
|
||||||
toPatient(
|
toPatient(
|
||||||
unwrap(
|
unwrap(
|
||||||
await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/update/${id}`, {
|
await clientFetch<ApiEnvelope<PatientWire>>(`${BASE}/update/${id}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(toBody(input)),
|
body: JSON.stringify(toBody(input)),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
input,
|
|
||||||
),
|
),
|
||||||
|
|
||||||
archive: async (id) => {
|
archive: async (id) => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* mock. Flip to false once those fields land — no hook/component changes are needed
|
* mock. Flip to false once those fields land — no hook/component changes are needed
|
||||||
* (see dev/shared-working-context/reports/mocks-registry.md).
|
* (see dev/shared-working-context/reports/mocks-registry.md).
|
||||||
*/
|
*/
|
||||||
export const USE_PATIENTS_MOCK = true;
|
export const USE_PATIENTS_MOCK = false;
|
||||||
|
|
||||||
export const PATIENTS_STALE_TIME = 60_000;
|
export const PATIENTS_STALE_TIME = 60_000;
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* store, and issues the invoice — so C5 → C6 → gateway → confirmation → booking detail demos end-to-end.
|
* store, and issues the invoice — so C5 → C6 → gateway → confirmation → booking detail demos end-to-end.
|
||||||
* Flip to `false` once the upstream domains are real and REQ-016/017 land — no hook/component change.
|
* Flip to `false` once the upstream domains are real and REQ-016/017 land — no hook/component change.
|
||||||
*/
|
*/
|
||||||
export const USE_PAYMENT_MOCK = true;
|
export const USE_PAYMENT_MOCK = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* f11 wired the BNPL method screens (D1–D5): C6's «پرداخت اقساطی» seam now navigates into the installment
|
* f11 wired the BNPL method screens (D1–D5): C6's «پرداخت اقساطی» seam now navigates into the installment
|
||||||
|
|||||||
@@ -25,23 +25,34 @@ async function orNull<T>(promise: Promise<T>): Promise<T | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The wire DTOs carry no avatar/name yet (REQ-006/007); reads default the augmented fields.
|
/** REQ-006 (delivered): both profile DTOs now carry `avatarUrl`; the customer DTO also carries `preferredLanguage`. */
|
||||||
function toNurseProfile(dto: NurseProfileDto): NurseProfile {
|
interface NurseProfileWire extends NurseProfileDto {
|
||||||
return { ...dto, avatarUrl: null };
|
avatarUrl: string | null;
|
||||||
}
|
}
|
||||||
function toCustomerProfile(dto: CustomerProfileDto): CustomerProfile {
|
interface CustomerProfileWire extends CustomerProfileDto {
|
||||||
return { ...dto, firstName: null, lastName: null, preferredLanguage: null };
|
avatarUrl: string | null;
|
||||||
|
preferredLanguage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNurseProfile(dto: NurseProfileWire): NurseProfile {
|
||||||
|
return { ...dto, avatarUrl: dto.avatarUrl ?? null };
|
||||||
|
}
|
||||||
|
// The customer name lives on `/me` (REQ-007 design), not on `CustomerProfileDto` — the profile screen
|
||||||
|
// sources first/last name from `useMe`. Here we carry the served `preferredLanguage`; name stays null.
|
||||||
|
function toCustomerProfile(dto: CustomerProfileWire): CustomerProfile {
|
||||||
|
return { ...dto, firstName: null, lastName: null, preferredLanguage: dto.preferredLanguage ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real HTTP implementation of the ProfilesApi seam (b3 action-style routes). Selected once
|
* Real HTTP implementation of the ProfilesApi seam (b3 action-style routes). PRIMARY once
|
||||||
* USE_PROFILES_MOCK is false and the avatar/name gaps land. `uploadAvatar` has no route yet
|
* USE_PROFILES_MOCK is false (refinement-phase-4; REQ-006/007 delivered). Avatar upload posts multipart
|
||||||
* (REQ-006) and the JSON-only fetch layer can't send multipart — it stays mock-only.
|
* to the b3 `nurse_profiles/avatar` route (the JSON-only default is bypassed for `FormData` bodies —
|
||||||
|
* see `lib/api/client.ts`); the customer name/language reach the server via the upsert body.
|
||||||
*/
|
*/
|
||||||
export const profilesClientApi: ProfilesApi = {
|
export const profilesClientApi: ProfilesApi = {
|
||||||
getCustomerProfile: async () =>
|
getCustomerProfile: async () =>
|
||||||
orNull(
|
orNull(
|
||||||
clientFetch<ApiEnvelope<CustomerProfileDto>>(`${BASE}/customer_profiles/me`).then((env) =>
|
clientFetch<ApiEnvelope<CustomerProfileWire>>(`${BASE}/customer_profiles/me`).then((env) =>
|
||||||
toCustomerProfile(unwrap(env)),
|
toCustomerProfile(unwrap(env)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -49,11 +60,15 @@ export const profilesClientApi: ProfilesApi = {
|
|||||||
upsertCustomerProfile: async (input: UpsertCustomerProfileInput) =>
|
upsertCustomerProfile: async (input: UpsertCustomerProfileInput) =>
|
||||||
toCustomerProfile(
|
toCustomerProfile(
|
||||||
unwrap(
|
unwrap(
|
||||||
await clientFetch<ApiEnvelope<CustomerProfileDto>>(`${BASE}/customer_profiles/upsert`, {
|
await clientFetch<ApiEnvelope<CustomerProfileWire>>(`${BASE}/customer_profiles/upsert`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
defaultEmergencyContactName: input.defaultEmergencyContactName,
|
defaultEmergencyContactName: input.defaultEmergencyContactName,
|
||||||
defaultEmergencyContactPhone: input.defaultEmergencyContactPhone,
|
defaultEmergencyContactPhone: input.defaultEmergencyContactPhone,
|
||||||
|
// REQ-007 (delivered): name + preferred language are accepted on the upsert command.
|
||||||
|
firstName: input.firstName ?? null,
|
||||||
|
lastName: input.lastName ?? null,
|
||||||
|
preferredLanguage: input.preferredLanguage ?? null,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -61,13 +76,13 @@ export const profilesClientApi: ProfilesApi = {
|
|||||||
|
|
||||||
getNurseProfile: async () =>
|
getNurseProfile: async () =>
|
||||||
orNull(
|
orNull(
|
||||||
clientFetch<ApiEnvelope<NurseProfileDto>>(`${BASE}/nurse_profiles/me`).then((env) => toNurseProfile(unwrap(env))),
|
clientFetch<ApiEnvelope<NurseProfileWire>>(`${BASE}/nurse_profiles/me`).then((env) => toNurseProfile(unwrap(env))),
|
||||||
),
|
),
|
||||||
|
|
||||||
upsertNurseProfile: async (input: UpsertNurseProfileInput) =>
|
upsertNurseProfile: async (input: UpsertNurseProfileInput) =>
|
||||||
toNurseProfile(
|
toNurseProfile(
|
||||||
unwrap(
|
unwrap(
|
||||||
await clientFetch<ApiEnvelope<NurseProfileDto>>(`${BASE}/nurse_profiles/upsert`, {
|
await clientFetch<ApiEnvelope<NurseProfileWire>>(`${BASE}/nurse_profiles/upsert`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
bio: input.bio,
|
bio: input.bio,
|
||||||
@@ -80,7 +95,16 @@ export const profilesClientApi: ProfilesApi = {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
uploadAvatar: async (): Promise<AvatarUploadResult> => {
|
// REQ-006 (delivered): only the nurse profile screen uploads an avatar today; it persists immediately
|
||||||
throw new ApiError(501, 'Avatar upload has no backend route yet (REQ-006); served by the mock.');
|
// via the dedicated multipart route and the returned URL is echoed for display + read back on reload.
|
||||||
|
uploadAvatar: async (file: File): Promise<AvatarUploadResult> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
return unwrap(
|
||||||
|
await clientFetch<ApiEnvelope<AvatarUploadResult>>(`${BASE}/nurse_profiles/avatar`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* (REQ-006 / REQ-007), so this phase demos behind the mock. Flip to false once those land —
|
* (REQ-006 / REQ-007), so this phase demos behind the mock. Flip to false once those land —
|
||||||
* no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md).
|
* no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md).
|
||||||
*/
|
*/
|
||||||
export const USE_PROFILES_MOCK = true;
|
export const USE_PROFILES_MOCK = false;
|
||||||
|
|
||||||
/** Profiles are stable within a session; revisiting a screen shouldn't refetch. */
|
/** Profiles are stable within a session; revisiting a screen shouldn't refetch. */
|
||||||
export const PROFILE_STALE_TIME = 60_000;
|
export const PROFILE_STALE_TIME = 60_000;
|
||||||
|
|||||||
@@ -24,11 +24,17 @@ interface NurseReviewsWire {
|
|||||||
reviews: Paginated<ReviewListItem>;
|
reviews: Paginated<ReviewListItem>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Wire `MyReviewDto` (REQ-026) — keys the moderation state as `moderationStatus` (`'none'` when unreviewed). */
|
||||||
* Wire `ModerationQueueItemDto`. Per the b14 contract it does **not** carry `tagCodes` (REQ-037 — the admin
|
interface MyReviewDto {
|
||||||
* card can't show the review's tags); the client defaults it to `[]` on map.
|
moderationStatus: MyReviewState['status'];
|
||||||
*/
|
rating: number | null;
|
||||||
type ModerationQueueItemWire = Omit<ModerationQueueItem, 'tagCodes'>;
|
body: string | null;
|
||||||
|
tagCodes: string[];
|
||||||
|
createdAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wire `ModerationQueueItemDto` — carries `tagCodes` since REQ-037 (delivered in refinement-phase-3). */
|
||||||
|
type ModerationQueueItemWire = ModerationQueueItem;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real HTTP implementation of the `ReviewsApi` seam (b14 contract `dev/contracts/domains/reviews-records.md`,
|
* Real HTTP implementation of the `ReviewsApi` seam (b14 contract `dev/contracts/domains/reviews-records.md`,
|
||||||
@@ -57,14 +63,30 @@ export const reviewsClientApi: ReviewsApi = {
|
|||||||
return { aggregate: wire.aggregate, reviews: wire.reviews };
|
return { aggregate: wire.aggregate, reviews: wire.reviews };
|
||||||
},
|
},
|
||||||
|
|
||||||
// REQ-026: proposed owner-scoped read (no wire endpoint yet). 404s until delivered — never called while
|
// REQ-026 (delivered): owner-scoped eligibility read. Wire `reason` is nullable; normalise to `undefined`.
|
||||||
// the domain is mock-primary. Kept symmetric so the swap stays a one-line config flip.
|
getReviewEligibility: async (bookingId: number): Promise<ReviewEligibility> => {
|
||||||
getReviewEligibility: async (bookingId: number): Promise<ReviewEligibility> =>
|
const wire = unwrap(
|
||||||
unwrap(await clientFetch<ApiEnvelope<ReviewEligibility>>(`${API}/bookings/${bookingId}/review_eligibility`)),
|
await clientFetch<ApiEnvelope<{ canReview: boolean; reason: ReviewEligibility['reason'] | null }>>(
|
||||||
|
`${API}/bookings/${bookingId}/review_eligibility`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { canReview: wire.canReview, reason: wire.reason ?? undefined };
|
||||||
|
},
|
||||||
|
|
||||||
// REQ-026: proposed owner-scoped read of the caller's own review for this booking.
|
// REQ-026 (delivered): the caller's own review for this booking + its moderation state. The wire dto keys
|
||||||
getMyReviewForBooking: async (bookingId: number): Promise<MyReviewState> =>
|
// the state as `moderationStatus` (incl. `'none'` when unreviewed); the client model calls it `status`.
|
||||||
unwrap(await clientFetch<ApiEnvelope<MyReviewState>>(`${API}/bookings/${bookingId}/my_review`)),
|
getMyReviewForBooking: async (bookingId: number): Promise<MyReviewState> => {
|
||||||
|
const wire = unwrap(
|
||||||
|
await clientFetch<ApiEnvelope<MyReviewDto>>(`${API}/bookings/${bookingId}/my_review`),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: wire.moderationStatus,
|
||||||
|
rating: wire.rating,
|
||||||
|
body: wire.body,
|
||||||
|
tagCodes: wire.tagCodes ?? [],
|
||||||
|
createdAt: wire.createdAt,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
createReview: async (bookingId: number, body: CreateReviewRequest): Promise<SubmitReviewResult> =>
|
createReview: async (bookingId: number, body: CreateReviewRequest): Promise<SubmitReviewResult> =>
|
||||||
unwrap(
|
unwrap(
|
||||||
@@ -84,8 +106,8 @@ export const reviewsClientApi: ReviewsApi = {
|
|||||||
`${API}/admin/reviews/moderation_queue?${query.toString()}`,
|
`${API}/admin/reviews/moderation_queue?${query.toString()}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// REQ-037: the wire dto omits tagCodes — default to [] so the admin card renders without them.
|
// REQ-037 (delivered): the wire dto carries tagCodes; default to [] only if the server omits it.
|
||||||
return { ...wire, items: wire.items.map((item) => ({ ...item, tagCodes: [] })) };
|
return { ...wire, items: wire.items.map((item) => ({ ...item, tagCodes: item.tagCodes ?? [] })) };
|
||||||
},
|
},
|
||||||
|
|
||||||
moderateReview: async (
|
moderateReview: async (
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* review appear on the profile (the f15 moderation UI is deferred). Flip to `false` once REQ-026 lands — no
|
* review appear on the profile (the f15 moderation UI is deferred). Flip to `false` once REQ-026 lands — no
|
||||||
* hook/component change (only `clientApi.ts`'s two gap methods start returning real data).
|
* hook/component change (only `clientApi.ts`'s two gap methods start returning real data).
|
||||||
*/
|
*/
|
||||||
export const USE_REVIEWS_MOCK = true;
|
export const USE_REVIEWS_MOCK = false;
|
||||||
|
|
||||||
/** Page size for the public nurse-reviews list (api-conventions `pageSize`). */
|
/** Page size for the public nurse-reviews list (api-conventions `pageSize`). */
|
||||||
export const REVIEWS_PAGE_SIZE = 5;
|
export const REVIEWS_PAGE_SIZE = 5;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { clientFetch } from '@/lib/api/client';
|
import { clientFetch } from '@/lib/api/client';
|
||||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||||
import type { PriceUnit } from '@/services/catalog/types';
|
import type { PriceUnit } from '@/services/catalog/types';
|
||||||
import type { TrustBadge } from '@/services/verification/types';
|
|
||||||
import { SEARCH_PAGE_SIZE } from '../constants';
|
import { SEARCH_PAGE_SIZE } from '../constants';
|
||||||
import type {
|
import type {
|
||||||
NurseGender,
|
NurseGender,
|
||||||
NurseProfile,
|
NurseProfile,
|
||||||
|
NurseProfileServiceRow,
|
||||||
|
NurseReviewSnippet,
|
||||||
NurseSearchFilters,
|
NurseSearchFilters,
|
||||||
NurseSearchResult,
|
NurseSearchResult,
|
||||||
SearchApi,
|
SearchApi,
|
||||||
@@ -27,23 +28,44 @@ interface NurseSearchResultDto {
|
|||||||
totalCompletedBookings: number;
|
totalCompletedBookings: number;
|
||||||
cityId: number;
|
cityId: number;
|
||||||
districtId: number | null;
|
districtId: number | null;
|
||||||
|
/** REQ-012 — the C2 card identity, now denormalized into the index row. */
|
||||||
|
nurseName: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
distanceKm: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The INO-membership credential type code (see b6 verification). */
|
/** The b6/b7 aggregated `NursePublicProfileDto` (REQ-012) — the C3 profile payload. */
|
||||||
const INO_MEMBERSHIP_CODE = 'ino_membership';
|
interface NursePublicProfileDto {
|
||||||
|
nurseId: number;
|
||||||
|
nurseName: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
bio: string;
|
||||||
|
yearsExperience: number;
|
||||||
|
averageRating: number;
|
||||||
|
totalReviews: number;
|
||||||
|
totalCompletedBookings: number;
|
||||||
|
isVerified: boolean;
|
||||||
|
inoMembership: boolean;
|
||||||
|
attributeChips: string[];
|
||||||
|
services: {
|
||||||
|
variantId: number;
|
||||||
|
displayName: string;
|
||||||
|
priceIrr: string;
|
||||||
|
priceUnit: PriceUnit;
|
||||||
|
sessionCount: number | null;
|
||||||
|
}[];
|
||||||
|
latestReview: { rating: number; body: string | null; authorMasked: string | null; createdAt: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real HTTP implementation of the `SearchApi` seam (b7 `search/nurses`, b6 trust badge). Routes are
|
* Real HTTP implementation of the `SearchApi` seam (b7 `search/nurses`, b6/b7 public profile). Routes are
|
||||||
* action-style + snake_case; query params are snake_case per the contract; JSON fields are camelCase and
|
* action-style + snake_case; query params are snake_case per the contract; JSON fields are camelCase and
|
||||||
* `clientFetch` returns the raw envelope, so we `unwrap()`.
|
* `clientFetch` returns the raw envelope, so we `unwrap()`.
|
||||||
*
|
*
|
||||||
* NOT the primary implementation this phase (`USE_SEARCH_MOCK = true`): b7's index row omits the nurse
|
* The PRIMARY implementation once `USE_SEARCH_MOCK = false` (refinement-phase-4). REQ-012 delivered the
|
||||||
* **display name, avatar, and distance** the C2 card renders, and there is **no** aggregated
|
* discovery enrichment the C2 card + C3 profile need: `nurseName`/`avatarUrl`/`distanceKm` are now
|
||||||
* nurse-profile endpoint (name/bio/specialties/full services list/latest review) for C3 — only the b6
|
* denormalized onto `NurseSearchResultDto`, and `GET nurses/{id}/profile` aggregates identity + bio +
|
||||||
* trust badge is public. Both gaps are filed in
|
* specialties + the full services list + the latest review. This client maps both 1:1.
|
||||||
* `dev/shared-working-context/frontend/requests/for-backend.md`. This client maps everything b7/b6
|
|
||||||
* currently provide (leaving the missing fields blank) so the swap is a single config flip once the
|
|
||||||
* backend lands the join + profile route.
|
|
||||||
*/
|
*/
|
||||||
export const searchClientApi: SearchApi = {
|
export const searchClientApi: SearchApi = {
|
||||||
searchNurses: async (filters: NurseSearchFilters): Promise<Paginated<NurseSearchResult>> => {
|
searchNurses: async (filters: NurseSearchFilters): Promise<Paginated<NurseSearchResult>> => {
|
||||||
@@ -70,16 +92,15 @@ export const searchClientApi: SearchApi = {
|
|||||||
nurseId: dto.nurseId,
|
nurseId: dto.nurseId,
|
||||||
variantId: dto.variantId,
|
variantId: dto.variantId,
|
||||||
serviceCategoryId: dto.serviceCategoryId,
|
serviceCategoryId: dto.serviceCategoryId,
|
||||||
// Gap (filed): b7 does not yet join the nurse's name/avatar; the card falls back to a label.
|
// REQ-012 — identity denormalized onto the index row; card falls back to a label only when null.
|
||||||
nurseName: '',
|
nurseName: dto.nurseName ?? '',
|
||||||
avatarUrl: null,
|
avatarUrl: dto.avatarUrl,
|
||||||
// Every returned row is searchable by the index invariant.
|
// Every returned row is searchable by the index invariant.
|
||||||
isVerified: true,
|
isVerified: true,
|
||||||
averageRating: dto.averageRating,
|
averageRating: dto.averageRating,
|
||||||
totalReviews: dto.totalReviews,
|
totalReviews: dto.totalReviews,
|
||||||
totalCompletedBookings: dto.totalCompletedBookings,
|
totalCompletedBookings: dto.totalCompletedBookings,
|
||||||
// Gap (filed): no geo-distance in the index row yet.
|
distanceKm: dto.distanceKm,
|
||||||
distanceKm: null,
|
|
||||||
priceFromIrr: dto.price,
|
priceFromIrr: dto.price,
|
||||||
priceUnit: dto.priceUnit,
|
priceUnit: dto.priceUnit,
|
||||||
nurseGender: dto.nurseGender,
|
nurseGender: dto.nurseGender,
|
||||||
@@ -90,26 +111,43 @@ export const searchClientApi: SearchApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getNurseProfile: async (nurseId: number): Promise<NurseProfile> => {
|
getNurseProfile: async (nurseId: number): Promise<NurseProfile> => {
|
||||||
// Only the public trust badge is available today; the aggregated profile (name/bio/specialties/
|
const dto = unwrap(
|
||||||
// services list/latest review) is filed for the backend. Compose what b6 exposes; leave the rest blank.
|
await clientFetch<ApiEnvelope<NursePublicProfileDto>>(`${NURSES_BASE}/${nurseId}/profile`),
|
||||||
const badge = unwrap(
|
|
||||||
await clientFetch<ApiEnvelope<TrustBadge>>(`${NURSES_BASE}/${nurseId}/trust_badge`),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const services: NurseProfileServiceRow[] = dto.services.map((s) => ({
|
||||||
|
variantId: s.variantId,
|
||||||
|
displayName: s.displayName,
|
||||||
|
priceIrr: s.priceIrr,
|
||||||
|
priceUnit: s.priceUnit,
|
||||||
|
sessionCount: s.sessionCount,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const latestReview: NurseReviewSnippet | null = dto.latestReview
|
||||||
|
? {
|
||||||
|
rating: dto.latestReview.rating,
|
||||||
|
body: dto.latestReview.body ?? '',
|
||||||
|
authorMasked: dto.latestReview.authorMasked ?? '',
|
||||||
|
createdAt: dto.latestReview.createdAt,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nurseId: badge.nurseId,
|
nurseId: dto.nurseId,
|
||||||
nurseName: '',
|
nurseName: dto.nurseName,
|
||||||
avatarUrl: null,
|
avatarUrl: dto.avatarUrl,
|
||||||
bio: null,
|
bio: dto.bio || null,
|
||||||
yearsExperience: null,
|
yearsExperience: dto.yearsExperience,
|
||||||
averageRating: 0,
|
averageRating: dto.averageRating,
|
||||||
totalReviews: 0,
|
totalReviews: dto.totalReviews,
|
||||||
totalCompletedBookings: 0,
|
totalCompletedBookings: dto.totalCompletedBookings,
|
||||||
isVerified: badge.isVerified,
|
isVerified: dto.isVerified,
|
||||||
inoMembership: badge.credentialTypes.includes(INO_MEMBERSHIP_CODE),
|
inoMembership: dto.inoMembership,
|
||||||
attributeChips: badge.credentialTypes,
|
attributeChips: dto.attributeChips,
|
||||||
services: [],
|
services,
|
||||||
latestReview: null,
|
latestReview,
|
||||||
|
// Not carried by the public profile DTO; the same-gender intent for booking comes from the C1
|
||||||
|
// filter carried through the query string, not this field. Unused by the C3 page.
|
||||||
nurseGender: 'female',
|
nurseGender: 'female',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* mock supplies real-shaped fixtures so C1/C2/C3 demo end-to-end. Flip to false once the backend fills
|
* mock supplies real-shaped fixtures so C1/C2/C3 demo end-to-end. Flip to false once the backend fills
|
||||||
* the gap — no hook/component changes (see `dev/shared-working-context/reports/frontend-phase-6-report.md`).
|
* the gap — no hook/component changes (see `dev/shared-working-context/reports/frontend-phase-6-report.md`).
|
||||||
*/
|
*/
|
||||||
export const USE_SEARCH_MOCK = true;
|
export const USE_SEARCH_MOCK = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Results are read-heavy and change slowly, so a revisit (or a filter **revert**) serves from cache
|
* Results are read-heavy and change slowly, so a revisit (or a filter **revert**) serves from cache
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* reachable. Flip to false to use the live `api/v1/nurse_service_areas/*` routes — no
|
* reachable. Flip to false to use the live `api/v1/nurse_service_areas/*` routes — no
|
||||||
* hook/component changes (see dev/shared-working-context/reports/mocks-registry.md).
|
* hook/component changes (see dev/shared-working-context/reports/mocks-registry.md).
|
||||||
*/
|
*/
|
||||||
export const USE_SERVICE_AREAS_MOCK = true;
|
export const USE_SERVICE_AREAS_MOCK = false;
|
||||||
|
|
||||||
/** Service areas change only on mutation; keep them warm across screen visits. */
|
/** Service areas change only on mutation; keep them warm across screen visits. */
|
||||||
export const SERVICE_AREAS_STALE_TIME = 60_000;
|
export const SERVICE_AREAS_STALE_TIME = 60_000;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import type {
|
|||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1';
|
||||||
|
|
||||||
/** Wire `TicketSummaryDto` (camelCase, per api-conventions). */
|
/** Wire `TicketSummaryDto` (camelCase). REQ-028 (delivered) added `lastMessageAt`/`unreadCount`. */
|
||||||
interface TicketSummaryWire {
|
interface TicketSummaryWire {
|
||||||
id: number;
|
id: number;
|
||||||
referenceCode: string;
|
referenceCode: string;
|
||||||
@@ -31,6 +31,8 @@ interface TicketSummaryWire {
|
|||||||
bookingId: number | null;
|
bookingId: number | null;
|
||||||
refundId: number | null;
|
refundId: number | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
lastMessageAt: string | null;
|
||||||
|
unreadCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wire `TicketMessageDto`. `isInternal` is present on the DTO but is `false` in the user view (server-stripped). */
|
/** Wire `TicketMessageDto`. `isInternal` is present on the DTO but is `false` in the user view (server-stripped). */
|
||||||
@@ -67,6 +69,9 @@ function mapSummary(w: TicketSummaryWire): TicketSummary {
|
|||||||
bookingId: w.bookingId,
|
bookingId: w.bookingId,
|
||||||
refundId: w.refundId,
|
refundId: w.refundId,
|
||||||
createdAt: w.createdAt,
|
createdAt: w.createdAt,
|
||||||
|
// REQ-028 (delivered): the inbox unread badge + last-activity sort now come off the wire.
|
||||||
|
lastMessageAt: w.lastMessageAt,
|
||||||
|
unreadCount: w.unreadCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,9 +167,11 @@ function mapAdminThread(w: TicketThreadWire, viewerUserId?: number): AdminTicket
|
|||||||
* - `openTicket` → `POST /tickets`.
|
* - `openTicket` → `POST /tickets`.
|
||||||
* - `postMessage` → `POST /tickets/{id}/messages` (a non-staff caller never sets `isInternal`).
|
* - `postMessage` → `POST /tickets/{id}/messages` (a non-staff caller never sets `isInternal`).
|
||||||
*
|
*
|
||||||
* NOT the primary implementation this phase (`USE_TICKETS_MOCK = true`) — see `constants.ts`. The wire
|
* PRIMARY once `USE_TICKETS_MOCK = false` (refinement-phase-4; REQ-028 delivered): the summary now carries
|
||||||
* summary has no `unreadCount`/`lastMessageAt` (REQ-028), so those stay undefined here (the inbox degrades).
|
* `unreadCount`/`lastMessageAt` (inbox badge + last-activity sort) and the message post sends the optimistic
|
||||||
* `clientMessageId` is client-only (optimistic reconcile) — not sent (the server has no field for it yet).
|
* `clientMessageId` (server dedupes + echoes it back). The user list still filters only by `Status`; the
|
||||||
|
* "jump to the existing coordination ticket" by-booking lookup is a minor follow-up (REQ-028 #3 —
|
||||||
|
* `GET /tickets?BookingId=` is served, but no client method targets it yet).
|
||||||
*/
|
*/
|
||||||
export const ticketsClientApi: TicketsApi = {
|
export const ticketsClientApi: TicketsApi = {
|
||||||
listMyTickets: async (params: TicketListParams): Promise<Paginated<TicketSummary>> => {
|
listMyTickets: async (params: TicketListParams): Promise<Paginated<TicketSummary>> => {
|
||||||
@@ -202,7 +209,9 @@ export const ticketsClientApi: TicketsApi = {
|
|||||||
unwrap(
|
unwrap(
|
||||||
await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, {
|
await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ body: body.body }),
|
// REQ-028 (delivered): send the optimistic `clientMessageId` so the server dedupes a retried send
|
||||||
|
// and echoes it back on `PostMessageResult` for reconciliation.
|
||||||
|
body: JSON.stringify({ body: body.body, clientMessageId: body.clientMessageId }),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -233,12 +242,17 @@ export const ticketsClientApi: TicketsApi = {
|
|||||||
return mapAdminThread(wire, viewerUserId);
|
return mapAdminThread(wire, viewerUserId);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Staff post — may set `isInternal` (the one caller allowed to). `clientMessageId` stays client-only.
|
// Staff post — may set `isInternal` (the one caller allowed to). REQ-028: send `clientMessageId` too.
|
||||||
postAdminMessage: async (ticketId: number, body: PostAdminMessageRequest): Promise<PostMessageResult> =>
|
postAdminMessage: async (ticketId: number, body: PostAdminMessageRequest): Promise<PostMessageResult> =>
|
||||||
unwrap(
|
unwrap(
|
||||||
await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, {
|
await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ body: body.body, isInternal: body.isInternal }),
|
body: JSON.stringify({
|
||||||
|
body: body.body,
|
||||||
|
isInternal: body.isInternal,
|
||||||
|
clientMessageId: body.clientMessageId,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* ticket reachable. Flip to `false` once the upstreams are real — no hook/component change (only the seam
|
* ticket reachable. Flip to `false` once the upstreams are real — no hook/component change (only the seam
|
||||||
* selection in `apis/index.ts`).
|
* selection in `apis/index.ts`).
|
||||||
*/
|
*/
|
||||||
export const USE_TICKETS_MOCK = true;
|
export const USE_TICKETS_MOCK = false;
|
||||||
|
|
||||||
/** Inbox page size (api-conventions `pageSize`, default 50 / max 100). */
|
/** Inbox page size (api-conventions `pageSize`, default 50 / max 100). */
|
||||||
export const TICKETS_PAGE_SIZE = 20;
|
export const TICKETS_PAGE_SIZE = 20;
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ server — mirror that, don't invent a new envelope.
|
|||||||
the client picks by locale.
|
the client picks by locale.
|
||||||
|
|
||||||
## Pagination (mandatory on lists)
|
## Pagination (mandatory on lists)
|
||||||
- Query params: `page` (1-based) + `page_size` (cap it server-side, e.g. ≤100). Response payload carries
|
- Query params: `page` (1-based) + `pageSize` (cap it server-side, e.g. ≤100). Response payload carries
|
||||||
`items` + `total` (+ `page`/`page_size`). Document the default and max `page_size` per endpoint.
|
`items` + `total` (+ `page`/`pageSize`). Document the default and max `pageSize` per endpoint.
|
||||||
|
|
||||||
## Idempotency (money & side-effecting POSTs)
|
## Idempotency (money & side-effecting POSTs)
|
||||||
- Where stated, the client sends an idempotency key (header or body field) and the server dedups. Webhook
|
- Where stated, the client sends an idempotency key (header or body field) and the server dedups. Webhook
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
### `<HTTP> api/v1/<controller>/<action>`
|
### `<HTTP> api/v1/<controller>/<action>`
|
||||||
- **Purpose:** …
|
- **Purpose:** …
|
||||||
- **Auth:** none | authenticated | policy/role … · **Rate-limited:** yes/no · **Idempotency key:** yes/no
|
- **Auth:** none | authenticated | policy/role … · **Rate-limited:** yes/no · **Idempotency key:** yes/no
|
||||||
- **Path/query params:** `name` (type) — meaning; pagination `page`/`page_size` (default/max) for lists.
|
- **Path/query params:** `name` (type) — meaning; pagination `page`/`pageSize` (default/max) for lists.
|
||||||
- **Request body:**
|
- **Request body:**
|
||||||
```json
|
```json
|
||||||
{ "field": "example" }
|
{ "field": "example" }
|
||||||
|
|||||||
@@ -148,3 +148,17 @@ customer's repayment schedule — `installment_count` is informational (default
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
- b12 — initial contract (eligibility, initiate, customer/admin status, webhook, admin verify/settle/revert).
|
- b12 — initial contract (eligibility, initiate, customer/admin status, webhook, admin verify/settle/revert).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-022/023/024)
|
||||||
|
|
||||||
|
- `balinyaar` added to the `provider_code` enum (in-house plan; identical net-of-fee mechanics, resolves to the
|
||||||
|
same adapter). The set is now `snapppay|digipay|tara|torobpay|balinyaar`.
|
||||||
|
- `POST checkout_bnpl/eligibility` accepts optional `{ nationalId, mobile, consent }` (consent required when the
|
||||||
|
KYC inputs are present; a supplied mobile drives the provider inquiry, else the account mobile).
|
||||||
|
- `GET api/v1/checkout_bnpl/by_request/{bookingRequestId}` (owner-scoped) → `BnplOrderStatusDto`; `bookingId` on
|
||||||
|
the settled order was already present on the DTO.
|
||||||
|
- **DEFERRED:** `checkout_bnpl/options/{id}` + `schedule` + `wallet_installments` — b12 deliberately does not model
|
||||||
|
the customer repayment schedule / per-installment status, and there is no installment ledger to serve them from.
|
||||||
|
Keep the D1/D2/D4/D5 plan visualization mocked until a provider-schedule integration (or a schedule table) lands.
|
||||||
|
|||||||
@@ -146,3 +146,21 @@
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
- b8 — initial contract (create/accept/reject/cancel + role-scoped list + single get + admin expire).
|
- b8 — initial contract (create/accept/reject/cancel + role-scoped list + single get + admin expire).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-013/014/016/017)
|
||||||
|
|
||||||
|
- **`BookingRequestDto`** gains `variantPrice` (IRR digit-string — the chosen variant's *display rate*, not
|
||||||
|
an engagement total; the request stays money-free), `nurseAvatarUrl` (nullable), and `bookingId`
|
||||||
|
(nullable — the booking created once the request is `converted`, for the confirmation deep-link).
|
||||||
|
- **`BookingRequestListItemDto`** gains `variantLabel` (self-describing inbox row) and `patientAge`
|
||||||
|
(nullable coarse triage age).
|
||||||
|
- **`GET api/v1/booking_requests/checkout_summary/{id}`** (owner-scoped) — the C6 money breakdown:
|
||||||
|
`{ bookingRequestId, requestStatus, nurseName, patientName, variantLabel, variantPriceUnit, sessionCount,
|
||||||
|
requestedDate, requestedTimeStart, requestedTimeEnd, paymentDeadlineAt, serviceCostIrr, commissionIrr,
|
||||||
|
vatIrr, vatRate, totalIrr, grossPriceIrr, balinyaarCommissionIrr, nursePayoutAmount }`. All IRR
|
||||||
|
digit-strings, computed server-side. **Canonical rates:** `platform_fee_rate = 0.15`, `vat_rate = 0.10`.
|
||||||
|
VAT is **carved out of the commission** so `serviceCostIrr + commissionIrr + vatIrr = totalIrr = gross`
|
||||||
|
(the captured amount); `commissionIrr` is the commission **net of VAT**, and the raw b10 amounts
|
||||||
|
(`grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`) are surfaced alongside.
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ ISO-8601. Enums cross as their stable string codes.
|
|||||||
(customer own / nurse assigned / admin all). The **nurse** view omits `addressSnapshotJson`. Never includes
|
(customer own / nurse assigned / admin all). The **nurse** view omits `addressSnapshotJson`. Never includes
|
||||||
care-instruction clinical fields. **Failure:** `401`, `404` (not found / not a party → no leak).
|
care-instruction clinical fields. **Failure:** `401`, `404` (not found / not a party → no leak).
|
||||||
|
|
||||||
### `GET api/v1/bookings/list?role=customer|nurse|all&status=&page=&page_size=`
|
### `GET api/v1/bookings/list?role=customer|nurse|all&status=&page=&pageSize=`
|
||||||
- **Purpose:** role-scoped "My bookings" (paginated, projected). `role=all` is admin-only (`403` otherwise).
|
- **Purpose:** role-scoped "My bookings" (paginated, projected). `role=all` is admin-only (`403` otherwise).
|
||||||
**Success:** `PagedResult<BookingListItem>`.
|
**Success:** `PagedResult<BookingListItem>`.
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ ISO-8601. Enums cross as their stable string codes.
|
|||||||
`payout_eligible_at`, and — when all sessions are settled — completes the booking + sets
|
`payout_eligible_at`, and — when all sessions are settled — completes the booking + sets
|
||||||
`dispute_window_ends_at`. **Failure:** `400` no open check-in, `409` not checkout-able.
|
`dispute_window_ends_at`. **Failure:** `400` no open check-in, `409` not checkout-able.
|
||||||
|
|
||||||
### `GET api/v1/booking_sessions/today?date=&page=&page_size=`
|
### `GET api/v1/booking_sessions/today?date=&page=&pageSize=`
|
||||||
- **Purpose:** the nurse's sessions for a day (default all), with check-in/out CTA state. **Auth:** nurse,
|
- **Purpose:** the nurse's sessions for a day (default all), with check-in/out CTA state. **Auth:** nurse,
|
||||||
tenancy-scoped. **Success:** `PagedResult<BookingSessionListItem>`.
|
tenancy-scoped. **Success:** `PagedResult<BookingSessionListItem>`.
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ ISO-8601. Enums cross as their stable string codes.
|
|||||||
- **Purpose:** cancel a single un-started session · **Rate-limited:** yes. Snapshots the policy + computes the
|
- **Purpose:** cancel a single un-started session · **Rate-limited:** yes. Snapshots the policy + computes the
|
||||||
session's refundable share. **Failure:** `409` if the session already started.
|
session's refundable share. **Failure:** `409` if the session already started.
|
||||||
|
|
||||||
### `GET api/v1/admin_evv/list?type=mismatch|no_show&page=&page_size=`
|
### `GET api/v1/admin_evv/list?type=mismatch|no_show&page=&pageSize=`
|
||||||
- **Purpose:** admin EVV-review queue. **Auth:** admin policy · **Rate-limited:** yes. **Success:**
|
- **Purpose:** admin EVV-review queue. **Auth:** admin policy · **Rate-limited:** yes. **Success:**
|
||||||
`PagedResult<AdminEvvItem>`.
|
`PagedResult<AdminEvvItem>`.
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,8 @@
|
|||||||
|
|
||||||
## Public catalog browse — `CatalogController` (no auth)
|
## Public catalog browse — `CatalogController` (no auth)
|
||||||
|
|
||||||
### `GET api/v1/catalog/categories?page=&page_size=`
|
### `GET api/v1/catalog/categories?page=&pageSize=`
|
||||||
- Active categories ordered by `sortOrder`, **paginated** (default `page_size` 50, max 100). Cached. `data`:
|
- Active categories ordered by `sortOrder`, **paginated** (default `pageSize` 50, max 100). Cached. `data`:
|
||||||
`PagedResult<ServiceCategoryDto>`.
|
`PagedResult<ServiceCategoryDto>`.
|
||||||
|
|
||||||
### `GET api/v1/catalog/option_groups?category_id={id}`
|
### `GET api/v1/catalog/option_groups?category_id={id}`
|
||||||
@@ -89,7 +89,7 @@ Every write **invalidates the catalog cache**. Both labels required (`nameFa`/`n
|
|||||||
### `POST api/v1/nurse_variants/set_active/{id}`
|
### `POST api/v1/nurse_variants/set_active/{id}`
|
||||||
- **Body:** `{ isActive }`. Deactivate/reactivate — **never hard-delete**. `data`: `true`. `404` if not owned.
|
- **Body:** `{ isActive }`. Deactivate/reactivate — **never hard-delete**. `data`: `true`. `404` if not owned.
|
||||||
|
|
||||||
### `GET api/v1/nurse_variants/list?page=&page_size=`
|
### `GET api/v1/nurse_variants/list?page=&pageSize=`
|
||||||
- The nurse's own offerings — **active and inactive**, active-first, paginated. `data`:
|
- The nurse's own offerings — **active and inactive**, active-first, paginated. `data`:
|
||||||
`PagedResult<VariantDto>`.
|
`PagedResult<VariantDto>`.
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
**Status:** live as of backend-phase-1 · **Frontend consumer:** frontend-phase-f14 (notification center) / frontend-phase-f15 (admin config/holidays/audit/alerts)
|
**Status:** live as of backend-phase-1 · **Frontend consumer:** frontend-phase-f14 (notification center) / frontend-phase-f15 (admin config/holidays/audit/alerts)
|
||||||
|
|
||||||
All responses are the standard `OperationResult`→`ApiResult` envelope (camelCase body, snake_case URLs).
|
All responses are the standard `OperationResult`→`ApiResult` envelope (camelCase body, snake_case URLs).
|
||||||
Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-based) + `page_size`
|
Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-based) + `pageSize`
|
||||||
(default 50, max 100) — bound from the query string; derive exact casing from `swagger.v1.json`.
|
(default 50, max 100) — bound from the query string; derive exact casing from `swagger.v1.json`.
|
||||||
|
|
||||||
## Enums used
|
## Enums used
|
||||||
@@ -25,7 +25,7 @@ Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-
|
|||||||
|
|
||||||
### `GET api/v1/platform_config/get_platform_configs`
|
### `GET api/v1/platform_config/get_platform_configs`
|
||||||
- **Purpose:** list config rows. **Auth:** admin (DynamicPermission). **Rate-limited:** no.
|
- **Purpose:** list config rows. **Auth:** admin (DynamicPermission). **Rate-limited:** no.
|
||||||
- **Query:** `page`, `page_size`.
|
- **Query:** `page`, `pageSize`.
|
||||||
- **200 `data`:** `PagedResult<PlatformConfigDto>` — `{ items:[{ key, value, dataType, description }], total, page, pageSize }`.
|
- **200 `data`:** `PagedResult<PlatformConfigDto>` — `{ items:[{ key, value, dataType, description }], total, page, pageSize }`.
|
||||||
|
|
||||||
### `POST api/v1/platform_config/update_platform_config`
|
### `POST api/v1/platform_config/update_platform_config`
|
||||||
@@ -36,7 +36,7 @@ Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-
|
|||||||
|
|
||||||
### `GET api/v1/platform_config/get_config_change_history`
|
### `GET api/v1/platform_config/get_config_change_history`
|
||||||
- **Purpose:** the audited change history for one key (from the append-only trail). **Auth:** admin.
|
- **Purpose:** the audited change history for one key (from the append-only trail). **Auth:** admin.
|
||||||
- **Query:** `key` (required), `page`, `page_size`.
|
- **Query:** `key` (required), `page`, `pageSize`.
|
||||||
- **200 `data`:** `PagedResult<ConfigChangeDto>` — `{ items:[{ id, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first.
|
- **200 `data`:** `PagedResult<ConfigChangeDto>` — `{ items:[{ id, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -44,7 +44,7 @@ Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-
|
|||||||
## Admin — Holidays (`holidays` controller, `[Authorize(DynamicPermission)]`)
|
## Admin — Holidays (`holidays` controller, `[Authorize(DynamicPermission)]`)
|
||||||
|
|
||||||
### `GET api/v1/holidays/get_holidays`
|
### `GET api/v1/holidays/get_holidays`
|
||||||
- **Query:** `from` (date, optional), `to` (date, optional), `page`, `page_size`.
|
- **Query:** `from` (date, optional), `to` (date, optional), `page`, `pageSize`.
|
||||||
- **200 `data`:** `PagedResult<HolidayDto>` — `{ items:[{ id, holidayDate, nameFa, type, isBankClosed }], … }`, by date.
|
- **200 `data`:** `PagedResult<HolidayDto>` — `{ items:[{ id, holidayDate, nameFa, type, isBankClosed }], … }`, by date.
|
||||||
|
|
||||||
### `POST api/v1/holidays/upsert_holiday`
|
### `POST api/v1/holidays/upsert_holiday`
|
||||||
@@ -60,7 +60,7 @@ Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-
|
|||||||
|
|
||||||
### `GET api/v1/audit/get_audit_trail`
|
### `GET api/v1/audit/get_audit_trail`
|
||||||
- **Purpose:** the immutable trail for one entity. **Auth:** admin.
|
- **Purpose:** the immutable trail for one entity. **Auth:** admin.
|
||||||
- **Query:** `entity_type` (e.g. `PlatformConfig`), `entity_id` (string), `page`, `page_size`.
|
- **Query:** `entity_type` (e.g. `PlatformConfig`), `entity_id` (string), `page`, `pageSize`.
|
||||||
- **200 `data`:** `PagedResult<AuditLogDto>` — `{ items:[{ id, entityType, entityId, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first. **Notes:** read-only; there is no write/update/delete endpoint for audit rows.
|
- **200 `data`:** `PagedResult<AuditLogDto>` — `{ items:[{ id, entityType, entityId, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first. **Notes:** read-only; there is no write/update/delete endpoint for audit rows.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -68,7 +68,7 @@ Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-
|
|||||||
## Admin — Support alerts (`support_alerts` controller, `[Authorize(DynamicPermission)]`, never user-facing)
|
## Admin — Support alerts (`support_alerts` controller, `[Authorize(DynamicPermission)]`, never user-facing)
|
||||||
|
|
||||||
### `GET api/v1/support_alerts/get_support_alerts`
|
### `GET api/v1/support_alerts/get_support_alerts`
|
||||||
- **Query:** `type?`, `status?`, `owner_user_id?`, `page`, `page_size`.
|
- **Query:** `type?`, `status?`, `owner_user_id?`, `page`, `pageSize`.
|
||||||
- **200 `data`:** `PagedResult<SupportAlertDto>` — `{ items:[{ id, type, severity, status, entityType, entityId, bookingId, reviewId, ownerUserId, resolutionNote, resolvedAt, createdAt }], … }`.
|
- **200 `data`:** `PagedResult<SupportAlertDto>` — `{ items:[{ id, type, severity, status, entityType, entityId, bookingId, reviewId, ownerUserId, resolutionNote, resolvedAt, createdAt }], … }`.
|
||||||
|
|
||||||
### `POST api/v1/support_alerts/assign_support_alert`
|
### `POST api/v1/support_alerts/assign_support_alert`
|
||||||
@@ -84,7 +84,7 @@ Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-
|
|||||||
Every endpoint is scoped to the signed-in caller (`ICurrentUser`) — never a body-supplied user id.
|
Every endpoint is scoped to the signed-in caller (`ICurrentUser`) — never a body-supplied user id.
|
||||||
|
|
||||||
### `GET api/v1/notifications/get_notifications`
|
### `GET api/v1/notifications/get_notifications`
|
||||||
- **Query:** `page`, `page_size`.
|
- **Query:** `page`, `pageSize`.
|
||||||
- **200 `data`:** `PagedResult<NotificationDto>` — `{ items:[{ id, type, title, body, dataJson, isRead, readAt, createdAt }], … }`, **unread-first** then newest-first.
|
- **200 `data`:** `PagedResult<NotificationDto>` — `{ items:[{ id, type, title, body, dataJson, isRead, readAt, createdAt }], … }`, **unread-first** then newest-first.
|
||||||
|
|
||||||
### `GET api/v1/notifications/get_unread_count`
|
### `GET api/v1/notifications/get_unread_count`
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ Every write **invalidates the geo cache**. Names required (`nameFa`/`nameEn`); `
|
|||||||
### `DELETE api/v1/nurse_service_areas/remove/{id}`
|
### `DELETE api/v1/nurse_service_areas/remove/{id}`
|
||||||
- Soft-removes the nurse's own area. `data`: `true`. `404` if not owned/absent (existence not leaked).
|
- Soft-removes the nurse's own area. `data`: `true`. `404` if not owned/absent (existence not leaked).
|
||||||
|
|
||||||
### `GET api/v1/nurse_service_areas/list?page=&page_size=`
|
### `GET api/v1/nurse_service_areas/list?page=&pageSize=`
|
||||||
- The nurse's own areas, whole-city first, paginated. `data`: `PagedResult<NurseServiceAreaDto>`.
|
- The nurse's own areas, whole-city first, paginated. `data`: `PagedResult<NurseServiceAreaDto>`.
|
||||||
|
|
||||||
## Customer addresses — `CustomerAddressesController` (authenticated; customer-scoped in handler)
|
## Customer addresses — `CustomerAddressesController` (authenticated; customer-scoped in handler)
|
||||||
@@ -97,7 +97,7 @@ Every write **invalidates the geo cache**. Names required (`nameFa`/`nameEn`); `
|
|||||||
### `DELETE api/v1/customer_addresses/delete/{id}`
|
### `DELETE api/v1/customer_addresses/delete/{id}`
|
||||||
- Soft-deletes the owned address. `data`: `true`. `404` if not owned.
|
- Soft-deletes the owned address. `data`: `true`. `404` if not owned.
|
||||||
|
|
||||||
### `GET api/v1/customer_addresses/list?page=&page_size=`
|
### `GET api/v1/customer_addresses/list?page=&pageSize=`
|
||||||
- The customer's own addresses, **primary first**, paginated, with PII **decrypted for the owner**. `data`:
|
- The customer's own addresses, **primary first**, paginated, with PII **decrypted for the owner**. `data`:
|
||||||
`PagedResult<CustomerAddressDto>`.
|
`PagedResult<CustomerAddressDto>`.
|
||||||
|
|
||||||
@@ -123,3 +123,13 @@ Tehran city id `101`, Tehran districts `1001…1022`; other cities have no distr
|
|||||||
## Changelog
|
## Changelog
|
||||||
- b4 — initial contract: public geo lookups, admin geo CRUD + set_active, nurse service areas, customer
|
- b4 — initial contract: public geo lookups, admin geo CRUD + set_active, nurse service areas, customer
|
||||||
addresses; `IGeocoder` seam; `409` conflict added to the envelope.
|
addresses; `IGeocoder` seam; `409` conflict added to the envelope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-008/009)
|
||||||
|
|
||||||
|
- **`CustomerAddressDto`** gains `provinceId` (joined from `cities.province_id`) so the edit form can
|
||||||
|
prefill the province → city cascade from a server-loaded address.
|
||||||
|
- **`customer_addresses/create` + `update/{id}`** now accept optional `latitude`/`longitude` (both or
|
||||||
|
neither). When present, the user's dropped pin is stored (`geocode_source = user_pin`, preferred for the
|
||||||
|
EVV distance check); when absent the server geocodes as before (`geocode_source = geocoder`).
|
||||||
|
|||||||
@@ -128,3 +128,14 @@
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
- b2 — initial contract (phone-OTP auth, sessions, `/me`, role selection).
|
- b2 — initial contract (phone-OTP auth, sessions, `/me`, role selection).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-002/003)
|
||||||
|
|
||||||
|
- **`RequestOtpResult`** gains `codeLength` (6) and `expiresInSeconds` (60) so the OTP box count + expiry
|
||||||
|
hint are contract-driven.
|
||||||
|
- **`verify_otp` failures** now carry a stable machine `code` on the envelope: `otp_invalid` (wrong **or**
|
||||||
|
expired — collapsed for anti-enumeration) and `otp_locked` with `data: { retryAfterSeconds }` on lockout.
|
||||||
|
The coded-error envelope is `{ isSuccess: false, statusCode: 400, message, code, data? }` (the optional
|
||||||
|
`code` is omitted from every other response).
|
||||||
|
|||||||
@@ -85,3 +85,16 @@ segments are snake_case; responses use the standard `OperationResult`→`ApiResu
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
- b3 — initial contract (nurse/customer profiles, patients, nurse bank accounts + ownership inquiry).
|
- b3 — initial contract (nurse/customer profiles, patients, nurse bank accounts + ownership inquiry).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-005/006/007)
|
||||||
|
|
||||||
|
- **`PatientDto` + create/update** gain `relation` (`parent|spouse|child|self`, nullable) and `conditions`
|
||||||
|
(`string[]` of stable codes; empty, never null). Stored as a nullable code + a JSON array column.
|
||||||
|
- **`NurseProfileDto`** and **`CustomerProfileDto`** gain `avatarUrl` (nullable). `CustomerProfileDto` also
|
||||||
|
gains `preferredLanguage` (nullable); the customer `upsert` body now accepts `firstName`/`lastName`
|
||||||
|
(persisted on the base `users` row) and `preferredLanguage`.
|
||||||
|
- **Avatar upload (multipart):** `POST api/v1/nurse_profiles/avatar` and
|
||||||
|
`POST api/v1/customer_profiles/avatar` — `multipart/form-data` field `file` (JPEG/PNG/WebP, ≤ 5 MB),
|
||||||
|
stored via `IObjectStorage`, returns `{ url }` and persists it on the profile.
|
||||||
|
|||||||
@@ -173,3 +173,23 @@ rebuild). All are `[Authorize(DynamicPermission)]` (admin role passes; other sta
|
|||||||
| Verification queue / refunds / payouts / moderation / config / holidays | their own phase routes | b6/b11/b13/b14/b1 |
|
| Verification queue / refunds / payouts / moderation / config / holidays | their own phase routes | b6/b11/b13/b14/b1 |
|
||||||
|
|
||||||
`support_alerts` are internal-only and must never appear in a user-facing response or join.
|
`support_alerts` are internal-only and must never appear in a user-facing response or join.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-029/030/031/032/033/034/035/036/037)
|
||||||
|
|
||||||
|
**Delivered:**
|
||||||
|
- **REQ-029** `PlatformConfigDto` gains `updatedAt` + `updatedBy` (from the entity audit fields).
|
||||||
|
- **REQ-030** `GET audit/get_audit_trail` filters also by `actorId`, `action`, `from`, `to` (all optional; `entityType`/
|
||||||
|
`entityId` now optional too). **Query params bind camelCase** (`actorId`/`from`/`to`), not `actor_id`.
|
||||||
|
- **REQ-037** `tagCodes: string[]` on `ModerationQueueItemDto`. **REQ-033** `totalIrr` on `InvoiceDto`
|
||||||
|
(= platform commission + BNPL commission + VAT).
|
||||||
|
- **REQ-032** activate/suspend toggle `POST admin/partner-centers/{id}/set-active { isActive }`. **Route casing
|
||||||
|
pinned:** the admin partner-center routes are **kebab-case** (`admin/partner-centers`, `.../set-active`) — an
|
||||||
|
intentional b15 divergence from the `snake_case` convention; the frontend's kebab-case client is CORRECT.
|
||||||
|
|
||||||
|
**Deferred (admin-console polish, documented in the tracker):** REQ-031 (RBAC `admin_roles/list|grant|revoke`),
|
||||||
|
REQ-032 `centers/me` + split portal reads (need the user↔center admin association REQ-038 deferred), REQ-033
|
||||||
|
center-scoped invoice list, REQ-034 verification nurse-queue/signed-url/whole-approve, REQ-035 refund admin
|
||||||
|
preview+approve/reject (the customer preview REQ-020 IS delivered), REQ-036 payout admin preview/holidayShifted/
|
||||||
|
transfer-reference.
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-028)
|
||||||
|
|
||||||
|
- **`TicketSummaryDto`** gains `lastMessageAt` (last non-internal activity) + `unreadCount` (the caller's unread
|
||||||
|
non-internal messages from others; 0 on the admin queue). Unread is computed against the participant's
|
||||||
|
`last_read_at`, **stamped when the participant fetches the user-facing thread**.
|
||||||
|
- **`GET /tickets`** gains a `bookingId` query filter (jump to a booking's coordination ticket).
|
||||||
|
- **`POST /tickets/{id}/messages`** accepts an optional `clientMessageId` — a retried send with the same key is
|
||||||
|
deduplicated (returns the original) and the key is echoed on `PostMessageResult`.
|
||||||
|
- **Message author = role label, not a name** (confirmed intentional, privacy): the DTO carries `senderId`; the
|
||||||
|
client derives the author label from the participant role. No raw identity/name is exposed.
|
||||||
@@ -69,3 +69,11 @@ DEBIT escrow_held gross_price_irr (e.g. 23300000)
|
|||||||
- **The checkout shows gross + commission/VAT breakdown only** — never the internal `account_type`s.
|
- **The checkout shows gross + commission/VAT breakdown only** — never the internal `account_type`s.
|
||||||
- **Payment is idempotent end-to-end**: a retried `initiate` (same `Idempotency-Key`) reuses the attempt; a
|
- **Payment is idempotent end-to-end**: a retried `initiate` (same `Idempotency-Key`) reuses the attempt; a
|
||||||
replayed webhook is a no-op; a repeat `initiate` after capture is a `409`.
|
replayed webhook is a no-op; a repeat `initiate` after capture is a `409`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-018)
|
||||||
|
|
||||||
|
- **Invoice auto-issue on capture/settle:** the commission invoice is now issued automatically when a card
|
||||||
|
capture (`ConfirmPaymentAndPostLedger`) or a BNPL settle first creates the booking — idempotent per
|
||||||
|
booking — so a paying customer's `GET api/v1/invoices/{bookingId}` resolves right away (was admin-only).
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ The response envelope is the standard `{ data, … }`; the shapes below are the
|
|||||||
|
|
||||||
## Shared shapes
|
## Shared shapes
|
||||||
- `EligibleNurseEarningsDto`: `nurseId` (long), `nurseName` (string?), `bookingCount` (int), `grossEarningsIrr` (string), `clawbackAppliedIrr` (string), `netAmountIrr` (string), `hasVerifiedPrimaryIban` (bool).
|
- `EligibleNurseEarningsDto`: `nurseId` (long), `nurseName` (string?), `bookingCount` (int), `grossEarningsIrr` (string), `clawbackAppliedIrr` (string), `netAmountIrr` (string), `hasVerifiedPrimaryIban` (bool).
|
||||||
- `PayoutBatchDto`: `id` (long), `periodStart`/`periodEnd`/`processingDate` (date), `totalAmount` (string), `payoutCount` (int), `status` (`PayoutBatchStatus`), `initiatedByAdminId` (int), `processedAt` (datetime?), `failureNotes` (string?), `createdAt` (datetime).
|
- `PayoutBatchDto`: `id` (long), `periodStart`/`periodEnd`/`processingDate` (date), `totalAmount` (string), `payoutCount` (int), `status` (`PayoutBatchStatus`), `initiatedByAdminId` (int?, **null = system-initiated / scheduled batch** — refinement-phase-7), `processedAt` (datetime?), `failureNotes` (string?), `createdAt` (datetime).
|
||||||
- `PayoutDto`: `id` (long), `nurseId` (long), `nurseName` (string?), `maskedIban` (string, last-4 only), `grossEarningsIrr`/`clawbackAppliedIrr`/`netAmountIrr`/`amount` (string), `bookingCount` (int), `status` (`PayoutStatus`), `transferReference` (string?), `paidAt` (datetime?), `failureReason` (string?), `bookings` (`PayoutBookingLinkDto[]`).
|
- `PayoutDto`: `id` (long), `nurseId` (long), `nurseName` (string?), `maskedIban` (string, last-4 only), `grossEarningsIrr`/`clawbackAppliedIrr`/`netAmountIrr`/`amount` (string), `bookingCount` (int), `status` (`PayoutStatus`), `transferReference` (string?), `paidAt` (datetime?), `failureReason` (string?), `bookings` (`PayoutBookingLinkDto[]`).
|
||||||
- `PayoutBookingLinkDto`: `bookingId` (long), `sessionId` (long?), `payoutAmountIrr` (string).
|
- `PayoutBookingLinkDto`: `bookingId` (long), `sessionId` (long?), `payoutAmountIrr` (string).
|
||||||
- `PayoutBatchDetailDto`: `batch` (`PayoutBatchDto`), `payouts` (`PayoutDto[]`), `total` (int), `page` (int), `pageSize` (int).
|
- `PayoutBatchDetailDto`: `batch` (`PayoutBatchDto`), `payouts` (`PayoutDto[]`), `total` (int), `page` (int), `pageSize` (int).
|
||||||
@@ -97,3 +97,16 @@ The response envelope is the standard `{ data, … }`; the shapes below are the
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
- b13 — initial contract.
|
- b13 — initial contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-025 — nurse earnings)
|
||||||
|
|
||||||
|
- **`GET api/v1/nurse_payouts/earnings_balance`** → `{ pendingTotalIrr, eligibleTotalIrr, paidTotalIrr,
|
||||||
|
clawbackOutstandingIrr, netPayableBalanceIrr }`. `netPayableBalanceIrr` is the **ledger-derived, SIGNED**
|
||||||
|
nurse_payable balance (may be negative = "owed back"; never clamped); `paidTotalIrr` is lifetime, not in the net.
|
||||||
|
- **`GET api/v1/nurse_payouts/earnings?state=&page=&pageSize=`** → `PagedResult<NurseEarningsItem>`; `state`
|
||||||
|
(`pending|eligible|paid|clawback_applied`) is **derived server-side** from `bookings.status` +
|
||||||
|
`dispute_window_ends_at < now` + the payout link + any clawback. Filterable by `state`.
|
||||||
|
- **`GET api/v1/nurse_payouts/{id}`** → nurse-scoped payout detail (batch window + covered bookings).
|
||||||
|
- **`NursePayoutHistoryDto`** gains `failureReason`.
|
||||||
|
|||||||
@@ -65,10 +65,29 @@ ISO-8601; `expected_customer_refund_eta` is a **date** (`"2026-08-24"`).
|
|||||||
Post-payout: `clawbackId` is set (a `pending` `nurse_clawbacks` row + a support alert were created).
|
Post-payout: `clawbackId` is set (a `pending` `nurse_clawbacks` row + a support alert were created).
|
||||||
- **Failure cases:** `400` invalid amount/legs or missing percentage · `401` unauth · `403` non-admin ·
|
- **Failure cases:** `400` invalid amount/legs or missing percentage · `401` unauth · `403` non-admin ·
|
||||||
`404` no captured payment for the booking · `409` **`Σ refunded > captured`** (over-refund) · `400` channel refused.
|
`404` no captured payment for the booking · `409` **`Σ refunded > captured`** (over-refund) · `400` channel refused.
|
||||||
- **Notes:** whole money-path runs under `lock(booking:{id}:refund)`; posts the balanced ledger reversal via
|
- **Notes:** whole money-path runs under `lock(booking:{id}:refund)`; the refund row is persisted (`approved`)
|
||||||
b10's helper; the `refund_payable ↔ escrow_held` clearing posts immediately for a succeeded card refund and is
|
**before** the external channel executes (crash-window fix), then the balanced ledger reversal posts via b10's
|
||||||
deferred to reconciliation for BNPL/manual. `ticketId` is required only when `refund_ticket_required` config is
|
helper; the `refund_payable ↔ escrow_held` clearing posts immediately for a succeeded card refund and is
|
||||||
on (off until b15). Notifies the customer.
|
**deferred to reconciliation for BNPL/manual** (settled later via `confirm_settlement`, below). `ticketId` is
|
||||||
|
optional — one is auto-opened when omitted (b15), so a refund is always ticket-anchored. Notifies the customer.
|
||||||
|
|
||||||
|
### `POST api/v1/admin_refunds/{id}/confirm_settlement`
|
||||||
|
- **Purpose:** reconciliation confirmed the customer cash-back for a `processing` BNPL/manual refund — transitions
|
||||||
|
it `processing → succeeded`, stamps the settled instant, and posts the deferred `refund_payable ↔ escrow_held`
|
||||||
|
clearing in the same commit. (Also reached automatically by the BNPL provider cash-back callback.)
|
||||||
|
- **Auth:** admin · **Rate-limited:** yes (sensitive) · **Idempotent:** a replay against an already-`succeeded`
|
||||||
|
refund is a no-op success (the clearing never posts twice).
|
||||||
|
- **Request body:** none (id in the route).
|
||||||
|
- **Success `200` (`data`):** `RefundSettlement` — `{ "refundId": 7, "bookingId": 42, "status": "succeeded",
|
||||||
|
"completedAt": "2026-08-12T10:00:00Z" }`.
|
||||||
|
- **Failure:** `404` refund not found · `409` refund not in `processing` (e.g. still `approved`, already `failed`).
|
||||||
|
|
||||||
|
### `POST api/v1/admin_refunds/{id}/mark_failed`
|
||||||
|
- **Purpose:** reconciliation reported the BNPL/manual customer cash-back did **not** land — transitions the
|
||||||
|
`processing` refund to `failed`. No ledger moves (the clearing was never posted for a processing refund).
|
||||||
|
- **Auth:** admin · **Rate-limited:** yes · **Idempotent:** a replay against an already-`failed` refund is a no-op.
|
||||||
|
- **Request body:** `{ "reason": "bank_rejected" }` (optional).
|
||||||
|
- **Success `200` (`data`):** `RefundSettlement` (as above, `status: "failed"`). **Failure:** `404` · `409` not `processing`.
|
||||||
|
|
||||||
### `GET api/v1/admin_refunds?booking_id=&status=&page=&pageSize=`
|
### `GET api/v1/admin_refunds?booking_id=&status=&page=&pageSize=`
|
||||||
- **Purpose:** admin refund worklist — projected + paginated (`page` default 1, `pageSize` default 20 / max 100).
|
- **Purpose:** admin refund worklist — projected + paginated (`page` default 1, `pageSize` default 20 / max 100).
|
||||||
@@ -125,3 +144,29 @@ ISO-8601; `expected_customer_refund_eta` is a **date** (`"2026-08-24"`).
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
- b11 — initial contract (create refund, list refunds, write-off clawback, issue invoice, refund status, get invoice).
|
- b11 — initial contract (create refund, list refunds, write-off clawback, issue invoice, refund status, get invoice).
|
||||||
|
- refinement-phase-6 — added `POST admin_refunds/{id}/confirm_settlement` + `.../mark_failed` (the BNPL/manual
|
||||||
|
`processing → succeeded/failed` settlement, `RefundSettlement` shape), so the deferred `refund_payable ↔
|
||||||
|
escrow_held` clearing is now reachable. Refunds are persisted before the channel call (crash-window fix). The
|
||||||
|
`refund_ticket_required` gate was retired (a refund ticket is always auto-opened). Forward-dep FKs added on
|
||||||
|
`refunds.ticket_id`, `nurse_clawbacks.original_payout_id`/`recovered_in_payout_id`, `invoices.partner_center_id`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-019/020/021 — customer refunds)
|
||||||
|
|
||||||
|
- **`POST api/v1/bookings/{id}/cancel`** (customer) — cancels the booking (freezing the policy snapshot) AND
|
||||||
|
opens its refund in one call → `RefundStatusDto`. Body `{ reasonCategory, reasonNotes?, sessionIds? }`
|
||||||
|
(MVP cancels all un-started sessions; `sessionIds` is accepted for forward-compat).
|
||||||
|
- **`GET api/v1/bookings/{id}/cancellation_policy`** (customer) — pre-cancel disclosure: resolves the
|
||||||
|
applicable policy by **current** lead time + per-session refundability →
|
||||||
|
`{ bookingId, cancellable, cancellationPolicyCode, refundPercentageApplied, feePercentage, refundAmountIrr,
|
||||||
|
feeAmountIrr, refundableAmountIrr, platformFeeRefundedIrr, nursePayoutRefundedIrr, appliesTo, leadTimeLabel,
|
||||||
|
refundChannel, expectedCustomerRefundEta (null in preview), sessions: [{ bookingSessionId, sessionIndex,
|
||||||
|
scheduledDate, refundable, reasonCode }] }`. `refundAmountIrr + feeAmountIrr = refundableAmountIrr`.
|
||||||
|
- **`GET api/v1/refunds/by_booking/{bookingId}`** (customer) — the booking's latest refund status (404 if none).
|
||||||
|
- **`RefundStatusDto`** gains `platformFeeRefundedIrr`, `nursePayoutRefundedIrr`, `refundPercentageApplied`,
|
||||||
|
`cancellationPolicyCode`, `createdAt`, `completedAt` (the fee-leg transparency split).
|
||||||
|
- **Canonical `cancellation_policy_code` set** (seeded, stable — the frontend's `free_24h`/`partial_under_24h`/
|
||||||
|
`customer_no_show` were invented): **`standard_24h`** (customer ≥24h → full refund), **`standard_inside_24h`**
|
||||||
|
(customer <24h → partial), **`nurse_no_show`** (nurse-initiated → full refund + penalty), **`admin_cancellation`**
|
||||||
|
(admin → full refund). Per-session `reasonCode`: **`un_started`** when refundable, else the blocking session status.
|
||||||
|
|||||||
@@ -119,3 +119,19 @@ access rule is enforced in the handler, not just the route policy.
|
|||||||
- **Failure cases:** `401`; `403` no clinical access; `404` patient not found.
|
- **Failure cases:** `401`; `403` no clinical access; `404` patient not found.
|
||||||
- **Notes:** The record is **patient-scoped, not booking-scoped** — a new nurse taking over reads the whole
|
- **Notes:** The record is **patient-scoped, not booking-scoped** — a new nurse taking over reads the whole
|
||||||
history (not just their own booking's notes).
|
history (not just their own booking's notes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-026/027)
|
||||||
|
|
||||||
|
- **`GET api/v1/bookings/{bookingId}/review_eligibility`** → `{ canReview, reason?:
|
||||||
|
not_completed|already_reviewed|not_owner|not_found }`.
|
||||||
|
- **`GET api/v1/bookings/{bookingId}/my_review`** → `{ moderationStatus:
|
||||||
|
pending_moderation|published|hidden|rejected|none, rating?, body?, tagCodes[], createdAt? }`. Masked-author
|
||||||
|
omission on the public list is **intentional** (privacy).
|
||||||
|
- **Family-owned care plan (new entity `usr.PatientCarePlans`):** `GET/PUT api/v1/patients/{patientId}/care_record`
|
||||||
|
→ `{ patientId, medications:[{id,name,dosage?,frequency,timingNote?}], routine:[{id,label,timeOfDay?,note?}],
|
||||||
|
tasks:[{id,label,done}] }`. Read = owner/nurse-with-booking/admin; write = owning customer only.
|
||||||
|
- **`GET api/v1/patients/{patientId}/record_access`** → `{ canView, canEdit, canAppendNote, deniedReason? }`
|
||||||
|
(always 200; non-leaking `not_found`/`not_authorized`).
|
||||||
|
- **Structured `taskResults`** (`[{ label, done }]`) added to the visit-note write body + the history DTO.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
- `nurse_gender` (`male`|`female`, optional) — the same-gender facet.
|
- `nurse_gender` (`male`|`female`, optional) — the same-gender facet.
|
||||||
- `min_price` / `max_price` (long IRR, optional) — inclusive range over the copied `price`.
|
- `min_price` / `max_price` (long IRR, optional) — inclusive range over the copied `price`.
|
||||||
- `price_unit` (enum, optional) — compare like-for-like listings (e.g. only `per_day`).
|
- `price_unit` (enum, optional) — compare like-for-like listings (e.g. only `per_day`).
|
||||||
- `page` (int, default 1), `page_size` (int, default 50, max 100).
|
- `page` (int, default 1), `pageSize` (int, default 50, max 100).
|
||||||
- **Success `200` payload (`data` = `PagedResultOfNurseSearchResultDto`):**
|
- **Success `200` payload (`data` = `PagedResultOfNurseSearchResultDto`):**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
- **Failure cases:** `400` — `service_category_id`/`city_id` missing or ≤ 0, `nurse_gender` not `male`/`female`,
|
- **Failure cases:** `400` — `service_category_id`/`city_id` missing or ≤ 0, `nurse_gender` not `male`/`female`,
|
||||||
`min_price > max_price`, invalid `price_unit`, or `page_size > 100`.
|
`min_price > max_price`, invalid `price_unit`, or `pageSize > 100`.
|
||||||
- **Notes:** returns only `is_searchable = 1` rows. `districtId = null` in a result row means the nurse covers
|
- **Notes:** returns only `is_searchable = 1` rows. `districtId = null` in a result row means the nurse covers
|
||||||
the whole city. No entity is hydrated — the read is a projected, paginated, `AsNoTracking` index scan.
|
the whole city. No entity is hydrated — the read is a projected, paginated, `AsNoTracking` index scan.
|
||||||
|
|
||||||
@@ -103,3 +103,15 @@
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
- b7 — initial contract: public `search/nurses`, admin `admin_search/rebuild_index`.
|
- b7 — initial contract: public `search/nurses`, admin `admin_search/rebuild_index`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-012)
|
||||||
|
|
||||||
|
- **`NurseSearchResultDto`** gains `nurseName` + `avatarUrl` (denormalized onto `nurse_search_index`, so no
|
||||||
|
per-row join) and `distanceKm` (nullable — the covering index carries no coordinate, so it is null today).
|
||||||
|
- **`GET api/v1/nurses/{id}/profile`** (public) — the aggregated discovery detail:
|
||||||
|
`{ nurseId, nurseName, avatarUrl, bio, yearsExperience, averageRating, totalReviews,
|
||||||
|
totalCompletedBookings, isVerified, inoMembership, attributeChips[], services: [{ variantId, displayName,
|
||||||
|
priceIrr, priceUnit, sessionCount? }], latestReview?: { rating, body, authorMasked (null by design),
|
||||||
|
createdAt } }`. No encrypted credential number is ever exposed.
|
||||||
|
|||||||
@@ -124,9 +124,9 @@
|
|||||||
|
|
||||||
## Admin review queue — `AdminVerificationsController` (`[Authorize(DynamicPermission)]`, rate-limited `sensitive`)
|
## Admin review queue — `AdminVerificationsController` (`[Authorize(DynamicPermission)]`, rate-limited `sensitive`)
|
||||||
|
|
||||||
### `GET api/v1/admin_verifications?status=&page=&page_size=`
|
### `GET api/v1/admin_verifications?status=&page=&pageSize=`
|
||||||
- **Purpose:** the review queue — one row per step awaiting attention.
|
- **Purpose:** the review queue — one row per step awaiting attention.
|
||||||
- **Params:** `status` (default `in_review`) + pagination `page`/`page_size`.
|
- **Params:** `status` (default `in_review`) + pagination `page`/`pageSize`.
|
||||||
- **`data`:** `PagedResult<AdminPendingStepDto>`. Documents carry **signed GET URLs**.
|
- **`data`:** `PagedResult<AdminPendingStepDto>`. Documents carry **signed GET URLs**.
|
||||||
|
|
||||||
### `GET api/v1/admin_verifications/{nurseVerificationId}`
|
### `GET api/v1/admin_verifications/{nurseVerificationId}`
|
||||||
@@ -255,3 +255,15 @@ GET /api/v1/nurses/{nurseId}/trust_badge -> { isVerified: true, approvedAt, cr
|
|||||||
`verification_status` / `verification_step_status` / `credential_type` / `verification_method` enums;
|
`verification_status` / `verification_step_status` / `credential_type` / `verification_method` enums;
|
||||||
transactional `is_verified` flip; encrypted-never-serialized `credential_number`; three new mocked vendor
|
transactional `is_verified` flip; encrypted-never-serialized `credential_number`; three new mocked vendor
|
||||||
seams (`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`). Scheduled expiry cron deferred.
|
seams (`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`). Scheduled expiry cron deferred.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-011)
|
||||||
|
|
||||||
|
- **`VerificationStepDto`** gains `isRequired` (mirrors the step-type catalog; an optional step never blocks
|
||||||
|
bookability).
|
||||||
|
- **`POST api/v1/nurse_verification/credential_details`** (nurse) — captures the structured credential
|
||||||
|
fields collected with the uploads: `{ inoNumber (required), specialties: string[], licenseNumber?,
|
||||||
|
issuingAuthority?, holderName?, issuedAt?, expiresAt? }` → `VerificationStatusDto`. Upserts an
|
||||||
|
`ino_membership` (and, if a license number is sent, `moh_competency_license`) `nurse_credentials` row
|
||||||
|
(unverified — admin still decides) and persists `specialties` on the profile. The INO number is encrypted.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -75,9 +75,11 @@ Development environment, so this never affects a deployed build.
|
|||||||
dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
|
dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
|
||||||
```
|
```
|
||||||
|
|
||||||
On boot the API applies all EF migrations and seeds roles + an admin user + a sandbox payment gateway
|
On boot the API applies all EF migrations and seeds roles against the (empty) local DB, then listens on
|
||||||
against the (empty) local DB, then listens on **`https://localhost:5002`** — Swagger at
|
**`https://localhost:5002`** — Swagger at `https://localhost:5002/swagger`. In **Development** it also seeds a
|
||||||
`https://localhost:5002/swagger`.
|
sandbox payment gateway and the demo world (below). It does **not** seed the old `admin`/`qw123321` account
|
||||||
|
anymore (refinement-phase-5); a break-glass admin is created only if you set `Seed:AdminUsername` /
|
||||||
|
`Seed:AdminPassword` (see below), and the day-to-day admin path is the phone-OTP demo admins.
|
||||||
|
|
||||||
In **Development** it additionally runs the **demo-world seeder** (Refinement Phase 1): verified/unverified
|
In **Development** it additionally runs the **demo-world seeder** (Refinement Phase 1): verified/unverified
|
||||||
demo nurses with priced variants + Tehran coverage (and therefore real `nurse_search_index` rows), plus demo
|
demo nurses with priced variants + Tehran coverage (and therefore real `nurse_search_index` rows), plus demo
|
||||||
@@ -110,7 +112,24 @@ endpoint works). Use them to see the real path populated:
|
|||||||
| `09120000003` | nurse | مریم احمدی (female) | **unverified** — not discoverable in search |
|
| `09120000003` | nurse | مریم احمدی (female) | **unverified** — not discoverable in search |
|
||||||
| `09120000010` | customer | سارا محمدی (female) | 2 patients, 1 Tehran address |
|
| `09120000010` | customer | سارا محمدی (female) | 2 patients, 1 Tehran address |
|
||||||
| `09120000011` | customer | رضا حسینی (male) | 1 patient, 1 Tehran address |
|
| `09120000011` | customer | رضا حسینی (male) | 1 patient, 1 Tehran address |
|
||||||
| `admin` / `qw123321` | admin | seeded admin (username+password) | backoffice |
|
| `09120000020` | admin (`super_admin`) | نگار مدیری (female) | full backoffice — **lands on `/admin`**, sees every console incl. RBAC |
|
||||||
|
| `09120000021` | admin (`finance`) | کامران مالی (male) | scoped backoffice — lands on `/admin`, sidebar shows only the money consoles (`useAdminCapabilities` gating) |
|
||||||
|
|
||||||
|
> The old username+password `admin`/`qw123321` account is **no longer auto-seeded** (refinement-phase-5 —
|
||||||
|
> no committed credential). To bootstrap a break-glass username+password admin, set both secrets before boot,
|
||||||
|
> then log in via the API (not the web UI, which is phone-OTP only):
|
||||||
|
> ```bash
|
||||||
|
> cd server/src/API/Baya.Web.Api
|
||||||
|
> dotnet user-secrets set "Seed:AdminUsername" "admin"
|
||||||
|
> dotnet user-secrets set "Seed:AdminPassword" "<a-strong-password>"
|
||||||
|
> ```
|
||||||
|
|
||||||
|
The **phone-OTP admins** (`09120000020` / `09120000021`, refinement-phase-2) are how you reach the `/admin`
|
||||||
|
console through the same web login flow as everyone else — admin sub-roles are server-granted, never
|
||||||
|
self-selectable via `me/select_role`. Log in with either phone exactly like a nurse/customer; role
|
||||||
|
hydration routes you to `/admin`. To reach the **nurse** app, log in as a verified nurse phone
|
||||||
|
(`09120000001`); a fresh customer can also become a nurse in-app (SelectRole → `me/select_role`) and is
|
||||||
|
then routed to `/nurse` after the next `/me`.
|
||||||
|
|
||||||
Prove search works without the frontend: open Swagger →
|
Prove search works without the frontend: open Swagger →
|
||||||
`GET /api/v1/search/nurses?service_category_id=1&city_id=101` returns the two verified nurses' variants;
|
`GET /api/v1/search/nurses?service_category_id=1&city_id=101` returns the two verified nurses' variants;
|
||||||
@@ -129,7 +148,8 @@ Prove search works without the frontend: open Swagger →
|
|||||||
`{ "data": { "phone": "09120000001", "code": "123456" }, ... }`.
|
`{ "data": { "phone": "09120000001", "code": "123456" }, ... }`.
|
||||||
This endpoint returns **404 outside Development** and is superseded by real SMS in
|
This endpoint returns **404 outside Development** and is superseded by real SMS in
|
||||||
[Refinement Phase 8](refinement-phase-8-external-rails.md).
|
[Refinement Phase 8](refinement-phase-8-external-rails.md).
|
||||||
4. Enter the code and submit → you land on the customer home.
|
4. Enter the code and submit → role hydration routes you to the app for your role: a customer to the family
|
||||||
|
home (`/`), a nurse to `/nurse`, an admin to `/admin` (refinement-phase-2).
|
||||||
5. **Verify in DevTools → Network:** `POST /api/v1/auth/request_otp`, `POST /api/v1/auth/verify_otp`, and
|
5. **Verify in DevTools → Network:** `POST /api/v1/auth/request_otp`, `POST /api/v1/auth/verify_otp`, and
|
||||||
`GET /api/v1/me` all return **200** with the `ApiResult` envelope, and there is **no CORS error** in the
|
`GET /api/v1/me` all return **200** with the `ApiResult` envelope, and there is **no CORS error** in the
|
||||||
console. That is the first real authenticated request between the two projects.
|
console. That is the first real authenticated request between the two projects.
|
||||||
@@ -138,8 +158,17 @@ Prove search works without the frontend: open Swagger →
|
|||||||
|
|
||||||
## Good to know
|
## Good to know
|
||||||
|
|
||||||
- **The API speaks HTTP/2** (Kestrel `Protocols: Http2`, for gRPC). Browsers negotiate h2-over-TLS
|
- **The API speaks HTTP/1.1 and HTTP/2** (Kestrel `Protocols: Http1AndHttp2`, refinement-phase-5 — the
|
||||||
automatically, so `fetch` just works; for `curl` add `--http2`.
|
previous HTTP/2-only default broke non-TLS HTTP/1.1 hops). Over TLS the client negotiates h2 via ALPN, so
|
||||||
|
gRPC and `fetch` both work; plain-HTTP hops fall back to HTTP/1.1.
|
||||||
|
- **Secrets fail fast.** On a fresh clone with no user-secrets the API refuses to start with
|
||||||
|
`Refusing to start: required secret configuration is missing…` — set the connection-string user-secret
|
||||||
|
(step 3) and boot again. Deployed environments must additionally supply real `IdentitySettings` JWE keys
|
||||||
|
and `Seams:FieldEncryption` keys (Development uses dev-only defaults from `appsettings.Development.json`).
|
||||||
|
- **Enable the secret-scan pre-commit hook** once per clone so a stray credential can't be committed:
|
||||||
|
`git config core.hooksPath .githooks` (see [`.githooks/README.md`](../../../.githooks/README.md)).
|
||||||
|
- **Behind a reverse proxy**, list its address in `ForwardedHeaders:KnownProxies` (or a CIDR in
|
||||||
|
`:KnownNetworks`) so the rate limiter partitions on the real client IP, not the proxy's.
|
||||||
- **Only `auth` is real by default.** 21 of 22 client service domains default to an in-browser mock
|
- **Only `auth` is real by default.** 21 of 22 client service domains default to an in-browser mock
|
||||||
(`USE_*_MOCK = true`); the home, search, bookings, etc. are fake in-memory data until Refinement Phase 4.
|
(`USE_*_MOCK = true`); the home, search, bookings, etc. are fake in-memory data until Refinement Phase 4.
|
||||||
- **The DB self-migrates + self-seeds**, so pointing at an empty local instance is enough — including the
|
- **The DB self-migrates + self-seeds**, so pointing at an empty local instance is enough — including the
|
||||||
@@ -173,6 +202,7 @@ world, wipe the volume (`docker compose down -v`) and boot again.
|
|||||||
| Symptom | Fix |
|
| Symptom | Fix |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Browser: `net::ERR_CERT_AUTHORITY_INVALID` on `:5002` | Run `dotnet dev-certs https --trust` (setup step 1). |
|
| Browser: `net::ERR_CERT_AUTHORITY_INVALID` on `:5002` | Run `dotnet dev-certs https --trust` (setup step 1). |
|
||||||
|
| API startup: `Refusing to start: required secret configuration is missing…` | The connection-string user-secret isn't set (or still the placeholder). Do setup step 3. |
|
||||||
| API startup: `Login failed for user 'sa'` / connect timeout | DB not up or wrong password — check `docker compose ps` and that the user-secrets password matches `docker-compose.yml`. |
|
| API startup: `Login failed for user 'sa'` / connect timeout | DB not up or wrong password — check `docker compose ps` and that the user-secrets password matches `docker-compose.yml`. |
|
||||||
| Console: `...has been blocked by CORS policy` | `UseCors` missing/mis-ordered, or the browser origin isn't in `Cors:AllowedOrigins`. It must sit after `UseRouting` and before the rate limiter. |
|
| Console: `...has been blocked by CORS policy` | `UseCors` missing/mis-ordered, or the browser origin isn't in `Cors:AllowedOrigins`. It must sit after `UseRouting` and before the rate limiter. |
|
||||||
| `dotnet user-secrets` errors with "could not find UserSecretsId" | Run it from `server/src/API/Baya.Web.Api` (the project with `<UserSecretsId>`). |
|
| `dotnet user-secrets` errors with "could not find UserSecretsId" | Run it from `server/src/API/Baya.Web.Api` (the project with `<UserSecretsId>`). |
|
||||||
|
|||||||
@@ -9,6 +9,22 @@ is invented; "not needed" claims are backed by the absence of the package/code.
|
|||||||
(app DB + log DB); everything else (18 seams) is an in-process mock, so "deployment" today is one container
|
(app DB + log DB); everything else (18 seams) is an in-process mock, so "deployment" today is one container
|
||||||
+ one database — and the table below is the roadmap of what must exist as each seam goes real.
|
+ one database — and the table below is the roadmap of what must exist as each seam goes real.
|
||||||
|
|
||||||
|
> **Refinement-phase-8 update (2026-07-13):** services **8–14** and **16** below now have a **real HTTP adapter
|
||||||
|
> shipped behind their seam**, config-selected by a per-rail `Seams:*:Provider` selector (mock stays the default).
|
||||||
|
> "Depends via" now points at a real client, not just a planned one — provisioning the vendor account + credential
|
||||||
|
> and flipping the selector turns each on, no code change. What is still genuinely absent (no adapter): **Redis**
|
||||||
|
> (5), **Elasticsearch** (17), and the LLM **review-moderation classifier** (15, optional). MoH/INO/eNamad (16)
|
||||||
|
> stay **manual by design**. See the mocks-registry refinement-phase-8 banner for the provider tokens per rail.
|
||||||
|
>
|
||||||
|
> **Refinement-phase-9 update (2026-07-13) — observability (service 4):** the two overlapping metric stacks were
|
||||||
|
> consolidated onto **one OpenTelemetry stack** — prometheus-net was removed; metrics are scraped at `/metrics` via
|
||||||
|
> the OTel Prometheus exporter, and **distributed tracing** (ASP.NET Core + EF Core) was added, exporting **OTLP
|
||||||
|
> only when `OpenTelemetry:Otlp:Endpoint` is configured** (an MVP with Prometheus alone is unchanged). A request's
|
||||||
|
> `ApiResult.requestId` is its W3C trace id (support ↔ trace 1:1). Health checks split into `/healthz/live`
|
||||||
|
> (process) vs `/healthz/ready` (app DB + log DB [deployed] + an object-storage write probe); `/HealthCheck` stays
|
||||||
|
> as the aggregate. An **OTLP collector** (Grafana Tempo / Jaeger / OTEL Collector) becomes the optional new
|
||||||
|
> observability service when trace export is turned on. Elasticsearch (17) is still deferred.
|
||||||
|
|
||||||
## Service inventory
|
## Service inventory
|
||||||
|
|
||||||
| # | Service | Purpose | Depends via (seam / config) | MVP? | Registry row |
|
| # | Service | Purpose | Depends via (seam / config) | MVP? | Registry row |
|
||||||
@@ -16,10 +32,10 @@ is invented; "not needed" claims are backed by the absence of the package/code.
|
|||||||
| 1 | **SQL Server** (app DB `Baya`) | System of record — 12 schemas (`usr ops geo catalog verif search booking payments payouts reviews messaging partner`) | EF Core; `ConnectionStrings:SqlServer` | **Required now** | — |
|
| 1 | **SQL Server** (app DB `Baya`) | System of record — 12 schemas (`usr ops geo catalog verif search booking payments payouts reviews messaging partner`) | EF Core; `ConnectionStrings:SqlServer` | **Required now** | — |
|
||||||
| 2 | **SQL Server** (log DB `Baya_Logs`) | Serilog sink in deployed envs (Warning+, auto-created `log.LogEvents`) | `ConnectionStrings:logDb` | **Required now** (deployed) | — |
|
| 2 | **SQL Server** (log DB `Baya_Logs`) | Serilog sink in deployed envs (Warning+, auto-created `log.LogEvents`) | `ConnectionStrings:logDb` | **Required now** (deployed) | — |
|
||||||
| 3 | **Reverse proxy / TLS** (nginx·caddy·traefik) | TLS termination, HTTP/1.1+2, forwarded headers | Kestrel config; JWE bearer | **Required now** | — |
|
| 3 | **Reverse proxy / TLS** (nginx·caddy·traefik) | TLS termination, HTTP/1.1+2, forwarded headers | Kestrel config; JWE bearer | **Required now** | — |
|
||||||
| 4 | **Prometheus** (+ Grafana) | Scrapes `/metrics`; health forwarded to gauges | `UseMetricServer` + OTel exporter | **Recommended now** | — |
|
| 4 | **Prometheus** (+ Grafana) · optional **OTLP collector** | Scrapes `/metrics` (one OTel stack, refinement-phase-9); traces export OTLP when configured | OTel metrics + tracing; `OpenTelemetry:Otlp:Endpoint` | **Recommended now** (collector optional) | — |
|
||||||
| 5 | **Redis** | `ICacheService` + `IDistributedLock` (money-path mutex) | `Seams:*` (keys TBD; none today) | Before >1 API instance | rows 14, 42 |
|
| 5 | **Redis** | `ICacheService` + `IDistributedLock` (money-path mutex) | `Seams:*` (keys TBD; none today) | Before >1 API instance | rows 14, 42 |
|
||||||
| 6 | **MinIO / S3 / ArvanCloud** | `IObjectStorage` — verification docs, avatars (REQ-006), invoice PDFs | `Seams:ObjectStorage:*` | Before real verification | row 13 |
|
| 6 | **MinIO / S3 / ArvanCloud** | `IObjectStorage` — verification docs, avatars (REQ-006), invoice PDFs | `Seams:ObjectStorage:*` | Before real verification | row 13 |
|
||||||
| 7 | **Job scheduler** (Hangfire/Quartz, in-app on SQL) | The deferred crons: payout batch, expiry scan, no-show, Moadian poll | hosted services (no interface exists) | Before unattended ops | row 26 |
|
| 7 | **Job scheduler** — in-process, SQL only (refinement-phase-7 **done**) | The recurring crons: booking-expiry, notification-retention, credential-expiry scan, no-show sweep, weekly payout-batch generation (Moadian/refund-settlement poll = Phase 8) | `RecurringJobSchedulerHostedService` + `IRecurringJob`s | **Done for single instance** (no new infra) | row 26 |
|
||||||
| 8 | **SMS gateway** (Kavenegar·Ghasedak·SMS.ir) | `ISmsSender` — OTP delivery (login is impossible without it) | `Seams:Sms:*` (to be added) | **Launch-critical** | row 12 |
|
| 8 | **SMS gateway** (Kavenegar·Ghasedak·SMS.ir) | `ISmsSender` — OTP delivery (login is impossible without it) | `Seams:Sms:*` (to be added) | **Launch-critical** | row 12 |
|
||||||
| 9 | **PSP / IPG + Shaparak** (ZarinPal·Sadad·Vandar·Jibit) | `IPaymentProvider` + `IWebhookVerifier` + `ISettlementSplitProvider` (تسهیم) | encrypted `payment_gateways.config_json` | Real payments | rows 39–41 |
|
| 9 | **PSP / IPG + Shaparak** (ZarinPal·Sadad·Vandar·Jibit) | `IPaymentProvider` + `IWebhookVerifier` + `ISettlementSplitProvider` (تسهیم) | encrypted `payment_gateways.config_json` | Real payments | rows 39–41 |
|
||||||
| 10 | **BNPL providers** (SnappPay·Digipay) | `IBnplProvider` / `IBnplProviderResolver` / `ICurrencyNormalizer` | `Seams:Bnpl:*`, `Seams:Currency:*`, gateway config | Optional at launch | rows 46–47 |
|
| 10 | **BNPL providers** (SnappPay·Digipay) | `IBnplProvider` / `IBnplProviderResolver` / `ICurrencyNormalizer` | `Seams:Bnpl:*`, `Seams:Currency:*`, gateway config | Optional at launch | rows 46–47 |
|
||||||
@@ -126,8 +142,9 @@ flowchart LR
|
|||||||
Both DBs fit one instance; `Baya_Logs` can move later.
|
Both DBs fit one instance; `Baya_Logs` can move later.
|
||||||
- **Config:** `ConnectionStrings:SqlServer`, `ConnectionStrings:logDb` — **rotate + externalize first**
|
- **Config:** `ConnectionStrings:SqlServer`, `ConnectionStrings:logDb` — **rotate + externalize first**
|
||||||
(plan §1.1; live `sa` credentials are committed today).
|
(plan §1.1; live `sa` credentials are committed today).
|
||||||
- **Health/readiness:** the app's only health check (`/HealthCheck`,
|
- **Health/readiness (refinement-phase-9 — §7.2 landed):** split `/healthz/live` (process) vs `/healthz/ready`
|
||||||
`Monitoring/Configurations/HealthCheckConfigurations.cs:17`); `logDb` has none (plan §7.2). Boot runs
|
(app DB + `logDb` [deployed] + object-storage write probe); `/HealthCheck` stays as the aggregate
|
||||||
|
(`Monitoring/Configurations/HealthCheckConfigurations.cs`). Boot runs
|
||||||
`MigrateAsync` + 3 seeders (`Program.cs:99-104`) → the login needs DDL rights and concurrent multi-node
|
`MigrateAsync` + 3 seeders (`Program.cs:99-104`) → the login needs DDL rights and concurrent multi-node
|
||||||
boot races (plan §4.3).
|
boot races (plan §4.3).
|
||||||
|
|
||||||
@@ -142,13 +159,16 @@ flowchart LR
|
|||||||
- **Default:** caddy 2 / nginx 1.27; terminate TLS, h2 to clients, HTTP/1.1 (or h2c) upstream once §1.5
|
- **Default:** caddy 2 / nginx 1.27; terminate TLS, h2 to clients, HTTP/1.1 (or h2c) upstream once §1.5
|
||||||
lands.
|
lands.
|
||||||
|
|
||||||
### 4 · Prometheus (+ Grafana)
|
### 4 · Prometheus (+ Grafana) · optional OTLP collector
|
||||||
|
|
||||||
- **Evidence:** `/metrics` via prometheus-net `UseMetricServer` + OTel `AddPrometheusExporter` (two stacks —
|
- **Evidence (refinement-phase-9 — §7.1 landed):** **one** OpenTelemetry stack — metrics scraped at `/metrics` via
|
||||||
consolidate, plan §7.1) at `Monitoring/Configurations/PrometheusMetricsConfigurations.cs:11` and
|
`UseOpenTelemetryPrometheusScrapingEndpoint()` (`Monitoring/Configurations/PrometheusMetricsConfigurations.cs`),
|
||||||
`OpenTelemetryConfigurations.cs:21`; health forwarded (`HealthCheckConfigurations.cs:18`).
|
plus `WithTracing` (ASP.NET Core + EF Core) exporting **OTLP only when `OpenTelemetry:Otlp:Endpoint` is set**
|
||||||
- **Default:** `prom/prometheus:v2.53` + `grafana/grafana:11`. No tracing backend exists yet (metrics-only);
|
(`OpenTelemetryConfigurations.cs`). The duplicate prometheus-net stack (`UseMetricServer`/`UseHttpMetrics`/
|
||||||
an OTLP collector becomes relevant with plan §7.1.
|
`ForwardToPrometheus` + packages) was removed. A request's `ApiResult.requestId` is its W3C trace id.
|
||||||
|
- **Default:** `prom/prometheus:v2.53` + `grafana/grafana:11`. A **tracing backend / OTLP collector** (Grafana
|
||||||
|
Tempo · Jaeger · OpenTelemetry Collector) is the optional new service — set `OpenTelemetry:Otlp:Endpoint` at it to
|
||||||
|
turn trace + metric export on; Prometheus-scrape-only is an acceptable MVP.
|
||||||
|
|
||||||
### 5 · Redis
|
### 5 · Redis
|
||||||
|
|
||||||
@@ -262,19 +282,25 @@ flowchart LR
|
|||||||
|
|
||||||
## Deployment notes (from the code, not aspiration)
|
## Deployment notes (from the code, not aspiration)
|
||||||
|
|
||||||
1. **Boot = migrate + seed.** Every non-Testing start applies EF migrations and seeds roles, the
|
1. **Boot ≠ migrate (refinement-phase-7).** DDL is a separate deploy step — `dotnet run -- migrate` applies
|
||||||
`admin`/`qw123321` user, and an **active sandbox ZarinPal gateway** (`Program.cs:99-104`). Until plan
|
migrations + idempotent seeders then exits. **Development** boot still migrates + seeds (incl. the
|
||||||
§1.3/§1.4/§4.3 land: single-instance start-up, DDL-privileged login, and clean up the seeded credentials
|
Development-only sandbox gateway + demo world) for convenience; **deployed** boot only *checks* the schema is
|
||||||
per environment.
|
current (`EnsureSchemaUpToDateAsync`, fail-fast on a pending migration) and seeds roles/break-glass admin. So
|
||||||
|
multi-instance boots no longer race on DDL and the runtime login needs no permanent DDL rights (plan §1.3/§1.4
|
||||||
|
also landed — no committed `admin`/`qw123321`; sandbox gateway is Development-only).
|
||||||
2. **Environment files:** `appsettings.json` ≡ `appsettings.Development.json` (byte-identical); **no
|
2. **Environment files:** `appsettings.json` ≡ `appsettings.Development.json` (byte-identical); **no
|
||||||
Production/Staging file exists.** All non-secret env differences ride on ~14 `Seams:*` groups whose
|
Production/Staging file exists.** All non-secret env differences ride on ~14 `Seams:*` groups whose
|
||||||
defaults live in code (`SeamOptions.cs`), not in config files.
|
defaults live in code (`SeamOptions.cs`), not in config files.
|
||||||
3. **HTTP posture:** HTTP/2-only Kestrel default (plan §1.5), gRPC plugin + reflection always on
|
3. **HTTP posture:** mixed `Http1AndHttp2` Kestrel (refinement-phase-5), so gRPC shares the listener via ALPN;
|
||||||
(plan §7.5), TLS required for JWE sanity.
|
gRPC **reflection is Development-only** (refinement-phase-9 §7.5); TLS required for JWE sanity.
|
||||||
4. **Single-instance constraints today:** in-memory cache, in-proc money lock, in-proc sweeps, per-instance
|
4. **Single-instance constraints today:** in-memory cache, in-proc money lock, the in-proc recurring-job
|
||||||
rate-limit buckets. Scaling past one instance requires plan §4.2 (Redis) + §4.3 (migrations) first — the
|
scheduler (refinement-phase-7 — its per-tick lock is that same in-proc seam), per-instance rate-limit buckets.
|
||||||
DB uniques keep money *correct* either way, but locks/cache/limits silently degrade.
|
§4.3 (migrations split from boot) **landed**; scaling past one instance still requires §4.2 (Redis for the
|
||||||
5. **Logs:** deployed envs write Warning+ to `Baya_Logs` only (Information dropped — plan §7.3); dev writes
|
shared cache + the cross-instance lock the scheduler/money path use) first — the DB uniques keep money
|
||||||
console + `logs/log.json`.
|
*correct* either way, but locks/cache/limits/scheduler-de-dup silently degrade.
|
||||||
|
5. **Logs (refinement-phase-9 — §7.3 landed):** deployed envs write **Information+** to `Baya_Logs` (framework
|
||||||
|
categories held at Warning); **no PII/secrets** (the OTP code is no longer logged in any env). The dead
|
||||||
|
Elasticsearch sink + package were removed; log-table retention is an ops/DBA task (or ship logs to the OTLP
|
||||||
|
collector). Dev writes console + `logs/log.json`.
|
||||||
6. **Client:** the Next.js app needs `NEXT_PUBLIC_API_URL` pointing at the proxy; wire casing camelCase;
|
6. **Client:** the Next.js app needs `NEXT_PUBLIC_API_URL` pointing at the proxy; wire casing camelCase;
|
||||||
snake_case routes.
|
snake_case routes.
|
||||||
|
|||||||
@@ -12,6 +12,43 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
|
|||||||
- **Notes for frontend:** <anything load-bearing>
|
- **Notes for frontend:** <anything load-bearing>
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
## refinement-phase-9 — Observability, ops hardening, docs honesty & scale-later — 2026-07-13
|
||||||
|
- **Shipped:** **one OpenTelemetry stack** (metrics scraped at `/metrics` + **tracing** ASP.NET Core/EF, opt-in
|
||||||
|
OTLP via `OpenTelemetry:Otlp:Endpoint`; prometheus-net removed) — `ApiResult.requestId` = W3C trace id.
|
||||||
|
**Health split** `/healthz/live` vs `/healthz/ready` (app DB + logDb[deployed] + object-storage write probe);
|
||||||
|
`/HealthCheck` aggregate kept. **Prod logs Information+** with **no PII** (OTP code no longer logged) + dead
|
||||||
|
Elasticsearch sink/package removed. **`AuditLogRetentionJob`** (`IRecurringJob`, two-tier legal retention).
|
||||||
|
**`TicketMessage.Body` encrypted at rest** (`IFieldEncryptor`; column → nvarchar(max)). **gRPC reflection
|
||||||
|
Development-only.** Docs reconciled (mocks-registry stale rows pruned; deferrals 9.7–9.11 recorded with pull-
|
||||||
|
triggers). Migration `RefinementPhase9TicketBodyEncryptionAndAuditRetention` (Body widen + 3 config seed rows).
|
||||||
|
- **Contracts:** none changed — no wire/shape change (observability + docs + at-rest encryption only).
|
||||||
|
- **Mocked:** none new. Deferred (recorded, not gaps): Elasticsearch `INurseSearch`, SMS/push
|
||||||
|
`INotificationDispatcher`, analytics pipeline, holiday feed, 8 product tables — each with a written pull-trigger.
|
||||||
|
- **Gate:** build clean (0 new warnings) / **407 tests pass** (402 prior + 5 new: audit-retention, ticket-body
|
||||||
|
encryption, liveness).
|
||||||
|
- **Handoff:** backend/handoff/after-refinement-phase-9.md
|
||||||
|
- **Notes for frontend:** no client-facing change. Ticket message bodies are now encrypted at rest server-side
|
||||||
|
(the thread read still returns plaintext — unchanged wire). A request's `requestId` is a real trace id useful
|
||||||
|
for support correlation.
|
||||||
|
|
||||||
|
## refinement-phase-7 — Unattended ops: scheduler, locking & multi-instance readiness — 2026-07-13
|
||||||
|
- **Shipped:** one in-process **`RecurringJobSchedulerHostedService`** + the **`IRecurringJob`** seam
|
||||||
|
(`Persistence/Services/Scheduling/`) replacing the two `PeriodicTimer` hosted services and scheduling the
|
||||||
|
previously admin-manual crons — `booking_request_expiry`, `notification_retention`, `verification_expiry_scan`,
|
||||||
|
`no_show_sweep`, `weekly_payout_generation` — each reading its seeded cadence key; admin triggers stay overrides.
|
||||||
|
Payout **generation** is scheduled (system-initiated `draft`); **processing** stays admin-only
|
||||||
|
(`NursePayoutBatch.InitiatedByAdminId` now nullable = system; `SystemInitiated` flag is scheduler-only,
|
||||||
|
controller-neutralized). **Migrations split from boot:** `dotnet run -- migrate` one-shot + deployed-boot schema
|
||||||
|
*check* (`EnsureSchemaUpToDateAsync`); Dev keeps migrate-on-boot. **No Redis/Hangfire added** — documented as the
|
||||||
|
>1-instance scale-out gate; the scheduler's per-tick `IDistributedLock` is that swap point.
|
||||||
|
- **Contracts:** `PayoutBatchDto.initiatedByAdminId` nullable — `dev/contracts/domains/payouts.md` + openapi
|
||||||
|
snapshot refreshed (yes).
|
||||||
|
- **Mocked:** none new. Redis = scale gate; Moadian/refund-settlement poll = Phase 8 jobs (see mocks-registry).
|
||||||
|
- **Gate:** build clean (0 new warnings) / **402 tests pass** (396 prior + 6 new scheduling tests).
|
||||||
|
- **Handoff:** backend/handoff/after-refinement-phase-7.md
|
||||||
|
- **Notes for frontend:** admin payout batch `initiatedByAdminId` can be `null` (system/scheduled batch) — render
|
||||||
|
a "system"/"scheduled" label rather than assuming an admin id.
|
||||||
|
|
||||||
## refinement-phase-1 — Database: local-dev story, demo seed & migration hygiene — 2026-07-13
|
## refinement-phase-1 — Database: local-dev story, demo seed & migration hygiene — 2026-07-13
|
||||||
- **Shipped (no migration, no endpoint, no contract change):** Development-gated **demo-world seeder** —
|
- **Shipped (no migration, no endpoint, no contract change):** Development-gated **demo-world seeder** —
|
||||||
`Persistence/Services/Seeding/DemoWorldSeeder.cs` + `DemoWorldDefinitions.cs`, scoped-registered in
|
`Persistence/Services/Seeding/DemoWorldSeeder.cs` + `DemoWorldDefinitions.cs`, scoped-registered in
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# After refinement-phase-6 — Money-path correctness completion
|
||||||
|
|
||||||
|
**Track:** backend (money path). Gate: `dotnet build` 0 new warnings · `dotnet test` 396 pass (+13). Migration
|
||||||
|
`RefinementPhase6MoneyFks` scaffolded (additive: 3 FK sets + invoice index + delete of a dead config seed row).
|
||||||
|
|
||||||
|
## What's now live (new endpoints — admin)
|
||||||
|
- **`POST api/v1/admin_refunds/{id}/confirm_settlement`** — settle a `processing` BNPL/manual refund
|
||||||
|
(`processing → succeeded`, posts the deferred `refund_payable ↔ escrow_held` clearing). Idempotent.
|
||||||
|
- **`POST api/v1/admin_refunds/{id}/mark_failed`** — fail a `processing` refund (no ledger). Body
|
||||||
|
`{ "reason": "..." }` (optional). Idempotent.
|
||||||
|
- Both return `RefundSettlement` `{ refundId, bookingId, status, completedAt }`. Contract:
|
||||||
|
`dev/contracts/domains/refunds-invoices.md`; snapshot `swagger.v1.json` refreshed.
|
||||||
|
|
||||||
|
## What changed under the hood (no client-visible shape change)
|
||||||
|
- The **BNPL provider cash-back callback** now auto-settles the matching `processing` refund (a
|
||||||
|
`refund/revert completed|confirmed|settled` / `cashback` event) — so a real BNPL revert reaches `succeeded`
|
||||||
|
without an admin click.
|
||||||
|
- **Refund create** persists the row (`approved`) before the external channel call (crash-window fix) — an
|
||||||
|
interrupted refund is now a reconcilable `approved` row, not a lost execution.
|
||||||
|
- **Forward-dep FKs** added: `refunds.ticket_id`, `nurse_clawbacks.original_payout_id`/`recovered_in_payout_id`,
|
||||||
|
`invoices.partner_center_id` (+ index). All nullable, `NO ACTION`. No behavior change; integrity backstop only.
|
||||||
|
- **Audit:** `Refund`/`NurseClawback`/`NursePayout`/`NursePayoutBatch`/`NurseVerification` are now `IAuditable`
|
||||||
|
→ admin decisions leave an `audit_logs` diff row (IBAN redacted).
|
||||||
|
- **Retired** the orphaned `refund_ticket_required` config key (a refund ticket is always auto-opened).
|
||||||
|
|
||||||
|
## Frontend notes
|
||||||
|
- The customer refund-status flow is **unchanged** — a BNPL refund still shows `processing` with the ETA, and now
|
||||||
|
actually flips to `succeeded` once settled (via admin confirm or the provider callback). No new client work
|
||||||
|
required for the customer side.
|
||||||
|
- If/when an admin refund console surfaces settlement, the two new endpoints are the actions (staff-gated,
|
||||||
|
`sensitive` rate policy).
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# After refinement-phase-7 — Unattended operation (scheduler, locking, migrations-from-boot)
|
||||||
|
|
||||||
|
**For the frontend / next backend phase. Backend-owned; frontend reads.**
|
||||||
|
|
||||||
|
## What changed for a client
|
||||||
|
|
||||||
|
Almost nothing user-facing — this is infrastructure. One wire delta:
|
||||||
|
|
||||||
|
- **`PayoutBatchDto.initiatedByAdminId` is now nullable.** `null` = a **system-initiated / scheduled** payout
|
||||||
|
batch (the weekly cron generated it, no human initiator). Admin payout UIs should render a "system"/"scheduled"
|
||||||
|
label instead of assuming an admin id. Contract + `swagger.v1.json` updated.
|
||||||
|
|
||||||
|
## What the platform now does on its own
|
||||||
|
|
||||||
|
The four previously admin-click-only sweeps run on schedule (each reading its `platform_configs` cadence key):
|
||||||
|
credential-expiry scan, EVV no-show sweep, and **weekly payout-batch generation** — plus the two re-homed sweeps
|
||||||
|
(booking-request expiry, notification retention). **Admin manual triggers are unchanged and remain overrides.**
|
||||||
|
|
||||||
|
- **Payout generation only.** The cron opens a `draft` batch; **processing (money movement) is still an explicit
|
||||||
|
admin action** (`POST admin_payouts/batches/{id}/process`). Do not build a client flow that auto-processes.
|
||||||
|
|
||||||
|
## For the next backend phase (Phase 8 — external rails)
|
||||||
|
|
||||||
|
- **Register new crons via the seam, not a new host.** Implement `IRecurringJob`
|
||||||
|
(`Persistence/Services/Scheduling/`) + one `services.AddSingleton<IRecurringJob, YourJob>()` in
|
||||||
|
`AddPersistenceServices`. Phase 8 owns the **Moadian reconciliation poll** and the **refund-settlement
|
||||||
|
reconciliation** this way (each reads/adds its own cadence key). The scheduler already provides the per-tick
|
||||||
|
scope, the `scheduler:{name}` lock, and error isolation.
|
||||||
|
- **Jobs must stay idempotent** — a retry (or a second instance once the lock is Redis-backed) must never
|
||||||
|
double-pay/double-post; the DB uniques/state-machines are the backstop.
|
||||||
|
|
||||||
|
## Ops / deployment
|
||||||
|
|
||||||
|
- **DDL is a deploy step now:** run `dotnet run -- migrate` (applies migrations + idempotent seeders, then exits)
|
||||||
|
before starting the API in a deployed environment. A normal deployed boot only *checks* the schema and **fails
|
||||||
|
fast** if a migration is pending. Development still migrates + seeds on boot.
|
||||||
|
- **Redis is the >1-instance gate** (shared cache + the cross-instance scheduler/money lock). Single-instance MVP
|
||||||
|
does not need it; the in-proc seams are correct for one instance. Elasticsearch is never MVP.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# After refinement-phase-8 — External rails go real (config-selected vendor adapters)
|
||||||
|
|
||||||
|
**For the frontend / next backend phase. Backend-owned; frontend reads.**
|
||||||
|
|
||||||
|
## What changed for a client
|
||||||
|
|
||||||
|
**Nothing user-facing changed by default** — the mocks stay the default registration, so every existing flow
|
||||||
|
behaves exactly as before. This phase makes each vendor rail *swappable to real by config*, not on by default.
|
||||||
|
|
||||||
|
One **new endpoint** (backend-to-backend, not for the browser): `POST /api/v1/webhooks/payouts/{provider}` — the
|
||||||
|
async PAYA/SATNA reconciliation callback (signature-authenticated, anonymous, `webhook` rate policy). It flips a
|
||||||
|
`submitted` payout to `paid`/`failed`. No client calls it.
|
||||||
|
|
||||||
|
## How a rail goes real (ops)
|
||||||
|
|
||||||
|
Set the rail's **`Seams:{rail}:Provider`** + its credentials (user-secrets/env) and restart — no code change,
|
||||||
|
no deploy of new binaries. Provider tokens (mock stays default; a typo falls closed to mock):
|
||||||
|
|
||||||
|
| Rail | Key | Real value | Also needs |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| SMS (**launch-critical**) | `Seams:Sms:Provider` | `kavenegar` | `Seams:Sms:{ApiKey,SenderLine,OtpTemplate}` |
|
||||||
|
| Shahkar / e-KYC / شبا | `Seams:{Shahkar,IdentityKyc,BankOwnership}:Provider` | `finnotech` | `Seams:Finnotech:{BaseUrl,ClientId,AccessToken}` |
|
||||||
|
| Geocoding | `Seams:Geocoding:Provider` | `neshan` | `Seams:Geocoding:{ApiKey}` |
|
||||||
|
| Object storage | `Seams:ObjectStorage:Provider` | `s3` | `Seams:ObjectStorage:{ServiceUrl,Bucket,Region,AccessKey,SecretKey}` |
|
||||||
|
| Card PSP (+ HMAC webhook + تسهیم) | `Seams:Payments:Provider` | `zarinpal` | `Seams:Payments:{MerchantId,CallbackUrl,WebhookSigningSecrets}` |
|
||||||
|
| BNPL | `Seams:Bnpl:Provider` | `real` | `Seams:Bnpl:Providers:{snapppay,digipay}:*` (creds via gateway config) |
|
||||||
|
| Payout rail | `Seams:BankTransfer:Provider` | `jibit` | `Seams:BankTransfer:{ApiKey,SourceSettlementAccount}` + webhook secret |
|
||||||
|
| Moadian | `Seams:Moadian:Provider` | `moadian` | `Seams:Moadian:{MemoryId,AccessToken}` + signing cert |
|
||||||
|
|
||||||
|
**Two hard rules baked in:**
|
||||||
|
- **SMS real ⇒ the OTP is never logged.** The Development OTP-in-logs bridge (`GET dev/last_otp`) runs **only while
|
||||||
|
the mock SMS sender is selected**. Once `Seams:Sms:Provider=kavenegar`, the code only leaves the process over the
|
||||||
|
SMS wire.
|
||||||
|
- **Money callbacks fail closed.** The payout reconciliation + PSP webhook verify a per-provider HMAC over the raw
|
||||||
|
body; an invalid signature mutates nothing. The confirm path still re-verifies the amount server-side.
|
||||||
|
|
||||||
|
## Behavioural notes the next phase should know
|
||||||
|
|
||||||
|
- **Payout rail is async now (when real).** A real `JibitBankTransferProvider` accepts a transfer as `submitted`;
|
||||||
|
the ledger posts only when the reconciliation callback confirms `paid`. The `ExecutePayoutBatch` handler already
|
||||||
|
handled this (`MarkSubmitted` first, ledger on `paid`) — it was unchanged.
|
||||||
|
- **`bookings/convert` is Dev/Testing only.** `IPaymentCaptureSimulator` is out of the production registration
|
||||||
|
(prod = fail-closed `DisabledPaymentCaptureSimulator`). Production converts via the b10 payment **webhook confirm**
|
||||||
|
calling `ConvertRequestToBooking` directly — do not build a client convert flow.
|
||||||
|
- **`balinyaar` BNPL = in-house.** In real BNPL mode `provider_code=balinyaar` resolves to the deterministic
|
||||||
|
net-of-fee model (no external API); `tara`/`torobpay` are unbuilt and rejected cleanly.
|
||||||
|
- **New cron:** `MoadianReconciliationJob` (6 h) walks `pending/submitted` invoices to `registered` — registered
|
||||||
|
the phase-7 way (`IRecurringJob` + one `AddSingleton`), no migration.
|
||||||
|
|
||||||
|
## Follow-ups carried forward
|
||||||
|
|
||||||
|
- Per-code BNPL revert (the b11 refund path uses the SnappPay default); SMS.ir/Ghasedak adapters; Finnotech/Moadian
|
||||||
|
token exchange + Moadian signing cert; the **refund-settlement poll** (BNPL `processing → succeeded`, pairs with
|
||||||
|
Moadian — the confirm command exists, the poll job is the remaining wiring); a dedicated **center-settlement
|
||||||
|
payout** (deferred per 6.6 — MoR centers settle via a تسهیم split leg, non-MoR have no separate money path).
|
||||||
|
- **Redis** stays the >1-instance gate; **Elasticsearch** is never MVP.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# After refinement-phase-9 — Observability, ops hardening, docs honesty & scale-later
|
||||||
|
|
||||||
|
**For the frontend / next backend phase. Backend-owned; frontend reads.**
|
||||||
|
|
||||||
|
## What changed for a client
|
||||||
|
|
||||||
|
**Nothing user-facing.** No route, envelope, shape, or enum changed. Two things worth knowing:
|
||||||
|
|
||||||
|
- **`ApiResult.requestId` is now a real W3C trace id** (the request's OpenTelemetry trace). It's the id to quote in
|
||||||
|
a support ticket — it maps 1:1 to the server-side trace once an OTLP collector is wired.
|
||||||
|
- **Ticket message bodies are encrypted at rest** server-side. The thread read still returns **plaintext** (the wire
|
||||||
|
is unchanged); the change is purely storage-side (the refund/dispute paper trail is no longer plaintext in the DB).
|
||||||
|
|
||||||
|
## What the platform now does / exposes (ops)
|
||||||
|
|
||||||
|
- **One OpenTelemetry stack.** Metrics scrape at `/metrics`; distributed **tracing** (ASP.NET Core + EF Core) is
|
||||||
|
wired. **OTLP export is opt-in** — set `OpenTelemetry:Otlp:Endpoint` (Grafana Tempo / Jaeger / OTEL Collector) to
|
||||||
|
turn trace + metric export on. Prometheus-scrape-only is an acceptable MVP; prometheus-net was removed.
|
||||||
|
- **Health endpoints:** `/healthz/live` (process only — safe liveness), `/healthz/ready` (app DB + log DB [deployed]
|
||||||
|
+ an object-storage write probe — pull an instance out of rotation when a dependency is down), `/HealthCheck`
|
||||||
|
(aggregate, kept for compat). Point the orchestrator's liveness probe at `/healthz/live`, readiness at
|
||||||
|
`/healthz/ready`.
|
||||||
|
- **Prod logs are Information+ with no PII/secrets.** The OTP code is no longer logged in any environment. Log-table
|
||||||
|
retention on `Baya_Logs` is an ops/DBA task (or ship logs to the OTLP collector).
|
||||||
|
- **Audit-log retention** runs as a scheduled `IRecurringJob` — two-tier (financial/verification rows kept ~7 yr,
|
||||||
|
everyday rows ~2 yr) via the `audit_retention_*` config keys.
|
||||||
|
- **gRPC reflection is Development-only** (the plugin itself is unchanged; it shares the mixed-protocol listener).
|
||||||
|
|
||||||
|
## For the next backend phase / deploy
|
||||||
|
|
||||||
|
- **Turn tracing on in deployed envs** by provisioning an OTLP collector and setting `OpenTelemetry:Otlp:Endpoint`.
|
||||||
|
- **When Redis lands (>1 instance)**, add a `redis` readiness check (tagged `ready`) to `ConfigureHealthChecks`.
|
||||||
|
- **Register any new retention/cron via `IRecurringJob`** (unchanged from phase 7).
|
||||||
|
|
||||||
|
## Deferred — recorded, NOT gaps (each has a written pull-trigger; see the phase report)
|
||||||
|
|
||||||
|
- **Elasticsearch `INurseSearch` backend + outbox feeder** — pull when SQL search shows strain. SQL search is the
|
||||||
|
real MVP (`Search:Backend=sql`; any other value fails fast).
|
||||||
|
- **SMS/push channels of `INotificationDispatcher`** — pull when the notification UX demands out-of-app reach.
|
||||||
|
In-app notifications are real now.
|
||||||
|
- **Analytics warehouse/stream, holiday-calendar feed, 8 deferred product tables** (`organizations`,
|
||||||
|
`organization_nurses`, `fraud_flags`, `recurring_booking_schedules`, `bnpl_settlement_entries`, availability
|
||||||
|
slots, customer national-ID KYC, geo bulk import) — each a pure additive step when product pulls it.
|
||||||
@@ -12,6 +12,23 @@ for awareness.
|
|||||||
- **Requests filed:** frontend/requests/for-backend.md (yes/no)
|
- **Requests filed:** frontend/requests/for-backend.md (yes/no)
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
## refinement-phase-2 — Auth & role-aware navigation ("only customer side" fix) — 2026-07-13
|
||||||
|
- **Shipped:** the resolved-vs-pending role fix. `useRoleHydration()` (`services/auth`, `loading|error|ready`
|
||||||
|
over `useMe`) + `RoleGuard` (wraps every private shell; tested) + `AuthAccountError`. Shells now: neutral
|
||||||
|
splash while `/me` loads (never the customer shell), explicit `/me`-failed recovery (never a silent customer
|
||||||
|
fallback), and role-mismatch **redirect** to `resolveRoleDestination` + `guard_denied` toast. `(customer)`/
|
||||||
|
`nurse`/`admin` guard `expected={APP_ROLES.*}`; `partner` is hydration-only (self-gates via
|
||||||
|
useMyPartnerCenter). i18n `auth.guard_denied`/`account_error_*` (en+fa). Backend (a little): 2 phone-OTP
|
||||||
|
admins added to the demo seeder (`09120000020` super_admin / `09120000021` finance) so `/admin` is reachable
|
||||||
|
via phone-OTP + `useAdminCapabilities` gating is demonstrable.
|
||||||
|
- **Consumes:** the real b2 auth (`/me`, `me/select_role`) — no new contract. `USE_AUTH_MOCK` stays false.
|
||||||
|
- **Mocked client-side:** none new. Partner login-routing deferred (`/partner` reachable by direct nav via the
|
||||||
|
existing partnerCenter mock).
|
||||||
|
- **Gate:** npm run check green · RoleGuard.test.tsx 8/8 · en/fa in sync · server build 0 errors ·
|
||||||
|
DemoWorldSeederTests 4/4.
|
||||||
|
- **Requests filed:** yes — **REQ-004 resolved** (client owns active-role); **REQ-038 filed** (a `/me`
|
||||||
|
partner-center-admin signal for partner login-routing).
|
||||||
|
|
||||||
## frontend-phase-15-b15 — Admin backoffice & partner-center consoles — 2026-07-10 — **MVP COMPLETE**
|
## frontend-phase-15-b15 — Admin backoffice & partner-center consoles — 2026-07-10 — **MVP COMPLETE**
|
||||||
- **Shipped:** the internal **operational cockpit** — the role-gated admin backoffice (desktop sidebar shell) +
|
- **Shipped:** the internal **operational cockpit** — the role-gated admin backoffice (desktop sidebar shell) +
|
||||||
the separately-scoped **partner-center portal**. Two new domains: **`services/admin`** (config / holidays /
|
the separately-scoped **partner-center portal**. Two new domains: **`services/admin`** (config / holidays /
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
pattern every later frontend phase inherits.
|
pattern every later frontend phase inherits.
|
||||||
- **Proposed shape:** `{ isSuccess: boolean, statusCode: number, message?: string, requestId?: string, data?: T }`
|
- **Proposed shape:** `{ isSuccess: boolean, statusCode: number, message?: string, requestId?: string, data?: T }`
|
||||||
and `data: { items: T[], total: number, page: number, pageSize: number }` for lists.
|
and `data: { items: T[], total: number, page: number, pageSize: number }` for lists.
|
||||||
- **Status:** open
|
- **Status:** confirmed in refinement-phase-3 — the `ApiResult` envelope (payload under `data`, camelCase body, integer `statusCode`) and `PagedResult` `{ items, total, page, pageSize }` are the intended shapes for all endpoints. **Note:** the list query param binds camelCase **`pageSize`** (case-insensitive); the `page_size` doc occurrences were swept (REQ-010).
|
||||||
|
|
||||||
## REQ-002 — OTP length + expiry in RequestOtpResult — filed by frontend-phase-1-b2 — 2026-07-02
|
## REQ-002 — OTP length + expiry in RequestOtpResult — filed by frontend-phase-1-b2 — 2026-07-02
|
||||||
- **Need:** Add `codeLength` (int) and `expiresInSeconds` (int) to `RequestOtpResult`.
|
- **Need:** Add `codeLength` (int) and `expiresInSeconds` (int) to `RequestOtpResult`.
|
||||||
@@ -38,7 +38,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
(`OTP_CODE_LENGTH = 6`, inferred from the live 6-digit verify example, not the 4-box wireframe). Surfacing
|
(`OTP_CODE_LENGTH = 6`, inferred from the live 6-digit verify example, not the 4-box wireframe). Surfacing
|
||||||
the length makes the box count contract-driven; the expiry lets us show "code expires in …".
|
the length makes the box count contract-driven; the expiry lets us show "code expires in …".
|
||||||
- **Proposed shape:** `{ otpSent: boolean, resendAvailableInSeconds: number, codeLength: number, expiresInSeconds: number }`
|
- **Proposed shape:** `{ otpSent: boolean, resendAvailableInSeconds: number, codeLength: number, expiresInSeconds: number }`
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-003 — Machine-readable error codes for verify_otp failures — filed by frontend-phase-1-b2 — 2026-07-02
|
## REQ-003 — Machine-readable error codes for verify_otp failures — filed by frontend-phase-1-b2 — 2026-07-02
|
||||||
- **Need:** A stable `code` on the 400 envelope for verify_otp that distinguishes wrong code vs expired code
|
- **Need:** A stable `code` on the 400 envelope for verify_otp that distinguishes wrong code vs expired code
|
||||||
@@ -50,7 +50,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
otherwise degrades to a generic "incorrect or expired" message. A stable machine code (kept generic enough
|
otherwise degrades to a generic "incorrect or expired" message. A stable machine code (kept generic enough
|
||||||
to avoid account enumeration) would let the UI render the precise state + the unlock countdown.
|
to avoid account enumeration) would let the UI render the precise state + the unlock countdown.
|
||||||
- **Proposed shape:** `{ isSuccess: false, statusCode: 400, message: "…", code: "otp_locked", data: { retryAfterSeconds: 60 } }`
|
- **Proposed shape:** `{ isSuccess: false, statusCode: 400, message: "…", code: "otp_locked", data: { retryAfterSeconds: 60 } }`
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-005 — Patient `relation` + `conditions` fields — filed by frontend-phase-2-b3 — 2026-07-02
|
## REQ-005 — Patient `relation` + `conditions` fields — filed by frontend-phase-2-b3 — 2026-07-02
|
||||||
- **Need:** Add two fields to `PatientDto` and the `patients/create` + `patients/update` bodies:
|
- **Need:** Add two fields to `PatientDto` and the `patients/create` + `patients/update` bodies:
|
||||||
@@ -62,7 +62,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
and drops them on the real path. Adding the columns lets the client flip the flag to the live endpoints.
|
and drops them on the real path. Adding the columns lets the client flip the flag to the live endpoints.
|
||||||
- **Proposed shape:** `PatientDto { …, relation: string|null, conditions: string[] }`; same fields accepted on
|
- **Proposed shape:** `PatientDto { …, relation: string|null, conditions: string[] }`; same fields accepted on
|
||||||
create/update. Enum for `relation`; `conditions` a stable code list (could also be a normalized child table).
|
create/update. Enum for `relation`; `conditions` a stable code list (could also be a normalized child table).
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-006 — Avatar / object-storage upload route (nurse & customer) — filed by frontend-phase-2-b3 — 2026-07-02
|
## REQ-006 — Avatar / object-storage upload route (nurse & customer) — filed by frontend-phase-2-b3 — 2026-07-02
|
||||||
- **Need:** A multipart image-upload endpoint backed by `IObjectStorage` that returns a stored URL, plus an
|
- **Need:** A multipart image-upload endpoint backed by `IObjectStorage` that returns a stored URL, plus an
|
||||||
@@ -72,7 +72,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
has no avatar field or upload route, and the client fetch layer is JSON-only (can't send multipart). The
|
has no avatar field or upload route, and the client fetch layer is JSON-only (can't send multipart). The
|
||||||
client mocks this behind the `services/profiles` seam (`uploadAvatar` returns an object URL). The real
|
client mocks this behind the `services/profiles` seam (`uploadAvatar` returns an object URL). The real
|
||||||
`profilesClientApi.uploadAvatar` throws `501` until this lands.
|
`profilesClientApi.uploadAvatar` throws `501` until this lands.
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-007 — Customer name + preferred-language update — filed by frontend-phase-2-b3 — 2026-07-02
|
## REQ-007 — Customer name + preferred-language update — filed by frontend-phase-2-b3 — 2026-07-02
|
||||||
- **Need:** Either add `firstName`/`lastName`/`preferredLanguage` to the `customer_profiles/upsert` body +
|
- **Need:** Either add `firstName`/`lastName`/`preferredLanguage` to the `customer_profiles/upsert` body +
|
||||||
@@ -83,7 +83,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
contact. Absent a wire field/endpoint, the client augments name/language behind the `services/profiles` seam
|
contact. Absent a wire field/endpoint, the client augments name/language behind the `services/profiles` seam
|
||||||
(mock-persisted; the real upsert sends only the emergency contact). Confirm the intended home for these so the
|
(mock-persisted; the real upsert sends only the emergency contact). Confirm the intended home for these so the
|
||||||
client stops augmenting.
|
client stops augmenting.
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-004 — Confirm multi-role disambiguation (activeRole?) — filed by frontend-phase-1-b2 — 2026-07-02
|
## REQ-004 — Confirm multi-role disambiguation (activeRole?) — filed by frontend-phase-1-b2 — 2026-07-02
|
||||||
- **Need:** Confirm whether `MeResult` will gain an `activeRole` (the user's currently-selected actor) for a
|
- **Need:** Confirm whether `MeResult` will gain an `activeRole` (the user's currently-selected actor) for a
|
||||||
@@ -93,7 +93,12 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
app. If the backend intends to persist a "current role", the router should prefer it. Also note: verify_otp
|
app. If the backend intends to persist a "current role", the router should prefer it. Also note: verify_otp
|
||||||
returns `roles` but no user `id` (only `/me` has it) — fine for now (context id is hydrated from `/me`),
|
returns `roles` but no user `id` (only `/me` has it) — fine for now (context id is hydrated from `/me`),
|
||||||
flagging in case that changes.
|
flagging in case that changes.
|
||||||
- **Status:** open
|
- **Resolution (refinement-phase-2, 2026-07-13):** **The client owns the active-role choice** — `MeResult`
|
||||||
|
will *not* gain an `activeRole`. A dual customer+nurse session is disambiguated by the client-carried
|
||||||
|
intended role (the A1 vs B1 login switch), defaulting to the family app; `RoleGuard` lets a dual-role user
|
||||||
|
move freely between shells. No server change needed. If the backend ever wants to persist a "current role",
|
||||||
|
file a new REQ and the router will prefer it. **Confirmed the `/me` `id`-hydration note still holds.**
|
||||||
|
- **Status:** resolved (client owns it; no backend change)
|
||||||
|
|
||||||
## REQ-008 — Accept the client-picked map pin on address create/update — filed by frontend-phase-3-b4 — 2026-07-02
|
## REQ-008 — Accept the client-picked map pin on address create/update — filed by frontend-phase-3-b4 — 2026-07-02
|
||||||
- **Need:** Let `customer_addresses/create` and `customer_addresses/update/{id}` accept optional
|
- **Need:** Let `customer_addresses/create` and `customer_addresses/update/{id}` accept optional
|
||||||
@@ -107,7 +112,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
- **Proposed shape:** create/update body gains `latitude?: number, longitude?: number`; when both present, store
|
- **Proposed shape:** create/update body gains `latitude?: number, longitude?: number`; when both present, store
|
||||||
them (and mark the geocode source as "user-pin"); when absent, geocode as today. `CustomerAddressDto` already
|
them (and mark the geocode source as "user-pin"); when absent, geocode as today. `CustomerAddressDto` already
|
||||||
returns `latitude`/`longitude`.
|
returns `latitude`/`longitude`.
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-009 — Add `provinceId` to `CustomerAddressDto` — filed by frontend-phase-3-b4 — 2026-07-02
|
## REQ-009 — Add `provinceId` to `CustomerAddressDto` — filed by frontend-phase-3-b4 — 2026-07-02
|
||||||
- **Need:** Add `provinceId` (long) to `CustomerAddressDto` (the province that owns the address's `cityId`).
|
- **Need:** Add `provinceId` (long) to `CustomerAddressDto` (the province that owns the address's `cityId`).
|
||||||
@@ -119,7 +124,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
the server** can't prefill the province until this lands. `cityId` still implies the province server-side —
|
the server** can't prefill the province until this lands. `cityId` still implies the province server-side —
|
||||||
this is purely to prefill the client cascade.
|
this is purely to prefill the client cascade.
|
||||||
- **Proposed shape:** `CustomerAddressDto { …, provinceId: long }` (join from `cities.province_id`).
|
- **Proposed shape:** `CustomerAddressDto { …, provinceId: long }` (join from `cities.province_id`).
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-010 — Confirm/align the list pagination query-param name (catalog + all lists) — filed by frontend-phase-4-b5 — 2026-07-05
|
## REQ-010 — Confirm/align the list pagination query-param name (catalog + all lists) — filed by frontend-phase-4-b5 — 2026-07-05
|
||||||
- **Need:** Confirm the exact query-param name the paginated list endpoints bind for page size. The
|
- **Need:** Confirm the exact query-param name the paginated list endpoints bind for page size. The
|
||||||
@@ -131,7 +136,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
the server truly binds `pageSize`, please update the `page_size` occurrences in the contract docs to match;
|
the server truly binds `pageSize`, please update the `page_size` occurrences in the contract docs to match;
|
||||||
if it binds `page_size`, tell us and we'll switch the client (one line per list call).
|
if it binds `page_size`, tell us and we'll switch the client (one line per list call).
|
||||||
- **Proposed shape:** list query = `?page={1-based}&pageSize={≤100}`; response `data` = `{ items, total, page, pageSize }`.
|
- **Proposed shape:** list query = `?page={1-based}&pageSize={≤100}`; response `data` = `{ items, total, page, pageSize }`.
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3 — server binds **`pageSize`**; the `page_size` occurrences in `dev/contracts/domains/*.md` + `conventions/api-conventions.md` were swept to `pageSize`. (One generated swagger endpoint still shows a `page_size` query name with `x-originalName: pageSize`; it binds `pageSize` case-insensitively.)
|
||||||
|
|
||||||
## REQ-011 — Nurse-facing endpoint for structured professional-credential details — filed by frontend-phase-5-b6 — 2026-07-09
|
## REQ-011 — Nurse-facing endpoint for structured professional-credential details — filed by frontend-phase-5-b6 — 2026-07-09
|
||||||
- **Need:** A nurse-facing command to submit the **structured** credential fields B5 collects alongside the
|
- **Need:** A nurse-facing command to submit the **structured** credential fields B5 collects alongside the
|
||||||
@@ -149,7 +154,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
→ `VerificationStatusDto`. Alternatively, extend the manual-step `documents` confirm body with these fields.
|
→ `VerificationStatusDto`. Alternatively, extend the manual-step `documents` confirm body with these fields.
|
||||||
- **Also (minor):** the contract's `VerificationStepDto` has no `isRequired` — the client treats **every**
|
- **Also (minor):** the contract's `VerificationStepDto` has no `isRequired` — the client treats **every**
|
||||||
seeded step as required (the "X از Y" meter Y = `steps.length`). Confirm that holds, or add `isRequired`.
|
seeded step as required (the "X از Y" meter Y = `steps.length`). Confirm that holds, or add `isRequired`.
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-012 — Search result + nurse-profile enrichment for discovery (C2/C3) — filed by frontend-phase-6-b7 — 2026-07-09
|
## REQ-012 — Search result + nurse-profile enrichment for discovery (C2/C3) — filed by frontend-phase-6-b7 — 2026-07-09
|
||||||
- **Need:** Two extra read surfaces the discovery UI renders but b7/b6/b5 don't yet expose:
|
- **Need:** Two extra read surfaces the discovery UI renders but b7/b6/b5 don't yet expose:
|
||||||
@@ -170,7 +175,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
When both land, the swap is a single config flip (no hook/component change).
|
When both land, the swap is a single config flip (no hook/component change).
|
||||||
- **Proposed shape:** enrich `NurseSearchResultDto` with `{ nurseName, avatarUrl, distanceKm? }`; add
|
- **Proposed shape:** enrich `NurseSearchResultDto` with `{ nurseName, avatarUrl, distanceKm? }`; add
|
||||||
`GET api/v1/nurses/{id}/profile` returning the object above. `price`/`priceIrr` stay IRR digit-strings.
|
`GET api/v1/nurses/{id}/profile` returning the object above. `price`/`priceIrr` stay IRR digit-strings.
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-013 — Variant price on `BookingRequestDto` — filed by frontend-phase-7-b8 — 2026-07-09
|
## REQ-013 — Variant price on `BookingRequestDto` — filed by frontend-phase-7-b8 — 2026-07-09
|
||||||
- **Need:** Add the variant's **price** (IRR digit-string) to `BookingRequestDto` (and ideally the nurse's
|
- **Need:** Add the variant's **price** (IRR digit-string) to `BookingRequestDto` (and ideally the nurse's
|
||||||
@@ -183,7 +188,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
lets the summary price the service once the domain flips to the real endpoint.
|
lets the summary price the service once the domain flips to the real endpoint.
|
||||||
- **Proposed shape:** `BookingRequestDto { …, variantPrice: string (IRR digits), nurseAvatarUrl?: string }`.
|
- **Proposed shape:** `BookingRequestDto { …, variantPrice: string (IRR digits), nurseAvatarUrl?: string }`.
|
||||||
(Money-free rule intact — this is the *rate* of the chosen variant for display, not an engagement total.)
|
(Money-free rule intact — this is the *rate* of the chosen variant for display, not an engagement total.)
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-014 — Enrich the nurse-inbox list item (variant label + patient age) — filed by frontend-phase-7-b8 — 2026-07-09
|
## REQ-014 — Enrich the nurse-inbox list item (variant label + patient age) — filed by frontend-phase-7-b8 — 2026-07-09
|
||||||
- **Need:** Add `variantLabel` (and optionally the patient's **age/age-band**) to
|
- **Need:** Add `variantLabel` (and optionally the patient's **age/age-band**) to
|
||||||
@@ -194,7 +199,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
the nurse must open the detail (`get/{id}`, which *does* carry `variantLabel`) to see the service. Surfacing
|
the nurse must open the detail (`get/{id}`, which *does* carry `variantLabel`) to see the service. Surfacing
|
||||||
`variantLabel` on the row makes the inbox self-describing; a coarse age is a nice-to-have for triage.
|
`variantLabel` on the row makes the inbox self-describing; a coarse age is a nice-to-have for triage.
|
||||||
- **Proposed shape:** `BookingRequestListItemDto { …, variantLabel: string, patientAge?: int }`.
|
- **Proposed shape:** `BookingRequestListItemDto { …, variantLabel: string, patientAge?: int }`.
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-015 — Confirm the booking/session/EVV enum codes + `checkInAddressMatch` tri-state — filed by frontend-phase-8-b9 — 2026-07-10
|
## REQ-015 — Confirm the booking/session/EVV enum codes + `checkInAddressMatch` tri-state — filed by frontend-phase-8-b9 — 2026-07-10
|
||||||
- **Need:** Two confirmations so the f8 `services/bookings/types.ts` client unions stay wire-accurate:
|
- **Need:** Two confirmations so the f8 `services/bookings/types.ts` client unions stay wire-accurate:
|
||||||
@@ -213,7 +218,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
- **Why:** f8 renders the status timeline, per-session chips, and the EVV banner strictly off these codes;
|
- **Why:** f8 renders the status timeline, per-session chips, and the EVV banner strictly off these codes;
|
||||||
a casing/int drift or a `false`-vs-`null` conflation would mislabel a visit. Low-risk (mock-primary now),
|
a casing/int drift or a `false`-vs-`null` conflation would mislabel a visit. Low-risk (mock-primary now),
|
||||||
but worth locking before f9/f13 consume the same shapes.
|
but worth locking before f9/f13 consume the same shapes.
|
||||||
- **Status:** open
|
- **Status:** confirmed in refinement-phase-3 — booking/session/EVV statuses serialize as the exact snake_case string codes the client unions expect (verified in code); `checkInAddressMatch` is `bool?` = `null` when GPS was absent **or** the frozen address has no resolvable coordinate, `false` = advisory out-of-range (never a block), `true` = in range.
|
||||||
|
|
||||||
## REQ-016 — Checkout summary for C6 (served gross/commission/VAT breakdown) — filed by frontend-phase-9-b10 — 2026-07-10
|
## REQ-016 — Checkout summary for C6 (served gross/commission/VAT breakdown) — filed by frontend-phase-9-b10 — 2026-07-10
|
||||||
- **Need:** A customer-facing read that serves the C6 «خلاصه و پرداخت» money rows for an
|
- **Need:** A customer-facing read that serves the C6 «خلاصه و پرداخت» money rows for an
|
||||||
@@ -232,7 +237,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
serviceCostIrr, commissionIrr, vatIrr, vatRate, totalIrr, grossPriceIrr, balinyaarCommissionIrr,
|
serviceCostIrr, commissionIrr, vatIrr, vatRate, totalIrr, grossPriceIrr, balinyaarCommissionIrr,
|
||||||
nursePayoutAmount }` — the client's real `paymentClientApi.getCheckoutSummary` already targets this
|
nursePayoutAmount }` — the client's real `paymentClientApi.getCheckoutSummary` already targets this
|
||||||
slug and unwraps this exact shape (`client/src/services/payment/types.ts: CheckoutSummaryDto`).
|
slug and unwraps this exact shape (`client/src/services/payment/types.ts: CheckoutSummaryDto`).
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-017 — Client-readable payment outcome + `bookingId` on a converted request — filed by frontend-phase-9-b10 — 2026-07-10
|
## REQ-017 — Client-readable payment outcome + `bookingId` on a converted request — filed by frontend-phase-9-b10 — 2026-07-10
|
||||||
- **Need:** After the gateway redirect returns, the client needs to learn (a) the payment transaction's
|
- **Need:** After the gateway redirect returns, the client needs to learn (a) the payment transaction's
|
||||||
@@ -251,7 +256,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
- **Proposed shape:** add `bookingId: long?` to `BookingRequestDto` (null until converted) **and/or**
|
- **Proposed shape:** add `bookingId: long?` to `BookingRequestDto` (null until converted) **and/or**
|
||||||
`GET api/v1/bookings/{bookingRequestId}/payments/latest` → `{ transactionId, status,
|
`GET api/v1/bookings/{bookingRequestId}/payments/latest` → `{ transactionId, status,
|
||||||
gatewayReferenceCode, bookingId? }`.
|
gatewayReferenceCode, bookingId? }`.
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-018 — Customer invoice availability after capture (auto-issue or owner-issue) — filed by frontend-phase-9-b10 — 2026-07-10
|
## REQ-018 — Customer invoice availability after capture (auto-issue or owner-issue) — filed by frontend-phase-9-b10 — 2026-07-10
|
||||||
- **Need:** Make the b11 invoice reachable by the paying customer right after capture: auto-issue the
|
- **Need:** Make the b11 invoice reachable by the paying customer right after capture: auto-issue the
|
||||||
@@ -262,7 +267,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
admin acts. The UI handles the 404 as a "فاکتور هنوز صادر نشده است" state (and the mock auto-issues at
|
admin acts. The UI handles the 404 as a "فاکتور هنوز صادر نشده است" state (and the mock auto-issues at
|
||||||
capture to demo the full flow), but on the real rails every fresh payment would land on that empty
|
capture to demo the full flow), but on the real rails every fresh payment would land on that empty
|
||||||
state.
|
state.
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-019 — Customer-initiated booking cancellation command — filed by frontend-phase-10-b11 — 2026-07-10
|
## REQ-019 — Customer-initiated booking cancellation command — filed by frontend-phase-10-b11 — 2026-07-10
|
||||||
- **Need:** A **customer-facing** command to cancel a booking (post-payment) and open its refund, e.g.
|
- **Need:** A **customer-facing** command to cancel a booking (post-payment) and open its refund, e.g.
|
||||||
@@ -281,7 +286,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
server resolves the snapshotted policy, enforces the outside-policy/state rules (`409`), posts the
|
server resolves the snapshotted policy, enforces the outside-policy/state rules (`409`), posts the
|
||||||
balanced reversal, and (per the admin-only rule) may route the refund through an admin/ticket step — the
|
balanced reversal, and (per the admin-only rule) may route the refund through an admin/ticket step — the
|
||||||
customer surface just needs to *create* the cancellation request and read the resulting refund.
|
customer surface just needs to *create* the cancellation request and read the resulting refund.
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-020 — Cancellation-policy preview (pre-cancel, per-session) — filed by frontend-phase-10-b11 — 2026-07-10
|
## REQ-020 — Cancellation-policy preview (pre-cancel, per-session) — filed by frontend-phase-10-b11 — 2026-07-10
|
||||||
- **Need:** A read that **resolves the applicable cancellation policy by current lead time** *before* the
|
- **Need:** A read that **resolves the applicable cancellation policy by current lead time** *before* the
|
||||||
@@ -303,7 +308,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
set so the client maps the real codes.
|
set so the client maps the real codes.
|
||||||
- **Proposed shape:** as above. The `cancellationPolicyCode` set + the per-session `reasonCode` set
|
- **Proposed shape:** as above. The `cancellationPolicyCode` set + the per-session `reasonCode` set
|
||||||
(`un_started` / the blocking session status) should be documented as stable enum codes → i18n keys.
|
(`un_started` / the blocking session status) should be documented as stable enum codes → i18n keys.
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-021 — Customer refund lookup-by-booking + fee-leg decomposition on the customer status — filed by frontend-phase-10-b11 — 2026-07-10
|
## REQ-021 — Customer refund lookup-by-booking + fee-leg decomposition on the customer status — filed by frontend-phase-10-b11 — 2026-07-10
|
||||||
- **Need:** Two additions to the customer refund surface:
|
- **Need:** Two additions to the customer refund surface:
|
||||||
@@ -322,7 +327,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
commission on a revert) is **nullable** on the refund shape and reconciled from the provider response —
|
commission on a revert) is **nullable** on the refund shape and reconciled from the provider response —
|
||||||
the b12 `IBnplProvider` mock echoes it as nullable and the client treats any provider-commission figure
|
the b12 `IBnplProvider` mock echoes it as nullable and the client treats any provider-commission figure
|
||||||
as opaque/never customer-facing.
|
as opaque/never customer-facing.
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-022 — BNPL provider/plan options + repayment schedule (D1/D2/D4) — filed by frontend-phase-11-b12 — 2026-07-10
|
## REQ-022 — BNPL provider/plan options + repayment schedule (D1/D2/D4) — filed by frontend-phase-11-b12 — 2026-07-10
|
||||||
- **Need:** Two customer-facing reads the installment checkout renders that b12 serves **nothing** for:
|
- **Need:** Two customer-facing reads the installment checkout renders that b12 serves **nothing** for:
|
||||||
@@ -344,7 +349,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
- **Note on provider set:** the wireframe includes an **in-house `balinyaar`** plan not in the b12
|
- **Note on provider set:** the wireframe includes an **in-house `balinyaar`** plan not in the b12
|
||||||
`provider_code` enum (`snapppay|digipay|tara|torobpay`). Please add `balinyaar` (or state how the in-house
|
`provider_code` enum (`snapppay|digipay|tara|torobpay`). Please add `balinyaar` (or state how the in-house
|
||||||
plan is modelled) so `providerCode` stays a closed set.
|
plan is modelled) so `providerCode` stays a closed set.
|
||||||
- **Status:** open
|
- **Status:** partially delivered in refinement-phase-3 — `balinyaar` added to the `provider_code` enum. **DEFERRED:** `checkout_bnpl/options/{id}` + `schedule` (per-plan monthly/down-payment split + due-dated repayment table) — b12 deliberately does not model the customer repayment schedule and there is no installment ledger to serve it from; keep D1/D2/D4 mocked until a provider-schedule integration or schedule table lands.
|
||||||
|
|
||||||
## REQ-023 — BNPL eligibility should accept the D3 credit-check inputs (national ID / mobile / consent) — filed by frontend-phase-11-b12 — 2026-07-10
|
## REQ-023 — BNPL eligibility should accept the D3 credit-check inputs (national ID / mobile / consent) — filed by frontend-phase-11-b12 — 2026-07-10
|
||||||
- **Need:** Either extend `POST api/v1/checkout_bnpl/eligibility` to accept `{ nationalId, mobile, consent }`
|
- **Need:** Either extend `POST api/v1/checkout_bnpl/eligibility` to accept `{ nationalId, mobile, consent }`
|
||||||
@@ -354,7 +359,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
the extra fields today (ignored server-side until the KYC step exists) and the mock uses them for the
|
the extra fields today (ignored server-side until the KYC step exists) and the mock uses them for the
|
||||||
deterministic declined-path demo. The response already carries `eligibilityStatus` + `creditCeilingIrr`,
|
deterministic declined-path demo. The response already carries `eligibilityStatus` + `creditCeilingIrr`,
|
||||||
which D3 renders — only the request inputs are the gap.
|
which D3 renders — only the request inputs are the gap.
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3 — `checkout_bnpl/eligibility` now accepts `{ nationalId, mobile, consent }` (consent required when the KYC inputs are present; supplied mobile drives the inquiry, else the account mobile). Mock still uses only the mobile until the real KYC step exists.
|
||||||
|
|
||||||
## REQ-024 — BNPL provider-reported installment status for the Wallet (D5) + customer bookingId link — filed by frontend-phase-11-b12 — 2026-07-10
|
## REQ-024 — BNPL provider-reported installment status for the Wallet (D5) + customer bookingId link — filed by frontend-phase-11-b12 — 2026-07-10
|
||||||
- **Need:** Two additions for the Wallet installment view and the confirmation deep-link:
|
- **Need:** Two additions for the Wallet installment view and the confirmation deep-link:
|
||||||
@@ -378,7 +383,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
surface, and b12 models none of the per-installment schedule/status. The client mocks the whole D5 read
|
surface, and b12 models none of the per-installment schedule/status. The client mocks the whole D5 read
|
||||||
behind the `services/bnpl` seam (seeded plan + a plan pushed on each settled checkout). When it lands the
|
behind the `services/bnpl` seam (seeded plan + a plan pushed on each settled checkout). When it lands the
|
||||||
swap is one config flip.
|
swap is one config flip.
|
||||||
- **Status:** open
|
- **Status:** partially delivered in refinement-phase-3 — (2) `bookingId` on the settled order **already present** on `BnplOrderStatusDto` (confirmed); (3) `GET checkout_bnpl/by_request/{bookingRequestId}` added (owner-scoped). **DEFERRED:** (1) `checkout_bnpl/wallet_installments` — per-installment provider-reported status Balinyaar does not own/track (no installment ledger in b12); needs provider integration. Keep D5 mocked.
|
||||||
|
|
||||||
## REQ-025 — Nurse-read earnings surface: four-bucket balance + per-booking earnings list + nurse payout detail — filed by frontend-phase-12-b13 — 2026-07-10
|
## REQ-025 — Nurse-read earnings surface: four-bucket balance + per-booking earnings list + nurse payout detail — filed by frontend-phase-12-b13 — 2026-07-10
|
||||||
- **Need:** b13 serves the nurse exactly one endpoint (`GET api/v1/nurse_payouts/history` → `NursePayoutHistoryDto`).
|
- **Need:** b13 serves the nurse exactly one endpoint (`GET api/v1/nurse_payouts/history` → `NursePayoutHistoryDto`).
|
||||||
@@ -412,7 +417,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
- **Note (money invariants the server owns):** `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`;
|
- **Note (money invariants the server owns):** `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`;
|
||||||
`net_amount = gross_earnings − clawback_applied`; a payout's booking-link `payout_amount_irr` sum = its
|
`net_amount = gross_earnings − clawback_applied`; a payout's booking-link `payout_amount_irr` sum = its
|
||||||
`gross_earnings_irr`; the nurse amount is **payment-method-invariant** (BNPL provider commission never deducted).
|
`gross_earnings_irr`; the nurse amount is **payment-method-invariant** (BNPL provider commission never deducted).
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-026 — Review eligibility + my-review-for-booking reads (+ masked author confirmation) — filed by frontend-phase-13-b14 — 2026-07-10
|
## REQ-026 — Review eligibility + my-review-for-booking reads (+ masked author confirmation) — filed by frontend-phase-13-b14 — 2026-07-10
|
||||||
- **Need:** Three customer-facing additions the leave-a-review flow renders that b14 does not serve:
|
- **Need:** Three customer-facing additions the leave-a-review flow renders that b14 does not serve:
|
||||||
@@ -432,7 +437,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
reads the shared f8 bookings store for completed-booking eligibility, tracks the submission for the under-review
|
reads the shared f8 bookings store for completed-booking eligibility, tracks the submission for the under-review
|
||||||
state, and seeds a published list per nurse. The real `reviewsClientApi` maps `getNurseReviews`/`createReview`
|
state, and seeds a published list per nurse. The real `reviewsClientApi` maps `getNurseReviews`/`createReview`
|
||||||
1:1 and targets the two proposed slugs for the gaps — one config flip when they land.
|
1:1 and targets the two proposed slugs for the gaps — one config flip when they land.
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-027 — Family-owned care record (medications/routine/tasks) + record access + structured task results — filed by frontend-phase-13-b14 — 2026-07-10
|
## REQ-027 — Family-owned care record (medications/routine/tasks) + record access + structured task results — filed by frontend-phase-13-b14 — 2026-07-10
|
||||||
- **Need:** The b14 `care_records` GET/POST serve the **nurse-authored visit-note history** (سوابق) — that half
|
- **Need:** The b14 `care_records` GET/POST serve the **nurse-authored visit-note history** (سوابق) — that half
|
||||||
@@ -455,7 +460,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
methods target the proposed slugs (REQ-027) and the domain is **mock-primary** (`USE_PATIENT_RECORDS_MOCK = true`)
|
methods target the proposed slugs (REQ-027) and the domain is **mock-primary** (`USE_PATIENT_RECORDS_MOCK = true`)
|
||||||
until they land. **Note:** the wireframe's four-tab E2 record is not in the data model — please confirm whether
|
until they land. **Note:** the wireframe's four-tab E2 record is not in the data model — please confirm whether
|
||||||
the family-owned record is a real MVP entity or a future addition (the client treats it as forward-looking).
|
the family-owned record is a real MVP entity or a future addition (the client treats it as forward-looking).
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
## REQ-028 — Ticket inbox enrichment (unread + last-activity), message author name, by-booking lookup, optimistic idempotency — filed by frontend-phase-14-b15 — 2026-07-10
|
## REQ-028 — Ticket inbox enrichment (unread + last-activity), message author name, by-booking lookup, optimistic idempotency — filed by frontend-phase-14-b15 — 2026-07-10
|
||||||
- **Need:** Four additions the f14 messaging UI renders that the b15 `TicketSummaryDto`/`TicketThreadDto`/message
|
- **Need:** Four additions the f14 messaging UI renders that the b15 `TicketSummaryDto`/`TicketThreadDto`/message
|
||||||
@@ -487,7 +492,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
optimistic composer render these; the domain is mock-primary precisely because (1)/(3) aren't served and the
|
optimistic composer render these; the domain is mock-primary precisely because (1)/(3) aren't served and the
|
||||||
linked bookings are themselves mock-primary. When they land the swap is a single `USE_TICKETS_MOCK = false` flip
|
linked bookings are themselves mock-primary. When they land the swap is a single `USE_TICKETS_MOCK = false` flip
|
||||||
(no hook/component change) — `ticketsClientApi` already maps the live routes.
|
(no hook/component change) — `ticketsClientApi` already maps the live routes.
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3 — (1) `unreadCount` + `lastMessageAt` on `TicketSummaryDto` (unread = non-internal messages from others after the caller's `last_read_at`, stamped when the participant fetches the thread; admin queue = 0). (3) `bookingId` query param on `GET /tickets`. (4) optional `clientMessageId` on `POST /tickets/{id}/messages` (deduped, echoed on `PostMessageResult`). (2) **Confirmed:** the role-label approach is intended — no raw identity/name is added (privacy).
|
||||||
|
|
||||||
## REQ-029 — Config `updatedAt`/`updatedBy` on `PlatformConfigDto` — filed by frontend-phase-15-b15 — 2026-07-10
|
## REQ-029 — Config `updatedAt`/`updatedBy` on `PlatformConfigDto` — filed by frontend-phase-15-b15 — 2026-07-10
|
||||||
- **Need:** the f15 config editor shows each row's last-changed meta ("updated {date} by {actor}"), but the b1
|
- **Need:** the f15 config editor shows each row's last-changed meta ("updated {date} by {actor}"), but the b1
|
||||||
@@ -496,14 +501,14 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
latest on the row without opening the drawer). Mock supplies both; the real row degrades gracefully without.
|
latest on the row without opening the drawer). Mock supplies both; the real row degrades gracefully without.
|
||||||
- **Why:** finance needs the effective value + who last touched it at a glance. `services/admin` is mock-primary
|
- **Why:** finance needs the effective value + who last touched it at a glance. `services/admin` is mock-primary
|
||||||
(`USE_ADMIN_MOCK = true`); `adminClientApi.listConfigs` maps the live route 1:1 and leaves these undefined.
|
(`USE_ADMIN_MOCK = true`); `adminClientApi.listConfigs` maps the live route 1:1 and leaves these undefined.
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3 — `updatedAt` + `updatedBy` on `PlatformConfigDto` (from the entity's audit fields; falls back to creation).
|
||||||
|
|
||||||
## REQ-030 — Audit-trail filters: actor / action / date-range — filed by frontend-phase-15-b15 — 2026-07-10
|
## REQ-030 — Audit-trail filters: actor / action / date-range — filed by frontend-phase-15-b15 — 2026-07-10
|
||||||
- **Need:** `GET audit/get_audit_trail` filters only by `entity_type` + `entity_id`. The f15 audit viewer offers
|
- **Need:** `GET audit/get_audit_trail` filters only by `entity_type` + `entity_id`. The f15 audit viewer offers
|
||||||
actor, action, and from/to date filters. Proposed: add `actor_id`, `action`, `from`, `to` query params (the mock
|
actor, action, and from/to date filters. Proposed: add `actor_id`, `action`, `from`, `to` query params (the mock
|
||||||
honours all four). Until then the real client passes only the supported two and the rest degrade.
|
honours all four). Until then the real client passes only the supported two and the rest degrade.
|
||||||
- **Why:** ops audits by actor and by time window, not only by a single entity. Mock-primary.
|
- **Why:** ops audits by actor and by time window, not only by a single entity. Mock-primary.
|
||||||
- **Status:** open
|
- **Status:** delivered in refinement-phase-3 — `GET audit/get_audit_trail` now also filters by `actorId`, `action`, `from`, `to` (all optional; `entityType`/`entityId` are now optional too). **Note:** query params bind camelCase (`actorId`/`from`/`to`), like `pageSize` — not `actor_id`.
|
||||||
|
|
||||||
## REQ-031 — RBAC role grant/revoke/list endpoints — filed by frontend-phase-15-b15 — 2026-07-10
|
## REQ-031 — RBAC role grant/revoke/list endpoints — filed by frontend-phase-15-b15 — 2026-07-10
|
||||||
- **Need:** the b15 contract exposes no role-management endpoints. The (optional, **DEFERRED-IF-MISSING**) admin
|
- **Need:** the b15 contract exposes no role-management endpoints. The (optional, **DEFERRED-IF-MISSING**) admin
|
||||||
@@ -512,7 +517,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
{ userId, role }`, where `role` is one of `super_admin|admin|support|finance|moderation`. The screen is built
|
{ userId, role }`, where `role` is one of `super_admin|admin|support|finance|moderation`. The screen is built
|
||||||
against the mock and flagged DEFERRED-IF-MISSING; swap `USE_ADMIN_MOCK=false` once the routes land.
|
against the mock and flagged DEFERRED-IF-MISSING; swap `USE_ADMIN_MOCK=false` once the routes land.
|
||||||
- **Why:** to manage which users hold which admin scopes. Not on the testable acceptance path.
|
- **Why:** to manage which users hold which admin scopes. Not on the testable acceptance path.
|
||||||
- **Status:** open
|
- **Status:** deferred in refinement-phase-3 — the RBAC `admin_roles/list|grant|revoke` console. The admin sub-role vocabulary + phone-OTP admins are seeded (refinement-phase-2), but a full grant/revoke management surface is admin-console tooling not on the frontend acceptance path (flagged DEFERRED-IF-MISSING); keep `USE_ADMIN_MOCK` for `/admin/roles`. To deliver: 3 endpoints over `user_roles` (grant/revoke audited) + a `RoleGrant[]` read.
|
||||||
|
|
||||||
## REQ-032 — Partner-portal split reads + activate/suspend + IBAN write-then-masked — filed by frontend-phase-15-b15 — 2026-07-10
|
## REQ-032 — Partner-portal split reads + activate/suspend + IBAN write-then-masked — filed by frontend-phase-15-b15 — 2026-07-10
|
||||||
- **Need:** the b15 contract has admin partner-center CRUD/verify/sponsor + a single `GET /centers/{id}/dashboard`
|
- **Need:** the b15 contract has admin partner-center CRUD/verify/sponsor + a single `GET /centers/{id}/dashboard`
|
||||||
@@ -524,7 +529,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
full `settlementIban`, GET returns only `settlementIbanMasked` last-4). Also the admin **roster read**
|
full `settlementIban`, GET returns only `settlementIbanMasked` last-4). Also the admin **roster read**
|
||||||
(`GET admin/partner-centers/{id}/nurses`). `services/partnerCenter` is mock-primary (`USE_PARTNER_MOCK`).
|
(`GET admin/partner-centers/{id}/nurses`). `services/partnerCenter` is mock-primary (`USE_PARTNER_MOCK`).
|
||||||
- **Why:** the portal (separate authz scope, own-center tenancy) + the admin management screens render these.
|
- **Why:** the portal (separate authz scope, own-center tenancy) + the admin management screens render these.
|
||||||
- **Status:** open
|
- **Status:** partially delivered in refinement-phase-3 — (3) activate/suspend toggle `POST admin/partner-centers/{id}/set-active { isActive }` added; (4) **confirmed** the write-then-masked IBAN contract (PATCH accepts full `settlementIban`; reads return only masked last-4). **Route casing confirmed:** the b15 admin partner-center routes are **kebab-case** (`admin/partner-centers`, `.../set-active`) — an intentional b15 divergence, so the frontend's kebab-case guess is CORRECT (no change needed). **DEFERRED:** (1) `centers/me` + (2) the split portal reads (`centers/me/nurses|bookings|settlement`) — these need the user↔partner-center admin association that REQ-038 deferred; `/partner` stays reachable by direct nav + the partnerCenter mock until that seed + `/me` signal land.
|
||||||
|
|
||||||
## REQ-033 — Partner settlement: per-booking commission invoices + invoice `total` — filed by frontend-phase-15-b15 — 2026-07-10
|
## REQ-033 — Partner settlement: per-booking commission invoices + invoice `total` — filed by frontend-phase-15-b15 — 2026-07-10
|
||||||
- **Need:** the merchant-of-record settlement view lists per-booking **commission invoices** (b11 `Invoice`
|
- **Need:** the merchant-of-record settlement view lists per-booking **commission invoices** (b11 `Invoice`
|
||||||
@@ -534,7 +539,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
Proposed: serve `totalIrr` on the invoice (= commission + bnplCommission + vat), plus a center-scoped list.
|
Proposed: serve `totalIrr` on the invoice (= commission + bnplCommission + vat), plus a center-scoped list.
|
||||||
- **Why:** the settlement/invoice view (rendered only when `is_merchant_of_record`) needs a reconciling total.
|
- **Why:** the settlement/invoice view (rendered only when `is_merchant_of_record`) needs a reconciling total.
|
||||||
VAT stays on the commission line only; the rate is config-driven (`vat_rate`), never hardcoded.
|
VAT stays on the commission line only; the rate is config-driven (`vat_rate`), never hardcoded.
|
||||||
- **Status:** open
|
- **Status:** partially delivered in refinement-phase-3 — `totalIrr` (= platform commission + BNPL commission + VAT) added to `InvoiceDto`. **DEFERRED:** the center-scoped invoice list (depends on the partner portal split reads, REQ-032).
|
||||||
|
|
||||||
## REQ-034 — Verification admin: nurse-level queue + on-demand document URL + whole-verification approve/reject — filed by frontend-phase-15-b15 — 2026-07-10
|
## REQ-034 — Verification admin: nurse-level queue + on-demand document URL + whole-verification approve/reject — filed by frontend-phase-15-b15 — 2026-07-10
|
||||||
- **Need:** three gaps in the b6 admin surface for the f15 review queue: (1) `GET admin_verifications` returns
|
- **Need:** three gaps in the b6 admin surface for the f15 review queue: (1) `GET admin_verifications` returns
|
||||||
@@ -547,7 +552,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
Reject action. `services/verification` is mock-primary.
|
Reject action. `services/verification` is mock-primary.
|
||||||
- **Why:** the queue + per-nurse case + signed-URL document viewer render these. The client never writes
|
- **Why:** the queue + per-nurse case + signed-URL document viewer render these. The client never writes
|
||||||
`is_verified` — the server flips it transactionally (§5).
|
`is_verified` — the server flips it transactionally (§5).
|
||||||
- **Status:** open
|
- **Status:** deferred in refinement-phase-3 — verification admin polish (nurse-grouped queue, on-demand signed document URL, explicit whole-verification approve/reject). The per-step admin surface + the transactional `is_verified` flip already exist (b6); these are ergonomic refinements to the admin queue, not on the frontend acceptance path.
|
||||||
|
|
||||||
## REQ-035 — Refund preview + explicit approve/reject — filed by frontend-phase-15-b15 — 2026-07-10
|
## REQ-035 — Refund preview + explicit approve/reject — filed by frontend-phase-15-b15 — 2026-07-10
|
||||||
- **Need:** b11 `POST admin_refunds` **creates and executes** in one call, so there is no way to render a
|
- **Need:** b11 `POST admin_refunds` **creates and executes** in one call, so there is no way to render a
|
||||||
@@ -558,7 +563,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
(today the single POST both creates + executes). `services/refunds` admin methods are mock-primary;
|
(today the single POST both creates + executes). `services/refunds` admin methods are mock-primary;
|
||||||
`initiateRefund` maps the live `POST admin_refunds`.
|
`initiateRefund` maps the live `POST admin_refunds`.
|
||||||
- **Why:** the ticket-linked refund panel shows the preview, then initiate → (retry on provider failure) / reject.
|
- **Why:** the ticket-linked refund panel shows the preview, then initiate → (retry on provider failure) / reject.
|
||||||
- **Status:** open
|
- **Status:** deferred in refinement-phase-3 — refund admin preview + explicit approve/reject (the single `POST admin_refunds` creates+executes today). The customer preview (REQ-020) IS delivered and serves the same decomposition; the admin-side preview/approve/reject split is admin-console tooling.
|
||||||
|
|
||||||
## REQ-036 — Payout single preview endpoint + `holidayShifted` flag + record-transfer-reference — filed by frontend-phase-15-b15 — 2026-07-10
|
## REQ-036 — Payout single preview endpoint + `holidayShifted` flag + record-transfer-reference — filed by frontend-phase-15-b15 — 2026-07-10
|
||||||
- **Need:** the f15 payout dashboard wants (1) a **single preview** call returning eligible + skipped + the
|
- **Need:** the f15 payout dashboard wants (1) a **single preview** call returning eligible + skipped + the
|
||||||
@@ -571,7 +576,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
(b13 has `mark_failed` but no reconcile-reference write). `services/payouts` admin methods are mock-primary; the
|
(b13 has `mark_failed` but no reconcile-reference write). `services/payouts` admin methods are mock-primary; the
|
||||||
run/retry map the live routes with the `Idempotency-Key` header.
|
run/retry map the live routes with the `Idempotency-Key` header.
|
||||||
- **Why:** preview → run (idempotency-keyed, no double-pay) → detail with per-nurse retry + transfer-ref reconcile.
|
- **Why:** preview → run (idempotency-keyed, no double-pay) → detail with per-nurse retry + transfer-ref reconcile.
|
||||||
- **Status:** open
|
- **Status:** deferred in refinement-phase-3 — payout admin single-preview endpoint + `holidayShifted` flag + record-transfer-reference route. The eligible/skipped data is already returned by the batch generate/`GET admin_payouts/eligible`; a consolidated dry-run preview + reconcile-reference write are admin-console refinements.
|
||||||
|
|
||||||
## REQ-037 — Moderation queue `tagCodes` on `ModerationQueueItemDto` — filed by frontend-phase-15-b15 — 2026-07-10
|
## REQ-037 — Moderation queue `tagCodes` on `ModerationQueueItemDto` — filed by frontend-phase-15-b15 — 2026-07-10
|
||||||
- **Need:** the f15 moderation queue renders each review's tag chips, but `ModerationQueueItemDto` (b14) doesn't
|
- **Need:** the f15 moderation queue renders each review's tag chips, but `ModerationQueueItemDto` (b14) doesn't
|
||||||
@@ -579,4 +584,20 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
DTO. The client defaults to `[]` meanwhile. `services/reviews` moderation methods are mock-primary; `moderateReview`
|
DTO. The client defaults to `[]` meanwhile. `services/reviews` moderation methods are mock-primary; `moderateReview`
|
||||||
maps the live `PATCH reviews/{id}/status` and the queue maps `GET admin/reviews/moderation_queue`.
|
maps the live `PATCH reviews/{id}/status` and the queue maps `GET admin/reviews/moderation_queue`.
|
||||||
- **Why:** moderators see the tags a review carries before publishing/hiding.
|
- **Why:** moderators see the tags a review carries before publishing/hiding.
|
||||||
|
- **Status:** delivered in refinement-phase-3
|
||||||
|
|
||||||
|
## REQ-038 — Signal on `/me` that the caller administers a partner center (partner auto-routing) — filed by refinement-phase-2 — 2026-07-13
|
||||||
|
- **Need:** add a boolean/id on `MeResult` — e.g. `administersPartnerCenterId: number | null` (or a plain
|
||||||
|
`isPartnerCenterAdmin: bool`) — indicating the signed-in user is the admin of a `partner_center`.
|
||||||
|
- **Why:** the partner portal (`/partner`) is a **separate authz scope** — a center admin is not a Balinyaar
|
||||||
|
admin, and partner-ness is **not** derivable from `me.roles`. So `resolveRoleDestination` (the login role
|
||||||
|
router) can't route a partner admin to `/partner` on login the way it routes customer/nurse/admin. Today
|
||||||
|
`/partner` is only reachable by **direct navigation** (in dev the `services/partnerCenter` mock resolves a
|
||||||
|
center for `useMyPartnerCenter`, so the shell renders instead of access-denied); there is no seeded real
|
||||||
|
partner-center↔user association and no `/me`-level signal, so login→`/partner` can't be delivered. With this
|
||||||
|
field the router gains a partner branch and the demo seed can associate a phone user with a seeded center.
|
||||||
|
- **Proposed shape:** `MeResult.administersPartnerCenterId?: number | null`; when non-null,
|
||||||
|
`resolveRoleDestination` routes to `ROUTES.PARTNER`. Pair with a Development seed (a partner-center + a
|
||||||
|
`demo_partner_*` phone user linked as its admin) so the actor is reachable end-to-end like the others.
|
||||||
|
- **Status:** open (partner login-routing deferred; `/partner` reachable by direct nav + the partnerCenter mock)
|
||||||
- **Status:** open
|
- **Status:** open
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ A 26-agent review/verify pass over the diff confirmed and fixed, pre-merge:
|
|||||||
- **i18n/UX:** inline initiate errors always use the localized copy (never raw `ApiError.message`); en
|
- **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;
|
`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
|
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
|
uses the product spelling «بالینیار» (note: fa `common.brand` reads «بالین یار» — a pre-existing
|
||||||
wordmark/product-spelling divergence worth a product decision).
|
wordmark/product-spelling divergence worth a product decision).
|
||||||
- **Dark scheme:** EscrowNotice text/border use `--bal-primary` (the info token is an alert *background*
|
- **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
|
and is illegible as dark-mode text); the print button temporarily flips `data-mui-color-scheme` to
|
||||||
|
|||||||
@@ -5,41 +5,71 @@ exact steps to make each one real. Backend lane owns this file; every phase that
|
|||||||
seam updates its row. This is the checklist the team works through to go from "MVP with mocks" to
|
seam updates its row. This is the checklist the team works through to go from "MVP with mocks" to
|
||||||
"production with real providers".
|
"production with real providers".
|
||||||
|
|
||||||
Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 real integration live.
|
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 |
|
| 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 | 🟡 |
|
| `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 | 🟡 |
|
| `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 | 🟡 |
|
| `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) |
|
||||||
| `IDistributedLock` | backend-phase-10 | Money-path locks — no-op/in-proc | _tbd_ | Redis lock (RedLock); DB constraint remains the backstop | 🔴 |
|
|
||||||
| `INurseSearch` | backend-phase-7 | Search — SQL over `nurse_search_index` | _tbd_ | Elasticsearch index + feeder; reimplement the interface | 🔴 |
|
|
||||||
| `IPaymentProvider` | backend-phase-10 | Card PSP/IPG — deterministic success | _tbd_ | ZarinPal/Sadad/Vandar/Jibit + Shaparak; merchant/terminal/تسهیم | 🔴 |
|
|
||||||
| `ISettlementSplitProvider` | backend-phase-10 | تسهیم split — accepts any balanced legs | _tbd_ | Provider split-by-ratio to registered Shebas | 🔴 |
|
|
||||||
| `IWebhookVerifier` | backend-phase-10 | Callback auth — always valid | _tbd_ | Per-provider HMAC/signature + server-side re-verify | 🔴 |
|
|
||||||
| `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` | 🟡 |
|
| `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 | 🟡 |
|
| `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 | 🟡 |
|
| `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 | 🟡 |
|
| `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 | 🟡 |
|
| `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 | 🟡 |
|
| `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 | 🟡 |
|
||||||
| `IJobScheduler` (retention + booking expiry) | backend-phase-1 | Scheduling — in-process interval `BackgroundService`s: `PurgeOldReadNotifications` daily (`NotificationRetentionHostedService`, `Persistence/Services/Notifications/`) and **b8** `BookingRequestExpiryHostedService` (`Persistence/Services/Booking/`) running the idempotent booking-request expiry sweep every minute | _none_ | Swap to Hangfire/Quartz; register **both** jobs there; keep the purge predicate (`is_read=1 AND age>90d`) and the booking-expiry command | 🟡 |
|
| 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) | 🟡 |
|
| `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 | 🟡 |
|
| `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** | 🟡 |
|
| `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` | 🟡 |
|
| `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 | 🟡 |
|
| `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 | 🟡 |
|
||||||
| `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoice — leaves ref pending | _tbd_ | Real مودیان submission → 22-digit ref | 🔴 |
|
|
||||||
| `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` | 🟡 |
|
| `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 | 🟡 |
|
| `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 | 🟡 |
|
| `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 | 🟡 |
|
||||||
| `ILicenseVerificationService` | backend-phase-15 | eNamad / MoH establishment-permit — manual approve | _tbd_ | Real registry/API | 🔴 |
|
| `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) |
|
||||||
| `IPaymentCaptureSimulator` | backend-phase-9 | The **temporary conversion trigger** standing in for b10's real card capture. `MockPaymentCaptureSimulator` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic *succeeded* capture (a fake `gateway_reference` + a configurable `psp_fee_amount`) so `ConvertRequestToBookingCommand` is exercisable now; a config switch forces a *failed* capture (→ no booking is created). **This is the trigger, not a parallel money path** — registered singleton in `AddCrossCuttingSeams` | `Seams:PaymentCapture:ForceFailure` (default `false`), `Seams:PaymentCapture:PspFeeAmount` (default unset) | In b10: 1) build the real card capture (`payment_transactions`, PSP/IPG client, webhook verify); 2) on a real `payment_transactions.succeeded`, call `ConvertRequestToBooking` **directly** (the same conversion command that computes the three-amount split + generates sessions) instead of this seam; 3) remove the `IPaymentCaptureSimulator` registration + `MockPaymentCaptureSimulator`; the conversion/idempotency logic is unchanged | 🟡 |
|
|
||||||
| `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 🟡 |
|
| `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 | 🟡 |
|
| `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) | 🟡 |
|
| `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 | 🟡 |
|
| `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 | 🟡 |
|
| `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) |
|
| `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 | 🟡 |
|
| `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 | 🟡 |
|
||||||
@@ -57,31 +87,51 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
|
|||||||
These are in-browser mocks behind a `services/{domain}` interface, selected by a config flag. They exist so
|
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.
|
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 |
|
| 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 | 🟡 |
|
| `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 | `USE_PROFILES_MOCK` (`services/profiles/constants.ts`, default `true`) | b3 `customer_profiles/*` + `nurse_profiles/*` are 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) | 🟡 |
|
| `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 | `USE_PROFILES_MOCK` (`services/profiles/constants.ts`, default `true`) | b3 `customer_profiles/*` + `nurse_profiles/*` are 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) | 🟢 (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 | 🟡 |
|
| `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 |
|
| `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 | 🟡 |
|
| `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) | 🟡 |
|
| `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 | 🟡 |
|
| `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 | 🟡 |
|
| `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 | 🟡 |
|
| `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 | `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). **Caveat:** 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. No hook/component change | 🟡 |
|
| `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 | `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). **Caveat:** 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. 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` | `USE_BOOKINGS_MOCK` (`services/bookings/constants.ts`, default `true`) | 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 | 🟡 |
|
| `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` | `USE_BOOKINGS_MOCK` (`services/bookings/constants.ts`, default `true`) | 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 | 🟢 (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 | 🟡 |
|
| `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 | `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), make the upstream `bookingRequests` flow real, then set flag `false`. No hook/component change | 🟡 |
|
| `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 | `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), 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 | 🟡 |
|
| 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 | `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`), 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 | 🟡 |
|
| `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 | `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`), 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 | 🟡 |
|
| `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 | _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 | 🟡 |
|
| 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 | _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 | `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), then set flag `false` — `payoutsClientApi` already maps the live `GET nurse_payouts/history` 1:1 and targets the proposed slugs for the other three. No hook/component change | 🟡 |
|
| `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 | `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), then set flag `false` — `payoutsClientApi` already maps the live `GET nurse_payouts/history` 1:1 and targets the proposed slugs for the other three. 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 | 🟡 |
|
| `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 | 🟡 |
|
| `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 | 🟡 |
|
| 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 — but the linked bookings are themselves mock-primary and the wire summary lacks `unreadCount`/`lastMessageAt` (**REQ-028**), so the mock is primary. 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`** (so "Get support" from a booking jumps to the existing thread) and prepends a new ticket to the inbox; `postMessage` appends as the current viewer (tracked from the last `getTicket` so an optimistic message reconciles as **mine** in whichever app is open), throws `403` on a **closed** ticket, and throws `500` on the dev sentinel body `'/fail'` (the optimistic failure→retry path). `MOCK_VIEWER_USER_ID` (per-role "me") drives `isMine`; **`isInternal` is never modelled in the user-app types** | `USE_TICKETS_MOCK` (`services/tickets/constants.ts`, default `true`) | Deliver **REQ-028** (`unreadCount`/`lastMessageAt` on the summary + a by-booking user lookup + optional author name + optional `clientMessageId` idempotency) and make the upstream bookings flow real, then set flag `false` — `ticketsClientApi` already maps the live b15 routes 1:1 (drops any leaked internal message defensively). No hook/component change | 🟡 |
|
| `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 — but the linked bookings are themselves mock-primary and the wire summary lacks `unreadCount`/`lastMessageAt` (**REQ-028**), so the mock is primary. 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`** (so "Get support" from a booking jumps to the existing thread) and prepends a new ticket to the inbox; `postMessage` appends as the current viewer (tracked from the last `getTicket` so an optimistic message reconciles as **mine** in whichever app is open), throws `403` on a **closed** ticket, and throws `500` on the dev sentinel body `'/fail'` (the optimistic failure→retry path). `MOCK_VIEWER_USER_ID` (per-role "me") drives `isMine`; **`isInternal` is never modelled in the user-app types** | `USE_TICKETS_MOCK` (`services/tickets/constants.ts`, default `true`) | Deliver **REQ-028** (`unreadCount`/`lastMessageAt` on the summary + a by-booking user lookup + optional author name + optional `clientMessageId` idempotency) and make the upstream bookings flow real, then set flag `false` — `ticketsClientApi` already maps the live b15 routes 1:1 (drops any leaked internal message defensively). No hook/component change | 🟢 (real, refinement-phase-4) |
|
||||||
| `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 | 🟡 |
|
| `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` | `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), then set flag `false`. No hook/component change | 🟡 |
|
| `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` | `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), 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) | `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`), then set flag `false` — `partnerCenterClientApi` maps the live admin routes and targets the proposed portal slugs. 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) | `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`), 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 | 🟡 |
|
| 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,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.
|
||||||
+140
-36
@@ -35,7 +35,7 @@ You are a **senior .NET software engineer** working on this codebase. That means
|
|||||||
- **EF Core 10** + **SQL Server** (Repository + Unit of Work pattern)
|
- **EF Core 10** + **SQL Server** (Repository + Unit of Work pattern)
|
||||||
- **ASP.NET Core Identity** with **JWE** (signed + AES-128-encrypted JWT), OTP, and dynamic permission authorization
|
- **ASP.NET Core Identity** with **JWE** (signed + AES-128-encrypted JWT), OTP, and dynamic permission authorization
|
||||||
- **Mapster** for mapping, **FluentValidation** for validation, **Serilog** for structured logging
|
- **Mapster** for mapping, **FluentValidation** for validation, **Serilog** for structured logging
|
||||||
- **OpenTelemetry** + **prometheus-net** for observability, **NSwag** for OpenAPI, **Asp.Versioning** for versioning
|
- **OpenTelemetry** (metrics + tracing; Prometheus-scrape at `/metrics`, opt-in OTLP export) for observability, **NSwag** for OpenAPI, **Asp.Versioning** for versioning
|
||||||
- **xUnit** + **NSubstitute** for tests
|
- **xUnit** + **NSubstitute** for tests
|
||||||
- All NuGet versions are centrally pinned in `Directory.Packages.props`
|
- All NuGet versions are centrally pinned in `Directory.Packages.props`
|
||||||
|
|
||||||
@@ -56,9 +56,19 @@ You are a **senior .NET software engineer** working on this codebase. That means
|
|||||||
| Update DB | `dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
|
| Update DB | `dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
|
||||||
|
|
||||||
**Default URL:** `https://localhost:5002` — Swagger at `/swagger`.
|
**Default URL:** `https://localhost:5002` — Swagger at `/swagger`.
|
||||||
On boot, `Program.cs` calls `ApplyMigrationsAsync()`, `SeedDefaultUsersAsync()`, `SeedPaymentGatewaysAsync()`
|
**Migrations are split from boot (refinement-phase-7).** `dotnet run -- migrate` (the deploy-time one-shot / a CI
|
||||||
— and, **only in Development**, `SeedDemoWorldAsync()` (the demo marketplace seeder, see Persistence below).
|
`dotnet ef database update`) applies migrations + the idempotent seeders, then exits — so multi-instance boots never
|
||||||
A reachable SQL Server is required to start.
|
race on DDL and the runtime login needs no permanent DDL rights. **In Development**, boot still migrates + seeds for
|
||||||
|
convenience: `Program.cs` calls `ApplyMigrationsAsync()` + `SeedDefaultUsersAsync()` (roles always; a bootstrap admin
|
||||||
|
**only if `Seed:AdminUsername`/`Seed:AdminPassword` are configured** — never a committed credential) + the
|
||||||
|
Development-only `SeedPaymentGatewaysAsync()` (sandbox gateway) + `SeedDemoWorldAsync()` (demo marketplace, see
|
||||||
|
Persistence below). **In deployed environments**, boot instead only *checks* the schema is current
|
||||||
|
(`EnsureSchemaUpToDateAsync` — fail fast on a pending migration) and seeds roles/break-glass admin (idempotent). A
|
||||||
|
reachable SQL Server is required to start. Startup **fails fast**
|
||||||
|
(`StartupSecretsGuard`) if a load-bearing secret — the DB connection strings, and in deployed environments the
|
||||||
|
JWE + field-encryption keys — is missing or left at its committed `SET_VIA_USER_SECRETS_OR_ENV` placeholder
|
||||||
|
(refinement-phase-5). Development supplies working dev-only crypto keys via `appsettings.Development.json`; only
|
||||||
|
the connection string must come from user-secrets (see [RUNBOOK](../dev/post-phase/refinement/RUNBOOK.md)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -83,12 +93,12 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
|
|||||||
src/
|
src/
|
||||||
├── Core/
|
├── Core/
|
||||||
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), Payments/ (b10 ledger/txn/webhook/gateway + LedgerPosting; b11 adds Refunds/ + Invoices/), Bnpl/ (b12 BnplTransaction + BnplStatus/BnplTransitions/BnplEligibilityStatus/BnplProviderCodes — the net-of-fee card-payment model), Payouts/ (b13 NursePayoutBatch/NursePayout/NursePayoutBookingLink + PayoutBatchStatus/PayoutStatus/*Transitions — the weekly payout run), Reviews/ (b14 Review (IAuditable) + ReviewModerationStatus/ReviewModerationAction codes + ReviewTagMaster/ReviewTagLink + PatientCareRecord — moderated reviews, tag vocab & patient-scoped encrypted clinical notes), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
|
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), Payments/ (b10 ledger/txn/webhook/gateway + LedgerPosting; b11 adds Refunds/ + Invoices/), Bnpl/ (b12 BnplTransaction + BnplStatus/BnplTransitions/BnplEligibilityStatus/BnplProviderCodes — the net-of-fee card-payment model), Payouts/ (b13 NursePayoutBatch/NursePayout/NursePayoutBookingLink + PayoutBatchStatus/PayoutStatus/*Transitions — the weekly payout run), Reviews/ (b14 Review (IAuditable) + ReviewModerationStatus/ReviewModerationAction codes + ReviewTagMaster/ReviewTagLink + PatientCareRecord — moderated reviews, tag vocab & patient-scoped encrypted clinical notes), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
|
||||||
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); Payments area = the b10 money core (initiate/webhook/confirm-post-ledger/nurse-payable-balance); Refunds + Invoices areas = the b11 reversal leg (create refund/write-off clawback/list/refund-status; issue invoice/get invoice); Bnpl area = the b12 provider-financed-installment checkout (eligibility/initiate/verify/settle/revert/callback/status + BookingConversion shared with b10); Payouts area = the b13 weekly payout engine (compute-eligible/generate-batch/process/retry/mark-failed + admin batch detail/list + nurse history; PayoutSettlement shared ledger+clawback-netting step); Reviews area = the b14 reviews & ratings (submit/moderate/attach-tags + public list/tag-aggregates + admin moderation-queue; RecomputeNurseRating from-source helper + ReviewCache); PatientCareRecords area = the b14 encrypted patient-scoped clinical notes (write/history under strict clinical access); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + Contracts/Reviews IReviewModerationService (AI review pre-screen seam) + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository + IReviewRepository + IPatientCareRecordRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
|
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); Payments area = the b10 money core (initiate/webhook/confirm-post-ledger/nurse-payable-balance); Refunds + Invoices areas = the b11 reversal leg (create refund/confirm-settlement/mark-failed [refinement-phase-6: the BNPL/manual `processing → succeeded` clearing]/write-off clawback/list/refund-status; issue invoice/get invoice); Bnpl area = the b12 provider-financed-installment checkout (eligibility/initiate/verify/settle/revert/callback/status + BookingConversion shared with b10); Payouts area = the b13 weekly payout engine (compute-eligible/generate-batch/process/retry/mark-failed + admin batch detail/list + nurse history; PayoutSettlement shared ledger+clawback-netting step); Reviews area = the b14 reviews & ratings (submit/moderate/attach-tags + public list/tag-aggregates + admin moderation-queue; RecomputeNurseRating from-source helper + ReviewCache); PatientCareRecords area = the b14 encrypted patient-scoped clinical notes (write/history under strict clinical access); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + Contracts/Reviews IReviewModerationService (AI review pre-screen seam) + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository + IReviewRepository + IPatientCareRecordRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
|
||||||
├── Infrastructure/
|
├── Infrastructure/
|
||||||
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed + ReviewsConfig/ — b14 reviews/tags-master (seeded)/tag-links/patient-care-records configs), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository + b14 ReviewRepository + PatientCareRecordRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch + Booking/ = BookingRequestExpiryHostedService)
|
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed + ReviewsConfig/ — b14 reviews/tags-master (seeded)/tag-links/patient-care-records configs), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository + b14 ReviewRepository + PatientCareRecordRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + Scheduling/ = RecurringJobSchedulerHostedService + Jobs/ (the IRecurringJob crons — refinement-phase-7) + Search/ = SearchIndexMaintainer + SqlNurseSearch)
|
||||||
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
|
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
|
||||||
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator + MockBankTransferProvider + MockReviewModerationService) + AddCrossCuttingSeams
|
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator + MockBankTransferProvider + MockReviewModerationService) + AddCrossCuttingSeams
|
||||||
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
|
│ └── Baya.Infrastructure.Monitoring HealthChecks (live/ready split + IObjectStorage write-probe → refs Baya.Application), OpenTelemetry (one stack: metrics + tracing, opt-in OTLP)
|
||||||
├── API/
|
├── API/
|
||||||
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Development-only Dev (dev/last_otp OTP helper, 404 outside Development) + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices + customer CheckoutBnpl + public WebhooksBnpl + admin AdminBnpl + admin AdminPayouts + nurse NursePayouts + customer BookingReviews (submit) + owner/admin Reviews (tags + moderate status) + admin AdminReviews (moderation queue) + public Nurses (reviews + review_tags) + nurse/owner/admin PatientCareRecords), appsettings*.json
|
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Development-only Dev (dev/last_otp OTP helper, 404 outside Development) + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices + customer CheckoutBnpl + public WebhooksBnpl + admin AdminBnpl + admin AdminPayouts + nurse NursePayouts + customer BookingReviews (submit) + owner/admin Reviews (tags + moderate status) + admin AdminReviews (moderation queue) + public Nurses (reviews + review_tags) + nurse/owner/admin PatientCareRecords), appsettings*.json
|
||||||
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
|
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
|
||||||
@@ -114,6 +124,30 @@ Application reference Infrastructure or the API — this is a hard rule.
|
|||||||
real provider is a registration change — handlers depend only on the contract. Audit fields are
|
real provider is a registration change — handlers depend only on the contract. Audit fields are
|
||||||
stamped by `AuditFieldInterceptor` (Persistence), not in handlers.
|
stamped by `AuditFieldInterceptor` (Persistence), not in handlers.
|
||||||
|
|
||||||
|
**External rails go real — config-selected vendor adapters (refinement-phase-8).** Every vendor rail now has a
|
||||||
|
**real HTTP adapter** in `Baya.Infrastructure.CrossCutting/Seams/Real/`, **config-selected** by a per-rail
|
||||||
|
`Seams:*:Provider` selector in `AddCrossCuttingSeams` (default = the mock, so an unconfigured env is unchanged;
|
||||||
|
a typo falls closed to the mock). Real adapters use `HttpClient` (typed via `IHttpClientFactory`) +
|
||||||
|
`System.Text.Json` + BCL crypto — **no new NuGet packages**; credentials come from `Seams:*` (user-secrets/env,
|
||||||
|
never committed). Swapping is a registration change; **no handler is touched**. The adapters:
|
||||||
|
`KavenegarSmsSender` (`Sms:Provider=kavenegar` — **launch-critical**; when a real provider is selected the
|
||||||
|
Development OTP-in-logs bridge is **disabled**, so the OTP is never logged), `Finnotech{Shahkar,IdentityKyc,
|
||||||
|
BankAccountOwnership}` (`{Shahkar,IdentityKyc,BankOwnership}:Provider=finnotech`, shared `Seams:Finnotech`
|
||||||
|
creds), `NeshanGeocoder` (`Geocoding:Provider=neshan`), `S3ObjectStorage` (`ObjectStorage:Provider=s3` — MinIO/
|
||||||
|
S3/ArvanCloud via **manual AWS SigV4**, presigned GET = the real b6 signed-URL contract), `ZarinPalPaymentProvider`
|
||||||
|
+ `HmacWebhookVerifier` (per-provider HMAC over the raw body) + `ProviderSettlementSplitProvider`
|
||||||
|
(`Payments:Provider=zarinpal`), `SnappPayBnplProvider`/`DigipayBnplProvider` + `ConfiguredBnplProviderResolver`
|
||||||
|
(`Bnpl:Provider=real`; **`balinyaar` = in-house, resolves to the net-of-fee model, no external API**),
|
||||||
|
`JibitBankTransferProvider` (`BankTransfer:Provider=jibit` — **async rail**: accepts as `submitted`, the
|
||||||
|
reconciliation callback `POST webhooks/payouts/{provider}` → `ReconcilePayoutBatchCommand` [HMAC-verified] flips
|
||||||
|
`submitted → paid/failed`), and `MoadianClient` (`Moadian:Provider=moadian`) with the `MoadianReconciliationJob`
|
||||||
|
`IRecurringJob` (6 h, walks `pending/submitted → registered`). **6.4:** `IPaymentCaptureSimulator` is out of the
|
||||||
|
production registration — prod gets the fail-closed `DisabledPaymentCaptureSimulator`; Dev/Testing re-register the
|
||||||
|
succeeding `MockPaymentCaptureSimulator` (the `bookings/convert` path is a Dev/Testing affordance — prod converts
|
||||||
|
via the b10 webhook confirm). **5.6:** `ICredentialVerifier`/`ILicenseVerificationService` stay mock —
|
||||||
|
**manual MoH/INO/eNamad review is the intended MVP** (no public B2B API). `ICurrencyNormalizer` is already
|
||||||
|
config-driven (the real impl). See the mocks-registry for the per-rail config keys.
|
||||||
|
|
||||||
**Platform-signal facades (backend-phase-1).** The cross-cutting marketplace tables live in a dedicated
|
**Platform-signal facades (backend-phase-1).** The cross-cutting marketplace tables live in a dedicated
|
||||||
**`ops` schema** (mirroring how Identity uses `usr`): `PlatformConfigs`, `AuditLogs`, `SystemEvents`,
|
**`ops` schema** (mirroring how Identity uses `usr`): `PlatformConfigs`, `AuditLogs`, `SystemEvents`,
|
||||||
`IranianHolidays`, `Notifications`, `SupportAlerts`. Because they are DB-backed, their Application
|
`IranianHolidays`, `Notifications`, `SupportAlerts`. Because they are DB-backed, their Application
|
||||||
@@ -122,11 +156,13 @@ contracts — `IPlatformConfig` (typed cached config), `IHolidayCalendar` (bank-
|
|||||||
trail), `INotificationService` (per-user notification reads/commands), `ISupportAlertService` (internal
|
trail), `INotificationService` (per-user notification reads/commands), `ISupportAlertService` (internal
|
||||||
worklist) — are implemented in **`Baya.Infrastructure.Persistence/Services/`** and registered by
|
worklist) — are implemented in **`Baya.Infrastructure.Persistence/Services/`** and registered by
|
||||||
`AddPersistenceServices`, *not* in CrossCutting. The real `INotificationDispatcher` (in-app
|
`AddPersistenceServices`, *not* in CrossCutting. The real `INotificationDispatcher` (in-app
|
||||||
`notifications` write) also lives there and **supersedes** the b0 log stub. The
|
`notifications` write) also lives there and **supersedes** the b0 log stub. Other domains call these
|
||||||
`NotificationRetentionHostedService` (the retention/`IJobScheduler` seam) is registered as a hosted
|
contracts; they never re-create the tables. The
|
||||||
service there too. Other domains call these contracts; they never re-create the tables. The
|
|
||||||
`AuditFieldInterceptor` additionally writes an append-only `audit_logs` row for any `IAuditable` entity
|
`AuditFieldInterceptor` additionally writes an append-only `audit_logs` row for any `IAuditable` entity
|
||||||
(currently `PlatformConfig`) in the same transaction as the change.
|
(`PlatformConfig`, `PartnerCenter`, `Review`, and — refinement-phase-6 — the admin-decided money & trust
|
||||||
|
entities `Refund`, `NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification`; encrypted columns
|
||||||
|
like `NursePayout.IbanSnapshot` carry `[AuditRedacted]` so the diff records a marker, never plaintext) in the
|
||||||
|
same transaction as the change.
|
||||||
|
|
||||||
**Identity profiles, patients & nurse bank accounts (backend-phase-3).** On top of the b2 auth spine,
|
**Identity profiles, patients & nurse bank accounts (backend-phase-3).** On top of the b2 auth spine,
|
||||||
the `usr` schema gains four role-attached tables: `NurseProfiles` (1:1 with `Users`; guarded
|
the `usr` schema gains four role-attached tables: `NurseProfiles` (1:1 with `Users`; guarded
|
||||||
@@ -233,8 +269,8 @@ b9/b10). One customer requests one nurse for a patient/variant/address/date; the
|
|||||||
30-minute payment window) or rejects before a frozen response deadline; unanswered/unpaid requests auto-expire.
|
30-minute payment window) or rejects before a frozen response deadline; unanswered/unpaid requests auto-expire.
|
||||||
Features under `Baya.Application/Features/Booking/{Commands|Queries}/`; config in
|
Features under `Baya.Application/Features/Booking/{Commands|Queries}/`; config in
|
||||||
`Persistence/Configuration/BookingConfig/`; per-domain repo (`IBookingRequestRepository`) on `IUnitOfWork`;
|
`Persistence/Configuration/BookingConfig/`; per-domain repo (`IBookingRequestRepository`) on `IUnitOfWork`;
|
||||||
the recurring sweep is `Persistence/Services/Booking/BookingRequestExpiryHostedService` (reuses the b1
|
the recurring expiry sweep is the `booking_request_expiry` `IRecurringJob` run by the scheduler (see
|
||||||
`IJobScheduler`/`BackgroundService` seam). Load-bearing rules:
|
"Unattended operation" below — refinement-phase-7 re-homed it from a standalone hosted service). Load-bearing rules:
|
||||||
- **No money, ever, and no `bookings` row.** A request carries no price/total; accept only opens the payment
|
- **No money, ever, and no `bookings` row.** A request carries no price/total; accept only opens the payment
|
||||||
window. b9 consumes an `accepted_awaiting_payment` request → creates the booking → sets it `converted`.
|
window. b9 consumes an `accepted_awaiting_payment` request → creates the booking → sets it `converted`.
|
||||||
- **Two-stage clinical disclosure (stage 1).** The nurse sees **only** the unencrypted, limited `customer_notes`
|
- **Two-stage clinical disclosure (stage 1).** The nurse sees **only** the unencrypted, limited `customer_notes`
|
||||||
@@ -350,16 +386,23 @@ per-domain repos `IRefundRepository` + `IInvoiceRepository` on `IUnitOfWork`; co
|
|||||||
external reference (`gateway_refund_reference` vs `external_revert_reference`), and the ETA differ (card =
|
external reference (`gateway_refund_reference` vs `external_revert_reference`), and the ETA differ (card =
|
||||||
immediate `succeeded` + clearing posts now; BNPL = `processing` + `expected_customer_refund_eta` ≈ now + config
|
immediate `succeeded` + clearing posts now; BNPL = `processing` + `expected_customer_refund_eta` ≈ now + config
|
||||||
business days, clearing deferred to reconciliation). The `refund_payable ↔ escrow_held` clearing posts only
|
business days, clearing deferred to reconciliation). The `refund_payable ↔ escrow_held` clearing posts only
|
||||||
once the customer cash-back confirms.
|
once the customer cash-back confirms — **reached (refinement-phase-6) by `ConfirmRefundSettlementCommand`**
|
||||||
|
(admin `POST admin_refunds/{id}/confirm_settlement` + the BNPL cash-back callback branch), which transitions
|
||||||
|
`processing → succeeded`, stamps the settled instant, and posts `LedgerPosting.RefundPayableClearing` in the
|
||||||
|
same commit (idempotent under `booking:{id}:refund`); `MarkRefundSettlementFailedCommand` (`.../mark_failed`)
|
||||||
|
is the counterpart. **The refund row is now persisted (approved) *before* the external channel call** — the
|
||||||
|
crash-window fix (claim-first / execute-second), matching the webhook handler.
|
||||||
- **Invoices: VAT on the commission line only, sequential number.** `IssueInvoiceCommand` computes
|
- **Invoices: VAT on the commission line only, sequential number.** `IssueInvoiceCommand` computes
|
||||||
`vat_irr = round(platform_commission_irr × vat_rate)` (config `vat_rate`, default 0.10; `vat_rate = 0` ⇒ 0),
|
`vat_irr = round(platform_commission_irr × vat_rate)` (config `vat_rate`, default 0.10; `vat_rate = 0` ⇒ 0),
|
||||||
never on the nurse payout, and draws a gap-free `invoice_number` from the `InvoiceNumberSequences` counter row
|
never on the nurse payout, and draws a gap-free `invoice_number` from the `InvoiceNumberSequences` counter row
|
||||||
(locked + committed with the invoice, portable across SQL Server/SQLite — no DB sequence). Idempotent per
|
(locked + committed with the invoice, portable across SQL Server/SQLite — no DB sequence). Idempotent per
|
||||||
booking (`UNIQUE(booking_id)`). `IMoadianClient` (introduced here; `MockMoadianClient` in CrossCutting) submits
|
booking (`UNIQUE(booking_id)`). `IMoadianClient` (introduced here; `MockMoadianClient` in CrossCutting) submits
|
||||||
to سامانه مودیان — mock leaves `moadian_status = pending` / no ref (config can force `registered`).
|
to سامانه مودیان — mock leaves `moadian_status = pending` / no ref (config can force `registered`).
|
||||||
- **Forward-deps as nullable columns, no FK.** `refunds.ticket_id` (tickets → b15; "ticket required" is the
|
- **Forward-dep columns — FKs added in refinement-phase-6.** `refunds.ticket_id` (→ `messaging.Tickets`),
|
||||||
config-gated `refund_ticket_required` rule, off by default), `nurse_clawbacks.original_payout_id` /
|
`nurse_clawbacks.original_payout_id` / `recovered_in_payout_id` (→ `payouts.NursePayouts`),
|
||||||
`recovered_in_payout_id` (nurse_payouts → b13), `invoices.partner_center_id` (partner_centers → b15). The
|
`invoices.partner_center_id` (→ `partner.PartnerCenters`, + index) now carry real FKs (`ON DELETE NO ACTION`;
|
||||||
|
all nullable) — b15 unconditionally auto-opens the refund ticket so `refunds.ticket_id` is always non-null, and
|
||||||
|
the orphaned `refund_ticket_required` config key was retired (its rule had no consumer left). The
|
||||||
data-model's `manual_bank` channel is stored/served as the canonical wire code **`manual`**. `IBnplProvider` is
|
data-model's `manual_bank` channel is stored/served as the canonical wire code **`manual`**. `IBnplProvider` is
|
||||||
introduced here as a **thin local stub** so the `bnpl_revert` path runs before b12 merges — **b12 owns the real
|
introduced here as a **thin local stub** so the `bnpl_revert` path runs before b12 merges — **b12 owns the real
|
||||||
seam definition**.
|
seam definition**.
|
||||||
@@ -497,6 +540,52 @@ controllers `TicketsController` / `AdminTicketsController` / `AdminPartnerCenter
|
|||||||
manual-approve at MVP; `VerifyPartnerCenter` records the human decision. There is **no** telephony/VoIP seam
|
manual-approve at MVP; `VerifyPartnerCenter` records the human decision. There is **no** telephony/VoIP seam
|
||||||
(the emergency call is an out-of-platform `tel:` link by design). This is the last backend phase.
|
(the emergency call is an out-of-platform `tel:` link by design). This is the last backend phase.
|
||||||
|
|
||||||
|
**Unattended operation — the recurring-job scheduler (refinement-phase-7).** A single in-process scheduler,
|
||||||
|
`Persistence/Services/Scheduling/RecurringJobSchedulerHostedService`, drives every registered `IRecurringJob`
|
||||||
|
(`Services/Scheduling/Jobs/`) on its own cadence — replacing the two stand-alone `PeriodicTimer` hosted services
|
||||||
|
and giving the previously admin-manual sweeps a schedule, **using no new infrastructure** (SQL Server stays the
|
||||||
|
only external dependency). Jobs, each reading its seeded `platform_configs` cadence key via `IPlatformConfig`:
|
||||||
|
`booking_request_expiry` (1 min const) · `notification_retention` (24 h const) · `verification_expiry_scan`
|
||||||
|
(`verification_expiry_scan_cadence_hours`) · `no_show_sweep` (`no_show_scan_cadence_hours`) ·
|
||||||
|
`weekly_payout_generation` (`nurse_payout_interval_days`) · `MoadianReconciliationJob` (6 h, refinement-phase-8) ·
|
||||||
|
`audit_log_retention` (`audit_retention_scan_cadence_hours`, refinement-phase-9). Load-bearing rules:
|
||||||
|
- **Add a cron = implement `IRecurringJob` + one `AddSingleton<IRecurringJob, …>()`** in `AddPersistenceServices`.
|
||||||
|
Phase 8 registers the Moadian reconciliation + refund-settlement poll exactly this way. The scheduler owns the
|
||||||
|
per-tick DI scope, error isolation (a throwing tick never kills the loop), and the lock; a job says only *how
|
||||||
|
often* and *what one idempotent run does*.
|
||||||
|
- **Jobs must be idempotent** — a retry (or a second instance once the lock is Redis-backed) must never double-pay
|
||||||
|
or double-post; the DB uniques/state-machines are the backstop. Each tick runs under
|
||||||
|
`IDistributedLock("scheduler:{name}")` — in-proc today, the **>1-instance scale-out gate** (swap the seam to
|
||||||
|
Redis to serialize ticks across nodes; single-instance MVP needs neither Redis nor Hangfire/Quartz).
|
||||||
|
- **Money movement stays human-approved.** The payout job schedules *generation* only (a `draft` batch, recorded
|
||||||
|
system-initiated — `NursePayoutBatch.InitiatedByAdminId` is nullable = "no human initiator"); the irreversible
|
||||||
|
`process` step remains an explicit admin action. The command's `SystemInitiated` flag is scheduler-only —
|
||||||
|
`AdminPayoutsController` neutralizes any request-supplied value.
|
||||||
|
- **Admin manual triggers remain overrides** (the same idempotent commands). The scheduler is **dormant under the
|
||||||
|
`Testing` environment** so integration tests stay deterministic; each job/command is unit-tested directly.
|
||||||
|
- **Audit-log retention (refinement-phase-9 §9.4)** is an `IRecurringJob` (`AuditLogRetentionJob`) over the
|
||||||
|
append-only `ops.AuditLogs`: a **two-tier** sweep via `IAuditLogger.PurgeExpiredAsync` — financial/verification
|
||||||
|
entity types (`Refund`/`NurseClawback`/`NursePayout`/`NursePayoutBatch`/`NurseVerification`/`PlatformConfig`/
|
||||||
|
`PartnerCenter`) keep `audit_retention_financial_days` (default 2555 ≈ 7 yr); everyday rows
|
||||||
|
`audit_retention_general_days` (default 730 ≈ 2 yr). Oldest-first, capped, id-keyed delete; idempotent.
|
||||||
|
|
||||||
|
**Observability (refinement-phase-9).** One **OpenTelemetry** stack (`Baya.Infrastructure.Monitoring`,
|
||||||
|
`SetupOpenTelemetry`): metrics (runtime + ASP.NET Core + the `mediator_meter` histogram) scraped at `/metrics` via
|
||||||
|
the OTel Prometheus exporter, and **tracing** (ASP.NET Core + EF Core) sharing `service.name = Baya.Web.Api`. The
|
||||||
|
duplicate prometheus-net stack was removed. **OTLP export (traces + metrics) is opt-in** — wired only when
|
||||||
|
`OpenTelemetry:Otlp:Endpoint` is set, so an MVP with Prometheus alone runs unchanged. `ApiResult.RequestId` is the
|
||||||
|
W3C trace id (`Activity.Current.TraceId`, `Activity.DefaultIdFormat = W3C`), so a support ticket maps 1:1 to a
|
||||||
|
trace. **Health checks split** (`ConfigureHealthChecks`/`UseHealthChecks`): `/healthz/live` (process, dependency-
|
||||||
|
free), `/healthz/ready` (app DB + `logDb` [deployed only] + an `IObjectStorage` write-probe), `/HealthCheck`
|
||||||
|
(aggregate, kept for compat). **Logs:** deployed envs write **Information+** to `Baya_Logs` (framework categories
|
||||||
|
held at Warning); **no PII/secrets** — the mock SMS sender never logs the OTP code; clinical text/IBANs are
|
||||||
|
encrypted/masked. The dead Elasticsearch sink + package were removed (SQL sink is the deployed default; set the
|
||||||
|
OTLP collector to ship logs off-box). **gRPC reflection is Development-only** (`GrpcPluginStartup` gates
|
||||||
|
`AddGrpcReflection`/`MapGrpcReflectionService` on `IsDevelopment`); the plugin shares the mixed-protocol Kestrel
|
||||||
|
listener. **`TicketMessage.Body` is encrypted at rest** through `IFieldEncryptor` (converter in
|
||||||
|
`ApplicationDbContext`; column widened to `nvarchar(max)`; the 4000-char cap stays a boundary-validation rule) —
|
||||||
|
ticket bodies are the refund/dispute paper trail (phone numbers, addresses, clinical detail).
|
||||||
|
|
||||||
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
|
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
|
||||||
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
|
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
|
||||||
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
|
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
|
||||||
@@ -510,25 +599,29 @@ only canonical if it stays accurate.
|
|||||||
Service registration is composed from per-layer extension methods (each project's `ServiceConfiguration/`):
|
Service registration is composed from per-layer extension methods (each project's `ServiceConfiguration/`):
|
||||||
|
|
||||||
```
|
```
|
||||||
ConfigureHealthChecks() · SetupOpenTelemetry()
|
builder.ValidateRequiredSecrets() // refinement-phase-5: fail fast on missing/placeholder DB + crypto secrets
|
||||||
|
ConfigureHealthChecks() · SetupOpenTelemetry() // refinement-phase-9: live/ready health split + object-storage probe; one OTel stack (metrics + tracing, opt-in OTLP)
|
||||||
AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate)
|
AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate)
|
||||||
RegisterIdentityServices(...) // Identity, JWT/JWE, authorization policies, ICurrentUser + IHttpContextAccessor
|
RegisterIdentityServices(…, requireHttpsMetadata) // Identity, JWT/JWE (RequireHttpsMetadata on outside Dev/Testing), ICurrentUser
|
||||||
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories
|
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories, the IRecurringJob crons + RecurringJobSchedulerHostedService (refinement-phase-7)
|
||||||
AddCrossCuttingSeams(config) // IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher (mocks)
|
AddCrossCuttingSeams(config) // IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher (mocks)
|
||||||
AddWebFrameworkServices() // API versioning + snake_case routing
|
AddWebFrameworkServices() // API versioning + snake_case routing
|
||||||
AddCorsPolicies(config) // browser CORS policy from Cors:AllowedOrigins (refinement-phase-0; default http://localhost:3000 in Dev)
|
AddCorsPolicies(config) // browser CORS policy from Cors:AllowedOrigins (refinement-phase-0; default http://localhost:3000 in Dev)
|
||||||
AddRateLimitingPolicies() // built-in rate limiter: per-IP global + named (otp/auth/sensitive)
|
AddForwardedHeadersConfiguration(config) // refinement-phase-5: trust ForwardedHeaders:KnownProxies/KnownNetworks so the rate limiter sees the real client IP behind a proxy
|
||||||
|
AddRateLimitingPolicies() // built-in rate limiter: per-resolved-IP global + named (otp/auth/sensitive/webhook)
|
||||||
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
|
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
|
||||||
ConfigureGrpcPluginServices()
|
ConfigureGrpcPluginServices(builder.Environment) // refinement-phase-9: gRPC reflection registered only in Development
|
||||||
// Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates ISmsSender to capture each
|
// Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates ISmsSender to capture each
|
||||||
// OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development.
|
// OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development.
|
||||||
```
|
```
|
||||||
|
|
||||||
Pipeline order: exception handler → Swagger → routing → **CORS → rate limiter → authentication →
|
Pipeline order: **forwarded headers** → exception handler → Swagger → routing → **CORS → rate limiter →
|
||||||
authorization** → controllers → metrics → health checks → gRPC. `UseCors(...)` (refinement-phase-0) sits
|
authentication → authorization** → controllers → metrics → health checks → gRPC. `UseForwardedHeaders()`
|
||||||
**after `UseRouting()` and before `UseRateLimiter()`** so a pre-flight `OPTIONS` is answered before the
|
(refinement-phase-5) is **first** so the resolved client IP (`X-Forwarded-For` from a trusted proxy) is in
|
||||||
limiter/auth run; `UseRateLimiter()` is placed **before** `UseAuthentication()` so over-limit auth/OTP
|
place before the rate limiter partitions on it. `UseCors(...)` (refinement-phase-0) sits **after
|
||||||
attempts are rejected (`429`) before hitting the auth stack.
|
`UseRouting()` and before `UseRateLimiter()`** so a pre-flight `OPTIONS` is answered before the limiter/auth
|
||||||
|
run; `UseRateLimiter()` is placed **before** `UseAuthentication()` so over-limit auth/OTP attempts are
|
||||||
|
rejected (`429`) before hitting the auth stack.
|
||||||
|
|
||||||
When adding new infrastructure, expose it as an extension method and call it from `Program.cs` —
|
When adding new infrastructure, expose it as an extension method and call it from `Program.cs` —
|
||||||
never inline registrations there directly.
|
never inline registrations there directly.
|
||||||
@@ -575,8 +668,11 @@ action to `sender.Send(...)`. Full conventions are in [CONVENTIONS.md](CONVENTIO
|
|||||||
- **Development demo seeder (refinement-phase-1).** `Persistence/Services/Seeding/DemoWorldSeeder.cs`
|
- **Development demo seeder (refinement-phase-1).** `Persistence/Services/Seeding/DemoWorldSeeder.cs`
|
||||||
(+ `DemoWorldDefinitions.cs`) idempotently populates a coherent demo marketplace on top of the reference
|
(+ `DemoWorldDefinitions.cs`) idempotently populates a coherent demo marketplace on top of the reference
|
||||||
`HasData` seeds — 3 nurses (2 verified w/ variants + Tehran coverage + `approved` verification + credentials
|
`HasData` seeds — 3 nurses (2 verified w/ variants + Tehran coverage + `approved` verification + credentials
|
||||||
+ a `matched_national_id` bank account, 1 unverified), 2 customers (patients + addresses), and one
|
+ a `matched_national_id` bank account, 1 unverified), 2 customers (patients + addresses), **2 phone-OTP
|
||||||
cross-category required demo option group (شیفت / *Shift Type*). It writes through the real entities and
|
admins** (refinement-phase-2: a `super_admin` + a scoped `finance` operator, so the `/admin` console is
|
||||||
|
reachable through the normal phone-OTP login and `useAdminCapabilities` gating is demonstrable — admin
|
||||||
|
sub-roles are server-granted, never self-selectable), and one cross-category required demo option group
|
||||||
|
(شیفت / *Shift Type*). It writes through the real entities and
|
||||||
drives the search projection through `ISearchIndexMaintainer.RebuildAsync` (never hand-inserts index rows),
|
drives the search projection through `ISearchIndexMaintainer.RebuildAsync` (never hand-inserts index rows),
|
||||||
guarding each persona on its phone number so re-runs are a no-op. Invoked via `SeedDemoWorldAsync()`
|
guarding each persona on its phone number so re-runs are a no-op. Invoked via `SeedDemoWorldAsync()`
|
||||||
**only under `IsDevelopment()`** — never in Production/Staging. The demo world (phones, which nurse is
|
**only under `IsDevelopment()`** — never in Production/Staging. The demo world (phones, which nurse is
|
||||||
@@ -603,18 +699,26 @@ action to `sender.Send(...)`. Full conventions are in [CONVENTIONS.md](CONVENTIO
|
|||||||
process-wide singleton because EF caches the model). Equality lookups go through the deterministic
|
process-wide singleton because EF caches the model). Equality lookups go through the deterministic
|
||||||
`PhoneHash` column (UNIQUE, synced on SaveChanges — which also resets `ShahkarVerifiedAt` when the
|
`PhoneHash` column (UNIQUE, synced on SaveChanges — which also resets `ShahkarVerifiedAt` when the
|
||||||
phone actually changes). Never query `PhoneNumber == x`.
|
phone actually changes). Never query `PhoneNumber == x`.
|
||||||
- **Roles:** full vocabulary in `Domain/Entities/User/RoleNames` (seeded by `SeedDataBase`).
|
- **Roles:** full vocabulary in `Domain/Entities/User/RoleNames`; `SeedDataBase` always seeds the roles,
|
||||||
`customer`/`nurse` are self-selectable via `POST me/select_role` (audited
|
and seeds a **bootstrap admin only when `Seed:AdminUsername`/`Seed:AdminPassword` are configured**
|
||||||
`granted_by`/`granted_at`, idempotent, both can be held); admin sub-roles are internal-only and
|
(refinement-phase-5 — no more committed `admin`/`qw123321`; break-glass only, day-to-day admins come from
|
||||||
return 403 there. `user_roles.revoked_at` has a global query filter, so revoked grants disappear
|
the phone-OTP demo seeds or are provisioned out-of-band). `customer`/`nurse` are self-selectable via
|
||||||
from every role read automatically. Auth knobs (`auth_otp_resend_seconds`, `auth_otp_max_attempts`,
|
`POST me/select_role` (audited `granted_by`/`granted_at`, idempotent, both can be held); admin sub-roles are
|
||||||
|
internal-only and return 403 there. `user_roles.revoked_at` has a global query filter, so revoked grants
|
||||||
|
disappear from every role read automatically. Auth knobs (`auth_otp_resend_seconds`, `auth_otp_max_attempts`,
|
||||||
`auth_session_ttl_days`) are `platform_configs` rows read via `IPlatformConfig`.
|
`auth_session_ttl_days`) are `platform_configs` rows read via `IPlatformConfig`.
|
||||||
- Dynamic permission system: `DynamicPermissionHandler` reads `[controller]` + `[action]` route
|
- Dynamic permission system: `DynamicPermissionHandler` reads `[controller]` + `[action]` route
|
||||||
values and checks role claims. Always use `[controller]`/`[action]` tokens so the keys stay
|
values and checks role claims. Always use `[controller]`/`[action]` tokens so the keys stay
|
||||||
consistent (see CONVENTIONS.md §1 Routing).
|
consistent (see CONVENTIONS.md §1 Routing).
|
||||||
- Settings bound from `appsettings.json` → `IdentitySettings`.
|
- Settings bound from `appsettings.json` → `IdentitySettings`. **JWE keys are never committed**: the
|
||||||
|
committed values are `SET_VIA_USER_SECRETS_OR_ENV` placeholders (real ones via user-secrets/env; Development
|
||||||
|
uses dev-only keys in `appsettings.Development.json`). `RequireHttpsMetadata` is **on outside Dev/Testing**
|
||||||
|
(passed into `RegisterIdentityServices`), the access-token lifetime is `ExpirationMinutes: 60`, and
|
||||||
|
`Issuer`/`Audience` are real (`Balinyaar`/`BalinyaarClient`) — refinement-phase-5.
|
||||||
- Auth and OTP endpoints must be rate-limited (CONVENTIONS.md §11) — `request_otp`/`verify_otp` use
|
- Auth and OTP endpoints must be rate-limited (CONVENTIONS.md §11) — `request_otp`/`verify_otp` use
|
||||||
the `otp` policy, `refresh` the `auth` policy; plus a per-phone resend window via `ICacheService`.
|
the `otp` policy, `refresh` the `auth` policy; plus a per-phone resend window via `ICacheService`. The two
|
||||||
|
PSP/BNPL webhooks share the single deliberate **`webhook`** policy (bursty-tolerant, partitioned per-provider);
|
||||||
|
behind a reverse proxy the limiter partitions on the forwarded client IP (see Startup wiring).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -36,19 +36,18 @@
|
|||||||
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
||||||
<PackageVersion Include="NSwag.AspNetCore" Version="14.7.1" />
|
<PackageVersion Include="NSwag.AspNetCore" Version="14.7.1" />
|
||||||
<PackageVersion Include="NuGet.Packaging" Version="7.6.0" />
|
<PackageVersion Include="NuGet.Packaging" Version="7.6.0" />
|
||||||
|
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
|
||||||
<PackageVersion Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-beta.1" />
|
<PackageVersion Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-beta.1" />
|
||||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
|
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
|
||||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
||||||
|
<PackageVersion Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.15.1-beta.1" />
|
||||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
|
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
|
||||||
<PackageVersion Include="Pluralize.NET" Version="1.0.2" />
|
<PackageVersion Include="Pluralize.NET" Version="1.0.2" />
|
||||||
<PackageVersion Include="prometheus-net" Version="8.2.1" />
|
|
||||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
|
||||||
<PackageVersion Include="prometheus-net.AspNetCore.HealthChecks" Version="8.2.1" />
|
|
||||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageVersion Include="Serilog.Enrichers.Span" Version="3.1.0" />
|
<PackageVersion Include="Serilog.Enrichers.Span" Version="3.1.0" />
|
||||||
<PackageVersion Include="Serilog.Exceptions" Version="8.4.0" />
|
<PackageVersion Include="Serilog.Exceptions" Version="8.4.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Elasticsearch" Version="10.0.0" />
|
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.MSSqlServer" Version="10.0.0" />
|
<PackageVersion Include="Serilog.Sinks.MSSqlServer" Version="10.0.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.PeriodicBatching" Version="5.0.0" />
|
<PackageVersion Include="Serilog.Sinks.PeriodicBatching" Version="5.0.0" />
|
||||||
<PackageVersion Include="System.Linq.Async" Version="7.0.1" />
|
<PackageVersion Include="System.Linq.Async" Version="7.0.1" />
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
namespace Baya.Web.Api.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fail-fast validation that no load-bearing secret is missing or left at its committed placeholder.
|
||||||
|
/// A database connection is required in every real environment; the JWE + field-encryption keys are
|
||||||
|
/// required only in <b>deployed</b> environments (Development keeps working dev-only defaults in
|
||||||
|
/// <c>appsettings.Development.json</c>, and the "Testing" environment runs on in-memory SQLite with
|
||||||
|
/// test-injected keys). The effect: a fresh clone with no user-secrets stops at boot with a clear
|
||||||
|
/// message instead of silently connecting somewhere unintended, and a deployment can never fall back
|
||||||
|
/// to a committed placeholder key.
|
||||||
|
/// </summary>
|
||||||
|
public static class StartupSecretsGuard
|
||||||
|
{
|
||||||
|
// Substrings that mark a value as a committed placeholder, never a real secret. Any configured value
|
||||||
|
// containing one of these is treated as "not provided".
|
||||||
|
private static readonly string[] PlaceholderMarkers =
|
||||||
|
[
|
||||||
|
"SET_VIA_USER_SECRETS_OR_ENV",
|
||||||
|
"not-for-production",
|
||||||
|
"change-me",
|
||||||
|
"ShouldBe-LongerThan-16Char-SecretKey",
|
||||||
|
"16CharEncryptKey"
|
||||||
|
];
|
||||||
|
|
||||||
|
public static void ValidateRequiredSecrets(this WebApplicationBuilder builder)
|
||||||
|
{
|
||||||
|
// Integration tests boot as "Testing" over in-memory SQLite and inject their own crypto keys.
|
||||||
|
if (builder.Environment.IsEnvironment("Testing"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var config = builder.Configuration;
|
||||||
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
RequireReal(errors, "ConnectionStrings:SqlServer", config.GetConnectionString("SqlServer"));
|
||||||
|
RequireReal(errors, "ConnectionStrings:logDb", config.GetConnectionString("logDb"));
|
||||||
|
|
||||||
|
// Development supplies working dev-only keys via appsettings.Development.json; only deployed
|
||||||
|
// environments must inject real per-environment secrets (env vars / Key Vault / KMS).
|
||||||
|
if (!builder.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
RequireReal(errors, "IdentitySettings:SecretKey", config["IdentitySettings:SecretKey"]);
|
||||||
|
RequireReal(errors, "IdentitySettings:Encryptkey", config["IdentitySettings:Encryptkey"]);
|
||||||
|
RequireReal(errors, "Seams:FieldEncryption:Key", config["Seams:FieldEncryption:Key"]);
|
||||||
|
RequireReal(errors, "Seams:FieldEncryption:HashKey", config["Seams:FieldEncryption:HashKey"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Refusing to start: required secret configuration is missing or still a committed placeholder. " +
|
||||||
|
"Provide real values via user-secrets (Development) or environment variables (deployed) — see " +
|
||||||
|
"dev/post-phase/refinement/RUNBOOK.md.\n - " + string.Join("\n - ", errors));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RequireReal(List<string> errors, string key, string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
errors.Add($"{key} is not set.");
|
||||||
|
else if (PlaceholderMarkers.Any(marker => value.Contains(marker, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
errors.Add($"{key} is still a committed placeholder.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Asp.Versioning;
|
using Asp.Versioning;
|
||||||
using Baya.Application.Features.PartnerCenters.Commands.CreatePartnerCenter;
|
using Baya.Application.Features.PartnerCenters.Commands.CreatePartnerCenter;
|
||||||
|
using Baya.Application.Features.PartnerCenters.Commands.SetPartnerCenterActive;
|
||||||
using Baya.Application.Features.PartnerCenters.Commands.SponsorNurse;
|
using Baya.Application.Features.PartnerCenters.Commands.SponsorNurse;
|
||||||
using Baya.Application.Features.PartnerCenters.Commands.UpdatePartnerCenter;
|
using Baya.Application.Features.PartnerCenters.Commands.UpdatePartnerCenter;
|
||||||
using Baya.Application.Features.PartnerCenters.Commands.VerifyPartnerCenter;
|
using Baya.Application.Features.PartnerCenters.Commands.VerifyPartnerCenter;
|
||||||
@@ -48,6 +49,12 @@ public sealed class AdminPartnerCentersController(ISender sender) : BaseControll
|
|||||||
public async Task<IActionResult> SponsorNurse(long id, SponsorNurseCommand command, CancellationToken cancellationToken)
|
public async Task<IActionResult> SponsorNurse(long id, SponsorNurseCommand command, CancellationToken cancellationToken)
|
||||||
=> OperationResult(await sender.Send(command with { CenterId = id }, cancellationToken));
|
=> OperationResult(await sender.Send(command with { CenterId = id }, cancellationToken));
|
||||||
|
|
||||||
|
// Activate/suspend toggle (distinct from verify, which records licensing approval) — REQ-032.
|
||||||
|
[HttpPost("{id}/set-active")]
|
||||||
|
[ProducesOkApiResponseType<PartnerCenterDetailDto>]
|
||||||
|
public async Task<IActionResult> SetActive(long id, SetPartnerCenterActiveCommand command, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesOkApiResponseType<PagedResult<PartnerCenterListItemDto>>]
|
[ProducesOkApiResponseType<PagedResult<PartnerCenterListItemDto>>]
|
||||||
public async Task<IActionResult> List([FromQuery] ListPartnerCentersQuery query, CancellationToken cancellationToken)
|
public async Task<IActionResult> List([FromQuery] ListPartnerCentersQuery query, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ public sealed class AdminPayoutsController(ISender sender) : BaseController
|
|||||||
[HttpPost("batches")]
|
[HttpPost("batches")]
|
||||||
[ProducesOkApiResponseType<GeneratePayoutBatchResult>]
|
[ProducesOkApiResponseType<GeneratePayoutBatchResult>]
|
||||||
public async Task<IActionResult> Generate(GeneratePayoutBatchCommand command, CancellationToken cancellationToken)
|
public async Task<IActionResult> Generate(GeneratePayoutBatchCommand command, CancellationToken cancellationToken)
|
||||||
=> OperationResult(await sender.Send(command, cancellationToken));
|
// SystemInitiated is scheduler-only — neutralize any request-supplied value so an API caller can never
|
||||||
|
// record a batch without an authenticated admin initiator (refinement-phase-7).
|
||||||
|
=> OperationResult(await sender.Send(command with { SystemInitiated = false }, cancellationToken));
|
||||||
|
|
||||||
[HttpPost("batches/{id}/process")]
|
[HttpPost("batches/{id}/process")]
|
||||||
[ProducesOkApiResponseType<ExecutePayoutBatchResult>]
|
[ProducesOkApiResponseType<ExecutePayoutBatchResult>]
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Asp.Versioning;
|
using Asp.Versioning;
|
||||||
|
using Baya.Application.Features.Refunds.Commands.ConfirmRefundSettlement;
|
||||||
using Baya.Application.Features.Refunds.Commands.CreateRefund;
|
using Baya.Application.Features.Refunds.Commands.CreateRefund;
|
||||||
|
using Baya.Application.Features.Refunds.Commands.MarkRefundSettlementFailed;
|
||||||
using Baya.Application.Features.Refunds.Queries.ListRefunds;
|
using Baya.Application.Features.Refunds.Queries.ListRefunds;
|
||||||
using Baya.Application.Models.Common;
|
using Baya.Application.Models.Common;
|
||||||
using Baya.Application.Models.Refunds;
|
using Baya.Application.Models.Refunds;
|
||||||
@@ -37,4 +39,20 @@ public sealed class AdminRefundsController(ISender sender) : BaseController
|
|||||||
[ProducesOkApiResponseType<PagedResult<RefundListItemDto>>]
|
[ProducesOkApiResponseType<PagedResult<RefundListItemDto>>]
|
||||||
public async Task<IActionResult> List([FromQuery] ListRefundsQuery query, CancellationToken cancellationToken)
|
public async Task<IActionResult> List([FromQuery] ListRefundsQuery query, CancellationToken cancellationToken)
|
||||||
=> OperationResult(await sender.Send(query, cancellationToken));
|
=> OperationResult(await sender.Send(query, cancellationToken));
|
||||||
|
|
||||||
|
// Reconciliation confirmed the customer cash-back for a processing BNPL/manual refund — settle it (posts the
|
||||||
|
// deferred refund_payable ↔ escrow_held clearing). Idempotent.
|
||||||
|
[HttpPost("{id}/[action]")]
|
||||||
|
[ProducesOkApiResponseType<RefundSettlementResult>]
|
||||||
|
public async Task<IActionResult> ConfirmSettlement(long id, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(new ConfirmRefundSettlementCommand(id), cancellationToken));
|
||||||
|
|
||||||
|
// Reconciliation reported the customer cash-back did not land — fail the processing refund (no ledger moves).
|
||||||
|
[HttpPost("{id}/[action]")]
|
||||||
|
[ProducesOkApiResponseType<RefundSettlementResult>]
|
||||||
|
public async Task<IActionResult> MarkFailed(long id, MarkRefundFailedBody body, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(new MarkRefundSettlementFailedCommand(id, body.Reason), cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The mark-failed body (the id comes from the route).</summary>
|
||||||
|
public record MarkRefundFailedBody(string? Reason);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Baya.Application.Features.Booking.Commands.CancelBookingRequest;
|
|||||||
using Baya.Application.Features.Booking.Commands.CreateBookingRequest;
|
using Baya.Application.Features.Booking.Commands.CreateBookingRequest;
|
||||||
using Baya.Application.Features.Booking.Commands.RejectBookingRequest;
|
using Baya.Application.Features.Booking.Commands.RejectBookingRequest;
|
||||||
using Baya.Application.Features.Booking.Queries.GetBookingRequest;
|
using Baya.Application.Features.Booking.Queries.GetBookingRequest;
|
||||||
|
using Baya.Application.Features.Booking.Queries.GetCheckoutSummary;
|
||||||
using Baya.Application.Features.Booking.Queries.ListBookingRequests;
|
using Baya.Application.Features.Booking.Queries.ListBookingRequests;
|
||||||
using Baya.Application.Models.Booking;
|
using Baya.Application.Models.Booking;
|
||||||
using Baya.Application.Models.Common;
|
using Baya.Application.Models.Common;
|
||||||
@@ -57,4 +58,10 @@ public sealed class BookingRequestsController(ISender sender) : BaseController
|
|||||||
[ProducesOkApiResponseType<BookingRequestDto>]
|
[ProducesOkApiResponseType<BookingRequestDto>]
|
||||||
public async Task<IActionResult> Get(long id, CancellationToken cancellationToken)
|
public async Task<IActionResult> Get(long id, CancellationToken cancellationToken)
|
||||||
=> OperationResult(await sender.Send(new GetBookingRequestQuery(id), cancellationToken));
|
=> OperationResult(await sender.Send(new GetBookingRequestQuery(id), cancellationToken));
|
||||||
|
|
||||||
|
// The C6 money breakdown for an accepted-awaiting-payment request (owner-scoped, server-computed).
|
||||||
|
[HttpGet("[action]/{id}")]
|
||||||
|
[ProducesOkApiResponseType<CheckoutSummaryDto>]
|
||||||
|
public async Task<IActionResult> CheckoutSummary(long id, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(new GetCheckoutSummaryQuery(id), cancellationToken));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Asp.Versioning;
|
using Asp.Versioning;
|
||||||
using Baya.Application.Features.Reviews.Commands.SubmitReview;
|
using Baya.Application.Features.Reviews.Commands.SubmitReview;
|
||||||
|
using Baya.Application.Features.Reviews.Queries.GetMyReview;
|
||||||
|
using Baya.Application.Features.Reviews.Queries.GetReviewEligibility;
|
||||||
using Baya.Application.Models.Reviews;
|
using Baya.Application.Models.Reviews;
|
||||||
using Baya.WebFramework.Attributes;
|
using Baya.WebFramework.Attributes;
|
||||||
using Baya.WebFramework.BaseController;
|
using Baya.WebFramework.BaseController;
|
||||||
@@ -27,6 +29,18 @@ public sealed class BookingReviewsController(ISender sender) : BaseController
|
|||||||
=> OperationResult(await sender.Send(
|
=> OperationResult(await sender.Send(
|
||||||
new SubmitReviewCommand(bookingId, body.Rating, body.Body, body.TagCodes), cancellationToken));
|
new SubmitReviewCommand(bookingId, body.Rating, body.Body, body.TagCodes), cancellationToken));
|
||||||
|
|
||||||
|
// Can the caller review this booking? (completed/closed AND not already reviewed) — REQ-026.
|
||||||
|
[HttpGet("{bookingId}/review_eligibility")]
|
||||||
|
[ProducesOkApiResponseType<ReviewEligibilityDto>]
|
||||||
|
public async Task<IActionResult> ReviewEligibility(long bookingId, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(new GetReviewEligibilityQuery(bookingId), cancellationToken));
|
||||||
|
|
||||||
|
// The caller's own review for the booking (persistent "under review" state across sessions) — REQ-026.
|
||||||
|
[HttpGet("{bookingId}/my_review")]
|
||||||
|
[ProducesOkApiResponseType<MyReviewDto>]
|
||||||
|
public async Task<IActionResult> MyReview(long bookingId, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(new GetMyReviewQuery(bookingId), cancellationToken));
|
||||||
|
|
||||||
/// <summary>The review body (the booking id comes from the route).</summary>
|
/// <summary>The review body (the booking id comes from the route).</summary>
|
||||||
public record SubmitReviewBody(int Rating, string? Body, IReadOnlyList<string>? TagCodes);
|
public record SubmitReviewBody(int Rating, string? Body, IReadOnlyList<string>? TagCodes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ using Baya.Application.Features.Bookings.Commands.TransitionBookingStatus;
|
|||||||
using Baya.Application.Features.Bookings.Queries.GetBookingDetail;
|
using Baya.Application.Features.Bookings.Queries.GetBookingDetail;
|
||||||
using Baya.Application.Features.Bookings.Queries.GetCareInstructions;
|
using Baya.Application.Features.Bookings.Queries.GetCareInstructions;
|
||||||
using Baya.Application.Features.Bookings.Queries.ListBookings;
|
using Baya.Application.Features.Bookings.Queries.ListBookings;
|
||||||
|
using Baya.Application.Features.Refunds.Commands.CancelBookingAndRefund;
|
||||||
|
using Baya.Application.Features.Refunds.Queries.GetCancellationPolicyPreview;
|
||||||
using Baya.Application.Models.Booking;
|
using Baya.Application.Models.Booking;
|
||||||
using Baya.Application.Models.Common;
|
using Baya.Application.Models.Common;
|
||||||
|
using Baya.Application.Models.Refunds;
|
||||||
using Baya.WebFramework.Attributes;
|
using Baya.WebFramework.Attributes;
|
||||||
using Baya.WebFramework.BaseController;
|
using Baya.WebFramework.BaseController;
|
||||||
using Baya.WebFramework.ServiceConfiguration;
|
using Baya.WebFramework.ServiceConfiguration;
|
||||||
@@ -60,6 +63,19 @@ public sealed class BookingsController(ISender sender) : BaseController
|
|||||||
public async Task<IActionResult> Cancel(long id, CancelBookingCommand command, CancellationToken cancellationToken)
|
public async Task<IActionResult> Cancel(long id, CancelBookingCommand command, CancellationToken cancellationToken)
|
||||||
=> OperationResult(await sender.Send(command with { BookingId = id }, cancellationToken));
|
=> OperationResult(await sender.Send(command with { BookingId = id }, cancellationToken));
|
||||||
|
|
||||||
|
// Customer-initiated cancel: cancels the booking AND opens its refund in one call (REQ-019).
|
||||||
|
[HttpPost("{id}/cancel")]
|
||||||
|
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
|
||||||
|
[ProducesOkApiResponseType<RefundStatusDto>]
|
||||||
|
public async Task<IActionResult> CancelAndRefund(long id, CancelBookingAndRefundCommand command, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(command with { BookingId = id }, cancellationToken));
|
||||||
|
|
||||||
|
// Pre-cancel disclosure: the applicable policy + per-session refundability, resolved by current lead time (REQ-020).
|
||||||
|
[HttpGet("{id}/cancellation_policy")]
|
||||||
|
[ProducesOkApiResponseType<CancellationPolicyPreviewDto>]
|
||||||
|
public async Task<IActionResult> CancellationPolicy(long id, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(new GetCancellationPolicyPreviewQuery(id), cancellationToken));
|
||||||
|
|
||||||
[HttpPost("[action]/{id}")]
|
[HttpPost("[action]/{id}")]
|
||||||
[ProducesOkApiResponseType<CareInstructionsDto>]
|
[ProducesOkApiResponseType<CareInstructionsDto>]
|
||||||
public async Task<IActionResult> SubmitCareInstructions(long id, SubmitCareInstructionsCommand command, CancellationToken cancellationToken)
|
public async Task<IActionResult> SubmitCareInstructions(long id, SubmitCareInstructionsCommand command, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
using Asp.Versioning;
|
using Asp.Versioning;
|
||||||
using Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder;
|
using Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder;
|
||||||
using Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility;
|
using Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility;
|
||||||
|
using Baya.Application.Features.Bnpl.Queries.GetBnplOrderByRequest;
|
||||||
using Baya.Application.Features.Bnpl.Queries.GetBnplOrderStatus;
|
using Baya.Application.Features.Bnpl.Queries.GetBnplOrderStatus;
|
||||||
using Baya.Application.Models.Bnpl;
|
using Baya.Application.Models.Bnpl;
|
||||||
using Baya.WebFramework.Attributes;
|
using Baya.WebFramework.Attributes;
|
||||||
@@ -42,11 +43,17 @@ public sealed class CheckoutBnplController(ISender sender) : BaseController
|
|||||||
new InitiateBnplOrderCommand(body.BookingRequestId, body.ProviderCode, idempotencyKey), cancellationToken));
|
new InitiateBnplOrderCommand(body.BookingRequestId, body.ProviderCode, idempotencyKey), cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id:long}")]
|
||||||
[ProducesOkApiResponseType<BnplOrderStatusDto>]
|
[ProducesOkApiResponseType<BnplOrderStatusDto>]
|
||||||
public async Task<IActionResult> Get(long id, CancellationToken cancellationToken)
|
public async Task<IActionResult> Get(long id, CancellationToken cancellationToken)
|
||||||
=> OperationResult(await sender.Send(new GetBnplOrderStatusQuery(id, AdminView: false), cancellationToken));
|
=> OperationResult(await sender.Send(new GetBnplOrderStatusQuery(id, AdminView: false), cancellationToken));
|
||||||
|
|
||||||
|
// Reach the BNPL order from the booking request id (the return-poll holds the request id, not the order id).
|
||||||
|
[HttpGet("by_request/{bookingRequestId}")]
|
||||||
|
[ProducesOkApiResponseType<BnplOrderStatusDto>]
|
||||||
|
public async Task<IActionResult> ByRequest(long bookingRequestId, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(new GetBnplOrderByRequestQuery(bookingRequestId), cancellationToken));
|
||||||
|
|
||||||
/// <summary>The initiate body (the idempotency key comes from the <c>Idempotency-Key</c> header).</summary>
|
/// <summary>The initiate body (the idempotency key comes from the <c>Idempotency-Key</c> header).</summary>
|
||||||
public record InitiateBnplBody(long BookingRequestId, string ProviderCode);
|
public record InitiateBnplBody(long BookingRequestId, string ProviderCode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Asp.Versioning;
|
using Asp.Versioning;
|
||||||
|
using Baya.Application.Features.Identity.Commands.UploadCustomerAvatar;
|
||||||
using Baya.Application.Features.Identity.Commands.UpsertCustomerProfile;
|
using Baya.Application.Features.Identity.Commands.UpsertCustomerProfile;
|
||||||
using Baya.Application.Features.Identity.Queries.GetMyCustomerProfile;
|
using Baya.Application.Features.Identity.Queries.GetMyCustomerProfile;
|
||||||
|
using Baya.Application.Models.Common;
|
||||||
using Baya.Application.Models.Identity;
|
using Baya.Application.Models.Identity;
|
||||||
using Baya.WebFramework.Attributes;
|
using Baya.WebFramework.Attributes;
|
||||||
using Baya.WebFramework.BaseController;
|
using Baya.WebFramework.BaseController;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Baya.Web.Api.Controllers.V1;
|
namespace Baya.Web.Api.Controllers.V1;
|
||||||
@@ -27,4 +30,18 @@ public sealed class CustomerProfilesController(ISender sender) : BaseController
|
|||||||
[ProducesOkApiResponseType<CustomerProfileDto>]
|
[ProducesOkApiResponseType<CustomerProfileDto>]
|
||||||
public async Task<IActionResult> Me(CancellationToken cancellationToken)
|
public async Task<IActionResult> Me(CancellationToken cancellationToken)
|
||||||
=> OperationResult(await sender.Send(new GetMyCustomerProfileQuery(), cancellationToken));
|
=> OperationResult(await sender.Send(new GetMyCustomerProfileQuery(), cancellationToken));
|
||||||
|
|
||||||
|
// Multipart image upload → stored via IObjectStorage; the returned URL is persisted on the profile.
|
||||||
|
[HttpPost("[action]")]
|
||||||
|
[ProducesOkApiResponseType<AvatarUploadResult>]
|
||||||
|
public async Task<IActionResult> Avatar(IFormFile file, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (file is null || file.Length == 0)
|
||||||
|
return OperationResult(Baya.Application.Models.Common.OperationResult<AvatarUploadResult>.FailureResult("No file uploaded."));
|
||||||
|
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
await file.CopyToAsync(buffer, cancellationToken);
|
||||||
|
var command = new UploadCustomerAvatarCommand(buffer.ToArray(), file.ContentType, file.Length);
|
||||||
|
return OperationResult(await sender.Send(command, cancellationToken));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Asp.Versioning;
|
using Asp.Versioning;
|
||||||
|
using Baya.Application.Features.Payouts.Queries.GetNurseEarnings;
|
||||||
|
using Baya.Application.Features.Payouts.Queries.GetNurseEarningsBalance;
|
||||||
|
using Baya.Application.Features.Payouts.Queries.GetNursePayoutDetail;
|
||||||
using Baya.Application.Features.Payouts.Queries.GetNursePayoutHistory;
|
using Baya.Application.Features.Payouts.Queries.GetNursePayoutHistory;
|
||||||
using Baya.Application.Models.Common;
|
using Baya.Application.Models.Common;
|
||||||
using Baya.Application.Models.Payouts;
|
using Baya.Application.Models.Payouts;
|
||||||
@@ -25,4 +28,22 @@ public sealed class NursePayoutsController(ISender sender) : BaseController
|
|||||||
[ProducesOkApiResponseType<PagedResult<NursePayoutHistoryDto>>]
|
[ProducesOkApiResponseType<PagedResult<NursePayoutHistoryDto>>]
|
||||||
public async Task<IActionResult> History([FromQuery] GetNursePayoutHistoryQuery query, CancellationToken cancellationToken)
|
public async Task<IActionResult> History([FromQuery] GetNursePayoutHistoryQuery query, CancellationToken cancellationToken)
|
||||||
=> OperationResult(await sender.Send(query, cancellationToken));
|
=> OperationResult(await sender.Send(query, cancellationToken));
|
||||||
|
|
||||||
|
// The four-bucket balance + ledger-derived signed net payable (REQ-025).
|
||||||
|
[HttpGet("earnings_balance")]
|
||||||
|
[ProducesOkApiResponseType<NurseEarningsBalanceDto>]
|
||||||
|
public async Task<IActionResult> EarningsBalance(CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(new GetNurseEarningsBalanceQuery(), cancellationToken));
|
||||||
|
|
||||||
|
// The per-booking earnings list with server-derived money-state, filterable by state (REQ-025).
|
||||||
|
[HttpGet("earnings")]
|
||||||
|
[ProducesOkApiResponseType<PagedResult<NurseEarningsItemDto>>]
|
||||||
|
public async Task<IActionResult> Earnings([FromQuery] GetNurseEarningsQuery query, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(query, cancellationToken));
|
||||||
|
|
||||||
|
// The nurse's own payout detail — batch window + covered bookings for reconciliation (REQ-025).
|
||||||
|
[HttpGet("{id:long}")]
|
||||||
|
[ProducesOkApiResponseType<NursePayoutDetailDto>]
|
||||||
|
public async Task<IActionResult> Detail(long id, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(new GetNursePayoutDetailQuery(id), cancellationToken));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Asp.Versioning;
|
using Asp.Versioning;
|
||||||
using Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings;
|
using Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings;
|
||||||
|
using Baya.Application.Features.Identity.Commands.UploadNurseAvatar;
|
||||||
using Baya.Application.Features.Identity.Commands.UpsertNurseProfile;
|
using Baya.Application.Features.Identity.Commands.UpsertNurseProfile;
|
||||||
using Baya.Application.Features.Identity.Queries.GetMyNurseProfile;
|
using Baya.Application.Features.Identity.Queries.GetMyNurseProfile;
|
||||||
|
using Baya.Application.Models.Common;
|
||||||
using Baya.Application.Models.Identity;
|
using Baya.Application.Models.Identity;
|
||||||
using Baya.WebFramework.Attributes;
|
using Baya.WebFramework.Attributes;
|
||||||
using Baya.WebFramework.BaseController;
|
using Baya.WebFramework.BaseController;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Baya.Web.Api.Controllers.V1;
|
namespace Baya.Web.Api.Controllers.V1;
|
||||||
@@ -33,4 +36,18 @@ public sealed class NurseProfilesController(ISender sender) : BaseController
|
|||||||
[ProducesOkApiResponseType<NurseProfileDto>]
|
[ProducesOkApiResponseType<NurseProfileDto>]
|
||||||
public async Task<IActionResult> Me(CancellationToken cancellationToken)
|
public async Task<IActionResult> Me(CancellationToken cancellationToken)
|
||||||
=> OperationResult(await sender.Send(new GetMyNurseProfileQuery(), cancellationToken));
|
=> OperationResult(await sender.Send(new GetMyNurseProfileQuery(), cancellationToken));
|
||||||
|
|
||||||
|
// Multipart image upload → stored via IObjectStorage; the returned URL is persisted on the profile.
|
||||||
|
[HttpPost("[action]")]
|
||||||
|
[ProducesOkApiResponseType<AvatarUploadResult>]
|
||||||
|
public async Task<IActionResult> Avatar(IFormFile file, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (file is null || file.Length == 0)
|
||||||
|
return OperationResult(Baya.Application.Models.Common.OperationResult<AvatarUploadResult>.FailureResult("No file uploaded."));
|
||||||
|
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
await file.CopyToAsync(buffer, cancellationToken);
|
||||||
|
var command = new UploadNurseAvatarCommand(buffer.ToArray(), file.ContentType, file.Length);
|
||||||
|
return OperationResult(await sender.Send(command, cancellationToken));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Baya.Application.Features.Verification.Commands.RequestDocumentUploadUrl;
|
|||||||
using Baya.Application.Features.Verification.Commands.RunBankAccountVerification;
|
using Baya.Application.Features.Verification.Commands.RunBankAccountVerification;
|
||||||
using Baya.Application.Features.Verification.Commands.RunIdentityKyc;
|
using Baya.Application.Features.Verification.Commands.RunIdentityKyc;
|
||||||
using Baya.Application.Features.Verification.Commands.RunShahkarMatch;
|
using Baya.Application.Features.Verification.Commands.RunShahkarMatch;
|
||||||
|
using Baya.Application.Features.Verification.Commands.SubmitCredentialDetails;
|
||||||
using Baya.Application.Features.Verification.Commands.SubmitVerification;
|
using Baya.Application.Features.Verification.Commands.SubmitVerification;
|
||||||
using Baya.Application.Features.Verification.Queries.GetStatus;
|
using Baya.Application.Features.Verification.Queries.GetStatus;
|
||||||
using Baya.Application.Models.Verification;
|
using Baya.Application.Models.Verification;
|
||||||
@@ -43,6 +44,13 @@ public sealed class NurseVerificationController(ISender sender) : BaseController
|
|||||||
public async Task<IActionResult> ConfirmDocument(long stepId, ConfirmDocumentUploadCommand command, CancellationToken cancellationToken)
|
public async Task<IActionResult> ConfirmDocument(long stepId, ConfirmDocumentUploadCommand command, CancellationToken cancellationToken)
|
||||||
=> OperationResult(await sender.Send(command with { StepId = stepId }, cancellationToken));
|
=> OperationResult(await sender.Send(command with { StepId = stepId }, cancellationToken));
|
||||||
|
|
||||||
|
// Captures the structured credential fields (INO number, specialties, optional license details) B5
|
||||||
|
// collects — the real path used to drop them silently.
|
||||||
|
[HttpPost("[action]")]
|
||||||
|
[ProducesOkApiResponseType<VerificationStatusDto>]
|
||||||
|
public async Task<IActionResult> CredentialDetails(SubmitCredentialDetailsCommand command, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(command, cancellationToken));
|
||||||
|
|
||||||
[HttpPost("steps/identity_kyc/run")]
|
[HttpPost("steps/identity_kyc/run")]
|
||||||
[ProducesOkApiResponseType<RunStepResult>]
|
[ProducesOkApiResponseType<RunStepResult>]
|
||||||
public async Task<IActionResult> RunIdentityKyc(RunIdentityKycCommand command, CancellationToken cancellationToken)
|
public async Task<IActionResult> RunIdentityKyc(RunIdentityKycCommand command, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Asp.Versioning;
|
using Asp.Versioning;
|
||||||
|
using Baya.Application.Features.Nurses.Queries.GetNursePublicProfile;
|
||||||
using Baya.Application.Features.Reviews.Queries.GetTagAggregates;
|
using Baya.Application.Features.Reviews.Queries.GetTagAggregates;
|
||||||
using Baya.Application.Features.Reviews.Queries.ListReviewsForNurse;
|
using Baya.Application.Features.Reviews.Queries.ListReviewsForNurse;
|
||||||
using Baya.Application.Features.Verification.Queries.GetTrustBadge;
|
using Baya.Application.Features.Verification.Queries.GetTrustBadge;
|
||||||
|
using Baya.Application.Models.Nurses;
|
||||||
using Baya.Application.Models.Reviews;
|
using Baya.Application.Models.Reviews;
|
||||||
using Baya.Application.Models.Verification;
|
using Baya.Application.Models.Verification;
|
||||||
using Baya.WebFramework.Attributes;
|
using Baya.WebFramework.Attributes;
|
||||||
@@ -26,6 +28,12 @@ public sealed class NursesController(ISender sender) : BaseController
|
|||||||
public async Task<IActionResult> TrustBadge(long nurseId, CancellationToken cancellationToken)
|
public async Task<IActionResult> TrustBadge(long nurseId, CancellationToken cancellationToken)
|
||||||
=> OperationResult(await sender.Send(new GetVerifiedTrustBadgeQuery(nurseId), cancellationToken));
|
=> OperationResult(await sender.Send(new GetVerifiedTrustBadgeQuery(nurseId), cancellationToken));
|
||||||
|
|
||||||
|
// Public: the aggregated discovery detail (identity + aggregates + verification + services + latest review).
|
||||||
|
[HttpGet("{nurseId}/[action]")]
|
||||||
|
[ProducesOkApiResponseType<NursePublicProfileDto>]
|
||||||
|
public async Task<IActionResult> Profile(long nurseId, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(new GetNursePublicProfileQuery(nurseId), cancellationToken));
|
||||||
|
|
||||||
// Public: published reviews only (the publish gate is enforced in the query) + the cached rating aggregate.
|
// Public: published reviews only (the publish gate is enforced in the query) + the cached rating aggregate.
|
||||||
[HttpGet("{nurseProfileId}/reviews")]
|
[HttpGet("{nurseProfileId}/reviews")]
|
||||||
[ProducesOkApiResponseType<NurseReviewsResult>]
|
[ProducesOkApiResponseType<NurseReviewsResult>]
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Asp.Versioning;
|
using Asp.Versioning;
|
||||||
|
using Baya.Application.Features.PatientCareRecords.Commands.UpsertCarePlan;
|
||||||
using Baya.Application.Features.PatientCareRecords.Commands.WritePatientCareRecord;
|
using Baya.Application.Features.PatientCareRecords.Commands.WritePatientCareRecord;
|
||||||
|
using Baya.Application.Features.PatientCareRecords.Queries.GetCarePlan;
|
||||||
using Baya.Application.Features.PatientCareRecords.Queries.GetPatientHistory;
|
using Baya.Application.Features.PatientCareRecords.Queries.GetPatientHistory;
|
||||||
|
using Baya.Application.Features.PatientCareRecords.Queries.GetRecordAccess;
|
||||||
using Baya.Application.Models.Common;
|
using Baya.Application.Models.Common;
|
||||||
|
using Baya.Application.Models.Patients;
|
||||||
using Baya.Application.Models.Reviews;
|
using Baya.Application.Models.Reviews;
|
||||||
using Baya.WebFramework.Attributes;
|
using Baya.WebFramework.Attributes;
|
||||||
using Baya.WebFramework.BaseController;
|
using Baya.WebFramework.BaseController;
|
||||||
@@ -29,13 +33,38 @@ public sealed class PatientCareRecordsController(ISender sender) : BaseControlle
|
|||||||
[ProducesOkApiResponseType<WriteCareRecordResult>]
|
[ProducesOkApiResponseType<WriteCareRecordResult>]
|
||||||
public async Task<IActionResult> Write(long patientId, WriteCareRecordBody body, CancellationToken cancellationToken)
|
public async Task<IActionResult> Write(long patientId, WriteCareRecordBody body, CancellationToken cancellationToken)
|
||||||
=> OperationResult(await sender.Send(
|
=> OperationResult(await sender.Send(
|
||||||
new WritePatientCareRecordCommand(patientId, body.BookingId, body.Body), cancellationToken));
|
new WritePatientCareRecordCommand(patientId, body.BookingId, body.Body, body.TaskResults), cancellationToken));
|
||||||
|
|
||||||
[HttpGet("{patientId}/care_records")]
|
[HttpGet("{patientId}/care_records")]
|
||||||
[ProducesOkApiResponseType<PagedResult<CareRecordDto>>]
|
[ProducesOkApiResponseType<PagedResult<CareRecordDto>>]
|
||||||
public async Task<IActionResult> History(long patientId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> History(long patientId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default)
|
||||||
=> OperationResult(await sender.Send(new GetPatientHistoryQuery(patientId, page, pageSize), cancellationToken));
|
=> OperationResult(await sender.Send(new GetPatientHistoryQuery(patientId, page, pageSize), cancellationToken));
|
||||||
|
|
||||||
|
// The family-owned care plan (medications/routine/tasks) — read (owner/nurse/admin), REQ-027.
|
||||||
|
[HttpGet("{patientId}/care_record")]
|
||||||
|
[ProducesOkApiResponseType<CarePlanDto>]
|
||||||
|
public async Task<IActionResult> CarePlan(long patientId, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(new GetCarePlanQuery(patientId), cancellationToken));
|
||||||
|
|
||||||
|
// Replace the family-owned care plan (owning customer only), REQ-027.
|
||||||
|
[HttpPut("{patientId}/care_record")]
|
||||||
|
[ProducesOkApiResponseType<CarePlanDto>]
|
||||||
|
public async Task<IActionResult> UpsertCarePlan(long patientId, UpsertCarePlanBody body, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(
|
||||||
|
new UpsertCarePlanCommand(patientId, body.Medications, body.Routine, body.Tasks), cancellationToken));
|
||||||
|
|
||||||
|
// The caller's access to this patient's records (view/edit/append-note + non-leaking denied), REQ-027.
|
||||||
|
[HttpGet("{patientId}/record_access")]
|
||||||
|
[ProducesOkApiResponseType<RecordAccessDto>]
|
||||||
|
public async Task<IActionResult> RecordAccess(long patientId, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(new GetRecordAccessQuery(patientId), cancellationToken));
|
||||||
|
|
||||||
/// <summary>The care-record body (the patient id comes from the route).</summary>
|
/// <summary>The care-record body (the patient id comes from the route).</summary>
|
||||||
public record WriteCareRecordBody(long? BookingId, string Body);
|
public record WriteCareRecordBody(long? BookingId, string Body, IReadOnlyList<TaskResultDto>? TaskResults);
|
||||||
|
|
||||||
|
/// <summary>The family care-plan body (the patient id comes from the route).</summary>
|
||||||
|
public record UpsertCarePlanBody(
|
||||||
|
IReadOnlyList<MedicationDto>? Medications,
|
||||||
|
IReadOnlyList<RoutineItemDto>? Routine,
|
||||||
|
IReadOnlyList<CareTaskDto>? Tasks);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Asp.Versioning;
|
using Asp.Versioning;
|
||||||
|
using Baya.Application.Features.Refunds.Queries.GetRefundByBooking;
|
||||||
using Baya.Application.Features.Refunds.Queries.GetRefundStatus;
|
using Baya.Application.Features.Refunds.Queries.GetRefundStatus;
|
||||||
using Baya.Application.Models.Refunds;
|
using Baya.Application.Models.Refunds;
|
||||||
using Baya.WebFramework.Attributes;
|
using Baya.WebFramework.Attributes;
|
||||||
@@ -23,4 +24,10 @@ public sealed class RefundsController(ISender sender) : BaseController
|
|||||||
[ProducesOkApiResponseType<RefundStatusDto>]
|
[ProducesOkApiResponseType<RefundStatusDto>]
|
||||||
public async Task<IActionResult> Status(long id, CancellationToken cancellationToken)
|
public async Task<IActionResult> Status(long id, CancellationToken cancellationToken)
|
||||||
=> OperationResult(await sender.Send(new GetRefundStatusQuery(id), cancellationToken));
|
=> OperationResult(await sender.Send(new GetRefundStatusQuery(id), cancellationToken));
|
||||||
|
|
||||||
|
// Reach the refund from its booking id (the id the customer holds) — 404 when none exists (REQ-021).
|
||||||
|
[HttpGet("by_booking/{bookingId}")]
|
||||||
|
[ProducesOkApiResponseType<RefundStatusDto>]
|
||||||
|
public async Task<IActionResult> ByBooking(long bookingId, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(new GetRefundByBookingQuery(bookingId), cancellationToken));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ namespace Baya.Web.Api.Controllers.V1;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v{version:apiVersion}/webhooks_bnpl")]
|
[Route("api/v{version:apiVersion}/webhooks_bnpl")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
|
[EnableRateLimiting(RateLimitingServiceExtension.WebhookPolicy)]
|
||||||
[Display(Description = "BNPL provider callbacks (signature-authenticated, idempotent)")]
|
[Display(Description = "BNPL provider callbacks (signature-authenticated, idempotent)")]
|
||||||
public sealed class WebhooksBnplController(ISender sender) : BaseController
|
public sealed class WebhooksBnplController(ISender sender) : BaseController
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ using Baya.Application.Features.Payments.Commands.HandlePaymentWebhook;
|
|||||||
using Baya.Application.Models.Payments;
|
using Baya.Application.Models.Payments;
|
||||||
using Baya.WebFramework.Attributes;
|
using Baya.WebFramework.Attributes;
|
||||||
using Baya.WebFramework.BaseController;
|
using Baya.WebFramework.BaseController;
|
||||||
|
using Baya.WebFramework.ServiceConfiguration;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
|
||||||
namespace Baya.Web.Api.Controllers.V1;
|
namespace Baya.Web.Api.Controllers.V1;
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ namespace Baya.Web.Api.Controllers.V1;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v{version:apiVersion}/webhooks")]
|
[Route("api/v{version:apiVersion}/webhooks")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting(RateLimitingServiceExtension.WebhookPolicy)]
|
||||||
[Display(Description = "PSP/BNPL payment callbacks (signature-authenticated, idempotent)")]
|
[Display(Description = "PSP/BNPL payment callbacks (signature-authenticated, idempotent)")]
|
||||||
public sealed class WebhooksController(ISender sender) : BaseController
|
public sealed class WebhooksController(ISender sender) : BaseController
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using Asp.Versioning;
|
||||||
|
using Baya.Application.Features.Payouts.Commands.ReconcilePayoutBatch;
|
||||||
|
using Baya.WebFramework.Attributes;
|
||||||
|
using Baya.WebFramework.BaseController;
|
||||||
|
using Baya.WebFramework.ServiceConfiguration;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
|
||||||
|
namespace Baya.Web.Api.Controllers.V1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The async PAYA/SATNA payout <b>reconciliation callback</b> (refinement-phase-8, 6.3). The real bank rail
|
||||||
|
/// accepts a payout as <c>submitted</c> and calls back later with the settled outcome, flipping each payout
|
||||||
|
/// <c>submitted → paid/failed</c> (the mock rail collapsed this into the submit). Authenticated by
|
||||||
|
/// <b>signature</b>, not a user session, so it is anonymous to the auth pipeline; the callback is HMAC-verified and
|
||||||
|
/// idempotent (a replayed callback re-driving an already-settled payout is a no-op). Shares the deliberate
|
||||||
|
/// bursty-tolerant <c>webhook</c> rate policy with the PSP/BNPL callbacks.
|
||||||
|
/// </summary>
|
||||||
|
[ApiVersion("1")]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v{version:apiVersion}/webhooks")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting(RateLimitingServiceExtension.WebhookPolicy)]
|
||||||
|
[Display(Description = "PAYA/SATNA payout reconciliation callbacks (signature-authenticated, idempotent)")]
|
||||||
|
public sealed class WebhooksPayoutsController(ISender sender) : BaseController
|
||||||
|
{
|
||||||
|
[HttpPost("payouts/{provider}")]
|
||||||
|
[ProducesOkApiResponseType<bool>]
|
||||||
|
public async Task<IActionResult> Payouts(string provider, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
|
||||||
|
var rawBody = await reader.ReadToEndAsync(cancellationToken);
|
||||||
|
|
||||||
|
var headers = Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return OperationResult(await sender.Send(new ReconcilePayoutBatchCommand(provider, headers, rawBody), cancellationToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ using Baya.Infrastructure.Identity.ServiceConfiguration;
|
|||||||
using Baya.Infrastructure.Monitoring.Configurations;
|
using Baya.Infrastructure.Monitoring.Configurations;
|
||||||
using Baya.Infrastructure.Persistence.ServiceConfiguration;
|
using Baya.Infrastructure.Persistence.ServiceConfiguration;
|
||||||
using Baya.SharedKernel.Extensions;
|
using Baya.SharedKernel.Extensions;
|
||||||
|
using Baya.Web.Api.Configuration;
|
||||||
using Baya.Web.Plugins.Grpc;
|
using Baya.Web.Plugins.Grpc;
|
||||||
using Baya.WebFramework.Filters;
|
using Baya.WebFramework.Filters;
|
||||||
using Baya.WebFramework.Middlewares;
|
using Baya.WebFramework.Middlewares;
|
||||||
@@ -29,8 +30,16 @@ builder.Host.UseSerilog(LoggingConfiguration.ConfigureLogger);
|
|||||||
|
|
||||||
var configuration = builder.Configuration;
|
var configuration = builder.Configuration;
|
||||||
|
|
||||||
|
// Fail fast if a load-bearing secret (DB connection, JWE/field-encryption keys) is missing or still a
|
||||||
|
// committed placeholder — before any service reaches for it. Skipped in the "Testing" environment.
|
||||||
|
builder.ValidateRequiredSecrets();
|
||||||
|
|
||||||
Activity.DefaultIdFormat = ActivityIdFormat.W3C;
|
Activity.DefaultIdFormat = ActivityIdFormat.W3C;
|
||||||
|
|
||||||
|
// HTTPS metadata is required for the token exchange in deployed environments; relaxed for local
|
||||||
|
// Development and the Testing host, which run over plain HTTP.
|
||||||
|
var requireHttpsMetadata = !builder.Environment.IsDevelopment() && !builder.Environment.IsEnvironment("Testing");
|
||||||
|
|
||||||
builder
|
builder
|
||||||
.ConfigureHealthChecks()
|
.ConfigureHealthChecks()
|
||||||
.SetupOpenTelemetry();
|
.SetupOpenTelemetry();
|
||||||
@@ -68,18 +77,28 @@ builder.Services.AddSwagger("v1","v1.1");
|
|||||||
|
|
||||||
|
|
||||||
builder.Services.AddApplicationServices()
|
builder.Services.AddApplicationServices()
|
||||||
.RegisterIdentityServices(identitySettings)
|
.RegisterIdentityServices(identitySettings, requireHttpsMetadata)
|
||||||
.AddPersistenceServices(configuration)
|
.AddPersistenceServices(configuration)
|
||||||
.AddCrossCuttingSeams(configuration)
|
.AddCrossCuttingSeams(configuration)
|
||||||
.AddWebFrameworkServices()
|
.AddWebFrameworkServices()
|
||||||
.AddCorsPolicies(configuration)
|
.AddCorsPolicies(configuration)
|
||||||
|
.AddForwardedHeadersConfiguration(configuration)
|
||||||
.AddRateLimitingPolicies();
|
.AddRateLimitingPolicies();
|
||||||
|
|
||||||
// Development-only: capture each OTP in-memory so GET /api/v1/dev/last_otp/{phone} can complete a login
|
// Development-only: capture each OTP in-memory so GET /api/v1/dev/last_otp/{phone} can complete a login
|
||||||
// without an SMS gateway. Nothing here is wired in any other environment.
|
// without an SMS gateway. refinement-phase-8: the capture bridge runs ONLY while the log-only mock SMS sender is
|
||||||
if (builder.Environment.IsDevelopment())
|
// selected — once a real gateway (Seams:Sms:Provider) ships, the OTP is delivered over the wire and never logged
|
||||||
|
// or captured. Nothing here is wired in any other environment.
|
||||||
|
var smsProvider = configuration["Seams:Sms:Provider"];
|
||||||
|
var usingMockSms = string.IsNullOrWhiteSpace(smsProvider) || smsProvider.Equals("mock", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (builder.Environment.IsDevelopment() && usingMockSms)
|
||||||
builder.Services.AddDevelopmentOtpCapture();
|
builder.Services.AddDevelopmentOtpCapture();
|
||||||
|
|
||||||
|
// The IPaymentCaptureSimulator + bookings/convert path is a Development/Testing affordance (b10's real webhook
|
||||||
|
// confirm supersedes it in production). Re-register the succeeding mock over the production fail-closed stand-in.
|
||||||
|
if (builder.Environment.IsDevelopment() || builder.Environment.IsEnvironment("Testing"))
|
||||||
|
builder.Services.AddDevelopmentPaymentCapture();
|
||||||
|
|
||||||
builder.Services.RegisterValidatorsAsServices();
|
builder.Services.RegisterValidatorsAsServices();
|
||||||
builder.Services.AddExceptionHandler<ExceptionHandler>();
|
builder.Services.AddExceptionHandler<ExceptionHandler>();
|
||||||
|
|
||||||
@@ -91,7 +110,7 @@ TypeAdapterConfig.GlobalSettings.Scan(typeof(UserCreateCommand).Assembly,
|
|||||||
|
|
||||||
#region Plugin Services Configuration
|
#region Plugin Services Configuration
|
||||||
|
|
||||||
builder.Services.ConfigureGrpcPluginServices();
|
builder.Services.ConfigureGrpcPluginServices(builder.Environment);
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -100,18 +119,43 @@ builder.Services.ConfigureGrpcPluginServices();
|
|||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|
||||||
|
// Deploy-time migration one-shot (refinement-phase-7): `dotnet run -- migrate` (or `<binary> migrate`) applies
|
||||||
|
// EF migrations + the idempotent seeders, then exits. Running DDL as a separate deploy step means normal boots —
|
||||||
|
// especially concurrent multi-instance start-ups — never race on schema, and the runtime login needs no
|
||||||
|
// permanent DDL rights.
|
||||||
|
if (args.Any(a => string.Equals(a, "migrate", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
await app.ApplyMigrationsAsync();
|
||||||
|
await app.SeedDefaultUsersAsync();
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
await app.SeedPaymentGatewaysAsync();
|
||||||
|
await app.SeedDemoWorldAsync();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Integration tests (WebApplicationFactory, env "Testing") run on in-memory SQLite — the SQL Server
|
// Integration tests (WebApplicationFactory, env "Testing") run on in-memory SQLite — the SQL Server
|
||||||
// migrations can't apply there; the test factory does EnsureCreated + seeding itself.
|
// migrations can't apply there; the test factory does EnsureCreated + seeding itself.
|
||||||
if (!app.Environment.IsEnvironment("Testing"))
|
if (!app.Environment.IsEnvironment("Testing"))
|
||||||
{
|
{
|
||||||
await app.ApplyMigrationsAsync();
|
|
||||||
await app.SeedDefaultUsersAsync();
|
|
||||||
await app.SeedPaymentGatewaysAsync();
|
|
||||||
|
|
||||||
// Development-only: populate a demo marketplace (nurses/variants/search rows, customers/patients)
|
|
||||||
// so the real-path screens aren't empty. Idempotent; never runs in Production/Staging.
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
// Local convenience: apply migrations + seed on boot. Development-only: a sandbox payment gateway
|
||||||
|
// (all-zeros merchant id) and a demo marketplace (nurses/variants/search rows, customers/patients) so the
|
||||||
|
// real-path screens aren't empty. Neither belongs in a deployed DB — both are idempotent.
|
||||||
|
await app.ApplyMigrationsAsync();
|
||||||
|
await app.SeedDefaultUsersAsync();
|
||||||
|
await app.SeedPaymentGatewaysAsync();
|
||||||
await app.SeedDemoWorldAsync();
|
await app.SeedDemoWorldAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Deployed: DDL is the separate `migrate` step above. Boot only *checks* the schema is current (fail fast
|
||||||
|
// on a pending migration) and seeds idempotent runtime data (roles + any configured break-glass admin).
|
||||||
|
await app.EnsureSchemaUpToDateAsync();
|
||||||
|
await app.SeedDefaultUsersAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
@@ -121,6 +165,10 @@ if (app.Environment.IsDevelopment())
|
|||||||
else
|
else
|
||||||
app.UseExceptionHandler(_=>{});
|
app.UseExceptionHandler(_=>{});
|
||||||
|
|
||||||
|
// First in the pipeline so the resolved client IP (X-Forwarded-For, from a trusted proxy) is in place
|
||||||
|
// before anything downstream — notably the rate limiter — reads HttpContext.Connection.RemoteIpAddress.
|
||||||
|
app.UseForwardedHeaders();
|
||||||
|
|
||||||
app.UseSwaggerAndUi();
|
app.UseSwaggerAndUi();
|
||||||
|
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
|
|||||||
@@ -1,37 +1,15 @@
|
|||||||
{
|
{
|
||||||
"ConnectionStrings": {
|
|
||||||
"SqlServer": "Server=localhost,1433;Database=Baya;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;",
|
|
||||||
"logDb": "Server=localhost,1433;Database=Baya_Logs;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;"
|
|
||||||
},
|
|
||||||
"IdentitySettings": {
|
"IdentitySettings": {
|
||||||
"SecretKey": "ShouldBe-LongerThan-16Char-SecretKey",
|
"SecretKey": "dev-only-jwe-signing-key-not-for-production-0123456789abcdef",
|
||||||
"Encryptkey": "16CharEncryptKey",
|
"Encryptkey": "dev-only-16bytes"
|
||||||
"Issuer": "MyWebsite",
|
|
||||||
"Audience": "MyWebsite",
|
|
||||||
"NotBeforeMinutes": "0",
|
|
||||||
"ExpirationMinutes": "10000"
|
|
||||||
},
|
},
|
||||||
"Seams": {
|
"Seams": {
|
||||||
"FieldEncryption": {
|
"FieldEncryption": {
|
||||||
"Key": "local-dev-field-encryption-key-change-me",
|
"Key": "local-dev-field-encryption-key-not-for-production",
|
||||||
"HashKey": "local-dev-field-hash-key-change-me"
|
"HashKey": "local-dev-field-hash-key-not-for-production"
|
||||||
},
|
|
||||||
"ObjectStorage": {
|
|
||||||
"RootPath": ""
|
|
||||||
},
|
|
||||||
"Geocoding": {
|
|
||||||
"ReturnNullCoordinates": false,
|
|
||||||
"LowConfidenceMarker": "NO_GEO",
|
|
||||||
"ResolvedConfidence": 0.9
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Cors": {
|
"Cors": {
|
||||||
"AllowedOrigins": [ "http://localhost:3000" ]
|
"AllowedOrigins": [ "http://localhost:3000" ]
|
||||||
},
|
|
||||||
"AllowedHosts": "*",
|
|
||||||
"Kestrel": {
|
|
||||||
"EndpointDefaults": {
|
|
||||||
"Protocols": "Http2"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,17 @@
|
|||||||
"logDb": "Server=localhost,1433;Database=Baya_Logs;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;"
|
"logDb": "Server=localhost,1433;Database=Baya_Logs;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;"
|
||||||
},
|
},
|
||||||
"IdentitySettings": {
|
"IdentitySettings": {
|
||||||
"SecretKey": "ShouldBe-LongerThan-16Char-SecretKey",
|
"SecretKey": "SET_VIA_USER_SECRETS_OR_ENV",
|
||||||
"Encryptkey": "16CharEncryptKey",
|
"Encryptkey": "SET_VIA_USER_SECRETS_OR_ENV",
|
||||||
"Issuer": "MyWebsite",
|
"Issuer": "Balinyaar",
|
||||||
"Audience": "MyWebsite",
|
"Audience": "BalinyaarClient",
|
||||||
"NotBeforeMinutes": "0",
|
"NotBeforeMinutes": "0",
|
||||||
"ExpirationMinutes": "10000"
|
"ExpirationMinutes": "60"
|
||||||
},
|
},
|
||||||
"Seams": {
|
"Seams": {
|
||||||
"FieldEncryption": {
|
"FieldEncryption": {
|
||||||
"Key": "local-dev-field-encryption-key-change-me",
|
"Key": "SET_VIA_USER_SECRETS_OR_ENV",
|
||||||
"HashKey": "local-dev-field-hash-key-change-me"
|
"HashKey": "SET_VIA_USER_SECRETS_OR_ENV"
|
||||||
},
|
},
|
||||||
"ObjectStorage": {
|
"ObjectStorage": {
|
||||||
"RootPath": ""
|
"RootPath": ""
|
||||||
@@ -28,10 +28,14 @@
|
|||||||
"Cors": {
|
"Cors": {
|
||||||
"AllowedOrigins": []
|
"AllowedOrigins": []
|
||||||
},
|
},
|
||||||
|
"ForwardedHeaders": {
|
||||||
|
"KnownProxies": [],
|
||||||
|
"KnownNetworks": []
|
||||||
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"Kestrel": {
|
"Kestrel": {
|
||||||
"EndpointDefaults": {
|
"EndpointDefaults": {
|
||||||
"Protocols": "Http2"
|
"Protocols": "Http1AndHttp2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ public class BaseController : ControllerBase
|
|||||||
return new JsonResult(new ApiResult(false, ApiResultStatusCode.Conflict, FirstErrorMessage(result)))
|
return new JsonResult(new ApiResult(false, ApiResultStatusCode.Conflict, FirstErrorMessage(result)))
|
||||||
{ StatusCode = StatusCodes.Status409Conflict };
|
{ StatusCode = StatusCodes.Status409Conflict };
|
||||||
|
|
||||||
|
// A coded failure (e.g. otp_locked) is written as the full envelope so the client sees the stable
|
||||||
|
// `code` (+ optional `data`), rather than the bare ModelState errors of an ordinary 400.
|
||||||
|
if (result.ErrorCode is not null)
|
||||||
|
return new JsonResult(
|
||||||
|
new ApiResult<object>(false, ApiResultStatusCode.BadRequest, result.ErrorData, FirstErrorMessage(result))
|
||||||
|
{ Code = result.ErrorCode })
|
||||||
|
{ StatusCode = StatusCodes.Status400BadRequest };
|
||||||
|
|
||||||
AddErrors(result);
|
AddErrors(result);
|
||||||
|
|
||||||
var badRequestErrors = new ValidationProblemDetails(ModelState);
|
var badRequestErrors = new ValidationProblemDetails(ModelState);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user