Skip to content

Workflows

Quick-reference checklists for frequent operations. Each lists the exact files to touch. For pattern details and code examples, see the relevant chapter.


§1. Add a Field to an Existing Scope

Pattern details: chapter 05

Example: adding middleName to students.anagraphic.

  1. prisma/schema.prisma — add column to the model
  2. Run migration — follow chapter 12 (check for uncommitted migration; audit generated SQL against the hazard checklist), then npx prisma migrate dev --name add-<field>
  3. src/<domain>/dto/scopes/<scope>.dto.ts — add field to Create*Dto (with validators) and *ResponseDto (with @ApiProperty)
  4. src/common/constants/scope-fields.ts — add field name to the scope's array (e.g. STUDENT_SCOPES.anagraphic)
  5. If the field has a relation (FK) — update getQueryInclude() in the service to add the include
  6. src/<domain>/<domain>.service.spec.ts — add field to mock factory objects
  7. Verifynpm run build && npm test

No seed changes needed — ScopeFieldMapping rows in seed use the shared constant arrays, so they pick up the new field automatically.

Note: If the scope uses a function mapping (not an array) in getScopeFieldMappings() — like enrollment in StudentsService — also update the function to include the new field.


§2. Add a New Scope to an Existing Entity

Pattern details: chapter 05

Example: adding a medical scope to students.

  1. prisma/schema.prisma — add new columns for the scope's fields
  2. Run migration — follow chapter 12 (check for uncommitted migration; audit generated SQL against the hazard checklist), then npx prisma migrate dev --name add-<scope>-fields
  3. src/<domain>/dto/scopes/<scope>.dto.ts — NEW file with Create*Dto, Update*Dto (via PartialType), and *ResponseDto classes. See src/students/dto/scopes/student-sensitive.dto.ts for pattern.
  4. src/<domain>/dto/create-<domain>.dto.ts — add scope property with @ValidateNested() @Type(() => Create*Dto). Use @IsDefined() if required, @IsOptional() if optional.
  5. src/<domain>/dto/update-<domain>.dto.ts — add scope property with @IsOptional() @ValidateNested() @Type(() => Update*Dto)
  6. src/<domain>/dto/<domain>-response.dto.ts — add @ApiPropertyOptional({ type: *ResponseDto }) property
  7. src/common/constants/scope-fields.ts — add new scope array to the entity's constant (e.g. STUDENT_SCOPES.medical = [...])
  8. src/<domain>/<domain>.service.ts — add scope entry in getScopeFieldMappings() return value (spread the constant array, or use a function for custom shapes)
  9. prisma/seed/rbac-catalogue.ts — add PermissionScope + ScopeFieldMapping entries (using the shared constant) + prisma/seed/roles.ts — add role_permissions grants (WRITE for admin, READ for teacher if appropriate)
  10. src/<domain>/<domain>.service.spec.ts — update mock factories and add tests for new scope fields
  11. Verifynpm run build && npm test && npx prisma db seed

§3. Add a New Domain Entity

Module structure: chapter 05. Permission seed: chapter 04.

interfaces/ convention

Every module has an interfaces/ folder for human-designed domain types. The boundary follows the two-lane rule:

Lane 1 — queries.ts (Prisma plumbing): include/select consts, Prisma-derived payload types (Prisma.XGetPayload<>), pure query functions. These are mechanically derived from the schema and stay co-located.

Lane 2 — interfaces/ (domain interfaces): types that a human designed to represent a domain concept.

What goes in interfaces/: - Lookup structures — data structures for import or business logic (e.g. DepartmentLookup, ImportLookupData) - Service contracts — interfaces consumed beyond a single function (e.g. ImportCallbacks, SyncConfig, HookContext) - Guard/decorator metadata — types for cross-cutting concerns (e.g. PermissionRequirement, ActionRequirement) - Union/utility types — anything the module or its consumers need that is not a request/response DTO

What does NOT go here: - DTOs (request/response shapes) — those live in dto/ - Prisma payload types — those stay in queries.ts (co-located with include consts) - Types used exclusively inside a single function — keep those local

Naming convention: <topic>.interfaces.ts — groups related types by domain concept.

Barrel rules: - Each module's interfaces/ has an index.ts barrel - Module-internal types are imported from the sibling interfaces/ folder (not from barrel index.ts) - Types needed by other modules are re-exported from the module barrel index.ts

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

