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:
hamid
2026-07-05 17:24:26 +03:30
parent e2b22df2d6
commit 5839b3508f
47 changed files with 5743 additions and 41 deletions
+347 -1
View File
@@ -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": [
{