REFERENCE¶
Read this at the start of any non-trivial task. It is the single-file map of the project: what exists, where it lives, and which invariants you must not break. It does not explain concepts — it points at the chapter that does.
Triage rule:
CLAUDE.mdtells you when to read what. This file tells you where everything is.docs/NN-*.mdtells you how a specific area works.
1. What this is¶
Multi-tenant Student Information System for K-12 schools. NestJS 11 monolith, Prisma ORM, PostgreSQL, JWT auth. Modules map 1:1 to future services. Beta target: small team, ship fast, preserve migration path to scale.
Full stack and philosophy: docs/01-architecture.md.
2. Mental model — the abstractions everything rotates around¶
You cannot reason about a task in this repo without these seven concepts. Each bullet is one sentence; follow the link for the real chapter.
- Tenant context — every row carries
tenantId; every query filters on it. Service-layer enforcement, RLS deferred.docs/02-multitenancy.md. - Academic year context — time-scoped data (enrollments, study plans) is additionally filtered by
academicYearId. Reads accept an override viaAcademicYearQueryDto; writes (POST/PATCH/DELETE/reorder) ignore any override and always target the tenant's ACTIVE year.docs/05-crud-patterns.md. - Entity-Scope-Action RBAC — permissions are
(entity, scope)for visibility and(entity, action)for operations. Scopes group fields; actions group operations;readis implicit and never an action.docs/04-rbac.md. BaseTenantedCrudService— generic abstract service used by students/teachers/staff. Subclasses provide entity key, Prisma delegate, and scope-field mappings. Everything else (create, findAll, findOne, update, remove, toScopedResponse, flattenDto, custom fields) is inherited. Canonical:src/common/services/base-tenanted-crud.service.ts.FieldFilterInterceptor— strips unauthorized fields from responses based on the caller's scopes. Runs last in the request pipeline; response DTOs are the contract it filters against. Canonical:src/permissions/interceptors/field-filter.interceptor.ts.AppException+ErrorCode— the only way to signal domain errors. Carries a machine-readable code preserved byAllExceptionsFilter. Params are typed per code at compile time.docs/06-error-handling.md.queries.tsconvention — every domain module ships a<domain>.queries.tswith include/select constants and named query functions. No repository classes, no inline Prisma calls in services beyond the base class.docs/05-crud-patterns.md.
3. Request lifecycle¶
Every authenticated endpoint passes through the same chain. @ProtectedResource() composes it.
HTTP request
│
├─ JwtAuthGuard validates token, attaches { userId, tenantId, roles }
├─ ScopeGuard reads @RequireScopes(); rejects 403 INSUFFICIENT_SCOPE
├─ ActionGuard reads @RequireAction(); rejects 403 ACTION_NOT_PERMITTED
├─ FieldWriteGuard blocks write payloads touching unauthorized fields
├─ RolesGuard enforces @RequireRoles() when present
│
├─ Controller method @TenantId(), @Query() AcademicYearQueryDto, @Body() DTO
│ │
│ └─ Service method (extends BaseTenantedCrudService)
│ │
│ └─ Prisma (via queries.ts include/select constants)
│
├─ FieldFilterInterceptor strips fields the caller's scopes cannot read
│
└─ AllExceptionsFilter converts AppException → JSON { code, message, data?, params? }
Canonical: src/common/decorators/protected-resource.decorator.ts, src/common/filters/all-exceptions.filter.ts.
4. Module map¶
Every module under src/ follows the same shape: <name>.module.ts, <name>.controller.ts, <name>.service.ts, <name>.queries.ts, <name>.swagger.ts, dto/, interfaces/, index.ts. Structure rules: docs/05-crud-patterns.md.
| Module | Responsibility | Notes |
|---|---|---|
auth/ |
Login (3-step state machine: credentials → tenant → profile), JWT issue/refresh, logout, /me, cookie delivery, activeProfile selection and switching |
Passport strategies in strategies/; cookie helper owns Set-Cookie semantics. docs/03-auth.md. |
profile/ |
GET /auth/profile — User identity joined with the caller's full Teacher/Staff/Student/Referent snapshots |
Owns the route at /auth/* to stay adjacent to /me, but lives in its own module to avoid an AuthModule ⇄ InvitationsModule cycle. docs/03-auth.md. |
permissions/ |
RBAC enforcement: guards, interceptors, @RequireScopes, @RequireAction, @RequireRoles |
The authoritative module for authorization. docs/04-rbac.md. |
prisma/ |
PrismaService wrapper, module export |
Do not import PrismaClient directly from generated/. |
common/ |
Shared services, decorators, DTOs, filters, exceptions, validators, constants | BaseTenantedCrudService and AppException live here. |
students/ |
Student CRUD with 5 scopes (anagraphic, contacts, documents, sensitive, enrollment) + import | Canonical reference for the CRUD pattern. |
teachers/ |
Teacher CRUD (5 scopes) + import | |
staff/ |
Staff CRUD (5 scopes) + import | |
users/ |
User read-only (profile + admin scopes) | Identity layer; user != person. |
referents/ |
Parent/guardian contact records + PATCH profile/link flags | Name fields nullable; admin-controlled canWrite on each StudentReferentLink gates referent writes on the linked student (enforced in StudentsService.updateForAccessContext). |
departments/ |
Department + nested Grade sub-resource | configuration scope. |
rooms/ |
Room CRUD, canteen lunch shifts | configuration scope. |
school/ |
School identity — singleton per tenant, single configuration scope |
GET /school + PATCH /school (admin write, all roles read). Field set: legalName, operationalName, schoolType (INTERNATIONAL/PARITARIA/NON_PARITARIA/STATE), taxId, structured address (country/city/street/streetNumber/postalCode), timezone, primaryLanguage, phone (E.164), email, optional website, optional logo (base64-encoded PNG inline on JSON body — service decodes + persists via FilesService.replaceFileSlot with FileUsage.LOGO). logoFileId is system-managed and rejected on direct writes; the response embeds a SignedFileUrlDto under configuration.logoFile. Wizard still writes via bulkSync; PATCH 404s if no row exists. |
academic-years/ |
Academic year + periods | Setup wizard writes the first year via bulkSync (lands ACTIVE). Admin endpoints: POST creates a DRAFT, PATCH edits a DRAFT (scalar fields + period array sync, missing ids deleted), and per-period CRUD under :id/periods — all reject non-DRAFT years with ACADEMIC_YEAR_NOT_EDITABLE. |
curriculum/ |
Curricula + study plans CRUD | Also consumed by setup wizard. |
custom-fields/ |
Custom field definitions CRUD + validation | Plugs into BaseTenantedCrudService via pickCustomFields. |
setup/ |
Tenant setup wizard: state machine + handler orchestration | docs/08-setup-wizard.md. |
mailer/ |
Generic outbound email facade (MailerPort) + active transport selected by MAIL_TRANSPORT env var (log default, resend wires src/mailer/resend/ submodule with Svix-signed POST /api/v1/webhooks/resend) + MemoryMailer test transport |
See docs/superpowers/specs/2026-04-23-resend-adapter-design.md. |
invitations/ |
Admin-side credential issuance: Invitation model + send/list/resend/reset endpoints; async webhook transitions to FAILED and COMPLAINED; invalidateByRecipient hook called from Teacher/Staff mutations |
See docs/superpowers/specs/2026-04-20-invitations-design.md. Iteration 2 shipped resend/reset + auto-invalidation on email change and hard delete (Teacher + Staff; Referent wiring deferred to iteration 2.5). Iteration 3 adds acceptance + delivery reports (COMPLAINED via Resend webhook). |
files/ |
Document storage for Student/Teacher/Staff/Referent. Two cardinalities behind one FileUsage enum: single-slot (PASSPORT, IDENTITY_CARD) lives on entity FKs; collection (PERSONAL, EDUCATIONAL) lives as File rows tagged with (ownerType, ownerId) (polymorphic, same pattern Invitation uses). For single-slot files the File row also carries documentNumber + expiryDate, set via multipart form fields on upload or via a dedicated PATCH /:id/documents/by-id/:fileId/metadata route. Synchronous multipart upload; reads return a pre-signed URL JSON envelope (SignedFileUrlDto) that the FE GETs directly from the bucket — TTL via S3_SIGNED_URL_TTL_SECONDS (default 15 min). Hard delete on replace / delete / entity hard-delete. S3-protocol storage (MinIO local, Railway bucket deployed). See chapter 05 §11. |
|
command-center/ |
Admin dashboard tabs: GET /command-center/completeness (admin + referent — data-completion view, scope-restricted missingFields per row, isMe flag for referent self) and GET /command-center/onboarding (admin-only — invitation lifecycle for Teacher/Staff/Referent with isOverdue overlay). Both anchored on the active year's gracePeriodEnding. |
See docs/superpowers/specs/2026-05-05-command-center-design.md. Reuses Invitation projection from invitations/ and the per-scope computeMissingFields engine. |
health/ |
GET /api/v1/health — public, no auth |
|
logger/ |
Pino + CLS request context | |
config/ |
Typed env loading |
5. Cross-cutting invariants — do not break¶
Each item is one line. Linked chapter has the full rationale and patterns.
Tenant isolation¶
- Every service method that reads or writes a tenanted table must filter by
tenantId.docs/02-multitenancy.md. - Tenant id comes from the request via
@TenantId(), never from the body.
RBAC¶
readis never an action. Visibility is governed by scopes +FieldFilterInterceptor. Actions arecreate,update,delete, and domain-specific verbs.- A route that writes to scoped fields must declare
@RequireAction(entity, action);FieldWriteGuardrejects payloads touching fields outside granted scopes. - New scopes require a
PermissionScope+ScopeFieldMappingseed entry, an array insrc/common/constants/scope-fields.ts, and a matching DTO underdto/scopes/.docs/04-rbac.md.
Data access¶
- No repository classes. Queries live in
<domain>.queries.tsas named functions or include/select constants. Services call them directly or through the base class. Prisma.XGetPayload<{ include: typeof K }>is reserved for contract boundaries (base serviceTRecordgeneric, cross-moduleinterfaces/payloads). Do not sprinkle it on intermediate query results — let inference work.docs/13-typing-conventions.md.- Build Prisma
datapayloads from optional DTO fields withpickDefined(src/common/utils/); forBaseTenantedCrudServicesubclasses use the inheritedflattenDto. Do not accumulate viaRecord<string, unknown>or hand-rolledif (x !== undefined)chains.docs/13-typing-conventions.md.
DTO contracts¶
- Scope sub-DTOs live in
dto/scopes/, one file per scope, withCreate*Dto/Update*Dto(viaPartialType) /*ResponseDto. - Response DTO fields must reference real DTO classes.
unknown[]and Swaggertype: 'object'on a response field are bugs. - Swagger decorators live in a sibling
<domain>.swagger.ts, not inline on the controller. Scalar autoinspects DTO types andclass-validatorconstraints —descriptionstrings that only restate the field name or a validator are noise. Seedocs/05-crud-patterns.md§5.6.
Errors¶
- Throw
AppException(code, message, status, { params, data }). Never throwHttpExceptiondirectly from domain code. - Adding an error code: extend
ErrorCodeinsrc/common/constants/error-codes.ts, add a params shape if needed, and add a Swagger example inerror-examples.ts.docs/06-error-handling.md.
Imports and cross-module boundaries¶
- Import a module from its folder barrel (
'../students'), never from internal files. - Do not call a foreign service directly — import the module in
AppModuleand expose functionality through its public DTO/interface contract. interfaces/holds Prisma payloads, lookup structures, and service contracts. DTOs stay indto/.
Migrations¶
- Before
npx prisma migrate dev: readdocs/12-migrations.md. Check for an uncommitted migration and fold if present; audit generated SQL against the hazard checklist. Never commit a migration without the safety audit.
Verification discipline¶
- Do not run
npm run build,npm test,npm run lint, or any verification command unless the user explicitly asks. User runs these before committing.
6. File index — "if you're touching X, open Y first"¶
Task-type → minimal file set. Open these before anything else; they give you the shape of the solution.
| Task | Files to open | Chapter |
|---|---|---|
| Add a field to an existing scope | dto/scopes/<scope>.dto.ts, src/common/constants/scope-fields.ts, <domain>.service.ts (getQueryInclude if FK), <domain>.service.spec.ts |
11 §1 |
| Add a new scope to an entity | dto/scopes/, dto/create-<domain>.dto.ts, dto/update-<domain>.dto.ts, dto/<domain>-response.dto.ts, scope-fields.ts, <domain>.service.ts (getScopeFieldMappings), prisma/seed/rbac-catalogue.ts, prisma/seed/roles.ts |
11 §2, 04 |
| Add a new domain entity | prisma/schema.prisma, new src/<domain>/ folder (module/controller/service/queries/swagger/dto/interfaces/index), src/common/constants/entity-keys.ts, src/app.module.ts, prisma/seed/rbac-catalogue.ts |
11 §3, 05 |
| Add an endpoint to an existing module | <domain>.controller.ts, <domain>.swagger.ts, <domain>.service.ts, <domain>.queries.ts if new query |
05 |
| Add a list endpoint with filters/sort | dto/list-<domain>-query.dto.ts (extends PaginatedListQueryDto or BasicPaginatedListQueryDto), <domain>.queries.ts (buildListArgsForX), <domain>.controller.ts, <domain>.service.ts, src/common/utils/transform-to-array.ts for multi-value |
05 §List endpoint query DTOs |
| Upload / mint signed URL / list / edit metadata on a file | src/files/files.service.ts (helpers: replaceFileSlot / appendCollectionFile / getSignedUrlByOwner / deleteFileById / listFilesForEntity / cleanupCollectionForOwner / updateFileMetadataById), src/files/storage/file-storage.port.ts (getSignedReadUrl), src/<entity>/<entity>.service.ts (the uploadDocument / getDocumentUrl / deleteDocument / *ById / listDocuments / updateDocumentMetadata methods), src/<entity>/<entity>.controller.ts, prisma/schema.prisma (File model with documentNumber + expiryDate for single-slot + per-entity FK + ownerType/ownerId) |
05 §11 |
| Add an error code | src/common/constants/error-codes.ts, error-examples.ts, optional params shape in error-codes.ts, throw site |
06 |
| Touch auth flow | src/auth/auth.service.ts, src/auth/cookie-helper.service.ts, src/auth/strategies/, src/auth/guards/ |
03 |
| Touch active profile / login state machine | src/auth/auth.service.ts, src/auth/auth.controller.ts, src/auth/dto/select-profile.dto.ts, src/auth/dto/switch-profile.dto.ts, src/auth/dto/profile-selection-response.dto.ts, src/common/constants/person-profiles.ts |
03, 04 |
| Touch the role-narrowing rule | src/common/utils/narrow-roles.ts, src/common/constants/person-profiles.ts |
04, 03 |
| Touch RBAC enforcement | src/permissions/guards/, src/permissions/interceptors/field-filter.interceptor.ts, src/permissions/decorators/, src/permissions/permissions.service.ts |
04 |
| Add a field to the completion-required list | src/common/constants/completion-required-fields.ts |
04 §Profile completeness |
| Add a setup step | src/setup/ (state machine + handler), src/setup/handlers/, relevant domain service |
08 |
| Add a custom field type | src/custom-fields/ |
05 |
| Build an import flow | src/common/services/import-pipeline.ts, <domain>.service.ts (getImportPreviewConfig), <domain> import spec |
07 |
| Run or write a migration | prisma/schema.prisma, prisma/migrations/ |
12 required read before migrating |
| Write a test | <target>.spec.ts, src/common/testing/ helpers |
09 |
| Deploy or CI change | .github/, Railway config, env handling in src/config/ |
10 |
7. Glossary — project-specific terms¶
Short definitions. Where a term maps to a chapter, that chapter is authoritative.
- Tenant — a school instance. Every business row has
tenantId. Not a user. - Entity — a protected resource key (
STUDENTS,TEACHERS, ...). Defined insrc/common/constants/entity-keys.ts. - Scope — a named subset of fields on an entity (e.g.
anagraphic,sensitive). Grants visibility and write access to those fields. - Action — a named operation on an entity (e.g.
create,delete,import). Neverread. - Anagraphic — basic person data (name, DoB, ID). Italian convention carried through the domain.
- Sensitive scope — fields requiring elevated access (medical, legal, disciplinary).
- Person profile / Role distinction — a profile (
PersonProfileKey) identifies which person-entity row a user is acting as in a session (teacher,staff,referent,student). A role is a named RBAC key (e.g.CLASS_TEACHER,HEAD_OF_YEAR) granting specific permissions. Profiles and roles are orthogonal: employee profiles (teacher/staff) carry the full role set; non-RBAC profiles (referent/student) receive only[activeProfile]as their effective roles for that session. Seesrc/common/utils/narrow-roles.tsanddocs/04-rbac.md. - Referent — parent/guardian attached to a student. Separate entity; names nullable, populated post-login. Intentionally not academic-year-scoped (flat per
(tenantId, email)); year context comes viaStudentReferentLink → Student.academicYearId. Seedocs/01-architecture.md§6. - Academic year — top-level time partition; most student/teacher/staff data is year-scoped (Referent is the notable exception — see above).
- Active profile — the person-profile key (
teacher,staff,referent,student) that a user has selected for the current session. Stored asactiveProfileon the JWT payload and on theRefreshTokenrow; consumed bynarrowRolesForActiveProfileto restrictroles[]to only the permissions relevant to that profile. Set at login (or viaPOST /auth/switch-profile) and immutable until explicitly switched or the session ends. Seedocs/03-auth.md§9. - Study plan — a curriculum assigned to a specific class/grade for a given academic year.
- Setup wizard — one-shot flow that initializes a tenant: school identity → academic year → curricula → users. State machine under
src/setup/. - Scope field mapping — runtime map from scope name → fields present on the Prisma record. Used by
FieldFilterInterceptorandFieldWriteGuard. - Canonical example — a file path called out in docs or this reference as the authoritative implementation of a pattern. Read it; do not duplicate it.
- Invitation — credential-issuance state for a Teacher/Staff/Referent. One row per invitable role entity;
NOT_SENTis absence-of-row at the projection layer, not a stored enum. See the invitations design doc.
8. Where to go deeper¶
Open the specific chapter only when your task lands in its area. The table below is the same as docs/README.md but re-sorted by task frequency.
| You are about to... | Chapter |
|---|---|
| Build or modify any CRUD feature | 05 - CRUD Patterns |
| Add or change permissions | 04 - Permissions |
| Follow a recipe step-by-step | 11 - Workflows |
Type a Prisma result, build a data payload, or fight any |
13 - Typing Conventions |
| Throw or handle an error | 06 - Error Handling |
| Run a migration | 12 - Migrations |
| Write tests | 09 - Testing |
| Touch auth | 03 - Authentication |
| Touch multitenancy | 02 - Multitenancy |
| Build an import flow | 07 - Import Pipeline |
| Add a setup step | 08 - Setup Wizard |
| Understand system design | 01 - Architecture |
| Set up locally / onboard | 00 - Getting Started |
| Work on infra/CI/deploy | 10 - Infrastructure |
9. Meta — how to keep this file useful¶
- Update this file whenever a new module is added, a cross-cutting invariant changes, or a file path in section 6 moves. It is a map; stale maps mislead.
- Never copy prose from a chapter into this file. If a section needs more than two sentences of explanation, it belongs in a chapter, and this file points to it.
- Keep under ~500 lines. If it grows past that, the entries are too detailed — compress them back into pointers.