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
FileUsageenum gainedPERSONALandEDUCATIONALvalues for many-files-per-person categories. They use a polymorphic(ownerType, ownerId)pointer on theFilerow — the polymorphic approach §2 of this spec deferred. The single-slot model documented here (PASSPORT, IDENTITY_CARD via per-entity*FileIdFK) ships unchanged. The current shape, including the newby-id/:fileIdURL for collection items, is documented indocs/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/:usageandGET /:id/documents/by-id/:fileIdnow return aSignedFileUrlDtoJSON envelope (url+expiresAt+ file metadata). The application is no longer in the byte path; the bucket honoursContent-Disposition: inlineso PDFs/images render in the browser with the original filename preserved. TTL is configurable viaS3_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. TheFileStoragePortno longer exposesgetStream; onlyput,getSignedReadUrl, anddelete.signed URLsis removed from §13's deferred list.Subsequent changes (2026-05-07): document metadata on the File row.
documentNumber(passport / identity-card number, ≤64 chars) andexpiryDate(ISOYYYY-MM-DD) for passport / identity card moved off the per-entity tables onto the linkedFilerow. The 24passportNumber/passportIssueDate/passportExpiryDate/identityCardNumber/identityCardIssueDate/identityCardExpiryDatecolumns 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 onPOST /:id/documents(atomic with the file upload) and a dedicatedPATCH /:id/documents/by-id/:fileId/metadataroute (tri-state patch). Both reject collection-kind usage withINVALID_USAGE_FOR_SLOT. Profile-completeness rules now key onpassportFileId/identityCardFileIdonly (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:
- A
Filetable with metadata. - A storage-backend port plus a single S3-compatible transport that targets MinIO locally and Railway's managed bucket in deployed environments.
- Per-entity HTTP routes for upload / download / delete, mounted on the existing
students,teachers,staff,referentscontrollers. - The migration that retypes the eight mock columns to real
UUIDFKs.
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
FilePrisma 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.FileStoragePortinterface with two transports:S3FileStorage(production code path, also used in dev pointing at MinIO) andInMemoryFileStorage(unit tests).@Global()soFilesServicecan be injected by entity modules without re-export.- Retype of the eight mock columns:
passportFileIdandidentityCardFileIdon Student, Teacher, Staff, Referent — fromString?toString? @db.Uuidwith FK tofiles.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;usagein 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.ymlMinIO service for local dev;.env.exampleupdated; bucket auto-created on boot via idempotentHeadBucket→CreateBucket.
Out of scope (explicitly deferred)¶
- Document categories beyond identity — medical, educational, employee, driver's licence. Each requires a polymorphic
PlatformDocumenttable (variable cardinality, history retained on replacement) which doesn't fit the inline-FK pattern this MVP ships. - Virus scanning. No
PENDING_SCAN/CLEAN/INFECTEDlifecycle, 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-typelibrary). Trust the declaredContent-Typeagainst the per-usageallowlist. - Multipart / resumable uploads. Single-shot
multipart/form-dataonly. - Cross-entity file sharing. A
Filerow 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¶
- 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 separateLocalFileStorage. - Entity-scoped endpoints. No standalone
/filescontroller, nofilespermission entity. Upload happens through the entity that owns the FK; RBAC is the entity's existingdocumentsscope. - Generic upload, parameterised by
usage. A singlePOST /:id/documentsroute per entity accepts the file plus ausagefield in the multipart body.GETandDELETEtake:usagein the URL because they address a specific slot. - Hard delete throughout. No
deletedAt, no reaper job. Every replacement / explicit delete / entity hard-delete removes the row and the storage object atomically. - MIME allowlist + size cap; no magic-byte sniff. Reject early at the controller via class-validator + Multer; trust declared
Content-Typethereafter. - Storage key includes file id.
{tenantId}/{usage}/{fileId}.{ext}. No collisions, deletion is idempotent, no rename path needed. - Bucket auto-created on boot.
S3FileStorage.onModuleInitrunsHeadBucket→CreateBucketon 404. Painless local dev; in production the bucket pre-exists, so it's a fast no-op. - Storage operations inside the DB transaction. A storage
putfailure rolls back the DB write. Storagedeleteis 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 samefileIdslot would overwrite — andfileIdis unique per row, so the worst case is a forgotten orphan, not corruption. - No back-pointer columns on
File. The URL names the owner (/students/:id/documents/passport); back-pointers would only matter for a generic/files/:idroute, which we're not shipping. FieldWriteGuardrejects direct writes to the FK columns. PATCH onpassportFileIdfrom outside the upload route is rejected withFILE_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-datawith two parts: file— the binary blob.usage—passportoridentity-card(lowercased; mapped toFileUsageenum).- Validation:
usage∈FileUsage(class-validator).mimeType∈[application/pdf, image/jpeg, image/png](Multer fileFilter).byteSize≤10 * 1024 * 1024(Multer limits).- Behaviour (single transaction):
- Resolve owning entity by
tenantId(plusacademicYearIdfor Student / Teacher / Staff — Referent is intentionally year-flat); 404 if not found, same as PATCH. - Capture the previous
fileIdfrom the relevant column. - Upload blob via
FileStoragePort.put({tenantId}/{usage}/{newFileId}.{ext}, body, mimeType). - Insert a
Filerow with the resolved fields. - Update the entity's FK column to the new
fileId. - If a previous
fileIdwas captured, runFilesService.deleteByReference(tx, previousFileId). - Response:
201 FileMetadataDto: - 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:
usage∈FileUsage. - Behaviour: load entity, read the matching FK; if
null, return404 FILE_NOT_FOUND. Otherwise stream the blob fromFileStoragePort.getStream. SetContent-Typefrom the row,Content-Disposition: inline; filename="<fileName>",Content-LengthfrombyteSize.
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 — anullFK 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:
- Replacement — inside
POST /<entity>/:id/documents, after the FK is updated to the new id, with the previously capturedfileId. - Explicit delete — inside
DELETE /<entity>/:id/documents/:usage, after the FK is set tonull. - Entity hard-delete — inside each entity's
remove()service method, called for bothpassportFileIdandidentityCardFileIdbefore the entity row is deleted. Sametx.
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 ::UUIDwill 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 VALIDfollowed by a separateVALIDATE CONSTRAINTto 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 → DELETE → GET 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_TENANTenv 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 (
FilesServiceis 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+ correctContent-Typeshould let the FE render PDFs via<embed>and images via<img>against the same URL with auth cookies.
14. Cross-references¶
- Supersedes parts of
2026-04-27-files-model-design.md— single transport, no virus scan, no signed URLs, no soft-delete, no hashing, entity-scoped endpoints. The earlier spec's broader categories (medical, educational, employee documents) remain as a follow-up. 2026-04-27-missing-fields-design.md§11.2 / §11.3 — file-mock invariant resolved by Migration 2.2026-04-27-guardian-model-design.md— Guardian inherits the same FK pattern when its model lands; not in this MVP.docs/12-migrations.md— hazard checklist (#4 + #7).docs/05-crud-patterns.md— module structure conventions.docs/13-typing-conventions.md— DTO contracts,pickDefinedfor partial updates.docs/04-rbac.md— scope-driven read filtering, action gating.docs/REFERENCE.md— module map gains an entry forfiles/; the "if you're touching X, open Y first" table gains a row for "Upload / serve a file" pointing at this spec andsrc/files/.2026-04-20-invitations-design.md— precedent for port-based external integration (MailerPort↔FileStoragePort).