Skoolsewa - Ecommerce Docs
Developer ResourcesSchool

School API Reference

Complete API contracts for departments, designations, person classifications, and the school profile singleton, including routes, auth, DTOs, responses, errors, and examples.

School - API Reference

Audience: Frontend engineers, mobile engineers, backend engineers, QA, and API consumers. Scope: Admin-facing APIs owned by LookupsModule (departments, designations, person classifications) and SchoolProfileModule (the school profile singleton). No public or mobile-facing route exists in this module.

1. Documentation Evidence

AreaFiles InspectedWhat Was Verified
Controllersapps/api/src/modules/lookups/lookups.controller.ts, apps/api/src/modules/school-profile/school-profile.controller.tsRoutes, methods, guards, decorators, status codes — including PersonClassificationsController in the first file.
DTOsapps/api/src/modules/lookups/dto/department.dto.ts, apps/api/src/modules/lookups/dto/designation.dto.ts, apps/api/src/modules/lookups/dto/person-classification.dto.ts, apps/api/src/modules/school-profile/dto/school-profile.dto.tsRequest, query, response, validation, defaults.
Servicesapps/api/src/modules/lookups/lookups.service.ts, apps/api/src/modules/lookups/person-classifications.service.ts, apps/api/src/modules/school-profile/school-profile.service.tsBehavior, side effects, response mapping, errors.
Schemapackages/db/src/schema/school/lookups.ts, packages/db/src/schema/school/school-profile.ts, packages/db/src/schema/school/people.ts, packages/db/src/schema/identity.tsIDs, constraints, persisted fields.
Seed datapackages/db/src/seed/seed-reference-data.ts, packages/db/src/seed/seed-auth.tsThe seeded ethnicity/mother-tongue baseline and which roles are granted which classification permission codes.
Shared query baseapps/api/src/common/dto/query.dto.tsInherited pagination, page, size, sort, order, search fields, and where ListPersonClassificationsQueryDto overrides one of them.
Response envelopeapps/api/src/common/dto/response-dto.tsExact success envelope shape.
Error envelopeapps/api/src/common/filters/all-exceptions.filter.tsExact error envelope shape.
Errorsapps/api/src/common/types/error-codes.ts (lines 228-251, 267-275)Every error code this module can produce, including PERSON_CLASSIFICATION_NOT_FOUND/NAME_TAKEN/IN_USE.
Authapps/api/src/modules/auth/guards/jwt-auth.guard.ts, apps/api/src/common/authorization/role.guard.tsGuard chain and identity shape.
Permissionspackages/db/src/authorization/permission-catalog.ts (lines 78-91)Permission modules/actions this module checks, including PersonClassifications.
Pagination utilityapps/api/src/common/utils/pagination.util.tsDefault/max size, offset math, UNPAGINATED_HARD_CAP.
Search escapingpackages/db/src/search/escape-like-pattern.tsILIKE escaping mechanism.
Cache key builderapps/api/src/common/utils/cache-key.util.tsDeterministic key construction folding the full query into every list cache key.
Wiringapps/api/src/app.module.ts (lines 47-48, 176-177)Both modules registered directly on AppModule.

2. Module Summary

FieldValue
Module nameLookupsModule (departments, designations, person classifications), SchoolProfileModule (school profile)
Module slugschool
Primary actorsAdmin for every mutation and for departments/designations/school-profile reads; staff and teacher roles additionally hold PersonClassifications_READ
API surfacesAdmin only — no @Public() route and no /api/mobile/... route exists in any of the three controllers
Base route prefixes/api/departments, /api/designations, /api/lookups/ethnicities, /api/lookups/mother-tongues, /api/school-profile (the global prefix api is set in apps/api/src/main.ts; controllers declare departments, designations, lookups, school-profile locally)
Auth modelJwtAuthGuard + RoleGuard, class-level on all three controllers
PersistencePostgreSQL (departments, designations, ethnicities, mother_tongues, school_profile); Redis (every list endpoint plus the school profile — see 11)
Runtime source of truthdepartments/designations/ethnicities/mother_tongues tables, each served from a 1-hour Redis cache keyed on the exact query and cleared on any write; school_profile row 1, served from a 1-hour Redis cache after the first read following any change
Sibling docsBackend, Features and flows

3. Concepts and Terminology

TermMeaningSource FileUsed By
DepartmentA top-level, school-editable grouping (e.g. "Science", "Administration").packages/db/src/schema/school/lookups.ts:41-65GET/POST/PATCH/DELETE /departments; the departmentId field on a designation and on staff.
DesignationAn employment title, scoped to one department, school-editable (e.g. "Senior Teacher", "Lab Assistant"). Not a role — see below.lookups.ts:67-123GET/POST/PATCH/DELETE /designations; the designationId field on staff.
Person classificationEither of the two national reference lists a person is recorded against: ethnicity (caste/ethnic group) or mother tongue. Seeded from the Central Bureau of Statistics' national census classification, unlike departments/designations, which a school invents itself.packages/db/src/schema/identity.tsGET/POST/PATCH/DELETE /lookups/ethnicities, .../mother-tongues; the ethnicityId/motherTongueId fields on a person record.
PersonClassificationKindThe TypeScript union "ethnicities" | "mother-tongues" that selects which table a classification route/service call targets — never exposed as a request field, only as the literal path segment.apps/api/src/modules/lookups/dto/person-classification.dto.tsPersonClassificationsService.classificationTable(kind) and every method that calls through it.
Role (contrast term, not owned by this module)What an actor may do in the system — checked by RoleGuard/RoleService, unrelated to designations.apps/api/src/common/authorization/role.guard.tsEvery guarded route in this module, but as an authorization input, never as data this module returns.
isTeachingA boolean on designations marking a title as a teaching one.lookups.ts:87-88GET /designations?isTeaching=true — the query the Teachers screen runs, since there is no Teachers entity.
isActiveRetirement flag on departments, designations, ethnicities, and mother_tongues. false removes a row from active pick lists without breaking any existing reference.lookups.ts:50,90; identity.tsisActive query filter on every list endpoint; the isActive field on every create/update body in this module.
Public ID (publicId)The UUIDv7 identifier every PATCH/DELETE route in this module addresses a row by. On departments/designations/classifications, the internal integer id never appears in a route param but is still returned in the response body — for classifications specifically, that integer is the value a person form posts back in person.ethnicityId/person.motherTongueId, so both ids are load-bearing on that one DTO.lookups.ts:44-47,73-76; identity.tsEvery :publicId route param below; PersonClassificationDto.id and .publicId together.
School profileThe single row of institutional facts (name, address, branding, currency, timezone, academic year start).packages/db/src/schema/school/school-profile.tsGET/PATCH /school-profile.
Academic year start monthThe calendar month (1-12) the school's academic year begins in; defaults to 4 (Nepal's Baisakh, mid-April) and is not the calendar year.school-profile.ts:47-54academicYearStartMonth field on the school profile.
TimezoneThe IANA zone name (default Asia/Kathmandu) used to decide which year an admission/employee number belongs to.school-profile.ts:58-63timezone field on the school profile; consumed elsewhere via SchoolProfileService.getTimezone().

4. API Surface Map

SurfaceMethodPathActorAuth/GuardPermissionControllerPurpose
AdminGET/api/departmentsAdminJwtAuthGuard, RoleGuardDepartments_READDepartmentsControllerList/search departments, paginated.
AdminPOST/api/departmentsAdminJwtAuthGuard, RoleGuardDepartments_CREATEDepartmentsControllerCreate a department.
AdminPATCH/api/departments/:publicIdAdminJwtAuthGuard, RoleGuardDepartments_UPDATEDepartmentsControllerRename, re-describe, or retire/reactivate a department.
AdminDELETE/api/departments/:publicIdAdminJwtAuthGuard, RoleGuardDepartments_DELETEDepartmentsControllerHard delete — permitted only when no designation references it.
AdminGET/api/designationsAdminJwtAuthGuard, RoleGuardDesignations_READDesignationsControllerList/search designations, paginated, joined to their department.
AdminPOST/api/designationsAdminJwtAuthGuard, RoleGuardDesignations_CREATEDesignationsControllerCreate a designation under a department.
AdminPATCH/api/designations/:publicIdAdminJwtAuthGuard, RoleGuardDesignations_UPDATEDesignationsControllerRename, retoggle isTeaching, or retire/reactivate — department cannot change.
AdminDELETE/api/designations/:publicIdAdminJwtAuthGuard, RoleGuardDesignations_DELETEDesignationsControllerHard delete — permitted only when no staff row references it.
AdminGET/api/lookups/ethnicitiesAdmin/Staff/TeacherJwtAuthGuard, RoleGuardPersonClassifications_READPersonClassificationsControllerList/search caste and ethnic groups, paginated.
AdminPOST/api/lookups/ethnicitiesAdminJwtAuthGuard, RoleGuardPersonClassifications_CREATEPersonClassificationsControllerAdd a caste or ethnic group.
AdminPATCH/api/lookups/ethnicities/:publicIdAdminJwtAuthGuard, RoleGuardPersonClassifications_UPDATEPersonClassificationsControllerRename or retire/reactivate an entry.
AdminDELETE/api/lookups/ethnicities/:publicIdAdminJwtAuthGuard, RoleGuardPersonClassifications_DELETEPersonClassificationsControllerHard delete — permitted only when no users row references it.
AdminGET/api/lookups/mother-tonguesAdmin/Staff/TeacherJwtAuthGuard, RoleGuardPersonClassifications_READPersonClassificationsControllerList/search mother tongues, paginated.
AdminPOST/api/lookups/mother-tonguesAdminJwtAuthGuard, RoleGuardPersonClassifications_CREATEPersonClassificationsControllerAdd a mother tongue.
AdminPATCH/api/lookups/mother-tongues/:publicIdAdminJwtAuthGuard, RoleGuardPersonClassifications_UPDATEPersonClassificationsControllerRename or retire/reactivate an entry.
AdminDELETE/api/lookups/mother-tongues/:publicIdAdminJwtAuthGuard, RoleGuardPersonClassifications_DELETEPersonClassificationsControllerHard delete — permitted only when no users row references it.
AdminGET/api/school-profileAdminJwtAuthGuard, RoleGuardSchoolProfile_READSchoolProfileControllerRead the single school profile row.
AdminPATCH/api/school-profileAdminJwtAuthGuard, RoleGuardSchoolProfile_UPDATESchoolProfileControllerUpdate any subset of the school profile's fields.

No alias routes, no restore endpoints (none of departments/designations/ethnicities/mother_tongues/the profile has deleted_at), and no nested sub-resource routes exist — verified against the full contents of both controller files. The eight classification routes are the only ones in this module nested under a shared prefix (/lookups) rather than a bare path — PersonClassificationsController's own class comment gives the reason: so a later /lookups/... list joins them instead of claiming another top-level path segment.

5. Auth, Identity, and Permissions

