Skoolsewa - School Management System Docs
Developer ResourcesSchool

School API Reference

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

School - API Reference

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

1. Documentation Evidence

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

2. Module Summary

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

3. Concepts and Terminology

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

4. API Surface Map

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

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

5. Auth, Identity, and Permissions

SurfaceGuard/DecoratorIdentity ShapePermissionGuest AllowedNotes
All eighteen routes above@UseGuards(JwtAuthGuard, RoleGuard) at the controller class levelreq.user populated by the JWT strategy; activeRole resolved from itOne of the eighteen codes in the surface map aboveNoNo route in this module carries @Public().
  • Auth is mandatory on every route. JwtAuthGuard rejects a missing/invalid token with 401 AUTH_UNAUTHENTICATED before RoleGuard ever runs.
  • Every handler declares a permission. RoleGuard's fail-open branch (a handler with zero @Permissions() on a non-admin-classified controller) never applies here, because every handler in this module declares one — verified by reading both controller files in full.
  • PersonClassifications_READ is the one permission in this document not confined to an administrative role. packages/db/src/seed/seed-auth.ts seeds it onto STAFF_PERMISSIONS and TEACHER_PERMISSIONS alongside Students_READ/Guardians_READ/Staff_READ/Departments_READ/Designations_READ/SchoolProfile_READ (Activity_READ and the admin-screen codes StaffView_READ/DepartmentView_READ/SchoolProfileView_READ are on neither seeded role: a teacher also holds staff and can switch to it, so they were taken off both — TEACHER_PERMISSIONS is STAFF_PERMISSIONS plus the teaching screens) — every other code in this document, and all four PersonClassifications write codes, are granted only through an explicit administrative role. A client should not assume a caller who can GET /api/lookups/ethnicities can also reach any other route in this module.
  • Active-role scoping. Permissions resolve from the caller's currently-active role only. A user holding several roles who has not selected one is refused with 403 AUTH_ACTIVE_ROLE_REQUIRED; a user with no role at all gets 403 PERMISSION_ROLE_NOT_ASSIGNED.
  • Superadmin bypass. A role with is_superadmin = true skips the permission list entirely. This is keyed on the boolean flag, never on the role's name string.
  • Permission catalog mechanics. Departments, Designations, PersonClassifications, and SchoolProfile are each declared once in PERMISSION_MODULES (packages/db/src/authorization/permission-catalog.ts:78-91), and the full catalog is generated as every module crossed with every action in PERMISSION_ACTIONS (CREATE, READ, UPDATE, DELETE, RESTORE). This means Departments_RESTORE, Designations_RESTORE, PersonClassifications_RESTORE, SchoolProfile_CREATE, SchoolProfile_DELETE, and SchoolProfile_RESTORE all exist as valid, grantable, seeded permission codes — but no route in this module ever checks them. A consumer building a role-management screen should not infer a corresponding route exists just because the permission code does.
  • Screen codes, split from the data codes. StaffView_READ, DepartmentView_READ and SchoolProfileView_READ gate the panel's Staff, Departments and School profile admin SCREENS and appear on zero API routes; every route here keeps its data code (Departments_READ, SchoolProfile_READ, and Staff_READ on GET /api/staff). The split exists so the seeded staff and teacher roles keep the data reads their own screens call — the Teachers list, its department lookups, the report print header — without the admin screens. Migration 0055_admin_screen_view_codes.sql granted each screen code to every role that held its data code, except the seeded teacher role (name = 'teacher' AND is_system_role = true); migration 0056_staff_revoke_admin_screens.sql then revoked the three screen codes and Activity_READ from the seeded staff role (name = 'staff' AND is_system_role = true), leaving custom roles and superadmin untouched. A school that wants an office administrator to open these screens creates a role holding them.
  • Headers parsed but not trusted: not applicable — no route in this module reads any identity-bearing header other than the standard Authorization: Bearer <jwt> consumed by JwtAuthGuard.

6. DTO and Model Reference

6.1 DepartmentDto (response)

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

designationCount on a freshly created department is always 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)

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

6.3 UpdateDepartmentDto (body)

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

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

6.4 ListDepartmentsQueryDto (query, extends QueryDto)

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

6.5 ListDesignationsQueryDto (query, extends QueryDto)

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

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

6.6 DesignationDto (response)

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

staffCount is 0 on a create response — a designation that did not exist a moment ago has no staff — and an accurate count of non-soft-deleted staff on an update response, which re-counts staff for that designation (lookups.service.ts, updateDesignation). An update response for a department likewise carries its real designationCount, re-counted in updateDepartment.

6.7 CreateDesignationDto (body)

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

6.8 UpdateDesignationDto (body)

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

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

6.9 SchoolProfileDto (response)

