refinement phase 3
This commit is contained in:
@@ -35,8 +35,8 @@ server — mirror that, don't invent a new envelope.
|
|||||||
the client picks by locale.
|
the client picks by locale.
|
||||||
|
|
||||||
## Pagination (mandatory on lists)
|
## Pagination (mandatory on lists)
|
||||||
- Query params: `page` (1-based) + `page_size` (cap it server-side, e.g. ≤100). Response payload carries
|
- Query params: `page` (1-based) + `pageSize` (cap it server-side, e.g. ≤100). Response payload carries
|
||||||
`items` + `total` (+ `page`/`page_size`). Document the default and max `page_size` per endpoint.
|
`items` + `total` (+ `page`/`pageSize`). Document the default and max `pageSize` per endpoint.
|
||||||
|
|
||||||
## Idempotency (money & side-effecting POSTs)
|
## Idempotency (money & side-effecting POSTs)
|
||||||
- Where stated, the client sends an idempotency key (header or body field) and the server dedups. Webhook
|
- Where stated, the client sends an idempotency key (header or body field) and the server dedups. Webhook
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
### `<HTTP> api/v1/<controller>/<action>`
|
### `<HTTP> api/v1/<controller>/<action>`
|
||||||
- **Purpose:** …
|
- **Purpose:** …
|
||||||
- **Auth:** none | authenticated | policy/role … · **Rate-limited:** yes/no · **Idempotency key:** yes/no
|
- **Auth:** none | authenticated | policy/role … · **Rate-limited:** yes/no · **Idempotency key:** yes/no
|
||||||
- **Path/query params:** `name` (type) — meaning; pagination `page`/`page_size` (default/max) for lists.
|
- **Path/query params:** `name` (type) — meaning; pagination `page`/`pageSize` (default/max) for lists.
|
||||||
- **Request body:**
|
- **Request body:**
|
||||||
```json
|
```json
|
||||||
{ "field": "example" }
|
{ "field": "example" }
|
||||||
|
|||||||
@@ -148,3 +148,17 @@ customer's repayment schedule — `installment_count` is informational (default
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
- b12 — initial contract (eligibility, initiate, customer/admin status, webhook, admin verify/settle/revert).
|
- b12 — initial contract (eligibility, initiate, customer/admin status, webhook, admin verify/settle/revert).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-022/023/024)
|
||||||
|
|
||||||
|
- `balinyaar` added to the `provider_code` enum (in-house plan; identical net-of-fee mechanics, resolves to the
|
||||||
|
same adapter). The set is now `snapppay|digipay|tara|torobpay|balinyaar`.
|
||||||
|
- `POST checkout_bnpl/eligibility` accepts optional `{ nationalId, mobile, consent }` (consent required when the
|
||||||
|
KYC inputs are present; a supplied mobile drives the provider inquiry, else the account mobile).
|
||||||
|
- `GET api/v1/checkout_bnpl/by_request/{bookingRequestId}` (owner-scoped) → `BnplOrderStatusDto`; `bookingId` on
|
||||||
|
the settled order was already present on the DTO.
|
||||||
|
- **DEFERRED:** `checkout_bnpl/options/{id}` + `schedule` + `wallet_installments` — b12 deliberately does not model
|
||||||
|
the customer repayment schedule / per-installment status, and there is no installment ledger to serve them from.
|
||||||
|
Keep the D1/D2/D4/D5 plan visualization mocked until a provider-schedule integration (or a schedule table) lands.
|
||||||
|
|||||||
@@ -146,3 +146,21 @@
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
- b8 — initial contract (create/accept/reject/cancel + role-scoped list + single get + admin expire).
|
- b8 — initial contract (create/accept/reject/cancel + role-scoped list + single get + admin expire).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-013/014/016/017)
|
||||||
|
|
||||||
|
- **`BookingRequestDto`** gains `variantPrice` (IRR digit-string — the chosen variant's *display rate*, not
|
||||||
|
an engagement total; the request stays money-free), `nurseAvatarUrl` (nullable), and `bookingId`
|
||||||
|
(nullable — the booking created once the request is `converted`, for the confirmation deep-link).
|
||||||
|
- **`BookingRequestListItemDto`** gains `variantLabel` (self-describing inbox row) and `patientAge`
|
||||||
|
(nullable coarse triage age).
|
||||||
|
- **`GET api/v1/booking_requests/checkout_summary/{id}`** (owner-scoped) — the C6 money breakdown:
|
||||||
|
`{ bookingRequestId, requestStatus, nurseName, patientName, variantLabel, variantPriceUnit, sessionCount,
|
||||||
|
requestedDate, requestedTimeStart, requestedTimeEnd, paymentDeadlineAt, serviceCostIrr, commissionIrr,
|
||||||
|
vatIrr, vatRate, totalIrr, grossPriceIrr, balinyaarCommissionIrr, nursePayoutAmount }`. All IRR
|
||||||
|
digit-strings, computed server-side. **Canonical rates:** `platform_fee_rate = 0.15`, `vat_rate = 0.10`.
|
||||||
|
VAT is **carved out of the commission** so `serviceCostIrr + commissionIrr + vatIrr = totalIrr = gross`
|
||||||
|
(the captured amount); `commissionIrr` is the commission **net of VAT**, and the raw b10 amounts
|
||||||
|
(`grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`) are surfaced alongside.
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ ISO-8601. Enums cross as their stable string codes.
|
|||||||
(customer own / nurse assigned / admin all). The **nurse** view omits `addressSnapshotJson`. Never includes
|
(customer own / nurse assigned / admin all). The **nurse** view omits `addressSnapshotJson`. Never includes
|
||||||
care-instruction clinical fields. **Failure:** `401`, `404` (not found / not a party → no leak).
|
care-instruction clinical fields. **Failure:** `401`, `404` (not found / not a party → no leak).
|
||||||
|
|
||||||
### `GET api/v1/bookings/list?role=customer|nurse|all&status=&page=&page_size=`
|
### `GET api/v1/bookings/list?role=customer|nurse|all&status=&page=&pageSize=`
|
||||||
- **Purpose:** role-scoped "My bookings" (paginated, projected). `role=all` is admin-only (`403` otherwise).
|
- **Purpose:** role-scoped "My bookings" (paginated, projected). `role=all` is admin-only (`403` otherwise).
|
||||||
**Success:** `PagedResult<BookingListItem>`.
|
**Success:** `PagedResult<BookingListItem>`.
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ ISO-8601. Enums cross as their stable string codes.
|
|||||||
`payout_eligible_at`, and — when all sessions are settled — completes the booking + sets
|
`payout_eligible_at`, and — when all sessions are settled — completes the booking + sets
|
||||||
`dispute_window_ends_at`. **Failure:** `400` no open check-in, `409` not checkout-able.
|
`dispute_window_ends_at`. **Failure:** `400` no open check-in, `409` not checkout-able.
|
||||||
|
|
||||||
### `GET api/v1/booking_sessions/today?date=&page=&page_size=`
|
### `GET api/v1/booking_sessions/today?date=&page=&pageSize=`
|
||||||
- **Purpose:** the nurse's sessions for a day (default all), with check-in/out CTA state. **Auth:** nurse,
|
- **Purpose:** the nurse's sessions for a day (default all), with check-in/out CTA state. **Auth:** nurse,
|
||||||
tenancy-scoped. **Success:** `PagedResult<BookingSessionListItem>`.
|
tenancy-scoped. **Success:** `PagedResult<BookingSessionListItem>`.
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ ISO-8601. Enums cross as their stable string codes.
|
|||||||
- **Purpose:** cancel a single un-started session · **Rate-limited:** yes. Snapshots the policy + computes the
|
- **Purpose:** cancel a single un-started session · **Rate-limited:** yes. Snapshots the policy + computes the
|
||||||
session's refundable share. **Failure:** `409` if the session already started.
|
session's refundable share. **Failure:** `409` if the session already started.
|
||||||
|
|
||||||
### `GET api/v1/admin_evv/list?type=mismatch|no_show&page=&page_size=`
|
### `GET api/v1/admin_evv/list?type=mismatch|no_show&page=&pageSize=`
|
||||||
- **Purpose:** admin EVV-review queue. **Auth:** admin policy · **Rate-limited:** yes. **Success:**
|
- **Purpose:** admin EVV-review queue. **Auth:** admin policy · **Rate-limited:** yes. **Success:**
|
||||||
`PagedResult<AdminEvvItem>`.
|
`PagedResult<AdminEvvItem>`.
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,8 @@
|
|||||||
|
|
||||||
## Public catalog browse — `CatalogController` (no auth)
|
## Public catalog browse — `CatalogController` (no auth)
|
||||||
|
|
||||||
### `GET api/v1/catalog/categories?page=&page_size=`
|
### `GET api/v1/catalog/categories?page=&pageSize=`
|
||||||
- Active categories ordered by `sortOrder`, **paginated** (default `page_size` 50, max 100). Cached. `data`:
|
- Active categories ordered by `sortOrder`, **paginated** (default `pageSize` 50, max 100). Cached. `data`:
|
||||||
`PagedResult<ServiceCategoryDto>`.
|
`PagedResult<ServiceCategoryDto>`.
|
||||||
|
|
||||||
### `GET api/v1/catalog/option_groups?category_id={id}`
|
### `GET api/v1/catalog/option_groups?category_id={id}`
|
||||||
@@ -89,7 +89,7 @@ Every write **invalidates the catalog cache**. Both labels required (`nameFa`/`n
|
|||||||
### `POST api/v1/nurse_variants/set_active/{id}`
|
### `POST api/v1/nurse_variants/set_active/{id}`
|
||||||
- **Body:** `{ isActive }`. Deactivate/reactivate — **never hard-delete**. `data`: `true`. `404` if not owned.
|
- **Body:** `{ isActive }`. Deactivate/reactivate — **never hard-delete**. `data`: `true`. `404` if not owned.
|
||||||
|
|
||||||
### `GET api/v1/nurse_variants/list?page=&page_size=`
|
### `GET api/v1/nurse_variants/list?page=&pageSize=`
|
||||||
- The nurse's own offerings — **active and inactive**, active-first, paginated. `data`:
|
- The nurse's own offerings — **active and inactive**, active-first, paginated. `data`:
|
||||||
`PagedResult<VariantDto>`.
|
`PagedResult<VariantDto>`.
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
**Status:** live as of backend-phase-1 · **Frontend consumer:** frontend-phase-f14 (notification center) / frontend-phase-f15 (admin config/holidays/audit/alerts)
|
**Status:** live as of backend-phase-1 · **Frontend consumer:** frontend-phase-f14 (notification center) / frontend-phase-f15 (admin config/holidays/audit/alerts)
|
||||||
|
|
||||||
All responses are the standard `OperationResult`→`ApiResult` envelope (camelCase body, snake_case URLs).
|
All responses are the standard `OperationResult`→`ApiResult` envelope (camelCase body, snake_case URLs).
|
||||||
Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-based) + `page_size`
|
Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-based) + `pageSize`
|
||||||
(default 50, max 100) — bound from the query string; derive exact casing from `swagger.v1.json`.
|
(default 50, max 100) — bound from the query string; derive exact casing from `swagger.v1.json`.
|
||||||
|
|
||||||
## Enums used
|
## Enums used
|
||||||
@@ -25,7 +25,7 @@ Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-
|
|||||||
|
|
||||||
### `GET api/v1/platform_config/get_platform_configs`
|
### `GET api/v1/platform_config/get_platform_configs`
|
||||||
- **Purpose:** list config rows. **Auth:** admin (DynamicPermission). **Rate-limited:** no.
|
- **Purpose:** list config rows. **Auth:** admin (DynamicPermission). **Rate-limited:** no.
|
||||||
- **Query:** `page`, `page_size`.
|
- **Query:** `page`, `pageSize`.
|
||||||
- **200 `data`:** `PagedResult<PlatformConfigDto>` — `{ items:[{ key, value, dataType, description }], total, page, pageSize }`.
|
- **200 `data`:** `PagedResult<PlatformConfigDto>` — `{ items:[{ key, value, dataType, description }], total, page, pageSize }`.
|
||||||
|
|
||||||
### `POST api/v1/platform_config/update_platform_config`
|
### `POST api/v1/platform_config/update_platform_config`
|
||||||
@@ -36,7 +36,7 @@ Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-
|
|||||||
|
|
||||||
### `GET api/v1/platform_config/get_config_change_history`
|
### `GET api/v1/platform_config/get_config_change_history`
|
||||||
- **Purpose:** the audited change history for one key (from the append-only trail). **Auth:** admin.
|
- **Purpose:** the audited change history for one key (from the append-only trail). **Auth:** admin.
|
||||||
- **Query:** `key` (required), `page`, `page_size`.
|
- **Query:** `key` (required), `page`, `pageSize`.
|
||||||
- **200 `data`:** `PagedResult<ConfigChangeDto>` — `{ items:[{ id, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first.
|
- **200 `data`:** `PagedResult<ConfigChangeDto>` — `{ items:[{ id, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -44,7 +44,7 @@ Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-
|
|||||||
## Admin — Holidays (`holidays` controller, `[Authorize(DynamicPermission)]`)
|
## Admin — Holidays (`holidays` controller, `[Authorize(DynamicPermission)]`)
|
||||||
|
|
||||||
### `GET api/v1/holidays/get_holidays`
|
### `GET api/v1/holidays/get_holidays`
|
||||||
- **Query:** `from` (date, optional), `to` (date, optional), `page`, `page_size`.
|
- **Query:** `from` (date, optional), `to` (date, optional), `page`, `pageSize`.
|
||||||
- **200 `data`:** `PagedResult<HolidayDto>` — `{ items:[{ id, holidayDate, nameFa, type, isBankClosed }], … }`, by date.
|
- **200 `data`:** `PagedResult<HolidayDto>` — `{ items:[{ id, holidayDate, nameFa, type, isBankClosed }], … }`, by date.
|
||||||
|
|
||||||
### `POST api/v1/holidays/upsert_holiday`
|
### `POST api/v1/holidays/upsert_holiday`
|
||||||
@@ -60,7 +60,7 @@ Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-
|
|||||||
|
|
||||||
### `GET api/v1/audit/get_audit_trail`
|
### `GET api/v1/audit/get_audit_trail`
|
||||||
- **Purpose:** the immutable trail for one entity. **Auth:** admin.
|
- **Purpose:** the immutable trail for one entity. **Auth:** admin.
|
||||||
- **Query:** `entity_type` (e.g. `PlatformConfig`), `entity_id` (string), `page`, `page_size`.
|
- **Query:** `entity_type` (e.g. `PlatformConfig`), `entity_id` (string), `page`, `pageSize`.
|
||||||
- **200 `data`:** `PagedResult<AuditLogDto>` — `{ items:[{ id, entityType, entityId, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first. **Notes:** read-only; there is no write/update/delete endpoint for audit rows.
|
- **200 `data`:** `PagedResult<AuditLogDto>` — `{ items:[{ id, entityType, entityId, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first. **Notes:** read-only; there is no write/update/delete endpoint for audit rows.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -68,7 +68,7 @@ Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-
|
|||||||
## Admin — Support alerts (`support_alerts` controller, `[Authorize(DynamicPermission)]`, never user-facing)
|
## Admin — Support alerts (`support_alerts` controller, `[Authorize(DynamicPermission)]`, never user-facing)
|
||||||
|
|
||||||
### `GET api/v1/support_alerts/get_support_alerts`
|
### `GET api/v1/support_alerts/get_support_alerts`
|
||||||
- **Query:** `type?`, `status?`, `owner_user_id?`, `page`, `page_size`.
|
- **Query:** `type?`, `status?`, `owner_user_id?`, `page`, `pageSize`.
|
||||||
- **200 `data`:** `PagedResult<SupportAlertDto>` — `{ items:[{ id, type, severity, status, entityType, entityId, bookingId, reviewId, ownerUserId, resolutionNote, resolvedAt, createdAt }], … }`.
|
- **200 `data`:** `PagedResult<SupportAlertDto>` — `{ items:[{ id, type, severity, status, entityType, entityId, bookingId, reviewId, ownerUserId, resolutionNote, resolvedAt, createdAt }], … }`.
|
||||||
|
|
||||||
### `POST api/v1/support_alerts/assign_support_alert`
|
### `POST api/v1/support_alerts/assign_support_alert`
|
||||||
@@ -84,7 +84,7 @@ Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-
|
|||||||
Every endpoint is scoped to the signed-in caller (`ICurrentUser`) — never a body-supplied user id.
|
Every endpoint is scoped to the signed-in caller (`ICurrentUser`) — never a body-supplied user id.
|
||||||
|
|
||||||
### `GET api/v1/notifications/get_notifications`
|
### `GET api/v1/notifications/get_notifications`
|
||||||
- **Query:** `page`, `page_size`.
|
- **Query:** `page`, `pageSize`.
|
||||||
- **200 `data`:** `PagedResult<NotificationDto>` — `{ items:[{ id, type, title, body, dataJson, isRead, readAt, createdAt }], … }`, **unread-first** then newest-first.
|
- **200 `data`:** `PagedResult<NotificationDto>` — `{ items:[{ id, type, title, body, dataJson, isRead, readAt, createdAt }], … }`, **unread-first** then newest-first.
|
||||||
|
|
||||||
### `GET api/v1/notifications/get_unread_count`
|
### `GET api/v1/notifications/get_unread_count`
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ Every write **invalidates the geo cache**. Names required (`nameFa`/`nameEn`); `
|
|||||||
### `DELETE api/v1/nurse_service_areas/remove/{id}`
|
### `DELETE api/v1/nurse_service_areas/remove/{id}`
|
||||||
- Soft-removes the nurse's own area. `data`: `true`. `404` if not owned/absent (existence not leaked).
|
- Soft-removes the nurse's own area. `data`: `true`. `404` if not owned/absent (existence not leaked).
|
||||||
|
|
||||||
### `GET api/v1/nurse_service_areas/list?page=&page_size=`
|
### `GET api/v1/nurse_service_areas/list?page=&pageSize=`
|
||||||
- The nurse's own areas, whole-city first, paginated. `data`: `PagedResult<NurseServiceAreaDto>`.
|
- The nurse's own areas, whole-city first, paginated. `data`: `PagedResult<NurseServiceAreaDto>`.
|
||||||
|
|
||||||
## Customer addresses — `CustomerAddressesController` (authenticated; customer-scoped in handler)
|
## Customer addresses — `CustomerAddressesController` (authenticated; customer-scoped in handler)
|
||||||
@@ -97,7 +97,7 @@ Every write **invalidates the geo cache**. Names required (`nameFa`/`nameEn`); `
|
|||||||
### `DELETE api/v1/customer_addresses/delete/{id}`
|
### `DELETE api/v1/customer_addresses/delete/{id}`
|
||||||
- Soft-deletes the owned address. `data`: `true`. `404` if not owned.
|
- Soft-deletes the owned address. `data`: `true`. `404` if not owned.
|
||||||
|
|
||||||
### `GET api/v1/customer_addresses/list?page=&page_size=`
|
### `GET api/v1/customer_addresses/list?page=&pageSize=`
|
||||||
- The customer's own addresses, **primary first**, paginated, with PII **decrypted for the owner**. `data`:
|
- The customer's own addresses, **primary first**, paginated, with PII **decrypted for the owner**. `data`:
|
||||||
`PagedResult<CustomerAddressDto>`.
|
`PagedResult<CustomerAddressDto>`.
|
||||||
|
|
||||||
@@ -123,3 +123,13 @@ Tehran city id `101`, Tehran districts `1001…1022`; other cities have no distr
|
|||||||
## Changelog
|
## Changelog
|
||||||
- b4 — initial contract: public geo lookups, admin geo CRUD + set_active, nurse service areas, customer
|
- b4 — initial contract: public geo lookups, admin geo CRUD + set_active, nurse service areas, customer
|
||||||
addresses; `IGeocoder` seam; `409` conflict added to the envelope.
|
addresses; `IGeocoder` seam; `409` conflict added to the envelope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-008/009)
|
||||||
|
|
||||||
|
- **`CustomerAddressDto`** gains `provinceId` (joined from `cities.province_id`) so the edit form can
|
||||||
|
prefill the province → city cascade from a server-loaded address.
|
||||||
|
- **`customer_addresses/create` + `update/{id}`** now accept optional `latitude`/`longitude` (both or
|
||||||
|
neither). When present, the user's dropped pin is stored (`geocode_source = user_pin`, preferred for the
|
||||||
|
EVV distance check); when absent the server geocodes as before (`geocode_source = geocoder`).
|
||||||
|
|||||||
@@ -128,3 +128,14 @@
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
- b2 — initial contract (phone-OTP auth, sessions, `/me`, role selection).
|
- b2 — initial contract (phone-OTP auth, sessions, `/me`, role selection).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-002/003)
|
||||||
|
|
||||||
|
- **`RequestOtpResult`** gains `codeLength` (6) and `expiresInSeconds` (60) so the OTP box count + expiry
|
||||||
|
hint are contract-driven.
|
||||||
|
- **`verify_otp` failures** now carry a stable machine `code` on the envelope: `otp_invalid` (wrong **or**
|
||||||
|
expired — collapsed for anti-enumeration) and `otp_locked` with `data: { retryAfterSeconds }` on lockout.
|
||||||
|
The coded-error envelope is `{ isSuccess: false, statusCode: 400, message, code, data? }` (the optional
|
||||||
|
`code` is omitted from every other response).
|
||||||
|
|||||||
@@ -85,3 +85,16 @@ segments are snake_case; responses use the standard `OperationResult`→`ApiResu
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
- b3 — initial contract (nurse/customer profiles, patients, nurse bank accounts + ownership inquiry).
|
- b3 — initial contract (nurse/customer profiles, patients, nurse bank accounts + ownership inquiry).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-005/006/007)
|
||||||
|
|
||||||
|
- **`PatientDto` + create/update** gain `relation` (`parent|spouse|child|self`, nullable) and `conditions`
|
||||||
|
(`string[]` of stable codes; empty, never null). Stored as a nullable code + a JSON array column.
|
||||||
|
- **`NurseProfileDto`** and **`CustomerProfileDto`** gain `avatarUrl` (nullable). `CustomerProfileDto` also
|
||||||
|
gains `preferredLanguage` (nullable); the customer `upsert` body now accepts `firstName`/`lastName`
|
||||||
|
(persisted on the base `users` row) and `preferredLanguage`.
|
||||||
|
- **Avatar upload (multipart):** `POST api/v1/nurse_profiles/avatar` and
|
||||||
|
`POST api/v1/customer_profiles/avatar` — `multipart/form-data` field `file` (JPEG/PNG/WebP, ≤ 5 MB),
|
||||||
|
stored via `IObjectStorage`, returns `{ url }` and persists it on the profile.
|
||||||
|
|||||||
@@ -173,3 +173,23 @@ rebuild). All are `[Authorize(DynamicPermission)]` (admin role passes; other sta
|
|||||||
| Verification queue / refunds / payouts / moderation / config / holidays | their own phase routes | b6/b11/b13/b14/b1 |
|
| Verification queue / refunds / payouts / moderation / config / holidays | their own phase routes | b6/b11/b13/b14/b1 |
|
||||||
|
|
||||||
`support_alerts` are internal-only and must never appear in a user-facing response or join.
|
`support_alerts` are internal-only and must never appear in a user-facing response or join.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-029/030/031/032/033/034/035/036/037)
|
||||||
|
|
||||||
|
**Delivered:**
|
||||||
|
- **REQ-029** `PlatformConfigDto` gains `updatedAt` + `updatedBy` (from the entity audit fields).
|
||||||
|
- **REQ-030** `GET audit/get_audit_trail` filters also by `actorId`, `action`, `from`, `to` (all optional; `entityType`/
|
||||||
|
`entityId` now optional too). **Query params bind camelCase** (`actorId`/`from`/`to`), not `actor_id`.
|
||||||
|
- **REQ-037** `tagCodes: string[]` on `ModerationQueueItemDto`. **REQ-033** `totalIrr` on `InvoiceDto`
|
||||||
|
(= platform commission + BNPL commission + VAT).
|
||||||
|
- **REQ-032** activate/suspend toggle `POST admin/partner-centers/{id}/set-active { isActive }`. **Route casing
|
||||||
|
pinned:** the admin partner-center routes are **kebab-case** (`admin/partner-centers`, `.../set-active`) — an
|
||||||
|
intentional b15 divergence from the `snake_case` convention; the frontend's kebab-case client is CORRECT.
|
||||||
|
|
||||||
|
**Deferred (admin-console polish, documented in the tracker):** REQ-031 (RBAC `admin_roles/list|grant|revoke`),
|
||||||
|
REQ-032 `centers/me` + split portal reads (need the user↔center admin association REQ-038 deferred), REQ-033
|
||||||
|
center-scoped invoice list, REQ-034 verification nurse-queue/signed-url/whole-approve, REQ-035 refund admin
|
||||||
|
preview+approve/reject (the customer preview REQ-020 IS delivered), REQ-036 payout admin preview/holidayShifted/
|
||||||
|
transfer-reference.
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-028)
|
||||||
|
|
||||||
|
- **`TicketSummaryDto`** gains `lastMessageAt` (last non-internal activity) + `unreadCount` (the caller's unread
|
||||||
|
non-internal messages from others; 0 on the admin queue). Unread is computed against the participant's
|
||||||
|
`last_read_at`, **stamped when the participant fetches the user-facing thread**.
|
||||||
|
- **`GET /tickets`** gains a `bookingId` query filter (jump to a booking's coordination ticket).
|
||||||
|
- **`POST /tickets/{id}/messages`** accepts an optional `clientMessageId` — a retried send with the same key is
|
||||||
|
deduplicated (returns the original) and the key is echoed on `PostMessageResult`.
|
||||||
|
- **Message author = role label, not a name** (confirmed intentional, privacy): the DTO carries `senderId`; the
|
||||||
|
client derives the author label from the participant role. No raw identity/name is exposed.
|
||||||
@@ -69,3 +69,11 @@ DEBIT escrow_held gross_price_irr (e.g. 23300000)
|
|||||||
- **The checkout shows gross + commission/VAT breakdown only** — never the internal `account_type`s.
|
- **The checkout shows gross + commission/VAT breakdown only** — never the internal `account_type`s.
|
||||||
- **Payment is idempotent end-to-end**: a retried `initiate` (same `Idempotency-Key`) reuses the attempt; a
|
- **Payment is idempotent end-to-end**: a retried `initiate` (same `Idempotency-Key`) reuses the attempt; a
|
||||||
replayed webhook is a no-op; a repeat `initiate` after capture is a `409`.
|
replayed webhook is a no-op; a repeat `initiate` after capture is a `409`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-018)
|
||||||
|
|
||||||
|
- **Invoice auto-issue on capture/settle:** the commission invoice is now issued automatically when a card
|
||||||
|
capture (`ConfirmPaymentAndPostLedger`) or a BNPL settle first creates the booking — idempotent per
|
||||||
|
booking — so a paying customer's `GET api/v1/invoices/{bookingId}` resolves right away (was admin-only).
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -119,3 +119,19 @@ access rule is enforced in the handler, not just the route policy.
|
|||||||
- **Failure cases:** `401`; `403` no clinical access; `404` patient not found.
|
- **Failure cases:** `401`; `403` no clinical access; `404` patient not found.
|
||||||
- **Notes:** The record is **patient-scoped, not booking-scoped** — a new nurse taking over reads the whole
|
- **Notes:** The record is **patient-scoped, not booking-scoped** — a new nurse taking over reads the whole
|
||||||
history (not just their own booking's notes).
|
history (not just their own booking's notes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-026/027)
|
||||||
|
|
||||||
|
- **`GET api/v1/bookings/{bookingId}/review_eligibility`** → `{ canReview, reason?:
|
||||||
|
not_completed|already_reviewed|not_owner|not_found }`.
|
||||||
|
- **`GET api/v1/bookings/{bookingId}/my_review`** → `{ moderationStatus:
|
||||||
|
pending_moderation|published|hidden|rejected|none, rating?, body?, tagCodes[], createdAt? }`. Masked-author
|
||||||
|
omission on the public list is **intentional** (privacy).
|
||||||
|
- **Family-owned care plan (new entity `usr.PatientCarePlans`):** `GET/PUT api/v1/patients/{patientId}/care_record`
|
||||||
|
→ `{ patientId, medications:[{id,name,dosage?,frequency,timingNote?}], routine:[{id,label,timeOfDay?,note?}],
|
||||||
|
tasks:[{id,label,done}] }`. Read = owner/nurse-with-booking/admin; write = owning customer only.
|
||||||
|
- **`GET api/v1/patients/{patientId}/record_access`** → `{ canView, canEdit, canAppendNote, deniedReason? }`
|
||||||
|
(always 200; non-leaking `not_found`/`not_authorized`).
|
||||||
|
- **Structured `taskResults`** (`[{ label, done }]`) added to the visit-note write body + the history DTO.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
- `nurse_gender` (`male`|`female`, optional) — the same-gender facet.
|
- `nurse_gender` (`male`|`female`, optional) — the same-gender facet.
|
||||||
- `min_price` / `max_price` (long IRR, optional) — inclusive range over the copied `price`.
|
- `min_price` / `max_price` (long IRR, optional) — inclusive range over the copied `price`.
|
||||||
- `price_unit` (enum, optional) — compare like-for-like listings (e.g. only `per_day`).
|
- `price_unit` (enum, optional) — compare like-for-like listings (e.g. only `per_day`).
|
||||||
- `page` (int, default 1), `page_size` (int, default 50, max 100).
|
- `page` (int, default 1), `pageSize` (int, default 50, max 100).
|
||||||
- **Success `200` payload (`data` = `PagedResultOfNurseSearchResultDto`):**
|
- **Success `200` payload (`data` = `PagedResultOfNurseSearchResultDto`):**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
- **Failure cases:** `400` — `service_category_id`/`city_id` missing or ≤ 0, `nurse_gender` not `male`/`female`,
|
- **Failure cases:** `400` — `service_category_id`/`city_id` missing or ≤ 0, `nurse_gender` not `male`/`female`,
|
||||||
`min_price > max_price`, invalid `price_unit`, or `page_size > 100`.
|
`min_price > max_price`, invalid `price_unit`, or `pageSize > 100`.
|
||||||
- **Notes:** returns only `is_searchable = 1` rows. `districtId = null` in a result row means the nurse covers
|
- **Notes:** returns only `is_searchable = 1` rows. `districtId = null` in a result row means the nurse covers
|
||||||
the whole city. No entity is hydrated — the read is a projected, paginated, `AsNoTracking` index scan.
|
the whole city. No entity is hydrated — the read is a projected, paginated, `AsNoTracking` index scan.
|
||||||
|
|
||||||
@@ -103,3 +103,15 @@
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
- b7 — initial contract: public `search/nurses`, admin `admin_search/rebuild_index`.
|
- b7 — initial contract: public `search/nurses`, admin `admin_search/rebuild_index`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-012)
|
||||||
|
|
||||||
|
- **`NurseSearchResultDto`** gains `nurseName` + `avatarUrl` (denormalized onto `nurse_search_index`, so no
|
||||||
|
per-row join) and `distanceKm` (nullable — the covering index carries no coordinate, so it is null today).
|
||||||
|
- **`GET api/v1/nurses/{id}/profile`** (public) — the aggregated discovery detail:
|
||||||
|
`{ nurseId, nurseName, avatarUrl, bio, yearsExperience, averageRating, totalReviews,
|
||||||
|
totalCompletedBookings, isVerified, inoMembership, attributeChips[], services: [{ variantId, displayName,
|
||||||
|
priceIrr, priceUnit, sessionCount? }], latestReview?: { rating, body, authorMasked (null by design),
|
||||||
|
createdAt } }`. No encrypted credential number is ever exposed.
|
||||||
|
|||||||
@@ -124,9 +124,9 @@
|
|||||||
|
|
||||||
## Admin review queue — `AdminVerificationsController` (`[Authorize(DynamicPermission)]`, rate-limited `sensitive`)
|
## Admin review queue — `AdminVerificationsController` (`[Authorize(DynamicPermission)]`, rate-limited `sensitive`)
|
||||||
|
|
||||||
### `GET api/v1/admin_verifications?status=&page=&page_size=`
|
### `GET api/v1/admin_verifications?status=&page=&pageSize=`
|
||||||
- **Purpose:** the review queue — one row per step awaiting attention.
|
- **Purpose:** the review queue — one row per step awaiting attention.
|
||||||
- **Params:** `status` (default `in_review`) + pagination `page`/`page_size`.
|
- **Params:** `status` (default `in_review`) + pagination `page`/`pageSize`.
|
||||||
- **`data`:** `PagedResult<AdminPendingStepDto>`. Documents carry **signed GET URLs**.
|
- **`data`:** `PagedResult<AdminPendingStepDto>`. Documents carry **signed GET URLs**.
|
||||||
|
|
||||||
### `GET api/v1/admin_verifications/{nurseVerificationId}`
|
### `GET api/v1/admin_verifications/{nurseVerificationId}`
|
||||||
@@ -255,3 +255,15 @@ GET /api/v1/nurses/{nurseId}/trust_badge -> { isVerified: true, approvedAt, cr
|
|||||||
`verification_status` / `verification_step_status` / `credential_type` / `verification_method` enums;
|
`verification_status` / `verification_step_status` / `credential_type` / `verification_method` enums;
|
||||||
transactional `is_verified` flip; encrypted-never-serialized `credential_number`; three new mocked vendor
|
transactional `is_verified` flip; encrypted-never-serialized `credential_number`; three new mocked vendor
|
||||||
seams (`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`). Scheduled expiry cron deferred.
|
seams (`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`). Scheduled expiry cron deferred.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refinement phase 3 additions (REQ-011)
|
||||||
|
|
||||||
|
- **`VerificationStepDto`** gains `isRequired` (mirrors the step-type catalog; an optional step never blocks
|
||||||
|
bookability).
|
||||||
|
- **`POST api/v1/nurse_verification/credential_details`** (nurse) — captures the structured credential
|
||||||
|
fields collected with the uploads: `{ inoNumber (required), specialties: string[], licenseNumber?,
|
||||||
|
issuingAuthority?, holderName?, issuedAt?, expiresAt? }` → `VerificationStatusDto`. Upserts an
|
||||||
|
`ino_membership` (and, if a license number is sent, `moh_competency_license`) `nurse_credentials` row
|
||||||
|
(unverified — admin still decides) and persists `specialties` on the profile. The INO number is encrypted.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||||
|
|||||||
+21
-2
@@ -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;
|
||||||
|
|
||||||
|
// 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);
|
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,
|
||||||
|
|||||||
+5
@@ -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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -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>>;
|
||||||
|
|||||||
+10
-1
@@ -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,
|
||||||
|
|||||||
+5
@@ -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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -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>>;
|
||||||
|
|||||||
+3
-1
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -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>>>;
|
||||||
|
|||||||
+5
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -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(
|
||||||
|
|||||||
+6
-1
@@ -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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -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>>;
|
||||||
|
|||||||
+25
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+73
@@ -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);
|
||||||
|
}
|
||||||
+12
@@ -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>>;
|
||||||
+6
-1
@@ -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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -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>>;
|
||||||
|
|||||||
+4
-2
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+6
-1
@@ -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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -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>>;
|
||||||
|
|||||||
+53
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -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>>;
|
||||||
+46
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -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>>;
|
||||||
+18
-1
@@ -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
-2
@@ -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>>;
|
||||||
|
|||||||
+10
-2
@@ -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,
|
||||||
|
|||||||
+12
-1
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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>>;
|
||||||
|
|||||||
+14
-1
@@ -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));
|
||||||
|
|||||||
+1
-1
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -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>>>;
|
||||||
|
|||||||
+19
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -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>>;
|
||||||
+24
@@ -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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -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 (> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -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>>;
|
||||||
+2
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+8
-3
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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>>;
|
||||||
+17
-1
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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
Reference in New Issue
Block a user