SurfaceGuard/DecoratorIdentity ShapePermissionGuest AllowedNotes
All eighteen routes above@UseGuards(JwtAuthGuard, RoleGuard) at the controller class levelreq.user populated by the JWT strategy; activeRole resolved from itOne of the eighteen codes in the surface map aboveNoNo route in this module carries @Public().
  • Auth is mandatory on every route. JwtAuthGuard rejects a missing/invalid token with 401 AUTH_UNAUTHENTICATED before RoleGuard ever runs.
  • Every handler declares a permission. RoleGuard's fail-open branch (a handler with zero @Permissions() on a non-admin-classified controller) never applies here, because every handler in this module declares one — verified by reading both controller files in full.
  • PersonClassifications_READ is the one permission in this document not confined to an administrative role. packages/db/src/seed/seed-auth.ts seeds it onto STAFF_PERMISSIONS and TEACHER_PERMISSIONS alongside Students_READ/Guardians_READ/Staff_READ/Departments_READ/Designations_READ/SchoolProfile_READ/Activity_READ — every other code in this document, and all four PersonClassifications write codes, are granted only through an explicit administrative role. A client should not assume a caller who can GET /api/lookups/ethnicities can also reach any other route in this module.
  • Active-role scoping. Permissions resolve from the caller's currently-active role only. A user holding several roles who has not selected one is refused with 403 AUTH_ACTIVE_ROLE_REQUIRED; a user with no role at all gets 403 PERMISSION_ROLE_NOT_ASSIGNED.
  • Superadmin bypass. A role with is_superadmin = true skips the permission list entirely. This is keyed on the boolean flag, never on the role's name string.
  • Permission catalog mechanics. Departments, Designations, PersonClassifications, and SchoolProfile are each declared once in PERMISSION_MODULES (packages/db/src/authorization/permission-catalog.ts:78-91), and the full catalog is generated as every module crossed with every action in PERMISSION_ACTIONS (CREATE, READ, UPDATE, DELETE, RESTORE). This means Departments_RESTORE, Designations_RESTORE, PersonClassifications_RESTORE, SchoolProfile_CREATE, SchoolProfile_DELETE, and SchoolProfile_RESTORE all exist as valid, grantable, seeded permission codes — but no route in this module ever checks them. A consumer building a role-management screen should not infer a corresponding route exists just because the permission code does.
  • Headers parsed but not trusted: not applicable — no route in this module reads any identity-bearing header other than the standard Authorization: Bearer <jwt> consumed by JwtAuthGuard.

6. DTO and Model Reference

6.1 DepartmentDto (response)

FieldTypeRequiredDefaultValidationExampleSource
idnumberYesServer-generatedN/A — response-only, internal serial id12department.dto.ts:29
publicIdstring (UUID)YesServer-generated (uuid7())N/A"018f2a1e-..."department.dto.ts:30
namestringYesUnique case-insensitively"Science"department.dto.ts:31
codestring | nullYes (nullable)null"SCI"department.dto.ts:32-33
descriptionstring | nullYes (nullable)null"Physical and life sciences"department.dto.ts:34-35
isActivebooleanYestruetruedepartment.dto.ts:36
designationCountnumberYesComputedCorrelated subquery count of designations rows for this department4department.dto.ts:37; computed in lookups.service.ts (findAllDepartments)
createdAtstring (ISO date)YesServer-generated"2026-01-10T04:15:00.000Z"department.dto.ts:38
updatedAtstring (ISO date)YesServer-generated"2026-01-10T04:15:00.000Z"department.dto.ts:39

designationCount on a freshly created department is always 0createDepartment constructs the response with a hardcoded 0 rather than re-querying, since a brand-new department cannot yet have any designations (lookups.service.ts, createDepartment).

6.2 CreateDepartmentDto (body)

FieldTypeRequiredDefaultValidationExampleSource
namestringYes@IsString, @MinLength(1), @MaxLength(120)"Science"department.dto.ts:41-46
codestringNo@IsOptional, @IsString, @MaxLength(32)"SCI"department.dto.ts:48-52
descriptionstringNo@IsOptional, @IsString, @MaxLength(500)"Physical and life sciences"department.dto.ts:54-58
isActivebooleanNotrue (applied in the service, not the DTO)@IsOptional, @IsBooleantruedepartment.dto.ts:60-63; default applied in lookups.service.ts (dto.isActive ?? true)

6.3 UpdateDepartmentDto (body)

FieldTypeRequiredDefaultValidationExampleSource
namestringNoUnchanged if omitted@IsOptional, @IsString, @MinLength(1), @MaxLength(120)"Sciences"department.dto.ts:66-71
codestringNoUnchanged if omitted; an empty string clears it to null@IsOptional, @IsString, @MaxLength(32)""department.dto.ts:73-77
descriptionstringNoSame clearing behavior as code@IsOptional, @IsString, @MaxLength(500)""department.dto.ts:79-83
isActivebooleanNoUnchanged if omitted@IsOptional, a query-style boolean transform ("true"/"1" also coerce), then @IsBooleanfalsedepartment.dto.ts:85-89

Every field is independently optional and independently applied — LookupsService.updateDepartment spreads only the keys present in dto into the SET clause, so a PATCH with a single field leaves every other column untouched.

6.4 ListDepartmentsQueryDto (query, extends QueryDto)

FieldTypeRequiredDefaultValidationExampleSource
isActivebooleanNoUnset (no filter)Query-string boolean transform, then @IsBoolean?isActive=truedepartment.dto.ts:96-100
search, pagination, page, size, sort, orderInherited from QueryDtoNoSee 6.7Inheritedquery.dto.ts

6.5 ListDesignationsQueryDto (query, extends QueryDto)

Declared inside department.dto.ts, not designation.dto.ts — verified by reading both files.

FieldTypeRequiredDefaultValidationExampleSource
departmentIdnumberNoUnset (no filter)@Type(() => Number), @IsInt?departmentId=3department.dto.ts:104-108
isTeachingbooleanNoUnset (no filter)Query-string boolean transform, then @IsBoolean?isTeaching=truedepartment.dto.ts:110-118; this is the exact filter the Teachers screen applies
isActivebooleanNoUnset (no filter)Same as isTeaching?isActive=truedepartment.dto.ts:120-124
search, pagination, page, size, sort, orderInherited from QueryDtoNoSee 6.7Inheritedquery.dto.ts

6.6 DesignationDto (response)

FieldTypeRequiredDefaultValidationExampleSource
idnumberYesServer-generatedInternal serial id7designation.dto.ts:12
publicIdstring (UUID)YesServer-generated"018f2a1f-..."designation.dto.ts:13
departmentIdnumberYesThe parent department's internal id, not its publicId3designation.dto.ts:14
departmentNamestringYesDenormalized at response time via a join/re-read"Science"designation.dto.ts:15; populated by joining departments in findAllDesignations, or by the department already read during create/update
namestringYesUnique case-insensitively within departmentId"Senior Teacher"designation.dto.ts:16
isTeachingbooleanYesfalsetruedesignation.dto.ts:17
isActivebooleanYestruetruedesignation.dto.ts:18
staffCountnumberYesComputedCorrelated subquery count of non-soft-deleted staff rows holding this designation12designation.dto.ts:19; computed in findAllDesignations
createdAtstring (ISO date)YesServer-generateddesignation.dto.ts:20
updatedAtstring (ISO date)YesServer-generateddesignation.dto.ts:21

staffCount is 0 on create and update responses (not re-queried) — same reasoning as designationCount above, except it is always accurate immediately after create (no staff can reference a designation that did not exist a moment ago) but is a stale placeholder, not a fresh count, immediately after an update, since an update never changes staff assignment and the service simply reuses 0 rather than re-querying staff. A client that renders staffCount from an update response for an existing, already-staffed designation would show 0 where the true count may be nonzero — re-fetch via the list endpoint if an accurate count is needed after an update.

6.7 CreateDesignationDto (body)

FieldTypeRequiredDefaultValidationExampleSource
departmentIdnumberYes@IsInt3designation.dto.ts:26-28
namestringYes@IsString, @MinLength(1), @MaxLength(120)"Senior Teacher"designation.dto.ts:30-34
isTeachingbooleanNofalse (applied in the service)@IsOptional, @IsBooleantruedesignation.dto.ts:36-39
isActivebooleanNotrue (applied in the service)@IsOptional, @IsBooleantruedesignation.dto.ts:41-44

6.8 UpdateDesignationDto (body)

FieldTypeRequiredDefaultValidationExampleSource
namestringNoUnchanged if omitted@IsOptional, @IsString, @MinLength(1), @MaxLength(120)"Lead Teacher"designation.dto.ts:57-62
isTeachingbooleanNoUnchanged if omitted@IsOptional, @IsBooleanfalsedesignation.dto.ts:64-67
isActivebooleanNoUnchanged if omitted@IsOptional, @IsBooleanfalsedesignation.dto.ts:69-72

departmentId is deliberately absent from this DTO. A designation's department is immutable once any staff row references it — the composite foreign key (department_id, designation_id) on staff would be violated from every referencing row at once if it moved. Because the global ValidationPipe runs whitelist: true, forbidNonWhitelisted: true (apps/api/src/main.ts:64-69), sending departmentId in this endpoint's body produces a standard 400 validation error ("property departmentId should not exist") — not a domain-specific error. To move a designation to a different department, retire it (isActive: false) and create a new one under the right department.

6.9 SchoolProfileDto (response)

FieldTypeRequiredDefaultValidationExampleSource
namestringYes"" (column default)"Green Valley School"school-profile.dto.ts:13
logoUrlstring | nullYes (nullable)nullNot validated as a URL — plain string"https://cdn.example/logo.png"school-profile.dto.ts:14-15
registrationNumberstring | nullYes (nullable)null"REG-2019-0042"school-profile.dto.ts:16-17
addressAddressDtoYesAll fields nullThe school's own Nepali address — province, district, municipality, ward, tole, house number, plus each id's resolved name. One address, not a permanent/current pair — a school has no present address distinct from its permanent one.school-profile.dto.ts
phonestring | nullYes (nullable)null"+977-1-4123456"school-profile.dto.ts:25
emailstring | nullYes (nullable)null"info@school.edu.np"school-profile.dto.ts:26
websitestring | nullYes (nullable)null"https://school.edu.np"school-profile.dto.ts:27
academicYearStartMonthnumberYes4DB CHECK BETWEEN 1 AND 124school-profile.dto.ts:28-33
currencyCodestringYes"NPR""NPR"school-profile.dto.ts:38
timezonestringYes"Asia/Kathmandu"Not validated as a real IANA zone name at write time (see 16.5 in the backend doc)"Asia/Kathmandu"school-profile.dto.ts:39-42
principalSchoolPrincipalDto | nullYes (nullable)Derived, not storedNull when nobody actively holds the designation flagged is_principal{ "fullName": "Anita Sharma", "designationName": "Principal", "departmentName": "Administration" }school-profile.dto.ts
updatedAtstring (ISO date)YesServer-generatedschool-profile.dto.ts:43

id and createdAt are read from the database row but omitted from the responseSchoolProfileService.toDto explicitly strips them (const { id: _id, createdAt: _createdAt, ...rest } = row;). There is exactly one row, so the internal id (always 1) carries no information a client needs, and createdAt never changes after the migration seeds it.

principal replaced principalName, brandPrimaryColor, and brandSecondaryColor in the response. Those three columns are no longer read out — SchoolProfileService.toDto strips them from every row alongside id and createdAt (RETIRED_COLUMNS, school-profile.service.ts). In their place, principal is a small object — { fullName, designationName, departmentName }, or null — resolved fresh on every read by PrincipalInvariantService.resolvePrincipal, using the exact same predicate (flagged designation, not soft-deleted, employmentStatus: "active") that the at-most-one-principal assert uses when a staff write would create a second principal. That predicate match is deliberate: a profile resolved by any looser rule could name a principal the assert would not count, and the two would silently disagree about who holds the role. principal carries no staff id — the profile is readable by every actor who can read the school's details, and a staff id would be a link into a record many of those callers cannot open. It is not cached with the rest of the profile: it changes whenever a staff member is appointed, removed, or set inactive, none of which this module hears about, so it is resolved with one indexed query on every GET/PATCH rather than through the hour-long profile cache.

SchoolPrincipalDto

FieldTypeNotes
fullNamestring | nullNullable because users.fullName is; a person record with no name is a broken row, and the profile still has to render rather than crash.
designationNamestringThe name of the designation flagged is_principal (typically "Principal", but this is data, not a hardcoded string).
departmentNamestringThe department that designation belongs to.

6.10 UpdateSchoolProfileDto (body)