FieldTypeRequiredDefaultValidationExampleSource
namestringYes"" (column default)—"Green Valley School"school-profile.dto.ts:112
logoUrlstring | nullYes (nullable)nullNot validated as a URL — plain string"https://cdn.example/logo.png"school-profile.dto.ts:113
registrationNumberstring | nullYes (nullable)null—"REG-2019-0042"school-profile.dto.ts:114-115
addressAddressDtoYesAll fields nullThe school's own Nepali address — province, district, municipality, ward, tole, house number, plus each id's resolved name. One address, not a permanent/current pair — a school has no present address distinct from its permanent one.—school-profile.dto.ts
phonestring | nullYes (nullable)null—"+977-1-4123456"school-profile.dto.ts:123
emailstring | nullYes (nullable)null—"info@school.edu.np"school-profile.dto.ts:124
websitestring | nullYes (nullable)null—"https://school.edu.np"school-profile.dto.ts:125
academicYearStartMonthnumberYes4DB CHECK BETWEEN 1 AND 124school-profile.dto.ts:126-130
currencyCodestringYes"NPR"—"NPR"school-profile.dto.ts:131
timezonestringYes"Asia/Kathmandu"Must be an IANA zone name, e.g. Asia/Kathmandu; a UTC offset such as +05:45 or an abbreviation such as EST is refused 400 on write (see 16.5 in the backend doc)"Asia/Kathmandu"school-profile.dto.ts:132-136
rollNumberStrategy"admission_order" | "alphabetical"Yes"admission_order"Consumed by the class module's RollNumberService.policy, never enforced here"admission_order"school-profile.dto.ts:137-142
rollNumberAutoRenumberbooleanYesfalseSame — read by the class module, not enforced herefalseschool-profile.dto.ts:143-147
iemisCodestring | nullYes (nullable)null—"12345678"school-profile.dto.ts
establishedYearnumber | nullYes (nullable)nullYear the school was established, AD (Gregorian) — never Bikram Sambat1998school-profile.dto.ts
additionalEmailsstring[]Yes[]At most 5, each already trimmed, lower-cased, and de-duplicated on write["accounts@school.edu.np"]school-profile.dto.ts
additionalPhonesstring[]Yes[]At most 5, each already trimmed and de-duplicated on write["+977-1-4123457"]school-profile.dto.ts
classificationstring | nullYes (nullable)nullPlain text — no fixed enum"Community"school-profile.dto.ts
schoolTypestring | nullYes (nullable)nullPlain text — no fixed enum"Co-educational"school-profile.dto.ts
classRegisteredUptoSchoolProfileGradeRefDto | nullYes (nullable)Derived, not stored{ publicId, name }. Null when no grade is set, or when the previously-set grade no longer exists at all (a retired grade still resolves){ "publicId": "…", "name": "Grade 10" }school-profile.dto.ts
seeCodestring | nullYes (nullable)null—"SEE-2081-0042"school-profile.dto.ts
hsebCodestring | nullYes (nullable)null—"HSEB-2081-0042"school-profile.dto.ts
bankNamestring | nullYes (nullable)nullVisible to every reader of this endpoint, unlike bankAccountNumber below"Nepal Bank Limited"school-profile.dto.ts
bankAccountNumberstring | nullNo — key absent unless the caller holds SchoolProfile_UPDATEnull4-40 characters of digits/letters/hyphens, at least one digit"01234567890"school-profile.dto.ts
panNumberstring | nullYes (nullable)nullNepal PAN: exactly nine digits"123456789"school-profile.dto.ts
grantSourcestring | nullYes (nullable)null—"Government of Nepal"school-profile.dto.ts
latitudenumber | nullYes (nullable)null-90..90; set together with longitude or not at all27.7172school-profile.dto.ts
longitudenumber | nullYes (nullable)null-180..180; set together with latitude or not at all85.324school-profile.dto.ts
elevationMetersnumber | nullYes (nullable)null-500..90001400school-profile.dto.ts
hasEcdLevel, hasBasicPrimaryLevel (Basic 1–5), hasBasicLowerSecondaryLevel (Basic 6–8)booleanYesfalsefalse means "not declared, or no"trueschool-profile.dto.ts
ecdApprovalDate, primaryApprovalDate, lowerSecondaryApprovalDatestring | null (YYYY-MM-DD)Yes (nullable)nullGregorian (AD), not tied to the corresponding level flag by any constraint"2023-04-14"school-profile.dto.ts
teachesScienceGrade11To12, isModelSchoolSelected, hasComplaintHearingMechanism, hasForeignAffiliation, isInformalSchool, isMobileGhuntiSchool, isOpenSchool, isSpecialDisabilitySchool, isMultilingualEducationSchool, isMgmlImplemented, runsResidentialScholarship, receivesZeroPositionGrant, runsTechnicalStreambooleanYesfalsefalse means "not declared, or no"trueschool-profile.dto.ts
runningGradesSchoolProfileRunningGradeDto[]YesDerived, not storedDistinct grades with at least one active class in the current academic session, ordered by the grade's own sort order then name. [] when there is no current session[{ "publicId": "…", "name": "Grade 5", "code": "G5" }]school-profile.dto.ts
principalSchoolPrincipalDto | nullYes (nullable)Derived, not storedNull when nobody actively holds the designation flagged is_principal{ "fullName": "Anita Sharma", "designationName": "Principal", "departmentName": "Administration", "isTeaching": false }school-profile.dto.ts
updatedAtstring (ISO date)YesServer-generated——school-profile.dto.ts:250

bankAccountNumber is the one field on this response gated below SchoolProfile_READ. SchoolProfileService.buildResponse attaches the key only when the caller also holds SchoolProfile_UPDATE — a caller with SchoolProfile_READ alone gets a response with the key entirely absent, not present and null. bankName and panNumber carry no such gate: this endpoint serves the school's own staff, to whom the bank's name is not itself secret, unlike the account number. Contrast GET /api/integration/fees/school, which serves a third-party API key and gates both bankName and bankAccountNumber together behind a dedicated payroll:bank:read scope.

