# 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 entity docs point here rather than restating it. Hand-written slices (e.g. users, 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`, `findOwnedByPk`, `tenantWhere`). - `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` 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: 'campuses' })` enforces `CREATE_CAMPUSES` / `READ_CAMPUSES` / `UPDATE_CAMPUSES` / `DELETE_CAMPUSES` 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: - `tenantWhere(currentUser)` — the `{ organizationId }` clause to AND into a query, or `{}` for a global-access user / no resolvable org. The shared tenant-scoping primitive. - `findOwnedByPk(model, id, options?)` — tenant-scoped `findOne` by id; returns `null` when the row is absent **or** belongs to another organization. Used by each entity `update` (and read-by-id) in place of `findByPk`, so cross-tenant ids are not visible or mutable. - `removeRecord(model, id, options?)` — soft-deletes via `findOwnedByPk` (tenant-scoped) then `destroy`; returns the record or `null`. - `deleteRecordsByIds(model, ids, options?)` — `findAll` where `id IN ids` AND `tenantWhere(currentUser)`, then `destroy` each; cross-tenant ids are silently skipped. - `autocompleteByField(model, field, query, limit, offset, globalAccess, organizationId)` — returns `{ id, label }[]` from a single label column. ANDs `organizationId` for non-global users **and keeps it when a `query` is present** (the query branch merges, it no longer overwrites the tenant clause); matches by `id` (`Utils.uuid`) or substring (`Utils.ilike`). ### 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(fileBuffer)` (`src/services/shared/csv-import.ts`) — parses an uploaded CSV buffer into typed rows via a `PassThrough` stream piped through `csv-parser`. - `withTransaction(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. `class_enrollments`, `attendance_records`, `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. `campuses.md`) for entity-specific repository behavior, filters, and associations.