FieldTypeRequiredDefaultValidationExampleSource
namestringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(200)"Green Valley School"school-profile.dto.ts:46-50
logoUrlstringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(500) — not a URL format check"https://cdn.example/logo.png"school-profile.dto.ts:52-56
registrationNumberstringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(100)"REG-2019-0042"school-profile.dto.ts:58-62
principalNamestringNoAccepted, ignored@IsOptional, @IsString, @MaxLength(200)"Anita Sharma"school-profile.dto.ts:64-68
addressAddressInputDtoNoUntouched if the key is absent@IsOptional, @ValidateNested, @Type(() => AddressInputDto)replaced whole, never merged field by field, exactly like a person's permanentAddress/currentAddress: when address is present, all six columns (provinceId, districtId, municipalityId, wardNo, tole, houseNo) are written from it and a missing key inside it means NULL{ "provinceId": 3, "districtId": 27, "municipalityId": 118, "wardNo": 5, "tole": "Suryabinayak", "houseNo": null }school-profile.dto.ts
phonestringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(40)"+977-1-4123456"school-profile.dto.ts:94-98
emailstringNoUnchanged if omitted@IsOptional, @IsEmail"info@school.edu.np"school-profile.dto.ts:100-103
websitestringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(200)"https://school.edu.np"school-profile.dto.ts:105-109
academicYearStartMonthnumberNoUnchanged if omitted@IsOptional, @IsInt, @Min(1), @Max(12)4school-profile.dto.ts:111-116
brandPrimaryColorstringNoAccepted, ignored@IsOptional, @IsHexColor"#1A6FBF"school-profile.dto.ts:118-121
brandSecondaryColorstringNoAccepted, ignored@IsOptional, @IsHexColor"#155A9C"school-profile.dto.ts:123-126
currencyCodestringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(8) — not validated against ISO 4217"NPR"school-profile.dto.ts:128-131
timezonestringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(64)not validated as a real IANA zone name"Asia/Kathmandu"school-profile.dto.ts:133-137

principalName, brandPrimaryColor, and brandSecondaryColor are still accepted on this DTO, and are silently ignored for the caller's purposes. All three are still real, writable columns on the school_profile row — SchoolProfileService.update still writes whatever value is sent for them, applying the same trim/blank-to-null handling as every other nullable text field — but toDto strips them back out of every response (see 6.9), so a client that sends and then re-reads one of these fields will never see its own write reflected. They are kept on the write DTO for one release rather than removed outright: the global ValidationPipe runs whitelist: true, forbidNonWhitelisted: true, so if the admin app is still sending these fields (built against the previous contract) and the DTO stopped declaring them, POST/PATCH would reject the entire save with a 400 rather than merely drop the one field. SchoolProfileService.RETIRED_COLUMNS names the three columns being phased out this way, and the columns themselves are scheduled to be dropped in a later migration, one release after consumers stop sending them.

Omitting a field always leaves it untouched. Sending an empty string, however, does not behave the same way across every field. On the plain nullable text columns — logoUrl, registrationNumber, principalName, phone, email, website, brandPrimaryColor, brandSecondaryColor — a trimmed-to-empty string is the documented "clear this field" sentinel and is written as NULL, exactly like UpdateDepartmentDto's code/description. On name, currencyCode, and timezone — the three columns that are NOT NULL with a database default — a trimmed-to-empty string is instead dropped from the write entirely, leaving the column at its current value, because these three cannot represent "unset" at all and a blank timezone in particular would silently break admission/employee-number allocation. address follows neither rule: it is a group, intercepted and applied before any per-field blanking logic runs, so sending it at all rewrites every one of its six columns together — see §6.1 for the whole-group-replacement reasoning. SchoolProfileService.update runs the DTO through toPatch() before it ever reaches .set() — it does not spread the raw DTO — which is what implements this distinction; see the backend doc's 6.2 for the full field-by-field rule.

6.11 QueryDto — shared base, inherited by both list queries

FieldTypeRequiredDefaultValidationExampleSource
paginationbooleanNotrueQuery-string boolean transform, @IsBoolean?pagination=falsequery.dto.ts:15-28
pagenumberNo1@IsInt, @Min(1)?page=2query.dto.ts:30-35
sizenumberNo20@IsInt, @Min(1), @Max(100) — silently clamped to 100 by PaginationUtil, not rejected?size=50query.dto.ts:37-42
sortstringNo"updatedAt"@IsStringnot restricted to a known column allowlist at the DTO layer; LookupsService only branches on the literal value "name" and treats every other value as "updatedAt"?sort=namequery.dto.ts:44-47
order"asc" | "desc"No"desc"@IsEnum(["asc", "desc"])?order=ascquery.dto.ts:49-52
searchstringNo— (no filter)@IsString, @MaxLength(100), trimmed?search=sciquery.dto.ts:54-60

sort=name sorts by departments.name/designations.name; any other value — including an unrecognized one like sort=code — silently falls back to sorting by updatedAt, because LookupsService's sort-column selection is a two-way ternary (query.sort === "name" ? ... : ...), not a lookup that rejects unknown values.

6.12 PersonClassificationDto (response)

FieldTypeRequiredDefaultValidationExampleSource
idnumberYesServer-generatedN/A — the internal serial id, but not an internal-only field here42person-classification.dto.ts:46
publicIdstring (UUID)YesServer-generated (uuid7())N/A"018f2a20-..."person-classification.dto.ts:47
namestringYesUnique case-insensitively within the same list (ethnicities and mother tongues never collide with each other)"Chhetri" or "Nepali"person-classification.dto.ts:48
isActivebooleanYestruetrueperson-classification.dto.ts:49-53

Both id and publicId are load-bearing here, unlike every other public-id-bearing response in this codebase. Elsewhere a client addresses a row by publicId because a serial id leaks how many rows exist and lets a caller walk them; neither concern applies to a fixed national classification, since the list is public knowledge, identical across every deployment, and its size is not a secret. What does matter is that users.ethnicity_id/users.mother_tongue_id are the integer foreign keys, so id is the value a person form posts straight back into person.ethnicityId/person.motherTongueId — handing out only publicId would force every write path to resolve it back to the integer first, a lookup per person saved for no gain. publicId is still returned because it is what this DTO's own PATCH/DELETE routes address the row by.

6.13 ListPersonClassificationsQueryDto (query, extends QueryDto)

FieldTypeRequiredDefaultValidationExampleSource
isActivebooleanNoUnset (no filter)Query-string boolean transform, then @IsBoolean?isActive=trueperson-classification.dto.ts:61-68
order"asc" | "desc"No"asc" — overridden from QueryDto's own "desc" default@IsIn(["asc", "desc"])?order=descperson-classification.dto.ts:80-88
search, pagination, page, sizeInherited from QueryDto, unmodifiedNoSee 6.11Inheritedquery.dto.ts

No sort field. Unlike ListDepartmentsQueryDto/ListDesignationsQueryDto, this DTO does not accept a sort query parameter at all — the service always orders by name (then id as a tie-breaker), never by updatedAt, so there is no second sort column to select between.

The order default is the one deliberate deviation from QueryDto anywhere in this module. It is implemented as a field initializer (readonly order: "asc" | "desc" = "asc"), not a declare re-typing of the inherited field — a declare would leave the parent class's own = "desc" initializer running underneath an annotation that claimed otherwise, since field initializers execute parent-first and a child's own initializer is what actually overwrites the value. An explicit ?order=desc from a caller still overwrites this default, because class-transformer applies the request value after the initializer runs. The reason for the divergence: every other list in this codebase defaults to newest-first, appropriate for a list of records; a caste/ethnic-group or mother-tongue select is scanned by a person hunting for a specific word, and a list rendering Z-to-A by default is one nobody can find anything in.

6.14 CreatePersonClassificationDto (body)

FieldTypeRequiredDefaultValidationExampleSource
namestringYesTrimmed by @Transform before @IsString() @IsNotEmpty() @MaxLength(120) run"Bhote"person-classification.dto.ts:98-111
isActivebooleanNotrue (applied in the service)@IsOptional, @IsBooleantrueperson-classification.dto.ts:113-118

Trim-before-validate ordering is load-bearing, not incidental. @IsNotEmpty() alone passes a string of only spaces — it is technically a non-empty string — so without the @Transform running first, the service would trim it down to "" afterward and insert a blank row that renders as an unlabelled option nobody can identify. Trimming before validation makes the emptiness visible to the validator, so the caller gets a 400 naming the field instead of a database row that has to be found and deleted by hand.

6.15 UpdatePersonClassificationDto (body)

FieldTypeRequiredDefaultValidationExampleSource
namestringNoUnchanged if omittedSame trim-then-validate ordering as CreatePersonClassificationDto: @Transform, then @IsString() @IsNotEmpty() @MaxLength(120)"Bhote (Sherpa)"person-classification.dto.ts:132-142
isActivebooleanNoUnchanged if omitted@IsOptional, @IsBooleanfalseperson-classification.dto.ts:144-150

Renaming is not a cosmetic edit. Every person already recorded against this row keeps pointing at it, so a rename rewrites their recorded ethnicity or mother tongue retroactively — including inside a government return already filed under the old spelling. Correcting a misspelling is the intended use; repurposing a row to mean a different group is not, and the documented alternative is to retire this entry (isActive: false) and create a new one.

7. Enum Reference

No true enum exists on any DTO in this module. order ("asc" | "desc") is the closest — documented in 6.11 and, for its overridden default, 6.13 above. isActive and isTeaching are plain booleans, not enums, and both admit only true/false. PersonClassificationKind ("ethnicities" | "mother-tongues") is a TypeScript union used internally by PersonClassificationsService to select a table — it is never a request field; a client selects between the two lists by calling a different route (/lookups/ethnicities vs. /lookups/mother-tongues), not by passing a value.

8. Endpoint Reference

8.1 GET /api/departments

Purpose

Returns a paginated, optionally-filtered list of departments. Called by the department management screen's list view and by any dropdown that needs the full department list (typically with pagination=false and isActive=true) — for example, populating the department selector when creating a designation or assigning staff.

Source Evidence

EvidencePath
Controllerapps/api/src/modules/lookups/lookups.controller.ts:44-53
DTOapps/api/src/modules/lookups/dto/department.dto.ts (ListDepartmentsQueryDto, DepartmentDto)
Serviceapps/api/src/modules/lookups/lookups.service.ts (findAllDepartments)
Schemapackages/db/src/schema/school/lookups.ts
TestsN/A — no spec file exists for this module.

Auth and Permissions

  • Auth: Required (JwtAuthGuard).
  • Guard chain: JwtAuthGuardRoleGuard.
  • Permission: Departments_READ.
  • Guest support: None.
  • Rate limit: None module-specific.
  • Idempotency: N/A (read).

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>.
ParamsNo
QueryNosearch, isActive, pagination, page, size, sort ("name" or default updatedAt), order.
BodyNo
GET /api/departments?search=sci&isActive=true&page=1&size=20&sort=name&order=asc HTTP/1.1

Response

{
  "message": "Departments fetched.",
  "data": [
    {
      "id": 12,
      "publicId": "018f2a1e-6b1e-7c3a-9d2e-1a2b3c4d5e6f",
      "name": "Science",
      "code": "SCI",
      "description": "Physical and life sciences",
      "isActive": true,
      "designationCount": 4,
      "createdAt": "2026-01-10T04:15:00.000Z",
      "updatedAt": "2026-01-10T04:15:00.000Z"
    }
  ],
  "errorCode": null,
  "count": 1,
  "currentPage": 1,
  "totalPage": 1
}

count/currentPage/totalPage are present only when the request's pagination resolved to true (the default) — ResponseDto only sets them when a pagination object with numeric count/page/size is passed in, which the controller only does when query.pagination is truthy (lookups.controller.ts:48-51).

Side Effects

  • Cache: getSoft is tried first on a deterministic key folding the entire query; a hit returns with no database round trip at all.
  • Database reads (cache miss only): SELECT on departments (plus a correlated subquery per row for designationCount); a separate COUNT(*) when pagination is enabled.
  • Cache write (cache miss only): setSoft at a 3600-second TTL — see 11.
  • No jobs, realtime events, notifications, audit logs, or external calls.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