SchoolPrincipalDto's four personal fields — phone, qualification, gender, ethnicityName — are gated the same way bankAccountNumber is: key absent, not present-and-null. They appear only when the caller both holds Staff_READ and can see the principal's own staff row through PeopleAccessService (row-level access control, e.g. a scoped role covering that staff member); fullName, designationName, departmentName, and isTeaching are always present. There is deliberately no staff id anywhere on principal — the profile is readable by everyone who can read the school's details, and a staff id would be a link into a record many of those callers cannot open.

classRegisteredUpto and runningGrades are resolved fresh on every request, never cached. classRegisteredUpto survives the referenced grade being retired (is_active: false) — a registration is a historical fact — and resolves to null only if the grade row no longer exists at all. runningGrades reflects only the CURRENT academic session's active classes; it is [] whenever no session is flagged current, even if a past session had running grades.

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.

The response derives principal instead of reading principalName, brandPrimaryColor, or brandSecondaryColor. SchoolProfileService.toDto strips those three columns from every row alongside id and createdAt. In their place, principal is resolved fresh on every GET/PATCH by SchoolProfileReadsService.resolvePrincipalForProfile, which first calls PrincipalInvariantService.resolvePrincipal — the exact same predicate (flagged designation, not soft-deleted, active employment status) the at-most-one-principal assert uses when a staff write would create a second principal — and returns null when nobody holds the role. When there is a principal, the object always carries fullName, designationName, departmentName, and isTeaching (true when the principal's own designation is flagged is_teaching, or they teach a live class in the current session as class_teacher or through an active class_subjects assignment). Four further fields — phone, qualification, gender, ethnicityName — are gated and OMITTED (not present-and-null) unless the caller both holds Staff_READ and can see the principal's own staff row through PeopleAccessService; when the gate passes but a queried detail is itself null in the database, that key is present with value null. 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 indexed queries on every read rather than through the hour-long profile cache.

rollNumberStrategy and rollNumberAutoRenumber belong to the class module, and this response is where a consumer reads them. They decide, respectively, the ORDER a class's roll numbers are assigned in when the class is explicitly RENUMBERED (admission_order sorts by students.admissionNumber then id; alphabetical by firstName/middleName/lastName then id), and whether ADMITTING a pupil triggers that renumber automatically instead of appending the next free number. Changing either field here moves nobody by itself — see the class module's PATCH /enrollments/:id/roll-number and POST .../enrollments/renumber for the routes that actually move a pupil's number. SchoolProfileService does not read either column back through getTimezone() or any other narrow accessor the way it does timezone — a consumer wanting the roll-number policy always goes through the full get()/GET /api/school-profile, or (as the class module does) reads school_profile directly.

SchoolPrincipalDto

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

6.10 UpdateSchoolProfileDto (body)

FieldTypeRequiredDefaultValidationExampleSource
namestringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(200)"Green Valley School"school-profile.dto.ts:254-258
logoUrlstringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(500) — not a URL format check"https://cdn.example/logo.png"school-profile.dto.ts:260-264
registrationNumberstringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(100)"REG-2019-0042"school-profile.dto.ts:266-270
principalNamestringNoAccepted, ignored@IsOptional, @IsString, @MaxLength(200)"Anita Sharma"school-profile.dto.ts:272-276
addressAddressInputDtoNoUntouched if the key is absent@IsOptional, @ValidateNested, @Type(() => AddressInputDto) — replaced whole, never merged field by field, exactly like a person's permanentAddress/currentAddress: when address is present, all six columns (provinceId, districtId, municipalityId, wardNo, tole, houseNo) are written from it and a missing key inside it means NULL{ "provinceId": 3, "districtId": 27, "municipalityId": 118, "wardNo": 5, "tole": "Suryabinayak", "houseNo": null }school-profile.dto.ts
phonestringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(40)"+977-1-4123456"school-profile.dto.ts:287-291
emailstring | nullNoUnchanged if omitted; "" or null clears it@IsOptional, @IsString, @ValidateIf (skips @IsEmail only on "")"info@school.edu.np"school-profile.dto.ts:293-298
websitestringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(200)"https://school.edu.np"school-profile.dto.ts:300-304
academicYearStartMonthnumberNoUnchanged if omitted@IsOptional, @IsInt, @Min(1), @Max(12)4school-profile.dto.ts:306-311
brandPrimaryColorstringNoAccepted, ignored@IsOptional, @IsHexColor"#1A6FBF"school-profile.dto.ts:313-316
brandSecondaryColorstringNoAccepted, ignored@IsOptional, @IsHexColor"#155A9C"school-profile.dto.ts:318-321
currencyCodestringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(8) — not validated against ISO 4217"NPR"school-profile.dto.ts:323-327
timezonestringNoUnchanged if omitted; an empty string "" or null is ignored (NOT NULL column); a whitespace-only value such as " " reaches @IsTimeZone() and is refused 400@IsOptional, @IsString, @MaxLength(64), @ValidateIf + @IsTimeZone + @Matches (skips only on ""/null/undefined) — a non-empty value must be an IANA zone name such as Asia/Kathmandu; a UTC offset such as +05:45 or an abbreviation such as EST is refused 400. Casing is enforced only on the first letter of the first segment of a Region/City name: ASIA/KATHMANDU passes both checks and is stored as sent (only trimmed). The bare form must be exactly UTC (utc is refused), and single-segment names such as GMT or Zulu are refused"Asia/Kathmandu"school-profile.dto.ts:329-342
rollNumberStrategy"admission_order" | "alphabetical"NoUnchanged if omitted@IsOptional, @IsIn(["admission_order", "alphabetical"])"alphabetical"school-profile.dto.ts:344-347
rollNumberAutoRenumberbooleanNoUnchanged if omitted@IsOptional, @IsBooleantrueschool-profile.dto.ts:354-357
iemisCodestringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(32)"12345678"school-profile.dto.ts
establishedYearnumber | nullNoUnchanged if omitted; null clears it@ValidateIf (skips only on undefined), @IsInt, @Min(1800), @Max(2100) — AD (Gregorian)1998school-profile.dto.ts
additionalEmailsstring[]NoUnchanged if omitted; [] clears it@Transform (trims, lower-cases, drops blanks, de-duplicates) before @IsArray, @ArrayMaxSize(5), @IsEmail({}, {each:true}), @MaxLength(254, {each:true})["accounts@school.edu.np"]school-profile.dto.ts
additionalPhonesstring[]NoUnchanged if omitted; [] clears it@Transform (trims, drops blanks, de-duplicates — no lower-casing) before @IsArray, @ArrayMaxSize(5), @IsString({each:true}), @MaxLength(40, {each:true})["+977-1-4123457"]school-profile.dto.ts
classificationstringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(100)"Community"school-profile.dto.ts
schoolTypestringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(100)"Co-educational"school-profile.dto.ts
classRegisteredUptoGradeIdstring | nullNoUnchanged if omitted; "" or null clears it@IsOptional, @IsString, then @IsUUID unless the value is "" — the publicId of a row from GET /lookups/grades"a1b2c3d4-…"school-profile.dto.ts
seeCodestringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(32)"SEE-2081-0042"school-profile.dto.ts
hsebCodestringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(32)"HSEB-2081-0042"school-profile.dto.ts
bankNamestringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(120)"Nepal Bank Limited"school-profile.dto.ts
bankAccountNumberstringNoUnchanged if omitted; "" clears it@IsOptional, @IsString, @Matches(/^$|^(?=.*[0-9])[0-9A-Za-z-]{4,40}$/) — 4-40 characters of digits/letters/hyphens, at least one digit"01234567890"school-profile.dto.ts
panNumberstringNoUnchanged if omitted; "" clears it@IsOptional, @IsString, @Matches(/^([0-9]{9})?$/) — Nepal PAN, exactly nine digits"123456789"school-profile.dto.ts
grantSourcestringNoUnchanged if omitted@IsOptional, @IsString, @MaxLength(200)"Government of Nepal"school-profile.dto.ts
coordinates{latitude, longitude} | nullNoUnchanged if omitted; null clears both@ValidateIf (skips only on undefined/null), @IsObject, @ValidateNested, @Type(() => SchoolCoordinatesInputDto) — both fields required together, each range-checked; {latitude:0,longitude:0} is refused by the service, not the DTO — see below{ "latitude": 27.7172, "longitude": 85.324 }school-profile.dto.ts
elevationMetersnumber | nullNoUnchanged if omitted; null clears it@ValidateIf (skips only on undefined), @IsInt, @Min(-500), @Max(9000)1400school-profile.dto.ts
hasEcdLevel, hasBasicPrimaryLevel, hasBasicLowerSecondaryLevelbooleanNoUnchanged if omitted@ValidateIf (skips only on undefined), @IsBooleantrueschool-profile.dto.ts
ecdApprovalDate, primaryApprovalDate, lowerSecondaryApprovalDatestring | nullNoUnchanged if omitted; null clears it@ValidateIf (skips only on undefined/null), @Matches(/^\d{4}-\d{2}-\d{2}$/), @IsDateString({strict:true}) — Gregorian (AD), never Bikram Sambat; refused if before 1850-01-01 or after today in the school's own timezone (service-level, not DTO-level)"2023-04-14"school-profile.dto.ts
teachesScienceGrade11To12, isModelSchoolSelected, hasComplaintHearingMechanism, hasForeignAffiliation, isInformalSchool, isMobileGhuntiSchool, isOpenSchool, isSpecialDisabilitySchool, isMultilingualEducationSchool, isMgmlImplemented, runsResidentialScholarship, receivesZeroPositionGrant, runsTechnicalStreambooleanNoUnchanged if omitted@ValidateIf (skips only on undefined), @IsBooleantrueschool-profile.dto.ts

additionalEmails/additionalPhones normalize before they validate. A @Transform runs first: each non-string array element is passed through untouched (so @IsEmail/@IsString on that element is what rejects it), each string element is trimmed (and, for emails only, lower-cased), a resulting empty string is dropped from the list entirely, and an exact duplicate (post-trim, post-lower-case for emails) is dropped rather than kept twice. Only after that does @ArrayMaxSize(5) count elements — so five distinct emails is the real ceiling, not five raw entries that might collapse to fewer. A non-array value (including null) is passed through unchanged, so @IsArray() is what produces the 400.

classRegisteredUptoGradeId takes the grade's publicId, never its integer id, resolved server-side to grades.id before the write. "" and null both clear the registration; undefined (the key omitted) leaves it untouched; any other string must be a real publicId of an active grade or the request is refused — SCHOOL_PROFILE_GRADE_NOT_FOUND (404) if no such grade exists, SCHOOL_PROFILE_GRADE_INACTIVE (400) if it is retired.

coordinates is a group, exactly like address. Sending an object writes both latitude and longitude together; sending null clears both together; omitting the key leaves both untouched. {latitude: 0, longitude: 0} passes the DTO's own range validation (zero is a valid latitude and longitude) but is refused by SchoolProfileService.update itself with SCHOOL_PROFILE_COORDINATES_UNSET — it reads as an unfilled numeric form defaulting to zero, not a real point in the Gulf of Guinea; send coordinates: null to genuinely clear both fields.

The three approval dates are Gregorian (AD) YYYY-MM-DD strings, never Bikram Sambat, and are validated twice. The DTO's @Matches/@IsDateString({strict: true}) rejects a malformed string; SchoolProfileService.assertApprovalDateInRange then separately rejects a well-formed date before 1850-01-01 or after "today" as computed in the school's own timezone column (SCHOOL_PROFILE_APPROVAL_DATE_OUT_OF_RANGE, 400) — a future-dated approval is refused even though the DTO alone would accept it.

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.toDto 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, and null behaves like an empty string wherever the DTO lets it through at all. On the plain nullable text columns — logoUrl, registrationNumber, principalName, phone, email, website, brandPrimaryColor, brandSecondaryColor, iemisCode, classification, schoolType, seeCode, hsebCode, bankName, bankAccountNumber, panNumber, grantSource — a trimmed-to-empty string is the documented "clear this field" sentinel and is written as NULL, exactly like UpdateDepartmentDto's code/description (brandPrimaryColor/brandSecondaryColor in practice never reach this path with a blank, since @IsHexColor() on the DTO rejects an empty string before the service runs). email additionally accepts null itself as a second way to clear it: @IsOptional() lets null skip every validator on the DTO, and the service's non-string branch treats it exactly like a blank on this field. On name, academicYearStartMonth, currencyCode, timezone, rollNumberStrategy, and rollNumberAutoRenumber — the six columns that are NOT NULL with a database default and whose DTO decorator is a plain @IsOptional() — a trimmed-to-empty string (on the four string-typed ones) or a bare null (on all six, including the two non-string ones) is instead dropped from the write entirely, leaving the column at its current value, because none of the six can represent "unset" at all and a blank/null timezone in particular would otherwise either silently break admission/employee-number allocation or reach Postgres as an unhandled NOT NULL violation (23502), surfacing as a 500 rather than being ignored. 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.9 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.

rollNumberStrategy has no blank-clearing sentinel — @IsIn rejects an empty string outright — but both it and rollNumberAutoRenumber are protected from null the same way every other NOT NULL column above is: the service drops it rather than writing it. rollNumberAutoRenumber in particular is documented on the DTO itself as changing behavior with no immediate visible effect: setting it renumbers nothing on the PATCH that sets it (school-profile.dto.ts:344-357) — it only changes what the class module's NEXT enrolment does. An existing, already-numbered class is resequenced only by the class module's own POST /api/classes/:publicId/enrollments/renumber — see /docs/developer/classes/api.

6.11 QueryDto — shared base, inherited by both list queries

FieldTypeRequiredDefaultValidationExampleSource
paginationbooleanNotrueQuery-string boolean transform, @IsBoolean?pagination=falsequery.dto.ts:15-28
pagenumberNo1@IsInt, @Min(1)?page=2query.dto.ts:30-35
sizenumberNo20@IsInt, @Min(1), @Max(100) — silently clamped to 100 by PaginationUtil, not rejected?size=50query.dto.ts:37-42
sortstringNo"updatedAt"@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=namequery.dto.ts:44-47
order"asc" | "desc"No"desc"@IsEnum(["asc", "desc"])?order=ascquery.dto.ts:49-52
searchstringNo— (no filter)@IsString, @MaxLength(100), trimmed?search=sciquery.dto.ts:54-60

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

6.12 PersonClassificationDto (response)

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

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

6.13 ListPersonClassificationsQueryDto (query, extends QueryDto)

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

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

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

6.15 UpdatePersonClassificationDto (body)

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

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

7. Enum Reference

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

8. Endpoint Reference

8.1 GET /api/departments

Purpose

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

Source Evidence

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

Auth and Permissions

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

Request

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

Response

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

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

Side Effects

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

Error Cases

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

Edge Cases

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

Example Requests

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

8.2 POST /api/departments

Purpose

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

Source Evidence

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

Auth and Permissions

  • Auth: Required.
  • Guard chain: 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

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

Minimal valid request:

{
  "name": "Science"
}

Full valid request:

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

Response

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

Side Effects

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

Error Cases

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

Edge Cases

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

Example Requests

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

8.3 PATCH /api/departments/:publicId

Purpose

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

Source Evidence

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

Auth and Permissions

  • Auth: Required.
  • Guard chain: 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 updatedAt each time).

