Skip to content

CRUD Patterns

Dense recipe reference for building domain modules. Every pattern here is extracted from working code — canonical examples are called out inline. For permission wiring details see chapter 04. For error handling see chapter 06.


1. Module File Structure

Each domain lives in one folder under src/. The folder boundary is the module boundary. The tree below is the baseline shape for a single-resource module; once a module accumulates multiple sub-resources it folds them into subfolders — see Foldering large modules.

src/<domain>/
├── <domain>.module.ts
├── <domain>.controller.ts
├── <domain>.swagger.ts              # Swagger decorator compositions (sibling-only, not exported)
├── <domain>.service.ts
├── <domain>.service.spec.ts
├── <domain>.queries.ts              # Include/select constants + query functions (sibling-only)
├── dto/
│   ├── scopes/                      # Scope sub-DTOs (one file per scope)
│   ├── create-<domain>.dto.ts       # Composes scope sub-DTOs with @ValidateNested
│   ├── update-<domain>.dto.ts       # PartialType of scope sub-DTOs
│   └── <domain>-response.dto.ts     # Scope-grouped response shape
├── interfaces/                      # Domain interfaces: lookup structures, service contracts
│   ├── <domain>.interfaces.ts       # Named exports — no default exports
│   └── index.ts                     # Barrel for interfaces/
└── index.ts                         # Barrel export: module, service, DTOs

Domain module rules

  • One module per domain — encapsulates controller, service, DTOs, interfaces, tests. Maps 1:1 to a future microservice.
  • Barrel exports — every module has index.ts. Import from the folder ('../students'), never from internal files ('../students/students.service').
  • Do not import services across modules — export the service from the module, then import the module in AppModule. Never call a foreign service directly.
  • Keep AppModule lean — only module imports, no business logic.
  • DTOs in dto/ subfolder — scope sub-DTOs in dto/scopes/. Use PartialType() / OmitType() from @nestjs/mapped-types for Update DTOs.
  • Interfaces in interfaces/ subfolder — types that never leave the backend. Each interfaces/ folder has its own index.ts barrel.

Foldering large modules

The flat tree above is correct while a module is single-resource and small. Fold into sub-resource folders once a module has ≥2 distinct sub-resources / controllers or crosses ~15 files — past that the flat folder interleaves unrelated sub-features alphabetically and the sub-resource boundary is lost. Below the threshold (rooms, school, staff, teachers) stays flat; foldering them would be churn for nothing.

Foldering rule:

  • The dominant resource keeps its layer files at module root (<domain>.controller.ts, .service.ts, .queries.ts, .policy.ts, .swagger.ts, specs).
  • Every other sub-resource gets its own folder, keeping the suffix convention (grades/grades.controller.ts — never grades/controller.ts; the suffix keeps editor tabs legible and matches Nest CLI output).
  • A module with no dominant resource (co-equal peers) folds all of them; the root then holds only <domain>.module.ts, index.ts, shared/, dto/.
src/departments/
├── departments.module.ts          # DI boundary — always at root
├── departments.controller.ts      # dominant resource stays flat
├── departments.service.ts
├── departments.queries.ts
├── departments.policy.ts
├── departments.swagger.ts
├── *.spec.ts
├── index.ts                       # the only public barrel
├── grades/                        # satellite sub-resource
│   ├── grades.controller.ts
│   ├── grades.service.ts
│   ├── grades.policy.ts
│   └── *.spec.ts
├── department-periods/
│   └── …
├── shared/                        # cross-sub-resource helpers (e.g. reorder-in-scope.ts)
└── dto/                           # dominant + shared DTOs

command-center/ has no dominant resource, so all peers fold and the root is wiring-only:

src/command-center/
├── command-center.module.ts
├── index.ts
├── completeness/   onboarding/   curriculum-selections/   overview/
├── shared/
└── dto/

Placement within a foldered module:

  • DTOs / interfaces — sub-resource-specific ones co-locate in the sub-resource folder (grades/dto/); shared + dominant-resource DTOs stay in module-root dto/ / interfaces/. (dto/ is already grouped, so foldering it is polish, not required.)
  • Barrels — only the module-root index.ts is the public surface; the "import from the folder, never internal files" rule above is unchanged. Sub-resource folders get no index.ts unless a sibling module consumes them directly; within a module, sub-folders import each other by relative path.
  • Cross-sub-resource helpers go in shared/ (precedent: command-center/shared/).

Foldering is filesystem organization only — it does not change the NestJS module / DI boundary (one .module.ts per module), RBAC wiring (EntityKey / scopes / policies), swagger tags (sub-resources still fold into the parent tag — see §5), the queries-per-module rule, or the base-service contract. No runtime behavior moves when a module is foldered.

Apply incrementally — fold a qualifying module when you next meaningfully touch it, not in a repo-wide sweep. Convention adopted 2026-06-08; the trees above are the target shape and are not yet applied to any module, so don't treat src/departments/ or src/command-center/ as folded examples until the first one lands.

interfaces/ convention

What goes in interfaces/: - Prisma payload typesPrisma.XGetPayload<{ include: typeof constant }> (e.g. StudentWithRelations, DepartmentWithCounts) - Lookup interfaces — data structures for import or business logic (e.g. ImportLookupData) - Service contracts — interfaces for cross-module consumption - Union/utility types — anything the module or consumers need that is not a request/response DTO

What does NOT go in interfaces/: - DTOs (request/response shapes) — those live in dto/ - Types used exclusively inside a single function — keep those local

Barrel rules: - Module-internal types are imported from ./interfaces (relative to the service), not from the module barrel - Types needed by sibling modules are re-exported from the module's top-level index.ts

Canonical examples: src/auth/interfaces/, src/common/interfaces/, src/permissions/interfaces/


2. Controller Recipe

@ApiStudentsController()        // class-level Swagger (tag, error models)
@Controller('students')
@ProtectedResource()            // composes JwtAuthGuard + ScopeGuard + ActionGuard +
                                // FieldWriteGuard + FieldFilterInterceptor + ApiBearerAuth
export class StudentsController {
  constructor(private readonly studentsService: StudentsService) {}

  /** Create a student */
  @Post()
  @RequireAction(EntityKey.STUDENTS, 'create')
  @ApiCreateStudent()
  async create(
    @Body() dto: CreateStudentDto,
    @TenantId() tenantId: string,
  ): Promise<StudentResponseDto> {
    return this.studentsService.create(tenantId, dto);
  }