401AUTH_UNAUTHENTICATEDMissing/invalid JWT.Re-authenticate.jwt-auth.guard.ts
403PERMISSION_INSUFFICIENTActive role lacks Departments_READ.Not authorized to view departments.role.guard.ts
400VALIDATION_FAILEDAn invalid query value (e.g. page=0, order=up).Fix the query string.Global ValidationPipe

Edge Cases

  • Empty search (?search=): QueryDto's own @Transform returns undefined for an empty string, so the filter is dropped entirely — the unfiltered list is returned, not zero rows.
  • search containing % or _: escaped via escapeLikePattern before entering the ILIKE pattern, so ?search=100% matches literal "100%" substrings only, not "everything starting with 100".
  • pagination=false: every matching row up to PaginationUtil.UNPAGINATED_HARD_CAP (1000) is returned in one response, and count/currentPage/totalPage are omitted from the envelope. A department table genuinely exceeding 1000 rows would be silently truncated at that cap — not a realistic size for this domain, but the cap is a real ceiling, not a theoretical one.
  • size above 100: silently clamped to 100 by PaginationUtil.normalize, not rejected.
  • sort set to an unrecognized column name: silently falls back to sorting by updatedAt — see 6.11.
  • No department rows exist: returns { "data": [], "count": 0, "currentPage": 1, "totalPage": 0 } (when paginated) rather than an error.
  • Every list is tie-broken by id ascending after the requested sort column, so rows created in the same transaction (identical updatedAt) do not silently drop or duplicate across pages under offset pagination.

Example Requests

curl -X GET "$API_URL/api/departments?isActive=true&pagination=false" \
  -H "Authorization: Bearer TOKEN"

8.2 POST /api/departments

Purpose

Creates a new department. Called from the department management screen's "add department" action.

Source Evidence

EvidencePath
Controllerlookups.controller.ts:55-63
DTOdepartment.dto.ts (CreateDepartmentDto)
Servicelookups.service.ts (createDepartment, assertDepartmentNameFree)
Schemalookups.ts (departments_name_unique)
TestsN/A

Auth and Permissions

  • Auth: Required.
  • Guard chain: JwtAuthGuardRoleGuard.
  • Permission: Departments_CREATE.
  • Guest support: None.
  • Rate limit: None module-specific.
  • Idempotency: None — no idempotency key is accepted; a resubmitted identical request creates a second department unless the name collides (see edge cases).

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
ParamsNo
QueryNo
BodyYesCreateDepartmentDto — see 6.2.

Minimal valid request:

{
  "name": "Science"
}

Full valid request:

{
  "name": "Science",
  "code": "SCI",
  "description": "Physical and life sciences",
  "isActive": true
}

Response

{
  "message": "Department created.",
  "data": {
    "id": 12,
    "publicId": "018f2a1e-6b1e-7c3a-9d2e-1a2b3c4d5e6f",
    "name": "Science",
    "code": "SCI",
    "description": "Physical and life sciences",
    "isActive": true,
    "designationCount": 0,
    "createdAt": "2026-01-10T04:15:00.000Z",
    "updatedAt": "2026-01-10T04:15:00.000Z"
  },
  "errorCode": null
}

Side Effects

  • Database writes: one INSERT into departments.
  • Cache: every cached page under both the lookups:departments:* and lookups:designations:* prefixes is cleared (delPatternSoft, fail-soft) — see 11 for why designations are cleared too.
  • No jobs, realtime events, notifications, audit logs, or external calls.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDname missing, too short/long, or an unknown field is present in the body.Fix the request body.Global ValidationPipe
409DEPARTMENT_NAME_TAKENAnother department already has this name (case-insensitive), caught by the pre-check.Choose a different name.lookups.service.ts (assertDepartmentNameFree)
409RESOURCE_ALREADY_EXISTSTwo concurrent creates for the same name both pass the pre-check; the loser hits the DB's unique index.The name is now taken — refresh and retry with a different one.all-exceptions.filter.ts (global unique-violation fallback)
401AUTH_UNAUTHENTICATEDMissing/invalid JWT.Re-authenticate.jwt-auth.guard.ts
403PERMISSION_INSUFFICIENTActive role lacks Departments_CREATE.Not authorized.role.guard.ts

Edge Cases

  • Re-submitting the exact same body twice (e.g. a double-click) creates two departments unless the name collides — there is no idempotency key on this endpoint.
  • A name that differs only in case from an existing department ("science" vs "Science") is rejected as taken.
  • Leading/trailing whitespace in name/code/description is trimmed server-side before comparison and storage.
  • An empty code/description ("") passes DTO validation (both are optional strings with no @MinLength) but is not specially normalized on create — it is stored as "", not null (contrast with the update path's clearing behavior).

Example Requests

curl -X POST "$API_URL/api/departments" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Science","code":"SCI"}'

8.3 PATCH /api/departments/:publicId

Purpose

Renames, re-describes, recodes, or retires/reactivates an existing department. Called from the department management screen's edit action, and from the "retire this department" toggle (isActive: false) that is the recommended alternative to deletion.

Source Evidence

EvidencePath
Controllerlookups.controller.ts:65-76
DTOdepartment.dto.ts (UpdateDepartmentDto)
Servicelookups.service.ts (updateDepartment)
Schemalookups.ts
TestsN/A

Auth and Permissions

  • Auth: Required.
  • Guard chain: JwtAuthGuardRoleGuard.
  • Permission: Departments_UPDATE.
  • Guest support: None.
  • Rate limit: None module-specific.
  • Idempotency: Naturally idempotent — resubmitting the same body produces the same end state (with a bumped updatedAt each time).

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
ParamsYespublicId — the department's UUID.
QueryNo
BodyYesUpdateDepartmentDto, every field optional — see 6.3.

Minimal valid request (retire only):

{
  "isActive": false
}

Response

Same shape as 8.2's response, with message: "Department updated.".

Side Effects

  • Database writes: one UPDATE on the matched departments row, only for the fields present in the body, plus updatedAt.
  • Cache: same invalidation as create — both prefixes cleared entirely.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404DEPARTMENT_NOT_FOUNDNo department with this publicId.The department may have been deleted; refresh the list.lookups.service.ts (findDepartmentOrThrow)
409DEPARTMENT_NAME_TAKENRenaming to a name another department already holds (case-insensitive). Only checked when the new name actually differs case-insensitively from the current one.Choose a different name.lookups.service.ts
409RESOURCE_ALREADY_EXISTSRace past the rename pre-check.Refresh and retry.Global unique-violation fallback
400VALIDATION_FAILEDInvalid field value or an unknown field in the body.Fix the request body.Global ValidationPipe
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs above.As above.

Edge Cases

  • Renaming to the same name with different casing ("Science""SCIENCE") is accepted as a no-op rename, not rejected as a clash against itself.
  • Setting isActive: false on a department that still has active designations is allowed — retirement does not cascade or require the children to be retired first; only hard delete is blocked while children exist.
  • An empty string for code/description on update clears the field to null (dto.code.trim() || null) — different from create, where an empty string is stored literally.

Example Requests

curl -X PATCH "$API_URL/api/departments/018f2a1e-6b1e-7c3a-9d2e-1a2b3c4d5e6f" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"isActive":false}'

8.4 DELETE /api/departments/:publicId

Purpose

Permanently removes a department. Intended for cleaning up a department created in error — for any department that has ever had real designations under it, retiring (PATCH isActive: false) is the supported path, since this endpoint refuses to run while any designation still exists under it.

Source Evidence

EvidencePath
Controllerlookups.controller.ts:78-89
DTONone — no body.
Servicelookups.service.ts (deleteDepartment)
Schemalookups.ts (departments.id referenced by designations.department_id ON DELETE restrict)
TestsN/A

Auth and Permissions

  • Auth: Required.
  • Guard chain: JwtAuthGuardRoleGuard.
  • Permission: Departments_DELETE.
  • Guest support: None.
  • Rate limit: None module-specific.
  • Idempotency: Not idempotent in the strict sense — a second identical DELETE on an already-deleted publicId returns 404, not a repeated 200.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>.
ParamsYespublicId.
QueryNo
BodyNo

Response

{
  "message": "Department deleted.",
  "data": null,
  "errorCode": null
}

Side Effects

  • Database writes: one DELETE on departments, only reached after the designation-existence check finds no rows.
  • Cache: same invalidation as create/update — both prefixes cleared entirely.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404DEPARTMENT_NOT_FOUNDNo department with this publicId.Already deleted, or never existed.lookups.service.ts
409DEPARTMENT_HAS_DESIGNATIONSAt least one designation still has department_id pointing here (regardless of that designation's own isActive value).Retire the department instead (isActive: false), or move/retire its designations first.lookups.service.ts (deleteDepartment)
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs above.As above.

Edge Cases

  • A department with zero designations but with staff rows directly assigned to it (staff.department_id, independent of any designation — that column is nullable and does not require a designation) is not blocked by this check, which only inspects designations. The delete succeeds; the staff.department_id ON DELETE restrict FK on the schema itself would then be the only remaining backstop, and a DELETE reaching that FK with staff still attached would 500 as an unmapped 23503 rather than a friendly 409 — the service-level guard here only covers the designation path, not the direct-staff path.
  • Deleting a department that is isActive: false behaves identically to deleting an active one — retirement status has no bearing on delete eligibility.

Example Requests

curl -X DELETE "$API_URL/api/departments/018f2a1e-6b1e-7c3a-9d2e-1a2b3c4d5e6f" \
  -H "Authorization: Bearer TOKEN"

8.5 GET /api/designations

Purpose

Returns a paginated, filterable list of designations, each joined to its department's name and its current staff count. This is the exact query the Teachers screen runs with isTeaching=true, since there is no separate Teachers entity or endpoint.

Source Evidence

EvidencePath
Controllerlookups.controller.ts:105-114
DTOdepartment.dto.ts (ListDesignationsQueryDto), designation.dto.ts (DesignationDto)
Servicelookups.service.ts (findAllDesignations)
Schemalookups.ts, people.ts (staff)
TestsN/A

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Designations_READ. Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>.
ParamsNo
QueryNosearch, departmentId, isTeaching, isActive, pagination, page, size, sort, order.
BodyNo
GET /api/designations?departmentId=3&isTeaching=true&isActive=true HTTP/1.1

Response

{
  "message": "Designations fetched.",
  "data": [
    {
      "id": 7,
      "publicId": "018f2a1f-7c2e-7d4b-8e3f-2b3c4d5e6f70",
      "departmentId": 3,
      "departmentName": "Science",
      "name": "Senior Teacher",
      "isTeaching": true,
      "isActive": true,
      "staffCount": 12,
      "createdAt": "2026-01-10T04:20:00.000Z",
      "updatedAt": "2026-01-10T04:20:00.000Z"
    }
  ],
  "errorCode": null,
  "count": 1,
  "currentPage": 1,
  "totalPage": 1
}

Side Effects

  • Cache: getSoft tried first on a deterministic key folding the entire query; a hit returns with no database round trip.
  • Database reads (cache miss only): designations INNER JOIN departments, plus a correlated subquery per row counting non-soft-deleted staff for staffCount.
  • Cache write (cache miss only): setSoft at a 3600-second TTL.

Error Cases

Same shape as 8.1401 AUTH_UNAUTHENTICATED, 403 PERMISSION_INSUFFICIENT, 400 VALIDATION_FAILED.

Edge Cases

  • staffCount excludes soft-deleted staff (staff.deletedAt IS NULL is part of the subquery) — a designation held only by staff who have since left still reports 0 here, even though the same designation cannot be deleted while those soft-deleted staff rows exist (the delete-guard query, unlike this count, does not filter on deletedAt). These two numbers can legitimately disagree.
  • departmentId for a department that does not exist: returns an empty list, not 404 — the filter is a plain WHERE, not an existence check.
  • All other edge cases match 8.1 (empty search, %/_ escaping, pagination clamping, unknown sort falling back to updatedAt, tie-break by id).

Example Requests

curl -X GET "$API_URL/api/designations?isTeaching=true&isActive=true&pagination=false" \
  -H "Authorization: Bearer TOKEN"

8.6 POST /api/designations

Purpose

Creates a new designation under a specific department. Called from the designation management screen and from any "add a new title" affordance surfaced while assigning a staff member's role.

Source Evidence

EvidencePath
Controllerlookups.controller.ts:116-124
DTOdesignation.dto.ts (CreateDesignationDto)
Servicelookups.service.ts (createDesignation)
Schemalookups.ts (designations_department_name_unique)
TestsN/A

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Designations_CREATE. Guest support: none. Rate limit: none module-specific. Idempotency: none — no idempotency key.

Request

Minimal valid request:

{
  "departmentId": 3,
  "name": "Senior Teacher"
}

Full valid request:

{
  "departmentId": 3,
  "name": "Senior Teacher",
  "isTeaching": true,
  "isActive": true
}

Response

{
  "message": "Designation created.",
  "data": {
    "id": 7,
    "publicId": "018f2a1f-7c2e-7d4b-8e3f-2b3c4d5e6f70",
    "departmentId": 3,
    "departmentName": "Science",
    "name": "Senior Teacher",
    "isTeaching": true,
    "isActive": true,
    "staffCount": 0,
    "createdAt": "2026-01-10T04:20:00.000Z",
    "updatedAt": "2026-01-10T04:20:00.000Z"
  },
  "errorCode": null
}

Side Effects

  • Database reads: one lookup on departments (parent existence), one on designations (name pre-check scoped to departmentId).
  • Database writes: one INSERT into designations.
  • Cache: same invalidation as every lookup write.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404DEPARTMENT_NOT_FOUNDdepartmentId does not reference an existing department.Choose a valid department.lookups.service.ts (createDesignation)
409DESIGNATION_NAME_TAKENAnother designation in the same department already has this name (case-insensitive).Choose a different name, or confirm the department.lookups.service.ts
409RESOURCE_ALREADY_EXISTSRace past the name pre-check.Refresh and retry.Global unique-violation fallback
400VALIDATION_FAILEDMissing/invalid departmentId/name, or an unknown field.Fix the request body.Global ValidationPipe
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs above.As above.

Edge Cases

  • The same name is accepted in a different department — uniqueness is scoped per departmentId, not global.
  • departmentId referencing a retired (isActive: false) department is still accepted — the existence check does not filter on isActive, so a designation can be created under a retired department without warning.
  • isTeaching defaults to false when omitted — a designation is not automatically a teaching one; the Teachers screen would not surface staff holding it unless this is explicitly set true.

Example Requests

curl -X POST "$API_URL/api/designations" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"departmentId":3,"name":"Senior Teacher","isTeaching":true}'

