6.4 KiB
Documents Backend
Purpose
documents stores file/document metadata records (with related file uploads) attached to
school entities such as students, staff, classes, invoices, organizations, and campuses. The
slice is hand-written (not the generic CRUD factory): the service returns trimmed DTOs via
toDocumentDto, supports CSV export and CSV bulk import, and resolves related organization,
campus, and file on single-record reads.
Slice Files (by layer)
- Route:
src/routes/documents.ts(wires CRUD plusbulk-import,count,autocomplete,deleteByIds; appliescheckCrudPermissions('documents')to every route). - Controller:
src/api/controllers/documents.controller.ts(custom — maps DTOs, handles CSV export and file upload). - Service (BLL):
src/services/documents.ts(exportstoDocumentDto; wraps writes in transactions; parses CSV buffers for bulk import). - Repository (DAL):
src/db/api/documents.ts. - Model:
src/db/models/documents.ts. - Shared used:
db/api/shared/repository.ts(removeRecord,deleteRecordsByIds,autocompleteByField),db/api/file.ts(FileDBApi.replaceRelationFiles),db/utils.ts(Utils.uuid,Utils.ilike),shared/constants/pagination.ts(resolvePagination),shared/constants/database.ts(BULK_IMPORT_TIMESTAMP_STEP_MS),shared/csv.ts(toCsv),middlewares/upload.ts(processFile),middlewares/check-permissions.ts,shared/errors/validation.ts(ValidationError).
API
All routes are mounted under /api/documents and require JWT authentication (mounted with
authenticated in src/index.ts). Every route additionally passes
checkCrudPermissions('documents'), which checks the permission ${METHOD}_DOCUMENTS
(see permissions.md).
POST /api/documents->201, the created document DTO. Request body:{ data: <DocumentInput> }.POST /api/documents/bulk-import->200true. Multipart file upload (processFile); a CSV buffer is parsed into rows. Missing file raisesValidationError('importer.errors.invalidFileEmpty').PUT /api/documents/:id->200, the updated document DTO. The controller callsService.update(req.body.data, req.body.id, ...)(it readsreq.body.id, notreq.params.id).DELETE /api/documents/:id->200true.POST /api/documents/deleteByIds->200true. Request body:{ data: string[] }.GET /api/documents->200{ rows, count }whererowsare DTOs. When?filetype=csv, responds with a CSV attachment of fieldsid, entity_reference, name, notes, uploaded_at.GET /api/documents/count->200{ rows: [], count }(count-only).GET /api/documents/autocomplete->200array of{ id, label }matched onname.GET /api/documents/:id->200, a single record (plain) with eager-resolvedorganization,campus, andfile. This response is NOT passed throughtoDocumentDto.
Access Rules
Authorization is by CRUD permission only: checkCrudPermissions('documents') requires the
effective role (or a custom per-user permission) to hold ${METHOD}_DOCUMENTS. There is no
additional role-name gate or owner check inside the service. The self-access bypass in
check-permissions.ts (matching req.params.id/req.body.id to the current user id) does not
meaningfully apply to documents since those ids are document ids, not user ids.
Tenant Scope
findAllscopes tocurrentUser.organizationIdonly when the user has a loadedorganizationsassociation and anorganizationId. Callers withglobalAccess(fromcurrentUser.app_role.globalAccess) have theorganizationIdconstraint removed, so they read across organizations.- On
create, the document's organization is forced tocurrentUser.organizationId(setOrganization), regardless of input. - On
update, organization is only changed whendata.organizationis provided: global-access users may set the provided organization; non-global users are pinned back tocurrentUser.organizationId. campusis set from input on create and update.
Data Contract
Model columns (src/db/models/documents.ts): id (UUID PK), entity_type (ENUM: student,
staff, class, invoice, organization, campus, other), entity_reference (text),
name (text), category (ENUM: policy, report, id, medical, consent, invoice,
receipt, other), uploaded_at (date), notes (text), importHash (unique), createdAt,
updatedAt, deletedAt (paranoid soft-delete), campusId, organizationId, createdById,
updatedById. Associations: belongsTo organizations (as organization), belongsTo campuses
(as campus), hasMany file (as file, scoped relation upload), belongsTo users (as
createdBy/updatedBy).
toDocumentDto exposes only: id, entity_type, entity_reference, name, category,
uploaded_at, notes, organizationId, campusId, createdById, updatedById, createdAt,
updatedAt. It deliberately omits importHash, deletedAt, and eager relations.
List filters (findAll): id, entity_reference, name, notes (all ILIKE),
uploaded_atRange, active, entity_type, category, campus (filter-only inner join on id
or name, |-separated), organization (|-separated ids), createdAtRange, plus field/sort
ordering (defaults to createdAt desc) and limit/page pagination.
Behavior / Notes
- All mutations (
create,bulkImport,update,deleteByIds,remove) run inside a manual Sequelize transaction (db.sequelize.transaction()), committing on success and rolling back on error. updateraisesValidationError('documentsNotFound')when the record does not exist.- Bulk import parses the uploaded CSV (
csv-parser) into rows, thenbulkCreates withignoreDuplicates: trueand per-rowcreatedAtstaggered byBULK_IMPORT_TIMESTAMP_STEP_MSto preserve ordering. Related files are attached per row viareplaceRelationFiles. - The list query selects only scalar columns (no eager org/file load); the
campusfilter join selects no attributes (filter-only, inner join).
Tests
None yet (no documents unit/e2e test under src/).
Related
- Frontend:
frontend/docs/policies-integration.md(the handbook/policies workflow reads and mutates policy documents viaGET /api/documents?category=policy,POST,PUT,DELETE). - Backend slices:
permissions.md(the${METHOD}_DOCUMENTSpermission gate), and thefileupload relation used byreplaceRelationFiles.