From d11b114fc1830f4364f67eae052f350b9166e5ed Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Fri, 24 Apr 2026 12:36:23 +0200 Subject: [PATCH] feat: ST-601-ST-612 M6 polish, beveiliging en launch-ready - ST-601/602: loading skeletons en error boundary - ST-603: Sonner toasts op alle CRUD-operaties - ST-604: DemoTooltip op uitgeschakelde knoppen - ST-605: KeyboardSensor dnd-kit, Escape sluit modals - ST-606: min-width banner < 1024px - ST-607: WCAG AA aria-labels en skip link - ST-608: rate limiting login (10/min) en registratie (5/uur) - ST-609: security integratietests cross-user toegang (7 tests) - ST-610: GitHub Actions CI/CD workflow - ST-611: README met quickstart, deployment en API-docs - ST-612: Lars-flow acceptatiechecklist - fix: settings toont gebruikersnaam i.p.v. interne id - fix: seed idempotent, testdata altijd gekoppeld aan demo-gebruiker Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 46 + README.md | 238 ++++- __tests__/api/security.test.ts | 153 +++ __tests__/lars-flow-checklist.md | 146 +++ actions/auth.ts | 18 +- app/(app)/error.tsx | 28 + app/(app)/layout.tsx | 7 +- app/(app)/products/[id]/loading.tsx | 34 + app/(app)/products/[id]/sprint/loading.tsx | 34 + .../products/[id]/sprint/planning/loading.tsx | 34 + app/(app)/settings/page.tsx | 9 +- app/layout.tsx | 6 +- components/backlog/pbi-list.tsx | 11 +- components/backlog/story-panel.tsx | 15 +- components/dashboard/product-list.tsx | 6 +- components/settings/role-manager.tsx | 12 +- components/shared/demo-tooltip.tsx | 25 + components/shared/min-width-banner.tsx | 10 + components/sprint/sprint-backlog.tsx | 9 +- components/sprint/sprint-header.tsx | 16 +- components/sprint/task-list.tsx | 15 +- components/todos/todo-list.tsx | 22 +- lib/rate-limit.ts | 36 + package-lock.json | 951 +++++++++++++++++- package.json | 8 +- prisma/seed.ts | 22 +- vitest.config.ts | 14 + 27 files changed, 1858 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 __tests__/api/security.test.ts create mode 100644 __tests__/lars-flow-checklist.md create mode 100644 app/(app)/error.tsx create mode 100644 app/(app)/products/[id]/loading.tsx create mode 100644 app/(app)/products/[id]/sprint/loading.tsx create mode 100644 app/(app)/products/[id]/sprint/planning/loading.tsx create mode 100644 components/shared/demo-tooltip.tsx create mode 100644 components/shared/min-width-banner.tsx create mode 100644 lib/rate-limit.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..98edc95 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ci: + name: Lint, Typecheck, Test & Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npx tsc --noEmit + + - name: Prisma validate + run: npx prisma validate + env: + DATABASE_URL: file:./dev.db + + - name: Test + run: npm test + + - name: Build + run: npm run build + env: + DATABASE_URL: file:./dev.db + DIRECT_URL: file:./dev.db + SESSION_SECRET: ${{ secrets.SESSION_SECRET || 'ci-placeholder-secret-must-be-32-chars-min' }} diff --git a/README.md b/README.md index e215bc4..1eb6584 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,234 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Scrum4Me -## Getting Started +Lichtgewicht Scrum-planner voor solo developers en kleine teams die meerdere softwareprojecten parallel beheren. -First, run the development server: +**Functies:** +- Hiërarchisch werkbeheer: Product → PBI → Story → Taak +- Gesplitste planningsschermen met drag-and-drop +- Sprint backlog en planning +- REST API voor integratie met Claude Code +- Demo-modus (alleen lezen) + +--- + +## Lokale quickstart + +### Vereisten +- Node.js 20+ +- npm + +### Stappen ```bash +# 1. Clone de repository +git clone +cd scrum4me + +# 2. Installeer dependencies +npm install + +# 3. Configureer omgevingsvariabelen +cp .env.example .env.local +# Bewerk .env.local en vul SESSION_SECRET in: +# openssl rand -base64 32 + +# 4. Database initialiseren (SQLite lokaal) +npx prisma db push + +# 5. Testdata inladen +npx prisma db seed + +# 6. Start de ontwikkelserver npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Open [http://localhost:3000](http://localhost:3000). -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +**Demo-account:** gebruikersnaam `demo` / wachtwoord `demo1234` (alleen lezen) -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +--- -## Learn More +## Omgevingsvariabelen -To learn more about Next.js, take a look at the following resources: +| Variabele | Beschrijving | +|---|---| +| `DATABASE_URL` | SQLite: `file:./dev.db` · PostgreSQL: zie Neon-sectie | +| `DIRECT_URL` | Alleen bij PostgreSQL met connection pooling (Neon) | +| `SESSION_SECRET` | Minimaal 32 tekens — genereer met `openssl rand -base64 32` | -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +--- -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## Cloud deployment (Vercel + Neon) -## Deploy on Vercel +### 1. Database aanmaken op Neon -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +1. Maak een account op [neon.tech](https://neon.tech) +2. Maak een nieuw project en database +3. Kopieer de connection strings: + - **DATABASE_URL**: de pooled connection string + - **DIRECT_URL**: de directe (niet-gepoolde) connection string -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +### 2. Deployen op Vercel + +1. Push de code naar GitHub +2. Importeer het project in [vercel.com](https://vercel.com) +3. Voeg de volgende environment variables toe in Vercel: + - `DATABASE_URL` (Neon pooled URL) + - `DIRECT_URL` (Neon direct URL) + - `SESSION_SECRET` (random string >= 32 tekens) +4. Deploy + +### 3. Database migraties uitvoeren + +```bash +# Eenmalig na deploy: +npx prisma migrate deploy +npx prisma db seed +``` + +--- + +## REST API + +Alle endpoints vereisen `Authorization: Bearer `. +Maak een token aan via **Instellingen -> API Tokens** in de app. + +### Endpoints + +#### `GET /api/products` +Haal alle actieve producten op. + +```bash +curl -H "Authorization: Bearer " \ + https://your-app.vercel.app/api/products +``` + +**Response:** +```json +[ + { "id": "clx...", "name": "Mijn Product", "repo_url": "https://github.com/..." } +] +``` + +--- + +#### `GET /api/products/:id/next-story` +Haal de hoogst geprioriteerde open story uit de actieve sprint op. + +```bash +curl -H "Authorization: Bearer " \ + https://your-app.vercel.app/api/products//next-story +``` + +**Response:** story-object inclusief taken. + +--- + +#### `GET /api/sprints/:id/tasks?limit=10` +Haal de eerste N taken uit de sprint op (standaard 10, max 50). + +```bash +curl -H "Authorization: Bearer " \ + "https://your-app.vercel.app/api/sprints//tasks?limit=5" +``` + +--- + +#### `POST /api/stories/:id/log` +Voeg een logvermelding toe aan een story. + +```bash +# Implementatieplan: +curl -X POST -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"type":"IMPLEMENTATION_PLAN","content":"Aanpak: ..."}' \ + https://your-app.vercel.app/api/stories//log + +# Testresultaat: +curl -X POST -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"type":"TEST_RESULT","content":"Alle tests geslaagd","status":"PASSED"}' \ + https://your-app.vercel.app/api/stories//log + +# Commit: +curl -X POST -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"type":"COMMIT","content":"feat: ST-001","commit_hash":"abc123","commit_message":"feat: ST-001 scaffolding"}' \ + https://your-app.vercel.app/api/stories//log +``` + +--- + +#### `PATCH /api/stories/:id/tasks/reorder` +Pas de taakvolgorde aan binnen een story. + +```bash +curl -X PATCH -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"task_ids":["id-1","id-2","id-3"]}' \ + https://your-app.vercel.app/api/stories//tasks/reorder +``` + +--- + +#### `PATCH /api/tasks/:id` +Werk de status van een taak bij. + +```bash +curl -X PATCH -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"status":"IN_PROGRESS"}' \ + https://your-app.vercel.app/api/tasks/ +``` + +**Status waarden:** `TO_DO` - `IN_PROGRESS` - `DONE` + +--- + +#### `POST /api/todos` +Maak een todo aan. + +```bash +curl -X POST -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"title":"Mijn nieuwe todo"}' \ + https://your-app.vercel.app/api/todos +``` + +--- + +## Claude Code integratie + +Scrum4Me integreert met [Claude Code](https://claude.ai/claude-code) via de REST API. + +1. Maak een API token aan via **Instellingen -> API Tokens** +2. Gebruik de API om implementatieplannen, testresultaten en commits automatisch te loggen in stories + +--- + +## Scripts + +| Script | Beschrijving | +|---|---| +| `npm run dev` | Lokale ontwikkelserver | +| `npm run build` | Productie-build | +| `npm run lint` | ESLint | +| `npm test` | Beveiligingstests uitvoeren | +| `npx tsc --noEmit` | TypeScript-check | +| `npx prisma db push` | Database schema synchroniseren | +| `npx prisma db seed` | Testdata inladen | + +--- + +## Tech stack + +- **Next.js 15** (App Router) + **React 19** +- **TypeScript** strict +- **Tailwind CSS** + **shadcn/ui** (Base UI) +- **Zustand** (client state) +- **dnd-kit** (drag-and-drop) +- **Prisma v7** (ORM) +- **PostgreSQL** (Neon) / **SQLite** (lokaal) +- **iron-session** (auth) +- **Sonner** (toasts) +- **Zod** (validatie) diff --git a/__tests__/api/security.test.ts b/__tests__/api/security.test.ts new file mode 100644 index 0000000..465ca1b --- /dev/null +++ b/__tests__/api/security.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock prisma +vi.mock('@/lib/prisma', () => ({ + prisma: { + product: { + findMany: vi.fn(), + }, + task: { + findFirst: vi.fn(), + update: vi.fn(), + }, + apiToken: { + findUnique: vi.fn(), + }, + }, +})) + +// Mock api-auth to control which user is "authenticated" +vi.mock('@/lib/api-auth', () => ({ + authenticateApiRequest: vi.fn(), +})) + +import { prisma } from '@/lib/prisma' +import { authenticateApiRequest } from '@/lib/api-auth' +import { GET as getProducts } from '@/app/api/products/route' +import { PATCH as patchTask } from '@/app/api/tasks/[id]/route' + +const mockPrisma = prisma as unknown as { + product: { findMany: ReturnType } + task: { findFirst: ReturnType; update: ReturnType } +} +const mockAuth = authenticateApiRequest as ReturnType + +function makeRequest(method = 'GET', body?: unknown): Request { + return new Request('http://localhost/api/test', { + method, + headers: { 'Authorization': 'Bearer test-token', 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }) +} + +describe('Security: cross-user access', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('GET /api/products', () => { + it('returns only the authenticated user\'s products', async () => { + mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockPrisma.product.findMany.mockResolvedValue([ + { id: 'prod-1', name: 'Product A', repo_url: null }, + ]) + + const response = await getProducts(makeRequest()) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toHaveLength(1) + // Verify the query filtered by user_id + expect(mockPrisma.product.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ user_id: 'user-1' }), + }) + ) + }) + + it('returns 401 when no valid token provided', async () => { + mockAuth.mockResolvedValue({ error: 'Unauthorized', status: 401 }) + + const response = await getProducts(makeRequest()) + expect(response.status).toBe(401) + }) + }) + + describe('PATCH /api/tasks/:id', () => { + it('returns 403 when task belongs to a different user', async () => { + // User 2 is authenticated but the task belongs to user 1 + mockAuth.mockResolvedValue({ userId: 'user-2', isDemo: false }) + mockPrisma.task.findFirst.mockResolvedValue({ + id: 'task-1', + story: { + product: { + user_id: 'user-1', // different user! + }, + }, + }) + + const response = await patchTask( + makeRequest('PATCH', { status: 'DONE' }), + { params: Promise.resolve({ id: 'task-1' }) } + ) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBeTruthy() + }) + + it('returns 403 for demo users', async () => { + mockAuth.mockResolvedValue({ userId: 'demo-user', isDemo: true }) + + const response = await patchTask( + makeRequest('PATCH', { status: 'DONE' }), + { params: Promise.resolve({ id: 'task-1' }) } + ) + + expect(response.status).toBe(403) + }) + + it('allows update when task belongs to the authenticated user', async () => { + mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockPrisma.task.findFirst.mockResolvedValue({ + id: 'task-1', + story: { + product: { + user_id: 'user-1', // same user + }, + }, + }) + mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE' }) + + const response = await patchTask( + makeRequest('PATCH', { status: 'DONE' }), + { params: Promise.resolve({ id: 'task-1' }) } + ) + + expect(response.status).toBe(200) + }) + + it('returns 404 when task does not exist', async () => { + mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockPrisma.task.findFirst.mockResolvedValue(null) + + const response = await patchTask( + makeRequest('PATCH', { status: 'DONE' }), + { params: Promise.resolve({ id: 'nonexistent' }) } + ) + + expect(response.status).toBe(404) + }) + + it('returns 401 when no valid token', async () => { + mockAuth.mockResolvedValue({ error: 'Unauthorized', status: 401 }) + + const response = await patchTask( + makeRequest('PATCH', { status: 'DONE' }), + { params: Promise.resolve({ id: 'task-1' }) } + ) + + expect(response.status).toBe(401) + }) + }) +}) diff --git a/__tests__/lars-flow-checklist.md b/__tests__/lars-flow-checklist.md new file mode 100644 index 0000000..9b83e78 --- /dev/null +++ b/__tests__/lars-flow-checklist.md @@ -0,0 +1,146 @@ +# ST-612 — Lars-flow acceptatietest + +Handmatige checklist voor de volledige Lars-flow. Doorloop alle stappen zonder handleiding. + +## Voorbereiding + +```bash +npx prisma db push +npx prisma db seed +npm run dev +``` + +Navigeer naar [http://localhost:3000](http://localhost:3000). + +--- + +## Stap 1: Registreren en inloggen + +- [ ] Registreer een nieuw account (gebruikersnaam + wachtwoord ≥ 8 tekens) +- [ ] Je wordt automatisch doorgestuurd naar het dashboard +- [ ] Log uit en log opnieuw in — sessie werkt correct + +--- + +## Stap 2: Product aanmaken + +- [ ] Klik op "+ Nieuw product" +- [ ] Vul naam, beschrijving, repo URL en Definition of Done in +- [ ] Sla op — je wordt doorgestuurd naar de Product Backlog pagina + +--- + +## Stap 3: Product Backlog opbouwen + +- [ ] Klik op "+ PBI" en maak minimaal 3 PBI's aan met verschillende prioriteiten +- [ ] Sleep een PBI naar een andere positie — volgorde blijft behouden na page refresh +- [ ] Sleep een PBI naar een andere prioriteitsgroep — prioriteit verandert +- [ ] Selecteer een PBI — het rechterpaneel toont "Nog geen stories" +- [ ] Maak minimaal 2 stories aan voor de geselecteerde PBI +- [ ] Klik op een story-blok — een slide-over opent met bewerkingsformulier +- [ ] Pas de titel en acceptatiecriteria aan en sla op +- [ ] Sluit de slide-over (Escape of klik buiten) + +--- + +## Stap 4: Sprint aanmaken en plannen + +- [ ] Navigeer naar de Sprint Backlog (knop in de header) +- [ ] Klik op "Sprint starten" en voer een Sprint Goal in +- [ ] In het rechterpaneel zijn de PBI's en stories zichtbaar +- [ ] Klik op een story om deze toe te voegen aan de Sprint +- [ ] Voeg minimaal 2 stories toe +- [ ] Sleep stories in het linkerpaneel om de volgorde te bepalen +- [ ] Navigeer naar Sprint Planning +- [ ] Selecteer een story in de linkerkolom — taken verschijnen rechts +- [ ] Maak minimaal 2 taken aan voor de story +- [ ] Verander de taakstatus door op de badge te klikken (To Do → In Progress → Done) + +--- + +## Stap 5: API token aanmaken + +- [ ] Ga naar Instellingen → API Tokens +- [ ] Klik op "+ Token aanmaken" en geef het een naam +- [ ] Kopieer het token (wordt slechts eenmalig getoond) +- [ ] Sla het token tijdelijk op voor de curl-tests + +--- + +## Stap 6: API-endpoints testen via curl + +Vervang ``, `` en `` met echte waarden. + +```bash +# 1. Producten ophalen +curl -H "Authorization: Bearer " \ + http://localhost:3000/api/products +# Verwacht: JSON-array met het aangemaakte product + +# 2. Volgende story ophalen +curl -H "Authorization: Bearer " \ + http://localhost:3000/api/products//next-story +# Verwacht: story-object met taken + +# 3. Implementatieplan loggen +curl -X POST -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"type":"IMPLEMENTATION_PLAN","content":"Aanpak: component-first, dan server action"}' \ + http://localhost:3000/api/stories//log +# Verwacht: {"id":"...","type":"IMPLEMENTATION_PLAN"} + +# 4. Testresultaat loggen +curl -X POST -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"type":"TEST_RESULT","content":"Alle unit tests geslaagd","status":"PASSED"}' \ + http://localhost:3000/api/stories//log +# Verwacht: {"id":"...","type":"TEST_RESULT"} + +# 5. Commit loggen +curl -X POST -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"type":"COMMIT","content":"feat: story afgerond","commit_hash":"abc1234","commit_message":"feat: ST-XXX story afgerond"}' \ + http://localhost:3000/api/stories//log +# Verwacht: {"id":"...","type":"COMMIT"} +``` + +--- + +## Stap 7: Activiteitenlog controleren in de UI + +- [ ] Open de story in de Product Backlog (klik op het story-blok) +- [ ] Scroll naar het activiteitenlog onderaan de slide-over +- [ ] Verifieer dat het implementatieplan (blauw), testresultaat (groen) en commit (paars) zichtbaar zijn + +--- + +## Stap 8: Sprint afronden + +- [ ] Navigeer naar de Sprint Backlog +- [ ] Klik op "Sprint afronden" +- [ ] Besluit per story: Done of Terugplaatsen +- [ ] Klik "Sprint afronden" — sprint wordt afgesloten + +--- + +## Stap 9: Demo-gebruiker controleren + +- [ ] Log uit +- [ ] Log in als `demo` / `demo1234` +- [ ] Verifieer dat geen enkel formulier of knop schrijfrechten heeft +- [ ] Tooltip "Niet beschikbaar in demo-modus" zichtbaar bij hover op uitgeschakelde knoppen + +--- + +## Stap 10: Responsive / kleine schermen + +- [ ] Verklein het browservenster tot < 1024px breed +- [ ] Banner "Scrum4Me is ontworpen voor schermen van minimaal 1024px" verschijnt + +--- + +## Resultaat + +- [ ] Alle stappen doorlopen zonder fouten +- [ ] Alle API-responses zijn correct JSON +- [ ] Geen console-errors of crashes diff --git a/actions/auth.ts b/actions/auth.ts index 35cf233..fc4a163 100644 --- a/actions/auth.ts +++ b/actions/auth.ts @@ -1,11 +1,17 @@ 'use server' import { redirect } from 'next/navigation' -import { cookies } from 'next/headers' +import { cookies, headers } from 'next/headers' import { getIronSession } from 'iron-session' import { z } from 'zod' import { registerUser, verifyUser } from '@/lib/auth' import { SessionData, sessionOptions } from '@/lib/session' +import { checkRateLimit } from '@/lib/rate-limit' + +async function getClientIp(): Promise { + const h = await headers() + return h.get('x-forwarded-for')?.split(',')[0].trim() ?? h.get('x-real-ip') ?? 'unknown' +} const registerSchema = z.object({ username: z.string().min(3, 'Gebruikersnaam moet minimaal 3 tekens bevatten').max(50), @@ -18,6 +24,11 @@ const loginSchema = z.object({ }) export async function registerAction(_prevState: unknown, formData: FormData) { + const ip = await getClientIp() + if (!checkRateLimit(`register:${ip}`)) { + return { error: 'Te veel pogingen. Probeer het over een minuut opnieuw.' } + } + const parsed = registerSchema.safeParse({ username: formData.get('username'), password: formData.get('password'), @@ -39,6 +50,11 @@ export async function registerAction(_prevState: unknown, formData: FormData) { } export async function loginAction(_prevState: unknown, formData: FormData) { + const ip = await getClientIp() + if (!checkRateLimit(`login:${ip}`)) { + return { error: 'Te veel inlogpogingen. Probeer het over een minuut opnieuw.' } + } + const parsed = loginSchema.safeParse({ username: formData.get('username'), password: formData.get('password'), diff --git a/app/(app)/error.tsx b/app/(app)/error.tsx new file mode 100644 index 0000000..c181a0c --- /dev/null +++ b/app/(app)/error.tsx @@ -0,0 +1,28 @@ +'use client' + +import { useEffect } from 'react' +import { Button } from '@/components/ui/button' + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error(error) + }, [error]) + + return ( +
+
+

