# File Backend

## Purpose

The file slice handles binary file upload and download. Uploaded files are stored either on
local disk (development) or in Google Cloud Storage (production), selected at request time. The
`file` table records file metadata (name, URLs, owning relation) and is written indirectly by the
file DAL when entity relations are persisted, not by the upload endpoint itself.

## Slice Files (by layer)

- Route: `src/routes/file.ts` (thin wiring; `GET /download`, `POST /upload/:table/:field`).
- Controller: `src/api/controllers/file.controller.ts` (custom — `download` and `upload`). `download`
  calls `assertCanDownloadFile` before serving.
- Service (BLL): `src/services/file.ts` (`uploadLocal`, `downloadLocal`, `uploadGCloud`,
  `downloadGCloud`, `deleteGCloud`, `initGCloud`) for the storage I/O, plus
  `src/services/file-access.ts` (`assertCanDownloadFile`) for the per-file authorization. Both
  upload and download require JWT; local handlers reject path traversal. Download enforces a
  per-file tenant/ownership check: the file's owning organization (resolved from its `privateUrl`
  via the uploader `createdById`) must match the requester's organization, unless the requester
  has global access; files with no tracked row are denied. (Upload-side per-file ownership and a
  typed frontend upload client are still open — tracked in the file workstream.)
- Repository (DAL): `src/db/api/file.ts` (`FileDBApi` — `replaceRelationFiles`, `_addFiles`,
  `_removeLegacyFiles`); persists/removes `file` rows for entity relations. Note: this DAL imports
  `@/services/file` to call `deleteGCloud` (see Behavior / Notes).
- Model: `src/db/models/file.ts`.
- Shared used: `api/http/request.ts` (`paramStr`), `middlewares/upload.ts` (`processFile` multer
  wrapper), `shared/config.ts` (`config.uploadDir`, `config.gcloud.bucket`, `config.gcloud.hash`),
  `shared/errors/validation.ts` (`ValidationError`, in the DAL).

## API

- `GET /api/file/download?privateUrl=<path>` -> downloads the file. **Requires JWT authentication**
  (`passport.authenticate('jwt')`) **and per-file ownership**: `assertCanDownloadFile` resolves the
  file's owning organization (via `FileDBApi.findOwnerOrganizationIdByPrivateUrl`, which reads the
  uploader `createdById`) and returns `403` (`ForbiddenError`) unless the requester has global
  access or shares that organization; an untracked `privateUrl` is also `403`. The controller then
  dispatches to `downloadGCloud` when `NODE_ENV === 'production'` or `NEXT_PUBLIC_BACK_API` is set,
  otherwise `downloadLocal`.
  - Local: missing `privateUrl` -> `404`; a `privateUrl` that escapes the upload dir (path
    traversal via `..`) -> `403` (`resolveWithinUploadDir`); otherwise streams via `res.download`
    from `config.uploadDir`.
  - GCloud: serves `${hash}/${privateUrl}` from the bucket; file missing or error -> `404`
    `{ message }`.
- `POST /api/file/upload/:table/:field` -> uploads a single file. Requires JWT authentication
  (`passport.authenticate('jwt')`). The folder is computed as `:table/:field`. Dispatches to
  `uploadGCloud` when `NODE_ENV === 'production'` or `NEXT_PUBLIC_BACK_API` is set, otherwise
  `uploadLocal`. The multipart form field name is `file`; the destination filename is taken from the
  request body field `filename`.
  - Local: no `req.currentUser` -> `403`; missing file -> `400`; missing `filename` -> `500`;
    success -> `200` (empty body). Errors -> `500` with the stringified error.
  - GCloud: missing file -> `400` `{ message }`; success -> `200`
    `{ message, url }` where `url` is the public `storage.googleapis.com` URL; errors -> `500`
    `{ message }`.

## Access Rules

- Upload requires a valid JWT (route-level passport auth). The local upload path additionally
  rejects when `req.currentUser` is absent (`403`) and when an `entity` validation is supplied
  (`403`); the controller calls `uploadLocal` with `entity: null`, so the entity branch is not
  exercised from this endpoint.
- Download has no authentication middleware and performs no ownership check; access is governed
  solely by knowing the `privateUrl`.

## Tenant Scope

None enforced in this slice. Neither upload nor download filters by organization; files are keyed
by the `:table/:field` folder, the `filename`, and (for GCloud) the configured `hash` prefix. The
`file` model has no `organizationId` column.

## Data Contract

`file` model columns: `id` (UUID, PK), `belongsTo` (string, nullable), `belongsToId` (UUID,
nullable), `belongsToColumn` (string, nullable), `name` (string, required, non-empty),
`sizeInBytes` (integer, nullable), `privateUrl` (string, nullable), `publicUrl` (string, required,
non-empty), `createdAt`, `updatedAt`, `deletedAt` (paranoid soft delete), `createdById` (UUID,
nullable), `updatedById` (UUID, nullable). Associations: `belongsTo` users as `createdBy` and
`updatedBy`.

In the DAL (`replaceRelationFiles` / `_addFiles`), each new file requires `name` and `publicUrl`,
otherwise `ValidationError('iam.errors.fileNameRequired')` is raised.

## Behavior / Notes

- Storage backend is chosen per request from `NODE_ENV` / `NEXT_PUBLIC_BACK_API`: GCloud in
  production, local disk otherwise.
- The upload middleware (`src/middlewares/upload.ts`) uses multer memory storage with a 10 MB file
  size limit on the field named `file`. The controller passes `maxFileSize: 10 * 1024 * 1024` to
  `uploadLocal`, though that value is not consulted by `uploadLocal` (the limit comes from multer).
- `services/file.ts` still operates directly on Express `Request`/`Response` (streaming
  upload/download). This is a documented architecture exception: the import-boundaries test
  (`src/shared/architecture/import-boundaries.test.ts`) allows the BLL→HTTP dependency only for
  `services/file.ts` and `services/auth.ts`.
- `src/db/api/file.ts` imports `@/services/file` (calling `deleteGCloud` when removing legacy
  files), which is a DAL→BLL dependency. The same import-boundaries test caps DAL→BLL violations at
  one, and this file is the allowed one.
- `FileDBApi.replaceRelationFiles` syncs a relation's files: it deletes existing `file` rows not
  present in the input (removing the GCloud object first when `privateUrl` is set) and creates rows
  for inputs marked `new`.

## Tests

No dedicated `file` unit/e2e test exists. The architecture test
`src/shared/architecture/import-boundaries.test.ts` references this slice by name in its
BLL→HTTP and DAL→BLL debt-ceiling assertions.

## Related

- `search.md` (the other backend infrastructure slice).
- Architecture contract: `backend/docs/backend-architecture.md`.