  /** List students */
  @Get()
  @RequireScopes(EntityKey.STUDENTS, 'read')
  @AppliesPolicy(StudentsPolicy)        // record-level narrowing + route admission
  @ApiListStudents()
  async findAll(
    @Query() query: ListStudentsQueryDto,
    @AccessContext() ctx: RecordAccessContext,
  ) {
    return this.studentsService.findAllForAccessContext(ctx, query);
  }

  /** Get a student by ID */
  @Get(':id')
  @RequireScopes(EntityKey.STUDENTS, 'read')
  @AppliesPolicy(StudentsPolicy)
  @ApiGetStudent()
  async findOne(
    @Param('id', ParseUUIDPipe) id: string,
    @AccessContext() ctx: RecordAccessContext,
  ) {
    return this.studentsService.findOneForAccessContext(id, ctx);
  }

  /** Update a student */
  @Patch(':id')
  @RequireScopes(EntityKey.STUDENTS, 'write')
  @AppliesPolicy(StudentsPolicy)
  @ApiUpdateStudent()
  async update(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: UpdateStudentDto,
    @AccessContext() ctx: RecordAccessContext,
  ) {
    return this.studentsService.updateForAccessContext(id, ctx, dto);
  }

  /** Delete a student */
  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  @RequireAction(EntityKey.STUDENTS, 'delete')
  @AppliesPolicy(StudentsPolicy)
  @ApiDeleteStudent()
  async remove(
    @Param('id', ParseUUIDPipe) id: string,
    @AccessContext() ctx: RecordAccessContext,
  ) {
    await this.studentsService.removeForAccessContext(id, ctx);
  }
}

Controller rules

  • Controllers are thin — no business logic, no Prisma calls, no conditional branching. Delegate everything to the service.
  • All UUID path params use ParseUUIDPipe — never accept raw strings for IDs.
  • JSDoc /** */ on controller methods is public API documentationnest-cli.json enables introspectComments: true, so the swagger plugin pulls every /** */ block above a controller method (or the controller class) into Scalar as the operation/tag description. Treat controller JSDoc as user-facing API docs, not internal developer notes. See §5 JSDoc vs *.swagger.ts.
  • @HttpCode(HttpStatus.NO_CONTENT) on DELETE — NestJS defaults POST to 201, but DELETE must return 204.
  • @HttpCode(HttpStatus.OK) on non-CRUD POSTs — import routes, reorder routes, any POST returning 200.
  • Decorator–logic alignment — if a decorator says @RequireAction(entity, 'create'), the method must call a create service method. Never mix.
  • Permission decorators — use @RequireScopes for GET/PATCH, @RequireAction for POST/DELETE. Never combine both on the same route (see chapter 04).

Canonical example: src/students/students.controller.ts


3. Service Recipe

All domain services extend BaseTenantedCrudService, which provides create, findAll, findOne, update, and remove with built-in tenant filtering, academic year resolution, and scope-filtered responses.

@Injectable()
export class StudentsService extends BaseTenantedCrudService<
  StudentWithRelations,   // TRecord — Prisma payload type
  CreateStudentDto,       // TCreate
  UpdateStudentDto,       // TUpdate
  StudentResponseDto      // TResponse
> {
  constructor(
    prisma: PrismaService,
    customFieldsService: CustomFieldsService,
  ) {
    super(prisma, customFieldsService, EntityKey.STUDENTS, 'Student');
  }

  /** Which Prisma delegate to use (supports transaction clients) */
  protected getPrismaDelegate(client?: any) {
    return (client ?? this.prisma).student;
  }

  /** Include clause for all queries — defined in *.queries.ts */
  protected getQueryInclude() {
    return studentQueryInclude;
  }

  /**
   * Maps each permission scope to its fields (or a custom response function).
   * Array form: base service iterates the list and picks fields from TRecord.
   * Function form: full control over the shape returned for this scope group.
   */
  protected getScopeFieldMappings(): Record<string, ScopeMapping<StudentWithRelations>> {
    return {
      identity: [...STUDENT_SCOPES.identity],   // array = simple field list
      assignment: (r) => ({                          // function = custom shape
        enrollmentDate: r.enrollmentDate,
        department: r.department,                    // resolved relation
        grade: r.grade,
      }),
    };
  }

  // Optional lifecycle hooks — positional params, no CreateContext type.
  protected async beforeCreate(
    tenantId: string,
    dto: CreateStudentDto,
    ctx: HookContext,
  ): Promise<void> {
    // Validate, resolve FK IDs, detect duplicates. ctx.academicYearId is set.
  }

  // afterCreate runs INSIDE the $transaction and receives the new record's
  // id (not a hydrated record) plus the tx client to chain related writes.
  protected async afterCreate(
    tx: Prisma.TransactionClient,
    recordId: string,
    tenantId: string,
    dto: CreateStudentDto,
    ctx: HookContext,
  ): Promise<void> {
    // Post-create side effects (related rows, logging) — atomic with the create.
  }

  // The update pair mirrors the create pair:
  //   beforeUpdate(id, tenantId, dto, ctx)
  //   afterUpdate(tx, id, tenantId, dto, ctx)
}

Base service internals

Dynamic scope auto-discoverytoScopedResponse() first iterates this.scopeFieldMappings to build native scope groups from record data. Then it iterates definitionsByScope keys from CustomFieldsService to auto-discover dynamic scopes. Scopes with no native field mappings get auto-created with custom fields only — this is how the others scope works without any service changes.

Update pre-fetch optimization — when custom field data exists during an update() call, the base service pre-fetches the existing record to merge its current customFields with incoming ones, then applies native updates in memory.

Universal academic year scopingfindAll() accepts an optional academicYearId (reads can target any non-DELETED year). Writes (create, update, remove, reorder) never accept an override — they always resolve to the tenant's ACTIVE year via resolveActiveYear(tenantId). You cannot create or modify records belonging to a past or draft year. assertYearWritable() is called automatically in create(), update(), and remove() — it blocks mutations on ARCHIVED years (defence-in-depth on the update/remove paths, redundant on create). The full create lifecycle order is: resolveActiveYearassertYearWritablebeforeCreatenormalizeForPersistencevalidateCustomFieldsflattenDto$transaction(create + afterCreate)findOne. Hooks can rely on ctx.academicYearId being set.

The lifecycle-hook inventory (all no-op defaults; override per entity):

