# 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<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: '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<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.
`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.
