173 lines
9.0 KiB
Markdown
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.
|