8.7 PATCH /api/designations/:publicId

Purpose

Renames a designation, retoggles isTeaching, or retires/reactivates it. Cannot move a designation to a different department — see 6.8.

Source Evidence

EvidencePath
Controllerlookups.controller.ts:126-141
DTOdesignation.dto.ts (UpdateDesignationDto)
Servicelookups.service.ts (updateDesignation)
Schemalookups.ts
TestsN/A

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Designations_UPDATE. Guest support: none. Rate limit: none module-specific. Idempotency: naturally idempotent.

Request

{
  "isTeaching": false
}

Response

Same shape as 8.6, with message: "Designation updated." and staffCount reused from the prior read rather than recomputed — see the caveat in 6.6.

Side Effects

  • Database reads: lookup by publicId; name pre-check if renaming; a re-read of the parent department's name for the response.
  • Database writes: one UPDATE on designations.
  • Cache: same invalidation as every lookup write.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404DESIGNATION_NOT_FOUNDNo designation with this publicId.Refresh the list.lookups.service.ts
409DESIGNATION_NAME_TAKENRenaming to a name another designation in the same department already holds.Choose a different name.lookups.service.ts
409PRINCIPAL_DESIGNATION_RETIRE_FORBIDDENisActive: false on the designation flagged is_principal, regardless of whether anybody currently holds it.Retiring it would leave the school with no way to appoint a successor Principal.lookups.service.ts (updateDesignation)
409RESOURCE_ALREADY_EXISTSRace past the rename pre-check.Refresh and retry.Global unique-violation fallback
400VALIDATION_FAILEDA departmentId field (or any other unknown field) is present in the body, or a declared field is invalid.Remove the unsupported field / fix the value.Global ValidationPipe
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs above.As above.

Edge Cases

  • Sending departmentId in the body (attempting to move the designation) is rejected with a generic 400, not the more specific DESIGNATION_DEPARTMENT_IMMUTABLE error code — that code exists in the registry but is never thrown by any code path (verified in the backend doc).
  • The designation flagged is_principal can never be retired — checked before the update, whether or not any staff member currently holds it. The whole principal mechanism hangs on this row (PrincipalInvariantService.assertSinglePrincipal locks it, and SchoolProfileService/PrincipalInvariantService.resolvePrincipal resolves the school's principal by joining to it); retiring it would remove it from the designation pick list, silently blocking any future appointment while a retired-but-referenced row keeps resolving for whoever already holds it.
  • Turning isTeaching off for a designation currently held by active staff does not affect those staff rows at all — it only changes whether new/existing holders of this designation appear on the Teachers screen going forward; no cascading update touches staff.
  • Setting isActive: false while staff still hold the designation is allowed — retirement never requires reassignment first; only hard delete does.

Example Requests

curl -X PATCH "$API_URL/api/designations/018f2a1f-7c2e-7d4b-8e3f-2b3c4d5e6f70" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"isTeaching":false}'

8.8 DELETE /api/designations/:publicId

Purpose

Permanently removes a designation. Blocked while any staff row — active or soft-deleted — still references it; retiring (isActive: false) is the supported path once a designation has ever been assigned to anyone.

Source Evidence

EvidencePath
Controllerlookups.controller.ts:143-152
DTONone — no body.
Servicelookups.service.ts (deleteDesignation)
Schemapeople.ts (staff.designation_id)
TestsN/A

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: Designations_DELETE. Guest support: none. Rate limit: none module-specific. Idempotency: not idempotent — a second DELETE on an already-deleted publicId returns 404.

Request

publicId in the path only, no body.

Response

{
  "message": "Designation deleted.",
  "data": null,
  "errorCode": null
}

Side Effects

  • Database reads: lookup by publicId; an existence check against staff.designationId.
  • Database writes: one DELETE on designations, only reached when no staff row (including soft-deleted ones) references it.
  • Cache: same invalidation as every lookup write.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404DESIGNATION_NOT_FOUNDNo designation with this publicId.Already deleted, or never existed.lookups.service.ts
409PRINCIPAL_DESIGNATION_DELETE_FORBIDDENThe designation flagged is_principal. Checked before the in-use check.The school profile resolves its principal through this row; it can never be deleted.lookups.service.ts (deleteDesignation)
409DESIGNATION_IN_USEAt least one staff row — including a soft-deleted one — has this designation_id.Reassign or (for active staff) retire them first; a soft-deleted staff row referencing this designation blocks deletion permanently unless that historical record is itself removed.lookups.service.ts (deleteDesignation)
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs above.As above.

Edge Cases

  • A designation whose only holders have all been soft-deleted from staff is still blocked from deletion — the existence check does not filter on staff.deletedAt, deliberately, so historical staff records never end up pointing at a deleted designation.
  • Deleting an isActive: false (retired) designation behaves identically to deleting an active one.
  • The PRINCIPAL_DESIGNATION_DELETE_FORBIDDEN check runs before the in-use check deliberately: an unheld Principal designation would pass the in-use check cleanly and be deleted, and an unheld Principal designation is the state a school is normally in the moment its principal leaves — the likely case, not the unlikely one.

Example Requests

curl -X DELETE "$API_URL/api/designations/018f2a1f-7c2e-7d4b-8e3f-2b3c4d5e6f70" \
  -H "Authorization: Bearer TOKEN"

8.9 GET /api/school-profile

Purpose

Returns the single school profile row. Called by every screen that displays the school's name, logo, address, branding, currency, or timezone — typically the admin settings screen and any print/report header that needs the school's identity.

Source Evidence

EvidencePath
Controllerapps/api/src/modules/school-profile/school-profile.controller.ts:17-21
DTOschool-profile.dto.ts (SchoolProfileDto)
Serviceschool-profile.service.ts (get)
Schemapackages/db/src/schema/school/school-profile.ts
TestsN/A

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: SchoolProfile_READ. Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).

Request

No params, query, or body — Authorization header only.

Response

{
  "message": "School profile fetched.",
  "data": {
    "name": "Green Valley School",
    "logoUrl": null,
    "registrationNumber": null,
    "address": {
      "provinceId": null,
      "provinceName": null,
      "districtId": null,
      "districtName": null,
      "municipalityId": null,
      "municipalityName": null,
      "municipalityType": null,
      "wardNo": null,
      "tole": null,
      "houseNo": null
    },
    "phone": null,
    "email": null,
    "website": null,
    "academicYearStartMonth": 4,
    "currencyCode": "NPR",
    "timezone": "Asia/Kathmandu",
    "principal": {
      "fullName": "Anita Sharma",
      "designationName": "Principal",
      "departmentName": "Administration"
    },
    "updatedAt": "2026-01-10T04:00:00.000Z"
  },
  "errorCode": null
}

Side Effects

  • Cache: reads school:profile first (getSoft); on a hit, no database query runs at all.
  • Database reads (cache miss only): one SELECT on school_profile by the hardcoded id 1.
  • Cache write (cache miss only): setSoft("school:profile", dto, 3600).
  • No jobs, realtime events, notifications, audit logs, or external calls.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404SCHOOL_PROFILE_NOT_INITIALIZEDRow 1 is missing from school_profile.Should not occur in a normally-migrated database; contact an operator.school-profile.service.ts
401AUTH_UNAUTHENTICATEDMissing/invalid JWT.Re-authenticate.jwt-auth.guard.ts
403PERMISSION_INSUFFICIENTActive role lacks SchoolProfile_READ.Not authorized.role.guard.ts

Edge Cases

  • A Redis outage during the cache read is fail-soft: getSoft logs a WARN and is treated as a miss, so the request still succeeds by falling through to the database — the client never sees a cache failure as an error.
  • A Redis outage during the cache write after a fresh DB read is also fail-soft — the request still returns the correct, freshly-read data; only the next request will miss the cache too and repeat the DB read.
  • No pagination, filtering, or search applies — this is a single-object endpoint.

Example Requests

curl -X GET "$API_URL/api/school-profile" \
  -H "Authorization: Bearer TOKEN"

8.10 PATCH /api/school-profile

Purpose

Updates any subset of the school profile's fields. Called from the admin settings screen when the office edits the school's identity, contact details, branding, currency, academic year start, or timezone.

Source Evidence

EvidencePath
Controllerschool-profile.controller.ts:23-30
DTOschool-profile.dto.ts (UpdateSchoolProfileDto)
Serviceschool-profile.service.ts (update)
Schemaschool-profile.ts (both CHECK constraints)
TestsN/A

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: SchoolProfile_UPDATE. Guest support: none. Rate limit: none module-specific. Idempotency: naturally idempotent — resubmitting the same body reapplies the same values (with a bumped updatedAt).

Request

Minimal valid request:

{
  "phone": "+977-1-4123456"
}

Full valid request:

{
  "name": "Green Valley School",
  "logoUrl": "https://cdn.example/logo.png",
  "registrationNumber": "REG-2019-0042",
  "address": {
    "provinceId": 3,
    "provinceName": "Bagmati",
    "districtId": 27,
    "districtName": "Bhaktapur",
    "municipalityId": 121,
    "municipalityName": "Suryabinayak",
    "municipalityType": "municipality",
    "wardNo": 5,
    "tole": "Suryabinayak",
    "houseNo": null
  },
  "phone": "+977-1-4123456",
  "email": "info@school.edu.np",
  "website": "https://school.edu.np",
  "academicYearStartMonth": 4,
  "currencyCode": "NPR",
  "timezone": "Asia/Kathmandu"
}

