24 Commits
Author SHA1 Message Date
vadim 4ac4d314e4 cicd
Gitea Actions / Deploy (push) Successful in 2m11s
2026-11-03 09:35:02 +03:00
vadim 917ea9748e docs
Gitea Actions / Deploy (push) Failing after 27m39s
2026-11-01 21:36:45 +03:00
vadim d170fc8790 tags
Gitea Actions / Deploy (push) Canceled after 45m38s
2026-10-28 16:22:24 +03:00
vadimandClaude Opus 5.5 b1f6b61994 create issues fix
Gitea Actions / Deploy (push) Canceled after 5m13s
issue observers

Наблюдатели задачи видят её и комментируют, но не редактируют.

- таблица issue_observer, наблюдатели — часть сущности Issue
- видимость: reporter, assignee или наблюдатель (задачи, история, SSE)
- правка/удаление задачи и вложений — только reporter/assignee (403 наблюдателю)
- observers в create/update, DELETE /issues/:id/observers/me, фильтр role
- история изменений и уведомления новым наблюдателям

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:23:49 +03:00
vadim 46903e9ec5 avatar
Gitea Actions / Deploy (push) Successful in 1m52s
2026-10-23 19:26:26 +03:00
vadim f085517732 email 2026-10-23 19:26:26 +03:00
vadim de1b3754df otp code, packages update
Gitea Actions / Deploy (push) Successful in 1m34s
2026-10-19 17:23:50 +03:00
vadim c5691da2b8 issue type
Gitea Actions / Deploy (push) Successful in 32s
2026-10-18 20:37:58 +03:00
vadim afc37f8fdb send push to assignee
Gitea Actions / Deploy (push) Successful in 32s
2026-10-18 20:19:57 +03:00
vadim 8d02d25da0 firebase messaging
Gitea Actions / Deploy (push) Successful in 2m1s
2026-10-16 16:51:55 +03:00
vadim 5ad0ecdf4c typo
Gitea Actions / Deploy (push) Successful in 36s
2026-10-14 21:26:15 +03:00
vadim 7aecc6f96e calendar due date
Gitea Actions Demo / Deploy (push) Successful in 31s
2026-10-12 11:08:27 +03:00
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
284 changed files with 12455 additions and 1510 deletions

No files matched your search