Hook When Notes
beforeCreate(tenantId, dto, ctx) / beforeUpdate(id, tenantId, dto, ctx) pre-validation / DTO transform runs before the transaction; ctx.academicYearId set
afterCreate(tx, recordId, tenantId, dto, ctx) / afterUpdate(tx, id, tenantId, dto, ctx) inside the $transaction, after the main write gets the record id, not a hydrated record; chain related writes on tx
normalizeForPersistence(mode, id, tenantId, dto, ctx) between the before-hook and flattenDto returns { native?, relational?, omitFromNative? } — inject computed native defaults, clear conditional fields, and extract relational (M2M) payloads (relational is Object.assign-ed onto ctx for the after-hook; omitFromNative keys are skipped by flattenDto) without mutating the DTO
getBaseWhere(tenantId, accessContext?) every findAll / findOne + the visibility pre-check in update / remove record-level visibility composition; default { tenantId }, override to merge <entity>ForAccessContext
getInvitationConfig() on update / remove when non-null snapshots emailField and invalidates the invitation on email change; on remove invalidates + revokes the linked user's refresh tokens (via userIdField); revokeAccessOnArchive: true makes update() deactivate the linked User + revoke tokens + drop the pending invite on status→ARCHIVED (binding kept), and restore login on ARCHIVED→other — the PeopleStatus {ACTIVE, ON_LEAVE, ARCHIVED} access flow
getRemovePreFetchSelect() + cleanupBeforeRemove(tx, id, tenantId, existing) pre-delete, inside remove()'s transaction atomic cleanup that must commit with the delete (e.g. file blobs + File rows); existing is the pre-fetched row including the extra select columns

Entities not year-scoped (intentionally): RoomType, CustomFieldDefinition, User, Role, School — configuration/catalogue entities.

Duplicate detection scope — checks in beforeCreate hooks and the import classifyRows callback must be scoped to the target year. The same personUuid may appear across years; only same-year matches are considered.

Canonical example: src/students/students.service.ts, src/common/services/base-tenanted-crud.service.ts


4. DTOs & Validation

Scope sub-DTO pattern

One file per scope under dto/scopes/. Each file contains three classes:

// dto/scopes/student-identity.dto.ts
export class CreateStudentIdentityDto {
  @IsString() @MaxLength(100)
  @ApiProperty({ maxLength: 100 })
  firstName: string;

  @IsDate() @Type(() => Date)
  @ApiProperty({ format: 'date' })
  dateOfBirth: Date;

  @IsOptional() @IsEnum(Gender)
  @ApiPropertyOptional()
  gender?: Gender;

  @IsOptional() @IsObject()
  @ApiPropertyOptional()
  customFields?: Record<string, unknown>;
}

export class UpdateStudentIdentityDto extends PartialType(CreateStudentIdentityDto) {}

export class StudentIdentityResponseDto {
  @ApiProperty() firstName: string;
  @ApiProperty({ format: 'date-time' }) dateOfBirth: Date;
  @ApiPropertyOptional({ nullable: true }) gender: Gender | null;
  @ApiPropertyOptional({ type: 'object' }) customFields?: Record<string, CustomFieldValueDto>;
}

Validator cheat sheet

Type Decorators
Required string @IsString() @MaxLength(N)
Optional string @IsOptional() @IsString() @MaxLength(N)
Date @IsDate() @Type(() => Date)
Enum @IsEnum(EnumType)
Country code @IsISO31661Alpha2()
Nested required @IsDefined() @ValidateNested() @Type(() => DtoClass)
Nested optional @IsOptional() @ValidateNested() @Type(() => DtoClass)
JSON object @IsOptional() @IsObject()

@ValidateNested() alone does NOT enforce presence. Always pair with @IsDefined() for required nested DTOs.

Wrapper DTO composition

// dto/create-student.dto.ts
export class CreateStudentDto {
  @IsDefined() @ValidateNested() @Type(() => CreateStudentIdentityDto)
  identity: CreateStudentIdentityDto;       // required scope

  @IsOptional() @ValidateNested() @Type(() => CreateStudentHealthDto)
  health?: CreateStudentHealthDto;               // optional scope
}

// dto/update-student.dto.ts
export class UpdateStudentDto {
  @IsOptional() @ValidateNested() @Type(() => UpdateStudentIdentityDto)
  identity?: UpdateStudentIdentityDto;

  @IsOptional() @ValidateNested() @Type(() => UpdateStudentHealthDto)
  health?: UpdateStudentHealthDto;
}

Response shape

// dto/student-response.dto.ts
export class StudentResponseDto {
  id: string;
  personUuid: string;
  academicYearId: string;
  identity?: StudentIdentityResponseDto;     // all scopes optional —
  health?: StudentHealthResponseDto;              // FieldFilterInterceptor strips
  createdAt: Date;                               // unauthorized groups
  updatedAt: Date;
}

Canonical example: src/students/dto/

List endpoint query DTOs

The global ValidationPipe runs with forbidNonWhitelisted: true (src/main.ts), so any query param not declared on the bound DTO is rejected with VALIDATION_FAILED (whitelistValidation). Keep that safety net — it catches typos and silently-ignored filters — and make the contract explicit per endpoint.

A list query is composed of four orthogonal pieces. Each lives on a shared base; the per-entity DTO extends the right base and adds the typed filter columns + sortBy allowlist on top.

Piece Source Notes
Pagination PaginationQueryDto page (default 1, min 1), limit (default 20, max 100).
Year scoping AcademicYearQueryDto academicYearId optional; defaults to active year via resolveActiveYear.
Sort direction SortQueryDto sortOrder only; the sortBy allowlist is per-entity (@IsIn).
Search + filters per-entity ListXQueryDto q, typed filter fields, multi-value via @TransformToArray.

Bases (all in src/common/dto/):

  • PaginatedListQueryDto — pagination + year + sortOrder. Year-scoped people-tables (Students, Teachers, Staff).
  • BasicPaginatedListQueryDto — pagination + sortOrder. Non-year entities (Users, Referents).
  • AcademicYearPaginationQueryDto — pagination + year only (no sort). Kept for endpoints that don't need filtering yet (Rooms, Departments, Grades).

Operator-by-type convention for filters in a ListXQueryDto:

Field type Operator Wire form
Text (name, email) case-insensitive contains ?firstName=mar
FK (uuid) multi-value IN ?gradeId=<uuid>&gradeId=<uuid> or ?gradeId=<uuid>,<uuid>
Enum multi-value IN ?gender=MALE&gender=FEMALE
Boolean exact ?isActive=true
Date range, inclusive ?dateOfBirthFrom=YYYY-MM-DD&dateOfBirthTo=YYYY-MM-DD

Wire mechanics for multi-value filters: pair @TransformToArray() (in src/common/utils/) with @IsUUID('4', { each: true }) / @IsEnum(E, { each: true }) / @IsISO31661Alpha2({ each: true }). The helper handles "single value", "repeated params", "comma-separated string", and the empty cases (which become undefined to avoid the { in: [] } foot-gun that silently matches zero rows).