Request

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

Minimal valid request (retire only):

{
  "isActive": false
}

Response

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

Side Effects

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

Error Cases

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

Edge Cases

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

Example Requests

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

8.4 DELETE /api/departments/:publicId

Purpose

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

Source Evidence

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

Auth and Permissions

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

Request

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

Response

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

Side Effects

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

Error Cases

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

Edge Cases

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

Example Requests

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

8.5 GET /api/designations

Purpose

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

Source Evidence

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

Auth and Permissions

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

Request

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

Response

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

Side Effects

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

Error Cases

Same shape as 8.1 — 401 AUTH_UNAUTHENTICATED, 403 PERMISSION_INSUFFICIENT, 400 VALIDATION_FAILED.

Edge Cases

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

Example Requests

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

8.6 POST /api/designations

Purpose

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

Source Evidence

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

Auth and Permissions

  • Auth: Required. Guard chain: 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 on designations (name pre-check scoped to departmentId).
  • Database writes: one INSERT into designations.
  • Cache: same invalidation as every lookup write.

Error Cases

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

Edge Cases

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

Example Requests

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

8.7 PATCH /api/designations/:publicId

Purpose

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

Source Evidence

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

Auth and Permissions

  • Auth: Required. Guard chain: 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 UPDATE on designations.
  • Cache: same invalidation as every lookup write.

