Skip to content

Files MVP — Identity Document Storage

Status: Shipped (single-slot only — see Subsequent changes below) Date: 2026-04-28

Subsequent changes (2026-05-06): collection-kind documents. The FileUsage enum gained PERSONAL and EDUCATIONAL values for many-files-per-person categories. They use a polymorphic (ownerType, ownerId) pointer on the File row — the polymorphic approach §2 of this spec deferred. The single-slot model documented here (PASSPORT, IDENTITY_CARD via per-entity *FileId FK) ships unchanged. The current shape, including the new by-id/:fileId URL for collection items, is documented in docs/05-crud-patterns.md §11.

Subsequent changes (2026-05-06): pre-signed URL reads. The MVP's "direct authenticated streaming" through the application is gone. GET /:id/documents/:usage and GET /:id/documents/by-id/:fileId now return a SignedFileUrlDto JSON envelope (url + expiresAt + file metadata). The application is no longer in the byte path; the bucket honours Content-Disposition: inline so PDFs/images render in the browser with the original filename preserved. TTL is configurable via S3_SIGNED_URL_TTL_SECONDS (default 900 = 15 min). The bucket cannot revoke an issued URL early — sensitive documents lean on a short TTL plus issuance-time auth as the audit point. The FileStoragePort no longer exposes getStream; only put, getSignedReadUrl, and delete. signed URLs is removed from §13's deferred list.

Subsequent changes (2026-05-07): document metadata on the File row. documentNumber (passport / identity-card number, ≤64 chars) and expiryDate (ISO YYYY-MM-DD) for passport / identity card moved off the per-entity tables onto the linked File row. The 24 passportNumber / passportIssueDate / passportExpiryDate / identityCardNumber / identityCardIssueDate / identityCardExpiryDate columns across Student / Teacher / Staff / Referent were dropped; issue dates are no longer tracked at all. Two write paths land the new metadata: optional multipart form fields on POST /:id/documents (atomic with the file upload) and a dedicated PATCH /:id/documents/by-id/:fileId/metadata route (tri-state patch). Both reject collection-kind usage with INVALID_USAGE_FOR_SLOT. Profile-completeness rules now key on passportFileId / identityCardFileId only (the file's existence gates completeness; the on-file metadata isn't separately required). CSV imports for Student / Teacher / Staff dropped these columns — import predates file upload, so it has nowhere to write metadata.

Treat this spec as the historical baseline — read it for the single-slot rationale and the still-deferred features (virus scanning, soft delete, hashing).

Source user stories: US-13 Add New Student Record, US-17 Manage Referent Record, US-19 Add New Teacher or Staff Record, US-23 Grace Period and Completion Reminders, US-16.2 Manage Platform Documents (identity-doc subset only).

Supersedes parts of: 2026-04-27-files-model-design.md. The earlier spec carried virus scanning, signed-URL downloads, soft-delete with retention, content hashing, and a standalone /files controller. This MVP drops all of those in favour of a leaner shape: synchronous upload, direct authenticated streaming, hard delete, entity-scoped routes. The earlier design's broader categories (medical, educational, employee documents) are not in scope here — they remain a follow-up.


1. Problem

Four entities — Student, Teacher, Staff, Referent — carry identity documents (passport, identity card) that gate completeness in the grace-period flow. The schema currently holds passportFileId and identityCardFileId as string mocks with the comment "File-mock placeholder; retyped to Uuid + FK when Files spec lands." No upload, download, or deletion is wired. Curation depends on these columns existing, and the existing column type prevents an FK from being added without a cutover.

This spec ships:

  1. A File table with metadata.
  2. A storage-backend port plus a single S3-compatible transport that targets MinIO locally and Railway's managed bucket in deployed environments.
  3. Per-entity HTTP routes for upload / download / delete, mounted on the existing students, teachers, staff, referents controllers.
  4. The migration that retypes the eight mock columns to real UUID FKs.

It does not ship: medical, educational, or employee documents (US-16.2 categories beyond identity); driver's licence; bulk upload by naming convention; expiry tracking and notifications; duplicate-number detection; document history on replacement; configurable upload notifications; admin overview view; Guardian (model not yet shipped).

2. Scope