Per-entity buildListArgsForX(query) lives in <domain>.queries.ts. Pure function: typed DTO → ListQueryArgs (the shared shape consumed by BaseTenantedCrudService.findAll and by the standalone Users/Referents findAll methods). No DB calls, no exceptions; trivially unit-testable. The controller wires the DTO straight to the queries-helper output:

@Get()
async findAll(@Query() query: ListStudentsQueryDto, @AccessContext() ctx) {
  return this.studentsService.findAllForAccessContext(ctx, query);
}

Visibility AND composition is load-bearing. BaseTenantedCrudService.findAll ANDs three WHERE fragments: the visibility filter from getBaseWhere(), the year filter, and args.where. This cannot be replaced with a spread — a filter clause that named the same key as a visibility clause (e.g. tenantId, referents) would otherwise silently override visibility. The standalone findAll methods on UsersService and ReferentsService follow the same AND pattern.

Scope-gating-by-inclusion. A v1 ListXQueryDto only declares filter fields whose visibility scope every caller of the route already has — there is no per-field @RequiresScope mechanism. If a sensitive-scope filter ever lands (admin-only medicalNotesContains), introduce a separate admin-only DTO behind a guard rather than tagging individual fields.

Rule for @Query() bindings on list endpoints:

  • No filters, no sort → bind AcademicYearPaginationQueryDto / PaginationQueryDto directly.
  • Filters or sort → declare ListXQueryDto extends PaginatedListQueryDto (or BasicPaginatedListQueryDto) in src/<domain>/dto/list-<domain>-query.dto.ts, plus buildListArgsForX(query) in <domain>.queries.ts. Wire the controller to call the queries helper before handing args to the service.
  • Static / lookup endpoints (presets, types, dropdown data) stay non-paginated and use @AggregateResponse().
// src/students/dto/list-students-query.dto.ts
export const STUDENT_SORT_BY = ['lastName', 'firstName', 'enrollmentDate', 'createdAt'] as const;

export class ListStudentsQueryDto extends PaginatedListQueryDto {
  @IsOptional() @IsString() @MaxLength(100)
  q?: string;

  @IsOptional() @TransformToArray() @IsUUID('4', { each: true })
  gradeId?: string[];

  @IsOptional() @IsDate() @Type(() => Date)
  dateOfBirthFrom?: Date;

  @IsOptional() @IsIn(STUDENT_SORT_BY)
  sortBy?: (typeof STUDENT_SORT_BY)[number] = 'createdAt';
}

Do not add a new filter by relaxing the pipe or by silently accepting undeclared params — add the field to the DTO. The convention is additive: modules with no extra filters can keep binding the shared base directly.

Canonical examples: src/students/dto/list-students-query.dto.ts, src/students/students.queries.ts (buildListArgsForStudents), src/curriculum/dto/list-curricula-query.dto.ts (single-filter case).


5. Swagger

Sibling *.swagger.ts per controller

Swagger decorator compositions live in a sibling file, not inside the controller. The controller only applies the composed decorators.

// students.swagger.ts
export const ApiStudentsController = () =>
  ApiResourceController({ tag: 'students', extraModels: [StudentResponseDto, StudentIdentityResponseDto] });

export const ApiCreateStudent  = () => ApiCreateEndpoint({ responseType: StudentResponseDto });
export const ApiListStudents   = () => ApiListEndpoint({ responseType: StudentResponseDto });
export const ApiGetStudent     = () => ApiGetEndpoint({ resourceName: 'Student', responseType: StudentResponseDto });
export const ApiUpdateStudent  = () => ApiUpdateEndpoint({ resourceName: 'Student', responseType: StudentResponseDto });
export const ApiDeleteStudent  = () => ApiDeleteEndpoint({ resourceName: 'Student' });

CRUD factories (from src/common/decorators/api-crud-helpers.ts)

Factory Applies
ApiResourceController({ tag, extraModels? }) Class-level: @ApiTags, @ApiBadRequestResponse, @ApiTooManyRequestsResponse, @ApiExtraModels
ApiCreateEndpoint({ responseType, conflict? }) @ApiCreatedResponse, @ApiUnauthorizedResponse, @ApiForbiddenResponses
ApiListEndpoint({ responseType }) @ApiPaginatedResponse, @ApiUnauthorizedResponse, @ApiForbiddenResponses
ApiGetEndpoint({ resourceName, responseType }) @ApiOkResponse, @ApiNotFoundResponse, auth responses
ApiUpdateEndpoint({ resourceName, responseType, conflict? }) @ApiOkResponse, @ApiNotFoundResponse, auth responses
ApiDeleteEndpoint({ resourceName }) @ApiNoContentResponse, @ApiNotFoundResponse, auth responses

Tag names are canonical, not free-form. The tag passed to ApiResourceController (and any bare @ApiTags(...)) MUST match a tag declared in src/swagger.config.ts. That file enumerates every tag — kebab-case, each with a short description and a deliberate sidebar order — and is the single source of truth. A controller tag that doesn't match a declared one produces two broken Scalar entries: the declared tag renders described-but-empty (nothing points at it), and an undescribed ghost group appears for the drifted string. Rules:

  • Match the declared kebab tag exactly'curriculum', not 'curricula' or 'Curriculum'.
  • Sub-resources fold into the parent's tag/students/:id/curriculum-selection uses students; /departments/:id/grades uses departments; /selection-windows folds into curriculum. The parent tag's description should mention the sub-resource.
  • A genuinely distinct surface gets its own declared tag (e.g. roles, curriculum-presets) — add it to swagger.config.ts in the same change.
  • Don't declare a tag no controller uses (it renders empty), and don't leave a service-only module with a declared tag — files has no controller; its document endpoints live under students/teachers/staff.

Error response DTO families

Status DTO When
400 (validation) ValidationErrorResponseDto class-validator failures (data.errors[] of ValidationFieldErrorDto)
400 (setup) SetupValidationErrorResponseDto Setup step validation
401, 403, 429 ErrorResponseDto (base) Auth, permission, rate-limit
404, 409 EntityErrorResponseDto Not found, unique constraint (has params)
422 ImportErrorResponseDto Import file validation (has data.errors[])
  • ApiForbiddenResponses() takes string keys: 'insufficientScope', 'actionNotPermitted', 'forbiddenFields'
  • Register all referenced error DTOs via @ApiExtraModels() in the controller swagger decorators
  • JSDoc /** */ on controller methods auto-infers Swagger summaries via the NestJS plugin — never duplicate the summary manually

5.5. JSDoc vs *.swagger.ts — where docs live

