School (departments, designations, person classifications, school profile)
Submodule
Departments, Designations, PersonClassifications, SchoolProfile — four independently permissioned areas served by two backend modules
Primary user value
Gives the office a school-owned vocabulary for who works where and as what, a shared national reference for the caste/ethnic group and mother tongue a pupil or employee is recorded against, and a single place to declare the facts (name, address, branding, currency, timezone, academic year) every other screen in the product assumes about the school.
Actors
Admin for every mutation and for departments/designations/school-profile reads; staff and teachers additionally hold read-only access to the two person-classification lists, because both the pupil and the staff people-forms render them as select boxes. No guest or unauthenticated actor, and no mobile-facing route, touches this module.
Persisted departments/designations/ethnicities/mother_tongues/school_profile rows; the response DTOs consumed by department/designation admin screens, the Teachers screen's filter, staff-creation dropdowns, the pupil and staff demographic forms' ethnicity/mother-tongue selects, and every screen that displays the school's identity.
Every route requires a valid JWT; there is no @Public() route anywhere in LookupsModule or SchoolProfileModule.
Logged-in user without the relevant permission
Nothing module-specific beyond whatever their own role already grants elsewhere.
List, create, update, or delete departments/designations/classifications; read or update the school profile.
JWT + active role, but lacking Departments_*/Designations_*/PersonClassifications_*/SchoolProfile_*.
Refused with 403 PERMISSION_INSUFFICIENT (or 403 AUTH_ACTIVE_ROLE_REQUIRED if they hold several roles and have not chosen one).
Staff or teacher (any administrative role, not necessarily an "admin" screen operator)
List and search the ethnicity and mother-tongue lists — nothing else in this module.
Create, rename, retire, or delete a classification entry; anything on departments, designations, or the school profile unless separately granted.
JWT + active role with PersonClassifications_READ, seeded onto both staff and teacher roles.
The only_READ permission in this module granted outside an explicit admin role — see 12.3 for why. Both the pupil and staff demographic forms render these two lists as select boxes, so a colleague without this permission would see two empty boxes with no way to tell it is a permissions problem.
Admin holding the relevant _READ permission
List and search departments/designations/classifications; read the school profile.
Create, update, or delete anything in this module.
JWT + active role with Departments_READ/Designations_READ/PersonClassifications_READ/SchoolProfile_READ.
Read access is granted independently per entity — an admin could hold SchoolProfile_READ without Departments_READ.
Admin holding _CREATE/_UPDATE/_DELETE
The corresponding mutation.
Anything outside the permissions actually granted — e.g. Departments_UPDATE alone does not permit Departments_DELETE.
JWT + active role with the specific action permission.
Every action is its own permission code — there is no single "manage departments" grant that implies all four. Unlike _READ, every PersonClassifications write code is administrator-only.
Superadmin
Everything in this module, unconditionally.
Nothing is withheld.
JWT + a role flagged is_superadmin.
The bypass is keyed on the boolean flag, never on a role's display name.
Worker/system
No automated actor touches this module.
Everything, since none exists.
N/A
No job, scheduler, or event consumer reads or writes departments, designations, classifications, or the school profile — verified against the backend doc's Sections 9-10 (BullMQ and Realtime, both "not applicable").
An admin opens the department management screen, which loads every department (or a search-filtered, paginated subset) to display in a table, with an active/retired toggle.
An admin sets isActive on a department without deleting it — the recommended way to remove a department from active use while any staff or designation still references it.
Referenced only by staff.department_id directly (no designation)
A staff member is assigned to the department with no designation set
Not caught by this check — the guard only inspects designations. The delete proceeds to the database, where the staff.department_id ON DELETE restrict foreign key is the only remaining backstop and would surface as an unmapped 500/23503 rather than a friendly 409.
See the risk noted in the backend doc — this is a real gap in the service-level guard, not a documentation gap.
Not found
Already deleted or invalid publicId
—
404 DEPARTMENT_NOT_FOUND.
Retired department
isActive: false
Deletable exactly like an active one — retirement status has no bearing here.
An admin (or the Teachers screen, indirectly) lists designations, optionally scoped to one department, filtered to teaching-only titles, and/or filtered to active-only.
A designation's only holders have all been soft-deleted from staff
staffCount reports 0 (the count excludes soft-deleted staff), but the designation cannot be deleted (the delete-guard does not exclude soft-deleted staff).
No error on the list — the disagreement only surfaces if the admin then tries to delete a designation the list showed as "unused."
departmentId for a nonexistent department
Filter value does not match any real department
Empty result, not an error.
data: [].
Designation under a retired department
Parent department has isActive: false
Still listed normally — the list does not hide or flag designations whose parent is retired.
Allowed — uniqueness is per department, not global.
Success in both.
Department is retired
departmentId points to an isActive: false department
Allowed — the existence check does not filter on isActive.
Success; the designation is created under a department nobody can currently pick fresh in most dropdowns (which typically filter isActive: true), a state worth flagging to the admin in the UI even though the API permits it.
isTeaching omitted
No value supplied
Defaults to false.
The designation will not surface staff on the Teachers screen until explicitly retoggled.
An admin edits an existing designation's name, flips its teaching flag, or retires/reactivates it. The department it belongs to can never be changed through this flow.
An admin permanently removes a designation created in error. Blocked while any staff row — including a soft-deleted (former) staff member — still references it.
Every referencing staff row is soft-deleted (a former employee)
Still refused — the check deliberately does not filter on staff.deletedAt, so a former staff member's historical record can never be left pointing at a deleted designation.
409 DESIGNATION_IN_USE, even though the list endpoint's staffCount may show 0 for the same designation — see 5.5.
Any admin screen that needs the school's identity, contact details, branding, currency, or timezone reads the single profile row — cached for up to an hour after the first read following any change.
The office edits any subset of the school's facts — name, contact details, branding colors, currency, academic year start month, or timezone — from a settings screen.
Accepted — neither the DTO nor the column validates it is a real timezone.
No error at write time; admission/employee number allocation silently misdates going forward. This is a real product risk, not a documentation gap — see the backend doc's risk register.
Invalid currency code
Any string ≤ 8 characters
Accepted — not validated against ISO 4217.
Same category of silent-acceptance risk as timezone.
Invalid academic year start month
Outside 1-12
Rejected by the DTO before it can reach the database's own matching CHECK.
400 VALIDATION_FAILED.
Invalid hex color
Not a valid hex string
Rejected.
400 VALIDATION_FAILED.
Attempt to clear a field to empty
Sends "" for one of the twelve nullable text fields (logoUrl, registrationNumber, principalName, address, city, state, pinCode, phone, email, website, brandPrimaryColor, brandSecondaryColor)
Trimmed and written as NULL — this is the intended, and only, way to clear one of these fields back to "not set."
Success; the field renders as unset (e.g. the "no logo" placeholder) rather than an empty string.
Attempt to clear name, currencyCode, or timezone to empty
Sends "" (or all-whitespace) for one of these three NOT NULL columns
The blank is dropped from the write entirely — the existing value is left untouched, exactly as if the field had been omitted.
Success on the request, but the field is silently unchanged — this is deliberate: a blank timezone would break admission/employee-number allocation, which resolves the academic year through it.
Read landing in the invalidation gap
A GET arrives between the UPDATE commit and the DEL
Could observe the pre-update cached value.
Bounded by the (very short, in practice near-immediate) gap between the two statements — not the full TTL.
Any staff member or teacher — not only an administrator — opens a pupil or staff demographic form (or, less often, the classification maintenance screen) and the ethnicity and mother-tongue selects populate from these two lists.
An administrator adds an entry the seeded national list is missing — the seeded list is deliberately the broad Central Bureau of Statistics classification, not an exhaustive one, and a real school roll regularly contains someone it does not name.
An administrator corrects a spelling, or retires an entry the school does not use — retirement is the recommended action for "remove from new picks" rather than a hard delete, exactly as with departments/designations.
Allowed — retirement never requires clearing references first, exactly like departments/designations.
Success; every existing record continues to resolve.
Renaming an entry people are recorded against
Any name change, retired or not
Allowed, with a real consequence: every pupil or employee already recorded against this row is retroactively understood to carry the new name, including inside a government return already filed under the old spelling.
Success — this is a deliberate, documented tradeoff, not a bug; see 12.3.
Refused — this is what stops ethnicity_id's ON DELETE set null from silently blanking a real person's recorded classification the moment the row they point at is removed.
409 PERSON_CLASSIFICATION_IN_USE, with a message recommending retirement instead.
Not found
Already deleted or invalid publicId
—
404 PERSON_CLASSIFICATION_NOT_FOUND.
Retired entry
isActive: false
Deletable exactly like an active one — retirement status has no bearing on delete eligibility, same as departments/designations.
Departments and designations: see 5.2 and 5.6. Person classifications: see 5.12. All three require the corresponding _CREATE permission (administrator-only, even for classifications where _READ is broader), write one row, and invalidate the affected cache prefix — every list in this module is now cache-aside, so the very next matching GET reflects the write.
There is no single-resource "read detail" route for a department, a designation, or a classification entry — only the list endpoints (GET /api/departments, GET /api/designations, GET /api/lookups/ethnicities, GET /api/lookups/mother-tongues), which return every matching row including the one an admin might want to inspect individually. The school profile has no list — it is a singleton read via GET /api/school-profile.
See 5.3, 5.7, 5.13, and 5.10. Every update on this module is a PATCH, every field is independently optional, and cache invalidation follows every successful write.
Not applicable. No ordering/position column exists on departments, designations, ethnicities, mother_tongues, or school_profile — lists are sorted by name or updatedAt (classifications: name only), never by a stored manual order.
Covered by the isActive field on departments, designations, ethnicities, and mother_tongues — see 5.3, the retire/reactivate branch of 5.7, and 5.13. This is the module's substitute for soft delete: it hides a row from active-only pick lists without breaking any existing reference, and — unlike a real soft delete — it never blocks a subsequent hard delete on its own (only an existing reference does).
Not applicable. None of departments, designations, ethnicities, or mother_tongues has a deleted_at column — see 7. Lifecycle and State Transitions for why that is a deliberate design choice, not an oversight. school_profile cannot be deleted at all (no DELETE route exists on it).
Not applicable, for the same reason as 6.6 — there is nothing to restore. Note that the permission catalog still generates a _RESTORE code for every module in this document, including PersonClassifications_RESTORE (every module gets every action by the catalog's own generation rule), but no route in this module ever checks any of them.
See 5.4, 5.8, and 5.14. All three are the module's only true deletion path for their respective entity, all are reference-guarded, and all name the exact blocking condition in the error rather than surfacing an unmapped foreign-key failure or a silent data loss (the classification case is the sharpest version of this: ethnicity_id/mother_tongue_id are ON DELETE set null, so without the guard a delete would succeed and silently blank a real person's recorded classification).
Not owned by this module directly, but relevant: the data-transfer module's bulk import resolves department/designation names against these tables and produces IMPORT_UNKNOWN_DEPARTMENT/IMPORT_UNKNOWN_DESIGNATION when a name in an import file does not match an existing row here. This module does not implement that import — it is only the vocabulary the import validates against.
Not applicable. Nothing in this module requires approval, review, or a moderation queue — every mutation an admin with the right permission makes takes effect immediately.
See 5.9 and 5.10. This is the one "settings-shaped" flow in the module: a single row, read-cached, write-invalidated, with no create/delete lifecycle at all.
Departments, designations, and both classification lists do not carry a multi-state lifecycle — isActive is a boolean flag, not a state machine, and that is a deliberate schema choice (see the backend doc's tradeoffs): a soft-deleted row never fires ON DELETE restrict/set null correctly, so it would keep resolving through every existing reference while disappearing from admin pick lists — "gone to the eye, present to the query." isActive avoids that by never touching referential integrity at all; only hard delete does, and hard delete is blocked while referenced.
Entity
From
Event/Action
To
Guard Condition
Side Effects
Department/Designation/Ethnicity/MotherTongue
isActive: true
PATCH isActive: false
isActive: false
None — always allowed, even with active children/references.
Removed from active-only pick lists; every existing reference (designations, staff, or a person's recorded classification) still resolves normally.
Department/Designation/Ethnicity/MotherTongue
isActive: false
PATCH isActive: true
isActive: true
None — always allowed.
Reappears in active-only pick lists. Deliberately not automatic on a seed re-run — seedReferenceData() never reactivates a row it finds retired.
Department/Designation/Ethnicity/MotherTongue
Either isActive value
DELETE
Row no longer exists
No referencing row (designation for a department; staff, including soft-deleted, for a designation; any users row for a classification entry).
Permanent removal; cache invalidated.
The school profile has no lifecycle at all — it is a single row that exists from the moment migration 0002 runs, is never created or deleted through the API, and only ever transitions field-by-field via PATCH.
Stable pagination even with duplicate updatedAt values
None — read-only ordering detail
lookups.service.ts
List departments
Silent fallback for an unrecognized sort value
Admin
sort= anything other than name
Sorted by updatedAt instead, with no error
None
lookups.service.ts
List designations
staffCount excludes soft-deleted staff
Admin/Teachers screen
Any GET /api/designations
A designation held only by former staff shows staffCount: 0
None — read-only
lookups.service.ts
Create department
Empty code/description stored literally, not normalized
Admin
POST with code: ""
Stored as "", not null
Row written with an empty string
lookups.service.ts
Update department
Empty code/description normalized to null
Admin
PATCH with code: ""
Field cleared to null — different from the create-path behavior above
Row written with null
lookups.service.ts
Update department/designation
Rename to same name, different case, is a no-op
Admin
PATCH name: "SCIENCE" on "Science"
Accepted, not rejected as a self-clash
Row updated with new casing
lookups.service.ts
Create designation
Parent department existence checked before the name check
Admin
POST /api/designations with a bad departmentId
404, never reaches the name-uniqueness check
None
lookups.service.ts
Create designation
Accepted under a retired department
Admin
POST with departmentId pointing to isActive: false
Succeeds silently
Row written
lookups.service.ts
Update designation
staffCount in the response is a stale 0 placeholder, not re-queried
Admin
Any PATCH /api/designations/:publicId
Client sees 0 even for an already-staffed designation
None — display-only mismatch until the next fresh GET
lookups.service.ts
Delete designation
Soft-deleted staff still block deletion
Admin
DELETE on a designation whose only holders have left
Refused with DESIGNATION_IN_USE despite the list showing staffCount: 0 for the same row
None — the delete never runs
lookups.service.ts
Delete department
Direct staff.department_id references (no designation) are not checked
Admin
DELETE on a department with a directly-assigned staff member and no designations
The check passes and the delete is attempted at the database, which then enforces its own FK
Potential unmapped 500/23503 instead of a friendly 409
lookups.service.ts + schema FK
List departments/designations
Cache-aside, keyed on the full query — a filter/page/size change is a different cache entry, not a different read of the same entry
Admin
Any GET
Repeat identical queries are served without a database round trip
Cache populated on the first request for a given query, cleared on any write
lookups.service.ts
List person classifications
Sorted by name ascending by default — the one list in this module that defaults ascending
Staff/Teacher/Admin
GET /api/lookups/ethnicities or .../mother-tongues with no order
Alphabetical by default, suited to a select box
None — read-only ordering detail
person-classification.dto.ts
Create person classification
Name trimmed before the not-empty check, not after
Admin
POST with name: " "
400, not a blank row silently inserted
None
person-classification.dto.ts
Delete person classification
Referential check against users.ethnicity_id/users.mother_tongue_id, on the column matching kind
Admin
DELETE on an entry a person is recorded against
Refused with PERSON_CLASSIFICATION_IN_USE rather than silently blanking that person's record via the column's ON DELETE set null
None — the delete never runs
person-classifications.service.ts
Read person classifications
PersonClassifications_READ is granted to staff and teacher roles, unlike every other _READ permission in this document
Staff/Teacher
Any GET on either classification list
Succeeds for a non-administrative role that would be refused on every other list in this module
None — read-only
seed-auth.ts
Read school profile
Cache-aside with no lock — a burst of misses can each redundantly re-read and re-write
Any admin
Concurrent GETs immediately after a PATCH
All correct, just briefly redundant work
Multiple identical SELECT/SETEX pairs
school-profile.service.ts
Update school profile
Blank on one of twelve nullable text fields clears it to NULL; blank on name/currencyCode/timezone is silently ignored instead
Admin
PATCH with logoUrl: "" vs. PATCH with timezone: ""
Field cleared vs. field unchanged — the same-shaped input produces two different outcomes depending which field it targets
Row written with NULL, or the patch key dropped entirely
school-profile.service.ts
Update school profile
No validation that timezone/currencyCode are real values
Admin
PATCH with a bad timezone/currency string
Accepted; silently wrong going forward
Row written with the bad value
school-profile.dto.ts
Rules from the format are honored above: no restore/retry/fallback/cache-miss/duplicate-action/permission-failure behavior has been grouped away, even where the underlying rule (e.g. "empty search is dropped") might look too small to mention on its own.
A designation's department is immutable once created.
Moving it would violate the composite foreign key from every staff row referencing it at once.
An admin who assigned the wrong department must retire and recreate, not edit in place.
UpdateDesignationDto omits departmentId; global forbidNonWhitelisted rejects it if sent.
PATCH with departmentId in the body always 400s.
staff_designation_in_department_fk.
None.
Neither lookup table soft-deletes.
A soft-deleted row would keep resolving through references while vanishing from pick lists — inconsistent with what "deleted" should mean when ON DELETE restrict exists specifically to prevent silent dangling references.
Deletion always requires clearing references first; retirement (isActive) is the "hide but keep" tool instead.
Absence of a deleted_at column; hard-delete guards in the service.
DELETE always either succeeds permanently or is refused with a named reason.
Schema design choice, documented in the backend doc.
None.
PersonClassifications_READ is granted to staff and teacher roles, not just administrators.
Both the pupil and staff demographic forms render the ethnicity and mother-tongue lists as select boxes; a colleague who cannot read them sees two empty boxes with no way to tell it is a permissions problem rather than an empty table.
Any staff member or teacher can populate these two selects; every write action stays administrator-only.
Seeded explicitly onto STAFF_PERMISSIONS/TEACHER_PERMISSIONS in seed-auth.ts, the only _READ code in this document seeded that way.
GET /api/lookups/ethnicities/.../mother-tongues succeed for a non-administrative role that every other list in this module would refuse.
Permission catalog + seed grants.
None.
Ethnicities and mother tongues are seeded national data, not a school's own invented vocabulary.
Every government return (IEMIS, scholarship and free-textbook allocations, reservation quotas) is aggregated against the same national classification; two schools spelling a group differently produce two rows the ministry cannot add together.
A school starts with a complete, correct baseline (56 ethnicities, 42 mother tongues) rather than typing every entry in from nothing.
seedReferenceData(), called from both seed.ts and seed-prod.ts, idempotent and non-reactivating.
The lists are populated from the very first GET after migration, with no empty-state onboarding flow needed.
seed-reference-data.ts.
None.
Renaming a classification entry retroactively changes what every already-filed government return meant.
The rename rewrites the recorded classification of every pupil or employee already pointing at the row — there is no versioning of "what this row meant as of a given date."
A typo correction is safe and encouraged; repurposing a row to mean a different group is not, and the documented remedy is to retire the row and create a new one instead.
Not enforced by any constraint — a design invariant relying on administrator judgment, backed only by the write permission being administrator-only.
PATCH accepts any rename unconditionally; the API cannot distinguish "fixing a typo" from "repurposing this row."
See the backend doc's risk register.
None.
The school profile is a singleton.
Typed, redeployable-free school configuration in one row, replacing both environment variables and a removed key-value config table.
Admins edit school facts without a deploy; there is exactly one profile per database.
CHECK (id = 1) plus the PK on id.
No POST/DELETE route exists on /school-profile at all.
school_profile_singleton.
None.
An empty string clears a nullable school-profile field to NULL; the same empty string on a NOT NULL column with a default is a no-op instead.
class-validator's @IsOptional() treats an actual null in the request body as absent, so a "clear this field" UI control has only the empty string available to send; but name/currencyCode/timezone are NOT NULL with defaults, and blanking timezone specifically would break admission/employee-number allocation.
A client can genuinely clear a logo, address, or contact field; the same gesture on the three protected fields is silently absorbed rather than corrupting them.
SchoolProfileService.toPatch — see the backend doc's 6.2.
PATCH with logoUrl: "" clears it; PATCH with timezone: "" leaves the existing timezone untouched, with no error distinguishing the two outcomes.
school-profile.service.spec.ts.
school-profile.service.spec.ts.
The school's timezone decides year numbering for admission/employee numbers.
Asia/Kathmandu is UTC+05:45; a UTC clock would misdate numbers issued near midnight local time.
An admin who mis-sets the timezone silently corrupts future numbering, with no validation to catch it.
SchoolProfileService.getTimezone() is the documented read path for consumers.
PATCH /school-profile accepts any string for timezone.
isActive flag instead of soft delete on lookup tables
An admin can hide a department/designation from new picks without losing history on existing staff
No deleted_at filtering needed anywhere these tables are joined
Soft delete like staff
Hard delete requires clearing references first — a longer path
Low — this is the safer default, not a shortcut.
Hard delete guarded by an explicit existence check, not left to the database FK alone
An admin sees "this department still has designations," not a raw constraint-violation error
A named error code is easier to branch on client-side than parsing a Postgres error
Let ON DELETE restrict surface as an unmapped 500
The department-delete guard has a known gap for direct staff.department_id references — see 12.1
Moderate — a rare shape (staff assigned to a department with no designation) can still hit an unfriendly error.
Cache-aside for every list in this module — the school profile, departments, designations, and both classification lists
Every list is read on a high-traffic form (staff creation, pupil/staff demographic sections) and rarely written
The full query (filters, sort, page, size) is folded into the cache key via CacheKeyUtil.build, so no two distinct requests can ever share a cache entry
Invalidation-only, with lists always querying live (the prior shape for departments/designations)
Correctness risk of a stale read is bounded by the TTL (1 hour) and by every write clearing the relevant prefix(es) immediately
Low — every write path already invalidates synchronously; the only residual risk is a lost invalidation during a Redis fault, which is logged.
PersonClassifications_READ granted to staff and teacher roles; every other _READ in this module stays administrator-only
The pupil/staff demographic forms every staff member and teacher fills out need these two selects populated
A dedicated seed grant onto STAFF_PERMISSIONS/TEACHER_PERMISSIONS, while the four write codes stay administrator-only
Keep every code in this module administrator-only, requiring a broader role just to fill out a demographic form
A non-administrative role can read (never write) national reference data it has no reason to be blocked from seeing
Low — read-only, and the data is not sensitive; the write side is unaffected.
No idempotency key on create endpoints
Simpler client integration
No dedup infrastructure needed
Idempotency-Key header + server-side dedup
A double-click can create a duplicate department/designation/classification entry unless the name collides
Low — these are low-frequency, human-reviewed admin actions.
No format validation on timezone/currencyCode
Simpler DTO, no dependency on a timezone/currency data set
Less validation code to maintain
Validate against Intl.supportedValuesOf("timeZone") / ISO 4217 list
A typo silently corrupts year numbering or displays a wrong currency symbol
Moderate — this is a real, documented risk in the backend doc's risk register, not a hypothetical.
Appears on the very next list call for that exact query — a differently-filtered request cannot have a stale cache entry to serve, since no query populates another query's key
Immediate visibility
Backend doc §8 (cache keyed per query)
List person classifications
Empty state after filtering
isActive=true when every remaining entry has been retired
The doc explains what the actor is trying to accomplish (organize staff into departments/designations; keep the school's own facts current).
The doc explains what the backend does that the actor does not see (name pre-checks, cache-aside on the profile, the staffCount/deletability disagreement, the direct-staff delete gap).
The doc covers every minor flow and branch, including ones a casual read of the controllers would group away (empty search, case-only renames, retired-department designation creation).
The doc includes admin and (by composition) Teachers-screen flows; there is no guest, worker, or system flow to include, and that absence is stated explicitly rather than left silent.
The doc explains business logic, tradeoffs, and rationale (§12.3, §12.4).
The doc maps every flow to API routes and backend side effects (§12.6, and every flow's Main Flow table).
The doc includes diagrams appropriate to each flow type (sequence per flow, one state diagram, one error-branch flowchart, one flow-to-data diagram).
The doc covers edge cases and failure recovery (§10, §12.5).
Every feature, minor action, and submodule capability is listed (§4, §12.1).
Every actor has allowed and forbidden behavior listed (§3) — including the actors that do not exist for this module (guest, worker/system), stated explicitly rather than omitted.
Every major and minor flow includes steps, branches, and diagrams (§5).
The one real lifecycle (isActive) has a transition table and state diagram; the absence of a lifecycle for the school profile is stated explicitly (§7).
Every flow links to the API and backend docs.
No claim in this document is unverified against the current source files cited in the backend and API docs.