Compare commits

Author SHA1 Message Date
vadim cb29c7d2c4 Add issue change history and daily issue stats
Gitea Actions Demo / Deploy (push) Successful in 32s
Issue changes are recorded per field in the issue_change table, in the same
transaction as the issue itself. The new history module exposes:
- GET /api/issues/:issueId/history — change timeline grouped by save
- GET /api/issues/stats/daily — created, status transitions and status
  snapshot per day, with time zone support

The migration backfills history for existing issues. Setting a field to its
current value no longer bumps updatedAt or emits issue.updated.
2026-10-08 09:55:21 +03:00
vadim f44590b8ca update issue filter
Gitea Actions Demo / Deploy (push) Successful in 31s
2026-10-06 23:18:00 +03:00
vadim 5607e97533 sort comments
Gitea Actions Demo / Deploy (push) Successful in 1m50s
2026-10-02 21:43:37 +03:00
vadimandClaude Opus 5.5 d0fcc93a8d Add WIP notes on attachments work
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:18:15 +03:00
vadimandClaude Opus 5.5 60fb39f98f Add attachments to comments
- CommentAttachment link table (comment <-> file) + migration
- fileIds in create/update comment; update replaces the whole set,
  synced in one transaction with the comment
- CommentDto.attachments; empty for deleted comments
- Soft delete drops attachments so GC can collect the files
- Max 10 files per comment, ordered by upload time (uuid v7)
- Registers comments as a file owner
- Rename CommentAuthorExpanderService -> CommentExpanderService
  (expands authors and files)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:18:08 +03:00
vadimandClaude Opus 5.5 423afa658d Add issue attachments
- IssueAttachment link table (issue <-> file) + migration
- GET/POST /issues/:issueId/attachments, DELETE .../attachments/:fileId
- Only own uploaded files can be attached; attach is idempotent
- Registers issues as a file owner: attached files stay visible to issue
  participants and are not collected by GC
- IssueDetail.attachments GraphQL field via @ResolveField, issue module untouched

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:18:02 +03:00
vadimandClaude Opus 5.5 f32c6e348d Add file module for uploads and storage
- File model + migration: file metadata, content lives in object storage
- IFileStorage port with local disk and S3-compatible (RustFS/MinIO) adapters
- POST /files, GET /files/:id/download, DELETE /files/:id
- MIME sniffed from content; only safe images served inline, nosniff header
- FileOwnerRegistry: owner modules register to grant visibility and keep
  their files from GC; FileGcService removes orphaned files hourly
- TransformResponseInterceptor passes StreamableFile through

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:17:55 +03:00
vadim 5cfc8986d7 prettier
Gitea Actions Demo / Deploy (push) Successful in 1m34s
2026-10-01 12:04:21 +03:00
vadim 4b6ecfbf5d comments implement 2026-10-01 12:02:03 +03:00
vadim 9e0bef2527 update git ignore 2026-10-01 11:25:31 +03:00
vadimandClaude Opus 5.5 3272c75512 Add Comment model for issue comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:23:51 +03:00
vadimandClaude Opus 5.5 2696f33a7e Keep issues when assignee or reporter is deleted
Switch issue.assigneeId/reporterId FKs from CASCADE to SET NULL and make
reporter nullable through the entity, DTOs and GraphQL types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:23:51 +03:00
vadimandClaude Opus 5.5 4b8c855855 Add missing migration for notification tables
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:23:46 +03:00
vadim 60391ee123 notification 2026-09-29 18:42:29 +03:00
175 changed files with 7300 additions and 82 deletions

No files matched your search

