Compare commits
19
Commits
5cfc8986d7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ac4d314e4 | ||
|
|
917ea9748e | ||
|
|
d170fc8790 | ||
|
|
b1f6b61994 | ||
|
|
46903e9ec5 | ||
|
|
f085517732 | ||
|
|
de1b3754df | ||
|
|
c5691da2b8 | ||
|
|
afc37f8fdb | ||
|
|
8d02d25da0 | ||
|
|
5ad0ecdf4c | ||
|
|
7aecc6f96e | ||
|
|
cb29c7d2c4 | ||
|
|
f44590b8ca | ||
|
|
5607e97533 | ||
|
|
d0fcc93a8d | ||
|
|
60fb39f98f | ||
|
|
423afa658d | ||
|
|
f32c6e348d |
No files matched your search
@@ -10,3 +10,6 @@ coverage
|
|||||||
*.log
|
*.log
|
||||||
tsconfig.build.tsbuildinfo
|
tsconfig.build.tsbuildinfo
|
||||||
src/generated
|
src/generated
|
||||||
|
storage
|
||||||
|
configs
|
||||||
|
service-account.json
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Gitea Actions Demo
|
name: Gitea Actions
|
||||||
run-name: Deploy 🚀
|
run-name: Deploy 🚀
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- name: Docker build
|
- name: Docker build
|
||||||
run: docker build --tag ${{ env.IMAGE_NAME }} .
|
run: docker build --tag ${{ env.IMAGE_NAME }} .
|
||||||
- name: Login to gitea
|
- name: Login to gitea
|
||||||
|
|||||||
@@ -58,3 +58,8 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
/src/schema.gql
|
/src/schema.gql
|
||||||
|
|
||||||
|
# Local file storage (FILE_STORAGE=local)
|
||||||
|
/storage
|
||||||
|
/configs
|
||||||
|
configs/service-account.json
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# Prisma
|
||||||
|
|
||||||
npm install prisma@7 --save-dev
|
npm install prisma@7 --save-dev
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
Generated
+2767
-952
File diff suppressed because it is too large.
Load diff
+20
-17
@@ -24,44 +24,47 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/server": "^5.5.1",
|
"@apollo/server": "^5.5.1",
|
||||||
"@as-integrations/express5": "^1.1.2",
|
"@as-integrations/express5": "^1.1.2",
|
||||||
"@better-auth/prisma-adapter": "^1.7.2",
|
"@aws-sdk/client-s3": "^3.1152.0",
|
||||||
|
"@better-auth/prisma-adapter": "^1.7.11",
|
||||||
"@nestjs/apollo": "^14.0.0",
|
"@nestjs/apollo": "^14.0.0",
|
||||||
"@nestjs/common": "^12.0.1",
|
"@nestjs/common": "^12.2.0",
|
||||||
"@nestjs/config": "^12.0.0",
|
"@nestjs/config": "^12.0.1",
|
||||||
"@nestjs/core": "^12.0.1",
|
"@nestjs/core": "^12.2.0",
|
||||||
"@nestjs/event-emitter": "^12.0.1",
|
"@nestjs/event-emitter": "^12.0.1",
|
||||||
"@nestjs/graphql": "^14.0.0",
|
"@nestjs/graphql": "^14.0.0",
|
||||||
"@nestjs/platform-express": "^12.0.1",
|
"@nestjs/platform-express": "^12.2.0",
|
||||||
"@nestjs/typeorm": "^12.0.1",
|
"@nestjs/schedule": "^12.0.2",
|
||||||
"@prisma/adapter-pg": "^7.10.0",
|
"@prisma/adapter-pg": "^7.10.0",
|
||||||
"@prisma/client": "^7.10.0",
|
"@prisma/client": "^7.10.0",
|
||||||
"@thallesp/nestjs-better-auth": "^2.7.0",
|
"@thallesp/nestjs-better-auth": "^2.8.0",
|
||||||
"better-auth": "^1.7.2",
|
"better-auth": "^1.7.11",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
"file-type": "^22.2.0",
|
||||||
|
"firebase-admin": "^14.5.0",
|
||||||
"graphql": "^16.14.2",
|
"graphql": "^16.14.2",
|
||||||
"nodemailer": "^10.0.0",
|
"nodemailer": "^10.1.7",
|
||||||
"pg": "^8.23.0",
|
"pg": "^8.23.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^1.1.1",
|
"valibot": "^1.5.0"
|
||||||
"valibot": "^1.4.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^12.0.0",
|
"@nestjs/cli": "^12.0.0",
|
||||||
"@nestjs/mau": "^0.2.6",
|
"@nestjs/mau": "^0.2.8",
|
||||||
"@nestjs/schematics": "^12.0.0",
|
"@nestjs/schematics": "^12.0.0",
|
||||||
"@nestjs/testing": "^12.0.1",
|
"@nestjs/testing": "^12.2.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/multer": "^2.3.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/pg": "^8.23.1",
|
"@types/pg": "^8.23.1",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.2.1",
|
||||||
"@vitest/coverage-v8": "^4.1.2",
|
"@vitest/coverage-v8": "^4.1.2",
|
||||||
"oxlint": "^1.58.0",
|
"oxlint": "^1.88.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.9.10",
|
||||||
"prisma": "^7.10.0",
|
"prisma": "^7.10.0",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.3.1",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^4.1.2"
|
"vitest": "^4.1.2"
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "file" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"storageKey" TEXT NOT NULL,
|
||||||
|
"fileName" TEXT NOT NULL,
|
||||||
|
"mimeType" TEXT NOT NULL,
|
||||||
|
"size" INTEGER NOT NULL,
|
||||||
|
"uploaderId" TEXT,
|
||||||
|
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "file_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "file_uploaderId_idx" ON "file"("uploaderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "file_createdAt_idx" ON "file"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "file_storageKey_key" ON "file"("storageKey");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "file" ADD CONSTRAINT "file_uploaderId_fkey" FOREIGN KEY ("uploaderId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "issue_attachment" (
|
||||||
|
"issueId" INTEGER NOT NULL,
|
||||||
|
"fileId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "issue_attachment_pkey" PRIMARY KEY ("issueId","fileId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "issue_attachment_fileId_idx" ON "issue_attachment"("fileId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "issue_attachment" ADD CONSTRAINT "issue_attachment_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "issue_attachment" ADD CONSTRAINT "issue_attachment_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "file"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "comment_attachment" (
|
||||||
|
"commentId" TEXT NOT NULL,
|
||||||
|
"fileId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "comment_attachment_pkey" PRIMARY KEY ("commentId","fileId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "comment_attachment_fileId_idx" ON "comment_attachment"("fileId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "comment_attachment" ADD CONSTRAINT "comment_attachment_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "comment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "comment_attachment" ADD CONSTRAINT "comment_attachment_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "file"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "issue_change" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"issueId" INTEGER NOT NULL,
|
||||||
|
"changeId" TEXT NOT NULL,
|
||||||
|
"field" TEXT NOT NULL,
|
||||||
|
"oldValue" TEXT,
|
||||||
|
"newValue" TEXT,
|
||||||
|
"actorId" TEXT,
|
||||||
|
"changedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "issue_change_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "issue_change_issueId_changedAt_idx" ON "issue_change"("issueId", "changedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "issue_change_field_changedAt_idx" ON "issue_change"("field", "changedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "issue_change_actorId_idx" ON "issue_change"("actorId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "issue_change" ADD CONSTRAINT "issue_change_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "issue_change" ADD CONSTRAINT "issue_change_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "issue_projectId_dueDate_idx" ON "issue"("projectId", "dueDate");
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "issue" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'TASK';
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "issue_projectId_type_idx" ON "issue"("projectId", "type");
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "user" ADD COLUMN "avatarFileId" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_avatarFileId_key" ON "user"("avatarFileId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "user" ADD CONSTRAINT "user_avatarFileId_fkey" FOREIGN KEY ("avatarFileId") REFERENCES "file"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "issue_observer" (
|
||||||
|
"issueId" INTEGER NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "issue_observer_pkey" PRIMARY KEY ("issueId","userId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "issue_observer_userId_idx" ON "issue_observer"("userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "issue_observer" ADD CONSTRAINT "issue_observer_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "issue_observer" ADD CONSTRAINT "issue_observer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ProjectRole" AS ENUM ('MEMBER', 'OBSERVER');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "project_member" ADD COLUMN "role" "ProjectRole" NOT NULL DEFAULT 'MEMBER';
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "tag" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"normalizedName" TEXT NOT NULL,
|
||||||
|
"color" TEXT NOT NULL DEFAULT 'default',
|
||||||
|
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "tag_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "issue_tag" (
|
||||||
|
"issueId" INTEGER NOT NULL,
|
||||||
|
"tagId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "issue_tag_pkey" PRIMARY KEY ("issueId","tagId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "tag_projectId_normalizedName_key" ON "tag"("projectId", "normalizedName");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "issue_tag_tagId_idx" ON "issue_tag"("tagId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "tag" ADD CONSTRAINT "tag_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "issue_tag" ADD CONSTRAINT "issue_tag_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "issue_tag" ADD CONSTRAINT "issue_tag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
+180
-28
@@ -13,6 +13,10 @@ model User {
|
|||||||
email String
|
email String
|
||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
image String?
|
image String?
|
||||||
|
/// Файл аватара; image хранит его URL для better-auth и клиента.
|
||||||
|
/// Меняется только через PUT /users/me/avatar — там проверяется, что файл свой.
|
||||||
|
avatarFileId String? @unique
|
||||||
|
avatarFile File? @relation(name: "userAvatar", fields: [avatarFileId], references: [id], onDelete: SetNull)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
@@ -25,6 +29,9 @@ model User {
|
|||||||
notificationPreferences NotificationPreference[]
|
notificationPreferences NotificationPreference[]
|
||||||
notificationMessages NotificationMessage[]
|
notificationMessages NotificationMessage[]
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
|
uploadedFiles File[] @relation(name: "fileUploader")
|
||||||
|
issueChanges IssueChange[]
|
||||||
|
observedIssues IssueObserver[]
|
||||||
|
|
||||||
@@unique([email])
|
@@unique([email])
|
||||||
@@map("user")
|
@@map("user")
|
||||||
@@ -80,22 +87,27 @@ model Verification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Issue {
|
model Issue {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String
|
key String
|
||||||
title String
|
title String
|
||||||
content String?
|
content String?
|
||||||
status String
|
status String
|
||||||
priority Int @default(0)
|
priority Int @default(0)
|
||||||
assigneeId String?
|
type String @default("TASK")
|
||||||
assignee User? @relation(name: "assigneeIssues", fields: [assigneeId], references: [id], onDelete: SetNull)
|
assigneeId String?
|
||||||
reporterId String?
|
assignee User? @relation(name: "assigneeIssues", fields: [assigneeId], references: [id], onDelete: SetNull)
|
||||||
reporter User? @relation(name: "reporterIssues", fields: [reporterId], references: [id], onDelete: SetNull)
|
reporterId String?
|
||||||
projectId String
|
reporter User? @relation(name: "reporterIssues", fields: [reporterId], references: [id], onDelete: SetNull)
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
projectId String
|
||||||
dueDate DateTime? @db.Timestamptz(3)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
comments Comment[]
|
dueDate DateTime? @db.Timestamptz(3)
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
comments Comment[]
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
attachments IssueAttachment[]
|
||||||
|
changes IssueChange[]
|
||||||
|
observers IssueObserver[]
|
||||||
|
tags IssueTag[]
|
||||||
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([assigneeId])
|
@@index([assigneeId])
|
||||||
@@ -104,9 +116,26 @@ model Issue {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([projectId, createdAt])
|
@@index([projectId, createdAt])
|
||||||
@@index([dueDate])
|
@@index([dueDate])
|
||||||
|
@@index([projectId, dueDate])
|
||||||
|
@@index([projectId, type])
|
||||||
@@map("issue")
|
@@map("issue")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Наблюдатель задачи: видит её и может комментировать, но не редактировать.
|
||||||
|
/// Редактируют задачу только reporter и assignee.
|
||||||
|
model IssueObserver {
|
||||||
|
issueId Int
|
||||||
|
userId String
|
||||||
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
|
||||||
|
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([issueId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("issue_observer")
|
||||||
|
}
|
||||||
|
|
||||||
model Project {
|
model Project {
|
||||||
id String @id @default(uuid(7))
|
id String @id @default(uuid(7))
|
||||||
name String
|
name String
|
||||||
@@ -117,6 +146,7 @@ model Project {
|
|||||||
ownerId String
|
ownerId String
|
||||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Restrict)
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Restrict)
|
||||||
members ProjectMember[]
|
members ProjectMember[]
|
||||||
|
tags Tag[]
|
||||||
|
|
||||||
@@unique([key])
|
@@unique([key])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@ -124,11 +154,51 @@ model Project {
|
|||||||
@@map("project")
|
@@map("project")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Тег — элемент справочника проекта. Уникален в проекте без учёта регистра:
|
||||||
|
/// normalizedName — name в нижнем регистре. color — из палитры (TAG_COLORS),
|
||||||
|
/// валидируется в коде. Задаче тег назначают по имени — отсутствующий создаётся.
|
||||||
|
model Tag {
|
||||||
|
id String @id @default(uuid(7))
|
||||||
|
projectId String
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
name String
|
||||||
|
normalizedName String
|
||||||
|
color String @default("default")
|
||||||
|
issues IssueTag[]
|
||||||
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
|
@@unique([projectId, normalizedName])
|
||||||
|
@@map("tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Тег задачи. Удаление тега из справочника снимает его со всех задач (без записи в историю).
|
||||||
|
model IssueTag {
|
||||||
|
issueId Int
|
||||||
|
tagId String
|
||||||
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
|
||||||
|
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
|
||||||
|
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([issueId, tagId])
|
||||||
|
@@index([tagId])
|
||||||
|
@@map("issue_tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MEMBER создаёт задачи в проекте; OBSERVER только видит проект —
|
||||||
|
/// он наблюдает за отдельными задачами (IssueObserver).
|
||||||
|
enum ProjectRole {
|
||||||
|
MEMBER
|
||||||
|
OBSERVER
|
||||||
|
}
|
||||||
|
|
||||||
model ProjectMember {
|
model ProjectMember {
|
||||||
id String @id @default(uuid(7))
|
id String @id @default(uuid(7))
|
||||||
projectId String
|
projectId String
|
||||||
userId String
|
userId String
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
role ProjectRole @default(MEMBER)
|
||||||
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
@@ -194,18 +264,100 @@ model NotificationMessage {
|
|||||||
/// Комментарий к задаче. При удалении автора комментарий остаётся (authorId -> NULL).
|
/// Комментарий к задаче. При удалении автора комментарий остаётся (authorId -> NULL).
|
||||||
/// deletedAt — мягкое удаление, editedAt — время последней правки текста.
|
/// deletedAt — мягкое удаление, editedAt — время последней правки текста.
|
||||||
model Comment {
|
model Comment {
|
||||||
id String @id @default(uuid(7))
|
id String @id @default(uuid(7))
|
||||||
content String
|
content String
|
||||||
issueId Int
|
issueId Int
|
||||||
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
|
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
|
||||||
authorId String?
|
authorId String?
|
||||||
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
||||||
editedAt DateTime? @db.Timestamptz(3)
|
editedAt DateTime? @db.Timestamptz(3)
|
||||||
deletedAt DateTime? @db.Timestamptz(3)
|
deletedAt DateTime? @db.Timestamptz(3)
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
attachments CommentAttachment[]
|
||||||
|
|
||||||
@@index([issueId, createdAt])
|
@@index([issueId, createdAt])
|
||||||
@@index([authorId])
|
@@index([authorId])
|
||||||
@@map("comment")
|
@@map("comment")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Загруженный файл. Ничего не знает о владельце: к задачам, комментариям и т.п.
|
||||||
|
/// файлы привязываются через таблицы связей в модулях-владельцах.
|
||||||
|
/// Файл, на который никто не ссылается, считается сиротой и удаляется GC.
|
||||||
|
/// storageKey — ключ объекта в хранилище, имя от пользователя в него не попадает.
|
||||||
|
model File {
|
||||||
|
id String @id @default(uuid(7))
|
||||||
|
storageKey String
|
||||||
|
fileName String
|
||||||
|
mimeType String
|
||||||
|
size Int
|
||||||
|
uploaderId String?
|
||||||
|
uploader User? @relation(name: "fileUploader", fields: [uploaderId], references: [id], onDelete: SetNull)
|
||||||
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
|
||||||
|
issueAttachments IssueAttachment[]
|
||||||
|
commentAttachments CommentAttachment[]
|
||||||
|
avatarOf User? @relation(name: "userAvatar")
|
||||||
|
|
||||||
|
@@unique([storageKey])
|
||||||
|
@@index([uploaderId])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("file")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Вложение задачи — связь задачи с файлом. Удаление задачи или файла
|
||||||
|
/// удаляет связь каскадом; сам файл, оставшийся без связей, удалит GC.
|
||||||
|
model IssueAttachment {
|
||||||
|
issueId Int
|
||||||
|
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
|
||||||
|
fileId String
|
||||||
|
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
|
||||||
|
@@id([issueId, fileId])
|
||||||
|
@@index([fileId])
|
||||||
|
@@map("issue_attachment")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Вложение комментария — связь комментария с файлом. Управляется вместе
|
||||||
|
/// с комментарием (fileIds в create/update); при мягком удалении комментария
|
||||||
|
/// связи снимаются, чтобы файлы забрал GC.
|
||||||
|
model CommentAttachment {
|
||||||
|
commentId String
|
||||||
|
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||||
|
fileId String
|
||||||
|
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
|
||||||
|
@@id([commentId, fileId])
|
||||||
|
@@index([fileId])
|
||||||
|
@@map("comment_attachment")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// История изменений задачи — одна строка на изменённое поле.
|
||||||
|
/// Строки одного сохранения задачи объединены общим changeId.
|
||||||
|
/// field: created | title | content | status | priority | type | assignee | dueDate | observer | tag
|
||||||
|
/// (валидируется в коде, см. ISSUE_CHANGE_FIELDS). Значения — строки:
|
||||||
|
/// priority — число, dueDate — ISO-дата, assignee — id пользователя.
|
||||||
|
/// observer и tag — по строке на добавленный (oldValue = null) или снятый (newValue = null) элемент;
|
||||||
|
/// observer — id пользователя, tag — имя тега на момент изменения.
|
||||||
|
/// При создании задачи пишутся две строки: created и status null -> OPEN,
|
||||||
|
/// поэтому статус задачи на любой момент восстанавливается по строкам status.
|
||||||
|
/// У задач, созданных до появления истории, строк нет — в статистику они не попадают.
|
||||||
|
model IssueChange {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
issueId Int
|
||||||
|
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
|
||||||
|
changeId String
|
||||||
|
field String
|
||||||
|
oldValue String?
|
||||||
|
newValue String?
|
||||||
|
actorId String?
|
||||||
|
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
|
||||||
|
changedAt DateTime @db.Timestamptz(3)
|
||||||
|
|
||||||
|
@@index([issueId, changedAt])
|
||||||
|
@@index([field, changedAt])
|
||||||
|
@@index([actorId])
|
||||||
|
@@map("issue_change")
|
||||||
|
}
|
||||||
+12
-7
@@ -5,12 +5,18 @@ import { IssueModule } from './modules/issue/issue.module.js';
|
|||||||
import { ProjectModule } from './modules/project/project.module.js';
|
import { ProjectModule } from './modules/project/project.module.js';
|
||||||
import { UserModule } from './modules/user/user.module.js';
|
import { UserModule } from './modules/user/user.module.js';
|
||||||
import { StatusModule } from './modules/status/status.module.js';
|
import { StatusModule } from './modules/status/status.module.js';
|
||||||
|
import { IssueTypeModule } from './modules/issue-type/issue-type.module.js';
|
||||||
import { NotificationModule } from './modules/notification/notification.module.js';
|
import { NotificationModule } from './modules/notification/notification.module.js';
|
||||||
import { CommentModule } from './modules/comment/comment.module.js';
|
import { CommentModule } from './modules/comment/comment.module.js';
|
||||||
|
import { FileModule } from './modules/file/file.module.js';
|
||||||
|
import { IssueAttachmentModule } from './modules/issue-attachment/issue-attachment.module.js';
|
||||||
|
import { HistoryModule } from './modules/history/history.module.js';
|
||||||
|
import { TagModule } from './modules/tag/tag.module.js';
|
||||||
import { AuthModule } from '@thallesp/nestjs-better-auth';
|
import { AuthModule } from '@thallesp/nestjs-better-auth';
|
||||||
import { auth } from './lib/auth.js';
|
import { auth } from './lib/auth.js';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { GraphQLModule } from '@nestjs/graphql';
|
import { GraphQLModule } from '@nestjs/graphql';
|
||||||
import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo';
|
import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
@@ -21,19 +27,13 @@ import { join } from 'node:path';
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
cache: true,
|
cache: true,
|
||||||
}),
|
}),
|
||||||
// Внутрипроцессная шина доменных событий. Notification-модуль слушает
|
|
||||||
// события через неё, не завязываясь на модули, которые их порождают.
|
|
||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
GraphQLModule.forRoot<ApolloDriverConfig>({
|
GraphQLModule.forRoot<ApolloDriverConfig>({
|
||||||
driver: ApolloDriver,
|
driver: ApolloDriver,
|
||||||
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
|
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
|
||||||
sortSchema: true,
|
sortSchema: true,
|
||||||
}),
|
}),
|
||||||
// TypeOrmModule.forRootAsync({
|
|
||||||
// imports: [ConfigModule],
|
|
||||||
// inject: [ConfigService],
|
|
||||||
// useFactory: pgConfig,
|
|
||||||
// }),
|
|
||||||
AuthModule.forRoot({
|
AuthModule.forRoot({
|
||||||
auth: auth,
|
auth: auth,
|
||||||
bodyParser: {
|
bodyParser: {
|
||||||
@@ -46,8 +46,13 @@ import { join } from 'node:path';
|
|||||||
ProjectModule,
|
ProjectModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
StatusModule,
|
StatusModule,
|
||||||
|
IssueTypeModule,
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
CommentModule,
|
CommentModule,
|
||||||
|
FileModule,
|
||||||
|
IssueAttachmentModule,
|
||||||
|
HistoryModule,
|
||||||
|
TagModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
import { IsBoolean, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
import { applyDecorators } from '@nestjs/common';
|
import { applyDecorators } from '@nestjs/common';
|
||||||
|
|
||||||
interface ToStringOptions {
|
interface ToStringOptions {
|
||||||
@@ -11,6 +11,7 @@ interface BaseDecoratorParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StringDecoratorParams extends BaseDecoratorParams {}
|
export interface StringDecoratorParams extends BaseDecoratorParams {}
|
||||||
|
export interface BooleanDecoratorParams extends BaseDecoratorParams {}
|
||||||
export interface IntDecoratorParams extends BaseDecoratorParams {
|
export interface IntDecoratorParams extends BaseDecoratorParams {
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
@@ -24,6 +25,15 @@ const ToNumber = () =>
|
|||||||
return Number.isNaN(parsed) ? undefined : parsed;
|
return Number.isNaN(parsed) ? undefined : parsed;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Query-параметры приходят строками: 'true'/'false' → boolean, остальное оставляем как есть для IsBoolean */
|
||||||
|
const ToBoolean = () =>
|
||||||
|
Transform(({ value }) => {
|
||||||
|
if (value === undefined || value === null || value === '') return undefined;
|
||||||
|
if (value === 'true') return true;
|
||||||
|
if (value === 'false') return false;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
const ToString = (options?: ToStringOptions) =>
|
const ToString = (options?: ToStringOptions) =>
|
||||||
Transform(({ value }: TransformFnParams) => {
|
Transform(({ value }: TransformFnParams) => {
|
||||||
if (value === undefined || value === null) return undefined;
|
if (value === undefined || value === null) return undefined;
|
||||||
@@ -52,3 +62,11 @@ export function IntDecorator(params?: IntDecoratorParams) {
|
|||||||
|
|
||||||
return applyDecorators(...args);
|
return applyDecorators(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BooleanDecorator(params?: BooleanDecoratorParams) {
|
||||||
|
const args = [IsBoolean(), ToBoolean()];
|
||||||
|
|
||||||
|
if (params?.isOptional) args.push(IsOptional());
|
||||||
|
|
||||||
|
return applyDecorators(...args);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, StreamableFile } from '@nestjs/common';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { SSE_METADATA } from '@nestjs/common/constants.js';
|
import { SSE_METADATA } from '@nestjs/common/constants.js';
|
||||||
@@ -46,6 +46,11 @@ export class TransformResponseInterceptor<T> implements NestInterceptor<
|
|||||||
|
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
map((result) => {
|
map((result) => {
|
||||||
|
// Файл отдаётся потоком как есть — обёртка {data, code} сломала бы скачивание
|
||||||
|
if (result instanceof StreamableFile) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
if (isPaginatedResult<T>(result)) {
|
if (isPaginatedResult<T>(result)) {
|
||||||
return {
|
return {
|
||||||
data: result.data ?? null,
|
data: result.data ?? null,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
|
||||||
|
|
||||||
export function pgConfig(config: ConfigService): TypeOrmModuleOptions {
|
export function pgConfig(config: ConfigService): unknown {
|
||||||
return {
|
return {
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
url: config.getOrThrow<string>('DATABASE_URL'),
|
url: config.getOrThrow<string>('DATABASE_URL'),
|
||||||
|
|||||||
+28
-11
@@ -2,12 +2,35 @@ import { betterAuth } from 'better-auth';
|
|||||||
import { prismaAdapter } from '@better-auth/prisma-adapter';
|
import { prismaAdapter } from '@better-auth/prisma-adapter';
|
||||||
import prisma from './prisma.js';
|
import prisma from './prisma.js';
|
||||||
import EmailSender from './email-sender.js';
|
import EmailSender from './email-sender.js';
|
||||||
|
import { emailOTP } from 'better-auth/plugins';
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
appName: process.env.APP_NAME ?? 'app',
|
appName: process.env.APP_NAME ?? 'app',
|
||||||
basePath: '/api/auth',
|
basePath: '/api/auth',
|
||||||
plugins: [
|
plugins: [
|
||||||
// authTransformResponse()
|
// authTransformResponse()
|
||||||
|
emailOTP({
|
||||||
|
async sendVerificationOTP({ email, otp, type }) {
|
||||||
|
if (type === 'sign-in') {
|
||||||
|
EmailSender.sendInBackground({
|
||||||
|
to: email,
|
||||||
|
subject: `Your OTP code is: ${otp}`,
|
||||||
|
text: `Your OTP code is: ${otp}`,
|
||||||
|
});
|
||||||
|
} else if (type === 'email-verification') {
|
||||||
|
// Send the OTP for email verification
|
||||||
|
console.log({ email, otp, type });
|
||||||
|
} else if (type === 'forget-password') {
|
||||||
|
EmailSender.sendInBackground({
|
||||||
|
to: email,
|
||||||
|
subject: `Your password reset code is: ${otp}`,
|
||||||
|
text: `Your password reset code is: ${otp}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log({ email, otp, type });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
database: prismaAdapter(prisma, {
|
database: prismaAdapter(prisma, {
|
||||||
provider: 'postgresql',
|
provider: 'postgresql',
|
||||||
@@ -24,28 +47,23 @@ export const auth = betterAuth({
|
|||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
autoSignIn: false,
|
autoSignIn: false,
|
||||||
requireEmailVerification: false,
|
requireEmailVerification: true,
|
||||||
onExistingUserSignUp: async ({ user }, request) => {
|
onExistingUserSignUp: async ({ user }, request) => {
|
||||||
console.log('onExistingUserSignUp', { user });
|
EmailSender.sendInBackground({
|
||||||
|
|
||||||
void EmailSender.getTransporter().sendMail({
|
|
||||||
to: user.email,
|
to: user.email,
|
||||||
subject: 'Sign-up attempt with your email',
|
subject: 'Sign-up attempt with your email',
|
||||||
text: 'Someone tried to create an account using your email address. If this was you, try signing in instead. If not, you can safely ignore this email.',
|
text: 'Someone tried to create an account using your email address. If this was you, try signing in instead. If not, you can safely ignore this email.',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
sendResetPassword: async ({ user, url, token }, request) => {
|
sendResetPassword: async ({ user, url, token }, request) => {
|
||||||
console.log('sendResetPassword', { user, url, token });
|
EmailSender.sendInBackground({
|
||||||
void EmailSender.getTransporter().sendMail({
|
|
||||||
to: user.email,
|
to: user.email,
|
||||||
subject: 'Reset your password',
|
subject: 'Reset your password',
|
||||||
text: `Click the link to reset your password: ${url}`,
|
text: `Click the link to reset your password: ${url}`,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onPasswordReset: async ({ user }, request) => {
|
onPasswordReset: async ({ user }, request) => {
|
||||||
// your logic here
|
EmailSender.sendInBackground({
|
||||||
console.log('onPasswordReset', { user });
|
|
||||||
void EmailSender.getTransporter().sendMail({
|
|
||||||
to: user.email,
|
to: user.email,
|
||||||
subject: 'Reset your password result',
|
subject: 'Reset your password result',
|
||||||
text: `Password for user ${user.email} has been reset.`,
|
text: `Password for user ${user.email} has been reset.`,
|
||||||
@@ -56,8 +74,7 @@ export const auth = betterAuth({
|
|||||||
sendOnSignIn: true,
|
sendOnSignIn: true,
|
||||||
autoSignInAfterVerification: true,
|
autoSignInAfterVerification: true,
|
||||||
sendVerificationEmail: async ({ user, url }) => {
|
sendVerificationEmail: async ({ user, url }) => {
|
||||||
console.log('emailVerification', { user, url });
|
EmailSender.sendInBackground({
|
||||||
void EmailSender.getTransporter().sendMail({
|
|
||||||
to: user.email,
|
to: user.email,
|
||||||
subject: 'Verify your email address',
|
subject: 'Verify your email address',
|
||||||
text: `Click the link to verify your email: ${url}`,
|
text: `Click the link to verify your email: ${url}`,
|
||||||
|
|||||||
+33
-2
@@ -1,13 +1,44 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import nodemailer, { SendMailOptions, Transporter } from 'nodemailer';
|
import nodemailer, { SendMailOptions, Transporter } from 'nodemailer';
|
||||||
|
|
||||||
export default class EmailSender {
|
export default class EmailSender {
|
||||||
static getTransporter() {
|
private static readonly logger = new Logger(EmailSender.name);
|
||||||
return nodemailer.createTransport({
|
private static transporter?: Transporter;
|
||||||
|
|
||||||
|
private static isLocal() {
|
||||||
|
return process.env.NODE_ENV === 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getTransporter() {
|
||||||
|
this.transporter ??= nodemailer.createTransport({
|
||||||
service: 'gmail',
|
service: 'gmail',
|
||||||
auth: {
|
auth: {
|
||||||
user: process.env.GOOGLE_APP_USER,
|
user: process.env.GOOGLE_APP_USER,
|
||||||
pass: process.env.GOOGLE_APP_PASSWORD,
|
pass: process.env.GOOGLE_APP_PASSWORD,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return this.transporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends an email; throws on failure. In local env only logs the message. */
|
||||||
|
static async send(options: SendMailOptions): Promise<void> {
|
||||||
|
if (this.isLocal()) {
|
||||||
|
this.logger.log(
|
||||||
|
`[local] email not sent\n to: ${String(options.to)}\n subject: ${options.subject}\n text: ${String(options.text)}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.getTransporter().sendMail(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget variant: never throws, errors are logged. */
|
||||||
|
static sendInBackground(options: SendMailOptions): void {
|
||||||
|
this.send(options).catch((error: unknown) => {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to send email "${options.subject}" to ${String(options.to)}`,
|
||||||
|
error instanceof Error ? error.stack : String(error),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,12 +16,7 @@ async function bootstrap() {
|
|||||||
logger: new CustomLogger(),
|
logger: new CustomLogger(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// app.useGlobalPipes(new StandardSchemaValidationPipe());
|
|
||||||
|
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
// Глобальный body-parser отключён (better-auth сам парсит своё тело),
|
|
||||||
// поэтому для GraphQL включаем JSON-парсинг точечно на его пути.
|
|
||||||
app.use('/graphql', json());
|
app.use('/graphql', json());
|
||||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||||
app.useGlobalInterceptors(new TransformResponseInterceptor());
|
app.useGlobalInterceptors(new TransformResponseInterceptor());
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { UserDto } from '../../../user/application/dto/user.dto.js';
|
import { UserDto } from '../../../user/application/dto/user.dto.js';
|
||||||
|
import { FileDto } from '../../../file/application/dto/file.dto.js';
|
||||||
|
|
||||||
export class CommentDto {
|
export class CommentDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -8,6 +9,8 @@ export class CommentDto {
|
|||||||
author: UserDto | null;
|
author: UserDto | null;
|
||||||
/** null у удалённого комментария — текст наружу не отдаём */
|
/** null у удалённого комментария — текст наружу не отдаём */
|
||||||
content: string | null;
|
content: string | null;
|
||||||
|
/** Пусто у удалённого комментария. Скачивание — GET /files/:id/download */
|
||||||
|
attachments: FileDto[];
|
||||||
editedAt: Date | null;
|
editedAt: Date | null;
|
||||||
deletedAt: Date | null;
|
deletedAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ const author: UserDto = {
|
|||||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildComment = (deletedAt: Date | null = null, authorId: string | null = 'user-1') =>
|
const buildComment = (deletedAt: Date | null = null, authorId: string | null = 'user-1', fileIds: string[] = []) =>
|
||||||
Comment.reconstitute({
|
Comment.reconstitute({
|
||||||
id: 'comment-1',
|
id: 'comment-1',
|
||||||
content: 'Secret',
|
content: 'Secret',
|
||||||
issueId: 1,
|
issueId: 1,
|
||||||
authorId,
|
authorId,
|
||||||
|
fileIds,
|
||||||
editedAt: null,
|
editedAt: null,
|
||||||
deletedAt,
|
deletedAt,
|
||||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
@@ -46,4 +47,28 @@ describe('CommentApplicationMapper', () => {
|
|||||||
expect(dto.authorId).toBeNull();
|
expect(dto.authorId).toBeNull();
|
||||||
expect(dto.author).toBeNull();
|
expect(dto.author).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('expands attachments and skips files that no longer exist', () => {
|
||||||
|
const file = {
|
||||||
|
id: 'file-1',
|
||||||
|
fileName: 'a.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
size: 1,
|
||||||
|
uploaderId: 'user-1',
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
};
|
||||||
|
const dto = CommentApplicationMapper.toDto(
|
||||||
|
buildComment(null, 'user-1', ['file-1', 'gone']),
|
||||||
|
new Map(),
|
||||||
|
new Map([[file.id, file]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dto.attachments).toEqual([file]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides attachments of a deleted comment', () => {
|
||||||
|
const dto = CommentApplicationMapper.toDto(buildComment(new Date(), 'user-1', ['file-1']), new Map(), new Map());
|
||||||
|
|
||||||
|
expect(dto.attachments).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,15 +1,27 @@
|
|||||||
import { Comment } from '../../domain/entities/comment.entity.js';
|
import { Comment } from '../../domain/entities/comment.entity.js';
|
||||||
import { CommentDto } from '../dto/comment.dto.js';
|
import { CommentDto } from '../dto/comment.dto.js';
|
||||||
import { UserDto } from '../../../user/application/dto/user.dto.js';
|
import { UserDto } from '../../../user/application/dto/user.dto.js';
|
||||||
|
import { FileDto } from '../../../file/application/dto/file.dto.js';
|
||||||
|
|
||||||
export class CommentApplicationMapper {
|
export class CommentApplicationMapper {
|
||||||
static toDto(comment: Comment, usersById: ReadonlyMap<string, UserDto>): CommentDto {
|
static toDto(
|
||||||
|
comment: Comment,
|
||||||
|
usersById: ReadonlyMap<string, UserDto>,
|
||||||
|
filesById: ReadonlyMap<string, FileDto> = new Map(),
|
||||||
|
): CommentDto {
|
||||||
return {
|
return {
|
||||||
id: comment.id as string,
|
id: comment.id as string,
|
||||||
issueId: comment.issueId,
|
issueId: comment.issueId,
|
||||||
authorId: comment.authorId,
|
authorId: comment.authorId,
|
||||||
author: comment.authorId ? (usersById.get(comment.authorId) ?? null) : null,
|
author: comment.authorId ? (usersById.get(comment.authorId) ?? null) : null,
|
||||||
content: comment.isDeleted ? null : comment.content,
|
content: comment.isDeleted ? null : comment.content,
|
||||||
|
// Файл могли удалить между запросами — такое вложение просто не показываем
|
||||||
|
attachments: comment.isDeleted
|
||||||
|
? []
|
||||||
|
: comment.fileIds.flatMap((fileId) => {
|
||||||
|
const file = filesById.get(fileId);
|
||||||
|
return file ? [file] : [];
|
||||||
|
}),
|
||||||
editedAt: comment.editedAt,
|
editedAt: comment.editedAt,
|
||||||
deletedAt: comment.deletedAt,
|
deletedAt: comment.deletedAt,
|
||||||
createdAt: comment.createdAt,
|
createdAt: comment.createdAt,
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { ICommentRepository } from '../../domain/repositories/comment.repository.interface.js';
|
||||||
|
import type { IIssueRepository } from '../../../issue/domain/repositories/issue.repository.interface.js';
|
||||||
|
import { FileOwnerRegistry } from '../../../file/application/services/file-owner.registry.js';
|
||||||
|
import { CommentAttachmentFileOwnerService } from './comment-attachment-file-owner.service.js';
|
||||||
|
import { buildIssue, mockCommentRepository, mockIssueRepository } from '../use-cases/test-helpers.js';
|
||||||
|
|
||||||
|
describe('CommentAttachmentFileOwnerService', () => {
|
||||||
|
let commentRepository: ICommentRepository;
|
||||||
|
let issueRepository: IIssueRepository;
|
||||||
|
let registry: FileOwnerRegistry;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
commentRepository = mockCommentRepository();
|
||||||
|
issueRepository = mockIssueRepository();
|
||||||
|
registry = new FileOwnerRegistry();
|
||||||
|
new CommentAttachmentFileOwnerService(commentRepository, issueRepository, registry).onModuleInit();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers itself, so GC keeps files attached to comments', async () => {
|
||||||
|
vi.mocked(commentRepository.findReferencedFileIds).mockResolvedValue(['a']);
|
||||||
|
|
||||||
|
expect(await registry.findReferenced(['a', 'b'])).toEqual(new Set(['a']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes a file visible to users who see the issue of its comment', async () => {
|
||||||
|
vi.mocked(commentRepository.findIssueIdsByAttachedFile).mockResolvedValue([1]);
|
||||||
|
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
|
||||||
|
|
||||||
|
expect(await registry.isVisibleThroughAnyOwner('file-1', 'user-2')).toBe(true);
|
||||||
|
expect(issueRepository.findById).toHaveBeenCalledWith(1, 'user-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides a file when the issue is not visible', async () => {
|
||||||
|
vi.mocked(commentRepository.findIssueIdsByAttachedFile).mockResolvedValue([1]);
|
||||||
|
vi.mocked(issueRepository.findById).mockResolvedValue(null);
|
||||||
|
|
||||||
|
expect(await registry.isVisibleThroughAnyOwner('file-1', 'user-3')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Inject, Injectable, type OnModuleInit } from '@nestjs/common';
|
||||||
|
import { COMMENT_REPOSITORY, type ICommentRepository } from '../../domain/repositories/comment.repository.interface.js';
|
||||||
|
import {
|
||||||
|
ISSUE_REPOSITORY,
|
||||||
|
type IIssueRepository,
|
||||||
|
} from '../../../issue/domain/repositories/issue.repository.interface.js';
|
||||||
|
import { FileOwnerRegistry, type FileOwner } from '../../../file/application/services/file-owner.registry.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Регистрирует комментарии как владельца файлов: без этого GC file-модуля
|
||||||
|
* считал бы прикреплённые к комментариям файлы сиротами и удалял их.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CommentAttachmentFileOwnerService implements FileOwner, OnModuleInit {
|
||||||
|
readonly name = 'comment';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(COMMENT_REPOSITORY)
|
||||||
|
private readonly commentRepository: ICommentRepository,
|
||||||
|
@Inject(ISSUE_REPOSITORY)
|
||||||
|
private readonly issueRepository: IIssueRepository,
|
||||||
|
private readonly fileOwners: FileOwnerRegistry,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
this.fileOwners.register(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
findReferenced(fileIds: string[]): Promise<string[]> {
|
||||||
|
return this.commentRepository.findReferencedFileIds(fileIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Файл виден, если видна задача хоть одного неудалённого комментария с ним */
|
||||||
|
async isVisibleThrough(fileId: string, userId: string): Promise<boolean> {
|
||||||
|
const issueIds = await this.commentRepository.findIssueIdsByAttachedFile(fileId);
|
||||||
|
|
||||||
|
for (const issueId of issueIds) {
|
||||||
|
if (await this.issueRepository.findById(issueId, userId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-6
@@ -4,11 +4,16 @@ import { CommentDto } from '../dto/comment.dto.js';
|
|||||||
import { CommentApplicationMapper } from '../mappers/comment-application.mapper.js';
|
import { CommentApplicationMapper } from '../mappers/comment-application.mapper.js';
|
||||||
import { GetUsersUseCase } from '../../../user/application/use-cases/get-users/get-users.use-case.js';
|
import { GetUsersUseCase } from '../../../user/application/use-cases/get-users/get-users.use-case.js';
|
||||||
import { GetUsersQuery } from '../../../user/application/use-cases/get-users/get-users.query.js';
|
import { GetUsersQuery } from '../../../user/application/use-cases/get-users/get-users.query.js';
|
||||||
|
import { GetFilesUseCase } from '../../../file/application/use-cases/get-files/get-files.use-case.js';
|
||||||
|
import { GetFilesQuery } from '../../../file/application/use-cases/get-files/get-files.query.js';
|
||||||
|
|
||||||
/** Раскрывает автора комментария (или списка комментариев) в полноценного пользователя */
|
/** Раскрывает автора и вложения комментария (или списка комментариев) */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommentAuthorExpanderService {
|
export class CommentExpanderService {
|
||||||
constructor(private readonly getUsersUseCase: GetUsersUseCase) {}
|
constructor(
|
||||||
|
private readonly getUsersUseCase: GetUsersUseCase,
|
||||||
|
private readonly getFilesUseCase: GetFilesUseCase,
|
||||||
|
) {}
|
||||||
|
|
||||||
async expand(comment: Comment): Promise<CommentDto> {
|
async expand(comment: Comment): Promise<CommentDto> {
|
||||||
const [dto] = await this.expandAll([comment]);
|
const [dto] = await this.expandAll([comment]);
|
||||||
@@ -19,9 +24,15 @@ export class CommentAuthorExpanderService {
|
|||||||
if (comments.length === 0) return [];
|
if (comments.length === 0) return [];
|
||||||
|
|
||||||
const userIds = [...new Set(comments.map((comment) => comment.authorId).filter((id): id is string => Boolean(id)))];
|
const userIds = [...new Set(comments.map((comment) => comment.authorId).filter((id): id is string => Boolean(id)))];
|
||||||
const users = userIds.length > 0 ? await this.getUsersUseCase.execute(new GetUsersQuery(userIds)) : [];
|
const fileIds = comments.flatMap((comment) => comment.fileIds);
|
||||||
const usersById = new Map(users.map((user) => [user.id, user]));
|
|
||||||
|
|
||||||
return comments.map((comment) => CommentApplicationMapper.toDto(comment, usersById));
|
const [users, files] = await Promise.all([
|
||||||
|
userIds.length > 0 ? this.getUsersUseCase.execute(new GetUsersQuery(userIds)) : [],
|
||||||
|
fileIds.length > 0 ? this.getFilesUseCase.execute(new GetFilesQuery(fileIds)) : [],
|
||||||
|
]);
|
||||||
|
const usersById = new Map(users.map((user) => [user.id, user]));
|
||||||
|
const filesById = new Map(files.map((file) => [file.id, file]));
|
||||||
|
|
||||||
|
return comments.map((comment) => CommentApplicationMapper.toDto(comment, usersById, filesById));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,5 +3,7 @@ export class CreateCommentCommand {
|
|||||||
public readonly issueId: number,
|
public readonly issueId: number,
|
||||||
public readonly authorId: string,
|
public readonly authorId: string,
|
||||||
public readonly content: string,
|
public readonly content: string,
|
||||||
|
/** Файлы, заранее загруженные автором через POST /files */
|
||||||
|
public readonly fileIds: string[] = [],
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
+33
-2
@@ -6,7 +6,14 @@ import { Comment } from '../../../domain/entities/comment.entity.js';
|
|||||||
import { CommentEventsService } from '../../services/comment-events.service.js';
|
import { CommentEventsService } from '../../services/comment-events.service.js';
|
||||||
import { CreateCommentUseCase } from './create-comment.use-case.js';
|
import { CreateCommentUseCase } from './create-comment.use-case.js';
|
||||||
import { CreateCommentCommand } from './create-comment.command.js';
|
import { CreateCommentCommand } from './create-comment.command.js';
|
||||||
import { buildIssue, mockAuthorExpander, mockCommentRepository, mockIssueRepository } from '../test-helpers.js';
|
import { FileForbiddenException } from '../../../../file/domain/exceptions/file-forbidden.exception.js';
|
||||||
|
import {
|
||||||
|
buildIssue,
|
||||||
|
mockCommentExpander,
|
||||||
|
mockCommentRepository,
|
||||||
|
mockFileAccess,
|
||||||
|
mockIssueRepository,
|
||||||
|
} from '../test-helpers.js';
|
||||||
|
|
||||||
describe('CreateCommentUseCase', () => {
|
describe('CreateCommentUseCase', () => {
|
||||||
let commentRepository: ICommentRepository;
|
let commentRepository: ICommentRepository;
|
||||||
@@ -18,7 +25,13 @@ describe('CreateCommentUseCase', () => {
|
|||||||
commentRepository = mockCommentRepository();
|
commentRepository = mockCommentRepository();
|
||||||
issueRepository = mockIssueRepository();
|
issueRepository = mockIssueRepository();
|
||||||
commentEventsService = new CommentEventsService();
|
commentEventsService = new CommentEventsService();
|
||||||
useCase = new CreateCommentUseCase(commentRepository, issueRepository, mockAuthorExpander(), commentEventsService);
|
useCase = new CreateCommentUseCase(
|
||||||
|
commentRepository,
|
||||||
|
issueRepository,
|
||||||
|
mockCommentExpander(),
|
||||||
|
commentEventsService,
|
||||||
|
mockFileAccess({ foreign: new FileForbiddenException('foreign') }),
|
||||||
|
);
|
||||||
|
|
||||||
vi.mocked(commentRepository.create).mockImplementation(async (comment: Comment) =>
|
vi.mocked(commentRepository.create).mockImplementation(async (comment: Comment) =>
|
||||||
Comment.reconstitute({
|
Comment.reconstitute({
|
||||||
@@ -26,6 +39,7 @@ describe('CreateCommentUseCase', () => {
|
|||||||
content: comment.content,
|
content: comment.content,
|
||||||
issueId: comment.issueId,
|
issueId: comment.issueId,
|
||||||
authorId: comment.authorId,
|
authorId: comment.authorId,
|
||||||
|
fileIds: [...comment.fileIds],
|
||||||
editedAt: null,
|
editedAt: null,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
createdAt: comment.createdAt,
|
createdAt: comment.createdAt,
|
||||||
@@ -54,4 +68,21 @@ describe('CreateCommentUseCase', () => {
|
|||||||
);
|
);
|
||||||
expect(commentRepository.create).not.toHaveBeenCalled();
|
expect(commentRepository.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('saves own files as attachments', async () => {
|
||||||
|
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
|
||||||
|
|
||||||
|
await useCase.execute(new CreateCommentCommand(1, 'user-1', 'Hello', ['b', 'a', 'b']));
|
||||||
|
|
||||||
|
expect(vi.mocked(commentRepository.create).mock.calls[0]![0].fileIds).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not create the comment when a file is not attachable', async () => {
|
||||||
|
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
|
||||||
|
|
||||||
|
await expect(useCase.execute(new CreateCommentCommand(1, 'user-1', 'Hello', ['a', 'foreign']))).rejects.toThrow(
|
||||||
|
FileForbiddenException,
|
||||||
|
);
|
||||||
|
expect(commentRepository.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+11
-3
@@ -11,8 +11,9 @@ import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issu
|
|||||||
import { Comment } from '../../../domain/entities/comment.entity.js';
|
import { Comment } from '../../../domain/entities/comment.entity.js';
|
||||||
import { CreateCommentCommand } from './create-comment.command.js';
|
import { CreateCommentCommand } from './create-comment.command.js';
|
||||||
import { CommentDto } from '../../dto/comment.dto.js';
|
import { CommentDto } from '../../dto/comment.dto.js';
|
||||||
import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js';
|
import { CommentExpanderService } from '../../services/comment-expander.service.js';
|
||||||
import { CommentEventsService } from '../../services/comment-events.service.js';
|
import { CommentEventsService } from '../../services/comment-events.service.js';
|
||||||
|
import { FileAccessService } from '../../../../file/application/services/file-access.service.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CreateCommentUseCase {
|
export class CreateCommentUseCase {
|
||||||
@@ -21,8 +22,9 @@ export class CreateCommentUseCase {
|
|||||||
private readonly commentRepository: ICommentRepository,
|
private readonly commentRepository: ICommentRepository,
|
||||||
@Inject(ISSUE_REPOSITORY)
|
@Inject(ISSUE_REPOSITORY)
|
||||||
private readonly issueRepository: IIssueRepository,
|
private readonly issueRepository: IIssueRepository,
|
||||||
private readonly commentAuthorExpander: CommentAuthorExpanderService,
|
private readonly commentExpander: CommentExpanderService,
|
||||||
private readonly commentEventsService: CommentEventsService,
|
private readonly commentEventsService: CommentEventsService,
|
||||||
|
private readonly fileAccess: FileAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: CreateCommentCommand): Promise<CommentDto> {
|
async execute(command: CreateCommentCommand): Promise<CommentDto> {
|
||||||
@@ -36,10 +38,16 @@ export class CreateCommentUseCase {
|
|||||||
issueId: command.issueId,
|
issueId: command.issueId,
|
||||||
authorId: command.authorId,
|
authorId: command.authorId,
|
||||||
content: command.content,
|
content: command.content,
|
||||||
|
fileIds: command.fileIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Прикреплять можно только свои файлы (см. FileAccessService.getAttachableFile)
|
||||||
|
for (const fileId of comment.fileIds) {
|
||||||
|
await this.fileAccess.getAttachableFile(fileId, command.authorId);
|
||||||
|
}
|
||||||
|
|
||||||
const saved = await this.commentRepository.create(comment);
|
const saved = await this.commentRepository.create(comment);
|
||||||
const dto = await this.commentAuthorExpander.expand(saved);
|
const dto = await this.commentExpander.expand(saved);
|
||||||
|
|
||||||
this.commentEventsService.emit('comment.created', dto);
|
this.commentEventsService.emit('comment.created', dto);
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -11,7 +11,7 @@ import { DeleteCommentCommand } from './delete-comment.command.js';
|
|||||||
import {
|
import {
|
||||||
buildComment,
|
buildComment,
|
||||||
buildIssue,
|
buildIssue,
|
||||||
mockAuthorExpander,
|
mockCommentExpander,
|
||||||
mockCommentRepository,
|
mockCommentRepository,
|
||||||
mockIssueRepository,
|
mockIssueRepository,
|
||||||
} from '../test-helpers.js';
|
} from '../test-helpers.js';
|
||||||
@@ -29,7 +29,7 @@ describe('DeleteCommentUseCase', () => {
|
|||||||
useCase = new DeleteCommentUseCase(
|
useCase = new DeleteCommentUseCase(
|
||||||
commentRepository,
|
commentRepository,
|
||||||
new CommentAccessService(commentRepository, issueRepository),
|
new CommentAccessService(commentRepository, issueRepository),
|
||||||
mockAuthorExpander(),
|
mockCommentExpander(),
|
||||||
commentEventsService,
|
commentEventsService,
|
||||||
);
|
);
|
||||||
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
|
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
|
||||||
@@ -49,6 +49,16 @@ describe('DeleteCommentUseCase', () => {
|
|||||||
expect(received[0]!.comment.content).toBeNull();
|
expect(received[0]!.comment.content).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('drops attachments so GC can collect the files', async () => {
|
||||||
|
const comment = buildComment();
|
||||||
|
comment.edit('Original', 'user-1', ['file-1']);
|
||||||
|
vi.mocked(commentRepository.findById).mockResolvedValue(comment);
|
||||||
|
|
||||||
|
await useCase.execute(new DeleteCommentCommand('comment-1', 'user-1'));
|
||||||
|
|
||||||
|
expect(vi.mocked(commentRepository.update).mock.calls[0]![0].fileIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('throws CommentForbiddenException for a non-author', async () => {
|
it('throws CommentForbiddenException for a non-author', async () => {
|
||||||
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
|
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
type ICommentRepository,
|
type ICommentRepository,
|
||||||
} from '../../../domain/repositories/comment.repository.interface.js';
|
} from '../../../domain/repositories/comment.repository.interface.js';
|
||||||
import { DeleteCommentCommand } from './delete-comment.command.js';
|
import { DeleteCommentCommand } from './delete-comment.command.js';
|
||||||
import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js';
|
import { CommentExpanderService } from '../../services/comment-expander.service.js';
|
||||||
import { CommentEventsService } from '../../services/comment-events.service.js';
|
import { CommentEventsService } from '../../services/comment-events.service.js';
|
||||||
import { CommentAccessService } from '../../services/comment-access.service.js';
|
import { CommentAccessService } from '../../services/comment-access.service.js';
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ export class DeleteCommentUseCase {
|
|||||||
@Inject(COMMENT_REPOSITORY)
|
@Inject(COMMENT_REPOSITORY)
|
||||||
private readonly commentRepository: ICommentRepository,
|
private readonly commentRepository: ICommentRepository,
|
||||||
private readonly commentAccess: CommentAccessService,
|
private readonly commentAccess: CommentAccessService,
|
||||||
private readonly commentAuthorExpander: CommentAuthorExpanderService,
|
private readonly commentExpander: CommentExpanderService,
|
||||||
private readonly commentEventsService: CommentEventsService,
|
private readonly commentEventsService: CommentEventsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -25,6 +25,6 @@ export class DeleteCommentUseCase {
|
|||||||
comment.delete(command.userId);
|
comment.delete(command.userId);
|
||||||
await this.commentRepository.update(comment);
|
await this.commentRepository.update(comment);
|
||||||
|
|
||||||
this.commentEventsService.emit('comment.deleted', await this.commentAuthorExpander.expand(comment));
|
this.commentEventsService.emit('comment.deleted', await this.commentExpander.expand(comment));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import type { CommentListDirection } from '../../../domain/repositories/comment.repository.interface.js';
|
||||||
|
|
||||||
export class ListCommentsQuery {
|
export class ListCommentsQuery {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly issueId: number,
|
public readonly issueId: number,
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly limit: number,
|
public readonly limit: number,
|
||||||
public readonly cursor?: string,
|
public readonly cursor?: string,
|
||||||
|
public readonly direction: CommentListDirection = 'forward',
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
+22
-3
@@ -7,7 +7,7 @@ import { ListCommentsQuery } from './list-comments.query.js';
|
|||||||
import {
|
import {
|
||||||
buildComment,
|
buildComment,
|
||||||
buildIssue,
|
buildIssue,
|
||||||
mockAuthorExpander,
|
mockCommentExpander,
|
||||||
mockCommentRepository,
|
mockCommentRepository,
|
||||||
mockIssueRepository,
|
mockIssueRepository,
|
||||||
} from '../test-helpers.js';
|
} from '../test-helpers.js';
|
||||||
@@ -20,7 +20,7 @@ describe('ListCommentsUseCase', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
commentRepository = mockCommentRepository();
|
commentRepository = mockCommentRepository();
|
||||||
issueRepository = mockIssueRepository();
|
issueRepository = mockIssueRepository();
|
||||||
useCase = new ListCommentsUseCase(commentRepository, issueRepository, mockAuthorExpander());
|
useCase = new ListCommentsUseCase(commentRepository, issueRepository, mockCommentExpander());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forwards pagination params and returns the next cursor', async () => {
|
it('forwards pagination params and returns the next cursor', async () => {
|
||||||
@@ -32,11 +32,30 @@ describe('ListCommentsUseCase', () => {
|
|||||||
|
|
||||||
const result = await useCase.execute(new ListCommentsQuery(1, 'user-1', 1, 'comment-1'));
|
const result = await useCase.execute(new ListCommentsQuery(1, 'user-1', 1, 'comment-1'));
|
||||||
|
|
||||||
expect(commentRepository.findByIssue).toHaveBeenCalledWith({ issueId: 1, limit: 1, cursor: 'comment-1' });
|
expect(commentRepository.findByIssue).toHaveBeenCalledWith({
|
||||||
|
issueId: 1,
|
||||||
|
limit: 1,
|
||||||
|
cursor: 'comment-1',
|
||||||
|
direction: 'forward',
|
||||||
|
});
|
||||||
expect(result.list.map((comment) => comment.id)).toEqual(['comment-2']);
|
expect(result.list.map((comment) => comment.id)).toEqual(['comment-2']);
|
||||||
expect(result.nextCursor).toBe('comment-2');
|
expect(result.nextCursor).toBe('comment-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('forwards the backward direction for chat-style paging', async () => {
|
||||||
|
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
|
||||||
|
vi.mocked(commentRepository.findByIssue).mockResolvedValue({ list: [], nextCursor: null });
|
||||||
|
|
||||||
|
await useCase.execute(new ListCommentsQuery(1, 'user-1', 20, 'comment-5', 'backward'));
|
||||||
|
|
||||||
|
expect(commentRepository.findByIssue).toHaveBeenCalledWith({
|
||||||
|
issueId: 1,
|
||||||
|
limit: 20,
|
||||||
|
cursor: 'comment-5',
|
||||||
|
direction: 'backward',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('throws IssueNotFoundException when the issue is not visible to the user', async () => {
|
it('throws IssueNotFoundException when the issue is not visible to the user', async () => {
|
||||||
vi.mocked(issueRepository.findById).mockResolvedValue(null);
|
vi.mocked(issueRepository.findById).mockResolvedValue(null);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
|
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
|
||||||
import { ListCommentsQuery } from './list-comments.query.js';
|
import { ListCommentsQuery } from './list-comments.query.js';
|
||||||
import { CommentDto } from '../../dto/comment.dto.js';
|
import { CommentDto } from '../../dto/comment.dto.js';
|
||||||
import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js';
|
import { CommentExpanderService } from '../../services/comment-expander.service.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ListCommentsUseCase {
|
export class ListCommentsUseCase {
|
||||||
@@ -19,7 +19,7 @@ export class ListCommentsUseCase {
|
|||||||
private readonly commentRepository: ICommentRepository,
|
private readonly commentRepository: ICommentRepository,
|
||||||
@Inject(ISSUE_REPOSITORY)
|
@Inject(ISSUE_REPOSITORY)
|
||||||
private readonly issueRepository: IIssueRepository,
|
private readonly issueRepository: IIssueRepository,
|
||||||
private readonly commentAuthorExpander: CommentAuthorExpanderService,
|
private readonly commentExpander: CommentExpanderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: ListCommentsQuery): Promise<{ list: CommentDto[]; nextCursor: string | null }> {
|
async execute(query: ListCommentsQuery): Promise<{ list: CommentDto[]; nextCursor: string | null }> {
|
||||||
@@ -32,8 +32,9 @@ export class ListCommentsUseCase {
|
|||||||
issueId: query.issueId,
|
issueId: query.issueId,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
cursor: query.cursor,
|
cursor: query.cursor,
|
||||||
|
direction: query.direction,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { list: await this.commentAuthorExpander.expandAll(list), nextCursor };
|
return { list: await this.commentExpander.expandAll(list), nextCursor };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,9 @@ import type { ICommentRepository } from '../../domain/repositories/comment.repos
|
|||||||
import type { IIssueRepository } from '../../../issue/domain/repositories/issue.repository.interface.js';
|
import type { IIssueRepository } from '../../../issue/domain/repositories/issue.repository.interface.js';
|
||||||
import { Comment } from '../../domain/entities/comment.entity.js';
|
import { Comment } from '../../domain/entities/comment.entity.js';
|
||||||
import { Issue } from '../../../issue/domain/entities/issue.entity.js';
|
import { Issue } from '../../../issue/domain/entities/issue.entity.js';
|
||||||
import { CommentAuthorExpanderService } from '../services/comment-author-expander.service.js';
|
import { CommentExpanderService } from '../services/comment-expander.service.js';
|
||||||
import { CommentApplicationMapper } from '../mappers/comment-application.mapper.js';
|
import { CommentApplicationMapper } from '../mappers/comment-application.mapper.js';
|
||||||
|
import type { FileAccessService } from '../../../file/application/services/file-access.service.js';
|
||||||
|
|
||||||
export const buildComment = (
|
export const buildComment = (
|
||||||
overrides: Partial<{ id: string; issueId: number; authorId: string; deletedAt: Date | null }> = {},
|
overrides: Partial<{ id: string; issueId: number; authorId: string; deletedAt: Date | null }> = {},
|
||||||
@@ -28,6 +29,7 @@ export const buildIssue = () =>
|
|||||||
content: null,
|
content: null,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
assignee: null,
|
assignee: null,
|
||||||
reporter: 'user-1',
|
reporter: 'user-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
@@ -41,23 +43,35 @@ export const mockCommentRepository = (): ICommentRepository => ({
|
|||||||
findByIssue: vi.fn(),
|
findByIssue: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
findIssueIdsByAttachedFile: vi.fn(),
|
||||||
|
findReferencedFileIds: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mockIssueRepository = (): IIssueRepository => ({
|
export const mockIssueRepository = (): IIssueRepository => ({
|
||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Экспандер без похода за пользователями: author всегда null */
|
/** Все файлы прикрепляемы, кроме перечисленных — для них бросается заданная ошибка */
|
||||||
export const mockAuthorExpander = () => {
|
export const mockFileAccess = (rejections: Record<string, Error> = {}) =>
|
||||||
|
({
|
||||||
|
getAttachableFile: vi.fn(async (fileId: string) => {
|
||||||
|
if (rejections[fileId]) throw rejections[fileId];
|
||||||
|
return { id: fileId };
|
||||||
|
}),
|
||||||
|
}) as unknown as FileAccessService;
|
||||||
|
|
||||||
|
/** Экспандер без похода за пользователями и файлами: author всегда null, attachments пустые */
|
||||||
|
export const mockCommentExpander = () => {
|
||||||
const toDto = (comment: Comment) => CommentApplicationMapper.toDto(comment, new Map());
|
const toDto = (comment: Comment) => CommentApplicationMapper.toDto(comment, new Map());
|
||||||
return {
|
return {
|
||||||
expand: vi.fn(async (comment: Comment) => toDto(comment)),
|
expand: vi.fn(async (comment: Comment) => toDto(comment)),
|
||||||
expandAll: vi.fn(async (comments: Comment[]) => comments.map(toDto)),
|
expandAll: vi.fn(async (comments: Comment[]) => comments.map(toDto)),
|
||||||
} as unknown as CommentAuthorExpanderService;
|
} as unknown as CommentExpanderService;
|
||||||
};
|
};
|
||||||
@@ -3,5 +3,7 @@ export class UpdateCommentCommand {
|
|||||||
public readonly id: string,
|
public readonly id: string,
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly content: string,
|
public readonly content: string,
|
||||||
|
/** Новый набор вложений целиком; undefined — не менять */
|
||||||
|
public readonly fileIds?: string[],
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
+29
-2
@@ -10,10 +10,13 @@ import { UpdateCommentCommand } from './update-comment.command.js';
|
|||||||
import {
|
import {
|
||||||
buildComment,
|
buildComment,
|
||||||
buildIssue,
|
buildIssue,
|
||||||
mockAuthorExpander,
|
mockCommentExpander,
|
||||||
mockCommentRepository,
|
mockCommentRepository,
|
||||||
|
mockFileAccess,
|
||||||
mockIssueRepository,
|
mockIssueRepository,
|
||||||
} from '../test-helpers.js';
|
} from '../test-helpers.js';
|
||||||
|
import { FileNotFoundException } from '../../../../file/domain/exceptions/file-not-found.exception.js';
|
||||||
|
import type { FileAccessService } from '../../../../file/application/services/file-access.service.js';
|
||||||
|
|
||||||
describe('UpdateCommentUseCase', () => {
|
describe('UpdateCommentUseCase', () => {
|
||||||
let commentRepository: ICommentRepository;
|
let commentRepository: ICommentRepository;
|
||||||
@@ -21,16 +24,19 @@ describe('UpdateCommentUseCase', () => {
|
|||||||
let commentEventsService: CommentEventsService;
|
let commentEventsService: CommentEventsService;
|
||||||
let useCase: UpdateCommentUseCase;
|
let useCase: UpdateCommentUseCase;
|
||||||
let received: unknown[];
|
let received: unknown[];
|
||||||
|
let fileAccess: FileAccessService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
commentRepository = mockCommentRepository();
|
commentRepository = mockCommentRepository();
|
||||||
issueRepository = mockIssueRepository();
|
issueRepository = mockIssueRepository();
|
||||||
commentEventsService = new CommentEventsService();
|
commentEventsService = new CommentEventsService();
|
||||||
|
fileAccess = mockFileAccess({ gone: new FileNotFoundException('gone') });
|
||||||
useCase = new UpdateCommentUseCase(
|
useCase = new UpdateCommentUseCase(
|
||||||
commentRepository,
|
commentRepository,
|
||||||
new CommentAccessService(commentRepository, issueRepository),
|
new CommentAccessService(commentRepository, issueRepository),
|
||||||
mockAuthorExpander(),
|
mockCommentExpander(),
|
||||||
commentEventsService,
|
commentEventsService,
|
||||||
|
fileAccess,
|
||||||
);
|
);
|
||||||
received = [];
|
received = [];
|
||||||
commentEventsService.onEvents().subscribe((event) => received.push(event));
|
commentEventsService.onEvents().subscribe((event) => received.push(event));
|
||||||
@@ -82,4 +88,25 @@ describe('UpdateCommentUseCase', () => {
|
|||||||
CommentNotFoundException,
|
CommentNotFoundException,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('replaces attachments, checking only newly added files', async () => {
|
||||||
|
const comment = buildComment();
|
||||||
|
comment.edit('Original', 'user-1', ['kept', 'removed']);
|
||||||
|
vi.mocked(commentRepository.findById).mockResolvedValue(comment);
|
||||||
|
|
||||||
|
await useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Original', ['kept', 'new']));
|
||||||
|
|
||||||
|
expect(fileAccess.getAttachableFile).toHaveBeenCalledExactlyOnceWith('new', 'user-1');
|
||||||
|
expect(vi.mocked(commentRepository.update).mock.calls[0]![0].fileIds).toEqual(['kept', 'new']);
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not save when an added file is gone', async () => {
|
||||||
|
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Original', ['gone'])),
|
||||||
|
).rejects.toThrow(FileNotFoundException);
|
||||||
|
expect(commentRepository.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+15
-4
@@ -5,9 +5,10 @@ import {
|
|||||||
} from '../../../domain/repositories/comment.repository.interface.js';
|
} from '../../../domain/repositories/comment.repository.interface.js';
|
||||||
import { UpdateCommentCommand } from './update-comment.command.js';
|
import { UpdateCommentCommand } from './update-comment.command.js';
|
||||||
import { CommentDto } from '../../dto/comment.dto.js';
|
import { CommentDto } from '../../dto/comment.dto.js';
|
||||||
import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js';
|
import { CommentExpanderService } from '../../services/comment-expander.service.js';
|
||||||
import { CommentEventsService } from '../../services/comment-events.service.js';
|
import { CommentEventsService } from '../../services/comment-events.service.js';
|
||||||
import { CommentAccessService } from '../../services/comment-access.service.js';
|
import { CommentAccessService } from '../../services/comment-access.service.js';
|
||||||
|
import { FileAccessService } from '../../../../file/application/services/file-access.service.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UpdateCommentUseCase {
|
export class UpdateCommentUseCase {
|
||||||
@@ -15,19 +16,29 @@ export class UpdateCommentUseCase {
|
|||||||
@Inject(COMMENT_REPOSITORY)
|
@Inject(COMMENT_REPOSITORY)
|
||||||
private readonly commentRepository: ICommentRepository,
|
private readonly commentRepository: ICommentRepository,
|
||||||
private readonly commentAccess: CommentAccessService,
|
private readonly commentAccess: CommentAccessService,
|
||||||
private readonly commentAuthorExpander: CommentAuthorExpanderService,
|
private readonly commentExpander: CommentExpanderService,
|
||||||
private readonly commentEventsService: CommentEventsService,
|
private readonly commentEventsService: CommentEventsService,
|
||||||
|
private readonly fileAccess: FileAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: UpdateCommentCommand): Promise<CommentDto> {
|
async execute(command: UpdateCommentCommand): Promise<CommentDto> {
|
||||||
const comment = await this.commentAccess.getVisibleComment(command.id, command.userId);
|
const comment = await this.commentAccess.getVisibleComment(command.id, command.userId);
|
||||||
|
const attachedBefore = new Set(comment.fileIds);
|
||||||
|
|
||||||
|
const changed = comment.edit(command.content, command.userId, command.fileIds);
|
||||||
|
|
||||||
|
// Проверяем только добавленные файлы: уже прикреплённые проверены при прикреплении
|
||||||
|
for (const fileId of comment.fileIds) {
|
||||||
|
if (!attachedBefore.has(fileId)) {
|
||||||
|
await this.fileAccess.getAttachableFile(fileId, command.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const changed = comment.edit(command.content, command.userId);
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
await this.commentRepository.update(comment);
|
await this.commentRepository.update(comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dto = await this.commentAuthorExpander.expand(comment);
|
const dto = await this.commentExpander.expand(comment);
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
this.commentEventsService.emit('comment.updated', dto);
|
this.commentEventsService.emit('comment.updated', dto);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
|
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
|
||||||
import { IssueModule } from '../issue/issue.module.js';
|
import { IssueModule } from '../issue/issue.module.js';
|
||||||
import { UserModule } from '../user/user.module.js';
|
import { UserModule } from '../user/user.module.js';
|
||||||
|
import { FileModule } from '../file/file.module.js';
|
||||||
import { COMMENT_REPOSITORY } from './domain/repositories/comment.repository.interface.js';
|
import { COMMENT_REPOSITORY } from './domain/repositories/comment.repository.interface.js';
|
||||||
import { CommentPrismaRepository } from './infrastructure/persistence/prisma/repositories/comment.prisma-repository.js';
|
import { CommentPrismaRepository } from './infrastructure/persistence/prisma/repositories/comment.prisma-repository.js';
|
||||||
import { CommentController } from './presentation/controllers/comment.controller.js';
|
import { CommentController } from './presentation/controllers/comment.controller.js';
|
||||||
@@ -11,11 +12,12 @@ import { UpdateCommentUseCase } from './application/use-cases/update-comment/upd
|
|||||||
import { DeleteCommentUseCase } from './application/use-cases/delete-comment/delete-comment.use-case.js';
|
import { DeleteCommentUseCase } from './application/use-cases/delete-comment/delete-comment.use-case.js';
|
||||||
import { WatchIssueCommentsUseCase } from './application/use-cases/watch-issue-comments/watch-issue-comments.use-case.js';
|
import { WatchIssueCommentsUseCase } from './application/use-cases/watch-issue-comments/watch-issue-comments.use-case.js';
|
||||||
import { CommentAccessService } from './application/services/comment-access.service.js';
|
import { CommentAccessService } from './application/services/comment-access.service.js';
|
||||||
import { CommentAuthorExpanderService } from './application/services/comment-author-expander.service.js';
|
import { CommentExpanderService } from './application/services/comment-expander.service.js';
|
||||||
import { CommentEventsService } from './application/services/comment-events.service.js';
|
import { CommentEventsService } from './application/services/comment-events.service.js';
|
||||||
|
import { CommentAttachmentFileOwnerService } from './application/services/comment-attachment-file-owner.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, IssueModule, UserModule],
|
imports: [PrismaModule, IssueModule, UserModule, FileModule],
|
||||||
controllers: [CommentController],
|
controllers: [CommentController],
|
||||||
providers: [
|
providers: [
|
||||||
CreateCommentUseCase,
|
CreateCommentUseCase,
|
||||||
@@ -24,8 +26,9 @@ import { CommentEventsService } from './application/services/comment-events.serv
|
|||||||
DeleteCommentUseCase,
|
DeleteCommentUseCase,
|
||||||
WatchIssueCommentsUseCase,
|
WatchIssueCommentsUseCase,
|
||||||
CommentAccessService,
|
CommentAccessService,
|
||||||
CommentAuthorExpanderService,
|
CommentExpanderService,
|
||||||
CommentEventsService,
|
CommentEventsService,
|
||||||
|
CommentAttachmentFileOwnerService,
|
||||||
{
|
{
|
||||||
// Domain видит только интерфейс ICommentRepository,
|
// Domain видит только интерфейс ICommentRepository,
|
||||||
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь
|
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь
|
||||||
|
|||||||
@@ -73,4 +73,50 @@ describe('Comment', () => {
|
|||||||
expect(() => buildComment().delete('user-2')).toThrow(CommentForbiddenException);
|
expect(() => buildComment().delete('user-2')).toThrow(CommentForbiddenException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('attachments', () => {
|
||||||
|
it('creates a comment with deduplicated files', () => {
|
||||||
|
const comment = Comment.create({ issueId: 1, authorId: 'user-1', content: 'Hi', fileIds: ['a', 'b', 'a'] });
|
||||||
|
|
||||||
|
expect(comment.fileIds).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits the number of files', () => {
|
||||||
|
const fileIds = Array.from({ length: Comment.MAX_ATTACHMENTS + 1 }, (_, i) => `f${i}`);
|
||||||
|
|
||||||
|
expect(() => Comment.create({ issueId: 1, authorId: 'user-1', content: 'Hi', fileIds })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a changed file set as an edit, even with the same text', () => {
|
||||||
|
const comment = buildComment();
|
||||||
|
|
||||||
|
expect(comment.edit('Original', 'user-1', ['a'])).toBe(true);
|
||||||
|
expect(comment.fileIds).toEqual(['a']);
|
||||||
|
expect(comment.editedAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps files when fileIds is not passed', () => {
|
||||||
|
const comment = buildComment();
|
||||||
|
comment.edit('Original', 'user-1', ['a']);
|
||||||
|
|
||||||
|
expect(comment.edit('Changed', 'user-1')).toBe(true);
|
||||||
|
expect(comment.fileIds).toEqual(['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no change for the same text and files', () => {
|
||||||
|
const comment = buildComment();
|
||||||
|
comment.edit('Original', 'user-1', ['a', 'b']);
|
||||||
|
|
||||||
|
expect(comment.edit('Original', 'user-1', ['a', 'b'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops files on delete so GC can collect them', () => {
|
||||||
|
const comment = buildComment();
|
||||||
|
comment.edit('Original', 'user-1', ['a']);
|
||||||
|
|
||||||
|
comment.delete('user-1');
|
||||||
|
|
||||||
|
expect(comment.fileIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -7,6 +7,8 @@ interface CommentProps {
|
|||||||
issueId: number;
|
issueId: number;
|
||||||
/** null — автор удалил аккаунт */
|
/** null — автор удалил аккаунт */
|
||||||
authorId: string | null;
|
authorId: string | null;
|
||||||
|
/** Прикреплённые файлы (file-модуль), по времени загрузки */
|
||||||
|
fileIds: string[];
|
||||||
editedAt: Date | null;
|
editedAt: Date | null;
|
||||||
deletedAt: Date | null;
|
deletedAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -17,14 +19,19 @@ interface CommentProps {
|
|||||||
* Комментарий к задаче. Удаление мягкое: запись остаётся (deletedAt),
|
* Комментарий к задаче. Удаление мягкое: запись остаётся (deletedAt),
|
||||||
* чтобы в ленте можно было показать «комментарий удалён».
|
* чтобы в ленте можно было показать «комментарий удалён».
|
||||||
*
|
*
|
||||||
|
* Вложения — часть комментария: задаются при создании и правке,
|
||||||
|
* снимаются при удалении (тогда осиротевшие файлы удалит GC file-модуля).
|
||||||
|
*
|
||||||
* `id` (uuid v7) генерируется базой, поэтому у нового комментария
|
* `id` (uuid v7) генерируется базой, поэтому у нового комментария
|
||||||
* он равен null до сохранения в репозитории.
|
* он равен null до сохранения в репозитории.
|
||||||
*/
|
*/
|
||||||
export class Comment {
|
export class Comment {
|
||||||
|
static readonly MAX_ATTACHMENTS = 10;
|
||||||
|
|
||||||
private constructor(private readonly props: CommentProps) {}
|
private constructor(private readonly props: CommentProps) {}
|
||||||
|
|
||||||
/** Фабричный метод для создания НОВОГО комментария */
|
/** Фабричный метод для создания НОВОГО комментария */
|
||||||
static create(params: { issueId: number; authorId: string; content: string }): Comment {
|
static create(params: { issueId: number; authorId: string; content: string; fileIds?: string[] }): Comment {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
return new Comment({
|
return new Comment({
|
||||||
@@ -32,6 +39,7 @@ export class Comment {
|
|||||||
content: CommentContent.create(params.content),
|
content: CommentContent.create(params.content),
|
||||||
issueId: params.issueId,
|
issueId: params.issueId,
|
||||||
authorId: params.authorId,
|
authorId: params.authorId,
|
||||||
|
fileIds: Comment.normalizeFileIds(params.fileIds ?? []),
|
||||||
editedAt: null,
|
editedAt: null,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -45,6 +53,7 @@ export class Comment {
|
|||||||
content: string;
|
content: string;
|
||||||
issueId: number;
|
issueId: number;
|
||||||
authorId: string | null;
|
authorId: string | null;
|
||||||
|
fileIds?: string[];
|
||||||
editedAt: Date | null;
|
editedAt: Date | null;
|
||||||
deletedAt: Date | null;
|
deletedAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -53,6 +62,7 @@ export class Comment {
|
|||||||
return new Comment({
|
return new Comment({
|
||||||
...props,
|
...props,
|
||||||
content: CommentContent.create(props.content),
|
content: CommentContent.create(props.content),
|
||||||
|
fileIds: [...(props.fileIds ?? [])],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +82,10 @@ export class Comment {
|
|||||||
return this.props.authorId;
|
return this.props.authorId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get fileIds(): readonly string[] {
|
||||||
|
return this.props.fileIds;
|
||||||
|
}
|
||||||
|
|
||||||
get editedAt(): Date | null {
|
get editedAt(): Date | null {
|
||||||
return this.props.editedAt;
|
return this.props.editedAt;
|
||||||
}
|
}
|
||||||
@@ -92,17 +106,27 @@ export class Comment {
|
|||||||
return this.props.updatedAt;
|
return this.props.updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Меняет текст. Возвращает false, если текст не изменился */
|
/**
|
||||||
edit(content: string, userId: string): boolean {
|
* Меняет текст и, если передан fileIds, — набор вложений (целиком).
|
||||||
|
* Возвращает false, если ничего не изменилось.
|
||||||
|
*/
|
||||||
|
edit(content: string, userId: string, fileIds?: string[]): boolean {
|
||||||
this.assertCanModify(userId);
|
this.assertCanModify(userId);
|
||||||
|
|
||||||
const next = CommentContent.create(content);
|
const nextContent = CommentContent.create(content);
|
||||||
if (next.equals(this.props.content)) {
|
const nextFileIds = fileIds === undefined ? this.props.fileIds : Comment.normalizeFileIds(fileIds);
|
||||||
|
|
||||||
|
const contentChanged = !nextContent.equals(this.props.content);
|
||||||
|
const filesChanged =
|
||||||
|
nextFileIds.length !== this.props.fileIds.length || nextFileIds.some((id, i) => id !== this.props.fileIds[i]);
|
||||||
|
|
||||||
|
if (!contentChanged && !filesChanged) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
this.props.content = next;
|
this.props.content = nextContent;
|
||||||
|
this.props.fileIds = nextFileIds;
|
||||||
this.props.editedAt = now;
|
this.props.editedAt = now;
|
||||||
this.props.updatedAt = now;
|
this.props.updatedAt = now;
|
||||||
return true;
|
return true;
|
||||||
@@ -112,10 +136,24 @@ export class Comment {
|
|||||||
this.assertCanModify(userId);
|
this.assertCanModify(userId);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
this.props.fileIds = [];
|
||||||
this.props.deletedAt = now;
|
this.props.deletedAt = now;
|
||||||
this.props.updatedAt = now;
|
this.props.updatedAt = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Без дублей и в порядке загрузки: id файлов — uuid v7, их строковый порядок
|
||||||
|
* совпадает с временем создания. Так порядок одинаков в ответе на create
|
||||||
|
* и при чтении из БД, а сравнение наборов в edit не зависит от порядка у клиента.
|
||||||
|
*/
|
||||||
|
private static normalizeFileIds(fileIds: string[]): string[] {
|
||||||
|
const unique = [...new Set(fileIds)].sort();
|
||||||
|
if (unique.length > Comment.MAX_ATTACHMENTS) {
|
||||||
|
throw new Error(`К комментарию можно прикрепить не больше ${Comment.MAX_ATTACHMENTS} файлов`);
|
||||||
|
}
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
/** Править и удалять комментарий может только его автор, и только пока он не удалён */
|
/** Править и удалять комментарий может только его автор, и только пока он не удалён */
|
||||||
private assertCanModify(userId: string): void {
|
private assertCanModify(userId: string): void {
|
||||||
if (this.isDeleted) {
|
if (this.isDeleted) {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Comment } from '../entities/comment.entity.js';
|
import { Comment } from '../entities/comment.entity.js';
|
||||||
|
|
||||||
|
/** Направление пагинации комментариев: forward — от старых к новым, backward — от новых к старым */
|
||||||
|
export const COMMENT_LIST_DIRECTIONS = ['forward', 'backward'] as const;
|
||||||
|
export type CommentListDirection = (typeof COMMENT_LIST_DIRECTIONS)[number];
|
||||||
|
|
||||||
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
|
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
|
||||||
export const COMMENT_REPOSITORY = Symbol('COMMENT_REPOSITORY');
|
export const COMMENT_REPOSITORY = Symbol('COMMENT_REPOSITORY');
|
||||||
|
|
||||||
@@ -8,13 +12,25 @@ export interface ICommentRepository {
|
|||||||
findById(id: string): Promise<Comment | null>;
|
findById(id: string): Promise<Comment | null>;
|
||||||
/**
|
/**
|
||||||
* Комментарии задачи от старых к новым, включая удалённые.
|
* Комментарии задачи от старых к новым, включая удалённые.
|
||||||
* Курсорная пагинация: cursor — id последнего полученного комментария.
|
* Курсорная пагинация. forward: cursor — id последнего полученного комментария,
|
||||||
|
* отдаются более новые. backward: cursor — id самого старого полученного,
|
||||||
|
* отдаются более старые (без cursor — последняя страница). Список всегда
|
||||||
|
* отсортирован от старых к новым.
|
||||||
*/
|
*/
|
||||||
findByIssue(params: {
|
findByIssue(params: {
|
||||||
issueId: number;
|
issueId: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
|
direction?: CommentListDirection;
|
||||||
}): Promise<{ list: Comment[]; nextCursor: string | null }>;
|
}): Promise<{ list: Comment[]; nextCursor: string | null }>;
|
||||||
|
/**
|
||||||
|
* Создание и обновление сохраняют и вложения (fileIds) — атомарно с комментарием.
|
||||||
|
* FileNotFoundException, если файл удалили между проверкой и записью.
|
||||||
|
*/
|
||||||
create(comment: Comment): Promise<Comment>;
|
create(comment: Comment): Promise<Comment>;
|
||||||
update(comment: Comment): Promise<void>;
|
update(comment: Comment): Promise<void>;
|
||||||
|
/** Задачи неудалённых комментариев, к которым прикреплён файл */
|
||||||
|
findIssueIdsByAttachedFile(fileId: string): Promise<number[]>;
|
||||||
|
/** Подмножество fileIds, прикреплённых хотя бы к одному комментарию */
|
||||||
|
findReferencedFileIds(fileIds: string[]): Promise<string[]>;
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import type { Comment as PrismaComment, Prisma } from '../../../../../../generated/prisma/client.js';
|
import type { Comment as PrismaComment, Prisma } from '../../../../../../generated/prisma/client.js';
|
||||||
import { Comment } from '../../../../domain/entities/comment.entity.js';
|
import { Comment } from '../../../../domain/entities/comment.entity.js';
|
||||||
|
|
||||||
|
/** Комментарий вместе с id прикреплённых файлов (см. COMMENT_INCLUDE в репозитории) */
|
||||||
|
export type PrismaCommentWithAttachments = PrismaComment & { attachments: { fileId: string }[] };
|
||||||
|
|
||||||
export class CommentPrismaMapper {
|
export class CommentPrismaMapper {
|
||||||
static toDomain(record: PrismaComment): Comment {
|
static toDomain(record: PrismaCommentWithAttachments): Comment {
|
||||||
return Comment.reconstitute({
|
return Comment.reconstitute({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
content: record.content,
|
content: record.content,
|
||||||
issueId: record.issueId,
|
issueId: record.issueId,
|
||||||
authorId: record.authorId,
|
authorId: record.authorId,
|
||||||
|
fileIds: record.attachments.map((attachment) => attachment.fileId),
|
||||||
editedAt: record.editedAt,
|
editedAt: record.editedAt,
|
||||||
deletedAt: record.deletedAt,
|
deletedAt: record.deletedAt,
|
||||||
createdAt: record.createdAt,
|
createdAt: record.createdAt,
|
||||||
@@ -22,6 +26,7 @@ export class CommentPrismaMapper {
|
|||||||
authorId: comment.authorId,
|
authorId: comment.authorId,
|
||||||
createdAt: comment.createdAt,
|
createdAt: comment.createdAt,
|
||||||
updatedAt: comment.updatedAt,
|
updatedAt: comment.updatedAt,
|
||||||
|
attachments: { create: comment.fileIds.map((fileId) => ({ fileId })) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+77
-15
@@ -2,18 +2,27 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
|
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
|
||||||
import { Prisma } from '../../../../../../generated/prisma/client.js';
|
import { Prisma } from '../../../../../../generated/prisma/client.js';
|
||||||
import { Comment } from '../../../../domain/entities/comment.entity.js';
|
import { Comment } from '../../../../domain/entities/comment.entity.js';
|
||||||
import { ICommentRepository } from '../../../../domain/repositories/comment.repository.interface.js';
|
import type {
|
||||||
|
CommentListDirection,
|
||||||
|
ICommentRepository,
|
||||||
|
} from '../../../../domain/repositories/comment.repository.interface.js';
|
||||||
import { CommentPrismaMapper } from '../mappers/comment.mapper.js';
|
import { CommentPrismaMapper } from '../mappers/comment.mapper.js';
|
||||||
import { IssueNotFoundException } from '../../../../../issue/domain/exceptions/issue-not-found.exception.js';
|
import { IssueNotFoundException } from '../../../../../issue/domain/exceptions/issue-not-found.exception.js';
|
||||||
|
import { FileNotFoundException } from '../../../../../file/domain/exceptions/file-not-found.exception.js';
|
||||||
|
|
||||||
const FOREIGN_KEY_VIOLATION = 'P2003';
|
const FOREIGN_KEY_VIOLATION = 'P2003';
|
||||||
|
|
||||||
|
/** Вложения подгружаются вместе с комментарием, по времени загрузки файла (uuid v7) */
|
||||||
|
const COMMENT_INCLUDE = {
|
||||||
|
attachments: { select: { fileId: true }, orderBy: { fileId: 'asc' } },
|
||||||
|
} satisfies Prisma.CommentInclude;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommentPrismaRepository implements ICommentRepository {
|
export class CommentPrismaRepository implements ICommentRepository {
|
||||||
constructor(private readonly prisma: PrismaAdapter) {}
|
constructor(private readonly prisma: PrismaAdapter) {}
|
||||||
|
|
||||||
async findById(id: string): Promise<Comment | null> {
|
async findById(id: string): Promise<Comment | null> {
|
||||||
const record = await this.prisma.comment.findUnique({ where: { id } });
|
const record = await this.prisma.comment.findUnique({ where: { id }, include: COMMENT_INCLUDE });
|
||||||
return record ? CommentPrismaMapper.toDomain(record) : null;
|
return record ? CommentPrismaMapper.toDomain(record) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,47 +30,100 @@ export class CommentPrismaRepository implements ICommentRepository {
|
|||||||
issueId: number;
|
issueId: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
|
direction?: CommentListDirection;
|
||||||
}): Promise<{ list: Comment[]; nextCursor: string | null }> {
|
}): Promise<{ list: Comment[]; nextCursor: string | null }> {
|
||||||
const limit = Math.max(1, params.limit);
|
const limit = Math.max(1, params.limit);
|
||||||
|
const order = params.direction === 'backward' ? 'desc' : 'asc';
|
||||||
|
|
||||||
// Берём на одну запись больше, чтобы понять, есть ли следующая страница.
|
// Берём на одну запись больше, чтобы понять, есть ли следующая страница.
|
||||||
// id (uuid v7) — тай-брейкер для комментариев с одинаковым createdAt.
|
// id (uuid v7) — тай-брейкер для комментариев с одинаковым createdAt.
|
||||||
const records = await this.prisma.comment.findMany({
|
const records = await this.prisma.comment.findMany({
|
||||||
where: { issueId: params.issueId },
|
where: { issueId: params.issueId },
|
||||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
include: COMMENT_INCLUDE,
|
||||||
|
orderBy: [{ createdAt: order }, { id: order }],
|
||||||
take: limit + 1,
|
take: limit + 1,
|
||||||
...(params.cursor ? { cursor: { id: params.cursor }, skip: 1 } : {}),
|
...(params.cursor ? { cursor: { id: params.cursor }, skip: 1 } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasMore = records.length > limit;
|
const hasMore = records.length > limit;
|
||||||
const page = hasMore ? records.slice(0, limit) : records;
|
const page = hasMore ? records.slice(0, limit) : records;
|
||||||
|
// Курсор — последняя запись страницы в порядке выборки: для backward это самая старая
|
||||||
|
const nextCursor = hasMore ? page[page.length - 1]!.id : null;
|
||||||
|
|
||||||
return {
|
if (order === 'desc') page.reverse();
|
||||||
list: page.map(CommentPrismaMapper.toDomain),
|
|
||||||
nextCursor: hasMore ? page[page.length - 1]!.id : null,
|
return { list: page.map(CommentPrismaMapper.toDomain), nextCursor };
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(comment: Comment): Promise<Comment> {
|
async create(comment: Comment): Promise<Comment> {
|
||||||
try {
|
try {
|
||||||
|
// Вложения создаются вложенной записью — одним атомарным запросом
|
||||||
const record = await this.prisma.comment.create({
|
const record = await this.prisma.comment.create({
|
||||||
data: CommentPrismaMapper.toCreateInput(comment),
|
data: CommentPrismaMapper.toCreateInput(comment),
|
||||||
|
include: COMMENT_INCLUDE,
|
||||||
});
|
});
|
||||||
|
|
||||||
return CommentPrismaMapper.toDomain(record);
|
return CommentPrismaMapper.toDomain(record);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Задачу удалили между проверкой доступа и вставкой
|
// Задачу или файл удалили между проверкой доступа и вставкой
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === FOREIGN_KEY_VIOLATION) {
|
throw this.mapForeignKeyError(error, comment) ?? error;
|
||||||
throw new IssueNotFoundException(comment.issueId);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(comment: Comment): Promise<void> {
|
async update(comment: Comment): Promise<void> {
|
||||||
await this.prisma.comment.update({
|
const id = comment.id as string;
|
||||||
where: { id: comment.id as string },
|
const fileIds = [...comment.fileIds];
|
||||||
data: CommentPrismaMapper.toUpdateInput(comment),
|
|
||||||
|
try {
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.comment.update({
|
||||||
|
where: { id },
|
||||||
|
data: CommentPrismaMapper.toUpdateInput(comment),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Синхронизируем набор вложений: лишние снимаем, новые добавляем
|
||||||
|
await tx.commentAttachment.deleteMany({ where: { commentId: id, fileId: { notIn: fileIds } } });
|
||||||
|
if (fileIds.length > 0) {
|
||||||
|
await tx.commentAttachment.createMany({
|
||||||
|
data: fileIds.map((fileId) => ({ commentId: id, fileId })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw this.mapForeignKeyError(error, comment) ?? error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findIssueIdsByAttachedFile(fileId: string): Promise<number[]> {
|
||||||
|
const records = await this.prisma.comment.findMany({
|
||||||
|
where: { deletedAt: null, attachments: { some: { fileId } } },
|
||||||
|
select: { issueId: true },
|
||||||
|
distinct: ['issueId'],
|
||||||
});
|
});
|
||||||
|
return records.map((record) => record.issueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findReferencedFileIds(fileIds: string[]): Promise<string[]> {
|
||||||
|
if (fileIds.length === 0) return [];
|
||||||
|
|
||||||
|
const records = await this.prisma.commentAttachment.findMany({
|
||||||
|
where: { fileId: { in: fileIds } },
|
||||||
|
select: { fileId: true },
|
||||||
|
distinct: ['fileId'],
|
||||||
|
});
|
||||||
|
return records.map((record) => record.fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Какой FK нарушен — по имени ограничения в meta (comment_attachment_fileId_fkey и т.п.) */
|
||||||
|
private mapForeignKeyError(error: unknown, comment: Comment): Error | null {
|
||||||
|
if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== FOREIGN_KEY_VIOLATION) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JSON.stringify(error.meta ?? {}).includes('fileId')) {
|
||||||
|
return new FileNotFoundException(comment.fileIds.join(', '));
|
||||||
|
}
|
||||||
|
return new IssueNotFoundException(comment.issueId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,8 @@ import { CommentNotFoundException } from '../../domain/exceptions/comment-not-fo
|
|||||||
import { CommentForbiddenException } from '../../domain/exceptions/comment-forbidden.exception.js';
|
import { CommentForbiddenException } from '../../domain/exceptions/comment-forbidden.exception.js';
|
||||||
import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js';
|
import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js';
|
||||||
import { IssueVisibleGuard } from '../guards/issue-visible.guard.js';
|
import { IssueVisibleGuard } from '../guards/issue-visible.guard.js';
|
||||||
|
import { FileNotFoundException } from '../../../file/domain/exceptions/file-not-found.exception.js';
|
||||||
|
import { FileForbiddenException } from '../../../file/domain/exceptions/file-forbidden.exception.js';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class CommentController {
|
export class CommentController {
|
||||||
@@ -54,7 +56,7 @@ export class CommentController {
|
|||||||
) {
|
) {
|
||||||
return this.handleErrors(() =>
|
return this.handleErrors(() =>
|
||||||
this.listCommentsUseCase.execute(
|
this.listCommentsUseCase.execute(
|
||||||
new ListCommentsQuery(issueId, session.user.id, Number(query.limit), query.cursor),
|
new ListCommentsQuery(issueId, session.user.id, Number(query.limit), query.cursor, query.direction),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -66,7 +68,7 @@ export class CommentController {
|
|||||||
@Session() session: UserSession,
|
@Session() session: UserSession,
|
||||||
) {
|
) {
|
||||||
return this.handleErrors(() =>
|
return this.handleErrors(() =>
|
||||||
this.createCommentUseCase.execute(new CreateCommentCommand(issueId, session.user.id, dto.content)),
|
this.createCommentUseCase.execute(new CreateCommentCommand(issueId, session.user.id, dto.content, dto.fileIds)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +95,7 @@ export class CommentController {
|
|||||||
@Patch('comments/:id')
|
@Patch('comments/:id')
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateCommentRequestDto, @Session() session: UserSession) {
|
update(@Param('id') id: string, @Body() dto: UpdateCommentRequestDto, @Session() session: UserSession) {
|
||||||
return this.handleErrors(() =>
|
return this.handleErrors(() =>
|
||||||
this.updateCommentUseCase.execute(new UpdateCommentCommand(id, session.user.id, dto.content)),
|
this.updateCommentUseCase.execute(new UpdateCommentCommand(id, session.user.id, dto.content, dto.fileIds)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,10 +110,14 @@ export class CommentController {
|
|||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof CommentNotFoundException || error instanceof IssueNotFoundException) {
|
if (
|
||||||
|
error instanceof CommentNotFoundException ||
|
||||||
|
error instanceof IssueNotFoundException ||
|
||||||
|
error instanceof FileNotFoundException
|
||||||
|
) {
|
||||||
throw new NotFoundException(error.message);
|
throw new NotFoundException(error.message);
|
||||||
}
|
}
|
||||||
if (error instanceof CommentForbiddenException) {
|
if (error instanceof CommentForbiddenException || error instanceof FileForbiddenException) {
|
||||||
throw new ForbiddenException(error.message);
|
throw new ForbiddenException(error.message);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
import { ArrayMaxSize, IsArray, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
import { CommentContent } from '../../domain/value-objects/comment-content.vo.js';
|
import { CommentContent } from '../../domain/value-objects/comment-content.vo.js';
|
||||||
|
import { Comment } from '../../domain/entities/comment.entity.js';
|
||||||
|
|
||||||
export class CreateCommentRequestDto {
|
export class CreateCommentRequestDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(CommentContent.MAX_LENGTH)
|
@MaxLength(CommentContent.MAX_LENGTH)
|
||||||
content: string;
|
content: string;
|
||||||
|
|
||||||
|
/** id файлов, загруженных через POST /files */
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(Comment.MAX_ATTACHMENTS)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@MaxLength(64, { each: true })
|
||||||
|
fileIds?: string[];
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import { IsIn, IsOptional } from 'class-validator';
|
||||||
import { IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js';
|
import { IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js';
|
||||||
|
import {
|
||||||
|
COMMENT_LIST_DIRECTIONS,
|
||||||
|
type CommentListDirection,
|
||||||
|
} from '../../domain/repositories/comment.repository.interface.js';
|
||||||
|
|
||||||
export class ListCommentsDto {
|
export class ListCommentsDto {
|
||||||
@IntDecorator({ isOptional: true, min: 1, max: 100 })
|
@IntDecorator({ isOptional: true, min: 1, max: 100 })
|
||||||
@@ -7,4 +12,13 @@ export class ListCommentsDto {
|
|||||||
/** id последнего полученного комментария (nextCursor из предыдущего ответа) */
|
/** id последнего полученного комментария (nextCursor из предыдущего ответа) */
|
||||||
@StringDecorator({ isOptional: true })
|
@StringDecorator({ isOptional: true })
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* forward — от старых к новым, cursor — последний полученный (новые после него).
|
||||||
|
* backward — страницы от новых к старым (для чата), cursor — самый старый полученный.
|
||||||
|
* В обоих случаях список в ответе отсортирован от старых к новым.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(COMMENT_LIST_DIRECTIONS)
|
||||||
|
direction: CommentListDirection = 'forward';
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
import { ArrayMaxSize, IsArray, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
import { CommentContent } from '../../domain/value-objects/comment-content.vo.js';
|
import { CommentContent } from '../../domain/value-objects/comment-content.vo.js';
|
||||||
|
import { Comment } from '../../domain/entities/comment.entity.js';
|
||||||
|
|
||||||
export class UpdateCommentRequestDto {
|
export class UpdateCommentRequestDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(CommentContent.MAX_LENGTH)
|
@MaxLength(CommentContent.MAX_LENGTH)
|
||||||
content: string;
|
content: string;
|
||||||
|
|
||||||
|
/** id файлов, загруженных через POST /files — набор целиком; не передан — не менять */
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(Comment.MAX_ATTACHMENTS)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@MaxLength(64, { each: true })
|
||||||
|
fileIds?: string[];
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export class FileDto {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
/** null — загрузивший удалил аккаунт */
|
||||||
|
uploaderId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { StoredFile } from '../../domain/entities/stored-file.entity.js';
|
||||||
|
import { FileDto } from '../dto/file.dto.js';
|
||||||
|
|
||||||
|
export class FileApplicationMapper {
|
||||||
|
/** storageKey — деталь хранилища, наружу не отдаём */
|
||||||
|
static toDto(file: StoredFile): FileDto {
|
||||||
|
return {
|
||||||
|
id: file.id as string,
|
||||||
|
fileName: file.fileName,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
size: file.size,
|
||||||
|
uploaderId: file.uploaderId,
|
||||||
|
createdAt: file.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { IFileRepository } from '../../domain/repositories/file.repository.interface.js';
|
||||||
|
import { FileForbiddenException } from '../../domain/exceptions/file-forbidden.exception.js';
|
||||||
|
import { FileNotFoundException } from '../../domain/exceptions/file-not-found.exception.js';
|
||||||
|
import { FileAccessService } from './file-access.service.js';
|
||||||
|
import { buildFile, mockFileRepository, registryWithOwner } from '../use-cases/test-helpers.js';
|
||||||
|
|
||||||
|
describe('FileAccessService.getAttachableFile', () => {
|
||||||
|
let fileRepository: IFileRepository;
|
||||||
|
let service: FileAccessService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fileRepository = mockFileRepository();
|
||||||
|
service = new FileAccessService(fileRepository, registryWithOwner({ visibleTo: { 'user-2': ['file-1'] } }));
|
||||||
|
vi.mocked(fileRepository.findById).mockResolvedValue(buildFile());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an own file', async () => {
|
||||||
|
await expect(service.getAttachableFile('file-1', 'user-1')).resolves.toMatchObject({ id: 'file-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids attaching a visible file uploaded by someone else', async () => {
|
||||||
|
await expect(service.getAttachableFile('file-1', 'user-2')).rejects.toThrow(FileForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides an invisible file', async () => {
|
||||||
|
await expect(service.getAttachableFile('file-1', 'user-3')).rejects.toThrow(FileNotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { FILE_REPOSITORY, type IFileRepository } from '../../domain/repositories/file.repository.interface.js';
|
||||||
|
import { StoredFile } from '../../domain/entities/stored-file.entity.js';
|
||||||
|
import { FileNotFoundException } from '../../domain/exceptions/file-not-found.exception.js';
|
||||||
|
import { FileOwnerRegistry } from './file-owner.registry.js';
|
||||||
|
import { FileDto } from '../dto/file.dto.js';
|
||||||
|
import { FileApplicationMapper } from '../mappers/file-application.mapper.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FileAccessService {
|
||||||
|
constructor(
|
||||||
|
@Inject(FILE_REPOSITORY)
|
||||||
|
private readonly fileRepository: IFileRepository,
|
||||||
|
private readonly fileOwners: FileOwnerRegistry,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Файл виден загрузившему и всем, кому виден хоть один его владелец
|
||||||
|
* (например, задача, к которой он прикреплён). Иначе — FileNotFoundException,
|
||||||
|
* чтобы не раскрывать существование чужих файлов.
|
||||||
|
*/
|
||||||
|
async getVisibleFile(id: string, userId: string): Promise<StoredFile> {
|
||||||
|
const file = await this.fileRepository.findById(id);
|
||||||
|
if (!file) {
|
||||||
|
throw new FileNotFoundException(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.isUploadedBy(userId) || (await this.fileOwners.isVisibleThroughAnyOwner(id, userId))) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FileNotFoundException(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Файл, который пользователь может прикрепить к задаче/комментарию:
|
||||||
|
* невидимый — FileNotFoundException, видимый, но чужой — FileForbiddenException.
|
||||||
|
*/
|
||||||
|
async getAttachableFile(id: string, userId: string): Promise<FileDto> {
|
||||||
|
const file = await this.getVisibleFile(id, userId);
|
||||||
|
file.assertOwnedBy(userId);
|
||||||
|
return FileApplicationMapper.toDto(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { IFileRepository } from '../../domain/repositories/file.repository.interface.js';
|
||||||
|
import type { IFileStorage } from '../../domain/ports/file-storage.port.js';
|
||||||
|
import { FileGcService } from './file-gc.service.js';
|
||||||
|
import { buildFile, mockFileRepository, mockFileStorage, registryWithOwner } from '../use-cases/test-helpers.js';
|
||||||
|
|
||||||
|
describe('FileGcService', () => {
|
||||||
|
let fileRepository: IFileRepository;
|
||||||
|
let fileStorage: IFileStorage;
|
||||||
|
const config = new ConfigService({ FILE_ORPHAN_TTL_HOURS: '24' });
|
||||||
|
const now = new Date('2026-01-10T00:00:00.000Z');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fileRepository = mockFileRepository();
|
||||||
|
fileStorage = mockFileStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes only unreferenced files older than the TTL', async () => {
|
||||||
|
const gc = new FileGcService(fileRepository, fileStorage, registryWithOwner({ referenced: ['kept'] }), config);
|
||||||
|
vi.mocked(fileRepository.findCreatedBefore).mockResolvedValueOnce([
|
||||||
|
buildFile({ id: 'kept' }),
|
||||||
|
buildFile({ id: 'orphan' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const removed = await gc.collect(now);
|
||||||
|
|
||||||
|
expect(fileRepository.findCreatedBefore).toHaveBeenCalledWith({
|
||||||
|
before: new Date('2026-01-09T00:00:00.000Z'),
|
||||||
|
limit: 500,
|
||||||
|
cursor: undefined,
|
||||||
|
});
|
||||||
|
expect(removed).toBe(1);
|
||||||
|
expect(fileStorage.delete).toHaveBeenCalledExactlyOnceWith('files/orphan');
|
||||||
|
expect(fileRepository.delete).toHaveBeenCalledExactlyOnceWith('orphan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the row when the object cannot be deleted and goes on', async () => {
|
||||||
|
const gc = new FileGcService(fileRepository, fileStorage, registryWithOwner(), config);
|
||||||
|
vi.mocked(fileRepository.findCreatedBefore).mockResolvedValueOnce([buildFile({ id: 'a' }), buildFile({ id: 'b' })]);
|
||||||
|
vi.mocked(fileStorage.delete).mockRejectedValueOnce(new Error('s3 down'));
|
||||||
|
|
||||||
|
const removed = await gc.collect(now);
|
||||||
|
|
||||||
|
expect(removed).toBe(1);
|
||||||
|
expect(fileRepository.delete).toHaveBeenCalledExactlyOnceWith('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pages through files by cursor', async () => {
|
||||||
|
const gc = new FileGcService(fileRepository, fileStorage, registryWithOwner(), config);
|
||||||
|
const fullBatch = Array.from({ length: 500 }, (_, i) => buildFile({ id: `f${i}` }));
|
||||||
|
vi.mocked(fileRepository.findCreatedBefore)
|
||||||
|
.mockResolvedValueOnce(fullBatch)
|
||||||
|
.mockResolvedValueOnce([buildFile({ id: 'last' })]);
|
||||||
|
|
||||||
|
const removed = await gc.collect(now);
|
||||||
|
|
||||||
|
expect(removed).toBe(501);
|
||||||
|
expect(vi.mocked(fileRepository.findCreatedBefore).mock.calls[1]![0]).toMatchObject({ cursor: 'f499' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { FILE_REPOSITORY, type IFileRepository } from '../../domain/repositories/file.repository.interface.js';
|
||||||
|
import { FILE_STORAGE, type IFileStorage } from '../../domain/ports/file-storage.port.js';
|
||||||
|
import { FileOwnerRegistry } from './file-owner.registry.js';
|
||||||
|
|
||||||
|
const BATCH_SIZE = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет файлы-сироты — на которые не ссылается ни один владелец:
|
||||||
|
* загруженные, но так и не прикреплённые, и те, чьи задачи/комментарии
|
||||||
|
* удалены (каскад в БД убирает связь, но не сам файл).
|
||||||
|
*
|
||||||
|
* TTL нужен, чтобы не удалить файл, который пользователь только что загрузил
|
||||||
|
* и вот-вот прикрепит (например, пока пишет комментарий).
|
||||||
|
*
|
||||||
|
* Сначала удаляется объект, потом строка: если упадёт удаление строки,
|
||||||
|
* файл останется сиротой и будет подобран следующим запуском
|
||||||
|
* (удаление объекта идемпотентно). Обратный порядок терял бы объекты навсегда.
|
||||||
|
*
|
||||||
|
* Несколько инстансов приложения могут запустить GC одновременно — это
|
||||||
|
* безопасно, все операции идемпотентны.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class FileGcService {
|
||||||
|
private readonly logger = new Logger(FileGcService.name);
|
||||||
|
private readonly orphanTtlMs: number;
|
||||||
|
private running = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(FILE_REPOSITORY)
|
||||||
|
private readonly fileRepository: IFileRepository,
|
||||||
|
@Inject(FILE_STORAGE)
|
||||||
|
private readonly fileStorage: IFileStorage,
|
||||||
|
private readonly fileOwners: FileOwnerRegistry,
|
||||||
|
config: ConfigService,
|
||||||
|
) {
|
||||||
|
this.orphanTtlMs = Number(config.get('FILE_ORPHAN_TTL_HOURS', 24)) * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_HOUR)
|
||||||
|
async handleCron(): Promise<void> {
|
||||||
|
if (this.running) return;
|
||||||
|
|
||||||
|
this.running = true;
|
||||||
|
try {
|
||||||
|
const removed = await this.collect();
|
||||||
|
if (removed > 0) {
|
||||||
|
this.logger.log(`Удалено файлов-сирот: ${removed}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Сборка файлов-сирот упала', error instanceof Error ? error.stack : error);
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Возвращает количество удалённых файлов */
|
||||||
|
async collect(now = new Date()): Promise<number> {
|
||||||
|
const before = new Date(now.getTime() - this.orphanTtlMs);
|
||||||
|
let cursor: string | undefined;
|
||||||
|
let removed = 0;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const batch = await this.fileRepository.findCreatedBefore({ before, limit: BATCH_SIZE, cursor });
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
|
||||||
|
const ids = batch.map((file) => file.id as string);
|
||||||
|
const referenced = await this.fileOwners.findReferenced(ids);
|
||||||
|
|
||||||
|
for (const file of batch) {
|
||||||
|
if (referenced.has(file.id as string)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.fileStorage.delete(file.storageKey);
|
||||||
|
await this.fileRepository.delete(file.id as string);
|
||||||
|
removed++;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Не удалось удалить файл id=${file.id}: ${error instanceof Error ? error.message : error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.length < BATCH_SIZE) break;
|
||||||
|
cursor = ids[ids.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модуль, к сущностям которого привязываются файлы (задачи, комментарии...).
|
||||||
|
* Связи хранятся в его таблицах (issue_attachment и т.п.), file-модуль
|
||||||
|
* о них не знает и спрашивает через этот интерфейс.
|
||||||
|
*/
|
||||||
|
export interface FileOwner {
|
||||||
|
/** Для логов */
|
||||||
|
readonly name: string;
|
||||||
|
/** Подмножество fileIds, на которые ссылаются сущности этого владельца (для GC) */
|
||||||
|
findReferenced(fileIds: string[]): Promise<string[]>;
|
||||||
|
/** Виден ли файл пользователю через сущность этого владельца */
|
||||||
|
isVisibleThrough(fileId: string, userId: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реестр владельцев файлов. Модуль-владелец регистрируется в onModuleInit:
|
||||||
|
*
|
||||||
|
* constructor(private readonly fileOwners: FileOwnerRegistry) {}
|
||||||
|
* onModuleInit() { this.fileOwners.register(this); }
|
||||||
|
*
|
||||||
|
* Важно: файл, на который не ссылается ни один зарегистрированный владелец,
|
||||||
|
* GC считает сиротой. Новый владелец ОБЯЗАН зарегистрироваться.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class FileOwnerRegistry {
|
||||||
|
private readonly owners: FileOwner[] = [];
|
||||||
|
|
||||||
|
register(owner: FileOwner): void {
|
||||||
|
this.owners.push(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findReferenced(fileIds: string[]): Promise<Set<string>> {
|
||||||
|
if (fileIds.length === 0) return new Set();
|
||||||
|
|
||||||
|
const results = await Promise.all(this.owners.map((owner) => owner.findReferenced(fileIds)));
|
||||||
|
return new Set(results.flat());
|
||||||
|
}
|
||||||
|
|
||||||
|
async isVisibleThroughAnyOwner(fileId: string, userId: string): Promise<boolean> {
|
||||||
|
for (const owner of this.owners) {
|
||||||
|
if (await owner.isVisibleThrough(fileId, userId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class DeleteFileCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly id: string,
|
||||||
|
public readonly userId: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
|
||||||
|
import type { IFileStorage } from '../../../domain/ports/file-storage.port.js';
|
||||||
|
import { FileForbiddenException } from '../../../domain/exceptions/file-forbidden.exception.js';
|
||||||
|
import { FileNotFoundException } from '../../../domain/exceptions/file-not-found.exception.js';
|
||||||
|
import { FileAccessService } from '../../services/file-access.service.js';
|
||||||
|
import { DeleteFileUseCase } from './delete-file.use-case.js';
|
||||||
|
import { DeleteFileCommand } from './delete-file.command.js';
|
||||||
|
import { buildFile, mockFileRepository, mockFileStorage, registryWithOwner } from '../test-helpers.js';
|
||||||
|
|
||||||
|
describe('DeleteFileUseCase', () => {
|
||||||
|
let fileRepository: IFileRepository;
|
||||||
|
let fileStorage: IFileStorage;
|
||||||
|
let useCase: DeleteFileUseCase;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fileRepository = mockFileRepository();
|
||||||
|
fileStorage = mockFileStorage();
|
||||||
|
const registry = registryWithOwner({ visibleTo: { 'user-2': ['file-1'] } });
|
||||||
|
useCase = new DeleteFileUseCase(fileRepository, fileStorage, new FileAccessService(fileRepository, registry));
|
||||||
|
|
||||||
|
vi.mocked(fileRepository.findById).mockResolvedValue(buildFile());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes the object first, then the row', async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
vi.mocked(fileStorage.delete).mockImplementation(async () => void order.push('storage'));
|
||||||
|
vi.mocked(fileRepository.delete).mockImplementation(async () => void order.push('row'));
|
||||||
|
|
||||||
|
await useCase.execute(new DeleteFileCommand('file-1', 'user-1'));
|
||||||
|
|
||||||
|
expect(fileStorage.delete).toHaveBeenCalledWith('files/file-1');
|
||||||
|
expect(fileRepository.delete).toHaveBeenCalledWith('file-1');
|
||||||
|
expect(order).toEqual(['storage', 'row']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the row when deleting the object fails, so a retry can finish', async () => {
|
||||||
|
vi.mocked(fileStorage.delete).mockRejectedValue(new Error('s3 down'));
|
||||||
|
|
||||||
|
await expect(useCase.execute(new DeleteFileCommand('file-1', 'user-1'))).rejects.toThrow('s3 down');
|
||||||
|
expect(fileRepository.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids deleting a visible file uploaded by someone else', async () => {
|
||||||
|
await expect(useCase.execute(new DeleteFileCommand('file-1', 'user-2'))).rejects.toThrow(FileForbiddenException);
|
||||||
|
expect(fileStorage.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns not found for an invisible file', async () => {
|
||||||
|
await expect(useCase.execute(new DeleteFileCommand('file-1', 'user-3'))).rejects.toThrow(FileNotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { FILE_REPOSITORY, type IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
|
||||||
|
import { FILE_STORAGE, type IFileStorage } from '../../../domain/ports/file-storage.port.js';
|
||||||
|
import { DeleteFileCommand } from './delete-file.command.js';
|
||||||
|
import { FileAccessService } from '../../services/file-access.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет файл целиком; связи с владельцами уходят каскадом в БД.
|
||||||
|
* Порядок как в FileGcService: сначала объект, потом строка — при сбое
|
||||||
|
* повторный DELETE доведёт дело до конца, объект не потеряется.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DeleteFileUseCase {
|
||||||
|
constructor(
|
||||||
|
@Inject(FILE_REPOSITORY)
|
||||||
|
private readonly fileRepository: IFileRepository,
|
||||||
|
@Inject(FILE_STORAGE)
|
||||||
|
private readonly fileStorage: IFileStorage,
|
||||||
|
private readonly fileAccess: FileAccessService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: DeleteFileCommand): Promise<void> {
|
||||||
|
const file = await this.fileAccess.getVisibleFile(command.id, command.userId);
|
||||||
|
file.assertOwnedBy(command.userId);
|
||||||
|
|
||||||
|
await this.fileStorage.delete(file.storageKey);
|
||||||
|
await this.fileRepository.delete(command.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class DownloadFileQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly id: string,
|
||||||
|
public readonly userId: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { Readable } from 'node:stream';
|
||||||
|
import type { IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
|
||||||
|
import type { IFileStorage } from '../../../domain/ports/file-storage.port.js';
|
||||||
|
import { FileNotFoundException } from '../../../domain/exceptions/file-not-found.exception.js';
|
||||||
|
import { FileAccessService } from '../../services/file-access.service.js';
|
||||||
|
import { DownloadFileUseCase } from './download-file.use-case.js';
|
||||||
|
import { DownloadFileQuery } from './download-file.query.js';
|
||||||
|
import { buildFile, mockFileRepository, mockFileStorage, registryWithOwner } from '../test-helpers.js';
|
||||||
|
|
||||||
|
describe('DownloadFileUseCase', () => {
|
||||||
|
let fileRepository: IFileRepository;
|
||||||
|
let fileStorage: IFileStorage;
|
||||||
|
let useCase: DownloadFileUseCase;
|
||||||
|
const stream = {} as Readable;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fileRepository = mockFileRepository();
|
||||||
|
fileStorage = mockFileStorage();
|
||||||
|
const registry = registryWithOwner({ visibleTo: { 'user-2': ['file-1'] } });
|
||||||
|
useCase = new DownloadFileUseCase(fileStorage, new FileAccessService(fileRepository, registry));
|
||||||
|
|
||||||
|
vi.mocked(fileStorage.get).mockResolvedValue(stream);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the uploader download the file', async () => {
|
||||||
|
vi.mocked(fileRepository.findById).mockResolvedValue(buildFile());
|
||||||
|
|
||||||
|
const result = await useCase.execute(new DownloadFileQuery('file-1', 'user-1'));
|
||||||
|
|
||||||
|
expect(fileStorage.get).toHaveBeenCalledWith('files/file-1');
|
||||||
|
expect(result).toMatchObject({ file: { id: 'file-1' }, inline: true, stream });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a user who sees an owner of the file download it', async () => {
|
||||||
|
vi.mocked(fileRepository.findById).mockResolvedValue(buildFile());
|
||||||
|
|
||||||
|
await expect(useCase.execute(new DownloadFileQuery('file-1', 'user-2'))).resolves.toMatchObject({
|
||||||
|
file: { id: 'file-1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks non-image files as attachment-only', async () => {
|
||||||
|
vi.mocked(fileRepository.findById).mockResolvedValue(buildFile({ mimeType: 'application/octet-stream' }));
|
||||||
|
|
||||||
|
const result = await useCase.execute(new DownloadFileQuery('file-1', 'user-1'));
|
||||||
|
|
||||||
|
expect(result.inline).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the file from everyone else', async () => {
|
||||||
|
vi.mocked(fileRepository.findById).mockResolvedValue(buildFile());
|
||||||
|
|
||||||
|
await expect(useCase.execute(new DownloadFileQuery('file-1', 'user-3'))).rejects.toThrow(FileNotFoundException);
|
||||||
|
expect(fileStorage.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws FileNotFoundException for a missing file', async () => {
|
||||||
|
vi.mocked(fileRepository.findById).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(useCase.execute(new DownloadFileQuery('file-1', 'user-1'))).rejects.toThrow(FileNotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Readable } from 'node:stream';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { FILE_STORAGE, type IFileStorage } from '../../../domain/ports/file-storage.port.js';
|
||||||
|
import { DownloadFileQuery } from './download-file.query.js';
|
||||||
|
import { FileDto } from '../../dto/file.dto.js';
|
||||||
|
import { FileApplicationMapper } from '../../mappers/file-application.mapper.js';
|
||||||
|
import { FileAccessService } from '../../services/file-access.service.js';
|
||||||
|
|
||||||
|
export interface DownloadFileResult {
|
||||||
|
file: FileDto;
|
||||||
|
/** Можно ли показывать в браузере inline, иначе — только скачивание */
|
||||||
|
inline: boolean;
|
||||||
|
stream: Readable;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DownloadFileUseCase {
|
||||||
|
constructor(
|
||||||
|
@Inject(FILE_STORAGE)
|
||||||
|
private readonly fileStorage: IFileStorage,
|
||||||
|
private readonly fileAccess: FileAccessService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: DownloadFileQuery): Promise<DownloadFileResult> {
|
||||||
|
const file = await this.fileAccess.getVisibleFile(query.id, query.userId);
|
||||||
|
const stream = await this.fileStorage.get(file.storageKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: FileApplicationMapper.toDto(file),
|
||||||
|
inline: file.isInlineViewable,
|
||||||
|
stream,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export class GetFilesQuery {
|
||||||
|
constructor(public readonly ids: string[]) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { FILE_REPOSITORY, type IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
|
||||||
|
import { GetFilesQuery } from './get-files.query.js';
|
||||||
|
import { FileDto } from '../../dto/file.dto.js';
|
||||||
|
import { FileApplicationMapper } from '../../mappers/file-application.mapper.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Метаданные файлов по id — для модулей-владельцев, раскрывающих свои связи
|
||||||
|
* в FileDto (по аналогии с GetUsersUseCase). Доступ проверяет вызывающий:
|
||||||
|
* он отдаёт только файлы своих сущностей, видимых пользователю.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class GetFilesUseCase {
|
||||||
|
constructor(
|
||||||
|
@Inject(FILE_REPOSITORY)
|
||||||
|
private readonly fileRepository: IFileRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: GetFilesQuery): Promise<FileDto[]> {
|
||||||
|
const ids = [...new Set(query.ids)];
|
||||||
|
const files = await this.fileRepository.findByIds(ids);
|
||||||
|
return files.map(FileApplicationMapper.toDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { vi } from 'vitest';
|
||||||
|
import type { IFileRepository } from '../../domain/repositories/file.repository.interface.js';
|
||||||
|
import type { IFileStorage } from '../../domain/ports/file-storage.port.js';
|
||||||
|
import { StoredFile } from '../../domain/entities/stored-file.entity.js';
|
||||||
|
import { FileOwnerRegistry, type FileOwner } from '../services/file-owner.registry.js';
|
||||||
|
|
||||||
|
export const buildFile = (
|
||||||
|
overrides: Partial<{ id: string; uploaderId: string | null; mimeType: string; createdAt: Date }> = {},
|
||||||
|
) =>
|
||||||
|
StoredFile.reconstitute({
|
||||||
|
id: overrides.id ?? 'file-1',
|
||||||
|
storageKey: `files/${overrides.id ?? 'file-1'}`,
|
||||||
|
fileName: 'photo.png',
|
||||||
|
mimeType: overrides.mimeType ?? 'image/png',
|
||||||
|
size: 10,
|
||||||
|
uploaderId: overrides.uploaderId === undefined ? 'user-1' : overrides.uploaderId,
|
||||||
|
createdAt: overrides.createdAt ?? new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mockFileRepository = (): IFileRepository => ({
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByIds: vi.fn(),
|
||||||
|
findCreatedBefore: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mockFileStorage = (): IFileStorage => ({
|
||||||
|
put: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Реестр с одним владельцем, которому видны/принадлежат перечисленные файлы */
|
||||||
|
export const registryWithOwner = (params: { referenced?: string[]; visibleTo?: Record<string, string[]> } = {}) => {
|
||||||
|
const owner: FileOwner = {
|
||||||
|
name: 'test',
|
||||||
|
findReferenced: vi.fn(async (ids: string[]) => ids.filter((id) => params.referenced?.includes(id))),
|
||||||
|
isVisibleThrough: vi.fn(async (fileId: string, userId: string) =>
|
||||||
|
Boolean(params.visibleTo?.[userId]?.includes(fileId)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const registry = new FileOwnerRegistry();
|
||||||
|
registry.register(owner);
|
||||||
|
return registry;
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export class UploadFileCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly uploaderId: string,
|
||||||
|
/** Временный файл, куда multer сохранил загрузку. Удаляет его вызывающий */
|
||||||
|
public readonly tempPath: string,
|
||||||
|
public readonly originalName: string,
|
||||||
|
public readonly size: number,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
|
||||||
|
import type { IFileStorage } from '../../../domain/ports/file-storage.port.js';
|
||||||
|
import type { IFileTypeDetector } from '../../../domain/ports/file-type-detector.port.js';
|
||||||
|
import { StoredFile } from '../../../domain/entities/stored-file.entity.js';
|
||||||
|
import { UploadFileUseCase } from './upload-file.use-case.js';
|
||||||
|
import { UploadFileCommand } from './upload-file.command.js';
|
||||||
|
import { mockFileRepository, mockFileStorage } from '../test-helpers.js';
|
||||||
|
|
||||||
|
vi.mock('node:fs', () => ({ createReadStream: vi.fn(() => 'stream') }));
|
||||||
|
|
||||||
|
describe('UploadFileUseCase', () => {
|
||||||
|
let fileRepository: IFileRepository;
|
||||||
|
let fileStorage: IFileStorage;
|
||||||
|
let detector: IFileTypeDetector;
|
||||||
|
let useCase: UploadFileUseCase;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fileRepository = mockFileRepository();
|
||||||
|
fileStorage = mockFileStorage();
|
||||||
|
detector = { detect: vi.fn() };
|
||||||
|
useCase = new UploadFileUseCase(fileRepository, fileStorage, detector);
|
||||||
|
|
||||||
|
vi.mocked(fileRepository.create).mockImplementation(async (file: StoredFile) =>
|
||||||
|
StoredFile.reconstitute({
|
||||||
|
id: 'file-1',
|
||||||
|
storageKey: file.storageKey,
|
||||||
|
fileName: file.fileName,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
size: file.size,
|
||||||
|
uploaderId: file.uploaderId,
|
||||||
|
createdAt: file.createdAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores the file under a generated key with the sniffed MIME type', async () => {
|
||||||
|
vi.mocked(detector.detect).mockResolvedValue('image/png');
|
||||||
|
|
||||||
|
const dto = await useCase.execute(new UploadFileCommand('user-1', '/tmp/upload', 'photo.png', 10));
|
||||||
|
|
||||||
|
const [key, , meta] = vi.mocked(fileStorage.put).mock.calls[0]!;
|
||||||
|
expect(key).toMatch(/^files\/[0-9a-f-]{36}$/);
|
||||||
|
expect(meta).toEqual({ contentType: 'image/png', size: 10 });
|
||||||
|
expect(dto).toEqual({
|
||||||
|
id: 'file-1',
|
||||||
|
fileName: 'photo.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
size: 10,
|
||||||
|
uploaderId: 'user-1',
|
||||||
|
createdAt: expect.any(Date),
|
||||||
|
});
|
||||||
|
expect(dto).not.toHaveProperty('storageKey');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to octet-stream for unrecognized content, ignoring the name', async () => {
|
||||||
|
vi.mocked(detector.detect).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const dto = await useCase.execute(new UploadFileCommand('user-1', '/tmp/upload', 'evil.html', 10));
|
||||||
|
|
||||||
|
expect(dto.mimeType).toBe('application/octet-stream');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the stored object when saving the row fails', async () => {
|
||||||
|
vi.mocked(detector.detect).mockResolvedValue('image/png');
|
||||||
|
vi.mocked(fileRepository.create).mockRejectedValue(new Error('db down'));
|
||||||
|
|
||||||
|
await expect(useCase.execute(new UploadFileCommand('user-1', '/tmp/upload', 'photo.png', 10))).rejects.toThrow(
|
||||||
|
'db down',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [key] = vi.mocked(fileStorage.put).mock.calls[0]!;
|
||||||
|
expect(fileStorage.delete).toHaveBeenCalledWith(key);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { FILE_REPOSITORY, type IFileRepository } from '../../../domain/repositories/file.repository.interface.js';
|
||||||
|
import { FILE_STORAGE, type IFileStorage } from '../../../domain/ports/file-storage.port.js';
|
||||||
|
import { FILE_TYPE_DETECTOR, type IFileTypeDetector } from '../../../domain/ports/file-type-detector.port.js';
|
||||||
|
import { StoredFile } from '../../../domain/entities/stored-file.entity.js';
|
||||||
|
import { UploadFileCommand } from './upload-file.command.js';
|
||||||
|
import { FileDto } from '../../dto/file.dto.js';
|
||||||
|
import { FileApplicationMapper } from '../../mappers/file-application.mapper.js';
|
||||||
|
|
||||||
|
const UNKNOWN_MIME_TYPE = 'application/octet-stream';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает файл без привязки к владельцу. Прикрепление к задаче/комментарию —
|
||||||
|
* отдельный шаг в модуле-владельце; не прикреплённый файл удалит GC.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class UploadFileUseCase {
|
||||||
|
private readonly logger = new Logger(UploadFileUseCase.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(FILE_REPOSITORY)
|
||||||
|
private readonly fileRepository: IFileRepository,
|
||||||
|
@Inject(FILE_STORAGE)
|
||||||
|
private readonly fileStorage: IFileStorage,
|
||||||
|
@Inject(FILE_TYPE_DETECTOR)
|
||||||
|
private readonly fileTypeDetector: IFileTypeDetector,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: UploadFileCommand): Promise<FileDto> {
|
||||||
|
// Заголовку Content-Type от клиента не доверяем. Нераспознанное
|
||||||
|
// (в т.ч. HTML/SVG) хранится как octet-stream и отдаётся только на скачивание.
|
||||||
|
const mimeType = (await this.fileTypeDetector.detect(command.tempPath)) ?? UNKNOWN_MIME_TYPE;
|
||||||
|
const storageKey = `files/${randomUUID()}`;
|
||||||
|
|
||||||
|
await this.fileStorage.put(storageKey, createReadStream(command.tempPath), {
|
||||||
|
contentType: mimeType,
|
||||||
|
size: command.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = await this.fileRepository.create(
|
||||||
|
StoredFile.create({
|
||||||
|
storageKey,
|
||||||
|
fileName: command.originalName,
|
||||||
|
mimeType,
|
||||||
|
size: command.size,
|
||||||
|
uploaderId: command.uploaderId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return FileApplicationMapper.toDto(file);
|
||||||
|
} catch (error) {
|
||||||
|
// Строки нет — GC этот объект никогда не найдёт, убираем сразу
|
||||||
|
try {
|
||||||
|
await this.fileStorage.delete(storageKey);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
this.logger.warn(`Не удалось удалить объект ${storageKey}: ${String(cleanupError)}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { StoredFile } from './stored-file.entity.js';
|
||||||
|
import { FileForbiddenException } from '../exceptions/file-forbidden.exception.js';
|
||||||
|
|
||||||
|
const build = (mimeType = 'image/png', uploaderId: string | null = 'user-1') =>
|
||||||
|
StoredFile.reconstitute({
|
||||||
|
id: 'file-1',
|
||||||
|
storageKey: 'files/key',
|
||||||
|
fileName: 'photo.png',
|
||||||
|
mimeType,
|
||||||
|
size: 10,
|
||||||
|
uploaderId,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StoredFile', () => {
|
||||||
|
it('creates a new file with sanitized name and no id', () => {
|
||||||
|
const file = StoredFile.create({
|
||||||
|
storageKey: 'files/key',
|
||||||
|
fileName: '../secret/report.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
size: 100,
|
||||||
|
uploaderId: 'user-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(file.id).toBeNull();
|
||||||
|
expect(file.fileName).toBe('report.pdf');
|
||||||
|
expect(file.uploaderId).toBe('user-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows inline view only for safe image types', () => {
|
||||||
|
expect(build('image/png').isInlineViewable).toBe(true);
|
||||||
|
expect(build('image/svg+xml').isInlineViewable).toBe(false);
|
||||||
|
expect(build('text/html').isInlineViewable).toBe(false);
|
||||||
|
expect(build('application/octet-stream').isInlineViewable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats only the uploader as the owner', () => {
|
||||||
|
expect(() => build().assertOwnedBy('user-1')).not.toThrow();
|
||||||
|
expect(() => build().assertOwnedBy('user-2')).toThrow(FileForbiddenException);
|
||||||
|
expect(() => build('image/png', null).assertOwnedBy('user-1')).toThrow(FileForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { FileName } from '../value-objects/file-name.vo.js';
|
||||||
|
import { FileForbiddenException } from '../exceptions/file-forbidden.exception.js';
|
||||||
|
|
||||||
|
interface StoredFileProps {
|
||||||
|
id: string | null;
|
||||||
|
storageKey: string;
|
||||||
|
fileName: FileName;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
/** null — загрузивший удалил аккаунт */
|
||||||
|
uploaderId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Типы, которые безопасно показывать в браузере inline. Всё остальное
|
||||||
|
* (в т.ч. HTML и SVG — источник stored XSS) отдаётся только на скачивание.
|
||||||
|
*/
|
||||||
|
const INLINE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загруженный файл (не путать с глобальным `File` из Web API).
|
||||||
|
* О владельцах (задача, комментарий...) ничего не знает — связи живут
|
||||||
|
* в модулях-владельцах, см. FileOwnerRegistry.
|
||||||
|
*
|
||||||
|
* `id` (uuid v7) генерирует Prisma, поэтому у нового файла он null до сохранения.
|
||||||
|
*/
|
||||||
|
export class StoredFile {
|
||||||
|
private constructor(private readonly props: StoredFileProps) {}
|
||||||
|
|
||||||
|
/** Фабричный метод для НОВОГО файла, уже положенного в хранилище */
|
||||||
|
static create(params: {
|
||||||
|
storageKey: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
uploaderId: string;
|
||||||
|
}): StoredFile {
|
||||||
|
return new StoredFile({
|
||||||
|
id: null,
|
||||||
|
storageKey: params.storageKey,
|
||||||
|
fileName: FileName.create(params.fileName),
|
||||||
|
mimeType: params.mimeType,
|
||||||
|
size: params.size,
|
||||||
|
uploaderId: params.uploaderId,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Восстановление сущности из персистентного слоя */
|
||||||
|
static reconstitute(props: {
|
||||||
|
id: string;
|
||||||
|
storageKey: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
uploaderId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}): StoredFile {
|
||||||
|
return new StoredFile({
|
||||||
|
...props,
|
||||||
|
fileName: FileName.create(props.fileName),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): string | null {
|
||||||
|
return this.props.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get storageKey(): string {
|
||||||
|
return this.props.storageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
get fileName(): string {
|
||||||
|
return this.props.fileName.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get mimeType(): string {
|
||||||
|
return this.props.mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.props.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
get uploaderId(): string | null {
|
||||||
|
return this.props.uploaderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get createdAt(): Date {
|
||||||
|
return this.props.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isInlineViewable(): boolean {
|
||||||
|
return INLINE_MIME_TYPES.has(this.props.mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
isUploadedBy(userId: string): boolean {
|
||||||
|
return this.props.uploaderId === userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить файл или прикрепить его к сущности может только тот, кто его загрузил.
|
||||||
|
* Иначе, увидев файл через одну задачу, можно было бы «перенести» его
|
||||||
|
* в другую и раскрыть людям, которым он не предназначался.
|
||||||
|
*/
|
||||||
|
assertOwnedBy(userId: string): void {
|
||||||
|
if (!this.isUploadedBy(userId)) {
|
||||||
|
throw new FileForbiddenException(this.props.id ?? 'new');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class FileForbiddenException extends Error {
|
||||||
|
constructor(id: string) {
|
||||||
|
super(`Нет прав на это действие с файлом id=${id}`);
|
||||||
|
this.name = 'FileForbiddenException';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class FileNotFoundException extends Error {
|
||||||
|
constructor(id: string) {
|
||||||
|
super(`Файл с id=${id} не найден`);
|
||||||
|
this.name = 'FileNotFoundException';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Readable } from 'node:stream';
|
||||||
|
|
||||||
|
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
|
||||||
|
export const FILE_STORAGE = Symbol('FILE_STORAGE');
|
||||||
|
|
||||||
|
/** Объектное хранилище содержимого файлов (S3-совместимое или локальный диск) */
|
||||||
|
export interface IFileStorage {
|
||||||
|
put(key: string, body: Readable, meta: { contentType: string; size: number }): Promise<void>;
|
||||||
|
/** Бросает ошибку сразу, если объекта нет, — до того как ответ начнёт отправляться */
|
||||||
|
get(key: string): Promise<Readable>;
|
||||||
|
/** Идемпотентно: отсутствие объекта не ошибка */
|
||||||
|
delete(key: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
|
||||||
|
export const FILE_TYPE_DETECTOR = Symbol('FILE_TYPE_DETECTOR');
|
||||||
|
|
||||||
|
/** Определяет MIME-тип по содержимому файла, а не по заголовку от клиента */
|
||||||
|
export interface IFileTypeDetector {
|
||||||
|
/** null — тип не распознан (в т.ч. все текстовые форматы: HTML, SVG, CSV...) */
|
||||||
|
detect(path: string): Promise<string | null>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { StoredFile } from '../entities/stored-file.entity.js';
|
||||||
|
|
||||||
|
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
|
||||||
|
export const FILE_REPOSITORY = Symbol('FILE_REPOSITORY');
|
||||||
|
|
||||||
|
export interface IFileRepository {
|
||||||
|
findById(id: string): Promise<StoredFile | null>;
|
||||||
|
findByIds(ids: string[]): Promise<StoredFile[]>;
|
||||||
|
/**
|
||||||
|
* Файлы, созданные раньше `before`, по возрастанию id (uuid v7 ≈ время создания).
|
||||||
|
* Курсорная пагинация для GC: cursor — id последнего полученного файла.
|
||||||
|
*/
|
||||||
|
findCreatedBefore(params: { before: Date; limit: number; cursor?: string }): Promise<StoredFile[]>;
|
||||||
|
create(file: StoredFile): Promise<StoredFile>;
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { FileName } from './file-name.vo.js';
|
||||||
|
|
||||||
|
describe('FileName', () => {
|
||||||
|
it('keeps a regular name, including cyrillic', () => {
|
||||||
|
expect(FileName.create('Отчёт за май.pdf').value).toBe('Отчёт за май.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips directories from the client-supplied path', () => {
|
||||||
|
expect(FileName.create('C:\\Users\\me\\photo.png').value).toBe('photo.png');
|
||||||
|
expect(FileName.create('../../etc/passwd').value).toBe('passwd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes control characters', () => {
|
||||||
|
expect(FileName.create('evil\r\nname\u0000.txt').value).toBe('evilname.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back when nothing is left', () => {
|
||||||
|
expect(FileName.create('').value).toBe(FileName.FALLBACK);
|
||||||
|
expect(FileName.create(' ').value).toBe(FileName.FALLBACK);
|
||||||
|
expect(FileName.create('dir/..').value).toBe(FileName.FALLBACK);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates long names keeping the extension', () => {
|
||||||
|
const name = FileName.create(`${'a'.repeat(300)}.docx`).value;
|
||||||
|
|
||||||
|
expect([...name]).toHaveLength(FileName.MAX_LENGTH);
|
||||||
|
expect(name.endsWith('.docx')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Оригинальное имя файла — только для отображения и Content-Disposition.
|
||||||
|
* В ключ хранилища не попадает, поэтому достаточно убрать путь и
|
||||||
|
* управляющие символы, а не экранировать всё подряд.
|
||||||
|
*/
|
||||||
|
export class FileName {
|
||||||
|
static readonly MAX_LENGTH = 255;
|
||||||
|
static readonly FALLBACK = 'file';
|
||||||
|
|
||||||
|
private constructor(private readonly _value: string) {}
|
||||||
|
|
||||||
|
static create(value: string): FileName {
|
||||||
|
const baseName = (value ?? '').split(/[/\\]/).pop() ?? '';
|
||||||
|
const cleaned = Array.from(baseName.normalize('NFC'))
|
||||||
|
.filter((char) => !FileName.isControl(char))
|
||||||
|
.join('')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (!cleaned || cleaned === '.' || cleaned === '..') {
|
||||||
|
return new FileName(FileName.FALLBACK);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FileName(FileName.truncate(cleaned));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** C0-управляющие символы и DEL: переводы строк ломают заголовки, \0 — пути */
|
||||||
|
private static isControl(char: string): boolean {
|
||||||
|
const code = char.codePointAt(0)!;
|
||||||
|
return code <= 0x1f || code === 0x7f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Обрезает длинное имя, сохраняя расширение */
|
||||||
|
private static truncate(name: string): string {
|
||||||
|
const chars = Array.from(name);
|
||||||
|
if (chars.length <= FileName.MAX_LENGTH) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
// Array.from, а не split(''): длина в code points, суррогатные пары не рвём
|
||||||
|
const ext = dot > 0 ? Array.from(name.slice(dot)) : [];
|
||||||
|
if (ext.length === 0 || ext.length > 16) {
|
||||||
|
return chars.slice(0, FileName.MAX_LENGTH).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return chars.slice(0, FileName.MAX_LENGTH - ext.length).join('') + ext.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): string {
|
||||||
|
return this._value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { MulterModule } from '@nestjs/platform-express';
|
||||||
|
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
|
||||||
|
import { FILE_REPOSITORY } from './domain/repositories/file.repository.interface.js';
|
||||||
|
import { FILE_STORAGE } from './domain/ports/file-storage.port.js';
|
||||||
|
import { FILE_TYPE_DETECTOR } from './domain/ports/file-type-detector.port.js';
|
||||||
|
import { FilePrismaRepository } from './infrastructure/persistence/prisma/repositories/file.prisma-repository.js';
|
||||||
|
import { createFileStorage } from './infrastructure/storage/file-storage.factory.js';
|
||||||
|
import { FileTypeDetector } from './infrastructure/storage/file-type.detector.js';
|
||||||
|
import { FileController } from './presentation/controllers/file.controller.js';
|
||||||
|
import { UploadFileUseCase } from './application/use-cases/upload-file/upload-file.use-case.js';
|
||||||
|
import { DownloadFileUseCase } from './application/use-cases/download-file/download-file.use-case.js';
|
||||||
|
import { DeleteFileUseCase } from './application/use-cases/delete-file/delete-file.use-case.js';
|
||||||
|
import { GetFilesUseCase } from './application/use-cases/get-files/get-files.use-case.js';
|
||||||
|
import { FileAccessService } from './application/services/file-access.service.js';
|
||||||
|
import { FileGcService } from './application/services/file-gc.service.js';
|
||||||
|
import { FileOwnerRegistry } from './application/services/file-owner.registry.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PrismaModule,
|
||||||
|
MulterModule.registerAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
// Загрузка пишется во временный файл, а не в память: тип определяем
|
||||||
|
// по содержимому, размер известен заранее, память не зависит от размера файла
|
||||||
|
dest: tmpdir(),
|
||||||
|
// Без этого multer декодирует имя файла как latin1 и ломает кириллицу
|
||||||
|
defParamCharset: 'utf8',
|
||||||
|
limits: {
|
||||||
|
fileSize: Number(config.get('FILE_MAX_SIZE_MB', 25)) * 1024 * 1024,
|
||||||
|
files: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [FileController],
|
||||||
|
providers: [
|
||||||
|
UploadFileUseCase,
|
||||||
|
DownloadFileUseCase,
|
||||||
|
DeleteFileUseCase,
|
||||||
|
GetFilesUseCase,
|
||||||
|
FileAccessService,
|
||||||
|
FileGcService,
|
||||||
|
FileOwnerRegistry,
|
||||||
|
{
|
||||||
|
// Domain видит только интерфейсы, конкретные реализации подставляет DI-контейнер здесь
|
||||||
|
provide: FILE_REPOSITORY,
|
||||||
|
useClass: FilePrismaRepository,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: FILE_STORAGE,
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: createFileStorage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: FILE_TYPE_DETECTOR,
|
||||||
|
useClass: FileTypeDetector,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Модули-владельцы (issue, comment) регистрируются в реестре,
|
||||||
|
// проверяют прикрепляемые файлы и раскрывают связи в FileDto
|
||||||
|
exports: [FileOwnerRegistry, FileAccessService, GetFilesUseCase],
|
||||||
|
})
|
||||||
|
export class FileModule {}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { File as PrismaFile, Prisma } from '../../../../../../generated/prisma/client.js';
|
||||||
|
import { StoredFile } from '../../../../domain/entities/stored-file.entity.js';
|
||||||
|
|
||||||
|
export class FilePrismaMapper {
|
||||||
|
static toDomain(record: PrismaFile): StoredFile {
|
||||||
|
return StoredFile.reconstitute({
|
||||||
|
id: record.id,
|
||||||
|
storageKey: record.storageKey,
|
||||||
|
fileName: record.fileName,
|
||||||
|
mimeType: record.mimeType,
|
||||||
|
size: record.size,
|
||||||
|
uploaderId: record.uploaderId,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static toCreateInput(file: StoredFile): Prisma.FileUncheckedCreateInput {
|
||||||
|
return {
|
||||||
|
storageKey: file.storageKey,
|
||||||
|
fileName: file.fileName,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
size: file.size,
|
||||||
|
uploaderId: file.uploaderId,
|
||||||
|
createdAt: file.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
|
||||||
|
import { StoredFile } from '../../../../domain/entities/stored-file.entity.js';
|
||||||
|
import { IFileRepository } from '../../../../domain/repositories/file.repository.interface.js';
|
||||||
|
import { FilePrismaMapper } from '../mappers/file.mapper.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FilePrismaRepository implements IFileRepository {
|
||||||
|
constructor(private readonly prisma: PrismaAdapter) {}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<StoredFile | null> {
|
||||||
|
const record = await this.prisma.file.findUnique({ where: { id } });
|
||||||
|
return record ? FilePrismaMapper.toDomain(record) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByIds(ids: string[]): Promise<StoredFile[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
|
||||||
|
const records = await this.prisma.file.findMany({ where: { id: { in: ids } } });
|
||||||
|
return records.map(FilePrismaMapper.toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCreatedBefore(params: { before: Date; limit: number; cursor?: string }): Promise<StoredFile[]> {
|
||||||
|
const records = await this.prisma.file.findMany({
|
||||||
|
where: { createdAt: { lt: params.before } },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
take: Math.max(1, params.limit),
|
||||||
|
...(params.cursor ? { cursor: { id: params.cursor }, skip: 1 } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map(FilePrismaMapper.toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(file: StoredFile): Promise<StoredFile> {
|
||||||
|
const record = await this.prisma.file.create({
|
||||||
|
data: FilePrismaMapper.toCreateInput(file),
|
||||||
|
});
|
||||||
|
|
||||||
|
return FilePrismaMapper.toDomain(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
// deleteMany не падает, если строку уже удалил параллельный запрос/GC
|
||||||
|
await this.prisma.file.deleteMany({ where: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { IFileStorage } from '../../domain/ports/file-storage.port.js';
|
||||||
|
import { LocalFileStorage } from './local-file-storage.js';
|
||||||
|
import { S3FileStorage } from './s3-file-storage.js';
|
||||||
|
|
||||||
|
/** FILE_STORAGE=s3 — S3-совместимое хранилище, иначе локальный диск */
|
||||||
|
export function createFileStorage(config: ConfigService): IFileStorage {
|
||||||
|
const driver = config.get<string>('FILE_STORAGE', 'local');
|
||||||
|
|
||||||
|
if (driver === 's3') {
|
||||||
|
return new S3FileStorage({
|
||||||
|
endpoint: config.get<string>('S3_ENDPOINT') || undefined,
|
||||||
|
region: config.get<string>('S3_REGION', 'us-east-1'),
|
||||||
|
bucket: config.getOrThrow<string>('S3_BUCKET'),
|
||||||
|
accessKeyId: config.getOrThrow<string>('S3_ACCESS_KEY_ID'),
|
||||||
|
secretAccessKey: config.getOrThrow<string>('S3_SECRET_ACCESS_KEY'),
|
||||||
|
forcePathStyle: config.get<string>('S3_FORCE_PATH_STYLE', 'true') !== 'false',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (driver !== 'local') {
|
||||||
|
throw new Error(`Неизвестный FILE_STORAGE=${driver}, ожидается local | s3`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LocalFileStorage(config.get<string>('FILE_STORAGE_LOCAL_DIR', './storage'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { fileTypeFromFile } from 'file-type';
|
||||||
|
import type { IFileTypeDetector } from '../../domain/ports/file-type-detector.port.js';
|
||||||
|
|
||||||
|
/** Определение типа по сигнатуре (magic bytes) через file-type */
|
||||||
|
@Injectable()
|
||||||
|
export class FileTypeDetector implements IFileTypeDetector {
|
||||||
|
async detect(path: string): Promise<string | null> {
|
||||||
|
return (await fileTypeFromFile(path))?.mime ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { text } from 'node:stream/consumers';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { LocalFileStorage } from './local-file-storage.js';
|
||||||
|
|
||||||
|
describe('LocalFileStorage', () => {
|
||||||
|
let root: string;
|
||||||
|
let storage: LocalFileStorage;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
root = await mkdtemp(join(tmpdir(), 'ulitka-files-'));
|
||||||
|
storage = new LocalFileStorage(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts, gets and deletes an object', async () => {
|
||||||
|
await storage.put('files/a', Readable.from(['hello']));
|
||||||
|
|
||||||
|
expect(await text(await storage.get('files/a'))).toBe('hello');
|
||||||
|
|
||||||
|
await storage.delete('files/a');
|
||||||
|
await expect(storage.get('files/a')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats deleting a missing object as success', async () => {
|
||||||
|
await expect(storage.delete('files/missing')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects keys escaping the root', async () => {
|
||||||
|
await expect(storage.put('../escape', Readable.from(['x']))).rejects.toThrow('Недопустимый ключ');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createReadStream, createWriteStream } from 'node:fs';
|
||||||
|
import { mkdir, rm, stat } from 'node:fs/promises';
|
||||||
|
import { dirname, resolve, sep } from 'node:path';
|
||||||
|
import type { Readable } from 'node:stream';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
import type { IFileStorage } from '../../domain/ports/file-storage.port.js';
|
||||||
|
|
||||||
|
/** Хранилище на локальном диске — для разработки и тестов */
|
||||||
|
export class LocalFileStorage implements IFileStorage {
|
||||||
|
private readonly root: string;
|
||||||
|
|
||||||
|
constructor(root: string) {
|
||||||
|
this.root = resolve(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(key: string, body: Readable): Promise<void> {
|
||||||
|
const path = this.resolveKey(key);
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await pipeline(body, createWriteStream(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<Readable> {
|
||||||
|
const path = this.resolveKey(key);
|
||||||
|
await stat(path);
|
||||||
|
return createReadStream(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string): Promise<void> {
|
||||||
|
await rm(this.resolveKey(key), { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ключи генерирует приложение, но выход за пределы root всё равно запрещаем */
|
||||||
|
private resolveKey(key: string): string {
|
||||||
|
const path = resolve(this.root, key);
|
||||||
|
if (!path.startsWith(this.root + sep)) {
|
||||||
|
throw new Error(`Недопустимый ключ хранилища: ${key}`);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { Readable } from 'node:stream';
|
||||||
|
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||||
|
import type { IFileStorage } from '../../domain/ports/file-storage.port.js';
|
||||||
|
|
||||||
|
export interface S3FileStorageOptions {
|
||||||
|
/** Для MinIO/RustFS — адрес сервера, для AWS можно не задавать */
|
||||||
|
endpoint?: string;
|
||||||
|
region: string;
|
||||||
|
bucket: string;
|
||||||
|
accessKeyId: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
/** MinIO/RustFS обычно требуют path-style (http://host/bucket/key) */
|
||||||
|
forcePathStyle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3-совместимое хранилище (AWS S3, MinIO, RustFS...).
|
||||||
|
* Бакет должен существовать заранее — приложение его не создаёт.
|
||||||
|
*/
|
||||||
|
export class S3FileStorage implements IFileStorage {
|
||||||
|
private readonly client: S3Client;
|
||||||
|
private readonly bucket: string;
|
||||||
|
|
||||||
|
constructor(options: S3FileStorageOptions) {
|
||||||
|
this.bucket = options.bucket;
|
||||||
|
this.client = new S3Client({
|
||||||
|
endpoint: options.endpoint,
|
||||||
|
region: options.region,
|
||||||
|
forcePathStyle: options.forcePathStyle,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: options.accessKeyId,
|
||||||
|
secretAccessKey: options.secretAccessKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(key: string, body: Readable, meta: { contentType: string; size: number }): Promise<void> {
|
||||||
|
await this.client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key,
|
||||||
|
Body: body,
|
||||||
|
ContentType: meta.contentType,
|
||||||
|
// Без длины SDK не может отправить поток одним PUT
|
||||||
|
ContentLength: meta.size,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<Readable> {
|
||||||
|
const response = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
|
||||||
|
// В Node.js Body — это Readable (IncomingMessage с примесями SDK)
|
||||||
|
return response.Body as Readable;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string): Promise<void> {
|
||||||
|
// DeleteObject в S3 идемпотентен — отсутствие объекта не ошибка
|
||||||
|
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { rm } from 'node:fs/promises';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Res,
|
||||||
|
StreamableFile,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { UploadFileUseCase } from '../../application/use-cases/upload-file/upload-file.use-case.js';
|
||||||
|
import { DownloadFileUseCase } from '../../application/use-cases/download-file/download-file.use-case.js';
|
||||||
|
import { DeleteFileUseCase } from '../../application/use-cases/delete-file/delete-file.use-case.js';
|
||||||
|
import { UploadFileCommand } from '../../application/use-cases/upload-file/upload-file.command.js';
|
||||||
|
import { DownloadFileQuery } from '../../application/use-cases/download-file/download-file.query.js';
|
||||||
|
import { DeleteFileCommand } from '../../application/use-cases/delete-file/delete-file.command.js';
|
||||||
|
import { FileNotFoundException } from '../../domain/exceptions/file-not-found.exception.js';
|
||||||
|
import { FileForbiddenException } from '../../domain/exceptions/file-forbidden.exception.js';
|
||||||
|
import { contentDisposition } from '../helpers/content-disposition.js';
|
||||||
|
|
||||||
|
@Controller('files')
|
||||||
|
export class FileController {
|
||||||
|
constructor(
|
||||||
|
private readonly uploadFileUseCase: UploadFileUseCase,
|
||||||
|
private readonly downloadFileUseCase: DownloadFileUseCase,
|
||||||
|
private readonly deleteFileUseCase: DeleteFileUseCase,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* multipart/form-data с полем `file`. Лимиты и временный каталог задаёт
|
||||||
|
* MulterModule в FileModule; превышение размера — 413 от Nest.
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
async upload(@UploadedFile() file: Express.Multer.File | undefined, @Session() session: UserSession) {
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('Поле file обязательно');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.uploadFileUseCase.execute(
|
||||||
|
new UploadFileCommand(session.user.id, file.path, file.originalname, file.size),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(file.path, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/download')
|
||||||
|
async download(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Session() session: UserSession,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): Promise<StreamableFile> {
|
||||||
|
const { file, inline, stream } = await this.handleErrors(() =>
|
||||||
|
this.downloadFileUseCase.execute(new DownloadFileQuery(id, session.user.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Браузер не должен «угадывать» тип: иначе octet-stream с HTML внутри
|
||||||
|
// может исполниться как страница на нашем домене
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
||||||
|
|
||||||
|
return new StreamableFile(stream, {
|
||||||
|
type: file.mimeType,
|
||||||
|
length: file.size,
|
||||||
|
disposition: contentDisposition(inline ? 'inline' : 'attachment', file.fileName),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async delete(@Param('id') id: string, @Session() session: UserSession) {
|
||||||
|
await this.handleErrors(() => this.deleteFileUseCase.execute(new DeleteFileCommand(id, session.user.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */
|
||||||
|
private async handleErrors<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FileNotFoundException) {
|
||||||
|
throw new NotFoundException(error.message);
|
||||||
|
}
|
||||||
|
if (error instanceof FileForbiddenException) {
|
||||||
|
throw new ForbiddenException(error.message);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
/** Метаданные файла. Содержимое — REST: GET /api/files/:id/download */
|
||||||
|
@ObjectType('File')
|
||||||
|
export class FileType {
|
||||||
|
@Field()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
fileName: string;
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
mimeType: string;
|
||||||
|
|
||||||
|
@Field(() => Int)
|
||||||
|
size: number;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
uploaderId: string | null;
|
||||||
|
|
||||||
|
@Field(() => Date)
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { contentDisposition } from './content-disposition.js';
|
||||||
|
|
||||||
|
describe('contentDisposition', () => {
|
||||||
|
it('encodes a plain ASCII name', () => {
|
||||||
|
expect(contentDisposition('attachment', 'report.pdf')).toBe(
|
||||||
|
`attachment; filename="report.pdf"; filename*=UTF-8''report.pdf`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes cyrillic with an ASCII fallback', () => {
|
||||||
|
expect(contentDisposition('inline', 'Отчёт.png')).toBe(
|
||||||
|
`inline; filename="_____.png"; filename*=UTF-8''%D0%9E%D1%82%D1%87%D1%91%D1%82.png`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes quotes and RFC 5987 special characters', () => {
|
||||||
|
expect(contentDisposition('attachment', `a"b'(c).txt`)).toBe(
|
||||||
|
`attachment; filename="a_b'(c).txt"; filename*=UTF-8''a%22b%27%28c%29.txt`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Заголовок Content-Disposition с именем файла по RFC 6266/5987:
|
||||||
|
* `filename` — ASCII-фолбэк для старых клиентов, `filename*` — полное UTF-8 имя.
|
||||||
|
*/
|
||||||
|
export function contentDisposition(type: 'inline' | 'attachment', fileName: string): string {
|
||||||
|
const fallback = fileName.replace(/[^\x20-\x7e]|["\\%]/g, '_');
|
||||||
|
const encoded = encodeURIComponent(fileName).replace(
|
||||||
|
/['()*]/g,
|
||||||
|
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return `${type}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { IssueStatusValue } from '../../../issue/domain/value-objects/issue-status.vo.js';
|
||||||
|
|
||||||
|
/** Счётчик по каждому статусу; статусы без задач — 0 */
|
||||||
|
export type IssueStatusCounts = Record<IssueStatusValue, number>;
|
||||||
|
|
||||||
|
export class DailyIssueStatsDayDto {
|
||||||
|
/** YYYY-MM-DD в часовом поясе запроса */
|
||||||
|
date: string;
|
||||||
|
/** Создано задач за день */
|
||||||
|
created: number;
|
||||||
|
/** Переходов в статус за день (создание задачи в OPEN сюда не входит). transitions.DONE — выполнено за день */
|
||||||
|
transitions: IssueStatusCounts;
|
||||||
|
/** Задач в каждом статусе на конец дня */
|
||||||
|
byStatus: IssueStatusCounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DailyIssueStatsDto {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
timeZone: string;
|
||||||
|
days: DailyIssueStatsDayDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { IssueChangeField } from '../../../issue/domain/entities/issue-change.js';
|
||||||
|
import { UserDto } from '../../../user/application/dto/user.dto.js';
|
||||||
|
|
||||||
|
export class IssueFieldChangeDto {
|
||||||
|
field: IssueChangeField;
|
||||||
|
oldValue: string | null;
|
||||||
|
newValue: string | null;
|
||||||
|
/** Только для field = assignee/observer: пользователи из oldValue/newValue (null — не назначен или удалён) */
|
||||||
|
oldUser?: UserDto | null;
|
||||||
|
newUser?: UserDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Одно сохранение задачи: кто, когда и какие поля изменил */
|
||||||
|
export class IssueHistoryEntryDto {
|
||||||
|
/** changeId */
|
||||||
|
id: string;
|
||||||
|
changedAt: Date;
|
||||||
|
actorId: string | null;
|
||||||
|
actor: UserDto | null;
|
||||||
|
changes: IssueFieldChangeDto[];
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
export class GetDailyIssueStatsQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly projectId?: string,
|
||||||
|
/** YYYY-MM-DD */
|
||||||
|
public readonly from?: string,
|
||||||
|
/** YYYY-MM-DD */
|
||||||
|
public readonly to?: string,
|
||||||
|
/** IANA, например Europe/Moscow */
|
||||||
|
public readonly timeZone?: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { IIssueHistoryRepository } from '../../../domain/repositories/issue-history.repository.interface.js';
|
||||||
|
import { InvalidStatsPeriodException } from '../../../domain/exceptions/invalid-stats-period.exception.js';
|
||||||
|
import { GetDailyIssueStatsUseCase } from './get-daily-issue-stats.use-case.js';
|
||||||
|
import { GetDailyIssueStatsQuery } from './get-daily-issue-stats.query.js';
|
||||||
|
|
||||||
|
const zero = { OPEN: 0, IN_PROGRESS: 0, DONE: 0, CANCELLED: 0 };
|
||||||
|
|
||||||
|
describe('GetDailyIssueStatsUseCase', () => {
|
||||||
|
let repository: IIssueHistoryRepository;
|
||||||
|
let useCase: GetDailyIssueStatsUseCase;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repository = {
|
||||||
|
findByIssue: vi.fn(),
|
||||||
|
countDailyEvents: vi.fn().mockResolvedValue([]),
|
||||||
|
countDailyStatuses: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
useCase = new GetDailyIssueStatsUseCase(repository);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the validated period and visibility params to the repository', async () => {
|
||||||
|
await useCase.execute(
|
||||||
|
new GetDailyIssueStatsQuery('user-1', 'project-1', '2026-10-01', '2026-10-03', 'Europe/Moscow'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const range = {
|
||||||
|
userId: 'user-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
from: '2026-10-01',
|
||||||
|
to: '2026-10-03',
|
||||||
|
timeZone: 'Europe/Moscow',
|
||||||
|
};
|
||||||
|
expect(repository.countDailyEvents).toHaveBeenCalledWith(range);
|
||||||
|
expect(repository.countDailyStatuses).toHaveBeenCalledWith(range);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills every day and every status, including the empty ones', async () => {
|
||||||
|
vi.mocked(repository.countDailyEvents).mockResolvedValue([
|
||||||
|
{ date: '2026-10-01', kind: 'created', count: 2 },
|
||||||
|
{ date: '2026-10-03', kind: 'status', status: 'DONE', count: 1 },
|
||||||
|
]);
|
||||||
|
vi.mocked(repository.countDailyStatuses).mockResolvedValue([
|
||||||
|
{ date: '2026-10-01', status: 'OPEN', count: 2 },
|
||||||
|
{ date: '2026-10-03', status: 'OPEN', count: 1 },
|
||||||
|
{ date: '2026-10-03', status: 'DONE', count: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await useCase.execute(new GetDailyIssueStatsQuery('user-1', undefined, '2026-10-01', '2026-10-03'));
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
from: '2026-10-01',
|
||||||
|
to: '2026-10-03',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
days: [
|
||||||
|
{ date: '2026-10-01', created: 2, transitions: zero, byStatus: { ...zero, OPEN: 2 } },
|
||||||
|
{ date: '2026-10-02', created: 0, transitions: zero, byStatus: zero },
|
||||||
|
{ date: '2026-10-03', created: 0, transitions: { ...zero, DONE: 1 }, byStatus: { ...zero, OPEN: 1, DONE: 1 } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores statuses that are no longer known', async () => {
|
||||||
|
vi.mocked(repository.countDailyStatuses).mockResolvedValue([{ date: '2026-10-01', status: 'LEGACY', count: 5 }]);
|
||||||
|
|
||||||
|
const result = await useCase.execute(new GetDailyIssueStatsQuery('user-1', undefined, '2026-10-01', '2026-10-01'));
|
||||||
|
|
||||||
|
expect(result.days[0]!.byStatus).toEqual(zero);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid period without querying the database', async () => {
|
||||||
|
await expect(
|
||||||
|
useCase.execute(new GetDailyIssueStatsQuery('user-1', undefined, '2026-10-05', '2026-10-01')),
|
||||||
|
).rejects.toThrow(InvalidStatsPeriodException);
|
||||||
|
expect(repository.countDailyEvents).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { ISSUE_STATUSES, type IssueStatusValue } from '../../../../issue/domain/value-objects/issue-status.vo.js';
|
||||||
|
import {
|
||||||
|
ISSUE_HISTORY_REPOSITORY,
|
||||||
|
type IIssueHistoryRepository,
|
||||||
|
} from '../../../domain/repositories/issue-history.repository.interface.js';
|
||||||
|
import { StatsPeriod } from '../../../domain/value-objects/stats-period.vo.js';
|
||||||
|
import { DailyIssueStatsDayDto, DailyIssueStatsDto, IssueStatusCounts } from '../../dto/daily-issue-stats.dto.js';
|
||||||
|
import { GetDailyIssueStatsQuery } from './get-daily-issue-stats.query.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Статистика задач по дням, считается по истории изменений (issue_change).
|
||||||
|
* Видимость — как у списка задач: только задачи, где пользователь reporter/assignee/наблюдатель.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class GetDailyIssueStatsUseCase {
|
||||||
|
constructor(
|
||||||
|
@Inject(ISSUE_HISTORY_REPOSITORY)
|
||||||
|
private readonly historyRepository: IIssueHistoryRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: GetDailyIssueStatsQuery): Promise<DailyIssueStatsDto> {
|
||||||
|
const period = StatsPeriod.create({ from: query.from, to: query.to, timeZone: query.timeZone });
|
||||||
|
const range = {
|
||||||
|
userId: query.userId,
|
||||||
|
projectId: query.projectId,
|
||||||
|
from: period.from,
|
||||||
|
to: period.to,
|
||||||
|
timeZone: period.timeZone,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [events, statuses] = await Promise.all([
|
||||||
|
this.historyRepository.countDailyEvents(range),
|
||||||
|
this.historyRepository.countDailyStatuses(range),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Каждый день периода попадает в ответ, даже если в нём ничего не было
|
||||||
|
const days = new Map<string, DailyIssueStatsDayDto>(
|
||||||
|
period.days.map((date) => [
|
||||||
|
date,
|
||||||
|
{ date, created: 0, transitions: emptyStatusCounts(), byStatus: emptyStatusCounts() },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const day = days.get(event.date);
|
||||||
|
if (!day) continue;
|
||||||
|
|
||||||
|
if (event.kind === 'created') {
|
||||||
|
day.created += event.count;
|
||||||
|
} else if (isKnownStatus(event.status)) {
|
||||||
|
day.transitions[event.status] += event.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of statuses) {
|
||||||
|
const day = days.get(entry.date);
|
||||||
|
if (day && isKnownStatus(entry.status)) {
|
||||||
|
day.byStatus[entry.status] += entry.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { from: period.from, to: period.to, timeZone: period.timeZone, days: [...days.values()] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyStatusCounts(): IssueStatusCounts {
|
||||||
|
return Object.fromEntries(ISSUE_STATUSES.map((status) => [status.alias, 0])) as IssueStatusCounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKnownStatus(status: string): status is IssueStatusValue {
|
||||||
|
return ISSUE_STATUSES.some((known) => known.alias === status);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class GetIssueHistoryQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly issueId: number,
|
||||||
|
public readonly userId: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type {
|
||||||
|
IIssueHistoryRepository,
|
||||||
|
IssueChangeRecord,
|
||||||
|
} from '../../../domain/repositories/issue-history.repository.interface.js';
|
||||||
|
import type { IIssueRepository } from '../../../../issue/domain/repositories/issue.repository.interface.js';
|
||||||
|
import { Issue } from '../../../../issue/domain/entities/issue.entity.js';
|
||||||
|
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
|
||||||
|
import type { GetUsersUseCase } from '../../../../user/application/use-cases/get-users/get-users.use-case.js';
|
||||||
|
import type { UserDto } from '../../../../user/application/dto/user.dto.js';
|
||||||
|
import { GetIssueHistoryUseCase } from './get-issue-history.use-case.js';
|
||||||
|
import { GetIssueHistoryQuery } from './get-issue-history.query.js';
|
||||||
|
|
||||||
|
const buildIssue = () =>
|
||||||
|
Issue.reconstitute({
|
||||||
|
id: 1,
|
||||||
|
key: 'ISSUE-1',
|
||||||
|
title: 'Title',
|
||||||
|
content: null,
|
||||||
|
status: 'OPEN',
|
||||||
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
|
assignee: null,
|
||||||
|
reporter: 'user-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
dueDate: null,
|
||||||
|
createdAt: new Date('2026-10-01T00:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-10-01T00:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = (id: string) => ({ id, name: id }) as UserDto;
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
const record = (
|
||||||
|
props: Partial<IssueChangeRecord> & Pick<IssueChangeRecord, 'changeId' | 'field'>,
|
||||||
|
): IssueChangeRecord => ({
|
||||||
|
id: nextId++,
|
||||||
|
issueId: 1,
|
||||||
|
oldValue: null,
|
||||||
|
newValue: null,
|
||||||
|
actorId: 'user-1',
|
||||||
|
changedAt: new Date('2026-10-01T00:00:00.000Z'),
|
||||||
|
...props,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GetIssueHistoryUseCase', () => {
|
||||||
|
let historyRepository: IIssueHistoryRepository;
|
||||||
|
let issueRepository: IIssueRepository;
|
||||||
|
let getUsersUseCase: GetUsersUseCase;
|
||||||
|
let useCase: GetIssueHistoryUseCase;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
historyRepository = { findByIssue: vi.fn(), countDailyEvents: vi.fn(), countDailyStatuses: vi.fn() };
|
||||||
|
issueRepository = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByCKey: vi.fn(),
|
||||||
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
|
countByStatus: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
getUsersUseCase = {
|
||||||
|
execute: vi.fn(async (query: { ids: string[] }) => query.ids.filter((id) => id !== 'deleted').map(user)),
|
||||||
|
} as unknown as GetUsersUseCase;
|
||||||
|
useCase = new GetIssueHistoryUseCase(historyRepository, issueRepository, getUsersUseCase);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws IssueNotFoundException when the issue is not visible to the user', async () => {
|
||||||
|
vi.mocked(issueRepository.findById).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(useCase.execute(new GetIssueHistoryQuery(1, 'user-9'))).rejects.toThrow(IssueNotFoundException);
|
||||||
|
expect(issueRepository.findById).toHaveBeenCalledWith(1, 'user-9');
|
||||||
|
expect(historyRepository.findByIssue).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups rows of one save into an entry and expands actors and assignees', async () => {
|
||||||
|
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
|
||||||
|
vi.mocked(historyRepository.findByIssue).mockResolvedValue([
|
||||||
|
record({ changeId: 'c1', field: 'created' }),
|
||||||
|
record({ changeId: 'c1', field: 'status', newValue: 'OPEN' }),
|
||||||
|
record({ changeId: 'c2', field: 'status', oldValue: 'OPEN', newValue: 'DONE', actorId: 'user-2' }),
|
||||||
|
record({ changeId: 'c2', field: 'assignee', oldValue: 'deleted', newValue: 'user-3', actorId: 'user-2' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const entries = await useCase.execute(new GetIssueHistoryQuery(1, 'user-1'));
|
||||||
|
|
||||||
|
expect(entries.map((entry) => [entry.id, entry.actor?.id, entry.changes.map((change) => change.field)])).toEqual([
|
||||||
|
['c1', 'user-1', ['created', 'status']],
|
||||||
|
['c2', 'user-2', ['status', 'assignee']],
|
||||||
|
]);
|
||||||
|
expect(entries[1]!.changes[0]).toEqual({ field: 'status', oldValue: 'OPEN', newValue: 'DONE' });
|
||||||
|
expect(entries[1]!.changes[1]).toMatchObject({ oldUser: null, newUser: { id: 'user-3' } });
|
||||||
|
expect(getUsersUseCase.execute).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not query users for an empty history', async () => {
|
||||||
|
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
|
||||||
|
vi.mocked(historyRepository.findByIssue).mockResolvedValue([]);
|
||||||
|
|
||||||
|
expect(await useCase.execute(new GetIssueHistoryQuery(1, 'user-1'))).toEqual([]);
|
||||||
|
expect(getUsersUseCase.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user