nest-cli.json sets introspectComments: true for the swagger plugin. That means every /** */ block above a controller method or controller class gets pulled into the generated OpenAPI spec and rendered verbatim in Scalar. Anything an API consumer shouldn't see must not live there.

Three audiences, three destinations:

Audience Destination Form
API consumer (frontend, external integrators) *.swagger.tsApiOperation({ description }) Explicit, maintained string
API consumer, terse case Controller method JSDoc /** one-liner */ Plugin picks it up as the operation summary
Internal developer (mechanisms, guard order, why this decorator) Service method JSDoc, or // line comment above the controller method/class Line comments are not JSDoc and are ignored by introspectComments

Rules:

  • Never put internal mechanism detail in a controller /** */ block. No guard names (FieldWriteGuard, ProtectedResource), no {@link EntityKey.X} references, no explanations of guard order, scope stacks, or decorator internals. Those belong in service JSDoc or in // line comments.
  • If the *.swagger.ts entry already sets ApiOperation({ description }), drop the controller method JSDoc entirely — two sources drift.
  • A short user-facing one-liner on a controller method (e.g. /** List rooms (paginated) */) is fine and becomes the Scalar summary automatically.
  • Side effects that matter to API consumers (cascading deletes, upsert-on-conflict, invalidation of related resources) belong in ApiOperation({ description }), not in controller JSDoc.
  • Non-obvious decorator choices (e.g. "the action MUST be 'write' because FieldWriteGuard short-circuits on 'read'") go as // line comments directly above the decorator row — not in JSDoc.
  • Class-level controller JSDoc leaks to Scalar as a tag description. Keep it for user-facing resource summaries only; any "uses N scopes", "protected via ProtectedResource", "shares EntityKey with X" notes go in // line comments above the class.

Canonical example: src/referents/referents.controller.ts + src/referents/referents.swagger.ts — the update/delete/create swagger entries carry the user-facing descriptions; the controller holds only terse summaries and the one // line comment that documents the action: 'write' requirement.

Canonical example: src/students/students.swagger.ts, src/common/decorators/api-crud-helpers.ts

5.6. Swagger decoration discipline — Scalar autoinspects, so don't restate

The docs are rendered with Scalar (/reference), which derives field types, formats, enums, and constraints directly from TypeScript types and class-validator decorators on the DTO. Anything a description string only restates is noise that pushes the genuinely useful entries below the fold.

The same applies to @ApiOperation({ description }) on routes. Description prose is for client-relevant rules the spec cannot infer: state-machine errors, slot cardinality, server-assigned values (Auto-generated T-XXXXX), tri-state PATCH semantics, signed-URL TTL behavior, multipart form-field shapes that a JSON DTO can't express. Sequential server-side flow ("validates headers, then validates rows, then creates records") belongs in code comments or chapter docs, not in the API spec.

Drop the description (keep the decorator + any example / format / enum / maxLength / nullable) if any of these are true:

  • It restates the field name (firstName: 'Student first name', name: 'Department name').
  • It restates a class-validator constraint (@IsEmail description "valid email", @Min(0) description "non-negative").
  • It just narrates the handler's sequential flow ("uploads file, validates, creates...").
  • It is a generic 401/429/400 phrase the status code already conveys ('Missing or invalid access token', 'Rate limit exceeded', 'Validation error').

Keep the description when it adds non-derivable contract info — units the field name doesn't carry (yearLessons → "Total annual hours"), state-machine error codes (Rejects with ACADEMIC_YEAR_NOT_EDITABLE when not DRAFT), scope visibility rules (Configuration scope is stripped for callers without READ on …), or auto-generated value formats.

Shared error-response decorators (ApiResourceController, ApiCreateEndpoint, ApiListEndpoint, etc. in src/common/decorators/api-crud-helpers.ts) cascade across every CRUD endpoint — the ${resourceName} not found and conflict descriptions there are deliberately preserved because they inject entity-specific info; the boilerplate 401/429 strings have been stripped.

Canonical examples after cleanup: src/students/students.swagger.ts (file-upload routes — keeps single-slot vs collection rules, drops handler narration) and src/common/decorators/api-crud-helpers.ts (the shared cascade).


6. Queries Convention

Every module with any Prisma call beyond BaseTenantedCrudService gets a mandatory <domain>.queries.ts file. There is no threshold — even a single extra query goes in the queries file.

Rules

  • Named pure functions accepting PrismaService as first param — compatible with transaction clients
  • No business logic, no exception throwing — just data loading
  • Include constants use as const, derive payload types via Prisma.XGetPayload<{ include: typeof constant }>
  • Prisma payload types stay here — co-located with include consts. Domain interfaces (lookups, contracts) go to interfaces/
  • Sibling-only imports*.queries.ts is not exported from the module index.ts (unless cross-module usage exists)
  • Every non-standard Prisma call lives here — services import from queries, never inline queries
  • Function names describe intentfindGradeIdsByTenantAndYear, not getGrades

Condensed example

// students.queries.ts
export const studentQueryInclude = {
  department: { select: { id: true, name: true } },
  grade:      { select: { id: true, name: true } },
} as const;

// Payload type lives in interfaces/, not here
// export type StudentWithRelations = Prisma.StudentGetPayload<{ include: typeof studentQueryInclude }>;

export async function loadImportLookups(
  prisma: PrismaService,
  tenantId: string,
  academicYearId: string,
): Promise<ImportLookupData> {
  // pure data fetch — no exceptions, no business logic
}

export function findExistingStudentsForDuplicates(
  prisma: PrismaService,
  tenantId: string,
  academicYearId: string,
) {
  return prisma.student.findMany({
    where: { tenantId, academicYearId },
    select: duplicateCheckSelect,
  });
}

Existing query files

Every module with any Prisma call beyond BaseTenantedCrudService ships a <domain>.queries.ts (some carry only include/select constants, most carry constants + named query functions). The set keeps growing as modules land — students alone has several (students.queries.ts, curriculum-selection.queries.ts, school-relationships.queries.ts, guardians.queries.ts), and newer modules (audit-log, attendance, the command-center sub-resources) all follow suit. Don't treat any enumerated list here as exhaustive; the rule is the contract.

Canonical example: src/students/students.queries.ts


7. Nested Sub-Resources

Pattern for resources scoped under a parent entity, such as grades under departments (/departments/:departmentId/grades).

Controller

@ApiGradesController()
@ApiParam({ name: 'departmentId', format: 'uuid' })   // class-level — applies to all routes
@Controller('departments/:departmentId/grades')
@ProtectedResource()
export class GradesController {
  /** Create a grade within a department */
  @Post()
  @RequireAction(EntityKey.GRADES, 'create')
  async create(
    @Param('departmentId', ParseUUIDPipe) departmentId: string,
    @Body() dto: CreateGradeDto,
    @TenantId() tenantId: string,
  ): Promise<GradeResponseDto> {
    return this.gradesService.create(departmentId, dto, tenantId);
  }

