Workflows¶
Use these recipes with the owning chapter and canonical source. They list the integration points; they do not replace the design gate in AGENTS.md.
Read REFERENCE first. For a meaningful change, edit the durable contract in the relevant numbered chapter and update the reference index only if navigation changes. Keep implementation history, commit IDs, test-run results and temporary rollout notes out of the handbook.
Commands below describe development workflows. Agents must not run migration resets, builds, tests, lint or other verification without the authorization required by the repository instructions. Select checks appropriate to the change when the user requests them.
1. Add a field to an existing scope¶
See CRUD patterns and typing.
- Add the storage field or relation to
prisma/schema.prismawhen needed. Follow migrations, including the uncommitted-migration check and SQL safety audit. - Add input validation and response documentation in
src/<domain>/dto/scopes/. Distinguish create requirements, optional PATCH values and explicit null. - Update the scope's array in
src/common/constants/scope-fields.ts. - Update include/select constants in the domain queries file and any custom scope mapper. Function mappings do not automatically project new fields.
- Review write filtering, self-service allowlists, completeness rules, imports and reports only where the field participates in those contracts.
- Update meaningful fixtures/assertions. Generated Prisma client changes require explicit
npx prisma generatein the authorized workflow.
Seed field mappings derive from the shared constants. A new scope or role grant requires the additional catalogue work below.
2. Add a scope¶
- Define the scope and mapped fields in
scope-fields.ts. - Add input/response scope DTOs and compose them into the domain wrappers using
@ValidateNestedand@Type. - Implement the response mapping and any domain write checks.
- Add the scope and field mappings to
prisma/seed/rbac-catalogue.ts. - Add only the intended grants to
prisma/seed/roles.ts; follow the preset reconciliation path in seeding. - Add localized catalogue/field labels where applicable.
- Review route gates and record policies together; a field grant does not establish record ownership.
Gate-only scopes¶
A gate-only scope has no field mappings. It authorizes a sub-resource using singular @RequireScope(entity, scope, mode); it does not make a scope-grouped response or body-field filter.
Register the empty scope, its catalogue entry and intended grants. Do not combine singular @RequireScope with plural @RequireScopes on one handler. Startup validation checks that configuration. The service still owns parent visibility, row policy and domain constraints.
Canonical example: src/students/curriculum-selection.controller.ts. See permissions for response modes.
3. Add a domain entity¶
A new domain model/module is subject to the design gate. Once the design is approved:
- Add the Prisma model and migration. For tenant-bearing models, also add RLS policy coverage and the registry entries required by chapter 12.
- Add the entity key and scope declarations. Decide whether the API uses grouped, flat or aggregate responses.
- Create DTOs, queries, service, controller, Swagger helpers and module exports following chapter 05.
- Use
BaseTenantedCrudServiceonly when its person-style CRUD lifecycle fits. Domain aggregates may need their own service. - Compose
@ProtectedResource, route scope/action gates and record policy. Add tenant and parent restrictions in queries even when RLS also applies. - Register the module in
app.module.ts, preserving literal-route precedence. - Add permission catalogue definitions, intended preset grants and localized labels.
- Add fixture data only to the appropriate seed tier; production catalogue seed must not acquire test people or tenants.
- Update focused contracts/tests and the relevant chapter/index.
Human-designed shared domain types belong in interfaces/; Prisma-derived payload types stay beside their include/select constants in queries files. DTOs belong in dto/. Keep one-function types local. Export cross-module contracts through module barrels.
Custom fields¶
Custom fields are opt-in. Register supported entities, add the JSON storage where appropriate, use CustomFieldsService and the base service's validation/projection seams, and define the default others scope. Do not duplicate validation already performed by the base lifecycle.
The runtime definitions API is feature-gated. Check its actual controller/configuration before promising a tenant editor. Definitions can be seeded for fixtures; see permissions and seeding.
4. Add a custom-field definition¶
For an entity already supporting custom fields, the definition contains the tenant, entity key, scope key, unique field key, label, supported field type and required flag. SELECT definitions also need options.
Use the definitions service's validation for runtime writes. Fixture definitions belong with fixture seed data, not the platform catalogue. The entity's JSON values must be validated and projected through the custom-field service; direct JSON insertion is not a substitute for the API contract.
5. Add a permission action¶
Actions represent operations such as create, delete, publish or take. Scope WRITE handles ordinary field updates; do not introduce an update action for them.
- Add the action and its actual prerequisite scope requirements to
prisma/seed/rbac-catalogue.ts; add the verb toACTION_NAMESinsrc/permissions/interfaces/decorators.interfaces.tswhen it is new. - Add intended preset action grants to
prisma/seed/roles.tsand the localized label. - Apply
@RequireAction(entity, action)to the route. The action guard evaluates its configured prerequisites; do not stack a redundant plural scope requirement. - Implement the service's record/domain checks. For parameterized roles, use authority from the qualifying assignment rather than flattening unrelated grants and parameter IDs.
- Reconcile catalogue/grants through the documented deployment path and update focused permission assertions.
See permissions.
6. Add an error code¶
The error catalogue and renderer own user-facing messages; throw sites supply machine codes and typed context.
- Add the code to
src/common/constants/error-codes.ts. - Add its typed parameters to
ErrorParamsMapwhen required. - Add all supported-language messages to
src/common/i18n/error-messages.catalog.ts, including any reason/kind variants. - Add safe field-location or structured-data rendering only when the client needs it. The wire response does not expose raw exception parameters.
- Update Swagger examples and the response DTO family when the code has a new structured collection shape; export/register new DTOs.
- Keep the owning chapter's behavior accurate without copying the entire code catalogue into it.
throw new AppException(ErrorCode.NOT_FOUND, HttpStatus.NOT_FOUND, {
params: { entity: 'student', id },
});
The base wire fields are statusCode, code, messages, optional field, timestamp, and path; allowlisted collection errors additionally carry typed data. See error handling.
7. Add queries to a module¶
Put include/select constants, derived Prisma payload types and named query functions in src/<domain>/<domain>.queries.ts or a focused satellite. Query functions receive the client/context they need; they must preserve tenant, year and policy restrictions.
Services orchestrate the domain operation. Keep transaction ownership and external effects visible there. A helper using the injected Prisma proxy may join an ambient transaction; it must not assume its return means the request has committed.
8. Define import validation¶
- Define
ImportFieldDescriptor[]in the domain import-schema file. - Derive all/required column lists with
deriveImportColumns. - Run
validateImportRows, then add domain lookups and cross-column validation. - Render the aggregator through the standard registry plus domain-specific codes. Set static or dynamic allowed values through the supported helpers.
- Reuse those descriptors for the columns endpoint and the import handshake.
- Cover malformed rows, duplicate classification and the affected write behavior.
CSV headers use snake_case. Error keys use column:rule. See import pipeline for date handling, classification and transaction-time account conflicts.
9. Wire an import route¶
The people controllers are the canonical route shape:
@Post('import')
@HttpCode(HttpStatus.OK)
@RequireAction(EntityKey.STUDENTS, 'create')
@RequireRoles('admin')
@AggregateResponse()
@TenantTxBudget('IMPORT')
@UseInterceptors(
FileInterceptor('file', { limits: { fileSize: 10_485_760 } }),
)
async importStudents(
@UploadedFile(AssertFilePresentPipe) file: Express.Multer.File,
@TenantId() tenantId: string,
@AccessContext() ctx: RecordAccessContext,
): Promise<StudentImportSummaryDto> {
return this.studentsService.importFromFile(
file.buffer,
file.mimetype,
tenantId,
ctx,
);
}
Use the actual domain Swagger decorator and imports. Actor identity is needed by people import self-lockout protection; do not drop it during delegation.
The service resolves the active academic year and passes year options to runImportPipeline. Wire lookup, row validation, duplicate classification and record-writing callbacks. Person identity resolution may carry a prior-year personUuid and a compatible user binding; do not infer account compatibility from an email alone.
Build the response with ImportSummaryDtoOf. Its envelope includes count, items, created, updated, deleted, skippedItems, deletedItems, rolloverAppliedAt and optional importColumns. Non-person imports supply suitable item/skip/delete DTOs.
Setup imports use full replacement; post-setup imports do not delete omitted records. Preserve domain teardown, account checks, audit and post-commit delivery behavior. Read chapter 07 before adding callbacks.
10. Add a setup step¶
- Add the database enum value through an audited migration where needed.
- Add the TypeScript step and place it in
SETUP_STEP_ORDER. Execution order comes from this array, not database enum declaration order. - Update
SETUP_GROUPSand group order. - Create input/state DTOs and Swagger discriminator registrations; doc-facing step enums use the routable order.
- Use
FormStepHandlerfor the existing year-scoped service contract (DEPARTMENTS and ROOMS), or a bespoke handler when the service shape differs. - Register the handler and optional DTO in
SetupModule. Pass authenticated save context to delegated writers. - Preserve completion, concurrency and terminal-state behavior in
SetupService; update relevant assertions and setup documentation.
Handlers read/save through domain services. Separate import or review actions may have a no-op wizard save, but must define an explicit completion condition.
11. Add a nested sub-resource¶
- Place routes under
<parent>/:parentId/<child>and validate parent IDs. - Resolve the parent through its authorized tenant/year/record predicate. Compose child predicates with that parent restriction using AND.
- Choose whether the child is an independently permissioned entity or belongs behind an existing parent scope. Route nesting alone does not answer that.
- Validate child ownership on every read/write; never fetch solely by child ID.
- Keep DTOs, Swagger and domain cleanup in the owning module unless the approved design gives the child independent ownership.
See CRUD patterns.
12. Add a reorder endpoint¶
Use a scope WRITE gate, a validated list of IDs/positions, and one transaction. Check the complete affected scope, duplicate IDs, position validity and year editability before mutation. Account for database uniqueness while moving ordinals; do not perform an unsafe sequence of direct swaps.
Canonical implementation: src/departments/reorder-in-scope.ts. Declare literal reorder routes before :id routes and document the actual request wrapper.
13. Add a record policy¶
Use definePolicy for synchronous context-only predicates and defineResolvedPolicy when database-backed relations must be resolved.
- Define allowed role branches and the tenant/year base in the domain policy file.
- Apply
@AppliesPolicyto matching controller routes; use a narrower role gate when the route intentionally admits fewer roles. - Resolve the policy through the existing resolver and policy-evaluation context. Do not force an asynchronous policy into a synchronous
.where(ctx)call. - Share the operation's school day and bounded relation lookups where the policy needs dated membership.
- Test no-role, personal, managed and mixed-role cases, including parameter grants from distinct assignments.
- Keep startup scope/policy alignment checks satisfied; they validate metadata, not the full service query.
See permissions for qualified assignments, membership resolution and fail-closed behavior.