From 223feed0e0d8a3f16c01968e1f48cad0aac8f108 Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 6 Mar 2026 13:58:34 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B1=D1=8D=D0=BA?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B0=20=D1=81=20=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D0=B5=D0=B9=20(AGENT?= =?UTF-8?q?=5FTASK=5FBACKEND=5FSYNC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добвлен @fastify/cookie и настройку httpOnly cookie для refresh token - Добавлен префикс /api/v1 для auth, profile, tests, admin - Скорректировано в Login: возвращать user (id, nickname, avatarUrl, role, emailVerified), ставить refreshToken в Set-Cookie - Скорректировано в Logout: Bearer + cookie, пустое тело, 200 + { message }, очищать cookie - Скорректировано в Refresh: token из cookie, пустое тело, 200 + { accessToken }, Set-Cookie - Добавлено в getPrivateProfile: поля role и plan - Скорректировано в Tests: score = количество правильных, ответ { score, totalQuestions, percentage } - Добавлено в question_cache_meta: поля valid, retryCount, questionsGenerated - Обновлены тесты --- ...окументация_vs_реализация_0932d1a0.plan.md | 366 +++++ AGENT_TASK_BACKEND_SYNC.md | 221 +++ AGENT_TASK_DOCS_SYNC.md | 157 +++ package-lock.json | 37 + package.json | 1 + src/app.ts | 13 +- .../migrations/0001_fluffy_yellowjacket.sql | 3 + src/db/migrations/meta/0001_snapshot.json | 1252 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/schema/questionCacheMeta.ts | 5 +- src/routes/auth.ts | 57 +- src/routes/profile.ts | 3 +- src/services/auth/auth.service.ts | 36 +- src/services/llm/llm.service.ts | 16 +- src/services/questions/question.service.ts | 3 + src/services/tests/tests.service.ts | 11 +- src/services/user/user.service.ts | 33 +- tests/helpers/build-test-app.ts | 14 +- tests/integration/auth.routes.test.ts | 60 +- tests/services/auth.service.test.ts | 9 +- tests/services/tests.service.test.ts | 2 +- 21 files changed, 2244 insertions(+), 62 deletions(-) create mode 100644 .cursor/plans/документация_vs_реализация_0932d1a0.plan.md create mode 100644 AGENT_TASK_BACKEND_SYNC.md create mode 100644 AGENT_TASK_DOCS_SYNC.md create mode 100644 src/db/migrations/0001_fluffy_yellowjacket.sql create mode 100644 src/db/migrations/meta/0001_snapshot.json diff --git a/.cursor/plans/документация_vs_реализация_0932d1a0.plan.md b/.cursor/plans/документация_vs_реализация_0932d1a0.plan.md new file mode 100644 index 0000000..90ee6c6 --- /dev/null +++ b/.cursor/plans/документация_vs_реализация_0932d1a0.plan.md @@ -0,0 +1,366 @@ +--- +name: Документация vs Реализация +overview: "Подробный отчёт о соответствии бэкенда документации samreshu_docs: выявлены расхождения в API контрактах, схеме БД, аутентификации, тестах и других компонентах." +todos: [] +isProject: false +--- + +# Отчёт: Соответствие бэкенда документации Samreshu + +## 1. Базовый URL и структура API + +**Документация** ([contracts.md](samreshu_docs/api/contracts.md)): + +- Базовый URL: `/api/v1` +- Все эндпоинты: `/api/v1/auth/`*, `/api/v1/profile/`*, `/api/v1/tests/*`, `/api/v1/admin/*` + +**Реализация** ([app.ts](src/app.ts)): + +- Префикс `/api/v1` отсутствует. Маршруты зарегистрированы как `/auth`, `/profile`, `/tests`, `/admin` +- Фактические URL: `/auth/`*, `/profile/`*, `/tests/*`, `/admin/*` + +**Вывод:** Критическое расхождение. Клиент, следующий документации, будет отправлять запросы на несуществующие эндпоинты. + +--- + +## 2. Аутентификация (Auth) + +### 2.1 POST /auth/register + + +| Аспект | Документация | Реализация | +| -------------- | ------------------------------------------------- | --------------------------------------------------------------------- | +| Response 201 | `{ user, accessToken }` + Set-Cookie refreshToken | `{ userId, message, verificationCode }` | +| Логика | Сразу выдаёт токены, отправляет письмо | Требует верификацию email; не выдаёт токены; возвращает код (для dev) | +| NICKNAME_TAKEN | — | Есть в коде; в документации не описан | + + +**Вывод:** Разный flow. Документация описывает выдачу токенов сразу после регистрации; реализация ожидает верификацию email. + +### 2.2 POST /auth/login + + +| Аспект | Документация | Реализация | +| ----------------------- | ------------------------------------ | ------------------------------------------------------- | +| Response | `{ user, accessToken }` + Set-Cookie | `{ accessToken, refreshToken, expiresIn }` — без `user` | +| Lockout при brute force | 403 `ACCOUNT_LOCKED` | 429 `RATE_LIMIT_EXCEEDED` | +| Ошибка неверных данных | 401 `INVALID_CREDENTIALS` | 401 `UNAUTHORIZED` (общий код) | + + +**Вывод:** Расхождения в формате ответа, коде ошибки и семантике блокировки (403 vs 429). + +### 2.3 POST /auth/logout + + +| Аспект | Документация | Реализация | +| ------------ | ------------------------ | -------------------------- | +| Авторизация | Bearer token | refreshToken в теле | +| Request body | Пустое | `{ refreshToken: string }` | +| Response | 200 `{ message: "..." }` | 204 (без тела) | + + +**Вывод:** Другой механизм авторизации и формат ответа. + +### 2.4 POST /auth/refresh + + +| Аспект | Документация | Реализация | +| ------------ | --------------- | -------------------------- | +| Токен | httpOnly cookie | `{ refreshToken }` в body | +| Request body | Пустое | `{ refreshToken: string }` | + + +**Вывод:** Документация предполагает cookie; реализация использует body. + +### 2.5 POST /auth/verify-email + + +| Аспект | Документация | Реализация | +| ----------- | ------------ | ------------------ | +| Request | `{ code }` | `{ userId, code }` | +| Авторизация | Bearer token | Не требуется | + + +**Вывод:** Документация — только `code` с Bearer; реализация — `userId` и `code` без Bearer. + +### 2.6 POST /auth/reset-password + + +| Аспект | Документация | Реализация | +| ----------- | ------------ | ------------- | +| Поле пароля | `password` | `newPassword` | + + +**Вывод:** Разные имена полей в теле запроса. + +### 2.7 Set-Cookie для refresh token + +**Документация:** RefreshToken в httpOnly cookie (`Path=/api/v1/auth`, `Max-Age=604800`). + +**Реализация:** Cookie не устанавливаются; refresh token возвращается только в JSON. + +--- + +## 3. Profile + +### 3.1 GET /profile + +**Документация:** Ответ включает `role`, `plan` (из subscriptions). + +**Реализация** ([user.service.ts](src/services/user/user.service.ts)): `getPrivateProfile` возвращает `id`, `nickname`, `avatarUrl`, `country`, `city`, `selfLevel`, `isPublic`, `email`, `emailVerifiedAt`, `createdAt`, `updatedAt`, `stats`. Поля `role` и `plan` отсутствуют. + +**Вывод:** В ответе нет полей, указанных в документации. + +### 3.2 GET /profile/:username + +**Документация:** `stats: { testsCompleted, averageScore }`. + +**Реализация:** `stats: { byStack, totalTestsTaken, totalQuestions, correctAnswers, accuracy }` — другая структура. + +**Вывод:** Формат статистики не совпадает. + +### 3.3 PATCH /profile + +**Документация:** Поле `avatarUrl` не описано. + +**Реализация:** Поддерживается `avatarUrl`. + +**Вывод:** Документация неполная (расхождение в пользу реализации). + +--- + +## 4. Tests + +### 4.1 POST /tests (создание) + + +| Аспект | Документация | Реализация | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------- | +| questionCount | enum: 10 / 20 | integer 1–50 | +| stack/level (MVP 0) | html, css / basic, beginner | Все значения enum | +| Response структура | `{ id, stack, level, questionCount, status, currentQuestion, startedAt, timeLimitSeconds, question: {...} }` — только текущий вопрос | `{ ...test, questions: [...] }` — все вопросы | + + +**Вывод:** Разная структура ответа и валидация параметров. + +### 4.2 POST /tests/:id/answer + +**Документация:** `{ answered: {...}, progress: {...}, nextQuestion: {...} }`. + +**Реализация:** Возвращает полный `TestSnapshot` отвеченного вопроса, без `progress` и `nextQuestion`. + +**Вывод:** Формат ответа не совпадает с документацией. + +### 4.3 POST /tests/:id/finish + + +| Аспект | Документация | Реализация | +| -------------- | --------------------------------------- | ----------------------- | +| score | Количество правильных ответов (8 из 10) | Процент (0–100) | +| totalQuestions | В ответе | Нет в ответе | +| percentage | В ответе | Нет (score как процент) | + + +**Реализация** ([tests.service.ts](src/services/tests/tests.service.ts) 239–241): + +```ts +const score = Math.round((correctCount / questions.length) * 100); +``` + +**Вывод:** Критическое расхождение: `score` в документации — количество, в реализации — процент. + +### 4.4 GET /tests/:id/results + +**Документация:** Детальные результаты с `questions`, `userAnswer`, `correctAnswer`, `isCorrect`, `explanation`. + +**Реализация:** Эндпоинт отсутствует. + +**Вывод:** Критическое расхождение: описанный эндпоинт не реализован. + +### 4.5 GET /tests/history + + +| Аспект | Документация | Реализация | +| ------------- | ------------------------------------------------------ | ------------------------------ | +| Путь | GET /tests/history | GET /tests (то же для истории) | +| Пагинация | cursor-based (limit, cursor) | offset-based (limit, offset) | +| Формат ответа | `{ data: [...], pagination: { nextCursor, hasMore } }` | `{ tests: [...], total }` | +| Параметры | stack, status фильтры | Нет фильтров | + + +**Вывод:** Другая схема пагинации и структура ответа. + +### 4.6 Параметр теста + +**Документация:** `:id`. + +**Реализация:** `:testId` в params. + +**Вывод:** Незначительное расхождение в именовании. + +--- + +## 5. Admin + +### 5.1 Список вопросов + + +| Аспект | Документация | Реализация | +| -------------- | ----------------------------- | ---------------------------- | +| Эндпоинт | GET /admin/questions/queue | GET /admin/questions/pending | +| Пагинация | cursor (limit, cursor) | offset (limit, offset) | +| status в query | pending / approved / rejected | — | + + +### 5.2 Редактирование + +**Документация:** PATCH /admin/questions/:id с `status: approved | rejected` и полями для правок. + +**Реализация:** + +- POST /admin/questions/:questionId/approve +- POST /admin/questions/:questionId/reject +- PATCH /admin/questions/:questionId — для редактирования + +**Вывод:** Другая схема: отдельные approve/reject вместо смены статуса через PATCH. + +--- + +## 6. База данных + +### 6.1 Таблицы + +**Документация** ([schema.md](samreshu_docs/database/schema.md)): `verification_tokens` (обобщённо). + +**Реализация:** Отдельные таблицы `email_verification_codes` и `password_reset_tokens` (в [verificationTokens.ts](src/db/schema/verificationTokens.ts)). + +**Вывод:** Расхождение в модели хранения токенов. + +### 6.2 Отсутствующие таблицы (Phase 2+) + +В документации есть таблицы, которых нет в текущем коде: `oauth_accounts`, `totp_secrets`, `payments`, `payment_events`, `notifications_log`, `promo_codes`, `user_achievements`. Это ожидаемо для Phase 2+. + +--- + +## 7. Безопасность + +### 7.1 Argon2 + +**Документация:** argon2id, 19 MiB, 2 iterations. + +**Реализация** ([password.ts](src/utils/password.ts)): `memoryCost: 19456` (KiB), `timeCost: 2`. + +**Вывод:** Совпадение. + +### 7.2 JWT + +**Документация:** Access 15 мин, Refresh 7 дней, HS256. + +**Реализация:** `JWT_ACCESS_TTL=15m`, `JWT_REFRESH_TTL=7d`. + +**Вывод:** Совпадение. + +### 7.3 Login lockout + +**Документация:** 5/15 мин, 10/1 ч, 20/24 ч. + +**Реализация** ([loginLockout.ts](src/utils/loginLockout.ts)): Те же пороги (5, 10, 20). + +**Вывод:** Совпадение. + +### 7.4 Rate limits + +**Документация:** RATE_LIMIT_LOGIN, RATE_LIMIT_REGISTER, RATE_LIMIT_FORGOT_PASSWORD, RATE_LIMIT_VERIFY_EMAIL, RATE_LIMIT_API_AUTHED, RATE_LIMIT_API_GUEST. + +**Реализация** ([env.ts](src/config/env.ts)): RATE_LIMIT_LOGIN отсутствует (используется progressive lockout). Остальные переменные есть. + +**Вывод:** Незначительное расхождение; security.md упоминает RATE_LIMIT_LOGIN, но логика lockout иная. + +### 7.5 CORS + +**Документация:** `http://localhost:5173`, `https://samreshu.ru`, `credentials: true`, методы GET, POST, PATCH, DELETE. + +**Реализация:** Origins из `CORS_ORIGINS`, `credentials: true`, методы включают PUT и OPTIONS. + +**Вывод:** Реализация шире, расхождение несущественное. + +### 7.6 Helmet + +**Документация:** Полный набор заголовков, включая CSP. + +**Реализация:** `contentSecurityPolicy: false`, `crossOriginEmbedderPolicy: false`. + +**Вывод:** CSP и COEP отключены. + +--- + +## 8. LLM + +### 8.1 Конфигурация + +**Документация:** LLM_BASE_URL, LLM_MODEL, LLM_API_KEY, LLM_TIMEOUT_MS, LLM_MAX_RETRIES, LLM_TEMPERATURE, LLM_MAX_TOKENS. + +**Реализация:** Дополнительно `LLM_FALLBACK_MODEL`, `LLM_RETRY_DELAY_MS`. + +**Вывод:** Реализация расширяет документацию. + +### 8.2 question_cache_meta + +**Документация:** model, generation_time_ms, prompt_hash. Также упоминаются valid, retry_count, questions_generated. + +**Реализация:** Нужно проверить сохранение этих полей в [questionCacheMeta](src/db/schema/questionCacheMeta.ts) и связанных сервисах. + +--- + +## 9. Код и инфраструктура + +### 9.1 Onboarding / setup + +**Документация:** docker-compose.dev.yml с postgres и redis. + +**Реализация:** Соответствует. + +### 9.2 .env.example + +**Документация:** Полный перечень переменных. + +**Реализация:** Совпадает, включая rate limits и LLM. JWT_SECRET требует не менее 32 символов — в примере выполнено. + +--- + +## 10. Сводка расхождений + +### Критические + +1. Отсутствие префикса `/api/v1` +2. Auth: другой flow (verify-email до токенов, refresh/logout через body вместо cookie) +3. Tests: `score` как процент вместо количества +4. Отсутствует GET /tests/:id/results +5. Формат ответов create/answer/finish/history не совпадает с документацией + +### Значительные + +1. Login lockout: 429 вместо 403 ACCOUNT_LOCKED +2. Login: нет поля `user` в ответе +3. Profile: нет `role`, `plan`; другая структура `stats` +4. Admin: другой набор эндпоинтов и логика approve/reject +5. Пагинация: offset вместо cursor + +### Незначительные + +1. Имена полей (testId vs id, newPassword vs password) +2. verify-email: userId + code вместо только code +3. Эндпоинт admin: /pending вместо /queue + +--- + +## Рекомендации + +1. **Привести маршрутизацию к документации:** добавить префикс `/api/v1` при регистрации роутов. +2. **Унифицировать auth:** реализовать cookie для refresh token и обновить logout/refresh под документацию, либо явно зафиксировать в документации текущий подход (body). +3. **Исправить score в тестах:** хранить и возвращать количество правильных ответов, а процент считать отдельно. +4. **Реализовать GET /tests/:id/results** по описанному в документации формату. +5. **Привести ответы create/answer/finish/history** к формату из contracts.md. +6. **Обновить документацию** под уже реализованные отличия (offset, admin approve/reject и т.д.), если менять реализацию не планируется. +7. **Расширить getPrivateProfile** полями `role` и `plan` из subscription middleware. + diff --git a/AGENT_TASK_BACKEND_SYNC.md b/AGENT_TASK_BACKEND_SYNC.md new file mode 100644 index 0000000..19c1a07 --- /dev/null +++ b/AGENT_TASK_BACKEND_SYNC.md @@ -0,0 +1,221 @@ +# Задача: Синхронизация бэкенда с документацией + +## Контекст + +По итогам аудита документации vs кода приняты решения. Данная задача — изменения в backend-репозитории. + +--- + +## 0. Настройка Cookie в Fastify + +Cookie нужны для хранения refresh token (httpOnly). Без этого разделы 3, 4 и 5 (logout, refresh, login Set-Cookie) не реализуемы. + +### 0.1 Установка плагина + +```bash +npm install @fastify/cookie +``` + +### 0.2 Регистрация в app.ts + +Зарегистрировать **до** auth-роутов (cookie должны быть доступны в onRequest): + +```ts +import cookie from '@fastify/cookie'; + +// После securityPlugin, до authPlugin +await app.register(cookie, { + secret: env.JWT_SECRET, // для подписанных cookie (опционально) + parseOptions: {}, // опции для парсинга входящих cookie +}); +``` + +Порядок плагинов: `redis` → `database` → `security` → `cookie` → `rateLimit` → `auth` → `subscription` → routes. + +### 0.3 Чтение cookie в роуте + +```ts +const refreshToken = req.cookies.refreshToken; // string | undefined +``` + +Если cookie не передан, `refreshToken` будет `undefined`. + +### 0.4 Установка cookie в ответе + +```ts +reply.setCookie('refreshToken', token, { + httpOnly: true, + secure: env.NODE_ENV === 'production', + sameSite: 'strict', + path: '/api/v1/auth', + maxAge: 604800, // 7 дней в секундах + signed: false, // если не используем подпись +}); +``` + +Для production: `secure: true` (только HTTPS). Для development: `secure: false`, иначе cookie не установится на localhost:3000 (если без HTTPS). + +### 0.5 Очистка cookie (logout) + +```ts +reply.clearCookie('refreshToken', { + path: '/api/v1/auth', + httpOnly: true, + secure: env.NODE_ENV === 'production', + sameSite: 'strict', +}); +``` + +Либо `reply.setCookie('refreshToken', '', { ... same opts ..., maxAge: 0 })`. + +### 0.6 CORS и credentials + +Убедиться, что CORS настроен с `credentials: true` (уже есть в `@fastify/cors`), иначе браузер не будет отправлять cookie с cross-origin запросами. Origin фронтенда должен быть в whitelist `CORS_ORIGINS`. + +### 0.7 Согласование пути cookie и роутов + +Cookie с `path: '/api/v1/auth'` отправляется только на запросы к `/api/v1/auth/*`. Refresh и logout должны вызываться по этим путям. + +--- + +## 1. Префикс /api/v1 + +**Файл:** `src/app.ts` + +При регистрации роутов добавить префикс `/api/v1`: + +```ts +await app.register(authRoutes, { prefix: '/api/v1/auth' }); +await app.register(profileRoutes, { prefix: '/api/v1/profile' }); +await app.register(testsRoutes, { prefix: '/api/v1/tests' }); +await app.register(adminQuestionsRoutes, { prefix: '/api/v1/admin' }); +``` + +Health check оставить без префикса (например `/health`) или перенести по необходимости. + +--- + +## 2. Auth: Login — вернуть объект user в ответ + +**Файлы:** `src/routes/auth.ts`, `src/services/auth/auth.service.ts` + +- `AuthService.login()` должен возвращать `{ user, accessToken, refreshToken, expiresIn }`. +- `user`: `{ id, email, nickname, avatarUrl, role, emailVerified }` — нужно подтянуть из БД. +- Фронтенду нужны id, nickname, avatarUrl для стейта (шапка, Redux/Pinia). + +--- + +## 3. Auth: Logout — по документации (cookie, Bearer) + +**Файлы:** `src/routes/auth.ts`, `src/services/auth/auth.service.ts` + +- **Авторизация:** Bearer token в заголовке. +- **Request:** пустое тело (refresh token читается из cookie). +- **Response:** 200 с `{ message: "Logged out successfully" }`. +- Set-Cookie: `refreshToken=; ... Max-Age=0` для очистки cookie. + +Требуется: + +- Читать refresh token из cookie `refreshToken` (httpOnly cookie). +- Удалять сессию по хешу refresh token. +- Очищать cookie в ответе. + +--- + +## 4. Auth: Refresh — по документации (cookie) + +**Файлы:** `src/routes/auth.ts`, `src/services/auth/auth.service.ts` + +- **Request:** пустое тело (refresh token из cookie). +- **Response:** 200 с `{ accessToken }`. +- Set-Cookie: новый refreshToken (ротация). + +Требуется: + +- Читать refresh token из cookie. +- Убрать `refreshToken` из body в schema и коде. +- Устанавливать httpOnly cookie с новым refresh token в ответе. + +--- + +## 5. Auth: Login — установка cookie при успехе + +При успешном login устанавливать refresh token в httpOnly cookie: + +```text +Set-Cookie: refreshToken=; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth; Max-Age=604800 +``` + +В development (`NODE_ENV=development`) Secure можно опустить, если используется HTTP. + +--- + +## 6. Profile: добавить role и plan в getPrivateProfile + +**Файлы:** `src/services/user/user.service.ts`, `src/routes/profile.ts` + +- Расширить `PrivateProfile`: добавить `role`, `plan`. +- `role` — из `users.role`. +- `plan` — из `subscriptions` через subscription middleware (уже загружается в `req.subscription` для GET /profile). +- В route GET /profile после `getPrivateProfile` добавить в ответ `role: req.user` (из users) и `plan: req.subscription?.plan ?? 'free'`. + +Либо загружать subscription в UserService при запросе профиля и включать plan/role в ответ. + +--- + +## 7. Tests: score — хранить и отдавать количество правильных + +**Файлы:** `src/services/tests/tests.service.ts`, `src/db/schema/tests.ts` (если нужно) + +- `score` в БД и API = количество правильных ответов (integer), не процент. +- В `finishTest`: `score = correctCount` (не `Math.round((correctCount / questions.length) * 100)`). +- В ответе finish и results: `{ score, totalQuestions, percentage }`, где `percentage = (score / totalQuestions) * 100`. + +--- + +## 8. question_cache_meta: привести схему к документации + +**Файлы:** `src/db/schema/questionCacheMeta.ts`, миграция, `src/services/llm/llm.service.ts`, `src/services/questions/question.service.ts` + +Документация (llm/strategy.md) требует: + +| Поле | Тип | Описание | +| - | - | - | +| model | varchar | Уже есть как llm_model | +| generation_time_ms | integer | Есть | +| prompt_hash | varchar | Есть | +| valid | boolean | Прошёл ли валидацию с первого раза | +| retry_count | integer | Сколько retry потребовалось | +| questions_generated | integer | Сколько вопросов вернул LLM | + +Действия: + +1. Добавить в схему `questionCacheMeta`: `valid` (boolean), `retryCount` (integer), `questionsGenerated` (integer). +2. Создать миграцию Drizzle. +3. Расширить `LlmGenerationMeta` в llm.service: `valid`, `retryCount`, `questionsGenerated`. +4. В `LlmService.generateQuestions` и `chatWithMeta`: при необходимости возвращать retry count. +5. В `QuestionService.generateAndPersistQuestions`: при вставке в `questionCacheMeta` передавать новые поля. + +--- + +## 9. Cookie-настройки + +- Путь для cookie: `/api/v1/auth` (совпадает с префиксом auth-роутов). +- Max-Age для refresh: 604800 (7 дней). +- Secure: только в production (NODE_ENV=production). +- SameSite=Strict. + +--- + +## Чеклист + +- [x] @fastify/cookie установлен и зарегистрирован (раздел 0) +- [x] Префикс /api/v1 для роутов +- [x] Login: user в ответе (id, nickname, avatarUrl, role, emailVerified) +- [x] Login: Set-Cookie refreshToken +- [x] Logout: Bearer + cookie, пустое тело, 200 + message +- [x] Refresh: cookie, пустое тело, 200 + accessToken + Set-Cookie +- [x] getPrivateProfile: role, plan +- [x] Tests finish: score = correctCount, возвращать score, totalQuestions, percentage +- [x] question_cache_meta: valid, retryCount, questionsGenerated +- [x] Обновить тесты под новые контракты diff --git a/AGENT_TASK_DOCS_SYNC.md b/AGENT_TASK_DOCS_SYNC.md new file mode 100644 index 0000000..62cf554 --- /dev/null +++ b/AGENT_TASK_DOCS_SYNC.md @@ -0,0 +1,157 @@ +# Задача: Синхронизация документации с реализацией + +## Контекст + +По итогам аудита документация должна быть приведена в соответствие с текущей реализацией backend. Данная задача — правки в samreshu_docs (submodule или основной репо с документацией). + +--- + +## 1. API: Базовый URL + +Убедиться, что базовый URL `/api/v1` соответствует backend (после внесения префикса агентом backend). Если в docs есть примеры с другим базовым путём — исправить. + +--- + +## 2. Auth: Register + +**Файл:** `samreshu_docs/api/contracts.md` + +- Регистрация **не выдаёт токены** до подтверждения email. +- Response 201: `{ userId, message, verificationCode }` (verificationCode — для dev/тестов, в prod не отдаётся). +- Добавить ошибку `NICKNAME_TAKEN` (409), если в коде она есть. + +--- + +## 3. Auth: Login + +- Ошибка при блокировке (lockout): **429** `RATE_LIMIT_EXCEEDED` (не 403 ACCOUNT_LOCKED). +- Response 200 должен включать объект `user`: `{ id, email, nickname, avatarUrl, role, emailVerified }`. +- Set-Cookie: refreshToken (httpOnly, Secure, SameSite=Strict, Path=/api/v1/auth). + +--- + +## 4. Auth: Verify-email + +- **Request:** `{ userId, code }` (не только code). +- **Авторизация:** не требуется (Bearer не нужен). + +--- + +## 5. Auth: Reset-password + +- **Request:** поле `newPassword` (не `password`). + +--- + +## 6. Profile: GET /profile + +- В ответе: `role`, `plan` (из subscriptions). + +--- + +## 7. Profile: GET /profile/:username (публичный профиль) + +- Структура `stats`: `{ byStack, totalTestsTaken, totalQuestions, correctAnswers, accuracy }` (а не только testsCompleted, averageScore). +- Описать формат `byStack` и остальных полей. + +--- + +## 8. Tests: POST /tests (создание) + +- Response: тест со списком **всех** вопросов в `questions` (не только текущий в `question`). +- Структура ответа: `{ id, stack, level, questionCount, status, startedAt, timeLimitSeconds, questions: [...] }`. + +--- + +## 9. Tests: POST /tests/:id/answer + +- Response: полный snapshot отвеченного вопроса (формат из реализации). +- Указать, что структура может отличаться от минимальной "answered + progress + nextQuestion". + +--- + +## 10. Tests: POST /tests/:id/finish + +- `score` — количество правильных ответов (integer). +- В ответе: `score`, `totalQuestions`, `percentage` (процент считает фронтенд или он приходит с бэка). + +--- + +## 11. Tests: GET /tests/history (или GET /tests для истории) + +- **Пагинация:** offset-based: `limit`, `offset` (не cursor). +- Формат: `{ tests: [...], total }` (не `{ data, pagination }`). +- Указать параметры `limit`, `offset`. + +--- + +## 12. Admin + +- Эндпоинт списка: **GET /admin/questions/pending** (не /queue). +- Отдельные эндпоинты: **POST /admin/questions/:id/approve**, **POST /admin/questions/:id/reject**. +- **PATCH /admin/questions/:id** — для редактирования контента (без смены статуса). +- Пагинация: limit/offset. + +--- + +## 13. Database: Токены верификации + +**Файл:** `samreshu_docs/database/schema.md` + +- Вместо общей таблицы `verification_tokens` описать: + - `email_verification_codes` (userId, code, expiresAt) + - `password_reset_tokens` (userId, tokenHash, expiresAt) + +--- + +## 14. Security: CORS + +**Файл:** `samreshu_docs/principles/security.md` + +- Origins задаются через переменную окружения **CORS_ORIGINS** (не хардкод localhost/prod). +- Методы: **GET, POST, PATCH, DELETE, PUT, OPTIONS** (OPTIONS нужен для preflight, PUT — для идемпотентных обновлений). + +--- + +## 15. Security: Helmet (CSP, COEP) + +- **CSP и COEP отключены** — бэкенд отдаёт только JSON API. +- Эти заголовки предназначены для HTML-страниц; для REST API они не нужны и могут мешать Swagger UI. + +--- + +## 16. LLM: Переменные окружения + +**Файлы:** `samreshu_docs/llm/strategy.md`, `samreshu_docs/onboarding/setup.md` + +Добавить в документацию: +- **LLM_FALLBACK_MODEL** — запасная модель при падении основной. +- **LLM_RETRY_DELAY_MS** — задержка между retry при ошибках API. + +--- + +## 17. LLM: Логирование в question_cache_meta + +**Файл:** `samreshu_docs/llm/strategy.md` + +- Актуальная структура: `llm_model`, `prompt_hash`, `generation_time_ms`, `valid`, `retry_count`, `questions_generated`, `raw_response` (опционально). + +--- + +## 18. Onboarding: .env.example + +- Закрепить правило: `.env.example` обновляется при добавлении новых фич (новые переменные, rate limits, LLM и т.д.). +- Упомянуть требование JWT_SECRET >= 32 символа. + +--- + +## Чеклист + +- [ ] api/contracts.md: register, login, verify-email, reset-password, logout, refresh +- [ ] api/contracts.md: profile (role, plan, stats) +- [ ] api/contracts.md: tests (create, answer, finish, history) +- [ ] api/contracts.md: admin (pending, approve, reject, PATCH) +- [ ] database/schema.md: email_verification_codes, password_reset_tokens +- [ ] principles/security.md: CORS, Helmet +- [ ] llm/strategy.md: LLM_FALLBACK_MODEL, LLM_RETRY_DELAY_MS, question_cache_meta +- [ ] onboarding/setup.md: правило .env.example diff --git a/package-lock.json b/package-lock.json index 70203b5..19c2ac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "samreshu-backend", "version": "0.1.0", "dependencies": { + "@fastify/cookie": "^11.0.2", "@fastify/cors": "^10.0.0", "@fastify/helmet": "^12.0.0", "@fastify/rate-limit": "^10.0.0", @@ -1180,6 +1181,42 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@fastify/cookie": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", + "integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^1.0.0", + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/cookie/node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/cors": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz", diff --git a/package.json b/package.json index 509708b..703daa7 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@fastify/cookie": "^11.0.2", "@fastify/cors": "^10.0.0", "@fastify/helmet": "^12.0.0", "@fastify/rate-limit": "^10.0.0", diff --git a/src/app.ts b/src/app.ts index 26e0b6c..9ab4d9b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,5 @@ import Fastify, { FastifyInstance } from 'fastify'; +import cookie from '@fastify/cookie'; import { AppError } from './utils/errors.js'; import databasePlugin from './plugins/database.js'; import redisPlugin from './plugins/redis.js'; @@ -74,13 +75,17 @@ export async function buildApp(): Promise { await app.register(redisPlugin); await app.register(databasePlugin); await app.register(securityPlugin); + await app.register(cookie, { + secret: env.JWT_SECRET, + parseOptions: {}, + }); await app.register(rateLimitPlugin); await app.register(authPlugin); await app.register(subscriptionPlugin); - await app.register(authRoutes, { prefix: '/auth' }); - await app.register(profileRoutes, { prefix: '/profile' }); - await app.register(testsRoutes, { prefix: '/tests' }); - await app.register(adminQuestionsRoutes, { prefix: '/admin' }); + await app.register(authRoutes, { prefix: '/api/v1/auth' }); + await app.register(profileRoutes, { prefix: '/api/v1/profile' }); + await app.register(testsRoutes, { prefix: '/api/v1/tests' }); + await app.register(adminQuestionsRoutes, { prefix: '/api/v1/admin' }); app.get('/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() })); diff --git a/src/db/migrations/0001_fluffy_yellowjacket.sql b/src/db/migrations/0001_fluffy_yellowjacket.sql new file mode 100644 index 0000000..d428df8 --- /dev/null +++ b/src/db/migrations/0001_fluffy_yellowjacket.sql @@ -0,0 +1,3 @@ +ALTER TABLE "question_cache_meta" ADD COLUMN "valid" boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE "question_cache_meta" ADD COLUMN "retry_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "question_cache_meta" ADD COLUMN "questions_generated" integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/meta/0001_snapshot.json b/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..252ca26 --- /dev/null +++ b/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,1252 @@ +{ + "id": "69f57b43-ebfa-49eb-a6e9-a82350f468ab", + "prevId": "2a8f572c-86d8-47f3-af8a-cb158e81d93e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "admin_id": { + "name": "admin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_logs_admin_id_users_id_fk": { + "name": "audit_logs_admin_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "nickname": { + "name": "nickname", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "self_level": { + "name": "self_level", + "type": "self_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payment_provider": { + "name": "payment_provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_user_id_users_id_fk": { + "name": "subscriptions_user_id_users_id_fk", + "tableFrom": "subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_user_id_unique": { + "name": "subscriptions_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tests": { + "name": "tests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stack": { + "name": "stack", + "type": "stack", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "question_count": { + "name": "question_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "test_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "status": { + "name": "status", + "type": "test_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'in_progress'" + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_limit_seconds": { + "name": "time_limit_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "tests_user_id_users_id_fk": { + "name": "tests_user_id_users_id_fk", + "tableFrom": "tests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.test_questions": { + "name": "test_questions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "test_id": { + "name": "test_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "question_bank_id": { + "name": "question_bank_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "order_number": { + "name": "order_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "question_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "question_text": { + "name": "question_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "correct_answer": { + "name": "correct_answer", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_answer": { + "name": "user_answer", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "answered_at": { + "name": "answered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "test_questions_test_id_tests_id_fk": { + "name": "test_questions_test_id_tests_id_fk", + "tableFrom": "test_questions", + "tableTo": "tests", + "columnsFrom": [ + "test_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "test_questions_question_bank_id_question_bank_id_fk": { + "name": "test_questions_question_bank_id_question_bank_id_fk", + "tableFrom": "test_questions", + "tableTo": "question_bank", + "columnsFrom": [ + "question_bank_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.question_bank": { + "name": "question_bank", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "stack": { + "name": "stack", + "type": "stack", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "question_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "question_text": { + "name": "question_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "correct_answer": { + "name": "correct_answer", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "question_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "source": { + "name": "source", + "type": "question_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.question_cache_meta": { + "name": "question_cache_meta", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "question_bank_id": { + "name": "question_bank_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "llm_model": { + "name": "llm_model", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "prompt_hash": { + "name": "prompt_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "generation_time_ms": { + "name": "generation_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "raw_response": { + "name": "raw_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "valid": { + "name": "valid", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "questions_generated": { + "name": "questions_generated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "question_cache_meta_question_bank_id_question_bank_id_fk": { + "name": "question_cache_meta_question_bank_id_question_bank_id_fk", + "tableFrom": "question_cache_meta", + "tableTo": "question_bank", + "columnsFrom": [ + "question_bank_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.question_reports": { + "name": "question_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "question_bank_id": { + "name": "question_bank_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "report_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "question_reports_question_bank_id_question_bank_id_fk": { + "name": "question_reports_question_bank_id_question_bank_id_fk", + "tableFrom": "question_reports", + "tableTo": "question_bank", + "columnsFrom": [ + "question_bank_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "question_reports_user_id_users_id_fk": { + "name": "question_reports_user_id_users_id_fk", + "tableFrom": "question_reports", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stack": { + "name": "stack", + "type": "stack", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "total_questions": { + "name": "total_questions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "correct_answers": { + "name": "correct_answers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tests_taken": { + "name": "tests_taken", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_users_id_fk": { + "name": "user_stats_user_id_users_id_fk", + "tableFrom": "user_stats", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_stack_level_unique": { + "name": "user_stats_user_id_stack_level_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "stack", + "level" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_question_log": { + "name": "user_question_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "question_bank_id": { + "name": "question_bank_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_question_log_user_id_users_id_fk": { + "name": "user_question_log_user_id_users_id_fk", + "tableFrom": "user_question_log", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_question_log_question_bank_id_question_bank_id_fk": { + "name": "user_question_log_question_bank_id_question_bank_id_fk", + "tableFrom": "user_question_log", + "tableTo": "question_bank", + "columnsFrom": [ + "question_bank_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_verification_codes": { + "name": "email_verification_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_verification_codes_user_id_users_id_fk": { + "name": "email_verification_codes_user_id_users_id_fk", + "tableFrom": "email_verification_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_tokens": { + "name": "password_reset_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "password_reset_tokens_user_id_users_id_fk": { + "name": "password_reset_tokens_user_id_users_id_fk", + "tableFrom": "password_reset_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.level": { + "name": "level", + "schema": "public", + "values": [ + "basic", + "beginner", + "intermediate", + "advanced", + "expert" + ] + }, + "public.plan": { + "name": "plan", + "schema": "public", + "values": [ + "free", + "pro" + ] + }, + "public.question_source": { + "name": "question_source", + "schema": "public", + "values": [ + "llm_generated", + "manual" + ] + }, + "public.question_status": { + "name": "question_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected" + ] + }, + "public.question_type": { + "name": "question_type", + "schema": "public", + "values": [ + "single_choice", + "multiple_select", + "true_false", + "short_text" + ] + }, + "public.report_status": { + "name": "report_status", + "schema": "public", + "values": [ + "open", + "resolved", + "dismissed" + ] + }, + "public.self_level": { + "name": "self_level", + "schema": "public", + "values": [ + "jun", + "mid", + "sen" + ] + }, + "public.stack": { + "name": "stack", + "schema": "public", + "values": [ + "html", + "css", + "js", + "ts", + "react", + "vue", + "nodejs", + "git", + "web_basics" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "active", + "trialing", + "cancelled", + "expired" + ] + }, + "public.test_mode": { + "name": "test_mode", + "schema": "public", + "values": [ + "fixed", + "infinite", + "marathon" + ] + }, + "public.test_status": { + "name": "test_status", + "schema": "public", + "values": [ + "in_progress", + "completed", + "abandoned" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "guest", + "free", + "pro", + "admin" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index cc4ebdc..8e5c38a 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1772620981431, "tag": "0000_fearless_salo", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1772792192122, + "tag": "0001_fluffy_yellowjacket", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema/questionCacheMeta.ts b/src/db/schema/questionCacheMeta.ts index c0b1e82..684a1c1 100644 --- a/src/db/schema/questionCacheMeta.ts +++ b/src/db/schema/questionCacheMeta.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, varchar, integer, timestamp } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, varchar, integer, timestamp, boolean } from 'drizzle-orm/pg-core'; import { jsonb } from 'drizzle-orm/pg-core'; import { questionBank } from './questionBank.js'; @@ -11,6 +11,9 @@ export const questionCacheMeta = pgTable('question_cache_meta', { promptHash: varchar('prompt_hash', { length: 64 }).notNull(), generationTimeMs: integer('generation_time_ms').notNull(), rawResponse: jsonb('raw_response').$type(), + valid: boolean('valid').notNull().default(true), + retryCount: integer('retry_count').notNull().default(0), + questionsGenerated: integer('questions_generated').notNull().default(0), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index c987f19..42808ca 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,6 +1,21 @@ import type { FastifyInstance } from 'fastify'; import { AuthService } from '../services/auth/auth.service.js'; import { checkBlocked, clearOnSuccess, recordFailedAttempt } from '../utils/loginLockout.js'; +import { env } from '../config/env.js'; + +const COOKIE_PATH = '/api/v1/auth'; +const REFRESH_MAX_AGE = 604800; // 7 days + +function getRefreshCookieOptions() { + return { + httpOnly: true, + secure: env.NODE_ENV === 'production', + sameSite: 'strict' as const, + path: COOKIE_PATH, + maxAge: REFRESH_MAX_AGE, + signed: false, + }; +} const registerSchema = { body: { @@ -25,17 +40,11 @@ const loginSchema = { }, }; -const refreshTokenSchema = { - body: { - type: 'object', - required: ['refreshToken'], - properties: { - refreshToken: { type: 'string' }, - }, - }, -}; +/** Refresh: empty body, token from cookie */ +const refreshSchema = { body: { type: 'object', properties: {} } }; -const logoutSchema = refreshTokenSchema; +/** Logout: Bearer + cookie, empty body */ +const logoutSchema = { body: { type: 'object', properties: {} } }; const verifyEmailSchema = { body: { @@ -122,6 +131,7 @@ export async function authRoutes(app: FastifyInstance) { ipAddress: ip, }); await clearOnSuccess(app.redis, ip); + reply.setCookie('refreshToken', result.refreshToken, getRefreshCookieOptions()); return reply.send(result); } catch (err) { await recordFailedAttempt(app.redis, ip); @@ -132,29 +142,40 @@ export async function authRoutes(app: FastifyInstance) { app.post( '/logout', - { schema: logoutSchema, config: { rateLimit: rateLimitOptions.apiGuest } }, + { + schema: logoutSchema, + config: { rateLimit: rateLimitOptions.apiGuest }, + preHandler: [app.authenticate], + }, async (req, reply) => { - const body = req.body as { refreshToken: string }; - await authService.logout(body.refreshToken); - return reply.status(204).send(); + const refreshToken = req.cookies?.refreshToken; + await authService.logout(refreshToken); + reply.clearCookie('refreshToken', { + path: COOKIE_PATH, + httpOnly: true, + secure: env.NODE_ENV === 'production', + sameSite: 'strict', + }); + return reply.status(200).send({ message: 'Logged out successfully' }); }, ); app.post( '/refresh', - { schema: refreshTokenSchema, config: { rateLimit: rateLimitOptions.apiGuest } }, + { schema: refreshSchema, config: { rateLimit: rateLimitOptions.apiGuest } }, async (req, reply) => { - const body = req.body as { refreshToken: string }; + const refreshToken = req.cookies?.refreshToken; const userAgent = req.headers['user-agent']; const ipAddress = req.ip; const result = await authService.refresh({ - refreshToken: body.refreshToken, + refreshToken, userAgent, ipAddress, }); - return reply.send(result); + reply.setCookie('refreshToken', result.refreshToken, getRefreshCookieOptions()); + return reply.send({ accessToken: result.accessToken }); }, ); diff --git a/src/routes/profile.ts b/src/routes/profile.ts index ef2b97a..72e8f63 100644 --- a/src/routes/profile.ts +++ b/src/routes/profile.ts @@ -38,7 +38,8 @@ export async function profileRoutes(app: FastifyInstance) { }, async (req, reply) => { const userId = req.user!.id; - const profile = await userService.getPrivateProfile(userId); + const plan = req.subscription?.plan ?? 'free'; + const profile = await userService.getPrivateProfile(userId, plan); return reply.send(profile); }, ); diff --git a/src/services/auth/auth.service.ts b/src/services/auth/auth.service.ts index db5efed..8f1b271 100644 --- a/src/services/auth/auth.service.ts +++ b/src/services/auth/auth.service.ts @@ -34,14 +34,24 @@ export interface LoginInput { ipAddress?: string; } +export interface LoginUser { + id: string; + email: string; + nickname: string; + avatarUrl: string | null; + role: string; + emailVerified: boolean; +} + export interface LoginResult { + user: LoginUser; accessToken: string; refreshToken: string; expiresIn: number; } export interface RefreshInput { - refreshToken: string; + refreshToken: string | undefined; userAgent?: string; ipAddress?: string; } @@ -138,18 +148,24 @@ export class AuthService { }); return { + user: { + id: user.id, + email: user.email, + nickname: user.nickname, + avatarUrl: user.avatarUrl ?? null, + role: user.role, + emailVerified: !!user.emailVerifiedAt, + }, accessToken, refreshToken, expiresIn: Math.floor(REFRESH_TTL_MS / 1000), }; } - async logout(refreshToken: string): Promise { - const hash = hashToken(refreshToken); - await this.db.delete(sessions).where(eq(sessions.refreshTokenHash, hash)); - } - - async refresh(input: RefreshInput): Promise { + async refresh(input: RefreshInput): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { + if (!input.refreshToken) { + throw new AppError(ERROR_CODES.INVALID_REFRESH_TOKEN, 'Refresh token required (cookie)', 401); + } const payload = await verifyToken(input.refreshToken); if (!isRefreshPayload(payload)) { @@ -199,6 +215,12 @@ export class AuthService { }; } + async logout(refreshToken: string | undefined): Promise { + if (!refreshToken) return; + const hash = hashToken(refreshToken); + await this.db.delete(sessions).where(eq(sessions.refreshTokenHash, hash)); + } + async verifyEmail(userId: string, verificationCode: string): Promise { const codeUpper = verificationCode.toUpperCase(); const [record] = await this.db diff --git a/src/services/llm/llm.service.ts b/src/services/llm/llm.service.ts index b12870b..bb8ff63 100644 --- a/src/services/llm/llm.service.ts +++ b/src/services/llm/llm.service.ts @@ -64,6 +64,9 @@ export interface LlmGenerationMeta { promptHash: string; generationTimeMs: number; rawResponse: unknown; + valid: boolean; + retryCount: number; + questionsGenerated: number; } export interface GenerateQuestionsResult { @@ -93,8 +96,10 @@ export class LlmService { return content; } - /** Returns content and model used (for logging to question_cache_meta) */ - async chatWithMeta(messages: ChatMessage[]): Promise<{ content: string; model: string }> { + /** Returns content, model, and retry count (for logging to question_cache_meta) */ + async chatWithMeta( + messages: ChatMessage[] + ): Promise<{ content: string; model: string; retryCount: number }> { let lastError: Error | null = null; const modelsToTry = [this.config.model]; @@ -106,7 +111,7 @@ export class LlmService { for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) { try { const content = await this.executeChat(messages, model); - return { content, model }; + return { content, model, retryCount: attempt }; } catch (err) { lastError = err instanceof Error ? err : new Error('LLM request failed'); if (attempt < this.config.maxRetries) { @@ -185,7 +190,7 @@ Rules: type must be one of: ${typeList}. For single_choice/multiple_select: opti const promptHash = createHash('sha256').update(promptForHash).digest('hex'); const start = Date.now(); - const { content: raw, model } = await this.chatWithMeta([ + const { content: raw, model, retryCount } = await this.chatWithMeta([ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, ]); @@ -221,6 +226,9 @@ Rules: type must be one of: ${typeList}. For single_choice/multiple_select: opti promptHash, generationTimeMs, rawResponse: parsed, + valid: retryCount === 0, + retryCount, + questionsGenerated: questions.length, }, }; } diff --git a/src/services/questions/question.service.ts b/src/services/questions/question.service.ts index 9e93e39..f61e259 100644 --- a/src/services/questions/question.service.ts +++ b/src/services/questions/question.service.ts @@ -179,6 +179,9 @@ export class QuestionService { promptHash: meta.promptHash, generationTimeMs: meta.generationTimeMs, rawResponse: meta.rawResponse, + valid: meta.valid, + retryCount: meta.retryCount, + questionsGenerated: meta.questionsGenerated, }); inserted.push({ diff --git a/src/services/tests/tests.service.ts b/src/services/tests/tests.service.ts index 93cb2d2..bac9eca 100644 --- a/src/services/tests/tests.service.ts +++ b/src/services/tests/tests.service.ts @@ -39,6 +39,8 @@ export type TestWithQuestions = { mode: string; status: string; score: number | null; + totalQuestions: number; + percentage: number | null; startedAt: string; finishedAt: string | null; timeLimitSeconds: number | null; @@ -236,7 +238,7 @@ export class TestsService { } const correctCount = questions.filter((q) => q.isCorrect === true).length; - const score = Math.round((correctCount / questions.length) * 100); + const score = correctCount; // score = count of correct answers, not percentage const [updatedTest] = await this.db .update(tests) @@ -368,6 +370,11 @@ export class TestsService { test: (typeof tests.$inferSelect), questionsRows: (typeof testQuestions.$inferSelect)[] ): TestWithQuestions { + const totalQuestions = questionsRows.length; + const percentage = + test.score !== null && totalQuestions > 0 + ? (test.score / totalQuestions) * 100 + : null; return { id: test.id, userId: test.userId, @@ -377,6 +384,8 @@ export class TestsService { mode: test.mode, status: test.status, score: test.score, + totalQuestions, + percentage, startedAt: test.startedAt.toISOString(), finishedAt: test.finishedAt?.toISOString() ?? null, timeLimitSeconds: test.timeLimitSeconds, diff --git a/src/services/user/user.service.ts b/src/services/user/user.service.ts index 3d50a3d..d51b6b6 100644 --- a/src/services/user/user.service.ts +++ b/src/services/user/user.service.ts @@ -1,7 +1,7 @@ import { eq } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import type * as schema from '../../db/schema/index.js'; -import { users, userStats } from '../../db/schema/index.js'; +import { users, userStats, subscriptions } from '../../db/schema/index.js'; import { notFound, conflict, ERROR_CODES } from '../../utils/errors.js'; import type { User } from '../../db/schema/users.js'; import type { SelfLevel } from '../../db/schema/index.js'; @@ -50,6 +50,8 @@ export type PrivateProfile = PublicProfile & { emailVerifiedAt: string | null; createdAt: string; updatedAt: string; + role: string; + plan: 'free' | 'pro'; }; async function getStatsForUser(db: Db, userId: string): Promise { @@ -88,13 +90,19 @@ function toPublicProfile(user: User, stats: ProfileStats): PublicProfile { }; } -function toPrivateProfile(user: User, stats: ProfileStats): PrivateProfile { +function toPrivateProfile( + user: User, + stats: ProfileStats, + plan: 'free' | 'pro' = 'free' +): PrivateProfile { return { ...toPublicProfile(user, stats), email: user.email, emailVerifiedAt: user.emailVerifiedAt?.toISOString() ?? null, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt.toISOString(), + role: user.role, + plan, }; } @@ -115,12 +123,15 @@ export class UserService { return user ?? null; } - async getPrivateProfile(userId: string): Promise { + async getPrivateProfile( + userId: string, + plan: 'free' | 'pro' = 'free' + ): Promise { const [user, stats] = await Promise.all([this.getById(userId), getStatsForUser(this.db, userId)]); if (!user) { throw notFound('User not found'); } - return toPrivateProfile(user, stats); + return toPrivateProfile(user, stats, plan); } async getPublicProfile(username: string): Promise { @@ -170,6 +181,18 @@ export class UserService { } const stats = await getStatsForUser(this.db, userId); - return toPrivateProfile(updated, stats); + const plan = await this.getPlanForUser(userId); + return toPrivateProfile(updated, stats, plan); + } + + async getPlanForUser(userId: string): Promise<'free' | 'pro'> { + const [sub] = await this.db + .select() + .from(subscriptions) + .where(eq(subscriptions.userId, userId)) + .limit(1); + if (!sub || (sub.expiresAt && sub.expiresAt < new Date())) return 'free'; + if (sub.plan === 'pro' && (sub.status === 'active' || sub.status === 'trialing')) return 'pro'; + return 'free'; } } diff --git a/tests/helpers/build-test-app.ts b/tests/helpers/build-test-app.ts index 02b1a21..3de0d81 100644 --- a/tests/helpers/build-test-app.ts +++ b/tests/helpers/build-test-app.ts @@ -1,9 +1,17 @@ import Fastify, { FastifyInstance } from 'fastify'; +import cookie from '@fastify/cookie'; +import fp from 'fastify-plugin'; import { AppError } from '../../src/utils/errors.js'; +import authPlugin from '../../src/plugins/auth.js'; import { authRoutes } from '../../src/routes/auth.js'; import type { MockDb } from '../test-utils.js'; import { createMockDb } from '../test-utils.js'; +const mockDatabasePlugin = (db: MockDb) => + fp(async (app) => { + app.decorate('db', db); + }, { name: 'database' }); + /** Mock Redis for login lockout in auth tests. Implements ttl, setex, del, eval. */ const mockRedis = { async ttl(_key: string): Promise { @@ -50,7 +58,6 @@ export async function buildAuthTestApp(mockDb?: MockDb): Promise ({ + env: { + NODE_ENV: 'test', + JWT_SECRET: 'test-secret', + }, +})); + import { buildAuthTestApp } from '../helpers/build-test-app.js'; import { createMockDb, @@ -17,6 +25,7 @@ vi.mock('../../src/utils/jwt.js', () => ({ signAccessToken: vi.fn().mockResolvedValue('access-token'), signRefreshToken: vi.fn().mockResolvedValue('refresh-token'), verifyToken: vi.fn(), + isAccessPayload: vi.fn((p: { type?: string }) => p?.type === 'access'), isRefreshPayload: vi.fn(), hashToken: vi.fn((t: string) => `hash-${t}`), })); @@ -49,7 +58,7 @@ describe('Auth routes integration', () => { const res = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/api/v1/auth/register', payload: { email: 'test@example.com', password: 'password123', @@ -71,7 +80,7 @@ describe('Auth routes integration', () => { const res = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/api/v1/auth/register', payload: { email: 'test@example.com', password: 'password123', @@ -87,7 +96,7 @@ describe('Auth routes integration', () => { it('returns 422 when validation fails', async () => { const res = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/api/v1/auth/register', payload: { email: 'short', password: '123', // too short @@ -102,10 +111,14 @@ describe('Auth routes integration', () => { }); describe('POST /auth/login', () => { - it('returns tokens when credentials are valid', async () => { + it('returns user and tokens when credentials are valid', async () => { const mockUser = { id: 'user-1', email: 'test@example.com', + nickname: 'tester', + avatarUrl: null, + role: 'free', + emailVerifiedAt: null, passwordHash: 'hashed', }; (mockDb.select as ReturnType).mockReturnValueOnce( @@ -117,12 +130,16 @@ describe('Auth routes integration', () => { const res = await app.inject({ method: 'POST', - url: '/auth/login', + url: '/api/v1/auth/login', payload: { email: 'test@example.com', password: 'password123' }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); + expect(body.user).toBeDefined(); + expect(body.user.id).toBe('user-1'); + expect(body.user.email).toBe('test@example.com'); + expect(body.user.nickname).toBeDefined(); expect(body.accessToken).toBe('access-token'); expect(body.refreshToken).toBe('refresh-token'); }); @@ -134,7 +151,7 @@ describe('Auth routes integration', () => { const res = await app.inject({ method: 'POST', - url: '/auth/login', + url: '/api/v1/auth/login', payload: { email: 'unknown@example.com', password: 'wrong' }, }); @@ -143,23 +160,31 @@ describe('Auth routes integration', () => { }); describe('POST /auth/logout', () => { - it('returns 204 on success', async () => { + it('returns 200 with message on success (Bearer + cookie)', async () => { + vi.mocked(verifyToken).mockResolvedValueOnce({ + sub: 'user-1', + email: 'test@example.com', + type: 'access', + } as never); (mockDb.delete as ReturnType).mockReturnValueOnce( deleteChain() ); const res = await app.inject({ method: 'POST', - url: '/auth/logout', - payload: { refreshToken: 'some-token' }, + url: '/api/v1/auth/logout', + headers: { authorization: 'Bearer valid-access-token' }, + payload: {}, }); - expect(res.statusCode).toBe(204); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.message).toBe('Logged out successfully'); }); }); describe('POST /auth/refresh', () => { - it('returns new tokens when refresh token is valid', async () => { + it('returns accessToken when refresh token from cookie is valid', async () => { vi.mocked(verifyToken).mockResolvedValueOnce({ sub: 'user-1', sid: 'sid-1', @@ -178,14 +203,15 @@ describe('Auth routes integration', () => { const res = await app.inject({ method: 'POST', - url: '/auth/refresh', - payload: { refreshToken: 'valid-refresh-token' }, + url: '/api/v1/auth/refresh', + payload: {}, + headers: { cookie: 'refreshToken=valid-refresh-token' }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.accessToken).toBe('access-token'); - expect(body.refreshToken).toBe('refresh-token'); + expect(body.refreshToken).toBeUndefined(); }); }); @@ -209,7 +235,7 @@ describe('Auth routes integration', () => { const res = await app.inject({ method: 'POST', - url: '/auth/verify-email', + url: '/api/v1/auth/verify-email', payload: { userId: 'user-1', code: 'ABC123' }, }); @@ -228,7 +254,7 @@ describe('Auth routes integration', () => { const res = await app.inject({ method: 'POST', - url: '/auth/forgot-password', + url: '/api/v1/auth/forgot-password', payload: { email: 'test@example.com' }, }); @@ -255,7 +281,7 @@ describe('Auth routes integration', () => { const res = await app.inject({ method: 'POST', - url: '/auth/reset-password', + url: '/api/v1/auth/reset-password', payload: { token: 'valid-token', newPassword: 'newPassword123' }, }); diff --git a/tests/services/auth.service.test.ts b/tests/services/auth.service.test.ts index 8947a29..0eca37c 100644 --- a/tests/services/auth.service.test.ts +++ b/tests/services/auth.service.test.ts @@ -91,10 +91,14 @@ describe('AuthService', () => { }); describe('login', () => { - it('returns tokens when credentials are valid', async () => { + it('returns user and tokens when credentials are valid', async () => { const mockUser = { id: 'user-1', email: 'test@example.com', + nickname: 'tester', + avatarUrl: null, + role: 'free', + emailVerifiedAt: null, passwordHash: 'hashed', }; (mockDb.select as ReturnType).mockReturnValueOnce( @@ -109,6 +113,9 @@ describe('AuthService', () => { password: 'password123', }); + expect(result.user).toBeDefined(); + expect(result.user.id).toBe('user-1'); + expect(result.user.email).toBe('test@example.com'); expect(result.accessToken).toBe('access-token'); expect(result.refreshToken).toBe('refresh-token'); expect(verifyPassword).toHaveBeenCalledWith('hashed', 'password123'); diff --git a/tests/services/tests.service.test.ts b/tests/services/tests.service.test.ts index 023b120..8c321f9 100644 --- a/tests/services/tests.service.test.ts +++ b/tests/services/tests.service.test.ts @@ -214,7 +214,7 @@ describe('TestsService', () => { describe('getHistory', () => { it('returns paginated test history', async () => { const mockTests = [ - { id: 't-1', userId: 'user-1', stack: 'js', level: 'beginner', questionCount: 1, mode: 'fixed', status: 'completed', score: 100, startedAt: new Date(), finishedAt: new Date(), timeLimitSeconds: null }, + { id: 't-1', userId: 'user-1', stack: 'js', level: 'beginner', questionCount: 1, mode: 'fixed', status: 'completed', score: 1, startedAt: new Date(), finishedAt: new Date(), timeLimitSeconds: null }, ]; const mockTqRows = []; (mockDb.select as ReturnType)