  /** List grades for a department */
  @Get()
  @RequireScopes(EntityKey.GRADES, 'read')
  async findAll(
    @Param('departmentId', ParseUUIDPipe) departmentId: string,
    @TenantId() tenantId: string,
  ): Promise<GradeResponseDto[]> {
    return this.gradesService.findAll(departmentId, tenantId);
  }
}

Service

Service methods accept parentId as the first param and filter by both tenantId and parentId. When the parent has record-level visibility narrowing (e.g. department_head narrowing for Departments), threads the caller's RecordAccessContext through and asserts visibility on the parent before mutating the child:

async create(
  departmentId: string,
  dto: CreateGradeDto,
  tenantId: string,
  ctx: RecordAccessContext,
) {
  // 1. Validate parent exists, belongs to tenant, AND is visible to the caller
  const dept = await this.prisma.department.findFirst({
    where: { id: departmentId, ...departmentsForAccessContext(ctx) },
  });
  if (!dept) throw new AppException(ErrorCode.NOT_FOUND, 'Department not found', HttpStatus.NOT_FOUND);

  // 2. Create child scoped to both tenant and parent
  return this.prisma.grade.create({
    data: { ...dto.configuration, departmentId, tenantId },
  });
}

The controller passes @AccessContext() into every read and write path on the nested resource — same pattern as top-level resources. Without it, the parent visibility check silently falls back to tenant-only filtering and a department_head can mutate grades under a department they don't head.

Permission setup

The child entity gets its own EntityKey, PermissionEntity, PermissionScope(s), and PermissionAction(s) in the seed — it is never piggy-backed on the parent's permissions.

Canonical example: src/departments/grades.controller.ts, src/departments/grades.service.ts

7.1. Cross-Role Reads on a Single Entity Endpoint

Don't add a route on entity A's controller that returns rows of entity B to solve "role X needs a filtered view of B". Instead, expose one endpoint on B's controller and push the filtering into the service via @AccessContext(). See chapter 04 § Record-Level Access for the RecordAccessContext + <entity>ForAccessContext recipe.

Concretely: referents read their children through GET /students (filtered to their linked kids by studentsForAccessContext), not through GET /referents/me/students. The old nested-route pattern is gone because it multiplied surfaces, split scope evaluation across entities, and made role-specific routing implicit.

7.2. Don't fold distinct surfaces into one route ("students + a view")

§7.1 warns against splitting one read across many role-specific routes. This is the inverse footgun: collapsing several student-shaped surfaces — the roster, the assignment/eligibility pickers, the curriculum-selection sub-resource — into a single GET /students "and the FE picks a view". The pitch is always "it's the same student data, just a different view." It isn't, and conceding it breaks the permission model.

What a presentation-layer "view" can and cannot change:

A FE view can change A FE view cannot change
Which columns are shown Which rows the server returned
Layout, grouping, labels Who is authorized to run the query

A route's identity is exactly the two things a view can't touch: its authorization gate (@RequireScope(s) entity+scope, plus any @RequireRoles) and its row set (the WHERE plus the record-level @AppliesPolicy narrowing). Two surfaces that differ on either are different routes, however similar their rows look.

Concede what's true first. The "same data + a view" intuition is already honored where it's correct:

  • The base GET /students (@RequireScopes(STUDENTS, 'read')) is one route serving per-caller views — FieldFilterInterceptor strips the scope groups (identity / contacts / assignment / health / documents) the caller isn't granted. The simple rosters are already folded.
  • The student projection is DRY'd at the DTO layer: StudentSummaryDto (src/students/dto/student-summary.dto.ts) is the single source of truth for the {id, name, idCode} shape used by both rosters and pickers.
  • The composite grouped boards (GET /homerooms/grouped-homerooms, GET /subject-groups/grouped-courses) reuse the class list-item DTOs (HomeroomListItemDto / SubjectGroupListItemDto) for their leaves even though they live under the class entity's namespace, not /homerooms-list / /subject-groups-list alone (see below).

So the answer to FE is: share the DTO, give the composite its own route. The surfaces they want to additionally fold in are the ones that are not field projections of a student:

  1. Different authorization gate. The grouped boards are gated on a different entity's capability — plus a second scope — not students.read. GET /homerooms/grouped-homerooms is @RequireScopes(HOMEROOMS, 'read') + an inline students.read assert + @RequireRoles('admin','department_head') (src/homerooms/grouped-homerooms.controller.ts); the courses board adds SUBJECT_GROUPS. Folding either into a plain students.read route forces one of two broken outcomes: widen the route to students.read (now any teacher/referent who can see their students can enumerate tenant-wide placement counts — privilege leak), or keep the gate but branch on a ?view= param (authorization moves out of the declarative route layer into runtime ifs on the one endpoint where a slip exposes health/contact data). It's the EntityKey on the scope decorator — not the URL prefix — that ScopeGuard enforces.
  2. Different row set. The grouped board is a catalog-driven dept → curriculum (→ grade) counts tree (class rows + counts folded from a zero-PII student projection) — a different WHERE and shape than the flat GET /students roster. Record-level visibility also differs per surface and is enforced by a different policy per route (@AppliesPolicy): e.g. CurriculumSelectionReadPolicy.where(ctx) narrows a referent to linked children (404 on anyone else) and a department-parametric reader to its headed departments. One route carries one policy; satisfying every caller from one route means defaulting to the most-permissive policy — a leak.
  3. Stateful sub-resources aren't projections at all. GET /students/:id/curriculum-selection returns SelectionReadResponseDto — a window state machine (windowStatus), a referent lock (confirmedAt), a write gate (canWrite), the grade-sliced curriculum tree, and PATCH side-effects (removedMemberships). Its scope (students.curriculum_selection) was deliberately extracted as a gate-only scope specifically to keep this surface OUT of the student read payload; folding it back in reintroduces the field-PATCH footgun that split was meant to close.

The rule: one route = one authorization gate (@RequireScope(s) [+ @RequireRoles]) × one @AppliesPolicy × one row set. If a candidate "view" changes any of those three, it's a separate route — give it its own controller method, reuse the shared DTO, and (optionally) co-locate it under the same URL namespace. If FE wants one network round-trip per screen, that's BFF/response-composition on the client or a gateway, not a backend route merge.

