40227-vm/backend/docs/shared-crud-factories.md
2026-06-10 18:27:19 +02:00

173 lines
9.0 KiB
Markdown

# Shared CRUD Factories Backend
## Purpose
These are the shared building blocks that every generic-CRUD entity slice is assembled
from. Instead of copy-pasting a service, controller, router, and repository per entity,
each slice wires its repository through three factories (`createCrudService`,
`createCrudController`, `createCrudRouter`) plus a set of repository and validation
helpers. This document is the canonical reference for the resulting 9-endpoint CRUD
surface; the 23 entity docs point here rather than restating it. Hand-written slices
(e.g. users, documents, roles, permissions, campuses, frame_entries) do not use these
factories.
## Files
- `src/services/shared/crud-service.ts``createCrudService` (BLL factory) and the
`CrudDbApi` repository-shape interface.
- `src/api/controllers/shared/crud-controller.ts``createCrudController` (API-layer
factory), the `CrudControllerService` interface, and the `CrudController` type.
- `src/api/http/crud-router.ts``createCrudRouter` (route-wiring factory).
- `src/db/api/shared/repository.ts` — generic repository helpers (`removeRecord`,
`deleteRecordsByIds`, `autocompleteByField`).
- `src/services/shared/access.ts` — tenant/role access helpers.
- `src/services/shared/validate.ts` — input validation helpers.
- `src/services/shared/csv-import.ts``parseCsvRows` CSV-buffer parser.
- `src/db/with-transaction.ts``withTransaction` managed-transaction wrapper.
- `src/shared/object.ts``isRecord` type guard.
## Public Interface
### `createCrudService(dbApi, { notFoundCode })` (`src/services/shared/crud-service.ts`)
Builds the standard BLL service from a repository matching the `CrudDbApi<CreateData,
UpdateData, ListFilter, BulkRow, Entity>` interface (`create`, `bulkImport`, `update`,
`deleteByIds`, `remove`, `findBy`, `findAll`, `findAllAutocomplete`). The generics are
inferred from the passed repository, so the produced service stays fully typed. Returns
an object with:
- `create(data, currentUser?)` — runs `dbApi.create` inside `withTransaction`.
- `bulkImport(fileBuffer, currentUser?)``parseCsvRows` then `dbApi.bulkImport` with
`{ ignoreDuplicates: true, validate: true }` inside `withTransaction`.
- `update(data, id, currentUser?)``dbApi.update` inside `withTransaction`; throws
`ValidationError(notFoundCode)` when the repository returns null.
- `remove(id, currentUser?)` / `deleteByIds(ids, currentUser?)` — inside `withTransaction`.
- `list(filter, globalAccess, currentUser?)``dbApi.findAll(filter, globalAccess, ...)`.
- `count(filter, globalAccess, currentUser?)``dbApi.findAll` with `countOnly: true`.
- `autocomplete(query, limit, offset, globalAccess, organizationId?)`
`dbApi.findAllAutocomplete`.
- `findById(id)``dbApi.findBy({ id })`.
### `createCrudController(service, { csvFields })` (`src/api/controllers/shared/crud-controller.ts`)
Builds the 9 HTTP handlers from a service matching the `CrudControllerService` interface.
`csvFields: string[]` selects the columns the CSV list export emits. Returns the handler
object typed as `CrudController`. Each handler maps the request to the service and sends
the result (see endpoint surface below). `globalAccess` is read from
`req.currentUser?.app_role?.globalAccess`.
### `createCrudRouter(controller, { permission })` (`src/api/http/crud-router.ts`)
Creates an `express.Router`, applies `permissions.checkCrudPermissions(permission)` to
the whole router, and wires the 9 routes (each wrapped with `wrapAsync`).
### Endpoint surface (the standard 9)
All nine routes are mounted on the entity base path, guarded by
`checkCrudPermissions(permission)`, and respond `200`:
- `POST /` — creates; body `req.body.data`; sends `true`.
- `POST /bulk-import` — runs `processFile`, requires `req.file` (else
`ValidationError('importer.errors.invalidFileEmpty')`), imports `req.file.buffer`;
sends `true`.
- `PUT /:id` — updates; reads both `req.body.data` and `req.body.id` (the id comes from
the **body**, not the path param); sends `true`.
- `DELETE /:id` — removes by `req.params.id`; sends `true`.
- `POST /deleteByIds` — deletes `req.body.data` (an id array); sends `true`.
- `GET /` — lists with `req.query`; when `req.query.filetype === 'csv'` it streams a CSV
of `csvFields` (via `toCsv` + `res.attachment`), otherwise sends `{ rows, count }`.
- `GET /count` — sends the count payload (`findAll` with `countOnly`).
- `GET /autocomplete` — reads `query`/`limit`/`offset` from the query string and the
caller's `organizationId`; sends the autocomplete payload.
- `GET /:id` — sends `findById(req.params.id)`.
Note on route order: `GET /count` and `GET /autocomplete` are registered before
`GET /:id`, so those literal paths are matched ahead of the id parameter.
### Permission derivation (`src/middlewares/check-permissions.ts`)
`checkCrudPermissions(name)` derives the permission as
`${METHOD_MAP[req.method]}_${name.toUpperCase()}`, where `METHOD_MAP` is
`POST -> CREATE`, `GET -> READ`, `PUT -> UPDATE`, `PATCH -> UPDATE`, `DELETE -> DELETE`,
then delegates to `checkPermissions(permissionName)`. So `createCrudRouter(..., {
permission: 'students' })` enforces `CREATE_STUDENTS` / `READ_STUDENTS` /
`UPDATE_STUDENTS` / `DELETE_STUDENTS` per method. See `permissions.md`.
### Repository helpers (`src/db/api/shared/repository.ts`)
Generic over `Model`; cover the methods that are byte-identical across entities, leaving
`create`/`update`/`bulkImport`/`findBy`/`findAll` in each entity repository:
- `removeRecord(model, id, options?)``findByPk` then soft-deletes via `destroy`;
returns the record or `null` when absent.
- `deleteRecordsByIds(model, ids, options?)``findAll` where `id IN ids` then
`destroy` each (within the caller's transaction); returns the records.
- `autocompleteByField(model, field, query, limit, offset, globalAccess, organizationId)`
— returns `{ id, label }[]` from a single label column. Scopes `where.organizationId`
to the tenant unless `globalAccess`; when `query` is set it matches by `id` (via
`Utils.uuid`) or case-insensitive substring (`Utils.ilike`); orders by the field ASC.
### Access helpers (`src/services/shared/access.ts`)
- `getOrganizationId(currentUser?)` / `getCampusId(currentUser?)` / `getRoleName(currentUser?)`
— resolve scope/role from the current user.
- `getDisplayName(currentUser?)` — full name, else email, else `'Staff Member'`.
- `requireOrganizationId(currentUser?)` / `requireUserId(currentUser?)` — return the id
or throw `ForbiddenError`.
- `assertAuthenticatedTenantUser(currentUser?)` — throws `ForbiddenError` unless the user
has both an id and an organization.
- `hasRoleAccess(currentUser, roleNames)``true` for `globalAccess` users or those
holding one of `roleNames`.
- `campusScope(currentUser, tenantWideRoleNames)` — returns `{}` for tenant-wide/global
users, else `{ campusId }` restricting to the user's campus.
### Validation helpers (`src/services/shared/validate.ts`)
- `clampLimit(value, defaultLimit, maxLimit)` — parses a positive-integer limit,
defaulting and capping it; throws `ValidationError` on invalid input.
- `nullableString(value)` — trims a string; returns `null` for non-strings/blanks.
- `requiredIsoDate(value)` — requires `YYYY-MM-DD` (`ISO_DATE_PATTERN`); throws otherwise.
- `optionalIsoDate(value)` — like `requiredIsoDate`, but `undefined` yields `null`.
### Other helpers
- `parseCsvRows<Row>(fileBuffer)` (`src/services/shared/csv-import.ts`) — parses an
uploaded CSV buffer into typed rows via a `PassThrough` stream piped through
`csv-parser`.
- `withTransaction<T>(fn)` (`src/db/with-transaction.ts`) — runs `fn(transaction)` inside
a managed Sequelize transaction (`db.sequelize.transaction()`): commits on success,
rolls back and rethrows on failure.
- `isRecord(value)` (`src/shared/object.ts`) — type guard for a non-null, non-array plain
object.
## Behavior / Notes
- Tenant scoping lives in each entity repository's `findAll`/`create`/`update`; the
factories pass `globalAccess` (from `app_role.globalAccess`) and `currentUser` through
unchanged.
- All write operations (`create`, `bulkImport`, `update`, `remove`, `deleteByIds`) run in
a single managed transaction via `withTransaction`.
- `bulkImport` always passes `ignoreDuplicates: true` and `validate: true` to the
repository.
## Used By
The generic-CRUD entity slices documented under `backend/docs/` (e.g. `students.md`,
`guardians`, `class_enrollments`, `attendance_records`, `invoices`,
`assessment_results`, and the other CRUD entities). Each route file calls
`createCrudRouter(controller, { permission })`, each controller calls
`createCrudController(service, { csvFields })`, and each service calls
`createCrudService(DbApi, { notFoundCode })`.
## Tests
None yet.
## Related
- `backend-architecture.md` — the three-layer model and module-authoring guidance these
factories implement.
- `permissions.md` — how `checkCrudPermissions` resolves the per-method permission.
- Per-entity slice docs (e.g. `students.md`) for entity-specific repository behavior,
filters, and associations.