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

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 plus bulk-import, count, autocomplete, deleteByIds; applies checkCrudPermissions('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 (exports toDocumentDto; 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 -> 200 true. Multipart file upload (processFile); a CSV buffer is parsed into rows. Missing file raises ValidationError('importer.errors.invalidFileEmpty').
  • PUT /api/documents/:id -> 200, the updated document DTO. The controller calls Service.update(req.body.data, req.body.id, ...) (it reads req.body.id, not req.params.id).
  • DELETE /api/documents/:id -> 200 true.
  • POST /api/documents/deleteByIds -> 200 true. Request body: { data: string[] }.
  • GET /api/documents -> 200 { rows, count } where rows are DTOs. When ?filetype=csv, responds with a CSV attachment of fields id, entity_reference, name, notes, uploaded_at.
  • GET /api/documents/count -> 200 { rows: [], count } (count-only).
  • GET /api/documents/autocomplete -> 200 array of { id, label } matched on name.
  • GET /api/documents/:id -> 200, a single record (plain) with eager-resolved organization, campus, and file. This response is NOT passed through toDocumentDto.

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

  • findAll scopes to currentUser.organizationId only when the user has a loaded organizations association and an organizationId. Callers with globalAccess (from currentUser.app_role.globalAccess) have the organizationId constraint removed, so they read across organizations.
  • On create, the document's organization is forced to currentUser.organizationId (setOrganization), regardless of input.
  • On update, organization is only changed when data.organization is provided: global-access users may set the provided organization; non-global users are pinned back to currentUser.organizationId.
  • campus is 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.
  • update raises ValidationError('documentsNotFound') when the record does not exist.
  • Bulk import parses the uploaded CSV (csv-parser) into rows, then bulkCreates with ignoreDuplicates: true and per-row createdAt staggered by BULK_IMPORT_TIMESTAMP_STEP_MS to preserve ordering. Related files are attached per row via replaceRelationFiles.
  • The list query selects only scalar columns (no eager org/file load); the campus filter join selects no attributes (filter-only, inner join).

Tests

None yet (no documents unit/e2e test under src/).

  • Frontend: frontend/docs/policies-integration.md (the handbook/policies workflow reads and mutates policy documents via GET /api/documents?category=policy, POST, PUT, DELETE).
  • Backend slices: permissions.md (the ${METHOD}_DOCUMENTS permission gate), and the file upload relation used by replaceRelationFiles.