Error Cases

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

Edge Cases

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

Example Requests

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

8.8 DELETE /api/designations/:publicId

Purpose

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

Source Evidence

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

Auth and Permissions

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

Request

publicId in the path only, no body.

Response

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

Side Effects

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

Error Cases

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

Edge Cases

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

Example Requests

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

8.9 GET /api/school-profile

Purpose

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

Source Evidence

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

Auth and Permissions

  • Auth: Required. Guard chain: 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",
    "rollNumberStrategy": "admission_order",
    "rollNumberAutoRenumber": false,
    "iemisCode": null,
    "establishedYear": null,
    "additionalEmails": [],
    "additionalPhones": [],
    "classification": null,
    "schoolType": null,
    "classRegisteredUpto": null,
    "seeCode": null,
    "hsebCode": null,
    "bankName": null,
    "panNumber": null,
    "grantSource": null,
    "latitude": null,
    "longitude": null,
    "elevationMeters": null,
    "hasEcdLevel": false,
    "hasBasicPrimaryLevel": false,
    "hasBasicLowerSecondaryLevel": false,
    "ecdApprovalDate": null,
    "primaryApprovalDate": null,
    "lowerSecondaryApprovalDate": null,
    "teachesScienceGrade11To12": false,
    "isModelSchoolSelected": false,
    "hasComplaintHearingMechanism": false,
    "hasForeignAffiliation": false,
    "isInformalSchool": false,
    "isMobileGhuntiSchool": false,
    "isOpenSchool": false,
    "isSpecialDisabilitySchool": false,
    "isMultilingualEducationSchool": false,
    "isMgmlImplemented": false,
    "runsResidentialScholarship": false,
    "receivesZeroPositionGrant": false,
    "runsTechnicalStream": false,
    "runningGrades": [],
    "principal": {
      "fullName": "Anita Sharma",
      "designationName": "Principal",
      "departmentName": "Administration",
      "isTeaching": false
    },
    "updatedAt": "2026-01-10T04:00:00.000Z"
  },
  "errorCode": null
}

