diff --git a/.claude/commands/audit-schemas.md b/.claude/commands/audit-schemas.md new file mode 100644 index 0000000..82688b6 --- /dev/null +++ b/.claude/commands/audit-schemas.md @@ -0,0 +1,54 @@ +Аудит Zod-схем (DTO) на соответствие Prisma-модели. Аргументы: $ARGUMENTS + +## Контекст + +Частая проблема: поле в Prisma nullable (`String?`), а в Zod Create-схеме только `.optional()` без `.nullable()`. Это приводит к ошибке валидации, когда клиент отправляет `null`. + +Обратная проблема: поле required в Prisma (без `?` и без `@default`), но в Zod помечено `.optional()` — Prisma упадёт при INSERT с неясной ошибкой. + +## Шаги + +### 1. Определи проект +Prisma схема: `backend/src/prisma/schema.prisma`. DTO: `backend/src/modules/**/*.dto.ts`. + +### 2. Прочитай Prisma-схему +Для каждой модели составь карту полей: +- **Nullable:** `Type?` → поле nullable +- **Required:** `Type` без `@default` → поле обязательно при INSERT +- **Default:** `@default(...)` → поле необязательно при INSERT +- **Relation:** `@relation(...)` → пропустить + +### 3. Прочитай все DTO-файлы +Для каждой Create/Update схемы проверь каждое поле: + +**Категория 1 — Prisma nullable, Zod не nullable:** +Поле `Type?` в Prisma, но в Zod только `.optional()` без `.nullable()`. +Клиент может отправить `null`, Zod отклонит → 400 Bad Request. +Фикс: заменить `.optional()` на `.nullable().optional()`. + +**Категория 2 — Prisma required, Zod optional:** +Поле обязательно в БД (`Type` без `?` и без `@default`), но Zod пропускает `undefined`. +Риск: Prisma упадёт при INSERT. +Фикс: убрать `.optional()` или добавить `.default()`. + +**Категория 3 — Update-схемы:** +В Update-схемах все поля `.optional()`. +Nullable в Prisma → `.nullable().optional()` (`.partial()` не добавляет `.nullable()` автоматически). + +### 4. Составь отчёт + +Для каждого нарушения: +``` +Файл: backend/src/modules/tasks/tasks.dto.ts +Схема: createTaskDto +Поле: assigneeId +Prisma: String? (nullable) +Zod: z.string().uuid().optional() ← НАРУШЕНИЕ категория 1 +Фикс: z.string().uuid().nullable().optional() +``` + +Если нарушений нет — сообщить "Все схемы соответствуют Prisma-модели ✅". + +### 5. Примени фиксы +Исправь все нарушения категорий 1 и 2. +Для nullable полей: всегда `.nullable()` стоит ПЕРЕД `.optional()`. diff --git a/.claude/commands/design-doc.md b/.claude/commands/design-doc.md new file mode 100644 index 0000000..f63d7ac --- /dev/null +++ b/.claude/commands/design-doc.md @@ -0,0 +1,132 @@ +Создай design-doc и spec для задачи перед началом реализации. Аргументы: $ARGUMENTS + +## Использование +``` +/design-doc "Расширение RBAC до per-feature permissions" +/design-doc gap-22 "Настройки пространства" +``` + +## Контекст +Design-doc + spec — обязательные артефакты перед реализацией задачи с >3 файлами или новой сущностью/паттерном. Создаются ДО написания кода. Design-doc фиксирует архитектурные решения и живёт в `docs/design/`. Spec описывает поведение в BDD + контракты в SDD и живёт в `specs/`. + +## Шаги + +### 1. Изучи контекст +- Найди связанные файлы через `gitnexus_query({ query: "{тема}" })` +- Прочитай существующие specs в `specs/existing/` для понимания масштаба +- Если указан gap-номер — прочитай `specs/gaps/gap-{N}-*.md` + +### 2. Создай design-doc + +Файл: `docs/design/{slug}.md` + +```markdown +# Design Doc: {Название} + +**Дата:** {дата} +**Статус:** draft +**Spec:** [specs/{path}](../../specs/{path}) + +## Цель +Одно предложение: что пользователь/система получит в результате. + +## Инвентаризация данных +ВСЕ места где затронутая сущность читается или пишется: +- `backend/src/modules/{module}/{file}.service.ts` — что делает +- `backend/src/modules/{module}/{file}.router.ts` — какие эндпоинты +- `frontend/src/api/{file}.ts` — какие вызовы +- `frontend/src/components/{Component}.tsx` — что рендерит + +## Карта компонентов +Кто потребляет изменяемые данные (upstream от изменения). + +## Модель данных +Prisma-изменения: новые модели, поля, enum-значения, миграции. + +## API контракт +Новые или изменённые эндпоинты (метод, путь, DTO shape). + +## Стыки +Какие существующие файлы нужно менять и почему. + +## Риски и blast radius +Результат `gitnexus_impact()` на ключевые символы. +Что может неожиданно сломаться. + +## Решения +Принятые архитектурные решения и их обоснование. +Альтернативы которые рассматривались и почему отклонены. +``` + +### 3. Создай spec + +Для gap-задачи: `specs/gaps/gap-{N}-{slug}.md` +Для новой фичи без gap-номера: `specs/existing/{N}-{slug}.md` со статусом `draft` + +```markdown +--- +id: {slug} +type: gap-feat | gap-fix | existing +priority: P0 | P1 | P2 | P3 +status: draft +design-doc: docs/design/{slug}.md +--- + +# Spec: {Название} + +## Intent +{Одна фраза — ЧТО и ЗАЧЕМ} + +## BDD Scenarios + +\`\`\`gherkin +Feature: {Название} + + Background: + Given {предусловие} + + Scenario: {Happy path — основной сценарий} + Given {условие} + When {действие} + Then {результат} + + Scenario: {Edge case} + Given {условие} + When {действие} + Then {результат} + + Scenario: {Негативный / ошибка} + Given {условие} + When {действие} + Then {ожидаемая ошибка} +\`\`\` + +## SDD Contracts + +\`\`\`typescript +// Интерфейсы, DTO, API shape — минимально необходимое для реализации +// Пример: +// interface CreateLabelDto { name: string; color: string; workspaceId: string; } +// POST /api/workspaces/:wid/labels → LabelDto +// GET /api/workspaces/:wid/labels → { items: LabelDto[]; total: number } +\`\`\` + +## Scope +- {Что входит} + +## Out of Scope +- {Что не входит} + +## Constraints +- {Технические ограничения, RBAC-правила, существующие паттерны} + +## Acceptance Criteria +- [ ] {Проверяемое условие} +- [ ] API: `{method} {path}` → `{ожидаемый ответ}` +- [ ] UI: {действие} → {результат} +``` + +### 4. Добавь перекрёстные ссылки +- В design-doc уже есть ссылка на spec (шаг 2) +- В spec frontmatter: `design-doc: docs/design/{slug}.md` +- Обнови `specs/README.md` — добавь новый spec в таблицу приоритетов diff --git a/.claude/commands/new-api.md b/.claude/commands/new-api.md new file mode 100644 index 0000000..234b6a7 --- /dev/null +++ b/.claude/commands/new-api.md @@ -0,0 +1,144 @@ +Создай новый API-эндпоинт по стандартам проекта. Аргументы: $ARGUMENTS + +## Использование +``` +/new-api attachments POST +/new-api workspaces/:wid/milestones CRUD +/new-api admin/reports GET +``` + +## Шаги + +### 1. Разбери аргументы +- **Путь:** `tasks/:id/attachments` → модуль `attachments` +- **Методы:** `GET`, `POST`, `PATCH`, `DELETE` или `CRUD` (= GET список + POST + GET один + PATCH + DELETE) +- Если аргументы не указаны — спроси путь и методы + +### 2. Impact analysis +``` +gitnexus_impact({ target: "{смежный сервис или модуль}", direction: "upstream" }) +``` +Убедиться что новый эндпоинт не конфликтует с существующими маршрутами. + +### 3. Создай Zod DTO + +Файл: `backend/src/modules/{module}/{module}.dto.ts` + +```typescript +import { z } from 'zod'; +import { registry } from '../../shared/openapi/registry.js'; + +export const create{Entity}Dto = registry.register( + 'Create{Entity}', + z.object({ + // обязательные поля — без .optional() + name: z.string().min(1).max(255), + // nullable поля из Prisma (Type?) — .nullable().optional() + description: z.string().nullable().optional(), + // FK-поля — .string().uuid() + workspaceId: z.string().uuid(), + }) +); + +export const update{Entity}Dto = registry.register( + 'Update{Entity}', + // .partial() делает все поля .optional(), но не добавляет .nullable() + // для nullable полей Prisma — добавь .nullable() явно + z.object({ + name: z.string().min(1).max(255).optional(), + description: z.string().nullable().optional(), + }) +); + +export const {entity}FiltersDto = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(20).optional(), + offset: z.coerce.number().int().min(0).default(0).optional(), +}); + +export type Create{Entity}Dto = z.infer; +export type Update{Entity}Dto = z.infer; +``` + +**Правила DTO:** +- Nullable в Prisma (`Type?`) → `.nullable().optional()` в Zod — всегда +- Update-схемы: все поля `.optional()`, nullable остаются `.nullable().optional()` +- ВСЕГДА оборачивать через `registry.register()` для OpenAPI генерации + +### 4. Зарегистрируй пути в OpenAPI + +Файл: `backend/src/shared/openapi/routes/{module}.ts` (создай если нет) + +```typescript +import { registry } from '../registry.js'; +import { create{Entity}Dto } from '../../modules/{module}/{module}.dto.js'; + +registry.registerPath({ + method: 'post', + path: '/api/{path}', + summary: 'Создать {сущность}', + tags: ['{Module}'], + request: { + body: { content: { 'application/json': { schema: create{Entity}Dto } } }, + }, + responses: { + 201: { description: 'Создано' }, + 400: { description: 'Ошибка валидации' }, + 401: { description: 'Не авторизован' }, + 403: { description: 'Нет прав' }, + }, +}); +``` + +Импортируй новый файл в `backend/src/shared/openapi/registry.ts`. + +### 5. Создай роутер + +Файл: `backend/src/modules/{module}/{module}.router.ts` + +```typescript +import { Router } from 'express'; +import { authenticate } from '../../shared/middleware/auth.js'; +import { validate } from '../../shared/middleware/validate.js'; +import { asyncHandler, authHandler } from '../../shared/utils/async-handler.js'; +import { create{Entity}Dto, {entity}FiltersDto } from './{module}.dto.js'; +import * as service from './{module}.service.js'; + +const router = Router({ mergeParams: true }); +router.use(authenticate); + +router.get('/', validate({entity}FiltersDto, 'query'), authHandler(async (req, res) => { + res.json(await service.list{Entities}(req.user!.userId, req.query as never)); +})); + +router.post('/', validate(create{Entity}Dto), authHandler(async (req, res) => { + res.status(201).json(await service.create{Entity}(req.user!.userId, req.body)); +})); + +export { router as {module}Router }; +``` + +### 6. Смонтируй в app.ts +```typescript +import { {module}Router } from './modules/{module}/{module}.router.js'; +app.use('/api/{path}', {module}Router); +``` + +### 7. Напиши тесты + +Файл: `backend/src/__tests__/{module}.test.ts` + +Минимальный набор сценариев: +- POST → 201 + тело ответа +- POST с невалидными данными → 400 + `details` +- GET список → 200 + массив +- GET несуществующего → 404 +- Без токена → 401 +- Без прав → 403 (если применимо) + +### 8. Чеклист +- [ ] DTO зарегистрирован через `registry.register()` +- [ ] OpenAPI пути зарегистрированы в `routes/{module}.ts` +- [ ] Файл роутов импортирован в `registry.ts` +- [ ] Роутер смонтирован в `app.ts` +- [ ] Тесты написаны +- [ ] `npm run check:rbac` — зелёный diff --git a/.claude/commands/preflight.md b/.claude/commands/preflight.md new file mode 100644 index 0000000..6dc1d21 --- /dev/null +++ b/.claude/commands/preflight.md @@ -0,0 +1,70 @@ +Предпушевая проверка — полный чеклист перед отправкой кода. Аргументы: $ARGUMENTS + +## Использование +``` +/preflight # полная проверка +/preflight quick # только tsc + lint +``` + +## Шаги + +### 1. Git status +Запусти `git status` и `git diff --stat`. СТОП если есть: +- Файлы `.env`, `*.key`, `credentials*`, `*.pem` — никогда не пушить +- Конфликтные маркеры `<<<<<<<` в любом файле +- Файлы >1MB (возможно бинарники по ошибке) + +Предупреди если есть: +- `console.log` / `debugger` в `backend/src/` или `frontend/src/` +- TODO-комментарии в новых файлах + +### 2. TypeScript +```bash +cd backend && npx tsc --noEmit +cd frontend && npx tsc --noEmit +``` +При ошибках — показать список, предложить исправить. Нельзя пушить с TS-ошибками. + +### 3. ESLint +```bash +cd backend && npm run lint +cd frontend && npm run lint +``` +При ошибках — показать список. Нельзя пушить с lint errors. + +### 4. RBAC static check +```bash +cd backend && npm run check:rbac +``` +Проверяет что все роуты покрыты RBAC guards. Провал = неавторизованный доступ. + +### 5. Prisma validate +```bash +cd backend && npx prisma validate +``` +Проверяет синтаксис `schema.prisma`. +Если были изменения в схеме — также проверить `npx prisma migrate status`. + +### 6. Тесты (если не `quick`) +```bash +cd backend && npm run test -- --passWithNoTests +``` +Показать результат. Падающие тесты — СТОП. + +### 7. GitNexus scope check +``` +gitnexus_detect_changes({ scope: "staged" }) +``` +Убедиться что изменённые символы ожидаемые, нет случайно затронутых файлов. + +### 8. Итоговый отчёт +``` +✅ Git — чисто, нет секретов +✅ TypeScript — OK +✅ ESLint — OK +✅ RBAC check — OK +✅ Prisma validate — OK +✅ Tests — N passed +✅ GitNexus — scope confirmed +``` +Если хоть один ❌ — не пушить до исправления. diff --git a/CLAUDE.md b/CLAUDE.md index beb83d7..4d3b664 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,3 +57,38 @@ make dev # backend :3101 + frontend :5174 ## Paper дизайн 27 артбордов (Dark + Light). Палитра Dark: #03050F bg, #0F1320 cards, #4F6EF7 accent. + +--- + +## Обязательный процесс разработки + +Эти правила применяются автоматически — не нужно напоминать. + +### Перед изменением любого существующего символа +ВСЕГДА запускать `gitnexus_impact({ target: "symbolName", direction: "upstream" })` и сообщать blast radius. При HIGH или CRITICAL риске — предупредить и ждать подтверждения перед правками. + +### Перед реализацией нетривиальной задачи (>3 файлов или новая сущность) +ВСЕГДА создавать два артефакта ДО кода: +1. Design-doc в `docs/design/{slug}.md` — архитектурные решения +2. Spec в `specs/` — BDD сценарии + SDD контракты + +Не начинать реализацию без этих артефактов. Использовать команду `design-doc` из `.claude/commands/design-doc.md`. + +### После любого изменения Prisma схемы или DTO +ВСЕГДА проверять соответствие nullable/optional по всем затронутым схемам. Использовать команду `audit-schemas` из `.claude/commands/audit-schemas.md`. + +### При создании нового API эндпоинта +ВСЕГДА следовать порядку: Zod DTO (через `registry.register()`) → OpenAPI регистрация → router → service → тест. Никогда не создавать роут без DTO и без OpenAPI записи. Использовать команду `new-api` из `.claude/commands/new-api.md`. + +### После написания кода — три ревью последовательно +1. **code-reviewer** — качество: TypeScript строгость, паттерны проекта, размер функций, иммутабельность +2. **security-reviewer** — безопасность: RBAC, IDOR, валидация, audit log, нет утечки PII +3. **UX/UI-reviewer** — опыт: loading/empty/error состояния, feedback, доступность, соответствие дизайн-системе + +### Перед каждым push +ВСЕГДА выполнять preflight из `.claude/commands/preflight.md`: +`tsc --noEmit` + `lint` + `check:rbac` + `prisma validate` + тесты + `gitnexus_detect_changes` + +### Справочники +- Полный цикл разработки фичи: `docs/claude-patterns/feature-development.md` +- Конвенции кода и API: `docs/claude-patterns/dev-conventions.md` diff --git a/backend/package-lock.json b/backend/package-lock.json index 6aa7fae..df4d5c5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,15 +1,17 @@ { "name": "flowtask-backend", - "version": "1.3.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flowtask-backend", - "version": "1.3.0", + "version": "1.5.0", "license": "MIT", "dependencies": { + "@asteasolutions/zod-to-openapi": "^7.3.4", "@prisma/client": "^6.19.2", + "@scalar/express-api-reference": "^0.9.14", "@types/cookie-parser": "^1.4.10", "@types/multer": "^2.1.0", "bcryptjs": "^2.4.3", @@ -24,6 +26,7 @@ "openid-client": "^6.8.4", "redis": "^5.11.0", "sanitize-html": "^2.17.3", + "swagger-ui-express": "^5.0.1", "zod": "^3.24.2" }, "devDependencies": { @@ -36,6 +39,7 @@ "@types/nodemailer": "^8.0.0", "@types/sanitize-html": "^2.16.1", "@types/supertest": "^6.0.2", + "@types/swagger-ui-express": "^4.1.8", "@typescript-eslint/eslint-plugin": "^8.18.2", "@typescript-eslint/parser": "^8.18.2", "@vitest/coverage-v8": "^3.2.4", @@ -64,11 +68,23 @@ "node": ">=6.0.0" } }, + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-7.3.4.tgz", + "integrity": "sha512-/2rThQ5zPi9OzVwes6U7lK1+Yvug0iXu25olp7S0XsYmOqnyMfxH7gdSQjn/+DSOHRg7wnotwGJSyL+fBKdnEA==", + "license": "MIT", + "dependencies": { + "openapi3-ts": "^4.1.2" + }, + "peerDependencies": { + "zod": "^3.20.2" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -78,7 +94,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -88,7 +104,7 @@ "version": "7.29.3", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -104,7 +120,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -961,7 +977,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", @@ -974,14 +990,14 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -995,14 +1011,14 @@ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3", @@ -1014,7 +1030,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3" @@ -1438,11 +1454,93 @@ "win32" ] }, + "node_modules/@scalar/client-side-rendering": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.1.7.tgz", + "integrity": "sha512-IDzjKF93jrOljlvKBsLHXT1FPWgz56jFrMPC+iLihREp1qH8wF92mG8Zpakw8cURkEuw5WijRk0xNBP2moGyuw==", + "license": "MIT", + "dependencies": { + "@scalar/types": "0.9.6" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/express-api-reference": { + "version": "0.9.14", + "resolved": "https://registry.npmjs.org/@scalar/express-api-reference/-/express-api-reference-0.9.14.tgz", + "integrity": "sha512-OPhqH+OOPHjuka/4v6x+/sYU1aPB5xfAMeb1lNMallWZBok7ZmVicDVSfRC/9NQCHzOgpMzdRdo847qJJ6TTXw==", + "license": "MIT", + "dependencies": { + "@scalar/client-side-rendering": "0.1.7" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.6.0.tgz", + "integrity": "sha512-pfSamAgBxqFeE8IpEG6uGkHlnPhY1CLeOTttV9+vKQbrBk5b7vvyTsUXv0Hz4kNU1TFrxcTTPE+Akn5S+jlTtQ==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/types": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.9.6.tgz", + "integrity": "sha512-UaCQQcscFTJdxZREE8KhUdSJgaDlc44TZbmWcZffs4m1hzqOvEI7lEBS13iBpLq7/cxUXFgyJdecywvNqJ0PkA==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.6.0", + "nanoid": "^5.1.6", + "type-fest": "^5.3.1", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/types/node_modules/nanoid": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@scalar/types/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/bcryptjs": { @@ -1676,6 +1774,17 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", @@ -2306,7 +2415,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.3", @@ -2335,7 +2444,7 @@ "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -2441,7 +2550,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -2457,7 +2566,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "consola": "^3.2.3" @@ -2541,14 +2650,14 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -2696,7 +2805,7 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -2706,7 +2815,7 @@ "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/delayed-stream": { @@ -2732,7 +2841,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/destroy": { @@ -2875,7 +2984,7 @@ "version": "3.21.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -2893,7 +3002,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -3361,14 +3470,14 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -3665,7 +3774,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -4064,7 +4173,7 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -4280,7 +4389,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.25.4", @@ -4462,7 +4571,7 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/nodemailer": { @@ -4478,7 +4587,7 @@ "version": "0.6.6", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz", "integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.2.2", @@ -4496,7 +4605,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/oauth4webapi": { @@ -4533,7 +4642,7 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/on-finished": { @@ -4558,6 +4667,15 @@ "wrappy": "1" } }, + "node_modules/openapi3-ts": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz", + "integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==", + "license": "MIT", + "dependencies": { + "yaml": "^2.8.0" + } + }, "node_modules/openid-client": { "version": "6.8.4", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz", @@ -4703,7 +4821,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/pathval": { @@ -4720,7 +4838,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -4746,7 +4864,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "confbox": "^0.2.4", @@ -4796,7 +4914,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -4845,7 +4963,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -4901,7 +5019,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "defu": "^6.1.4", @@ -4926,7 +5044,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -5495,6 +5613,42 @@ "node": ">=8" } }, + "node_modules/swagger-ui-dist": { + "version": "5.32.5", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.5.tgz", + "integrity": "sha512-7/FQfWe9A4qoyYFdAwy0chD0uDYidDp/ZT9VQ9LZlgD4AnnHJk8/+ytAA1HkJYOPySmK6helPDdJQMlcumt7HA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/test-exclude": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", @@ -5521,7 +5675,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -5629,6 +5783,21 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -5652,7 +5821,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -6058,6 +6227,21 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index d597e1e..f96f079 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,7 +24,9 @@ "check:all": "npm run check:rbac" }, "dependencies": { + "@asteasolutions/zod-to-openapi": "^7.3.4", "@prisma/client": "^6.19.2", + "@scalar/express-api-reference": "^0.9.14", "@types/cookie-parser": "^1.4.10", "@types/multer": "^2.1.0", "bcryptjs": "^2.4.3", @@ -39,6 +41,7 @@ "openid-client": "^6.8.4", "redis": "^5.11.0", "sanitize-html": "^2.17.3", + "swagger-ui-express": "^5.0.1", "zod": "^3.24.2" }, "prisma": { @@ -54,6 +57,7 @@ "@types/nodemailer": "^8.0.0", "@types/sanitize-html": "^2.16.1", "@types/supertest": "^6.0.2", + "@types/swagger-ui-express": "^4.1.8", "@typescript-eslint/eslint-plugin": "^8.18.2", "@typescript-eslint/parser": "^8.18.2", "@vitest/coverage-v8": "^3.2.4", diff --git a/backend/src/app.ts b/backend/src/app.ts index e284e9e..909da6a 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,7 +1,9 @@ -import express from 'express'; +import express, { type Request, type Response, type NextFunction } from 'express'; import cors from 'cors'; import helmet from 'helmet'; import cookieParser from 'cookie-parser'; +import swaggerUi from 'swagger-ui-express'; +import { generateOpenApiSpec } from './shared/openapi/index.js'; import { errorHandler } from './shared/middleware/error-handler.js'; import authRouter from './modules/auth/auth.router.js'; @@ -41,6 +43,18 @@ export function createApp() { }); }); + // OpenAPI spec + Swagger UI (self-contained, no CDN) + // CSP override must come before swaggerUi.serve so Helmet's restrictive policy doesn't block eval() + const openApiSpec = generateOpenApiSpec(); + app.get('/api/openapi.json', (_req, res) => res.json(openApiSpec)); + app.use('/api/docs', (_req: Request, res: Response, next: NextFunction) => { + res.setHeader( + 'Content-Security-Policy', + "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;", + ); + next(); + }, swaggerUi.serve, swaggerUi.setup(openApiSpec)); + // Routes app.use('/api/auth', authRouter); app.use('/api/workspaces', workspacesRouter); diff --git a/backend/src/shared/openapi/index.ts b/backend/src/shared/openapi/index.ts new file mode 100644 index 0000000..220e9d4 --- /dev/null +++ b/backend/src/shared/openapi/index.ts @@ -0,0 +1,26 @@ +import { OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'; +import { registry } from './registry.js'; + +// Route files register their paths as a side effect on import. +import './routes/auth.js'; +import './routes/workspaces.js'; +import './routes/boards.js'; +import './routes/tasks.js'; +import './routes/labels.js'; +import './routes/comments.js'; +import './routes/search.js'; +import './routes/notifications.js'; +import './routes/admin.js'; + +export function generateOpenApiSpec() { + const generator = new OpenApiGeneratorV3(registry.definitions); + return generator.generateDocument({ + openapi: '3.0.3', + info: { + title: 'FlowTask API', + version: process.env.npm_package_version ?? '1.0.0', + description: 'REST API таск-трекера FlowTask. Kanban-доски, задачи, RBAC, SSO.', + }, + servers: [{ url: '/api', description: 'API' }], + }); +} diff --git a/backend/src/shared/openapi/registry.ts b/backend/src/shared/openapi/registry.ts new file mode 100644 index 0000000..9574d0b --- /dev/null +++ b/backend/src/shared/openapi/registry.ts @@ -0,0 +1,9 @@ +import { OpenAPIRegistry, extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; + +// Must be called once before any schema is used in registerPath/register. +extendZodWithOpenApi(z); + +// Singleton registry — imported by all route registration files. +// Do NOT import route files here (circular dep). Import in index.ts instead. +export const registry = new OpenAPIRegistry(); diff --git a/backend/src/shared/openapi/routes/admin.ts b/backend/src/shared/openapi/routes/admin.ts new file mode 100644 index 0000000..27d9c70 --- /dev/null +++ b/backend/src/shared/openapi/routes/admin.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; +import { registry } from '../registry.js'; +import { createUserDto, reviewRequestDto, updateUserDto } from '../../../modules/admin/admin.dto.js'; + +const idParam = z.object({ id: z.string().uuid() }); + +registry.registerPath({ + method: 'get', path: '/admin/users', tags: ['Admin'], summary: 'Все пользователи (superadmin)', + responses: { 200: { description: 'Массив пользователей' }, 403: { description: 'Только superadmin' } }, +}); + +registry.registerPath({ + method: 'post', path: '/admin/users', tags: ['Admin'], summary: 'Создать пользователя (superadmin)', + request: { body: { content: { 'application/json': { schema: createUserDto } } } }, + responses: { 201: { description: 'Создан' }, 403: { description: 'Только superadmin' } }, +}); + +registry.registerPath({ + method: 'patch', path: '/admin/users/{id}', tags: ['Admin'], summary: 'Обновить пользователя (superadmin)', + request: { params: idParam, body: { content: { 'application/json': { schema: updateUserDto } } } }, + responses: { 200: { description: 'Обновлён' } }, +}); + +registry.registerPath({ + method: 'get', path: '/admin/users/{id}/stats', tags: ['Admin'], summary: 'Статистика пользователя', + request: { params: idParam }, + responses: { 200: { description: 'Статистика: задачи, воркспейсы, активность' } }, +}); + +registry.registerPath({ + method: 'get', path: '/admin/registration-requests', tags: ['Admin'], summary: 'Заявки на регистрацию', + responses: { 200: { description: 'Массив заявок PENDING' } }, +}); + +registry.registerPath({ + method: 'patch', path: '/admin/registration-requests/{id}', tags: ['Admin'], summary: 'Одобрить / отклонить заявку', + request: { params: idParam, body: { content: { 'application/json': { schema: reviewRequestDto } } } }, + responses: { 200: { description: 'Заявка обработана' } }, +}); diff --git a/backend/src/shared/openapi/routes/auth.ts b/backend/src/shared/openapi/routes/auth.ts new file mode 100644 index 0000000..e6d44ff --- /dev/null +++ b/backend/src/shared/openapi/routes/auth.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; +import { registry } from '../registry.js'; +import { + loginDto, + registerDto, + updateProfileDto, + changePasswordDto, + forgotPasswordDto, + resetPasswordDto, +} from '../../../modules/auth/auth.dto.js'; + +const tokenResponse = z.object({ + accessToken: z.string(), + user: z.object({ id: z.string(), email: z.string(), name: z.string(), isSuperadmin: z.boolean() }), +}); + +registry.registerPath({ + method: 'post', path: '/auth/login', tags: ['Auth'], summary: 'Вход по email + пароль', + request: { body: { content: { 'application/json': { schema: loginDto } } } }, + responses: { 200: { description: 'OK + refresh cookie', content: { 'application/json': { schema: tokenResponse } } }, 401: { description: 'Неверные данные' }, 429: { description: 'Brute-force lockout' } }, +}); + +registry.registerPath({ + method: 'post', path: '/auth/register', tags: ['Auth'], summary: 'Создать заявку на регистрацию', + request: { body: { content: { 'application/json': { schema: registerDto } } } }, + responses: { 201: { description: 'Заявка создана, ожидает одобрения' }, 409: { description: 'Email уже существует' } }, +}); + +registry.registerPath({ + method: 'post', path: '/auth/refresh', tags: ['Auth'], summary: 'Обновить access token', + responses: { 200: { description: 'Новый accessToken', content: { 'application/json': { schema: tokenResponse } } }, 401: { description: 'Refresh token невалиден' } }, +}); + +registry.registerPath({ + method: 'post', path: '/auth/logout', tags: ['Auth'], summary: 'Выход', + responses: { 204: { description: 'Сессия завершена' } }, +}); + +registry.registerPath({ + method: 'get', path: '/auth/me', tags: ['Auth'], summary: 'Текущий пользователь', + responses: { 200: { description: 'Профиль пользователя' }, 401: { description: 'Не авторизован' } }, +}); + +registry.registerPath({ + method: 'patch', path: '/auth/me', tags: ['Auth'], summary: 'Обновить профиль', + request: { body: { content: { 'application/json': { schema: updateProfileDto } } } }, + responses: { 200: { description: 'Обновлённый профиль' }, 400: { description: 'Ошибка валидации' } }, +}); + +registry.registerPath({ + method: 'post', path: '/auth/change-password', tags: ['Auth'], summary: 'Сменить пароль', + request: { body: { content: { 'application/json': { schema: changePasswordDto } } } }, + responses: { 204: { description: 'Пароль изменён' }, 400: { description: 'Неверный текущий пароль' } }, +}); + +registry.registerPath({ + method: 'post', path: '/auth/forgot-password', tags: ['Auth'], summary: 'Запросить сброс пароля', + request: { body: { content: { 'application/json': { schema: forgotPasswordDto } } } }, + responses: { 204: { description: 'Письмо отправлено (если email существует)' } }, +}); + +registry.registerPath({ + method: 'post', path: '/auth/reset-password', tags: ['Auth'], summary: 'Сбросить пароль по токену', + request: { body: { content: { 'application/json': { schema: resetPasswordDto } } } }, + responses: { 204: { description: 'Пароль сброшен' }, 400: { description: 'Токен невалиден или истёк' } }, +}); diff --git a/backend/src/shared/openapi/routes/boards.ts b/backend/src/shared/openapi/routes/boards.ts new file mode 100644 index 0000000..c2864e0 --- /dev/null +++ b/backend/src/shared/openapi/routes/boards.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; +import { registry } from '../registry.js'; +import { createBoardDto, updateBoardDto } from '../../../modules/boards/boards.dto.js'; + +const widParam = z.object({ wid: z.string().uuid() }); +const idParam = z.object({ id: z.string().uuid() }); + +registry.registerPath({ + method: 'get', path: '/workspaces/{wid}/boards', tags: ['Boards'], summary: 'Доски воркспейса', + request: { params: widParam }, + responses: { 200: { description: 'Массив досок' } }, +}); + +registry.registerPath({ + method: 'post', path: '/workspaces/{wid}/boards', tags: ['Boards'], summary: 'Создать доску', + request: { params: widParam, body: { content: { 'application/json': { schema: createBoardDto } } } }, + responses: { 201: { description: 'Создана' }, 409: { description: 'Prefix занят' } }, +}); + +registry.registerPath({ + method: 'get', path: '/boards/{id}', tags: ['Boards'], summary: 'Получить доску по ID или prefix', + request: { params: idParam }, + responses: { 200: { description: 'Доска с workflow и статусами' }, 404: { description: 'Не найдена' } }, +}); + +registry.registerPath({ + method: 'patch', path: '/boards/{id}', tags: ['Boards'], summary: 'Обновить доску', + request: { params: idParam, body: { content: { 'application/json': { schema: updateBoardDto } } } }, + responses: { 200: { description: 'Обновлена' } }, +}); + +registry.registerPath({ + method: 'delete', path: '/boards/{id}', tags: ['Boards'], summary: 'Удалить доску', + request: { params: idParam }, + responses: { 204: { description: 'Удалена' } }, +}); diff --git a/backend/src/shared/openapi/routes/comments.ts b/backend/src/shared/openapi/routes/comments.ts new file mode 100644 index 0000000..7aab8ce --- /dev/null +++ b/backend/src/shared/openapi/routes/comments.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; +import { registry } from '../registry.js'; +import { createCommentDto, updateCommentDto } from '../../../modules/comments/comments.dto.js'; + +const tidParam = z.object({ tid: z.string().uuid() }); +const idParam = z.object({ id: z.string().uuid() }); + +registry.registerPath({ + method: 'get', path: '/tasks/{tid}/comments', tags: ['Comments'], summary: 'Комментарии к задаче', + request: { params: tidParam }, + responses: { 200: { description: 'Массив комментариев с пагинацией' } }, +}); + +registry.registerPath({ + method: 'post', path: '/tasks/{tid}/comments', tags: ['Comments'], summary: 'Добавить комментарий', + request: { params: tidParam, body: { content: { 'application/json': { schema: createCommentDto } } } }, + responses: { 201: { description: 'Создан' } }, +}); + +registry.registerPath({ + method: 'patch', path: '/comments/{id}', tags: ['Comments'], summary: 'Обновить комментарий', + request: { params: idParam, body: { content: { 'application/json': { schema: updateCommentDto } } } }, + responses: { 200: { description: 'Обновлён' }, 403: { description: 'Не автор' } }, +}); + +registry.registerPath({ + method: 'delete', path: '/comments/{id}', tags: ['Comments'], summary: 'Удалить комментарий', + request: { params: idParam }, + responses: { 204: { description: 'Удалён' } }, +}); diff --git a/backend/src/shared/openapi/routes/labels.ts b/backend/src/shared/openapi/routes/labels.ts new file mode 100644 index 0000000..9299688 --- /dev/null +++ b/backend/src/shared/openapi/routes/labels.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; +import { registry } from '../registry.js'; +import { createLabelDto } from '../../../modules/labels/labels.dto.js'; + +const widParam = z.object({ wid: z.string().uuid() }); +const tidLabelParam = z.object({ tid: z.string().uuid(), labelId: z.string().uuid() }); + +registry.registerPath({ + method: 'get', path: '/workspaces/{wid}/labels', tags: ['Labels'], summary: 'Метки воркспейса', + request: { params: widParam }, + responses: { 200: { description: 'Массив меток' } }, +}); + +registry.registerPath({ + method: 'post', path: '/workspaces/{wid}/labels', tags: ['Labels'], summary: 'Создать метку', + request: { params: widParam, body: { content: { 'application/json': { schema: createLabelDto } } } }, + responses: { 201: { description: 'Создана' } }, +}); + +registry.registerPath({ + method: 'post', path: '/tasks/{tid}/labels/{labelId}', tags: ['Labels'], summary: 'Добавить метку к задаче', + request: { params: tidLabelParam }, + responses: { 200: { description: 'Добавлена' }, 403: { description: 'Метка не из этого воркспейса' } }, +}); + +registry.registerPath({ + method: 'delete', path: '/tasks/{tid}/labels/{labelId}', tags: ['Labels'], summary: 'Убрать метку с задачи', + request: { params: tidLabelParam }, + responses: { 204: { description: 'Убрана' } }, +}); diff --git a/backend/src/shared/openapi/routes/notifications.ts b/backend/src/shared/openapi/routes/notifications.ts new file mode 100644 index 0000000..328f639 --- /dev/null +++ b/backend/src/shared/openapi/routes/notifications.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; +import { registry } from '../registry.js'; + +const idParam = z.object({ id: z.string().uuid() }); + +registry.registerPath({ + method: 'get', path: '/notifications', tags: ['Notifications'], summary: 'Уведомления текущего пользователя', + responses: { 200: { description: 'Массив уведомлений (непрочитанные первыми)' }, 401: { description: 'Не авторизован' } }, +}); + +registry.registerPath({ + method: 'patch', path: '/notifications/{id}/read', tags: ['Notifications'], summary: 'Пометить прочитанным', + request: { params: idParam }, + responses: { 200: { description: 'Помечено' } }, +}); + +registry.registerPath({ + method: 'post', path: '/notifications/read-all', tags: ['Notifications'], summary: 'Пометить все прочитанными', + responses: { 200: { description: 'Все помечены' } }, +}); diff --git a/backend/src/shared/openapi/routes/search.ts b/backend/src/shared/openapi/routes/search.ts new file mode 100644 index 0000000..73d5422 --- /dev/null +++ b/backend/src/shared/openapi/routes/search.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { registry } from '../registry.js'; + +const searchQuery = z.object({ + q: z.string().min(2).max(200).describe('Поисковый запрос, минимум 2 символа'), + limit: z.string().optional().describe('Максимум результатов, default: 5, max: 10'), +}); + +registry.registerPath({ + method: 'get', path: '/search', tags: ['Search'], summary: 'Глобальный поиск задач (Cmd+K)', + request: { query: searchQuery }, + responses: { + 200: { description: 'Массив задач (до 10)' }, + 429: { description: 'Rate limit: 30 req/min' }, + }, +}); diff --git a/backend/src/shared/openapi/routes/tasks.ts b/backend/src/shared/openapi/routes/tasks.ts new file mode 100644 index 0000000..cb506c2 --- /dev/null +++ b/backend/src/shared/openapi/routes/tasks.ts @@ -0,0 +1,106 @@ +import { z } from 'zod'; +import { registry } from '../registry.js'; +import { + createTaskDto, + updateTaskDto, + bulkUpdateDto, + bulkDeleteDto, + reorderTasksDto, + myTasksFiltersDto, +} from '../../../modules/tasks/tasks.dto.js'; + +const bidParam = z.object({ bid: z.string().uuid() }); +const idParam = z.object({ id: z.string().uuid() }); + +// Simplified query schema without coerce/transform for OpenAPI compatibility +const taskFiltersQuery = z.object({ + statusId: z.string().uuid().optional(), + assigneeId: z.string().uuid().optional(), + priority: z.enum(['HIGH', 'MEDIUM', 'LOW']).optional(), + labelId: z.string().uuid().optional(), + search: z.string().max(200).optional(), + duePreset: z.enum(['today', 'this_week', 'next_week', 'overdue', 'no_date']).optional(), + limit: z.string().optional().describe('Default: 100, max: 500'), + offset: z.string().optional().describe('Default: 0'), +}); + +const myTasksQuery = z.object({ + priority: z.enum(['HIGH', 'MEDIUM', 'LOW']).optional(), + duePreset: z.enum(['today', 'this_week', 'next_week', 'overdue', 'no_date']).optional(), + search: z.string().max(200).optional(), + workspaceId: z.string().uuid().optional(), + limit: z.string().optional(), + offset: z.string().optional(), +}); + +registry.registerPath({ + method: 'get', path: '/boards/{bid}/tasks', tags: ['Tasks'], summary: 'Задачи доски (с серверными фильтрами)', + request: { params: bidParam, query: taskFiltersQuery }, + responses: { 200: { description: 'Массив задач' } }, +}); + +registry.registerPath({ + method: 'post', path: '/boards/{bid}/tasks', tags: ['Tasks'], summary: 'Создать задачу', + request: { params: bidParam, body: { content: { 'application/json': { schema: createTaskDto } } } }, + responses: { 201: { description: 'Создана' }, 400: { description: 'Ошибка валидации' } }, +}); + +registry.registerPath({ + method: 'patch', path: '/boards/{bid}/tasks/reorder', tags: ['Tasks'], summary: 'Переупорядочить задачи (DnD)', + request: { params: bidParam, body: { content: { 'application/json': { schema: reorderTasksDto } } } }, + responses: { 200: { description: 'OK' } }, +}); + +registry.registerPath({ + method: 'patch', path: '/boards/{bid}/tasks/bulk', tags: ['Tasks'], summary: 'Массовое обновление (до 100)', + request: { params: bidParam, body: { content: { 'application/json': { schema: bulkUpdateDto } } } }, + responses: { 200: { description: 'Обновлены' } }, +}); + +registry.registerPath({ + method: 'post', path: '/boards/{bid}/tasks/bulk-delete', tags: ['Tasks'], summary: 'Массовое удаление (до 100)', + request: { params: bidParam, body: { content: { 'application/json': { schema: bulkDeleteDto } } } }, + responses: { 200: { description: 'Удалены' } }, +}); + +registry.registerPath({ + method: 'get', path: '/tasks/{id}', tags: ['Tasks'], summary: 'Получить задачу', + request: { params: idParam }, + responses: { 200: { description: 'Задача' }, 404: { description: 'Не найдена' } }, +}); + +registry.registerPath({ + method: 'patch', path: '/tasks/{id}', tags: ['Tasks'], summary: 'Обновить задачу', + request: { params: idParam, body: { content: { 'application/json': { schema: updateTaskDto } } } }, + responses: { 200: { description: 'Обновлена' } }, +}); + +registry.registerPath({ + method: 'patch', path: '/tasks/{id}/move', tags: ['Tasks'], summary: 'Переместить в другой статус', + request: { params: idParam }, + responses: { 200: { description: 'Перемещена' } }, +}); + +registry.registerPath({ + method: 'delete', path: '/tasks/{id}', tags: ['Tasks'], summary: 'Удалить задачу', + request: { params: idParam }, + responses: { 204: { description: 'Удалена' } }, +}); + +registry.registerPath({ + method: 'get', path: '/tasks/{id}/subtasks', tags: ['Tasks'], summary: 'Подзадачи (до 5 уровней)', + request: { params: idParam }, + responses: { 200: { description: 'Массив подзадач' } }, +}); + +registry.registerPath({ + method: 'get', path: '/tasks/{id}/history', tags: ['Tasks'], summary: 'История изменений задачи', + request: { params: idParam }, + responses: { 200: { description: 'Массив событий' } }, +}); + +registry.registerPath({ + method: 'get', path: '/my-tasks', tags: ['Tasks'], summary: 'Мои задачи', + request: { query: myTasksQuery }, + responses: { 200: { description: 'Массив задач' } }, +}); diff --git a/backend/src/shared/openapi/routes/workspaces.ts b/backend/src/shared/openapi/routes/workspaces.ts new file mode 100644 index 0000000..70d7a90 --- /dev/null +++ b/backend/src/shared/openapi/routes/workspaces.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; +import { registry } from '../registry.js'; +import { + createWorkspaceDto, + updateWorkspaceDto, + updateMemberRoleDto, + inviteByEmailDto, +} from '../../../modules/workspaces/workspaces.dto.js'; + +const idParam = z.object({ id: z.string().uuid() }); +const memberParam = z.object({ id: z.string().uuid(), userId: z.string().uuid() }); + +registry.registerPath({ + method: 'get', path: '/workspaces', tags: ['Workspaces'], summary: 'Список рабочих пространств пользователя', + responses: { 200: { description: 'Массив воркспейсов' }, 401: { description: 'Не авторизован' } }, +}); + +registry.registerPath({ + method: 'post', path: '/workspaces', tags: ['Workspaces'], summary: 'Создать воркспейс', + request: { body: { content: { 'application/json': { schema: createWorkspaceDto } } } }, + responses: { 201: { description: 'Создан' }, 400: { description: 'Ошибка валидации' }, 409: { description: 'Slug занят' } }, +}); + +registry.registerPath({ + method: 'get', path: '/workspaces/{id}', tags: ['Workspaces'], summary: 'Получить воркспейс', + request: { params: idParam }, + responses: { 200: { description: 'Воркспейс' }, 404: { description: 'Не найден' } }, +}); + +registry.registerPath({ + method: 'patch', path: '/workspaces/{id}', tags: ['Workspaces'], summary: 'Обновить воркспейс', + request: { params: idParam, body: { content: { 'application/json': { schema: updateWorkspaceDto } } } }, + responses: { 200: { description: 'Обновлён' }, 403: { description: 'Нет прав' } }, +}); + +registry.registerPath({ + method: 'delete', path: '/workspaces/{id}', tags: ['Workspaces'], summary: 'Удалить воркспейс', + request: { params: idParam }, + responses: { 204: { description: 'Удалён' }, 403: { description: 'Только Owner' } }, +}); + +registry.registerPath({ + method: 'get', path: '/workspaces/{id}/members', tags: ['Workspaces'], summary: 'Участники воркспейса', + request: { params: idParam }, + responses: { 200: { description: 'Массив участников с ролями' } }, +}); + +registry.registerPath({ + method: 'post', path: '/workspaces/{id}/invite', tags: ['Workspaces'], summary: 'Пригласить участника по email', + request: { params: idParam, body: { content: { 'application/json': { schema: inviteByEmailDto } } } }, + responses: { 200: { description: 'Участник добавлен' }, 404: { description: 'Пользователь не найден' } }, +}); + +registry.registerPath({ + method: 'patch', path: '/workspaces/{id}/members/{userId}', tags: ['Workspaces'], summary: 'Изменить роль участника', + request: { params: memberParam, body: { content: { 'application/json': { schema: updateMemberRoleDto } } } }, + responses: { 200: { description: 'Роль обновлена' }, 403: { description: 'Только Owner' } }, +}); + +registry.registerPath({ + method: 'delete', path: '/workspaces/{id}/members/{userId}', tags: ['Workspaces'], summary: 'Удалить участника', + request: { params: memberParam }, + responses: { 204: { description: 'Удалён' } }, +}); diff --git a/docs/claude-patterns/dev-conventions.md b/docs/claude-patterns/dev-conventions.md new file mode 100644 index 0000000..171fda5 --- /dev/null +++ b/docs/claude-patterns/dev-conventions.md @@ -0,0 +1,110 @@ +# Паттерн: Конвенции разработки FlowTask + +Единые правила для всего проекта. Эти конвенции применяются автоматически — не нужно напоминать. + +## TypeScript + +### Типизация +- **Никогда `any`**: объекты → `Record`, ошибки → `unknown` с `instanceof` guard +- Неиспользуемые переменные — удалять. В деструктуризации массива — пропуск через `,` +- Enum из Prisma — использовать сгенерированные типы, не дублировать вручную +- Возвращаемые типы функций сервисов — всегда явные (`Promise`, не `Promise`) + +### ESLint +- `eslint-disable` только блочный `/* eslint-disable rule */`, не строчный `// eslint-disable-line` +- Исключения документировать комментарием — почему отключено + +--- + +## Git + +### Коммиты +- `feat:` — новая функциональность +- `fix:` — исправление бага +- `refactor:` — рефакторинг без изменения поведения +- `chore:` — зависимости, конфиг, CI +- `docs:` — документация +- `test:` — тесты + +### Ветки +- `claude/jack-{slug}` — jackrescuer-gif через Claude Code +- `claude/alex-{slug}` — St1tcher86 через Claude Code +- `cursor/jack-{slug}` — jackrescuer-gif через Cursor +- `cursor/alex-{slug}` — St1tcher86 через Cursor + +--- + +## API + +### Обработка ошибок +```typescript +// ВСЕГДА asyncHandler или authHandler — никогда голый async +router.get('/', asyncHandler(async (req, res) => { ... })); +router.post('/', validate(dto), authHandler(async (req, res) => { ... })); + +// Структура ошибки — единый формат +res.status(400).json({ error: 'Сообщение', details: [...] }); +res.status(404).json({ error: 'Не найдено' }); +res.status(403).json({ error: 'Нет прав' }); +``` + +### Валидация +- Все входные данные через `validate(dto)` middleware — никогда вручную в сервисе +- Источник: `body` (default), `params`, `query` +- DTO файл: `{module}.dto.ts` — рядом с роутером + +### RBAC +- Каждый роут защищён `authenticate` + проверкой принадлежности к workspace +- Superadmin операции: `requireSuperadmin` middleware +- Workspace isolation: проверять `workspaceId` через `WorkspaceMember` перед любым действием над данными + +### OpenAPI +- Каждый новый DTO — через `registry.register()`, не голый `z.object()` +- Каждый новый путь — зарегистрировать в `shared/openapi/routes/{module}.ts` +- `/api/docs` — живая документация, обновляется автоматически + +--- + +## Prisma + +### Миграции +- После любого изменения `schema.prisma` — ОБЯЗАТЕЛЬНО создать миграцию (`prisma migrate dev`) +- Никогда `prisma db push` в разработке — только через миграции +- Миграции только аддитивные: добавлять поля/таблицы можно, переименовывать/удалять — с backfill + +### Nullable +- Поле `Type?` в Prisma → `.nullable().optional()` в Zod (обязательно оба) +- После изменения схемы — запустить `/audit-schemas` для проверки всех DTO + +### Queries +- `select` вместо полного объекта там где возможно — не тащить лишние поля +- N+1 — использовать `include` или отдельный batch-запрос + +--- + +## BDD / SDD + +### Когда писать spec +Нетривиальная задача (>3 файлов или новая сущность) → сначала spec в `specs/`, потом код. + +### Формат BDD +Gherkin в секции `## BDD Scenarios` файла spec. Три обязательных сценария: +- Happy path (основной поток) +- Edge case (граничное условие) +- Негативный (ошибка или недостаточно прав) + +### Формат SDD +TypeScript интерфейсы + API shape в секции `## SDD Contracts`. Минимально необходимое для реализации — не весь DTO, только контракт. + +### Ссылки +Design-doc (архитектура) → ссылка на spec (поведение), и обратно. +Обновить `specs/README.md` при добавлении нового spec. + +--- + +## Безопасность + +- Никаких секретов в коде — только через переменные окружения +- PII (email, имя) — не логировать в plaintext, только с маскировкой +- Деструктивные операции (delete, bulk) — логировать в `AuditLog` +- Rate limiting — на всех публичных и потенциально дорогих эндпоинтах diff --git a/docs/claude-patterns/feature-development.md b/docs/claude-patterns/feature-development.md new file mode 100644 index 0000000..0054b08 --- /dev/null +++ b/docs/claude-patterns/feature-development.md @@ -0,0 +1,115 @@ +# Паттерн: Разработка фичи от дизайна до релиза + +Полный цикл для нетривиальной задачи (>3 файлов или новая сущность/паттерн). + +## Этапы + +### 0. Impact analysis (обязательно перед любым изменением) + +Перед тем как трогать существующий код: +``` +gitnexus_impact({ target: "{символ}", direction: "upstream" }) +``` +Сообщить blast radius. Если HIGH или CRITICAL — предупредить и получить подтверждение. + +--- + +### 1. Spec-first — `/design-doc` + +**Когда:** задача затрагивает >3 файлов или вводит новую сущность/паттерн. + +Создать два артефакта ДО кода: + +**`docs/design/{slug}.md`** — архитектурное решение: +- Цель (одно предложение) +- Инвентаризация данных (все места чтения/записи) +- Карта компонентов (кто потребляет) +- Модель данных (Prisma changes) +- API контракт (эндпоинты и DTO shape) +- Стыки (какие существующие файлы менять) +- Риски (gitnexus_impact на ключевые символы) + +**`specs/gaps/` или `specs/existing/`** — поведение: +- BDD Scenarios (Gherkin: happy path + edge cases + ошибки) +- SDD Contracts (TypeScript интерфейсы и API shape) +- Acceptance Criteria (проверяемые условия) + +Не начинать реализацию без этих артефактов. + +--- + +### 2. Модель данных + +Если задача требует изменений в БД: +``` +schema.prisma → миграция → prisma generate → перезапуск dev +``` +- Nullable поля: `Type?` в Prisma = `.nullable().optional()` в Zod +- Enum: добавить в Prisma + Zod + проверить все места использования +- После любого изменения схемы — запустить `/audit-schemas` + +--- + +### 3. API — `/new-api` + +Порядок: **Zod DTO → OpenAPI регистрация → router → service → тест** + +Никогда не создавать роут без: +- Zod DTO зарегистрированного через `registry.register()` +- OpenAPI пути в `shared/openapi/routes/{module}.ts` +- Supertest-теста (минимум: 201/200, 400, 401, 403, 404) + +--- + +### 4. UI + +Порядок: **API-клиент → компонент → интеграция** + +- Обновить или создать функцию в `frontend/src/api/` +- Компонент в `frontend/src/components/` или страница в `frontend/src/pages/` +- Проверить тёмную и светлую тему +- Проверить мобильный breakpoint (mobile / tablet / desktop) + +--- + +### 5. Три ревью — последовательно + +После написания кода, до коммита: + +**1. code-reviewer** — качество кода: +- TypeScript строгость, отсутствие `any` +- Паттерны проекта (asyncHandler, authHandler, validate middleware) +- Размер функций (<50 строк), размер файлов (<800 строк) +- Иммутабельность, обработка ошибок + +**2. security-reviewer** — безопасность: +- RBAC: все эндпоинты защищены, проверка workspace isolation +- Валидация: все входные данные валидируются через Zod +- IDOR: нет прямого доступа по ID без проверки принадлежности к workspace +- Audit log: деструктивные операции логируются в AuditLog +- Нет hardcoded секретов, нет утечки PII в ответах + +**3. UX/UI-reviewer** — пользовательский опыт: +- Состояния loading / empty / error обработаны в UI +- Feedback пользователю на успех и ошибку (toast/notification) +- Доступность (ARIA, keyboard navigation для критичных действий) +- Соответствие дизайн-системе (Ant Design 5 + CSS переменные проекта) + +Исправить все находки перед переходом к следующему шагу. + +--- + +### 6. `/preflight` → коммит + +```bash +/preflight # tsc + lint + rbac-check + prisma validate + tests + gitnexus +``` +Только после зелёного preflight — коммит и push. + +--- + +## Сокращённый цикл (мелкие задачи) + +Для задач в 1-2 файлах без новых сущностей: +1. gitnexus_impact → code-reviewer → security-reviewer → /preflight → коммит +2. Spec и design-doc не обязательны, но добавить acceptance criteria в PR description