In scope

  • New File Prisma model — one row per uploaded blob; minimal metadata (filename, MIME type, byte size, storage key, uploader, timestamps). No content hash, no scan status, no soft delete.
  • New src/files/ module:
  • FilesService — orchestrates storage + DB writes, exposes hooks consumed by entity services.
  • FileStoragePort interface with two transports: S3FileStorage (production code path, also used in dev pointing at MinIO) and InMemoryFileStorage (unit tests).
  • @Global() so FilesService can be injected by entity modules without re-export.
  • Retype of the eight mock columns: passportFileId and identityCardFileId on Student, Teacher, Staff, Referent — from String? to String? @db.Uuid with FK to files.id, onDelete: SetNull. Each entity declares uniquely-named Prisma relations to avoid the ambiguous-relation error.
  • Per-entity HTTP routes, three per entity:
  • POST /<entities>/:id/documents — multipart upload; usage in body picks the slot.
  • GET /<entities>/:id/documents/:usage — streams the blob.
  • DELETE /<entities>/:id/documents/:usage — clears the FK and hard-deletes the file.
  • Cleanup hooks (transaction-aware): replacement on upload, FK clear on DELETE, file deletion on entity hard-delete.
  • MIME allowlist (PDF, JPEG, PNG) and 10 MB size cap, enforced at upload by class-validator + a Multer config.
  • Tenant-prefixed storage keys ({tenantId}/{usage}/{fileId}.{ext}); bucket private; cross-tenant URL crafting returns 404.
  • docker-compose.yml MinIO service for local dev; .env.example updated; bucket auto-created on boot via idempotent HeadBucketCreateBucket.

Out of scope (explicitly deferred)

  • Document categories beyond identity — medical, educational, employee, driver's licence. Each requires a polymorphic PlatformDocument table (variable cardinality, history retained on replacement) which doesn't fit the inline-FK pattern this MVP ships.
  • Virus scanning. No PENDING_SCAN/CLEAN/INFECTED lifecycle, no port. Add when first abuse pattern surfaces.
  • Signed-URL downloads / single-use tokens. Direct authenticated streaming only.
  • Soft delete + retention window. Hard delete on replacement, on explicit DELETE, and on entity hard-delete.
  • SHA-256 content hashing / de-duplication.
  • MIME magic-byte sniffing (file-type library). Trust the declared Content-Type against the per-usage allowlist.
  • Multipart / resumable uploads. Single-shot multipart/form-data only.
  • Cross-entity file sharing. A File row is owned by exactly one referencing column; re-using the same image on two entities means two uploads.
  • Per-tenant storage quota. Add when a customer hits a limit.
  • Bulk upload by naming convention (US-16.2 Scenario 3).
  • Document expiry tracking + notifications (US-16.2 Scenario 4, Scenario 12).
  • Configurable upload notifications (US-16.2 Scenario 14).
  • Admin overview view (US-16.2 Scenario 11).
  • Duplicate document-number detection (US-16.2 Scenario 13).
  • Audit-log integration. US-34 picks up file events when it ships; the seam exists today (every upload/delete goes through FilesService), only the consumer is missing.

3. Decisions log

  1. One transport, S3-protocol. AWS SDK v3 (@aws-sdk/client-s3) talks to both MinIO (local dev) and Railway's managed bucket (deployed) using the same code path; only env vars differ. No separate LocalFileStorage.
  2. Entity-scoped endpoints. No standalone /files controller, no files permission entity. Upload happens through the entity that owns the FK; RBAC is the entity's existing documents scope.
  3. Generic upload, parameterised by usage. A single POST /:id/documents route per entity accepts the file plus a usage field in the multipart body. GET and DELETE take :usage in the URL because they address a specific slot.
  4. Hard delete throughout. No deletedAt, no reaper job. Every replacement / explicit delete / entity hard-delete removes the row and the storage object atomically.
  5. MIME allowlist + size cap; no magic-byte sniff. Reject early at the controller via class-validator + Multer; trust declared Content-Type thereafter.
  6. Storage key includes file id. {tenantId}/{usage}/{fileId}.{ext}. No collisions, deletion is idempotent, no rename path needed.
  7. Bucket auto-created on boot. S3FileStorage.onModuleInit runs HeadBucketCreateBucket on 404. Painless local dev; in production the bucket pre-exists, so it's a fast no-op.
  8. Storage operations inside the DB transaction. A storage put failure rolls back the DB write. Storage delete is idempotent (S3 returns 204 when absent), so a DB-commit-but-storage-delete-fails scenario leaves a stale object that the next upload to the same fileId slot would overwrite — and fileId is unique per row, so the worst case is a forgotten orphan, not corruption.
  9. No back-pointer columns on File. The URL names the owner (/students/:id/documents/passport); back-pointers would only matter for a generic /files/:id route, which we're not shipping.
  10. FieldWriteGuard rejects direct writes to the FK columns. PATCH on passportFileId from outside the upload route is rejected with FILE_REFERENCE_READ_ONLY — the column is system-managed.