bankAccountNumber is omitted here — this example response is for a caller holding SchoolProfile_READ only. A caller who also holds SchoolProfile_UPDATE sees the same body with "bankAccountNumber": null (or its value) inserted after bankName.

Side Effects

  • Cache: reads school:profile:v2 first (getSoft); on a hit, no database query runs for the row's own columns.
  • Database reads (cache miss only): one SELECT on school_profile by the hardcoded id 1.
  • Cache write (cache miss only): setSoft("school:profile:v2", dto, 3600).
  • Database reads (every call, regardless of the cache outcome above): classRegisteredUpto (grades), principal (staff/designations/users, plus ethnicities when the caller holds Staff_READ and can access the principal's own row), and runningGrades (grades joined to classes) — none of these three is cached.
  • No jobs, realtime events, notifications, audit logs, or external calls.

Error Cases

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

Edge Cases

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

Example Requests

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

8.10 PATCH /api/school-profile

Purpose

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

Source Evidence

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

Auth and Permissions

  • Auth: Required. Guard chain: 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 bumped updatedAt).

Request

Minimal valid request:

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

Full valid request:

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

Setting rollNumberAutoRenumber: true here renumbers nothing on this call — see 6.10 and the class module's POST .../enrollments/renumber, which is the only route that ever moves an existing pupil's number.

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: reads bankName/bankAccountNumber/panNumber under a FOR UPDATE row lock, then one UPDATE ... RETURNING * on school_profile id 1, applying only the keys present in the body — both inside one transaction.
  • Cache: delSoft("school:profile:v2") 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).
  • Audit: a best-effort school_profile.financial_update activity entry, only when bankName/bankAccountNumber/panNumber actually changed, with bankAccountNumber/panNumber masked to at most their last four characters; and a separate best-effort school_profile.contact_update entry, only when email actually changed, with the value UNMASKED since the school email is a public contact address. Both are skipped when the request carries no authenticated userId, and neither fails the PATCH if the audit write fails.
  • No jobs, realtime events, or notifications.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