Steps

  1. src/common/constants/entity-keys.ts — add EntityKey.<ENTITY> + add to CUSTOM_FIELD_ENTITY_KEYS if it has custom fields
  2. prisma/schema.prisma — add model with tenantId FK + customFields Json? (if custom fields needed)
  3. Run migration — follow chapter 12 (check for uncommitted migration; audit generated SQL against the hazard checklist), then npx prisma migrate dev --name add-<entity>
  4. src/common/constants/scope-fields.ts — add <ENTITY>_SCOPES constant with field arrays per scope
  5. src/<domain>/interfaces/ — create domain interfaces (lookup structures, service contracts). Prisma payload types stay in queries.ts. Export from interfaces/index.ts.
  6. src/<domain>/dto/scopes/ — create scope sub-DTOs: Create*Dto (validators), Update*Dto (PartialType), *ResponseDto (@ApiProperty). Add customFields?: Record<string, unknown> to scope sub-DTOs if custom fields enabled.
  7. src/<domain>/dto/create-<domain>.dto.ts — wrapper DTO composing scope sub-DTOs with @IsDefined() @ValidateNested() @Type()
  8. src/<domain>/dto/update-<domain>.dto.ts — wrapper with @IsOptional() @ValidateNested() @Type() per scope
  9. src/<domain>/dto/<domain>-response.dto.ts — scope-grouped response shape with id, scope groups, createdAt, updatedAt
  10. src/<domain>/<domain>.service.ts — extend BaseTenantedCrudService, implement getPrismaDelegate() and getScopeFieldMappings(). Inject CustomFieldsService if custom fields. Use ScopeMapping: array of field names for simple scopes, function for custom response shapes.
  11. src/<domain>/<domain>.controller.ts@ProtectedResource() at class level. Routes: POST with @RequireAction(entity, 'create'), GET list/detail with @RequireScopes(entity, 'read'), PATCH with @RequireScopes(entity, 'write'), DELETE with @HttpCode(HttpStatus.NO_CONTENT) @RequireAction(entity, 'delete'). JSDoc /** */ on each method.
  12. src/<domain>/<domain>.swagger.ts — swagger decorator compositions using factories from api-crud-helpers.ts
  13. src/<domain>/<domain>.module.ts — NestJS module importing CustomFieldsModule (if needed), providing + exporting the service. Note: PrismaModule is @Global() — do NOT import it explicitly.
  14. src/<domain>/index.ts — barrel export: module, service, DTOs. Re-export cross-module interfaces from interfaces/.
  15. src/app.module.ts — import the new module
  16. src/<domain>/<domain>.service.spec.ts — unit tests with mocked PrismaService
  17. prisma/seed/ — seed permission catalogue across modules:
    • rbac-catalogue.tsPermissionEntity, PermissionScope(s) (incl. others scope if custom fields), PermissionAction(s) (create/delete only), ActionScopeRequirement(s), ScopeFieldMapping rows (using the shared constant)
    • roles.tsrole_permissions grants (WRITE for admin, READ for teacher) + RoleActionPermission(s)
    • e2e-fixtures.ts — sample records + custom field definitions if custom fields enabled (E2E test data only, not production seed)
  18. Verifynpm run build && npm test && npx prisma db seed
  19. Update docsCLAUDE.md project structure section, docs/architecture.md if architectural significance

Custom fields integration checklist

If the entity supports custom fields (customFields Json? column):

  1. Add customFields?: Record<string, unknown> to scope sub-DTOs that carry custom fields
  2. Inject CustomFieldsService in entity service constructor
  3. Call validateCustomFields() in the service's create() and update() overrides, before calling super
  4. toScopedResponse() handles pickCustomFields() automatically via base service
  5. Seed an others scope for the entity (default scope for custom fields)

Custom-field-only scopes (like others) need zero entity service changes — auto-discovered from definitions in toScopedResponse().

Error response pattern

7 typed Swagger DTOs (see chapter 06):

Status DTO to use When
400 (validation) ValidationErrorResponseDto Class-validator failures
400 (setup) SetupValidationErrorResponseDto, StepIncompleteErrorResponseDto, StepMismatchErrorResponseDto, or base ErrorResponseDto Step-specific validation, setup navigation
401, 403, 429 ErrorResponseDto (base) Auth, permission, rate-limit errors
404, 409 EntityErrorResponseDto Not found, unique constraint
422 ImportErrorResponseDto Import file validation
  • CRUD factories (api-crud-helpers.ts) auto-wire the correct DTOs — no per-endpoint override needed for standard CRUD
  • ApiForbiddenResponses() takes string keys ('insufficientScope', 'actionNotPermitted', 'forbiddenFields')
  • ERROR_EXAMPLES from src/common/constants/error-examples.ts for Swagger examples
  • Base ErrorResponseDto has 5 fields only (statusCode, code, message, timestamp, path) — no params, no data
  • Subclasses add strongly typed params or data — see src/common/dto/typed-error-responses.dto.ts and src/common/dto/error-params.dto.ts
  • Register all referenced error DTOs via ApiExtraModels() in controller swagger decorators

§4. Add a Custom Field Definition