principalName, brandPrimaryColor, and brandSecondaryColor still validate and still write successfully if sent (the DTO still declares them — see 6.10), but none of the three appears in the response, and the principal shown there is always the derived principal object, never principalName. There is no reason for a new integration to send any of the three.

Response

Same shape as 8.9, with message: "School profile updated.". The response never echoes principalName, brandPrimaryColor, or brandSecondaryColor even if the request just set them.

Side Effects

  • Database writes: one unconditional UPDATE ... RETURNING * on school_profile id 1, applying only the keys present in the body.
  • Cache: delSoft("school:profile") runs after the write commits — a request landing in the narrow window between the commit and the invalidation could still observe the pre-update cached value, bounded by the remaining TTL (at most 3600 seconds, in practice far less since the delete runs immediately after the write).
  • No jobs, realtime events, notifications, audit logs, or external calls.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404SCHOOL_PROFILE_NOT_INITIALIZEDThe UPDATE matches no row (row 1 missing).Should not occur in a normally-migrated database.school-profile.service.ts
400VALIDATION_FAILEDAn invalid field value (bad email, non-hex color, month outside 1-12) or an unknown field.Fix the request body.Global ValidationPipe
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs above.As above.

Note: academicYearStartMonth outside 1-12 is rejected by the DTO's @Min(1) @Max(12) before it can ever reach the database's own school_profile_academic_year_start_month_valid CHECK — the DB constraint is a second line of defense for a write that bypasses this DTO (e.g. a script), not a code path reachable through this endpoint.

Edge Cases

  • No server-side validation that timezone is a real IANA zone name. "Asia/Katmandu" (misspelled) or "Nowhere/Nothing" both pass @IsString() @MaxLength(64) and are accepted, then silently corrupt year numbering for admission/employee numbers going forward — see the backend doc's risk register.
  • No server-side validation that currencyCode is a real ISO 4217 code — any string up to 8 characters is accepted.
  • Omitting a field leaves it unchanged; there is no way to reset a field to null/empty through this endpoint — an empty string ("") is stored literally, not normalized.
  • Updating only non-timezone/non-academic-year fields (e.g. just phone) does not affect the school's already-configured timezone or academic year — every field is independent.

Example Requests

curl -X PATCH "$API_URL/api/school-profile" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"phone":"+977-1-4123456"}'

8.11 GET /api/lookups/ethnicities

Purpose

Returns a paginated, optionally-filtered list of caste/ethnic-group entries. Called by the classification maintenance screen's list view, and by the pupil/staff demographic form's ethnicity select (typically with pagination=false&isActive=true).

Source Evidence

EvidencePath
Controllerlookups.controller.ts:196-210
DTOperson-classification.dto.ts (ListPersonClassificationsQueryDto, PersonClassificationDto)
Serviceperson-classifications.service.ts (findAllPersonClassifications)
Schemapackages/db/src/schema/identity.ts
Testsperson-classifications.service.spec.ts (real Postgres).

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: PersonClassifications_READ — granted to staff and teacher roles in addition to any administrative role. Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>.
ParamsNo
QueryNosearch, isActive, pagination, page, size, order (default asc, unlike every other list in this module). No sort field — always ordered by name.
BodyNo
GET /api/lookups/ethnicities?search=chh&isActive=true&pagination=false HTTP/1.1

Response

{
  "message": "Ethnicities fetched.",
  "data": [
    {
      "id": 3,
      "publicId": "018f2a20-0000-7000-8000-000000000003",
      "name": "Chhetri",
      "isActive": true
    }
  ],
  "errorCode": null,
  "count": 1,
  "currentPage": 1,
  "totalPage": 1
}

count/currentPage/totalPage are present only when pagination resolves truthy, same rule as every other list in this module.

Side Effects

  • Cache: getSoft on a deterministic key folding the full query, prefixed lookups:classifications:ethnicities:; a hit returns with no database round trip.
  • Database reads (cache miss only): SELECT on ethnicities, plus a COUNT(*) when paginated.
  • Cache write (cache miss only): setSoft at a 3600-second TTL.
  • No jobs, realtime events, notifications, audit logs, or external calls.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
401AUTH_UNAUTHENTICATEDMissing/invalid JWT.Re-authenticate.jwt-auth.guard.ts
403PERMISSION_INSUFFICIENTActive role lacks PersonClassifications_READ.Not authorized to view this list.role.guard.ts
400VALIDATION_FAILEDAn invalid query value.Fix the query string.Global ValidationPipe

Edge Cases

  • Empty search: dropped entirely, same as every other list in this module — the unfiltered list is returned.
  • Default sort order is ascending by name (order defaults to "asc" on this DTO only) — an explicit ?order=desc still overrides it.
  • pagination=false on the full 56-row seeded list returns all of it in one response; the 1000-row UNPAGINATED_HARD_CAP is far above any realistic size for this table.
  • A staff or teacher role with no other permission in this module can still call this endpoint successfully — PersonClassifications_READ does not imply any other permission, and no other permission implies it either.

Example Requests

curl -X GET "$API_URL/api/lookups/ethnicities?pagination=false&isActive=true" \
  -H "Authorization: Bearer TOKEN"

8.12 POST /api/lookups/ethnicities

Purpose

Adds a caste/ethnic-group entry the seeded national list is missing. Called from the classification maintenance screen's "add" action; administrator-only, unlike the read route above.

Source Evidence

EvidencePath
Controllerlookups.controller.ts:239-249
DTOperson-classification.dto.ts (CreatePersonClassificationDto)
Serviceperson-classifications.service.ts (createPersonClassification, assertClassificationNameFree)
Schemaidentity.ts (ethnicities_name_unique)
Testsperson-classifications.service.spec.ts — "creates an entry, active by default, with both ids", "refuses a name that differs only by case".

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: PersonClassifications_CREATE — administrator only. Guest support: none. Rate limit: none module-specific. Idempotency: none — a resubmitted identical request creates a second entry unless the name collides.

Request

Minimal and only valid request shape:

{
  "name": "Bhote"
}

Full valid request:

{
  "name": "Bhote",
  "isActive": true
}

Response

{
  "message": "Ethnicity added.",
  "data": {
    "id": 57,
    "publicId": "018f2a20-0000-7000-8000-000000000039",
    "name": "Bhote",
    "isActive": true
  },
  "errorCode": null
}

Side Effects

  • Database reads: one lookup on ethnicities (case-insensitive name pre-check).
  • Database writes: one INSERT into ethnicities.
  • Cache: delPatternSoft clears only the lookups:classifications:ethnicities:* prefix — the mother-tongue cache is untouched.
  • No jobs, realtime events, notifications, audit logs, or external calls.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDname missing, blank after trimming, too long, or an unknown field is present.Fix the request body.Global ValidationPipe
409PERSON_CLASSIFICATION_NAME_TAKENAnother ethnicity already has this name (case-insensitive), caught by the pre-check.Choose a different name.person-classifications.service.ts
409RESOURCE_ALREADY_EXISTSTwo concurrent creates for the same name both pass the pre-check; the loser hits the DB's unique index.The name is now taken — refresh and retry with a different one.all-exceptions.filter.ts (global unique-violation fallback)
401AUTH_UNAUTHENTICATEDMissing/invalid JWT.Re-authenticate.jwt-auth.guard.ts
403PERMISSION_INSUFFICIENTActive role lacks PersonClassifications_CREATE — including a role that only holds _READ.Not authorized.role.guard.ts

Edge Cases

  • name: " " (all whitespace): trimmed before the not-empty check runs, so this is rejected as 400 VALIDATION_FAILED, never inserted as a blank row.
  • The identical name is accepted for a mother-tongue entry — the two lists are entirely independent; only same-list collisions are rejected.
  • Leading/trailing whitespace is trimmed server-side before comparison and storage.
  • A double-submit creates two entries unless the name collides — no idempotency key exists on this endpoint.

Example Requests

curl -X POST "$API_URL/api/lookups/ethnicities" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Bhote"}'

8.13 PATCH /api/lookups/ethnicities/:publicId

Purpose

Renames or retires/reactivates an existing ethnicity entry. Called from the classification maintenance screen's edit action.

Source Evidence

EvidencePath
Controllerlookups.controller.ts:251-265
DTOperson-classification.dto.ts (UpdatePersonClassificationDto)
Serviceperson-classifications.service.ts (updatePersonClassification)
Schemaidentity.ts
Testsperson-classifications.service.spec.ts — "renames and retires an entry", "permits a rename that only changes the casing of its own name".

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: PersonClassifications_UPDATE — administrator only. Guest support: none. Rate limit: none module-specific. Idempotency: naturally idempotent — resubmitting the same body produces the same end state.

Request

PartRequiredDetails
HeadersYesAuthorization: Bearer <jwt>, Content-Type: application/json.
ParamsYespublicId — validated as a UUID by ParseUUIDPipe before the handler runs.
QueryNo
BodyYesUpdatePersonClassificationDto, every field optional.

Minimal valid request (retire only):

{
  "isActive": false
}

Response

Same shape as 8.12's response, with message: "Ethnicity updated.".

Side Effects

  • Database reads: lookup by publicId (ParseUUIDPipe rejects a malformed UUID with a 400 before this ever runs); a name pre-check only if renaming.
  • Database writes: one UPDATE on the matched ethnicities row.
  • Cache: delPatternSoft on the ethnicities prefix only.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDpublicId is not a valid UUID, or the body fails DTO validation.Fix the request.ParseUUIDPipe / global ValidationPipe
404PERSON_CLASSIFICATION_NOT_FOUNDNo ethnicity with this publicId.The entry may have been deleted; refresh the list.person-classifications.service.ts
409PERSON_CLASSIFICATION_NAME_TAKENRenaming to a name another ethnicity already holds (case-insensitive). Checked only when the trimmed name actually differs case-insensitively from the current one.Choose a different name.person-classifications.service.ts
409RESOURCE_ALREADY_EXISTSRace past the rename pre-check.Refresh and retry.Global unique-violation fallback
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs above.As above.

Edge Cases

  • Renaming to the same name with different casing ("newar""Newar") is accepted as a no-op rename, not rejected as a clash against itself.
  • Renaming an entry retroactively changes what every person already recorded against it is understood to mean — including inside a government return already filed under the old name. This is accepted unconditionally; there is no confirmation step or audit entry.
  • Setting isActive: false on an entry people are already recorded against is allowed — retirement never requires clearing references first; only hard delete does.

Example Requests

curl -X PATCH "$API_URL/api/lookups/ethnicities/018f2a20-0000-7000-8000-000000000003" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"isActive":false}'

8.14 DELETE /api/lookups/ethnicities/:publicId

Purpose

Permanently removes an ethnicity entry created in error. Blocked while any users row is recorded against it — retiring (isActive: false) is the supported path once an entry has ever been used.

Source Evidence

EvidencePath
Controllerlookups.controller.ts:267-276
DTONone — no body.
Serviceperson-classifications.service.ts (deletePersonClassification)
Schemaidentity.ts (users.ethnicity_id ON DELETE set null)
Testsperson-classifications.service.spec.ts — "deletes an entry nothing points at", "refuses to delete an entry people are recorded against".

Auth and Permissions

  • Auth: Required. Guard chain: JwtAuthGuardRoleGuard. Permission: PersonClassifications_DELETE — administrator only. Guest support: none. Rate limit: none module-specific. Idempotency: not idempotent — a second DELETE on an already-deleted publicId returns 404.

Request

publicId in the path only (validated as a UUID by ParseUUIDPipe), no body.

Response

{
  "message": "Ethnicity deleted.",
  "data": null,
  "errorCode": null
}

Side Effects

  • Database reads: lookup by publicId; an existence check against users.ethnicity_id.
  • Database writes: one DELETE on ethnicities, only reached when no users row references it.
  • Cache: delPatternSoft on the ethnicities prefix only.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400VALIDATION_FAILEDpublicId is not a valid UUID.Fix the request.ParseUUIDPipe