Canonical examples: src/homerooms/grouped-homerooms.controller.ts (catalog-driven counts board, two-scope HOMEROOMS + students.read gate, its own /homerooms route), src/students/curriculum-selection.controller.ts (gate-only scope + per-route policy + stateful response), src/students/dto/student-summary.dto.ts (the shared projection that is the right place to DRY).


8. Reorder Endpoints

Bulk ordinal update for ordered resources (departments, grades).

Route declaration (must be before PATCH :id)

/** Reorder departments */
@Patch('reorder')                                  // declared BEFORE @Patch(':id')
@RequireScopes(EntityKey.DEPARTMENTS, 'write')     // not @RequireAction
@ApiReorderDepartments()
async reorder(
  @Body() dto: ReorderDepartmentsDto,
  @TenantId() tenantId: string,
): Promise<void> {
  return this.departmentsService.reorder(tenantId, dto.configuration.items);
}

/** Update a department */
@Patch(':id')                                      // comes after reorder
@RequireScopes(EntityKey.DEPARTMENTS, 'write')
async update(...) { ... }

Route ordering caveat: PATCH reorder must be declared before PATCH :id in the controller class. NestJS matches routes in declaration order — if :id comes first, the literal string reorder is incorrectly treated as an ID.

Service

async reorder(tenantId: string, items: ReorderItemDto[]): Promise<void> {
  // 1. Validate all IDs belong to this tenant
  const ids = items.map((i) => i.id);
  const existing = await this.prisma.department.findMany({
    where: { id: { in: ids }, tenantId },
    select: { id: true },
  });
  if (existing.length !== ids.length) {
    throw new AppException(ErrorCode.NOT_FOUND, 'One or more items not found', HttpStatus.NOT_FOUND);
  }

  // 2. Run all ordinal updates in a single transaction
  await this.prisma.$transaction(
    items.map((item) =>
      this.prisma.department.update({
        where: { id: item.id },
        data: { ordinalPosition: item.ordinalPosition },
      }),
    ),
  );
}

DTO

export class ReorderItemDto {
  @IsUUID() id: string;
  @IsInt() @Min(0) ordinalPosition: number;
}

export class ReorderDepartmentsDto {
  @IsDefined() @ValidateNested() @Type(() => ReorderConfigurationDto)
  configuration: { items: ReorderItemDto[] };
}

Canonical example: src/departments/departments.controller.ts, src/departments/departments.service.ts, src/departments/dto/reorder.dto.ts


9. PATCH with a Relational Sub-Resource Array

When a PATCH body needs to mutate rows on a join table (e.g. StudentReferentLink), expose them under the symmetric relational scope on the owning entity. Wrap the array in an items field (never a bare array on a top-level scope key). Entries are strict-update: each item selects an existing row by its natural key (e.g. { studentId } + the implicit :id path param); unknown keys throw a dedicated *_LINK_NOT_FOUND code — no upsert semantics. Omitting a row from the payload leaves it untouched.

Canonical example: UpdateReferentStudentsDto in src/referents/dto/update-referent.dto.ts and its handling in ReferentsService.update.


10. Code Gotchas

Dates are Date objects

DTOs use @IsDate() @Type(() => Date) from class-transformer. Never use new Date() string parsing in services, never call .toDateString() or manual format helpers in response mappers. The JSON serializer auto-converts Date → ISO string on the way out.

// CORRECT
@IsDate() @Type(() => Date)
dateOfBirth: Date;

// WRONG — strings bypass class-transformer
@IsString()
dateOfBirth: string;

Always use AppException for errors

Never throw NestJS built-ins (NotFoundException, ConflictException, etc.). Use AppException with an ErrorCode — every error carries a stable code for frontend i18n keying. See chapter 06.

// CORRECT
throw new AppException(ErrorCode.NOT_FOUND, 'Student not found', HttpStatus.NOT_FOUND);

// WRONG
throw new NotFoundException('Student not found');

Never catch just to re-throw

Let AllExceptionsFilter handle formatting. If you catch an exception, either handle it meaningfully or don't catch it at all.

Return DTOs, never Prisma model types

Service methods must return mapped DTO instances. BaseTenantedCrudService.toScopedResponse() handles the mapping for standard CRUD. For custom methods, map explicitly.

ParseUUIDPipe for all UUID path params

// CORRECT
@Param('id', ParseUUIDPipe) id: string

// WRONG — accepts any string, no validation
@Param('id') id: string

Transactions with prisma.$transaction()

Wrap multi-step mutations in a transaction. The transaction client is passed through getPrismaDelegate(client) so base service hooks work inside transactions.

await this.prisma.$transaction(async (tx) => {
  await tx.department.update(...);
  await tx.grade.updateMany(...);
});

nodenext module resolution — .js extensions

Some imports require explicit .js extensions due to nodenext module resolution. If the TypeScript compiler complains about a missing module, add .js to the import path:

import { something } from './my-util.js';

import type for decorator metadata subjects

Use import type for types that are only used as parameter type annotations in decorated classes — avoids circular dependency issues in module metadata:

import type { AuthenticatedRequest } from '../auth/interfaces/index.js';

@nestjs/jwt expiresIn type mismatch

expiresIn expects StringValue from the ms package, not string. Use an as any cast:

expiresIn: this.config.getOrThrow('JWT_EXPIRY') as any,

ConfigService.get() returns string | undefined

Use getOrThrow() for required values to get a compile-time guarantee and a clear runtime error:

// CORRECT
const secret = this.config.getOrThrow<string>('JWT_SECRET');

// WRONG — requires null check everywhere
const secret = this.config.get('JWT_SECRET'); // string | undefined

Test files need /* eslint-disable */

Mocked objects trigger no-unsafe-* ESLint rules. Add the disable comment at the top of every *.spec.ts file:

/* eslint-disable */

11. File-backed sub-resources

Documents attached to a person (Student / Teacher / Staff / Referent) come in two cardinality flavours. Both are served via the files/ module and routed through the owning entity's controller. RBAC piggybacks on each entity's documents scope.

11.1. Two cardinalities, one FileUsage enum

FileUsage has four values today, split by cardinality:

Cardinality Usages Storage Per-person count
Single-slot PASSPORT, IDENTITY_CARD FK column on the owning entity (passportFileId, identityCardFileId) exactly one
Collection PERSONAL, EDUCATIONAL File row tagged with (ownerType: FileOwnerType, ownerId) — same polymorphic pattern Invitation uses many

Helpers — isSingleSlotUsage(usage) / isCollectionUsage(usage) — gate which path a request follows. They live in src/files/files.service.ts and are exported from the module barrel.

11.2. Routes