404SCHOOL_PROFILE_NOT_INITIALIZEDThe UPDATE matches no row (row 1 missing).Should not occur in a normally-migrated database.school-profile.service.ts
404SCHOOL_PROFILE_GRADE_NOT_FOUNDclassRegisteredUptoGradeId does not match any grades.public_id.Fetch GET /lookups/grades again; the id may be wrong or the grade removed.school-profile.service.ts
400SCHOOL_PROFILE_GRADE_INACTIVEclassRegisteredUptoGradeId names a retired grade.Choose an active grade.school-profile.service.ts
400SCHOOL_PROFILE_APPROVAL_DATE_OUT_OF_RANGEAn approval date is before 1850-01-01, or after today in the school's own timezone.Correct the date.school-profile.service.ts
400SCHOOL_PROFILE_COORDINATES_UNSETcoordinates is exactly {"latitude": 0, "longitude": 0}.Send coordinates: null to clear, or the school's real coordinates.school-profile.service.ts
400VALIDATION_FAILEDAn invalid field value (bad email, non-hex color, month outside 1-12, malformed date, out-of-range PAN/bank-account format, timezone not a real IANA zone name) or an unknown field.Fix the request body.Global ValidationPipe
401 / 403AUTH_UNAUTHENTICATED / PERMISSION_INSUFFICIENTAs above.As above.—

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

Edge Cases

  • timezone must be an IANA zone name once it is non-empty. "Nowhere/Nothing" or "Kathmandu" is refused 400 by @IsTimeZone(); a UTC offset such as "+05:45" or an abbreviation such as "EST" is refused 400 by the accompanying @Matches(), which requires either the literal "UTC" or a Region/City-shaped name — a genuinely valid but unexpected zone (any zone Intl recognizes and shaped that way) still passes, so a school could still set a technically-real but wrong zone.
  • No server-side validation that currencyCode is a real ISO 4217 code — any string up to 8 characters is accepted.
  • Omitting a field leaves it unchanged. An empty string clears every plain nullable text field (including email, which also accepts null for the same effect) but is dropped as a no-op on the six NOT NULL columns (name, academicYearStartMonth, currencyCode, timezone, rollNumberStrategy, rollNumberAutoRenumber); null on any of those same six is likewise dropped rather than reaching Postgres as a constraint violation.
  • 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.
  • rollNumberStrategy outside ["admission_order", "alphabetical"] is rejected by @IsIn before it reaches the database — there is no CHECK constraint backstop the way academicYearStartMonth has one, since the column is a Postgres enum type and an out-of-range value is rejected by the type itself if this DTO is ever bypassed.
  • Setting rollNumberAutoRenumber/rollNumberStrategy has no effect on any class already numbered — moving an existing pupil's number always requires the class module's own explicit renumber call. A caller expecting this PATCH to resequence classes will see every roster unchanged.

Example Requests

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

8.11 GET /api/lookups/ethnicities

Purpose

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

Source Evidence

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

Auth and Permissions

  • Auth: Required. Guard chain: 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

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

Response

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

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

Side Effects

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

Error Cases

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

Edge Cases

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

Example Requests

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

8.12 POST /api/lookups/ethnicities

Purpose

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

Source Evidence

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

Auth and Permissions

  • Auth: Required. Guard chain: 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 INSERT into ethnicities.
  • Cache: delPatternSoft clears only the lookups:classifications:ethnicities:* prefix — the mother-tongue cache is untouched.
  • No jobs, realtime events, notifications, audit logs, or external calls.

Error Cases

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

Edge Cases

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

Example Requests

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

8.13 PATCH /api/lookups/ethnicities/:publicId

Purpose

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

Source Evidence

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

Auth and Permissions

  • Auth: Required. Guard chain: 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

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

Minimal valid request (retire only):

{
  "isActive": false
}

Response

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

Side Effects

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

Error Cases

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

Edge Cases

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

Example Requests

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

8.14 DELETE /api/lookups/ethnicities/:publicId

Purpose

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

Source Evidence

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

Auth and Permissions

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

Request

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

Response

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

Side Effects

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

Error Cases

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

Edge Cases

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

Example Requests

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

8.15 GET /api/lookups/mother-tongues

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

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

8.16 POST /api/lookups/mother-tongues

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

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

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

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

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

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

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

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

9. Flow Diagrams

9.1 Route Ownership

9.2 Request Sequence — create with a race

9.3 Error Branch — mutation endpoints

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

11. Caching, Jobs, and External Integrations

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

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

13. Mandatory Deep API Documentation Pack

13.1 Route-by-Route Completeness Matrix

