diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..064770e --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,53 @@ +name: e2e tests + +on: + deployment_status: + workflow_dispatch: + inputs: + base_url: + description: 'Base URL for e2e tests (e.g. https://smpl-app.vercel.app)' + required: true + type: string + +jobs: + e2e: + name: e2e tests + runs-on: ubuntu-latest + timeout-minutes: 15 + if: > + (github.event_name == 'deployment_status' && + github.event.deployment_status.state == 'success' && + github.event.deployment.environment == 'Preview') || + github.event_name == 'workflow_dispatch' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + + - name: Install dependencies + run: pnpm i --frozen-lockfile + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run e2e tests + run: pnpm test:e2e + env: + BASE_URL: ${{ github.event_name == 'deployment_status' && github.event.deployment_status.target_url || inputs.base_url }} + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index df2f158..be48381 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ package-lock.json # testing /coverage +/playwright-report/ +/blob-report/ +/test-results/ # next.js /.next/ diff --git a/AGENTS.md b/AGENTS.md index 8bbf017..2e3f2ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,7 +147,19 @@ Enum `TaskType` в Prisma генерирует TypeScript тип в `@/generated ### Тесты -Для каждой функции (кроме совсем тривиальных) должны быть написаны unit-тесты. Они пишутся ишутся на vitest. Файлы тестов рядом с кодом, название *.spec.ts. Названия тестов всегда на английском, комментарии не нужны кроме неочевидных случаев. Для каждой функции свой describe, внутри несколько it с различными тесткейсами. Они должны тестировать разные вещи: не пиши несколько повторяющихся тестов. +**Unit-тесты** — для каждой функции (кроме совсем тривиальных) должны быть написаны unit-тесты. Они пишутся на vitest. Файлы тестов рядом с кодом, название *.spec.ts. Названия тестов всегда на английском, комментарии не нужны кроме неочевидных случаев. Для каждой функции свой describe, внутри несколько it с различными тесткейсами. Они должны тестировать разные вещи: не пиши несколько повторяющихся тестов. + +**E2E-тесты** — написаны на Playwright, лежат в `e2e/`. Покрывают: +- `pages.spec.ts` — загрузка страниц, отсутствие console errors +- `api.spec.ts` — формат ответов API (tasks_info, tasks_count, progress и тд) +- `performance.spec.ts` — таймауты API (< 3s), время загрузки страниц (< 5s), CLS (< 0.25) + +Запуск: +- `pnpm test:e2e` — против локального сервера (нужен запущенный `pnpm dev`) +- `BASE_URL=https://preview.vercel.app pnpm test:e2e` — против Vercel preview deployment +- `pnpm test:e2e:ui` — с визуальным интерфейсом Playwright + +`MOCK_TELEGRAM_ID` в тестах должен совпадать с `USER_ID_MOCK` из `src/shared/constants/index.ts`. ### Работа с git @@ -293,3 +305,13 @@ const user = await withDbTiming('user.findByTelegramId', () => **Instrumentation** (`src/instrumentation.ts`) — серверный мониторинг. Функция `register()` выполняется при старте сервера, `onRequestError()` ловит необработанные ошибки рендеринга и роутинга. **Важно:** `@vercel/analytics`, `@vercel/speed-insights` и `web-vitals` должны быть в `dependencies` (не `devDependencies`), иначе они не попадут в production build. + +### Оптимизация производительности БД + +**DB connection warmup** — в `instrumentation.ts` при старте сервера выполняется `SELECT 1`, чтобы прогреть TCP-соединение с БД. Без этого первый запрос пользователя тратит ~1000ms на установление соединения. + +**Индексы на FK** — в `schema.prisma` добавлены `@@index` на все foreign key поля (`owner_id`, `workspace_id`, `project_id`, `type`). PostgreSQL не создаёт индексы на FK автоматически. После изменения индексов нужно запустить `pnpm migrate`. + +**Лёгкие запросы к воркспейсам** — методы `findUnique`, `findMany`, `findByUser`, `findMyWorkspace` в `prismaClient.ts` загружают только `{ type: true }` из задач вместо полных объектов. Этого достаточно для подсчёта completed/total. Если нужны полные задачи — использовать `db.task.findByWorkspace` или `db.task.findByUser`. + +**`findMyWorkspaceId`** — лёгкий метод, возвращающий только `{ id }` воркспейса. Используется в `/api/task?project=my`, где нужен только ID воркспейса, а не все его задачи и тиммейты. diff --git a/MONITORING.md b/MONITORING.md index 5d88e45..c3c57ed 100644 --- a/MONITORING.md +++ b/MONITORING.md @@ -185,6 +185,8 @@ Both are mounted in `src/app/layout.tsx`: ## Tests +### Unit tests + Logger utilities are covered by unit tests: | File | Tests | Coverage | @@ -192,3 +194,24 @@ Logger utilities are covered by unit tests: | `src/shared/utils/logger/logger.spec.ts` | 9 | JSON format, log levels, context serialization | | `src/shared/utils/logger/withLogging.spec.ts` | 5 | Response passthrough, log levels by status, error handling, context forwarding | | `src/shared/utils/logger/withDbTiming.spec.ts` | 5 | Return values, timing measurement, slow query threshold, error propagation | + +### E2E tests (Playwright) + +Located in `e2e/`. Can run against local dev server or Vercel preview deployments. + +| File | Tests | Coverage | +|------|-------|----------| +| `e2e/pages.spec.ts` | 5 | Page loading (home, my-workspace, profile, workspace), console errors | +| `e2e/api.spec.ts` | 7 | API response shapes (workspace tasks_info, project tasks_count/progress, task grouping, teammate list, error handling) | +| `e2e/performance.spec.ts` | 5 | API response times (<3s), page load time (<5s), CLS (<0.25) | + +```bash +# Run against local server (requires pnpm dev) +pnpm test:e2e + +# Run against Vercel preview deployment +BASE_URL=https://your-preview.vercel.app pnpm test:e2e + +# Interactive UI mode +pnpm test:e2e:ui +``` diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts new file mode 100644 index 0000000..f1a02fc --- /dev/null +++ b/e2e/api.spec.ts @@ -0,0 +1,159 @@ +import { test, expect } from '@playwright/test'; + + +const MOCK_TELEGRAM_ID = '862381667'; + +test.describe('API: /api/workspace', () => { + test('GET /api/workspace?userId returns workspaces with tasks_info', async ({ request }) => { + const response = await request.get(`/api/workspace?userId=${MOCK_TELEGRAM_ID}`); + + expect(response.status()).toBe(200); + + const data = await response.json(); + + expect(data).toHaveProperty('workspaces'); + expect(Array.isArray(data.workspaces)).toBe(true); + + if (data.workspaces.length > 0) { + const ws = data.workspaces[0]; + + expect(ws).toHaveProperty('id'); + expect(ws).toHaveProperty('title'); + expect(ws).toHaveProperty('tasks_info'); + expect(ws.tasks_info).toHaveProperty('total'); + expect(ws.tasks_info).toHaveProperty('completed'); + expect(typeof ws.tasks_info.total).toBe('number'); + expect(typeof ws.tasks_info.completed).toBe('number'); + expect(ws.tasks_info.completed).toBeLessThanOrEqual(ws.tasks_info.total); + } + }); + + test('GET /api/workspace/my returns workspace with tasks_info', async ({ request }) => { + const response = await request.get(`/api/workspace/my?userId=${MOCK_TELEGRAM_ID}`); + + expect(response.status()).toBe(200); + + const data = await response.json(); + + expect(data).toHaveProperty('id'); + expect(data).toHaveProperty('tasks_info'); + expect(data.tasks_info).toHaveProperty('total'); + expect(data.tasks_info).toHaveProperty('completed'); + expect(typeof data.tasks_info.total).toBe('number'); + expect(typeof data.tasks_info.completed).toBe('number'); + }); +}); + +test.describe('API: /api/project', () => { + test('GET /api/project?workspace returns projects with tasks_count and progress', async ({ request }) => { + const wsResponse = await request.get(`/api/workspace/my?userId=${MOCK_TELEGRAM_ID}`); + const ws = await wsResponse.json(); + + const response = await request.get(`/api/project?workspace=${ws.id}`); + + expect(response.status()).toBe(200); + + const data = await response.json(); + + expect(Array.isArray(data)).toBe(true); + + if (data.length > 0) { + const project = data[0]; + + expect(project).toHaveProperty('id'); + expect(project).toHaveProperty('title'); + expect(project).toHaveProperty('tasks_count'); + expect(project).toHaveProperty('progress'); + expect(typeof project.tasks_count).toBe('number'); + expect(typeof project.progress).toBe('number'); + expect(project.progress).toBeGreaterThanOrEqual(0); + expect(project.progress).toBeLessThanOrEqual(100); + } + }); +}); + +test.describe('API: /api/task', () => { + test('GET /api/task?project=my returns grouped tasks', async ({ request }) => { + const response = await request.get( + `/api/task?project=my&userId=${MOCK_TELEGRAM_ID}` + ); + + expect(response.status()).toBe(200); + + const data = await response.json(); + + expect(data).toHaveProperty('workspace_id'); + expect(data).toHaveProperty('todo'); + expect(data).toHaveProperty('progress'); + expect(data).toHaveProperty('review'); + expect(data).toHaveProperty('done'); + expect(Array.isArray(data.todo)).toBe(true); + expect(Array.isArray(data.progress)).toBe(true); + expect(Array.isArray(data.review)).toBe(true); + expect(Array.isArray(data.done)).toBe(true); + }); + + test('task objects have required fields', async ({ request }) => { + const response = await request.get( + `/api/task?project=my&userId=${MOCK_TELEGRAM_ID}` + ); + const data = await response.json(); + + const allTasks = [ + ...data.todo, + ...data.progress, + ...data.review, + ...data.done, + ]; + + if (allTasks.length > 0) { + const task = allTasks[0]; + + expect(task).toHaveProperty('id'); + expect(task).toHaveProperty('title'); + expect(task).toHaveProperty('type'); + expect(task).toHaveProperty('date'); + expect(task).toHaveProperty('serial'); + } + }); +}); + +test.describe('API: /api/teammate', () => { + test('GET /api/teammate/:workspaceId returns teammates', async ({ request }) => { + const wsResponse = await request.get(`/api/workspace?userId=${MOCK_TELEGRAM_ID}`); + const wsData = await wsResponse.json(); + + if (wsData.workspaces?.length > 0) { + const workspaceId = wsData.workspaces[0].id; + const response = await request.get(`/api/teammate/${workspaceId}`); + + expect(response.status()).toBe(200); + + const data = await response.json(); + + expect(data).toHaveProperty('workspaceId'); + expect(data).toHaveProperty('teammates'); + expect(Array.isArray(data.teammates)).toBe(true); + } + }); +}); + +test.describe('API: error handling', () => { + test('GET /api/workspace/my without userId returns 400', async ({ request }) => { + const response = await request.get('/api/workspace/my'); + + expect(response.status()).toBe(400); + }); + + test('GET /api/workspace/999999 returns 404', async ({ request }) => { + const response = await request.get('/api/workspace/999999'); + + expect(response.status()).toBe(404); + }); + + test('GET /api/task?project=my without userId returns valid response', async ({ request }) => { + const response = await request.get('/api/task?project=my'); + + expect(response.ok()).toBe(true); + }); +}); diff --git a/e2e/pages.spec.ts b/e2e/pages.spec.ts new file mode 100644 index 0000000..3b38e6a --- /dev/null +++ b/e2e/pages.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/test'; + + +test.describe('Page loading', () => { + test('homepage loads and redirects', async ({ page }) => { + const response = await page.goto('/'); + + expect(response?.status()).toBeLessThan(400); + }); + + test('my-workspace page loads', async ({ page }) => { + const response = await page.goto('/my-workspace'); + + expect(response?.status()).toBeLessThan(400); + await expect(page).toHaveURL(/my-workspace/); + }); + + test('profile page loads', async ({ page }) => { + const response = await page.goto('/profile'); + + expect(response?.status()).toBeLessThan(400); + }); + + test('workspace page loads', async ({ page }) => { + const workspaces = await page.request.get('/api/workspace'); + const data = await workspaces.json(); + + if (Array.isArray(data) && data.length > 0) { + const response = await page.goto(`/workspace/${data[0].id}`); + + expect(response?.status()).toBeLessThan(400); + } + }); + + test('no console errors on my-workspace', async ({ page }) => { + const errors: string[] = []; + + page.on('console', (msg) => { + if (msg.type() === 'error') { + errors.push(msg.text()); + } + }); + + await page.goto('/my-workspace'); + await page.waitForTimeout(2000); + + const criticalErrors = errors.filter( + (e) => !e.includes('favicon') && !e.includes('hydration') + ); + + expect(criticalErrors).toHaveLength(0); + }); +}); diff --git a/e2e/performance.spec.ts b/e2e/performance.spec.ts new file mode 100644 index 0000000..ae0858c --- /dev/null +++ b/e2e/performance.spec.ts @@ -0,0 +1,90 @@ +import { test, expect } from '@playwright/test'; + + +const MOCK_TELEGRAM_ID = '862381667'; +const API_TIMEOUT_MS = 3000; + +test.describe('API performance', () => { + test('GET /api/workspace responds within timeout', async ({ request }) => { + const start = Date.now(); + const response = await request.get(`/api/workspace?userId=${MOCK_TELEGRAM_ID}`); + const duration = Date.now() - start; + + expect(response.status()).toBe(200); + expect(duration).toBeLessThan(API_TIMEOUT_MS); + }); + + test('GET /api/workspace/my responds within timeout', async ({ request }) => { + const start = Date.now(); + const response = await request.get(`/api/workspace/my?userId=${MOCK_TELEGRAM_ID}`); + const duration = Date.now() - start; + + expect(response.status()).toBe(200); + expect(duration).toBeLessThan(API_TIMEOUT_MS); + }); + + test('GET /api/task?project=my responds within timeout', async ({ request }) => { + const start = Date.now(); + const response = await request.get( + `/api/task?project=my&userId=${MOCK_TELEGRAM_ID}` + ); + const duration = Date.now() - start; + + expect(response.status()).toBe(200); + expect(duration).toBeLessThan(API_TIMEOUT_MS); + }); + + test('GET /api/project responds within timeout', async ({ request }) => { + const wsResponse = await request.get(`/api/workspace/my?userId=${MOCK_TELEGRAM_ID}`); + const ws = await wsResponse.json(); + + const start = Date.now(); + const response = await request.get(`/api/project?workspace=${ws.id}`); + const duration = Date.now() - start; + + expect(response.status()).toBe(200); + expect(duration).toBeLessThan(API_TIMEOUT_MS); + }); +}); + +test.describe('Page performance', () => { + test('my-workspace page loads within 5 seconds', async ({ page }) => { + const start = Date.now(); + + await page.goto('/my-workspace'); + await page.waitForLoadState('networkidle'); + + const duration = Date.now() - start; + + expect(duration).toBeLessThan(5000); + }); + + test('no layout shifts on my-workspace (CLS check)', async ({ page }) => { + await page.goto('/my-workspace'); + await page.waitForLoadState('networkidle'); + + const cls = await page.evaluate(() => { + return new Promise((resolve) => { + let clsValue = 0; + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + // @ts-expect-error LayoutShift API + if (!entry.hadRecentInput) { + // @ts-expect-error LayoutShift API + clsValue += entry.value; + } + } + }); + + observer.observe({ type: 'layout-shift', buffered: true }); + + setTimeout(() => { + observer.disconnect(); + resolve(clsValue); + }, 3000); + }); + }); + + expect(cls).toBeLessThan(0.25); + }); +}); diff --git a/package.json b/package.json index 0de68e8..f04f843 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "build": "prisma generate && next build", "start": "next start", "test": "vitest run", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "migrate:deploy": "prisma migrate deploy", "lint": "eslint src", "stylelint": "stylelint \"**/*.scss\"", @@ -39,6 +41,7 @@ "@commitlint/cli": "^20.3.1", "@commitlint/config-conventional": "^20.3.1", "@eslint/eslintrc": "^3", + "@playwright/test": "^1.58.2", "@svgr/webpack": "^8.1.0", "@types/node": "^20", "@types/pg": "^8.16.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..6e64c55 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from '@playwright/test'; + + +const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: process.env.BASE_URL || 'http://localhost:3000', + trace: 'on-first-retry', + ...(bypassSecret && { + extraHTTPHeaders: { + 'x-vercel-protection-bypass': bypassSecret, + }, + }), + }, + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 969894f..f6c6ab2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,13 +19,13 @@ importers: version: 7.3.0 '@vercel/analytics': specifier: ^1.6.1 - version: 1.6.1(next@16.1.6(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(react@19.2.4) + version: 1.6.1(next@16.1.6(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(react@19.2.4) '@vercel/speed-insights': specifier: ^1.3.1 - version: 1.3.1(next@16.1.6(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(react@19.2.4) + version: 1.3.1(next@16.1.6(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(react@19.2.4) next: specifier: ^16.1.5 - version: 16.1.6(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3) + version: 16.1.6(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3) next-rspack: specifier: ^16.1.6 version: 16.1.6(@swc/helpers@0.5.15) @@ -54,6 +54,9 @@ importers: '@eslint/eslintrc': specifier: ^3 version: 3.3.3 + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 '@svgr/webpack': specifier: ^8.1.0 version: 8.1.0(typescript@5.9.3) @@ -1479,6 +1482,11 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@prisma/adapter-pg@7.3.0': resolution: {integrity: sha512-iuYQMbIPO6i9O45Fv8TB7vWu00BXhCaNAShenqF7gLExGDbnGp5BfFB4yz1K59zQ59jF6tQ9YHrg0P6/J3OoLg==} @@ -2854,6 +2862,11 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3623,6 +3636,16 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -5843,6 +5866,10 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.6 optional: true + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@prisma/adapter-pg@7.3.0': dependencies: '@prisma/driver-adapter-utils': 7.3.0 @@ -6355,14 +6382,14 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/analytics@1.6.1(next@16.1.6(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(react@19.2.4)': + '@vercel/analytics@1.6.1(next@16.1.6(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(react@19.2.4)': optionalDependencies: - next: 16.1.6(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3) + next: 16.1.6(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3) react: 19.2.4 - '@vercel/speed-insights@1.3.1(next@16.1.6(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(react@19.2.4)': + '@vercel/speed-insights@1.3.1(next@16.1.6(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(react@19.2.4)': optionalDependencies: - next: 16.1.6(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3) + next: 16.1.6(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3) react: 19.2.4 '@vitest/expect@4.0.18': @@ -7369,6 +7396,9 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -7927,7 +7957,7 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - next@16.1.6(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3): + next@16.1.6(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3): dependencies: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 @@ -7946,6 +7976,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.1.6 '@next/swc-win32-arm64-msvc': 16.1.6 '@next/swc-win32-x64-msvc': 16.1.6 + '@playwright/test': 1.58.2 sass: 1.97.3 sharp: 0.34.5 transitivePeerDependencies: @@ -8129,6 +8160,14 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-media-query-parser@0.2.3: {} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3a82d2c..14ad070 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,6 +35,9 @@ model Workspace { projects Project[] tasks Task[] teammates UserWorkspace[] + + @@index([owner_id]) + @@index([owner_id, is_my_workspace]) } model UserWorkspace { @@ -45,6 +48,7 @@ model UserWorkspace { workspace Workspace @relation(fields: [workspace_id], references: [id]) @@unique([user_id, workspace_id]) + @@index([workspace_id]) } model Project { @@ -56,6 +60,8 @@ model Project { alias String workspace Workspace @relation(fields: [workspace_id], references: [id]) tasks Task[] + + @@index([workspace_id]) } model Task { @@ -71,4 +77,8 @@ model Task { serial String workspace Workspace @relation(fields: [workspace_id], references: [id]) project Project? @relation(fields: [project_id], references: [id]) + + @@index([workspace_id]) + @@index([project_id]) + @@index([type]) } diff --git a/src/app/api/task/route.ts b/src/app/api/task/route.ts index 0454d0d..099251d 100644 --- a/src/app/api/task/route.ts +++ b/src/app/api/task/route.ts @@ -130,8 +130,8 @@ export const GET = withLogging(async (req: NextRequest) => { ); } - const myWorkspace = await withDbTiming('workspace.findMyWorkspace', () => - db.workspace.findMyWorkspace(user.id) + const myWorkspace = await withDbTiming('workspace.findMyWorkspaceId', () => + db.workspace.findMyWorkspaceId(user.id) ); if (!myWorkspace) { diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 9c77482..28b6986 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,11 +1,38 @@ export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') { - const { loggerInfoCtx } = await import('@/shared/utils/logger/logger'); + const { loggerInfoCtx, loggerWarnCtx } = await import('@/shared/utils/logger/logger'); + const { Pool } = await import('pg'); loggerInfoCtx( { runtime: 'nodejs', node_version: process.version }, 'Instrumentation registered' ); + + const connectionString = process.env.DATABASE_URL; + + if (connectionString) { + const start = performance.now(); + const pool = new Pool({ connectionString }); + + try { + await pool.query('SELECT 1'); + const duration = Math.round(performance.now() - start); + + loggerInfoCtx( + { duration_ms: duration }, + 'DB connection warmup completed' + ); + } catch (error) { + const duration = Math.round(performance.now() - start); + + loggerWarnCtx( + { duration_ms: duration, error: String(error) }, + 'DB connection warmup failed' + ); + } finally { + await pool.end(); + } + } } } diff --git a/src/shared/utils/prisma/prismaClient.ts b/src/shared/utils/prisma/prismaClient.ts index 6d6e12d..0de21c4 100644 --- a/src/shared/utils/prisma/prismaClient.ts +++ b/src/shared/utils/prisma/prismaClient.ts @@ -45,7 +45,7 @@ export const db = { return prisma.workspace.findUnique({ where: { id }, include: { - tasks: true, + tasks: { select: { type: true } }, teammates: { select: { user_id: true } }, }, }); @@ -54,7 +54,7 @@ export const db = { findMany: async () => { return prisma.workspace.findMany({ include: { - tasks: true, + tasks: { select: { type: true } }, teammates: { select: { user_id: true } }, }, }); @@ -69,7 +69,7 @@ export const db = { ], }, include: { - tasks: true, + tasks: { select: { type: true } }, teammates: { select: { user_id: true } }, }, }); @@ -82,12 +82,22 @@ export const db = { is_my_workspace: true, }, include: { - tasks: true, + tasks: { select: { type: true } }, teammates: { select: { user_id: true } }, }, }); }, + findMyWorkspaceId: async (userId: number) => { + return prisma.workspace.findFirst({ + where: { + owner_id: userId, + is_my_workspace: true, + }, + select: { id: true }, + }); + }, + create: async (data: { title: string; description?: string | null; @@ -133,20 +143,20 @@ export const db = { findUnique: async (id: number) => { return prisma.project.findUnique({ where: { id }, - include: { tasks: true }, + include: { tasks: { select: { type: true } } }, }); }, findMany: async () => { return prisma.project.findMany({ - include: { tasks: true }, + include: { tasks: { select: { type: true } } }, }); }, findByWorkspace: async (workspaceId: number) => { return prisma.project.findMany({ where: { workspace_id: workspaceId }, - include: { tasks: true }, + include: { tasks: { select: { type: true } } }, }); }, diff --git a/vitest.config.ts b/vitest.config.ts index cb5e801..c8cf765 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,8 @@ import path from 'node:path'; export default defineConfig({ test: { - environment: 'jsdom' + environment: 'jsdom', + exclude: ['e2e/**', 'node_modules/**'], }, resolve: { alias: {