Custom field definitions are seeded data — runtime CRUD is handled by src/custom-fields/. To seed a new definition:

  1. prisma/seed/e2e-fixtures.ts — add a custom_field_definitions row:
  2. tenantId — target tenant
  3. entityKey — must be in CUSTOM_FIELD_ENTITY_KEYS (e.g. 'students')
  4. scopeKey — target scope (defaults to 'others' if omitted at API level)
  5. fieldKey — snake_case identifier (unique per tenant+entity)
  6. label, fieldType (TEXT, NUMBER, DATE, BOOLEAN, SELECT), isRequired, sortOrder
  7. options — JSON array of strings (required for SELECT type only)
  8. Verifynpx prisma db seed

No code changes needed — the custom-fields module discovers definitions dynamically. If the target entity doesn't have a customFields Json? column yet, see the custom fields integration checklist in §3.


§5. Add a Permission Action

Model details: chapter 04

Actions are for binary operations only (create, delete). Do NOT add update actions — scope WRITE already handles field-level write access.

  1. prisma/seed/ — add entries across modules:
  2. rbac-catalogue.tsPermissionAction (entityId, key, label, description) + ActionScopeRequirement(s) (one per required scope, AND logic, WRITE access)
  3. roles.tsRoleActionPermission(s) — grant the action to roles (typically admin role per tenant)
  4. src/<domain>/<domain>.controller.ts — add @RequireAction(EntityKey.<ENTITY>, '<action>') to the route. Do NOT combine with @RequireScopes on the same route.
  5. src/<domain>/<domain>.controller.spec.ts — verify controller test mocks align
  6. Verifynpm run build && npm test && npx prisma db seed

See prisma/seed/rbac-catalogue.ts students create/delete actions for the canonical seed pattern.


§6. Add an Error Code

Error system: chapter 06

  1. src/common/constants/error-codes.ts — add entry to ErrorCode enum (UPPER_SNAKE_CASE)
  2. src/common/constants/error-examples.ts — add entry to ERROR_EXAMPLES object with summary, value: { statusCode, code, message, params?, data?, timestamp, path }
  3. Determine DTO family — which of the 7 typed DTOs carries this code? (see chapter 06)
  4. Simple code (no params, no data) → add to base ErrorResponseDto enum list in src/common/dto/error-response.dto.ts
  5. Code with params → add to existing subclass enum, or create new subclass + params class in src/common/dto/typed-error-responses.dto.ts + src/common/dto/error-params.dto.ts
  6. Code with data → same pattern with data class
  7. Update barrelssrc/common/dto/index.ts and src/common/index.ts if new classes were added
  8. docs/error-codes.md — add row to the appropriate section table (Authentication, Database, Permission, Setup, etc.)
  9. Verifynpm run build

Use AppException to throw the new code:

throw new AppException(ErrorCode.YOUR_CODE, 'Human-readable message', HttpStatus.XXX);
// With params:
throw new AppException(ErrorCode.YOUR_CODE, 'msg', HttpStatus.XXX, { params: { key: 'value' } });


§7. Add Queries to a Module

Convention: chapter 05

  1. Create src/<domain>/<domain>.queries.ts — include constants, Prisma payload types, and named pure query functions
  2. Update src/<domain>/<domain>.service.ts — import from queries file, remove local type definitions and extracted methods, replace this.method() calls with imported functions
  3. Verifynpx tsc --noEmit && npx jest --testPathPatterns="<module>" --no-coverage

§8. Define Import Validation for an Entity

Pipeline details: chapter 07

  1. src/<domain>/constants/import-schema.ts — define <ENTITY>_IMPORT_FIELDS: ImportFieldDescriptor[] array, one entry per column. Derive ALL_COLUMNS and REQUIRED_COLUMNS via deriveImportColumns(<ENTITY>_IMPORT_FIELDS). Add <ENTITY>_EXTRA_REGISTRY for hook-only keys.
  2. src/<domain>/constants/index.ts — export <ENTITY>_IMPORT_FIELDS, ALL_COLUMNS, REQUIRED_COLUMNS, <ENTITY>_EXTRA_REGISTRY
  3. src/<domain>/<domain>.service.ts — in validateRows callback:
  4. Call validateImportRows(rows, <ENTITY>_IMPORT_FIELDS, agg)
  5. Add entity-specific hooks (lookups, cross-column)
  6. Call agg.toErrors({ ...STANDARD_CODE_REGISTRY, ...<ENTITY>_EXTRA_REGISTRY })
  7. Call applyFieldAllowedValues(errors, <ENTITY>_IMPORT_FIELDS) + set dynamic values
  8. src/<domain>/<domain>-import.spec.ts — test validation behavior (existing tests + drift-fix tests)
  9. Verifynpm run build && npm test

