refinement phase 3

This commit is contained in:
hamid
2026-07-13 11:26:39 +03:30
parent 1ce36f9414
commit 314763f764
194 changed files with 24211 additions and 274 deletions
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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" }
+14
View File
@@ -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.
+18
View File
@@ -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.
+3 -3
View File
@@ -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>`.
+3 -3
View File
@@ -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>`.
+7 -7
View File
@@ -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`
+12 -2
View File
@@ -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`).
+11
View File
@@ -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.
+13
View File
@@ -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.
+8
View File
@@ -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).
+13
View File
@@ -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`.
+21
View File
@@ -125,3 +125,24 @@ 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 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.
+16
View File
@@ -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.
+14 -2
View File
@@ -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.
+14 -2
View File
@@ -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
@@ -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
@@ -112,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`).
@@ -124,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
@@ -136,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
@@ -154,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:
@@ -175,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
@@ -188,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
@@ -199,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:
@@ -218,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
@@ -237,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
@@ -256,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
@@ -267,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.
@@ -286,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
@@ -308,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:
@@ -327,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:
@@ -349,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 }`
@@ -359,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:
@@ -383,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`).
@@ -417,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:
@@ -437,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
@@ -460,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
@@ -492,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
@@ -501,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
@@ -517,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`
@@ -529,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`
@@ -539,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
@@ -552,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
@@ -563,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
@@ -576,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
@@ -584,6 +584,7 @@ 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 ## 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 - **Need:** add a boolean/id on `MeResult` — e.g. `administersPartnerCenterId: number | null` (or a plain
@@ -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)
@@ -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));
} }
@@ -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);
@@ -0,0 +1,30 @@
#nullable enable
namespace Baya.Application.Common;
/// <summary>
/// Shared validation + storage-key rules for avatar (profile photo) uploads. Keeps the nurse and customer
/// upload handlers in lock-step on the accepted content types, the size cap, and the key scheme.
/// </summary>
public static class AvatarUpload
{
public const long MaxSizeBytes = 5 * 1024 * 1024;
private static readonly IReadOnlyDictionary<string, string> AllowedTypes = new Dictionary<string, string>
{
["image/jpeg"] = ".jpg",
["image/png"] = ".png",
["image/webp"] = ".webp"
};
public static bool IsAllowedContentType(string? contentType)
=> contentType is not null && AllowedTypes.ContainsKey(contentType.ToLowerInvariant());
public static bool IsWithinSizeLimit(long length) => length > 0 && length <= MaxSizeBytes;
/// <summary>Opaque, collision-free storage key: <c>avatars/{scope}/{ownerId}/{token}{ext}</c>.</summary>
public static string BuildKey(string scope, long ownerId, string contentType, string token)
{
var ext = AllowedTypes.TryGetValue(contentType.ToLowerInvariant(), out var e) ? e : ".bin";
return $"avatars/{scope}/{ownerId}/{token}{ext}";
}
}
@@ -0,0 +1,29 @@
using System.Text.Json;
namespace Baya.Application.Common;
/// <summary>
/// Shared (de)serialization for a small list of stable string codes persisted as a JSON string array
/// (e.g. patient <c>conditions_json</c>, nurse <c>specializations_json</c>). A malformed or empty value
/// parses to an empty list; an empty list serializes back to <c>null</c> (a clean NULL column).
/// </summary>
public static class JsonCodeList
{
public static IReadOnlyList<string> Parse(string json)
{
if (string.IsNullOrWhiteSpace(json))
return [];
try
{
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
}
catch (JsonException)
{
return [];
}
}
public static string Serialize(IReadOnlyList<string> codes)
=> codes is null || codes.Count == 0 ? null : JsonSerializer.Serialize(codes);
}
@@ -19,8 +19,12 @@ public interface IAuditLogger
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
ValueTask<PagedResult<AuditLogDto>> GetTrailAsync( ValueTask<PagedResult<AuditLogDto>> GetTrailAsync(
string entityType, string? entityType,
string entityId, string? entityId,
int? actorUserId,
string? action,
System.DateTimeOffset? from,
System.DateTimeOffset? to,
int page, int page,
int pageSize, int pageSize,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
@@ -40,4 +40,8 @@ public interface IBnplRepository
/// <summary>The BNPL order view + the owning customer's user id for tenancy, and the linked refund's ETA /// <summary>The BNPL order view + the owning customer's user id for tenancy, and the linked refund's ETA
/// when reverted. Null when absent.</summary> /// when reverted. Null when absent.</summary>
Task<BnplOrderStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken); Task<BnplOrderStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken);
/// <summary>The BNPL order for a booking request (the id the return-poll surface holds), latest first, with the
/// owning customer's user id for tenancy. Null when the request has no BNPL order.</summary>
Task<BnplOrderStatusProjection?> GetStatusByRequestAsync(long bookingRequestId, CancellationToken cancellationToken);
} }
@@ -54,4 +54,9 @@ public interface IBookingRequestRepository
/// <summary>The facts b10's <c>InitiatePayment</c> needs to validate a card attempt (owning customer, /// <summary>The facts b10's <c>InitiatePayment</c> needs to validate a card attempt (owning customer,
/// status, frozen payment window, gross to charge). NULL when absent.</summary> /// status, frozen payment window, gross to charge). NULL when absent.</summary>
Task<BookingPaymentContext?> GetPaymentContextAsync(long id, CancellationToken cancellationToken); Task<BookingPaymentContext?> GetPaymentContextAsync(long id, CancellationToken cancellationToken);
/// <summary>Owner-scoped read for the checkout summary — the request's schedule/participant labels + the
/// variant price/session-count needed to compute the money decomposition. NULL when the request is absent
/// or not the caller's (existence not leaked).</summary>
Task<CheckoutContext?> GetCheckoutContextAsync(long id, long customerId, CancellationToken cancellationToken);
} }
@@ -1,5 +1,6 @@
#nullable enable #nullable enable
using Baya.Application.Models.Identity; using Baya.Application.Models.Identity;
using Baya.Application.Models.Nurses;
using Baya.Domain.Entities.Identity; using Baya.Domain.Entities.Identity;
namespace Baya.Application.Contracts.Persistence; namespace Baya.Application.Contracts.Persistence;
@@ -31,4 +32,9 @@ public interface INurseProfileRepository
/// read — what a booking-request create needs to notify the nurse and run the bookability + same-gender /// read — what a booking-request create needs to notify the nurse and run the bookability + same-gender
/// checks. NULL when no such nurse profile exists.</summary> /// checks. NULL when no such nurse profile exists.</summary>
Task<NurseBookingContext?> GetBookingContextByIdAsync(long nurseProfileId, CancellationToken cancellationToken); Task<NurseBookingContext?> GetBookingContextByIdAsync(long nurseProfileId, CancellationToken cancellationToken);
/// <summary>The aggregated public nurse profile for the discovery detail (C3): identity + aggregates +
/// verification signal + specialty chips + active bookable services + latest published review. NULL when
/// no such nurse profile exists. Exposes no encrypted credential number.</summary>
Task<NursePublicProfileDto?> GetPublicProfileAsync(long nurseProfileId, CancellationToken cancellationToken);
} }
@@ -1,6 +1,7 @@
#nullable enable #nullable enable
using Baya.Application.Models.Common; using Baya.Application.Models.Common;
using Baya.Application.Models.Reviews; using Baya.Application.Models.Reviews;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Reviews; using Baya.Domain.Entities.Reviews;
namespace Baya.Application.Contracts.Persistence; namespace Baya.Application.Contracts.Persistence;
@@ -25,4 +26,10 @@ public interface IPatientCareRecordRepository
/// <summary>Patient-scoped longitudinal history, paginated, newest first — <b>ciphertext</b> bodies; the /// <summary>Patient-scoped longitudinal history, paginated, newest first — <b>ciphertext</b> bodies; the
/// handler decrypts post-check.</summary> /// handler decrypts post-check.</summary>
Task<PagedResult<CareRecordCipherRow>> GetPatientHistoryAsync(long patientId, int page, int pageSize, CancellationToken cancellationToken); Task<PagedResult<CareRecordCipherRow>> GetPatientHistoryAsync(long patientId, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>The tracked family-owned care plan for a patient (for read + upsert). Null when none exists yet.</summary>
Task<PatientCarePlan?> GetCarePlanAsync(long patientId, CancellationToken cancellationToken);
/// <summary>Adds a new family-owned care plan (first PUT for a patient).</summary>
Task AddCarePlanAsync(PatientCarePlan plan, CancellationToken cancellationToken);
} }
@@ -70,6 +70,18 @@ public interface IPayoutRepository
/// <summary>The nurse's own payouts (tenancy-scoped), most recent first, projected + paginated. Masked IBAN.</summary> /// <summary>The nurse's own payouts (tenancy-scoped), most recent first, projected + paginated. Masked IBAN.</summary>
Task<PagedResult<NursePayoutHistoryDto>> GetNurseHistoryAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken); Task<PagedResult<NursePayoutHistoryDto>> GetNurseHistoryAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>Every completed/closed booking the nurse earned, each with its <b>server-derived</b> money-state
/// (<c>pending|eligible|paid|clawback_applied</c>) from <c>bookings.status</c> + <c>dispute_window_ends_at</c>
/// + the payout link + any clawback. The handler filters by state, sums the buckets, and paginates.</summary>
Task<IReadOnlyList<NurseEarningsItemDto>> GetNurseEarningsAsync(long nurseId, DateTime now, CancellationToken cancellationToken);
/// <summary>Lifetime total (IRR) of the nurse's <c>paid</c> payouts' net — the <c>paidTotalIrr</c> bucket.</summary>
Task<long> GetPaidNetTotalAsync(long nurseId, CancellationToken cancellationToken);
/// <summary>A nurse-scoped payout detail (payout + batch window + covered bookings). Null when the payout is
/// absent or not the nurse's — never leak another nurse's payout.</summary>
Task<NursePayoutDetailDto?> GetNursePayoutDetailAsync(long payoutId, long nurseId, CancellationToken cancellationToken);
} }
/// <summary>The verified primary account a payout snapshots — the account id and the decrypted IBAN (frozen into /// <summary>The verified primary account a payout snapshots — the account id and the decrypted IBAN (frozen into
@@ -35,6 +35,10 @@ public interface IRefundRepository
/// Null when absent.</summary> /// Null when absent.</summary>
Task<RefundStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken); Task<RefundStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken);
/// <summary>The customer-facing status of a booking's (latest) refund, with the owning customer's user id
/// for tenancy. Null when the booking has no refund. Lets the customer reach a refund from its booking.</summary>
Task<RefundStatusProjection?> GetStatusByBookingAsync(long bookingId, CancellationToken cancellationToken);
/// <summary>The provider revert reference on a <c>bnpl_revert</c> refund — the BNPL revert path records it /// <summary>The provider revert reference on a <c>bnpl_revert</c> refund — the BNPL revert path records it
/// as <c>revert_transaction_id</c> on the <c>bnpl_transactions</c> row. Null when absent.</summary> /// as <c>revert_transaction_id</c> on the <c>bnpl_transactions</c> row. Null when absent.</summary>
Task<string?> GetExternalRevertReferenceAsync(long refundId, CancellationToken cancellationToken); Task<string?> GetExternalRevertReferenceAsync(long refundId, CancellationToken cancellationToken);
@@ -20,6 +20,10 @@ public interface IReviewRepository
/// <summary>True if a (non-deleted) review already exists for the booking — the 1:1 pre-check.</summary> /// <summary>True if a (non-deleted) review already exists for the booking — the 1:1 pre-check.</summary>
Task<bool> ExistsForBookingAsync(long bookingId, CancellationToken cancellationToken); Task<bool> ExistsForBookingAsync(long bookingId, CancellationToken cancellationToken);
/// <summary>The caller's own review for a booking (with tags + moderation status) plus the owning
/// customer's user id for tenancy. Null when the booking has no review.</summary>
Task<MyReviewProjection?> GetMyReviewForBookingAsync(long bookingId, CancellationToken cancellationToken);
/// <summary>Tracked review for a moderation transition. Null if absent.</summary> /// <summary>Tracked review for a moderation transition. Null if absent.</summary>
Task<Review?> GetTrackedAsync(long reviewId, CancellationToken cancellationToken); Task<Review?> GetTrackedAsync(long reviewId, CancellationToken cancellationToken);
@@ -59,7 +59,11 @@ public interface ITicketRepository
/// <summary>Paginated tickets the user participates in (active membership), filterable by status and /// <summary>Paginated tickets the user participates in (active membership), filterable by status and
/// reference code, newest first.</summary> /// reference code, newest first.</summary>
Task<PagedResult<TicketSummaryDto>> ListMyTicketsAsync(int userId, string? status, string? referenceCode, int page, int pageSize, CancellationToken cancellationToken); Task<PagedResult<TicketSummaryDto>> ListMyTicketsAsync(int userId, string? status, string? referenceCode, long? bookingId, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>An existing message on the ticket carrying this client message id — the optimistic-send
/// idempotency lookup. Null when none (a fresh send). Ignores internal notes.</summary>
Task<TicketMessageDto?> GetMessageByClientIdAsync(long ticketId, string clientMessageId, CancellationToken cancellationToken);
/// <summary>The admin global queue — paginated, filter by status/category, search by reference code, optional /// <summary>The admin global queue — paginated, filter by status/category, search by reference code, optional
/// booking/refund link, newest first.</summary> /// booking/refund link, newest first.</summary>
@@ -10,6 +10,9 @@ public interface IUserAccountRepository
/// The phone comes back decrypted and unmasked — masking is the handler's job.</summary> /// The phone comes back decrypted and unmasked — masking is the handler's job.</summary>
Task<UserAccountSnapshot?> GetAccountSnapshotAsync(int userId, CancellationToken cancellationToken); Task<UserAccountSnapshot?> GetAccountSnapshotAsync(int userId, CancellationToken cancellationToken);
/// <summary>Tracked user row for an in-place edit of the base identity (e.g. name).</summary>
Task<User?> GetTrackedByIdAsync(int userId, CancellationToken cancellationToken);
Task<Role?> GetRoleByNameAsync(string roleName, CancellationToken cancellationToken); Task<Role?> GetRoleByNameAsync(string roleName, CancellationToken cancellationToken);
/// <summary>Tracked user-role lookup that bypasses the revoked-filter, so a revoked grant can be /// <summary>Tracked user-role lookup that bypasses the revoked-filter, so a revoked grant can be
@@ -38,6 +38,10 @@ public interface IVerificationRepository
Task AddDocumentAsync(VerificationDocument document, CancellationToken cancellationToken); Task AddDocumentAsync(VerificationDocument document, CancellationToken cancellationToken);
Task AddCredentialAsync(NurseCredential credential, CancellationToken cancellationToken); Task AddCredentialAsync(NurseCredential credential, CancellationToken cancellationToken);
/// <summary>Tracked credential for the nurse of a given type — lets the nurse-facing credential-details
/// capture upsert (rather than duplicate) its registry row. Null when none exists yet.</summary>
Task<NurseCredential?> GetTrackedCredentialAsync(long nurseId, string credentialType, CancellationToken cancellationToken);
// --- Projected reads --- // --- Projected reads ---
Task<VerificationStatusDto?> GetStatusForNurseAsync(long nurseId, CancellationToken cancellationToken); Task<VerificationStatusDto?> GetStatusForNurseAsync(long nurseId, CancellationToken cancellationToken);
Task<PagedResult<AdminPendingStepDto>> ListPendingStepsAsync( Task<PagedResult<AdminPendingStepDto>> ListPendingStepsAsync(
@@ -42,7 +42,24 @@ internal sealed class CreateAddressCommandHandler(
var isFirst = customerId is not { } existing || !await unitOfWork.CustomerAddressRepository.HasAnyAsync(existing, cancellationToken); var isFirst = customerId is not { } existing || !await unitOfWork.CustomerAddressRepository.HasAnyAsync(existing, cancellationToken);
var isPrimary = request.IsPrimary || isFirst; var isPrimary = request.IsPrimary || isFirst;
var geo = await geocoder.GeocodeAsync(request.AddressLine, city.NameEn, district?.NameEn, cancellationToken); // Prefer the client's dropped pin (the customer's exact door — best for the EVV distance check);
// fall back to the server geocode only when no pin was sent.
decimal? latitude;
decimal? longitude;
string geocodeSource;
if (request is { Latitude: { } pinLat, Longitude: { } pinLng })
{
latitude = pinLat;
longitude = pinLng;
geocodeSource = GeocodeSources.UserPin;
}
else
{
var geo = await geocoder.GeocodeAsync(request.AddressLine, city.NameEn, district?.NameEn, cancellationToken);
latitude = geo.Latitude;
longitude = geo.Longitude;
geocodeSource = GeocodeSources.Geocoder;
}
// Values are set as plaintext; the EF value converter encrypts the PII columns at rest. // Values are set as plaintext; the EF value converter encrypts the PII columns at rest.
var address = new CustomerAddress var address = new CustomerAddress
@@ -54,8 +71,9 @@ internal sealed class CreateAddressCommandHandler(
PostalCode = request.PostalCode, PostalCode = request.PostalCode,
RecipientName = request.RecipientName, RecipientName = request.RecipientName,
RecipientPhone = request.RecipientPhone, RecipientPhone = request.RecipientPhone,
Latitude = geo.Latitude, Latitude = latitude,
Longitude = geo.Longitude, Longitude = longitude,
GeocodeSource = geocodeSource,
IsPrimary = isPrimary IsPrimary = isPrimary
}; };
@@ -84,6 +102,7 @@ internal sealed class CreateAddressCommandHandler(
new( new(
address.Id, address.Id,
address.Title, address.Title,
city.ProvinceId,
city.Id, city.Id,
city.NameFa, city.NameFa,
city.NameEn, city.NameEn,
@@ -14,5 +14,10 @@ public sealed class CreateAddressCommandValidator : AbstractValidator<CreateAddr
.Matches("^[0-9۰-۹]{10}$") .Matches("^[0-9۰-۹]{10}$")
.When(x => !string.IsNullOrWhiteSpace(x.PostalCode)) .When(x => !string.IsNullOrWhiteSpace(x.PostalCode))
.WithMessage("Postal code must be 10 digits."); .WithMessage("Postal code must be 10 digits.");
RuleFor(x => x.Latitude).InclusiveBetween(-90m, 90m).When(x => x.Latitude.HasValue);
RuleFor(x => x.Longitude).InclusiveBetween(-180m, 180m).When(x => x.Longitude.HasValue);
RuleFor(x => x)
.Must(x => x.Latitude.HasValue == x.Longitude.HasValue)
.WithMessage("Latitude and longitude must be supplied together.");
} }
} }
@@ -17,4 +17,6 @@ public record CreateAddressCommand(
string PostalCode, string PostalCode,
string RecipientName, string RecipientName,
string RecipientPhone, string RecipientPhone,
bool IsPrimary) : IRequest<OperationResult<CustomerAddressDto>>; bool IsPrimary,
decimal? Latitude = null,
decimal? Longitude = null) : IRequest<OperationResult<CustomerAddressDto>>;
@@ -58,11 +58,19 @@ internal sealed class UpdateAddressCommandHandler(
address.RecipientName = request.RecipientName; address.RecipientName = request.RecipientName;
address.RecipientPhone = request.RecipientPhone; address.RecipientPhone = request.RecipientPhone;
if (locationChanged) // A freshly-dropped pin always wins; otherwise re-geocode only when the location text changed.
if (request is { Latitude: { } pinLat, Longitude: { } pinLng })
{
address.Latitude = pinLat;
address.Longitude = pinLng;
address.GeocodeSource = GeocodeSources.UserPin;
}
else if (locationChanged)
{ {
var geo = await geocoder.GeocodeAsync(request.AddressLine, city.NameEn, district?.NameEn, cancellationToken); var geo = await geocoder.GeocodeAsync(request.AddressLine, city.NameEn, district?.NameEn, cancellationToken);
address.Latitude = geo.Latitude; address.Latitude = geo.Latitude;
address.Longitude = geo.Longitude; address.Longitude = geo.Longitude;
address.GeocodeSource = GeocodeSources.Geocoder;
} }
await unitOfWork.CommitAsync(); await unitOfWork.CommitAsync();
@@ -70,6 +78,7 @@ internal sealed class UpdateAddressCommandHandler(
return OperationResult<CustomerAddressDto>.SuccessResult(new CustomerAddressDto( return OperationResult<CustomerAddressDto>.SuccessResult(new CustomerAddressDto(
address.Id, address.Id,
address.Title, address.Title,
city.ProvinceId,
city.Id, city.Id,
city.NameFa, city.NameFa,
city.NameEn, city.NameEn,
@@ -14,5 +14,10 @@ public sealed class UpdateAddressCommandValidator : AbstractValidator<UpdateAddr
.Matches("^[0-9۰-۹]{10}$") .Matches("^[0-9۰-۹]{10}$")
.When(x => !string.IsNullOrWhiteSpace(x.PostalCode)) .When(x => !string.IsNullOrWhiteSpace(x.PostalCode))
.WithMessage("Postal code must be 10 digits."); .WithMessage("Postal code must be 10 digits.");
RuleFor(x => x.Latitude).InclusiveBetween(-90m, 90m).When(x => x.Latitude.HasValue);
RuleFor(x => x.Longitude).InclusiveBetween(-180m, 180m).When(x => x.Longitude.HasValue);
RuleFor(x => x)
.Must(x => x.Latitude.HasValue == x.Longitude.HasValue)
.WithMessage("Latitude and longitude must be supplied together.");
} }
} }
@@ -14,4 +14,6 @@ public record UpdateAddressCommand(
string AddressLine, string AddressLine,
string PostalCode, string PostalCode,
string RecipientName, string RecipientName,
string RecipientPhone) : IRequest<OperationResult<CustomerAddressDto>>; string RecipientPhone,
decimal? Latitude = null,
decimal? Longitude = null) : IRequest<OperationResult<CustomerAddressDto>>;
@@ -12,7 +12,9 @@ internal sealed class GetAuditTrailQueryHandler(IAuditLogger auditLogger)
public async ValueTask<OperationResult<PagedResult<AuditLogDto>>> Handle(GetAuditTrailQuery request, CancellationToken cancellationToken) public async ValueTask<OperationResult<PagedResult<AuditLogDto>>> Handle(GetAuditTrailQuery request, CancellationToken cancellationToken)
{ {
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize); var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var result = await auditLogger.GetTrailAsync(request.EntityType, request.EntityId, page, pageSize, cancellationToken); var result = await auditLogger.GetTrailAsync(
request.EntityType, request.EntityId, request.ActorId, request.Action, request.From, request.To,
page, pageSize, cancellationToken);
return OperationResult<PagedResult<AuditLogDto>>.SuccessResult(result); return OperationResult<PagedResult<AuditLogDto>>.SuccessResult(result);
} }
} }
@@ -1,8 +1,16 @@
using System;
using Baya.Application.Models.Audit; using Baya.Application.Models.Audit;
using Baya.Application.Models.Common; using Baya.Application.Models.Common;
using Mediator; using Mediator;
namespace Baya.Application.Features.Audit.Queries.GetAuditTrail; namespace Baya.Application.Features.Audit.Queries.GetAuditTrail;
public record GetAuditTrailQuery(string EntityType, string EntityId, int Page = 1, int PageSize = 50) public record GetAuditTrailQuery(
: IRequest<OperationResult<PagedResult<AuditLogDto>>>; string? EntityType = null,
string? EntityId = null,
int? ActorId = null,
string? Action = null,
DateTimeOffset? From = null,
DateTimeOffset? To = null,
int Page = 1,
int PageSize = 50) : IRequest<OperationResult<PagedResult<AuditLogDto>>>;
@@ -124,8 +124,13 @@ internal sealed class SettleBnplOrderCommandHandler(
// Open the booking-coordination ticket once the booking is confirmed (idempotent, one per booking) — b15. // Open the booking-coordination ticket once the booking is confirmed (idempotent, one per booking) — b15.
if (conversion.Created) if (conversion.Created)
{
await sender.Send(new Messaging.Commands.AutoCreateCoordinationTicket.AutoCreateCoordinationTicketCommand(conversion.BookingId), cancellationToken); await sender.Send(new Messaging.Commands.AutoCreateCoordinationTicket.AutoCreateCoordinationTicketCommand(conversion.BookingId), cancellationToken);
// Auto-issue the commission invoice so the customer can reach it right after settlement (idempotent).
await sender.Send(new Invoices.Commands.IssueInvoice.IssueInvoiceCommand(conversion.BookingId), cancellationToken);
}
return OperationResult<bool>.SuccessResult(true); return OperationResult<bool>.SuccessResult(true);
} }
@@ -46,7 +46,10 @@ internal sealed class CheckBnplEligibilityQueryHandler(
if (gatewayId is null) if (gatewayId is null)
return OperationResult<BnplEligibilityDto>.FailureResult("No active BNPL gateway is configured."); return OperationResult<BnplEligibilityDto>.FailureResult("No active BNPL gateway is configured.");
var eligibility = await provider.CheckEligibilityAsync(ctx.CustomerMobile, ctx.GrossIrr, cancellationToken); // The D3 inputs (national id / mobile / consent) feed the provider credit inquiry; the mock uses only the
// mobile today (the KYC step is deferred). Prefer the supplied mobile, else the account mobile.
var inquiryMobile = string.IsNullOrWhiteSpace(request.Mobile) ? ctx.CustomerMobile : request.Mobile;
var eligibility = await provider.CheckEligibilityAsync(inquiryMobile, ctx.GrossIrr, cancellationToken);
var merchantOfRecord = await platformConfig.GetConfig<string>("bnpl_merchant_of_record", cancellationToken); var merchantOfRecord = await platformConfig.GetConfig<string>("bnpl_merchant_of_record", cancellationToken);
var bnpl = await BnplOrderInitializer.EnsureAsync( var bnpl = await BnplOrderInitializer.EnsureAsync(
@@ -11,6 +11,11 @@ public sealed class CheckBnplEligibilityQueryValidator : AbstractValidator<Check
RuleFor(x => x.ProviderCode) RuleFor(x => x.ProviderCode)
.NotEmpty() .NotEmpty()
.Must(BnplProviderCodes.IsKnown) .Must(BnplProviderCodes.IsKnown)
.WithMessage("provider_code must be one of snapppay, digipay, tara, torobpay."); .WithMessage("provider_code must be one of snapppay, digipay, tara, torobpay, balinyaar.");
// The D3 credit inquiry is consented: when national id / mobile are supplied, consent must be granted.
RuleFor(x => x.Consent)
.Equal(true)
.When(x => !string.IsNullOrWhiteSpace(x.NationalId) || !string.IsNullOrWhiteSpace(x.Mobile))
.WithMessage("Consent is required to run the credit inquiry.");
} }
} }
@@ -11,6 +11,13 @@ namespace Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility;
/// client shows the plan summary on <c>eligible</c> or falls back to card. Owned by the requesting customer. /// client shows the plan summary on <c>eligible</c> or falls back to card. Owned by the requesting customer.
/// </summary> /// </summary>
/// <param name="BookingRequestId">The accepted request to finance (a b9 <c>bookings</c> row exists only on settle).</param> /// <param name="BookingRequestId">The accepted request to finance (a b9 <c>bookings</c> row exists only on settle).</param>
/// <param name="ProviderCode">Which BNPL provider to check (<c>snapppay</c>/<c>digipay</c>/<c>tara</c>/<c>torobpay</c>).</param> /// <param name="ProviderCode">Which BNPL provider to check (<c>snapppay</c>/<c>digipay</c>/<c>tara</c>/<c>torobpay</c>/<c>balinyaar</c>).</param>
public record CheckBnplEligibilityQuery(long BookingRequestId, string ProviderCode) /// <param name="NationalId">The D3 credit-inquiry national id (optional; used for the provider KYC inquiry).</param>
: IRequest<OperationResult<BnplEligibilityDto>>; /// <param name="Mobile">The D3 credit-inquiry mobile (optional; falls back to the account mobile).</param>
/// <param name="Consent">The D3 consent checkbox — must be true when the KYC inputs are supplied.</param>
public record CheckBnplEligibilityQuery(
long BookingRequestId,
string ProviderCode,
string NationalId = null,
string Mobile = null,
bool? Consent = null) : IRequest<OperationResult<BnplEligibilityDto>>;
@@ -0,0 +1,25 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bnpl.Queries.GetBnplOrderByRequest;
internal sealed class GetBnplOrderByRequestQueryHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork)
: IRequestHandler<GetBnplOrderByRequestQuery, OperationResult<BnplOrderStatusDto>>
{
public async ValueTask<OperationResult<BnplOrderStatusDto>> Handle(GetBnplOrderByRequestQuery request, CancellationToken cancellationToken)
{
var projection = await unitOfWork.BnplRepository.GetStatusByRequestAsync(request.BookingRequestId, cancellationToken);
// Cross-customer access is indistinguishable from "no order" — never leak another customer's order.
if (projection is null || projection.CustomerUserId != currentUser.UserId)
return OperationResult<BnplOrderStatusDto>.NotFoundResult("BNPL order not found.");
return OperationResult<BnplOrderStatusDto>.SuccessResult(projection.Order);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bnpl.Queries.GetBnplOrderByRequest;
/// <summary>Reads the BNPL order for a booking request (the id the return-poll surface holds, not the order id),
/// owner-scoped. 404 when the request has no BNPL order.</summary>
public record GetBnplOrderByRequestQuery(long BookingRequestId) : IRequest<OperationResult<BnplOrderStatusDto>>;
@@ -1,4 +1,5 @@
#nullable enable #nullable enable
using System.Globalization;
using Baya.Application.Models.Booking; using Baya.Application.Models.Booking;
namespace Baya.Application.Features.Booking; namespace Baya.Application.Features.Booking;
@@ -43,5 +44,8 @@ internal static class BookingRequestMapper
p.NurseResponseDeadlineAt, p.NurseResponseDeadlineAt,
p.PaymentDeadlineAt, p.PaymentDeadlineAt,
p.NurseRejectionReason, p.NurseRejectionReason,
p.CreatedAt); p.CreatedAt,
p.VariantPrice.ToString(CultureInfo.InvariantCulture),
p.NurseAvatarUrl,
p.BookingId);
} }
@@ -0,0 +1,73 @@
#nullable enable
using System.Globalization;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Booking.Queries.GetCheckoutSummary;
internal sealed class GetCheckoutSummaryQueryHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IPlatformConfig platformConfig)
: IRequestHandler<GetCheckoutSummaryQuery, OperationResult<CheckoutSummaryDto>>
{
public async ValueTask<OperationResult<CheckoutSummaryDto>> Handle(GetCheckoutSummaryQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<CheckoutSummaryDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
return OperationResult<CheckoutSummaryDto>.ForbiddenResult("Only a customer can read a checkout summary.");
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerId is not { } cid)
return OperationResult<CheckoutSummaryDto>.NotFoundResult("Booking request not found.");
var ctx = await unitOfWork.BookingRequestRepository.GetCheckoutContextAsync(request.BookingRequestId, cid, cancellationToken);
if (ctx is null)
return OperationResult<CheckoutSummaryDto>.NotFoundResult("Booking request not found.");
var sessions = ctx.SessionCount is > 0 ? ctx.SessionCount.Value : 1;
var gross = ctx.VariantPrice * sessions;
var feeRate = await platformConfig.GetConfig<decimal>("platform_fee_rate", cancellationToken);
var vatRate = await platformConfig.GetConfig<decimal>("vat_rate", cancellationToken);
// b10 split (VAT-inclusive commission), then carve VAT out of the commission so the display
// decomposition reconciles to the captured total: serviceCost + commissionNet + vat = gross.
var (commission, payout) = BookingAmounts.Split(gross, feeRate);
var commissionNet = (long)decimal.Round(commission / (1 + vatRate), MidpointRounding.AwayFromZero);
var vat = commission - commissionNet;
var dto = new CheckoutSummaryDto(
ctx.Id,
ctx.Status,
ctx.NurseName,
ctx.PatientName,
ctx.VariantLabel,
ctx.VariantPriceUnit,
ctx.SessionCount,
ctx.RequestedDate,
ctx.RequestedTimeStart,
ctx.RequestedTimeEnd,
ctx.PaymentDeadlineAt,
Str(payout),
Str(commissionNet),
Str(vat),
vatRate,
Str(gross),
Str(gross),
Str(commission),
Str(payout));
return OperationResult<CheckoutSummaryDto>.SuccessResult(dto);
}
private static string Str(long value) => value.ToString(CultureInfo.InvariantCulture);
}
@@ -0,0 +1,12 @@
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Booking.Queries.GetCheckoutSummary;
/// <summary>
/// Owner-scoped checkout money summary for a booking request (C6). Serves the reconciling gross / commission
/// (net of VAT) / VAT / total decomposition computed server-side from config; the client renders, never
/// derives. A foreign or absent request is a clean not-found.
/// </summary>
public record GetCheckoutSummaryQuery(long BookingRequestId) : IRequest<OperationResult<CheckoutSummaryDto>>;
@@ -1,4 +1,5 @@
#nullable enable #nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence; using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common; using Baya.Application.Models.Common;
@@ -29,6 +30,8 @@ internal sealed class CreatePatientCommandHandler(ICurrentUser currentUser, IUni
Gender = request.Gender, Gender = request.Gender,
BloodType = request.BloodType, BloodType = request.BloodType,
InitialMedicalNotes = request.InitialMedicalNotes, InitialMedicalNotes = request.InitialMedicalNotes,
Relation = request.Relation,
ConditionsJson = JsonCodeList.Serialize(request.Conditions),
IsActive = true IsActive = true
}; };
@@ -58,6 +61,8 @@ internal sealed class CreatePatientCommandHandler(ICurrentUser currentUser, IUni
patient.Gender, patient.Gender,
patient.BloodType, patient.BloodType,
patient.InitialMedicalNotes, patient.InitialMedicalNotes,
patient.IsActive)); patient.IsActive,
patient.Relation,
JsonCodeList.Parse(patient.ConditionsJson)));
} }
} }
@@ -17,5 +17,11 @@ public sealed class CreatePatientCommandValidator : AbstractValidator<CreatePati
.NotEqual(default(DateOnly)) .NotEqual(default(DateOnly))
.Must(PatientRules.IsNotFuture) .Must(PatientRules.IsNotFuture)
.WithMessage("Birth date cannot be in the future."); .WithMessage("Birth date cannot be in the future.");
RuleFor(x => x.Relation)
.Must(PatientRules.IsValidRelation)
.WithMessage("Relation must be one of parent, spouse, child, self.");
RuleFor(x => x.Conditions)
.Must(PatientRules.AreValidConditions)
.WithMessage("Each condition must be a non-empty code up to 40 characters.");
} }
} }
@@ -16,4 +16,6 @@ public record CreatePatientCommand(
DateOnly BirthDate, DateOnly BirthDate,
string Gender, string Gender,
string BloodType, string BloodType,
string InitialMedicalNotes) : IRequest<OperationResult<PatientDto>>; string InitialMedicalNotes,
string Relation = null,
IReadOnlyList<string> Conditions = null) : IRequest<OperationResult<PatientDto>>;
@@ -33,7 +33,8 @@ internal sealed class RequestOtpCommandHandler(
var now = clock.UtcNow; var now = clock.UtcNow;
if (windowEndsAt > now) if (windowEndsAt > now)
return OperationResult<RequestOtpResult>.SuccessResult( return OperationResult<RequestOtpResult>.SuccessResult(
new RequestOtpResult(false, (int)Math.Ceiling((windowEndsAt - now).TotalSeconds))); new RequestOtpResult(false, (int)Math.Ceiling((windowEndsAt - now).TotalSeconds),
IdentityDefaults.OtpCodeLength, IdentityDefaults.OtpExpirySeconds));
var user = await userManager.GetUserByPhoneNumber(phone); var user = await userManager.GetUserByPhoneNumber(phone);
if (user is null) if (user is null)
@@ -64,6 +65,7 @@ internal sealed class RequestOtpCommandHandler(
await cache.SetAsync(resendKey, now.AddSeconds(resendSeconds), TimeSpan.FromSeconds(resendSeconds), cancellationToken); await cache.SetAsync(resendKey, now.AddSeconds(resendSeconds), TimeSpan.FromSeconds(resendSeconds), cancellationToken);
return OperationResult<RequestOtpResult>.SuccessResult(new RequestOtpResult(true, resendSeconds)); return OperationResult<RequestOtpResult>.SuccessResult(
new RequestOtpResult(true, resendSeconds, IdentityDefaults.OtpCodeLength, IdentityDefaults.OtpExpirySeconds));
} }
} }
@@ -1,4 +1,5 @@
#nullable enable #nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence; using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common; using Baya.Application.Models.Common;
@@ -34,6 +35,8 @@ internal sealed class UpdatePatientCommandHandler(ICurrentUser currentUser, IUni
patient.Gender = request.Gender; patient.Gender = request.Gender;
patient.BloodType = request.BloodType; patient.BloodType = request.BloodType;
patient.InitialMedicalNotes = request.InitialMedicalNotes; patient.InitialMedicalNotes = request.InitialMedicalNotes;
patient.Relation = request.Relation;
patient.ConditionsJson = JsonCodeList.Serialize(request.Conditions);
await unitOfWork.CommitAsync(); await unitOfWork.CommitAsync();
@@ -46,6 +49,8 @@ internal sealed class UpdatePatientCommandHandler(ICurrentUser currentUser, IUni
patient.Gender, patient.Gender,
patient.BloodType, patient.BloodType,
patient.InitialMedicalNotes, patient.InitialMedicalNotes,
patient.IsActive)); patient.IsActive,
patient.Relation,
JsonCodeList.Parse(patient.ConditionsJson)));
} }
} }
@@ -18,5 +18,11 @@ public sealed class UpdatePatientCommandValidator : AbstractValidator<UpdatePati
.NotEqual(default(DateOnly)) .NotEqual(default(DateOnly))
.Must(PatientRules.IsNotFuture) .Must(PatientRules.IsNotFuture)
.WithMessage("Birth date cannot be in the future."); .WithMessage("Birth date cannot be in the future.");
RuleFor(x => x.Relation)
.Must(PatientRules.IsValidRelation)
.WithMessage("Relation must be one of parent, spouse, child, self.");
RuleFor(x => x.Conditions)
.Must(PatientRules.AreValidConditions)
.WithMessage("Each condition must be a non-empty code up to 40 characters.");
} }
} }
@@ -13,4 +13,6 @@ public record UpdatePatientCommand(
DateOnly BirthDate, DateOnly BirthDate,
string Gender, string Gender,
string BloodType, string BloodType,
string InitialMedicalNotes) : IRequest<OperationResult<PatientDto>>; string InitialMedicalNotes,
string Relation = null,
IReadOnlyList<string> Conditions = null) : IRequest<OperationResult<PatientDto>>;
@@ -0,0 +1,53 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.UploadCustomerAvatar;
internal sealed class UploadCustomerAvatarCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IObjectStorage objectStorage)
: IRequestHandler<UploadCustomerAvatarCommand, OperationResult<AvatarUploadResult>>
{
public async ValueTask<OperationResult<AvatarUploadResult>> Handle(UploadCustomerAvatarCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<AvatarUploadResult>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
return OperationResult<AvatarUploadResult>.ForbiddenResult("Only a customer can upload a customer avatar.");
if (!AvatarUpload.IsAllowedContentType(request.ContentType))
return OperationResult<AvatarUploadResult>.FailureResult("Unsupported image type. Use JPEG, PNG, or WebP.");
if (!AvatarUpload.IsWithinSizeLimit(request.Length))
return OperationResult<AvatarUploadResult>.FailureResult("Image is empty or exceeds the 5 MB limit.");
var profile = await unitOfWork.CustomerProfileRepository.GetByUserIdAsync(userId, cancellationToken);
if (profile is null)
{
// Mirror the customer-profile upsert: provision the thin payer row so an avatar-first customer
// still gets a profile to attach the URL to.
profile = new CustomerProfile { UserId = userId };
await unitOfWork.CustomerProfileRepository.AddAsync(profile, cancellationToken);
await unitOfWork.CommitAsync();
}
var key = AvatarUpload.BuildKey("customer", profile.Id, request.ContentType, Guid.NewGuid().ToString("N"));
using (var stream = new MemoryStream(request.Content))
await objectStorage.PutAsync(key, stream, request.ContentType, cancellationToken);
var url = objectStorage.GetUrl(key);
profile.AvatarUrl = url;
await unitOfWork.CommitAsync();
return OperationResult<AvatarUploadResult>.SuccessResult(new AvatarUploadResult(url));
}
}
@@ -0,0 +1,14 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.UploadCustomerAvatar;
/// <summary>
/// Stores the signed-in customer's profile photo via <c>IObjectStorage</c> and persists the returned URL on
/// the customer profile. The controller reads the multipart file; the command carries the bytes + metadata.
/// </summary>
public record UploadCustomerAvatarCommand(
byte[] Content,
string ContentType,
long Length) : IRequest<OperationResult<AvatarUploadResult>>;
@@ -0,0 +1,46 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.UploadNurseAvatar;
internal sealed class UploadNurseAvatarCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IObjectStorage objectStorage)
: IRequestHandler<UploadNurseAvatarCommand, OperationResult<AvatarUploadResult>>
{
public async ValueTask<OperationResult<AvatarUploadResult>> Handle(UploadNurseAvatarCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<AvatarUploadResult>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<AvatarUploadResult>.ForbiddenResult("Only a nurse can upload a nurse avatar.");
if (!AvatarUpload.IsAllowedContentType(request.ContentType))
return OperationResult<AvatarUploadResult>.FailureResult("Unsupported image type. Use JPEG, PNG, or WebP.");
if (!AvatarUpload.IsWithinSizeLimit(request.Length))
return OperationResult<AvatarUploadResult>.FailureResult("Image is empty or exceeds the 5 MB limit.");
var profile = await unitOfWork.NurseProfileRepository.GetByUserIdAsync(userId, cancellationToken);
if (profile is null)
return OperationResult<AvatarUploadResult>.NotFoundResult("No nurse profile exists yet.");
var key = AvatarUpload.BuildKey("nurse", profile.Id, request.ContentType, Guid.NewGuid().ToString("N"));
using (var stream = new MemoryStream(request.Content))
await objectStorage.PutAsync(key, stream, request.ContentType, cancellationToken);
var url = objectStorage.GetUrl(key);
profile.AvatarUrl = url;
await unitOfWork.CommitAsync();
return OperationResult<AvatarUploadResult>.SuccessResult(new AvatarUploadResult(url));
}
}
@@ -0,0 +1,14 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.UploadNurseAvatar;
/// <summary>
/// Stores the signed-in nurse's profile photo via <c>IObjectStorage</c> and persists the returned URL on
/// the nurse profile. The controller reads the multipart file; the command carries the bytes + metadata.
/// </summary>
public record UploadNurseAvatarCommand(
byte[] Content,
string ContentType,
long Length) : IRequest<OperationResult<AvatarUploadResult>>;
@@ -29,7 +29,8 @@ internal sealed class UpsertCustomerProfileCommandHandler(ICurrentUser currentUs
{ {
UserId = userId, UserId = userId,
DefaultEmergencyContactName = request.DefaultEmergencyContactName, DefaultEmergencyContactName = request.DefaultEmergencyContactName,
DefaultEmergencyContactPhone = phone DefaultEmergencyContactPhone = phone,
PreferredLanguage = request.PreferredLanguage
}; };
await unitOfWork.CustomerProfileRepository.AddAsync(profile, cancellationToken); await unitOfWork.CustomerProfileRepository.AddAsync(profile, cancellationToken);
} }
@@ -37,6 +38,22 @@ internal sealed class UpsertCustomerProfileCommandHandler(ICurrentUser currentUs
{ {
profile.DefaultEmergencyContactName = request.DefaultEmergencyContactName; profile.DefaultEmergencyContactName = request.DefaultEmergencyContactName;
profile.DefaultEmergencyContactPhone = phone; profile.DefaultEmergencyContactPhone = phone;
if (request.PreferredLanguage is not null)
profile.PreferredLanguage = request.PreferredLanguage;
}
// The customer's display name lives on the base identity row, not the profile — update it in the
// same unit of work when supplied.
if (request.FirstName is not null || request.LastName is not null)
{
var user = await unitOfWork.UserAccountRepository.GetTrackedByIdAsync(userId, cancellationToken);
if (user is not null)
{
if (request.FirstName is not null)
user.Name = request.FirstName;
if (request.LastName is not null)
user.FamilyName = request.LastName;
}
} }
await unitOfWork.CommitAsync(); await unitOfWork.CommitAsync();
@@ -6,8 +6,12 @@ namespace Baya.Application.Features.Identity.Commands.UpsertCustomerProfile;
/// <summary> /// <summary>
/// Creates (first call) or updates the signed-in customer's payer profile and its default emergency /// Creates (first call) or updates the signed-in customer's payer profile and its default emergency
/// contact (encrypted at rest). Idempotent on the owning user. /// contact (encrypted at rest). Optionally updates the customer's display name (persisted on the base
/// <c>users</c> row) and preferred UI language (persisted on the profile). Idempotent on the owning user.
/// </summary> /// </summary>
public record UpsertCustomerProfileCommand( public record UpsertCustomerProfileCommand(
string DefaultEmergencyContactName, string DefaultEmergencyContactName,
string DefaultEmergencyContactPhone) : IRequest<OperationResult<CustomerProfileDto>>; string DefaultEmergencyContactPhone,
string FirstName = null,
string LastName = null,
string PreferredLanguage = null) : IRequest<OperationResult<CustomerProfileDto>>;
@@ -35,7 +35,13 @@ internal sealed class VerifyOtpCommandHandler(
var maxAttempts = await platformConfig.GetConfig<int>(IdentityDefaults.OtpMaxAttemptsKey, cancellationToken); var maxAttempts = await platformConfig.GetConfig<int>(IdentityDefaults.OtpMaxAttemptsKey, cancellationToken);
if (user.AccessFailedCount >= maxAttempts) if (user.AccessFailedCount >= maxAttempts)
return OperationResult<AuthTokensResult>.FailureResult("Too many failed attempts. Request a new code."); {
// Lockout is the one machine-distinguishable state (safe — it reveals nothing about the code or
// account). The client shows the unlock countdown; a fresh code is gated by the resend window.
var retryAfterSeconds = await platformConfig.GetConfig<int>(IdentityDefaults.OtpResendSecondsKey, cancellationToken);
return OperationResult<AuthTokensResult>.CodedFailureResult(
"otp_locked", "Too many failed attempts. Request a new code.", new { retryAfterSeconds });
}
// First-ever verify confirms the phone (ChangePhoneNumber to the same number); afterwards the // First-ever verify confirms the phone (ChangePhoneNumber to the same number); afterwards the
// passwordless TOTP path applies. Both rotate the security stamp, so the token is minted after. // passwordless TOTP path applies. Both rotate the security stamp, so the token is minted after.
@@ -47,7 +53,9 @@ internal sealed class VerifyOtpCommandHandler(
if (!verifyResult.Succeeded) if (!verifyResult.Succeeded)
{ {
await userManager.IncrementAccessFailedCountAsync(user); await userManager.IncrementAccessFailedCountAsync(user);
return OperationResult<AuthTokensResult>.FailureResult(InvalidCodeMessage); // Wrong and expired stay collapsed behind one code + message (anti-enumeration); only lockout is
// distinguished (above).
return OperationResult<AuthTokensResult>.CodedFailureResult("otp_invalid", InvalidCodeMessage);
} }
var now = clock.UtcNow; var now = clock.UtcNow;
@@ -21,6 +21,14 @@ internal static class IdentityDefaults
/// <summary>Refresh-token session lifetime, in days.</summary> /// <summary>Refresh-token session lifetime, in days.</summary>
public const string SessionTtlDaysKey = "auth_session_ttl_days"; public const string SessionTtlDaysKey = "auth_session_ttl_days";
/// <summary>Number of digits in the OTP code — mirrors the TOTP token provider (6). Surfaced on
/// <c>RequestOtpResult</c> so the client renders the correct number of input boxes contract-driven.</summary>
public const int OtpCodeLength = 6;
/// <summary>How long an OTP stays valid, in seconds — mirrors the passwordless TOTP
/// <c>TokenLifespan</c> (1 minute). Surfaced so the client can show a "code expires in …" hint.</summary>
public const int OtpExpirySeconds = 60;
/// <summary>Cache key of the per-phone resend window (keyed by phone hash, never the raw phone).</summary> /// <summary>Cache key of the per-phone resend window (keyed by phone hash, never the raw phone).</summary>
public static string OtpResendCacheKey(string phoneHash) => $"auth:otp:resend:{phoneHash}"; public static string OtpResendCacheKey(string phoneHash) => $"auth:otp:resend:{phoneHash}";
@@ -6,4 +6,12 @@ internal static class PatientRules
public static bool IsValidGender(string gender) => gender is "male" or "female"; public static bool IsValidGender(string gender) => gender is "male" or "female";
public static bool IsNotFuture(DateOnly birthDate) => birthDate <= DateOnly.FromDateTime(DateTime.UtcNow.Date); public static bool IsNotFuture(DateOnly birthDate) => birthDate <= DateOnly.FromDateTime(DateTime.UtcNow.Date);
/// <summary>The care-recipient relation-to-payer code set. Null/empty means "unspecified".</summary>
public static bool IsValidRelation(string relation)
=> string.IsNullOrEmpty(relation) || relation is "parent" or "spouse" or "child" or "self";
/// <summary>Each condition is a short stable code; the list itself may be null/empty.</summary>
public static bool AreValidConditions(IReadOnlyList<string> conditions)
=> conditions is null || conditions.All(c => !string.IsNullOrWhiteSpace(c) && c.Length <= 40);
} }
@@ -18,6 +18,7 @@ internal static class InvoiceDtoFactory
invoice.BnplCommissionIrr?.ToString(), invoice.BnplCommissionIrr?.ToString(),
invoice.VatRate, invoice.VatRate,
invoice.VatIrr.ToString(), invoice.VatIrr.ToString(),
(invoice.PlatformCommissionIrr + (invoice.BnplCommissionIrr ?? 0) + invoice.VatIrr).ToString(),
invoice.MoadianReferenceNumber, invoice.MoadianReferenceNumber,
invoice.MoadianStatus, invoice.MoadianStatus,
pdfUrl, pdfUrl,
@@ -41,6 +41,16 @@ internal sealed class PostMessageCommandHandler(
if (header.Status == TicketStatus.Closed && !isStaff) if (header.Status == TicketStatus.Closed && !isStaff)
return OperationResult<PostMessageResult>.ForbiddenResult("This ticket is closed."); return OperationResult<PostMessageResult>.ForbiddenResult("This ticket is closed.");
// Optimistic-send idempotency: a retried post with the same client message id returns the original
// message instead of a duplicate.
if (!string.IsNullOrWhiteSpace(request.ClientMessageId))
{
var existing = await unitOfWork.TicketRepository.GetMessageByClientIdAsync(request.TicketId, request.ClientMessageId, cancellationToken);
if (existing is not null)
return OperationResult<PostMessageResult>.SuccessResult(
new PostMessageResult(existing.Id, request.TicketId, existing.SentAt, request.ClientMessageId));
}
var now = dateTimeProvider.UtcNow; var now = dateTimeProvider.UtcNow;
var message = new TicketMessage var message = new TicketMessage
{ {
@@ -48,6 +58,7 @@ internal sealed class PostMessageCommandHandler(
SenderId = userId, SenderId = userId,
Body = request.Body, Body = request.Body,
IsInternal = request.IsInternal, IsInternal = request.IsInternal,
ClientMessageId = string.IsNullOrWhiteSpace(request.ClientMessageId) ? null : request.ClientMessageId,
SentAt = now SentAt = now
}; };
@@ -67,6 +78,6 @@ internal sealed class PostMessageCommandHandler(
} }
return OperationResult<PostMessageResult>.SuccessResult( return OperationResult<PostMessageResult>.SuccessResult(
new PostMessageResult(message.Id, request.TicketId, message.SentAt)); new PostMessageResult(message.Id, request.TicketId, message.SentAt, message.ClientMessageId));
} }
} }
@@ -8,5 +8,5 @@ namespace Baya.Application.Features.Messaging.Commands.PostMessage;
/// <summary>Appends a message to a ticket. Only an active participant (or staff) may post. <see cref="IsInternal"/> /// <summary>Appends a message to a ticket. Only an active participant (or staff) may post. <see cref="IsInternal"/>
/// (an admin-only note) can be set <b>only</b> by staff; a non-staff caller can neither set it nor post to a /// (an admin-only note) can be set <b>only</b> by staff; a non-staff caller can neither set it nor post to a
/// closed ticket.</summary> /// closed ticket.</summary>
public record PostMessageCommand(long TicketId, string Body, bool IsInternal = false) public record PostMessageCommand(long TicketId, string Body, bool IsInternal = false, string? ClientMessageId = null)
: IRequest<OperationResult<PostMessageResult>>; : IRequest<OperationResult<PostMessageResult>>;
@@ -15,7 +15,8 @@ namespace Baya.Application.Features.Messaging.Queries.GetTicketThread;
/// </summary> /// </summary>
internal sealed class GetTicketThreadQueryHandler( internal sealed class GetTicketThreadQueryHandler(
ICurrentUser currentUser, ICurrentUser currentUser,
IUnitOfWork unitOfWork) IUnitOfWork unitOfWork,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<GetTicketThreadQuery, OperationResult<TicketThreadDto>> : IRequestHandler<GetTicketThreadQuery, OperationResult<TicketThreadDto>>
{ {
public async ValueTask<OperationResult<TicketThreadDto>> Handle(GetTicketThreadQuery request, CancellationToken cancellationToken) public async ValueTask<OperationResult<TicketThreadDto>> Handle(GetTicketThreadQuery request, CancellationToken cancellationToken)
@@ -42,6 +43,18 @@ internal sealed class GetTicketThreadQueryHandler(
var participants = await unitOfWork.TicketRepository.GetActiveParticipantsAsync(request.TicketId, cancellationToken); var participants = await unitOfWork.TicketRepository.GetActiveParticipantsAsync(request.TicketId, cancellationToken);
var messages = await unitOfWork.TicketRepository.GetMessagesAsync(request.TicketId, includeInternal, cancellationToken); var messages = await unitOfWork.TicketRepository.GetMessagesAsync(request.TicketId, includeInternal, cancellationToken);
// Fetching the user-facing thread marks it read for the caller (drives the inbox unread count) — admins
// reading the staff view do not consume a participant's read state.
if (!request.AsAdmin)
{
var participant = await unitOfWork.TicketRepository.GetParticipantAsync(request.TicketId, userId, cancellationToken);
if (participant is { IsActive: true })
{
participant.MarkRead(dateTimeProvider.UtcNow);
await unitOfWork.CommitAsync();
}
}
return OperationResult<TicketThreadDto>.SuccessResult(new TicketThreadDto( return OperationResult<TicketThreadDto>.SuccessResult(new TicketThreadDto(
header.Id, header.ReferenceCode, header.Subject, header.Status, header.Category, header.Id, header.ReferenceCode, header.Subject, header.Status, header.Category,
header.BookingId, header.RefundId, header.OpenedById, header.ClosedAt, participants, messages)); header.BookingId, header.RefundId, header.OpenedById, header.ClosedAt, participants, messages));
@@ -19,7 +19,7 @@ internal sealed class ListMyTicketsQueryHandler(
return OperationResult<PagedResult<TicketSummaryDto>>.UnauthorizedResult("Not authenticated."); return OperationResult<PagedResult<TicketSummaryDto>>.UnauthorizedResult("Not authenticated.");
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize); var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var result = await unitOfWork.TicketRepository.ListMyTicketsAsync(userId, request.Status, request.ReferenceCode, page, pageSize, cancellationToken); var result = await unitOfWork.TicketRepository.ListMyTicketsAsync(userId, request.Status, request.ReferenceCode, request.BookingId, page, pageSize, cancellationToken);
return OperationResult<PagedResult<TicketSummaryDto>>.SuccessResult(result); return OperationResult<PagedResult<TicketSummaryDto>>.SuccessResult(result);
} }
} }
@@ -9,5 +9,6 @@ namespace Baya.Application.Features.Messaging.Queries.ListMyTickets;
public record ListMyTicketsQuery( public record ListMyTicketsQuery(
string? Status = null, string? Status = null,
string? ReferenceCode = null, string? ReferenceCode = null,
long? BookingId = null,
int Page = 1, int Page = 1,
int PageSize = 50) : IRequest<OperationResult<PagedResult<TicketSummaryDto>>>; int PageSize = 50) : IRequest<OperationResult<PagedResult<TicketSummaryDto>>>;
@@ -0,0 +1,19 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Nurses;
using Mediator;
namespace Baya.Application.Features.Nurses.Queries.GetNursePublicProfile;
internal sealed class GetNursePublicProfileQueryHandler(IUnitOfWork unitOfWork)
: IRequestHandler<GetNursePublicProfileQuery, OperationResult<NursePublicProfileDto>>
{
public async ValueTask<OperationResult<NursePublicProfileDto>> Handle(GetNursePublicProfileQuery request, CancellationToken cancellationToken)
{
var dto = await unitOfWork.NurseProfileRepository.GetPublicProfileAsync(request.NurseId, cancellationToken);
return dto is null
? OperationResult<NursePublicProfileDto>.NotFoundResult("Nurse not found.")
: OperationResult<NursePublicProfileDto>.SuccessResult(dto);
}
}
@@ -0,0 +1,12 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Nurses;
using Mediator;
namespace Baya.Application.Features.Nurses.Queries.GetNursePublicProfile;
/// <summary>
/// The aggregated public nurse profile for the C3 discovery detail. Anonymous — it exposes only public,
/// non-PII facts (identity name/avatar/bio, aggregates, verification signal, specialty chips, active
/// services, the latest published review). No encrypted credential number is ever returned.
/// </summary>
public record GetNursePublicProfileQuery(long NurseId) : IRequest<OperationResult<NursePublicProfileDto>>;
@@ -0,0 +1,24 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Mediator;
namespace Baya.Application.Features.PartnerCenters.Commands.SetPartnerCenterActive;
internal sealed class SetPartnerCenterActiveCommandHandler(IUnitOfWork unitOfWork)
: IRequestHandler<SetPartnerCenterActiveCommand, OperationResult<PartnerCenterDetailDto>>
{
public async ValueTask<OperationResult<PartnerCenterDetailDto>> Handle(SetPartnerCenterActiveCommand request, CancellationToken cancellationToken)
{
var center = await unitOfWork.PartnerCenterRepository.GetTrackedAsync(request.Id, cancellationToken);
if (center is null)
return OperationResult<PartnerCenterDetailDto>.NotFoundResult("Partner center not found.");
center.SetActive(request.IsActive);
await unitOfWork.CommitAsync();
var detail = await unitOfWork.PartnerCenterRepository.GetDetailAsync(center.Id, cancellationToken);
return OperationResult<PartnerCenterDetailDto>.SuccessResult(detail!);
}
}
@@ -0,0 +1,11 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.PartnerCenters;
using Mediator;
namespace Baya.Application.Features.PartnerCenters.Commands.SetPartnerCenterActive;
/// <summary>Activates or suspends a partner center (admin). Distinct from verify (verify records the licensing
/// approval + activates); this is the standalone activate/suspend toggle. The id comes from the route.</summary>
public record SetPartnerCenterActiveCommand(bool IsActive, long Id = 0)
: IRequest<OperationResult<PartnerCenterDetailDto>>;
@@ -0,0 +1,61 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Models.Patients;
namespace Baya.Application.Features.PatientCareRecords;
/// <summary>(De)serializes the family care plan's three JSON lists and assigns stable ids on write: a
/// caller-supplied id (&gt; 0) is preserved, a new item (id 0) gets the next free id — so ids stay stable
/// across edits within a plan.</summary>
internal static class CarePlanSerialization
{
public static IReadOnlyList<MedicationDto> ParseMedications(string? json)
=> Parse<MedicationDto>(json);
public static IReadOnlyList<RoutineItemDto> ParseRoutine(string? json)
=> Parse<RoutineItemDto>(json);
public static IReadOnlyList<CareTaskDto> ParseTasks(string? json)
=> Parse<CareTaskDto>(json);
public static (string Json, IReadOnlyList<MedicationDto> Items) AssignMedications(IReadOnlyList<MedicationDto>? items)
{
var assigned = AssignIds(items, (m, id) => m with { Id = id }, m => m.Id);
return (JsonSerializer.Serialize(assigned), assigned);
}
public static (string Json, IReadOnlyList<RoutineItemDto> Items) AssignRoutine(IReadOnlyList<RoutineItemDto>? items)
{
var assigned = AssignIds(items, (r, id) => r with { Id = id }, r => r.Id);
return (JsonSerializer.Serialize(assigned), assigned);
}
public static (string Json, IReadOnlyList<CareTaskDto> Items) AssignTasks(IReadOnlyList<CareTaskDto>? items)
{
var assigned = AssignIds(items, (t, id) => t with { Id = id }, t => t.Id);
return (JsonSerializer.Serialize(assigned), assigned);
}
private static IReadOnlyList<T> Parse<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return [];
try
{
return JsonSerializer.Deserialize<List<T>>(json) ?? [];
}
catch (JsonException)
{
return [];
}
}
private static IReadOnlyList<T> AssignIds<T>(IReadOnlyList<T>? items, Func<T, long, T> withId, Func<T, long> getId)
{
if (items is null || items.Count == 0)
return [];
var next = items.Select(getId).DefaultIfEmpty(0).Max() + 1;
return items.Select(i => getId(i) > 0 ? i : withId(i, next++)).ToList();
}
}
@@ -0,0 +1,51 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Patients;
using Baya.Domain.Entities.Identity;
using Mediator;
namespace Baya.Application.Features.PatientCareRecords.Commands.UpsertCarePlan;
internal sealed class UpsertCarePlanCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork)
: IRequestHandler<UpsertCarePlanCommand, OperationResult<CarePlanDto>>
{
public async ValueTask<OperationResult<CarePlanDto>> Handle(UpsertCarePlanCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is null)
return OperationResult<CarePlanDto>.UnauthorizedResult("Not authenticated.");
var (owner, access) = await PatientAccess.ResolveAsync(currentUser, unitOfWork, request.PatientId, cancellationToken);
if (owner is null)
return OperationResult<CarePlanDto>.NotFoundResult("Patient not found.");
if (!access.CanEdit)
return OperationResult<CarePlanDto>.ForbiddenResult("Only the owning customer can edit the care plan.");
var (medsJson, meds) = CarePlanSerialization.AssignMedications(request.Medications);
var (routineJson, routine) = CarePlanSerialization.AssignRoutine(request.Routine);
var (tasksJson, tasks) = CarePlanSerialization.AssignTasks(request.Tasks);
var plan = await unitOfWork.PatientCareRecordRepository.GetCarePlanAsync(request.PatientId, cancellationToken);
if (plan is null)
{
plan = new PatientCarePlan { PatientId = request.PatientId };
plan.MedicationsJson = medsJson;
plan.RoutineJson = routineJson;
plan.TasksJson = tasksJson;
await unitOfWork.PatientCareRecordRepository.AddCarePlanAsync(plan, cancellationToken);
}
else
{
plan.MedicationsJson = medsJson;
plan.RoutineJson = routineJson;
plan.TasksJson = tasksJson;
}
await unitOfWork.CommitAsync();
return OperationResult<CarePlanDto>.SuccessResult(new CarePlanDto(request.PatientId, meds, routine, tasks));
}
}
@@ -0,0 +1,14 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Patients;
using Mediator;
namespace Baya.Application.Features.PatientCareRecords.Commands.UpsertCarePlan;
/// <summary>Replaces the family-owned care plan for a patient (owning customer only). New items (id 0) are
/// assigned stable ids on save. The patient id comes from the route.</summary>
public record UpsertCarePlanCommand(
long PatientId,
IReadOnlyList<MedicationDto>? Medications,
IReadOnlyList<RoutineItemDto>? Routine,
IReadOnlyList<CareTaskDto>? Tasks) : IRequest<OperationResult<CarePlanDto>>;
@@ -1,4 +1,5 @@
#nullable enable #nullable enable
using System.Text.Json;
using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence; using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common; using Baya.Application.Models.Common;
@@ -47,6 +48,7 @@ internal sealed class WritePatientCareRecordCommandHandler(
BookingId = request.BookingId, BookingId = request.BookingId,
NurseProfileId = nurseId, NurseProfileId = nurseId,
BodyEncrypted = fieldEncryptor.Encrypt(request.Body.Trim()), BodyEncrypted = fieldEncryptor.Encrypt(request.Body.Trim()),
TaskResultsJson = request.TaskResults is { Count: > 0 } tr ? JsonSerializer.Serialize(tr) : null,
RecordedAt = dateTimeProvider.UtcNow.UtcDateTime RecordedAt = dateTimeProvider.UtcNow.UtcDateTime
}; };
@@ -1,11 +1,16 @@
#nullable enable #nullable enable
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 Mediator; using Mediator;
namespace Baya.Application.Features.PatientCareRecords.Commands.WritePatientCareRecord; namespace Baya.Application.Features.PatientCareRecords.Commands.WritePatientCareRecord;
/// <summary>A nurse authors a clinical note for a patient (optionally tagged with the booking that produced /// <summary>A nurse authors a clinical note for a patient (optionally tagged with the booking that produced
/// it). The patient id comes from the route; the body is encrypted at rest before persisting.</summary> /// it) plus the visit's ticked task checklist. The patient id comes from the route; the body is encrypted at
public record WritePatientCareRecordCommand(long PatientId, long? BookingId, string Body) /// rest before persisting.</summary>
: IRequest<OperationResult<WriteCareRecordResult>>; public record WritePatientCareRecordCommand(
long PatientId,
long? BookingId,
string Body,
IReadOnlyList<TaskResultDto>? TaskResults = null) : IRequest<OperationResult<WriteCareRecordResult>>;
@@ -0,0 +1,51 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Patients;
using Baya.Domain.Entities.User;
namespace Baya.Application.Features.PatientCareRecords;
/// <summary>
/// The single resolver for a caller's access to a patient's records: <b>edit</b> = the owning customer,
/// <b>append-note</b> = a nurse with a confirmed booking for the patient, <b>view</b> = either of those or an
/// admin. Centralized so the family care-plan get/put, the visit-note write/history, and the explicit
/// <c>record_access</c> read all apply the exact same rule.
/// </summary>
internal static class PatientAccess
{
private static readonly string[] AdminRoles = [RoleNames.Admin, RoleNames.SuperAdmin];
public const string DeniedNotFound = "not_found";
public const string DeniedNotAuthorized = "not_authorized";
/// <summary>Resolves access. <c>OwnerCustomerId</c> is null when the patient does not exist (a
/// <see cref="DeniedNotFound"/> access with everything false).</summary>
public static async Task<(long? OwnerCustomerId, RecordAccessDto Access)> ResolveAsync(
ICurrentUser currentUser, IUnitOfWork unitOfWork, long patientId, CancellationToken cancellationToken)
{
var ownerCustomerId = await unitOfWork.PatientCareRecordRepository.GetPatientOwnerCustomerIdAsync(patientId, cancellationToken);
if (ownerCustomerId is null)
return (null, new RecordAccessDto(false, false, false, DeniedNotFound));
var isAdmin = currentUser.Roles?.Any(AdminRoles.Contains) == true;
var userId = currentUser.UserId;
var customerProfileId = userId is null
? null
: await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId.Value, cancellationToken);
var canEdit = customerProfileId is { } cid && cid == ownerCustomerId;
var canAppendNote = false;
if (userId is not null)
{
var nurseProfileId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId.Value, cancellationToken);
if (nurseProfileId is { } nurseId)
canAppendNote = await unitOfWork.PatientCareRecordRepository
.NurseHasQualifyingBookingForPatientAsync(nurseId, patientId, cancellationToken);
}
var canView = canEdit || canAppendNote || isAdmin;
return (ownerCustomerId, new RecordAccessDto(canView, canEdit, canAppendNote, canView ? null : DeniedNotAuthorized));
}
}
@@ -0,0 +1,35 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Patients;
using Mediator;
namespace Baya.Application.Features.PatientCareRecords.Queries.GetCarePlan;
internal sealed class GetCarePlanQueryHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork)
: IRequestHandler<GetCarePlanQuery, OperationResult<CarePlanDto>>
{
public async ValueTask<OperationResult<CarePlanDto>> Handle(GetCarePlanQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is null)
return OperationResult<CarePlanDto>.UnauthorizedResult("Not authenticated.");
var (owner, access) = await PatientAccess.ResolveAsync(currentUser, unitOfWork, request.PatientId, cancellationToken);
if (owner is null)
return OperationResult<CarePlanDto>.NotFoundResult("Patient not found.");
if (!access.CanView)
return OperationResult<CarePlanDto>.ForbiddenResult("You do not have access to this patient's care plan.");
var plan = await unitOfWork.PatientCareRecordRepository.GetCarePlanAsync(request.PatientId, cancellationToken);
return OperationResult<CarePlanDto>.SuccessResult(plan is null
? new CarePlanDto(request.PatientId, [], [], [])
: new CarePlanDto(
request.PatientId,
CarePlanSerialization.ParseMedications(plan.MedicationsJson),
CarePlanSerialization.ParseRoutine(plan.RoutineJson),
CarePlanSerialization.ParseTasks(plan.TasksJson)));
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Patients;
using Mediator;
namespace Baya.Application.Features.PatientCareRecords.Queries.GetCarePlan;
/// <summary>Reads the family-owned care plan for a patient, under the same clinical access rule as the history
/// (owner / nurse-with-booking / admin). The patient id comes from the route.</summary>
public record GetCarePlanQuery(long PatientId) : IRequest<OperationResult<CarePlanDto>>;
@@ -1,7 +1,9 @@
#nullable enable #nullable enable
using System.Text.Json;
using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence; using Baya.Application.Contracts.Persistence;
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.Domain.Entities.User; using Baya.Domain.Entities.User;
using Mediator; using Mediator;
@@ -60,10 +62,24 @@ internal sealed class GetPatientHistoryQueryHandler(
var items = cipher.Items var items = cipher.Items
.Select(r => new CareRecordDto( .Select(r => new CareRecordDto(
r.Id, r.PatientId, r.BookingId, r.NurseProfileId, r.NurseName, r.Id, r.PatientId, r.BookingId, r.NurseProfileId, r.NurseName,
fieldEncryptor.Decrypt(r.BodyEncrypted), r.RecordedAt)) fieldEncryptor.Decrypt(r.BodyEncrypted), ParseTaskResults(r.TaskResultsJson), r.RecordedAt))
.ToList(); .ToList();
return OperationResult<PagedResult<CareRecordDto>>.SuccessResult( return OperationResult<PagedResult<CareRecordDto>>.SuccessResult(
new PagedResult<CareRecordDto>(items, cipher.Total, cipher.Page, cipher.PageSize)); new PagedResult<CareRecordDto>(items, cipher.Total, cipher.Page, cipher.PageSize));
} }
private static IReadOnlyList<TaskResultDto> ParseTaskResults(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return [];
try
{
return JsonSerializer.Deserialize<List<TaskResultDto>>(json) ?? [];
}
catch (JsonException)
{
return [];
}
}
} }
@@ -0,0 +1,25 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Patients;
using Mediator;
namespace Baya.Application.Features.PatientCareRecords.Queries.GetRecordAccess;
internal sealed class GetRecordAccessQueryHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork)
: IRequestHandler<GetRecordAccessQuery, OperationResult<RecordAccessDto>>
{
public async ValueTask<OperationResult<RecordAccessDto>> Handle(GetRecordAccessQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is null)
return OperationResult<RecordAccessDto>.UnauthorizedResult("Not authenticated.");
// Always 200 with the access flags (incl. the non-leaking not_found / not_authorized denied states) so
// the client can render the access-denied card without probing a 403/404.
var (_, access) = await PatientAccess.ResolveAsync(currentUser, unitOfWork, request.PatientId, cancellationToken);
return OperationResult<RecordAccessDto>.SuccessResult(access);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Patients;
using Mediator;
namespace Baya.Application.Features.PatientCareRecords.Queries.GetRecordAccess;
/// <summary>The caller's access to a patient's records (view/edit/append-note) — so the UI shows the right
/// affordances + a non-leaking access-denied state. The patient id comes from the route.</summary>
public record GetRecordAccessQuery(long PatientId) : IRequest<OperationResult<RecordAccessDto>>;

Some files were not shown because too many files have changed in this diff Show More