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 | Table creation and the row-1 singleton insert. |
| 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/school-profile.service.spec.ts | Real-database coverage for the classification service and the profile's field-clearing 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) via
SchoolProfileController(apps/api/src/modules/school-profile/school-profile.controller.ts). - 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.
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.service.spec.ts # real-Postgres coverage
dto/
index.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 whole row. | SchoolProfileService | Also exposes getTimezone() for other modules' year-numbering logic. |
dto/school-profile.dto.ts | Profile DTOs. | SchoolProfileDto, UpdateSchoolProfileDto | 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 no foreign-key relationship to any other table — it is deliberately a free-standing singleton, not shown joined above. 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 (RETIRED_COLUMNS), because the API now derives the principal instead: see SchoolProfileDto.principal, resolved by PrincipalInvariantService.resolvePrincipal from whoever actively holds the designation flagged is_principal. 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, but the column itself has no format constraint. |
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. |
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). Neither the DTO nor the column validates that the string is a real IANA timezone name — UpdateSchoolProfileDto.timezone is @IsString() @MaxLength(64) only (apps/api/src/modules/school-profile/dto/school-profile.dto.ts:128-132); an admin who saves "Asia/Katmandu" (misspelled) would silently break year allocation without a validation error at write time. |
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 (all-exceptions.filter.ts:50-63).
6.2 SchoolProfileService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
get() | SchoolProfileController.get; internally by getTimezone() | Cache (school:profile) for the base fields, then school_profile row 1 on a miss; staff/designations/users (via PrincipalInvariantService.resolvePrincipal) uncached, every call | — | Populates the base-fields 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. |
update(dto) | SchoolProfileController.update | school_profile row 1 (via the UPDATE ... RETURNING); staff/designations/users for the fresh principal resolution | school_profile row 1 | cache.delSoft("school:profile") | SCHOOL_PROFILE_NOT_INITIALIZED (404) if the UPDATE matches no row. |
getBase() (private) | get(), update(), getTimezone() | Cache (school:profile), 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. |
getTimezone() | Other modules' year-numbering logic (admission/employee number allocation) | Delegates to getBase(), not get() | — | — | Same as getBase(). |
toPatch(dto) (private, static) | update() | — | — | — | Never throws — a pure transform. |
toDto(row) (private) | getBase() | — | — | — | Strips id, created_at, principalName, brandPrimaryColor, and brandSecondaryColor (RETIRED_COLUMNS) from the raw row before it reaches the DTO — everything but principal, which get()/update() attach afterward. |
Code flow — get(): delegates the cacheable part to getBase() (cache-aside, read-through: getSoft is tried first; 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), then always resolves principal fresh by calling PrincipalInvariantService.resolvePrincipal — never cached alongside the rest. The 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, and invalidating this key from every staff write would couple two otherwise-independent modules for the sake of one field. The tradeoff is one extra indexed query on every profile read; getTimezone() deliberately calls getBase() instead of get() so the admission/employee-numbering hot path never pays it. 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(): no pre-read of the row, but the DTO is passed through toPatch(dto) before it ever reaches .set() — it is not spread directly. 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 two rules depending on the field:
- For the plain nullable
textcolumns (logoUrl,registrationNumber,principalName,phone,email,website,brandPrimaryColor,brandSecondaryColor) a trimmed-to-empty string is written asnull, not"". - The
addressfield is handled separately and first, before any per-field blanking rule runs: it is a group, replaced whole in the same way a person'spermanentAddress/currentAddressis (see thepeopledocs). Whenaddressis present on the request, 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:{ "address": { "provinceId": null } }clears the whole group rather than leavingdistrictIdbehind pointing at an now-absent province, which would otherwise raise the unmapped23514 school_district_needs_province. - For
name,currencyCode, andtimezone— the threetextcolumns that areNOT NULLwith a database default — a trimmed-to-empty string is dropped from the patch entirely, so the column keeps its current value. A non-string field (academicYearStartMonth) is copied through unchanged, with no trimming.
The reason the three NOT NULL columns are excluded from blanking rather than also clearing to null: class-validator's @IsOptional() treats null as "absent," so a client with a "clear the logo" button has only the empty string available to send, and a form that posts every field back (not just the ones the office actually edited) would otherwise blank timezone to "" the moment any other field on the same form was legitimately cleared. timezone in particular is read by getTimezone() to decide which year an admission/employee number belongs to — a blank 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 blank on a NOT NULL 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.
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.get — 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. |
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 are not always treated alike: on the twelve nullable text columns (logoUrl, registrationNumber, principalName, address, city, state, pinCode, phone, email, website, brandPrimaryColor, brandSecondaryColor) a trimmed-to-empty string is written as null — the one way a client can ever clear one of these fields back to "not set," since class-validator's @IsOptional() treats an actual null in the request body as absent, leaving the empty string as the only signal a "clear" button can send. On name, currencyCode, and timezone — the three NOT NULL columns with a database default — a blank is instead dropped from the patch and the existing value survives untouched, specifically because a blank timezone would silently break admission/employee-number allocation, which resolves the academic year through it (see 16.5).
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 | Hardcoded string constant (SchoolProfileService.CACHE_KEY) | The full SchoolProfileDto as JSON | 3600s, set explicitly on every setSoft call | delSoft on every successful update() | SchoolProfileService |
lookups:departments:list:<query> | CacheKeyUtil.build("lookups:departments:list:", [...]) over search, isActive, sort, order, pagination, page, size | { data: DepartmentDto[], totalCount } as JSON | 3600s | delPatternSoft("lookups:departments:*") on every department and designation write | LookupsService.findAllDepartments / invalidate() |
lookups:designations:list:<query> | CacheKeyUtil.build("lookups:designations:list:", [...]) over search, departmentId, isTeaching, isActive, sort, order, pagination, page, size | { data: DesignationDto[], totalCount } as JSON | 3600s | delPatternSoft("lookups: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 is currently defined as exactly the former), alongsideStudents_READ,Guardians_READ,Staff_READ,Departments_READ,Designations_READ,SchoolProfile_READ, andActivity_READ— 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.- Sensitive data redaction. None of these fields are sensitive; there is no salary, password, or token data in scope for this module.
- Audit logs. No audit table write accompanies any create/update/delete in this module — verified by the absence of any audit/activity import in either service file. Contrast with modules like
StaffSalary, which are gated and audited for that reason; departments/designations/school-profile carry no such requirement in the current code. - 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. |
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:39,50-63) | Every exception reaching the HTTP boundary is logged at error, 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 | None | No audit table write accompanies any mutation in this module — see Security. |
15. Testing and Validation
Two 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/school-profile.service.spec.ts — five tests, against real Postgres, 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). 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; and storing a non-string field (academicYearStartMonth) as itself.
Both suites are named and run as real-database integration tests per this repo's testing rules — neither mocks DatabaseModule, Drizzle, or any @skoolsewa/* package; only the Redis cache is stubbed, and only because these tests exist to prove what the database does, not what the cache does.
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 | Database, RedisCacheService | SchoolProfileController; other modules' year-numbering code via getTimezone() | Drizzle queries, cache.getSoft/setSoft/delSoft | school_profile row 1; school:profile cache key | 404 SCHOOL_PROFILE_NOT_INITIALIZED (documented unreachable). |
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. | 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. | Not a validation error — a wrong timezone string produces wrong year numbers silently, since neither the DTO nor the column validates it is a real IANA name. | None. |
| 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 sent for a nullable school-profile text field clears it to NULL; a blank sent for name/currencyCode/timezone is dropped and the existing value survives; the address group is replaced whole, never merged field by field. | SchoolProfileService.toPatch — NULLABLE_TEXT_FIELDS vs. the three excluded NOT NULL columns, with address intercepted before either rule applies. | class-validator's @IsOptional() treats null as absent, so an empty string is the only "clear this field" signal a client has; but the three excluded columns are NOT NULL with defaults, and a blank timezone in particular would silently break admission/employee-number allocation, which resolves the academic year through it. | No error — a blank on a nullable text column writes NULL; a blank on the three excluded columns 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.service.spec.ts — "writes NULL, not an empty string, when a nullable field is cleared", "ignores a blank on a NOT NULL column rather than writing one". |
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; 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. |
| Department/designation list cache | Redis KEYS lookups:departments:list:* / lookups: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. |
| Stale timezone breaking year numbering | school_profile.timezone | An admin saving a misspelled or invalid IANA timezone name is accepted silently and would misdate every admission/employee number allocated afterward. | None — no runtime validation of the timezone string's validity exists. | A validation step (e.g. attempting to construct an Intl.DateTimeFormat with the given zone) before accepting the update. |
| No audit trail on department/designation/classification/profile mutations | All four entity groups | 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 table in this module. |
| 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.