Add WIP notes on attachments work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vadimandClaude Opus 5.5 committed 2026-10-02 09:18:15 +03:00
1 parent 60fb39f98f
commit d0fcc93a8d
1 file changed
+214
+214
View File
@@ -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.