4. Data model

enum FileUsage {
  PASSPORT
  IDENTITY_CARD
}

/// Uploaded file blob. One row per upload. Referenced from Student / Teacher /
/// Staff / Referent via {passport,identityCard}FileId. Cross-entity sharing
/// is not allowed: each row is owned by exactly one referencing column.
model File {
  id           String    @id @default(uuid()) @db.Uuid
  tenantId     String    @map("tenant_id") @db.Uuid
  usage        FileUsage
  fileName     String    @map("file_name") @db.VarChar(255)
  mimeType     String    @map("mime_type") @db.VarChar(100)
  byteSize     Int       @map("byte_size")
  storageKey   String    @unique @map("storage_key") @db.VarChar(500)
  uploadedById String?   @map("uploaded_by_id") @db.Uuid
  uploadedAt   DateTime  @default(now()) @map("uploaded_at")
  createdAt    DateTime  @default(now()) @map("created_at")
  updatedAt    DateTime  @updatedAt @map("updated_at")

  tenant     Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  uploadedBy User?  @relation(fields: [uploadedById], references: [id], onDelete: SetNull)

  // Reverse relations from each owning entity's two FK columns. Each pair is
  // uniquely named to disambiguate Prisma's relation graph.
  studentPassports        Student[]  @relation("StudentPassportFile")
  studentIdentityCards    Student[]  @relation("StudentIdentityCardFile")
  teacherPassports        Teacher[]  @relation("TeacherPassportFile")
  teacherIdentityCards    Teacher[]  @relation("TeacherIdentityCardFile")
  staffPassports          Staff[]    @relation("StaffPassportFile")
  staffIdentityCards      Staff[]    @relation("StaffIdentityCardFile")
  referentPassports       Referent[] @relation("ReferentPassportFile")
  referentIdentityCards   Referent[] @relation("ReferentIdentityCardFile")

  @@index([tenantId, uploadedAt])
  @@map("files")
}

Retype of existing FK columns

For each of Student, Teacher, Staff, Referent:

passportFileId     String? @map("passport_file_id")     @db.Uuid
identityCardFileId String? @map("identity_card_file_id") @db.Uuid

passportFile     File? @relation("<Entity>PassportFile",     fields: [passportFileId],     references: [id], onDelete: SetNull)
identityCardFile File? @relation("<Entity>IdentityCardFile", fields: [identityCardFileId], references: [id], onDelete: SetNull)

(Replace <Entity> with Student, Teacher, Staff, Referent per entity. The onDelete: SetNull is a safety net — explicit cleanup hooks always run before the file is deleted, see §7.)

5. RBAC

No new permission entity, no new scope, no new action. The HTTP routes are guarded by the existing documents scope on each owning entity:

Route Guard
POST /<entity>/:id/documents @RequireScopes(<entity>.documents, WRITE) + @RequireAction(<entity>, 'update')
GET /<entity>/:id/documents/:usage @RequireScopes(<entity>.documents, READ)
DELETE /<entity>/:id/documents/:usage @RequireScopes(<entity>.documents, WRITE) + @RequireAction(<entity>, 'update')

FieldFilterInterceptor already strips passportFileId / identityCardFileId from entity responses when the caller lacks documents read; the new passportFile / identityCardFile relation fields are stripped the same way (added to scope-fields.ts for each entity).

For Referent specifically, the existing rule from StudentReferentLink.canWrite continues to govern: a Referent's right to upload their own documents flows through the same write-eligibility check used for PATCH on Referent fields today (handled in StudentsService.updateForAccessContext's sibling logic — referent-self-service uses the path the team already wired).

FieldWriteGuard is updated so that a PATCH body touching passportFileId or identityCardFileId directly is rejected with the new error code FILE_REFERENCE_READ_ONLY. Those columns are written exclusively by the upload routes.

6. Module layout

src/files/
├── files.module.ts            # @Global; exports FilesService
├── files.service.ts           # uploadAndAttach, getStream, deleteByReference
├── files.queries.ts           # File include/select constants
├── storage/
│   ├── file-storage.port.ts        # FileStoragePort interface
│   ├── s3-file-storage.ts          # AWS SDK v3 — MinIO + Railway
│   └── in-memory-file-storage.ts   # tests only
├── dto/
│   ├── file-metadata.dto.ts        # FileMetadataDto (response shape)
│   └── upload-document.dto.ts      # multipart body validator (usage field)
├── interfaces/
│   └── file-payload.interface.ts   # internal upload payload
└── index.ts                   # barrel