404PERSON_CLASSIFICATION_NOT_FOUNDNo ethnicity with this publicId.Already deleted, or never existed.person-classifications.service.ts
409PERSON_CLASSIFICATION_IN_USEAt least one users row has ethnicity_id pointing here.Retire the entry instead (isActive: false) — this removes it from pick lists and leaves existing records intact.person-classifications.service.ts (deletePersonClassification)
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs above.As above.

Edge Cases

  • The reference check is what makes this refusal honest: users.ethnicity_id is ON DELETE set null, so without it the DELETE would succeed at the database and silently blank every affected person's recorded ethnicity, with no error and no way to reconstruct who held which value.
  • Deleting an isActive: false (retired) entry behaves identically to deleting an active one — retirement status has no bearing on delete eligibility.

Example Requests

curl -X DELETE "$API_URL/api/lookups/ethnicities/018f2a20-0000-7000-8000-000000000003" \
  -H "Authorization: Bearer TOKEN"

8.15 GET /api/lookups/mother-tongues

Identical contract to 8.11, against the mother_tongues table (the 42-row seeded list) and the lookups:classifications:mother-tongues: cache prefix instead. Controller: lookups.controller.ts:212-228. Service: findAllPersonClassifications("mother-tongues", query).

curl -X GET "$API_URL/api/lookups/mother-tongues?pagination=false&isActive=true" \
  -H "Authorization: Bearer TOKEN"

8.16 POST /api/lookups/mother-tongues

Identical contract to 8.12, against mother_tongues. Controller: lookups.controller.ts:278-288. Response message: "Mother tongue added.". The same-named entry is freely accepted in the ethnicities list — uniqueness is scoped per list, never global.

curl -X POST "$API_URL/api/lookups/mother-tongues" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Bantawa"}'

8.17 PATCH /api/lookups/mother-tongues/:publicId

Identical contract to 8.13, against mother_tongues. Controller: lookups.controller.ts:290-304. Response message: "Mother tongue updated.". A rename here retroactively changes every recorded mother_tongue_id, exactly as an ethnicity rename does for ethnicity_id — the risk is identical, only the column differs.

curl -X PATCH "$API_URL/api/lookups/mother-tongues/018f2a20-0000-7000-8000-00000000004a" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"isActive":false}'

8.18 DELETE /api/lookups/mother-tongues/:publicId

Identical contract to 8.14, checked against users.mother_tongue_id instead of users.ethnicity_id — a distinction the service's own test suite verifies explicitly ("counts a mother tongue's references against the right column"), because the two columns are structurally interchangeable to the type system and a wrong check would be a data-loss bug that types could not catch. Controller: lookups.controller.ts:306-315. Response message: "Mother tongue deleted.".

curl -X DELETE "$API_URL/api/lookups/mother-tongues/018f2a20-0000-7000-8000-00000000004a" \
  -H "Authorization: Bearer TOKEN"

9. Flow Diagrams

9.1 Route Ownership

9.2 Request Sequence — create with a race

9.3 Error Branch — mutation endpoints

EndpointPagination TypeDefault SizeMax SizeSort FieldsFiltersResult Cap
GET /api/departmentspage/size, or unpaginated with pagination=false20100name, else updatedAt (default)search, isActivePaginationUtil.UNPAGINATED_HARD_CAP (1000) when unpaginated.
GET /api/designationsSame20100name, else updatedAt (default)search, departmentId, isTeaching, isActiveSame (1000).
GET /api/lookups/ethnicitiesSame20100Always name — no sort field exists on this DTOsearch, isActiveSame (1000; the seeded list is 56 rows).
GET /api/lookups/mother-tonguesSame20100Always namesearch, isActiveSame (1000; the seeded list is 42 rows).
  • Shared pagination utility: apps/api/src/common/utils/pagination.util.ts (PaginationUtil) — used identically by all four list endpoints.
  • Broad-search detection: none — search always runs as a plain ILIKE %term% regardless of term length or specificity.
  • Relevance scoring: none — results are ordered strictly by the requested sort/order (departments/designations) or always by name (classifications), with id as a deterministic tie-breaker; there is no trigram or full-text relevance ranking on any list.
  • Cache behavior per query: every list endpoint in this module is cache-aside, keyed on the full query (filters, sort, order, pagination, page, size) via CacheKeyUtil.build — two requests differing in even one of those fields never share a cache entry. See 11.
  • Empty result behavior: an empty data array with count: 0, not an error.
  • A client wanting an entire reference list (any of the four) sends pagination=false, not a large sizesize above MAX_PAGE_SIZE (100) is silently clamped to 100, never rejected and never honored above that ceiling.

11. Caching, Jobs, and External Integrations

IntegrationUsed?DetailsSource
Redis cache (school profile)YesKey school:profile, TTL 3600s, cache-aside (getSoft on read, setSoft on miss, delSoft on every PATCH). Fail-soft on every path.school-profile.service.ts
Redis cache (departments/designations)YesPrefixes lookups:departments:list:/lookups:designations:list:, TTL 3600s, cache-aside — getSoft tried on every GET, setSoft on a miss. CacheKeyUtil.build folds the entire query (search, active filter, sort, order, pagination, page, size) into the key, so no two distinct queries ever collide. invalidate() clears both prefixes with delPatternSoft on every department or designation write, because a designation's list row carries its department's name — a department rename that cleared only its own prefix would leave the designation list stale for up to an hour on the same staff form where both are chosen together.lookups.service.ts
Redis cache (person classifications)YesPrefixes lookups:classifications:ethnicities:/lookups:classifications:mother-tongues:, TTL 3600s, cache-aside, same key-construction mechanism as above. Unlike departments/designations, each kind's prefix is invalidated independently — an ethnicity write clears only the ethnicities prefix, since nothing about an ethnicity change can affect what the mother-tongue list would return.person-classifications.service.ts
BullMQNoNo queue import in any service in this module.
RealtimeNoNo Socket.IO emission in any service in this module.
External APINoNo outbound HTTP call in any service in this module.

Consumer implication: every list-returning endpoint in this module — departments, designations, both classification lists, and the school profile — can now return a value up to 3600 seconds old relative to the database, bounded by the TTL and by every write to that data clearing the relevant cache prefix(es) synchronously as part of the same request. A client that previously relied on GET /api/departments/GET /api/designations always reflecting the database at the instant of the request (true when those two endpoints had no cache-aside path) should re-test that assumption: a write from one client is visible to a second client's next request because the write's own invalidate() runs before the write's response is returned, not because the read path skips the cache.

13. Mandatory Deep API Documentation Pack

13.1 Route-by-Route Completeness Matrix

RouteController MethodDTOsService MethodGuardsPermissionsCacheJobsDB TouchesErrorsTestsDocumented?
GET /api/departmentsDepartmentsController.findAllListDepartmentsQueryDtoDepartmentDto[]findAllDepartmentsJWT, RoleDepartments_READReads + may populate lookups:departments:*N/Adepartments401,403,400N/AYes
POST /api/departmentsDepartmentsController.createCreateDepartmentDtoDepartmentDtocreateDepartmentJWT, RoleDepartments_CREATEInvalidates both lookups:departments:* and lookups:designations:*N/Adepartments401,403,400,409N/AYes
PATCH /api/departments/:publicIdDepartmentsController.updateUpdateDepartmentDtoDepartmentDtoupdateDepartmentJWT, RoleDepartments_UPDATEInvalidates both prefixesN/Adepartments401,403,400,404,409N/AYes
DELETE /api/departments/:publicIdDepartmentsController.removeNone → nulldeleteDepartmentJWT, RoleDepartments_DELETEInvalidates both prefixesN/Adepartments, reads designations401,403,404,409N/AYes
GET /api/designationsDesignationsController.findAllListDesignationsQueryDtoDesignationDto[]findAllDesignationsJWT, RoleDesignations_READReads + may populate lookups:designations:*N/Adesignations, departments (join), staff (count)401,403,400N/AYes
POST /api/designationsDesignationsController.createCreateDesignationDtoDesignationDtocreateDesignationJWT, RoleDesignations_CREATEInvalidates both prefixesN/Adesignations, reads departments401,403,400,404,409N/AYes
PATCH /api/designations/:publicIdDesignationsController.updateUpdateDesignationDtoDesignationDtoupdateDesignationJWT, RoleDesignations_UPDATEInvalidates both prefixesN/Adesignations, reads departments401,403,400,404,409N/AYes
DELETE /api/designations/:publicIdDesignationsController.removeNone → nulldeleteDesignationJWT, RoleDesignations_DELETEInvalidates both prefixesN/Adesignations, reads staff401,403,404,409N/AYes
GET /api/lookups/ethnicitiesPersonClassificationsController.findAllEthnicitiesListPersonClassificationsQueryDtoPersonClassificationDto[]findAllPersonClassifications("ethnicities", ...)JWT, RolePersonClassifications_READReads + may populate lookups:classifications:ethnicities:*N/Aethnicities401,403,400person-classifications.service.spec.tsYes
POST /api/lookups/ethnicitiesPersonClassificationsController.createEthnicityCreatePersonClassificationDtoPersonClassificationDtocreatePersonClassification("ethnicities", ...)JWT, RolePersonClassifications_CREATEInvalidates lookups:classifications:ethnicities:* onlyN/Aethnicities401,403,400,409person-classifications.service.spec.tsYes
PATCH /api/lookups/ethnicities/:publicIdPersonClassificationsController.updateEthnicityUpdatePersonClassificationDtoPersonClassificationDtoupdatePersonClassification("ethnicities", ...)JWT, RolePersonClassifications_UPDATEInvalidates ethnicities prefix onlyN/Aethnicities401,403,400,404,409person-classifications.service.spec.tsYes
DELETE /api/lookups/ethnicities/:publicIdPersonClassificationsController.deleteEthnicityNone → nulldeletePersonClassification("ethnicities", ...)JWT, RolePersonClassifications_DELETEInvalidates ethnicities prefix onlyN/Aethnicities, reads users401,403,400,404,409person-classifications.service.spec.tsYes
GET /api/lookups/mother-tonguesPersonClassificationsController.findAllMotherTonguesListPersonClassificationsQueryDtoPersonClassificationDto[]findAllPersonClassifications("mother-tongues", ...)JWT, RolePersonClassifications_READReads + may populate lookups:classifications:mother-tongues:*N/Amother_tongues401,403,400person-classifications.service.spec.tsYes
POST /api/lookups/mother-tonguesPersonClassificationsController.createMotherTongueCreatePersonClassificationDtoPersonClassificationDtocreatePersonClassification("mother-tongues", ...)JWT, RolePersonClassifications_CREATEInvalidates mother-tongues prefix onlyN/Amother_tongues401,403,400,409person-classifications.service.spec.tsYes
PATCH /api/lookups/mother-tongues/:publicIdPersonClassificationsController.updateMotherTongueUpdatePersonClassificationDtoPersonClassificationDtoupdatePersonClassification("mother-tongues", ...)JWT, RolePersonClassifications_UPDATEInvalidates mother-tongues prefix onlyN/Amother_tongues401,403,400,404,409person-classifications.service.spec.tsYes
DELETE /api/lookups/mother-tongues/:publicIdPersonClassificationsController.deleteMotherTongueNone → nulldeletePersonClassification("mother-tongues", ...)JWT, RolePersonClassifications_DELETEInvalidates mother-tongues prefix onlyN/Amother_tongues, reads users401,403,400,404,409person-classifications.service.spec.tsYes
GET /api/school-profileSchoolProfileController.getNone → SchoolProfileDtogetJWT, RoleSchoolProfile_READReads + may populate school:profileN/Aschool_profile (miss only)401,403,404school-profile.service.spec.tsYes
PATCH /api/school-profileSchoolProfileController.updateUpdateSchoolProfileDtoSchoolProfileDtoupdateJWT, RoleSchoolProfile_UPDATEInvalidates school:profileN/Aschool_profile401,403,400,404school-profile.service.spec.tsYes

