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
| Area | Files Inspected | What Was Verified |
|---|---|---|
| Controllers | apps/api/src/modules/lookups/lookups.controller.ts, apps/api/src/modules/school-profile/school-profile.controller.ts | Routes, methods, guards, decorators, status codes — including PersonClassificationsController in the first file. |
| DTOs | apps/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.ts | Request, query, response, validation, defaults. |
| Services | apps/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.ts | Behavior, side effects, response mapping, errors. |
| Schema | packages/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.ts | IDs, constraints, persisted fields. |
| Seed data | packages/db/src/seed/seed-reference-data.ts, packages/db/src/seed/seed-auth.ts | The seeded ethnicity/mother-tongue baseline and which roles are granted which classification permission codes. |
| Shared query base | apps/api/src/common/dto/query.dto.ts | Inherited pagination, page, size, sort, order, search fields, and where ListPersonClassificationsQueryDto overrides one of them. |
| Response envelope | apps/api/src/common/dto/response-dto.ts | Exact success envelope shape. |
| Error envelope | apps/api/src/common/filters/all-exceptions.filter.ts | Exact error envelope shape. |
| Errors | apps/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. |
| Auth | apps/api/src/modules/auth/guards/jwt-auth.guard.ts, apps/api/src/common/authorization/role.guard.ts | Guard chain and identity shape. |
| Permissions | packages/db/src/authorization/permission-catalog.ts (lines 78-91) | Permission modules/actions this module checks, including PersonClassifications. |
| Pagination utility | apps/api/src/common/utils/pagination.util.ts | Default/max size, offset math, UNPAGINATED_HARD_CAP. |
| Search escaping | packages/db/src/search/escape-like-pattern.ts | ILIKE escaping mechanism. |
| Cache key builder | apps/api/src/common/utils/cache-key.util.ts | Deterministic key construction folding the full query into every list cache key. |
| Wiring | apps/api/src/app.module.ts (lines 47-48, 176-177) | Both modules registered directly on AppModule. |
2. Module Summary
| Field | Value |
|---|---|
| Module name | LookupsModule (departments, designations, person classifications), SchoolProfileModule (school profile) |
| Module slug | school |
| Primary actors | Admin for every mutation and for departments/designations/school-profile reads; staff and teacher roles additionally hold PersonClassifications_READ |
| API surfaces | Admin 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 model | JwtAuthGuard + RoleGuard, class-level on all three controllers |
| Persistence | PostgreSQL (departments, designations, ethnicities, mother_tongues, school_profile); Redis (every list endpoint plus the school profile — see 11) |
| Runtime source of truth | departments/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 docs | Backend, Features and flows |
3. Concepts and Terminology
| Term | Meaning | Source File | Used By |
|---|---|---|---|
| Department | A top-level, school-editable grouping (e.g. "Science", "Administration"). | packages/db/src/schema/school/lookups.ts:41-65 | GET/POST/PATCH/DELETE /departments; the departmentId field on a designation and on staff. |
| Designation | An employment title, scoped to one department, school-editable (e.g. "Senior Teacher", "Lab Assistant"). Not a role — see below. | lookups.ts:67-123 | GET/POST/PATCH/DELETE /designations; the designationId field on staff. |
| Person classification | Either 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.ts | GET/POST/PATCH/DELETE /lookups/ethnicities, .../mother-tongues; the ethnicityId/motherTongueId fields on a person record. |
PersonClassificationKind | The 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.ts | PersonClassificationsService.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.ts | Every guarded route in this module, but as an authorization input, never as data this module returns. |
isTeaching | A boolean on designations marking a title as a teaching one. | lookups.ts:87-88 | GET /designations?isTeaching=true — the query the Teachers screen runs, since there is no Teachers entity. |
isActive | Retirement 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.ts | isActive 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.ts | Every :publicId route param below; PersonClassificationDto.id and .publicId together. |
| School profile | The single row of institutional facts (name, address, branding, currency, timezone, academic year start). | packages/db/src/schema/school/school-profile.ts | GET/PATCH /school-profile. |
| Academic year start month | The 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-54 | academicYearStartMonth field on the school profile. |
| Timezone | The IANA zone name (default Asia/Kathmandu) used to decide which year an admission/employee number belongs to. | school-profile.ts:58-63 | timezone field on the school profile; consumed elsewhere via SchoolProfileService.getTimezone(). |
4. API Surface Map
| Surface | Method | Path | Actor | Auth/Guard | Permission | Controller | Purpose |
|---|---|---|---|---|---|---|---|
| Admin | GET | /api/departments | Admin | JwtAuthGuard, RoleGuard | Departments_READ | DepartmentsController | List/search departments, paginated. |
| Admin | POST | /api/departments | Admin | JwtAuthGuard, RoleGuard | Departments_CREATE | DepartmentsController | Create a department. |
| Admin | PATCH | /api/departments/:publicId | Admin | JwtAuthGuard, RoleGuard | Departments_UPDATE | DepartmentsController | Rename, re-describe, or retire/reactivate a department. |
| Admin | DELETE | /api/departments/:publicId | Admin | JwtAuthGuard, RoleGuard | Departments_DELETE | DepartmentsController | Hard delete — permitted only when no designation references it. |
| Admin | GET | /api/designations | Admin | JwtAuthGuard, RoleGuard | Designations_READ | DesignationsController | List/search designations, paginated, joined to their department. |
| Admin | POST | /api/designations | Admin | JwtAuthGuard, RoleGuard | Designations_CREATE | DesignationsController | Create a designation under a department. |
| Admin | PATCH | /api/designations/:publicId | Admin | JwtAuthGuard, RoleGuard | Designations_UPDATE | DesignationsController | Rename, retoggle isTeaching, or retire/reactivate — department cannot change. |
| Admin | DELETE | /api/designations/:publicId | Admin | JwtAuthGuard, RoleGuard | Designations_DELETE | DesignationsController | Hard delete — permitted only when no staff row references it. |
| Admin | GET | /api/lookups/ethnicities | Admin/Staff/Teacher | JwtAuthGuard, RoleGuard | PersonClassifications_READ | PersonClassificationsController | List/search caste and ethnic groups, paginated. |
| Admin | POST | /api/lookups/ethnicities | Admin | JwtAuthGuard, RoleGuard | PersonClassifications_CREATE | PersonClassificationsController | Add a caste or ethnic group. |
| Admin | PATCH | /api/lookups/ethnicities/:publicId | Admin | JwtAuthGuard, RoleGuard | PersonClassifications_UPDATE | PersonClassificationsController | Rename or retire/reactivate an entry. |
| Admin | DELETE | /api/lookups/ethnicities/:publicId | Admin | JwtAuthGuard, RoleGuard | PersonClassifications_DELETE | PersonClassificationsController | Hard delete — permitted only when no users row references it. |
| Admin | GET | /api/lookups/mother-tongues | Admin/Staff/Teacher | JwtAuthGuard, RoleGuard | PersonClassifications_READ | PersonClassificationsController | List/search mother tongues, paginated. |
| Admin | POST | /api/lookups/mother-tongues | Admin | JwtAuthGuard, RoleGuard | PersonClassifications_CREATE | PersonClassificationsController | Add a mother tongue. |
| Admin | PATCH | /api/lookups/mother-tongues/:publicId | Admin | JwtAuthGuard, RoleGuard | PersonClassifications_UPDATE | PersonClassificationsController | Rename or retire/reactivate an entry. |
| Admin | DELETE | /api/lookups/mother-tongues/:publicId | Admin | JwtAuthGuard, RoleGuard | PersonClassifications_DELETE | PersonClassificationsController | Hard delete — permitted only when no users row references it. |
| Admin | GET | /api/school-profile | Admin | JwtAuthGuard, RoleGuard | SchoolProfile_READ | SchoolProfileController | Read the single school profile row. |
| Admin | PATCH | /api/school-profile | Admin | JwtAuthGuard, RoleGuard | SchoolProfile_UPDATE | SchoolProfileController | Update 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
| Surface | Guard/Decorator | Identity Shape | Permission | Guest Allowed | Notes |
|---|---|---|---|---|---|
| All eighteen routes above | @UseGuards(JwtAuthGuard, RoleGuard) at the controller class level | req.user populated by the JWT strategy; activeRole resolved from it | One of the eighteen codes in the surface map above | No | No route in this module carries @Public(). |
- Auth is mandatory on every route.
JwtAuthGuardrejects a missing/invalid token with401 AUTH_UNAUTHENTICATEDbeforeRoleGuardever 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_READis the one permission in this document not confined to an administrative role.packages/db/src/seed/seed-auth.tsseeds it ontoSTAFF_PERMISSIONSandTEACHER_PERMISSIONSalongsideStudents_READ/Guardians_READ/Staff_READ/Departments_READ/Designations_READ/SchoolProfile_READ/Activity_READ— every other code in this document, and all fourPersonClassificationswrite codes, are granted only through an explicit administrative role. A client should not assume a caller who canGET /api/lookups/ethnicitiescan 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 gets403 PERMISSION_ROLE_NOT_ASSIGNED. - Superadmin bypass. A role with
is_superadmin = trueskips the permission list entirely. This is keyed on the boolean flag, never on the role'snamestring. - Permission catalog mechanics.
Departments,Designations,PersonClassifications, andSchoolProfileare each declared once inPERMISSION_MODULES(packages/db/src/authorization/permission-catalog.ts:78-91), and the full catalog is generated as every module crossed with every action inPERMISSION_ACTIONS(CREATE,READ,UPDATE,DELETE,RESTORE). This meansDepartments_RESTORE,Designations_RESTORE,PersonClassifications_RESTORE,SchoolProfile_CREATE,SchoolProfile_DELETE, andSchoolProfile_RESTOREall 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 byJwtAuthGuard.
6. DTO and Model Reference
6.1 DepartmentDto (response)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
id | number | Yes | Server-generated | N/A — response-only, internal serial id | 12 | department.dto.ts:29 |
publicId | string (UUID) | Yes | Server-generated (uuid7()) | N/A | "018f2a1e-..." | department.dto.ts:30 |
name | string | Yes | — | Unique case-insensitively | "Science" | department.dto.ts:31 |
code | string | null | Yes (nullable) | null | — | "SCI" | department.dto.ts:32-33 |
description | string | null | Yes (nullable) | null | — | "Physical and life sciences" | department.dto.ts:34-35 |
isActive | boolean | Yes | true | — | true | department.dto.ts:36 |
designationCount | number | Yes | Computed | Correlated subquery count of designations rows for this department | 4 | department.dto.ts:37; computed in lookups.service.ts (findAllDepartments) |
createdAt | string (ISO date) | Yes | Server-generated | — | "2026-01-10T04:15:00.000Z" | department.dto.ts:38 |
updatedAt | string (ISO date) | Yes | Server-generated | — | "2026-01-10T04:15:00.000Z" | department.dto.ts:39 |
designationCount on a freshly created department is always 0 — createDepartment 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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
name | string | Yes | — | @IsString, @MinLength(1), @MaxLength(120) | "Science" | department.dto.ts:41-46 |
code | string | No | — | @IsOptional, @IsString, @MaxLength(32) | "SCI" | department.dto.ts:48-52 |
description | string | No | — | @IsOptional, @IsString, @MaxLength(500) | "Physical and life sciences" | department.dto.ts:54-58 |
isActive | boolean | No | true (applied in the service, not the DTO) | @IsOptional, @IsBoolean | true | department.dto.ts:60-63; default applied in lookups.service.ts (dto.isActive ?? true) |
6.3 UpdateDepartmentDto (body)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
name | string | No | Unchanged if omitted | @IsOptional, @IsString, @MinLength(1), @MaxLength(120) | "Sciences" | department.dto.ts:66-71 |
code | string | No | Unchanged if omitted; an empty string clears it to null | @IsOptional, @IsString, @MaxLength(32) | "" | department.dto.ts:73-77 |
description | string | No | Same clearing behavior as code | @IsOptional, @IsString, @MaxLength(500) | "" | department.dto.ts:79-83 |
isActive | boolean | No | Unchanged if omitted | @IsOptional, a query-style boolean transform ("true"/"1" also coerce), then @IsBoolean | false | department.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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
isActive | boolean | No | Unset (no filter) | Query-string boolean transform, then @IsBoolean | ?isActive=true | department.dto.ts:96-100 |
search, pagination, page, size, sort, order | Inherited from QueryDto | No | See 6.7 | Inherited | — | query.dto.ts |
6.5 ListDesignationsQueryDto (query, extends QueryDto)
Declared inside department.dto.ts, not designation.dto.ts — verified by reading both files.
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
departmentId | number | No | Unset (no filter) | @Type(() => Number), @IsInt | ?departmentId=3 | department.dto.ts:104-108 |
isTeaching | boolean | No | Unset (no filter) | Query-string boolean transform, then @IsBoolean | ?isTeaching=true | department.dto.ts:110-118; this is the exact filter the Teachers screen applies |
isActive | boolean | No | Unset (no filter) | Same as isTeaching | ?isActive=true | department.dto.ts:120-124 |
search, pagination, page, size, sort, order | Inherited from QueryDto | No | See 6.7 | Inherited | — | query.dto.ts |
6.6 DesignationDto (response)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
id | number | Yes | Server-generated | Internal serial id | 7 | designation.dto.ts:12 |
publicId | string (UUID) | Yes | Server-generated | — | "018f2a1f-..." | designation.dto.ts:13 |
departmentId | number | Yes | — | The parent department's internal id, not its publicId | 3 | designation.dto.ts:14 |
departmentName | string | Yes | Denormalized 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 |
name | string | Yes | — | Unique case-insensitively within departmentId | "Senior Teacher" | designation.dto.ts:16 |
isTeaching | boolean | Yes | false | — | true | designation.dto.ts:17 |
isActive | boolean | Yes | true | — | true | designation.dto.ts:18 |
staffCount | number | Yes | Computed | Correlated subquery count of non-soft-deleted staff rows holding this designation | 12 | designation.dto.ts:19; computed in findAllDesignations |
createdAt | string (ISO date) | Yes | Server-generated | — | — | designation.dto.ts:20 |
updatedAt | string (ISO date) | Yes | Server-generated | — | — | designation.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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
departmentId | number | Yes | — | @IsInt | 3 | designation.dto.ts:26-28 |
name | string | Yes | — | @IsString, @MinLength(1), @MaxLength(120) | "Senior Teacher" | designation.dto.ts:30-34 |
isTeaching | boolean | No | false (applied in the service) | @IsOptional, @IsBoolean | true | designation.dto.ts:36-39 |
isActive | boolean | No | true (applied in the service) | @IsOptional, @IsBoolean | true | designation.dto.ts:41-44 |
6.8 UpdateDesignationDto (body)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
name | string | No | Unchanged if omitted | @IsOptional, @IsString, @MinLength(1), @MaxLength(120) | "Lead Teacher" | designation.dto.ts:57-62 |
isTeaching | boolean | No | Unchanged if omitted | @IsOptional, @IsBoolean | false | designation.dto.ts:64-67 |
isActive | boolean | No | Unchanged if omitted | @IsOptional, @IsBoolean | false | designation.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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
name | string | Yes | "" (column default) | — | "Green Valley School" | school-profile.dto.ts:13 |
logoUrl | string | null | Yes (nullable) | null | Not validated as a URL — plain string | "https://cdn.example/logo.png" | school-profile.dto.ts:14-15 |
registrationNumber | string | null | Yes (nullable) | null | — | "REG-2019-0042" | school-profile.dto.ts:16-17 |
address | AddressDto | Yes | All fields null | The 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 | |
phone | string | null | Yes (nullable) | null | — | "+977-1-4123456" | school-profile.dto.ts:25 |
email | string | null | Yes (nullable) | null | — | "info@school.edu.np" | school-profile.dto.ts:26 |
website | string | null | Yes (nullable) | null | — | "https://school.edu.np" | school-profile.dto.ts:27 |
academicYearStartMonth | number | Yes | 4 | DB CHECK BETWEEN 1 AND 12 | 4 | school-profile.dto.ts:28-33 |
currencyCode | string | Yes | "NPR" | — | "NPR" | school-profile.dto.ts:38 |
timezone | string | Yes | "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 |
principal | SchoolPrincipalDto | null | Yes (nullable) | Derived, not stored | Null when nobody actively holds the designation flagged is_principal | { "fullName": "Anita Sharma", "designationName": "Principal", "departmentName": "Administration" } | school-profile.dto.ts |
updatedAt | string (ISO date) | Yes | Server-generated | — | — | school-profile.dto.ts:43 |
id and createdAt are read from the database row but omitted from the response — SchoolProfileService.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
| Field | Type | Notes |
|---|---|---|
fullName | string | null | Nullable because users.fullName is; a person record with no name is a broken row, and the profile still has to render rather than crash. |
designationName | string | The name of the designation flagged is_principal (typically "Principal", but this is data, not a hardcoded string). |
departmentName | string | The department that designation belongs to. |
6.10 UpdateSchoolProfileDto (body)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
name | string | No | Unchanged if omitted | @IsOptional, @IsString, @MaxLength(200) | "Green Valley School" | school-profile.dto.ts:46-50 |
logoUrl | string | No | Unchanged if omitted | @IsOptional, @IsString, @MaxLength(500) — not a URL format check | "https://cdn.example/logo.png" | school-profile.dto.ts:52-56 |
registrationNumber | string | No | Unchanged if omitted | @IsOptional, @IsString, @MaxLength(100) | "REG-2019-0042" | school-profile.dto.ts:58-62 |
principalName | string | No | Accepted, ignored | @IsOptional, @IsString, @MaxLength(200) | "Anita Sharma" | school-profile.dto.ts:64-68 |
address | AddressInputDto | No | Untouched 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 |
phone | string | No | Unchanged if omitted | @IsOptional, @IsString, @MaxLength(40) | "+977-1-4123456" | school-profile.dto.ts:94-98 |
email | string | No | Unchanged if omitted | @IsOptional, @IsEmail | "info@school.edu.np" | school-profile.dto.ts:100-103 |
website | string | No | Unchanged if omitted | @IsOptional, @IsString, @MaxLength(200) | "https://school.edu.np" | school-profile.dto.ts:105-109 |
academicYearStartMonth | number | No | Unchanged if omitted | @IsOptional, @IsInt, @Min(1), @Max(12) | 4 | school-profile.dto.ts:111-116 |
brandPrimaryColor | string | No | Accepted, ignored | @IsOptional, @IsHexColor | "#1A6FBF" | school-profile.dto.ts:118-121 |
brandSecondaryColor | string | No | Accepted, ignored | @IsOptional, @IsHexColor | "#155A9C" | school-profile.dto.ts:123-126 |
currencyCode | string | No | Unchanged if omitted | @IsOptional, @IsString, @MaxLength(8) — not validated against ISO 4217 | "NPR" | school-profile.dto.ts:128-131 |
timezone | string | No | Unchanged 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
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
pagination | boolean | No | true | Query-string boolean transform, @IsBoolean | ?pagination=false | query.dto.ts:15-28 |
page | number | No | 1 | @IsInt, @Min(1) | ?page=2 | query.dto.ts:30-35 |
size | number | No | 20 | @IsInt, @Min(1), @Max(100) — silently clamped to 100 by PaginationUtil, not rejected | ?size=50 | query.dto.ts:37-42 |
sort | string | No | "updatedAt" | @IsString — not 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=name | query.dto.ts:44-47 |
order | "asc" | "desc" | No | "desc" | @IsEnum(["asc", "desc"]) | ?order=asc | query.dto.ts:49-52 |
search | string | No | — (no filter) | @IsString, @MaxLength(100), trimmed | ?search=sci | query.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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
id | number | Yes | Server-generated | N/A — the internal serial id, but not an internal-only field here | 42 | person-classification.dto.ts:46 |
publicId | string (UUID) | Yes | Server-generated (uuid7()) | N/A | "018f2a20-..." | person-classification.dto.ts:47 |
name | string | Yes | — | Unique case-insensitively within the same list (ethnicities and mother tongues never collide with each other) | "Chhetri" or "Nepali" | person-classification.dto.ts:48 |
isActive | boolean | Yes | true | — | true | person-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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
isActive | boolean | No | Unset (no filter) | Query-string boolean transform, then @IsBoolean | ?isActive=true | person-classification.dto.ts:61-68 |
order | "asc" | "desc" | No | "asc" — overridden from QueryDto's own "desc" default | @IsIn(["asc", "desc"]) | ?order=desc | person-classification.dto.ts:80-88 |
search, pagination, page, size | Inherited from QueryDto, unmodified | No | See 6.11 | Inherited | — | query.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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
name | string | Yes | — | Trimmed by @Transform before @IsString() @IsNotEmpty() @MaxLength(120) run | "Bhote" | person-classification.dto.ts:98-111 |
isActive | boolean | No | true (applied in the service) | @IsOptional, @IsBoolean | true | person-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)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
name | string | No | Unchanged if omitted | Same trim-then-validate ordering as CreatePersonClassificationDto: @Transform, then @IsString() @IsNotEmpty() @MaxLength(120) | "Bhote (Sherpa)" | person-classification.dto.ts:132-142 |
isActive | boolean | No | Unchanged if omitted | @IsOptional, @IsBoolean | false | person-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
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/lookups/lookups.controller.ts:44-53 |
| DTO | apps/api/src/modules/lookups/dto/department.dto.ts (ListDepartmentsQueryDto, DepartmentDto) |
| Service | apps/api/src/modules/lookups/lookups.service.ts (findAllDepartments) |
| Schema | packages/db/src/schema/school/lookups.ts |
| Tests | N/A — no spec file exists for this module. |
Auth and Permissions
- Auth: Required (
JwtAuthGuard). - Guard chain:
JwtAuthGuard→RoleGuard. - Permission:
Departments_READ. - Guest support: None.
- Rate limit: None module-specific.
- Idempotency: N/A (read).
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>. |
| Params | No | — |
| Query | No | search, isActive, pagination, page, size, sort ("name" or default updatedAt), order. |
| Body | No | — |
GET /api/departments?search=sci&isActive=true&page=1&size=20&sort=name&order=asc HTTP/1.1Response
{
"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:
getSoftis 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):
SELECTondepartments(plus a correlated subquery per row fordesignationCount); a separateCOUNT(*)when pagination is enabled. - Cache write (cache miss only):
setSoftat a 3600-second TTL — see 11. - No jobs, realtime events, notifications, audit logs, or external calls.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. | Re-authenticate. | jwt-auth.guard.ts |
403 | PERMISSION_INSUFFICIENT | Active role lacks Departments_READ. | Not authorized to view departments. | role.guard.ts |
400 | VALIDATION_FAILED | An invalid query value (e.g. page=0, order=up). | Fix the query string. | Global ValidationPipe |
Edge Cases
- Empty
search(?search=):QueryDto's own@Transformreturnsundefinedfor an empty string, so the filter is dropped entirely — the unfiltered list is returned, not zero rows. searchcontaining%or_: escaped viaescapeLikePatternbefore entering theILIKEpattern, so?search=100%matches literal"100%"substrings only, not "everything starting with 100".pagination=false: every matching row up toPaginationUtil.UNPAGINATED_HARD_CAP(1000) is returned in one response, andcount/currentPage/totalPageare 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.sizeabove100: silently clamped to100byPaginationUtil.normalize, not rejected.sortset to an unrecognized column name: silently falls back to sorting byupdatedAt— 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
idascending after the requested sort column, so rows created in the same transaction (identicalupdatedAt) 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
| Evidence | Path |
|---|---|
| Controller | lookups.controller.ts:55-63 |
| DTO | department.dto.ts (CreateDepartmentDto) |
| Service | lookups.service.ts (createDepartment, assertDepartmentNameFree) |
| Schema | lookups.ts (departments_name_unique) |
| Tests | N/A |
Auth and Permissions
- Auth: Required.
- Guard chain:
JwtAuthGuard→RoleGuard. - 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
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Params | No | — |
| Query | No | — |
| Body | Yes | CreateDepartmentDto — 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
INSERTintodepartments. - Cache: every cached page under both the
lookups:departments:*andlookups: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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | name missing, too short/long, or an unknown field is present in the body. | Fix the request body. | Global ValidationPipe |
409 | DEPARTMENT_NAME_TAKEN | Another department already has this name (case-insensitive), caught by the pre-check. | Choose a different name. | lookups.service.ts (assertDepartmentNameFree) |
409 | RESOURCE_ALREADY_EXISTS | Two 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) |
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. | Re-authenticate. | jwt-auth.guard.ts |
403 | PERMISSION_INSUFFICIENT | Active 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/descriptionis 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"", notnull(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
| Evidence | Path |
|---|---|
| Controller | lookups.controller.ts:65-76 |
| DTO | department.dto.ts (UpdateDepartmentDto) |
| Service | lookups.service.ts (updateDepartment) |
| Schema | lookups.ts |
| Tests | N/A |
Auth and Permissions
- Auth: Required.
- Guard chain:
JwtAuthGuard→RoleGuard. - 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
updatedAteach time).
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Params | Yes | publicId — the department's UUID. |
| Query | No | — |
| Body | Yes | UpdateDepartmentDto, 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
UPDATEon the matcheddepartmentsrow, only for the fields present in the body, plusupdatedAt. - Cache: same invalidation as create — both prefixes cleared entirely.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | DEPARTMENT_NOT_FOUND | No department with this publicId. | The department may have been deleted; refresh the list. | lookups.service.ts (findDepartmentOrThrow) |
409 | DEPARTMENT_NAME_TAKEN | Renaming 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 |
409 | RESOURCE_ALREADY_EXISTS | Race past the rename pre-check. | Refresh and retry. | Global unique-violation fallback |
400 | VALIDATION_FAILED | Invalid field value or an unknown field in the body. | Fix the request body. | Global ValidationPipe |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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: falseon 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/descriptionon update clears the field tonull(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
| Evidence | Path |
|---|---|
| Controller | lookups.controller.ts:78-89 |
| DTO | None — no body. |
| Service | lookups.service.ts (deleteDepartment) |
| Schema | lookups.ts (departments.id referenced by designations.department_id ON DELETE restrict) |
| Tests | N/A |
Auth and Permissions
- Auth: Required.
- Guard chain:
JwtAuthGuard→RoleGuard. - Permission:
Departments_DELETE. - Guest support: None.
- Rate limit: None module-specific.
- Idempotency: Not idempotent in the strict sense — a second identical
DELETEon an already-deletedpublicIdreturns404, not a repeated200.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>. |
| Params | Yes | publicId. |
| Query | No | — |
| Body | No | — |
Response
{
"message": "Department deleted.",
"data": null,
"errorCode": null
}Side Effects
- Database writes: one
DELETEondepartments, only reached after the designation-existence check finds no rows. - Cache: same invalidation as create/update — both prefixes cleared entirely.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | DEPARTMENT_NOT_FOUND | No department with this publicId. | Already deleted, or never existed. | lookups.service.ts |
409 | DEPARTMENT_HAS_DESIGNATIONS | At 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 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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 inspectsdesignations. The delete succeeds; thestaff.department_id ON DELETE restrictFK on the schema itself would then be the only remaining backstop, and aDELETEreaching that FK with staff still attached would 500 as an unmapped23503rather than a friendly409— the service-level guard here only covers the designation path, not the direct-staff path. - Deleting a department that is
isActive: falsebehaves 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
| Evidence | Path |
|---|---|
| Controller | lookups.controller.ts:105-114 |
| DTO | department.dto.ts (ListDesignationsQueryDto), designation.dto.ts (DesignationDto) |
| Service | lookups.service.ts (findAllDesignations) |
| Schema | lookups.ts, people.ts (staff) |
| Tests | N/A |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. Permission:Designations_READ. Guest support: none. Rate limit: none module-specific. Idempotency: N/A (read).
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>. |
| Params | No | — |
| Query | No | search, departmentId, isTeaching, isActive, pagination, page, size, sort, order. |
| Body | No | — |
GET /api/designations?departmentId=3&isTeaching=true&isActive=true HTTP/1.1Response
{
"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:
getSofttried 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-deletedstaffforstaffCount. - Cache write (cache miss only):
setSoftat a 3600-second TTL.
Error Cases
Same shape as 8.1 — 401 AUTH_UNAUTHENTICATED, 403 PERMISSION_INSUFFICIENT, 400 VALIDATION_FAILED.
Edge Cases
staffCountexcludes soft-deleted staff (staff.deletedAt IS NULLis part of the subquery) — a designation held only by staff who have since left still reports0here, 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 ondeletedAt). These two numbers can legitimately disagree.departmentIdfor a department that does not exist: returns an empty list, not404— the filter is a plainWHERE, not an existence check.- All other edge cases match 8.1 (empty search,
%/_escaping, pagination clamping, unknownsortfalling back toupdatedAt, tie-break byid).
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
| Evidence | Path |
|---|---|
| Controller | lookups.controller.ts:116-124 |
| DTO | designation.dto.ts (CreateDesignationDto) |
| Service | lookups.service.ts (createDesignation) |
| Schema | lookups.ts (designations_department_name_unique) |
| Tests | N/A |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. 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 ondesignations(name pre-check scoped todepartmentId). - Database writes: one
INSERTintodesignations. - Cache: same invalidation as every lookup write.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | DEPARTMENT_NOT_FOUND | departmentId does not reference an existing department. | Choose a valid department. | lookups.service.ts (createDesignation) |
409 | DESIGNATION_NAME_TAKEN | Another designation in the same department already has this name (case-insensitive). | Choose a different name, or confirm the department. | lookups.service.ts |
409 | RESOURCE_ALREADY_EXISTS | Race past the name pre-check. | Refresh and retry. | Global unique-violation fallback |
400 | VALIDATION_FAILED | Missing/invalid departmentId/name, or an unknown field. | Fix the request body. | Global ValidationPipe |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As above. | As above. | — |
Edge Cases
- The same
nameis accepted in a different department — uniqueness is scoped perdepartmentId, not global. departmentIdreferencing a retired (isActive: false) department is still accepted — the existence check does not filter onisActive, so a designation can be created under a retired department without warning.isTeachingdefaults tofalsewhen omitted — a designation is not automatically a teaching one; the Teachers screen would not surface staff holding it unless this is explicitly settrue.
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
| Evidence | Path |
|---|---|
| Controller | lookups.controller.ts:126-141 |
| DTO | designation.dto.ts (UpdateDesignationDto) |
| Service | lookups.service.ts (updateDesignation) |
| Schema | lookups.ts |
| Tests | N/A |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. 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
UPDATEondesignations. - Cache: same invalidation as every lookup write.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | DESIGNATION_NOT_FOUND | No designation with this publicId. | Refresh the list. | lookups.service.ts |
409 | DESIGNATION_NAME_TAKEN | Renaming to a name another designation in the same department already holds. | Choose a different name. | lookups.service.ts |
409 | PRINCIPAL_DESIGNATION_RETIRE_FORBIDDEN | isActive: 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) |
409 | RESOURCE_ALREADY_EXISTS | Race past the rename pre-check. | Refresh and retry. | Global unique-violation fallback |
400 | VALIDATION_FAILED | A 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 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As above. | As above. | — |
Edge Cases
- Sending
departmentIdin the body (attempting to move the designation) is rejected with a generic400, not the more specificDESIGNATION_DEPARTMENT_IMMUTABLEerror code — that code exists in the registry but is never thrown by any code path (verified in the backend doc). - The designation flagged
is_principalcan 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.assertSinglePrincipallocks it, andSchoolProfileService/PrincipalInvariantService.resolvePrincipalresolves 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
isTeachingoff 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 touchesstaff. - Setting
isActive: falsewhile 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
| Evidence | Path |
|---|---|
| Controller | lookups.controller.ts:143-152 |
| DTO | None — no body. |
| Service | lookups.service.ts (deleteDesignation) |
| Schema | people.ts (staff.designation_id) |
| Tests | N/A |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. Permission:Designations_DELETE. Guest support: none. Rate limit: none module-specific. Idempotency: not idempotent — a secondDELETEon an already-deletedpublicIdreturns404.
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 againststaff.designationId. - Database writes: one
DELETEondesignations, only reached when no staff row (including soft-deleted ones) references it. - Cache: same invalidation as every lookup write.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | DESIGNATION_NOT_FOUND | No designation with this publicId. | Already deleted, or never existed. | lookups.service.ts |
409 | PRINCIPAL_DESIGNATION_DELETE_FORBIDDEN | The 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) |
409 | DESIGNATION_IN_USE | At 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 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As above. | As above. | — |
Edge Cases
- A designation whose only holders have all been soft-deleted from
staffis still blocked from deletion — the existence check does not filter onstaff.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_FORBIDDENcheck 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
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/school-profile/school-profile.controller.ts:17-21 |
| DTO | school-profile.dto.ts (SchoolProfileDto) |
| Service | school-profile.service.ts (get) |
| Schema | packages/db/src/schema/school/school-profile.ts |
| Tests | N/A |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. 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:profilefirst (getSoft); on a hit, no database query runs at all. - Database reads (cache miss only): one
SELECTonschool_profileby the hardcoded id1. - Cache write (cache miss only):
setSoft("school:profile", dto, 3600). - No jobs, realtime events, notifications, audit logs, or external calls.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | SCHOOL_PROFILE_NOT_INITIALIZED | Row 1 is missing from school_profile. | Should not occur in a normally-migrated database; contact an operator. | school-profile.service.ts |
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. | Re-authenticate. | jwt-auth.guard.ts |
403 | PERMISSION_INSUFFICIENT | Active role lacks SchoolProfile_READ. | Not authorized. | role.guard.ts |
Edge Cases
- A Redis outage during the cache read is fail-soft:
getSoftlogs aWARNand 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
| Evidence | Path |
|---|---|
| Controller | school-profile.controller.ts:23-30 |
| DTO | school-profile.dto.ts (UpdateSchoolProfileDto) |
| Service | school-profile.service.ts (update) |
| Schema | school-profile.ts (both CHECK constraints) |
| Tests | N/A |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. Permission:SchoolProfile_UPDATE. Guest support: none. Rate limit: none module-specific. Idempotency: naturally idempotent — resubmitting the same body reapplies the same values (with a bumpedupdatedAt).
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 *onschool_profileid1, 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 Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | SCHOOL_PROFILE_NOT_INITIALIZED | The UPDATE matches no row (row 1 missing). | Should not occur in a normally-migrated database. | school-profile.service.ts |
400 | VALIDATION_FAILED | An invalid field value (bad email, non-hex color, month outside 1-12) or an unknown field. | Fix the request body. | Global ValidationPipe |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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
timezoneis 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
currencyCodeis 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
| Evidence | Path |
|---|---|
| Controller | lookups.controller.ts:196-210 |
| DTO | person-classification.dto.ts (ListPersonClassificationsQueryDto, PersonClassificationDto) |
| Service | person-classifications.service.ts (findAllPersonClassifications) |
| Schema | packages/db/src/schema/identity.ts |
| Tests | person-classifications.service.spec.ts (real Postgres). |
Auth and Permissions
- Auth: Required. Guard chain:
JwtAuthGuard→RoleGuard. 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
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>. |
| Params | No | — |
| Query | No | search, isActive, pagination, page, size, order (default asc, unlike every other list in this module). No sort field — always ordered by name. |
| Body | No | — |
GET /api/lookups/ethnicities?search=chh&isActive=true&pagination=false HTTP/1.1Response
{
"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:
getSofton a deterministic key folding the full query, prefixedlookups:classifications:ethnicities:; a hit returns with no database round trip. - Database reads (cache miss only):
SELECTonethnicities, plus aCOUNT(*)when paginated. - Cache write (cache miss only):
setSoftat a 3600-second TTL. - No jobs, realtime events, notifications, audit logs, or external calls.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. | Re-authenticate. | jwt-auth.guard.ts |
403 | PERMISSION_INSUFFICIENT | Active role lacks PersonClassifications_READ. | Not authorized to view this list. | role.guard.ts |
400 | VALIDATION_FAILED | An 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 (
orderdefaults to"asc"on this DTO only) — an explicit?order=descstill overrides it. pagination=falseon the full 56-row seeded list returns all of it in one response; the 1000-rowUNPAGINATED_HARD_CAPis 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_READdoes 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
| Evidence | Path |
|---|---|
| Controller | lookups.controller.ts:239-249 |
| DTO | person-classification.dto.ts (CreatePersonClassificationDto) |
| Service | person-classifications.service.ts (createPersonClassification, assertClassificationNameFree) |
| Schema | identity.ts (ethnicities_name_unique) |
| Tests | person-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:
JwtAuthGuard→RoleGuard. 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
INSERTintoethnicities. - Cache:
delPatternSoftclears only thelookups:classifications:ethnicities:*prefix — the mother-tongue cache is untouched. - No jobs, realtime events, notifications, audit logs, or external calls.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | name missing, blank after trimming, too long, or an unknown field is present. | Fix the request body. | Global ValidationPipe |
409 | PERSON_CLASSIFICATION_NAME_TAKEN | Another ethnicity already has this name (case-insensitive), caught by the pre-check. | Choose a different name. | person-classifications.service.ts |
409 | RESOURCE_ALREADY_EXISTS | Two 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) |
401 | AUTH_UNAUTHENTICATED | Missing/invalid JWT. | Re-authenticate. | jwt-auth.guard.ts |
403 | PERMISSION_INSUFFICIENT | Active 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 as400 VALIDATION_FAILED, never inserted as a blank row.- The identical
nameis 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
| Evidence | Path |
|---|---|
| Controller | lookups.controller.ts:251-265 |
| DTO | person-classification.dto.ts (UpdatePersonClassificationDto) |
| Service | person-classifications.service.ts (updatePersonClassification) |
| Schema | identity.ts |
| Tests | person-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:
JwtAuthGuard→RoleGuard. 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
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <jwt>, Content-Type: application/json. |
| Params | Yes | publicId — validated as a UUID by ParseUUIDPipe before the handler runs. |
| Query | No | — |
| Body | Yes | UpdatePersonClassificationDto, 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(ParseUUIDPiperejects a malformed UUID with a400before this ever runs); a name pre-check only if renaming. - Database writes: one
UPDATEon the matchedethnicitiesrow. - Cache:
delPatternSofton the ethnicities prefix only.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | publicId is not a valid UUID, or the body fails DTO validation. | Fix the request. | ParseUUIDPipe / global ValidationPipe |
404 | PERSON_CLASSIFICATION_NOT_FOUND | No ethnicity with this publicId. | The entry may have been deleted; refresh the list. | person-classifications.service.ts |
409 | PERSON_CLASSIFICATION_NAME_TAKEN | Renaming 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 |
409 | RESOURCE_ALREADY_EXISTS | Race past the rename pre-check. | Refresh and retry. | Global unique-violation fallback |
401 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As 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: falseon 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
| Evidence | Path |
|---|---|
| Controller | lookups.controller.ts:267-276 |
| DTO | None — no body. |
| Service | person-classifications.service.ts (deletePersonClassification) |
| Schema | identity.ts (users.ethnicity_id ON DELETE set null) |
| Tests | person-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:
JwtAuthGuard→RoleGuard. Permission:PersonClassifications_DELETE— administrator only. Guest support: none. Rate limit: none module-specific. Idempotency: not idempotent — a secondDELETEon an already-deletedpublicIdreturns404.
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 againstusers.ethnicity_id. - Database writes: one
DELETEonethnicities, only reached when nousersrow references it. - Cache:
delPatternSofton the ethnicities prefix only.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | VALIDATION_FAILED | publicId is not a valid UUID. | Fix the request. | ParseUUIDPipe |
404 | PERSON_CLASSIFICATION_NOT_FOUND | No ethnicity with this publicId. | Already deleted, or never existed. | person-classifications.service.ts |
409 | PERSON_CLASSIFICATION_IN_USE | At 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 / 403 | AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENT | As above. | As above. | — |
Edge Cases
- The reference check is what makes this refusal honest:
users.ethnicity_idisON DELETE set null, so without it theDELETEwould 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
10. Pagination, Sorting, Filtering, and Search
| Endpoint | Pagination Type | Default Size | Max Size | Sort Fields | Filters | Result Cap |
|---|---|---|---|---|---|---|
GET /api/departments | page/size, or unpaginated with pagination=false | 20 | 100 | name, else updatedAt (default) | search, isActive | PaginationUtil.UNPAGINATED_HARD_CAP (1000) when unpaginated. |
GET /api/designations | Same | 20 | 100 | name, else updatedAt (default) | search, departmentId, isTeaching, isActive | Same (1000). |
GET /api/lookups/ethnicities | Same | 20 | 100 | Always name — no sort field exists on this DTO | search, isActive | Same (1000; the seeded list is 56 rows). |
GET /api/lookups/mother-tongues | Same | 20 | 100 | Always name | search, isActive | Same (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 —
searchalways runs as a plainILIKE %term%regardless of term length or specificity. - Relevance scoring: none — results are ordered strictly by the requested
sort/order(departments/designations) or always byname(classifications), withidas 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
dataarray withcount: 0, not an error. - A client wanting an entire reference list (any of the four) sends
pagination=false, not a largesize—sizeaboveMAX_PAGE_SIZE(100) is silently clamped to 100, never rejected and never honored above that ceiling.
11. Caching, Jobs, and External Integrations
| Integration | Used? | Details | Source |
|---|---|---|---|
| Redis cache (school profile) | Yes | Key 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) | Yes | Prefixes 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) | Yes | Prefixes 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 |
| BullMQ | No | No queue import in any service in this module. | — |
| Realtime | No | No Socket.IO emission in any service in this module. | — |
| External API | No | No 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
| Route | Controller Method | DTOs | Service Method | Guards | Permissions | Cache | Jobs | DB Touches | Errors | Tests | Documented? |
|---|---|---|---|---|---|---|---|---|---|---|---|
GET /api/departments | DepartmentsController.findAll | ListDepartmentsQueryDto → DepartmentDto[] | findAllDepartments | JWT, Role | Departments_READ | Reads + may populate lookups:departments:* | N/A | departments | 401,403,400 | N/A | Yes |
POST /api/departments | DepartmentsController.create | CreateDepartmentDto → DepartmentDto | createDepartment | JWT, Role | Departments_CREATE | Invalidates both lookups:departments:* and lookups:designations:* | N/A | departments | 401,403,400,409 | N/A | Yes |
PATCH /api/departments/:publicId | DepartmentsController.update | UpdateDepartmentDto → DepartmentDto | updateDepartment | JWT, Role | Departments_UPDATE | Invalidates both prefixes | N/A | departments | 401,403,400,404,409 | N/A | Yes |
DELETE /api/departments/:publicId | DepartmentsController.remove | None → null | deleteDepartment | JWT, Role | Departments_DELETE | Invalidates both prefixes | N/A | departments, reads designations | 401,403,404,409 | N/A | Yes |
GET /api/designations | DesignationsController.findAll | ListDesignationsQueryDto → DesignationDto[] | findAllDesignations | JWT, Role | Designations_READ | Reads + may populate lookups:designations:* | N/A | designations, departments (join), staff (count) | 401,403,400 | N/A | Yes |
POST /api/designations | DesignationsController.create | CreateDesignationDto → DesignationDto | createDesignation | JWT, Role | Designations_CREATE | Invalidates both prefixes | N/A | designations, reads departments | 401,403,400,404,409 | N/A | Yes |
PATCH /api/designations/:publicId | DesignationsController.update | UpdateDesignationDto → DesignationDto | updateDesignation | JWT, Role | Designations_UPDATE | Invalidates both prefixes | N/A | designations, reads departments | 401,403,400,404,409 | N/A | Yes |
DELETE /api/designations/:publicId | DesignationsController.remove | None → null | deleteDesignation | JWT, Role | Designations_DELETE | Invalidates both prefixes | N/A | designations, reads staff | 401,403,404,409 | N/A | Yes |
GET /api/lookups/ethnicities | PersonClassificationsController.findAllEthnicities | ListPersonClassificationsQueryDto → PersonClassificationDto[] | findAllPersonClassifications("ethnicities", ...) | JWT, Role | PersonClassifications_READ | Reads + may populate lookups:classifications:ethnicities:* | N/A | ethnicities | 401,403,400 | person-classifications.service.spec.ts | Yes |
POST /api/lookups/ethnicities | PersonClassificationsController.createEthnicity | CreatePersonClassificationDto → PersonClassificationDto | createPersonClassification("ethnicities", ...) | JWT, Role | PersonClassifications_CREATE | Invalidates lookups:classifications:ethnicities:* only | N/A | ethnicities | 401,403,400,409 | person-classifications.service.spec.ts | Yes |
PATCH /api/lookups/ethnicities/:publicId | PersonClassificationsController.updateEthnicity | UpdatePersonClassificationDto → PersonClassificationDto | updatePersonClassification("ethnicities", ...) | JWT, Role | PersonClassifications_UPDATE | Invalidates ethnicities prefix only | N/A | ethnicities | 401,403,400,404,409 | person-classifications.service.spec.ts | Yes |
DELETE /api/lookups/ethnicities/:publicId | PersonClassificationsController.deleteEthnicity | None → null | deletePersonClassification("ethnicities", ...) | JWT, Role | PersonClassifications_DELETE | Invalidates ethnicities prefix only | N/A | ethnicities, reads users | 401,403,400,404,409 | person-classifications.service.spec.ts | Yes |
GET /api/lookups/mother-tongues | PersonClassificationsController.findAllMotherTongues | ListPersonClassificationsQueryDto → PersonClassificationDto[] | findAllPersonClassifications("mother-tongues", ...) | JWT, Role | PersonClassifications_READ | Reads + may populate lookups:classifications:mother-tongues:* | N/A | mother_tongues | 401,403,400 | person-classifications.service.spec.ts | Yes |
POST /api/lookups/mother-tongues | PersonClassificationsController.createMotherTongue | CreatePersonClassificationDto → PersonClassificationDto | createPersonClassification("mother-tongues", ...) | JWT, Role | PersonClassifications_CREATE | Invalidates mother-tongues prefix only | N/A | mother_tongues | 401,403,400,409 | person-classifications.service.spec.ts | Yes |
PATCH /api/lookups/mother-tongues/:publicId | PersonClassificationsController.updateMotherTongue | UpdatePersonClassificationDto → PersonClassificationDto | updatePersonClassification("mother-tongues", ...) | JWT, Role | PersonClassifications_UPDATE | Invalidates mother-tongues prefix only | N/A | mother_tongues | 401,403,400,404,409 | person-classifications.service.spec.ts | Yes |
DELETE /api/lookups/mother-tongues/:publicId | PersonClassificationsController.deleteMotherTongue | None → null | deletePersonClassification("mother-tongues", ...) | JWT, Role | PersonClassifications_DELETE | Invalidates mother-tongues prefix only | N/A | mother_tongues, reads users | 401,403,400,404,409 | person-classifications.service.spec.ts | Yes |
GET /api/school-profile | SchoolProfileController.get | None → SchoolProfileDto | get | JWT, Role | SchoolProfile_READ | Reads + may populate school:profile | N/A | school_profile (miss only) | 401,403,404 | school-profile.service.spec.ts | Yes |
PATCH /api/school-profile | SchoolProfileController.update | UpdateSchoolProfileDto → SchoolProfileDto | update | JWT, Role | SchoolProfile_UPDATE | Invalidates school:profile | N/A | school_profile | 401,403,400,404 | school-profile.service.spec.ts | Yes |
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
| Consumer | Required Knowledge | Failure Handling | Contract Stability |
|---|---|---|---|
| Admin panel | All 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. |
| QA | Reproduce 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 frontend | This 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/A | N/A — not exposed to that surface. |
13.5 API Tradeoffs and Rationale
| Decision | Chosen Behavior | Alternatives Considered | Why This Tradeoff | Risk | Mitigation |
|---|---|---|---|---|---|
| Hard delete, not soft delete, for departments/designations/classifications | DELETE permanently removes the row, blocked while referenced | Soft delete (deletedAt), matching staff | A 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 cursor | pagination/page/size on all four list endpoints | Cursor-based pagination | Lookup 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 alone | A friendly 409 with a specific error code from the pre-check; the DB index as the real backstop | DB index only, with the generic global unique-violation fallback for every clash | A 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 query | Repeat 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 change | Every 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-only | Both list endpoints under /lookups succeed for a non-administrative caller | Require an administrative role for every route in this module, as departments/designations/school-profile do | The 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 columns | Always-fresh counts on GET, stale placeholder (0) on POST/PATCH responses | Denormalized counter columns updated by triggers or application code | Avoids 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 POST | A resubmitted create request creates a duplicate resource unless the name collides | Client-supplied idempotency key, deduplicated server-side | These 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
| Change | Affected Consumers | Backend Impact | Data Impact | Migration Needed? | Compatibility Plan |
|---|---|---|---|---|---|
Adding a departmentId move path to PATCH /designations/:publicId | Admin panel | Would 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 key | None — no schema change | No | Additive; existing clients that never send departmentId are unaffected. |
| Adding a versioned/audited rename on `PATCH /lookups/ethnicities | mother-tongues/:publicId` | Admin panel | Would need an audit table or a renamed_from-style column, since the current rename is unconditional and unlogged | Possibly — an audit table addition | Possibly |
Validating timezone against a real IANA list on PATCH /school-profile | Admin panel | A previously-accepted (but wrong) timezone string would now 400 | None | No | Existing 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
/apiglobal 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
orderdefault override. - Every response field, nullable field, and server-generated field is documented;
id/createdAtomission on the school profile response is called out explicitly; the deliberate exposure of bothidandpublicIdonPersonClassificationDtois called out as intentional, not an inconsistency. - Every auth guard and permission is documented (§5); the catalog's unused
_RESTORE/SchoolProfile_CREATE/SchoolProfile_DELETEcodes 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
PersonClassificationKindunion 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
- Backend doc:
/docs/developer/school/backend - Features and flows doc:
/docs/developer/school/feature