The four entity modules (students/, teachers/, staff/, referents/) gain three controller methods and three thin service methods (uploadDocument, streamDocument, deleteDocument) that delegate to FilesService after the existing tenant + scope + access checks they already do for PATCH.

7. Endpoints

All four entities follow the same shape. Examples below use Student.

POST /students/:id/documents

  • Auth: @RequireScopes(STUDENTS, 'documents', WRITE) + @RequireAction(STUDENTS, 'update').
  • Body: multipart/form-data with two parts:
  • file — the binary blob.
  • usagepassport or identity-card (lowercased; mapped to FileUsage enum).
  • Validation:
  • usageFileUsage (class-validator).
  • mimeType[application/pdf, image/jpeg, image/png] (Multer fileFilter).
  • byteSize10 * 1024 * 1024 (Multer limits).
  • Behaviour (single transaction):
  • Resolve owning entity by tenantId (plus academicYearId for Student / Teacher / Staff — Referent is intentionally year-flat); 404 if not found, same as PATCH.
  • Capture the previous fileId from the relevant column.
  • Upload blob via FileStoragePort.put({tenantId}/{usage}/{newFileId}.{ext}, body, mimeType).
  • Insert a File row with the resolved fields.
  • Update the entity's FK column to the new fileId.
  • If a previous fileId was captured, run FilesService.deleteByReference(tx, previousFileId).
  • Response: 201 FileMetadataDto:
    {
      id: string;
      fileName: string;
      mimeType: string;
      byteSize: number;
      uploadedAt: string; // ISO
      uploadedById: string | null;
    }
    
  • Errors: 404 ENTITY_NOT_FOUND, 400 UNSUPPORTED_FILE_TYPE, 400 FILE_TOO_LARGE, 500 FILE_UPLOAD_FAILED (storage put failure → DB rollback).

GET /students/:id/documents/:usage

  • Auth: @RequireScopes(STUDENTS, 'documents', READ).
  • Param: usageFileUsage.
  • Behaviour: load entity, read the matching FK; if null, return 404 FILE_NOT_FOUND. Otherwise stream the blob from FileStoragePort.getStream. Set Content-Type from the row, Content-Disposition: inline; filename="<fileName>", Content-Length from byteSize.

DELETE /students/:id/documents/:usage

  • Auth: @RequireScopes(STUDENTS, 'documents', WRITE) + @RequireAction(STUDENTS, 'update').
  • Behaviour (single transaction):
  • Resolve entity, capture current fileId.
  • Set the FK to null.
  • FilesService.deleteByReference(tx, fileId).
  • Response: 204 No Content. Idempotent — a null FK is a no-op success.

Embedded metadata in entity responses

The entity documents scope DTOs grow two optional fields:

class StudentDocumentsResponseDto {
  passportNumber?: string;
  passportIssueDate?: string;
  passportExpiryDate?: string;
  passportFileId?: string;
  passportFile?: FileMetadataDto;       // NEW — populated from the relation include
  identityCardNumber?: string;
  // ...
  identityCardFileId?: string;
  identityCardFile?: FileMetadataDto;   // NEW
}

<entity>.queries.ts includes the relations in the default include constant. FieldFilterInterceptor strips them when the caller lacks documents read.

8. Storage backend

Port

export interface FileStoragePort {
  put(key: string, body: Buffer, mimeType: string): Promise<void>;
  getStream(key: string): Promise<Readable>;
  delete(key: string): Promise<void>; // idempotent
}

S3FileStorage

@Injectable()
export class S3FileStorage implements FileStoragePort, OnModuleInit {
  private readonly client: S3Client;
  private readonly bucket: string;

  constructor(config: ConfigService) {
    this.bucket = config.get('S3_BUCKET');
    this.client = new S3Client({
      endpoint: config.get('S3_ENDPOINT'),
      region:   config.get('S3_REGION'),
      credentials: {
        accessKeyId:     config.get('S3_ACCESS_KEY_ID'),
        secretAccessKey: config.get('S3_SECRET_ACCESS_KEY'),
      },
      forcePathStyle: config.get('S3_FORCE_PATH_STYLE') ?? true,
    });
  }

