112 lines
6.4 KiB
Markdown
112 lines
6.4 KiB
Markdown
# 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 `bulkCreate`s 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/`).
|
|
|
|
## Related
|
|
|
|
- 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`.
|