+1
View File
@@ -10,3 +10,4 @@ coverage
*.log
tsconfig.build.tsbuildinfo
src/generated
storage
+4
View File
@@ -2,6 +2,7 @@
/dist
/node_modules
/build
*.tsbuildinfo
# Logs
logs
@@ -57,3 +58,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
/src/generated/prisma
/src/schema.gql
# Local file storage (FILE_STORAGE=local)
/storage
+214
View File
@@ -0,0 +1,214 @@
# WIP — вложения (2026-10-02)
Сделано за сегодня: модуль `file` (хранение файлов), модуль `issue-attachment`
(вложения задач) и вложения в комментариях. Всё в ветке `feature/attachments`
(не запушена), миграции к БД не применялись.
## Архитектура
Метаданные файла хранятся отдельно от того, к чему файл прикреплён:
```
file ←── issue_attachment (issueId, fileId) — модуль issue-attachment
←── comment_attachment (commentId, fileId) — модуль comment
```
- `file` ничего не знает о владельцах. Новый владелец = своя таблица связей
+ регистрация в `FileOwnerRegistry`; сам `file` при этом не меняется.
- Удаление задачи/комментария удаляет связи каскадом в БД, а файл без единой
связи (сирота) удаляет GC.
- Флоу для клиента в два шага: загрузить файл → прикрепить по `fileId`.
Почему не полиморфная связь `ownerType + ownerId`: нет FK, нет каскадов, разные
типы ключей (`Issue.id` — Int, остальные — uuid).
## Миграции
| Миграция | Что |
|---|---|
| `20261002045355_file` | таблица `file` |
| `20261002050156_issue_attachment` | таблица `issue_attachment` |
| `20261002060755_comment_attachment` | таблица `comment_attachment` |
Собраны через `prisma migrate diff` без подключения к БД. Применятся при деплое
(`prisma migrate deploy` в CMD Dockerfile).
## Модуль `file` — `src/modules/file/`
### API
| Метод | Путь | Что |
|---|---|---|
| POST | `/api/files` | загрузка, multipart, поле `file` → `FileDto` |
| GET | `/api/files/:id/download` | скачивание (поток) |
| DELETE | `/api/files/:id` | удалить файл (только загрузивший), 204 |
`FileDto`: `{ id, fileName, mimeType, size, uploaderId, createdAt }`.
`storageKey` наружу не отдаётся.
### Устройство
- **domain**:
- `StoredFile` — не `File`, чтобы не путать с глобальным Web API `File`;
- VO `FileName`: убирает путь и управляющие символы, обрезает до 255
с сохранением расширения;
- порты `IFileStorage`, `IFileTypeDetector`.
- **application**:
- use-case'ы upload / download / delete / get-files;
- `FileAccessService`, `FileOwnerRegistry`, `FileGcService`.
- **infrastructure**:
- `FilePrismaRepository`;
- хранилища `LocalFileStorage` (dev/тесты) и `S3FileStorage`
(S3 / RustFS / MinIO), выбираются в `file-storage.factory.ts`;
- `FileTypeDetector` на `file-type`.
- **presentation**: `FileController`, хелпер `contentDisposition`
(RFC 5987, кириллица), GraphQL-тип `File`.
### Загрузка
- multer пишет во временный файл (`os.tmpdir()`), а не в память. Лимит —
`FILE_MAX_SIZE_MB`, при превышении Nest отвечает 413.
- `defParamCharset: 'utf8'` — иначе multer ломает кириллицу в именах файлов.
- MIME определяется **по содержимому** (magic bytes). Нераспознанное
(HTML, SVG, текст) сохраняется как `application/octet-stream`.
- Ключ в хранилище — `files/{randomUUID}`. Имя от пользователя в ключ не попадает.
- Порядок записи: объект → строка в БД. Если запись строки упала, объект
удаляется сразу.
### Скачивание и безопасность
- Inline отдаются только `image/png|jpeg|gif|webp`, всё остальное — как
`attachment`. Плюс `X-Content-Type-Options: nosniff` — защита от stored XSS.
- Файл виден загрузившему и всем, кому виден хоть один его владелец (задача,
комментарий). Иначе 404, чтобы не раскрывать существование файла.
- `TransformResponseInterceptor` пропускает `StreamableFile`. Без этого обёртка
`{data, code}` ломала скачивание.
### Прикрепление (для модулей-владельцев)
- `FileAccessService.getAttachableFile(id, userId)`: прикрепить можно только
**свой** файл. Невидимый файл → 404, чужой → 403. Иначе файл, увиденный
через одну задачу, можно было бы перенести в другую.
- `GetFilesUseCase` — метаданные файлов по id, для раскрытия связей в DTO.
### GC файлов-сирот
- `FileGcService`, `@Cron(EVERY_HOUR)`. Подключён `ScheduleModule` в `app.module.ts`.
- Удаляет файлы старше `FILE_ORPHAN_TTL_HOURS` (по умолчанию 24), на которые
не ссылается ни один зарегистрированный владелец.
- Порядок удаления: объект → строка. Удаление объекта идемпотентно, поэтому
при сбое следующий запуск доделает работу. Тот же порядок в `DeleteFileUseCase`.
- Безопасен при нескольких инстансах приложения.
> ⚠️ Новый владелец файлов **обязан** зарегистрироваться в `FileOwnerRegistry`
> (`onModuleInit`), иначе GC удалит его файлы как сирот.
## Модуль `issue-attachment` — `src/modules/issue-attachment/`
Отдельный модуль, как `comment`: зависит от issue, а issue о нём не знает.
Иначе получался бы цикл issue ↔ attachment.
### API
| Метод | Путь | Что |
|---|---|---|
| GET | `/api/issues/:issueId/attachments` | список → `IssueAttachmentDto[]` |
| POST | `/api/issues/:issueId/attachments` | `{ fileId }` → `IssueAttachmentDto` |
| DELETE | `/api/issues/:issueId/attachments/:fileId` | открепить, 204 (файл удалит GC) |
`IssueAttachmentDto`: `{ issueId, file: FileDto, attachedAt }`.
GraphQL: поле `IssueDetail.attachments`. Его добавляет `IssueAttachmentResolver`
через `@ResolveField`, issue-модуль не тронут. В списке задач (`Issue`) этого
поля нет, чтобы не было N+1.
### Правила
- Прикреплять и откреплять может любой, кому видна задача (то же правило, что
для правки задачи), но прикрепить можно только свой файл.
- Повторное прикрепление идемпотентно (`upsert`).
- Если задачу или файл удалили между проверкой и вставкой (FK violation), ответ 404.
- `IssueAttachmentFileOwnerService` регистрируется в реестре. Видимость файла
проверяется через `IIssueRepository.findById`, поэтому правило
«reporter/assignee» живёт в одном месте.
## Вложения в комментариях — модуль `comment`
Вложения — часть агрегата `Comment` (`fileIds`). Отдельных эндпоинтов нет:
```
POST /api/issues/:issueId/comments { "content": "...", "fileIds": ["…"] }
PATCH /api/comments/:id { "content": "...", "fileIds": [...] }
```
- **Ответы.** `CommentDto.attachments: FileDto[]` — в create, update, списке
и SSE-событиях.
- **Правка.** `fileIds` при update задаёт набор целиком; не передан — вложения
не меняются.
- Синхронизация связей идёт в одной транзакции с правкой текста.
- Изменение только набора файлов тоже считается правкой
(`editedAt`, `comment.updated`).
- Проверяются только добавленные файлы.
- **Ограничения:** максимум `Comment.MAX_ATTACHMENTS = 10`, дубли убираются.
- **Порядок** — по времени загрузки. id файлов — uuid v7, поэтому
сортировка по строке совпадает с порядком загрузки.
- **Мягкое удаление** комментария снимает связи, файлы забирает GC. В ответе
у удалённого комментария `attachments: []`.
- `CommentAttachmentFileOwnerService` регистрируется в реестре. Файл виден
тем, кому видна задача неудалённого комментария с этим файлом.
- `CommentAuthorExpanderService` переименован в `CommentExpanderService`:
он раскрывает и авторов, и файлы (параллельно).
## Прочие изменения
- **Зависимости:** `@aws-sdk/client-s3`, `file-type`, `@nestjs/schedule`;
dev — `@types/multer`.
- **`tsconfig.json`:** `multer` добавлен в `types`, иначе не виден
`Express.Multer.File`.
- **`.gitignore` / `.dockerignore`:** добавлен `/storage` — каталог
локального хранилища.
## Переменные окружения
```
FILE_STORAGE=s3 # local (по умолчанию) | s3
FILE_STORAGE_LOCAL_DIR=./storage
FILE_MAX_SIZE_MB=25
FILE_ORPHAN_TTL_HOURS=24
S3_ENDPOINT=http://rustfs:9000 # для AWS можно не задавать
S3_REGION=us-east-1
S3_BUCKET=ulitka
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_FORCE_PATH_STYLE=true # для RustFS/MinIO
```
В `.env` были закомментированы `RUSTFS_*`. Адаптер использует общие `S3_*`.
## Проверено
- 154 unit-теста проходят. Типы, линтер и сборка чистые.
- Приложение стартует, DI резолвится, новые маршруты без сессии отвечают 401.
В `schema.gql` появились `File`, `IssueAttachment`, `IssueDetail.attachments`.
- **Не проверено:** сценарий целиком с авторизацией, реальной БД и S3
(upload → attach → download → GC).
## TODO / открытые вопросы
- [ ] Перед деплоем: создать бакет в RustFS (приложение его не создаёт),
прописать `FILE_STORAGE=s3` и `S3_*` на сервере.
- [ ] Прогнать e2e: загрузка → прикрепление → скачивание другим участником →
открепление → GC.
- [ ] Влить `feature/attachments` в `main`. Push в `main` запускает деплой —
сначала бакет и переменные окружения.
- [ ] Лимит числа вложений у задачи (у комментария — 10).
- [ ] SSE-события для вложений задачи (по образцу `CommentEventsService`).
- [ ] Комментарий только с файлами, без текста? Сейчас текст обязателен.
- [ ] Вложения комментариев в GraphQL — когда появятся комментарии в GraphQL.
- [ ] GC при большом числе файлов: сейчас каждый запуск проходит по всем старым
файлам. При сотнях тысяч файлов — переписать на `NOT EXISTS`.
- [ ] Маловероятная гонка: прикрепление файла, загруженного больше 24 ч назад,
ровно в момент прохода GC может его потерять.
- [ ] Позже: отдавать скачивание redirect'ом на presigned GET вместо стрима
через Node.
+480
View File
@@ -11,13 +11,16 @@
"dependencies": {
"@apollo/server": "^5.5.1",
"@as-integrations/express5": "^1.1.2",
"@aws-sdk/client-s3": "^3.1145.0",
"@better-auth/prisma-adapter": "^1.7.2",
"@nestjs/apollo": "^14.0.0",
"@nestjs/common": "^12.0.1",
"@nestjs/config": "^12.0.0",
"@nestjs/core": "^12.0.1",
"@nestjs/event-emitter": "^12.0.1",
"@nestjs/graphql": "^14.0.0",
"@nestjs/platform-express": "^12.0.1",
"@nestjs/schedule": "^12.0.2",
"@nestjs/typeorm": "^12.0.1",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
@@ -25,6 +28,7 @@
"better-auth": "^1.7.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"file-type": "^22.1.1",
"graphql": "^16.14.2",
"nodemailer": "^10.0.0",
"pg": "^8.23.0",
@@ -39,6 +43,7 @@
"@nestjs/schematics": "^12.0.0",
"@nestjs/testing": "^12.0.1",
"@types/express": "^5.0.0",
"@types/multer": "^2.3.0",
"@types/node": "^24.0.0",
"@types/pg": "^8.23.1",
"@types/supertest": "^7.0.0",
@@ -370,6 +375,314 @@
"express": "^5.0.0"
}
},
"node_modules/@aws-sdk/checksums": {
"version": "3.1001.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.1.tgz",
"integrity": "sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1145.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1145.0.tgz",
"integrity": "sha512-MPKAQdx8qCZW1/ChDPILNehtAn/BMP4GNTnaH/xhGCWg3v50ADVq1K2WzxIrFfeANdWUyjwiY0CXDBhclsNWgQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1001.1",
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/credential-provider-node": "^3.972.84",
"@aws-sdk/middleware-sdk-s3": "^3.972.77",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.978.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz",
"integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@aws-sdk/xml-builder": "^3.972.41",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.35.0",
"@smithy/signature-v4": "^5.7.3",
"@smithy/types": "^4.19.0",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz",
"integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.74",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz",
"integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.17",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz",
"integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/credential-provider-env": "^3.972.72",
"@aws-sdk/credential-provider-http": "^3.972.74",
"@aws-sdk/credential-provider-login": "^3.972.79",
"@aws-sdk/credential-provider-process": "^3.972.72",
"@aws-sdk/credential-provider-sso": "^3.973.16",
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.79",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz",
"integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.84",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz",
"integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.72",
"@aws-sdk/credential-provider-http": "^3.972.74",
"@aws-sdk/credential-provider-ini": "^3.973.17",
"@aws-sdk/credential-provider-process": "^3.972.72",
"@aws-sdk/credential-provider-sso": "^3.973.16",
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz",
"integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.16",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz",
"integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/token-providers": "3.1138.0",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.78",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz",
"integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.77",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.77.tgz",
"integrity": "sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.46",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz",
"integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.47",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz",
"integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@smithy/signature-v4": "^5.7.3",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1138.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz",
"integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.974.6",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz",
"integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.41",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz",
"integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
@@ -1239,6 +1552,22 @@
}
}
},
"node_modules/@nestjs/event-emitter": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-12.0.1.tgz",
"integrity": "sha512-7MbpgNcxUcDlqcYY/qgZm1H7MfK3uGcRsd8lEjj9ZYvI9qTl6kdkkykpxZ6YjBRH3wlZK5dFLAei8h5qpsHzOw==",
"license": "MIT",
"dependencies": {
"eventemitter2": "6.4.9"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@nestjs/common": "^11.0.0 || ^12.0.0",
"@nestjs/core": "^11.0.0 || ^12.0.0"
}
},
"node_modules/@nestjs/graphql": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/graphql/-/graphql-14.0.2.tgz",
@@ -1484,6 +1813,22 @@
"@nestjs/core": "^12.0.0"
}
},
"node_modules/@nestjs/schedule": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-12.0.2.tgz",
"integrity": "sha512-5iAvJEtk0njbfTrG4JQr+SXw8ZrqgDM1wJH9nUs6tVLCX5pWGd1LJ0a8b089UT1FDeDWaThRRfSLixFz9zztcA==",
"license": "MIT",
"dependencies": {
"cron": "4.4.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@nestjs/common": "^11.0.0 || ^12.0.0",
"@nestjs/core": "^11.0.0 || ^12.0.0"
}
},
"node_modules/@nestjs/schematics": {
"version": "12.0.5",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-12.0.5.tgz",
@@ -2789,6 +3134,87 @@
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@smithy/core": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.1.tgz",
"integrity": "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
"integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.2",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz",
"integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.18.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz",
"integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.18.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.7.4",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz",
"integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.19.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz",
"integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@sqltools/formatter": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz",
@@ -3055,6 +3481,12 @@
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
"license": "MIT"
},
"node_modules/@types/luxon": {
"version": "3.7.6",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.6.tgz",
"integrity": "sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==",
"license": "MIT"
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -3062,6 +3494,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.3.0.tgz",
"integrity": "sha512-i7STIm9V4K2MPH4ZYMtrZAwNxs3kglk2LgleaTuB9pUXgADBcQYQuYMd7+6xgTIxfoggIkFA/DkkvycdZpIARA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "24.13.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz",
@@ -3883,6 +4325,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
@@ -4434,6 +4882,23 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/cron": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.7.0",
"luxon": "~3.7.0"
},
"engines": {
"node": ">=18.x"
},
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/intcreator"
}
},
"node_modules/cross-inspect": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz",
@@ -5005,6 +5470,12 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter2": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==",
"license": "MIT"
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@@ -6444,6 +6915,15 @@
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz",
+5
View File
@@ -24,13 +24,16 @@
"dependencies": {
"@apollo/server": "^5.5.1",
"@as-integrations/express5": "^1.1.2",
"@aws-sdk/client-s3": "^3.1145.0",
"@better-auth/prisma-adapter": "^1.7.2",
"@nestjs/apollo": "^14.0.0",
"@nestjs/common": "^12.0.1",
"@nestjs/config": "^12.0.0",
"@nestjs/core": "^12.0.1",
"@nestjs/event-emitter": "^12.0.1",
"@nestjs/graphql": "^14.0.0",
"@nestjs/platform-express": "^12.0.1",
"@nestjs/schedule": "^12.0.2",
"@nestjs/typeorm": "^12.0.1",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
@@ -38,6 +41,7 @@
"better-auth": "^1.7.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"file-type": "^22.1.1",
"graphql": "^16.14.2",
"nodemailer": "^10.0.0",
"pg": "^8.23.0",
@@ -52,6 +56,7 @@
"@nestjs/schematics": "^12.0.0",
"@nestjs/testing": "^12.0.1",
"@types/express": "^5.0.0",
"@types/multer": "^2.3.0",
"@types/node": "^24.0.0",
"@types/pg": "^8.23.1",
"@types/supertest": "^7.0.0",
@@ -0,0 +1,64 @@
-- CreateTable
CREATE TABLE "notification_device_token" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"platform" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "notification_device_token_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_preference" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "notification_preference_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_message" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"subject" TEXT,
"content" TEXT NOT NULL,
"error" TEXT,
"sentAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notification_message_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "notification_device_token_userId_idx" ON "notification_device_token"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "notification_device_token_userId_token_key" ON "notification_device_token"("userId", "token");
-- CreateIndex
CREATE UNIQUE INDEX "notification_preference_userId_channel_key" ON "notification_preference"("userId", "channel");
-- CreateIndex
CREATE INDEX "notification_message_userId_idx" ON "notification_message"("userId");
-- CreateIndex
CREATE INDEX "notification_message_status_idx" ON "notification_message"("status");
-- CreateIndex
CREATE INDEX "notification_message_createdAt_idx" ON "notification_message"("createdAt");
-- AddForeignKey
ALTER TABLE "notification_device_token" ADD CONSTRAINT "notification_device_token_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_preference" ADD CONSTRAINT "notification_preference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_message" ADD CONSTRAINT "notification_message_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,14 @@
-- DropForeignKey
ALTER TABLE "issue" DROP CONSTRAINT "issue_assigneeId_fkey";
-- DropForeignKey
ALTER TABLE "issue" DROP CONSTRAINT "issue_reporterId_fkey";
-- AlterTable
ALTER TABLE "issue" ALTER COLUMN "reporterId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "issue" ADD CONSTRAINT "issue_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "issue" ADD CONSTRAINT "issue_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "comment" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"issueId" INTEGER NOT NULL,
"authorId" TEXT,
"editedAt" TIMESTAMPTZ(3),
"deletedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "comment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "comment_issueId_createdAt_idx" ON "comment"("issueId", "createdAt");
-- CreateIndex
CREATE INDEX "comment_authorId_idx" ON "comment"("authorId");
-- AddForeignKey
ALTER TABLE "comment" ADD CONSTRAINT "comment_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comment" ADD CONSTRAINT "comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -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;
+188 -28
View File
@@ -8,19 +8,25 @@ datasource db {
}
model User {
id String @id
name String
email String
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
assigneeIssues Issue[] @relation(name: "assigneeIssues")
reporterIssues Issue[] @relation(name: "reporterIssues")
projects Project[]
projectMemberships ProjectMember[]
id String @id
name String
email String
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
assigneeIssues Issue[] @relation(name: "assigneeIssues")
reporterIssues Issue[] @relation(name: "reporterIssues")
projects Project[]
projectMemberships ProjectMember[]
notificationDeviceTokens NotificationDeviceToken[]
notificationPreferences NotificationPreference[]
notificationMessages NotificationMessage[]
comments Comment[]
uploadedFiles File[]
issueChanges IssueChange[]
@@unique([email])
@@map("user")
@@ -76,21 +82,24 @@ model Verification {
}
model Issue {
id Int @id @default(autoincrement())
key String
title String
content String?
status String
priority Int @default(0)
assigneeId String?
assignee User? @relation(name: "assigneeIssues", fields: [assigneeId], references: [id], onDelete: Cascade)
reporterId String
reporter User @relation(name: "reporterIssues", fields: [reporterId], references: [id], onDelete: Cascade)
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
dueDate DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
id Int @id @default(autoincrement())
key String
title String
content String?
status String
priority Int @default(0)
assigneeId String?
assignee User? @relation(name: "assigneeIssues", fields: [assigneeId], references: [id], onDelete: SetNull)
reporterId String?
reporter User? @relation(name: "reporterIssues", fields: [reporterId], references: [id], onDelete: SetNull)
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
dueDate DateTime? @db.Timestamptz(3)
comments Comment[]
attachments IssueAttachment[]
changes IssueChange[]
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@index([status])
@@index([assigneeId])
@@ -132,3 +141,154 @@ model ProjectMember {
@@index([userId])
@@map("project_member")
}
/// Push-токены устройств пользователя. Регистрируются напрямую клиентом,
/// без участия остального приложения.
model NotificationDeviceToken {
id String @id @default(uuid(7))
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String
platform String // ios | android | web
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@unique([userId, token])
@@index([userId])
@@map("notification_device_token")
}
/// Включён ли у пользователя конкретный канал доставки уведомлений.
/// Категорий нет — настройка одна на канал.
/// Допустимые значения channel валидируются в коде (NotificationChannel VO),
/// а не типом БД — расширение списка каналов не требует миграции.
model NotificationPreference {
id String @id @default(uuid(7))
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
channel String
enabled Boolean @default(true)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@unique([userId, channel])
@@map("notification_preference")
}
/// Лог/история отправленных уведомлений — одна запись на попытку доставки
/// по одному каналу. channel/status — строки, валидируются в коде
/// (NotificationChannel/NotificationStatus VO), см. комментарий выше.
model NotificationMessage {
id String @id @default(uuid(7))
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
channel String
status String @default("PENDING")
subject String?
content String
error String?
sentAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
@@index([userId])
@@index([status])
@@index([createdAt])
@@map("notification_message")
}
/// Комментарий к задаче. При удалении автора комментарий остаётся (authorId -> NULL).
/// deletedAt — мягкое удаление, editedAt — время последней правки текста.
model Comment {
id String @id @default(uuid(7))
content String
issueId Int
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
authorId String?
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
editedAt DateTime? @db.Timestamptz(3)
deletedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
attachments CommentAttachment[]
@@index([issueId, createdAt])
@@index([authorId])
@@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(fields: [uploaderId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) @db.Timestamptz(3)
issueAttachments IssueAttachment[]
commentAttachments CommentAttachment[]
@@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 | assignee | dueDate
/// (валидируется в коде, см. ISSUE_CHANGE_FIELDS). Значения — строки:
/// priority — число, dueDate — ISO-дата, assignee — id пользователя.
/// При создании задачи пишутся две строки: 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")
}
+17
View File
@@ -5,9 +5,16 @@ import { IssueModule } from './modules/issue/issue.module.js';
import { ProjectModule } from './modules/project/project.module.js';
import { UserModule } from './modules/user/user.module.js';
import { StatusModule } from './modules/status/status.module.js';
import { NotificationModule } from './modules/notification/notification.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 { AuthModule } from '@thallesp/nestjs-better-auth';
import { auth } from './lib/auth.js';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'node:path';
@@ -18,6 +25,11 @@ import { join } from 'node:path';
isGlobal: true,
cache: true,
}),
// Внутрипроцессная шина доменных событий. Notification-модуль слушает
// события через неё, не завязываясь на модули, которые их порождают.
EventEmitterModule.forRoot(),
// Периодические задачи (@Cron), например сборка файлов-сирот в FileModule
ScheduleModule.forRoot(),
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
@@ -40,6 +52,11 @@ import { join } from 'node:path';
ProjectModule,
UserModule,
StatusModule,
NotificationModule,
CommentModule,
FileModule,
IssueAttachmentModule,
HistoryModule,
],
controllers: [AppController],
providers: [AppService],
@@ -1,6 +1,7 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, StreamableFile } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { SSE_METADATA } from '@nestjs/common/constants.js';
import { PaginationMeta } from '../interfaces/pagination-meta.interface.js';
export interface ResponseEnvelope<T> {
@@ -30,18 +31,26 @@ export class TransformResponseInterceptor<T> implements NestInterceptor<
context: ExecutionContext,
next: CallHandler<T>,
): Observable<T | ResponseEnvelope<T> | PaginatedResponseEnvelope<T>> {
// GraphQL уже оборачивает ответ в {data, errors} на уровне транспорта —
// конверт применяем только к HTTP (REST) контексту, чтобы не задваивать его.
if (context.getType() !== 'http') {
return next.handle();
}
// SSE-обработчик возвращает поток MessageEvent ({type, data, id}) — его
// сериализует сам Nest. Обёртка в {data, code} спрятала бы type внутрь data,
// и клиентский addEventListener('<type>') перестал бы срабатывать.
if (Reflect.getMetadata(SSE_METADATA, context.getHandler())) {
return next.handle();
}
const response = context.switchToHttp().getResponse();
return next.handle().pipe(
map((result) => {
// Пагинированные ответы ({data, meta}) разворачиваем на верхний
// уровень конверта, а не оборачиваем повторно как data.data.
// Файл отдаётся потоком как есть — обёртка {data, code} сломала бы скачивание
if (result instanceof StreamableFile) {
return result;
}
if (isPaginatedResult<T>(result)) {
return {
data: result.data ?? null,
@@ -0,0 +1,18 @@
import { UserDto } from '../../../user/application/dto/user.dto.js';
import { FileDto } from '../../../file/application/dto/file.dto.js';
export class CommentDto {
id: string;
issueId: number;
authorId: string | null;
/** null — автор удалил аккаунт */
author: UserDto | null;
/** null у удалённого комментария — текст наружу не отдаём */
content: string | null;
/** Пусто у удалённого комментария. Скачивание — GET /files/:id/download */
attachments: FileDto[];
editedAt: Date | null;
deletedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { Comment } from '../../domain/entities/comment.entity.js';
import { CommentApplicationMapper } from './comment-application.mapper.js';
import { UserDto } from '../../../user/application/dto/user.dto.js';
const author: UserDto = {
id: 'user-1',
name: 'User 1',
email: 'user-1@example.com',
emailVerified: true,
image: null,
createdAt: 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', fileIds: string[] = []) =>
Comment.reconstitute({
id: 'comment-1',
content: 'Secret',
issueId: 1,
authorId,
fileIds,
editedAt: null,
deletedAt,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
});
describe('CommentApplicationMapper', () => {
it('expands the author', () => {
const dto = CommentApplicationMapper.toDto(buildComment(), new Map([[author.id, author]]));
expect(dto.author).toEqual(author);
expect(dto.content).toBe('Secret');
});
it('hides content of a deleted comment', () => {
const dto = CommentApplicationMapper.toDto(buildComment(new Date()), new Map([[author.id, author]]));
expect(dto.content).toBeNull();
expect(dto.deletedAt).toBeInstanceOf(Date);
});
it('returns null author when the author account is deleted', () => {
const dto = CommentApplicationMapper.toDto(buildComment(null, null), new Map());
expect(dto.authorId).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([]);
});
});
@@ -0,0 +1,31 @@
import { Comment } from '../../domain/entities/comment.entity.js';
import { CommentDto } from '../dto/comment.dto.js';
import { UserDto } from '../../../user/application/dto/user.dto.js';
import { FileDto } from '../../../file/application/dto/file.dto.js';
export class CommentApplicationMapper {
static toDto(
comment: Comment,
usersById: ReadonlyMap<string, UserDto>,
filesById: ReadonlyMap<string, FileDto> = new Map(),
): CommentDto {
return {
id: comment.id as string,
issueId: comment.issueId,
authorId: comment.authorId,
author: comment.authorId ? (usersById.get(comment.authorId) ?? null) : null,
content: comment.isDeleted ? null : comment.content,
// Файл могли удалить между запросами — такое вложение просто не показываем
attachments: comment.isDeleted
? []
: comment.fileIds.flatMap((fileId) => {
const file = filesById.get(fileId);
return file ? [file] : [];
}),
editedAt: comment.editedAt,
deletedAt: comment.deletedAt,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
};
}
}
@@ -0,0 +1,42 @@
import { Inject, Injectable } 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 { Comment } from '../../domain/entities/comment.entity.js';
import { CommentNotFoundException } from '../../domain/exceptions/comment-not-found.exception.js';
@Injectable()
export class CommentAccessService {
constructor(
@Inject(COMMENT_REPOSITORY)
private readonly commentRepository: ICommentRepository,
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
) {}
/** Видна ли задача пользователю (то же правило, что в IIssueRepository.findById) */
async isIssueVisible(issueId: number, userId: string): Promise<boolean> {
return (await this.issueRepository.findById(issueId, userId)) !== null;
}
/**
* Возвращает неудалённый комментарий, если его задача видна пользователю.
* Иначе — CommentNotFoundException, чтобы не раскрывать существование
* комментариев к чужим задачам.
*/
async getVisibleComment(id: string, userId: string): Promise<Comment> {
const comment = await this.commentRepository.findById(id);
if (!comment || comment.isDeleted) {
throw new CommentNotFoundException(id);
}
const issue = await this.issueRepository.findById(comment.issueId, userId);
if (!issue) {
throw new CommentNotFoundException(id);
}
return comment;
}
}
@@ -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;
}
}
@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { Observable, Subject } from 'rxjs';
import { CommentDto } from '../dto/comment.dto.js';
export type CommentEventType = 'comment.created' | 'comment.updated' | 'comment.deleted';
export interface CommentEvent {
type: CommentEventType;
comment: CommentDto;
}
/**
* In-process шина событий comment-модуля для SSE-подписчиков
* (по аналогии с IssueEventsService). Работает в пределах одного
* инстанса приложения — для нескольких инстансов понадобится Redis pub/sub.
*/
@Injectable()
export class CommentEventsService {
private readonly events$ = new Subject<CommentEvent>();
emit(type: CommentEventType, comment: CommentDto): void {
this.events$.next({ type, comment });
}
onEvents(): Observable<CommentEvent> {
return this.events$.asObservable();
}
}
@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { Comment } from '../../domain/entities/comment.entity.js';
import { CommentDto } from '../dto/comment.dto.js';
import { CommentApplicationMapper } from '../mappers/comment-application.mapper.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 { 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()
export class CommentExpanderService {
constructor(
private readonly getUsersUseCase: GetUsersUseCase,
private readonly getFilesUseCase: GetFilesUseCase,
) {}
async expand(comment: Comment): Promise<CommentDto> {
const [dto] = await this.expandAll([comment]);
return dto!;
}
async expandAll(comments: Comment[]): Promise<CommentDto[]> {
if (comments.length === 0) return [];
const userIds = [...new Set(comments.map((comment) => comment.authorId).filter((id): id is string => Boolean(id)))];
const fileIds = comments.flatMap((comment) => comment.fileIds);
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));
}
}
@@ -0,0 +1,9 @@
export class CreateCommentCommand {
constructor(
public readonly issueId: number,
public readonly authorId: string,
public readonly content: string,
/** Файлы, заранее загруженные автором через POST /files */
public readonly fileIds: string[] = [],
) {}
}
@@ -0,0 +1,88 @@
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 { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
import { Comment } from '../../../domain/entities/comment.entity.js';
import { CommentEventsService } from '../../services/comment-events.service.js';
import { CreateCommentUseCase } from './create-comment.use-case.js';
import { CreateCommentCommand } from './create-comment.command.js';
import { FileForbiddenException } from '../../../../file/domain/exceptions/file-forbidden.exception.js';
import {
buildIssue,
mockCommentExpander,
mockCommentRepository,
mockFileAccess,
mockIssueRepository,
} from '../test-helpers.js';
describe('CreateCommentUseCase', () => {
let commentRepository: ICommentRepository;
let issueRepository: IIssueRepository;
let commentEventsService: CommentEventsService;
let useCase: CreateCommentUseCase;
beforeEach(() => {
commentRepository = mockCommentRepository();
issueRepository = mockIssueRepository();
commentEventsService = new CommentEventsService();
useCase = new CreateCommentUseCase(
commentRepository,
issueRepository,
mockCommentExpander(),
commentEventsService,
mockFileAccess({ foreign: new FileForbiddenException('foreign') }),
);
vi.mocked(commentRepository.create).mockImplementation(async (comment: Comment) =>
Comment.reconstitute({
id: 'comment-1',
content: comment.content,
issueId: comment.issueId,
authorId: comment.authorId,
fileIds: [...comment.fileIds],
editedAt: null,
deletedAt: null,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
}),
);
});
it('creates a comment on a visible issue and emits comment.created', async () => {
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
const received: unknown[] = [];
commentEventsService.onEvents().subscribe((event) => received.push(event));
const dto = await useCase.execute(new CreateCommentCommand(1, 'user-1', 'Hello'));
expect(issueRepository.findById).toHaveBeenCalledWith(1, 'user-1');
expect(dto).toMatchObject({ id: 'comment-1', issueId: 1, authorId: 'user-1', content: 'Hello' });
expect(received).toEqual([{ type: 'comment.created', comment: dto }]);
});
it('throws IssueNotFoundException when the issue is not visible to the user', async () => {
vi.mocked(issueRepository.findById).mockResolvedValue(null);
await expect(useCase.execute(new CreateCommentCommand(1, 'user-2', 'Hello'))).rejects.toThrow(
IssueNotFoundException,
);
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();
});
});
@@ -0,0 +1,56 @@
import { Inject, Injectable } 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 { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
import { Comment } from '../../../domain/entities/comment.entity.js';
import { CreateCommentCommand } from './create-comment.command.js';
import { CommentDto } from '../../dto/comment.dto.js';
import { CommentExpanderService } from '../../services/comment-expander.service.js';
import { CommentEventsService } from '../../services/comment-events.service.js';
import { FileAccessService } from '../../../../file/application/services/file-access.service.js';
@Injectable()
export class CreateCommentUseCase {
constructor(
@Inject(COMMENT_REPOSITORY)
private readonly commentRepository: ICommentRepository,
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
private readonly commentExpander: CommentExpanderService,
private readonly commentEventsService: CommentEventsService,
private readonly fileAccess: FileAccessService,
) {}
async execute(command: CreateCommentCommand): Promise<CommentDto> {
// Комментировать можно только видимую пользователю задачу
const issue = await this.issueRepository.findById(command.issueId, command.authorId);
if (!issue) {
throw new IssueNotFoundException(command.issueId);
}
const comment = Comment.create({
issueId: command.issueId,
authorId: command.authorId,
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 dto = await this.commentExpander.expand(saved);
this.commentEventsService.emit('comment.created', dto);
return dto;
}
}
@@ -0,0 +1,6 @@
export class DeleteCommentCommand {
constructor(
public readonly id: string,
public readonly userId: string,
) {}
}
@@ -0,0 +1,78 @@
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 { Comment } from '../../../domain/entities/comment.entity.js';
import { CommentNotFoundException } from '../../../domain/exceptions/comment-not-found.exception.js';
import { CommentForbiddenException } from '../../../domain/exceptions/comment-forbidden.exception.js';
import { CommentEventsService, type CommentEvent } from '../../services/comment-events.service.js';
import { CommentAccessService } from '../../services/comment-access.service.js';
import { DeleteCommentUseCase } from './delete-comment.use-case.js';
import { DeleteCommentCommand } from './delete-comment.command.js';
import {
buildComment,
buildIssue,
mockCommentExpander,
mockCommentRepository,
mockIssueRepository,
} from '../test-helpers.js';
describe('DeleteCommentUseCase', () => {
let commentRepository: ICommentRepository;
let issueRepository: IIssueRepository;
let commentEventsService: CommentEventsService;
let useCase: DeleteCommentUseCase;
beforeEach(() => {
commentRepository = mockCommentRepository();
issueRepository = mockIssueRepository();
commentEventsService = new CommentEventsService();
useCase = new DeleteCommentUseCase(
commentRepository,
new CommentAccessService(commentRepository, issueRepository),
mockCommentExpander(),
commentEventsService,
);
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
});
it('soft-deletes the comment and emits comment.deleted without content', async () => {
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
const received: CommentEvent[] = [];
commentEventsService.onEvents().subscribe((event) => received.push(event));
await useCase.execute(new DeleteCommentCommand('comment-1', 'user-1'));
const saved = vi.mocked(commentRepository.update).mock.calls[0]![0] as Comment;
expect(saved.isDeleted).toBe(true);
expect(received).toHaveLength(1);
expect(received[0]!.type).toBe('comment.deleted');
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 () => {
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
await expect(useCase.execute(new DeleteCommentCommand('comment-1', 'user-2'))).rejects.toThrow(
CommentForbiddenException,
);
expect(commentRepository.update).not.toHaveBeenCalled();
});
it('throws CommentNotFoundException when the comment does not exist', async () => {
vi.mocked(commentRepository.findById).mockResolvedValue(null);
await expect(useCase.execute(new DeleteCommentCommand('missing', 'user-1'))).rejects.toThrow(
CommentNotFoundException,
);
});
});
@@ -0,0 +1,30 @@
import { Inject, Injectable } from '@nestjs/common';
import {
COMMENT_REPOSITORY,
type ICommentRepository,
} from '../../../domain/repositories/comment.repository.interface.js';
import { DeleteCommentCommand } from './delete-comment.command.js';
import { CommentExpanderService } from '../../services/comment-expander.service.js';
import { CommentEventsService } from '../../services/comment-events.service.js';
import { CommentAccessService } from '../../services/comment-access.service.js';
/** Мягкое удаление: проставляет deletedAt, запись остаётся в ленте */
@Injectable()
export class DeleteCommentUseCase {
constructor(
@Inject(COMMENT_REPOSITORY)
private readonly commentRepository: ICommentRepository,
private readonly commentAccess: CommentAccessService,
private readonly commentExpander: CommentExpanderService,
private readonly commentEventsService: CommentEventsService,
) {}
async execute(command: DeleteCommentCommand): Promise<void> {
const comment = await this.commentAccess.getVisibleComment(command.id, command.userId);
comment.delete(command.userId);
await this.commentRepository.update(comment);
this.commentEventsService.emit('comment.deleted', await this.commentExpander.expand(comment));
}
}
@@ -0,0 +1,11 @@
import type { CommentListDirection } from '../../../domain/repositories/comment.repository.interface.js';
export class ListCommentsQuery {
constructor(
public readonly issueId: number,
public readonly userId: string,
public readonly limit: number,
public readonly cursor?: string,
public readonly direction: CommentListDirection = 'forward',
) {}
}
@@ -0,0 +1,65 @@
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 { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
import { ListCommentsUseCase } from './list-comments.use-case.js';
import { ListCommentsQuery } from './list-comments.query.js';
import {
buildComment,
buildIssue,
mockCommentExpander,
mockCommentRepository,
mockIssueRepository,
} from '../test-helpers.js';
describe('ListCommentsUseCase', () => {
let commentRepository: ICommentRepository;
let issueRepository: IIssueRepository;
let useCase: ListCommentsUseCase;
beforeEach(() => {
commentRepository = mockCommentRepository();
issueRepository = mockIssueRepository();
useCase = new ListCommentsUseCase(commentRepository, issueRepository, mockCommentExpander());
});
it('forwards pagination params and returns the next cursor', async () => {
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
vi.mocked(commentRepository.findByIssue).mockResolvedValue({
list: [buildComment({ id: 'comment-2' })],
nextCursor: 'comment-2',
});
const result = await useCase.execute(new ListCommentsQuery(1, 'user-1', 1, '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.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 () => {
vi.mocked(issueRepository.findById).mockResolvedValue(null);
await expect(useCase.execute(new ListCommentsQuery(1, 'user-2', 50))).rejects.toThrow(IssueNotFoundException);
expect(commentRepository.findByIssue).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,40 @@
import { Inject, Injectable } 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 { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
import { ListCommentsQuery } from './list-comments.query.js';
import { CommentDto } from '../../dto/comment.dto.js';
import { CommentExpanderService } from '../../services/comment-expander.service.js';
@Injectable()
export class ListCommentsUseCase {
constructor(
@Inject(COMMENT_REPOSITORY)
private readonly commentRepository: ICommentRepository,
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
private readonly commentExpander: CommentExpanderService,
) {}
async execute(query: ListCommentsQuery): Promise<{ list: CommentDto[]; nextCursor: string | null }> {
const issue = await this.issueRepository.findById(query.issueId, query.userId);
if (!issue) {
throw new IssueNotFoundException(query.issueId);
}
const { list, nextCursor } = await this.commentRepository.findByIssue({
issueId: query.issueId,
limit: query.limit,
cursor: query.cursor,
direction: query.direction,
});
return { list: await this.commentExpander.expandAll(list), nextCursor };
}
}
@@ -0,0 +1,75 @@
import { 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 { Comment } from '../../domain/entities/comment.entity.js';
import { Issue } from '../../../issue/domain/entities/issue.entity.js';
import { CommentExpanderService } from '../services/comment-expander.service.js';
import { CommentApplicationMapper } from '../mappers/comment-application.mapper.js';
import type { FileAccessService } from '../../../file/application/services/file-access.service.js';
export const buildComment = (
overrides: Partial<{ id: string; issueId: number; authorId: string; deletedAt: Date | null }> = {},
) =>
Comment.reconstitute({
id: overrides.id ?? 'comment-1',
content: 'Original',
issueId: overrides.issueId ?? 1,
authorId: overrides.authorId ?? 'user-1',
editedAt: null,
deletedAt: overrides.deletedAt ?? null,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
});
export const buildIssue = () =>
Issue.reconstitute({
id: 1,
key: 'ISSUE-1',
title: 'Issue',
content: null,
status: 'OPEN',
priority: 0,
assignee: null,
reporter: 'user-1',
projectId: 'project-1',
dueDate: null,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
});
export const mockCommentRepository = (): ICommentRepository => ({
findById: vi.fn(),
findByIssue: vi.fn(),
create: vi.fn(),
update: vi.fn(),
findIssueIdsByAttachedFile: vi.fn(),
findReferencedFileIds: vi.fn(),
});
export const mockIssueRepository = (): IIssueRepository => ({
findById: vi.fn(),
findByCKey: vi.fn(),
findAll: vi.fn(),
countByStatus: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
});
/** Все файлы прикрепляемы, кроме перечисленных — для них бросается заданная ошибка */
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());
return {
expand: vi.fn(async (comment: Comment) => toDto(comment)),
expandAll: vi.fn(async (comments: Comment[]) => comments.map(toDto)),
} as unknown as CommentExpanderService;
};
@@ -0,0 +1,9 @@
export class UpdateCommentCommand {
constructor(
public readonly id: string,
public readonly userId: string,
public readonly content: string,
/** Новый набор вложений целиком; undefined — не менять */
public readonly fileIds?: string[],
) {}
}
@@ -0,0 +1,112 @@
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 { CommentNotFoundException } from '../../../domain/exceptions/comment-not-found.exception.js';
import { CommentForbiddenException } from '../../../domain/exceptions/comment-forbidden.exception.js';
import { CommentEventsService } from '../../services/comment-events.service.js';
import { CommentAccessService } from '../../services/comment-access.service.js';
import { UpdateCommentUseCase } from './update-comment.use-case.js';
import { UpdateCommentCommand } from './update-comment.command.js';
import {
buildComment,
buildIssue,
mockCommentExpander,
mockCommentRepository,
mockFileAccess,
mockIssueRepository,
} 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', () => {
let commentRepository: ICommentRepository;
let issueRepository: IIssueRepository;
let commentEventsService: CommentEventsService;
let useCase: UpdateCommentUseCase;
let received: unknown[];
let fileAccess: FileAccessService;
beforeEach(() => {
commentRepository = mockCommentRepository();
issueRepository = mockIssueRepository();
commentEventsService = new CommentEventsService();
fileAccess = mockFileAccess({ gone: new FileNotFoundException('gone') });
useCase = new UpdateCommentUseCase(
commentRepository,
new CommentAccessService(commentRepository, issueRepository),
mockCommentExpander(),
commentEventsService,
fileAccess,
);
received = [];
commentEventsService.onEvents().subscribe((event) => received.push(event));
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
});
it('updates the content and emits comment.updated', async () => {
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
const dto = await useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Changed'));
expect(dto.content).toBe('Changed');
expect(dto.editedAt).toBeInstanceOf(Date);
expect(commentRepository.update).toHaveBeenCalledTimes(1);
expect(received).toEqual([{ type: 'comment.updated', comment: dto }]);
});
it('skips saving and events when content did not change', async () => {
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
await useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Original'));
expect(commentRepository.update).not.toHaveBeenCalled();
expect(received).toEqual([]);
});
it('throws CommentForbiddenException for a non-author', async () => {
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
await expect(useCase.execute(new UpdateCommentCommand('comment-1', 'user-2', 'Changed'))).rejects.toThrow(
CommentForbiddenException,
);
expect(commentRepository.update).not.toHaveBeenCalled();
});
it('throws CommentNotFoundException for a deleted comment', async () => {
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment({ deletedAt: new Date() }));
await expect(useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Changed'))).rejects.toThrow(
CommentNotFoundException,
);
});
it('throws CommentNotFoundException when the issue is not visible to the user', async () => {
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
vi.mocked(issueRepository.findById).mockResolvedValue(null);
await expect(useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Changed'))).rejects.toThrow(
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();
});
});
@@ -0,0 +1,49 @@
import { Inject, Injectable } from '@nestjs/common';
import {
COMMENT_REPOSITORY,
type ICommentRepository,
} from '../../../domain/repositories/comment.repository.interface.js';
import { UpdateCommentCommand } from './update-comment.command.js';
import { CommentDto } from '../../dto/comment.dto.js';
import { CommentExpanderService } from '../../services/comment-expander.service.js';
import { CommentEventsService } from '../../services/comment-events.service.js';
import { CommentAccessService } from '../../services/comment-access.service.js';
import { FileAccessService } from '../../../../file/application/services/file-access.service.js';
@Injectable()
export class UpdateCommentUseCase {
constructor(
@Inject(COMMENT_REPOSITORY)
private readonly commentRepository: ICommentRepository,
private readonly commentAccess: CommentAccessService,
private readonly commentExpander: CommentExpanderService,
private readonly commentEventsService: CommentEventsService,
private readonly fileAccess: FileAccessService,
) {}
async execute(command: UpdateCommentCommand): Promise<CommentDto> {
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);
}
}
if (changed) {
await this.commentRepository.update(comment);
}
const dto = await this.commentExpander.expand(comment);
if (changed) {
this.commentEventsService.emit('comment.updated', dto);
}
return dto;
}
}
@@ -0,0 +1,6 @@
export class WatchIssueCommentsQuery {
constructor(
public readonly issueId: number,
public readonly userId: string,
) {}
}
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IIssueRepository } from '../../../../issue/domain/repositories/issue.repository.interface.js';
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
import { CommentEventsService, type CommentEvent } from '../../services/comment-events.service.js';
import { CommentApplicationMapper } from '../../mappers/comment-application.mapper.js';
import { WatchIssueCommentsUseCase } from './watch-issue-comments.use-case.js';
import { WatchIssueCommentsQuery } from './watch-issue-comments.query.js';
import { buildComment, buildIssue, mockIssueRepository } from '../test-helpers.js';
const dtoForIssue = (issueId: number) =>
CommentApplicationMapper.toDto(buildComment({ id: `comment-of-${issueId}`, issueId }), new Map());
describe('WatchIssueCommentsUseCase', () => {
let issueRepository: IIssueRepository;
let commentEventsService: CommentEventsService;
let useCase: WatchIssueCommentsUseCase;
beforeEach(() => {
issueRepository = mockIssueRepository();
commentEventsService = new CommentEventsService();
useCase = new WatchIssueCommentsUseCase(issueRepository, commentEventsService);
});
it('streams only events of the requested issue', async () => {
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
const received: CommentEvent[] = [];
const events$ = await useCase.execute(new WatchIssueCommentsQuery(1, 'user-1'));
events$.subscribe((event) => received.push(event));
commentEventsService.emit('comment.created', dtoForIssue(1));
commentEventsService.emit('comment.created', dtoForIssue(2));
expect(received.map((event) => event.comment.id)).toEqual(['comment-of-1']);
});
it('throws IssueNotFoundException when the issue is not visible to the user', async () => {
vi.mocked(issueRepository.findById).mockResolvedValue(null);
await expect(useCase.execute(new WatchIssueCommentsQuery(1, 'user-2'))).rejects.toThrow(IssueNotFoundException);
});
});
@@ -0,0 +1,31 @@
import { Inject, Injectable } from '@nestjs/common';
import { filter, type Observable } from 'rxjs';
import {
ISSUE_REPOSITORY,
type IIssueRepository,
} from '../../../../issue/domain/repositories/issue.repository.interface.js';
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
import { WatchIssueCommentsQuery } from './watch-issue-comments.query.js';
import { type CommentEvent, CommentEventsService } from '../../services/comment-events.service.js';
/**
* Поток событий комментариев одной задачи для SSE. Доступ к задаче
* проверяется один раз — при подключении.
*/
@Injectable()
export class WatchIssueCommentsUseCase {
constructor(
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
private readonly commentEventsService: CommentEventsService,
) {}
async execute(query: WatchIssueCommentsQuery): Promise<Observable<CommentEvent>> {
const issue = await this.issueRepository.findById(query.issueId, query.userId);
if (!issue) {
throw new IssueNotFoundException(query.issueId);
}
return this.commentEventsService.onEvents().pipe(filter((event) => event.comment.issueId === query.issueId));
}
}
+40
View File
@@ -0,0 +1,40 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
import { IssueModule } from '../issue/issue.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 { CommentPrismaRepository } from './infrastructure/persistence/prisma/repositories/comment.prisma-repository.js';
import { CommentController } from './presentation/controllers/comment.controller.js';
import { CreateCommentUseCase } from './application/use-cases/create-comment/create-comment.use-case.js';
import { ListCommentsUseCase } from './application/use-cases/list-comments/list-comments.use-case.js';
import { UpdateCommentUseCase } from './application/use-cases/update-comment/update-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 { CommentAccessService } from './application/services/comment-access.service.js';
import { CommentExpanderService } from './application/services/comment-expander.service.js';
import { CommentEventsService } from './application/services/comment-events.service.js';
import { CommentAttachmentFileOwnerService } from './application/services/comment-attachment-file-owner.service.js';
@Module({
imports: [PrismaModule, IssueModule, UserModule, FileModule],
controllers: [CommentController],
providers: [
CreateCommentUseCase,
ListCommentsUseCase,
UpdateCommentUseCase,
DeleteCommentUseCase,
WatchIssueCommentsUseCase,
CommentAccessService,
CommentExpanderService,
CommentEventsService,
CommentAttachmentFileOwnerService,
{
// Domain видит только интерфейс ICommentRepository,
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь
provide: COMMENT_REPOSITORY,
useClass: CommentPrismaRepository,
},
],
})
export class CommentModule {}
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest';
import { Comment } from './comment.entity.js';
import { CommentForbiddenException } from '../exceptions/comment-forbidden.exception.js';
const buildComment = (overrides: Partial<{ authorId: string | null; deletedAt: Date | null }> = {}) =>
Comment.reconstitute({
id: 'comment-1',
content: 'Original',
issueId: 1,
authorId: overrides.authorId === undefined ? 'user-1' : overrides.authorId,
editedAt: null,
deletedAt: overrides.deletedAt ?? null,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
});
describe('Comment', () => {
describe('create', () => {
it('creates a new comment without id and edit/delete marks', () => {
const comment = Comment.create({ issueId: 1, authorId: 'user-1', content: ' Hello ' });
expect(comment.id).toBeNull();
expect(comment.content).toBe('Hello');
expect(comment.editedAt).toBeNull();
expect(comment.isDeleted).toBe(false);
});
it('rejects empty content', () => {
expect(() => Comment.create({ issueId: 1, authorId: 'user-1', content: '' })).toThrow();
});
});
describe('edit', () => {
it('changes content and sets editedAt', () => {
const comment = buildComment();
expect(comment.edit('Changed', 'user-1')).toBe(true);
expect(comment.content).toBe('Changed');
expect(comment.editedAt).toBeInstanceOf(Date);
});
it('is a no-op when content is the same', () => {
const comment = buildComment();
expect(comment.edit(' Original ', 'user-1')).toBe(false);
expect(comment.editedAt).toBeNull();
});
it('forbids editing by someone other than the author', () => {
expect(() => buildComment().edit('Changed', 'user-2')).toThrow(CommentForbiddenException);
});
it('forbids editing when the author account is deleted', () => {
expect(() => buildComment({ authorId: null }).edit('Changed', 'user-1')).toThrow(CommentForbiddenException);
});
it('forbids editing a deleted comment', () => {
expect(() => buildComment({ deletedAt: new Date() }).edit('Changed', 'user-1')).toThrow();
});
});
describe('delete', () => {
it('soft-deletes the comment', () => {
const comment = buildComment();
comment.delete('user-1');
expect(comment.isDeleted).toBe(true);
expect(comment.deletedAt).toBeInstanceOf(Date);
});
it('forbids deletion by someone other than the author', () => {
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([]);
});
});
});
@@ -0,0 +1,167 @@
import { CommentContent } from '../value-objects/comment-content.vo.js';
import { CommentForbiddenException } from '../exceptions/comment-forbidden.exception.js';
interface CommentProps {
id: string | null;
content: CommentContent;
issueId: number;
/** null — автор удалил аккаунт */
authorId: string | null;
/** Прикреплённые файлы (file-модуль), по времени загрузки */
fileIds: string[];
editedAt: Date | null;
deletedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
/**
* Комментарий к задаче. Удаление мягкое: запись остаётся (deletedAt),
* чтобы в ленте можно было показать «комментарий удалён».
*
* Вложения — часть комментария: задаются при создании и правке,
* снимаются при удалении (тогда осиротевшие файлы удалит GC file-модуля).
*
* `id` (uuid v7) генерируется базой, поэтому у нового комментария
* он равен null до сохранения в репозитории.
*/
export class Comment {
static readonly MAX_ATTACHMENTS = 10;
private constructor(private readonly props: CommentProps) {}
/** Фабричный метод для создания НОВОГО комментария */
static create(params: { issueId: number; authorId: string; content: string; fileIds?: string[] }): Comment {
const now = new Date();
return new Comment({
id: null,
content: CommentContent.create(params.content),
issueId: params.issueId,
authorId: params.authorId,
fileIds: Comment.normalizeFileIds(params.fileIds ?? []),
editedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now,
});
}
/** Восстановление сущности из персистентного слоя */
static reconstitute(props: {
id: string;
content: string;
issueId: number;
authorId: string | null;
fileIds?: string[];
editedAt: Date | null;
deletedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}): Comment {
return new Comment({
...props,
content: CommentContent.create(props.content),
fileIds: [...(props.fileIds ?? [])],
});
}
get id(): string | null {
return this.props.id;
}
get content(): string {
return this.props.content.value;
}
get issueId(): number {
return this.props.issueId;
}
get authorId(): string | null {
return this.props.authorId;
}
get fileIds(): readonly string[] {
return this.props.fileIds;
}
get editedAt(): Date | null {
return this.props.editedAt;
}
get deletedAt(): Date | null {
return this.props.deletedAt;
}
get isDeleted(): boolean {
return this.props.deletedAt !== null;
}
get createdAt(): Date {
return this.props.createdAt;
}
get updatedAt(): Date {
return this.props.updatedAt;
}
/**
* Меняет текст и, если передан fileIds, — набор вложений (целиком).
* Возвращает false, если ничего не изменилось.
*/
edit(content: string, userId: string, fileIds?: string[]): boolean {
this.assertCanModify(userId);
const nextContent = CommentContent.create(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;
}
const now = new Date();
this.props.content = nextContent;
this.props.fileIds = nextFileIds;
this.props.editedAt = now;
this.props.updatedAt = now;
return true;
}
delete(userId: string): void {
this.assertCanModify(userId);
const now = new Date();
this.props.fileIds = [];
this.props.deletedAt = 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 {
if (this.isDeleted) {
throw new Error('Комментарий уже удалён');
}
if (this.props.authorId !== userId) {
throw new CommentForbiddenException(this.props.id ?? 'new');
}
}
}
@@ -0,0 +1,6 @@
export class CommentForbiddenException extends Error {
constructor(id: string) {
super(`Нет прав на изменение комментария id=${id}`);
this.name = 'CommentForbiddenException';
}
}
@@ -0,0 +1,6 @@
export class CommentNotFoundException extends Error {
constructor(id: string) {
super(`Комментарий с id=${id} не найден`);
this.name = 'CommentNotFoundException';
}
}
@@ -0,0 +1,36 @@
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 */
export const COMMENT_REPOSITORY = Symbol('COMMENT_REPOSITORY');
export interface ICommentRepository {
/** Возвращает и мягко удалённые комментарии — решение принимает вызывающий */
findById(id: string): Promise<Comment | null>;
/**
* Комментарии задачи от старых к новым, включая удалённые.
* Курсорная пагинация. forward: cursor — id последнего полученного комментария,
* отдаются более новые. backward: cursor — id самого старого полученного,
* отдаются более старые (без cursor — последняя страница). Список всегда
* отсортирован от старых к новым.
*/
findByIssue(params: {
issueId: number;
limit: number;
cursor?: string;
direction?: CommentListDirection;
}): Promise<{ list: Comment[]; nextCursor: string | null }>;
/**
* Создание и обновление сохраняют и вложения (fileIds) — атомарно с комментарием.
* FileNotFoundException, если файл удалили между проверкой и записью.
*/
create(comment: Comment): Promise<Comment>;
update(comment: Comment): Promise<void>;
/** Задачи неудалённых комментариев, к которым прикреплён файл */
findIssueIdsByAttachedFile(fileId: string): Promise<number[]>;
/** Подмножество fileIds, прикреплённых хотя бы к одному комментарию */
findReferencedFileIds(fileIds: string[]): Promise<string[]>;
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { CommentContent } from './comment-content.vo.js';
describe('CommentContent', () => {
it('trims the value', () => {
expect(CommentContent.create(' hello ').value).toBe('hello');
});
it('rejects empty content', () => {
expect(() => CommentContent.create(' ')).toThrow('Комментарий не может быть пустым');
});
it('rejects content longer than the limit', () => {
expect(() => CommentContent.create('a'.repeat(CommentContent.MAX_LENGTH + 1))).toThrow(
`Комментарий не может превышать ${CommentContent.MAX_LENGTH} символов`,
);
});
});
@@ -0,0 +1,27 @@
export class CommentContent {
static readonly MAX_LENGTH = 10000;
private constructor(private readonly _value: string) {}
static create(value: string): CommentContent {
const trimmed = value?.trim();
if (!trimmed) {
throw new Error('Комментарий не может быть пустым');
}
if (trimmed.length > CommentContent.MAX_LENGTH) {
throw new Error(`Комментарий не может превышать ${CommentContent.MAX_LENGTH} символов`);
}
return new CommentContent(trimmed);
}
get value(): string {
return this._value;
}
equals(other: CommentContent): boolean {
return this._value === other.value;
}
}
@@ -0,0 +1,41 @@
import type { Comment as PrismaComment, Prisma } from '../../../../../../generated/prisma/client.js';
import { Comment } from '../../../../domain/entities/comment.entity.js';
/** Комментарий вместе с id прикреплённых файлов (см. COMMENT_INCLUDE в репозитории) */
export type PrismaCommentWithAttachments = PrismaComment & { attachments: { fileId: string }[] };
export class CommentPrismaMapper {
static toDomain(record: PrismaCommentWithAttachments): Comment {
return Comment.reconstitute({
id: record.id,
content: record.content,
issueId: record.issueId,
authorId: record.authorId,
fileIds: record.attachments.map((attachment) => attachment.fileId),
editedAt: record.editedAt,
deletedAt: record.deletedAt,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
});
}
static toCreateInput(comment: Comment): Prisma.CommentUncheckedCreateInput {
return {
content: comment.content,
issueId: comment.issueId,
authorId: comment.authorId,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
attachments: { create: comment.fileIds.map((fileId) => ({ fileId })) },
};
}
static toUpdateInput(comment: Comment): Prisma.CommentUncheckedUpdateInput {
return {
content: comment.content,
editedAt: comment.editedAt,
deletedAt: comment.deletedAt,
updatedAt: comment.updatedAt,
};
}
}
@@ -0,0 +1,129 @@
import { Injectable } from '@nestjs/common';
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
import { Prisma } from '../../../../../../generated/prisma/client.js';
import { Comment } from '../../../../domain/entities/comment.entity.js';
import type {
CommentListDirection,
ICommentRepository,
} from '../../../../domain/repositories/comment.repository.interface.js';
import { CommentPrismaMapper } from '../mappers/comment.mapper.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';
/** Вложения подгружаются вместе с комментарием, по времени загрузки файла (uuid v7) */
const COMMENT_INCLUDE = {
attachments: { select: { fileId: true }, orderBy: { fileId: 'asc' } },
} satisfies Prisma.CommentInclude;
@Injectable()
export class CommentPrismaRepository implements ICommentRepository {
constructor(private readonly prisma: PrismaAdapter) {}
async findById(id: string): Promise<Comment | null> {
const record = await this.prisma.comment.findUnique({ where: { id }, include: COMMENT_INCLUDE });
return record ? CommentPrismaMapper.toDomain(record) : null;
}
async findByIssue(params: {
issueId: number;
limit: number;
cursor?: string;
direction?: CommentListDirection;
}): Promise<{ list: Comment[]; nextCursor: string | null }> {
const limit = Math.max(1, params.limit);
const order = params.direction === 'backward' ? 'desc' : 'asc';
// Берём на одну запись больше, чтобы понять, есть ли следующая страница.
// id (uuid v7) — тай-брейкер для комментариев с одинаковым createdAt.
const records = await this.prisma.comment.findMany({
where: { issueId: params.issueId },
include: COMMENT_INCLUDE,
orderBy: [{ createdAt: order }, { id: order }],
take: limit + 1,
...(params.cursor ? { cursor: { id: params.cursor }, skip: 1 } : {}),
});
const hasMore = records.length > limit;
const page = hasMore ? records.slice(0, limit) : records;
// Курсор — последняя запись страницы в порядке выборки: для backward это самая старая
const nextCursor = hasMore ? page[page.length - 1]!.id : null;
if (order === 'desc') page.reverse();
return { list: page.map(CommentPrismaMapper.toDomain), nextCursor };
}
async create(comment: Comment): Promise<Comment> {
try {
// Вложения создаются вложенной записью — одним атомарным запросом
const record = await this.prisma.comment.create({
data: CommentPrismaMapper.toCreateInput(comment),
include: COMMENT_INCLUDE,
});
return CommentPrismaMapper.toDomain(record);
} catch (error) {
// Задачу или файл удалили между проверкой доступа и вставкой
throw this.mapForeignKeyError(error, comment) ?? error;
}
}
async update(comment: Comment): Promise<void> {
const id = comment.id as string;
const fileIds = [...comment.fileIds];
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);
}
}
@@ -0,0 +1,126 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
type MessageEvent,
NotFoundException,
Param,
ParseIntPipe,
Patch,
Post,
Query,
Sse,
UseGuards,
} from '@nestjs/common';
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
import { map, type Observable } from 'rxjs';
import { CreateCommentUseCase } from '../../application/use-cases/create-comment/create-comment.use-case.js';
import { ListCommentsUseCase } from '../../application/use-cases/list-comments/list-comments.use-case.js';
import { UpdateCommentUseCase } from '../../application/use-cases/update-comment/update-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 { CreateCommentCommand } from '../../application/use-cases/create-comment/create-comment.command.js';
import { ListCommentsQuery } from '../../application/use-cases/list-comments/list-comments.query.js';
import { UpdateCommentCommand } from '../../application/use-cases/update-comment/update-comment.command.js';
import { DeleteCommentCommand } from '../../application/use-cases/delete-comment/delete-comment.command.js';
import { WatchIssueCommentsQuery } from '../../application/use-cases/watch-issue-comments/watch-issue-comments.query.js';
import { CreateCommentRequestDto } from '../dto/create-comment.dto.js';
import { UpdateCommentRequestDto } from '../dto/update-comment.dto.js';
import { ListCommentsDto } from '../dto/list-comments.dto.js';
import { CommentNotFoundException } from '../../domain/exceptions/comment-not-found.exception.js';
import { CommentForbiddenException } from '../../domain/exceptions/comment-forbidden.exception.js';
import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.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()
export class CommentController {
constructor(
private readonly createCommentUseCase: CreateCommentUseCase,
private readonly listCommentsUseCase: ListCommentsUseCase,
private readonly updateCommentUseCase: UpdateCommentUseCase,
private readonly deleteCommentUseCase: DeleteCommentUseCase,
private readonly watchIssueCommentsUseCase: WatchIssueCommentsUseCase,
) {}
@Get('issues/:issueId/comments')
list(
@Param('issueId', ParseIntPipe) issueId: number,
@Query() query: ListCommentsDto,
@Session() session: UserSession,
) {
return this.handleErrors(() =>
this.listCommentsUseCase.execute(
new ListCommentsQuery(issueId, session.user.id, Number(query.limit), query.cursor, query.direction),
),
);
}
@Post('issues/:issueId/comments')
create(
@Param('issueId', ParseIntPipe) issueId: number,
@Body() dto: CreateCommentRequestDto,
@Session() session: UserSession,
) {
return this.handleErrors(() =>
this.createCommentUseCase.execute(new CreateCommentCommand(issueId, session.user.id, dto.content, dto.fileIds)),
);
}
/**
* SSE-поток комментариев одной задачи: клиент открывает его на странице
* задачи и закрывает при уходе. После переподключения клиенту стоит
* перезапросить список — события, пришедшие во время обрыва, не повторяются.
* Доступ проверяет IssueVisibleGuard — так клиент без доступа получает 404.
*/
@Sse('issues/:issueId/comments/events')
@UseGuards(IssueVisibleGuard)
events(
@Param('issueId', ParseIntPipe) issueId: number,
@Session() session: UserSession,
): Promise<Observable<MessageEvent>> {
return this.handleErrors(async () => {
const events$ = await this.watchIssueCommentsUseCase.execute(
new WatchIssueCommentsQuery(issueId, session.user.id),
);
return events$.pipe(map((event) => ({ type: event.type, data: event.comment })));
});
}
@Patch('comments/:id')
update(@Param('id') id: string, @Body() dto: UpdateCommentRequestDto, @Session() session: UserSession) {
return this.handleErrors(() =>
this.updateCommentUseCase.execute(new UpdateCommentCommand(id, session.user.id, dto.content, dto.fileIds)),
);
}
@Delete('comments/:id')
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@Param('id') id: string, @Session() session: UserSession) {
await this.handleErrors(() => this.deleteCommentUseCase.execute(new DeleteCommentCommand(id, session.user.id)));
}
/** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */
private async handleErrors<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (error) {
if (
error instanceof CommentNotFoundException ||
error instanceof IssueNotFoundException ||
error instanceof FileNotFoundException
) {
throw new NotFoundException(error.message);
}
if (error instanceof CommentForbiddenException || error instanceof FileForbiddenException) {
throw new ForbiddenException(error.message);
}
throw error;
}
}
}
@@ -0,0 +1,18 @@
import { ArrayMaxSize, IsArray, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { CommentContent } from '../../domain/value-objects/comment-content.vo.js';
import { Comment } from '../../domain/entities/comment.entity.js';
export class CreateCommentRequestDto {
@IsString()
@IsNotEmpty()
@MaxLength(CommentContent.MAX_LENGTH)
content: string;
/** id файлов, загруженных через POST /files */
@IsOptional()
@IsArray()
@ArrayMaxSize(Comment.MAX_ATTACHMENTS)
@IsString({ each: true })
@MaxLength(64, { each: true })
fileIds?: string[];
}
@@ -0,0 +1,24 @@
import { IsIn, IsOptional } from 'class-validator';
import { IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js';
import {
COMMENT_LIST_DIRECTIONS,
type CommentListDirection,
} from '../../domain/repositories/comment.repository.interface.js';
export class ListCommentsDto {
@IntDecorator({ isOptional: true, min: 1, max: 100 })
limit: number = 50;
/** id последнего полученного комментария (nextCursor из предыдущего ответа) */
@StringDecorator({ isOptional: true })
cursor?: string;
/**
* forward — от старых к новым, cursor — последний полученный (новые после него).
* backward — страницы от новых к старым (для чата), cursor — самый старый полученный.
* В обоих случаях список в ответе отсортирован от старых к новым.
*/
@IsOptional()
@IsIn(COMMENT_LIST_DIRECTIONS)
direction: CommentListDirection = 'forward';
}
@@ -0,0 +1,18 @@
import { ArrayMaxSize, IsArray, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { CommentContent } from '../../domain/value-objects/comment-content.vo.js';
import { Comment } from '../../domain/entities/comment.entity.js';
export class UpdateCommentRequestDto {
@IsString()
@IsNotEmpty()
@MaxLength(CommentContent.MAX_LENGTH)
content: string;
/** id файлов, загруженных через POST /files — набор целиком; не передан — не менять */
@IsOptional()
@IsArray()
@ArrayMaxSize(Comment.MAX_ATTACHMENTS)
@IsString({ each: true })
@MaxLength(64, { each: true })
fileIds?: string[];
}
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest';
import { BadRequestException, type ExecutionContext, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { CommentAccessService } from '../../application/services/comment-access.service.js';
import { IssueVisibleGuard } from './issue-visible.guard.js';
const buildContext = (params: Record<string, string>, userId?: string) =>
({
switchToHttp: () => ({
getRequest: () => ({ params, session: userId ? { user: { id: userId } } : undefined }),
}),
}) as unknown as ExecutionContext;
const buildGuard = (visible: boolean) => {
const commentAccess = { isIssueVisible: vi.fn().mockResolvedValue(visible) } as unknown as CommentAccessService;
return { guard: new IssueVisibleGuard(commentAccess), commentAccess };
};
describe('IssueVisibleGuard', () => {
it('allows access to a visible issue', async () => {
const { guard, commentAccess } = buildGuard(true);
await expect(guard.canActivate(buildContext({ issueId: '1' }, 'user-1'))).resolves.toBe(true);
expect(commentAccess.isIssueVisible).toHaveBeenCalledWith(1, 'user-1');
});
it('throws NotFoundException when the issue is not visible', async () => {
const { guard } = buildGuard(false);
await expect(guard.canActivate(buildContext({ issueId: '1' }, 'user-2'))).rejects.toThrow(NotFoundException);
});
it('throws BadRequestException for a non-numeric issueId', async () => {
const { guard, commentAccess } = buildGuard(true);
await expect(guard.canActivate(buildContext({ issueId: 'abc' }, 'user-1'))).rejects.toThrow(BadRequestException);
expect(commentAccess.isIssueVisible).not.toHaveBeenCalled();
});
it('throws UnauthorizedException without a session', async () => {
const { guard } = buildGuard(true);
await expect(guard.canActivate(buildContext({ issueId: '1' }))).rejects.toThrow(UnauthorizedException);
});
});
@@ -0,0 +1,48 @@
import {
BadRequestException,
type CanActivate,
type ExecutionContext,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import type { UserSession } from '@thallesp/nestjs-better-auth';
import { CommentAccessService } from '../../application/services/comment-access.service.js';
import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js';
/**
* Проверяет доступ к задаче из `:issueId` ДО вызова обработчика.
*
* Нужен SSE-эндпоинтам: при наличии глобального interceptor'а Nest вызывает
* @Sse-обработчик уже после отправки заголовков 200, и ошибка из него доходит
* до клиента только как `event: error`. Guard же отвечает честным 404.
*
* Сессию в request кладёт глобальный AuthGuard, который выполняется раньше.
*/
@Injectable()
export class IssueVisibleGuard implements CanActivate {
constructor(private readonly commentAccess: CommentAccessService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context
.switchToHttp()
.getRequest<{ params: Record<string, string | undefined>; session?: UserSession }>();
const userId = request.session?.user.id;
if (!userId) {
throw new UnauthorizedException();
}
// Пайпы (ParseIntPipe) выполняются после guard'ов — парсим сами
const issueId = Number(request.params.issueId);
if (!Number.isInteger(issueId)) {
throw new BadRequestException('Validation failed (numeric string is expected)');
}
if (!(await this.commentAccess.isIssueVisible(issueId, userId))) {
throw new NotFoundException(new IssueNotFoundException(issueId).message);
}
return true;
}
}
@@ -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;
}
}
+67
View File
@@ -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,
};
}
}
@@ -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: пользователи из 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[];
}
@@ -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,
) {}
}
@@ -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();
});
});
@@ -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);
}
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more