Error Handling¶
The backend returns structured error responses with a stable code field and a pre-rendered messages object localized in every supported language (en_US + it_IT). The frontend picks its language from messages — it no longer maps codes to translations itself. Message text is owned by the i18n catalog (src/common/i18n/error-messages.catalog.ts); see the backend-error-i18n spec under docs/superpowers/specs/ and chapter references in src/common/i18n/.
1. AppException — The Only Throw Pattern¶
All errors are thrown via AppException. Never throw NestJS HttpException subclasses (NotFoundException, ConflictException, etc.) — use AppException with the appropriate ErrorCode instead.
AppException carries no message text. The user-facing string lives in the i18n catalog, keyed by code (+ any params.reason / params.kind discriminator) and rendered in every language by AllExceptionsFilter. Throw sites pass only code, an optional statusCode (defaults to 500), and optional structured params / data.
import { AppException, ErrorCode } from '../common';
import { HttpStatus } from '@nestjs/common';
// Simple — code + status
throw new AppException(ErrorCode.AUTH_TOKEN_INVALID, HttpStatus.UNAUTHORIZED);
// With params — structural locators + interpolation values (NOT translated text)
throw new AppException(ErrorCode.NOT_FOUND, HttpStatus.NOT_FOUND, {
params: { entity: 'student' },
});
// With data — structured error payload (import pipeline, validation)
throw new AppException(ErrorCode.IMPORT_VALIDATION_FAILED, HttpStatus.UNPROCESSABLE_ENTITY, {
data: { errors },
});
Rules:
- Always use AppException — never throw new NotFoundException() etc.
- Never catch exceptions just to re-throw — let AllExceptionsFilter handle formatting.
- Never pass message text at the throw site — add/adjust it in the i18n catalog, keyed by code (+ discriminator). A new code without a catalog entry fails assertCatalogCoverage() at boot.
- New codes must be added to the ErrorCode enum (src/common/constants/error-codes.ts) and the catalog (en_US + it_IT), plus DISCRIMINATOR_COVERAGE if the code carries a reason/kind.
- Prefer structured params (ids, field paths, counts) over prose — the catalog interpolates them; the message stays generic.
- Use UPPER_SNAKE_CASE for all error codes.
2. Response Shape¶
Every error response carries these base fields:
{
"statusCode": 404,
"code": "NOT_FOUND",
"messages": { "en_US": "Record not found", "it_IT": "Elemento non trovato" },
"params": { "entity": "student" },
"timestamp": "2026-03-10T12:00:00.000Z",
"path": "/api/v1/students/abc-123"
}
messages is the localized object the frontend renders — read messages[lang]. The legacy English message field has been removed (it was a pure mirror of messages.en_US). params is still emitted but deprecated: it is no longer needed for text (that lives in messages), yet it still carries structural detail (discriminators like reason/kind, ids, counts) that the frontend may branch on — it is removed in a later phase once the frontend has migrated off it and that structure is relocated to data. Some error codes add a top-level field locator or data — see Typed Swagger Schemas below.
| Field | Description |
|---|---|
statusCode |
HTTP status code |
code |
Stable, machine-readable string. Frontend may branch on it |
messages |
Localized message, one string per supported language (en_US, it_IT) — pre-rendered + interpolated server-side |
field |
Optional body-relative dotted path of the offending input (Cat. 1 locator) |
params |
Optional typed object. Deprecated for text (superseded by messages); still emitted because it also carries structural detail (discriminators, ids, counts). Removed in a later phase once the FE migrates off it |
data |
Optional typed payload — structured collections (validation/import/violation leaves, each carrying its own messages) |
timestamp |
ISO 8601 timestamp |
path |
Request URL path |
Messages are self-contained¶
messages[lang] is fully rendered server-side — it already contains the counts, names, dates, and the specific failing rule that describe the error (e.g. "This room type is used by 3 rooms and cannot be deleted"). The frontend renders messages[lang] verbatim: no per-code logic, no template table, no params lookup. code/field/data remain as optional behavior hooks (branch on code for an auth redirect, focus field, highlight data rows) — but nothing about the displayed text depends on them.
Which params a message must surface is declared in ERROR_TEXT_PARAMS (src/common/constants/error-codes.ts), keyed by full catalog key. error-messages.drift.spec.ts enforces a bijection and fails the build if:
- a declared text param is not interpolated in both languages (completeness), or
- a template uses an undeclared {{placeholder}} (no orphans).
Raw ids and collections are behavior-only — they stay in data/field, never woven into text. Discriminated codes (CODE.<reason|kind>) render a specific sentence per branch. Collection codes carry a per-leaf messages, wired via the COLLECTION_ENRICHMENTS registry in render-error-messages.ts (currently VALIDATION_FAILED, IMPORT_VALIDATION_FAILED, SELECTION_VALIDATION_FAILED, SELECTION_WINDOW_PREREQ_FAILED).
Deferred:
paramsand leafrule/reasonare still emitted until the frontend migrates off them (dropped in a later phase). Enum-valued interpolations (weekday, status, step, target) currently render the raw value — a label pass is later polish.ROLLOVER_INVALID_STUDENT_IDSstays generic (its discriminator subkeys are already explanatory).
2a. The error-locating contract (4-category taxonomy)¶
Every error declares which of four categories it belongs to. This is the
once-and-for-all contract the frontend codes against — not "every error has a
field", because some errors are about an absence (the offending thing
isn't in the payload) or a whole-request precondition.
| Category | field |
Extra | Examples |
|---|---|---|---|
| 1. Located | exact body-relative dotted path, numeric array indices (same format as flattenValidationErrors) |
— | VALIDATION_FAILED, all SETUP_VALIDATION_FAILED element errors, CURRICULUM_HOURS_*, custom-field value errors, duplicate CONFLICT, in-tx curriculum checks |
| 2. Container + identifiers | path to the nearest containing element (or a collection name) | a typed data/param naming the absent/uncovered items |
missing_departments (data.missing), grades_not_covered (data.uncoveredGradeIds), SELECTION_WINDOW_PREREQ_FAILED (params.gaps) |
| 3. Request-level | absent | — | missing_year, server-precondition 400s |
| 4. Non-locatable / entity-state | absent | typed entity params | NOT_FOUND, permission/auth, ACADEMIC_YEAR_ARCHIVED, *_IN_USE deletion conflicts |
field is a path into this request's body, relative to its root. The same
business rule therefore yields a different root per endpoint:
- Bulk / setup curriculum (
POST /configure/setupcurriculum step,bulkSync):departments.2.curricula.0.grid.optionBlocks.0 - Single-resource (
POST/PATCH /curricula/:id):optionBlocks.0(families are flat at the body root — there is nodepartments/curricula/gridwrapper) - Single
POST /rooms:lunchShifts.0; setup rooms step:rooms.3.lunchShifts.0
Segments are always the literal request-DTO keys. The format is produced by
one helper — joinPath(prefix, ...segments) in src/common/utils/join-path.ts
— so it never drifts; src/common/utils/error-field-path.drift.spec.ts proves
every Category-1 path resolves against a representative payload.
Residual: the P2002 belt-and-suspenders catch in the curriculum/rooms bulk sync emits a collection-level
field(curricula/rooms), not an element path — the failing item isn't identifiable in the catch, and real duplicates are already caught pre-transaction with a full path.
3. Error Code Registry¶
Canonical source:
src/common/constants/error-codes.ts—ErrorCodeenum +ErrorParamsMap(typedparamsshape per code). Subsections below cover the well-trodden codes; per-domain codes for Homerooms / Subject Groups / Timetable Templates / Evaluation Scales / Curriculum Selection / Invitations / Files / Referents / Rollover are documented inline in their chapters and the canonical source. TheErrorParamsMapinterface ties every code to its strongly-typedparamsshape — theAppExceptionconstructor refuses to compile if a call site sends the wrong shape.
Generic Error Codes¶
| Code | Status | Params | Description |
|---|---|---|---|
INTERNAL_ERROR |
500 | — | Catch-all for unhandled server errors. Real error logged server-side, never leaked. |
VALIDATION_FAILED |
400 | — | Class-validator failures or custom field validation. See Validation Errors below. |
RATE_LIMITED |
429 | — | Rate limit exceeded. Global: 10 req/60s. Login: 5 req/60s. |
BAD_REQUEST / UNAUTHORIZED / FORBIDDEN |
400 / 401 / 403 | — | Fallback codes mapped by AllExceptionsFilter for plain NestJS BadRequestException / UnauthorizedException / ForbiddenException thrown outside the AppException discipline (typically by third-party middleware). Domain code should always throw a specific AppException. |
Authentication Error Codes¶
| Code | Status | Params | When |
|---|---|---|---|
INVALID_CREDENTIALS |
401 | — | Email exists but password does not match. |
AUTH_TOKEN_INVALID |
401 | — | JWT malformed or revoked. Refresh token invalid. User record not found. |
AUTH_TOKEN_EXPIRED |
401 | — | JWT or refresh token has passed its TTL. Distinct from AUTH_TOKEN_INVALID so the frontend can attempt a silent refresh before forcing re-login. |
AUTH_TOKEN_MISSING |
401 | — | No access token cookie or Authorization header on a protected route. |
AUTH_TOKEN_REUSE |
401 | — | Refresh token replay detected — entire token family revoked. Frontend should force re-login. |
AUTH_REFRESH_MISSING |
401 | — | No refresh token cookie in request. |
AUTH_TENANT_INVALID |
401 | — | Invalid tenant selection during multi-tenant login. |
CSRF_ORIGIN_REJECTED |
403 | — | Cookie-bearing request from a disallowed Origin / Referer (CSRF defense in depth). |
ACTIVE_PROFILE_NOT_AVAILABLE |
400 / 401 | — | The active profile selected/switched to is not available for this user. 400 on select-profile / switch-profile with a profile the user doesn't hold; 401 on refresh when the stored profile is missing from the user's current DB rows, or when the refresh-token row pre-dates the activeProfile column (legacy NULL value). |
TENANT_SUSPENDED |
403 | — | Tenant is not active (suspended or trial-expired). |
USER_DEACTIVATED |
403 | — | User account is deactivated. |
Permission Error Codes¶
| Code | Status | Params | When |
|---|---|---|---|
INSUFFICIENT_SCOPE |
403 | — | User lacks the required scope+action for the route (ScopeGuard). |
ACTION_NOT_PERMITTED |
403 | — | User does not have the required action permission (ActionGuard). |
NO_AUTHENTICATED_USER |
403 | — | Permission guard ran without authenticated user. Configuration error. |
FORBIDDEN_FIELDS |
403 | — | Write request contains scope groups outside the user's writable scopes (FieldWriteGuard). |
Database Error Codes¶
| Code | Status | Params | When |
|---|---|---|---|
NOT_FOUND |
404 | { entity } |
Record not found. entity is the lowercased model/entity name (e.g. "student", "teacher"). |
CONFLICT |
409 | { entity } |
Unique constraint violation. entity from Prisma model name when available. |
FOREIGN_KEY_VIOLATION |
400 | — | Foreign key constraint failed. |
NULL_CONSTRAINT_VIOLATION |
400 | — | A required (non-nullable) field received null. |
REQUIRED_RELATION_VIOLATION |
400 | — | A required relation is missing. |
DATABASE_TIMEOUT |
503 | — | Database connection pool timeout. Clients should retry. |
SCHEMA_OUT_OF_SYNC |
500 | — | Table or column missing (Prisma P2021/P2022). Run pending migrations. |
SCHEMA_TYPE_MISMATCH |
500 | — | Stored value is incompatible with the column type (Prisma P2023). Indicates schema/data drift, typically from a manual DB edit. |
DATABASE_ERROR |
400 | — | Unmapped Prisma error code. |
Academic Year Error Codes¶
| Code | Status | Params | When |
|---|---|---|---|
ACADEMIC_YEAR_NOT_FOUND |
404 | { entity?, id? } |
Provided academicYearId not found or doesn't belong to the tenant (or is DELETED). |
NO_ACTIVE_ACADEMIC_YEAR |
409 | — | No ACTIVE academic year exists for the tenant. Tenant hasn't completed setup. |
ACADEMIC_YEAR_ARCHIVED |
409 | — | Mutation attempted on a record belonging to an archived academic year. Create, update, delete, and import are blocked. |
Curriculum Selection Window Error Codes¶
| Code | Status | Params | When |
|---|---|---|---|
SELECTION_WINDOW_ALREADY_OPEN |
409 | — | A CurriculumSelectionWindow row already exists for the active academic year. |
SELECTION_WINDOW_NOT_FOUND |
404 | — | No window row exists for the active academic year. |
SELECTION_WINDOW_PREREQ_FAILED |
422 | { gaps: { departmentId, departmentName, gradeId, gradeName, activeStudentCount }[] } |
Tried to open a window while one or more (dept, grade) pairs with active students lacks a READY curriculum. |
SELECTION_WINDOW_INVALID_DATES |
422 | { reason: 'start_in_past' \| 'start_after_grace_period' \| 'grace_period_missing' \| 'grace_period_passed', startDate?, gracePeriodEnding? } |
Date validation against AcademicYear.gracePeriodEnding failed (end date is computed live, never accepted on input). |
SELECTION_WINDOW_CLOSED |
409 | — | Mutation attempted on a window whose live status is CLOSED (past gracePeriodEnding). |
Setup Wizard Error Codes¶
| Code | Status | Params | When |
|---|---|---|---|
SETUP_STEP_MISMATCH |
409 | { expected, received } |
Frontend's currentStep doesn't match backend state. User should reload. |
SETUP_INVALID_NAVIGATION |
400 | — | Cannot navigate to target step (only backward, same, or next allowed). |
SETUP_DATA_REQUIRED |
400 | — | Data is required for forward navigation. |
SETUP_STEP_INCOMPLETE |
400 | { step } |
Step not complete, can't advance. Completeness reasons are surfaced at save time as SETUP_VALIDATION_FAILED (e.g. curriculum coverage → grades_not_covered); this code is the blanket advance-gate backstop. |
SETUP_VALIDATION_FAILED |
400 | { reason, field? } |
Step-specific business rule validation failed. See reason values below. |
SETUP_VALIDATION_FAILED reason values¶
Frontend uses compound key SETUP_VALIDATION_FAILED.{reason} for translation.
field is a body-relative path (§2a, Category 1) — the column below shows the leaf segment; the full value is indexed from the request root, e.g. departments.0.periods.1 (bulk) or periods.1 (single-resource). Two reasons are Category 2 (the offending item is absent, so they carry identifiers in data instead of an element path): missing_departments → data.missing: string[]; grades_not_covered → data.uncoveredGradeIds: string[]. missing_year is Category 3 (no field).
reason |
When | Example field (leaf) |
|---|---|---|
date_range_invalid |
endDate before startDate (periods — single-day allowed); endDate not after startDate (department calendar) | calendarEndDate, periods |
period_out_of_bounds |
period outside year range | periods |
overlap |
overlapping periods of same type | periods |
duplicate_name |
non-unique name (case-insensitive) | departments, grades, periods, roomTypes |
ordinal_gap |
non-sequential ordinal positions | departments, grades |
missing_departments |
not all tenant depts represented | departments |
missing_year |
no academic year found | — |
invalid_department |
department not owned by tenant | departments, departmentId |
invalid_max_capacity |
room maxCapacity not a whole number ≥ 1 | maximumCapacity |
unknown_room_type |
room references a room type not in submission | roomTypeName |
canteen_missing_shifts |
canteen room has no lunch shifts | lunchShifts |
non_canteen_has_shifts |
non-canteen room has lunch shifts | lunchShifts |
shift_invalid_range |
lunch shift endTime <= startTime | lunchShifts |
shift_overlap |
overlapping lunch shifts in same room | lunchShifts |
unresolved_room_type |
room type not found after sync | roomTypeName |
duplicate |
unique constraint violation (belt-and-suspenders) | rooms, curricula, subject_groups |
invalid_grade |
grade does not belong to specified department | gradeIds |
invalid_curriculum |
curriculum not owned by tenant | curriculumId |
grade_not_in_curriculum |
grade not linked to curriculum via CurriculumGrade | gradeId |
grades_not_covered |
one or more grades in the year have no subject offering hours (curriculum step can't complete) | curricula |
missing_hours |
neither yearLessons nor weeklyLessons provided | subjects, option blocks |
min_exceeds_max |
option block minSelections > maxSelections | optionBlocks |
Import Validation Error Codes¶
Top-level envelope uses IMPORT_VALIDATION_FAILED (422). Each error inside data.errors[] carries an ImportErrorCode for i18n.
{
"statusCode": 422,
"code": "IMPORT_VALIDATION_FAILED",
"messages": { "en_US": "Import validation failed", "it_IT": "Importazione non riuscita" },
"data": {
"errors": [
{ "code": "FIELD_INVALID", "column": "gender", "rows": "2-5, 8", "allowedValues": ["MALE", "FEMALE"], "messages": { "en_US": "The value is not valid", "it_IT": "Il valore non è valido" } },
{ "code": "HEADERS_MISSING", "column": "headers", "rows": "1", "allowedValues": ["first_name", "last_name"], "messages": { "en_US": "Expected columns are missing", "it_IT": "Mancano colonne attese" } }
]
}
}
Each leaf carries a pre-rendered messages object; code (the discriminator) and the locators column/rows/allowedValues are retained. The legacy leaf params (interpolation-only) is no longer emitted.
| Code | Description |
|---|---|
HEADERS_MISSING |
Expected columns not found. params lists missing names. |
FIELD_REQUIRED |
A required field is empty. |
FIELD_MAX_LENGTH |
Value exceeds maximum length. |
FIELD_INVALID |
Value doesn't match allowed options. allowedValues lists valid options. |
Validation Errors¶
Produced by the global ValidationPipe (class-validator). Returns structured field-level errors:
{
"statusCode": 400,
"code": "VALIDATION_FAILED",
"messages": { "en_US": "Validation failed", "it_IT": "Validazione fallita" },
"data": {
"errors": [
{ "field": "email", "rule": "isEmail", "messages": { "en_US": "Must be a valid email address", "it_IT": "Deve essere un indirizzo email valido" } },
{ "field": "identity.firstName", "rule": "maxLength", "messages": { "en_US": "Must be at most 100 characters", "it_IT": "Deve contenere al massimo 100 caratteri" } },
{ "field": "identity.gender", "rule": "isEnum", "allowedValues": ["MALE", "FEMALE", "OTHER", "PREFER_NOT_TO_SAY"], "messages": { "en_US": "Must be one of the allowed values", "it_IT": "Deve essere uno dei valori ammessi" } }
]
}
}
Each error has:
- messages — pre-rendered { en_US, it_IT } for this field (interpolated server-side from validation.<rule>)
- field — dot-notation path to the field
- rule — class-validator constraint name (e.g. isEmail, isNotEmpty, maxLength, minLength, isEnum). Deprecated (superseded by messages); retained as a discriminator the frontend may still branch on, removed in a later phase
- allowedValues — optional list of valid values, present only when rule is isEnum. Mirrors the import pipeline's allowedValues so the frontend can render the same "pick one of …" hint on JSON writes and file imports. Parsed from the class-validator message in flatten-validation-errors.ts.
The legacy leaf params (interpolation-only, e.g. { max: "100" }) is no longer emitted — its value is baked into messages.
Frontend maps rule to a translated message: validation.isEmail → "Must be a valid email address".
Custom Fields Error Codes¶
Custom field validation errors use VALIDATION_FAILED with English messages. Custom field CRUD errors:
| Code | Status | When |
|---|---|---|
VALIDATION_FAILED |
400 | Wrong type, unknown field key, missing required field, invalid SELECT value, SELECT missing options, duplicate options. |
CONFLICT |
409 | Duplicate fieldKey within same tenant+entity. |
FORBIDDEN_FIELDS |
403 | User lacks WRITE access on target entity+scope. |
NOT_FOUND |
404 | Definition not found, or scope not found in permission catalogue. |
4. Typed Swagger Schemas¶
Swagger documents 7 distinct error response shapes. Each is a DTO class in src/common/dto/ — the base carries no params/data, subclasses add strongly typed fields.
| # | DTO | Codes | params type |
data type |
|---|---|---|---|---|
| 1 | ErrorResponseDto (base) |
Any ErrorCode value not covered by a subclass below (auth, rate-limiting, scope/action denials, window status, etc.) |
— | — |
| 2 | EntityErrorResponseDto |
NOT_FOUND, CONFLICT | EntityErrorParams { entity } |
— |
| 3 | StepMismatchErrorResponseDto |
SETUP_STEP_MISMATCH | StepMismatchErrorParams { expected, received } |
— |
| 4 | StepIncompleteErrorResponseDto |
SETUP_STEP_INCOMPLETE | StepIncompleteErrorParams { step } |
— |
| 5 | SetupValidationErrorResponseDto |
SETUP_VALIDATION_FAILED | SetupValidationErrorParams { reason, field? } |
— |
| 6 | ValidationErrorResponseDto |
VALIDATION_FAILED | — | ValidationErrorData { errors: ValidationFieldErrorDto[] } |
| 7 | ImportErrorResponseDto |
IMPORT_VALIDATION_FAILED | — | ImportValidationData { errors: ImportValidationErrorDto[] } |
Source files:
- Base + params: src/common/dto/error-response.dto.ts, src/common/dto/error-params.dto.ts
- Subclasses: src/common/dto/typed-error-responses.dto.ts
- Validation data: src/common/dto/validation-error-response.dto.ts, src/common/dto/import-validation-error.dto.ts
Runtime note: The AllExceptionsFilter continues to produce plain JSON objects. The typed DTOs are purely Swagger schema documentation — the filter's output already matches the DTO structures.
5. AppException Constructor¶
class AppException<TData = undefined, TCode extends ErrorCode = ErrorCode> extends HttpException {
constructor(
code: TCode,
statusCode?: HttpStatus, // default: 500
options?: { data?: TData; params?: ParamsFor<TCode> }, // params typed per code
);
}
No message parameter — text is resolved from the i18n catalog by AllExceptionsFilter. params is typed per code via ParamsFor<TCode> (a shape mismatch is a compile error; codes with no params entry reject any params).
Source: src/common/exceptions/app.exception.ts; catalog + renderer under src/common/i18n/.