9.5 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 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 theCrudDbApirepository-shape interface.src/api/controllers/shared/crud-controller.ts—createCrudController(API-layer factory), theCrudControllerServiceinterface, and theCrudControllertype.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—parseCsvRowsCSV-buffer parser.src/db/with-transaction.ts—withTransactionmanaged-transaction wrapper.src/shared/object.ts—isRecordtype 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?)— runsdbApi.createinsidewithTransaction.bulkImport(fileBuffer, currentUser?)—parseCsvRowsthendbApi.bulkImportwith{ ignoreDuplicates: true, validate: true }insidewithTransaction.update(data, id, currentUser?)—dbApi.updateinsidewithTransaction; throwsValidationError(notFoundCode)when the repository returns null.remove(id, currentUser?)/deleteByIds(ids, currentUser?)— insidewithTransaction.list(filter, globalAccess, currentUser?)—dbApi.findAll(filter, globalAccess, ...).count(filter, globalAccess, currentUser?)—dbApi.findAllwithcountOnly: 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; bodyreq.body.data; sendstrue.POST /bulk-import— runsprocessFile, requiresreq.file(elseValidationError('importer.errors.invalidFileEmpty')), importsreq.file.buffer; sendstrue.PUT /:id— updates; reads bothreq.body.dataandreq.body.id(the id comes from the body, not the path param); sendstrue.DELETE /:id— removes byreq.params.id; sendstrue.POST /deleteByIds— deletesreq.body.data(an id array); sendstrue.GET /— lists withreq.query; whenreq.query.filetype === 'csv'it streams a CSV ofcsvFields(viatoCsv+res.attachment), otherwise sends{ rows, count }.GET /count— sends the count payload (findAllwithcountOnly).GET /autocomplete— readsquery/limit/offsetfrom the query string and the caller'sorganizationId; sends the autocomplete payload.GET /:id— sendsfindById(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-scopedfindOneby id; returnsnullwhen the row is absent or belongs to another organization. Used by each entityupdate(and read-by-id) in place offindByPk, so cross-tenant ids are not visible or mutable.removeRecord(model, id, options?)— soft-deletes viafindOwnedByPk(tenant-scoped) thendestroy; returns the record ornull.deleteRecordsByIds(model, ids, options?)—findAllwhereid IN idsANDtenantWhere(currentUser), thendestroyeach; cross-tenant ids are silently skipped.autocompleteByField(model, field, query, limit, offset, globalAccess, organizationId)— returns{ id, label }[]from a single label column. ANDsorganizationIdfor non-global users and keeps it when aqueryis present (the query branch merges, it no longer overwrites the tenant clause); matches byid(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 throwForbiddenError.assertAuthenticatedTenantUser(currentUser?)— throwsForbiddenErrorunless the user has both an id and an organization.hasRoleAccess(currentUser, roleNames)—trueforglobalAccessusers or those holding one ofroleNames.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; throwsValidationErroron invalid input.nullableString(value)— trims a string; returnsnullfor non-strings/blanks.requiredIsoDate(value)— requiresYYYY-MM-DD(ISO_DATE_PATTERN); throws otherwise.optionalIsoDate(value)— likerequiredIsoDate, butundefinedyieldsnull.
Other helpers
parseCsvRows<Row>(fileBuffer)(src/services/shared/csv-import.ts) — parses an uploaded CSV buffer into typed rows via aPassThroughstream piped throughcsv-parser.withTransaction<T>(fn)(src/db/with-transaction.ts) — runsfn(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 passglobalAccess(fromapp_role.globalAccess) andcurrentUserthrough unchanged. - All write operations (
create,bulkImport,update,remove,deleteByIds) run in a single managed transaction viawithTransaction. bulkImportalways passesignoreDuplicates: trueandvalidate: trueto 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— howcheckCrudPermissionsresolves the per-method permission.- Per-entity slice docs (e.g.
campuses.md) for entity-specific repository behavior, filters, and associations.