+3
View File
@@ -10,3 +10,6 @@ coverage
*.log
tsconfig.build.tsbuildinfo
src/generated
storage
configs
service-account.json
+2 -2
View File
@@ -1,4 +1,4 @@
name: Gitea Actions Demo
name: Gitea Actions
run-name: Deploy 🚀
on:
push:
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Docker build
run: docker build --tag ${{ env.IMAGE_NAME }} .
- name: Login to gitea
+6
View File
@@ -2,6 +2,7 @@
/dist
/node_modules
/build
*.tsbuildinfo
# Logs
logs
@@ -57,3 +58,8 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
/src/generated/prisma
/src/schema.gql
# Local file storage (FILE_STORAGE=local)
/storage
/configs
configs/service-account.json
+1
View File
@@ -1,3 +1,4 @@
# Prisma
npm install prisma@7 --save-dev
+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.
+2767 -952
View File
File diff suppressed because it is too large. Load diff
+20 -17
View File
@@ -24,44 +24,47 @@
"dependencies": {
"@apollo/server": "^5.5.1",
"@as-integrations/express5": "^1.1.2",
"@better-auth/prisma-adapter": "^1.7.2",
"@aws-sdk/client-s3": "^3.1152.0",
"@better-auth/prisma-adapter": "^1.7.11",
"@nestjs/apollo": "^14.0.0",
"@nestjs/common": "^12.0.1",
"@nestjs/config": "^12.0.0",
"@nestjs/core": "^12.0.1",
"@nestjs/common": "^12.2.0",
"@nestjs/config": "^12.0.1",
"@nestjs/core": "^12.2.0",
"@nestjs/event-emitter": "^12.0.1",
"@nestjs/graphql": "^14.0.0",
"@nestjs/platform-express": "^12.0.1",
"@nestjs/typeorm": "^12.0.1",
"@nestjs/platform-express": "^12.2.0",
"@nestjs/schedule": "^12.0.2",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"@thallesp/nestjs-better-auth": "^2.7.0",
"better-auth": "^1.7.2",
"@thallesp/nestjs-better-auth": "^2.8.0",
"better-auth": "^1.7.11",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"file-type": "^22.2.0",
"firebase-admin": "^14.5.0",
"graphql": "^16.14.2",
"nodemailer": "^10.0.0",
"nodemailer": "^10.1.7",
"pg": "^8.23.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^1.1.1",
"valibot": "^1.4.2"
"valibot": "^1.5.0"
},
"devDependencies": {
"@nestjs/cli": "^12.0.0",
"@nestjs/mau": "^0.2.6",
"@nestjs/mau": "^0.2.8",
"@nestjs/schematics": "^12.0.0",
"@nestjs/testing": "^12.0.1",
"@nestjs/testing": "^12.2.0",
"@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",
"@types/supertest": "^7.2.1",
"@vitest/coverage-v8": "^4.1.2",
"oxlint": "^1.58.0",
"prettier": "^3.4.2",
"oxlint": "^1.88.0",
"prettier": "^3.9.10",
"prisma": "^7.10.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"supertest": "^7.3.1",
"typescript": "^6.0.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.1.2"
@@ -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;
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "issue_projectId_dueDate_idx" ON "issue"("projectId", "dueDate");
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "issue" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'TASK';
-- CreateIndex
CREATE INDEX "issue_projectId_type_idx" ON "issue"("projectId", "type");
@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "user" ADD COLUMN "avatarFileId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "user_avatarFileId_key" ON "user"("avatarFileId");
-- AddForeignKey
ALTER TABLE "user" ADD CONSTRAINT "user_avatarFileId_fkey" FOREIGN KEY ("avatarFileId") REFERENCES "file"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "issue_observer" (
"issueId" INTEGER NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "issue_observer_pkey" PRIMARY KEY ("issueId","userId")
);
-- CreateIndex
CREATE INDEX "issue_observer_userId_idx" ON "issue_observer"("userId");
-- AddForeignKey
ALTER TABLE "issue_observer" ADD CONSTRAINT "issue_observer_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "issue_observer" ADD CONSTRAINT "issue_observer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "ProjectRole" AS ENUM ('MEMBER', 'OBSERVER');
-- AlterTable
ALTER TABLE "project_member" ADD COLUMN "role" "ProjectRole" NOT NULL DEFAULT 'MEMBER';
@@ -0,0 +1,37 @@
-- CreateTable
CREATE TABLE "tag" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"normalizedName" TEXT NOT NULL,
"color" TEXT NOT NULL DEFAULT 'default',
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "issue_tag" (
"issueId" INTEGER NOT NULL,
"tagId" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "issue_tag_pkey" PRIMARY KEY ("issueId","tagId")
);
-- CreateIndex
CREATE UNIQUE INDEX "tag_projectId_normalizedName_key" ON "tag"("projectId", "normalizedName");
-- CreateIndex
CREATE INDEX "issue_tag_tagId_idx" ON "issue_tag"("tagId");
-- AddForeignKey
ALTER TABLE "tag" ADD CONSTRAINT "tag_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "issue_tag" ADD CONSTRAINT "issue_tag_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "issue_tag" ADD CONSTRAINT "issue_tag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+190 -17
View File
@@ -13,6 +13,10 @@ model User {
email String
emailVerified Boolean @default(false)
image String?
/// Файл аватара; image хранит его URL для better-auth и клиента.
/// Меняется только через PUT /users/me/avatar — там проверяется, что файл свой.
avatarFileId String? @unique
avatarFile File? @relation(name: "userAvatar", fields: [avatarFileId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
@@ -24,6 +28,10 @@ model User {
notificationDeviceTokens NotificationDeviceToken[]
notificationPreferences NotificationPreference[]
notificationMessages NotificationMessage[]
comments Comment[]
uploadedFiles File[] @relation(name: "fileUploader")
issueChanges IssueChange[]
observedIssues IssueObserver[]
@@unique([email])
@@map("user")
@@ -79,21 +87,27 @@ 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)
type String @default("TASK")
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[]
observers IssueObserver[]
tags IssueTag[]
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@index([status])
@@index([assigneeId])
@@ -102,9 +116,26 @@ model Issue {
@@index([createdAt])
@@index([projectId, createdAt])
@@index([dueDate])
@@index([projectId, dueDate])
@@index([projectId, type])
@@map("issue")
}
/// Наблюдатель задачи: видит её и может комментировать, но не редактировать.
/// Редактируют задачу только reporter и assignee.
model IssueObserver {
issueId Int
userId String
createdAt DateTime @default(now()) @db.Timestamptz(3)
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([issueId, userId])
@@index([userId])
@@map("issue_observer")
}
model Project {
id String @id @default(uuid(7))
name String
@@ -115,6 +146,7 @@ model Project {
ownerId String
owner User @relation(fields: [ownerId], references: [id], onDelete: Restrict)
members ProjectMember[]
tags Tag[]
@@unique([key])
@@index([createdAt])
@@ -122,11 +154,51 @@ model Project {
@@map("project")
}
/// Тег — элемент справочника проекта. Уникален в проекте без учёта регистра:
/// normalizedName — name в нижнем регистре. color — из палитры (TAG_COLORS),
/// валидируется в коде. Задаче тег назначают по имени — отсутствующий создаётся.
model Tag {
id String @id @default(uuid(7))
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
name String
normalizedName String
color String @default("default")
issues IssueTag[]
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@unique([projectId, normalizedName])
@@map("tag")
}
/// Тег задачи. Удаление тега из справочника снимает его со всех задач (без записи в историю).
model IssueTag {
issueId Int
tagId String
createdAt DateTime @default(now()) @db.Timestamptz(3)
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([issueId, tagId])
@@index([tagId])
@@map("issue_tag")
}
/// MEMBER создаёт задачи в проекте; OBSERVER только видит проект —
/// он наблюдает за отдельными задачами (IssueObserver).
enum ProjectRole {
MEMBER
OBSERVER
}
model ProjectMember {
id String @id @default(uuid(7))
id String @id @default(uuid(7))
projectId String
userId String
createdAt DateTime @default(now()) @db.Timestamptz(3)
role ProjectRole @default(MEMBER)
createdAt DateTime @default(now()) @db.Timestamptz(3)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -188,3 +260,104 @@ model NotificationMessage {
@@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(name: "fileUploader", fields: [uploaderId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) @db.Timestamptz(3)
issueAttachments IssueAttachment[]
commentAttachments CommentAttachment[]
avatarOf User? @relation(name: "userAvatar")
@@unique([storageKey])
@@index([uploaderId])
@@index([createdAt])
@@map("file")
}
/// Вложение задачи — связь задачи с файлом. Удаление задачи или файла
/// удаляет связь каскадом; сам файл, оставшийся без связей, удалит GC.
model IssueAttachment {
issueId Int
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
fileId String
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @db.Timestamptz(3)
@@id([issueId, fileId])
@@index([fileId])
@@map("issue_attachment")
}
/// Вложение комментария — связь комментария с файлом. Управляется вместе
/// с комментарием (fileIds в create/update); при мягком удалении комментария
/// связи снимаются, чтобы файлы забрал GC.
model CommentAttachment {
commentId String
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
fileId String
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @db.Timestamptz(3)
@@id([commentId, fileId])
@@index([fileId])
@@map("comment_attachment")
}
/// История изменений задачи — одна строка на изменённое поле.
/// Строки одного сохранения задачи объединены общим changeId.
/// field: created | title | content | status | priority | type | assignee | dueDate | observer | tag
/// (валидируется в коде, см. ISSUE_CHANGE_FIELDS). Значения — строки:
/// priority — число, dueDate — ISO-дата, assignee — id пользователя.
/// observer и tag — по строке на добавленный (oldValue = null) или снятый (newValue = null) элемент;
/// observer — id пользователя, tag — имя тега на момент изменения.
/// При создании задачи пишутся две строки: created и status null -> OPEN,
/// поэтому статус задачи на любой момент восстанавливается по строкам status.
/// У задач, созданных до появления истории, строк нет — в статистику они не попадают.
model IssueChange {
id Int @id @default(autoincrement())
issueId Int
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
changeId String
field String
oldValue String?
newValue String?
actorId String?
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
changedAt DateTime @db.Timestamptz(3)
@@index([issueId, changedAt])
@@index([field, changedAt])
@@index([actorId])
@@map("issue_change")
}
+14 -7
View File
@@ -5,11 +5,18 @@ 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 { IssueTypeModule } from './modules/issue-type/issue-type.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 { TagModule } from './modules/tag/tag.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';
@@ -20,19 +27,13 @@ import { join } from 'node:path';
isGlobal: true,
cache: true,
}),
// Внутрипроцессная шина доменных событий. Notification-модуль слушает
// события через неё, не завязываясь на модули, которые их порождают.
EventEmitterModule.forRoot(),
ScheduleModule.forRoot(),
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
}),
// TypeOrmModule.forRootAsync({
// imports: [ConfigModule],
// inject: [ConfigService],
// useFactory: pgConfig,
// }),
AuthModule.forRoot({
auth: auth,
bodyParser: {
@@ -45,7 +46,13 @@ import { join } from 'node:path';
ProjectModule,
UserModule,
StatusModule,
IssueTypeModule,
NotificationModule,
CommentModule,
FileModule,
IssueAttachmentModule,
HistoryModule,
TagModule,
],
controllers: [AppController],
providers: [AppService],
+19 -1
View File
@@ -1,5 +1,5 @@
import { Transform, TransformFnParams } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { IsBoolean, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
import { applyDecorators } from '@nestjs/common';
interface ToStringOptions {
@@ -11,6 +11,7 @@ interface BaseDecoratorParams {
}
export interface StringDecoratorParams extends BaseDecoratorParams {}
export interface BooleanDecoratorParams extends BaseDecoratorParams {}
export interface IntDecoratorParams extends BaseDecoratorParams {
min?: number;
max?: number;
@@ -24,6 +25,15 @@ const ToNumber = () =>
return Number.isNaN(parsed) ? undefined : parsed;
});
/** Query-параметры приходят строками: 'true'/'false' → boolean, остальное оставляем как есть для IsBoolean */
const ToBoolean = () =>
Transform(({ value }) => {
if (value === undefined || value === null || value === '') return undefined;
if (value === 'true') return true;
if (value === 'false') return false;
return value;
});
const ToString = (options?: ToStringOptions) =>
Transform(({ value }: TransformFnParams) => {
if (value === undefined || value === null) return undefined;
@@ -52,3 +62,11 @@ export function IntDecorator(params?: IntDecoratorParams) {
return applyDecorators(...args);
}
export function BooleanDecorator(params?: BooleanDecoratorParams) {
const args = [IsBoolean(), ToBoolean()];
if (params?.isOptional) args.push(IsOptional());
return applyDecorators(...args);
}
@@ -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,
+1 -2
View File
@@ -1,7 +1,6 @@
import { ConfigService } from '@nestjs/config';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export function pgConfig(config: ConfigService): TypeOrmModuleOptions {
export function pgConfig(config: ConfigService): unknown {
return {
type: 'postgres',
url: config.getOrThrow<string>('DATABASE_URL'),
+28 -11
View File
@@ -2,12 +2,35 @@ import { betterAuth } from 'better-auth';
import { prismaAdapter } from '@better-auth/prisma-adapter';
import prisma from './prisma.js';
import EmailSender from './email-sender.js';
import { emailOTP } from 'better-auth/plugins';
export const auth = betterAuth({
appName: process.env.APP_NAME ?? 'app',
basePath: '/api/auth',
plugins: [
// authTransformResponse()
emailOTP({
async sendVerificationOTP({ email, otp, type }) {
if (type === 'sign-in') {
EmailSender.sendInBackground({
to: email,
subject: `Your OTP code is: ${otp}`,
text: `Your OTP code is: ${otp}`,
});
} else if (type === 'email-verification') {
// Send the OTP for email verification
console.log({ email, otp, type });
} else if (type === 'forget-password') {
EmailSender.sendInBackground({
to: email,
subject: `Your password reset code is: ${otp}`,
text: `Your password reset code is: ${otp}`,
});
} else {
console.log({ email, otp, type });
}
},
}),
],
database: prismaAdapter(prisma, {
provider: 'postgresql',
@@ -24,28 +47,23 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
autoSignIn: false,
requireEmailVerification: false,
requireEmailVerification: true,
onExistingUserSignUp: async ({ user }, request) => {
console.log('onExistingUserSignUp', { user });
void EmailSender.getTransporter().sendMail({
EmailSender.sendInBackground({
to: user.email,
subject: 'Sign-up attempt with your email',
text: 'Someone tried to create an account using your email address. If this was you, try signing in instead. If not, you can safely ignore this email.',
});
},
sendResetPassword: async ({ user, url, token }, request) => {
console.log('sendResetPassword', { user, url, token });
void EmailSender.getTransporter().sendMail({
EmailSender.sendInBackground({
to: user.email,
subject: 'Reset your password',
text: `Click the link to reset your password: ${url}`,
});
},
onPasswordReset: async ({ user }, request) => {
// your logic here
console.log('onPasswordReset', { user });
void EmailSender.getTransporter().sendMail({
EmailSender.sendInBackground({
to: user.email,
subject: 'Reset your password result',
text: `Password for user ${user.email} has been reset.`,
@@ -56,8 +74,7 @@ export const auth = betterAuth({
sendOnSignIn: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => {
console.log('emailVerification', { user, url });
void EmailSender.getTransporter().sendMail({
EmailSender.sendInBackground({
to: user.email,
subject: 'Verify your email address',
text: `Click the link to verify your email: ${url}`,
+33 -2
View File
@@ -1,13 +1,44 @@
import { Logger } from '@nestjs/common';
import nodemailer, { SendMailOptions, Transporter } from 'nodemailer';
export default class EmailSender {
static getTransporter() {
return nodemailer.createTransport({
private static readonly logger = new Logger(EmailSender.name);
private static transporter?: Transporter;
private static isLocal() {
return process.env.NODE_ENV === 'local';
}
private static getTransporter() {
this.transporter ??= nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.GOOGLE_APP_USER,
pass: process.env.GOOGLE_APP_PASSWORD,
},
});
return this.transporter;
}
/** Sends an email; throws on failure. In local env only logs the message. */
static async send(options: SendMailOptions): Promise<void> {
if (this.isLocal()) {
this.logger.log(
`[local] email not sent\n to: ${String(options.to)}\n subject: ${options.subject}\n text: ${String(options.text)}`,
);
return;
}
await this.getTransporter().sendMail(options);
}
/** Fire-and-forget variant: never throws, errors are logged. */
static sendInBackground(options: SendMailOptions): void {
this.send(options).catch((error: unknown) => {
this.logger.error(
`Failed to send email "${options.subject}" to ${String(options.to)}`,
error instanceof Error ? error.stack : String(error),
);
});
}
}
-5
View File
@@ -16,12 +16,7 @@ async function bootstrap() {
logger: new CustomLogger(),
});
// app.useGlobalPipes(new StandardSchemaValidationPipe());
app.setGlobalPrefix('api');
// Глобальный body-parser отключён (better-auth сам парсит своё тело),
// поэтому для GraphQL включаем JSON-парсинг точечно на его пути.
app.use('/graphql', json());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.useGlobalInterceptors(new TransformResponseInterceptor());
@@ -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,77 @@
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,
type: 'TASK',
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(),
findByDueDateRange: 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;
}
}
Loaded 100 of 284 files, more files were not shown because too many files have changed in this diff. Show more