No route-level pipes, interceptors, or response-status overrides exist beyond the global ValidationPipe, ClassSerializerInterceptor, and AllExceptionsFilter applied app-wide — verified by the absence of any method-level @UsePipes/@UseInterceptors/@HttpCode in either controller file.

13.2 Request/Response Exhaustiveness

Covered per-endpoint in 8 — every endpoint includes a minimal and/or full request example where applicable, a success response, and its representative error set. There is no guest-accessible variant of any endpoint in this module (every route requires admin auth), so that example type is not applicable here.

13.3 API Diagram Pack

Covered in 9: route ownership, a concurrent-create sequence diagram illustrating the name-uniqueness race, and the shared error-decision tree every mutation endpoint follows. A cache-flow diagram for the school profile, and the equivalent shape shared by every query-keyed list cache in this module:

13.4 Consumer Integration Notes

ConsumerRequired KnowledgeFailure HandlingContract Stability
Admin panelAll eighteen routes require auth; every mutation needs the exact permission code from 4; departmentId can never be sent when updating a designation; every list-returning route can now return a value up to an hour stale relative to a write from a different in-flight request that has not yet committed.Map errorCode to a specific message per the tables in 8; a 409 on delete should surface the exact blocking reason (*_HAS_DESIGNATIONS/*_IN_USE/PERSON_CLASSIFICATION_IN_USE) rather than a generic "cannot delete."Stable.
Staff/teacher panel (a narrower role than a full administrator)Can call GET /api/lookups/ethnicities/.../mother-tongues with PersonClassifications_READ alone; every other route in this module requires an administrative permission this role does not hold.A 403 on any route other than the two classification GETs is expected behavior for this role, not a bug to report.Stable.
QAReproduce the name-uniqueness race (§9.2) by firing two identical creates concurrently; reproduce the staffCount staleness noted in 6.6 by updating a staffed designation and comparing against a fresh list call; reproduce PERSON_CLASSIFICATION_IN_USE by recording a test person against a classification entry before attempting to delete it.Fixtures should seed at least one soft-deleted staff row to exercise DESIGNATION_IN_USE and the staffCount/deletability disagreement, and at least one users row pointing at a test classification entry to exercise PERSON_CLASSIFICATION_IN_USE.Stable.
Internal service (e.g. people/staff module)Read SchoolProfileService.getTimezone() rather than re-querying school_profile directly for year-numbering logic, to stay consistent with the cache. Resolve staff.department_id/designation_id, and a person's ethnicity_id/mother_tongue_id, against these tables' id, not publicId.N/A — internal call, not HTTP.Stable.
Web/mobile frontendThis module has no mobile-facing route; a mobile client needing department/designation/classification names for display should go through whatever staff/student endpoint already denormalizes them, not call these admin routes directly.N/AN/A — not exposed to that surface.

13.5 API Tradeoffs and Rationale

DecisionChosen BehaviorAlternatives ConsideredWhy This TradeoffRiskMitigation
Hard delete, not soft delete, for departments/designations/classificationsDELETE permanently removes the row, blocked while referencedSoft delete (deletedAt), matching staffA soft-deleted lookup row would keep resolving through every reference (fine) while vanishing from active pick lists (fine), but would never trigger ON DELETE restrict/correctly guard ON DELETE set null — so a "deleted" but still-referenced row could exist inconsistently, or a classification delete could silently blank a person's record. isActive=false already covers the "hide but keep" need.An admin must retire-then-clear-references-then-delete, a longer path than one-click removal.Not applicable — this is a correctness-motivated choice, not a UX shortcut.
Page/size pagination, not cursorpagination/page/size on all four list endpointsCursor-based paginationLookup lists are small (tens to low hundreds of rows per school; classification lists start at 56/42 seeded rows); offset pagination's O(n) skip cost is negligible at this scale, and page/size is simpler for an admin table UI with page-number controls.Skip cost would grow if a school ever had thousands of departments — not realistic for this domain.The id tie-breaker at least keeps pages stable across concurrent inserts.
Case-insensitive uniqueness enforced by a pre-check and a DB index, not the DB index aloneA friendly 409 with a specific error code from the pre-check; the DB index as the real backstopDB index only, with the generic global unique-violation fallback for every clashA named, actionable error code (DEPARTMENT_NAME_TAKEN/PERSON_CLASSIFICATION_NAME_TAKEN) is a materially better client experience than a generic "a record with these values already exists."The pre-check is not atomic with the insert, so a race still surfaces the generic error — see 9.2.Documented explicitly rather than silently accepted; a future fix could catch the 23505 in the service and remap it.
Every list endpoint in this module is cache-aside, keyed on the full queryRepeat identical requests are served without a database round trip; every write clears exactly the affected prefix(es)Leave departments/designations/classifications uncached, matching only the school profile before this changeEvery one of these lists is read on a high-traffic form; there was no principled reason for the school profile alone to benefit from caching once the query-keyed infrastructure (CacheKeyUtil) existed for the rest.A cached response can be up to an hour stale if a write's own invalidation is itself lost — only possible during a Redis fault, which is logged.Every write path invalidates synchronously as part of the same request/response cycle, before the client sees success.
PersonClassifications_READ seeded onto staff/teacher roles; every other permission in this module stays administrator-onlyBoth list endpoints under /lookups succeed for a non-administrative callerRequire an administrative role for every route in this module, as departments/designations/school-profile doThe pupil and staff demographic forms every staff member and teacher fills out render these two lists as select boxes; requiring an administrative role just to populate a dropdown would mean routing every such form through an administrator.A staff/teacher role can enumerate the full national classification list — not sensitive data, and the four write codes remain administrator-only.None needed — this is a deliberate, narrow broadening of one specific permission.
staffCount/designationCount computed via correlated subquery on every list row, not denormalized columnsAlways-fresh counts on GET, stale placeholder (0) on POST/PATCH responsesDenormalized counter columns updated by triggers or application codeAvoids a second write path (and the drift risk that comes with it) for a value only needed on read; a correlated subquery on a small table is cheap.The 0 placeholder on update responses (documented in 6.6) can mislead a client that trusts it without re-fetching.Documented as a known caveat; a client wanting an accurate count after PATCH should re-fetch via the list endpoint.
No idempotency key on any POSTA resubmitted create request creates a duplicate resource unless the name collidesClient-supplied idempotency key, deduplicated server-sideThese are low-frequency, admin-driven, human-in-the-loop writes, not a high-throughput or auto-retried path where duplicate submission is a realistic risk.A double-click or an auto-retrying client could create a duplicate.The name-uniqueness constraint incidentally catches the most likely duplicate (same name), just not a deliberately-varied one.

13.6 API Change Impact

ChangeAffected ConsumersBackend ImpactData ImpactMigration Needed?Compatibility Plan
Adding a departmentId move path to PATCH /designations/:publicIdAdmin panelWould need to actually throw DESIGNATION_DEPARTMENT_IMMUTABLE from a real check, or implement the move with a cascading update to every staff row's composite keyNone — no schema changeNoAdditive; existing clients that never send departmentId are unaffected.
Adding a versioned/audited rename on `PATCH /lookups/ethnicitiesmother-tongues/:publicId`Admin panelWould need an audit table or a renamed_from-style column, since the current rename is unconditional and unloggedPossibly — an audit table additionPossibly
Validating timezone against a real IANA list on PATCH /school-profileAdmin panelA previously-accepted (but wrong) timezone string would now 400NoneNoExisting correct timezones are unaffected; only invalid ones that previously succeeded silently would start failing loudly — a net improvement, but technically a breaking change for any client that was relying on the lenient behavior.

14. Zero-Omission API Checklist

  • Every controller route is documented (18 of 18).
  • The /api global prefix is documented alongside each controller-local path.
  • Every DTO field, nested field, default, transform, and validator is documented (§6), including the two person-classification DTOs and the order default override.
  • Every response field, nullable field, and server-generated field is documented; id/createdAt omission on the school profile response is called out explicitly; the deliberate exposure of both id and publicId on PersonClassificationDto is called out as intentional, not an inconsistency.
  • Every auth guard and permission is documented (§5); the catalog's unused _RESTORE/SchoolProfile_CREATE/SchoolProfile_DELETE codes are called out as inert; PersonClassifications_READ's broader staff/teacher grant is called out as the one exception to "every permission in this module is administrator-only."
  • Every success, validation, auth, permission, not-found, and conflict branch is documented per endpoint (§8), including the three PERSON_CLASSIFICATION_* codes.
  • Every database read/write and cache hit/miss/write/invalidation is documented; every list endpoint in this module is now cache-aside, and the exact prefix(es) each write clears is stated per endpoint rather than left implicit.
  • Every route has at least a minimal/full request example and a success response.
  • Route ownership, a race-condition sequence, and the shared error-decision tree are diagrammed (§9).
  • Every tradeoff is documented with an alternative and a risk (§13.5).
  • Links to backend and features/flows docs are present below.

15. Integration Checklist

  • Every route from all three controllers is documented.
  • Every DTO field is documented.
  • There is no true enum in this module — noted explicitly in §7 rather than omitted; the PersonClassificationKind union is documented as a routing concept, not a request field.
  • Every response envelope is documented, including the pagination metadata fields and their conditional presence.
  • Every error code this module can produce (including the shared global ones it can surface) is documented.
  • Every auth guard and permission is documented, including the one permission granted outside an administrative role.
  • Every cache path in this module — the school profile and all four query-keyed list caches — is documented with its exact key construction, TTL, and invalidation scope.
  • Every diagram matches the current code — verified against the exact service/controller files cited throughout.
  • This doc links to backend and features/flows docs.

See Also

On this page

School - API Reference1. Documentation Evidence2. Module Summary3. Concepts and Terminology4. API Surface Map5. Auth, Identity, and Permissions6. DTO and Model Reference6.1 DepartmentDto (response)6.2 CreateDepartmentDto (body)6.3 UpdateDepartmentDto (body)6.4 ListDepartmentsQueryDto (query, extends QueryDto)6.5 ListDesignationsQueryDto (query, extends QueryDto)6.6 DesignationDto (response)6.7 CreateDesignationDto (body)6.8 UpdateDesignationDto (body)6.9 SchoolProfileDto (response)SchoolPrincipalDto6.10 UpdateSchoolProfileDto (body)6.11 QueryDto — shared base, inherited by both list queries6.12 PersonClassificationDto (response)6.13 ListPersonClassificationsQueryDto (query, extends QueryDto)6.14 CreatePersonClassificationDto (body)6.15 UpdatePersonClassificationDto (body)7. Enum Reference8. Endpoint Reference8.1 GET /api/departmentsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.2 POST /api/departmentsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.3 PATCH /api/departments/:publicIdPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.4 DELETE /api/departments/:publicIdPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.5 GET /api/designationsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.6 POST /api/designationsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.7 PATCH /api/designations/:publicIdPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.8 DELETE /api/designations/:publicIdPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.9 GET /api/school-profilePurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.10 PATCH /api/school-profilePurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.11 GET /api/lookups/ethnicitiesPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.12 POST /api/lookups/ethnicitiesPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.13 PATCH /api/lookups/ethnicities/:publicIdPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.14 DELETE /api/lookups/ethnicities/:publicIdPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.15 GET /api/lookups/mother-tongues8.16 POST /api/lookups/mother-tongues8.17 PATCH /api/lookups/mother-tongues/:publicId8.18 DELETE /api/lookups/mother-tongues/:publicId9. Flow Diagrams9.1 Route Ownership9.2 Request Sequence — create with a race9.3 Error Branch — mutation endpoints10. Pagination, Sorting, Filtering, and Search11. Caching, Jobs, and External Integrations13. Mandatory Deep API Documentation Pack13.1 Route-by-Route Completeness Matrix13.2 Request/Response Exhaustiveness13.3 API Diagram Pack13.4 Consumer Integration Notes13.5 API Tradeoffs and Rationale13.6 API Change Impact14. Zero-Omission API Checklist15. Integration ChecklistSee Also