Skoolsewa - Ecommerce Docs
Developer ResourcesGeography

Geography Features and Flows

Feature list, actor journeys, business rules, and edge cases for Nepal's administrative geography lookup.

Geography - Features and Flows

1. Documentation Evidence

Source TypeFiles or DocsWhat Was Extracted
APISibling API doc and geography.controller.tsRoute surface and actors.
BackendSibling backend doc and the three Geography*Service classesBusiness behavior and side effects.
Schemapackages/db/src/schema/school/geography.tsConstraint-level enforcement, the composite-FK design.
Seedpackages/db/src/seed/seed-geography.tsWhat is and is not seeded.
Consumersapps/api/src/modules/people/dto/address.dto.ts, apps/api/src/modules/school-profile/Who reads this hierarchy and why.

2. Feature Summary

FieldValue
Modulegeography (part of LookupsModule)
SubmoduleProvinces, districts, municipalities — three tiers of one hierarchy.
Primary user valueGives every person address and the school's own address a normalised place to point at, so a pupil's district can be aggregated nationally instead of read back as free text.
ActorsAdmin only — no other role is verified to hold any Geography_* permission.
Main entry pointsGET/POST/PATCH/DELETE /api/lookups/provinces, /api/lookups/districts, /api/lookups/municipalities.
Main outputsPersisted provinces, districts and municipalities rows that a person's address and the school profile foreign-key into.
Related docsAPI, Backend

3. Actor Matrix

ActorCan DoCannot DoAuth RequirementNotes
AdminList, create, update (including rename, reparent, retire), hard delete at all three tiers.Rename or reparent a row a district, a municipality, the school profile, or a person's address already points at.JWT + Geography_* permissionThe only actor verified to hold any permission in this module.
Staff / TeacherNothing on this module.Everything.N/AUnlike PersonClassifications_READ, this module is not granted broadly — see Backend §2 for why.
Guardian / StudentNothing.Everything, including reading the raw lists.N/AThe names a family needs come back denormalised on their own address instead — see the people docs.

4. Capability Matrix

CapabilitySurfaceActorRoute/TriggerState ReadState WrittenLinked API Section
List provincesAdminAdminGET /api/lookups/provincesprovinces7.1
Add a provinceAdminAdminPOST /api/lookups/provincesprovinces (name check)provinces7.2
Rename / recode / retire a provinceAdminAdminPATCH /api/lookups/provinces/:publicIdprovinces, districts, school_profile, users (referenced check)provinces7.3
Delete a provinceAdminAdminDELETE /api/lookups/provinces/:publicIdSame referenced checkprovinces (delete)7.4
List districtsAdminAdminGET /api/lookups/districtsdistricts, provinces (joined for the name)7.5
Add a districtAdminAdminPOST /api/lookups/districtsprovinces (parent check), districts (name check)districts7.6
Rename / reparent / retire a districtAdminAdminPATCH /api/lookups/districts/:publicIddistricts, municipalities, school_profile, users (referenced check)districts7.7
Delete a districtAdminAdminDELETE /api/lookups/districts/:publicIdSame referenced checkdistricts (delete)7.8
List municipalitiesAdminAdminGET /api/lookups/municipalitiesmunicipalities, districts (joined for the name)7.9
Add a municipalityAdminAdminPOST /api/lookups/municipalitiesdistricts (parent check), municipalities (name check)municipalities7.10
Rename / reparent / retire a municipalityAdminAdminPATCH /api/lookups/municipalities/:publicIdmunicipalities, school_profile, users (referenced check)municipalities7.11
Delete a municipalityAdminAdminDELETE /api/lookups/municipalities/:publicIdSame referenced checkmunicipalities (delete)7.12

5. User-Facing Flows

5.1 Adding a municipality a school needs

Summary

The seed carries all 7 provinces and all 77 districts, but only 36 of Nepal's 753 municipalities — see Backend §5.4. A school outside those 36 adds its own local level from the Classifications screen the first time it needs to record an address there.

Preconditions

  • The admin holds Geography_CREATE.
  • The parent district already exists (it does, for all 77).
  • No municipality of that name already exists under that district.

Main Flow

