School Backend Documentation
Backend architecture, data model, services, cache, and runtime rules for departments, designations, and the school profile singleton.
School Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/lookups/lookups.module.ts, apps/api/src/modules/school-profile/school-profile.module.ts | Imports, providers, controllers, exports. |
| Controllers | apps/api/src/modules/lookups/lookups.controller.ts, apps/api/src/modules/school-profile/school-profile.controller.ts | Route ownership, guards, permissions — including PersonClassificationsController in the first file. |
| Services | apps/api/src/modules/lookups/lookups.service.ts, apps/api/src/modules/lookups/person-classifications.service.ts, apps/api/src/modules/school-profile/school-profile.service.ts | Business logic, validation, writes, cache calls. |
| DTOs | apps/api/src/modules/lookups/dto/department.dto.ts, apps/api/src/modules/lookups/dto/designation.dto.ts, apps/api/src/modules/lookups/dto/person-classification.dto.ts, apps/api/src/modules/school-profile/dto/school-profile.dto.ts | Request/response contracts and validation. |
| Schema | packages/db/src/schema/school/lookups.ts, packages/db/src/schema/school/school-profile.ts, packages/db/src/schema/school/people.ts, packages/db/src/schema/identity.ts | Tables, constraints, indexes, relations. ethnicities/motherTongues live in identity.ts alongside users, not under schema/school/, because users.ethnicity_id/users.mother_tongue_id are the columns that reference them. |
| Migration | packages/db/src/migrations/0002_school_domain_foundation.sql, packages/db/src/migrations/0021_class_roll_number.sql | Table creation and the row-1 singleton insert; 0021 adds roll_number_strategy/roll_number_auto_renumber to this table (alongside student_class_enrollments.roll_number, owned by the class module). |
| Seed data | packages/db/src/seed/seed-reference-data.ts, packages/db/src/seed/seed.ts, packages/db/src/seed/seed-prod.ts, packages/db/src/seed/seed-auth.ts | The 56 ethnicities and 42 mother tongues inserted on every seed run, and the role grants for reading them. |
| Cache | apps/api/src/services/redis/redis.service.ts (getSoft, setSoft, delSoft, delPatternSoft), apps/api/src/common/utils/cache-key.util.ts | Key construction, TTLs, invalidation, fail-soft behavior. |
| Search | packages/db/src/search/escape-like-pattern.ts | ILIKE escaping. |
| Pagination | apps/api/src/common/utils/pagination.util.ts | Page/size normalization, drizzle params, and the UNPAGINATED_HARD_CAP ceiling applied when a caller turns pagination off. |
| Errors | apps/api/src/common/types/error-codes.ts, apps/api/src/common/filters/all-exceptions.filter.ts | Error codes and the response envelope they produce, including the three PERSON_CLASSIFICATION_* codes. |
| Authorization | packages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.ts, apps/api/src/common/authorization/role.guard.ts | Permission modules/actions, which roles are granted which codes, guard behavior. |
| Tests | apps/api/src/modules/lookups/person-classifications.service.spec.ts, apps/api/src/modules/school-profile/__tests__/school-profile.service.integration.spec.ts, apps/api/src/modules/school-profile/__tests__/school-profile.integration.spec.ts | Real-database coverage for the classification service, the profile's field-clearing behavior, and the EMIS fields' validation, gating, and audit behavior. |
| Wiring into the app | apps/api/src/app.module.ts | Both modules import directly into AppModule — there is no aggregate SchoolModule. |
2. Backend Scope and Boundaries
Owns
- Departments — a flat, school-editable list used to group designations and staff. CRUD via
DepartmentsController(apps/api/src/modules/lookups/lookups.controller.ts:41-89). - Designations — a school-editable employment title, scoped to a department, carrying the
isTeachingflag that the Teachers screen filters on. CRUD viaDesignationsController(same file, lines 93-152). - Person classifications — the two national reference lists a person is recorded against: caste/ethnic group (
ethnicities) and mother tongue (motherTongues). CRUD viaPersonClassificationsController, also declared inlookups.controller.ts(lines 189-316), backed by its ownPersonClassificationsServicerather thanLookupsService— see 5.2 for why these are seeded national data rather than a school's own vocabulary, and 6.3 for why they are a separate service despite the tables being structurally identical to departments/designations. - The school profile singleton — one row of typed institutional facts (name, address, branding colors, currency, timezone, academic year start month, the class module's roll-number policy —
roll_number_strategy/roll_number_auto_renumber— and the IEMIS/EMIS registration facts: IEMIS/SEE/HSEB codes, establishment year, additional contact emails/phones, classification, school type, the highest registered grade, bank and PAN details, geo-coordinates, and the level/programme/approval-date fields a government return is filled from) viaSchoolProfileController(apps/api/src/modules/school-profile/school-profile.controller.ts). The two roll-number columns are read byRollNumberServicein the class module, not by anything in this one — this module only stores and serves them; see/docs/developer/classes/backend.iemisCode/panNumberare also read, uncached, by the integration fees API'sGET /api/integration/fees/school(apps/api/src/modules/integration/fees-api/integration-fees-meta-read.service.ts) and by every generated report's export header (apps/api/src/modules/reports/jobs/generation/report-generation.service.ts) — see the integration and reports modules' own backend docs. - The financial audit trail for the school's own bank and PAN details. Every
PATCHthat actually changesbankName,bankAccountNumber, orpanNumberwrites aschool_profile.financial_updateactivity entry, withbankAccountNumber/panNumbermasked to at most their last four characters — see 6.2. - Name-uniqueness enforcement for departments (global), designations (per department), and both classification lists (global, per list), all case-insensitive.
- Delete-time referential checks that produce a mapped error instead of an unmapped foreign-key failure — including the classification lists' delete guard against
users.ethnicity_id/users.mother_tongue_id. - An hour-long Redis cache, populated on read and cleared on write, for the school profile row, the department and designation lists, and both classification lists. Every list carries its full query (search term, active filter, sort, page, size) folded into its cache key, so no cached page can ever answer for a different filter combination than the one that produced it — see Caching.
Does Not Own
- Roles and permissions. A designation is not a role — see 5.2
designations.RoleGuard/RoleService(apps/api/src/common/authorization/role.guard.ts) own what an actor may do; this module only owns what an actor is employed as. - Staff records.
packages/db/src/schema/school/people.tsowns thestafftable, which referencesdepartments/designationsby id but is created, updated, and soft-deleted by the people module. - The Teachers screen's list endpoint. There is no
Teacherscontroller or table — a teacher is astaffrow whosedesignation.isTeachingistrue(packages/db/src/schema/school/lookups.ts:87-88). This module only owns theisTeachingflag itself. - The
userstable and theethnicity_id/mother_tongue_idcolumns on it.packages/db/src/schema/identity.tsownsusers, including those two foreign keys; this module owns only the two tables they point at, and readsusersexactly once per delete request, to check whether anybody still points at the row being deleted. - Authentication.
JwtAuthGuard(apps/api/src/modules/auth/guards/jwt-auth.guard.ts) is imported, not implemented, by both controllers. - Redis connectivity and the generic fail-soft cache primitives (
getSoft/setSoft/delSoft/delPatternSoft) — owned byRedisCacheService. - CSV/bulk import of departments/designations.
apps/api/src/common/types/error-codes.ts:228-229definesIMPORT_UNKNOWN_DEPARTMENT/IMPORT_UNKNOWN_DESIGNATIONfor the data-transfer module, which resolves names against these tables but is not part of this module. - Assigning, editing, or enforcing a pupil's roll number. This module stores and serves
roll_number_strategy/roll_number_auto_renumber; the class module'sRollNumberService(enrollments-core/roll-numbers/roll-number.service.ts) reads them directly offschool_profile— not throughSchoolProfileService— and owns every write tostudent_class_enrollments.roll_number, including the two dedicated routes and the automatic assignment on enrolment. See/docs/developer/classes/backend.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| Department/designation existence, name, active flag | departments / designations tables | Served from a Redis cache keyed on the full query, populated on a miss and cleared on every write — see Caching. |
| A staff member's department/designation | staff.department_id / staff.designation_id, validated by the composite FK described in 5.2 | This module owns the vocabulary; staff owns the assignment. |
| A person's caste/ethnic group and mother tongue | ethnicities / motherTongues tables, referenced by users.ethnicity_id / users.mother_tongue_id | Seeded from the national census classification, school-editable thereafter; a rename here retroactively changes what every pupil already recorded against the row is understood to mean. |
| School profile fields | school_profile row 1 | Cached for up to an hour after a read; cache is cleared synchronously on every PATCH. |
| "Is this designation a teaching one" | designations.is_teaching | No derived or cached copy exists anywhere else. |
| Which admission/employee year a number belongs to | school_profile.timezone, read via SchoolProfileService.getTimezone() | See 6.2. Never trust a UTC clock for this. |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
LookupsModule | Leaf | apps/api/src/modules/lookups/lookups.module.ts | DepartmentsController, DesignationsController, PersonClassificationsController | LookupsService, PersonClassificationsService | LookupsService, PersonClassificationsService | Owns department/designation CRUD and the two person-classification lists. |
SchoolProfileModule | Leaf | apps/api/src/modules/school-profile/school-profile.module.ts | SchoolProfileController | SchoolProfileService | SchoolProfileService | Owns the singleton profile row. |
Both are registered directly on AppModule (apps/api/src/app.module.ts:47-48,176-177) — there is no SchoolModule aggregate wrapping them, unlike modules that group several leaf controllers under one parent. Each also imports RoleModule for RoleGuard's dependency on RoleService; a class-level guard whose module does not import that dependency compiles and unit-tests clean, then dies at Nest's InstanceLoader at boot, naming the consumer rather than the missing provider (lookups.module.ts:8-13, school-profile.module.ts:6-7).
PersonClassificationsService lives beside LookupsService in the same module and the same controller file rather than in its own submodule — lookups.controller.ts declares all three controller classes, and lookups.module.ts wires all three providers/exports together — but it is a deliberately separate class from LookupsService, not a set of methods added to it; see 6.3 for why.
LookupsService and PersonClassificationsService are both exported and can be injected by other modules that need to resolve a department/designation/classification id (e.g. the people module, when validating a staff record's department_id/designation_id, or a person's ethnicity_id/mother_tongue_id, against these tables) — no other module currently imports either (verified: LookupsModule and SchoolProfileModule each appear once in app.module.ts, and neither service is imported anywhere outside its own controller).
4. File and Directory Map
apps/api/src/modules/lookups/
lookups.module.ts
lookups.controller.ts # DepartmentsController + DesignationsController + PersonClassificationsController
lookups.service.ts # LookupsService — departments and designations
person-classifications.service.ts # PersonClassificationsService — ethnicities and mother tongues
person-classifications.service.spec.ts # real-Postgres coverage
dto/
index.ts
department.dto.ts # DepartmentDto, Create/UpdateDepartmentDto,
# ListDepartmentsQueryDto, ListDesignationsQueryDto
designation.dto.ts # DesignationDto, Create/UpdateDesignationDto
person-classification.dto.ts # PersonClassificationDto, ListPersonClassificationsQueryDto,
# Create/UpdatePersonClassificationDto
apps/api/src/modules/school-profile/
school-profile.module.ts
school-profile.controller.ts
school-profile.service.ts
school-profile-reads.service.ts # classRegisteredUpto, principal, runningGrades — resolved fresh, never cached
__tests__/
school-profile.service.integration.spec.ts # real-Postgres coverage
school-profile.integration.spec.ts # real-Postgres coverage of the EMIS fields, gating, and audit
dto/
index.ts
school-profile.dto.spec.ts
school-profile.dto.ts # SchoolProfileDto, UpdateSchoolProfileDto
packages/db/src/schema/school/
lookups.ts # departments, designations, their relations
school-profile.ts # schoolProfile
people.ts # staff — consumes departments/designations
packages/db/src/schema/
identity.ts # ethnicities, motherTongues, users (owns ethnicity_id/mother_tongue_id)
packages/db/src/seed/
seed-reference-data.ts # the 56 ethnicities and 42 mother tongues, called from seed.ts and seed-prod.ts| File | Purpose | Key Exports | Notes |
|---|---|---|---|
lookups.module.ts | Wires all three lookup controllers to their services. | LookupsModule | Imports RoleModule for the guard dependency. |
lookups.controller.ts | Three @Controller classes in one file: departments, designations, and lookups (the classification routes, nested under /lookups rather than a bare path). | DepartmentsController, DesignationsController, PersonClassificationsController | Every handler is thin — validation and business logic both live in the service. |
lookups.service.ts | All department/designation business logic. | LookupsService | One class for both entities — they share cache invalidation and follow the same list/create/update/delete shape. |
person-classifications.service.ts | All ethnicity/mother-tongue business logic. | PersonClassificationsService | One class for both tables, resolved through a single kind discriminator — see 6.3 for why this is a separate service from LookupsService despite the identical table shape. |
dto/department.dto.ts | Department DTOs and both list query DTOs. | DepartmentDto, CreateDepartmentDto, UpdateDepartmentDto, ListDepartmentsQueryDto, ListDesignationsQueryDto | ListDesignationsQueryDto lives in this file rather than designation.dto.ts — verified by reading the file; not a typo in this document. |
dto/designation.dto.ts | Designation DTOs. | DesignationDto, CreateDesignationDto, UpdateDesignationDto | UpdateDesignationDto deliberately omits departmentId — see 6.1. |
dto/person-classification.dto.ts | Ethnicity/mother-tongue DTOs and the PersonClassificationKind union. | PersonClassificationDto, ListPersonClassificationsQueryDto, CreatePersonClassificationDto, UpdatePersonClassificationDto, PersonClassificationKind | ListPersonClassificationsQueryDto overrides QueryDto.order's default from desc to asc — see 6.3. |
school-profile.module.ts | Wires the profile controller to its service. | SchoolProfileModule | Imports RoleModule for the same reason as above. |
school-profile.controller.ts | GET and PATCH on /school-profile. | SchoolProfileController | No POST/DELETE — the row always exists from migration 0002. |
school-profile.service.ts | Singleton read/update, cache-aside on the row's own columns, grade/approval-date/coordinate validation, the financial audit. | SchoolProfileService | Also exposes getTimezone() for other modules' year-numbering logic. |
school-profile-reads.service.ts | The entire cross-table read surface — classRegisteredUpto, principal, runningGrades — resolved fresh on every call, never cached. | SchoolProfileReadsService | See 6.2 for why these three are split out of the cached row. |
dto/school-profile.dto.ts | Profile DTOs. | SchoolProfileDto, UpdateSchoolProfileDto, SchoolProfileGradeRefDto, SchoolProfileRunningGradeDto, SchoolPrincipalDto, SchoolCoordinatesInputDto | All fields optional on update; see 6.2 for exactly which fields an empty string clears to null and which ones ignore a blank instead. |
5. Data Model
5.1 Schema Source
packages/db/src/schema/school/
lookups.ts # departments, designations
school-profile.ts # schoolProfile
people.ts # staff (department_id / designation_id columns + composite FK)
packages/db/src/schema/
identity.ts # ethnicities, motherTongues, users (ethnicity_id / mother_tongue_id columns)5.2 Tables and Collections
departments
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | serial | No | auto-increment | PK | Referenced by designations.department_id, staff.department_id | Internal integer id. |
public_id | uuid | No | uuid7() generated in application code | UNIQUE | N/A | The id exposed over the API; id never appears in a route param. |
name | text | No | — | departments_name_unique — UNIQUE index on lower(name) | N/A | Case-insensitive: "Science" and "science" collide. |
code | text | Yes | null | — | N/A | Free-text short code (e.g. "SCI"); not validated for uniqueness or format. |
description | text | Yes | null | — | N/A | — |
is_active | boolean | No | true | departments_is_active_idx (btree) | N/A | Retirement flag — see below. |
created_at | timestamptz | No | now() | — | N/A | — |
updated_at | timestamptz | No | now(), $onUpdateFn | — | N/A | Bumped on every UPDATE from the application, not a DB trigger. |
departments carries no deleted_at. Both lookup tables are deliberately soft-delete-free: a soft-deleted row never fires ON DELETE restrict, so a soft-deleted department would keep resolving for every staff row still pointing at it while disappearing from the admin's pick list — gone to the eye, present to the query. Retirement is is_active = false, which leaves every existing reference intact and removes the row from active-only pick lists; hard deletion is permitted only when nothing references the row, enforced by ON DELETE restrict on designations.department_id and checked in the service before staff (packages/db/src/schema/school/lookups.ts:16-34).
designations
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | serial | No | auto-increment | PK; part of designations_department_id_id_unique | Referenced by staff.designation_id and by the composite FK on staff | — |
public_id | uuid | No | uuid7() | UNIQUE | N/A | Exposed over the API. |
department_id | integer | No | — | FK → departments.id ON DELETE restrict; part of designations_department_id_id_unique | designations.department | Immutable once any staff row references this designation — see the invariant catalog in 16.5. |
name | text | No | — | designations_department_name_unique — UNIQUE on (department_id, lower(name)) | N/A | Case-insensitive and scoped per department — "Teacher" can exist once in Science and once in Arts. |
is_teaching | boolean | No | false | designations_is_teaching_idx (btree) | N/A | Load-bearing. There is no Teachers table; the Teachers screen is the staff list filtered on designation.is_teaching = true. A designation created without this flag produces staff who never appear among teachers even if they teach. |
is_active | boolean | No | true | — | N/A | Retirement flag, same semantics as departments.is_active. |
created_at | timestamptz | No | now() | — | N/A | — |
updated_at | timestamptz | No | now(), $onUpdateFn | — | N/A | — |
Constraint detail — designations_department_id_id_unique (packages/db/src/schema/school/lookups.ts:100-119): this is a table UNIQUE constraint, not a uniqueIndex, and the distinction is load-bearing. It exists solely so staff can carry a composite foreign key onto (department_id, designation_id). drizzle-kit emits every ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY before every CREATE INDEX in a generated migration, so if this were a uniqueIndex instead, the referencing foreign key on staff would be added before its target index exists, and migration 0002 would fail on a clean database with SQLSTATE 42830 ("there is no unique constraint matching given keys"). A table constraint is emitted inline inside CREATE TABLE, so it is always in place first. This was verified by running the generated migration against an empty database — the index form passed a hand-ordered probe but failed the real migration, which is the gap between probing a DDL copy and probing what actually ships.
ethnicities and mother_tongues
Declared in packages/db/src/schema/identity.ts, not under schema/school/ — they sit beside users, the table whose ethnicity_id/mother_tongue_id columns reference them, rather than beside departments/designations, which they otherwise structurally resemble.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | serial | No | auto-increment | PK | Referenced by users.ethnicity_id (ethnicities) or users.mother_tongue_id (mother_tongues), both ON DELETE set null | The value a person form posts back in person.ethnicityId/person.motherTongueId — see PersonClassificationDto for why the integer, not the public id, is the one the client uses here. |
public_id | uuid | No | uuid7(), $defaultFn in application code | UNIQUE | N/A | What the maintenance screen's own PATCH/DELETE paths address — not a value any other table's foreign key ever stores. |
name | text | No | — | ethnicities_name_unique / mother_tongues_name_unique — UNIQUE index on lower(name) | N/A | Case-insensitive: "Chhetri" and "chhetri" collide. |
is_active | boolean | No | true | — | N/A | Retirement flag, identical semantics to departments.is_active/designations.is_active. |
created_at | timestamptz | No | now() | — | N/A | — |
updated_at | timestamptz | No | now(), $onUpdateFn | — | N/A | Bumped on every UPDATE from the application. |
Neither table carries deleted_at, for the same reason departments/designations do not: a soft delete never fires ON DELETE set null, so a soft-deleted classification row would keep resolving for every person still pointing at it while vanishing from the admin's lookup screen — gone to the eye, present to the query. Retirement is is_active = false; hard deletion is permitted only once nothing references the row, checked in PersonClassificationsService.deletePersonClassification by querying users for the matching column before the DELETE runs (person-classifications.service.ts:231-255).
The two tables are structurally identical to each other — same columns, same constraint shapes — and that sameness is deliberate risk, not an oversight: two near-identical services would type-check even if one wrote an ethnicity row into mother_tongues, because Drizzle's inferred row types are indistinguishable. PersonClassificationsService.classificationTable(kind) is the single private method that resolves a PersonClassificationKind ("ethnicities" or "mother-tongues") to its table, so there is exactly one place that decision can be got wrong — see 6.3.
Both tables start empty from the migration and are populated by seedReferenceData() (packages/db/src/seed/seed-reference-data.ts), called from both seed.ts and seed-prod.ts: 56 ethnicities following the Central Bureau of Statistics' caste/ethnicity classification used in the national census, and 42 mother tongues ordered roughly by national speaker count, each list ending in "Other". The insert is onConflictDoNothing() against the lower(name) unique index, so re-running the seed against a live database is a no-op for every name already present, and it deliberately does not reactivate a row a school has since retired — is_active = false is a decision the school made on its own lookup screen, and a deployment's seed run must not quietly undo it.
staff — the columns this module's tables feed
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
department_id | integer | Yes | null | FK → departments.id ON DELETE restrict; staff_department_id_idx (btree) | staff.department | Nullable — a staff row can exist with no department assigned. |
designation_id | integer | Yes | null | Part of the composite FK below; staff_designation_id_idx (btree) | staff.designation | Nullable independently of department_id. |
Two constraints on staff close the gap a single nullable pair leaves open (packages/db/src/schema/school/people.ts:326-341):
staff_designation_in_department_fk— a composite foreign key(department_id, designation_id) → designations(department_id, id). This is what stops "Librarian" (a Library designation) being filed under the Accounts department on the same staff row: Postgres rejects any pair that is not a real(department, designation)combination indesignations.- MATCH SIMPLE hole, and the CHECK that closes it. Postgres composite foreign keys default to
MATCH SIMPLE, which skips the check entirely when any column in the pair isNULL. That is wanted for the legitimate "department chosen, designation not yet chosen" state — but it also accepts(NULL, 999999), i.e. adesignation_idset with nodepartment_idat all, which the composite key does not validate because the FK is skipped, and the plainstaff.department_idFK does not cover adesignation_id-only value either. A probe insert of(NULL, <a real designation id>)against a table whose only designation belonged to a different department was accepted by the composite FK alone. Thestaff_designation_needs_departmentCHECK closes that direction:designation_id IS NULL OR department_id IS NOT NULL. Together, the composite FK enforces "if both are set, they must agree," and the CHECK enforces "you may not set the designation without the department."
5.3 Relationship Diagram
school_profile has exactly one foreign-key relationship to another table's data, class_registered_upto_grade_id → grades.id (grades is owned by the classes module's schema, not shown here) — otherwise it is a free-standing singleton. users is owned by packages/db/src/schema/identity.ts, not this module; it is shown only for the two foreign keys this module's tables are the target of.
school_profile
| Column | Type | Nullable | Default | Index/Constraint | Notes |
|---|---|---|---|---|---|
id | integer | No | 1 | PK; school_profile_singleton CHECK id = 1 | The CHECK is what makes this a singleton — any INSERT with a different id, and any attempt to insert a second row with id = 1, is rejected by the PK. |
name | text | No | '' | — | Empty string, not null, is the "not yet configured" state. |
logo_url | text | Yes | null | — | Not validated as a URL by the DTO (@IsString, MaxLength(500) only — see 6.x school-profile DTOs). |
registration_number | text | Yes | null | — | — |
principal_name | text | Yes | null | — | Retired from the response. Still a real, writable column — UpdateSchoolProfileDto still accepts and writes it — but SchoolProfileService.toDto strips it from every read, because the API now derives the principal instead: see SchoolProfileDto.principal, resolved by SchoolProfileReadsService.resolvePrincipalForProfile from whoever actively holds the designation flagged is_principal (via PrincipalInvariantService.resolvePrincipal). The column is kept, unread, for one release rather than dropped, because the global ValidationPipe's forbidNonWhitelisted would reject a caller's entire save if the write DTO stopped declaring a field it is still sending. |
province_id, district_id, municipality_id, ward_no, tole, house_no | integer/integer/integer/smallint/text/text | Yes | null | Composite FKs school_profile_district_fk → (province_id, district_id) in districts, school_profile_municipality_fk → (district_id, municipality_id) in municipalities; fill-order CHECKs school_district_needs_province, school_municipality_needs_district, school_ward_needs_municipality, school_tole_needs_district, school_house_needs_tole, school_address_text_not_blank | The school's own address, in the same Nepali structure as a person's — one address, not a permanent/current pair, because a school does not have a present address distinct from its permanent one. Replaces the old free-text address/city/state/pin_code columns, which migration 0007 backfilled into these six and migration 0008 dropped; the singleton row held a complete, fully resolvable address at the time, so dropping the old columns unbackfilled would have destroyed real data. The same MATCH SIMPLE subtlety applies here as on users: the composite foreign keys are live only because the fill-order CHECKs force both columns of each key non-NULL together — see the geography.ts schema docblock. |
phone, email, website | text | Yes | null | — | Free text; email is validated by @IsEmail() on write unless the value is "" (blank clears it) — the column itself has no format constraint. email is the only nullable text field the DTO also accepts a bare null for, since @IsOptional()/@ValidateIf let null skip @IsEmail() the same way "" does. |
academic_year_start_month | smallint | No | 4 | school_profile_academic_year_start_month_valid CHECK BETWEEN 1 AND 12 | Nepal's academic year starts in Baisakh (mid-April); the calendar year and academic year are not the same thing, and any consumer reporting "this year" must know which one it means. |
roll_number_strategy | roll_number_strategy enum ("admission_order" | "alphabetical") | No | 'admission_order' | Added by migration 0021_class_roll_number.sql | Read by RollNumberService.policy in the class module (packages/db/src/schema/school/classes.ts, enrollments-core/roll-numbers/roll-number.service.ts) — this row does not enforce it, only stores it. Decides the order a class's roll numbers are assigned in when the class is RENUMBERED — admission_order sorts by students.admissionNumber then id, alphabetical by firstName/middleName/lastName then id. Changing this column moves nobody by itself; it only changes what the NEXT renumber (automatic or explicit) does. No third "manual" value: an administrator editing one pupil's number directly is an override, not a strategy, and a third enum value would make "what a renumber does" unanswerable for a school that had ever used it. |
roll_number_auto_renumber | boolean | No | false | Added by migration 0021_class_roll_number.sql | Also read by RollNumberService.policy, never enforced here. When true, admitting a pupil into a class resequences the WHOLE class by roll_number_strategy instead of appending the next free number. Off by default, and that default is the entire reason both columns exist as two columns rather than one: a school that asks for "alphabetical roll numbers" usually means the order it wants a register printed in, not that every existing pupil's number should shift the moment a new child is admitted in Ashwin — a roll number has by then already been read out in class and recorded against attendance history. |
brand_primary_color, brand_secondary_color | text | Yes | null | — | Validated as hex colors (@IsHexColor()) only at the DTO layer, not by the column. Retired from the response for the same reason as principal_name above — still writable, no longer read back; the theming feature they drove no longer exists. |
currency_code | text | No | 'NPR' | — | Free text at the column level; DTO caps it at 8 characters but does not validate against ISO 4217. |
timezone | text | No | 'Asia/Kathmandu' | — | Load-bearing beyond display. Read by SchoolProfileService.getTimezone() to decide which year an admission/employee number belongs to. Asia/Kathmandu is UTC+05:45, so allocating a year from a UTC clock instead would issue the closed year's numbers for the first five hours and forty-five minutes of every January 1st in UTC terms (the moment the school's local New Year has not yet arrived, or has just passed, depending on direction). The column itself has no format constraint, but a non-empty value must pass BOTH @IsTimeZone() and a @Matches() regex once it is not ""/null/undefined (apps/api/src/modules/school-profile/dto/school-profile.dto.ts:329-342): @IsTimeZone() accepts any name Intl resolves, including a real IANA alias such as "Asia/Katmandu", and `@Matches(/^(UTC |
iemis_code | text | Yes | null | school_iemis_code_not_blank CHECK (not blank, char_length <= 32) | The Ministry's IEMIS registration code — distinct from registration_number, which is the local registration. |
established_year_ad | smallint | Yes | null | school_established_year_ad_range CHECK BETWEEN 1800 AND 2100 | Gregorian (AD) only, never Bikram Sambat — the suffix says so, so a stored value has exactly one meaning; a BS entry is converted client-side. Exposed as establishedYear in the API. |
additional_emails, additional_phones | text[] | No | '{}'::text[] | school_additional_emails_valid / school_additional_phones_valid CHECK — cardinality <= 5, no NULL element, one dimension, no empty-string element | Contact lists beside the primary phone/email. App-enforced beyond the CHECK (the DTO, not the column): per-element length (emails <= 254, phones <= 40) and de-duplication (emails case-insensitively, both trimmed). |
classification, school_type | text | Yes | null | school_classification_not_blank / school_school_type_not_blank CHECK (not blank, char_length <= 100) | Plain text by decision — no IEMIS enum is imposed on either. |
class_registered_upto_grade_id | integer | Yes | null | FK school_profile_registered_upto_grade_fk → grades.id, ON DELETE restrict | The highest grade the school is registered to teach. No index — a one-row table, so the RESTRICT check a grade delete runs scans a single row. May point at a retired (is_active = false) grade: a registration is a historical fact that survives the grade being retired, and SchoolProfileReadsService.resolveClassRegisteredUpto resolves the name regardless, returning null only if the row no longer exists at all. |
see_code, hseb_code | text | Yes | null | school_see_code_not_blank / school_hseb_code_not_blank CHECK (not blank, char_length <= 32) | SEE and HSEB/NEB examination codes. |
bank_name | text | Yes | null | school_bank_name_not_blank CHECK (not blank, char_length <= 120) | Visible to every reader of GET /school-profile — the school's own staff, to whom a bank's name is not secret. |
bank_account_number | text | Yes | null | school_bank_account_number_format CHECK (^[0-9A-Za-z-]{4,40}$ and at least one digit) | KEY ABSENT from GET /school-profile — not present-and-null — unless the caller holds SchoolProfile_UPDATE. Diffed under a FOR UPDATE row lock on every PATCH and recorded, masked, in the school_profile.financial_update audit entry (see 6.2). |
pan_number | text | Yes | null | school_pan_number_format CHECK ^[0-9]{9}$ | Nepal PAN: exactly nine digits. |
grant_source | text | Yes | null | school_grant_source_not_blank CHECK (not blank, char_length <= 200) | — |
latitude, longitude | double precision | Yes | null | school_coordinates_paired CHECK (latitude IS NULL) = (longitude IS NULL); school_latitude_range CHECK BETWEEN -90 AND 90; school_longitude_range CHECK BETWEEN -180 AND 180 | Set together or not at all. SchoolProfileService.update refuses {latitude: 0, longitude: 0} outright with SCHOOL_PROFILE_COORDINATES_UNSET — it reads as an unfilled form defaulting to zero, not a real location; send null to clear both. |
elevation_meters | integer | Yes | null | school_elevation_meters_range CHECK BETWEEN -500 AND 9000 | — |
has_ecd_level, has_basic_primary_level, has_basic_lower_secondary_level | boolean | No | false | — | Level checkboxes: ECD, Basic 1–5, and Basic 6–8 respectively. false means "not declared, or no" — a checkbox is binary, so an unticked box and a deliberate "no" are the same stored value, and no report or export may present false as a declaration. |
ecd_approval_date, primary_approval_date, lower_secondary_approval_date | date | Yes | null | school_ecd_approval_date_range / school_primary_approval_date_range / school_lower_secondary_approval_date_range CHECK >= DATE '1850-01-01' | Gregorian (AD), Drizzle string mode (YYYY-MM-DD) so no UTC-midnight shift can move the date by a day. App-enforced beyond the CHECK: SchoolProfileService.assertApprovalDateInRange also refuses a date after today in the school's own timezone, since a CHECK cannot call now(). Deliberately not tied to the level flags above by a cross-column CHECK — an approval is a historical fact that stays true when a level is later switched off, and a cross-column CHECK would make un-ticking a level fail while its approval date is still filled. |
teaches_science_grade_11_12, is_model_school_selected, has_complaint_hearing_mechanism, has_foreign_affiliation, is_informal_school, is_mobile_ghunti_school, is_open_school, is_special_disability_school, is_multilingual_education_school, is_mgml_implemented, runs_residential_scholarship, receives_zero_position_grant, runs_technical_stream | boolean | No | false | — | Programme and indicator checkboxes, same false-means-"not declared, or no" convention as the level flags above. |
created_at | timestamptz | No | now() | — | Never updated after the migration inserts row 1. |
updated_at | timestamptz | No | now(), $onUpdateFn | — | — |
Row 1 is inserted by migration 0002_school_domain_foundation.sql (INSERT INTO "school_profile" ("id", "name") VALUES (1, '') ON CONFLICT ("id") DO NOTHING;, line 309) — not by a seed script. A singleton with CHECK (id = 1) is expected to exist from the moment the table does, because both the settings screen and code that reads timezone for year numbering assume row 1 is present; SchoolProfileService.get() throws SCHOOL_PROFILE_NOT_INITIALIZED if it is ever missing, and that path is documented as "should be unreachable... if this fires, the database was built by something other than the migrations" (school-profile.service.ts:41-49).
Index rationale:
| Index/Constraint | Columns | Type | Query/Invariant Supported | Tradeoff |
|---|---|---|---|---|
departments_name_unique | lower(name) | unique btree (expression index) | Case-insensitive global name uniqueness. | Every insert/update pays the lower() evaluation; negligible at this table's size. |
departments_is_active_idx | is_active | btree | The "active only" filter every staff-creation dropdown applies. | One extra index to maintain on every toggle. |
designations_department_name_unique | department_id, lower(name) | unique btree (expression index) | Case-insensitive name uniqueness scoped per department. | Same tradeoff as above, composite. |
designations_department_id_id_unique | department_id, id | unique table constraint | The only reason it exists: lets staff declare a composite FK onto (department_id, designation_id) — see 5.2. | Duplicates information already unique via id alone; accepted because it is the mechanism, not a query optimization. |
designations_is_teaching_idx | is_teaching | btree | The Teachers-screen filter (isTeaching=true on GET /designations, then joined against staff). | — |
ethnicities_name_unique | lower(name) | unique btree (expression index) | Case-insensitive uniqueness on the caste/ethnic-group list — enforces that a school's aggregation against the national census classification can't fork into two spellings of one group. | Every insert/update pays the lower() evaluation; negligible at this table's size (56 seeded rows). |
mother_tongues_name_unique | lower(name) | unique btree (expression index) | Same guarantee for the mother-tongue list (42 seeded rows). | Same tradeoff. |
staff_department_id_idx, staff_designation_id_idx | department_id / designation_id | btree | Staff list filters by department/designation; also the delete-guard queries in deleteDepartment/deleteDesignation. | — |
staff_designation_in_department_fk | (department_id, designation_id) | composite FK, MATCH SIMPLE | Prevents a designation being recorded under the wrong department on a staff row. | Skips validation when either column is NULL — closed by the CHECK below. |
staff_designation_needs_department | department_id, designation_id | CHECK | Closes the MATCH SIMPLE hole: a designation cannot be set without a department. | — |
school_profile_singleton | id | CHECK | Enforces the one-row table. | — |
school_profile_academic_year_start_month_valid | academic_year_start_month | CHECK | Rejects an out-of-range month at the database layer, independent of the DTO's own @Min(1) @Max(12). | — |
6. Services and Responsibilities
6.1 LookupsService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
findAllDepartments(query) | DepartmentsController.findAll | Cache first (DEPARTMENTS_CACHE_PREFIX, keyed on the full query); on a miss, departments plus a correlated subquery on designations for designationCount | — | Populates the cache on a miss (setSoft, TTL 3600s) | — |
createDepartment(dto) | DepartmentsController.create | departments (name-uniqueness pre-check) | departments insert | invalidate() (clears both cache prefixes) | DEPARTMENT_NAME_TAKEN (409, pre-check); a race past the pre-check surfaces as the generic RESOURCE_ALREADY_EXISTS (409) from the global unique-violation fallback, not DEPARTMENT_NAME_TAKEN — see 16.5. |
updateDepartment(publicId, dto) | DepartmentsController.update | departments (lookup + name pre-check if renaming) | departments update | invalidate() | DEPARTMENT_NOT_FOUND (404); DEPARTMENT_NAME_TAKEN (409) only when the new name actually changes case-insensitively. |
deleteDepartment(publicId) | DepartmentsController.remove | departments (lookup); designations (existence check) | departments delete | invalidate() | DEPARTMENT_NOT_FOUND (404); DEPARTMENT_HAS_DESIGNATIONS (409) if any designation still references it. |
findAllDesignations(query) | DesignationsController.findAll | Cache first (DESIGNATIONS_CACHE_PREFIX, keyed on the full query); on a miss, designations inner-joined to departments (for departmentName) plus a correlated subquery on staff (excluding soft-deleted rows) for staffCount | — | Populates the cache on a miss | — |
createDesignation(dto) | DesignationsController.create | departments (parent existence); designations (name pre-check, scoped to department) | designations insert | invalidate() | DEPARTMENT_NOT_FOUND (404, the parent); DESIGNATION_NAME_TAKEN (409). |
updateDesignation(publicId, dto) | DesignationsController.update | designations (lookup + name pre-check); departments (re-read for response mapping) | designations update | invalidate() | DESIGNATION_NOT_FOUND (404); DESIGNATION_NAME_TAKEN (409); PRINCIPAL_DESIGNATION_RETIRE_FORBIDDEN (409) — isActive: false refused when existing.isPrincipal, checked before the write regardless of whether anyone currently holds the designation. departmentId cannot be part of dto — see below. |
deleteDesignation(publicId) | DesignationsController.remove | designations (lookup); staff (existence check, not filtered on deleted_at) | designations delete | invalidate() | DESIGNATION_NOT_FOUND (404); PRINCIPAL_DESIGNATION_DELETE_FORBIDDEN (409) — the flagged designation can never be hard deleted, checked before the in-use check; DESIGNATION_IN_USE (409) if any staff row still references it — including a soft-deleted staff row, see 16.5. |
findDepartmentOrThrow / findDesignationOrThrow (private) | Every mutating method above | One row by public_id | — | — | DEPARTMENT_NOT_FOUND / DESIGNATION_NOT_FOUND. |
assertDepartmentNameFree / assertDesignationNameFree (private) | Create and rename paths | Case-insensitive name lookup | — | — | DEPARTMENT_NAME_TAKEN / DESIGNATION_NAME_TAKEN. |
invalidate() (private) | Every write | — | — | cache.delPatternSoft on both prefixes (DEPARTMENTS_CACHE_PREFIX:*, DESIGNATIONS_CACHE_PREFIX:*), unconditionally | Never throws — fail-soft (see 8. Caching). |
Input normalization. Every string field is .trim()-ed before it is compared or written (name, code, description); an update that sends an empty string for code/description is normalized to null via dto.code.trim() || null rather than stored as "".
Validation order. Existence is always checked before uniqueness, and uniqueness before the write — createDesignation looks up the parent department (404 if missing) before it checks the name (409 if taken) before it inserts. updateDepartment/updateDesignation only re-check the name when the caller actually changed it case-insensitively (dto.name.trim().toLowerCase() !== existing.name.toLowerCase()), so re-submitting the same name with different casing is accepted as a no-op rename rather than rejected as a clash against itself.
Transactions. None of these methods wraps its read-then-write in a database transaction. The name-uniqueness check is a plain SELECT followed by a separate INSERT/UPDATE; see the concurrency note in 16.5.
departmentId is immutable on a designation by omission, not by a runtime check. UpdateDesignationDto simply does not declare a departmentId field (apps/api/src/modules/lookups/dto/designation.dto.ts:47-64). Because the app's global ValidationPipe runs with whitelist: true, forbidNonWhitelisted: true (apps/api/src/main.ts:64-69), a PATCH body that includes departmentId is rejected with a standard 400 validation error ("property departmentId should not exist") before LookupsService.updateDesignation ever runs. The error registry does declare a dedicated DESIGNATION_DEPARTMENT_IMMUTABLE code (error-codes.ts:246-249) for this rule, but no code path in this service throws it — verified by reading the full service file. Treat it as a reserved code for a future explicit check, not as a code you will currently see on the wire.
The designation flagged is_principal cannot be retired or deleted. Two guards in this service exist solely for that one row: updateDesignation refuses isActive: false on it with PRINCIPAL_DESIGNATION_RETIRE_FORBIDDEN (checked before any held-by-staff consideration — an unheld Principal designation is the common case the moment a school's principal leaves, not an edge case), and deleteDesignation refuses any delete on it with PRINCIPAL_DESIGNATION_DELETE_FORBIDDEN, checked before the staff-reference existence check. Both guards exist because PrincipalInvariantService.assertSinglePrincipal locks this exact row (FOR UPDATE) to serialise every write that could create a second principal, and PrincipalInvariantService.resolvePrincipal/the school profile's principal field resolve the current principal by joining to it — retiring or deleting the flagged row would silently strand both mechanisms with no designation left to appoint a successor to, while a retired-but-referenced row would keep resolving for whoever already holds it. See apps/api/src/modules/people/shared/principal-invariant.service.ts for the full invariant and its five verified write sites.
Response mapping. Both list methods select exactly the columns the corresponding DTO needs (including the correlated-subquery counts) rather than SELECT *, so there is no server-internal field to strip before the response leaves the service.
Caching, precisely. findAllDepartments/findAllDesignations are cache-aside: CacheKeyUtil.build folds the entire query — search term, active filter, sort column, order, pagination flag, page, and size — into the key, under DEPARTMENTS_CACHE_PREFIX:list:/DESIGNATIONS_CACHE_PREFIX:list:. A hit returns the cached { data, totalCount } pair with no database round trip at all, including no COUNT(*). Neither list carries a per-department or per-caller scope segment, unlike the people lists — every department/designation row is visible to every caller who can reach the route, so there is nothing a shared key could leak. invalidate() clears both prefixes on every department or designation write, never just the one that changed: a designation's list row carries its department's name, so a department rename that cleared only the department prefix would leave the designation list showing the old name for up to an hour on the very staff form where the two are chosen together.
Fail-open vs fail-closed. Every failure mode here is fail-closed (a thrown, coded exception) except cache reads/writes/invalidation, which are fail-soft by design — see 8. Caching.
Logger usage. LookupsService does not log directly; a failed cache operation is logged by RedisCacheService itself (WARN, see redis.service.ts:247-266), and any exception thrown from the service is logged by AllExceptionsFilter at the HTTP boundary — at warn for a refusal below 500 (including a Postgres unique violation or guard-trigger refusal mapped to 409), at error with a stack otherwise.
6.2 SchoolProfileService and SchoolProfileReadsService
SchoolProfileService owns the cached, single-row half of the profile and every write. SchoolProfileReadsService owns everything the response resolves fresh, on every request, from OTHER tables — classRegisteredUpto, principal, and runningGrades — because none of those tables' writes invalidate this module's cache key, and caching them alongside the row's own columns would let the profile name a principal who has already left, or a running-grade list from a session that is no longer current. The split exists to keep each file under the module-size limit and to make that cache boundary a file boundary rather than a convention someone has to remember.
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
get(actor) | SchoolProfileController.get | getBase(), then delegates to buildResponse | — | — | Same as getBase(). |
getBase() (private) | get(), update(), getTimezone() | Cache (school:profile:v2), then school_profile row 1 on a miss | — | Populates the cache on a miss (setSoft, TTL 3600s) | SCHOOL_PROFILE_NOT_INITIALIZED (404) if row 1 is missing — documented as unreachable outside a database built by something other than the migrations. |
buildResponse(base, actor) (private) | get(), update() | SchoolProfileReadsService for classRegisteredUpto, principal, runningGrades (run concurrently via Promise.all); PeoplePermissionsService.can(actor, "SchoolProfile_UPDATE") to decide whether to attach bankAccountNumber | — | — | Propagates whatever SchoolProfileReadsService throws. |
resolveClassRegisteredUptoGradeIdForWrite(value) (private) | update() | grades, by public_id | — | — | SCHOOL_PROFILE_GRADE_NOT_FOUND (404) if the id does not resolve; SCHOOL_PROFILE_GRADE_INACTIVE (400) if the grade is retired. ""/null resolve to null (clear); undefined resolves to undefined (leave untouched) without a query. |
assertApprovalDateInRange(value) (private) | update(), once per approval-date field | getTimezone() (for "today" in the school's own zone) | — | — | SCHOOL_PROFILE_APPROVAL_DATE_OUT_OF_RANGE (400) if the date is before 1850-01-01 or after today in the school's timezone. |
update(dto, actor, context?) | SchoolProfileController.update | Locks and reads bankName/bankAccountNumber/panNumber under FOR UPDATE, then school_profile row 1 (via the UPDATE ... RETURNING), both inside one transaction; delegates to buildResponse for the response | school_profile row 1 | cache.delSoft("school:profile:v2") and SchoolBrandService.invalidate() (drops school:brand:v:1) after the transaction commits; recordFinancialAudit after the transaction commits | SCHOOL_PROFILE_COORDINATES_UNSET (400) if coordinates is exactly {latitude:0,longitude:0}; SCHOOL_PROFILE_APPROVAL_DATE_OUT_OF_RANGE; SCHOOL_PROFILE_GRADE_NOT_FOUND/SCHOOL_PROFILE_GRADE_INACTIVE; SCHOOL_PROFILE_NOT_INITIALIZED (404) if the UPDATE matches no row. |
recordFinancialAudit(before, after, context?) (private) | update(), after the write commits | — | — | Best-effort ActivityRecordService.recordActivity call, action school_profile.financial_update, only when bankName/bankAccountNumber/panNumber actually changed | Never throws — caught and logged at ERROR; skipped entirely when context?.userId is absent. |
maskFinancial(value) (private, static) | recordFinancialAudit() | — | — | — | Pure transform, never throws. |
getTimezone() | Other modules' year-numbering logic (admission/employee number allocation); assertApprovalDateInRange | Delegates to getBase(), not get() | — | — | Same as getBase(). |
toPatch(dto, resolvedGradeId) (private, static) | update() | — | — | — | Never throws — a pure transform. |
toDto(row) (private) | getBase() | — | — | — | Strips id, createdAt, principalName, brandPrimaryColor, and brandSecondaryColor from the raw row before it reaches the cached shape — everything but classRegisteredUpto, principal and runningGrades, which buildResponse attaches afterward. establishedYearAd becomes establishedYear and classRegisteredUptoGradeId is carried through raw, both named explicitly rather than left in the spread. |
SchoolProfileReadsService — the entire cross-table surface the response has:
| Method | Called By | Reads | Errors |
|---|---|---|---|
getCurrentSessionId() | buildResponse() | academic_sessions for the row flagged is_current | — |
resolveClassRegisteredUpto(gradeId) | buildResponse() | grades, by id | — (a missing or retired grade maps to null, never throws — the registration is a historical fact and a since-deleted grade must not fail the whole profile read) |
resolveRunningGrades(currentSessionId) | buildResponse() | grades joined to classes, filtered to is_active classes in the current session, ordered (sort_order, lower(name)) | — ([] when there is no current session) |
resolvePrincipalForProfile(actor, currentSessionId) | buildResponse() | PrincipalInvariantService.resolvePrincipal; then, only when the caller holds Staff_READ and PeopleAccessService.assertCanAccess allows the principal's own staff row, staff/users/ethnicities for qualification/phone/gender/ethnicityName | — |
isTeaching(staffId, currentSessionId) (private) | resolvePrincipalForProfile() | staff joined to designations; class_subjects/classes; classes again for the class-teacher case | — |
Code flow — get(): getBase() is cache-aside, read-through: getSoft is tried first against school:profile:v2; a hit returns the cached base fields with no database round trip; a miss selects row 1 by the hardcoded SINGLETON_ID = 1, throws if absent, maps it through toDto, writes it back with setSoft at a 3600-second TTL. buildResponse then always resolves classRegisteredUpto, principal and runningGrades fresh, concurrently, and decides whether to attach bankAccountNumber by checking SchoolProfile_UPDATE on the caller — a read holding only SchoolProfile_READ never sees the key at all; bankName and panNumber carry no such gate and reach every reader, because the read serves the school's own staff, to whom the bank name is not secret the way the account number is. None of the three cross-table fields is cached alongside the row's own columns: principal is derived from staff/designations/users, none of which this module hears about when they change (a staff member appointed, removed, or set inactive), so caching it for up to an hour would risk the profile naming a principal who has already left; runningGrades is derived from the current academic session and classes.is_active, both of which can flip without touching school_profile; invalidating the cache key from every one of those tables' writes would couple otherwise-independent modules for the sake of three fields. The tradeoff is several extra indexed queries — run concurrently — on every profile read; getTimezone() deliberately calls getBase() instead of get() so the admission/employee-numbering hot path never pays any of them. There is no lock or single-flight guard around a getBase() cache miss, so a burst of concurrent requests arriving after a PATCH (which just cleared the cache) can each independently miss and each independently run the same SELECT and the same setSoft — harmless here because the read is idempotent and cheap, unlike a write-amplifying miss.
Code flow — update(): three validations run before any write — coordinates of exactly {latitude: 0, longitude: 0} is refused outright (a caller wanting to clear coordinates must send null), each of the three approval dates is checked against 1850-01-01 and against "today" in the school's own timezone, and classRegisteredUptoGradeId is resolved from its public id to the integer FK (or to null/undefined), throwing before the transaction opens if the target grade does not exist or is retired. The write itself runs inside one transaction: bankName/bankAccountNumber/panNumber are read from row 1 under FOR UPDATE first, then the UPDATE ... RETURNING applies the patch toPatch(dto, resolvedGradeId) produced — it is not spread directly onto .set(). toPatch walks every key class-transformer populated on the DTO (skipping any key that is undefined, i.e. genuinely omitted from the request body) and, for a string value, trims it and then applies one of three rules depending on the field:
- For the plain nullable
textcolumns (logoUrl,registrationNumber,principalName,phone,email,website,brandPrimaryColor,brandSecondaryColor,iemisCode,classification,schoolType,seeCode,hsebCode,bankName,bankAccountNumber,panNumber,grantSource) a trimmed-to-empty string is written asnull, not""— in practicebrandPrimaryColor/brandSecondaryColornever reach this path with a blank, since@IsHexColor()on the DTO rejects an empty string with a400before the service ever runs.emailis also reachable here as a barenullrather than a string at all, which the non-string branch below handles identically — see theNOT_NULL_FIELDSparagraph. - The
addressandcoordinatesfields are each handled separately, before any per-field blanking rule runs — both are groups, replaced whole.addressfollows the same rule as a person'spermanentAddress/currentAddress(see thepeopledocs): when present, all six columns (provinceId,districtId,municipalityId,wardNo,tole,houseNo) are written from it — a missing key inside the group meansNULL— andtole/houseNoare individually blanked-to-null. Field-wise merging is deliberately not supported for either group:{ "address": { "provinceId": null } }clears the whole address rather than leavingdistrictIdbehind pointing at a now-absent province, which would otherwise raise the unmapped23514 school_district_needs_province;coordinates: nullclears bothlatitudeandlongitudetogether, enforcingschool_coordinates_pairedat the application layer before the CHECK ever has to. - For
name,currencyCode,timezone, androllNumberStrategy— four of the sixNOT_NULL_FIELDS— a trimmed-to-empty string is dropped from the patch entirely, so the column keeps its current value;rollNumberStrategyreaches this branch in principle but never in practice, sinceUpdateSchoolProfileDto.rollNumberStrategy's@IsIn(["admission_order", "alphabetical"])rejects an empty string with a400before the service ever runs. A non-string field (academicYearStartMonth,rollNumberAutoRenumber,establishedYear,elevationMeters, every level/programme boolean,additionalEmails,additionalPhones) is normally copied through unchanged, with no trimming — except when the value isnulland the key is one ofNOT_NULL_FIELDS, in which case it is dropped the same as a blank string would be.establishedYearis renamed to the columnestablishedYearAdon the way in, andclassRegisteredUptoGradeIdis excluded from the generic loop entirely — it is resolved beforetoPatchruns and applied afterward, because resolving it needs a querytoPatchitself never makes.
NOT_NULL_FIELDS is the private static set {"name", "academicYearStartMonth", "currencyCode", "timezone", "rollNumberStrategy", "rollNumberAutoRenumber"} — every .notNull() column whose DTO decorator is a plain @IsOptional(), the decorator that lets class-validator skip every check for null and let it reach the service unrefused. rollNumberAutoRenumber is a boolean but belongs in the set for exactly that reason; the other NOT NULL booleans (hasEcdLevel … runsTechnicalStream) and the two arrays (additionalEmails, additionalPhones) instead use @ValidateIf((_, v) => v !== undefined), which runs @IsBoolean()/@IsArray() against a null and refuses it with a 400 before it ever reaches toPatch, so they need no entry. Without this set, {"timezone": null} would reach the UPDATE and Postgres would refuse it with 23502 (NOT NULL violation), which all-exceptions.filter.ts turns into an undifferentiated 500 — toPatch instead drops the key, so the column keeps its current value and the request still succeeds, identically to sending a blank string on the four string-typed members of the set.
The reason the six NOT_NULL_FIELDS columns are excluded from blanking rather than also clearing to null: class-validator's @IsOptional() treats an actual null in the request body as absent for most fields, so a client with a "clear the logo" button has only the empty string available to send on those — but a form that posts every field back (not just the ones the office actually edited) could still send a blank OR a stray null for any of the six, and both must be no-ops rather than corrupting a NOT NULL column. timezone in particular is read by getTimezone() to decide which year an admission/employee number belongs to — a blank or null there is not a cosmetic bug, it is admission numbers filed under no discoverable year at all. The UPDATE ... SET ... RETURNING then applies exactly the patch toPatch produced, plus a fresh updatedAt — a field the caller never sent, and a field the caller sent as a no-op blank or null on a NOT_NULL_FIELDS column, are indistinguishable in the final SET clause: both are simply absent from it. The cache is cleared after the write commits, so a request that reads the profile in the narrow window between the UPDATE committing and the delSoft running could still see the stale cached value — the TTL bounds how long that window can ever last (at most the remaining seconds on the 3600s TTL), and the very next read after invalidation is guaranteed fresh. recordFinancialAudit and recordContactAudit both run after the cache is cleared and before the response is built — the write has already committed by then, so a throw while resolving the response must not cost either audit entry.
The financial audit. bankName, bankAccountNumber and panNumber are read under the same row lock the UPDATE takes, in the same transaction, specifically so two concurrent PATCH requests cannot each read the same before and each record the same from value twice. recordFinancialAudit compares that locked before against the written row and, for each of the three fields that actually changed, pushes a {field, from, to} entry — bankAccountNumber and panNumber are masked with maskFinancial before they reach the entry; bankName is not, since a bank's name is not itself sensitive. maskFinancial reveals at most the last four characters and never more than length - 4, so a value four characters or shorter is never shown at all ("••••" with nothing appended); a null masks to null. No entry is written when nothing changed, and none is written when context?.userId is absent — a call made outside a real HTTP request, such as a test constructing the service directly. The call is wrapped in try/catch: a failure is logged at ERROR and swallowed, deliberately, because an audit-logging fault must never fail a legitimate profile update.
The contact audit. recordContactAudit is a sibling of recordFinancialAudit, not a widening of it, and runs immediately after it. It reads email from the same locked before row — the locked SELECT reads email alongside bankName/bankAccountNumber/panNumber for exactly this — and, only when before.email !== after.email, writes one school_profile.contact_update entry with changes: [{field: "email", from, to}]. Unlike the financial fields, the value is unmasked: the school email is a public contact address, not a secret, so there is nothing to hide from an operator reading the audit trail. Skipped when context?.userId is absent, and wrapped in the same try/catch-and-log contract as the financial audit — an audit-logging fault must never fail a legitimate profile update.
Why no cache-population lock/transaction: this table is read far more than it is written (every screen that shows the school's name/logo/timezone), and stale-for-a-request-or-two after an admin edit is an acceptable tradeoff against the cost of a distributed lock around a once-a-day write.
6.3 PersonClassificationsService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
findAllPersonClassifications(kind, query) | PersonClassificationsController.findAllEthnicities / findAllMotherTongues | Cache first (CACHE_PREFIX:kind:, keyed on the full query); on a miss, ethnicities or mother_tongues per kind, ordered by name (then id) | — | Populates the cache on a miss (setSoft, TTL 3600s) | — |
createPersonClassification(kind, dto) | ...createEthnicity / createMotherTongue | Name-uniqueness pre-check (case-insensitive) on the resolved table | Insert on the resolved table | invalidateClassifications(kind) | PERSON_CLASSIFICATION_NAME_TAKEN (409, pre-check). |
updatePersonClassification(kind, publicId, dto) | ...updateEthnicity / updateMotherTongue | Lookup by publicId; name pre-check only if the name actually changed case-insensitively | Update on the resolved table | invalidateClassifications(kind) | PERSON_CLASSIFICATION_NOT_FOUND (404); PERSON_CLASSIFICATION_NAME_TAKEN (409). |
deletePersonClassification(kind, publicId) | ...deleteEthnicity / deleteMotherTongue | Lookup by publicId; users existence check on ethnicity_id/mother_tongue_id | Delete on the resolved table | invalidateClassifications(kind) | PERSON_CLASSIFICATION_NOT_FOUND (404); PERSON_CLASSIFICATION_IN_USE (409) if any users row still references it. |
classificationTable(kind) (private, static) | Every method above | — | — | — | — |
findClassificationOrThrow(kind, publicId) (private) | Update and delete paths | One row by public_id | — | — | PERSON_CLASSIFICATION_NOT_FOUND. |
assertClassificationNameFree(kind, name) (private) | Create and rename paths | Case-insensitive name lookup on the resolved table | — | — | PERSON_CLASSIFICATION_NAME_TAKEN. |
invalidateClassifications(kind) (private) | Every write | — | — | cache.delPatternSoft on CACHE_PREFIX:kind:* — only the edited list's prefix | Never throws — fail-soft. |
Why a separate service, not more methods on LookupsService. ethnicities and mother_tongues have exactly the same shape as departments/designations — id, public id, name, active flag, case-insensitive unique name index — and it would be a small change to add two more entities to LookupsService. The service's own doc comment gives the reason not to: departments and designations are the school's own structure, invented by an administrator and safe to rename on a whim; ethnicities and mother tongues are the national classification every government return (IEMIS, scholarship and free-textbook allocations, reservation quotas) is aggregated against, arrive by seed rather than by an operator typing them, and a rename here retroactively changes what an already-filed return meant. The two sets have different write rules, different cache lifetimes, and different consequences for being wrong; one service holding both would invite a helper written for a department — a bulk rename, a lenient delete — to be reused on a table where that is dangerous.
One service for both tables anyway. Unlike the department/designation split, ethnicities and mother_tongues share one class, because they are read together by the same two forms at the same moment (a person's demographic section always renders both selects) and differ only in which table the rows come from. classificationTable(kind) is the single private method that resolves a PersonClassificationKind ("ethnicities" or "mother-tongues") to its Drizzle table object — every other method calls through it rather than repeating the ternary, so there is exactly one place a kind could be mapped to the wrong table, and it is covered by a dedicated test (see 15).
Input normalization. CreatePersonClassificationDto.name/UpdatePersonClassificationDto.name are trimmed by a @Transform decorator that runs before @IsNotEmpty() validates them — not after, and the ordering is load-bearing: without it, a name of all spaces passes @IsNotEmpty() (it is a non-empty string), and the service would then trim it to "" and insert a blank row that renders as an unlabelled option nobody can identify in the pick list. Trimming first makes the emptiness visible to the validator, so the caller gets a 400 naming the field instead of a row that has to be found and deleted by hand.
Sort order deliberately differs from every other list in this module. ListPersonClassificationsQueryDto overrides the inherited QueryDto.order field, changing its default from "desc" to "asc" via a field initializer (not a declare, which would leave the parent's default running underneath an annotation that claimed otherwise — field initializers run parent-first, so the child's wins, and class-transformer still overwrites it when a caller sends an explicit ?order=desc). Every other list in this codebase defaults to newest-first, which is right for a list of records; these two are picked out of a select box by someone scanning for a word, and a list running Z-to-A by default is one nobody can find anything in. The list is also always sorted by name first, never by updated_at — a pick list that reorders itself because an administrator corrected a spelling is a list nobody can scan reliably — with id breaking ties for stable pagination.
Hard delete, honestly guarded. ethnicity_id/mother_tongue_id on users are both ON DELETE set null, so deleting a referenced row would otherwise succeed and silently blank the recorded classification of every person pointing at it — a data loss with no error and no way to reconstruct who held which value. deletePersonClassification closes that by reading users for a matching row before the DELETE runs, addressed against the correct column per kind (users.ethnicityId for "ethnicities", users.motherTongueId for "mother-tongues" — the two columns are interchangeable to the type system, which is why this is covered by its own test rather than trusted by inspection). Neither table carries deleted_at, for the same reason departments/designations do not — see 5.2.
Validation order. Existence is checked before uniqueness, and uniqueness before the write, matching LookupsService's pattern exactly. updatePersonClassification only re-checks the name when it actually changed case-insensitively, so a rename that only fixes casing is accepted as a no-op rather than rejected as a clash against itself.
Caching, precisely. Same mechanism as LookupsService's lists: CacheKeyUtil.build folds the search term, active filter, order, pagination flag, page, and size into the key under CACHE_PREFIX:kind:, so a write to one list clears only that list's prefix — an ethnicity rename cannot invalidate the mother-tongue cache, because nothing about a rename in one list can change what the other list would return.
Transactions. None of these methods wraps its read-then-write in a database transaction, same TOCTOU caveat as LookupsService — see 16.5.
Fail-open vs fail-closed. Every domain failure here is fail-closed; only the cache is fail-soft.
Logger usage. PersonClassificationsService does not log directly, for the same reasons as LookupsService.
7. Runtime Flows
7.1 List departments (with search/filter/pagination)
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | DepartmentsController.findAll | Binds ListDepartmentsQueryDto, delegates. | 400 if isActive/pagination fields fail validation. |
| 2 | LookupsService.findAllDepartments — cache read | Builds a deterministic cache key from the whole query via CacheKeyUtil.build and tries getSoft. | Fail-soft: a Redis error is treated as a miss, never thrown. |
| 3 | LookupsService.findAllDepartments — DB path (cache miss only) | Builds WHERE from search (escaped, ILIKE) and isActive; orders by name or updatedAt with id as a tie-breaker; applies limit/offset only when pagination !== false. | Empty search ("") is dropped by QueryDto's own @Transform, so it never reaches the WHERE clause — an empty search returns the unfiltered list, not zero rows. |
| 4 | Database | Executes the SELECT, plus a COUNT(*) only when pagination is enabled. | — |
| 5 | Cache populate (cache miss only) | setSoft at a 3600-second TTL. | Fail-soft — the caller already has the correct result even if the write fails. |
7.2 Create a department
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | DepartmentsController.create | DTO-validates name (1-120 chars), optional code/description/isActive. | 400 on validation failure. |
| 2 | assertDepartmentNameFree | Case-insensitive pre-check. | 409 DEPARTMENT_NAME_TAKEN. |
| 3 | db.insert(departments) | Trims strings, defaults isActive to true. | A concurrent insert that slipped past step 2 surfaces here as a Postgres 23505 on departments_name_unique, caught by the global filter as a generic 409 RESOURCE_ALREADY_EXISTS — not DEPARTMENT_NAME_TAKEN. |
| 4 | invalidate() | Deletes both lookup cache prefixes unconditionally — every cached page of every filter combination for both departments and designations. | Fail-soft — a Redis outage does not fail the create. |
7.3 Create a designation
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | DesignationsController.create | Validates departmentId (int), name (1-120), optional isTeaching/isActive (both default false/true). | 400. |
| 2 | Parent lookup | Must exist before the name check runs. | 404 DEPARTMENT_NOT_FOUND. |
| 3 | assertDesignationNameFree(departmentId, name) | Scoped to the department — the same name is legal in a different department. | 409 DESIGNATION_NAME_TAKEN. |
| 4 | Insert + response mapping | departmentName/staffCount are populated from context (the parent already read, and 0 respectively) rather than re-queried. | Same race-to-23505 note as departments. |
7.4 Delete a department / a designation
The designation delete-guard query (staff.designationId = existing.id) is not filtered on staff.deletedAt IS NULL — a soft-deleted staff member who still carries the designation blocks the delete just as an active one would. See 16.5 for why that is the safer default.
7.5 Read the school profile (cache hit and cache miss)
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | SchoolProfileController.get | No query/body. | — |
| 2 | SchoolProfileService.getBase — cache read | getSoft is fail-soft: a Redis error is logged as WARN and treated as a miss, never thrown. | — |
| 3 | DB read on miss | Selects the hardcoded id 1. | 404 SCHOOL_PROFILE_NOT_INITIALIZED — documented as unreachable when the database was built by the migrations. |
| 4 | Cache populate | setSoft with an explicit 3600s TTL (not the Redis client's configured default). | Fail-soft — a failed cache write does not fail the read; the caller already has the correct row. |
| 5 | SchoolProfileService.buildResponse | Resolves classRegisteredUpto, principal and runningGrades via SchoolProfileReadsService, concurrently, on every call regardless of the cache outcome above; attaches bankAccountNumber only if the caller holds SchoolProfile_UPDATE. | — |
7.6 Update the school profile
Every field on UpdateSchoolProfileDto is optional and independently applied — a PATCH sending only {"phone": "..."} leaves every other column untouched, because a key genuinely absent from the body never survives toPatch into the SET clause. But an unsupplied key and a supplied blank or null are not always treated alike: on the nullable text columns (logoUrl, registrationNumber, principalName, address, city, state, pinCode, phone, email, website, iemisCode, classification, schoolType, seeCode, hsebCode, bankName, bankAccountNumber, panNumber, grantSource) a trimmed-to-empty string is written as null — for most of these it is the only way a client can clear the field, since class-validator's @IsOptional() treats an actual null in the request body as absent; email is the one exception, accepting a bare null as a second clearing signal alongside "". On name, academicYearStartMonth, currencyCode, timezone, rollNumberStrategy, and rollNumberAutoRenumber — the six NOT_NULL_FIELDS columns with a database default — a blank (on the four string-typed members) or a bare null (on all six) is instead dropped from the patch and the existing value survives untouched, specifically because a blank or null timezone would otherwise either silently break admission/employee-number allocation or reach Postgres as an unhandled NOT NULL violation (see 16.5). coordinates: null and an address group each clear or replace their whole group rather than merging field by field; classRegisteredUptoGradeId of "" or null clears the registration, undefined leaves it untouched, and any other value must resolve to an active grade or the request is refused before the transaction opens.
7.7 List person classifications (ethnicities or mother tongues)
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | PersonClassificationsController.findAllEthnicities/findAllMotherTongues | Binds ListPersonClassificationsQueryDto, passes the literal kind. | 400 on an invalid query field. |
| 2 | PersonClassificationsService.findAllPersonClassifications — cache read | Deterministic key over the full query, prefixed with the kind. | Fail-soft miss on a Redis error. |
| 3 | DB path (cache miss only) | classificationTable(kind) resolves the table; WHERE from escaped search and isActive; ordered by name (then id) — never updatedAt, unlike departments/designations. | Empty search dropped the same way as every other list in this module. |
| 4 | Cache populate | setSoft at 3600s. | Fail-soft. |
7.8 Create a person classification
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | PersonClassificationsController.createEthnicity/createMotherTongue | name is trimmed by a @Transform before @IsNotEmpty() runs. | 400 on a blank (post-trim) or over-length name. |
| 2 | assertClassificationNameFree | Case-insensitive pre-check on the resolved table. | 409 PERSON_CLASSIFICATION_NAME_TAKEN. |
| 3 | Insert | isActive defaults to true when omitted. | A concurrent insert past the pre-check surfaces as the global unique-violation fallback, 409 RESOURCE_ALREADY_EXISTS, same TOCTOU shape as departments/designations. |
| 4 | invalidateClassifications(kind) | Clears only the edited list's prefix. | Fail-soft. |
7.9 Rename or retire a person classification
Same shape as 7.8, routed through updatePersonClassification: findClassificationOrThrow (404 on a missing publicId), a name pre-check only when the trimmed name actually differs case-insensitively from the current one, then an UPDATE applying only the fields present in the DTO plus a fresh updatedAt, then invalidateClassifications(kind).
7.10 Delete a person classification
The reference check is what makes the refusal honest: users.ethnicity_id/users.mother_tongue_id are both ON DELETE set null, so without this check the DELETE would succeed at the database and silently blank the recorded classification of every person who held it, with no error and no way to reconstruct who had which value. The check queries the column matching kind — users.ethnicityId for "ethnicities", users.motherTongueId for "mother-tongues" — never the other one, which is exactly the mistake classificationTable-style single-decision-point design and the dedicated test in 15 exist to catch.
8. Caching
| Cache Key Pattern | Builder | Value | TTL | Invalidation | Caller |
|---|---|---|---|---|---|
school:profile:v2 | Hardcoded string constant (SchoolProfileService.CACHE_KEY) | The row's own columns as JSON — not classRegisteredUpto, principal, or runningGrades, each of which is resolved fresh by SchoolProfileReadsService on every call regardless of this key's state | 3600s, set explicitly on every setSoft call | delSoft on every successful update() | SchoolProfileService |
school:brand:v:1 | schoolBrandCacheKey() over CacheKeyUtil.build (common/brand/school-brand.cache-keys.ts) | The school name, logo URL and name source that outbound messages show, as JSON | 60s | SchoolBrandService.invalidate(), called by every successful update() after the commit. Owned and populated by SchoolBrandService; see the notification module documentation | SchoolBrandService |
lookups:v2:departments:list:<query> | CacheKeyUtil.build("lookups:v2:departments:list:", [...]) over search, isActive, sort, order, pagination, page, size | { data: DepartmentDto[], totalCount } as JSON | 3600s | delPatternSoft("lookups:v2:departments:*") on every department and designation write | LookupsService.findAllDepartments / invalidate() |
lookups:v2:designations:list:<query> | CacheKeyUtil.build("lookups:v2:designations:list:", [...]) over search, departmentId, isTeaching, isActive, sort, order, pagination, page, size | { data: DesignationDto[], totalCount } as JSON | 3600s | delPatternSoft("lookups:v2:designations:*") on every department and designation write | LookupsService.findAllDesignations / invalidate() |
lookups:classifications:ethnicities:<query> | CacheKeyUtil.build over search, isActive, order, pagination, page, size | { data: PersonClassificationDto[], totalCount } as JSON | 3600s | delPatternSoft("lookups:classifications:ethnicities:*") on any ethnicity write only | PersonClassificationsService |
lookups:classifications:mother-tongues:<query> | Same builder, kind = "mother-tongues" | Same shape | 3600s | delPatternSoft("lookups:classifications:mother-tongues:*") on any mother-tongue write only | PersonClassificationsService |
Verified, not assumed: lookups.service.ts's findAllDepartments/findAllDesignations both call this.cache.getSoft(...) before touching the database and this.cache.setSoft(...) after a miss; invalidate() calls delPatternSoft on both prefixes unconditionally on every write. person-classifications.service.ts follows the identical pattern under its own prefix, scoped further by kind. Every list cache key is built by CacheKeyUtil.build, which folds the entire query — not just the filter fields, but pagination, page, and size too — into the key, so two callers requesting different pages or page sizes of the same filtered list never collide and never serve each other's page.
Deterministic key composition, one rule for every list here and for LookupsService's own README-level comment: the key is a PREFIX, not a key. A single cache key per table would serve the first caller's filtered page to every later caller regardless of what they actually asked for. CacheKeyUtil.build orders segments deterministically and substitutes a sentinel for a null/undefined/empty filter, so a missing filter can never collide with a present-but-empty one.
Why both lookup prefixes clear on every write, department or designation. A designation's list row is denormalized with its department's name (departmentName). A department rename that cleared only the department prefix would leave the designation list — the exact screen a staff form pulls both selects from together — showing the stale department name for up to an hour.
Why classification prefixes clear independently, unlike lookups. An ethnicity rename cannot change anything a mother-tongue list would return, and vice versa; clearing both on every write would be strictly wasted invalidation with no correctness benefit, unlike the department/designation case above where the two lists are genuinely coupled through the denormalized name.
SchoolProfileService remains the module's simplest cache-aside implementation: a single hardcoded key rather than a query-derived one, because there is exactly one profile row and therefore no filter combination to distinguish. All four services share the same fail-soft contract: getSoft treats a Redis error as a miss; setSoft/delSoft/delPatternSoft log a WARN and continue rather than fail the request; and serialization is JSON.stringify/JSON.parse inside RedisCacheService itself, so no service in this module ever touches a raw Redis string.
9. BullMQ, Schedulers, and Async Work
Not applicable. No queue, job, or scheduler touches departments, designations, or the school profile — verified by the absence of any BullMQ import in lookups.service.ts or school-profile.service.ts.
10. Realtime and Events
Not applicable. Neither service emits a Socket.IO event or any other realtime signal.
11. Security, Auth, and Abuse Controls
- Guards. All three controllers carry
@UseGuards(JwtAuthGuard, RoleGuard)at the class level (lookups.controller.ts:38-39,191,school-profile.controller.ts:11-12) — every route requires a valid JWT and a role check, with no@Public()route anywhere in this module. - Permissions. Every handler declares an explicit
@Permissions(...)(Departments_READ/CREATE/UPDATE/DELETE,Designations_READ/CREATE/UPDATE/DELETE,PersonClassifications_READ/CREATE/UPDATE/DELETE,SchoolProfile_READ/UPDATE).RoleGuardfails a request open only when a handler declares zero permissions and its controller is not classified as an administrative surface (role.guard.ts:38-63); every route here declares a permission, so that branch never applies — androute-permissions.spec.ts(referenced in the controller's own comment) asserts the same in CI. PersonClassificationsis the one module in this file whose_READpermission is not admin-only.packages/db/src/seed/seed-auth.tsgrantsPersonClassifications_READto bothSTAFF_PERMISSIONSandTEACHER_PERMISSIONS(the latter spreads the former, plus the teaching screens), alongsideStudents_READ,Guardians_READ,Staff_READ,Departments_READ,Designations_READandSchoolProfile_READ; neither seeded role holdsActivity_READor the screen-only codesStaffView_READ,DepartmentView_READandSchoolProfileView_READ(they gate the admin screens and appear on zero routes; migration0055_admin_screen_view_codes.sqlgranted them to every existing holder of the matching data code except the seeded teacher, and0056_staff_revoke_admin_screens.sqlrevoked them andActivity_READfrom the seeded staff role, because a teacher also holdsstaffand can switch to it) — every other permission in this document, including the fourPersonClassificationswrite codes, is granted only through an explicit administrative role. The reason is that the pupil and staff people-forms both render an ethnicity select and a mother-tongue select, and a teacher or other staff member who cannot read these two lists sees two empty select boxes with no way to tell that it is a permissions problem rather than an empty table. Write access stays administrator-only because renaming an entry rewrites the recorded classification of every person already pointing at it, retroactively and including in government returns already filed — a consequence a form-filling teacher should not be able to trigger by editing a typo.- Permission source.
PERMISSION_MODULESinpackages/db/src/authorization/permission-catalog.ts:78-91listsDepartments,Designations,PersonClassifications,SchoolProfile. Permission codes are generated as the cartesian product of every module and every action inPERMISSION_ACTIONS(CREATE,READ,UPDATE,DELETE,RESTORE) — soDepartments_RESTORE,Designations_RESTORE,PersonClassifications_RESTORE,SchoolProfile_CREATE,SchoolProfile_DELETE, andSchoolProfile_RESTOREall exist as seeded, grantable permission codes even though no route in this module ever checks them (there is no restore endpoint, because none ofdepartments/designations/ethnicities/mother_tongueshasdeleted_at; there is no create/delete route on the singleton profile). These are inert-but-present permissions, not a bug — the catalog generates uniformly across every module by design (see the file's own header on why a per-module hand-maintained list previously drifted and caused a live authorization hole). - Superadmin bypass. Gated on the
is_superadminboolean flag, never on a role's name, and checked before the permission list — a role literally named"superadmin"grants nothing extra (role.guard.ts:145-152). - Active-role scoping. Permissions resolve from the caller's active role only, never the union of every role they hold — a staff member who is also a guardian, currently acting as Guardian, does not carry Staff module permissions (
role.guard.ts:126-129); the same rule means a user acting as Guardian cannot read the classification lists either, even if their Staff role would grant it. - Guest identity. Not applicable — no route in this module is reachable without authentication.
- Rate limits. None declared on these controllers specifically; whatever global rate limiting applies to the API applies here uniformly (not module-specific, so not documented as this module's behavior).
- Input normalization. Every writable string field is trimmed server-side before comparison or persistence (see 6.1); the
ILIKEsearch term is always escaped throughescapeLikePatternbefore it reaches a pattern (packages/db/src/search/escape-like-pattern.ts) — unescaped, a search for100%would match every row beginning100, and a bare_or%would silently stop filtering. ILIKEescaping mechanism, verified precisely:LookupsServicecalls the Drizzleilike()helper directly, passing it a template string built as%+ the escaped search term +%, which does not append an explicitESCAPE '\'clause — it relies on Postgres's own defaultLIKE/ILIKEescape character, which is backslash.escapeLikePattern's own doc comment recommends pairing it with an explicitESCAPE '\'clause because the default "is not guaranteed across configurations," so this module's usage is correct on this database's actual configuration but does not carry that extra guarantee explicitly in the query text.- Field-level gating on the profile response.
bankAccountNumberis the one field onSchoolProfileDtogated below the controller's ownSchoolProfile_READ/SchoolProfile_UPDATEsplit:SchoolProfileService.buildResponseattaches the key only when the caller also holdsSchoolProfile_UPDATE, and OMITS it — key absent, not present-and-null — for a caller holding onlySchoolProfile_READ.bankNameandpanNumbercarry no such gate and are visible to every profile reader, because this response serves the school's own staff, to whom the bank's name is not secret the way the account number is; contrast the integration read below, which serves a third-party API key and gates both bank fields as one unit behind a dedicated scope. - Sensitive data redaction.
bankAccountNumberandpanNumberare the two sensitive fields in this module's scope. Neither is redacted in the audit trail'sto/fromvalues as sent —maskFinancial(school-profile.service.ts) reveals at most the last four characters, and never more thanvalue.length - 4, before either reachesActivityRecordService.recordActivity, so a four-character-or-shorter value is never shown at all ("••••"with nothing appended).bankNameis not masked, since a bank's name is not itself sensitive. Because only up to four trailing characters ever survive, two different values can mask to the identical string — a change to a hidden, non-trailing character leavesfromandtoreading the same, even though the audit entry correctly recorded that the field changed. No hash of the original value is stored anywhere alongside the mask, deliberately: a bank account number or a nine-digit PAN is short enough to be brute-forced against a stored hash, so the entry keeps no reversible or comparable trace of it at all, only the masked display string. - Audit logs.
recordFinancialAudit(see 6.2) writes aschool_profile.financial_updateentry throughActivityRecordService.recordActivitywhenever aPATCHactually changesbankName,bankAccountNumber, orpanNumber; no other module here writes one. Departments, designations, and person classifications carry no audit requirement in the current code. Contrast with modules likeStaffSalary, which are gated and audited for the same reason the financial fields on this singleton are. - Fail-closed behavior. Every domain error in this module (not-found, name-taken, has-references) is fail-closed — a thrown, coded exception that stops the request. Only cache invalidation is fail-soft.
13. Error Handling
| Error Code | HTTP Status | Thrown By | Condition | Client Action |
|---|---|---|---|---|
DEPARTMENT_NOT_FOUND | 404 | LookupsService.findDepartmentOrThrow | No departments row for the given publicId. | Refresh the list; the id may have been deleted by another admin. |
DEPARTMENT_NAME_TAKEN | 409 | LookupsService.assertDepartmentNameFree | Another department already has this name, case-insensitively. | Prompt the admin to choose a different name. |
DEPARTMENT_HAS_DESIGNATIONS | 409 | LookupsService.deleteDepartment | At least one designations row still has this department_id. | Move or retire the designations first, or set isActive: false instead of deleting. |
DESIGNATION_NOT_FOUND | 404 | LookupsService.findDesignationOrThrow | No designations row for the given publicId. | Same as DEPARTMENT_NOT_FOUND. |
DESIGNATION_NAME_TAKEN | 409 | LookupsService.assertDesignationNameFree | Another designation in the same department already has this name, case-insensitively. | Choose a different name, or confirm which department is intended. |
DESIGNATION_IN_USE | 409 | LookupsService.deleteDesignation | At least one staff row (including soft-deleted ones) still has this designation_id. | Reassign or retire those staff first, or set isActive: false instead of deleting. |
DESIGNATION_DEPARTMENT_IMMUTABLE | — | Declared in error-codes.ts but never thrown by this module | Reserved for a future explicit "you tried to move a designation's department" check. | Currently unreachable — a departmentId in a PATCH /designations/:publicId body instead fails DTO whitelist validation with a generic 400, not this code. |
PERSON_CLASSIFICATION_NOT_FOUND | 404 | PersonClassificationsService.findClassificationOrThrow | No ethnicities/mother_tongues row for the given publicId, on either a rename/retire or a delete. | Refresh the list; the entry may have been deleted by another admin. |
PERSON_CLASSIFICATION_NAME_TAKEN | 409 | PersonClassificationsService.assertClassificationNameFree | Another entry in the same list already has this name, case-insensitively. | Choose a different name, or confirm the existing entry is the one intended. |
PERSON_CLASSIFICATION_IN_USE | 409 | PersonClassificationsService.deletePersonClassification | At least one users row still has ethnicity_id/mother_tongue_id (matching kind) pointing at this entry. | Retire the entry instead (isActive: false) — this removes it from pick lists on new records while leaving every person already recorded against it untouched. |
SCHOOL_PROFILE_NOT_INITIALIZED | 404 | SchoolProfileService.get, SchoolProfileService.update | Row 1 of school_profile does not exist. | Should not occur outside a database that was not built by the migrations; contact an operator. |
SCHOOL_PROFILE_GRADE_NOT_FOUND | 404 | SchoolProfileService.resolveClassRegisteredUptoGradeIdForWrite | classRegisteredUptoGradeId on a PATCH does not match any grades.public_id. | Fetch GET /lookups/grades again; the id may be wrong or the grade may have been removed. |
SCHOOL_PROFILE_GRADE_INACTIVE | 400 | SchoolProfileService.resolveClassRegisteredUptoGradeIdForWrite | The target grade exists but is retired (is_active: false). | Choose an active grade, or reactivate the grade first if the retirement was unintended. |
SCHOOL_PROFILE_APPROVAL_DATE_OUT_OF_RANGE | 400 | SchoolProfileService.assertApprovalDateInRange | One of ecdApprovalDate/primaryApprovalDate/lowerSecondaryApprovalDate is before 1850-01-01, or after today in the school's own timezone. | Correct the date; "today" is evaluated in school_profile.timezone, not the server's or the client's own zone. |
SCHOOL_PROFILE_COORDINATES_UNSET | 400 | SchoolProfileService.update | coordinates on a PATCH is exactly {"latitude": 0, "longitude": 0} — read as an unfilled form defaulting to zero, not a real location. | Send coordinates: null to clear both fields instead, or send the school's real coordinates. |
RESOURCE_ALREADY_EXISTS | 409 | Global (AllExceptionsFilter's unique-violation fallback), not this module's code | A Postgres 23505 reached the filter without a domain-specific ConflictException catching it first — the race-condition path past the name pre-checks in createDepartment/createDesignation. | Retry the read; the name is now taken by whichever request won the race. |
VALIDATION_FAILED | 400 | Global ValidationPipe default | A DTO field fails class-validator, or a field not declared on the DTO is present in the body (forbidNonWhitelisted). | Fix the request body against the DTO reference in the API doc. |
AUTH_UNAUTHENTICATED | 401 | JwtAuthGuard.handleRequest | Missing or invalid JWT. | Re-authenticate. |
PERMISSION_INSUFFICIENT | 403 | RoleGuard | Active role lacks the route's required permission. | Not recoverable client-side; requires a role/permission change. |
AUTH_ACTIVE_ROLE_REQUIRED / PERMISSION_ROLE_NOT_ASSIGNED | 403 | RoleGuard | Caller holds several roles and has not selected one / holds none at all. | Select a role via the role-context endpoint, or contact an admin. |
Every response above four hundred is wrapped by AllExceptionsFilter into { statusCode, errorCode, message, timestamp?, path? } — timestamp/path are included outside production only (all-exceptions.filter.ts:29-33,79-89).
14. Observability
| Signal | Location | Purpose |
|---|---|---|
| Log | AllExceptionsFilter (Logger, apps/api/src/common/filters/all-exceptions.filter.ts) | A refusal below 500 is logged once at warn as a single line with the method, redacted path, status, errorCode and message and no stack; that includes a Postgres unique violation or guard-trigger refusal, which answer 409. Every 5xx, every unmapped error and every ZodError is logged at error with its stack, including any Postgres cause chain, tagged with the method and URL. |
| Log | RedisCacheService (Logger, redis.service.ts) | getSoft failures log at warn and degrade to a cache miss; setSoft/delSoft failures log at warn and continue — a failed invalidation is explicitly called out as leaving a stale value until its TTL. |
| Metric | None declared | No dedicated metric exists for department/designation/profile operations in this module. |
| Audit | ActivityRecordService.recordActivity, action school_profile.financial_update | Written when SchoolProfileService.update actually changes bankName, bankAccountNumber, or panNumber; best-effort, never fails the request — see 6.2. No other mutation in this module writes an audit entry. |
15. Testing and Validation
Real-database integration suites exist under this document's modules; LookupsService (departments/designations) still has none of its own.
apps/api/src/modules/lookups/person-classifications.service.spec.ts — ten tests, against real Postgres via createTestDatabase()/closeTestDatabase(), with the cache stubbed to never hit (getSoft always resolves null) so every assertion is about what the database holds, not what a cache returned. Covers: creating an entry active-by-default with both id and publicId populated; refusing a name that differs only by case; keeping the two lists independent under a shared name (an ethnicity and a mother tongue can share a name without colliding with each other); renaming and retiring an entry; permitting a rename that only changes the casing of its own name (the clash check excludes the row being edited); a 404 on an unknown publicId; deleting an entry nothing points at; refusing to delete an entry people are recorded against (the important one — users.ethnicity_id is ON DELETE set null, so without the check the delete would succeed and silently blank everyone's recorded ethnicity); counting a mother tongue's references against the right column (mother_tongue_id, not ethnicity_id — the two are interchangeable to the type system, so this is checked explicitly rather than trusted by inspection); and filtering to active-only entries.
apps/api/src/modules/school-profile/__tests__/school-profile.service.integration.spec.ts — against real Postgres, with the cache stubbed to never hit and ActivityRecordService constructed over the same stub, snapshotting row 1 before the suite runs and restoring it in afterAll (there is only ever one row, so the suite edits the live singleton in place rather than creating a fixture). It is about what the database's own columns hold, not the permission gates on bankAccountNumber or the principal's details — those are covered in school-profile.integration.spec.ts below. Covers: writing NULL, not an empty string, when a nullable field is cleared; trimming what it stores; leaving fields a patch omitted untouched; ignoring a blank on a NOT NULL column (timezone, currencyCode, name) rather than writing one; storing a non-string field as itself; storing the roll-number strategy and its boolean companion; the principal resolving to the flagged designation's holder and dropping them when they go inactive; and the retired columns (principalName, brandPrimaryColor, brandSecondaryColor) never appearing in a read while still being accepted, and ignored, on a write.
apps/api/src/modules/school-profile/__tests__/school-profile.integration.spec.ts — against real Postgres, and against real Redis and Mongo through describeWithMongoRedis/connectMongoRedis (@/test-utils/integration-mongo-redis.util), which self-skip the whole describe block unless RUN_DB_INTEGRATION=true AND both a Mongo and a Redis URL are reachable — pnpm test/CI stay green with the block skipped when those are absent. Covers every EMIS field this document describes: round-tripping every new scalar field by reading back from the row (not the DTO) and dropping the two internal keys (classRegisteredUptoGradeId, establishedYearAd); clearing each of the nine nullable text keys with ""; clearing the nullable scalars with null and the two list fields with []; refusing coordinates sent as exactly (0, 0); refusing an approval date before 1850-01-01; refusing an approval date after today in the school's own timezone; refusing an unknown grade uuid with SCHOOL_PROFILE_GRADE_NOT_FOUND; refusing a retired grade with SCHOOL_PROFILE_GRADE_INACTIVE; resolving classRegisteredUpto fresh — a grade rename after a GET, including a GET that populated the cache, shows on the very next GET; carrying bankAccountNumber only for a caller holding SchoolProfile_UPDATE; the principal's gated details (phone, qualification, gender, ethnicityName) present only for a Staff_READ holder whose access scope covers the principal's own row, and absent both without the permission and without row access; isTeaching true via the designation's own flag, via an active class_subjects assignment in the current session, and via being a class's class_teacher, each independently; runningGrades listing distinct grades with a live class in the current session, ordered, and [] with none; classRegisteredUptoGradeId and establishedYearAd hidden from every response — cold cache and warm — though the cached row itself carries them; clearing the school email on "" and on null, reading back null both times; ignoring a null on each of the six NOT_NULL_FIELDS columns instead of throwing; recording exactly one masked financial-change audit entry for a bankAccountNumber change and none when nothing financial changed; skipping the financial audit entirely when the request context carries no userId; and recording exactly one unmasked contact-change audit entry for an email change, with none written when the email is left unchanged.
The admin GET /api/school-profile and the accounting integration's GET /api/integration/fees/school gate the same two bank columns differently, on purpose, for two different audiences: the admin response always includes bankName to any SchoolProfile_READ holder and gates only bankAccountNumber behind SchoolProfile_UPDATE, while the integration response gates bankName and bankAccountNumber together, as one unit, behind the API key holding payroll:bank:read — see the integration module's API doc for the accounting surface and the shared-scope consequence of granting that key.
apps/api/src/modules/school-profile/dto/school-profile.dto.spec.ts — DTO-only tests against class-validator, no database: an empty PATCH body validates; null is refused on a plain boolean field and on additionalEmails (@IsOptional() treats only undefined as absent, so a client sending null must get a validation error, not a silent no-op); an empty additionalEmails array is accepted; bankAccountNumber with no digit or below the four-character floor is refused, a value mixing letters/digits/hyphens is accepted, and an empty string (which clears the field) is accepted; coordinates out of range or half-filled are refused, null (which clears both) is accepted; additionalEmails is trimmed, lower-cased, blank-dropped, and de-duplicated, and refused past five entries; additionalPhones is normalized the same way without lower-casing; classRegisteredUptoGradeId accepts "", null, or a well-formed uuid and refuses anything else; establishedYear is refused outside 1800–2100 and accepts null; an approval date not shaped YYYY-MM-DD is refused and null is accepted; email accepts "", null, or a well-formed address and refuses a blank (" ") or malformed one; timezone accepts a real IANA zone or "" and refuses "Foo/Bar"/"Kathmandu"; panNumber not exactly nine digits is refused and an empty string is accepted.
All three suites are named and run as real-database integration tests, or a DTO-only unit test, per this repo's testing rules — none mocks DatabaseModule, Drizzle, or any @skoolsewa/* package; only the Redis cache is stubbed in the two service-level suites' main describe blocks, and only because those tests exist to prove what the database does, not what the cache does. school-profile.integration.spec.ts carries one further nested suite, gated the same way as the module-level describeWithMongoRedis block above: SchoolProfileService — cache and financial audit (real Postgres + Mongo + Redis) runs against a real RedisCacheService and real Mongo, proving the cache actually populates on a miss and clears on a PATCH, and that a financial-change audit entry actually lands in Mongo through ActivityRecordService rather than only through a stub that always resolves.
No *.spec.ts file exists for LookupsService itself (departments/designations) — verified by directory listing. Its behavior in this document is verified against the current source files listed in 1. Documentation Evidence, not against a test suite.
The repo-wide route-permissions.spec.ts (referenced in lookups.controller.ts's own class comment) does cover this module indirectly: it asserts every administrative-surface handler declares a @Permissions() decorator, which all three controllers satisfy.
Validation commands:
pnpm --filter @skoolsewa/api build
pnpm --filter @skoolsewa/db build16. Mandatory Backend Deep-Dive Pack
16.1 Submodule Coverage Matrix
| Unit | Type | Owns | Depends On | Called By | Calls | State Touched | Failure Modes |
|---|---|---|---|---|---|---|---|
DepartmentsController | Controller | /departments routes | LookupsService | HTTP | LookupsService methods | None directly | DTO validation 400; guard 401/403. |
DesignationsController | Controller | /designations routes | LookupsService | HTTP | LookupsService methods | None directly | Same as above. |
PersonClassificationsController | Controller | /lookups/ethnicities, /lookups/mother-tongues routes | PersonClassificationsService | HTTP | PersonClassificationsService methods | None directly | DTO validation 400; guard 401/403. |
LookupsService | Service | Department/designation business rules | Database, RedisCacheService, CacheKeyUtil, PaginationUtil | Both lookup controllers above | Drizzle queries, cache.getSoft/setSoft/delPatternSoft | departments, designations tables; two cache-aside prefixes | 404/409 domain errors; unmapped 23505 race. |
PersonClassificationsService | Service | Ethnicity/mother-tongue business rules | Database, RedisCacheService, CacheKeyUtil, PaginationUtil | PersonClassificationsController | Drizzle queries, cache.getSoft/setSoft/delPatternSoft | ethnicities, mother_tongues tables (via classificationTable); reads users on delete; one cache-aside prefix per kind | 404/409 domain errors; unmapped 23505 race. |
SchoolProfileController | Controller | /school-profile routes | SchoolProfileService | HTTP | SchoolProfileService methods | None directly | DTO validation 400; guard 401/403. |
SchoolProfileService | Service | Singleton read/update, cache-aside, financial audit | Database, RedisCacheService, SchoolProfileReadsService, PeoplePermissionsService, ActivityRecordService, SchoolBrandService | SchoolProfileController; other modules' year-numbering code via getTimezone() | Drizzle queries, cache.getSoft/setSoft/delSoft, SchoolBrandService.invalidate() | school_profile row 1; school:profile:v2 and school:brand:v:1 cache keys | 404 SCHOOL_PROFILE_NOT_INITIALIZED; 404 SCHOOL_PROFILE_GRADE_NOT_FOUND; 400 SCHOOL_PROFILE_GRADE_INACTIVE; 400 SCHOOL_PROFILE_APPROVAL_DATE_OUT_OF_RANGE; 400 SCHOOL_PROFILE_COORDINATES_UNSET. |
SchoolProfileReadsService | Service | Resolves classRegisteredUpto, principal, runningGrades fresh on every call | Database, PrincipalInvariantService, PeoplePermissionsService, PeopleAccessService | SchoolProfileService.buildResponse | Drizzle queries only — no cache | grades, academic_sessions, classes, staff, designations, users, ethnicities, class_subjects | None thrown directly. |
RoleModule | Imported dependency | Nothing owned by this module | — | — | Supplies RoleService to RoleGuard | — | Boot-time InstanceLoader failure if omitted from either leaf module. |
escapeLikePattern | Shared helper (packages/db), used locally | LIKE-pattern escaping | — | LookupsService's two search paths | — | — | None — pure function. |
PaginationUtil | Shared helper, used locally | Page/size normalization, drizzle limit/offset, UNPAGINATED_HARD_CAP | — | Every findAll* method in this document, including PersonClassificationsService | — | — | None — pure function; clamps invalid input to defaults rather than throwing. |
No provider, processor, scheduler, or mapper file exists in either module directory beyond what is listed above — verified against the file map in 4.
16.2 UML and Architecture Diagram Pack
State diagrams: no table in this module has a modeled multi-state lifecycle beyond a boolean — see 16.5 for why is_active is a flag, not a state machine, in this schema. This applies identically to ethnicities/mother_tongues.
16.3 Code Flow Narrative
Covered per-method in 6. Services and Responsibilities and per-flow in 7. Runtime Flows. No method in either service exceeds the branching shown there — each create/update/delete follows lookup → validate → write → invalidate, with no additional branch.
16.4 Data Layer Deep Dive
Covered in full in 5.2 (field tables, nullability, business meaning) and the index rationale table at the end of that section (why each index/constraint exists and what it prevents). No JSON column, versioning field, or money unit exists in this module's tables. Seed data dependency: row 1 of school_profile comes from the migration, not a seed script; departments/designations start empty and every row an admin sees is one an admin (or an import) created; ethnicities/mother_tongues are the one exception — both start populated by seedReferenceData() (56 and 42 rows respectively), and a school only ever adds to or retires from that seeded baseline rather than building the list from nothing.
16.5 Business Logic and Invariant Catalog
| Invariant | Enforced By | Why It Exists | Failure Error | Tests |
|---|---|---|---|---|
| A department name is unique, case-insensitively, across the whole school. | departments_name_unique (DB) + assertDepartmentNameFree (service pre-check) | Free text yields "Science"/"science" as two rows with no reliable staff report on top. | DEPARTMENT_NAME_TAKEN (pre-check) or RESOURCE_ALREADY_EXISTS (race past the pre-check — see below). | None in this module. |
| A designation name is unique, case-insensitively, within its department. | designations_department_name_unique (DB) + assertDesignationNameFree (service pre-check) | The same title ("Coordinator") is legitimate in more than one department. | DESIGNATION_NAME_TAKEN / RESOURCE_ALREADY_EXISTS. | None. |
The name pre-check is not atomic with the insert. Two concurrent POSTs for the same name each pass assertDepartmentNameFree before either commits its INSERT. | Not enforced at the service layer — only the DB's unique index catches the second writer. | A SELECT-then-INSERT without a transaction or advisory lock is a textbook TOCTOU race; nothing in this service closes it. | The loser gets RESOURCE_ALREADY_EXISTS (generic 409, from the global unique-violation fallback), not the friendlier DEPARTMENT_NAME_TAKEN/DESIGNATION_NAME_TAKEN the same request would get from the pre-check. Both are 409s, but the error code and message differ depending purely on timing. | None. |
A designation's department_id cannot change after creation. | By omission — UpdateDesignationDto has no departmentId field; the global ValidationPipe's forbidNonWhitelisted rejects any attempt to send one. | The composite FK on staff (department_id, designation_id) would be violated from every referencing row at once if a designation's department moved out from under them. | 400 (generic validation failure — property should not exist), not DESIGNATION_DEPARTMENT_IMMUTABLE (declared, never thrown). | None. |
| A department cannot be deleted while any designation still references it. | LookupsService.deleteDepartment's existence check, ahead of the DELETE | Gives an admin a named, actionable reason instead of an unmapped 23503 foreign key violation from the ON DELETE restrict FK. | DEPARTMENT_HAS_DESIGNATIONS (409). | None. |
| A designation cannot be deleted while any staff row — including a soft-deleted one — still references it. | LookupsService.deleteDesignation's existence check, which queries staff.designationId = existing.id with no staff.deletedAt IS NULL filter | A soft-deleted staff row is still a real historical record; deleting the designation it points to would leave that record's designation_id dangling relative to a row that no longer exists, defeating the point of keeping history. This is a deliberately stricter check than "no active staff member holds it." | DESIGNATION_IN_USE (409). | None. |
A staff row's (department_id, designation_id) pair must be a real combination that exists in designations. | staff_designation_in_department_fk composite FK (schema-level, in people.ts, enforced independent of this module's service code) | Stops "Librarian" being filed under Accounts. | Postgres 23503 if reached without this module's own validation (this module does not create staff rows, so this is enforced entirely by the schema against whatever module does). | None in this module — see packages/db/src/schema/school/people.ts. |
A staff row cannot carry a designation_id without also carrying a department_id. | staff_designation_needs_department CHECK — closes the MATCH SIMPLE hole in the FK above. | Otherwise (NULL, <designation>) slips past the composite FK entirely (MATCH SIMPLE skips the check when any column is NULL). | Postgres 23514 if reached. | None in this module. |
is_active = false is retirement, never deletion. Neither lookup table has deleted_at. | Schema design (absence of a soft-delete column) + the fact that hard delete is the only removal path, and it is blocked while referenced | A soft-deleted row never fires ON DELETE restrict, so it would silently keep resolving for every reference while vanishing from admin pick lists — "gone to the eye, present to the query." | N/A — this is a design invariant, not a runtime check. | None. |
The school profile is exactly one row, with id 1. | school_profile_singleton CHECK (id = 1) + the PK on id (rejects a second row at id = 1) | A one-row table is the deliberate replacement for both environment variables and a generic key-value config table (the latter was removed from this repo in an earlier migration for lacking a schema). | Postgres 23514 on any attempt to insert a row with id != 1; PK violation on a second id = 1 row. | None. |
academic_year_start_month is between 1 and 12. | Both the DTO (@Min(1) @Max(12)) and the DB CHECK school_profile_academic_year_start_month_valid. | Defense in depth — any future writer that bypasses the DTO (a script, a different service) still cannot violate this at the database. | 400 (DTO) or Postgres 23514 (DB, if ever reached directly). | None. |
The school's timezone (default Asia/Kathmandu) decides which year an admission/employee number belongs to. | Convention only — SchoolProfileService.getTimezone() is the documented read path for consumers; nothing prevents a consumer from reading schoolProfile.timezone directly instead. UpdateSchoolProfileDto.timezone runs @IsTimeZone() once the value is non-empty, so a misspelled or invalid zone is refused 400 rather than accepted silently. | Asia/Kathmandu is UTC+05:45; taking the year from a UTC clock would issue the closed year's numbers for part of every New Year's transition. | 400 VALIDATION_FAILED for a non-empty value Intl does not recognize as a real IANA zone. | dto/school-profile.dto.spec.ts — "accepts a real IANA timezone or an empty string, refuses an invalid one". |
| A designation is not a role. | Not enforced by any constraint — a design invariant maintained by keeping two separate concepts (designations here; roles/permissions in the authorization module) rather than merging them. | A vice-principal who still teaches, or a senior teacher granted administrative access, breaks the assumption that designation implies role. Merging them produces designations like "teacher_but_also_admin." | N/A. | None. |
| A classification entry's name is unique, case-insensitively, within its own list (ethnicities and mother tongues never collide with each other). | ethnicities_name_unique / mother_tongues_name_unique (DB) + assertClassificationNameFree (service pre-check, scoped by kind) | Two rows for what is really the same national classification breaks the government-return aggregation these lists exist to make possible. | PERSON_CLASSIFICATION_NAME_TAKEN (pre-check) or RESOURCE_ALREADY_EXISTS (race past the pre-check, same TOCTOU shape as departments/designations). | person-classifications.service.spec.ts — "refuses a name that differs only by case", "keeps the two lists independent". |
A classification entry cannot be hard-deleted while any users row references it — checked against the column matching kind, never the other one. | PersonClassificationsService.deletePersonClassification's existence check against users.ethnicityId/users.motherTongueId | ethnicity_id/mother_tongue_id are both ON DELETE set null; without this check the delete would succeed at the database and silently blank every person's recorded classification, with no error and no way to reconstruct who held which value. | PERSON_CLASSIFICATION_IN_USE (409). | person-classifications.service.spec.ts — "refuses to delete an entry people are recorded against", "counts a mother tongue's references against the right column". |
| A classification name is trimmed and validated for emptiness in that order, not the reverse. | @Transform runs before @IsNotEmpty() on both CreatePersonClassificationDto.name and UpdatePersonClassificationDto.name. | @IsNotEmpty() alone passes a string of only spaces; trimming first makes a would-be-blank name visible to the validator instead of silently becoming an unlabelled row after the service's own .trim(). | 400 VALIDATION_FAILED on an all-whitespace name, instead of a blank row that has to be found and deleted by hand. | None dedicated — covered by the DTO's own decorator ordering. |
A caller wanting an entire reference table (departments, designations, or either classification list) asks for pagination=false; a size above MAX_PAGE_SIZE (100) is clamped, never used to request more rows than that. | PaginationUtil.normalize clamps size; pagination=false bypasses limit/offset entirely and falls back to UNPAGINATED_HARD_CAP (1000) as the query's LIMIT. | Number.MAX_SAFE_INTEGER as an unpaginated fallback is an unbounded read wearing a number; UNPAGINATED_HARD_CAP keeps an unpaginated reference-table read to a single bounded round trip even if a table has quietly grown past what a select box can hold. | No error — a table with more than 1000 rows would be silently truncated at the cap on an unpaginated request, same as any other named limit. | None dedicated. |
A blank or null sent for email clears it to NULL, same as a blank alone does for the other nullable text fields; a blank (or, for all six, a bare null) sent for one of NOT_NULL_FIELDS (name, academicYearStartMonth, currencyCode, timezone, rollNumberStrategy, rollNumberAutoRenumber) is dropped and the existing value survives; the address group is replaced whole, never merged field by field. | SchoolProfileService.toPatch — NULLABLE_TEXT_FIELDS vs. NOT_NULL_FIELDS, with address intercepted before either rule applies. | class-validator's @IsOptional() treats null as absent for most fields, so an empty string is the only "clear this field" signal a client has — email is the one field whose DTO also lets a bare null through; but the six NOT_NULL_FIELDS columns are NOT NULL with defaults, and a blank or null timezone in particular would either silently break admission/employee-number allocation or reach Postgres as an unhandled 23502. | No error — a blank on a nullable text column writes NULL; a blank or null on a NOT_NULL_FIELDS column is a silent no-op on that field only; an address key present on the request always rewrites all six address columns together. | school-profile.integration.spec.ts — "clears the school email on '' and on null, and reads back null both times", "ignores a null on any of the six writable NOT NULL columns instead of throwing". |
Changing roll_number_strategy/roll_number_auto_renumber on this row renumbers no class by itself — it only changes what the class module's NEXT enrolment or renumber call does. | Not enforced by any constraint on this table; it is a design fact about who reads these two columns and when — RollNumberService.policy in the class module (enrollments-core/roll-numbers/roll-number.service.ts), read fresh on every enrolment and every renumber, never on a write to school_profile itself. | Coupling a settings-screen save to a mass rewrite of every class's register would make PATCH /api/school-profile silently touch a table this module does not own, on a save an office administrator would not expect to move a single pupil. | N/A — a cross-module design invariant, not a runtime check. | school-profile.service.integration.spec.ts asserts the two fields round-trip; the renumber behavior itself is tested in the class module's roll-number.service.integration.spec.ts, not here. |
bankAccountNumber is omitted — key absent, not null — from GET/PATCH /school-profile unless the caller holds SchoolProfile_UPDATE. bankName and panNumber carry no such gate. | SchoolProfileService.buildResponse — a conditional spread (...(canSeeBankAccount ? { bankAccountNumber } : {})), not a null-out. | A caller who can only read the profile (e.g. a teacher with SchoolProfile_READ) should not learn the school's bank account number; the bank's name and PAN are not treated as secret at this audience, since every profile reader is the school's own staff. | No error — the key is simply absent from the JSON body for a SchoolProfile_READ-only caller. | school-profile.integration.spec.ts — "carries bankAccountNumber only for a caller holding SchoolProfile_UPDATE". |
A bankName/bankAccountNumber/panNumber change on PATCH /school-profile writes a masked audit entry; an unrelated field change writes none. | SchoolProfileService.recordFinancialAudit, called after the transaction commits, diffing a before read taken under the same FOR UPDATE row lock as the UPDATE | The school's banking details are the one part of this profile with a financial consequence if changed incorrectly or maliciously; the row lock stops two concurrent PATCHes from each recording the same from value. | No error — a best-effort write, caught and logged at ERROR on failure, never failing the PATCH. | school-profile.integration.spec.ts — "records exactly one masked financial-change entry for a bankAccountNumber change, and none when nothing financial changed", "skips the audit entirely when the context carries no userId". |
An email change on PATCH /school-profile writes an unmasked contact-change audit entry; an unrelated field change, or a PATCH that leaves email unchanged, writes none. | SchoolProfileService.recordContactAudit, a sibling of recordFinancialAudit called immediately after it, diffing the same before/after pair (the locked SELECT also reads email) | The school email is read by ID cards and the fees integration feed, so a change to it is worth a record even though it is not financial; unmasked because a public contact address is not a secret. | No error — a best-effort write, caught and logged at ERROR on failure, never failing the PATCH. | school-profile.integration.spec.ts — "records exactly one contact-change entry for an email change, unmasked, and none when unchanged". |
classRegisteredUptoGradeId may point at a retired grade, and the profile read still resolves its name. | SchoolProfileReadsService.resolveClassRegisteredUpto looks the grade up by id alone, with no is_active filter; the FK is ON DELETE restrict, never set null. | The registration is a historical fact about what the school is registered to teach, not a live pointer that should vanish the moment an administrator retires the grade elsewhere in the system. | No error — a retired grade's name still renders; only a deleted grade (impossible while any school_profile row references it, by the restrict FK) would resolve to null. | school-profile.integration.spec.ts — "resolves classRegisteredUpto fresh: a grade rename after GET shows on the next GET", and its warm-cache counterpart. |
latitude and longitude are set together or cleared together, never one without the other; (0, 0) is refused as a write. | DB: school_coordinates_paired CHECK. Application: SchoolCoordinatesInputDto requires both fields when coordinates is an object; SchoolProfileService.update refuses {latitude: 0, longitude: 0} before the transaction opens. | A single coordinate with no pair is not a location; (0, 0) is the point in the Gulf of Guinea an unfilled numeric form field defaults to, not a real school location in Nepal. | 400 SCHOOL_PROFILE_COORDINATES_UNSET for the (0, 0) case; DTO validation 400 for a half-filled object; Postgres 23514 if the CHECK is ever reached directly. | school-profile.dto.spec.ts — "refuses coordinates out of range, and a half-filled object"; school-profile.integration.spec.ts — "refuses coordinates sent as exactly (0, 0)". |
The three approval-date columns accept a date only from 1850-01-01 up to and including today, evaluated in the school's own timezone, never the server's or the caller's. | DB: school_*_approval_date_range CHECK (lower bound only — a CHECK cannot call now()). Application: SchoolProfileService.assertApprovalDateInRange, called once per field before the transaction opens, using Intl.DateTimeFormat("en-CA", {timeZone}) so the comparison is a same-format string comparison against the stored YYYY-MM-DD. | A future-dated approval is not a fact yet; a date before 1850 is certainly a data-entry error, not a real historical record for any school on this platform. | 400 SCHOOL_PROFILE_APPROVAL_DATE_OUT_OF_RANGE (application); Postgres 23514 for the lower bound alone if ever reached directly, with no upper-bound protection at the database. | school-profile.integration.spec.ts — "refuses an approval date before 1850-01-01", "refuses an approval date after today in the school's timezone". |
Every level/programme checkbox (hasEcdLevel, isOpenSchool, etc.) stores false for both "never declared" and "explicitly no" — the two are the same value, and nothing may present one as the other. | Column default false, NOT NULL, no companion "declared" flag. | A boolean has exactly two states; adding a third ("unknown"/"not yet asked") would need a nullable column or a separate flag, neither of which exists — the fields are documented false-means-either, deliberately, rather than silently implying "no" carries more certainty than it does. | N/A — a design and documentation invariant, not a runtime check. | None dedicated — covered incidentally by the round-trip test in school-profile.integration.spec.ts. |
An approval date is never required by, or cleared by, its corresponding level checkbox — there is no cross-column CHECK tying hasBasicPrimaryLevel to primaryApprovalDate. | Deliberate omission — no CHECK exists linking any level flag to its approval date column. | An approval is a historical fact that survives a level later being switched off; a cross-column CHECK would make un-ticking a level fail while its date is still filled, which is the wrong failure for a school correcting its current declaration without erasing its history. | N/A — a design invariant, not a runtime check. | None dedicated. |
16.6 Tradeoffs, Alternatives, and ADR Notes
| Decision | Context | Chosen Option | Alternatives | Why Chosen | Tradeoffs | Revisit Trigger |
|---|---|---|---|---|---|---|
| Departments/designations as editable tables, not a fixed enum. | Every school uses different titles. | Two school-editable tables. | A hardcoded enum of common titles. | Every institution carries designations an outsider would not predict — a fixed list guarantees somebody is eventually filed as "Other." | Free text quality depends on admin discipline (mitigated by per-scope uniqueness, not format). | If cross-school reporting ever needs a canonical taxonomy layered on top of school-chosen labels. |
| A designation is a separate concept from a role. | Employment title and system capability usually align but not always. | Two independent tables/domains (designations here, roles/permissions elsewhere), joined only by a human choosing both for the same staff member. | Deriving role from designation automatically. | A vice-principal who still teaches, or a senior teacher granted admin access, would be misrepresented by an automatic mapping. | An admin must set both explicitly; nothing keeps them in sync automatically. | If the product ever needs a default-role-per-designation as a convenience, layered on top of (not replacing) explicit role assignment. |
is_active boolean instead of deleted_at on both lookup tables. | Deletion must not break existing FK references, but retirement must hide a row from new picks. | Boolean flag + ON DELETE restrict on real hard delete. | Soft delete via deleted_at, as staff itself uses. | A soft-deleted department/designation would keep resolving for existing FK references (fine) while disappearing from the admin's active list (fine) — but ON DELETE restrict would never fire for it, so a genuinely-referenced row could be "deleted" and still silently resolve everywhere, which is the opposite of what a delete should mean here. | Hard delete is only possible once every reference is gone — an admin must retire-then-move-then-delete, a longer path than a one-click soft delete. | If a future requirement needs delete history for these tables specifically. |
designations_department_id_id_unique as a table CONSTRAINT, not a uniqueIndex. | staff needs a composite FK onto (department_id, designation_id). | Table constraint, emitted inline in CREATE TABLE. | uniqueIndex, emitted as a separate CREATE INDEX statement. | drizzle-kit orders every FK ALTER TABLE before every CREATE INDEX; the index form fails migration 0002 on a clean database with 42830. Verified by running the generated migration. | None — this is a correctness fix, not a design tradeoff with a cost. | N/A. |
| Cache-aside for the school profile, departments, designations, and both classification lists — every list-returning read in this module. | Profile is read on nearly every screen; lookups and classifications are read on every people form. | Full cache-aside (getSoft/setSoft on read, delPatternSoft/delSoft on write) for all four list sources plus the profile. | Invalidation-only for the lists (the prior shape), or no caching at all. | Departments, designations, and both classification lists are read on the same high-traffic forms as the profile; leaving them uncached while the profile was fully cached was an inconsistency with no principled reason once the query-keyed cache infrastructure (CacheKeyUtil.build) existed to serve them correctly. | A cached list can serve a stale page for up to an hour if a write's invalidation is itself lost (only possible under a Redis fault during the write path, which is fail-soft and logs a WARN). | If a school's list volume ever grows enough that the correlated-subquery counts (designationCount, staffCount) become expensive to compute per cache miss. |
One PersonClassificationsService, separate from LookupsService, for two structurally-identical tables. | ethnicities/mother_tongues have the same shape as departments/designations. | A dedicated service, sharing no code with LookupsService. | Add two more entities to LookupsService, which already handles two structurally similar tables. | Departments/designations are the school's own, freely-renamable vocabulary; ethnicities/mother tongues are the national census classification, arrive by seed, and a rename retroactively changes an already-filed government return. Different write rules and different consequences for being wrong argue against sharing a class, even though sharing one class for both classification tables (rather than two) is still correct, because those two are read together by the same forms and differ only in table. | A future change to one classification table's business rule (e.g. an approval step before a rename) must not be mistakenly generalized onto LookupsService's departments/designations, which have no such requirement. | If a third national-classification list is ever added (there is no current plan for one), confirm it belongs in this service rather than becoming a fourth pattern. |
Case-insensitive uniqueness via lower(name) expression indexes, not a citext column type or app-only enforcement. | Postgres text is case-sensitive by default. | Expression UNIQUE index on lower(name), mirrored by an app-level pre-check for a friendly error. | citext column type; app-only enforcement with no DB constraint. | An expression index gives a real DB-level guarantee without a new column type dependency; the app-level check exists only to produce a named 409 instead of an unmapped 23505. | Every query that wants to use the index must also filter on lower(name), not name directly (the service already does this consistently). | N/A. |
UNPAGINATED_HARD_CAP (1000) as the fallback LIMIT when pagination=false, not Number.MAX_SAFE_INTEGER. | An unpaginated request against a reference table (departments, designations, either classification list) needs to return "everything" for a select box, without becoming an unbounded read. | A named constant capping the unpaginated read at 1000 rows. | Number.MAX_SAFE_INTEGER as the LIMIT — functionally "no limit at all." | A reference table that has quietly grown past what a select box can usefully hold should truncate in one bounded round trip, not stream an arbitrarily large result set into memory because a caller happened to ask for pagination=false. | A table genuinely exceeding 1000 rows is silently truncated for an unpaginated caller — no error, just fewer rows than exist. | If a reference table's realistic size for this domain is ever expected to exceed 1000 rows (none currently approach even a tenth of that). |
16.7 Operational Runbook
| Operation | How to Inspect | Healthy State | Failure Signal | Recovery |
|---|---|---|---|---|
| School profile cache | Redis GET school:profile:v2; application logs for RedisCacheService WARN lines | A JSON blob present with TTL ≤ 3600s, or absent immediately after a PATCH | WARN log lines from getSoft/setSoft/delSoft naming the key | Nothing to do manually — every path is fail-soft and self-heals on the next successful read/write once Redis recovers. classRegisteredUpto, principal, and runningGrades are never in this key — a stale value there cannot explain a wrong reading of any of the three. |
| Department/designation list cache | Redis KEYS lookups:v2:departments:list:* / lookups:v2:designations:list:*; application logs for RedisCacheService WARN lines | One JSON blob per distinct query combination actually requested, each with TTL ≤ 3600s; empty immediately after any department or designation write | A stale value surviving a write would mean delPatternSoft ran against the wrong pattern or failed silently past its own WARN | Nothing to do manually — fail-soft and self-healing on the next write or TTL expiry, same as the profile cache. If a rename appears not to show up on the list screen within the TTL, confirm the write actually reached invalidate() rather than assume the cache is broken. |
| Person-classification list cache | Redis KEYS lookups:classifications:ethnicities:* / lookups:classifications:mother-tongues:* | Same shape as the lookup list cache above, scoped independently per kind | Same failure signal as above | Same recovery as above; an ethnicity write never needs to (and does not) clear the mother-tongue prefix, so its absence there is not a bug. |
| Department/designation delete failures | API response body (errorCode) | DEPARTMENT_HAS_DESIGNATIONS/DESIGNATION_IN_USE returned with a human-readable message naming the blocker | Same codes appearing unexpectedly for a row the admin believes is unreferenced | Query designations/staff directly by the parent id to find the actual blocking row(s); the API does not enumerate them in the error response. |
| Person-classification delete failures | API response body (errorCode) | PERSON_CLASSIFICATION_IN_USE with a message pointing at retirement instead | Same code appearing for an entry the admin believes is unused | Query users directly by ethnicity_id/mother_tongue_id to find who still references it; the API does not enumerate them in the error response. |
| School profile singleton missing | SCHOOL_PROFILE_NOT_INITIALIZED on every GET/PATCH | Row 1 present in school_profile | Every request to this module's profile endpoints 404s | Run the pending migrations; the row is created by migration 0002, not by a seed script — if it is missing, migrations are behind. |
| Reference data missing or incomplete | SELECT count(*) FROM ethnicities / mother_tongues — expect 56 / 42 or more | Both tables populated from the seeded baseline, plus anything a school has added | Either table reporting 0, or fewer rows than the seeded baseline minus any legitimately hard-deleted (never reactivated-by-seed) rows | Run pnpm db:seed (or the production seed) — seedReferenceData() is idempotent via onConflictDoNothing(), so re-running it is always safe and never overwrites a school's own edits or reactivates a retired row. |
16.8 Backend Risk Register
| Risk | Area | Impact | Current Mitigation | Remaining Gap |
|---|---|---|---|---|
| Name-uniqueness race condition | createDepartment/createDesignation | Two concurrent identical-name creates: the loser gets a generic RESOURCE_ALREADY_EXISTS instead of the friendlier DEPARTMENT_NAME_TAKEN/DESIGNATION_NAME_TAKEN. No duplicate row is ever created — the DB unique index is the real backstop. | The UNIQUE index is authoritative; only the error message quality degrades under a race, not data integrity. | Wrapping the pre-check and insert in a single statement (e.g. INSERT ... ON CONFLICT) or catching the 23505 explicitly would restore the friendlier code even under a race. |
| No audit trail on department/designation/classification mutations | Departments, designations, classifications (not the school profile itself, which has recordFinancialAudit/recordContactAudit) | An admin cannot answer "who changed this and when" beyond updated_at. | updated_at is bumped on every write. | No created_by/updated_by column and no audit log entry exists for any of these three entity groups. |
| Renaming a person classification retroactively changes historical meaning | ethnicities, mother_tongues | Every pupil already recorded against a row keeps pointing at it after a rename, so a rename rewrites their recorded classification retroactively, including inside government returns already filed. | Write access (PersonClassifications_CREATE/UPDATE/DELETE) is administrator-only, unlike _READ; the name-uniqueness pre-check at least prevents two rows drifting to the same corrected spelling. | No confirmation step, audit entry, or "why are you renaming this" prompt exists at either the API or the database layer — an administrator's typo-fix and an administrator's accidental repurposing of a row are indistinguishable to the system. |
17. Zero-Omission Backend Checklist
- Every file in both module directories is represented (see 4).
- Every controller, service, and DTO is documented; no processor/scheduler/mapper exists in this module.
- Every method with business behavior has a code-flow narrative (6, 7).
- Every table has field-level detail (5.2).
- Every index/constraint/relation/delete behavior has rationale (5.2, index rationale table).
-
is_activeis documented as a flag, not a lifecycle, with the reasoning for why no state diagram applies. - Every read/write flow has a sequence or activity diagram (7).
- Every business invariant is cataloged (16.5).
- Every cache key and its actual (not assumed) invalidation/population path is documented (8).
- Every architectural tradeoff is documented with alternatives and a revisit trigger (16.6).
- Every operational failure mode has a runbook entry (16.7).
18. Backend Completion Checklist
- Module boundaries are documented (2).
- Every controller, service, DTO, and schema file is covered.
- Every database table has a field table and relationship diagram.
- Every runtime flow has a diagram and branch notes.
- API and features/flows docs are linked below.
- No claim is made without a source file reference — every fact above cites the file (and, where load-bearing, the line range) it was read from.
See Also
- API doc:
/docs/developer/school/api - Features and flows doc:
/docs/developer/school/feature
School Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for departments, designations, and the school profile.
School API Reference
Complete API contracts for departments, designations, person classifications, and the school profile singleton, including routes, auth, DTOs, responses, errors, and examples.