  async onModuleInit(): Promise<void> {
    try {
      await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
    } catch (err) {
      if (this.is404(err)) {
        await this.client.send(new CreateBucketCommand({ Bucket: this.bucket }));
      } else {
        throw err;
      }
    }
  }
  // put / getStream / delete implementations omitted — straight SDK calls.
}

InMemoryFileStorage

Used by unit tests. Keeps a Map<string, { body: Buffer; mimeType: string }>. Does not participate in dev — MinIO is the dev backend from day 1.

Config

src/config/ adds an S3Config schema (Joi-validated) for the six env vars:

S3_ENDPOINT
S3_REGION
S3_BUCKET
S3_ACCESS_KEY_ID
S3_SECRET_ACCESS_KEY
S3_FORCE_PATH_STYLE   # default true

docker-compose.yml

minio:
  image: minio/minio:latest
  command: server /data --console-address ":9001"
  environment:
    MINIO_ROOT_USER: minioadmin
    MINIO_ROOT_PASSWORD: minioadmin
  ports:
    - "9000:9000"
    - "9001:9001"
  volumes:
    - minio_data:/data

volumes:
  minio_data:

.env.example gains:

S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_BUCKET=sis-files-dev
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
S3_FORCE_PATH_STYLE=true

Deployed environments use the same env-var names with values pointing at Railway's managed bucket. Bucket per environment: sis-files-dev, sis-files-stage, sis-files-prod. Tenant isolation is via the storage key prefix, not separate buckets.

9. Cleanup hooks

A single function on FilesService, called in three places:

async deleteByReference(
  tx: Prisma.TransactionClient,
  fileId: string | null,
): Promise<void> {
  if (!fileId) return;
  const file = await tx.file.findUnique({ where: { id: fileId } });
  if (!file) return; // already gone — idempotent
  await this.storage.delete(file.storageKey);
  await tx.file.delete({ where: { id: fileId } });
}

Call sites:

  1. Replacement — inside POST /<entity>/:id/documents, after the FK is updated to the new id, with the previously captured fileId.
  2. Explicit delete — inside DELETE /<entity>/:id/documents/:usage, after the FK is set to null.
  3. Entity hard-delete — inside each entity's remove() service method, called for both passportFileId and identityCardFileId before the entity row is deleted. Same tx.

Hard-delete of an entity is rare in this codebase (most paths archive via status); the cleanup hook there is a guarantee, not a hot path.

10. Migration

Two migrations, in order. Per docs/12-migrations.md, never combine table additions with destructive type changes in a single migration.

Migration 1 — Add files table

Pure additions; safe.

CREATE TYPE "FileUsage" AS ENUM ('PASSPORT', 'IDENTITY_CARD');

CREATE TABLE "files" (
  "id"             UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  "tenant_id"      UUID         NOT NULL REFERENCES "tenants"("id") ON DELETE CASCADE,
  "usage"          "FileUsage"  NOT NULL,
  "file_name"      VARCHAR(255) NOT NULL,
  "mime_type"      VARCHAR(100) NOT NULL,
  "byte_size"      INTEGER      NOT NULL,
  "storage_key"    VARCHAR(500) NOT NULL UNIQUE,
  "uploaded_by_id" UUID         REFERENCES "users"("id") ON DELETE SET NULL,
  "uploaded_at"    TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
  "created_at"     TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
  "updated_at"     TIMESTAMPTZ  NOT NULL
);

CREATE INDEX "files_tenant_id_uploaded_at_idx" ON "files"("tenant_id", "uploaded_at");

Migration 2 — Retype the eight mock columns

Load-bearing. The mock columns currently hold opaque garbage from earlier dev (or NULL); the cast to UUID and the FK constraint require an empty / sanitised state. The migration NULLs the columns inline (the data is dev-only, no production yet), casts, and adds the FK constraint.

Pattern repeated for each (entity, column) pair:

-- Student passport
UPDATE "students" SET "passport_file_id" = NULL WHERE "passport_file_id" IS NOT NULL;
ALTER TABLE "students"
  ALTER COLUMN "passport_file_id" TYPE UUID USING "passport_file_id"::UUID;
ALTER TABLE "students"
  ADD CONSTRAINT "students_passport_file_id_fkey"
    FOREIGN KEY ("passport_file_id") REFERENCES "files"("id") ON DELETE SET NULL;
-- repeat: students.identity_card_file_id, teachers.passport_file_id, ..., referents.identity_card_file_id

