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.
prisma/schema.prisma— add column to the model- Run migration — follow chapter 12 (check for uncommitted migration; audit generated SQL against the hazard checklist), then
npx prisma migrate dev --name add-<field> src/<domain>/dto/scopes/<scope>.dto.ts— add field toCreate*Dto(with validators) and*ResponseDto(with@ApiProperty)src/common/constants/scope-fields.ts— add field name to the scope's array (e.g.STUDENT_SCOPES.anagraphic)- If the field has a relation (FK) — update
getQueryInclude()in the service to add theinclude src/<domain>/<domain>.service.spec.ts— add field to mock factory objects- Verify —
npm 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()— likeenrollmentin 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.
prisma/schema.prisma— add new columns for the scope's fields- 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 src/<domain>/dto/scopes/<scope>.dto.ts— NEW file withCreate*Dto,Update*Dto(viaPartialType), and*ResponseDtoclasses. Seesrc/students/dto/scopes/student-sensitive.dto.tsfor pattern.src/<domain>/dto/create-<domain>.dto.ts— add scope property with@ValidateNested() @Type(() => Create*Dto). Use@IsDefined()if required,@IsOptional()if optional.src/<domain>/dto/update-<domain>.dto.ts— add scope property with@IsOptional() @ValidateNested() @Type(() => Update*Dto)src/<domain>/dto/<domain>-response.dto.ts— add@ApiPropertyOptional({ type: *ResponseDto })propertysrc/common/constants/scope-fields.ts— add new scope array to the entity's constant (e.g.STUDENT_SCOPES.medical = [...])src/<domain>/<domain>.service.ts— add scope entry ingetScopeFieldMappings()return value (spread the constant array, or use a function for custom shapes)prisma/seed/rbac-catalogue.ts— addPermissionScope+ScopeFieldMappingentries (using the shared constant) +prisma/seed/roles.ts— addrole_permissionsgrants (WRITE for admin, READ for teacher if appropriate)src/<domain>/<domain>.service.spec.ts— update mock factories and add tests for new scope fields- Verify —
npm 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¶
src/common/constants/entity-keys.ts— addEntityKey.<ENTITY>+ add toCUSTOM_FIELD_ENTITY_KEYSif it has custom fieldsprisma/schema.prisma— add model withtenantIdFK +customFields Json?(if custom fields needed)- Run migration — follow chapter 12 (check for uncommitted migration; audit generated SQL against the hazard checklist), then
npx prisma migrate dev --name add-<entity> src/common/constants/scope-fields.ts— add<ENTITY>_SCOPESconstant with field arrays per scopesrc/<domain>/interfaces/— create domain interfaces (lookup structures, service contracts). Prisma payload types stay inqueries.ts. Export frominterfaces/index.ts.src/<domain>/dto/scopes/— create scope sub-DTOs:Create*Dto(validators),Update*Dto(PartialType),*ResponseDto(@ApiProperty). AddcustomFields?: Record<string, unknown>to scope sub-DTOs if custom fields enabled.src/<domain>/dto/create-<domain>.dto.ts— wrapper DTO composing scope sub-DTOs with@IsDefined() @ValidateNested() @Type()src/<domain>/dto/update-<domain>.dto.ts— wrapper with@IsOptional() @ValidateNested() @Type()per scopesrc/<domain>/dto/<domain>-response.dto.ts— scope-grouped response shape withid, scope groups,createdAt,updatedAtsrc/<domain>/<domain>.service.ts— extendBaseTenantedCrudService, implementgetPrismaDelegate()andgetScopeFieldMappings(). InjectCustomFieldsServiceif custom fields. UseScopeMapping: array of field names for simple scopes, function for custom response shapes.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.src/<domain>/<domain>.swagger.ts— swagger decorator compositions using factories fromapi-crud-helpers.tssrc/<domain>/<domain>.module.ts— NestJS module importingCustomFieldsModule(if needed), providing + exporting the service. Note:PrismaModuleis@Global()— do NOT import it explicitly.src/<domain>/index.ts— barrel export: module, service, DTOs. Re-export cross-module interfaces frominterfaces/.src/app.module.ts— import the new modulesrc/<domain>/<domain>.service.spec.ts— unit tests with mocked PrismaServiceprisma/seed/— seed permission catalogue across modules:rbac-catalogue.ts—PermissionEntity,PermissionScope(s)(incl.othersscope if custom fields),PermissionAction(s)(create/deleteonly),ActionScopeRequirement(s),ScopeFieldMappingrows (using the shared constant)roles.ts—role_permissionsgrants (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)
- Verify —
npm run build && npm test && npx prisma db seed - Update docs —
CLAUDE.mdproject structure section,docs/architecture.mdif architectural significance
Custom fields integration checklist¶
If the entity supports custom fields (customFields Json? column):
- Add
customFields?: Record<string, unknown>to scope sub-DTOs that carry custom fields - Inject
CustomFieldsServicein entity service constructor - Call
validateCustomFields()in the service'screate()andupdate()overrides, before callingsuper toScopedResponse()handlespickCustomFields()automatically via base service- Seed an
othersscope 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_EXAMPLESfromsrc/common/constants/error-examples.tsfor Swagger examples- Base
ErrorResponseDtohas 5 fields only (statusCode,code,message,timestamp,path) — noparams, nodata - Subclasses add strongly typed
paramsordata— seesrc/common/dto/typed-error-responses.dto.tsandsrc/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:
prisma/seed/e2e-fixtures.ts— add acustom_field_definitionsrow:tenantId— target tenantentityKey— must be inCUSTOM_FIELD_ENTITY_KEYS(e.g.'students')scopeKey— target scope (defaults to'others'if omitted at API level)fieldKey— snake_case identifier (unique per tenant+entity)label,fieldType(TEXT,NUMBER,DATE,BOOLEAN,SELECT),isRequired,sortOrderoptions— JSON array of strings (required forSELECTtype only)- Verify —
npx 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.
prisma/seed/— add entries across modules:rbac-catalogue.ts—PermissionAction(entityId,key,label,description) +ActionScopeRequirement(s)(one per required scope, AND logic, WRITE access)roles.ts—RoleActionPermission(s)— grant the action to roles (typically admin role per tenant)src/<domain>/<domain>.controller.ts— add@RequireAction(EntityKey.<ENTITY>, '<action>')to the route. Do NOT combine with@RequireScopeson the same route.src/<domain>/<domain>.controller.spec.ts— verify controller test mocks align- Verify —
npm 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
src/common/constants/error-codes.ts— add entry toErrorCodeenum (UPPER_SNAKE_CASE)src/common/constants/error-examples.ts— add entry toERROR_EXAMPLESobject withsummary,value: { statusCode, code, message, params?, data?, timestamp, path }- Determine DTO family — which of the 7 typed DTOs carries this code? (see chapter 06)
- Simple code (no params, no data) → add to base
ErrorResponseDtoenum list insrc/common/dto/error-response.dto.ts - Code with
params→ add to existing subclass enum, or create new subclass + params class insrc/common/dto/typed-error-responses.dto.ts+src/common/dto/error-params.dto.ts - Code with
data→ same pattern with data class - Update barrels —
src/common/dto/index.tsandsrc/common/index.tsif new classes were added docs/error-codes.md— add row to the appropriate section table (Authentication, Database, Permission, Setup, etc.)- Verify —
npm 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
- Create
src/<domain>/<domain>.queries.ts— include constants, Prisma payload types, and named pure query functions - Update
src/<domain>/<domain>.service.ts— import from queries file, remove local type definitions and extracted methods, replacethis.method()calls with imported functions - Verify —
npx tsc --noEmit && npx jest --testPathPatterns="<module>" --no-coverage
§8. Define Import Validation for an Entity¶
Pipeline details: chapter 07
src/<domain>/constants/import-schema.ts— define<ENTITY>_IMPORT_FIELDS: ImportFieldDescriptor[]array, one entry per column. DeriveALL_COLUMNSandREQUIRED_COLUMNSviaderiveImportColumns(<ENTITY>_IMPORT_FIELDS). Add<ENTITY>_EXTRA_REGISTRYfor hook-only keys.src/<domain>/constants/index.ts— export<ENTITY>_IMPORT_FIELDS,ALL_COLUMNS,REQUIRED_COLUMNS,<ENTITY>_EXTRA_REGISTRYsrc/<domain>/<domain>.service.ts— invalidateRowscallback:- Call
validateImportRows(rows, <ENTITY>_IMPORT_FIELDS, agg) - Add entity-specific hooks (lookups, cross-column)
- Call
agg.toErrors({ ...STANDARD_CODE_REGISTRY, ...<ENTITY>_EXTRA_REGISTRY }) - Call
applyFieldAllowedValues(errors, <ENTITY>_IMPORT_FIELDS)+ set dynamic values src/<domain>/<domain>-import.spec.ts— test validation behavior (existing tests + drift-fix tests)- Verify —
npm 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 } allowedValuesacceptsstring[] | (() => 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¶
src/<domain>/dto/<domain>-import.dto.ts— create<Entity>ImportSuccessDtowithsuccess: boolean,created: number,skipped: numbersrc/<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.src/<domain>/<domain>.service.ts— addimportFromFile()method callingrunImportPipeline<>()with 4 callbacks:loadLookupData,validateRows,checkDuplicates,createRecords. CallassertYearWritable(tenantId, resolvedYearId)at the top ofimportFromFile()before the pipeline runs. IncheckDuplicates, scope lookups to the targetacademicYearIdonly. For person entities (students, teachers, staff), callresolvePersonUuid()(src/common/utils/resolve-person-uuid.ts) increateRecordsto assign or reusepersonUuidvia cascading taxCode → name+DOB match.src/<domain>/<domain>.swagger.ts— addApiImport<Entity>()swagger decoratorsrc/<domain>/constants/import-schema.ts— define import field descriptors (see §8)- Update barrel exports —
src/<domain>/index.ts - Verify —
npm run build && npm test
§10. Add a Setup Wizard Step¶
Wizard patterns: chapter 08
prisma/schema.prisma— add value toSetupStepenum (position matters — steps execute in enum order)- 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 src/setup/constants/setup-groups.ts— add step to the appropriate group'sstepsarraysrc/setup/dto/steps/— create step DTO for validation (used viaplainToInstance()+validate()in the setup service)src/setup/step-handlers/<step>.handler.ts— create@Injectable()class implementingStepHandler. 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).src/setup/setup.module.ts— add handler class tostepHandlerProvidersarray and register in theSTEP_HANDLERSfactory with itsstepenum value,dtoclass (optional for import steps), and handler instance.- Verify —
npx prisma migrate dev && npm run build && npm test
§11. Add a Nested Sub-Resource¶
Pattern: chapter 05
- Follow §3 for the child entity, with these differences:
- Controller —
@Controller('<parent>/:parentId/<child>')route prefix. Add@ApiParam({ name: '<parentId>', format: 'uuid' })at class level. Every method extracts@Param('<parentId>', ParseUUIDPipe). - Service — methods accept
parentIdparam.create/findAll/update/removefilter by bothtenantIdandparentId. Validate parent existence before child operations. - Permission entity — the child gets its own
EntityKey,PermissionEntity,PermissionScope(s), andPermissionAction(s)in seed. - Swagger — child swagger decorators live in the parent's
*.swagger.tsfile if the child is small, or in its own file if complex.
§12. Add a Reorder Endpoint¶
Pattern: chapter 05
src/<domain>/dto/reorder.dto.ts— createReorder<Entity>Dtowrapping{ configuration: { items: ReorderItemDto[] } }where each item hasid: stringandordinalPosition: numbersrc/<domain>/<domain>.controller.ts— addPATCH /reorderroute. Must be declared beforePATCH :idto avoid route collision. Uses@RequireScopes(entity, 'write')(not@RequireAction).src/<domain>/<domain>.service.ts—reorder()method: validate all IDs belong to tenant, run ordinal updates inprisma.$transaction()src/<domain>/<domain>.swagger.ts— add reorder swagger decorator- Verify —
npm run build && npm test