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

9.0 KiB

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.tscreateCrudService (BLL factory) and the CrudDbApi repository-shape interface.
  • src/api/controllers/shared/crud-controller.tscreateCrudController (API-layer factory), the CrudControllerService interface, and the CrudController type.
  • src/api/http/crud-router.tscreateCrudRouter (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.tsparseCsvRows CSV-buffer parser.
  • src/db/with-transaction.tswithTransaction managed-transaction wrapper.
  • src/shared/object.tsisRecord 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.

  • 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.