Additional conventions

  • Error key format: {column}:{rule} (e.g., email:invalid_email)
  • Entity-specific registry merging: { ...STANDARD_CODE_REGISTRY, ...<ENTITY>_EXTRA_REGISTRY }
  • allowedValues accepts string[] | (() => string[]) for lazy evaluation
  • CSV column naming: snake_case (first_name, date_of_birth, institutional_email)

§9. Wire an Import Route

Pipeline details: chapter 07

Controller route

/** Import <entities> from CSV/XLSX file */
@Post('import')
@HttpCode(HttpStatus.OK)
@RequireAction(EntityKey.<ENTITY>, 'create')
@UseInterceptors(
  FileInterceptor('file', { limits: { fileSize: 10_485_760 } }),
)
@ApiImport<Entity>()
async import<Entity>(
  @UploadedFile() file: Express.Multer.File,
  @TenantId() tenantId: string,
  @Query('academicYearId') academicYearId?: string,
): Promise<<Entity>ImportSuccessDto> {
  if (!file) throw new BadRequestException('No file uploaded');
  return this.service.importFromFile(
    file.buffer,
    file.mimetype,
    tenantId,
    academicYearId,
  );
}

FileInterceptor is imported from @nestjs/platform-express.

Steps

  1. src/<domain>/dto/<domain>-import.dto.ts — create <Entity>ImportSuccessDto with success: boolean, created: number, skipped: number
  2. src/<domain>/<domain>.controller.ts — add the import route (see pattern above). Uses @RequireAction(entity, 'create') — same action as POST create. @TenantId() extracts tenantId, @Query('academicYearId') accepts optional year override.
  3. src/<domain>/<domain>.service.ts — add importFromFile() method calling runImportPipeline<>() with 4 callbacks: loadLookupData, validateRows, checkDuplicates, createRecords. Call assertYearWritable(tenantId, resolvedYearId) at the top of importFromFile() before the pipeline runs. In checkDuplicates, scope lookups to the target academicYearId only. For person entities (students, teachers, staff), call resolvePersonUuid() (src/common/utils/resolve-person-uuid.ts) in createRecords to assign or reuse personUuid via cascading taxCode → name+DOB match.
  4. src/<domain>/<domain>.swagger.ts — add ApiImport<Entity>() swagger decorator
  5. src/<domain>/constants/import-schema.ts — define import field descriptors (see §8)
  6. Update barrel exportssrc/<domain>/index.ts
  7. Verifynpm run build && npm test

§10. Add a Setup Wizard Step

Wizard patterns: chapter 08

  1. prisma/schema.prisma — add value to SetupStep enum (position matters — steps execute in enum order)
  2. Run migration — follow chapter 12 (check for uncommitted migration; audit generated SQL against the hazard checklist); pay special attention to hazard #6 (enum changes), then npx prisma migrate dev --name add-<step>-setup-step
  3. src/setup/constants/setup-groups.ts — add step to the appropriate group's steps array
  4. src/setup/dto/steps/ — create step DTO for validation (used via plainToInstance() + validate() in the setup service)
  5. src/setup/step-handlers/<step>.handler.ts — create @Injectable() class implementing StepHandler. Inject domain services via constructor. Implement three methods: load() (read from domain service), save() (delegate to domain service), isComplete() (gate forward navigation via domain service).
  6. src/setup/setup.module.ts — add handler class to stepHandlerProviders array and register in the STEP_HANDLERS factory with its step enum value, dto class (optional for import steps), and handler instance.
  7. Verifynpx prisma migrate dev && npm run build && npm test

§11. Add a Nested Sub-Resource

Pattern: chapter 05

  1. Follow §3 for the child entity, with these differences:
  2. Controller@Controller('<parent>/:parentId/<child>') route prefix. Add @ApiParam({ name: '<parentId>', format: 'uuid' }) at class level. Every method extracts @Param('<parentId>', ParseUUIDPipe).
  3. Service — methods accept parentId param. create/findAll/update/remove filter by both tenantId and parentId. Validate parent existence before child operations.
  4. Permission entity — the child gets its own EntityKey, PermissionEntity, PermissionScope(s), and PermissionAction(s) in seed.
  5. Swagger — child swagger decorators live in the parent's *.swagger.ts file if the child is small, or in its own file if complex.

§12. Add a Reorder Endpoint

Pattern: chapter 05

  1. src/<domain>/dto/reorder.dto.ts — create Reorder<Entity>Dto wrapping { configuration: { items: ReorderItemDto[] } } where each item has id: string and ordinalPosition: number
  2. src/<domain>/<domain>.controller.ts — add PATCH /reorder route. Must be declared before PATCH :id to avoid route collision. Uses @RequireScopes(entity, 'write') (not @RequireAction).
  3. src/<domain>/<domain>.service.tsreorder() method: validate all IDs belong to tenant, run ordinal updates in prisma.$transaction()
  4. src/<domain>/<domain>.swagger.ts — add reorder swagger decorator
  5. Verifynpm run build && npm test