Hazard checklist (per docs/12-migrations.md):

  • Hazard #4 (column type change) — mitigated by inline NULL-out; USING ::UUID will not fail on NULL values.
  • Hazard #7 (new FK on existing column) — mitigated because all values are NULL post-step-1, so no orphan-violation possible.
  • Lock duration on the four entity tables — small tables in this codebase, eight back-to-back ALTERs run quickly. If a future production DB hits scale before this lands, switch to ADD CONSTRAINT ... NOT VALID followed by a separate VALIDATE CONSTRAINT to avoid a long lock; not necessary now.

Pre-flight sanity (run before prisma migrate deploy in stage / prod; expects zero rows):

SELECT 'students.passport_file_id'        AS col, count(*) FROM students   WHERE passport_file_id        IS NOT NULL UNION ALL
SELECT 'students.identity_card_file_id'   AS col, count(*) FROM students   WHERE identity_card_file_id   IS NOT NULL UNION ALL
SELECT 'teachers.passport_file_id'        AS col, count(*) FROM teachers   WHERE passport_file_id        IS NOT NULL UNION ALL
SELECT 'teachers.identity_card_file_id'   AS col, count(*) FROM teachers   WHERE identity_card_file_id   IS NOT NULL UNION ALL
SELECT 'staff.passport_file_id'           AS col, count(*) FROM staff      WHERE passport_file_id        IS NOT NULL UNION ALL
SELECT 'staff.identity_card_file_id'      AS col, count(*) FROM staff      WHERE identity_card_file_id   IS NOT NULL UNION ALL
SELECT 'referents.passport_file_id'       AS col, count(*) FROM referents  WHERE passport_file_id        IS NOT NULL UNION ALL
SELECT 'referents.identity_card_file_id'  AS col, count(*) FROM referents  WHERE identity_card_file_id   IS NOT NULL;

Both migrations land in the same PR as separate files so a future rollback point sits between them.

11. Error codes

New ErrorCode entries in src/common/constants/error-codes.ts:

Code HTTP Trigger
UNSUPPORTED_FILE_TYPE 400 Declared Content-Type not in the usage's allowlist
FILE_TOO_LARGE 400 Body exceeds the per-usage size cap
FILE_REFERENCE_READ_ONLY 403 PATCH body touches passportFileId / identityCardFileId directly
FILE_UPLOAD_FAILED 500 Storage put failed; DB transaction rolled back
FILE_NOT_FOUND 404 GET on a usage whose FK is null, or storage object missing for a non-null FK

Each gets a Swagger example in error-examples.ts per chapter 06.

12. Testing

Suite Coverage
Unit — FilesService upload happy path; MIME rejection; size cap rejection; deleteByReference idempotence on null and on missing rows; transaction rollback on storage put failure
Unit — S3FileStorage aws-sdk-client-mock: put / getStream / delete round-trip; onModuleInit creates bucket on 404, no-op on 200, throws on other errors
Unit — InMemoryFileStorage round-trip; idempotent delete
Unit — entity services (Students, Teachers, Staff, Referents) uploadDocument calls FilesService with right args; replacement triggers deleteByReference on old fileId; DELETE clears FK and deletes file; remove() deletes both files before the entity row
E2E — new test/files.e2e-spec.ts against MinIO test container: upload → GET streams correct bytes → upload replacement → old key absent in MinIO → DELETEGET 404; tenant isolation (tenant A cannot read tenant B's file via crafted URL); scope filtering strips passportFile for callers without documents read
E2E — extend existing students.e2e-spec.ts etc. document upload integrated with the existing happy paths
Migration manual SQL pre-flight verification on a snapshot of dev DB before migrate deploy

CI: docker-compose.test.yml adds the same minio service; the e2e job starts it before npm run test:e2e. Unit tests use InMemoryFileStorage via test-module override.

13. Open questions

  • Per-tenant storage quota / abuse handling. Defer until a customer hits a limit; surface as MAX_BYTES_PER_TENANT env var when needed.
  • Multipart / resumable uploads. Defer; the 10 MB cap on identity docs makes single-shot uploads fine.
  • Audit log integration (US-34). Hook seam exists (FilesService is the choke point for upload + delete). Wire the consumer when US-34 ships.
  • Frontend rendering of the streamed blob. Out of scope here, but the Content-Disposition: inline + correct Content-Type should let the FE render PDFs via <embed> and images via <img> against the same URL with auth cookies.

14. Cross-references