diff --git a/WIP.md b/WIP.md new file mode 100644 index 0000000..ea60163 --- /dev/null +++ b/WIP.md @@ -0,0 +1,214 @@ +# WIP — вложения (2026-10-02) + +Сделано за сегодня: модуль `file` (хранение файлов), модуль `issue-attachment` +(вложения задач) и вложения в комментариях. Всё в ветке `feature/attachments` +(не запушена), миграции к БД не применялись. + +## Архитектура + +Метаданные файла хранятся отдельно от того, к чему файл прикреплён: + +``` +file ←── issue_attachment (issueId, fileId) — модуль issue-attachment + ←── comment_attachment (commentId, fileId) — модуль comment +``` + +- `file` ничего не знает о владельцах. Новый владелец = своя таблица связей + + регистрация в `FileOwnerRegistry`; сам `file` при этом не меняется. +- Удаление задачи/комментария удаляет связи каскадом в БД, а файл без единой + связи (сирота) удаляет GC. +- Флоу для клиента в два шага: загрузить файл → прикрепить по `fileId`. + +Почему не полиморфная связь `ownerType + ownerId`: нет FK, нет каскадов, разные +типы ключей (`Issue.id` — Int, остальные — uuid). + +## Миграции + +| Миграция | Что | +|---|---| +| `20261002045355_file` | таблица `file` | +| `20261002050156_issue_attachment` | таблица `issue_attachment` | +| `20261002060755_comment_attachment` | таблица `comment_attachment` | + +Собраны через `prisma migrate diff` без подключения к БД. Применятся при деплое +(`prisma migrate deploy` в CMD Dockerfile). + +## Модуль `file` — `src/modules/file/` + +### API + +| Метод | Путь | Что | +|---|---|---| +| POST | `/api/files` | загрузка, multipart, поле `file` → `FileDto` | +| GET | `/api/files/:id/download` | скачивание (поток) | +| DELETE | `/api/files/:id` | удалить файл (только загрузивший), 204 | + +`FileDto`: `{ id, fileName, mimeType, size, uploaderId, createdAt }`. +`storageKey` наружу не отдаётся. + +### Устройство + +- **domain**: + - `StoredFile` — не `File`, чтобы не путать с глобальным Web API `File`; + - VO `FileName`: убирает путь и управляющие символы, обрезает до 255 + с сохранением расширения; + - порты `IFileStorage`, `IFileTypeDetector`. +- **application**: + - use-case'ы upload / download / delete / get-files; + - `FileAccessService`, `FileOwnerRegistry`, `FileGcService`. +- **infrastructure**: + - `FilePrismaRepository`; + - хранилища `LocalFileStorage` (dev/тесты) и `S3FileStorage` + (S3 / RustFS / MinIO), выбираются в `file-storage.factory.ts`; + - `FileTypeDetector` на `file-type`. +- **presentation**: `FileController`, хелпер `contentDisposition` + (RFC 5987, кириллица), GraphQL-тип `File`. + +### Загрузка + +- multer пишет во временный файл (`os.tmpdir()`), а не в память. Лимит — + `FILE_MAX_SIZE_MB`, при превышении Nest отвечает 413. +- `defParamCharset: 'utf8'` — иначе multer ломает кириллицу в именах файлов. +- MIME определяется **по содержимому** (magic bytes). Нераспознанное + (HTML, SVG, текст) сохраняется как `application/octet-stream`. +- Ключ в хранилище — `files/{randomUUID}`. Имя от пользователя в ключ не попадает. +- Порядок записи: объект → строка в БД. Если запись строки упала, объект + удаляется сразу. + +### Скачивание и безопасность + +- Inline отдаются только `image/png|jpeg|gif|webp`, всё остальное — как + `attachment`. Плюс `X-Content-Type-Options: nosniff` — защита от stored XSS. +- Файл виден загрузившему и всем, кому виден хоть один его владелец (задача, + комментарий). Иначе 404, чтобы не раскрывать существование файла. +- `TransformResponseInterceptor` пропускает `StreamableFile`. Без этого обёртка + `{data, code}` ломала скачивание. + +### Прикрепление (для модулей-владельцев) + +- `FileAccessService.getAttachableFile(id, userId)`: прикрепить можно только + **свой** файл. Невидимый файл → 404, чужой → 403. Иначе файл, увиденный + через одну задачу, можно было бы перенести в другую. +- `GetFilesUseCase` — метаданные файлов по id, для раскрытия связей в DTO. + +### GC файлов-сирот + +- `FileGcService`, `@Cron(EVERY_HOUR)`. Подключён `ScheduleModule` в `app.module.ts`. +- Удаляет файлы старше `FILE_ORPHAN_TTL_HOURS` (по умолчанию 24), на которые + не ссылается ни один зарегистрированный владелец. +- Порядок удаления: объект → строка. Удаление объекта идемпотентно, поэтому + при сбое следующий запуск доделает работу. Тот же порядок в `DeleteFileUseCase`. +- Безопасен при нескольких инстансах приложения. + +> ⚠️ Новый владелец файлов **обязан** зарегистрироваться в `FileOwnerRegistry` +> (`onModuleInit`), иначе GC удалит его файлы как сирот. + +## Модуль `issue-attachment` — `src/modules/issue-attachment/` + +Отдельный модуль, как `comment`: зависит от issue, а issue о нём не знает. +Иначе получался бы цикл issue ↔ attachment. + +### API + +| Метод | Путь | Что | +|---|---|---| +| GET | `/api/issues/:issueId/attachments` | список → `IssueAttachmentDto[]` | +| POST | `/api/issues/:issueId/attachments` | `{ fileId }` → `IssueAttachmentDto` | +| DELETE | `/api/issues/:issueId/attachments/:fileId` | открепить, 204 (файл удалит GC) | + +`IssueAttachmentDto`: `{ issueId, file: FileDto, attachedAt }`. + +GraphQL: поле `IssueDetail.attachments`. Его добавляет `IssueAttachmentResolver` +через `@ResolveField`, issue-модуль не тронут. В списке задач (`Issue`) этого +поля нет, чтобы не было N+1. + +### Правила + +- Прикреплять и откреплять может любой, кому видна задача (то же правило, что + для правки задачи), но прикрепить можно только свой файл. +- Повторное прикрепление идемпотентно (`upsert`). +- Если задачу или файл удалили между проверкой и вставкой (FK violation), ответ 404. +- `IssueAttachmentFileOwnerService` регистрируется в реестре. Видимость файла + проверяется через `IIssueRepository.findById`, поэтому правило + «reporter/assignee» живёт в одном месте. + +## Вложения в комментариях — модуль `comment` + +Вложения — часть агрегата `Comment` (`fileIds`). Отдельных эндпоинтов нет: + +``` +POST /api/issues/:issueId/comments { "content": "...", "fileIds": ["…"] } +PATCH /api/comments/:id { "content": "...", "fileIds": [...] } +``` + +- **Ответы.** `CommentDto.attachments: FileDto[]` — в create, update, списке + и SSE-событиях. +- **Правка.** `fileIds` при update задаёт набор целиком; не передан — вложения + не меняются. + - Синхронизация связей идёт в одной транзакции с правкой текста. + - Изменение только набора файлов тоже считается правкой + (`editedAt`, `comment.updated`). + - Проверяются только добавленные файлы. +- **Ограничения:** максимум `Comment.MAX_ATTACHMENTS = 10`, дубли убираются. +- **Порядок** — по времени загрузки. id файлов — uuid v7, поэтому + сортировка по строке совпадает с порядком загрузки. +- **Мягкое удаление** комментария снимает связи, файлы забирает GC. В ответе + у удалённого комментария `attachments: []`. +- `CommentAttachmentFileOwnerService` регистрируется в реестре. Файл виден + тем, кому видна задача неудалённого комментария с этим файлом. +- `CommentAuthorExpanderService` переименован в `CommentExpanderService`: + он раскрывает и авторов, и файлы (параллельно). + +## Прочие изменения + +- **Зависимости:** `@aws-sdk/client-s3`, `file-type`, `@nestjs/schedule`; + dev — `@types/multer`. +- **`tsconfig.json`:** `multer` добавлен в `types`, иначе не виден + `Express.Multer.File`. +- **`.gitignore` / `.dockerignore`:** добавлен `/storage` — каталог + локального хранилища. + +## Переменные окружения + +``` +FILE_STORAGE=s3 # local (по умолчанию) | s3 +FILE_STORAGE_LOCAL_DIR=./storage +FILE_MAX_SIZE_MB=25 +FILE_ORPHAN_TTL_HOURS=24 + +S3_ENDPOINT=http://rustfs:9000 # для AWS можно не задавать +S3_REGION=us-east-1 +S3_BUCKET=ulitka +S3_ACCESS_KEY_ID=... +S3_SECRET_ACCESS_KEY=... +S3_FORCE_PATH_STYLE=true # для RustFS/MinIO +``` + +В `.env` были закомментированы `RUSTFS_*`. Адаптер использует общие `S3_*`. + +## Проверено + +- 154 unit-теста проходят. Типы, линтер и сборка чистые. +- Приложение стартует, DI резолвится, новые маршруты без сессии отвечают 401. + В `schema.gql` появились `File`, `IssueAttachment`, `IssueDetail.attachments`. +- **Не проверено:** сценарий целиком с авторизацией, реальной БД и S3 + (upload → attach → download → GC). + +## TODO / открытые вопросы + +- [ ] Перед деплоем: создать бакет в RustFS (приложение его не создаёт), + прописать `FILE_STORAGE=s3` и `S3_*` на сервере. +- [ ] Прогнать e2e: загрузка → прикрепление → скачивание другим участником → + открепление → GC. +- [ ] Влить `feature/attachments` в `main`. Push в `main` запускает деплой — + сначала бакет и переменные окружения. +- [ ] Лимит числа вложений у задачи (у комментария — 10). +- [ ] SSE-события для вложений задачи (по образцу `CommentEventsService`). +- [ ] Комментарий только с файлами, без текста? Сейчас текст обязателен. +- [ ] Вложения комментариев в GraphQL — когда появятся комментарии в GraphQL. +- [ ] GC при большом числе файлов: сейчас каждый запуск проходит по всем старым + файлам. При сотнях тысяч файлов — переписать на `NOT EXISTS`. +- [ ] Маловероятная гонка: прикрепление файла, загруженного больше 24 ч назад, + ровно в момент прохода GC может его потерять. +- [ ] Позже: отдавать скачивание redirect'ом на presigned GET вместо стрима + через Node.