POST   /<entity>/:id/documents                          # multipart: 1..N file parts + usage (+ optional documentNumber / expiryDate for single-slot)
GET    /<entity>/:id/documents                          # list — single-slot + collection merged
GET    /<entity>/:id/documents/:usage                   # single-slot only — mint signed URL
DELETE /<entity>/:id/documents/:usage                   # single-slot only — clear
PATCH  /<entity>/:id/documents/:usage                   # single-slot only — replace (1 file)
GET    /<entity>/:id/documents/by-id/:fileId            # universal — mint signed URL by file id
DELETE /<entity>/:id/documents/by-id/:fileId            # universal — delete by file id
PATCH  /<entity>/:id/documents/by-id/:fileId/metadata   # single-slot only — update documentNumber / expiryDate

POST accepts one or more parts named file (cap: MAX_FILES_PER_UPLOAD = 10). Branch on usage: single-slot requires exactly one file and replaces (delete previous + upload new); sending more than one file rejects with MULTI_FILE_NOT_ALLOWED_FOR_SLOT (400). Collection appends every file in a single transaction — if any file in the batch fails validation or storage, none are persisted (already-written blobs are cleaned up explicitly). Returns FileMetadataDto[] — length 1 for single-slot, length N for collection. Routes that take :usage (GET / DELETE / PATCH of that shape) reject collection usages with INVALID_USAGE_FOR_SLOT (400) — collection items must be addressed by file id, not by slot. PATCH is single-file by design (single-slot only) and returns a single FileMetadataDto.

The by-id/:fileId segment is literal to keep the router unambiguous versus :usage.

GET returns a SignedFileUrlDto JSON envelope (not bytes) — the FE opens / downloads the bytes by GETting the returned url directly from the bucket. The URL is pre-signed, valid for S3_SIGNED_URL_TTL_SECONDS (default 900 = 15 min), and cannot be revoked early — the bucket honours Content-Disposition: inline so PDFs and images render in the browser with the original filename preserved on save. Permission/scope checks run at issuance, which is the audit point; subsequent fetches against the bucket bypass the application.

11.3. System-managed FK columns

The single-slot FK columns (passportFileId, identityCardFileId) are system-managed: FieldWriteGuard rejects PATCH bodies that touch them with FILE_REFERENCE_READ_ONLY. Always go through the document routes. Collection files have no FK on the owning entity, so there's nothing to gate — FieldWriteGuard ignores them.

11.4. Service helpers

FilesService is the choke point for blob I/O and DB writes. Entity services do visibility + auth, then delegate:

Single-slot Collection Universal
replaceFileSlot appendCollectionFiles getSignedUrlByOwner
clearFileSlot deleteFileById
updateFileMetadataById listFilesForEntity (merges both)
cleanupCollectionForOwner (entity hard-delete)

appendCollectionFiles is the all-or-nothing N-file helper used by both single-payload and multi-file POST paths. It runs storage put + DB insert per file inside a $transaction; if any iteration throws, the tx rolls back AND the already-put blobs are removed from storage before re-raising.

replaceFileSlot runs the FK update + new-file uploadAndAttach inside a $transaction, but deletes the previous-file blob outside the tx (post-commit). A failure on the previous-file storage delete is logged as a warning and does not rollback — the orphan blob is recoverable, the FK swap must succeed.

cleanupCollectionForOwner runs prisma.file.deleteMany first and batches the storage deletes after, via Promise.allSettled. A storage delete failure leaves a blob orphan but never leaves an orphaned DB row.

updateFileMetadataById is the choke point for editing documentNumber / expiryDate on an existing single-slot file. It mirrors deleteFileById's ownership resolution and rejects collection-kind files with INVALID_USAGE_FOR_SLOT.

The storage port (FileStoragePort) exposes only put, getSignedReadUrl, and delete. Reads are always pre-signed URLs — there is no streaming path through the application. S3FileStorage uses @aws-sdk/s3-request-presigner to compute the URL locally from the configured credentials (no API round-trip).

Entity hard-delete cleanup runs inside the same transaction as the row delete via the cleanupBeforeRemove hook on BaseTenantedCrudService. Referents' hand-written remove() mirrors the same shape.

11.5. Canonical examples

  • src/students/students.controller.ts — full route surface (admin / teacher / referent allowlist + assertReferentCanWrite gate).
  • src/students/students.service.ts — branch on isCollectionUsage in uploadDocument; reject collection usage in getDocumentUrl / deleteDocument; getDocumentUrlById / deleteDocumentById for the universal by-id paths.
  • src/files/files.service.ts — the helpers above. getSignedUrlByOwner mirrors deleteFileById's ownership resolution before signing.

11.6. Document metadata on the File row

For single-slot files, documentNumber (passport / identity-card number, ≤64 chars) and expiryDate (ISO YYYY-MM-DD) live on the File row itself, not on the owning entity. Two write paths:

  1. Atomic on upload — POST /<entity>/:id/documents accepts documentNumber and expiryDate as multipart form fields alongside file and usage. Persisted on the new File row in the same transaction as the storage put + DB insert.
  2. Standalone PATCHPATCH /<entity>/:id/documents/by-id/:fileId/metadata takes a { documentNumber?, expiryDate? } body with tri-state semantics (omitted = unchanged, null = clear, string = set).

Both paths are single-slot only. Sending these fields with PERSONAL / EDUCATIONAL rejects with INVALID_USAGE_FOR_SLOT (400), and so does PATCHing metadata on a collection-kind file.

Replacing the file via PATCH /<entity>/:id/documents/:usage does not copy metadata from the previous File row — the new file starts with null metadata. Re-set via the metadata route or pass them again on POST.

11.7. Spec history

The original Files MVP spec (docs/superpowers/specs/2026-04-28-files-mvp-design.md) shipped only single-slot with backend-streamed reads. Three follow-on changes have landed since:

  1. Collection support (PERSONAL, EDUCATIONAL) using the polymorphic ownerType + ownerId pattern the spec deferred.
  2. Signed-URL readsGET endpoints now mint pre-signed URLs and return JSON envelopes; the application is no longer in the byte path. TTL is configurable via S3_SIGNED_URL_TTL_SECONDS (default 15 min).
  3. Document metadata on the File row (2026-05-07). documentNumber and expiryDate for passport / identity card moved off the owning entity onto File. The 24 per-entity metadata columns were dropped in the same release, including all passportIssueDate / identityCardIssueDate columns (issue dates are no longer tracked). Document-completeness rules now key on passportFileId / identityCardFileId only — the file's existence gates completeness.

The remaining deferred features in §13 of that doc (virus scanning, soft delete, hashing) are still deferred.


See also: chapter 04 for permission wiring, chapter 06 for error codes and the AppException pattern.