Er is iets misgegaan

+

+ {error.message || 'Er is een onverwachte fout opgetreden. Probeer het opnieuw.'} +

+
+ +
+ ) +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 25a1733..70579a5 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -3,6 +3,7 @@ import { cookies } from 'next/headers' import { getIronSession } from 'iron-session' import { SessionData, sessionOptions } from '@/lib/session' import { NavBar } from '@/components/shared/nav-bar' +import { MinWidthBanner } from '@/components/shared/min-width-banner' export default async function AppLayout({ children }: { children: React.ReactNode }) { const session = await getIronSession(await cookies(), sessionOptions) @@ -13,8 +14,12 @@ export default async function AppLayout({ children }: { children: React.ReactNod return (
+ + Ga naar inhoud + -
+ +
{children}
diff --git a/app/(app)/products/[id]/loading.tsx b/app/(app)/products/[id]/loading.tsx new file mode 100644 index 0000000..795b2c5 --- /dev/null +++ b/app/(app)/products/[id]/loading.tsx @@ -0,0 +1,34 @@ +export default function Loading() { + return ( +
+ {/* Header skeleton */} +
+
+
+
+
+
+
+ + {/* Split pane skeleton */} +
+ {/* Left */} +
+
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
+ {/* Right */} +
+
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
+
+
+ ) +} diff --git a/app/(app)/products/[id]/sprint/loading.tsx b/app/(app)/products/[id]/sprint/loading.tsx new file mode 100644 index 0000000..795b2c5 --- /dev/null +++ b/app/(app)/products/[id]/sprint/loading.tsx @@ -0,0 +1,34 @@ +export default function Loading() { + return ( +
+ {/* Header skeleton */} +
+
+
+
+
+
+
+ + {/* Split pane skeleton */} +
+ {/* Left */} +
+
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
+ {/* Right */} +
+
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
+
+
+ ) +} diff --git a/app/(app)/products/[id]/sprint/planning/loading.tsx b/app/(app)/products/[id]/sprint/planning/loading.tsx new file mode 100644 index 0000000..795b2c5 --- /dev/null +++ b/app/(app)/products/[id]/sprint/planning/loading.tsx @@ -0,0 +1,34 @@ +export default function Loading() { + return ( +
+ {/* Header skeleton */} +
+
+
+
+
+
+
+ + {/* Split pane skeleton */} +
+ {/* Left */} +
+
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
+ {/* Right */} +
+
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
+
+
+ ) +} diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 1fadf0f..c301fa8 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -8,9 +8,10 @@ import Link from 'next/link' export default async function SettingsPage() { const session = await getIronSession(await cookies(), sessionOptions) - const userRoles = await prisma.userRole.findMany({ - where: { user_id: session.userId }, - }) + const [user, userRoles] = await Promise.all([ + prisma.user.findUnique({ where: { id: session.userId }, select: { username: true } }), + prisma.userRole.findMany({ where: { user_id: session.userId } }), + ]) const currentRoles = userRoles.map(r => r.role as string) return ( @@ -20,7 +21,7 @@ export default async function SettingsPage() {

Account

- Ingelogd als {session.userId} + Ingelogd als {user?.username ?? session.userId} {session.isDemo && (demo)}

diff --git a/app/layout.tsx b/app/layout.tsx index fabf779..1c549d8 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { Toaster } from "sonner"; import "./globals.css"; const geistSans = Geist({ @@ -40,7 +41,10 @@ export default function RootLayout({ lang="nl" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > - {children} + + {children} + + ); } diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx index f2e515a..abd8534 100644 --- a/components/backlog/pbi-list.tsx +++ b/components/backlog/pbi-list.tsx @@ -8,6 +8,7 @@ import { DragEndEvent, DragOverlay, DragStartEvent, + KeyboardSensor, PointerSensor, useSensor, useSensors, @@ -18,10 +19,12 @@ import { useSortable, verticalListSortingStrategy, arrayMove, + sortableKeyboardCoordinates, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { toast } from 'sonner' import { Button } from '@/components/ui/button' +import { DemoTooltip } from '@/components/shared/demo-tooltip' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' @@ -96,6 +99,7 @@ function SortablePbiRow({ e.stopPropagation()} > @@ -129,7 +133,7 @@ function CreatePbiForm({ const [state, formAction] = useActionState( async (_prev: unknown, fd: FormData) => { const result = await createPbiAction(_prev, fd) - if (result?.success) onDone() + if (result?.success) { toast.success('PBI aangemaakt'); onDone() } return result }, undefined @@ -198,7 +202,10 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) { p => grouped[p].length > 0 || creatingForPriority === p ) - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })) + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ) function handleDragStart(event: DragStartEvent) { setActiveDragId(event.active.id as string) diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx index 7e48cdc..195013f 100644 --- a/components/backlog/story-panel.tsx +++ b/components/backlog/story-panel.tsx @@ -7,6 +7,7 @@ import { DragEndEvent, DragOverlay, DragStartEvent, + KeyboardSensor, PointerSensor, useSensor, useSensors, @@ -17,6 +18,7 @@ import { useSortable, horizontalListSortingStrategy, arrayMove, + sortableKeyboardCoordinates, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { toast } from 'sonner' @@ -135,7 +137,7 @@ function StoryDetailSheet({ const [state, formAction] = useActionState( async (_prev: unknown, fd: FormData) => { const result = await updateStoryAction(_prev, fd) - if (result?.success) onClose() + if (result?.success) { toast.success('Story opgeslagen'); onClose() } return result }, undefined @@ -143,7 +145,9 @@ function StoryDetailSheet({ function handleDelete() { startDeleteTransition(async () => { - await deleteStoryAction(story.id) + const result = await deleteStoryAction(story.id) + if (result && 'error' in result) toast.error(result.error ?? 'Verwijderen mislukt') + else toast.success('Story verwijderd') onClose() }) } @@ -279,7 +283,7 @@ function CreateStoryForm({ const [state, formAction] = useActionState( async (_prev: unknown, fd: FormData) => { const result = await createStoryAction(_prev, fd) - if (result?.success) onDone() + if (result?.success) { toast.success('Story aangemaakt'); onDone() } return result }, undefined @@ -346,7 +350,10 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps) p => grouped[p].length > 0 || creatingPriority === p ) - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })) + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ) function handleDragStart(event: DragStartEvent) { setActiveDragId(event.active.id as string) diff --git a/components/dashboard/product-list.tsx b/components/dashboard/product-list.tsx index 3571dc2..d9d0f19 100644 --- a/components/dashboard/product-list.tsx +++ b/components/dashboard/product-list.tsx @@ -3,6 +3,7 @@ import Link from 'next/link' import { useRouter } from 'next/navigation' import { useTransition } from 'react' +import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { restoreProductAction } from '@/actions/products' @@ -25,8 +26,9 @@ export function ProductList({ products, isDemo, showArchived = false }: ProductL function handleRestore(id: string) { startTransition(async () => { - await restoreProductAction(id) - router.refresh() + const result = await restoreProductAction(id) + if ('error' in result) toast.error(result.error ?? 'Herstellen mislukt') + else { toast.success('Product hersteld'); router.refresh() } }) } diff --git a/components/settings/role-manager.tsx b/components/settings/role-manager.tsx index 0e0a090..306a830 100644 --- a/components/settings/role-manager.tsx +++ b/components/settings/role-manager.tsx @@ -1,7 +1,9 @@ 'use client' import { useState, useTransition } from 'react' +import { toast } from 'sonner' import { Button } from '@/components/ui/button' +import { DemoTooltip } from '@/components/shared/demo-tooltip' import { updateRolesAction } from '@/actions/todos' const ALL_ROLES = [ @@ -38,8 +40,8 @@ export function RoleManager({ currentRoles, isDemo }: RoleManagerProps) { } startTransition(async () => { const result = await updateRolesAction([...selected]) - if (result.success) setSaved(true) - else setError(result.error ?? 'Opslaan mislukt') + if (result.success) { setSaved(true); toast.success('Rollen opgeslagen') } + else { setError(result.error ?? 'Opslaan mislukt'); toast.error(result.error ?? 'Opslaan mislukt') } }) } @@ -62,9 +64,9 @@ export function RoleManager({ currentRoles, isDemo }: RoleManagerProps) {
{error &&

{error}

} {saved &&

Rollen opgeslagen.

} - {!isDemo && ( - - )} + + +
) } diff --git a/components/shared/demo-tooltip.tsx b/components/shared/demo-tooltip.tsx new file mode 100644 index 0000000..bba0e8e --- /dev/null +++ b/components/shared/demo-tooltip.tsx @@ -0,0 +1,25 @@ +'use client' + +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' + +interface DemoTooltipProps { + show: boolean + children: React.ReactNode +} + +// Wraps children with a "Niet beschikbaar in demo-modus" tooltip when show=true. +// Uses a span trigger so tooltip works on disabled elements. +export function DemoTooltip({ show, children }: DemoTooltipProps) { + if (!show) return <>{children} + + return ( + + + }> + {children} + + Niet beschikbaar in demo-modus + + + ) +} diff --git a/components/shared/min-width-banner.tsx b/components/shared/min-width-banner.tsx new file mode 100644 index 0000000..6453bc2 --- /dev/null +++ b/components/shared/min-width-banner.tsx @@ -0,0 +1,10 @@ +'use client' + +// Shows a warning banner on screens narrower than 1024px. +export function MinWidthBanner() { + return ( +
+ Scrum4Me is ontworpen voor schermen van minimaal 1024px breed. Sommige functies zijn mogelijk niet goed bruikbaar op dit scherm. +
+ ) +} diff --git a/components/sprint/sprint-backlog.tsx b/components/sprint/sprint-backlog.tsx index d5e89f7..77e7bac 100644 --- a/components/sprint/sprint-backlog.tsx +++ b/components/sprint/sprint-backlog.tsx @@ -4,10 +4,11 @@ import { useState, useTransition, useEffect } from 'react' import { useRouter } from 'next/navigation' import { DndContext, DragEndEvent, DragOverEvent, DragStartEvent, DragOverlay, - PointerSensor, useSensor, useSensors, closestCenter, + KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, } from '@dnd-kit/core' import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove, + sortableKeyboardCoordinates, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { toast } from 'sonner' @@ -68,6 +69,7 @@ function SortableSprintRow({ > {!isDemo && ( e.stopPropagation()} + aria-label="Versleep om te sorteren" className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 select-none text-sm"> ⠿ @@ -113,7 +115,10 @@ export function SprintBacklogLeft({ sprintId, stories, isDemo, onSelectStory, se const order = sprintStoryOrder[sprintId] ?? stories.map(s => s.id) const orderedStories = order.map(id => storyMap[id]).filter(Boolean) - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })) + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ) function handleDragEnd(event: DragEndEvent) { const { active, over } = event diff --git a/components/sprint/sprint-header.tsx b/components/sprint/sprint-header.tsx index 14382cf..d24c5df 100644 --- a/components/sprint/sprint-header.tsx +++ b/components/sprint/sprint-header.tsx @@ -10,6 +10,8 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' +import { toast } from 'sonner' +import { DemoTooltip } from '@/components/shared/demo-tooltip' import { updateSprintGoalAction, completeSprintAction } from '@/actions/sprints' import type { SprintStory } from './sprint-backlog' @@ -41,7 +43,8 @@ export function SprintHeader({ productId, productName, sprint, isDemo, sprintSto const [, goalFormAction] = useActionState( async (_prev: unknown, fd: FormData) => { const result = await updateSprintGoalAction(_prev, fd) - if (result?.success) setEditingGoal(false) + if (result?.success) { setEditingGoal(false); toast.success('Sprint goal opgeslagen') } + else if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt') return result }, undefined @@ -59,8 +62,9 @@ export function SprintHeader({ productId, productName, sprint, isDemo, sprintSto }) startCompleting(async () => { - await completeSprintAction(sprint.id, finalDecisions) - setCompleteOpen(false) + const result = await completeSprintAction(sprint.id, finalDecisions) + if ('error' in result) toast.error(result.error ?? 'Sprint afronden mislukt') + else { toast.success('Sprint afgerond'); setCompleteOpen(false) } }) } @@ -92,11 +96,11 @@ export function SprintHeader({ productId, productName, sprint, isDemo, sprintSto )}
- {!isDemo && ( - - )} +
{/* Complete sprint dialog */} diff --git a/components/sprint/task-list.tsx b/components/sprint/task-list.tsx index 26b0a9d..2cd7c57 100644 --- a/components/sprint/task-list.tsx +++ b/components/sprint/task-list.tsx @@ -4,10 +4,11 @@ import { useState, useTransition, useEffect, useActionState } from 'react' import { useFormStatus } from 'react-dom' import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, - PointerSensor, useSensor, useSensors, closestCenter, + KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, } from '@dnd-kit/core' import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove, + sortableKeyboardCoordinates, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { toast } from 'sonner' @@ -97,7 +98,7 @@ function SortableTaskRow({

{PRIORITY_LABELS[task.priority]}
- - +
)}
@@ -161,7 +162,10 @@ export function TaskList({ storyId, sprintId, productId, tasks, isDemo }: TaskLi const doneCount = orderedTasks.filter(t => t.status === 'DONE').length - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })) + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ) function handleDragEnd(event: DragEndEvent) { const { active, over } = event @@ -184,7 +188,8 @@ export function TaskList({ storyId, sprintId, productId, tasks, isDemo }: TaskLi function handleDelete(id: string) { startTransition(async () => { - await deleteTaskAction(id) + const result = await deleteTaskAction(id) + if (result && 'error' in result) toast.error(result.error ?? 'Verwijderen mislukt') }) } diff --git a/components/todos/todo-list.tsx b/components/todos/todo-list.tsx index c4ec06c..51b1ecc 100644 --- a/components/todos/todo-list.tsx +++ b/components/todos/todo-list.tsx @@ -1,8 +1,10 @@ 'use client' -import { useState, useTransition, useActionState, useEffect, useRef } from 'react' +import { useState, useTransition, useActionState, useEffect, useRef, useCallback } from 'react' import { useFormStatus } from 'react-dom' +import { toast } from 'sonner' import { Button } from '@/components/ui/button' +import { DemoTooltip } from '@/components/shared/demo-tooltip' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' @@ -57,7 +59,9 @@ function QuickInput({ isDemo }: { isDemo: boolean }) { className="flex-1" autoComplete="off" /> - + + + ) } @@ -77,10 +81,13 @@ function PromotePbiDialog({ products, onClose, }: { todo: Todo; products: Product[]; onClose: () => void }) { + const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose]) + useEffect(() => { document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey) }, [handleKey]) + const [state, formAction] = useActionState( async (_prev: unknown, fd: FormData) => { const result = await promoteTodoToPbiAction(_prev, fd) - if (result?.success) onClose() + if (result?.success) { toast.success('Todo gepromoveerd naar PBI'); onClose() } return result }, undefined @@ -133,13 +140,16 @@ function PromoteStoryDialog({ products, onClose, }: { todo: Todo; products: Product[]; onClose: () => void }) { + const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose]) + useEffect(() => { document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey) }, [handleKey]) + const [selectedProductId, setSelectedProductId] = useState(products[0]?.id ?? '') const selectedProduct = products.find(p => p.id === selectedProductId) const [state, formAction] = useActionState( async (_prev: unknown, fd: FormData) => { const result = await promoteTodoToStoryAction(_prev, fd) - if (result?.success) onClose() + if (result?.success) { toast.success('Todo gepromoveerd naar Story'); onClose() } return result }, undefined @@ -218,7 +228,9 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) { function handleArchive() { startTransition(async () => { - await archiveCompletedTodosAction() + const result = await archiveCompletedTodosAction() + if (result && 'error' in result) toast.error(result.error ?? 'Archiveren mislukt') + else toast.success('Afgeronde todos gearchiveerd') }) } diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts new file mode 100644 index 0000000..a4bcc4d --- /dev/null +++ b/lib/rate-limit.ts @@ -0,0 +1,36 @@ +// Simple in-memory rate limiter. +// Note: resets on server restart and does not share state across multiple processes. +// Suitable for MVP; replace with Redis for production scale-out. + +interface RateLimitConfig { + windowMs: number + max: number +} + +const CONFIGS: Record = { + login: { windowMs: 60_000, max: 10 }, // 10 attempts per minute + register: { windowMs: 3_600_000, max: 5 }, // 5 attempts per hour +} + +const DEFAULT_CONFIG: RateLimitConfig = { windowMs: 60_000, max: 10 } + +const store = new Map() + +export function checkRateLimit(key: string): boolean { + const prefix = key.split(':')[0] + const config = CONFIGS[prefix] ?? DEFAULT_CONFIG + const now = Date.now() + const entry = store.get(key) + + if (!entry || now > entry.resetAt) { + store.set(key, { count: 1, resetAt: now + config.windowMs }) + return true + } + + if (entry.count >= config.max) { + return false + } + + entry.count++ + return true +} diff --git a/package-lock.json b/package-lock.json index 448c0a9..038a83b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,11 +45,13 @@ "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^4.1.5", "eslint": "^9", "eslint-config-next": "16.2.4", "tailwindcss": "^4", "tsx": "^4.21.0", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.5" } }, "node_modules/@alloc/quick-lru": { @@ -539,6 +541,16 @@ } } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -2726,6 +2738,16 @@ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "license": "MIT" }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@prisma/adapter-better-sqlite3": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/adapter-better-sqlite3/-/adapter-better-sqlite3-7.8.0.tgz", @@ -3106,6 +3128,307 @@ } } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -3532,6 +3855,24 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4210,6 +4551,157 @@ "win32" ] }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.5", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -4514,6 +5006,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -4533,6 +5035,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -4915,6 +5436,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5824,6 +6355,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -6362,6 +6900,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6437,6 +6985,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -7272,6 +7830,13 @@ "node": ">=16.9.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -8050,6 +8615,45 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -8693,6 +9297,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -9333,6 +9978,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -10372,6 +11028,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -10878,6 +11568,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -10994,6 +11691,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -11378,6 +12082,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -11426,6 +12147,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.28", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", @@ -11929,6 +12660,207 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -12058,6 +12990,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 39f11c4..29c73e2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@base-ui/react": "^1.4.1", @@ -49,10 +51,12 @@ "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^4.1.5", "eslint": "^9", "eslint-config-next": "16.2.4", "tailwindcss": "^4", "tsx": "^4.21.0", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.5" } } diff --git a/prisma/seed.ts b/prisma/seed.ts index 200af71..30d5dd1 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -62,11 +62,21 @@ async function main() { console.log(`Main user: ${user.username} (id: ${user.id})`) - // Create the Scrum4Me product (using the product backlog doc data) - const product = await prisma.product.upsert({ - where: { user_id_name: { user_id: demo.id, name: 'DevPlanner' } }, - update: {}, - create: { + // Reset demo product data — delete in dependency order to avoid FK violations + const existingProducts = await prisma.product.findMany({ where: { user_id: demo.id }, select: { id: true } }) + for (const p of existingProducts) { + // Stories reference product_id directly (no cascade), so delete PBIs first (cascades to stories via pbi_id) + const existingPbis = await prisma.pbi.findMany({ where: { product_id: p.id }, select: { id: true } }) + for (const pbi of existingPbis) { + await prisma.story.deleteMany({ where: { pbi_id: pbi.id } }) + } + await prisma.pbi.deleteMany({ where: { product_id: p.id } }) + await prisma.sprint.deleteMany({ where: { product_id: p.id } }) + } + await prisma.product.deleteMany({ where: { user_id: demo.id } }) + + const product = await prisma.product.create({ + data: { user_id: demo.id, name: 'DevPlanner', description: 'Een lichtgewicht Scrum-gebaseerde projectplanner voor solo developers en kleine Scrum Teams.', @@ -76,7 +86,7 @@ async function main() { }, }) - console.log(`Product created: ${product.name} (id: ${product.id})`) + console.log(`Product created: ${product.name} (id: ${product.id}, owner: demo)`) // PBI data from the product backlog document const pbis = [ diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..e6fe532 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config' +import path from 'path' + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + }, + }, +})