backend phase 7: search & matching (nurse_search_index)
Add the discovery layer: the denormalized nurse_search_index read model (one row per bookable variant x covered service area), maintained inline inside each source write's transaction, plus the single public search query behind the INurseSearch seam. - Entity + EF config + migration (search schema): covering search index, filtered-unique (variant_id, city_id, district_id) pair with NULL district participating, nurse_id index, soft-delete. - ISearchIndexMaintainer (write seam) + SearchIndexMaintainer: reindex variant / nurse / fan-out / remove-area / full rebuild, staged in the owning source write's unit of work; wired into the b3/b4/b5/b6 handlers. - INurseSearch (read seam) + SqlNurseSearch (real MVP backend): reads only is_searchable=1, category/city/district(NULL-aware)/gender/price filters, rating sort, pagination. Elasticsearch deferred (config Search:Backend). - SearchNursesQuery (+ validator) and RebuildSearchIndexCommand; public SearchController (GET search/nurses) + admin AdminSearchController (POST admin_search/rebuild_index). - Tests: 9 DB-backed maintainer/search + 4 WebApplicationFactory; updated affected b3/b4/b5/b6 handler tests. Build clean, 167 tests green. - Docs: server CLAUDE.md project map, contract search.md, swagger refresh, handoff, report, mocks-registry rows, STATUS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
# Contract — Nurse search & matching (backend phase b7)
|
||||
|
||||
> The single public nurse-discovery endpoint (category + city/district geo, same-gender filter, price range,
|
||||
> rating sort, paginated) plus the admin search-index rebuild. Reads a denormalized, maintained-on-write
|
||||
> projection and returns **only searchable (verified + accepting + not-suspended + active) nurses**. Assumes
|
||||
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
|
||||
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
|
||||
> [`../openapi/swagger.v1.json`](../openapi/README.md) (refreshed for b7).
|
||||
|
||||
**Status:** live as of backend-phase-b7 · **Frontend consumer:** frontend-phase-f6-b7
|
||||
|
||||
> **Routing note.** Routes are **action-style** (`[controller]/[action]`, snake_cased). All responses use the
|
||||
> standard `{ succeeded, statusCode, data }` envelope; `data` shapes are below. Query parameters are
|
||||
> **snake_case** (`service_category_id`, `city_id`, …).
|
||||
|
||||
## Key semantics (read first)
|
||||
- **Only `is_searchable = 1` rows are ever returned.** A row is searchable **only** when the nurse is
|
||||
`is_verified` AND not suspended AND `is_accepting_bookings` AND the variant `is_active`. An unverified,
|
||||
paused, suspended, or deactivated nurse/variant never appears — this is the phase's highest-stakes rule.
|
||||
- **The result unit is the variant, not the nurse.** Each hit is a bookable `nurse_service_variant` matched
|
||||
in a covered area; a nurse with multiple variants/areas can appear as multiple hits.
|
||||
- **`district_id = null` ⇒ whole city**, both directions:
|
||||
- A **city-only** search (no `district_id`) matches every row in the city — both the whole-city (NULL) rows
|
||||
and every district row.
|
||||
- A **district** search matches that district's rows **plus** the whole-city (NULL) rows (a whole-city
|
||||
nurse covers every district).
|
||||
- **Same-gender matching is a first-class facet.** `nurse_gender` (`male`/`female`) is an up-front filter;
|
||||
it is never silently defaulted or dropped. (Carrying the chosen gender *into* the booking request —
|
||||
`booking_requests.required_caregiver_gender` — lands in b8.)
|
||||
- **Money is IRR `BIGINT`.** `price` in results is a **digit string** (`"500000"`); `min_price`/`max_price`
|
||||
filters are integers. No floats anywhere.
|
||||
- **Rating sort only (MVP).** Results are ordered by `averageRating` desc, tiebroken by `totalReviews` desc
|
||||
then `nurseId`/`variantId` so paging is deterministic.
|
||||
- **Availability is not a filter.** Availability slots are soft guidance; they never hard-filter search (b7).
|
||||
|
||||
## Enums used
|
||||
- `nurse_gender`: `male` | `female`.
|
||||
- `price_unit`: `per_hour` | `per_session` | `per_half_day` | `per_day` | `per_24h` (copied from the variant).
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET api/v1/search/nurses`
|
||||
- **Purpose:** the single family-facing discovery query over the maintained search index.
|
||||
- **Auth:** none (public, pre-auth discovery) · **Rate-limited:** yes (per-IP global limiter) · **Idempotency key:** no
|
||||
- **Query params:**
|
||||
- `service_category_id` (long, **required**) — the primary search dimension.
|
||||
- `city_id` (long, **required**).
|
||||
- `district_id` (long, optional) — omit for a whole-city search; see geography rule above.
|
||||
- `nurse_gender` (`male`|`female`, optional) — the same-gender facet.
|
||||
- `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`).
|
||||
- `page` (int, default 1), `page_size` (int, default 50, max 100).
|
||||
- **Success `200` payload (`data` = `PagedResultOfNurseSearchResultDto`):**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"variantId": 12,
|
||||
"nurseId": 5,
|
||||
"serviceCategoryId": 1,
|
||||
"price": "8000000",
|
||||
"priceUnit": "per_24h",
|
||||
"nurseGender": "female",
|
||||
"averageRating": 4.8,
|
||||
"totalReviews": 9,
|
||||
"totalCompletedBookings": 12,
|
||||
"cityId": 101,
|
||||
"districtId": 1003
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"pageSize": 50
|
||||
}
|
||||
```
|
||||
- **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`.
|
||||
- **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.
|
||||
|
||||
### `POST api/v1/admin_search/rebuild_index`
|
||||
- **Purpose:** idempotent full rebuild of the search index from source — the convergence/reconciliation path
|
||||
(first-launch / nightly / after a bulk data fix).
|
||||
- **Auth:** admin (dynamic-permission policy) · **Rate-limited:** yes (`sensitive`) · **Idempotency key:** no
|
||||
- **Request body:** none.
|
||||
- **Success `200` payload (`data` = `SearchIndexRebuildResult`):**
|
||||
```json
|
||||
{ "nursesProcessed": 128, "rowsWritten": 342 }
|
||||
```
|
||||
- **Failure cases:** `401` unauthenticated · `403` non-admin.
|
||||
- **Notes:** truncates and repopulates the whole index in nurse-batches; the rebuilt index's live/searchable
|
||||
rows must match the incrementally-maintained state (no duplicate variant×area rows). Writes an audit-log row.
|
||||
|
||||
## Shared shapes
|
||||
- `NurseSearchResultDto`: `variantId` (long), `nurseId` (long), `serviceCategoryId` (long),
|
||||
`price` (string, IRR digits), `priceUnit` (enum), `nurseGender` (`male`/`female`), `averageRating` (decimal),
|
||||
`totalReviews` (int), `totalCompletedBookings` (int), `cityId` (long), `districtId` (long?, null = whole city).
|
||||
- `SearchIndexRebuildResult`: `nursesProcessed` (int), `rowsWritten` (int).
|
||||
|
||||
## Backend seam (not a wire shape)
|
||||
- **`INurseSearch`** — the search-service seam. MVP impl `SqlNurseSearch` (real, over `nurse_search_index`).
|
||||
Config key `Search:Backend` (default `sql`); a later `ElasticNurseSearch` is a config-selected drop-in.
|
||||
|
||||
## Changelog
|
||||
- b7 — initial contract: public `search/nurses`, admin `admin_search/rebuild_index`.
|
||||
@@ -1363,6 +1363,71 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin_search/rebuild_index": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"AdminSearch"
|
||||
],
|
||||
"operationId": "AdminSearch_RebuildIndex",
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfSearchIndexRebuildResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin_verifications": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -3721,7 +3786,7 @@
|
||||
"tags": [
|
||||
"Me"
|
||||
],
|
||||
"description": "Role claims live inside the access token — after selecting a role the client should\n refresh its tokens to pick the new role up.",
|
||||
"description": "Role claims live inside the access token \u2014 after selecting a role the client should\n refresh its tokens to pick the new role up.",
|
||||
"operationId": "Me_SelectRole",
|
||||
"requestBody": {
|
||||
"x-name": "command",
|
||||
@@ -6639,6 +6704,160 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/search/nurses": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Search"
|
||||
],
|
||||
"operationId": "Search_Nurses",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "service_category_id",
|
||||
"x-originalName": "serviceCategoryId",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"x-position": 1
|
||||
},
|
||||
{
|
||||
"name": "city_id",
|
||||
"x-originalName": "cityId",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"x-position": 2
|
||||
},
|
||||
{
|
||||
"name": "district_id",
|
||||
"x-originalName": "districtId",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true
|
||||
},
|
||||
"x-position": 3
|
||||
},
|
||||
{
|
||||
"name": "nurse_gender",
|
||||
"x-originalName": "nurseGender",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"x-position": 4
|
||||
},
|
||||
{
|
||||
"name": "min_price",
|
||||
"x-originalName": "minPrice",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true
|
||||
},
|
||||
"x-position": 5
|
||||
},
|
||||
{
|
||||
"name": "max_price",
|
||||
"x-originalName": "maxPrice",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true
|
||||
},
|
||||
"x-position": 6
|
||||
},
|
||||
{
|
||||
"name": "price_unit",
|
||||
"x-originalName": "priceUnit",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"x-position": 7
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"x-position": 8
|
||||
},
|
||||
{
|
||||
"name": "page_size",
|
||||
"x-originalName": "pageSize",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"x-position": 9
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfPagedResultOfNurseSearchResultDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/support_alerts/get_support_alerts": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -7617,6 +7836,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfSearchIndexRebuildResult": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"data": {
|
||||
"nullable": true,
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SearchIndexRebuildResult"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"SearchIndexRebuildResult": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"nursesProcessed": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"rowsWritten": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfPagedResultOfAdminPendingStepDto": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -10288,6 +10542,98 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfPagedResultOfNurseSearchResultDto": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/PagedResultOfNurseSearchResultDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"PagedResultOfNurseSearchResultDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"nullable": true,
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NurseSearchResultDto"
|
||||
}
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"pageSize": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NurseSearchResultDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"variantId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"nurseId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"serviceCategoryId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"price": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"priceUnit": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"nurseGender": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"averageRating": {
|
||||
"type": "number",
|
||||
"format": "decimal"
|
||||
},
|
||||
"totalReviews": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"totalCompletedBookings": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"cityId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"districtId": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfPagedResultOfSupportAlertDto": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -12,6 +12,31 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
|
||||
- **Notes for frontend:** <anything load-bearing>
|
||||
-->
|
||||
|
||||
## backend-phase-7 — Search & matching (nurse search index) — 2026-07-05
|
||||
- **Shipped:** the discovery layer via one additive migration — new **`search`** schema, **1 table**
|
||||
`NurseSearchIndices` (the denormalized `nurse_search_index`): **one flat row per (bookable variant ×
|
||||
covered area)** with copied category/price/unit, `city_id`/`district_id` (NULL = whole city), `nurse_gender`
|
||||
+ rating aggregates, and the single **`is_searchable`** gate. It is a **read-only projection**, maintained
|
||||
**inline in each source write's own transaction** by **`ISearchIndexMaintainer`** (`SearchIndexMaintainer`)
|
||||
wired into the b3/b4/b5/b6 handlers (`ReindexVariant`/`ReindexNurse`/`FanOutServiceArea`/
|
||||
`RemoveServiceAreaRows` + `Rebuild`). Read side is the **`INurseSearch`** seam — real MVP impl
|
||||
`SqlNurseSearch` (reads only `is_searchable=1`; category/city/district(NULL-aware)/gender/price filters +
|
||||
rating sort + pagination). **2 controllers:** public `SearchController` (`GET search/nurses`) + admin
|
||||
`AdminSearchController` (`POST admin_search/rebuild_index`, idempotent convergence rebuild). Covering search
|
||||
index + filtered-unique `(variant_id, city_id, district_id)` pair (NULL participating) + `nurse_id` index.
|
||||
- **Contracts:** dev/contracts/domains/search.md + openapi snapshot refreshed (yes — `search/nurses` +
|
||||
`admin_search/rebuild_index` + DTOs).
|
||||
- **Mocked:** `INurseSearch` → 🟢 **SQL is real** (Elastic backend 🟡 deferred, config `Search:Backend`);
|
||||
`ISearchIndexMaintainer` inline path real, outbox/feeder 🟡 deferred (see reports/mocks-registry.md).
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (167 pass: +9 DB-backed search + 4 API integration;
|
||||
affected b3/b4/b5/b6 handler tests updated for the new dependency).
|
||||
- **Handoff:** backend/handoff/after-backend-phase-7.md
|
||||
- **Notes for frontend:** f6-b7 = `GET api/v1/search/nurses` (public; snake_case params
|
||||
`service_category_id`/`city_id` required, optional `district_id`/`nurse_gender`/`min_price`/`max_price`/
|
||||
`price_unit`; `page`/`page_size` default 1/50 max 100). Returns **only searchable** nurses;
|
||||
`districtId=null` result = whole city; `price` is an IRR **digit string**; sort is rating-desc only.
|
||||
`required_caregiver_gender` capture into booking is **b8**.
|
||||
|
||||
## backend-phase-6 — Nurse verification & credentials (mocked vendors) — 2026-07-02
|
||||
- **Shipped:** the trust engine via one additive migration — new **`verif`** schema, **5 tables**:
|
||||
`NurseVerifications` (`status` = the **single source of verification truth**), `VerificationStepTypes`
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Handoff — after backend-phase-7 (Search & matching)
|
||||
|
||||
**Search is live.** Verified nurses are now discoverable through one public endpoint backed by a
|
||||
denormalized, maintained-on-write index. This unblocks **frontend f6-b7**: search + filters (C1), results
|
||||
list (C2), and the nurse profile (C3) can be built against a real API.
|
||||
|
||||
## What the frontend can now build (f6-b7)
|
||||
|
||||
- **Search + filters (C1)** → `GET api/v1/search/nurses` (public, no auth). Query params (snake_case):
|
||||
`service_category_id` (**required**), `city_id` (**required**), `district_id` (optional),
|
||||
`nurse_gender` (`male`/`female`, optional), `min_price`/`max_price` (IRR long, optional),
|
||||
`price_unit` (optional), `page`/`page_size` (default 1 / 50, max 100).
|
||||
- **Results list (C2)** → the `data` is a `PagedResult<NurseSearchResultDto>` (`items`, `total`, `page`,
|
||||
`pageSize`). Each item: `variantId`, `nurseId`, `serviceCategoryId`, `price` (IRR **digit string**),
|
||||
`priceUnit`, `nurseGender`, `averageRating`, `totalReviews`, `totalCompletedBookings`, `cityId`,
|
||||
`districtId` (null = whole city).
|
||||
- **Nurse profile (C3)** → reuse the b6 public trust badge (`GET api/v1/nurses/{id}/trust_badge`) and the b5
|
||||
public variant read (`GET api/v1/nurse_variants/get/{id}`) already live; b7 adds no new profile route.
|
||||
|
||||
Categories/cities/districts for the filter dropdowns come from the **b4** geo lookups (`geo/*`) and **b5**
|
||||
catalog (`catalog/*`) — unchanged.
|
||||
|
||||
## Rules the UI must respect
|
||||
|
||||
- **Only searchable nurses come back.** The backend returns a nurse **only** when verified + not suspended +
|
||||
accepting + variant active. No client-side re-check needed; an empty page is a valid result.
|
||||
- **`districtId = null` = whole city.** A city-only search returns both whole-city and district rows; a
|
||||
district search returns that district's rows **plus** whole-city rows. Show whole-city hits as covering the
|
||||
district the user searched.
|
||||
- **Same-gender filter is first-class.** Surface `nurse_gender` prominently; never default it silently.
|
||||
(Carrying the chosen gender into the booking request — `required_caregiver_gender` — is **b8**, not here.)
|
||||
- **`price` is an IRR digit string** — render with a formatter; never parse to a float. Combine with
|
||||
`priceUnit` (+ `sessionCount` from the variant, when booking) for the engagement total.
|
||||
- **Sort is rating-desc only** (MVP). No client sort options beyond what the API returns.
|
||||
|
||||
## Contracts
|
||||
|
||||
- New: [`dev/contracts/domains/search.md`](../../contracts/domains/search.md).
|
||||
- `swagger.v1.json` refreshed (adds `search/nurses` + `admin_search/rebuild_index` + the DTOs).
|
||||
|
||||
## Backend notes (not frontend-facing)
|
||||
|
||||
- The index is a **read-only projection** maintained inline inside each source write's transaction
|
||||
(`ISearchIndexMaintainer`, wired into the b3/b4/b5/b6 handlers). The read seam is **`INurseSearch`**
|
||||
(SQL now; Elasticsearch is a config-selected drop-in later, `Search:Backend`).
|
||||
- Admin `POST api/v1/admin_search/rebuild_index` (dynamic-permission) does an idempotent full rebuild — the
|
||||
reconciliation path; incremental maintenance and rebuild converge.
|
||||
- **Deferred to b8:** `booking_requests.required_caregiver_gender` capture (carry the chosen gender into the
|
||||
booking). **Deferred:** Elasticsearch backend + feeder, availability hard-filter, map/radius discovery,
|
||||
ranking beyond rating.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Backend Phase 7 report — Search & matching (nurse search index)
|
||||
|
||||
## What was built
|
||||
|
||||
- **`nurse_search_index` read model** — `Domain/Entities/Search/NurseSearchIndex` (table `search.NurseSearchIndices`),
|
||||
one flat row per **(bookable variant × covered service area)**: copied `variant_id`/`nurse_id`/
|
||||
`service_category_id`/`price`/`price_unit`, the covered `city_id`/`district_id` (NULL = whole city), the
|
||||
nurse's `nurse_gender` + `average_rating`/`total_reviews`/`total_completed_bookings`, the single
|
||||
`is_searchable` gate, `updated_at`, soft-delete `deleted_at`. EF config in
|
||||
`Persistence/Configuration/SearchConfig/`; one migration `NurseSearchIndex`. Indexes: a **covering** search
|
||||
index `(is_searchable, service_category_id, city_id, district_id) INCLUDE (price, nurse_gender,
|
||||
average_rating, total_reviews, nurse_id, variant_id)`; the **filtered-unique pair** on `(variant_id, city_id,
|
||||
district_id) WHERE deleted_at IS NULL` (NULL-district participating, via the `nurse_service_areas` trick); a
|
||||
`nurse_id` secondary index; soft-delete query filter.
|
||||
- **`ISearchIndexMaintainer` (write seam) + `SearchIndexMaintainer`** — `Persistence/Services/Search/`. Keeps
|
||||
the index consistent **inline, in the source write's own unit of work**. Methods: `ReindexVariantAsync`
|
||||
(variant create/edit/toggle — inserts a new variant's rows in the same graph via the `Variant` navigation),
|
||||
`ReindexNurseAsync` (verification flip / suspend / accepting-toggle / rating recompute), `FanOutServiceAreaAsync`
|
||||
+ `RemoveServiceAreaRowsAsync` (area add/remove), `RebuildAsync` (idempotent full rebuild). Resurrects a
|
||||
soft-deleted (variant × area) row on re-upsert so each pair has exactly one live row.
|
||||
- **`INurseSearch` (read seam) + `SqlNurseSearch`** — `Persistence/Services/Search/`. Reads **only
|
||||
`is_searchable = 1`** rows, applies category/city/district(NULL-aware)/gender/price filters + rating sort +
|
||||
pagination, `AsNoTracking` + `.Select` projection; `price` formatted to a digit string in memory.
|
||||
- **`SearchNursesQuery`** (`Features/Search/Queries/`) + FluentValidation validator, delegating to `INurseSearch`;
|
||||
**`RebuildSearchIndexCommand`** (`Features/Search/Commands/`) → `RebuildAsync` + audit log.
|
||||
- **Controllers:** public `SearchController` (`GET api/v1/search/nurses`, snake_case query params, per-IP
|
||||
global rate limit) and `AdminSearchController` (`POST api/v1/admin_search/rebuild_index`, dynamic-permission +
|
||||
`sensitive` rate limit).
|
||||
- **Wiring into source handlers** (same-transaction maintenance): b5 `CreateVariant`/`UpdateVariant`/
|
||||
`SetVariantActive`; b4 `AddNurseServiceArea`/`RemoveNurseServiceArea`; b3 `SetNurseAcceptingBookings`; b6
|
||||
`AdminReviewStep`/`AdminSuspendVerification`/`ScanExpiringCredentials`/`RunIdentityKyc`/`RunShahkarMatch`/
|
||||
`RunBankAccountVerification`.
|
||||
- **DI:** `AddPersistenceServices` registers `ISearchIndexMaintainer` + (config-selected) `INurseSearch`
|
||||
(`Search:Backend`, default `sql`).
|
||||
|
||||
## What is now testable and exactly how (per phase §7)
|
||||
|
||||
Seed fixtures via `SearchIndexTestHost` (Foundation) or drive the live API. Verified against tests:
|
||||
1. **Predicate** — a verified+accepting+not-suspended+active nurse is searchable; each missing condition
|
||||
(unverified / not accepting / suspended / inactive variant) makes it not searchable, but the row is kept.
|
||||
2. **Geography** — district-3 search returns the district-3 nurse **and** the whole-city (NULL) nurse; a
|
||||
different district returns only the whole-city nurse; a city-only search returns both.
|
||||
3. **Same-gender** — `nurse_gender=female`/`male` narrows to that gender.
|
||||
4. **Price range** — `min_price`/`max_price` filter on the copied IRR `price`; result `price` is a digit string.
|
||||
5. **Rating sort** — higher `average_rating` sorts first; deterministic paging.
|
||||
6. **Verification flip** — suspend/un-verify → the nurse disappears from search in the same transaction;
|
||||
reinstating brings them back (row resurrected, not duplicated).
|
||||
7. **Service-area fan-out/remove** — adding an area adds its rows; removing it drops exactly those rows.
|
||||
8. **Variant deactivate** — the variant stops appearing (`is_searchable=0`) without deleting its rows.
|
||||
9. **Rebuild convergence** — `RebuildAsync` reproduces the incrementally-maintained live/searchable row set,
|
||||
no duplicate (variant × area) rows.
|
||||
|
||||
**Tests:** `Baya.Test.Foundation/Search/SearchIndexTests` (9 DB-backed over real EF/SQLite) +
|
||||
`Baya.Test.Api/SearchApiTests` (4 WebApplicationFactory: public paged happy path, 400 missing category, 400
|
||||
invalid gender, 401 rebuild-unauth). Affected b3/b4/b5/b6 handler unit tests updated for the new dependency.
|
||||
**Gate:** `dotnet build Baya.sln` 0 new warnings; `dotnet test Baya.sln` green (167 pass).
|
||||
|
||||
Manual: `GET /api/v1/search/nurses?service_category_id=…&city_id=…` (public) returns the paged envelope;
|
||||
`POST /api/v1/admin_search/rebuild_index` (admin) returns `{ nursesProcessed, rowsWritten }`.
|
||||
|
||||
## Contracts produced / consumed
|
||||
|
||||
- **Produced:** `dev/contracts/domains/search.md`; `dev/contracts/openapi/swagger.v1.json` refreshed.
|
||||
- **Consumed:** b3 (profiles/gender/aggregates), b4 (service areas / geo), b5 (variants), b6 (verification status).
|
||||
|
||||
## What is mocked / deferred + how to make it real
|
||||
|
||||
- **Elasticsearch backend (`ElasticNurseSearch`) + outbox feeder** — DEFERRED. The SQL index is the real MVP
|
||||
backend and stays the projection/fallback. Seam ready (`INurseSearch`, config `Search:Backend`;
|
||||
`ISearchIndexMaintainer` change-event shape). Steps in `reports/mocks-registry.md` (both rows).
|
||||
- **`booking_requests.required_caregiver_gender` capture** — owned by **b8** (carry the chosen gender into the
|
||||
booking). b7 makes `nurse_gender` a first-class search facet and stops there.
|
||||
- **Availability hard-filter, map/radius discovery, ranking beyond rating, preferred-nurse continuity** —
|
||||
DEFERRED per the product doc.
|
||||
|
||||
## Follow-ups for later phases
|
||||
|
||||
- **b8** — consume `search/nurses` results into the booking flow; capture `required_caregiver_gender`.
|
||||
- **Optional** — a short-TTL `ICacheService` decorator over hot (category, city, gender) result pages,
|
||||
invalidated on index writes for the affected city/category (shipped no-cache at MVP).
|
||||
- **Perf** — `RebuildAsync` does per-nurse reads (N+1); fine for the batched admin/nightly job, worth a
|
||||
set-based rewrite if the nurse count grows large.
|
||||
- **Elastic** — build the outbox + feeder when search scale demands it (both registry rows).
|
||||
@@ -33,6 +33,8 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
|
||||
| `IFieldEncryptor` | backend-phase-0 | PII encryption — AES-256-CBC + HMAC hash from a local symmetric key (`SymmetricFieldEncryptor`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:FieldEncryption:Key`, `Seams:FieldEncryption:HashKey` | KMS / column encryption / Key Vault / HSM | 🟡 |
|
||||
| `INotificationDispatcher` | backend-phase-0/**1** | Notification channels — **in-app write is now real** (`InAppNotificationDispatcher`, `Persistence/Services/Notifications/`, writes an `ops.Notifications` row); b0 log stub removed. SMS/push channels still deferred (no-op) behind the same seam | _none_ | Add SMS (`ISmsSender`) / push (FCM) channels; polling → Redis pub/sub or SignalR later | 🟡 |
|
||||
| `ILicenseVerificationService` | backend-phase-15 | eNamad / MoH establishment-permit — manual approve | _tbd_ | Real registry/API | 🔴 |
|
||||
| `INurseSearch` | backend-phase-7 | The search-service seam (read side). **The MVP impl `SqlNurseSearch` (`Persistence/Services/Search/`) is REAL, not a mock** — it reads the maintained `nurse_search_index WHERE is_searchable=1`, applies the category/city/district (NULL=whole-city)/gender/price filters + rating sort + pagination, projected & `AsNoTracking`. Registered by `AddPersistenceServices`, config-selected. Only the DEFERRED Elasticsearch backend is unbuilt | `Search:Backend` (default `sql`; any other value throws until Elastic ships) | 1) add an Elasticsearch client package (`Elastic.Clients.Elasticsearch`) to `Directory.Packages.props`; 2) define the index mapping (the `NurseSearchResultDto` fields + `is_searchable`); 3) implement `ElasticNurseSearch : INurseSearch` (same filters/sort/paging) reading the ES index; 4) build the feeder that consumes the `ISearchIndexMaintainer` change events via an **outbox/CDC** stream into ES (see the next row); 5) point `Search:Backend=elastic` in config — **callers unchanged**; 6) keep the SQL index as the projection/fallback + the reconciliation source (`RebuildAsync`); 7) test filter/sort/paging parity vs `SqlNurseSearch` | 🟢 SQL real; Elastic 🟡 |
|
||||
| `ISearchIndexMaintainer` (the "`ISearchIndexWriter`" event shape) | backend-phase-7 | The index-maintenance seam (write side). **The inline SQL path is REAL** — `SearchIndexMaintainer` (`Persistence/Services/Search/`) re-derives `nurse_search_index` from source and **stages** it inside the owning source write's unit of work (single `CommitAsync`), invoked from the b3/b4/b5/b6 handlers (`ReindexVariantAsync`/`ReindexNurseAsync`/`FanOutServiceAreaAsync`/`RemoveServiceAreaRowsAsync`/`RebuildAsync`). Only the **outbox/queue routing** for an async Elastic feeder is deferred — the seam is shaped so the same change events can later be emitted to an outbox instead of an inline upsert | _none_ | 1) introduce an `outbox` table + a SaveChanges interceptor that captures each maintainer change as an event row in the same transaction; 2) a background feeder (Hangfire/Quartz or a hosted service) reads the outbox and applies to `ElasticNurseSearch`; 3) keep the inline SQL upsert as the projection/fallback so `RebuildAsync` stays the reconciliation path; 4) test that an outbox replay converges to the same rows as the inline path | 🟡 outbox deferred (inline real) |
|
||||
|
||||
> Exact config keys and file paths get filled in by the phase that builds each seam. Keep the
|
||||
> "Make it real →" column actionable enough that a developer can pick up any single row and ship it.
|
||||
@@ -52,3 +54,4 @@ the frontend can build before the backend phase merges, and swap to the real HTT
|
||||
| `AddressesApi` | `client/src/services/addresses/apis/mockApi.ts` | Customer address CRUD (list primary-first / create / update / set-primary / soft-delete) with the **exactly-one-primary** invariant enforced in-memory (first address auto-primary; promoting clears the prior; deleting the primary promotes the next). Persists the client-augmented `provinceId` (REQ-009) and the picked `latitude`/`longitude` (REQ-008) the wire DTO/create-body lack | `USE_ADDRESSES_MOCK` (`services/addresses/constants.ts`, default `true`) | b4 `customer_addresses/*` are live; deliver REQ-008 (accept the pin) + REQ-009 (`provinceId` on the DTO), then set flag `false` — `addressesClientApi` is wired (sends the pin + `pageSize`, echoes `provinceId` locally) | 🟡 |
|
||||
| `ServiceAreasApi` | `client/src/services/serviceAreas/apis/mockApi.ts` | Nurse coverage areas (list whole-city-first / add / remove). Enforces `UNIQUE(cityId, districtId)` exactly as the server — a duplicate (incl. a second whole-city row) throws the same **`409`** (`area_duplicate`) so the coverage editor's inline dup handling is demonstrable | `USE_SERVICE_AREAS_MOCK` (`services/serviceAreas/constants.ts`, default `true`) | b4 `nurse_service_areas/*` are live; set flag `false` — `serviceAreasClientApi` is wired (maps the server 409 to the same inline message). No hook/component change | 🟡 |
|
||||
| `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange` — `AddressForm` and every caller stay unchanged | 🟡 |
|
||||
| `CatalogApi` | `client/src/services/catalog/apis/mockApi.ts` (+ `apis/seed.ts`) | The catalog skeleton + nurse pricing layer. **Categories mirror the b5 seed exactly** (5 categories, ids 1–5, `sortOrder` 0–4). Seeds representative **option groups/values** the fresh backend does **not** (an admin authors them per category) — incl. required + optional groups and one **cross-category** (`serviceCategoryId=null`) group — so the builder's required-option gate + cross-category rendering demo. Enforces the server's create validation in-memory: `400` missing required dimension / bad price, and the `(nurse, category, option-set)` duplicate **`409`** (via `optionSetSignature`). Variant store seeded **empty** so the offerings empty-state demos; the nurse builds variants live (across price units). `create`/`update`/`set_active`/`list`(active-first, paginated)/`get`. Money stays an **IRR digit-string** end-to-end | `USE_CATALOG_MOCK` (`services/catalog/constants.ts`, default `true`) | b5 `catalog/*` + `nurse_variants/*` are live; set flag `false` — `catalogClientApi` is wired to the action-style routes (camelCase bodies, `pageSize` pagination per REQ-010, `category_id` snake_case filter). **When swapped, categories will have NO option groups until an admin authors them** (the mock's groups were illustrative). No hook/component change | 🟡 |
|
||||
|
||||
+35
-4
@@ -81,15 +81,15 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
|
||||
```
|
||||
src/
|
||||
├── Core/
|
||||
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
|
||||
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
|
||||
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
|
||||
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
|
||||
├── Infrastructure/
|
||||
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service)
|
||||
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch)
|
||||
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
|
||||
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier) + AddCrossCuttingSeams
|
||||
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
|
||||
├── API/
|
||||
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge)), appsettings*.json
|
||||
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch), appsettings*.json
|
||||
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
|
||||
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
|
||||
├── Shared/Baya.SharedKernel Extensions + validation base
|
||||
@@ -195,6 +195,37 @@ seed in `Persistence/Configuration/CatalogConfig/`; per-domain repos (`ICatalogR
|
||||
canonical `variant_snapshot_json` and is **consumed by b8** (which owns the `booking_requests` column);
|
||||
this phase ships and unit-tests it but persists nothing. `nurse_search_index` is **b7's** (not built here).
|
||||
|
||||
**Search & matching (backend-phase-7).** A new **`search` schema** holds the single denormalized read model
|
||||
`NurseSearchIndex` (table `NurseSearchIndices`) — **one flat row per (bookable variant × covered service
|
||||
area)** (fan-out), copying the variant's category/price/unit, the covered `city_id`/`district_id`
|
||||
(`district_id = NULL` = whole city), the nurse's `nurse_gender` + rating aggregates, and the single
|
||||
`is_searchable` visibility gate. It is a **read-only projection**, written only by the maintainer that
|
||||
re-derives it from source. Features under `Baya.Application/Features/Search/{Queries|Commands}/`; config in
|
||||
`Persistence/Configuration/SearchConfig/`; the maintainer + SQL search in `Persistence/Services/Search/`.
|
||||
Two seams live in `Application/Contracts/Search/`, registered by `AddPersistenceServices` (config key
|
||||
`Search:Backend`, default `sql`):
|
||||
- **`INurseSearch`** (read) — impl `SqlNurseSearch` reads **only `is_searchable = 1`** rows, applies the
|
||||
category/city/district/gender/price filters + rating sort + pagination. The real MVP backend; a later
|
||||
`ElasticNurseSearch` is a config-selected drop-in and callers depend only on the interface.
|
||||
- **`ISearchIndexMaintainer`** (write, the "ISearchIndexWriter" shape) — `SearchIndexMaintainer` keeps the
|
||||
index consistent **inline, inside the source write's own unit of work** (single `CommitAsync`), invoked
|
||||
from the b3/b4/b5/b6 handlers that own each source row: `ReindexVariantAsync` (variant create/edit/toggle),
|
||||
`ReindexNurseAsync` (verification flip / suspend / accepting-toggle / rating recompute),
|
||||
`FanOutServiceAreaAsync` + `RemoveServiceAreaRowsAsync` (area add/remove), and `RebuildAsync` (idempotent
|
||||
full rebuild — the admin `POST admin_search/rebuild_index` job). It shares the request-scoped
|
||||
`ApplicationDbContext`, so it only *stages* changes; the handler's commit flushes source + projection
|
||||
atomically. It reads the facts a trigger does **not** change from the DB and takes the facts it **does**
|
||||
change as tracked arguments, so it never reads a stale pre-commit value. Load-bearing rules:
|
||||
- **`is_searchable = 1` only when** nurse `is_verified = 1` AND `nurse_verifications.status != 'suspended'`
|
||||
AND `is_accepting_bookings = 1` AND variant `is_active = 1` — recomputed on every relevant source write.
|
||||
An unverified/paused/suspended/deactivated nurse or variant must **never** surface.
|
||||
- **`district_id = NULL` = whole city**, both directions: a city search matches every row in the city; a
|
||||
district search matches that district's rows **plus** the NULL-district (whole-city) rows. Uniqueness
|
||||
(`UNIQUE(variant_id, city_id, district_id) WHERE deleted_at IS NULL`) uses the filtered-index pair (the
|
||||
`nurse_service_areas` trick) so NULL participates on SQL Server; the maintainer resurrects a soft-deleted
|
||||
row on re-upsert so each (variant × area) has exactly one live row.
|
||||
- **Incremental maintenance and full rebuild must converge** — the index is fully re-derivable from source.
|
||||
|
||||
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
|
||||
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
|
||||
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Search.Commands.RebuildSearchIndex;
|
||||
using Baya.Application.Models.Search;
|
||||
using Baya.Infrastructure.Identity.Identity.PermissionManager;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Baya.WebFramework.ServiceConfiguration;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
/// <summary>
|
||||
/// Admin maintenance for the search index. The rebuild is the idempotent convergence/reconciliation path —
|
||||
/// its result must match the incrementally-maintained index.
|
||||
/// </summary>
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Authorize(ConstantPolicies.DynamicPermission)]
|
||||
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
|
||||
[Display(Description = "Admin search-index maintenance (full rebuild / reconciliation)")]
|
||||
public sealed class AdminSearchController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpPost("[action]")]
|
||||
[ProducesOkApiResponseType<SearchIndexRebuildResult>]
|
||||
public async Task<IActionResult> RebuildIndex(CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new RebuildSearchIndexCommand(), cancellationToken));
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#nullable enable
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Search.Queries.SearchNurses;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Search;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
/// <summary>
|
||||
/// Public nurse discovery. Pre-auth (families browse before signing in) and covered by the per-IP global
|
||||
/// rate limiter. Reads only searchable (verified + accepting + active) rows via the <c>INurseSearch</c> seam.
|
||||
/// </summary>
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Display(Description = "Public nurse search: category + city/district geo, same-gender filter, price range, rating sort")]
|
||||
public sealed class SearchController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpGet("[action]")]
|
||||
[ProducesOkApiResponseType<PagedResult<NurseSearchResultDto>>]
|
||||
public async Task<IActionResult> Nurses(
|
||||
[FromQuery(Name = "service_category_id")] long serviceCategoryId,
|
||||
[FromQuery(Name = "city_id")] long cityId,
|
||||
[FromQuery(Name = "district_id")] long? districtId,
|
||||
[FromQuery(Name = "nurse_gender")] string? nurseGender,
|
||||
[FromQuery(Name = "min_price")] long? minPrice,
|
||||
[FromQuery(Name = "max_price")] long? maxPrice,
|
||||
[FromQuery(Name = "price_unit")] string? priceUnit,
|
||||
[FromQuery(Name = "page")] int page,
|
||||
[FromQuery(Name = "page_size")] int pageSize,
|
||||
CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(
|
||||
new SearchNursesQuery(
|
||||
serviceCategoryId, cityId, districtId, nurseGender, minPrice, maxPrice, priceUnit,
|
||||
page <= 0 ? 1 : page,
|
||||
pageSize <= 0 ? Application.Common.Pagination.DefaultPageSize : pageSize),
|
||||
cancellationToken));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Search;
|
||||
|
||||
namespace Baya.Application.Contracts.Search;
|
||||
|
||||
/// <summary>
|
||||
/// The search-service seam. Discovery callers depend <b>only</b> on this interface — never on raw SQL or an
|
||||
/// Elasticsearch client — so the MVP→Elastic swap is a registration/config change with no caller edits.
|
||||
/// <para>
|
||||
/// The MVP implementation (<c>SqlNurseSearch</c>) is the <b>real, production backend</b>, not a mock: it
|
||||
/// reads the maintained <c>nurse_search_index</c> where <c>is_searchable = 1</c>, applies the
|
||||
/// category/city/district/gender/price filters and the rating sort, and paginates. A later
|
||||
/// <c>ElasticNurseSearch</c> is a config-selected drop-in; the SQL index stays the projection/fallback.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface INurseSearch
|
||||
{
|
||||
Task<PagedResult<NurseSearchResultDto>> SearchAsync(NurseSearchCriteria criteria, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Search;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
|
||||
namespace Baya.Application.Contracts.Search;
|
||||
|
||||
/// <summary>
|
||||
/// The index-maintenance seam (the "<c>ISearchIndexWriter</c>" shape). It keeps <c>nurse_search_index</c>
|
||||
/// consistent with its source tables. Each method is invoked by the handler that owns the source write and
|
||||
/// <b>stages</b> its index changes on the same unit of work — the handler's single <c>CommitAsync</c> then
|
||||
/// persists the source change and its projection atomically. A source write that rolls back rolls back its
|
||||
/// index change too; the projection can never diverge on a successful commit.
|
||||
/// <para>
|
||||
/// The projection is written <b>only by the code path that owns the source row</b>: a variant write
|
||||
/// reindexes that variant, a profile/verification write reindexes that nurse, a service-area write fans
|
||||
/// out / removes that nurse's rows for the area. The inline SQL path applies these today; the same change
|
||||
/// events can later be routed to an outbox/queue for an Elasticsearch feeder without touching callers.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The maintainer intentionally reads the facts a given trigger does <i>not</i> change from the database and
|
||||
/// takes the facts it <i>does</i> change as tracked arguments — so it never reads a stale, pre-commit value.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface ISearchIndexMaintainer
|
||||
{
|
||||
/// <summary>Variant create / edit / activate / deactivate. Reprojects <b>this variant across all the
|
||||
/// nurse's active service areas</b> (upsert one row per area) and reconciles away rows for areas no
|
||||
/// longer covered. A deactivated variant keeps its rows with <c>is_searchable = 0</c>. Pass the tracked
|
||||
/// variant entity — a freshly-created one (id still 0) is inserted in the same graph.</summary>
|
||||
Task ReindexVariantAsync(NurseServiceVariant variant, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>A change to the nurse's bookability or copied aggregates: the <c>is_verified</c> flip,
|
||||
/// suspend / un-suspend, the <c>is_accepting_bookings</c> toggle, or a rating recompute. Re-derives
|
||||
/// <b>every row for the nurse</b> (each variant × each active area), recomputing <c>is_searchable</c> and
|
||||
/// refreshing the copied gender/rating fields. Pass the tracked profile (its just-changed flags/aggregates
|
||||
/// are read from it); pass <paramref name="verificationStatus"/> when this same unit of work also changed
|
||||
/// verification state, else the committed status is read.</summary>
|
||||
Task ReindexNurseAsync(NurseProfile profile, VerificationStatus? verificationStatus, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Service-area <b>add</b>. Inserts one row per non-deleted variant for the newly-covered area
|
||||
/// (the area itself may not be committed yet — the city/district are taken from the write, not read
|
||||
/// back), with <c>is_searchable</c> per the visibility predicate.</summary>
|
||||
Task FanOutServiceAreaAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Service-area <b>remove</b>. Soft-deletes exactly the nurse's rows for that area across all
|
||||
/// variants — never collapses other areas.</summary>
|
||||
Task RemoveServiceAreaRowsAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Idempotent full rebuild from source (<c>nurse_profiles × variants × active areas</c>) — the
|
||||
/// convergence/reconciliation path. Owns its own batched commits; the incrementally-maintained index and
|
||||
/// a fresh rebuild must produce the same live rows.</summary>
|
||||
Task<SearchIndexRebuildResult> RebuildAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
+6
-1
@@ -1,13 +1,14 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings;
|
||||
|
||||
internal sealed class SetNurseAcceptingBookingsCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
internal sealed class SetNurseAcceptingBookingsCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork, ISearchIndexMaintainer searchIndex)
|
||||
: IRequestHandler<SetNurseAcceptingBookingsCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(SetNurseAcceptingBookingsCommand request, CancellationToken cancellationToken)
|
||||
@@ -23,6 +24,10 @@ internal sealed class SetNurseAcceptingBookingsCommandHandler(ICurrentUser curre
|
||||
return OperationResult<bool>.NotFoundResult("No nurse profile exists yet. Create your profile first.");
|
||||
|
||||
profile.SetAcceptingBookings(request.Accepting);
|
||||
|
||||
// Pausing/resuming bookings flips every one of the nurse's index rows' is_searchable in the same
|
||||
// transaction (verification status is unchanged here, so it is read from the committed record).
|
||||
await searchIndex.ReindexNurseAsync(profile, null, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Audit;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Search;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Search.Commands.RebuildSearchIndex;
|
||||
|
||||
internal sealed class RebuildSearchIndexCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
ISearchIndexMaintainer maintainer,
|
||||
IAuditLogger auditLogger)
|
||||
: IRequestHandler<RebuildSearchIndexCommand, OperationResult<SearchIndexRebuildResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<SearchIndexRebuildResult>> Handle(RebuildSearchIndexCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return OperationResult<SearchIndexRebuildResult>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var result = await maintainer.RebuildAsync(cancellationToken);
|
||||
|
||||
await auditLogger.WriteAsync(
|
||||
"nurse_search_index",
|
||||
"rebuild",
|
||||
"rebuild",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["admin_id"] = adminId,
|
||||
["nurses_processed"] = result.NursesProcessed,
|
||||
["rows_written"] = result.RowsWritten
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return OperationResult<SearchIndexRebuildResult>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Search;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Search.Commands.RebuildSearchIndex;
|
||||
|
||||
/// <summary>Admin/nightly full rebuild of <c>nurse_search_index</c> from source — the convergence path.
|
||||
/// Idempotent: the rebuilt index must match the incrementally-maintained one.</summary>
|
||||
public record RebuildSearchIndexCommand : IRequest<OperationResult<SearchIndexRebuildResult>>;
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Search;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Search.Queries.SearchNurses;
|
||||
|
||||
internal sealed class SearchNursesQueryHandler(INurseSearch search)
|
||||
: IRequestHandler<SearchNursesQuery, OperationResult<PagedResult<NurseSearchResultDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<NurseSearchResultDto>>> Handle(SearchNursesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
|
||||
|
||||
var criteria = new NurseSearchCriteria(
|
||||
request.ServiceCategoryId,
|
||||
request.CityId,
|
||||
request.DistrictId,
|
||||
string.IsNullOrWhiteSpace(request.NurseGender) ? null : request.NurseGender,
|
||||
request.MinPrice,
|
||||
request.MaxPrice,
|
||||
string.IsNullOrWhiteSpace(request.PriceUnit) ? null : request.PriceUnit,
|
||||
page,
|
||||
pageSize);
|
||||
|
||||
var result = await search.SearchAsync(criteria, cancellationToken);
|
||||
return OperationResult<PagedResult<NurseSearchResultDto>>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Search.Queries.SearchNurses;
|
||||
|
||||
public sealed class SearchNursesQueryValidator : AbstractValidator<SearchNursesQuery>
|
||||
{
|
||||
public SearchNursesQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.ServiceCategoryId).GreaterThan(0);
|
||||
RuleFor(x => x.CityId).GreaterThan(0);
|
||||
RuleFor(x => x.DistrictId).GreaterThan(0).When(x => x.DistrictId.HasValue);
|
||||
|
||||
// Same-gender matching is a first-class facet — when present it must be an exact known value.
|
||||
RuleFor(x => x.NurseGender)
|
||||
.Must(g => g is "male" or "female")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.NurseGender))
|
||||
.WithMessage("nurse_gender must be 'male' or 'female'.");
|
||||
|
||||
RuleFor(x => x.MinPrice).GreaterThanOrEqualTo(0).When(x => x.MinPrice.HasValue);
|
||||
RuleFor(x => x.MaxPrice).GreaterThanOrEqualTo(0).When(x => x.MaxPrice.HasValue);
|
||||
RuleFor(x => x)
|
||||
.Must(x => x.MinPrice <= x.MaxPrice)
|
||||
.When(x => x.MinPrice.HasValue && x.MaxPrice.HasValue)
|
||||
.WithMessage("min_price must be less than or equal to max_price.");
|
||||
|
||||
RuleFor(x => x.PriceUnit)
|
||||
.Must(PriceUnits.IsValid)
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.PriceUnit))
|
||||
.WithMessage("price_unit must be one of: per_hour, per_session, per_half_day, per_day, per_24h.");
|
||||
|
||||
RuleFor(x => x.PageSize).LessThanOrEqualTo(Baya.Application.Common.Pagination.MaxPageSize);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Search;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Search.Queries.SearchNurses;
|
||||
|
||||
/// <summary>
|
||||
/// The single family-facing discovery query. Category + city are required; district is optional (NULL =
|
||||
/// whole-city geography is resolved by the backend). Same-gender matching is a first-class facet; price is
|
||||
/// an IRR <c>long</c> range. Only <c>is_searchable = 1</c> rows are ever returned. Delegates to the
|
||||
/// <see cref="Baya.Application.Contracts.Search.INurseSearch"/> seam so an Elasticsearch backend can drop in
|
||||
/// later by configuration alone.
|
||||
/// </summary>
|
||||
public record SearchNursesQuery(
|
||||
long ServiceCategoryId,
|
||||
long CityId,
|
||||
long? DistrictId = null,
|
||||
string? NurseGender = null,
|
||||
long? MinPrice = null,
|
||||
long? MaxPrice = null,
|
||||
string? PriceUnit = null,
|
||||
int Page = 1,
|
||||
int PageSize = 50) : IRequest<OperationResult<PagedResult<NurseSearchResultDto>>>;
|
||||
+6
-3
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Geography;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
@@ -9,7 +10,7 @@ using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea;
|
||||
|
||||
internal sealed class AddNurseServiceAreaCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
internal sealed class AddNurseServiceAreaCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork, ISearchIndexMaintainer searchIndex)
|
||||
: IRequestHandler<AddNurseServiceAreaCommand, OperationResult<NurseServiceAreaDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<NurseServiceAreaDto>> Handle(AddNurseServiceAreaCommand request, CancellationToken cancellationToken)
|
||||
@@ -53,9 +54,11 @@ internal sealed class AddNurseServiceAreaCommandHandler(ICurrentUser currentUser
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
// DEFERRED (b7): this is the write that later fans out nurse_search_index rows. Keep it the single
|
||||
// trigger point — do not build the index here.
|
||||
await unitOfWork.NurseServiceAreaRepository.AddAsync(area, cancellationToken);
|
||||
|
||||
// Fan the newly-covered area out into nurse_search_index: one row per active variant, in the same
|
||||
// transaction. The area itself may still be uncommitted, so its city/district come from the request.
|
||||
await searchIndex.FanOutServiceAreaAsync(nid, request.CityId, request.DistrictId, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<NurseServiceAreaDto>.SuccessResult(new NurseServiceAreaDto(
|
||||
|
||||
+7
-2
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
@@ -10,7 +11,8 @@ namespace Baya.Application.Features.ServiceAreas.Commands.RemoveNurseServiceArea
|
||||
internal sealed class RemoveNurseServiceAreaCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider clock)
|
||||
IDateTimeProvider clock,
|
||||
ISearchIndexMaintainer searchIndex)
|
||||
: IRequestHandler<RemoveNurseServiceAreaCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(RemoveNurseServiceAreaCommand request, CancellationToken cancellationToken)
|
||||
@@ -30,8 +32,11 @@ internal sealed class RemoveNurseServiceAreaCommandHandler(
|
||||
if (area is null)
|
||||
return OperationResult<bool>.NotFoundResult("Service area not found.");
|
||||
|
||||
// DEFERRED (b7): triggers nurse_search_index row removal — keep this the single trigger point.
|
||||
area.DeletedAt = clock.UtcNow;
|
||||
|
||||
// Drop exactly this nurse×area's index rows across all variants, in the same transaction. Removing an
|
||||
// area must never collapse or touch the nurse's other areas.
|
||||
await searchIndex.RemoveServiceAreaRowsAsync(area.NurseId, area.CityId, area.DistrictId, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
|
||||
+6
-3
@@ -3,6 +3,7 @@ using System.Globalization;
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
@@ -11,7 +12,7 @@ using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Variants.Commands.CreateVariant;
|
||||
|
||||
internal sealed class CreateVariantCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
internal sealed class CreateVariantCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork, ISearchIndexMaintainer searchIndex)
|
||||
: IRequestHandler<CreateVariantCommand, OperationResult<VariantDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<VariantDto>> Handle(CreateVariantCommand request, CancellationToken cancellationToken)
|
||||
@@ -91,9 +92,11 @@ internal sealed class CreateVariantCommandHandler(ICurrentUser currentUser, IUni
|
||||
.ToList()
|
||||
};
|
||||
|
||||
// DEFERRED (b7): this is the write that later fans a variant out into nurse_search_index. Keep it the
|
||||
// single trigger point — do not build the index here.
|
||||
await unitOfWork.NurseServiceVariantRepository.AddAsync(variant, cancellationToken);
|
||||
|
||||
// Fan this variant out into nurse_search_index across the nurse's service areas, in the same unit of
|
||||
// work — the new variant's generated id is assigned to its index rows on the single commit below.
|
||||
await searchIndex.ReindexVariantAsync(variant, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<VariantDto>.SuccessResult(new VariantDto(
|
||||
|
||||
+5
-2
@@ -1,13 +1,14 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Variants.Commands.SetVariantActive;
|
||||
|
||||
internal sealed class SetVariantActiveCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
internal sealed class SetVariantActiveCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork, ISearchIndexMaintainer searchIndex)
|
||||
: IRequestHandler<SetVariantActiveCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(SetVariantActiveCommand request, CancellationToken cancellationToken)
|
||||
@@ -28,7 +29,9 @@ internal sealed class SetVariantActiveCommandHandler(ICurrentUser currentUser, I
|
||||
|
||||
variant.IsActive = request.IsActive;
|
||||
|
||||
// DEFERRED (b7): toggling active is the trigger point for the search-index add/remove.
|
||||
// Deactivate flips this variant's index rows to is_searchable=0 (kept, not deleted); activate makes
|
||||
// them searchable again — recomputed and staged in the same transaction as the toggle.
|
||||
await searchIndex.ReindexVariantAsync(variant, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
|
||||
+4
-1
@@ -2,6 +2,7 @@
|
||||
using System.Globalization;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
@@ -9,7 +10,7 @@ using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Variants.Commands.UpdateVariant;
|
||||
|
||||
internal sealed class UpdateVariantCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
internal sealed class UpdateVariantCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork, ISearchIndexMaintainer searchIndex)
|
||||
: IRequestHandler<UpdateVariantCommand, OperationResult<VariantDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<VariantDto>> Handle(UpdateVariantCommand request, CancellationToken cancellationToken)
|
||||
@@ -35,6 +36,8 @@ internal sealed class UpdateVariantCommandHandler(ICurrentUser currentUser, IUni
|
||||
if (!string.IsNullOrWhiteSpace(request.DisplayName))
|
||||
variant.DisplayName = request.DisplayName.Trim();
|
||||
|
||||
// Price/category changes must reach the search projection in the same transaction.
|
||||
await searchIndex.ReindexVariantAsync(variant, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
// Re-project with resolved labels for the response (the option-set is unchanged).
|
||||
|
||||
+7
-1
@@ -3,6 +3,7 @@ using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Audit;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Verification;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
@@ -16,7 +17,8 @@ internal sealed class AdminReviewStepCommandHandler(
|
||||
ICredentialVerifier credentialVerifier,
|
||||
IAuditLogger auditLogger,
|
||||
ICacheService cache,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
ISearchIndexMaintainer searchIndex)
|
||||
: IRequestHandler<AdminReviewStepCommand, OperationResult<ReviewStepResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<ReviewStepResult>> Handle(AdminReviewStepCommand request, CancellationToken cancellationToken)
|
||||
@@ -69,6 +71,10 @@ internal sealed class AdminReviewStepCommandHandler(
|
||||
|
||||
VerificationAggregator.Finalize(verification, profile, now);
|
||||
|
||||
// Any is_verified flip must reach the search projection in the same transaction — a newly-verified
|
||||
// nurse's rows become searchable; a rejection reverses it.
|
||||
await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken);
|
||||
|
||||
// The step decision, the recorded credential, and any is_verified flip land in one transaction.
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
|
||||
+6
-1
@@ -3,6 +3,7 @@ using System.Text.Json;
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Verification;
|
||||
using Baya.Domain.Entities.User;
|
||||
@@ -15,7 +16,8 @@ internal sealed class RunBankAccountVerificationCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IBankAccountOwnershipVerifier ownershipVerifier,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
ISearchIndexMaintainer searchIndex)
|
||||
: IRequestHandler<RunBankAccountVerificationCommand, OperationResult<RunStepResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<RunStepResult>> Handle(RunBankAccountVerificationCommand request, CancellationToken cancellationToken)
|
||||
@@ -77,6 +79,9 @@ internal sealed class RunBankAccountVerificationCommandHandler(
|
||||
return OperationResult<RunStepResult>.NotFoundResult("Nurse profile not found.");
|
||||
|
||||
VerificationAggregator.Finalize(verification, profile, now);
|
||||
|
||||
// An automated pass can flip is_verified — keep the search projection in step within this commit.
|
||||
await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<RunStepResult>.SuccessResult(new RunStepResult(step.Id, step.Status.ToCode(), step.FailureReason));
|
||||
|
||||
+6
-1
@@ -2,6 +2,7 @@
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Verification;
|
||||
using Baya.Domain.Entities.User;
|
||||
@@ -14,7 +15,8 @@ internal sealed class RunIdentityKycCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IIdentityKycProvider identityKyc,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
ISearchIndexMaintainer searchIndex)
|
||||
: IRequestHandler<RunIdentityKycCommand, OperationResult<RunStepResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<RunStepResult>> Handle(RunIdentityKycCommand request, CancellationToken cancellationToken)
|
||||
@@ -68,6 +70,9 @@ internal sealed class RunIdentityKycCommandHandler(
|
||||
return OperationResult<RunStepResult>.NotFoundResult("Nurse profile not found.");
|
||||
|
||||
VerificationAggregator.Finalize(verification, profile, now);
|
||||
|
||||
// An automated pass can flip is_verified — keep the search projection in step within this commit.
|
||||
await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<RunStepResult>.SuccessResult(new RunStepResult(step.Id, step.Status.ToCode(), step.FailureReason));
|
||||
|
||||
+6
-1
@@ -2,6 +2,7 @@
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Contracts.SupportAlerts;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Verification;
|
||||
@@ -17,7 +18,8 @@ internal sealed class RunShahkarMatchCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IShahkarVerifier shahkarVerifier,
|
||||
ISupportAlertService supportAlerts,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
ISearchIndexMaintainer searchIndex)
|
||||
: IRequestHandler<RunShahkarMatchCommand, OperationResult<RunStepResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<RunStepResult>> Handle(RunShahkarMatchCommand request, CancellationToken cancellationToken)
|
||||
@@ -71,6 +73,9 @@ internal sealed class RunShahkarMatchCommandHandler(
|
||||
return OperationResult<RunStepResult>.NotFoundResult("Nurse profile not found.");
|
||||
|
||||
VerificationAggregator.Finalize(verification, profile, now);
|
||||
|
||||
// An automated pass can flip is_verified — keep the search projection in step within this commit.
|
||||
await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
// Shared-SIM is a distinct, non-accusatory handled state — flag it for staff follow-up. Raised
|
||||
|
||||
+6
-1
@@ -2,6 +2,7 @@
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Contracts.SupportAlerts;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Verification;
|
||||
@@ -16,7 +17,8 @@ internal sealed class ScanExpiringCredentialsCommandHandler(
|
||||
ISupportAlertService supportAlerts,
|
||||
INotificationDispatcher notifications,
|
||||
ICacheService cache,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
ISearchIndexMaintainer searchIndex)
|
||||
: IRequestHandler<ScanExpiringCredentialsCommand, OperationResult<ScanExpiringResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<ScanExpiringResult>> Handle(ScanExpiringCredentialsCommand request, CancellationToken cancellationToken)
|
||||
@@ -62,6 +64,9 @@ internal sealed class ScanExpiringCredentialsCommandHandler(
|
||||
|
||||
// A lapsed required credential must never silently keep a nurse verified — re-gate atomically.
|
||||
VerificationAggregator.Finalize(verification, profile, now);
|
||||
|
||||
// The un-verify must reach search in the same commit so an expired nurse stops surfacing.
|
||||
await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
revertedNurses++;
|
||||
|
||||
|
||||
+6
-1
@@ -3,6 +3,7 @@ using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Audit;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using Mediator;
|
||||
@@ -14,7 +15,8 @@ internal sealed class AdminSuspendVerificationCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IAuditLogger auditLogger,
|
||||
ICacheService cache,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
ISearchIndexMaintainer searchIndex)
|
||||
: IRequestHandler<AdminSuspendVerificationCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(AdminSuspendVerificationCommand request, CancellationToken cancellationToken)
|
||||
@@ -41,6 +43,9 @@ internal sealed class AdminSuspendVerificationCommandHandler(
|
||||
// Suspended status → the aggregator reverses is_verified in the same transaction.
|
||||
VerificationAggregator.Finalize(verification, profile, now);
|
||||
|
||||
// A suspended nurse must vanish from search — flip all their rows to is_searchable=0 in this commit.
|
||||
await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
await auditLogger.WriteAsync(
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Models.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Normalized inputs the <see cref="Baya.Application.Contracts.Search.INurseSearch"/> backend queries.
|
||||
/// Category and city are required; district is optional (NULL-district = whole-city geography is resolved
|
||||
/// inside the backend). Prices are IRR Rials as <c>long</c> — no float.
|
||||
/// </summary>
|
||||
public sealed record NurseSearchCriteria(
|
||||
long ServiceCategoryId,
|
||||
long CityId,
|
||||
long? DistrictId,
|
||||
string? NurseGender,
|
||||
long? MinPrice,
|
||||
long? MaxPrice,
|
||||
string? PriceUnit,
|
||||
int Page,
|
||||
int PageSize);
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Baya.Application.Models.Search;
|
||||
|
||||
/// <summary>
|
||||
/// One family-facing search hit — a bookable variant matched in a covered area. <c>Price</c> is IRR Rials
|
||||
/// as a digit string (BIGINT on the wire, never a float); <c>DistrictId</c> == null means the nurse covers
|
||||
/// the whole city.
|
||||
/// </summary>
|
||||
public record NurseSearchResultDto(
|
||||
long VariantId,
|
||||
long NurseId,
|
||||
long ServiceCategoryId,
|
||||
string Price,
|
||||
string PriceUnit,
|
||||
string NurseGender,
|
||||
decimal AverageRating,
|
||||
int TotalReviews,
|
||||
int TotalCompletedBookings,
|
||||
long CityId,
|
||||
long? DistrictId);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Baya.Application.Models.Search;
|
||||
|
||||
/// <summary>Outcome of a full <c>nurse_search_index</c> rebuild: how many nurse profiles were scanned and
|
||||
/// how many live index rows the rebuild produced.</summary>
|
||||
public record SearchIndexRebuildResult(int NursesProcessed, int RowsWritten);
|
||||
@@ -0,0 +1,65 @@
|
||||
using Baya.Domain.Common;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
|
||||
namespace Baya.Domain.Entities.Search;
|
||||
|
||||
/// <summary>
|
||||
/// The denormalized, maintained-on-write read model behind nurse discovery (b7): <b>one flat row per
|
||||
/// (bookable variant × covered service area)</b>. It flattens facts that otherwise live across four
|
||||
/// domains — the variant's category/price (catalog), the covered city/district (geography), the nurse's
|
||||
/// gender + rating aggregates (identity), and the verification-derived bookability — so a family search is
|
||||
/// a single indexed, paginated scan instead of a 4+ table join with a rating sort.
|
||||
/// <para>
|
||||
/// This is a <b>read-only projection</b>: it is written <i>only</i> by the search-index maintainer, which
|
||||
/// re-derives every field from the source tables. Never let a search read mutate it, and never treat it as
|
||||
/// the source of truth for anything.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="IsSearchable"/> is the single visibility gate — a row is returned to families only when it is
|
||||
/// <c>true</c>, which holds <b>only</b> when the nurse is verified, not suspended, accepting bookings, and
|
||||
/// the variant is active (see the maintainer). <see cref="DistrictId"/> == <c>null</c> is a meaningful
|
||||
/// "whole city" coverage value, not missing data. <see cref="Price"/> is IRR Rials as an integer — no float.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class NurseSearchIndex : BaseEntity<long>
|
||||
{
|
||||
public long VariantId { get; set; }
|
||||
|
||||
/// <summary>Reference navigation so a row projected for a freshly-created variant is inserted in the same
|
||||
/// graph — EF assigns the generated <c>variant_id</c> in one <c>SaveChanges</c>.</summary>
|
||||
public NurseServiceVariant Variant { get; set; }
|
||||
|
||||
public long NurseId { get; set; }
|
||||
|
||||
public long ServiceCategoryId { get; set; }
|
||||
|
||||
/// <summary>IRR Rials, integer — copied from the variant. No float money path, ever.</summary>
|
||||
public long Price { get; set; }
|
||||
|
||||
/// <summary>Closed code set (see <see cref="PriceUnits"/>) — copied from the variant.</summary>
|
||||
public string PriceUnit { get; set; }
|
||||
|
||||
public long CityId { get; set; }
|
||||
|
||||
/// <summary>NULL = "whole city" — a deliberate coverage value, not missing data. A city search matches
|
||||
/// both NULL-district rows and any district row in the city; a district search matches that district's
|
||||
/// rows plus the NULL-district (whole-city) rows.</summary>
|
||||
public long? DistrictId { get; set; }
|
||||
|
||||
/// <summary>Copied from <c>users.gender</c> via the nurse, for the first-class same-gender filter.</summary>
|
||||
public string NurseGender { get; set; }
|
||||
|
||||
public decimal AverageRating { get; set; }
|
||||
public int TotalReviews { get; set; }
|
||||
public int TotalCompletedBookings { get; set; }
|
||||
|
||||
/// <summary>The single visibility gate: <c>true</c> only when nurse <c>is_verified=1</c> AND not
|
||||
/// suspended AND <c>is_accepting_bookings=1</c> AND variant <c>is_active=1</c>. Recomputed on every
|
||||
/// relevant source write — never trusted as a stale value.</summary>
|
||||
public bool IsSearchable { get; set; }
|
||||
|
||||
/// <summary>Stamped from <see cref="IDateTimeProvider"/> on every upsert.</summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Search;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.SearchConfig;
|
||||
|
||||
internal sealed class NurseSearchIndexConfig : IEntityTypeConfiguration<NurseSearchIndex>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<NurseSearchIndex> builder)
|
||||
{
|
||||
builder.ToTable("NurseSearchIndex", "search");
|
||||
|
||||
// Price is IRR Rials as BIGINT (long → bigint) — copied from the variant. No float money path.
|
||||
builder.Property(x => x.PriceUnit).HasMaxLength(20).IsRequired();
|
||||
builder.Property(x => x.NurseGender).HasMaxLength(10);
|
||||
builder.Property(x => x.AverageRating).HasPrecision(3, 2);
|
||||
|
||||
// The hot search path: filter on (is_searchable, category, city, district) then rating-sort + page.
|
||||
// INCLUDE the columns the projection reads so the filtered, sorted page is served straight from the
|
||||
// index with no key lookups (SQL Server; the INCLUDE annotation is ignored by other providers).
|
||||
builder.HasIndex(x => new { x.IsSearchable, x.ServiceCategoryId, x.CityId, x.DistrictId })
|
||||
.IncludeProperties(x => new { x.Price, x.NurseGender, x.AverageRating, x.TotalReviews, x.NurseId, x.VariantId })
|
||||
.HasDatabaseName("IX_NurseSearchIndex_Search");
|
||||
|
||||
// Exactly one live row per (variant × area) — the upsert target and anti-duplication backstop.
|
||||
// SQL Server treats NULLs as distinct, so a plain UNIQUE(variant, city, district) would wrongly allow
|
||||
// two "whole city" (NULL district) rows. Split into a filtered pair exactly like nurse_service_areas:
|
||||
// one enforces at most one whole-city row, the other enforces uniqueness of city+district rows. Both
|
||||
// exclude soft-deleted rows so a removed-then-recovered area re-inserts cleanly.
|
||||
builder.HasIndex(x => new { x.VariantId, x.CityId })
|
||||
.IsUnique()
|
||||
.HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL")
|
||||
.HasDatabaseName("UX_NurseSearchIndex_Variant_City_WholeCity");
|
||||
|
||||
builder.HasIndex(x => new { x.VariantId, x.CityId, x.DistrictId })
|
||||
.IsUnique()
|
||||
.HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL")
|
||||
.HasDatabaseName("UX_NurseSearchIndex_Variant_City_District");
|
||||
|
||||
// A nurse-scoped rebuild / suspend / remove touches every row for one nurse — keep it cheap.
|
||||
builder.HasIndex(x => x.NurseId);
|
||||
|
||||
builder.HasOne(x => x.Variant)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.VariantId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne<NurseProfile>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.NurseId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasQueryFilter(x => x.DeletedAt == null);
|
||||
}
|
||||
}
|
||||
+3528
File diff suppressed because it is too large
Load Diff
+100
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class NurseSearchIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "search");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NurseSearchIndices",
|
||||
schema: "search",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
VariantId = table.Column<long>(type: "bigint", nullable: false),
|
||||
NurseId = table.Column<long>(type: "bigint", nullable: false),
|
||||
ServiceCategoryId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Price = table.Column<long>(type: "bigint", nullable: false),
|
||||
PriceUnit = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
CityId = table.Column<long>(type: "bigint", nullable: false),
|
||||
DistrictId = table.Column<long>(type: "bigint", nullable: true),
|
||||
NurseGender = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
|
||||
AverageRating = table.Column<decimal>(type: "decimal(3,2)", precision: 3, scale: 2, nullable: false),
|
||||
TotalReviews = table.Column<int>(type: "int", nullable: false),
|
||||
TotalCompletedBookings = table.Column<int>(type: "int", nullable: false),
|
||||
IsSearchable = table.Column<bool>(type: "bit", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NurseSearchIndices", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_NurseSearchIndices_NurseProfiles_NurseId",
|
||||
column: x => x.NurseId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "NurseProfiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_NurseSearchIndices_NurseServiceVariants_VariantId",
|
||||
column: x => x.VariantId,
|
||||
principalSchema: "catalog",
|
||||
principalTable: "NurseServiceVariants",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NurseSearchIndex_Search",
|
||||
schema: "search",
|
||||
table: "NurseSearchIndices",
|
||||
columns: new[] { "IsSearchable", "ServiceCategoryId", "CityId", "DistrictId" })
|
||||
.Annotation("SqlServer:Include", new[] { "Price", "NurseGender", "AverageRating", "TotalReviews", "NurseId", "VariantId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NurseSearchIndices_NurseId",
|
||||
schema: "search",
|
||||
table: "NurseSearchIndices",
|
||||
column: "NurseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_NurseSearchIndex_Variant_City_District",
|
||||
schema: "search",
|
||||
table: "NurseSearchIndices",
|
||||
columns: new[] { "VariantId", "CityId", "DistrictId" },
|
||||
unique: true,
|
||||
filter: "[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_NurseSearchIndex_Variant_City_WholeCity",
|
||||
schema: "search",
|
||||
table: "NurseSearchIndices",
|
||||
columns: new[] { "VariantId", "CityId" },
|
||||
unique: true,
|
||||
filter: "[DistrictId] IS NULL AND [DeletedAt] IS NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "NurseSearchIndices",
|
||||
schema: "search");
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
@@ -2138,6 +2138,94 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("Notifications", "ops");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<decimal>("AverageRating")
|
||||
.HasPrecision(3, 2)
|
||||
.HasColumnType("decimal(3,2)");
|
||||
|
||||
b.Property<long>("CityId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<long?>("DistrictId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("IsSearchable")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("NurseGender")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<long>("NurseId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("Price")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("PriceUnit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<long>("ServiceCategoryId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("TotalCompletedBookings")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("TotalReviews")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<long>("VariantId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NurseId");
|
||||
|
||||
b.HasIndex("VariantId", "CityId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_NurseSearchIndex_Variant_City_WholeCity")
|
||||
.HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL");
|
||||
|
||||
b.HasIndex("VariantId", "CityId", "DistrictId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_NurseSearchIndex_Variant_City_District")
|
||||
.HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL");
|
||||
|
||||
b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId")
|
||||
.HasDatabaseName("IX_NurseSearchIndex_Search");
|
||||
|
||||
SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId"), new[] { "Price", "NurseGender", "AverageRating", "TotalReviews", "NurseId", "VariantId" });
|
||||
|
||||
b.ToTable("NurseSearchIndices", "search");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -3181,6 +3269,23 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("NurseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant")
|
||||
.WithMany()
|
||||
.HasForeignKey("VariantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Variant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.User.User", null)
|
||||
|
||||
+14
@@ -5,6 +5,7 @@ using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Holidays;
|
||||
using Baya.Application.Contracts.Notifications;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Contracts.SupportAlerts;
|
||||
using Baya.Infrastructure.Persistence.Interceptors;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
@@ -13,6 +14,7 @@ using Baya.Infrastructure.Persistence.Services.Audit;
|
||||
using Baya.Infrastructure.Persistence.Services.Configuration;
|
||||
using Baya.Infrastructure.Persistence.Services.Holidays;
|
||||
using Baya.Infrastructure.Persistence.Services.Notifications;
|
||||
using Baya.Infrastructure.Persistence.Services.Search;
|
||||
using Baya.Infrastructure.Persistence.Services.SupportAlerts;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -51,6 +53,18 @@ public static class ServiceCollectionExtensions
|
||||
// Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred).
|
||||
services.AddHostedService<NotificationRetentionHostedService>();
|
||||
|
||||
// Search (backend-phase-7). The index maintainer keeps nurse_search_index consistent inline inside
|
||||
// each source write's unit of work. The INurseSearch backend is config-selected — SQL is the real
|
||||
// MVP backend; a later ElasticNurseSearch drops in here with no caller change.
|
||||
services.AddScoped<ISearchIndexMaintainer, SearchIndexMaintainer>();
|
||||
|
||||
var searchBackend = configuration["Search:Backend"];
|
||||
if (string.IsNullOrWhiteSpace(searchBackend) || searchBackend.Equals("sql", StringComparison.OrdinalIgnoreCase))
|
||||
services.AddScoped<INurseSearch, SqlNurseSearch>();
|
||||
else
|
||||
throw new NotSupportedException(
|
||||
$"Search backend '{searchBackend}' is not available — only 'sql' is implemented (Elasticsearch is deferred).");
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Models.Search;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Search;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Search;
|
||||
|
||||
/// <summary>
|
||||
/// The inline SQL implementation of <see cref="ISearchIndexMaintainer"/>. It shares the request-scoped
|
||||
/// <see cref="ApplicationDbContext"/> with the calling handler's <c>IUnitOfWork</c>, so it only <b>stages</b>
|
||||
/// index changes — the handler's single <c>CommitAsync</c> flushes source + projection in one transaction.
|
||||
/// (<see cref="RebuildAsync"/> is the exception: a standalone job that owns its own batched commits.)
|
||||
/// <para>
|
||||
/// The visibility gate is recomputed on every call: a row is searchable only when the nurse is verified,
|
||||
/// not suspended, accepting bookings, and the variant is active. Each (variant × area) has exactly one live
|
||||
/// row; a soft-deleted row is resurrected on re-upsert rather than duplicated.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class SearchIndexMaintainer(ApplicationDbContext db, IDateTimeProvider clock) : ISearchIndexMaintainer
|
||||
{
|
||||
public async Task ReindexVariantAsync(NurseServiceVariant variant, CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = await LoadNurseContextAsync(variant.NurseId, cancellationToken);
|
||||
if (ctx is null)
|
||||
return;
|
||||
|
||||
var status = await LoadStatusAsync(variant.NurseId, cancellationToken);
|
||||
var bookable = NurseBookable(ctx.IsVerified, ctx.IsAcceptingBookings, status);
|
||||
var areas = await LoadActiveAreasAsync(variant.NurseId, cancellationToken);
|
||||
var vk = new VariantKey(variant.Id, variant.ServiceCategoryId, variant.Price, variant.PriceUnit, variant.IsActive);
|
||||
|
||||
foreach (var area in areas)
|
||||
await UpsertRowAsync(variant, vk, area, variant.NurseId, ctx, bookable, cancellationToken);
|
||||
|
||||
// Reconcile: soft-delete this variant's live rows whose area the nurse no longer covers.
|
||||
if (variant.Id != 0)
|
||||
{
|
||||
var live = await db.Set<NurseSearchIndex>()
|
||||
.Where(r => r.VariantId == variant.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var covered = areas.ToHashSet();
|
||||
foreach (var row in live)
|
||||
if (!covered.Contains(new AreaKey(row.CityId, row.DistrictId)))
|
||||
SoftDelete(row);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReindexNurseAsync(NurseProfile profile, VerificationStatus? verificationStatus, CancellationToken cancellationToken)
|
||||
{
|
||||
var status = verificationStatus ?? await LoadStatusAsync(profile.Id, cancellationToken);
|
||||
var gender = await LoadGenderAsync(profile.Id, cancellationToken);
|
||||
|
||||
// Bookability + aggregates come from the tracked profile (its just-changed values); gender is stable
|
||||
// for these triggers so it is read from the database.
|
||||
var ctx = new NurseContext(
|
||||
profile.IsVerified, profile.IsAcceptingBookings, gender,
|
||||
profile.AverageRating, profile.TotalReviews, profile.TotalCompletedBookings);
|
||||
var bookable = NurseBookable(profile.IsVerified, profile.IsAcceptingBookings, status);
|
||||
|
||||
var areas = await LoadActiveAreasAsync(profile.Id, cancellationToken);
|
||||
var variants = await LoadVariantsAsync(profile.Id, cancellationToken);
|
||||
|
||||
var target = new HashSet<(long VariantId, long CityId, long? DistrictId)>();
|
||||
foreach (var variant in variants)
|
||||
foreach (var area in areas)
|
||||
{
|
||||
await UpsertRowAsync(null, variant, area, profile.Id, ctx, bookable, cancellationToken);
|
||||
target.Add((variant.Id, area.CityId, area.DistrictId));
|
||||
}
|
||||
|
||||
// Prune live rows no longer derivable (variant deleted / area removed).
|
||||
var liveRows = await db.Set<NurseSearchIndex>()
|
||||
.Where(r => r.NurseId == profile.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var row in liveRows)
|
||||
if (!target.Contains((row.VariantId, row.CityId, row.DistrictId)))
|
||||
SoftDelete(row);
|
||||
}
|
||||
|
||||
public async Task FanOutServiceAreaAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = await LoadNurseContextAsync(nurseId, cancellationToken);
|
||||
if (ctx is null)
|
||||
return;
|
||||
|
||||
var status = await LoadStatusAsync(nurseId, cancellationToken);
|
||||
var bookable = NurseBookable(ctx.IsVerified, ctx.IsAcceptingBookings, status);
|
||||
var variants = await LoadVariantsAsync(nurseId, cancellationToken);
|
||||
var area = new AreaKey(cityId, districtId);
|
||||
|
||||
foreach (var variant in variants)
|
||||
await UpsertRowAsync(null, variant, area, nurseId, ctx, bookable, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RemoveServiceAreaRowsAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await db.Set<NurseSearchIndex>()
|
||||
.Where(r => r.NurseId == nurseId && r.CityId == cityId && r.DistrictId == districtId)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var row in rows)
|
||||
SoftDelete(row);
|
||||
}
|
||||
|
||||
public async Task<SearchIndexRebuildResult> RebuildAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Idempotent full rebuild: drop the whole projection, then re-derive from source in nurse-batches.
|
||||
await db.Set<NurseSearchIndex>().IgnoreQueryFilters().ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
const int batchSize = 200;
|
||||
var pageIndex = 0;
|
||||
var nursesProcessed = 0;
|
||||
var rowsWritten = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var nurseIds = await db.Set<NurseProfile>()
|
||||
.OrderBy(p => p.Id)
|
||||
.Skip(pageIndex * batchSize)
|
||||
.Take(batchSize)
|
||||
.Select(p => p.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (nurseIds.Count == 0)
|
||||
break;
|
||||
|
||||
foreach (var nurseId in nurseIds)
|
||||
{
|
||||
rowsWritten += await BuildFreshRowsForNurseAsync(nurseId, cancellationToken);
|
||||
nursesProcessed++;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
return new SearchIndexRebuildResult(nursesProcessed, rowsWritten);
|
||||
}
|
||||
|
||||
private async Task<int> BuildFreshRowsForNurseAsync(long nurseId, CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = await LoadNurseContextAsync(nurseId, cancellationToken);
|
||||
if (ctx is null)
|
||||
return 0;
|
||||
|
||||
var status = await LoadStatusAsync(nurseId, cancellationToken);
|
||||
var bookable = NurseBookable(ctx.IsVerified, ctx.IsAcceptingBookings, status);
|
||||
var areas = await LoadActiveAreasAsync(nurseId, cancellationToken);
|
||||
var variants = await LoadVariantsAsync(nurseId, cancellationToken);
|
||||
|
||||
var count = 0;
|
||||
foreach (var variant in variants)
|
||||
foreach (var area in areas)
|
||||
{
|
||||
await db.Set<NurseSearchIndex>().AddAsync(
|
||||
NewRow(null, variant, area, nurseId, ctx, bookable && variant.IsActive), cancellationToken);
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private async Task UpsertRowAsync(
|
||||
NurseServiceVariant? variantEntity,
|
||||
VariantKey variant,
|
||||
AreaKey area,
|
||||
long nurseId,
|
||||
NurseContext ctx,
|
||||
bool nurseBookable,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var isSearchable = nurseBookable && variant.IsActive;
|
||||
|
||||
// A new variant (id 0) can have no existing rows; otherwise look past the soft-delete filter so a
|
||||
// previously-removed (variant × area) row is resurrected rather than duplicated.
|
||||
var existing = variant.Id == 0
|
||||
? null
|
||||
: await db.Set<NurseSearchIndex>()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(
|
||||
r => r.VariantId == variant.Id && r.CityId == area.CityId && r.DistrictId == area.DistrictId,
|
||||
cancellationToken);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
await db.Set<NurseSearchIndex>().AddAsync(
|
||||
NewRow(variantEntity, variant, area, nurseId, ctx, isSearchable), cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
existing.NurseId = nurseId;
|
||||
existing.ServiceCategoryId = variant.ServiceCategoryId;
|
||||
existing.Price = variant.Price;
|
||||
existing.PriceUnit = variant.PriceUnit;
|
||||
existing.NurseGender = ctx.Gender;
|
||||
existing.AverageRating = ctx.AverageRating;
|
||||
existing.TotalReviews = ctx.TotalReviews;
|
||||
existing.TotalCompletedBookings = ctx.TotalCompletedBookings;
|
||||
existing.IsSearchable = isSearchable;
|
||||
existing.UpdatedAt = clock.UtcNow;
|
||||
existing.DeletedAt = null;
|
||||
}
|
||||
|
||||
private NurseSearchIndex NewRow(
|
||||
NurseServiceVariant? variantEntity, VariantKey variant, AreaKey area, long nurseId, NurseContext ctx, bool isSearchable)
|
||||
{
|
||||
var row = new NurseSearchIndex
|
||||
{
|
||||
VariantId = variant.Id,
|
||||
NurseId = nurseId,
|
||||
ServiceCategoryId = variant.ServiceCategoryId,
|
||||
Price = variant.Price,
|
||||
PriceUnit = variant.PriceUnit,
|
||||
CityId = area.CityId,
|
||||
DistrictId = area.DistrictId,
|
||||
NurseGender = ctx.Gender,
|
||||
AverageRating = ctx.AverageRating,
|
||||
TotalReviews = ctx.TotalReviews,
|
||||
TotalCompletedBookings = ctx.TotalCompletedBookings,
|
||||
IsSearchable = isSearchable,
|
||||
UpdatedAt = clock.UtcNow
|
||||
};
|
||||
|
||||
// For a freshly-created variant the id is not assigned yet — attach the tracked principal so EF sets
|
||||
// the generated variant_id in the same graph insert.
|
||||
if (variant.Id == 0 && variantEntity is not null)
|
||||
row.Variant = variantEntity;
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private void SoftDelete(NurseSearchIndex row)
|
||||
{
|
||||
row.DeletedAt = clock.UtcNow;
|
||||
row.IsSearchable = false;
|
||||
row.UpdatedAt = clock.UtcNow;
|
||||
}
|
||||
|
||||
private static bool NurseBookable(bool isVerified, bool isAccepting, VerificationStatus? status)
|
||||
=> isVerified && isAccepting && status != VerificationStatus.Suspended;
|
||||
|
||||
private Task<NurseContext?> LoadNurseContextAsync(long nurseId, CancellationToken cancellationToken)
|
||||
=> db.Set<NurseProfile>()
|
||||
.Where(p => p.Id == nurseId)
|
||||
.Select(p => new NurseContext(
|
||||
p.IsVerified, p.IsAcceptingBookings, p.User.Gender,
|
||||
p.AverageRating, p.TotalReviews, p.TotalCompletedBookings))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
private async Task<string> LoadGenderAsync(long nurseId, CancellationToken cancellationToken)
|
||||
=> await db.Set<NurseProfile>()
|
||||
.Where(p => p.Id == nurseId)
|
||||
.Select(p => p.User.Gender)
|
||||
.FirstOrDefaultAsync(cancellationToken) ?? string.Empty;
|
||||
|
||||
private Task<VerificationStatus?> LoadStatusAsync(long nurseId, CancellationToken cancellationToken)
|
||||
=> db.Set<NurseVerification>()
|
||||
.Where(v => v.NurseId == nurseId)
|
||||
.Select(v => (VerificationStatus?)v.Status)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
private Task<List<AreaKey>> LoadActiveAreasAsync(long nurseId, CancellationToken cancellationToken)
|
||||
=> db.Set<NurseServiceArea>()
|
||||
.Where(a => a.NurseId == nurseId && a.IsActive)
|
||||
.Select(a => new AreaKey(a.CityId, a.DistrictId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
private Task<List<VariantKey>> LoadVariantsAsync(long nurseId, CancellationToken cancellationToken)
|
||||
=> db.Set<NurseServiceVariant>()
|
||||
.Where(v => v.NurseId == nurseId)
|
||||
.Select(v => new VariantKey(v.Id, v.ServiceCategoryId, v.Price, v.PriceUnit, v.IsActive))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
private readonly record struct AreaKey(long CityId, long? DistrictId);
|
||||
|
||||
private readonly record struct VariantKey(long Id, long ServiceCategoryId, long Price, string PriceUnit, bool IsActive);
|
||||
|
||||
private sealed record NurseContext(
|
||||
bool IsVerified, bool IsAcceptingBookings, string Gender,
|
||||
decimal AverageRating, int TotalReviews, int TotalCompletedBookings);
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
#nullable enable
|
||||
using System.Globalization;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Search;
|
||||
using Baya.Domain.Entities.Search;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Search;
|
||||
|
||||
/// <summary>
|
||||
/// The MVP <see cref="INurseSearch"/> backend — the real, production search over the maintained
|
||||
/// <c>nurse_search_index</c>. It reads <b>only</b> <c>is_searchable = 1</c> rows (an unverified, suspended,
|
||||
/// paused, or deactivated nurse/variant never surfaces), applies the category/city/district/gender/price
|
||||
/// filters and the rating sort, and paginates. Served from the covering search index; a later
|
||||
/// <c>ElasticNurseSearch</c> replaces this class behind the same interface with no caller changes.
|
||||
/// </summary>
|
||||
internal sealed class SqlNurseSearch(ApplicationDbContext db) : INurseSearch
|
||||
{
|
||||
public async Task<PagedResult<NurseSearchResultDto>> SearchAsync(NurseSearchCriteria criteria, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = db.Set<NurseSearchIndex>()
|
||||
.AsNoTracking()
|
||||
.Where(r => r.IsSearchable
|
||||
&& r.ServiceCategoryId == criteria.ServiceCategoryId
|
||||
&& r.CityId == criteria.CityId);
|
||||
|
||||
// NULL-district = "whole city". A district search matches that district's rows PLUS the whole-city
|
||||
// (NULL) rows; a city-only search (no district) matches every row in the city, NULL or not.
|
||||
if (criteria.DistrictId is { } districtId)
|
||||
query = query.Where(r => r.DistrictId == districtId || r.DistrictId == null);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(criteria.NurseGender))
|
||||
query = query.Where(r => r.NurseGender == criteria.NurseGender);
|
||||
|
||||
if (criteria.MinPrice is { } min)
|
||||
query = query.Where(r => r.Price >= min);
|
||||
|
||||
if (criteria.MaxPrice is { } max)
|
||||
query = query.Where(r => r.Price <= max);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(criteria.PriceUnit))
|
||||
query = query.Where(r => r.PriceUnit == criteria.PriceUnit);
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
var rows = await query
|
||||
// Rating sort is the only MVP sort; the tiebreak on reviews then nurse_id keeps paging deterministic.
|
||||
.OrderByDescending(r => r.AverageRating)
|
||||
.ThenByDescending(r => r.TotalReviews)
|
||||
.ThenBy(r => r.NurseId)
|
||||
.ThenBy(r => r.VariantId)
|
||||
.Skip((criteria.Page - 1) * criteria.PageSize)
|
||||
.Take(criteria.PageSize)
|
||||
.Select(r => new Row(
|
||||
r.VariantId, r.NurseId, r.ServiceCategoryId, r.Price, r.PriceUnit, r.NurseGender,
|
||||
r.AverageRating, r.TotalReviews, r.TotalCompletedBookings, r.CityId, r.DistrictId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Format price to a digit string in memory (no long.ToString translation required in SQL).
|
||||
var items = rows.Select(r => new NurseSearchResultDto(
|
||||
r.VariantId, r.NurseId, r.ServiceCategoryId,
|
||||
r.Price.ToString(CultureInfo.InvariantCulture), r.PriceUnit, r.NurseGender,
|
||||
r.AverageRating, r.TotalReviews, r.TotalCompletedBookings, r.CityId, r.DistrictId)).ToList();
|
||||
|
||||
return new PagedResult<NurseSearchResultDto>(items, total, criteria.Page, criteria.PageSize);
|
||||
}
|
||||
|
||||
private sealed record Row(
|
||||
long VariantId, long NurseId, long ServiceCategoryId, long Price, string PriceUnit, string NurseGender,
|
||||
decimal AverageRating, int TotalReviews, int TotalCompletedBookings, long CityId, long? DistrictId);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Net;
|
||||
|
||||
namespace Baya.Test.Api;
|
||||
|
||||
public class SearchApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
|
||||
{
|
||||
private const long TehranCityId = 101;
|
||||
|
||||
[Fact]
|
||||
public async Task Search_Public_ReturnsPagedEnvelope()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Public (no auth). Nothing seeded matches, so the search returns an empty, well-formed page.
|
||||
var response = await client.GetAsync($"/api/v1/search/nurses?service_category_id=1&city_id={TehranCityId}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var data = await AuthTestClient.ReadDataAsync(response);
|
||||
Assert.Equal(0, data.GetProperty("total").GetInt32());
|
||||
Assert.Equal(0, data.GetProperty("items").GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_MissingRequiredCategory_Returns400()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"/api/v1/search/nurses?service_category_id=0&city_id={TehranCityId}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_InvalidGender_Returns400()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync(
|
||||
$"/api/v1/search/nurses?service_category_id=1&city_id={TehranCityId}&nurse_gender=other");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RebuildIndex_Unauthenticated_Returns401()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync("/api/v1/admin_search/rebuild_index", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Features.Variants.Commands.CreateVariant;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
@@ -16,6 +17,7 @@ public class CreateVariantHandlerTests
|
||||
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
|
||||
private readonly ICatalogRepository _catalog = Substitute.For<ICatalogRepository>();
|
||||
private readonly INurseServiceVariantRepository _variants = Substitute.For<INurseServiceVariantRepository>();
|
||||
private readonly ISearchIndexMaintainer _searchIndex = Substitute.For<ISearchIndexMaintainer>();
|
||||
|
||||
private const long CategoryId = 1;
|
||||
private const long ShiftGroupId = 10;
|
||||
@@ -50,7 +52,7 @@ public class CreateVariantHandlerTests
|
||||
.Returns(false);
|
||||
}
|
||||
|
||||
private CreateVariantCommandHandler Handler() => new(_currentUser, _unitOfWork);
|
||||
private CreateVariantCommandHandler Handler() => new(_currentUser, _unitOfWork, _searchIndex);
|
||||
|
||||
private static CreateVariantCommand Command(IReadOnlyList<VariantOptionSelection> options, string? displayName = null)
|
||||
=> new(CategoryId, options, "8000000", "per_24h", null, displayName);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea;
|
||||
using Baya.Application.Features.ServiceAreas.Commands.RemoveNurseServiceArea;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
@@ -16,6 +17,7 @@ public class NurseServiceAreaHandlersTests
|
||||
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
|
||||
private readonly IGeoRepository _geo = Substitute.For<IGeoRepository>();
|
||||
private readonly INurseServiceAreaRepository _areas = Substitute.For<INurseServiceAreaRepository>();
|
||||
private readonly ISearchIndexMaintainer _searchIndex = Substitute.For<ISearchIndexMaintainer>();
|
||||
|
||||
public NurseServiceAreaHandlersTests()
|
||||
{
|
||||
@@ -34,7 +36,7 @@ public class NurseServiceAreaHandlersTests
|
||||
public async Task Add_WholeCity_PersistsWholeCityRow()
|
||||
{
|
||||
_areas.DuplicateExistsAsync(42L, 101L, null, Arg.Any<CancellationToken>()).Returns(false);
|
||||
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
|
||||
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex);
|
||||
|
||||
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, null), CancellationToken.None);
|
||||
|
||||
@@ -50,7 +52,7 @@ public class NurseServiceAreaHandlersTests
|
||||
public async Task Add_DuplicateWholeCity_ReturnsConflictNotPersisted()
|
||||
{
|
||||
_areas.DuplicateExistsAsync(42L, 101L, null, Arg.Any<CancellationToken>()).Returns(true);
|
||||
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
|
||||
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex);
|
||||
|
||||
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, null), CancellationToken.None);
|
||||
|
||||
@@ -66,7 +68,7 @@ public class NurseServiceAreaHandlersTests
|
||||
_geo.GetDistrictAsync(5L, Arg.Any<CancellationToken>())
|
||||
.Returns(new District { NameFa = "منطقه ۱", NameEn = "District 1" });
|
||||
_areas.DuplicateExistsAsync(42L, 101L, 5L, Arg.Any<CancellationToken>()).Returns(true);
|
||||
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
|
||||
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex);
|
||||
|
||||
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, 5L), CancellationToken.None);
|
||||
|
||||
@@ -77,7 +79,7 @@ public class NurseServiceAreaHandlersTests
|
||||
public async Task Add_DistrictNotInCity_Fails()
|
||||
{
|
||||
_geo.IsDistrictInActiveCityAsync(999L, 101L, Arg.Any<CancellationToken>()).Returns(false);
|
||||
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
|
||||
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex);
|
||||
|
||||
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, 999L), CancellationToken.None);
|
||||
|
||||
@@ -89,7 +91,7 @@ public class NurseServiceAreaHandlersTests
|
||||
public async Task Add_NonNurse_IsForbidden()
|
||||
{
|
||||
_currentUser.Roles.Returns([RoleNames.Customer]);
|
||||
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
|
||||
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex);
|
||||
|
||||
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, null), CancellationToken.None);
|
||||
|
||||
@@ -100,7 +102,7 @@ public class NurseServiceAreaHandlersTests
|
||||
public async Task Remove_OtherNursesArea_IsNotFound()
|
||||
{
|
||||
_areas.GetOwnedAsync(99L, 42L, Arg.Any<CancellationToken>()).ReturnsNull();
|
||||
var handler = new RemoveNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, Substitute.For<IDateTimeProvider>());
|
||||
var handler = new RemoveNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, Substitute.For<IDateTimeProvider>(), _searchIndex);
|
||||
|
||||
var result = await handler.Handle(new RemoveNurseServiceAreaCommand(99L), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings;
|
||||
using Baya.Application.Features.Identity.Commands.UpsertNurseProfile;
|
||||
using Baya.Application.Models.Identity;
|
||||
@@ -15,6 +16,7 @@ public class NurseProfileHandlersTests
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
private readonly INurseProfileRepository _repo = Substitute.For<INurseProfileRepository>();
|
||||
private readonly ISearchIndexMaintainer _searchIndex = Substitute.For<ISearchIndexMaintainer>();
|
||||
|
||||
public NurseProfileHandlersTests()
|
||||
{
|
||||
@@ -58,7 +60,7 @@ public class NurseProfileHandlersTests
|
||||
public async Task SetAcceptingBookings_NoProfile_IsNotFound()
|
||||
{
|
||||
_repo.GetByUserIdAsync(7, Arg.Any<CancellationToken>()).ReturnsNull();
|
||||
var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork);
|
||||
var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork, _searchIndex);
|
||||
|
||||
var result = await handler.Handle(new SetNurseAcceptingBookingsCommand(true), CancellationToken.None);
|
||||
|
||||
@@ -71,7 +73,7 @@ public class NurseProfileHandlersTests
|
||||
{
|
||||
var profile = new NurseProfile { UserId = 7 };
|
||||
_repo.GetByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(profile);
|
||||
var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork);
|
||||
var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork, _searchIndex);
|
||||
|
||||
var result = await handler.Handle(new SetNurseAcceptingBookingsCommand(true), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using Baya.Infrastructure.Persistence;
|
||||
using Baya.Infrastructure.Persistence.Services.Search;
|
||||
using Baya.Tests.Setup.Setups;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation.Search;
|
||||
|
||||
/// <summary>
|
||||
/// A self-contained SQLite host for the search-index maintainer + SqlNurseSearch, exercising the real EF
|
||||
/// model (schema, filtered indexes, query filters) end-to-end. Seeds a shared province/city/two districts
|
||||
/// and one active service category; <see cref="SeedNurse"/> builds a full nurse (user + profile +
|
||||
/// verification + variant + area) so a test can drive the maintainer and assert what search returns.
|
||||
/// </summary>
|
||||
public sealed class SearchIndexTestHost : IDisposable
|
||||
{
|
||||
public static readonly DateTimeOffset Now = new(2026, 7, 5, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly SqliteConnection _connection;
|
||||
public ApplicationDbContext Db { get; }
|
||||
public ISearchIndexMaintainer Maintainer { get; }
|
||||
public INurseSearch Search { get; }
|
||||
|
||||
public long CategoryId { get; }
|
||||
public long OtherCategoryId { get; }
|
||||
public long CityId { get; }
|
||||
public long District3Id { get; }
|
||||
public long District7Id { get; }
|
||||
|
||||
private int _phoneSeed = 90000000;
|
||||
|
||||
public SearchIndexTestHost()
|
||||
{
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
|
||||
Db.Database.EnsureCreated();
|
||||
|
||||
var clock = Substitute.For<IDateTimeProvider>();
|
||||
clock.UtcNow.Returns(Now);
|
||||
Maintainer = new SearchIndexMaintainer(Db, clock);
|
||||
Search = new SqlNurseSearch(Db);
|
||||
|
||||
var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
|
||||
Db.Set<Province>().Add(province);
|
||||
Db.SaveChanges();
|
||||
|
||||
var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
|
||||
Db.Set<City>().Add(city);
|
||||
Db.SaveChanges();
|
||||
CityId = city.Id;
|
||||
|
||||
var d3 = new District { CityId = city.Id, NameFa = "منطقه ۳", NameEn = "District 3", SortOrder = 3, IsActive = true };
|
||||
var d7 = new District { CityId = city.Id, NameFa = "منطقه ۷", NameEn = "District 7", SortOrder = 7, IsActive = true };
|
||||
Db.Set<District>().AddRange(d3, d7);
|
||||
Db.SaveChanges();
|
||||
District3Id = d3.Id;
|
||||
District7Id = d7.Id;
|
||||
|
||||
var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true };
|
||||
var other = new ServiceCategory { NameFa = "نوزاد", NameEn = "Infant", SortOrder = 2, IsActive = true };
|
||||
Db.Set<ServiceCategory>().AddRange(category, other);
|
||||
Db.SaveChanges();
|
||||
CategoryId = category.Id;
|
||||
OtherCategoryId = other.Id;
|
||||
}
|
||||
|
||||
public sealed record SeededNurse(long NurseId, NurseProfile Profile, NurseVerification Verification, NurseServiceVariant Variant, NurseServiceArea Area);
|
||||
|
||||
/// <summary>Seeds one nurse with a single active variant + a single service area, in a chosen bookability
|
||||
/// state, and does NOT project it yet (the caller drives the maintainer).</summary>
|
||||
public SeededNurse SeedNurse(
|
||||
string gender,
|
||||
bool verified,
|
||||
bool accepting,
|
||||
VerificationStatus status,
|
||||
long price,
|
||||
long? districtId,
|
||||
decimal averageRating = 0m,
|
||||
int totalReviews = 0,
|
||||
long? categoryId = null,
|
||||
bool variantActive = true,
|
||||
string priceUnit = "per_day")
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
UserName = $"nurse{_phoneSeed}",
|
||||
PhoneNumber = $"0912{_phoneSeed++}",
|
||||
Gender = gender,
|
||||
IsActive = true
|
||||
};
|
||||
Db.Users.Add(user);
|
||||
Db.SaveChanges();
|
||||
|
||||
var profile = new NurseProfile { UserId = user.Id };
|
||||
if (verified)
|
||||
profile.MarkVerified();
|
||||
profile.SetAcceptingBookings(accepting);
|
||||
Db.Set<NurseProfile>().Add(profile);
|
||||
SetAggregates(profile, averageRating, totalReviews);
|
||||
Db.SaveChanges();
|
||||
|
||||
var verification = new NurseVerification { NurseId = profile.Id, Status = status };
|
||||
Db.Set<NurseVerification>().Add(verification);
|
||||
Db.SaveChanges();
|
||||
|
||||
var variant = new NurseServiceVariant
|
||||
{
|
||||
NurseId = profile.Id,
|
||||
ServiceCategoryId = categoryId ?? CategoryId,
|
||||
Price = price,
|
||||
PriceUnit = priceUnit,
|
||||
SessionCount = null,
|
||||
DisplayName = "variant",
|
||||
OptionSetHash = $"hash-{profile.Id}",
|
||||
IsActive = variantActive
|
||||
};
|
||||
Db.Set<NurseServiceVariant>().Add(variant);
|
||||
Db.SaveChanges();
|
||||
|
||||
var area = new NurseServiceArea { NurseId = profile.Id, CityId = CityId, DistrictId = districtId, IsActive = true };
|
||||
Db.Set<NurseServiceArea>().Add(area);
|
||||
Db.SaveChanges();
|
||||
|
||||
return new SeededNurse(profile.Id, profile, verification, variant, area);
|
||||
}
|
||||
|
||||
// The aggregate setters on NurseProfile are private (recomputed by b9/b14). Tests seed them via EF's
|
||||
// backing fields so a nurse can carry a rating without the (not-yet-built) review pipeline.
|
||||
private void SetAggregates(NurseProfile profile, decimal averageRating, int totalReviews)
|
||||
{
|
||||
var entry = Db.Entry(profile);
|
||||
entry.Property(nameof(NurseProfile.AverageRating)).CurrentValue = averageRating;
|
||||
entry.Property(nameof(NurseProfile.TotalReviews)).CurrentValue = totalReviews;
|
||||
}
|
||||
|
||||
/// <summary>Adds a real service-area row (as the b4 handler would) so a later fan-out and a full rebuild
|
||||
/// derive from the same source.</summary>
|
||||
public NurseServiceArea AddArea(long nurseId, long? districtId)
|
||||
{
|
||||
var area = new NurseServiceArea { NurseId = nurseId, CityId = CityId, DistrictId = districtId, IsActive = true };
|
||||
Db.Set<NurseServiceArea>().Add(area);
|
||||
Db.SaveChanges();
|
||||
return area;
|
||||
}
|
||||
|
||||
public int LiveRowCount() => Db.Set<Domain.Entities.Search.NurseSearchIndex>().Count();
|
||||
|
||||
public int SearchableRowCount() =>
|
||||
Db.Set<Domain.Entities.Search.NurseSearchIndex>().Count(r => r.IsSearchable);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Db.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using Baya.Application.Models.Search;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
|
||||
namespace Baya.Test.Foundation.Search;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage of the search-index maintainer + SqlNurseSearch over a real EF/SQLite model: the
|
||||
/// is_searchable predicate, NULL-district geography, gender/price filters, rating sort, the verification
|
||||
/// flip, service-area fan-out/remove, variant deactivate, and incremental↔rebuild convergence.
|
||||
/// </summary>
|
||||
public sealed class SearchIndexTests
|
||||
{
|
||||
private const string Female = "female";
|
||||
private const string Male = "male";
|
||||
|
||||
private static NurseSearchCriteria Criteria(
|
||||
long categoryId, long cityId, long? districtId = null, string? gender = null,
|
||||
long? minPrice = null, long? maxPrice = null, string? priceUnit = null, int page = 1, int pageSize = 50)
|
||||
=> new(categoryId, cityId, districtId, gender, minPrice, maxPrice, priceUnit, page, pageSize);
|
||||
|
||||
private static void Project(SearchIndexTestHost host, SearchIndexTestHost.SeededNurse nurse)
|
||||
{
|
||||
host.Maintainer.ReindexNurseAsync(nurse.Profile, nurse.Verification.Status, default).GetAwaiter().GetResult();
|
||||
host.Db.SaveChanges();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSearchable_TrueOnlyWhenVerifiedAcceptingNotSuspendedAndVariantActive()
|
||||
{
|
||||
using var host = new SearchIndexTestHost();
|
||||
|
||||
var good = host.SeedNurse(Female, verified: true, accepting: true, VerificationStatus.Approved, 1000, host.District3Id);
|
||||
var unverified = host.SeedNurse(Female, verified: false, accepting: true, VerificationStatus.Pending, 1000, host.District3Id);
|
||||
var notAccepting = host.SeedNurse(Female, verified: true, accepting: false, VerificationStatus.Approved, 1000, host.District3Id);
|
||||
var suspended = host.SeedNurse(Female, verified: true, accepting: true, VerificationStatus.Suspended, 1000, host.District3Id);
|
||||
var inactiveVariant = host.SeedNurse(Female, verified: true, accepting: true, VerificationStatus.Approved, 1000, host.District3Id, variantActive: false);
|
||||
|
||||
foreach (var n in new[] { good, unverified, notAccepting, suspended, inactiveVariant })
|
||||
Project(host, n);
|
||||
|
||||
Assert.True(IsSearchable(host, good.NurseId));
|
||||
Assert.False(IsSearchable(host, unverified.NurseId));
|
||||
Assert.False(IsSearchable(host, notAccepting.NurseId));
|
||||
Assert.False(IsSearchable(host, suspended.NurseId));
|
||||
Assert.False(IsSearchable(host, inactiveVariant.NurseId));
|
||||
|
||||
// Every nurse with a variant + area has an index row, but only the fully-bookable one is searchable.
|
||||
Assert.Equal(5, host.LiveRowCount());
|
||||
Assert.Equal(1, host.SearchableRowCount());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Geography_WholeCityAndDistrictMatchingIsExact()
|
||||
{
|
||||
using var host = new SearchIndexTestHost();
|
||||
|
||||
var district3 = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id);
|
||||
var wholeCity = host.SeedNurse(Male, true, true, VerificationStatus.Approved, 1000, districtId: null);
|
||||
Project(host, district3);
|
||||
Project(host, wholeCity);
|
||||
|
||||
// District-3 search: the district-3 nurse AND the whole-city (NULL) nurse.
|
||||
var d3 = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District3Id), default).Result;
|
||||
Assert.Equal(new[] { district3.NurseId, wholeCity.NurseId }.OrderBy(x => x), d3.Items.Select(i => i.NurseId).OrderBy(x => x));
|
||||
|
||||
// A different district in the same city: only the whole-city nurse.
|
||||
var d7 = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District7Id), default).Result;
|
||||
Assert.Equal(new[] { wholeCity.NurseId }, d7.Items.Select(i => i.NurseId).ToArray());
|
||||
|
||||
// City-only search (no district): both.
|
||||
var city = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result;
|
||||
Assert.Equal(2, city.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameGenderFilterNarrowsResults()
|
||||
{
|
||||
using var host = new SearchIndexTestHost();
|
||||
var female = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id);
|
||||
var male = host.SeedNurse(Male, true, true, VerificationStatus.Approved, 1000, host.District3Id);
|
||||
Project(host, female);
|
||||
Project(host, male);
|
||||
|
||||
var females = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, gender: Female), default).Result;
|
||||
Assert.Equal(new[] { female.NurseId }, females.Items.Select(i => i.NurseId).ToArray());
|
||||
|
||||
var males = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, gender: Male), default).Result;
|
||||
Assert.Equal(new[] { male.NurseId }, males.Items.Select(i => i.NurseId).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PriceRangeFiltersOnCopiedIrrPrice()
|
||||
{
|
||||
using var host = new SearchIndexTestHost();
|
||||
var cheap = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 500_000, host.District3Id);
|
||||
var pricey = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 5_000_000, host.District3Id);
|
||||
Project(host, cheap);
|
||||
Project(host, pricey);
|
||||
|
||||
var midBand = host.Search.SearchAsync(
|
||||
Criteria(host.CategoryId, host.CityId, minPrice: 400_000, maxPrice: 1_000_000), default).Result;
|
||||
|
||||
Assert.Equal(new[] { cheap.NurseId }, midBand.Items.Select(i => i.NurseId).ToArray());
|
||||
Assert.Equal("500000", midBand.Items[0].Price);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResultsSortByRatingDescending()
|
||||
{
|
||||
using var host = new SearchIndexTestHost();
|
||||
var low = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id, averageRating: 3.1m, totalReviews: 4);
|
||||
var high = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id, averageRating: 4.8m, totalReviews: 9);
|
||||
Project(host, low);
|
||||
Project(host, high);
|
||||
|
||||
var page = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result;
|
||||
|
||||
Assert.Equal(new[] { high.NurseId, low.NurseId }, page.Items.Select(i => i.NurseId).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuspendingANurseRemovesThemFromSearch()
|
||||
{
|
||||
using var host = new SearchIndexTestHost();
|
||||
var nurse = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id);
|
||||
Project(host, nurse);
|
||||
Assert.Single(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result.Items);
|
||||
|
||||
// Flip to suspended + unverified (the b6 suspend path) and reindex in place.
|
||||
nurse.Profile.MarkUnverified();
|
||||
nurse.Verification.Status = VerificationStatus.Suspended;
|
||||
Project(host, nurse);
|
||||
|
||||
Assert.Empty(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result.Items);
|
||||
Assert.False(IsSearchable(host, nurse.NurseId));
|
||||
|
||||
// Reinstating makes them searchable again — the row is resurrected, not duplicated.
|
||||
nurse.Profile.MarkVerified();
|
||||
nurse.Verification.Status = VerificationStatus.Approved;
|
||||
Project(host, nurse);
|
||||
Assert.Single(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result.Items);
|
||||
Assert.Equal(1, host.LiveRowCount());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FanOutAddsAreaRows_RemoveDropsExactlyThoseRows()
|
||||
{
|
||||
using var host = new SearchIndexTestHost();
|
||||
var nurse = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id);
|
||||
Project(host, nurse);
|
||||
Assert.Equal(1, host.LiveRowCount());
|
||||
|
||||
// Add a second area (district 7) and fan out.
|
||||
host.Maintainer.FanOutServiceAreaAsync(nurse.NurseId, host.CityId, host.District7Id, default).GetAwaiter().GetResult();
|
||||
host.Db.SaveChanges();
|
||||
Assert.Equal(2, host.SearchableRowCount());
|
||||
Assert.Single(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District7Id), default).Result.Items);
|
||||
|
||||
// Remove the district-7 area: only its rows drop; district 3 stays.
|
||||
host.Maintainer.RemoveServiceAreaRowsAsync(nurse.NurseId, host.CityId, host.District7Id, default).GetAwaiter().GetResult();
|
||||
host.Db.SaveChanges();
|
||||
Assert.Empty(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District7Id), default).Result.Items);
|
||||
Assert.Single(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District3Id), default).Result.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeactivatingAVariantKeepsRowsButHidesThem()
|
||||
{
|
||||
using var host = new SearchIndexTestHost();
|
||||
var nurse = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id);
|
||||
Project(host, nurse);
|
||||
Assert.Equal(1, host.SearchableRowCount());
|
||||
|
||||
nurse.Variant.IsActive = false;
|
||||
host.Maintainer.ReindexVariantAsync(nurse.Variant, default).GetAwaiter().GetResult();
|
||||
host.Db.SaveChanges();
|
||||
|
||||
Assert.Equal(1, host.LiveRowCount()); // row kept
|
||||
Assert.Equal(0, host.SearchableRowCount()); // but not searchable
|
||||
Assert.Empty(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementalMaintenanceConvergesWithFullRebuild()
|
||||
{
|
||||
using var host = new SearchIndexTestHost();
|
||||
|
||||
var a = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id, averageRating: 4.5m);
|
||||
var b = host.SeedNurse(Male, true, true, VerificationStatus.Approved, 2000, districtId: null);
|
||||
var c = host.SeedNurse(Female, false, true, VerificationStatus.Pending, 3000, host.District7Id);
|
||||
Project(host, a);
|
||||
Project(host, b);
|
||||
Project(host, c);
|
||||
// Add a second area to B incrementally — the real service-area row plus the fan-out, as b4 does.
|
||||
host.AddArea(b.NurseId, host.District3Id);
|
||||
host.Maintainer.FanOutServiceAreaAsync(b.NurseId, host.CityId, host.District3Id, default).GetAwaiter().GetResult();
|
||||
host.Db.SaveChanges();
|
||||
|
||||
var incrementalLive = host.LiveRowCount();
|
||||
var incrementalSearchable = host.SearchableRowCount();
|
||||
|
||||
// A full rebuild from source must reproduce the same live/searchable row set (convergence).
|
||||
var result = host.Maintainer.RebuildAsync(default).GetAwaiter().GetResult();
|
||||
|
||||
Assert.Equal(incrementalLive, host.LiveRowCount());
|
||||
Assert.Equal(incrementalSearchable, host.SearchableRowCount());
|
||||
Assert.Equal(3, result.NursesProcessed);
|
||||
Assert.Equal(incrementalLive, result.RowsWritten);
|
||||
|
||||
// No duplicate (variant × area) rows after rebuild.
|
||||
var duplicates = host.Db.Set<Domain.Entities.Search.NurseSearchIndex>()
|
||||
.GroupBy(r => new { r.VariantId, r.CityId, r.DistrictId })
|
||||
.Any(g => g.Count() > 1);
|
||||
Assert.False(duplicates);
|
||||
}
|
||||
|
||||
private static bool IsSearchable(SearchIndexTestHost host, long nurseId)
|
||||
=> host.Db.Set<Domain.Entities.Search.NurseSearchIndex>().Any(r => r.NurseId == nurseId && r.IsSearchable);
|
||||
}
|
||||
+6
-4
@@ -1,6 +1,7 @@
|
||||
using Baya.Application.Contracts.Audit;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Contracts.SupportAlerts;
|
||||
using Baya.Application.Features.Verification.Commands.ReviewStep;
|
||||
using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
|
||||
@@ -25,6 +26,7 @@ public class AdminVerificationHandlersTests
|
||||
private readonly ICacheService _cache = Substitute.For<ICacheService>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
|
||||
private readonly ISearchIndexMaintainer _searchIndex = Substitute.For<ISearchIndexMaintainer>();
|
||||
|
||||
public AdminVerificationHandlersTests()
|
||||
{
|
||||
@@ -44,7 +46,7 @@ public class AdminVerificationHandlersTests
|
||||
}
|
||||
|
||||
private AdminReviewStepCommandHandler ReviewHandler(ICredentialVerifier credentialVerifier)
|
||||
=> new(_currentUser, _unitOfWork, credentialVerifier, _audit, _cache, _clock);
|
||||
=> new(_currentUser, _unitOfWork, credentialVerifier, _audit, _cache, _clock, _searchIndex);
|
||||
|
||||
[Fact]
|
||||
public async Task Review_ApproveCredentialStep_RecordsCredentialAndFlipsVerified()
|
||||
@@ -121,7 +123,7 @@ public class AdminVerificationHandlersTests
|
||||
var profile = new NurseProfile();
|
||||
profile.MarkVerified();
|
||||
_nurses.GetTrackedByIdAsync(42, Arg.Any<CancellationToken>()).Returns(profile);
|
||||
var handler = new AdminSuspendVerificationCommandHandler(_currentUser, _unitOfWork, _audit, _cache, _clock);
|
||||
var handler = new AdminSuspendVerificationCommandHandler(_currentUser, _unitOfWork, _audit, _cache, _clock, _searchIndex);
|
||||
|
||||
var result = await handler.Handle(new AdminSuspendVerificationCommand(10, "Fraud reported"), CancellationToken.None);
|
||||
|
||||
@@ -151,7 +153,7 @@ public class AdminVerificationHandlersTests
|
||||
|
||||
var alerts = Substitute.For<ISupportAlertService>();
|
||||
var notifications = Substitute.For<INotificationDispatcher>();
|
||||
var handler = new ScanExpiringCredentialsCommandHandler(_unitOfWork, alerts, notifications, _cache, _clock);
|
||||
var handler = new ScanExpiringCredentialsCommandHandler(_unitOfWork, alerts, notifications, _cache, _clock, _searchIndex);
|
||||
|
||||
var result = await handler.Handle(new ScanExpiringCredentialsCommand(), CancellationToken.None);
|
||||
|
||||
@@ -193,7 +195,7 @@ public class AdminVerificationHandlersTests
|
||||
_nurses.GetTrackedByIdAsync(99, Arg.Any<CancellationToken>()).Returns(profileB);
|
||||
|
||||
var handler = new ScanExpiringCredentialsCommandHandler(
|
||||
_unitOfWork, Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), _cache, _clock);
|
||||
_unitOfWork, Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), _cache, _clock, _searchIndex);
|
||||
|
||||
var result = await handler.Handle(new ScanExpiringCredentialsCommand(), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Contracts.SupportAlerts;
|
||||
using Baya.Application.Features.Verification.Commands.RunBankAccountVerification;
|
||||
using Baya.Application.Features.Verification.Commands.RunIdentityKyc;
|
||||
@@ -24,6 +25,7 @@ public class RunStepHandlersTests
|
||||
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
|
||||
private readonly INurseBankAccountRepository _accounts = Substitute.For<INurseBankAccountRepository>();
|
||||
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
|
||||
private readonly ISearchIndexMaintainer _searchIndex = Substitute.For<ISearchIndexMaintainer>();
|
||||
|
||||
public RunStepHandlersTests()
|
||||
{
|
||||
@@ -54,7 +56,7 @@ public class RunStepHandlersTests
|
||||
var identityKyc = Substitute.For<IIdentityKycProvider>();
|
||||
identityKyc.VerifyAsync("0012345678", null, Arg.Any<CancellationToken>())
|
||||
.Returns(new IdentityKycResult(true, "Verified Nurse", "ref", "{}", null));
|
||||
var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock);
|
||||
var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock, _searchIndex);
|
||||
|
||||
var result = await handler.Handle(new RunIdentityKycCommand("0012345678", null), CancellationToken.None);
|
||||
|
||||
@@ -75,7 +77,7 @@ public class RunStepHandlersTests
|
||||
var identityKyc = Substitute.For<IIdentityKycProvider>();
|
||||
identityKyc.VerifyAsync("0000000000", null, Arg.Any<CancellationToken>())
|
||||
.Returns(new IdentityKycResult(false, null, "ref", "{}", "could not verify"));
|
||||
var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock);
|
||||
var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock, _searchIndex);
|
||||
|
||||
var result = await handler.Handle(new RunIdentityKycCommand("0000000000", null), CancellationToken.None);
|
||||
|
||||
@@ -96,7 +98,7 @@ public class RunStepHandlersTests
|
||||
shahkar.MatchAsync("09120000000", "0012345678", Arg.Any<CancellationToken>())
|
||||
.Returns(new ShahkarMatchResult(false, true, "ref", "{}", "shared sim"));
|
||||
var alerts = Substitute.For<ISupportAlertService>();
|
||||
var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock);
|
||||
var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock, _searchIndex);
|
||||
|
||||
var result = await handler.Handle(new RunShahkarMatchCommand(), CancellationToken.None);
|
||||
|
||||
@@ -115,7 +117,7 @@ public class RunStepHandlersTests
|
||||
_verif.GetTrackedUserAsync(7, Arg.Any<CancellationToken>()).Returns(new User { PhoneNumber = "09121112233" });
|
||||
var shahkar = Substitute.For<IShahkarVerifier>();
|
||||
var alerts = Substitute.For<ISupportAlertService>();
|
||||
var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock);
|
||||
var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock, _searchIndex);
|
||||
|
||||
var result = await handler.Handle(new RunShahkarMatchCommand(), CancellationToken.None);
|
||||
|
||||
@@ -135,7 +137,7 @@ public class RunStepHandlersTests
|
||||
var verifier = Substitute.For<IBankAccountOwnershipVerifier>();
|
||||
verifier.VerifyOwnershipAsync(account.Iban, "0012345678", Arg.Any<CancellationToken>())
|
||||
.Returns(new OwnershipInquiryResult(false, "Someone Else", "ref"));
|
||||
var handler = new RunBankAccountVerificationCommandHandler(_currentUser, _unitOfWork, verifier, _clock);
|
||||
var handler = new RunBankAccountVerificationCommandHandler(_currentUser, _unitOfWork, verifier, _clock, _searchIndex);
|
||||
|
||||
var result = await handler.Handle(new RunBankAccountVerificationCommand(), CancellationToken.None);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user