Geography API Reference
Complete API contracts for Nepal's administrative geography lookup — provinces, districts, municipalities, routes, auth, DTOs, and errors.
Geography - API Reference
Audience: Frontend engineers, mobile engineers, backend engineers, QA, and API consumers.
Scope: Admin-facing API owned by GeographyController, inside LookupsModule. No public or mobile-facing route exists.
1. Documentation Evidence
| Area | Files Inspected | What Was Verified |
|---|---|---|
| Controller | apps/api/src/modules/lookups/geography/geography.controller.ts | Routes, methods, guards, permissions, decorators. |
| DTOs | apps/api/src/modules/lookups/dto/geography.dto.ts | Request, query, response, validation, defaults. |
| Services | geography-provinces.service.ts, geography-districts.service.ts, geography-municipalities.service.ts | Behavior, error translation. |
| Module | apps/api/src/modules/lookups/lookups.module.ts | Wiring and dependency on RoleModule. |
| Schema | packages/db/src/schema/school/geography.ts | Columns, indexes, CHECK constraints. |
| Errors | apps/api/src/common/types/error-codes.ts (around lines 198-206) | Every error code this module can produce. |
| Permissions | packages/db/src/authorization/permission-catalog.ts (around line 100) | Geography declared as a permission module. |
2. Module Summary
| Field | Value |
|---|---|
| Module name | GeographyController (inside LookupsModule) |
| Module slug | geography |
| Primary actors | Admin (only role verified to hold any Geography_* permission in this codebase) |
| API surface | Admin only — no @Public() route on the controller |
| Base route prefix | /api/lookups/provinces, /api/lookups/districts, /api/lookups/municipalities (global prefix api set in apps/api/src/main.ts; controller declares lookups locally) |
| Auth model | JwtAuthGuard + RoleGuard, applied at the controller class level |
| Persistence | PostgreSQL, tables provinces, districts, municipalities; no cache layer |
| Sibling docs | Backend, Features and flows |
3. Concepts and Terminology
| Term | Meaning | Source File | Used By |
|---|---|---|---|
| Province | Nepal's top administrative tier — 7, nationally fixed. | geography.ts | Every district, and a person/school address. |
| District | The middle tier — 77, nationally fixed, each belonging to exactly one province. | geography.ts | Every municipality, and a person/school address. |
| Municipality (local level) | The lowest tier this module tracks — up to 753 nationally, of which 36 are seeded. Typed as metropolitan, sub_metropolitan, municipality, rural_municipality, or the historical vdc. | geography.ts | A person/school address, the leaf of the hierarchy. |
Retirement (isActive) | Removes a row from the default (active-only) pick list without touching any existing reference to it. There is no deleted_at on any of the three tables. | geography.ts | isActive/includeInactive on every route. |
| Rename/reparent refusal | Refuses to change a row's name or parent when it is already referenced — see Section 5. | All three services | PATCH on every tier. |
Public ID (publicId) | The UUID every PATCH/DELETE route addresses a row by. The internal integer id is still returned in the response body and is what a child row's provinceId/districtId field carries. | geography.ts | Every route param below. |
4. API Surface Map
| Surface | Method | Path | Actor | Permission | Purpose |
|---|---|---|---|---|---|
| Admin | GET | /api/lookups/provinces | Admin | Geography_READ | List provinces (unpaginated, 7 rows). |
| Admin | POST | /api/lookups/provinces | Admin | Geography_CREATE | Add a province. |
| Admin | PATCH | /api/lookups/provinces/:publicId | Admin | Geography_UPDATE | Rename, recode, or retire a province. |
| Admin | DELETE | /api/lookups/provinces/:publicId | Admin | Geography_DELETE | Delete an unused province. |
| Admin | GET | /api/lookups/districts | Admin | Geography_READ | List districts (unpaginated, 77 rows), optionally filtered by provinceId. |
| Admin | POST | /api/lookups/districts | Admin | Geography_CREATE | Add a district. |
| Admin | PATCH | /api/lookups/districts/:publicId | Admin | Geography_UPDATE | Rename, reparent, or retire a district. |
| Admin | DELETE | /api/lookups/districts/:publicId | Admin | Geography_DELETE | Delete an unused district. |
| Admin | GET | /api/lookups/municipalities | Admin | Geography_READ | List municipalities (paginated, can reach 753 nationally), optionally filtered by districtId. |
| Admin | POST | /api/lookups/municipalities | Admin | Geography_CREATE | Add a municipality. |
| Admin | PATCH | /api/lookups/municipalities/:publicId | Admin | Geography_UPDATE | Rename, reparent, or retire a municipality. |
| Admin | DELETE | /api/lookups/municipalities/:publicId | Admin | Geography_DELETE | Delete an unused municipality. |
All twelve routes are verified against geography.controller.ts, which declares exactly these and no others.
5. The Rename/Reparent and Delete Refusal Rules
5.1 Rename or reparent a referenced row
| Rule | Enforced By | Why |
|---|---|---|
| A province, district or municipality already referenced by a child row, the school profile, or a person's address cannot be renamed. | isReferenced pre-check in each service's update | The export writes place names, not surrogate ids. A rename would silently rewrite the content of every export already downloaded and repoint every future name-resolved import. |
A district or municipality already referenced cannot be reparented (provinceId/districtId changed). | Same pre-check, also gating isReparenting | Reparenting breaks the composite foreign key from every referencing address in the same way a rename does. |
Retirement (isActive: false/true) is never refused for this reason. | Not gated by isReferenced at all | Retirement does not change what the name says — it only removes the row from active pick lists. |
5.2 Delete a referenced row
| Rule | Enforced By | Why It Is a Pre-Check, Not a Caught Constraint |
|---|---|---|
| A row referenced by a child row, the school profile, or a person's address cannot be hard-deleted. | isReferenced pre-check in each service's delete, over an enumerated column list | This repo's established pattern for foreign-key-guarded deletes, and because an explicit ON DELETE RESTRICT raises SQLSTATE 23001, not 23503 — a caught-code translator written for 23503 would never fire, and the delete would surface as an unmapped 500. |
| A row referenced only by a soft-deleted person is still undeletable. | isReferenced deliberately does not filter users.deleted_at | ON DELETE RESTRICT counts every row regardless of the person's own soft-delete state; a liveness-filtered check would promise a delete the database then refuses. |
6. DTO and Model Reference
6.1 ProvinceDto (response)
| Field | Type | Required | Source |
|---|---|---|---|
id | number | Yes | Internal serial id. |
publicId | string (UUID) | Yes | |
name | string | Yes | e.g. "Bagmati". |
nameNp | string | null | Optional | The Devanagari label. |
code | string | null | Optional | "P1".."P7", optional. |
isActive | boolean | Yes | |
createdAt / updatedAt | Date | Yes |
6.2 DistrictDto (response)
Same fields as ProvinceDto plus provinceId (number) and provinceName (string) — the parent is always denormalised alongside the id so a list can render without a second round trip.
6.3 MunicipalityDto (response)
Same base fields plus districtId (number), districtName (string), type (enum, see below), and wardCount (number \| null — narrows the ward picker where known; never constrains the ward number actually stored on an address).
MUNICIPALITY_TYPES: metropolitan, sub_metropolitan, municipality, rural_municipality, vdc. vdc is offered even though it is seeded zero times — a school may still hold a historical address recorded that way.
6.4 List Query DTOs
None of the three extends the shared QueryDto. Provinces and districts are fixed, unpaginated lists — there is no pagination flag to toggle, so QueryDto's trio would advertise a parameter with no effect. Municipalities paginate, but the sort is fixed (name ASC, id ASC everywhere in this module) rather than caller-chosen, so sort/order/search are left off rather than inherited and ignored.
| DTO | Fields |
|---|---|
ListProvincesQueryDto | includeInactive?: boolean |
ListDistrictsQueryDto | provinceId?: number, includeInactive?: boolean |
ListMunicipalitiesQueryDto | districtId?: number, includeInactive?: boolean, pagination?: boolean (default true), page?: number (default 1), size?: number (default 20, max 100) |
6.5 Write DTOs
| DTO | Required Fields | Optional Fields | Notes |
|---|---|---|---|
CreateProvinceDto | name (≤120 chars, trimmed) | nameNp (≤120), code (≤20), isActive (default true) | |
UpdateProvinceDto | — | name, nameNp, code, isActive | Refused with GEOGRAPHY_RENAME_REFERENCED when renaming a referenced province. |
CreateDistrictDto | provinceId (int), name (≤120) | nameNp, isActive | |
UpdateDistrictDto | — | provinceId, name, nameNp, isActive | Reparenting or renaming a referenced district is refused the same way. |
CreateMunicipalityDto | districtId (int), name (≤120), type (enum) | nameNp, wardCount (1-35), isActive | wardCount's upper bound is deliberately generous — Kathmandu Metropolitan City has 32 wards — because it exists to reject a typo of 320, not to encode a national maximum a boundary redraw would invalidate. |
UpdateMunicipalityDto | — | districtId, name, nameNp, type, wardCount, isActive | Same reparent/rename refusal. |
7. Endpoint Reference
7.1 GET /api/lookups/provinces
Purpose: List all provinces. Unpaginated — 7 rows, nationally fixed.
Auth: Geography_READ.
Query: includeInactive (default: active only).
Response:
{
"message": "Provinces fetched.",
"data": [
{
"id": 3,
"publicId": "018f2a1e-6b1e-7c3a-9d2e-1a2b3c4d5e6f",
"name": "Bagmati",
"nameNp": "बागमती",
"code": "P3",
"isActive": true,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}
],
"errorCode": null
}Errors: 401 AUTH_UNAUTHENTICATED, 403 PERMISSION_INSUFFICIENT.
7.2 POST /api/lookups/provinces
Purpose: Add a province.
Auth: Geography_CREATE.
Body: CreateProvinceDto.
Errors:
| HTTP Status | Error Code | Condition |
|---|---|---|
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. |
403 | PERMISSION_INSUFFICIENT | Active role lacks Geography_CREATE. |
400 | VALIDATION_FAILED | Invalid body. |
409 | GEOGRAPHY_NAME_TAKEN | A province with that name already exists (case-insensitive). |
7.3 PATCH /api/lookups/provinces/:publicId
Purpose: Rename, recode, or retire a province.
Auth: Geography_UPDATE.
Body: UpdateProvinceDto.
Behavior: Renaming is refused with GEOGRAPHY_RENAME_REFERENCED when a district, the school profile, or a person's address already points at this province. Retirement (isActive: false) stays available regardless.
Errors:
| HTTP Status | Error Code | Condition |
|---|---|---|
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. |
403 | PERMISSION_INSUFFICIENT | Active role lacks Geography_UPDATE. |
404 | PROVINCE_NOT_FOUND | No province with that publicId. |
400 | VALIDATION_FAILED | Invalid body. |
409 | GEOGRAPHY_NAME_TAKEN | Renamed to a name already in use. |
409 | GEOGRAPHY_RENAME_REFERENCED | Renaming a province a district, the school profile, or a person's address already points at. |
7.4 DELETE /api/lookups/provinces/:publicId
Purpose: Delete an unused province.
Auth: Geography_DELETE.
Behavior: Permitted only when no district, school profile, or person's address references it. Retire with isActive=false instead.
Errors:
| HTTP Status | Error Code | Condition |
|---|---|---|
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. |
403 | PERMISSION_INSUFFICIENT | Active role lacks Geography_DELETE. |
404 | PROVINCE_NOT_FOUND | No province with that publicId. |
409 | GEOGRAPHY_IN_USE | A district, the school profile, or a person's address still points at it. |
7.5 GET /api/lookups/districts
Purpose: List districts. Unpaginated — 77 rows, nationally fixed.
Auth: Geography_READ.
Query: provinceId (restrict to one province), includeInactive.
Response: Same shape as provinces, plus provinceId and provinceName on each row.
Errors: 401 AUTH_UNAUTHENTICATED, 403 PERMISSION_INSUFFICIENT.
7.6 POST /api/lookups/districts
Purpose: Add a district.
Auth: Geography_CREATE.
Body: CreateDistrictDto.
Errors:
| HTTP Status | Error Code | Condition |
|---|---|---|
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. |
403 | PERMISSION_INSUFFICIENT | Active role lacks Geography_CREATE. |
400 | VALIDATION_FAILED | Invalid body. |
404 | PROVINCE_NOT_FOUND | provinceId does not exist. |
409 | GEOGRAPHY_NAME_TAKEN | A district with that name already exists in that province. |
7.7 PATCH /api/lookups/districts/:publicId
Purpose: Rename, reparent, or retire a district.
Auth: Geography_UPDATE.
Body: UpdateDistrictDto.
Behavior: Renaming or moving the district to a different province is refused with GEOGRAPHY_RENAME_REFERENCED when a municipality, the school profile, or a person's address already points at it. Retirement stays available regardless.
Errors:
| HTTP Status | Error Code | Condition |
|---|---|---|
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. |
403 | PERMISSION_INSUFFICIENT | Active role lacks Geography_UPDATE. |
404 | DISTRICT_NOT_FOUND | No district with that publicId. |
404 | PROVINCE_NOT_FOUND | Reparenting to a provinceId that does not exist. |
400 | VALIDATION_FAILED | Invalid body. |
409 | GEOGRAPHY_NAME_TAKEN | Renamed to a name already in use in the target province. |
409 | GEOGRAPHY_RENAME_REFERENCED | Renaming or reparenting a district a municipality, the school profile, or a person's address already points at. |
7.8 DELETE /api/lookups/districts/:publicId
Purpose: Delete an unused district.
Auth: Geography_DELETE.
Behavior: Permitted only when no municipality, school profile, or person's address references it.
Errors:
| HTTP Status | Error Code | Condition |
|---|---|---|
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. |
403 | PERMISSION_INSUFFICIENT | Active role lacks Geography_DELETE. |
404 | DISTRICT_NOT_FOUND | No district with that publicId. |
409 | GEOGRAPHY_IN_USE | A municipality, the school profile, or a person's address still points at it. |
7.9 GET /api/lookups/municipalities
Purpose: List municipalities. Paginated — the national count can reach 753.
Auth: Geography_READ.
Query: districtId (restrict to one district — the common case, keeping most callers well under a page), includeInactive, pagination (default true), page (default 1), size (default 20, max 100).
Response: Same shape as districts, with districtId/districtName in place of provinceId/provinceName, plus type and wardCount. When pagination=false, the response is capped at PaginationUtil.UNPAGINATED_HARD_CAP (1000).
Errors: 401 AUTH_UNAUTHENTICATED, 403 PERMISSION_INSUFFICIENT, 400 VALIDATION_FAILED.
7.10 POST /api/lookups/municipalities
Purpose: Add a municipality — the supported path for the 717 local levels the seed does not carry.
Auth: Geography_CREATE.
Body: CreateMunicipalityDto.
Errors:
| HTTP Status | Error Code | Condition |
|---|---|---|
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. |
403 | PERMISSION_INSUFFICIENT | Active role lacks Geography_CREATE. |
400 | VALIDATION_FAILED | Invalid body, including wardCount outside 1-35. |
404 | DISTRICT_NOT_FOUND | districtId does not exist. |
409 | GEOGRAPHY_NAME_TAKEN | A municipality with that name already exists in that district. |
7.11 PATCH /api/lookups/municipalities/:publicId
Purpose: Rename, reparent, or retire a municipality.
Auth: Geography_UPDATE.
Body: UpdateMunicipalityDto.
Behavior: Renaming or moving the municipality to a different district is refused with GEOGRAPHY_RENAME_REFERENCED when the school profile or a person's address already points at it.
Errors:
| HTTP Status | Error Code | Condition |
|---|---|---|
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. |
403 | PERMISSION_INSUFFICIENT | Active role lacks Geography_UPDATE. |
404 | MUNICIPALITY_NOT_FOUND | No municipality with that publicId. |
404 | DISTRICT_NOT_FOUND | Reparenting to a districtId that does not exist. |
400 | VALIDATION_FAILED | Invalid body. |
409 | GEOGRAPHY_NAME_TAKEN | Renamed to a name already in use in the target district. |
409 | GEOGRAPHY_RENAME_REFERENCED | Renaming or reparenting a municipality the school profile or a person's address already points at. |
7.12 DELETE /api/lookups/municipalities/:publicId
Purpose: Delete an unused municipality.
Auth: Geography_DELETE.
Behavior: Permitted only when no school profile or person's address references it. There is no fourth child table — municipalities are the leaf of the hierarchy.
Errors:
| HTTP Status | Error Code | Condition |
|---|---|---|
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. |
403 | PERMISSION_INSUFFICIENT | Active role lacks Geography_DELETE. |
404 | MUNICIPALITY_NOT_FOUND | No municipality with that publicId. |
409 | GEOGRAPHY_IN_USE | The school profile or a person's address still points at it. |
8. Error Code Reference
| Error Code | HTTP Status | Meaning |
|---|---|---|
PROVINCE_NOT_FOUND | 404 | No province with the given publicId. |
DISTRICT_NOT_FOUND | 404 | No district with the given publicId, or an unknown provinceId/districtId was supplied as a parent. |
MUNICIPALITY_NOT_FOUND | 404 | No municipality with the given publicId. |
GEOGRAPHY_NAME_TAKEN | 409 | The case-insensitive unique index on name (scoped to the parent for districts/municipalities) refused the write. |
GEOGRAPHY_RENAME_REFERENCED | 409 | A rename or reparent was refused because a child row, the school profile, or a person's address already points at this row. Retire it instead. |
GEOGRAPHY_IN_USE | 409 | A hard delete was refused because something still references this row. |
Source: apps/api/src/common/types/error-codes.ts, around lines 198-206.
See Also
- Backend doc:
/docs/developer/geography/backend - Feature and flows doc:
/docs/developer/geography/feature