# geography — provinces, cities, districts

> Client seam `client/src/services/geography/` · `USE_GEOGRAPHY_MOCK = false` (**real**) · 13 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).

The reference hierarchy every address, service area and search filter is keyed on. Also the home of the
single most load-bearing null in the schema.

## Endpoints

| Method | Path | Auth | Verdict |
| --- | --- | --- | --- |
| GET | `/api/v1/geo/provinces` | **anonymous** | wired |
| GET | `/api/v1/geo/cities` | **anonymous** | wired |
| GET | `/api/v1/geo/districts` | **anonymous** | wired |
| GET | `/api/v1/geo/tree` | **anonymous** | **unwired** — the client fetches the three levels separately and caches each |
| POST | `/api/v1/admin_geo/create_province` | admin | **unwired** — no console screen |
| POST | `/api/v1/admin_geo/update_province/{id}` | admin | **unwired** |
| POST | `/api/v1/admin_geo/set_province_active/{id}` | admin | **unwired** |
| POST | `/api/v1/admin_geo/create_city` | admin | **unwired** |
| POST | `/api/v1/admin_geo/update_city/{id}` | admin | **unwired** |
| POST | `/api/v1/admin_geo/set_city_active/{id}` | admin | **unwired** |
| POST | `/api/v1/admin_geo/create_district` | admin | **unwired** |
| POST | `/api/v1/admin_geo/update_district/{id}` | admin | **unwired** |
| POST | `/api/v1/admin_geo/set_district_active/{id}` | admin | **unwired** |

No phantoms. The nine `admin_geo` routes are `DynamicPermission` + `sensitive`; geography is seeded, so
there is no console editor. Mutations are **action-style** (`admin_geo/create_city`, never
`admin_geo/cities`).

## `districtId = null` means whole-city

This is the one rule to get right, and it reads in **both** directions:

- **On a nurse service area** (see [service-areas.md](service-areas.md)), `districtId = null` means the
  nurse covers the **entire city**, not "no district".
- **On a search query**, a customer in district *D* must match both a nurse whose area names *D* and a
  nurse whose area is whole-city. See [search.md](search.md).
- **On an address** it is genuinely optional metadata — a missing district does not widen anything.

Never coerce the null to 0 or to a sentinel id, and never write a query that drops whole-city rows.

## Shape rules the JSON does not express

- Every level returns **both** `nameFa` and `nameEn`; the client picks by locale.
- `isActive` filters the pickers. An inactive city must stay **resolvable** — an existing address or
  booking references it — so it is filtered from selection, never deleted.
- **Two Neshan keys exist and they are different products.** The server's geocoder key is
  `Seams:Geocoding:ApiKey` (server-side address→point); the client's map key is
  `NEXT_PUBLIC_NESHAN_KEY` (a *web* key for the embeddable map/search). Never share one value between
  them. With the client key unset, `AddressMapPicker` falls back to a bounded-canvas grid, which is why
  dev, CI and jsdom all work without it. See [../config-matrix.md](../config-matrix.md).
- Geocoding is a seam: `Seams:Geocoding:Provider` = `mock` (default) or `neshan`. The mock resolves a
  deterministic point near the city centroid; an address containing `NO_GEO` resolves to null coordinates
  so the "saved without a map pin" state is testable per-request.

## Enums

None. All three levels are integer ids with localised names.

## Open REQs

None. REQ-008 (accept the client-picked pin) and REQ-009 (`provinceId` on the address DTO) were delivered
and are documented in [addresses.md](addresses.md).