StepActor/SystemActionResultSource
1AdminPOST /api/lookups/municipalities with districtId, name, type.District existence and name-uniqueness validated.GeographyMunicipalitiesService.create.
2BackendInserts the row.The pick list at GET /api/lookups/municipalities?districtId= now includes it.Service.
3BackendReturns the row with districtName denormalised.The admin's form can render it without a second lookup.Service.

Branches and Edge Cases

BranchConditionBehaviorError/Result
Unknown districtdistrictId does not existRefused before any write404 DISTRICT_NOT_FOUND
Duplicate name in the same districtCase-insensitive match already exists under that districtIdRefused409 GEOGRAPHY_NAME_TAKEN
Same name, different districte.g. two districts both have a "Ward 1"-named local levelPermitted — uniqueness is scoped per parent

5.2 Refusing a rename that would rewrite history

Summary

Renaming or reparenting a province, district or municipality that a person's address, the school profile, or a child row already points at is refused outright, never merely warned about. Retirement (isActive: false) stays available regardless, because it does not touch what the name says.

Preconditions

  • The admin holds Geography_UPDATE.
  • The target row is referenced by at least one live-relevant record.

Main Flow

StepActor/SystemActionResultSource
1AdminPATCH /api/lookups/districts/:publicId with a new name or provinceId.Backend checks whether the district is referenced before writing anything.GeographyDistrictsService.update.
2BackendFinds a referencing row (a municipality, the school profile, or a person's address).Write refused.Service.
3BackendReturns the conflict, naming the reason.Admin retires the row and creates the replacement under the corrected name instead.Service.

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Referenced row, rename attemptedA person's address, the school profile, or a child row points at itRefused409 GEOGRAPHY_RENAME_REFERENCED
Referenced row, retirement attemptedSame referencing state, isActive: false onlyPermitted200, row retired
Unreferenced row, rename attemptedNothing points at itPermitted200, row updated
Reparenting a districtprovinceId changed on a referenced districtRefused for the same reason a rename is — the composite foreign key from every child address would break409 GEOGRAPHY_RENAME_REFERENCED

5.3 Deleting an unused row

Summary

Hard delete is permitted only when nothing references the row. This is an application pre-check over an enumerated set of referencing columns, not a caught database error.

Preconditions

  • The admin holds Geography_DELETE.
  • Nothing references the row.

Main Flow

StepActor/SystemActionResultSource
1AdminDELETE /api/lookups/provinces/:publicId.isReferenced checks the enumerated column list.GeographyProvincesService.delete.
2BackendNo reference found.Row is hard-deleted.Service.

Branches and Edge Cases

BranchConditionBehaviorError/Result
Referenced rowA district, the school profile, or a person's address points at itRefused before any DELETE statement runs409 GEOGRAPHY_IN_USE
Unreferenced, but a soft-deleted person still points at itusers.deleted_at is set on the only referencing personStill refused — ON DELETE RESTRICT counts the row regardless of the person's own soft-delete state409 GEOGRAPHY_IN_USE
Truly unreferencedNo row anywhere points at itHard-deleted

6. Lifecycle and State Transitions

EntityFromEvent/ActionToGuard ConditionSide Effects
Province / District / MunicipalityisActive=truePATCH {isActive:false}RetiredNone — always available, even when referencedRow leaves every active pick list; every existing reference keeps resolving.
Province / District / MunicipalityisActive=falsePATCH {isActive:true}ReactivatedNoneRow reappears in default (active-only) lists.
Province / District / MunicipalityAnyRename or reparentUpdated, or refusedRow must not be referenced by a child row, the school profile, or a person's address409 GEOGRAPHY_RENAME_REFERENCED if it is.
Province / District / MunicipalityAnyDELETERow removed, or refusedRow must not be referenced anywhere in the enumerated check409 GEOGRAPHY_IN_USE if it is.

7. Business Rules and Policy Traceability

RuleBusiness ReasonActor ImpactEnforced InAPI ImpactTests
Lists default to active onlyRetirement must actually remove a row from a form's pick list, or retiring it means nothing to the person filling the form.An admin managing retired rows must explicitly ask for them.findAll in all three services, includeInactive query flagRows with isActive: false are omitted unless includeInactive=trueSchema docblock; service findAll
Renaming or reparenting a referenced row is refusedThe export writes place NAMES rather than surrogate ids, so a rename retroactively rewrites the content of every export already downloaded and repoints every future name-resolved import.An admin correcting a name must retire and re-add rather than edit in place.isReferenced pre-check in all three update methods409 GEOGRAPHY_RENAME_REFERENCEDService tests
Delete is an application pre-check, not a caught constraintON DELETE RESTRICT raises 23001, not the 23503 a naive translator would expect — a caught-code mapping would have matched the wrong SQLSTATE entirely.An admin deleting a referenced row gets a named, actionable error instead of an unmapped 500.isReferenced pre-check in all three delete methods, enumerated column list409 GEOGRAPHY_IN_USEProvinces service docblock, probed this session
A district/municipality needs its parent to write a fill-order-consistent addressA composite foreign key is MATCH SIMPLE — it does nothing when any column is NULL — so the CHECK constraints on the address columns are what make a half-filled address representable and a wrong one impossible.An address form cannot select a district without its province being set first.CHECK constraints on users and school_profile, not on this module's own tablesADDRESS_HIERARCHY_INVALID from the writing module (people, school-profile)geography.ts schema docblock
Composite FK targets are unique(...) not uniqueIndex(...)drizzle-kit emits every foreign key before every index, so an index-form target does not exist yet when the referencing key is added, and the migration dies with 42830 on a clean database.None directly — an implementation detail that keeps migrations applying cleanly.districts_province_id_id_unique, municipalities_district_id_id_uniquegeography.ts schema docblock, recorded against designations_department_id_id_unique
No deleted_at on any of the three tablesA soft delete never fires ON DELETE RESTRICT, so a soft-deleted district would keep resolving for every address still pointing at it while vanishing from the admin's list.Retirement (isActive=false) is the only "remove from view" action; delete is destructive and checked.Schema — column absent

8. Flow Edge-Case Matrix

FlowEdge CaseTriggerExpected BehaviorSource
Create districtUnknown parent provinceprovinceId does not existRejected404 PROVINCE_NOT_FOUND
Create districtDuplicate name in the same provinceCase-insensitive matchRejected409 GEOGRAPHY_NAME_TAKEN
Create municipalitywardCount far above any real ward counte.g. 320Rejected — the bound exists to catch a typo, not to encode a national maximum400 VALIDATION_FAILED
UpdateRename a referenced rowA district, the school profile, or a person's address points at itRejected409 GEOGRAPHY_RENAME_REFERENCED
UpdateReparent a referenced districtprovinceId changed on a district a municipality points atRejected409 GEOGRAPHY_RENAME_REFERENCED
UpdateRetire a referenced rowisActive: false only, same referencing statePermitted200
UpdateRename to the same name, different casee.g. "Bagmati""BAGMATI"Treated as no rename — no uniqueness re-check against itself200
DeleteReferenced rowAny child row, the school profile, or a person's address points at itRejected409 GEOGRAPHY_IN_USE
DeleteOnly reference is a soft-deleted personusers.deleted_at set, ON DELETE RESTRICT still counts the rowRejected409 GEOGRAPHY_IN_USE
List municipalitiesNo districtId filterUnpaginated request against 753-scale dataCapped at PaginationUtil.UNPAGINATED_HARD_CAP (1000)PaginationUtil
Not foundUnknown publicIdPATCH/DELETE an id that does not exist404 PROVINCE_NOT_FOUND / DISTRICT_NOT_FOUND / MUNICIPALITY_NOT_FOUNDService findOrThrow

9. Completion Checklist

  • Every route and its actor is listed.
  • The rename/reparent refusal and its reason (names in exports) are documented.
  • The delete pre-check and the 23001 vs 23503 subtlety are documented.
  • The seed's real coverage (7 provinces, 77 districts, 36 of 753 municipalities) is stated plainly.
  • Lifecycle transitions and their guards are documented.
  • Every flow links to the API and backend docs.

See Also

  • API doc: /docs/developer/geography/api
  • Backend doc: /docs/developer/geography/backend