# 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.