RouteController MethodDTOsService MethodGuardsPermissionsCacheJobsDB TouchesErrorsTestsDocumented?
GET /api/departmentsDepartmentsController.findAllListDepartmentsQueryDto → DepartmentDto[]findAllDepartmentsJWT, RoleDepartments_READReads + may populate lookups:v2:departments:*N/Adepartments401,403,400N/AYes
POST /api/departmentsDepartmentsController.createCreateDepartmentDto → DepartmentDtocreateDepartmentJWT, RoleDepartments_CREATEInvalidates both lookups:v2:departments:* and lookups:v2:designations:*N/Adepartments401,403,400,409N/AYes
PATCH /api/departments/:publicIdDepartmentsController.updateUpdateDepartmentDto → DepartmentDtoupdateDepartmentJWT, RoleDepartments_UPDATEInvalidates both prefixesN/Adepartments401,403,400,404,409N/AYes
DELETE /api/departments/:publicIdDepartmentsController.removeNone → nulldeleteDepartmentJWT, RoleDepartments_DELETEInvalidates both prefixesN/Adepartments, reads designations401,403,404,409N/AYes
GET /api/designationsDesignationsController.findAllListDesignationsQueryDto → DesignationDto[]findAllDesignationsJWT, RoleDesignations_READReads + may populate lookups:v2:designations:*N/Adesignations, departments (join), staff (count)401,403,400N/AYes
POST /api/designationsDesignationsController.createCreateDesignationDto → DesignationDtocreateDesignationJWT, RoleDesignations_CREATEInvalidates both prefixesN/Adesignations, reads departments401,403,400,404,409N/AYes
PATCH /api/designations/:publicIdDesignationsController.updateUpdateDesignationDto → DesignationDtoupdateDesignationJWT, RoleDesignations_UPDATEInvalidates both prefixesN/Adesignations, reads departments401,403,400,404,409N/AYes
DELETE /api/designations/:publicIdDesignationsController.removeNone → nulldeleteDesignationJWT, RoleDesignations_DELETEInvalidates both prefixesN/Adesignations, reads staff401,403,404,409N/AYes
GET /api/lookups/ethnicitiesPersonClassificationsController.findAllEthnicitiesListPersonClassificationsQueryDto → PersonClassificationDto[]findAllPersonClassifications("ethnicities", ...)JWT, RolePersonClassifications_READReads + may populate lookups:classifications:ethnicities:*N/Aethnicities401,403,400person-classifications.service.spec.tsYes
POST /api/lookups/ethnicitiesPersonClassificationsController.createEthnicityCreatePersonClassificationDto → PersonClassificationDtocreatePersonClassification("ethnicities", ...)JWT, RolePersonClassifications_CREATEInvalidates lookups:classifications:ethnicities:* onlyN/Aethnicities401,403,400,409person-classifications.service.spec.tsYes
PATCH /api/lookups/ethnicities/:publicIdPersonClassificationsController.updateEthnicityUpdatePersonClassificationDto → PersonClassificationDtoupdatePersonClassification("ethnicities", ...)JWT, RolePersonClassifications_UPDATEInvalidates ethnicities prefix onlyN/Aethnicities401,403,400,404,409person-classifications.service.spec.tsYes
DELETE /api/lookups/ethnicities/:publicIdPersonClassificationsController.deleteEthnicityNone → nulldeletePersonClassification("ethnicities", ...)JWT, RolePersonClassifications_DELETEInvalidates ethnicities prefix onlyN/Aethnicities, reads users401,403,400,404,409person-classifications.service.spec.tsYes
GET /api/lookups/mother-tonguesPersonClassificationsController.findAllMotherTonguesListPersonClassificationsQueryDto → PersonClassificationDto[]findAllPersonClassifications("mother-tongues", ...)JWT, RolePersonClassifications_READReads + may populate lookups:classifications:mother-tongues:*N/Amother_tongues401,403,400person-classifications.service.spec.tsYes
POST /api/lookups/mother-tonguesPersonClassificationsController.createMotherTongueCreatePersonClassificationDto → PersonClassificationDtocreatePersonClassification("mother-tongues", ...)JWT, RolePersonClassifications_CREATEInvalidates mother-tongues prefix onlyN/Amother_tongues401,403,400,409person-classifications.service.spec.tsYes
PATCH /api/lookups/mother-tongues/:publicIdPersonClassificationsController.updateMotherTongueUpdatePersonClassificationDto → PersonClassificationDtoupdatePersonClassification("mother-tongues", ...)JWT, RolePersonClassifications_UPDATEInvalidates mother-tongues prefix onlyN/Amother_tongues401,403,400,404,409person-classifications.service.spec.tsYes
DELETE /api/lookups/mother-tongues/:publicIdPersonClassificationsController.deleteMotherTongueNone → nulldeletePersonClassification("mother-tongues", ...)JWT, RolePersonClassifications_DELETEInvalidates mother-tongues prefix onlyN/Amother_tongues, reads users401,403,400,404,409person-classifications.service.spec.tsYes
GET /api/school-profileSchoolProfileController.getNone → SchoolProfileDtogetJWT, RoleSchoolProfile_READReads + may populate school:profile:v2N/Aschool_profile (miss only); grades, academic_sessions, classes, staff, designations, users, ethnicities (every call, uncached)401,403,404__tests__/school-profile.service.integration.spec.ts, __tests__/school-profile.integration.spec.tsYes
PATCH /api/school-profileSchoolProfileController.updateUpdateSchoolProfileDto → SchoolProfileDtoupdateJWT, RoleSchoolProfile_UPDATEInvalidates school:profile:v2N/Aschool_profile; grades (grade resolution); same uncached reads as GET above for the response401,403,400,404__tests__/school-profile.service.integration.spec.ts, __tests__/school-profile.integration.spec.ts, dto/school-profile.dto.spec.tsYes

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

13.2 Request/Response Exhaustiveness

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

13.3 API Diagram Pack

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

13.4 Consumer Integration Notes

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

13.5 API Tradeoffs and Rationale

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

13.6 API Change Impact

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

14. Zero-Omission API Checklist

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

15. Integration Checklist

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

See Also

On this page

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