diff --git a/.env.example b/.env.example index ab61549..ede2b3c 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,30 @@ NODE_ENV="development" # local dev (the route returns 401 if the Authorization header doesn't match). # Generate with: openssl rand -base64 32 CRON_SECRET="" + +# PBI-55 — Web Push (VAPID). All optional; app starts without these. +# Generate keys with: npx web-push generate-vapid-keys +NEXT_PUBLIC_VAPID_PUBLIC_KEY="" +VAPID_PRIVATE_KEY="" +# Must start with mailto: e.g. mailto:admin@example.com +VAPID_SUBJECT="mailto:admin@example.com" +# Shared secret for POST /api/internal/push/send — min 32 chars +# Generate with: openssl rand -base64 32 +INTERNAL_PUSH_SECRET="" + +# PBI-66 — Anthropic API key voor `npm run db:sync-model-prices`. +# Optional. Alleen nodig om wekelijks de model_prices tabel te synchroniseren. +# Genereer op https://console.anthropic.com/ → API Keys. +# /v1/models is een gratis metadata-call (geen tokens, geen credit nodig). +ANTHROPIC_API_KEY="" + +# v1-readiness item 2 — Sentry error monitoring. +# Optional. Without DSN, the SDK is a no-op (no network, no overhead). +# Get a DSN at https://sentry.io → Project → Settings → Client Keys (DSN). +NEXT_PUBLIC_SENTRY_DSN="" + +# Required ONLY if you want source-map upload during build (production deploy). +# In Vercel: project settings → Environment Variables → add as encrypted. +SENTRY_ORG="" +SENTRY_PROJECT="" +SENTRY_AUTH_TOKEN="" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9b47e8..c8fda6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,11 +5,23 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: + inputs: + target: + type: choice + description: Deploy target + options: [preview, production] + default: preview + +permissions: + contents: read + pull-requests: read jobs: ci: name: Lint, Typecheck, Test & Build runs-on: ubuntu-latest + if: github.event_name != 'workflow_dispatch' steps: - name: Checkout @@ -49,11 +61,52 @@ jobs: DIRECT_URL: ${{ secrets.DIRECT_URL }} SESSION_SECRET: ${{ secrets.SESSION_SECRET }} + changes: + name: Detect deploy-relevant changes + runs-on: ubuntu-latest + needs: ci + # Alleen relevant voor auto-deploy jobs; skip wanneer auto-deploy uit staat. + if: vars.AUTO_DEPLOY_ENABLED == 'true' && github.event_name != 'workflow_dispatch' + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@v5 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + code: + - 'app/**' + - 'components/**' + - 'lib/**' + - 'actions/**' + - 'stores/**' + - 'prisma/**' + - 'public/**' + - 'package.json' + - 'package-lock.json' + - 'next.config.ts' + - 'tsconfig.json' + - 'vercel.json' + - 'proxy.ts' + - 'middleware.ts' + - '.github/workflows/**' + deploy-preview: name: Deploy Preview (PR) runs-on: ubuntu-latest - needs: ci - if: github.event_name == 'pull_request' + needs: [ci, changes] + # Auto-deploy is uit. Gebruik "Run workflow" (workflow_dispatch) op de + # Actions-pagina voor handmatige deploys. Zet repo-variable + # AUTO_DEPLOY_ENABLED=true in Settings → Secrets and variables → Actions + # om PR-preview-deploys weer in te schakelen. + if: | + vars.AUTO_DEPLOY_ENABLED == 'true' + && github.event_name == 'pull_request' && ( + (needs.changes.outputs.code == 'true' + && !contains(github.event.pull_request.labels.*.name, 'skip-deploy')) + || contains(github.event.pull_request.labels.*.name, 'force-deploy') + ) steps: - name: Checkout @@ -80,8 +133,15 @@ jobs: deploy-production: name: Deploy Production (main) runs-on: ubuntu-latest - needs: ci - if: github.ref == 'refs/heads/main' && github.event_name == 'push' + needs: [ci, changes] + # Auto-deploy is uit. Gebruik "Run workflow" (workflow_dispatch) → + # target=production voor handmatige productie-deploys. Zet repo-variable + # AUTO_DEPLOY_ENABLED=true om push-naar-main weer auto te deployen. + if: | + vars.AUTO_DEPLOY_ENABLED == 'true' + && github.ref == 'refs/heads/main' + && github.event_name == 'push' + && needs.changes.outputs.code == 'true' steps: - name: Checkout @@ -110,3 +170,42 @@ jobs: env: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + + deploy-manual: + name: Deploy Manual (workflow_dispatch) + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Vercel CLI + run: npm install -g vercel@latest + + - name: Run database migrations (production only) + if: inputs.target == 'production' + run: npx prisma migrate deploy + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + + - name: Deploy + run: | + if [ "${{ inputs.target }}" = "production" ]; then + vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }} + else + vercel deploy --token=${{ secrets.VERCEL_TOKEN }} + fi + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} diff --git a/.gitignore b/.gitignore index d20df70..fe6b79a 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ next-env.d.ts # Claude Code local settings .claude/settings.local.json +.claude/worktrees/ # Local plan/scratch files (per-developer, not shared) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..40a8e6c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,106 @@ +# Changelog + +All notable changes to **Scrum4Me** are documented in this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [Unreleased] + +--- + +## [1.0.0] — 2026-05-04 + +**Eerste stabiele release** — MVP volgens functional spec is af, getest en in +productie. Geen breaking changes ten opzichte van 0.9.0; deze tag markeert de +launch-ready state na de v1-readiness-checklist (Now + Before-launch items). + +### Added +- Rate-limiting: `enforceUserRateLimit(scope, userId)` helper toegepast op alle + high-value mutation paths — PBI/Story/Task/Todo/Sprint/Product/Token create, + Claude job enqueue, answerQuestion, story-log POST, avatar upload. + ([#86](https://github.com/madhura68/Scrum4Me/pull/86)) +- Sentry error-monitoring scaffolding (`@sentry/nextjs`) met no-op fallback + zonder DSN. Activeer via `NEXT_PUBLIC_SENTRY_DSN` in Vercel env-vars. + ([#85](https://github.com/madhura68/Scrum4Me/pull/85)) +- `CHANGELOG.md` (Keep a Changelog formaat) + `docs/runbooks/v1-smoke-test.md` + — 11-secties pre-launch verificatie. ([#89](https://github.com/madhura68/Scrum4Me/pull/89)) + +### Changed +- A11y Lighthouse score op `/products/[id]` van 86 → ≥95: `aria-selected` → + `aria-pressed` op PBI-cards (correct ARIA role-attribute pairing); tap-targets + ≥28×28 px op hover-icon-buttons. ([#88](https://github.com/madhura68/Scrum4Me/pull/88)) +- A11y form-label associaties (`htmlFor` + `id`) op happy-path dialogen + (Story/Task + Promote-PBI/Story); auth-pages krijgen `
` landmark. + ([#87](https://github.com/madhura68/Scrum4Me/pull/87)) +- README: test-count 69 → 445, env-vars-tabel uitgebreid met `CRON_SECRET` en + Sentry-vars. ([#89](https://github.com/madhura68/Scrum4Me/pull/89)) + +### Fixed +- Demo-policy: drie mutation-paden zonder `isDemo`-check gedicht + (`toggleTodoAction`, `archiveCompletedTodosAction`, `leaveProductAction`). + ([#89](https://github.com/madhura68/Scrum4Me/pull/89)) + +### Security +- Vier debug-routes (`/debug-env`, `/debug-realtime`, `/api/debug/*`) krijgen + een NODE_ENV-guard → 404 in productie. ([#89](https://github.com/madhura68/Scrum4Me/pull/89)) + +--- + +## [0.9.0] — 2026-05-04 + +[GitHub Release](https://github.com/madhura68/Scrum4Me/releases/tag/v0.9.0) + +### Added +- **PBI-11: Mobile-shell met landscape-lock** ([#81](https://github.com/madhura68/Scrum4Me/pull/81)): + - Aparte route group `app/(mobile)/m/{settings,pair,products}/...` met eigen + layout (zonder NavBar/StatusBar/MinWidthBanner) + - `LandscapeGuard` (rotate-overlay in portrait), `MobileTabBar` (3 lucide-iconen) + - PWA-manifest met `"orientation": "landscape"` + - UA-redirect bij login: telefoons (`Mobi`-substring) → `/m/products/[active]/solo`, + tablets en desktop → `/dashboard` + - Gedeelde `lib/auth-guard.ts` `requireSession()` helper, hergebruikt door beide layouts + - Mobile-fullscreen voor entity-dialogen via gedeelde `entityDialogContentClasses` +- Sprint Product-Backlog kolom: filter-popover (prioriteit + status) en + edit-iconen op PBI/story/task-rijen. ([#79](https://github.com/madhura68/Scrum4Me/pull/79)) +- Edit-icoon op product-card in dashboard (consistent met PBI/story/task-pattern). + ([#83](https://github.com/madhura68/Scrum4Me/pull/83)) +- v1.0 readiness checklist in `docs/old/plans/v1-readiness.md`. + ([#82](https://github.com/madhura68/Scrum4Me/pull/82)) + +### Changed +- Refactor `app/(app)/layout.tsx` om gedeelde `requireSession()` te gebruiken + (gedrag onveranderd). ([#81](https://github.com/madhura68/Scrum4Me/pull/81)) +- `/m/pair` filesystem-verhuisd uit `(app)/` naar `(mobile)/` — URL onveranderd. + ([#81](https://github.com/madhura68/Scrum4Me/pull/81)) + +--- + +## [0.4.0] — eerder + +### Added +- M9 — Actief Product Backlog: persistente actieve PB-keuze, gesplitste + navigatie, disabled-states bij geen actief product + +--- + +## [0.3.1] — eerder + +Initiële stabilisatie-release. + +--- + +## Pre-0.3.x + +Foundation-werk (M0 t/m M8) is niet retroactief in dit changelog opgenomen. +Voor de volledige milestone-historie zie [docs/old/backlog/index.md](./docs/old/backlog/index.md). + +--- + +[Unreleased]: https://github.com/madhura68/Scrum4Me/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/madhura68/Scrum4Me/releases/tag/v1.0.0 +[0.9.0]: https://github.com/madhura68/Scrum4Me/releases/tag/v0.9.0 +[0.4.0]: https://github.com/madhura68/Scrum4Me/commit/615f0c8 +[0.3.1]: https://github.com/madhura68/Scrum4Me/commit/ecc05dd diff --git a/CLAUDE.md b/CLAUDE.md index 566c755..06dc2fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ title: "CLAUDE.md — Scrum4Me" status: active audience: [ai-agent] language: nl -last_updated: 2026-05-03 +last_updated: 2026-05-11 --- # CLAUDE.md — Scrum4Me @@ -19,30 +19,25 @@ Desktop-first Scrum-app voor solo developers en kleine teams. Hiërarchie: produ | `docs/INDEX.md` | Gegenereerde index van alle docs — begin hier | | `docs/specs/functional.md` | Acceptatiecriteria, user flows | | `docs/architecture.md` | Breadcrumb → 6 topische arch-bestanden | -| `docs/backlog/index.md` | Implementatievolgorde, "done when"-criteria | | `docs/api/rest-contract.md` | REST API contract voor Claude Code | | `docs/design/styling.md` | **Lees vóór elk component** — MD3-tokens, shadcn | -| `docs/plans/-*.md` | Implementatieplan per milestone | +| `docs/adr/` | Architecture Decision Records — tech-keuzes (base-ui vs Radix, sort-order, demo-policy, …) | +| `docs/architecture/` | 6 topische architecture-bestanden (data-model, auth, sprint-execution, …) — uitwerking van `docs/architecture.md` | +| `docs/runbooks/plan-to-pbi-flow.md` | **Na goedgekeurd plan** — PBI/Story/Task aanmaken via MCP, zónder direct uitvoeren | --- ## Hoe werk vinden -**Track A — MCP (aanbevolen):** 1. Branch aanmaken: `git checkout -b feat/` — nog **geen** `gh pr create` 2. `mcp__scrum4me__get_claude_context` → pak de next story 3. Voer taken uit in `sort_order`; update status per taak 4. Lees het relevante patroon en styling vóór je begint -5. Verifieer: `npm run lint && npm test && npm run build` +5. Verifieer: `npm run verify && npm run build` — `verify` = lint + typecheck + test 6. Commit per laag: `git add -A && git commit` — **geen** `git push` — zie [docs/runbooks/branch-and-commit.md](./docs/runbooks/branch-and-commit.md) 7. Herhaal stap 2–6 per story; branch blijft dezelfde 8. Queue leeg → `git push -u origin ` + `gh pr create` -**Track B — manueel:** -1. Lees taak in `docs/backlog/index.md` -2. Zoek spec in `docs/specs/functional.md` -3. Lees patroon + styling → bouw → verifieer → vraag bevestiging → commit - Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbooks/mcp-integration.md) --- @@ -53,10 +48,13 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo - **UI:** gebruik `@base-ui/react` met `render`-prop, niet Radix `asChild` - **Push:** commits accumuleren lokaal per taak (`git add -A && git commit`); push + PR pas bij lege queue of na expliciete gebruikersbevestiging — zie [branch-and-commit.md](./docs/runbooks/branch-and-commit.md) - **Demo:** drie lagen — proxy.ts + server action + UI disabled knop +- **Proxy:** `proxy.ts` in repo-root (géén `middleware.ts`) onverzegelt de iron-session, redirect niet-geauthenticeerde users op `/dashboard|/products|/ideas`, en blokkeert niet-GET API-writes voor demo-users behalve `/api/cron/*` - **Enum:** DB UPPER_SNAKE ↔ API lowercase — uitsluitend via `lib/task-status.ts` - **Foutcodes:** 400 = parse-fout, 422 = Zod-validatie, 403 = demo-token - **Server/client grens:** `*-server.ts` bevat DB/node-only; nooit importeren in client component -- **Deployment:** `npm run lint && npm test && npm run build` vóór elke PR +- **Worker/jobs:** `ClaudeJob` queue (`QUEUED → CLAIMED → RUNNING → DONE|FAILED|SKIPPED`); MCP-worker claimt via `wait_for_job` en sluit met `update_job_status` — zie [worker-idempotency.md](./docs/runbooks/worker-idempotency.md) +- **Model/mode per ClaudeJob:** kind-default → product → job-snapshot → `task.requires_opus`. Resolver in `scrum4me-mcp/src/lib/job-config.ts` (en gespiegeld in `lib/job-config.ts`) — zie [job-model-selection.md](./docs/runbooks/job-model-selection.md) +- **Deployment:** `npm run verify && npm run build` vóór elke PR. Selectieve deploy-controle (labels + path-filter): zie [docs/runbooks/deploy-control.md](./docs/runbooks/deploy-control.md) --- @@ -64,12 +62,13 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo | Laag | Technologie | |---|---| -| Framework | Next.js 16 (App Router) + React 19 | +| Framework | Next.js 16.2 (App Router) + React 19.2 — PPR/Cache Components beschikbaar | | Taal | TypeScript strict | -| Styling | Tailwind CSS + shadcn/ui + MD3 via `app/styles/theme.css` | +| Styling | Tailwind CSS v4 + shadcn/ui + MD3 via `app/styles/theme.css` | | State | Zustand + dnd-kit | -| DB | Prisma v7 + PostgreSQL (Neon) | +| DB | Prisma v7.8 + PostgreSQL (Neon) | | Auth | iron-session + bcryptjs | +| Test | Vitest (`__tests__/`, config in `vitest.config.ts`) | | Utilities | Zod, Sonner, Sharp, Vercel Analytics | --- @@ -82,12 +81,20 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo | Prisma singleton | `docs/patterns/prisma-client.md` | | Server Action (auth + Zod) | `docs/patterns/server-action.md` | | Route Handler (REST) | `docs/patterns/route-handler.md` | +| Workspace-store + realtime (PBI-74) | `docs/patterns/workspace-store.md` | | Zustand optimistic update | `docs/patterns/zustand-optimistic.md` | | Float sort_order / drag-and-drop | `docs/patterns/sort-order.md` | | Proxy / route protection | `docs/patterns/proxy.md` | | QR-pairing | `docs/patterns/qr-login.md` | | Claude ↔ user vraagkanaal | `docs/patterns/claude-question-channel.md` | | Entity Dialog (verplicht) | `docs/patterns/dialog.md` | +| Realtime NOTIFY-payload | `docs/patterns/realtime-notify-payload.md` | +| Story met UI-component | `docs/patterns/story-with-ui-component.md` | +| Web Push | `docs/patterns/web-push.md` | +| Job-config resolver (PBI-67) | `lib/job-config.ts` ↔ `scrum4me-mcp/src/lib/job-config.ts` | +| Debug-id op component-root | `docs/patterns/debug-id.md` | +| Debug-labels (BEM) | `docs/patterns/debug-labels.md` | +| Demo client-state (PBI-80) | `docs/patterns/demo-client-state.md` | --- @@ -100,7 +107,18 @@ SESSION_SECRET="" # min 32 chars CRON_SECRET="" # Bearer-secret /api/cron/* ``` -Volledig schema: `lib/env.ts`. Canonieke lijst: `.env.example`. +Volledig schema: `lib/env.ts`. Canonieke lijst: `.env.example` — bevat ook web-push (`VAPID_*`, `INTERNAL_PUSH_SECRET`), Sentry (`SENTRY_*`) en optioneel `ANTHROPIC_API_KEY`. + +--- + +## MCP & cron + +- **MCP-server (extern):** standalone Node-proces in `~/Development/scrum4me-mcp/` — Prisma-schema gesynced via `sync-schema.sh`. 30+ tools (`get_claude_context`, `wait_for_job`, `update_task_status`, …) +- **Bewuste duplicaten:** `lib/job-config.ts` (deze repo) en `scrum4me-mcp/src/lib/job-config.ts` (externe MCP) bevatten dezelfde resolver-logica; dit voorkomt dat de MCP-server Next-deps importeert. **Wijzig beide** bij elke job-config aanpassing +- **Cron (vercel.json):** + - `/api/cron/expire-questions` — dagelijks 04:00 UTC + - `/api/cron/cleanup-agent-artifacts` — dagelijks 03:00 UTC +- **Realtime:** SSE op `/api/realtime/*`, gevoed door PostgreSQL `LISTEN`/`NOTIFY` op kanaal `scrum4me_changes` (vereist `DIRECT_URL` voor pooler-bypass) --- @@ -113,5 +131,24 @@ PBI (niet: Feature/Epic) · Story (niet: Ticket) · Sprint Goal (niet: Objective ## Verificatie ```bash -npm run lint && npm test && npm run build +npm run verify && npm run build # verify = lint + typecheck + test ``` + +Worker job-status protocol (wanneer `DONE` / `SKIPPED` / `FAILED`): zie [docs/runbooks/worker-idempotency.md](./docs/runbooks/worker-idempotency.md). + +### Scripts + +| Commando | Doel | +|---|---| +| `npm run dev` | Next dev op poort 3000 (`predev` kill-port draait automatisch) | +| `npm test` | Vitest eenmalig (`vitest run`) | +| `npm run test:watch` | Vitest watch-mode | +| `npm test -- ` | Eén bestand draaien — bv. `npm test -- lib/env` | +| `npm run seed` | Prisma seed via `prisma/seed.ts` | +| `npm run create-admin` | Admin-user toevoegen (`scripts/create-admin.ts`) | +| `npm run db:insert-milestone` | Milestone-script (`scripts/insert-milestone.ts`) | +| `npm run db:sync-model-prices` | Sync Anthropic-model-prijzen — vereist `ANTHROPIC_API_KEY` | +| `npm run docs` | Regenereer `docs/INDEX.md` + check links | +| `npm run diagrams` | Mermaid → SVG (`public/diagrams/architecture-{light,dark}.svg`) | + +> Vitest sluit `.claude/**` uit (relevant voor worktrees). `server-only` wordt via alias gemockt naar `tests/stubs/server-only.ts`, zodat `*-server.ts` modules laadbaar zijn in jsdom-tests. diff --git a/README.md b/README.md index 1f2da30..7cf3a14 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Scrum4Me biedt een lichtgewicht, web-based oplossing voor het beheren van sprint ## Documentation +- [CHANGELOG.md](CHANGELOG.md) — release-historie (Keep a Changelog) - [docs/INDEX.md](docs/INDEX.md) — generated index of all docs (front-matter driven) - [docs/glossary.md](docs/glossary.md) — domain terms (PBI, Story, MCP-job, etc.) - [CLAUDE.md](CLAUDE.md) / [AGENTS.md](AGENTS.md) — agent instructions @@ -122,16 +123,12 @@ Vul daarna `DATABASE_URL` en `SESSION_SECRET` in. `DIRECT_URL` is optioneel loka npx prisma db push ``` -4. Genereer Prisma Client en de ERD: +4. Genereer Prisma Client: ```bash -npm run db:erd +npx prisma generate ``` -Deze command voert lokaal `prisma generate` uit. Daardoor worden zowel de Prisma Client als `docs/assets/erd.svg` opnieuw opgebouwd. - -In CI en deployment wordt bewust alleen de Prisma Client gegenereerd met `prisma generate --generator client`. Het ERD-diagram gebruikt Mermaid/Puppeteer en wordt daarom niet in GitHub Actions of Vercel gegenereerd. - 5. Seed testdata indien nodig: ```bash @@ -152,7 +149,7 @@ npm run dev npm test ``` -Verwacht: alle 69 tests slagen, 0 failures. +Verwacht: alle 445 tests slagen, 0 failures. **API curl-tests (vereist lopende dev server + API token):** @@ -165,19 +162,9 @@ De curl-tests dekken alle 7 API-endpoints: auth (401), demo-blokkering (403), in ## Database -![ERD](./docs/assets/erd.svg) +Het schema staat in `prisma/schema.prisma`; uitgebreide documentatie in [`docs/architecture/data-model.md`](./docs/architecture/data-model.md). -De databasevisualisatie wordt lokaal gegenereerd uit `prisma/schema.prisma` via `prisma-erd-generator`. - -Handmatige generatie: - -```bash -npm run db:erd -``` - -Tijdens lokale development draait `npm run dev` naast Next.js ook `npm run db:erd:watch`. Bij wijzigingen in `prisma/schema.prisma` wordt `docs/assets/erd.svg` automatisch opnieuw gegenereerd. - -Gebruik `npx prisma db push` alleen om het schema naar de database te synchroniseren. Gebruik `npm run db:erd` om lokaal Prisma Client en de ERD te genereren. Gebruik in CI uitsluitend `npx prisma generate --generator client`. +Gebruik `npx prisma db push` om schema-wijzigingen naar de database te synchroniseren. `npx prisma generate` (of `prisma generate --generator client` in CI) genereert de Prisma Client. De app draait standaard op `http://localhost:3000`. @@ -188,7 +175,6 @@ npm run dev # lokale development server npm run lint # ESLint npm test # Vitest test suite npm run build # productiebuild zoals Vercel die verwacht -npm run db:erd # Prisma Client + docs/assets/erd.svg genereren ``` ### Environment variables @@ -198,8 +184,15 @@ Zie [.env.example](.env.example). | Variabele | Verplicht | Doel | |---|---:|---| | `DATABASE_URL` | Ja | PostgreSQL connection string voor Prisma | -| `DIRECT_URL` | Nee | Directe Neon connection string voor migraties | +| `DIRECT_URL` | Nee | Directe Neon connection string voor migraties (Prisma `directUrl`) | | `SESSION_SECRET` | Ja | Minimaal 32 tekens; gebruikt door iron-session | +| `CRON_SECRET` | Productie | Bearer-secret voor `/api/cron/*` routes — required als crons aan staan | +| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | Nee | VAPID public key voor Web Push — genereer met `npx web-push generate-vapid-keys` | +| `VAPID_PRIVATE_KEY` | Nee | VAPID private key voor Web Push | +| `VAPID_SUBJECT` | Nee | Contact URI voor Web Push (bijv. `mailto:admin@example.com`) | +| `INTERNAL_PUSH_SECRET` | Nee | Bearer-secret voor `/api/internal/push/*` routes (min 32 tekens) | +| `NEXT_PUBLIC_SENTRY_DSN` | Nee | Sentry DSN — zonder is de SDK een no-op | +| `SENTRY_ORG` / `SENTRY_PROJECT` / `SENTRY_AUTH_TOKEN` | Nee | Source-map upload tijdens build | | `NODE_ENV` | Nee | Wordt door Node/Vercel gezet | Vercel Analytics gebruikt geen project-specifieke environment variabele in deze app; de component staat in `app/layout.tsx`. @@ -254,13 +247,20 @@ Authorization: Bearer | Methode | Endpoint | Doel | |---|---|---| +| `GET` | `/api/health` | Liveness; `?db=1` doet ook een DB-ping (geen auth) | | `GET` | `/api/products` | Actieve producten waarvoor de tokengebruiker eigenaar of teamlid is | -| `GET` | `/api/products/:id/next-story` | Volgende story uit de actieve sprint | +| `GET` | `/api/products/:id/next-story` | Hoogst geprioriteerde open story uit de actieve sprint | +| `GET` | `/api/products/:id/claude-context` | Bundled product / actieve sprint / next-story (met tasks) / open ideas voor MCP | | `GET` | `/api/sprints/:id/tasks?limit=10` | Eerste taken van een sprint | | `PATCH` | `/api/stories/:id/tasks/reorder` | Taakvolgorde aanpassen; alle IDs moeten bij de story horen | | `POST` | `/api/stories/:id/log` | Implementatieplan, testresultaat of commit vastleggen | -| `PATCH` | `/api/tasks/:id` | Taakstatus of implementatieplan bijwerken | -| `POST` | `/api/todos` | Todo aanmaken binnen een productcontext | +| `PATCH` | `/api/tasks/:id` | Taakstatus of `implementation_plan` bijwerken | +| `GET / POST` | `/api/ideas` · `GET / PATCH /api/ideas/:id` | Idea CRUD (M12 — vervangt voormalige `/api/todos`) | +| `GET` | `/api/jobs/:id/sub-tasks` | `sprint_task_executions` van een SPRINT_IMPLEMENTATION-job | +| `GET` | `/api/users/:id/avatar` | Avatar van een specifieke gebruiker | +| `POST / GET` | `/api/profile/avatar` | Eigen avatar uploaden of opvragen | + +Daarnaast leveren `/api/realtime/{backlog,solo,jobs,notifications}` SSE-streams en zijn er auth-helpers `/api/auth/pair/*` (QR-pairing, M10), interne push-routes onder `/api/internal/push/*`, en cron-handlers (`/api/cron/cleanup-agent-artifacts`, `/api/cron/expire-questions`). ### Security-regels @@ -287,5 +287,4 @@ De productieomgeving is gericht op Vercel + Neon. - [Functionele specificatie](docs/specs/functional.md) - [Technische architectuur](docs/architecture.md) -- [Backlog](docs/backlog/index.md) - [Agent-instructie audit](docs/decisions/agent-instructions-history.md) diff --git a/__tests__/actions/active-sprint-action.test.ts b/__tests__/actions/active-sprint-action.test.ts new file mode 100644 index 0000000..b87a767 --- /dev/null +++ b/__tests__/actions/active-sprint-action.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ + cookies: vi.fn().mockResolvedValue({ + set: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }), +})) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + sprint: { findFirst: vi.fn() }, + product: { findFirst: vi.fn() }, + user: { + findUnique: vi.fn(), + update: vi.fn().mockResolvedValue({}), + }, + $executeRaw: vi.fn().mockResolvedValue(1), + }, +})) + +import { prisma } from '@/lib/prisma' +import { clearActiveSprintAction } from '@/actions/active-sprint' + +const mockPrisma = prisma as unknown as { + product: { findFirst: ReturnType } + user: { + findUnique: ReturnType + update: ReturnType + } +} + +describe('clearActiveSprintAction', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('writes null instead of deleting the key', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + mockPrisma.user.findUnique.mockResolvedValueOnce({ + settings: { layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } } }, + }) + + const result = await clearActiveSprintAction('p1') + + expect(result).toEqual({ success: true }) + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: { layout?: { activeSprints?: Record } } } + } + expect(updateArg.data.settings.layout?.activeSprints).toEqual({ + p1: null, + p2: 'sprint-2', + }) + }) + + it('preserves other product keys when clearing one', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + mockPrisma.user.findUnique.mockResolvedValueOnce({ + settings: { + layout: { + activeSprints: { p1: 'sprint-1', p2: 'sprint-2', p3: null }, + }, + }, + }) + + await clearActiveSprintAction('p1') + + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: { layout?: { activeSprints?: Record } } } + } + expect(updateArg.data.settings.layout?.activeSprints).toEqual({ + p1: null, + p2: 'sprint-2', + p3: null, + }) + }) + + it('rejects when product is not accessible', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce(null) + + const result = await clearActiveSprintAction('p1') + + expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' }) + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) + + it('rejects invalid productId', async () => { + const result = await clearActiveSprintAction('') + + expect(result).toEqual({ error: 'Ongeldig product-id' }) + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/actions/auth.test.ts b/__tests__/actions/auth.test.ts index 424e31b..7c8dd86 100644 --- a/__tests__/actions/auth.test.ts +++ b/__tests__/actions/auth.test.ts @@ -1,10 +1,21 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -const { redirectMock, verifyUserMock, headerGetMock, sessionSaveMock } = vi.hoisted(() => ({ +const { + redirectMock, + verifyUserMock, + headerGetMock, + sessionSaveMock, + requireSessionMock, + prismaUserUpdateMock, + prismaUserRoleFindFirstMock, +} = vi.hoisted(() => ({ redirectMock: vi.fn((path: string) => { throw new Error(`REDIRECT:${path}`) }), verifyUserMock: vi.fn(), headerGetMock: vi.fn(), sessionSaveMock: vi.fn(), + requireSessionMock: vi.fn(), + prismaUserUpdateMock: vi.fn(), + prismaUserRoleFindFirstMock: vi.fn().mockResolvedValue(null), })) vi.mock('next/navigation', () => ({ redirect: redirectMock })) @@ -23,10 +34,18 @@ vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 't', password: ' vi.mock('@/lib/auth', () => ({ verifyUser: verifyUserMock, registerUser: vi.fn(), + hashPassword: vi.fn().mockResolvedValue('hashed'), +})) +vi.mock('@/lib/auth-guard', () => ({ requireSession: requireSessionMock })) +vi.mock('@/lib/prisma', () => ({ + prisma: { + user: { update: prismaUserUpdateMock }, + userRole: { findFirst: prismaUserRoleFindFirstMock }, + }, })) vi.mock('@/lib/rate-limit', () => ({ checkRateLimit: vi.fn().mockReturnValue(true) })) -import { loginAction } from '@/actions/auth' +import { loginAction, resetPasswordAction } from '@/actions/auth' const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) Mobile/15E148 Safari/604.1' const IPAD_UA = 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) Safari/604.1' @@ -44,6 +63,9 @@ beforeEach(() => { verifyUserMock.mockReset() headerGetMock.mockReset() sessionSaveMock.mockReset() + requireSessionMock.mockReset() + prismaUserUpdateMock.mockReset() + prismaUserRoleFindFirstMock.mockResolvedValue(null) }) describe('loginAction UA-redirect', () => { @@ -83,3 +105,37 @@ describe('loginAction UA-redirect', () => { await expect(loginAction(undefined, fd('demo', 'demo123pw'))).rejects.toThrow('REDIRECT:/m/products/p1/solo') }) }) + +describe('resetPasswordAction', () => { + function fdReset(password: string, confirm: string) { + const f = new FormData() + f.set('password', password) + f.set('confirm', confirm) + return f + } + + it('redirect /dashboard na succesvolle reset', async () => { + requireSessionMock.mockResolvedValue({ userId: 'u1' }) + prismaUserUpdateMock.mockResolvedValue({}) + await expect(resetPasswordAction(undefined, fdReset('nieuwpass1', 'nieuwpass1'))).rejects.toThrow('REDIRECT:/dashboard') + expect(prismaUserUpdateMock).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'u1' }, + data: expect.objectContaining({ password_hash: 'hashed', must_reset_password: false }), + }) + ) + }) + + it('fout als wachtwoorden niet overeenkomen', async () => { + requireSessionMock.mockResolvedValue({ userId: 'u1' }) + const result = await resetPasswordAction(undefined, fdReset('nieuwpass1', 'anderpass1')) + expect(result).toMatchObject({ error: expect.objectContaining({ confirm: expect.any(Array) }) }) + expect(prismaUserUpdateMock).not.toHaveBeenCalled() + }) + + it('fout als wachtwoord te kort is', async () => { + requireSessionMock.mockResolvedValue({ userId: 'u1' }) + const result = await resetPasswordAction(undefined, fdReset('kort', 'kort')) + expect(result).toMatchObject({ error: expect.objectContaining({ password: expect.any(Array) }) }) + }) +}) diff --git a/__tests__/actions/claude-jobs-batch.test.ts b/__tests__/actions/claude-jobs-batch.test.ts index fc4e5e7..50c9be0 100644 --- a/__tests__/actions/claude-jobs-batch.test.ts +++ b/__tests__/actions/claude-jobs-batch.test.ts @@ -1,232 +1,29 @@ /** - * Uitgebreide integratie-stijl tests voor previewEnqueueAllAction en - * enqueueClaudeJobsBatchAction. Gebruikt realistische seed-data: - * 2 PBIs, elk met 1 story, elk 2 taken (4 taken totaal in PBI-volgorde). + * Per-task batch enqueue is gedeprecateerd ten gunste van startSprintRunAction + * (zie actions/sprint-runs.ts). De functies blijven exporteerbaar als stub voor + * backwards-compat met UI-componenten die in F4 worden vervangen. */ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -const { - mockGetSession, - mockFindFirstProduct, - mockFindFirstSprint, - mockFindManyTask, - mockTransaction, - mockExecuteRaw, -} = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockFindFirstProduct: vi.fn(), - mockFindFirstSprint: vi.fn(), - mockFindManyTask: vi.fn(), - mockTransaction: vi.fn(), - mockExecuteRaw: vi.fn().mockResolvedValue(undefined), -})) +import { describe, it, expect, vi } from 'vitest' vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) -vi.mock('@/lib/prisma', () => ({ - prisma: { - task: { findMany: mockFindManyTask }, - product: { findFirst: mockFindFirstProduct }, - sprint: { findFirst: mockFindFirstSprint }, - claudeJob: { create: vi.fn() }, - $executeRaw: mockExecuteRaw, - $transaction: mockTransaction, - }, -})) +vi.mock('@/lib/auth', () => ({ getSession: vi.fn() })) +vi.mock('@/lib/prisma', () => ({ prisma: {} })) -import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs' +import { + previewEnqueueAllAction, + enqueueClaudeJobsBatchAction, +} from '@/actions/claude-jobs' -const SESSION_USER = { userId: 'user-1', isDemo: false } -const SESSION_DEMO = { userId: 'demo-1', isDemo: true } -const PRODUCT_ID = 'product-1' -const SPRINT_ID = 'sprint-1' - -// --- Seed helpers --- -const makePbi1Task = (id: string, status = 'TO_DO') => ({ - id, - title: `PBI-1 Taak ${id}`, - status, - story: { - id: 'story-pbi1', - title: 'Story van PBI 1', - code: 'ST-1', - pbi: { id: 'pbi-1', status: 'READY', priority: 1, sort_order: 1.0 }, - }, -}) - -const makePbi2Task = (id: string, status = 'TO_DO', pbiStatus = 'READY') => ({ - id, - title: `PBI-2 Taak ${id}`, - status, - story: { - id: 'story-pbi2', - title: 'Story van PBI 2', - code: 'ST-2', - pbi: { id: 'pbi-2', status: pbiStatus, priority: 2, sort_order: 2.0 }, - }, -}) - -const makeBatchTask = (id: string, hasActiveJob = false) => ({ - id, - claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [], -}) - -// Canonical seed: [pbi1-t1, pbi1-t2, pbi2-t1, pbi2-t2] -const SEED_ALL_TODO = [ - makePbi1Task('pbi1-t1'), - makePbi1Task('pbi1-t2'), - makePbi2Task('pbi2-t1'), - makePbi2Task('pbi2-t2'), -] - -beforeEach(() => { - vi.clearAllMocks() - mockExecuteRaw.mockResolvedValue(undefined) - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) - mockFindFirstSprint.mockResolvedValue({ id: SPRINT_ID }) -}) - -// ============================================================= -// previewEnqueueAllAction -// ============================================================= -describe('previewEnqueueAllAction — 2 PBI scenario', () => { - it('geen blocker: alle 4 TO_DO taken → tasks=[4], blockerIndex=null', async () => { - mockFindManyTask.mockResolvedValue(SEED_ALL_TODO) - - const result = await previewEnqueueAllAction(PRODUCT_ID) - - expect(result).toMatchObject({ blockerIndex: null, blockerReason: null }) - if (!('error' in result)) { - expect(result.tasks).toHaveLength(4) - expect(result.tasks.map(t => t.id)).toEqual(['pbi1-t1', 'pbi1-t2', 'pbi2-t1', 'pbi2-t2']) - } - }) - - it('3e taak (pbi2-t1) REVIEW → blockerIndex=2, reden=task-review, tasks=[3]', async () => { - mockFindManyTask.mockResolvedValue([ - makePbi1Task('pbi1-t1'), - makePbi1Task('pbi1-t2'), - makePbi2Task('pbi2-t1', 'REVIEW'), - makePbi2Task('pbi2-t2'), - ]) - - const result = await previewEnqueueAllAction(PRODUCT_ID) - - expect(result).toMatchObject({ blockerIndex: 2, blockerReason: 'task-review' }) - if (!('error' in result)) { - expect(result.tasks).toHaveLength(3) - expect(result.tasks[2].id).toBe('pbi2-t1') - } - }) - - it('PBI 1 BLOCKED → blockerIndex=0, reden=pbi-blocked, tasks=[1]', async () => { - mockFindManyTask.mockResolvedValue([ - makePbi1Task('pbi1-t1', 'TO_DO'), - makePbi1Task('pbi1-t2', 'TO_DO'), - makePbi2Task('pbi2-t1'), - makePbi2Task('pbi2-t2'), - ].map((t, i) => i < 2 ? { ...t, story: { ...t.story, pbi: { ...t.story.pbi, status: 'BLOCKED' } } } : t)) - - const result = await previewEnqueueAllAction(PRODUCT_ID) - - expect(result).toMatchObject({ blockerIndex: 0, blockerReason: 'pbi-blocked' }) - if (!('error' in result)) expect(result.tasks).toHaveLength(1) - }) - - it('ACTIVE job op pbi1-t1 → geskipt door where-clause, geen blocker bij resterende 3', async () => { - // Simuleert dat pbi1-t1 een actieve job heeft: de where-clause sluit die taak uit - mockFindManyTask.mockResolvedValue([ - makePbi1Task('pbi1-t2'), - makePbi2Task('pbi2-t1'), - makePbi2Task('pbi2-t2'), - ]) - - const result = await previewEnqueueAllAction(PRODUCT_ID) - - expect(result).toMatchObject({ blockerIndex: null, blockerReason: null }) - if (!('error' in result)) { - expect(result.tasks).toHaveLength(3) - expect(result.tasks[0].id).toBe('pbi1-t2') - } - }) - - it('ACTIVE job op pbi1-t1 AND pbi2-t1 REVIEW → blockerIndex=1 in resterende array', async () => { - mockFindManyTask.mockResolvedValue([ - makePbi1Task('pbi1-t2'), - makePbi2Task('pbi2-t1', 'REVIEW'), - makePbi2Task('pbi2-t2'), - ]) - - const result = await previewEnqueueAllAction(PRODUCT_ID) - - expect(result).toMatchObject({ blockerIndex: 1, blockerReason: 'task-review' }) - if (!('error' in result)) expect(result.tasks).toHaveLength(2) - }) - - it('demo-user → error, findMany niet aangeroepen', async () => { - mockGetSession.mockResolvedValue(SESSION_DEMO) - - const result = await previewEnqueueAllAction(PRODUCT_ID) - - expect(result).toMatchObject({ error: expect.stringContaining('demo') }) - expect(mockFindManyTask).not.toHaveBeenCalled() +describe('previewEnqueueAllAction (deprecated)', () => { + it('retourneert een deprecation-error', async () => { + const result = await previewEnqueueAllAction('prod-1') + expect(result).toMatchObject({ error: expect.stringContaining('vervangen') }) }) }) -// ============================================================= -// enqueueClaudeJobsBatchAction -// ============================================================= -describe('enqueueClaudeJobsBatchAction — 2 PBI scenario', () => { - it('happy path: 2 taskIds → 2 QUEUED ClaudeJobs in invoervolgorde', async () => { - mockFindManyTask.mockResolvedValue([ - makeBatchTask('pbi1-t1'), - makeBatchTask('pbi2-t1'), - ]) - mockTransaction.mockResolvedValue([ - { id: 'job-a', task_id: 'pbi1-t1' }, - { id: 'job-b', task_id: 'pbi2-t1' }, - ]) - - const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'pbi2-t1']) - - expect(result).toEqual({ success: true, count: 2 }) - expect(mockExecuteRaw).toHaveBeenCalledTimes(2) - }) - - it('IDOR: taskId van niet-toegewezen story → error, geen transaction', async () => { - // Authorized tasks bevat maar 1 van de 2 gevraagde IDs - mockFindManyTask.mockResolvedValue([makeBatchTask('pbi1-t1')]) - - const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'other-user-task']) - - expect(result).toMatchObject({ error: expect.stringContaining('niet toegankelijk') }) - expect(mockTransaction).not.toHaveBeenCalled() - }) - - it('taak met ACTIVE job wordt overgeslagen (idempotent)', async () => { - mockFindManyTask.mockResolvedValue([ - makeBatchTask('pbi1-t1'), - makeBatchTask('pbi1-t2', true), // heeft actieve job → skip - makeBatchTask('pbi2-t1'), - ]) - mockTransaction.mockResolvedValue([ - { id: 'job-a', task_id: 'pbi1-t1' }, - { id: 'job-b', task_id: 'pbi2-t1' }, - ]) - - const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'pbi1-t2', 'pbi2-t1']) - - expect(result).toEqual({ success: true, count: 2 }) - expect(mockExecuteRaw).toHaveBeenCalledTimes(2) - }) - - it('demo-user → error, geen transaction', async () => { - mockGetSession.mockResolvedValue(SESSION_DEMO) - - const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1']) - - expect(result).toMatchObject({ error: expect.stringContaining('demo') }) - expect(mockTransaction).not.toHaveBeenCalled() +describe('enqueueClaudeJobsBatchAction (deprecated)', () => { + it('retourneert een deprecation-error', async () => { + const result = await enqueueClaudeJobsBatchAction('prod-1', ['t1', 't2']) + expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') }) }) }) diff --git a/__tests__/actions/claude-jobs.test.ts b/__tests__/actions/claude-jobs.test.ts index 120124c..484f185 100644 --- a/__tests__/actions/claude-jobs.test.ts +++ b/__tests__/actions/claude-jobs.test.ts @@ -1,47 +1,46 @@ +/** + * Per-task enqueue-acties zijn gedeprecateerd. cancelClaudeJobAction blijft + * actief — gebruikt voor het annuleren van losse jobs (bv. idea-jobs). + */ import { describe, it, expect, vi, beforeEach } from 'vitest' const { mockGetSession, - mockFindFirstTask, - mockFindManyTask, - mockFindFirstProduct, - mockFindFirstSprint, mockFindFirstJob, - mockCreateJob, mockUpdateJob, - mockExecuteRaw, + mockUpdateManyJob, + mockUpdateManySprintTaskExecution, mockTransaction, -} = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockFindFirstTask: vi.fn(), - mockFindManyTask: vi.fn(), - mockFindFirstProduct: vi.fn(), - mockFindFirstSprint: vi.fn(), - mockFindFirstJob: vi.fn(), - mockCreateJob: vi.fn(), - mockUpdateJob: vi.fn(), - mockExecuteRaw: vi.fn().mockResolvedValue(undefined), - mockTransaction: vi.fn(), -})) + mockExecuteRaw, +} = vi.hoisted(() => { + const mockUpdateManyJob = vi.fn() + const mockUpdateManySprintTaskExecution = vi.fn() + const mockTransaction = vi.fn() + return { + mockGetSession: vi.fn(), + mockFindFirstJob: vi.fn(), + mockUpdateJob: vi.fn(), + mockUpdateManyJob, + mockUpdateManySprintTaskExecution, + mockTransaction, + mockExecuteRaw: vi.fn().mockResolvedValue(undefined), + } +}) vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) - -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) vi.mock('@/lib/prisma', () => ({ prisma: { - task: { findFirst: mockFindFirstTask, findMany: mockFindManyTask }, - product: { findFirst: mockFindFirstProduct }, - sprint: { findFirst: mockFindFirstSprint }, claudeJob: { findFirst: mockFindFirstJob, - create: mockCreateJob, update: mockUpdateJob, + updateMany: mockUpdateManyJob, + }, + sprintTaskExecution: { + updateMany: mockUpdateManySprintTaskExecution, }, - $executeRaw: mockExecuteRaw, $transaction: mockTransaction, + $executeRaw: mockExecuteRaw, }, })) @@ -49,394 +48,194 @@ import { enqueueClaudeJobAction, enqueueAllTodoJobsAction, cancelClaudeJobAction, - previewEnqueueAllAction, - enqueueClaudeJobsBatchAction, + restartClaudeJobAction, } from '@/actions/claude-jobs' const SESSION_USER = { userId: 'user-1', isDemo: false } -const SESSION_DEMO = { userId: 'demo-1', isDemo: true } -const TASK_ID = 'task-cuid-1' -const JOB_ID = 'job-cuid-1' -const PRODUCT_ID = 'product-cuid-1' - -const MOCK_TASK = { id: TASK_ID, story: { product_id: PRODUCT_ID } } -const MOCK_JOB_QUEUED = { id: JOB_ID, status: 'QUEUED' as const, task_id: TASK_ID, product_id: PRODUCT_ID } - beforeEach(() => { vi.clearAllMocks() mockExecuteRaw.mockResolvedValue(undefined) + mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise) => + fn({ + claudeJob: { updateMany: mockUpdateManyJob }, + sprintTaskExecution: { updateMany: mockUpdateManySprintTaskExecution }, + }) + ) }) -describe('enqueueClaudeJobAction', () => { - it('happy path: creates job with QUEUED status', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstTask.mockResolvedValue(MOCK_TASK) - mockFindFirstJob.mockResolvedValue(null) - mockCreateJob.mockResolvedValue({ id: JOB_ID }) - - const result = await enqueueClaudeJobAction(TASK_ID) - - expect(result).toEqual({ success: true, jobId: JOB_ID }) - expect(mockCreateJob).toHaveBeenCalledWith( - expect.objectContaining({ data: expect.objectContaining({ status: 'QUEUED', task_id: TASK_ID }) }) - ) - }) - - it('blocks demo user', async () => { - mockGetSession.mockResolvedValue(SESSION_DEMO) - - const result = await enqueueClaudeJobAction(TASK_ID) - - expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' }) - expect(mockCreateJob).not.toHaveBeenCalled() - }) - - it('returns error when task not found', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstTask.mockResolvedValue(null) - - const result = await enqueueClaudeJobAction(TASK_ID) - - expect(result).toMatchObject({ error: 'Task niet gevonden' }) - expect(mockCreateJob).not.toHaveBeenCalled() - }) - - it('idempotency: returns existing jobId when QUEUED job exists', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstTask.mockResolvedValue(MOCK_TASK) - mockFindFirstJob.mockResolvedValue({ id: JOB_ID }) - - const result = await enqueueClaudeJobAction(TASK_ID) - - expect(result).toMatchObject({ error: 'Er loopt al een agent voor deze task', jobId: JOB_ID }) - expect(mockCreateJob).not.toHaveBeenCalled() - }) - - it('allows new enqueue after terminal (DONE) job', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstTask.mockResolvedValue(MOCK_TASK) - mockFindFirstJob.mockResolvedValue(null) // no active job - mockCreateJob.mockResolvedValue({ id: 'new-job-id' }) - - const result = await enqueueClaudeJobAction(TASK_ID) - - expect(result).toEqual({ success: true, jobId: 'new-job-id' }) +describe('enqueueClaudeJobAction (deprecated)', () => { + it('retourneert een deprecation-error', async () => { + const result = await enqueueClaudeJobAction('task-1') + expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') }) }) }) -describe('enqueueAllTodoJobsAction', () => { - it('happy path: scopes to active sprint + assignee, queues all queueable tasks', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) - mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) - mockFindManyTask.mockResolvedValue([{ id: 'task-a' }, { id: 'task-b' }]) - mockTransaction.mockResolvedValue([ - { id: 'job-a', task_id: 'task-a' }, - { id: 'job-b', task_id: 'task-b' }, - ]) - - const result = await enqueueAllTodoJobsAction(PRODUCT_ID) - - expect(result).toEqual({ success: true, count: 2 }) - expect(mockFindManyTask).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - status: 'TO_DO', - story: { sprint_id: 'sprint-1', assignee_id: SESSION_USER.userId }, - }), - }) - ) - expect(mockExecuteRaw).toHaveBeenCalledTimes(2) - }) - - it('returns count=0 when product has no active sprint', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) - mockFindFirstSprint.mockResolvedValue(null) - - const result = await enqueueAllTodoJobsAction(PRODUCT_ID) - - expect(result).toEqual({ success: true, count: 0 }) - expect(mockFindManyTask).not.toHaveBeenCalled() - expect(mockTransaction).not.toHaveBeenCalled() - }) - - it('returns count=0 when no queueable tasks in sprint+assignee scope', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) - mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) - mockFindManyTask.mockResolvedValue([]) - - const result = await enqueueAllTodoJobsAction(PRODUCT_ID) - - expect(result).toEqual({ success: true, count: 0 }) - expect(mockTransaction).not.toHaveBeenCalled() - expect(mockExecuteRaw).not.toHaveBeenCalled() - }) - - it('blocks demo user', async () => { - mockGetSession.mockResolvedValue(SESSION_DEMO) - - const result = await enqueueAllTodoJobsAction(PRODUCT_ID) - - expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' }) - expect(mockTransaction).not.toHaveBeenCalled() - }) - - it('returns error when product not accessible', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstProduct.mockResolvedValue(null) - - const result = await enqueueAllTodoJobsAction(PRODUCT_ID) - - expect(result).toMatchObject({ error: 'Geen toegang tot dit product' }) - expect(mockTransaction).not.toHaveBeenCalled() - }) -}) - -const makePbiTask = (id: string, status: string, pbiStatus = 'READY') => ({ - id, - title: `Task ${id}`, - status, - story: { id: 'story-1', title: 'Story 1', code: 'ST-1', pbi: { id: 'pbi-1', status: pbiStatus, priority: 1, sort_order: 1.0 } }, -}) - -describe('previewEnqueueAllAction', () => { - it('blocks demo user', async () => { - mockGetSession.mockResolvedValue(SESSION_DEMO) - - const result = await previewEnqueueAllAction(PRODUCT_ID) - - expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' }) - expect(mockFindManyTask).not.toHaveBeenCalled() - }) - - it('returns error when product not accessible', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstProduct.mockResolvedValue(null) - - const result = await previewEnqueueAllAction(PRODUCT_ID) - - expect(result).toMatchObject({ error: 'Geen toegang tot dit product' }) - expect(mockFindManyTask).not.toHaveBeenCalled() - }) - - it('returns empty tasks when no active sprint', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) - mockFindFirstSprint.mockResolvedValue(null) - - const result = await previewEnqueueAllAction(PRODUCT_ID) - - expect(result).toEqual({ tasks: [], blockerIndex: null, blockerReason: null }) - expect(mockFindManyTask).not.toHaveBeenCalled() - }) - - it('returns all tasks with no blocker when only TO_DO tasks', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) - mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) - mockFindManyTask.mockResolvedValue([ - makePbiTask('t1', 'TO_DO'), - makePbiTask('t2', 'TO_DO'), - ]) - - const result = await previewEnqueueAllAction(PRODUCT_ID) - - expect(result).toMatchObject({ blockerIndex: null, blockerReason: null }) - if (!('error' in result)) expect(result.tasks).toHaveLength(2) - }) - - it('detects REVIEW task as blocker at correct index', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) - mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) - mockFindManyTask.mockResolvedValue([ - makePbiTask('t1', 'TO_DO'), - makePbiTask('t2', 'TO_DO'), - makePbiTask('t3', 'REVIEW'), - makePbiTask('t4', 'TO_DO'), - ]) - - const result = await previewEnqueueAllAction(PRODUCT_ID) - - expect(result).toMatchObject({ blockerIndex: 2, blockerReason: 'task-review' }) - if (!('error' in result)) expect(result.tasks).toHaveLength(3) - }) - - it('detects BLOCKED PBI as blocker at first task of that PBI', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) - mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) - mockFindManyTask.mockResolvedValue([ - makePbiTask('t1', 'TO_DO', 'BLOCKED'), - makePbiTask('t2', 'TO_DO', 'BLOCKED'), - ]) - - const result = await previewEnqueueAllAction(PRODUCT_ID) - - expect(result).toMatchObject({ blockerIndex: 0, blockerReason: 'pbi-blocked' }) - if (!('error' in result)) expect(result.tasks).toHaveLength(1) - }) - - it('queries without TO_DO filter to expose REVIEW tasks', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) - mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) - mockFindManyTask.mockResolvedValue([]) - - await previewEnqueueAllAction(PRODUCT_ID) - - expect(mockFindManyTask).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.not.objectContaining({ status: 'TO_DO' }), - }) - ) - }) -}) - -const makeBatchTask = (id: string, hasActiveJob = false) => ({ - id, - claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [], -}) - -describe('enqueueClaudeJobsBatchAction', () => { - it('happy path: 3 taskIds → 3 jobs in input order', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) - mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) - mockFindManyTask.mockResolvedValue([ - makeBatchTask('t1'), - makeBatchTask('t2'), - makeBatchTask('t3'), - ]) - mockTransaction.mockResolvedValue([ - { id: 'job-1', task_id: 't1' }, - { id: 'job-2', task_id: 't2' }, - { id: 'job-3', task_id: 't3' }, - ]) - - const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't2', 't3']) - - expect(result).toEqual({ success: true, count: 3 }) - expect(mockExecuteRaw).toHaveBeenCalledTimes(3) - }) - - it('blocks demo user', async () => { - mockGetSession.mockResolvedValue(SESSION_DEMO) - - const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1']) - - expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' }) - expect(mockTransaction).not.toHaveBeenCalled() - }) - - it('returns error when product not accessible', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstProduct.mockResolvedValue(null) - - const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1']) - - expect(result).toMatchObject({ error: 'Geen toegang tot dit product' }) - expect(mockTransaction).not.toHaveBeenCalled() - }) - - it('returns error when task belongs to another user (IDOR)', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) - mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) - // Only 1 of 2 tasks authorized (other-user's task filtered out) - mockFindManyTask.mockResolvedValue([makeBatchTask('t1')]) - - const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't-other-user']) - - expect(result).toMatchObject({ error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' }) - expect(mockTransaction).not.toHaveBeenCalled() - }) - - it('skips tasks with active jobs (idempotent)', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) - mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) - mockFindManyTask.mockResolvedValue([ - makeBatchTask('t1'), - makeBatchTask('t2', true), // has active job — skip - makeBatchTask('t3'), - ]) - mockTransaction.mockResolvedValue([ - { id: 'job-1', task_id: 't1' }, - { id: 'job-3', task_id: 't3' }, - ]) - - const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't2', 't3']) - - expect(result).toEqual({ success: true, count: 2 }) - expect(mockExecuteRaw).toHaveBeenCalledTimes(2) - }) - - it('returns count=0 for empty taskIds', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - - const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, []) - - expect(result).toEqual({ success: true, count: 0 }) - expect(mockFindFirstProduct).not.toHaveBeenCalled() +describe('enqueueAllTodoJobsAction (deprecated)', () => { + it('retourneert een deprecation-error', async () => { + const result = await enqueueAllTodoJobsAction('prod-1') + expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') }) }) }) describe('cancelClaudeJobAction', () => { - it('happy path: cancels QUEUED job', async () => { + it('cancelt een actieve job', async () => { mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue(MOCK_JOB_QUEUED) - mockUpdateJob.mockResolvedValue({}) + mockFindFirstJob.mockResolvedValue({ + id: 'job-1', + status: 'QUEUED', + task_id: 'task-1', + product_id: 'prod-1', + }) + mockUpdateJob.mockResolvedValue(undefined) - const result = await cancelClaudeJobAction(JOB_ID) + const result = await cancelClaudeJobAction('job-1') expect(result).toEqual({ success: true }) - expect(mockUpdateJob).toHaveBeenCalledWith( + expect(mockUpdateJob).toHaveBeenCalledWith({ + where: { id: 'job-1' }, + data: expect.objectContaining({ status: 'CANCELLED' }), + }) + }) + + it('weigert demo-sessie', async () => { + mockGetSession.mockResolvedValue({ userId: 'demo', isDemo: true }) + + const result = await cancelClaudeJobAction('job-1') + expect(result).toMatchObject({ error: expect.stringContaining('demo') }) + expect(mockUpdateJob).not.toHaveBeenCalled() + }) + + it('retourneert error als job niet gevonden', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue(null) + + const result = await cancelClaudeJobAction('nonexistent') + expect(result).toMatchObject({ error: expect.stringContaining('niet gevonden') }) + }) + + it('weigert wanneer job niet meer actief is', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue({ + id: 'job-1', + status: 'DONE', + task_id: 'task-1', + product_id: 'prod-1', + }) + + const result = await cancelClaudeJobAction('job-1') + expect(result).toMatchObject({ error: expect.stringContaining('actieve') }) + }) +}) + +describe('restartClaudeJobAction', () => { + const FAILED_JOB = { + id: 'job-1', + status: 'FAILED', + kind: 'TASK_IMPLEMENTATION', + task_id: 'task-1', + idea_id: null, + sprint_run_id: null, + product_id: 'prod-1', + } + + it('reset een FAILED job naar QUEUED (happy path)', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue(FAILED_JOB) + mockUpdateManyJob.mockResolvedValue({ count: 1 }) + + const result = await restartClaudeJobAction('job-1') + + expect(result).toEqual({ success: true }) + expect(mockUpdateManyJob).toHaveBeenCalledWith( expect.objectContaining({ - where: { id: JOB_ID }, - data: expect.objectContaining({ status: 'CANCELLED' }), + where: expect.objectContaining({ id: 'job-1', status: { in: ['FAILED', 'CANCELLED', 'SKIPPED'] } }), + data: expect.objectContaining({ status: 'QUEUED' }), + }) + ) + expect(mockExecuteRaw).toHaveBeenCalled() + }) + + it('reset een CANCELLED job naar QUEUED', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue({ ...FAILED_JOB, status: 'CANCELLED' }) + mockUpdateManyJob.mockResolvedValue({ count: 1 }) + + const result = await restartClaudeJobAction('job-1') + expect(result).toEqual({ success: true }) + }) + + it('reset een SKIPPED job naar QUEUED', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue({ ...FAILED_JOB, status: 'SKIPPED' }) + mockUpdateManyJob.mockResolvedValue({ count: 1 }) + + const result = await restartClaudeJobAction('job-1') + expect(result).toEqual({ success: true }) + }) + + it('weigert demo-sessie', async () => { + mockGetSession.mockResolvedValue({ userId: 'demo', isDemo: true }) + + const result = await restartClaudeJobAction('job-1') + expect(result).toMatchObject({ error: expect.stringContaining('demo') }) + expect(mockUpdateManyJob).not.toHaveBeenCalled() + }) + + it('retourneert error als job niet gevonden', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue(null) + + const result = await restartClaudeJobAction('job-1') + expect(result).toMatchObject({ error: expect.stringContaining('niet gevonden') }) + }) + + it('weigert wanneer job een niet-restartbare status heeft', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue({ ...FAILED_JOB, status: 'DONE' }) + + const result = await restartClaudeJobAction('job-1') + expect(result).toMatchObject({ error: expect.stringContaining('mislukte') }) + expect(mockUpdateManyJob).not.toHaveBeenCalled() + }) + + it('retourneert error bij race-conditie (updateMany count === 0)', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue(FAILED_JOB) + mockUpdateManyJob.mockResolvedValue({ count: 0 }) + + const result = await restartClaudeJobAction('job-1') + expect(result).toMatchObject({ error: expect.stringContaining('gewijzigd') }) + }) + + it('reset ook SprintTaskExecution-rows bij SPRINT_IMPLEMENTATION', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue({ + ...FAILED_JOB, + kind: 'SPRINT_IMPLEMENTATION', + sprint_run_id: 'run-1', + }) + mockUpdateManyJob.mockResolvedValue({ count: 1 }) + mockUpdateManySprintTaskExecution.mockResolvedValue({ count: 3 }) + + const result = await restartClaudeJobAction('job-1') + + expect(result).toEqual({ success: true }) + expect(mockUpdateManySprintTaskExecution).toHaveBeenCalledWith( + expect.objectContaining({ + where: { sprint_job_id: 'job-1' }, + data: expect.objectContaining({ status: 'PENDING' }), }) ) }) - it('demo user is blocked', async () => { - mockGetSession.mockResolvedValue(SESSION_DEMO) - - const result = await cancelClaudeJobAction(JOB_ID) - - expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' }) - expect(mockUpdateJob).not.toHaveBeenCalled() - }) - - it('returns error when job not found (ownership check)', async () => { + it('reset geen SprintTaskExecution-rows bij TASK_IMPLEMENTATION', async () => { mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue(null) + mockFindFirstJob.mockResolvedValue(FAILED_JOB) + mockUpdateManyJob.mockResolvedValue({ count: 1 }) - const result = await cancelClaudeJobAction(JOB_ID) + await restartClaudeJobAction('job-1') - expect(result).toMatchObject({ error: 'Job niet gevonden' }) - expect(mockUpdateJob).not.toHaveBeenCalled() - }) - - it('returns error when cancelling terminal (DONE) job', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'DONE' as const }) - - const result = await cancelClaudeJobAction(JOB_ID) - - expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' }) - expect(mockUpdateJob).not.toHaveBeenCalled() - }) - - it('returns error when cancelling FAILED job', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'FAILED' as const }) - - const result = await cancelClaudeJobAction(JOB_ID) - - expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' }) + expect(mockUpdateManySprintTaskExecution).not.toHaveBeenCalled() }) }) diff --git a/__tests__/actions/commit-sprint-membership.test.ts b/__tests__/actions/commit-sprint-membership.test.ts new file mode 100644 index 0000000..af80547 --- /dev/null +++ b/__tests__/actions/commit-sprint-membership.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ + cookies: vi.fn().mockResolvedValue({ + set: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }), +})) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), + getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }), +})) +vi.mock('@/lib/rate-limit', () => ({ + enforceUserRateLimit: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/code-server', () => ({ + createWithCodeRetry: vi.fn(), + generateNextSprintCode: vi.fn(), +})) +vi.mock('@/lib/active-sprint', () => ({ + setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/lib/prisma', () => { + const txClient = { + sprint: { create: vi.fn() }, + story: { updateMany: vi.fn() }, + task: { updateMany: vi.fn() }, + } + return { + prisma: { + sprint: { findFirst: vi.fn() }, + story: { + findMany: vi.fn(), + updateMany: vi.fn(), + }, + task: { + findMany: vi.fn(), + updateMany: vi.fn(), + }, + $transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)), + __txClient: txClient, + }, + } +}) + +import { prisma } from '@/lib/prisma' +import { commitSprintMembershipAction } from '@/actions/sprints' + +type Mocked = { + sprint: { findFirst: ReturnType } + story: { + findMany: ReturnType + updateMany: ReturnType + } + task: { + findMany: ReturnType + updateMany: ReturnType + } + $transaction: ReturnType + __txClient: { + sprint: { create: ReturnType } + story: { updateMany: ReturnType } + task: { updateMany: ReturnType } + } +} +const mockPrisma = prisma as unknown as Mocked + +beforeEach(() => { + vi.clearAllMocks() + mockPrisma.sprint.findFirst.mockReset().mockResolvedValue({ + id: 'sprint-active', + product_id: 'product-1', + }) + mockPrisma.story.findMany.mockReset() + mockPrisma.story.updateMany.mockReset() + mockPrisma.task.findMany.mockReset() + mockPrisma.task.updateMany.mockReset() + mockPrisma.$transaction.mockImplementation( + async (fn: (tx: typeof mockPrisma.__txClient) => unknown) => + fn(mockPrisma.__txClient), + ) + mockPrisma.__txClient.story.updateMany.mockReset().mockResolvedValue({ count: 0 }) + mockPrisma.__txClient.task.updateMany.mockReset().mockResolvedValue({ count: 0 }) +}) + +describe('commitSprintMembershipAction', () => { + it('happy path: eligible adds + valid removes → transactie commits', async () => { + // adds-partition: alle eligible (sprint_id=null + niet DONE) + mockPrisma.story.findMany + // partition lookup + .mockResolvedValueOnce([ + { id: 's-add-1', sprint_id: null, status: 'OPEN', sprint: null }, + ]) + // removes-filter (sprint_id == activeSprintId) + .mockResolvedValueOnce([{ id: 's-rem-1' }]) + // affectedStories + .mockResolvedValueOnce([ + { pbi_id: 'pbiA' }, + { pbi_id: 'pbiB' }, + ]) + mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }]) + + const result = await commitSprintMembershipAction({ + activeSprintId: 'sprint-active', + adds: ['s-add-1'], + removes: ['s-rem-1'], + }) + + expect('success' in result).toBe(true) + if ('success' in result) { + expect(result.affectedStoryIds.sort()).toEqual(['s-add-1', 's-rem-1']) + expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB']) + expect(result.affectedTaskIds).toEqual(['t1']) + expect(result.conflicts.notEligible).toEqual([]) + expect(result.conflicts.alreadyRemoved).toEqual([]) + } + expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1) + expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledTimes(2) + expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledTimes(2) + }) + + it('add met status=DONE → conflicts.notEligible, story niet ge-update', async () => { + mockPrisma.story.findMany + .mockResolvedValueOnce([ + { id: 's-done', sprint_id: null, status: 'DONE', sprint: null }, + ]) + // removes-filter (geen removes) + .mockResolvedValueOnce([]) + + const result = await commitSprintMembershipAction({ + activeSprintId: 'sprint-active', + adds: ['s-done'], + removes: [], + }) + + expect('success' in result).toBe(true) + if ('success' in result) { + expect(result.affectedStoryIds).toEqual([]) + expect(result.conflicts.notEligible).toEqual([ + { storyId: 's-done', reason: 'DONE' }, + ]) + } + // Geen transaction omdat er niets te commiten valt. + expect(mockPrisma.$transaction).not.toHaveBeenCalled() + }) + + it('add met sprint_id in andere OPEN sprint → conflicts.notEligible IN_OTHER_SPRINT', async () => { + mockPrisma.story.findMany + .mockResolvedValueOnce([ + { + id: 's-elsewhere', + sprint_id: 'sprint-other', + status: 'IN_SPRINT', + sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' }, + }, + ]) + .mockResolvedValueOnce([]) + + const result = await commitSprintMembershipAction({ + activeSprintId: 'sprint-active', + adds: ['s-elsewhere'], + removes: [], + }) + + if ('success' in result) { + expect(result.conflicts.notEligible).toEqual([ + { storyId: 's-elsewhere', reason: 'IN_OTHER_SPRINT' }, + ]) + } + }) + + it('remove voor story die niet in actieve sprint zit → conflicts.alreadyRemoved', async () => { + mockPrisma.story.findMany + // adds-partition (geen adds) + .mockResolvedValueOnce([]) + // removes-filter — race scenario: story zit niet meer in active sprint + .mockResolvedValueOnce([]) + + const result = await commitSprintMembershipAction({ + activeSprintId: 'sprint-active', + adds: [], + removes: ['s-was-removed'], + }) + + if ('success' in result) { + expect(result.affectedStoryIds).toEqual([]) + expect(result.conflicts.alreadyRemoved).toEqual(['s-was-removed']) + } + }) + + it('transactie: story.status=IN_SPRINT bij add, =OPEN bij remove', async () => { + mockPrisma.story.findMany + .mockResolvedValueOnce([ + { id: 's-add', sprint_id: null, status: 'OPEN', sprint: null }, + ]) + .mockResolvedValueOnce([{ id: 's-rem' }]) + .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) + mockPrisma.task.findMany.mockResolvedValueOnce([]) + + await commitSprintMembershipAction({ + activeSprintId: 'sprint-active', + adds: ['s-add'], + removes: ['s-rem'], + }) + + const calls = mockPrisma.__txClient.story.updateMany.mock.calls + // Add: status=IN_SPRINT + sprint_id=sprint-active + expect(calls[0][0].data).toEqual({ + sprint_id: 'sprint-active', + status: 'IN_SPRINT', + }) + // Remove: status=OPEN + sprint_id=null + expect(calls[1][0].data).toEqual({ sprint_id: null, status: 'OPEN' }) + }) + + it('task.sprint_id wordt in dezelfde transactie ge-update', async () => { + mockPrisma.story.findMany + .mockResolvedValueOnce([ + { id: 's-add', sprint_id: null, status: 'OPEN', sprint: null }, + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) + mockPrisma.task.findMany.mockResolvedValueOnce([]) + + await commitSprintMembershipAction({ + activeSprintId: 'sprint-active', + adds: ['s-add'], + removes: [], + }) + + expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { story_id: { in: ['s-add'] } }, + data: { sprint_id: 'sprint-active' }, + }), + ) + }) + + it('return: affectedStoryIds + affectedPbiIds + affectedTaskIds + conflicts', async () => { + mockPrisma.story.findMany + .mockResolvedValueOnce([ + { id: 's-add', sprint_id: null, status: 'OPEN', sprint: null }, + ]) + .mockResolvedValueOnce([{ id: 's-rem' }]) + .mockResolvedValueOnce([ + { pbi_id: 'pbiA' }, + { pbi_id: 'pbiB' }, + ]) + mockPrisma.task.findMany.mockResolvedValueOnce([ + { id: 't1' }, + { id: 't2' }, + ]) + + const result = await commitSprintMembershipAction({ + activeSprintId: 'sprint-active', + adds: ['s-add'], + removes: ['s-rem'], + }) + + expect(result).toMatchObject({ + success: true, + affectedStoryIds: expect.arrayContaining(['s-add', 's-rem']), + affectedPbiIds: expect.arrayContaining(['pbiA', 'pbiB']), + affectedTaskIds: expect.arrayContaining(['t1', 't2']), + }) + }) + + it('rejects when sprint is not accessible', async () => { + mockPrisma.sprint.findFirst.mockResolvedValue(null) + + const result = await commitSprintMembershipAction({ + activeSprintId: 'sprint-active', + adds: [], + removes: [], + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.code).toBe(403) + } + }) +}) diff --git a/__tests__/actions/create-sprint-with-selection.test.ts b/__tests__/actions/create-sprint-with-selection.test.ts new file mode 100644 index 0000000..444008a --- /dev/null +++ b/__tests__/actions/create-sprint-with-selection.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ + cookies: vi.fn().mockResolvedValue({ + set: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }), +})) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), + getAccessibleProduct: vi.fn().mockResolvedValue({ + id: 'product-1', + user_id: 'user-1', + }), +})) +vi.mock('@/lib/rate-limit', () => ({ + enforceUserRateLimit: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/code-server', () => ({ + createWithCodeRetry: vi.fn(async (_gen, fn) => fn('SP-1')), + generateNextSprintCode: vi.fn().mockResolvedValue('SP-1'), +})) +vi.mock('@/lib/active-sprint', () => ({ + setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/lib/prisma', () => { + const txClient = { + sprint: { create: vi.fn() }, + story: { updateMany: vi.fn() }, + task: { updateMany: vi.fn() }, + } + return { + prisma: { + sprint: { + create: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + }, + story: { + findMany: vi.fn(), + updateMany: vi.fn(), + }, + task: { + findMany: vi.fn(), + updateMany: vi.fn(), + }, + pbi: { findMany: vi.fn() }, + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + $transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)), + __txClient: txClient, + }, + } +}) + +import { prisma } from '@/lib/prisma' +import { + createSprintWithSelectionAction, + type CreateSprintWithSelectionInput, +} from '@/actions/sprints' + +type Mocked = { + sprint: { + create: ReturnType + findFirst: ReturnType + update: ReturnType + } + story: { + findMany: ReturnType + updateMany: ReturnType + } + task: { + findMany: ReturnType + updateMany: ReturnType + } + $transaction: ReturnType + __txClient: { + sprint: { create: ReturnType } + story: { updateMany: ReturnType } + task: { updateMany: ReturnType } + } +} +const mockPrisma = prisma as unknown as Mocked + +function baseInput( + overrides: Partial = {}, +): CreateSprintWithSelectionInput { + return { + productId: 'product-1', + metadata: { goal: 'Sprint 1' }, + pbiIntent: {}, + storyOverrides: {}, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockPrisma.sprint.create.mockReset() + mockPrisma.story.findMany.mockReset() + mockPrisma.story.updateMany.mockReset() + mockPrisma.task.findMany.mockReset() + mockPrisma.task.updateMany.mockReset() + mockPrisma.$transaction.mockImplementation( + async (fn: (tx: typeof mockPrisma.__txClient) => unknown) => + fn(mockPrisma.__txClient), + ) + mockPrisma.__txClient.sprint.create + .mockReset() + .mockResolvedValue({ id: 'sprint-1', code: 'SP-1' }) + mockPrisma.__txClient.story.updateMany + .mockReset() + .mockResolvedValue({ count: 0 }) + mockPrisma.__txClient.task.updateMany + .mockReset() + .mockResolvedValue({ count: 0 }) +}) + +describe('createSprintWithSelectionAction', () => { + it('resolves intent=all naar alle child-stories en weert overrides.remove', async () => { + // Stap 1: stories voor PBI-A (intent=all). Plus eligibility-fetch. + mockPrisma.story.findMany + // resolve step (only for pbis with intent='all') + .mockResolvedValueOnce([ + { id: 's1', pbi_id: 'pbiA' }, + { id: 's2', pbi_id: 'pbiA' }, + { id: 's3', pbi_id: 'pbiA' }, + ]) + // partitionByEligibility — alle eligible + .mockResolvedValueOnce([ + { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, + { id: 's3', sprint_id: null, status: 'OPEN', sprint: null }, + ]) + // affectedStories + .mockResolvedValueOnce([ + { pbi_id: 'pbiA' }, + { pbi_id: 'pbiA' }, + ]) + mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }]) + + const result = await createSprintWithSelectionAction( + baseInput({ + pbiIntent: { pbiA: 'all' }, + storyOverrides: { pbiA: { add: [], remove: ['s2'] } }, + }), + ) + + expect('success' in result).toBe(true) + if ('success' in result) { + expect(result.affectedStoryIds).toEqual(['s1', 's3']) + expect(result.conflicts.notEligible).toEqual([]) + } + }) + + it('voegt storyOverrides.add toe over PBI heen (zelfs intent=none)', async () => { + // Geen PBI met intent=all → stap 1 wordt niet uitgevoerd. + mockPrisma.story.findMany + // partition + .mockResolvedValueOnce([ + { id: 's10', sprint_id: null, status: 'OPEN', sprint: null }, + ]) + // affectedStories + .mockResolvedValueOnce([{ pbi_id: 'pbiB' }]) + mockPrisma.task.findMany.mockResolvedValueOnce([]) + + const result = await createSprintWithSelectionAction( + baseInput({ + pbiIntent: { pbiB: 'none' }, + storyOverrides: { pbiB: { add: ['s10'], remove: [] } }, + }), + ) + + expect('success' in result).toBe(true) + if ('success' in result) { + expect(result.affectedStoryIds).toEqual(['s10']) + } + }) + + it('eligibility-filter classificeert DONE en cross-sprint stories', async () => { + mockPrisma.story.findMany + // resolve + .mockResolvedValueOnce([ + { id: 's1', pbi_id: 'pbiA' }, + { id: 's2', pbi_id: 'pbiA' }, + { id: 's3', pbi_id: 'pbiA' }, + ]) + // partition: s1=DONE, s2=eligible, s3=in andere OPEN sprint + .mockResolvedValueOnce([ + { id: 's1', sprint_id: null, status: 'DONE', sprint: null }, + { id: 's2', sprint_id: null, status: 'OPEN', sprint: null }, + { + id: 's3', + sprint_id: 'sprint-other', + status: 'IN_SPRINT', + sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' }, + }, + ]) + // affectedStories + .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) + mockPrisma.task.findMany.mockResolvedValueOnce([]) + + const result = await createSprintWithSelectionAction( + baseInput({ pbiIntent: { pbiA: 'all' } }), + ) + + expect('success' in result).toBe(true) + if ('success' in result) { + expect(result.affectedStoryIds).toEqual(['s2']) + expect(result.conflicts.notEligible.map((n) => n.storyId).sort()).toEqual( + ['s1', 's3'], + ) + expect(result.conflicts.crossSprint.map((c) => c.storyId)).toEqual(['s3']) + } + }) + + it('zet story.status=IN_SPRINT en task.sprint_id mee in dezelfde transactie', async () => { + mockPrisma.story.findMany + .mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }]) + .mockResolvedValueOnce([ + { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, + ]) + .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) + mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }]) + + await createSprintWithSelectionAction( + baseInput({ pbiIntent: { pbiA: 'all' } }), + ) + + expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1) + expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + sprint_id: 'sprint-1', + status: 'IN_SPRINT', + }), + }), + ) + expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: { sprint_id: 'sprint-1' }, + }), + ) + }) + + it('returnt affectedStoryIds + affectedPbiIds + affectedTaskIds', async () => { + mockPrisma.story.findMany + .mockResolvedValueOnce([ + { id: 's1', pbi_id: 'pbiA' }, + { id: 's2', pbi_id: 'pbiB' }, + ]) + .mockResolvedValueOnce([ + { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, + { id: 's2', sprint_id: null, status: 'OPEN', sprint: null }, + ]) + .mockResolvedValueOnce([{ pbi_id: 'pbiA' }, { pbi_id: 'pbiB' }]) + mockPrisma.task.findMany.mockResolvedValueOnce([ + { id: 't1' }, + { id: 't2' }, + ]) + + const result = await createSprintWithSelectionAction( + baseInput({ pbiIntent: { pbiA: 'all', pbiB: 'all' } }), + ) + + expect('success' in result).toBe(true) + if ('success' in result) { + expect(result.affectedStoryIds.sort()).toEqual(['s1', 's2']) + expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB']) + expect(result.affectedTaskIds.sort()).toEqual(['t1', 't2']) + } + }) + + it('returnt error wanneer geen eligible stories overblijven', async () => { + mockPrisma.story.findMany + .mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }]) + // s1 is DONE → notEligible + .mockResolvedValueOnce([ + { id: 's1', sprint_id: null, status: 'DONE', sprint: null }, + ]) + + const result = await createSprintWithSelectionAction( + baseInput({ pbiIntent: { pbiA: 'all' } }), + ) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.code).toBe(422) + } + }) +}) diff --git a/__tests__/actions/ideas-crud.test.ts b/__tests__/actions/ideas-crud.test.ts new file mode 100644 index 0000000..bf1ba41 --- /dev/null +++ b/__tests__/actions/ideas-crud.test.ts @@ -0,0 +1,717 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockSession } = vi.hoisted(() => ({ + mockSession: { userId: 'user-1', isDemo: false }, +})) + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockImplementation(async () => mockSession), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test-password-32-chars-minimum-len' }, +})) +vi.mock('@/lib/idea-code-server', () => ({ + nextIdeaCode: vi.fn().mockResolvedValue('IDEA-001'), +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + idea: { + create: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + ideaLog: { create: vi.fn() }, + claudeJob: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + claudeWorker: { + count: vi.fn(), + }, + pbi: { + findFirst: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + }, + story: { + findMany: vi.fn(), + create: vi.fn(), + }, + task: { + findMany: vi.fn(), + create: vi.fn(), + count: vi.fn(), + findUnique: vi.fn().mockResolvedValue(null), + }, + product: { + findUnique: vi.fn().mockResolvedValue(null), + }, + $transaction: vi.fn(), + $executeRaw: vi.fn().mockResolvedValue(0), + }, +})) + +import { prisma } from '@/lib/prisma' +import { + createIdeaAction, + updateIdeaAction, + archiveIdeaAction, + deleteIdeaAction, + updateGrillMdAction, + updatePlanMdAction, + uploadPlanMdAction, + downloadIdeaMdAction, + startGrillJobAction, + startMakePlanJobAction, + cancelIdeaJobAction, + materializeIdeaPlanAction, + relinkIdeaPlanAction, +} from '@/actions/ideas' + +type MockIdea = { + idea: { create: ReturnType; findFirst: ReturnType; update: ReturnType; delete: ReturnType } + ideaLog: { create: ReturnType } + claudeJob: { findFirst: ReturnType; create: ReturnType; update: ReturnType } + claudeWorker: { count: ReturnType } + pbi: { findFirst: ReturnType; findMany: ReturnType; findUnique: ReturnType; create: ReturnType; delete: ReturnType } + story: { findMany: ReturnType; create: ReturnType } + task: { findMany: ReturnType; create: ReturnType; count: ReturnType } + $transaction: ReturnType + $executeRaw: ReturnType +} +const m = prisma as unknown as MockIdea + +beforeEach(() => { + vi.clearAllMocks() + mockSession.userId = 'user-1' + mockSession.isDemo = false + // Default: $transaction passes its callback through with our mocked prisma + m.$transaction.mockImplementation(async (arg: unknown) => { + if (typeof arg === 'function') { + return (arg as (tx: unknown) => unknown)(m) + } + return arg + }) +}) + +describe('createIdeaAction', () => { + it('happy path: creates DRAFT idea with auto-generated code', async () => { + m.idea.create.mockResolvedValueOnce({ id: 'idea-1', code: 'IDEA-001' }) + + const r = await createIdeaAction({ title: 'Plant-watering reminder' }) + expect(r).toEqual({ success: true, data: { id: 'idea-1', code: 'IDEA-001' } }) + expect(m.idea.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + user_id: 'user-1', + code: 'IDEA-001', + title: 'Plant-watering reminder', + status: 'DRAFT', + }), + }), + ) + }) + + it('rejects unauthenticated', async () => { + mockSession.userId = '' + const r = await createIdeaAction({ title: 'x' }) + expect(r).toMatchObject({ error: expect.stringMatching(/ingelogd/), code: 401 }) + expect(m.idea.create).not.toHaveBeenCalled() + }) + + it('rejects demo-user', async () => { + mockSession.isDemo = true + const r = await createIdeaAction({ title: 'x' }) + expect(r).toMatchObject({ error: expect.stringMatching(/demo/), code: 403 }) + expect(m.idea.create).not.toHaveBeenCalled() + }) + + it('rejects invalid title (zod 422)', async () => { + const r = await createIdeaAction({ title: ' ' }) + expect(r).toMatchObject({ code: 422 }) + expect(m.idea.create).not.toHaveBeenCalled() + }) +}) + +describe('updateIdeaAction', () => { + it('happy: updates editable idea (DRAFT)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'DRAFT' }) + m.idea.update.mockResolvedValueOnce({}) + + const r = await updateIdeaAction('idea-1', { title: 'Updated' }) + expect(r).toEqual({ success: true }) + expect(m.idea.update).toHaveBeenCalledWith({ + where: { id: 'idea-1' }, + data: { title: 'Updated' }, + }) + }) + + it('blocks update on PLANNED (status-mismatch 422)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'PLANNED' }) + const r = await updateIdeaAction('idea-1', { title: 'x' }) + expect(r).toMatchObject({ code: 422 }) + expect(m.idea.update).not.toHaveBeenCalled() + }) + + it('blocks update during GRILLING', async () => { + m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'GRILLING' }) + const r = await updateIdeaAction('idea-1', { title: 'x' }) + expect(r).toMatchObject({ code: 422 }) + }) + + it('returns 404 when idea belongs to another user', async () => { + m.idea.findFirst.mockResolvedValueOnce(null) + const r = await updateIdeaAction('idea-1', { title: 'x' }) + expect(r).toMatchObject({ code: 404 }) + }) +}) + +describe('deleteIdeaAction', () => { + it('happy: deletes idea without pbi', async () => { + m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', pbi_id: null }) + const r = await deleteIdeaAction('idea-1') + expect(r).toEqual({ success: true }) + expect(m.idea.delete).toHaveBeenCalledWith({ where: { id: 'idea-1' } }) + }) + + it('blocks deletion when PBI is linked', async () => { + m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', pbi_id: 'pbi-1' }) + const r = await deleteIdeaAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + expect(m.idea.delete).not.toHaveBeenCalled() + }) +}) + +describe('archiveIdeaAction', () => { + it('archives owned idea', async () => { + m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1' }) + const r = await archiveIdeaAction('idea-1') + expect(r).toEqual({ success: true }) + expect(m.idea.update).toHaveBeenCalledWith({ + where: { id: 'idea-1' }, + data: { archived: true }, + }) + }) +}) + +describe('updateGrillMdAction', () => { + it('happy: updates grill_md in GRILLED', async () => { + m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLED' }) + const r = await updateGrillMdAction('idea-1', '# Updated grill') + expect(r).toEqual({ success: true }) + expect(m.$transaction).toHaveBeenCalled() + }) + + it('blocks in DRAFT', async () => { + m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' }) + const r = await updateGrillMdAction('idea-1', 'x') + expect(r).toMatchObject({ code: 422 }) + expect(m.$transaction).not.toHaveBeenCalled() + }) +}) + +describe('updatePlanMdAction', () => { + const VALID_PLAN = `--- +pbi: + title: Test + priority: 2 +stories: + - title: S1 + priority: 2 + tasks: + - title: T1 + priority: 2 +--- + +body +` + + it('happy: updates plan_md in PLAN_READY with valid yaml', async () => { + m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' }) + const r = await updatePlanMdAction('idea-1', VALID_PLAN) + expect(r).toEqual({ success: true }) + }) + + it('rejects invalid yaml (parse-fail 422 with details)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' }) + const r = await updatePlanMdAction('idea-1', '# no frontmatter') + expect(r).toMatchObject({ code: 422 }) + expect((r as { details?: unknown }).details).toBeDefined() + }) + + it('blocks in PLANNED', async () => { + m.idea.findFirst.mockResolvedValueOnce({ status: 'PLANNED' }) + const r = await updatePlanMdAction('idea-1', VALID_PLAN) + expect(r).toMatchObject({ code: 422 }) + }) +}) + +describe('uploadPlanMdAction', () => { + const VALID_PLAN = `--- +pbi: + title: Uploaded + priority: 2 +stories: + - title: S1 + priority: 2 + tasks: + - title: T1 + priority: 2 +--- + +body +` + + it('happy: uploads from DRAFT — skips grill, sets PLAN_READY', async () => { + m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' }) + const r = await uploadPlanMdAction('idea-1', VALID_PLAN) + expect(r).toEqual({ success: true }) + expect(m.$transaction).toHaveBeenCalled() + const txnArg = m.$transaction.mock.calls.at(-1)?.[0] as unknown[] | undefined + expect(txnArg).toBeDefined() + // The first call in the transaction is the update — confirm status=PLAN_READY. + expect(m.idea.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ plan_md: VALID_PLAN, status: 'PLAN_READY' }), + }), + ) + }) + + it('happy: uploads from GRILLED', async () => { + m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLED' }) + const r = await uploadPlanMdAction('idea-1', VALID_PLAN) + expect(r).toEqual({ success: true }) + }) + + it('happy: overwrites existing plan from PLAN_READY', async () => { + m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' }) + const r = await uploadPlanMdAction('idea-1', VALID_PLAN) + expect(r).toEqual({ success: true }) + }) + + it('happy: uploads from PLAN_FAILED (retry)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_FAILED' }) + const r = await uploadPlanMdAction('idea-1', VALID_PLAN) + expect(r).toEqual({ success: true }) + }) + + it('rejects from PLANNED (already materialized)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ status: 'PLANNED' }) + const r = await uploadPlanMdAction('idea-1', VALID_PLAN) + expect(r).toMatchObject({ code: 422 }) + expect(m.$transaction).not.toHaveBeenCalled() + }) + + it('rejects from GRILLING (job running)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLING' }) + const r = await uploadPlanMdAction('idea-1', VALID_PLAN) + expect(r).toMatchObject({ code: 422 }) + }) + + it('rejects empty markdown', async () => { + const r = await uploadPlanMdAction('idea-1', ' \n ') + expect(r).toMatchObject({ code: 422 }) + // Should fail before touching DB + expect(m.idea.findFirst).not.toHaveBeenCalled() + }) + + it('rejects oversized markdown', async () => { + const huge = 'a'.repeat(100_001) + const r = await uploadPlanMdAction('idea-1', huge) + expect(r).toMatchObject({ code: 422 }) + expect(m.idea.findFirst).not.toHaveBeenCalled() + }) + + it('rejects invalid yaml (parse-fail 422 with details)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' }) + const r = await uploadPlanMdAction('idea-1', '# no frontmatter') + expect(r).toMatchObject({ code: 422 }) + expect((r as { details?: unknown }).details).toBeDefined() + expect(m.$transaction).not.toHaveBeenCalled() + }) + + it('returns 404 when idea not found', async () => { + m.idea.findFirst.mockResolvedValueOnce(null) + const r = await uploadPlanMdAction('nope', VALID_PLAN) + expect(r).toMatchObject({ code: 404 }) + }) +}) + +describe('startGrillJobAction', () => { + const idea = { + id: 'idea-1', + status: 'DRAFT', + product_id: 'prod-1', + product: { id: 'prod-1', repo_url: 'https://github.com/x/y' }, + } + + beforeEach(() => { + m.idea.findFirst.mockResolvedValue(idea) + m.claudeJob.findFirst.mockResolvedValue(null) + m.claudeWorker.count.mockResolvedValue(1) + m.claudeJob.create.mockResolvedValue({ id: 'job-1' }) + }) + + it('happy path: creates IDEA_GRILL job, flips status to GRILLING', async () => { + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ success: true, data: { job_id: 'job-1' } }) + expect(m.$executeRaw).toHaveBeenCalled() + }) + + it('blocks demo-user', async () => { + mockSession.isDemo = true + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ code: 403 }) + expect(m.claudeJob.create).not.toHaveBeenCalled() + }) + + it('blocks when product has no repo_url', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + ...idea, + product: { id: 'prod-1', repo_url: null }, + }) + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ code: 422, error: expect.stringMatching(/repo_url/i) }) + }) + + it('blocks when no idea is unlinked', async () => { + m.idea.findFirst.mockResolvedValueOnce({ ...idea, product_id: null, product: null }) + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + }) + + it('blocks when no worker is active', async () => { + m.claudeWorker.count.mockResolvedValueOnce(0) + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ code: 422, error: expect.stringMatching(/worker/i) }) + expect(m.claudeJob.create).not.toHaveBeenCalled() + }) + + it('blocks when an active job already exists (409)', async () => { + m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'existing-job' }) + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ code: 409 }) + }) + + it('blocks invalid status (PLANNING)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ ...idea, status: 'PLANNING' }) + const r = await startGrillJobAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + }) +}) + +describe('startMakePlanJobAction', () => { + const idea = { + id: 'idea-1', + status: 'GRILLED', + product_id: 'prod-1', + product: { id: 'prod-1', repo_url: 'https://github.com/x/y' }, + } + + beforeEach(() => { + m.idea.findFirst.mockResolvedValue(idea) + m.claudeJob.findFirst.mockResolvedValue(null) + m.claudeWorker.count.mockResolvedValue(1) + m.claudeJob.create.mockResolvedValue({ id: 'job-2' }) + }) + + it('happy: GRILLED → PLANNING', async () => { + const r = await startMakePlanJobAction('idea-1') + expect(r).toMatchObject({ success: true }) + }) + + it('blocks from DRAFT (must grill first)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ ...idea, status: 'DRAFT' }) + const r = await startMakePlanJobAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + }) +}) + +describe('cancelIdeaJobAction', () => { + it('grill cancel without prior grill_md → DRAFT', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'GRILLING', + grill_md: null, + plan_md: null, + }) + m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'job-1', kind: 'IDEA_GRILL' }) + + const r = await cancelIdeaJobAction('idea-1') + expect(r).toEqual({ success: true }) + // Verify $transaction was called with 3 ops (job-update, idea-update, log) + expect(m.$transaction).toHaveBeenCalled() + }) + + it('grill re-grill cancel with prior grill_md → GRILLED', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'GRILLING', + grill_md: '# old grill', + plan_md: null, + }) + m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'job-1', kind: 'IDEA_GRILL' }) + + const r = await cancelIdeaJobAction('idea-1') + expect(r).toEqual({ success: true }) + }) + + it('returns 404 when no active job', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'GRILLED', + grill_md: null, + plan_md: null, + }) + m.claudeJob.findFirst.mockResolvedValueOnce(null) + const r = await cancelIdeaJobAction('idea-1') + expect(r).toMatchObject({ code: 404 }) + }) +}) + +describe('materializeIdeaPlanAction', () => { + const VALID_PLAN = `--- +pbi: + title: New PBI + priority: 2 +stories: + - title: Story A + priority: 2 + tasks: + - title: Task A1 + priority: 2 + implementation_plan: "1. Doe X" + - title: Task A2 + priority: 2 + - title: Story B + priority: 3 + tasks: + - title: Task B1 + priority: 3 +--- + +body +` + + beforeEach(() => { + m.idea.findFirst.mockResolvedValue({ + id: 'idea-1', + status: 'PLAN_READY', + product_id: 'prod-1', + plan_md: VALID_PLAN, + }) + m.pbi.findMany.mockResolvedValue([]) + m.story.findMany.mockResolvedValue([]) + m.task.findMany.mockResolvedValue([]) + m.pbi.findFirst.mockResolvedValue(null) + m.pbi.create.mockResolvedValue({ id: 'pbi-1', code: 'PBI-1' }) + m.story.create + .mockResolvedValueOnce({ id: 's-A' }) + .mockResolvedValueOnce({ id: 's-B' }) + m.task.create + .mockResolvedValueOnce({ id: 't-A1' }) + .mockResolvedValueOnce({ id: 't-A2' }) + .mockResolvedValueOnce({ id: 't-B1' }) + }) + + it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids; sort_order = parseCodeNumber(code)', async () => { + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ + success: true, + data: { + pbi_id: 'pbi-1', + pbi_code: 'PBI-1', + story_ids: ['s-A', 's-B'], + task_ids: ['t-A1', 't-A2', 't-B1'], + }, + }) + expect(m.pbi.create).toHaveBeenCalledTimes(1) + expect(m.story.create).toHaveBeenCalledTimes(2) + expect(m.task.create).toHaveBeenCalledTimes(3) + + // story sort_order = parseCodeNumber(auto-code): ST-001→1, ST-002→2 + expect(m.story.create.mock.calls[0][0].data.sort_order).toBe(1) + expect(m.story.create.mock.calls[1][0].data.sort_order).toBe(2) + + // task sort_order = parseCodeNumber(auto-code): T-1→1, T-2→2, T-3→3 + expect(m.task.create.mock.calls[0][0].data.sort_order).toBe(1) + expect(m.task.create.mock.calls[1][0].data.sort_order).toBe(2) + expect(m.task.create.mock.calls[2][0].data.sort_order).toBe(3) + }) + + it('blocks when not PLAN_READY (e.g. GRILLED)', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'GRILLED', + product_id: 'prod-1', + plan_md: VALID_PLAN, + }) + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + expect(m.pbi.create).not.toHaveBeenCalled() + }) + + it('returns 422 with details on parse-fail', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'PLAN_READY', + product_id: 'prod-1', + plan_md: '# no frontmatter', + }) + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + expect((r as { details?: unknown }).details).toBeDefined() + }) + + it('blocks demo-user', async () => { + mockSession.isDemo = true + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 403 }) + }) + + it('returns 409 on P2002 race', async () => { + m.$transaction.mockImplementationOnce(async () => { + throw new Error('Unique constraint failed (P2002)') + }) + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 409 }) + }) +}) + +describe('materializeIdeaPlanAction — existing PBI pre-check', () => { + const VALID_PLAN = `--- +pbi: + title: New PBI + priority: 2 +stories: + - title: Story A + priority: 2 + tasks: + - title: Task A1 + priority: 2 +--- + +body +` + + beforeEach(() => { + // Use a distinct userId to avoid sharing the rate-limit bucket with the + // materializeIdeaPlanAction describe block above. + mockSession.userId = 'user-precheck' + m.idea.findFirst.mockResolvedValue({ + id: 'idea-1', + status: 'PLAN_READY', + product_id: 'prod-1', + plan_md: VALID_PLAN, + pbi_id: 'old-pbi', + }) + m.pbi.findMany.mockResolvedValue([]) + m.story.findMany.mockResolvedValue([]) + m.task.findMany.mockResolvedValue([]) + m.pbi.findFirst.mockResolvedValue(null) + m.pbi.findUnique.mockResolvedValue({ code: 'PBI-X' }) + m.pbi.create.mockResolvedValue({ id: 'pbi-new', code: 'PBI-2' }) + m.pbi.delete.mockResolvedValue({}) + m.story.create.mockResolvedValue({ id: 's-1' }) + m.task.create.mockResolvedValue({ id: 't-1' }) + }) + + it('auto-vervang: deletes old PBI in transaction when no tasks executed', async () => { + m.task.count.mockResolvedValueOnce(0) + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ success: true, data: { pbi_id: 'pbi-new' } }) + expect(m.pbi.delete).toHaveBeenCalledWith({ where: { id: 'old-pbi' } }) + expect(m.pbi.create).toHaveBeenCalledTimes(1) + }) + + it('conflict-409: returns PBI_HAS_ACTIVE_TASKS when executed tasks exist', async () => { + m.task.count.mockResolvedValueOnce(1) + const r = await materializeIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 409, error: 'PBI_HAS_ACTIVE_TASKS:PBI-X' }) + expect(m.pbi.create).not.toHaveBeenCalled() + expect(m.pbi.delete).not.toHaveBeenCalled() + }) + + it('alongside: skips old PBI delete and creates new PBI when allowAlongside=true', async () => { + m.task.count.mockResolvedValueOnce(1) + const r = await materializeIdeaPlanAction('idea-1', { allowAlongside: true }) + expect(r).toMatchObject({ success: true, data: { pbi_id: 'pbi-new' } }) + expect(m.pbi.delete).not.toHaveBeenCalled() + expect(m.pbi.create).toHaveBeenCalledTimes(1) + }) +}) + +describe('relinkIdeaPlanAction', () => { + it('happy: PLANNED with pbi_id=null → PLAN_READY', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'PLANNED', + pbi_id: null, + }) + const r = await relinkIdeaPlanAction('idea-1') + expect(r).toEqual({ success: true }) + expect(m.$transaction).toHaveBeenCalled() + }) + + it('blocks when pbi still linked', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'PLANNED', + pbi_id: 'pbi-1', + }) + const r = await relinkIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + }) + + it('blocks when not PLANNED', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + id: 'idea-1', + status: 'PLAN_READY', + pbi_id: null, + }) + const r = await relinkIdeaPlanAction('idea-1') + expect(r).toMatchObject({ code: 422 }) + }) +}) + +describe('downloadIdeaMdAction', () => { + it('returns grill_md when present', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + code: 'IDEA-001', + grill_md: '# Idee\nscope', + plan_md: null, + }) + const r = await downloadIdeaMdAction('idea-1', 'grill') + expect(r).toMatchObject({ + success: true, + data: { filename: 'IDEA-001-grill.md', markdown: '# Idee\nscope' }, + }) + }) + + it('404 when md not yet generated', async () => { + m.idea.findFirst.mockResolvedValueOnce({ + code: 'IDEA-001', + grill_md: null, + plan_md: null, + }) + const r = await downloadIdeaMdAction('idea-1', 'plan') + expect(r).toMatchObject({ code: 404 }) + }) + + it('demo MAY download (read-only operation)', async () => { + mockSession.isDemo = true + m.idea.findFirst.mockResolvedValueOnce({ + code: 'IDEA-001', + grill_md: 'x', + plan_md: null, + }) + const r = await downloadIdeaMdAction('idea-1', 'grill') + expect(r).toMatchObject({ success: true }) + }) +}) diff --git a/__tests__/actions/push.test.ts b/__tests__/actions/push.test.ts new file mode 100644 index 0000000..1e74a22 --- /dev/null +++ b/__tests__/actions/push.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +const { mockUpsert, mockDeleteMany } = vi.hoisted(() => ({ + mockUpsert: vi.fn(), + mockDeleteMany: vi.fn(), +})) + +vi.mock('@/lib/prisma', () => ({ + prisma: { + pushSubscription: { + upsert: mockUpsert, + deleteMany: mockDeleteMany, + }, + }, +})) + +import { subscribeToPushAction, unsubscribeFromPushAction } from '@/actions/push' + +const VALID_INPUT = { + endpoint: 'https://push.example.com/subscription/abc123', + keys: { p256dh: 'aBcDeFgH', auth: 'xYzAbC' }, +} + +const SESSION_USER = { userId: 'user-1', isDemo: false } +const SESSION_DEMO = { userId: 'demo-1', isDemo: true } + +beforeEach(() => { + vi.clearAllMocks() + mockUpsert.mockResolvedValue({}) + mockDeleteMany.mockResolvedValue({ count: 1 }) +}) + +describe('subscribeToPushAction', () => { + it('upserts subscription for authenticated user', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + await subscribeToPushAction(VALID_INPUT) + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { endpoint: VALID_INPUT.endpoint }, + create: expect.objectContaining({ user_id: 'user-1', endpoint: VALID_INPUT.endpoint }), + }) + ) + }) + + it('is idempotent — calling twice upserts twice without error', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + await subscribeToPushAction(VALID_INPUT) + await subscribeToPushAction(VALID_INPUT) + expect(mockUpsert).toHaveBeenCalledTimes(2) + }) + + it('returns without writing for demo user', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + await subscribeToPushAction(VALID_INPUT) + expect(mockUpsert).not.toHaveBeenCalled() + }) + + it('returns without writing when not authenticated', async () => { + mockGetSession.mockResolvedValue({}) + await subscribeToPushAction(VALID_INPUT) + expect(mockUpsert).not.toHaveBeenCalled() + }) + + it('returns without writing for invalid input', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + // @ts-expect-error intentionally invalid + await subscribeToPushAction({ endpoint: 'not-a-url', keys: {} }) + expect(mockUpsert).not.toHaveBeenCalled() + }) +}) + +describe('unsubscribeFromPushAction', () => { + it('deletes subscription scoped to user_id', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + await unsubscribeFromPushAction({ endpoint: VALID_INPUT.endpoint }) + expect(mockDeleteMany).toHaveBeenCalledWith({ + where: { endpoint: VALID_INPUT.endpoint, user_id: 'user-1' }, + }) + }) + + it('does not touch subscriptions of other users', async () => { + mockGetSession.mockResolvedValue({ userId: 'other-user', isDemo: false }) + await unsubscribeFromPushAction({ endpoint: VALID_INPUT.endpoint }) + expect(mockDeleteMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ user_id: 'other-user' }) }) + ) + }) + + it('returns without writing when not authenticated', async () => { + mockGetSession.mockResolvedValue({}) + await unsubscribeFromPushAction({ endpoint: VALID_INPUT.endpoint }) + expect(mockDeleteMany).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/actions/questions.test.ts b/__tests__/actions/questions.test.ts index 22dd33d..ece85ff 100644 --- a/__tests__/actions/questions.test.ts +++ b/__tests__/actions/questions.test.ts @@ -16,6 +16,9 @@ vi.mock('@/lib/prisma', () => ({ findFirst: vi.fn(), updateMany: vi.fn(), }, + product: { + findFirst: vi.fn().mockResolvedValue({ id: 'product-1' }), + }, }, })) @@ -44,7 +47,13 @@ beforeEach(() => { describe('actions/questions — answerQuestion', () => { it('happy: status pending→answered, revalidatePath geroepen', async () => { mockGetSession.mockResolvedValue(SESSION_USER) - mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ id: VALID_ID }) // access-check + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ + id: VALID_ID, + story_id: 'story-1', + idea_id: null, + product_id: 'product-1', + idea: null, + }) mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 1 }) const res = await answerQuestion(VALID_ID, VALID_ANSWER) @@ -85,7 +94,13 @@ describe('actions/questions — answerQuestion', () => { it('al-answered: race-error met begrijpelijke melding', async () => { mockGetSession.mockResolvedValue(SESSION_USER) - mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ id: VALID_ID }) // access-check + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ + id: VALID_ID, + story_id: 'story-1', + idea_id: null, + product_id: 'product-1', + idea: null, + }) mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 0 }) mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ status: 'answered', @@ -99,7 +114,13 @@ describe('actions/questions — answerQuestion', () => { it('verlopen: updateMany count=0, nog open status maar voorbij expiry', async () => { mockGetSession.mockResolvedValue(SESSION_USER) - mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ id: VALID_ID }) + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ + id: VALID_ID, + story_id: 'story-1', + idea_id: null, + product_id: 'product-1', + idea: null, + }) mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 0 }) mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ status: 'open', diff --git a/__tests__/actions/settings.test.ts b/__tests__/actions/settings.test.ts new file mode 100644 index 0000000..415b059 --- /dev/null +++ b/__tests__/actions/settings.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockUserUpdate, mockGetIronSession } = vi.hoisted(() => ({ + mockUserUpdate: vi.fn(), + mockGetIronSession: vi.fn(), +})) + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) +vi.mock('iron-session', () => ({ getIronSession: mockGetIronSession })) +vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 'test', password: 'test' } })) +vi.mock('@/lib/prisma', () => ({ + prisma: { user: { update: mockUserUpdate } }, +})) + +import { updateMinQuotaPctAction } from '@/actions/settings' + +const SESSION_USER = { userId: 'user-1', isDemo: false } +const SESSION_DEMO = { userId: 'demo-1', isDemo: true } +const SESSION_UNAUTH = { userId: undefined, isDemo: false } + +describe('updateMinQuotaPctAction', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUserUpdate.mockResolvedValue({}) + }) + + it('returns error when not authenticated', async () => { + mockGetIronSession.mockResolvedValue(SESSION_UNAUTH) + const result = await updateMinQuotaPctAction(20) + expect(result).toMatchObject({ error: expect.any(String) }) + expect(mockUserUpdate).not.toHaveBeenCalled() + }) + + it('returns 403 error for demo session', async () => { + mockGetIronSession.mockResolvedValue(SESSION_DEMO) + const result = await updateMinQuotaPctAction(20) + expect(result).toMatchObject({ status: 403 }) + expect(mockUserUpdate).not.toHaveBeenCalled() + }) + + it('returns 422 error when value is 0 (below min)', async () => { + mockGetIronSession.mockResolvedValue(SESSION_USER) + const result = await updateMinQuotaPctAction(0) + expect(result).toMatchObject({ status: 422 }) + expect(mockUserUpdate).not.toHaveBeenCalled() + }) + + it('returns 422 error when value is 101 (above max)', async () => { + mockGetIronSession.mockResolvedValue(SESSION_USER) + const result = await updateMinQuotaPctAction(101) + expect(result).toMatchObject({ status: 422 }) + expect(mockUserUpdate).not.toHaveBeenCalled() + }) + + it('saves valid value and returns success', async () => { + mockGetIronSession.mockResolvedValue(SESSION_USER) + const result = await updateMinQuotaPctAction(35) + expect(result).toEqual({ success: true }) + expect(mockUserUpdate).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { min_quota_pct: 35 }, + }) + }) + + it('accepts boundary values 1 and 100', async () => { + mockGetIronSession.mockResolvedValue(SESSION_USER) + await updateMinQuotaPctAction(1) + await updateMinQuotaPctAction(100) + expect(mockUserUpdate).toHaveBeenCalledTimes(2) + }) +}) diff --git a/__tests__/actions/sprint-dates.test.ts b/__tests__/actions/sprint-dates.test.ts index eaa05db..af2474f 100644 --- a/__tests__/actions/sprint-dates.test.ts +++ b/__tests__/actions/sprint-dates.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) +vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({ set: vi.fn(), get: vi.fn(), delete: vi.fn() }) })) vi.mock('iron-session', () => ({ getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), })) @@ -16,16 +16,22 @@ vi.mock('@/lib/prisma', () => ({ prisma: { sprint: { findFirst: vi.fn(), + findMany: vi.fn(), create: vi.fn(), update: vi.fn(), }, + user: { + findUnique: vi.fn().mockResolvedValue({ settings: {} }), + update: vi.fn().mockResolvedValue({}), + }, + $executeRaw: vi.fn().mockResolvedValue(1), }, })) import { prisma } from '@/lib/prisma' import { createSprintAction, updateSprintDatesAction } from '@/actions/sprints' -const mockSprint = prisma as unknown as { sprint: { findFirst: ReturnType; create: ReturnType; update: ReturnType } } +const mockSprint = prisma as unknown as { sprint: { findFirst: ReturnType; findMany: ReturnType; create: ReturnType; update: ReturnType } } function makeFormData(data: Record) { const fd = new FormData() @@ -39,6 +45,7 @@ describe('createSprintAction — date validation', () => { beforeEach(() => { vi.clearAllMocks() mockSprint.sprint.findFirst.mockResolvedValue(null) + mockSprint.sprint.findMany.mockResolvedValue([]) mockSprint.sprint.create.mockResolvedValue({ id: 'sprint-1' }) }) diff --git a/__tests__/actions/sprint-draft.test.ts b/__tests__/actions/sprint-draft.test.ts new file mode 100644 index 0000000..f6fa3b1 --- /dev/null +++ b/__tests__/actions/sprint-draft.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ + cookies: vi.fn().mockResolvedValue({ + set: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }), +})) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + product: { findFirst: vi.fn() }, + user: { + findUnique: vi.fn(), + update: vi.fn().mockResolvedValue({}), + }, + $executeRaw: vi.fn().mockResolvedValue(1), + }, +})) + +import { prisma } from '@/lib/prisma' +import { + clearPendingSprintDraftAction, + setPendingSprintDraftAction, +} from '@/actions/sprint-draft' +import type { PendingSprintDraft, UserSettings } from '@/lib/user-settings' + +const mockPrisma = prisma as unknown as { + product: { findFirst: ReturnType } + user: { + findUnique: ReturnType + update: ReturnType + } +} + +const validDraft: PendingSprintDraft = { + goal: 'Sprint 1', + pbiIntent: { pbiA: 'all' }, + storyOverrides: { pbiA: { add: [], remove: ['story-1'] } }, +} + +describe('setPendingSprintDraftAction', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPrisma.product.findFirst.mockReset() + mockPrisma.user.findUnique.mockReset() + mockPrisma.user.update.mockReset().mockResolvedValue({}) + }) + + it('persists draft for accessible product', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} }) + + const result = await setPendingSprintDraftAction('p1', validDraft) + + expect(result).toEqual({ success: true }) + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: UserSettings } + } + expect(updateArg.data.settings.workflow?.pendingSprintDraft?.p1).toMatchObject({ + goal: 'Sprint 1', + pbiIntent: { pbiA: 'all' }, + }) + }) + + it('preserves drafts for other products', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + mockPrisma.user.findUnique.mockResolvedValueOnce({ + settings: { + workflow: { + pendingSprintDraft: { + p2: { goal: 'P2 draft', pbiIntent: {}, storyOverrides: {} }, + }, + }, + }, + }) + + await setPendingSprintDraftAction('p1', validDraft) + + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: UserSettings } + } + const drafts = updateArg.data.settings.workflow?.pendingSprintDraft + expect(Object.keys(drafts ?? {})).toEqual(expect.arrayContaining(['p1', 'p2'])) + }) + + it('rejects invalid draft (empty goal)', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + + const result = await setPendingSprintDraftAction('p1', { + ...validDraft, + goal: '', + } as PendingSprintDraft) + + expect(result).toHaveProperty('error') + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) + + it('rejects when product not accessible', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce(null) + + const result = await setPendingSprintDraftAction('p1', validDraft) + + expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' }) + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) +}) + +describe('clearPendingSprintDraftAction', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPrisma.product.findFirst.mockReset() + mockPrisma.user.findUnique.mockReset() + mockPrisma.user.update.mockReset().mockResolvedValue({}) + }) + + it('removes draft key for product', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + mockPrisma.user.findUnique.mockResolvedValueOnce({ + settings: { + workflow: { + pendingSprintDraft: { + p1: { goal: 'gone', pbiIntent: {}, storyOverrides: {} }, + p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} }, + }, + }, + }, + }) + + await clearPendingSprintDraftAction('p1') + + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: UserSettings } + } + expect(updateArg.data.settings.workflow?.pendingSprintDraft).toEqual({ + p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} }, + }) + }) + + it('is a no-op when there is no draft for the product', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} }) + + const result = await clearPendingSprintDraftAction('p1') + + expect(result).toEqual({ success: true }) + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) + + it('rejects when product not accessible', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce(null) + + const result = await clearPendingSprintDraftAction('p1') + + expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' }) + }) +}) diff --git a/__tests__/actions/sprint-runs.test.ts b/__tests__/actions/sprint-runs.test.ts new file mode 100644 index 0000000..acf4396 --- /dev/null +++ b/__tests__/actions/sprint-runs.test.ts @@ -0,0 +1,407 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn(), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) + +vi.mock('@/lib/prisma', () => ({ + prisma: { + sprint: { + findUnique: vi.fn(), + update: vi.fn(), + }, + sprintRun: { + findFirst: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + story: { + findMany: vi.fn(), + updateMany: vi.fn(), + }, + pbi: { + updateMany: vi.fn(), + }, + task: { + updateMany: vi.fn(), + findUnique: vi.fn().mockResolvedValue(null), + }, + claudeQuestion: { + findMany: vi.fn(), + }, + claudeJob: { + create: vi.fn(), + updateMany: vi.fn(), + }, + product: { + findUnique: vi.fn().mockResolvedValue(null), + }, + $transaction: vi.fn(), + }, +})) + +import { prisma } from '@/lib/prisma' +import { getIronSession } from 'iron-session' +import { + startSprintRunAction, + resumeSprintAction, + cancelSprintRunAction, +} from '@/actions/sprint-runs' + +const mockSession = getIronSession as ReturnType + +type Mocked = { + sprint: { findUnique: ReturnType; update: ReturnType } + sprintRun: { + findFirst: ReturnType + findUnique: ReturnType + create: ReturnType + update: ReturnType + } + story: { + findMany: ReturnType + updateMany: ReturnType + } + pbi: { updateMany: ReturnType } + task: { updateMany: ReturnType } + claudeQuestion: { findMany: ReturnType } + claudeJob: { + create: ReturnType + updateMany: ReturnType + } + $transaction: ReturnType +} +const mockPrisma = prisma as unknown as Mocked + +const SPRINT_OK = { + id: 'sprint-1', + status: 'OPEN', + product_id: 'prod-1', + product: { id: 'prod-1', pr_strategy: 'SPRINT' }, +} + +const STORY_OK = { + id: 'story-1', + pbi_id: 'pbi-1', + priority: 1, + sort_order: 1, + pbi: { + id: 'pbi-1', + code: 'PBI-1', + title: 'PBI', + status: 'READY', + priority: 1, + sort_order: 1, + }, + tasks: [ + { id: 'task-1', code: 'T-1', title: 'T1', priority: 1, sort_order: 1, implementation_plan: 'plan' }, + { id: 'task-2', code: 'T-2', title: 'T2', priority: 1, sort_order: 2, implementation_plan: 'plan' }, + ], +} + +beforeEach(() => { + vi.clearAllMocks() + mockSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockPrisma.$transaction.mockImplementation( + async (run: (tx: typeof prisma) => Promise) => run(prisma), + ) +}) + +describe('startSprintRunAction — happy path', () => { + it('maakt SprintRun + 2 ClaudeJobs voor 2 TO_DO tasks', async () => { + mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK) + mockPrisma.sprintRun.findFirst.mockResolvedValue(null) + mockPrisma.story.findMany.mockResolvedValue([STORY_OK]) + mockPrisma.claudeQuestion.findMany.mockResolvedValue([]) + mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-1' }) + mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-x' }) + + const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) + + expect(result).toEqual({ ok: true, sprint_run_id: 'run-1', jobs_count: 2 }) + expect(mockPrisma.sprintRun.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + sprint_id: 'sprint-1', + started_by_id: 'user-1', + status: 'QUEUED', + pr_strategy: 'SPRINT', + }), + }) + expect(mockPrisma.claudeJob.create).toHaveBeenCalledTimes(2) + }) +}) + +describe('startSprintRunAction — pre-flight blockers', () => { + it('blokkeert wanneer task geen implementation_plan heeft', async () => { + mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK) + mockPrisma.sprintRun.findFirst.mockResolvedValue(null) + mockPrisma.story.findMany.mockResolvedValue([ + { + ...STORY_OK, + tasks: [ + { id: 'task-1', code: 'T-1', title: 'T1', priority: 1, sort_order: 1, implementation_plan: null }, + ], + }, + ]) + mockPrisma.claudeQuestion.findMany.mockResolvedValue([]) + + const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) + + expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' }) + if (result.ok === false && 'blockers' in result) { + expect(result.blockers).toContainEqual({ + type: 'task_no_plan', + id: 'task-1', + label: 'T-1: T1', + }) + } + expect(mockPrisma.sprintRun.create).not.toHaveBeenCalled() + }) + + it('blokkeert wanneer er een open ClaudeQuestion in scope is', async () => { + mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK) + mockPrisma.sprintRun.findFirst.mockResolvedValue(null) + mockPrisma.story.findMany.mockResolvedValue([STORY_OK]) + mockPrisma.claudeQuestion.findMany.mockResolvedValue([ + { id: 'q-1', question: 'Welke route?' }, + ]) + + const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) + + expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' }) + if (result.ok === false && 'blockers' in result) { + expect(result.blockers).toContainEqual({ + type: 'open_question', + id: 'q-1', + label: 'Welke route?', + }) + } + }) + + it('blokkeert wanneer een PBI BLOCKED of FAILED is', async () => { + mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK) + mockPrisma.sprintRun.findFirst.mockResolvedValue(null) + mockPrisma.story.findMany.mockResolvedValue([ + { ...STORY_OK, pbi: { ...STORY_OK.pbi, status: 'BLOCKED' } }, + ]) + mockPrisma.claudeQuestion.findMany.mockResolvedValue([]) + + const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) + + expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' }) + if (result.ok === false && 'blockers' in result) { + expect(result.blockers).toContainEqual({ + type: 'pbi_blocked', + id: 'pbi-1', + label: 'PBI-1: PBI', + }) + } + }) +}) + +describe('startSprintRunAction — SPRINT_BATCH', () => { + const SPRINT_BATCH = { + ...SPRINT_OK, + product: { + id: 'prod-1', + pr_strategy: 'SPRINT_BATCH', + repo_url: 'https://github.com/example/main', + }, + } + + it('blokkeert task met afwijkende repo_url', async () => { + mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_BATCH) + mockPrisma.sprintRun.findFirst.mockResolvedValue(null) + mockPrisma.story.findMany.mockResolvedValue([ + { + ...STORY_OK, + tasks: [ + { + id: 'task-1', + code: 'T-1', + title: 'In main repo', + priority: 1, + sort_order: 1, + implementation_plan: 'plan', + repo_url: null, + }, + { + id: 'task-2', + code: 'T-2', + title: 'Cross-repo', + priority: 1, + sort_order: 2, + implementation_plan: 'plan', + repo_url: 'https://github.com/example/other', + }, + ], + }, + ]) + mockPrisma.claudeQuestion.findMany.mockResolvedValue([]) + + const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) + + expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' }) + if (result.ok === false && 'blockers' in result) { + expect(result.blockers).toContainEqual({ + type: 'task_cross_repo', + id: 'task-2', + label: 'T-2: Cross-repo', + }) + } + expect(mockPrisma.sprintRun.create).not.toHaveBeenCalled() + }) + + it('staat tasks toe wanneer repo_url leeg is of gelijk aan product.repo_url', async () => { + mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_BATCH) + mockPrisma.sprintRun.findFirst.mockResolvedValue(null) + mockPrisma.story.findMany.mockResolvedValue([ + { + ...STORY_OK, + tasks: [ + { + id: 'task-1', + code: 'T-1', + title: 'No override', + priority: 1, + sort_order: 1, + implementation_plan: 'plan', + repo_url: null, + }, + { + id: 'task-2', + code: 'T-2', + title: 'Same repo', + priority: 1, + sort_order: 2, + implementation_plan: 'plan', + repo_url: 'https://github.com/example/main', + }, + ], + }, + ]) + mockPrisma.claudeQuestion.findMany.mockResolvedValue([]) + mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-batch' }) + mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-sprint' }) + + const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) + + expect(result).toMatchObject({ ok: true, sprint_run_id: 'run-batch' }) + // Eén SPRINT_IMPLEMENTATION-job, niet per-task + expect(mockPrisma.claudeJob.create).toHaveBeenCalledTimes(1) + expect(mockPrisma.claudeJob.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + kind: 'SPRINT_IMPLEMENTATION', + sprint_run_id: 'run-batch', + product_id: 'prod-1', + }), + }) + }) +}) + +describe('startSprintRunAction — guards', () => { + it('weigert wanneer Sprint niet ACTIVE is', async () => { + mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'CLOSED' }) + + const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) + expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_ACTIVE' }) + }) + + it('weigert wanneer er al een actieve SprintRun is', async () => { + mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK) + mockPrisma.sprintRun.findFirst.mockResolvedValue({ id: 'run-existing', status: 'RUNNING' }) + + const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) + expect(result).toMatchObject({ ok: false, error: 'SPRINT_RUN_ALREADY_ACTIVE' }) + }) + + it('weigert demo-sessie', async () => { + mockSession.mockResolvedValue({ userId: 'demo', isDemo: true }) + + const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) + expect(result).toMatchObject({ ok: false, code: 403 }) + }) +}) + +describe('resumeSprintAction', () => { + it('zet sprint en cascade-statuses terug en maakt nieuwe SprintRun', async () => { + // Eerste findUnique (resume) ziet de sprint nog op FAILED; + // de tweede call (binnen startSprintRunCore na de update) ziet ACTIVE. + mockPrisma.sprint.findUnique + .mockResolvedValueOnce({ ...SPRINT_OK, status: 'FAILED' }) + .mockResolvedValue(SPRINT_OK) + mockPrisma.sprintRun.findFirst.mockResolvedValue(null) + mockPrisma.story.findMany.mockImplementation(async (args: { select?: { pbi_id?: boolean } }) => { + if (args.select?.pbi_id) return [{ pbi_id: 'pbi-1' }] + return [STORY_OK] + }) + mockPrisma.claudeQuestion.findMany.mockResolvedValue([]) + mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-2' }) + mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-x' }) + + const result = await resumeSprintAction({ sprint_id: 'sprint-1' }) + + expect(result).toMatchObject({ ok: true, sprint_run_id: 'run-2' }) + expect(mockPrisma.sprint.update).toHaveBeenCalledWith({ + where: { id: 'sprint-1' }, + data: { status: 'OPEN', completed_at: null }, + }) + expect(mockPrisma.story.updateMany).toHaveBeenCalledWith({ + where: { sprint_id: 'sprint-1', status: 'FAILED' }, + data: { status: 'IN_SPRINT' }, + }) + expect(mockPrisma.task.updateMany).toHaveBeenCalledWith({ + where: { story: { sprint_id: 'sprint-1' }, status: 'FAILED' }, + data: { status: 'TO_DO' }, + }) + }) + + it('weigert als sprint niet FAILED is', async () => { + mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'OPEN' }) + + const result = await resumeSprintAction({ sprint_id: 'sprint-1' }) + expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_FAILED' }) + }) +}) + +describe('cancelSprintRunAction', () => { + it('zet SprintRun op CANCELLED en cancelt openstaande jobs', async () => { + mockPrisma.sprintRun.findUnique.mockResolvedValue({ + id: 'run-1', + status: 'RUNNING', + sprint_id: 'sprint-1', + }) + + const result = await cancelSprintRunAction({ sprint_run_id: 'run-1' }) + + expect(result).toEqual({ ok: true }) + expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith({ + where: { id: 'run-1' }, + data: expect.objectContaining({ status: 'CANCELLED' }), + }) + expect(mockPrisma.claudeJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + sprint_run_id: 'run-1', + status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, + }), + data: expect.objectContaining({ status: 'CANCELLED' }), + })) + }) + + it('weigert wanneer SprintRun al DONE is', async () => { + mockPrisma.sprintRun.findUnique.mockResolvedValue({ + id: 'run-1', + status: 'DONE', + sprint_id: 'sprint-1', + }) + + const result = await cancelSprintRunAction({ sprint_run_id: 'run-1' }) + expect(result).toMatchObject({ ok: false, error: 'SPRINT_RUN_NOT_CANCELLABLE' }) + }) +}) diff --git a/__tests__/actions/sprints-cascade.test.ts b/__tests__/actions/sprints-cascade.test.ts index b302716..b501959 100644 --- a/__tests__/actions/sprints-cascade.test.ts +++ b/__tests__/actions/sprints-cascade.test.ts @@ -49,7 +49,7 @@ const mockPrisma = prisma as unknown as { $transaction: ReturnType } -const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'ACTIVE' } +const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'OPEN' } beforeEach(() => { vi.clearAllMocks() diff --git a/__tests__/actions/story-claim.test.ts b/__tests__/actions/story-claim.test.ts index 6fba5e5..bfcc402 100644 --- a/__tests__/actions/story-claim.test.ts +++ b/__tests__/actions/story-claim.test.ts @@ -50,7 +50,7 @@ const mockRequireProductWriter = requireProductWriter as ReturnType const STORY = { id: 'story-1', product_id: 'product-1', assignee_id: null } -const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'ACTIVE' } +const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'OPEN' } beforeEach(() => { vi.clearAllMocks() diff --git a/__tests__/actions/tasks-dialog.test.ts b/__tests__/actions/tasks-dialog.test.ts index 877aac5..bc3236f 100644 --- a/__tests__/actions/tasks-dialog.test.ts +++ b/__tests__/actions/tasks-dialog.test.ts @@ -23,6 +23,24 @@ vi.mock('@/lib/prisma', () => ({ story: { findFirst: vi.fn(), findUniqueOrThrow: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + pbi: { + findUniqueOrThrow: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + sprint: { + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + claudeJob: { + findFirst: vi.fn(), + updateMany: vi.fn(), + }, + sprintRun: { + findUnique: vi.fn(), update: vi.fn(), }, $transaction: vi.fn(), @@ -44,6 +62,24 @@ const mockPrisma = prisma as unknown as { story: { findFirst: ReturnType findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + pbi: { + findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + sprint: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + claudeJob: { + findFirst: ReturnType + updateMany: ReturnType + } + sprintRun: { + findUnique: ReturnType update: ReturnType } $transaction: ReturnType @@ -154,7 +190,14 @@ describe('saveTask — edit met status-promotie', () => { implementation_plan: null, }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) const result = await saveTask( { ...VALID_INPUT, status: 'DONE' }, diff --git a/__tests__/actions/update-sprint.test.ts b/__tests__/actions/update-sprint.test.ts new file mode 100644 index 0000000..f51219d --- /dev/null +++ b/__tests__/actions/update-sprint.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ + cookies: vi.fn().mockResolvedValue({ + set: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }), +})) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), + getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }), +})) +vi.mock('@/lib/rate-limit', () => ({ + enforceUserRateLimit: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/code-server', () => ({ + createWithCodeRetry: vi.fn(), + generateNextSprintCode: vi.fn(), +})) +vi.mock('@/lib/active-sprint', () => ({ + setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + sprint: { + findFirst: vi.fn(), + update: vi.fn(), + }, + story: { + findMany: vi.fn(), + updateMany: vi.fn(), + }, + task: { + findMany: vi.fn(), + updateMany: vi.fn(), + }, + $transaction: vi.fn(), + }, +})) + +import { prisma } from '@/lib/prisma' +import { updateSprintAction } from '@/actions/sprints' + +type Mocked = { + sprint: { + findFirst: ReturnType + update: ReturnType + } +} +const mockPrisma = prisma as unknown as Mocked + +beforeEach(() => { + vi.clearAllMocks() + mockPrisma.sprint.findFirst.mockReset().mockResolvedValue({ + id: 'sprint-1', + product_id: 'product-1', + }) + mockPrisma.sprint.update.mockReset().mockResolvedValue({}) +}) + +describe('updateSprintAction', () => { + it('updates sprint_goal alone', async () => { + const result = await updateSprintAction({ + sprintId: 'sprint-1', + fields: { goal: 'Nieuw doel' }, + }) + + expect('success' in result).toBe(true) + expect(mockPrisma.sprint.update).toHaveBeenCalledWith({ + where: { id: 'sprint-1' }, + data: { sprint_goal: 'Nieuw doel' }, + }) + }) + + it('updates dates only', async () => { + await updateSprintAction({ + sprintId: 'sprint-1', + fields: { startAt: '2026-06-01', endAt: '2026-06-14' }, + }) + + expect(mockPrisma.sprint.update).toHaveBeenCalledWith({ + where: { id: 'sprint-1' }, + data: { + start_date: new Date('2026-06-01'), + end_date: new Date('2026-06-14'), + }, + }) + }) + + it('accepts null to clear a date', async () => { + await updateSprintAction({ + sprintId: 'sprint-1', + fields: { startAt: null }, + }) + + expect(mockPrisma.sprint.update).toHaveBeenCalledWith({ + where: { id: 'sprint-1' }, + data: { start_date: null }, + }) + }) + + it('rejects when sprint not accessible', async () => { + mockPrisma.sprint.findFirst.mockResolvedValue(null) + + const result = await updateSprintAction({ + sprintId: 'sprint-1', + fields: { goal: 'x' }, + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.code).toBe(403) + } + expect(mockPrisma.sprint.update).not.toHaveBeenCalled() + }) + + it('rejects empty goal', async () => { + const result = await updateSprintAction({ + sprintId: 'sprint-1', + fields: { goal: '' }, + }) + + expect('error' in result).toBe(true) + expect(mockPrisma.sprint.update).not.toHaveBeenCalled() + }) + + it('rejects when no fields are supplied', async () => { + const result = await updateSprintAction({ + sprintId: 'sprint-1', + fields: {}, + }) + + // Schema-refine should reject; OR action treats empty data as no-op success. + // Current implementation: refine forces minstens één veld → 422 error. + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.code).toBe(422) + } + }) +}) diff --git a/__tests__/actions/user-settings.test.ts b/__tests__/actions/user-settings.test.ts new file mode 100644 index 0000000..1fb53ad --- /dev/null +++ b/__tests__/actions/user-settings.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + user: { findUnique: vi.fn() }, + $transaction: vi.fn(async (fn: (tx: unknown) => Promise) => { + return fn({ + user: { + findUnique: vi.fn().mockResolvedValue({ settings: {} }), + update: vi.fn().mockResolvedValue({}), + }, + }) + }), + $executeRaw: vi.fn().mockResolvedValue(1), + }, +})) + +import { prisma } from '@/lib/prisma' +import { getIronSession } from 'iron-session' +import { updateUserSettingsAction } from '@/actions/user-settings' + +const mockPrisma = prisma as unknown as { + user: { findUnique: ReturnType } + $transaction: ReturnType + $executeRaw: ReturnType +} +const mockGetIronSession = getIronSession as ReturnType + +beforeEach(() => { + vi.clearAllMocks() + mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockPrisma.$executeRaw.mockResolvedValue(1) +}) + +describe('updateUserSettingsAction', () => { + it('returns 401 when not logged in', async () => { + mockGetIronSession.mockResolvedValue({ userId: undefined, isDemo: false }) + const result = await updateUserSettingsAction({}) + expect(result).toEqual({ error: 'Niet ingelogd', code: 401 }) + }) + + it('returns 403 for demo accounts', async () => { + mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: true }) + const result = await updateUserSettingsAction({}) + expect('error' in result && result.code).toBe(403) + }) + + it('returns 422 when patch is invalid', async () => { + const result = await updateUserSettingsAction({ + views: { sprintBacklog: { filterStatus: 'NONSENSE' } }, + } as never) + expect('error' in result && result.code).toBe(422) + }) + + it('merges with current settings and emits notify on success', async () => { + const existingFindUnique = vi.fn().mockResolvedValue({ + settings: { views: { sprintBacklog: { sort: 'code' } } }, + }) + const update = vi.fn().mockResolvedValue({}) + mockPrisma.$transaction.mockImplementationOnce(async (fn: (tx: unknown) => Promise) => { + return fn({ user: { findUnique: existingFindUnique, update } }) + }) + + const result = await updateUserSettingsAction({ + views: { sprintBacklog: { sortDir: 'desc' } }, + }) + + expect('success' in result && result.success).toBe(true) + expect(update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { settings: { views: { sprintBacklog: { sort: 'code', sortDir: 'desc' } } } }, + }) + expect(mockPrisma.$executeRaw).toHaveBeenCalled() + }) +}) diff --git a/__tests__/api/backlog-realtime.test.ts b/__tests__/api/backlog-realtime.test.ts deleted file mode 100644 index 4898cda..0000000 --- a/__tests__/api/backlog-realtime.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() })) - -vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) -vi.mock('@/lib/product-access', () => ({ - getAccessibleProduct: vi.fn(), -})) - -import { getAccessibleProduct } from '@/lib/product-access' -import type { NextRequest } from 'next/server' -import { GET } from '@/app/api/realtime/backlog/route' -import { useBacklogStore } from '@/stores/backlog-store' - -const mockGetAccessibleProduct = getAccessibleProduct as ReturnType - -function makeReq(productId?: string): NextRequest { - const url = productId - ? `http://localhost/api/realtime/backlog?product_id=${productId}` - : 'http://localhost/api/realtime/backlog' - return { - signal: new AbortController().signal, - nextUrl: new URL(url), - } as unknown as NextRequest -} - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('GET /api/realtime/backlog', () => { - it('401 when not authenticated', async () => { - mockGetSession.mockResolvedValue({ userId: undefined, isDemo: false }) - const res = await GET(makeReq('prod-1')) - expect(res.status).toBe(401) - expect(mockGetAccessibleProduct).not.toHaveBeenCalled() - }) - - it('400 when product_id is missing', async () => { - mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) - const res = await GET(makeReq()) - expect(res.status).toBe(400) - }) - - it('403 when user has no access to the product', async () => { - mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) - mockGetAccessibleProduct.mockResolvedValue(null) - const res = await GET(makeReq('prod-1')) - expect(res.status).toBe(403) - expect(mockGetAccessibleProduct).toHaveBeenCalledWith('prod-1', 'user-1') - }) - - it('500 when DIRECT_URL and DATABASE_URL are absent', async () => { - mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) - mockGetAccessibleProduct.mockResolvedValue({ id: 'prod-1' }) - - const before = { DIRECT_URL: process.env.DIRECT_URL, DATABASE_URL: process.env.DATABASE_URL } - delete process.env.DIRECT_URL - delete process.env.DATABASE_URL - try { - const res = await GET(makeReq('prod-1')) - expect(res.status).toBe(500) - } finally { - if (before.DIRECT_URL !== undefined) process.env.DIRECT_URL = before.DIRECT_URL - if (before.DATABASE_URL !== undefined) process.env.DATABASE_URL = before.DATABASE_URL - } - }) - - it('demo user is allowed (no 403) when product is accessible', async () => { - mockGetSession.mockResolvedValue({ userId: 'demo-user', isDemo: true }) - mockGetAccessibleProduct.mockResolvedValue({ id: 'prod-1' }) - - const before = { DIRECT_URL: process.env.DIRECT_URL, DATABASE_URL: process.env.DATABASE_URL } - delete process.env.DIRECT_URL - delete process.env.DATABASE_URL - try { - const res = await GET(makeReq('prod-1')) - // Fails at 500 (no DB URL) — not 403, confirming demo user is not blocked - expect(res.status).toBe(500) - } finally { - if (before.DIRECT_URL !== undefined) process.env.DIRECT_URL = before.DIRECT_URL - if (before.DATABASE_URL !== undefined) process.env.DATABASE_URL = before.DATABASE_URL - } - }) -}) - -// shouldEmit scope filter — white-box unit tests -describe('shouldEmit scope filter (via backlog-store reducer)', () => { - it('applyChange: pbi INSERT adds to pbis array', () => { - useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} }) - const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Test', priority: 2, created_at: new Date(), status: 'ready' as const } - useBacklogStore.getState().applyChange('pbi', 'I', pbi) - expect(useBacklogStore.getState().pbis).toHaveLength(1) - expect(useBacklogStore.getState().pbis[0].id).toBe('pbi-1') - }) - - it('applyChange: pbi UPDATE patches existing pbi', () => { - const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Old', priority: 2, created_at: new Date(), status: 'ready' as const } - useBacklogStore.setState({ pbis: [pbi], storiesByPbi: {}, tasksByStory: {} }) - useBacklogStore.getState().applyChange('pbi', 'U', { id: 'pbi-1', title: 'New' }) - expect(useBacklogStore.getState().pbis[0].title).toBe('New') - }) - - it('applyChange: pbi DELETE removes pbi', () => { - const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Test', priority: 2, created_at: new Date(), status: 'ready' as const } - useBacklogStore.setState({ pbis: [pbi], storiesByPbi: {}, tasksByStory: {} }) - useBacklogStore.getState().applyChange('pbi', 'D', { id: 'pbi-1' }) - expect(useBacklogStore.getState().pbis).toHaveLength(0) - }) - - it('applyChange: story INSERT adds to storiesByPbi', () => { - useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} }) - const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', created_at: new Date() } - useBacklogStore.getState().applyChange('story', 'I', story) - expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1) - }) - - it('applyChange: story DELETE removes from correct pbi bucket', () => { - const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', created_at: new Date() } - useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [story] }, tasksByStory: {} }) - useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' }) - expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0) - }) - - it('applyChange: task UPDATE patches task across story buckets', () => { - const task = { id: 'task-1', title: 'Old', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: 'story-1', created_at: new Date() } - useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [task] } }) - useBacklogStore.getState().applyChange('task', 'U', { id: 'task-1', status: 'IN_PROGRESS' }) - expect(useBacklogStore.getState().tasksByStory['story-1'][0].status).toBe('IN_PROGRESS') - }) -}) diff --git a/__tests__/api/cron-cleanup-agent-artifacts.test.ts b/__tests__/api/cron-cleanup-agent-artifacts.test.ts index 188c558..bd86923 100644 --- a/__tests__/api/cron-cleanup-agent-artifacts.test.ts +++ b/__tests__/api/cron-cleanup-agent-artifacts.test.ts @@ -41,7 +41,7 @@ describe('POST /api/cron/cleanup-agent-artifacts', () => { expect(mockPrisma.claudeJob.deleteMany).not.toHaveBeenCalled() }) - it('200 met juiste secret + deleteMany aangeroepen voor FAILED/CANCELLED ouder dan 7 dagen', async () => { + it('200 met juiste secret + deleteMany aangeroepen voor FAILED/CANCELLED/SKIPPED ouder dan 7 dagen', async () => { mockPrisma.claudeJob.deleteMany.mockResolvedValue({ count: 5 }) const res = await POST(makeReq({ authorization: 'Bearer ' + SECRET })) @@ -51,7 +51,7 @@ describe('POST /api/cron/cleanup-agent-artifacts', () => { expect(body.ran_at).toMatch(/^\d{4}-\d{2}-\d{2}T/) const arg = mockPrisma.claudeJob.deleteMany.mock.calls[0][0] - expect(arg.where.status).toEqual({ in: ['FAILED', 'CANCELLED'] }) + expect(arg.where.status).toEqual({ in: ['FAILED', 'CANCELLED', 'SKIPPED'] }) expect(arg.where.finished_at.lt).toBeInstanceOf(Date) // cutoff should be approximately 7 days ago diff --git a/__tests__/api/cross-sprint-blocks.test.ts b/__tests__/api/cross-sprint-blocks.test.ts new file mode 100644 index 0000000..5447900 --- /dev/null +++ b/__tests__/api/cross-sprint-blocks.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + product: { findFirst: vi.fn() }, + story: { findMany: vi.fn() }, + }, +})) + +vi.mock('@/lib/api-auth', () => ({ + authenticateApiRequest: vi.fn(), +})) + +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), +})) + +import { prisma } from '@/lib/prisma' +import { authenticateApiRequest } from '@/lib/api-auth' +import { GET } from '@/app/api/products/[id]/cross-sprint-blocks/route' + +const mockPrisma = prisma as unknown as { + product: { findFirst: ReturnType } + story: { findMany: ReturnType } +} +const mockAuth = authenticateApiRequest as unknown as ReturnType + +function makeRequest(url: string) { + return new Request(url) +} + +describe('GET /api/products/[id]/cross-sprint-blocks', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPrisma.product.findFirst.mockReset() + mockPrisma.story.findMany.mockReset() + mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' }) + }) + + it('returns blocking sprint info per story for happy path', async () => { + mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) + mockPrisma.story.findMany.mockResolvedValue([ + { + id: 'story-1', + sprint: { id: 'sprint-x', code: 'SP-X' }, + }, + { + id: 'story-2', + sprint: { id: 'sprint-y', code: 'SP-Y' }, + }, + ]) + + const req = makeRequest( + 'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1&pbiIds=pbiA', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ + 'story-1': { sprintId: 'sprint-x', sprintName: 'SP-X' }, + 'story-2': { sprintId: 'sprint-y', sprintName: 'SP-Y' }, + }) + }) + + it('rejects when pbiIds is missing', async () => { + const req = makeRequest( + 'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(400) + }) + + it('rejects when pbiIds is empty', async () => { + const req = makeRequest( + 'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(400) + }) + + it('returns 404 when product is not accessible', async () => { + mockPrisma.product.findFirst.mockResolvedValue(null) + const req = makeRequest( + 'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(404) + }) + + it('returns auth error when authenticate fails', async () => { + mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 }) + const req = makeRequest( + 'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(401) + }) + + it('passes NOT excludeSprintId to prisma when provided', async () => { + mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) + mockPrisma.story.findMany.mockResolvedValue([]) + + const req = makeRequest( + 'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-active&pbiIds=pbiA', + ) + await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + + const callArg = mockPrisma.story.findMany.mock.calls[0][0] as { + where: Record + } + expect(callArg.where).toMatchObject({ + pbi_id: { in: ['pbiA'] }, + product_id: 'p1', + sprint_id: { not: null }, + NOT: { sprint_id: 'sp-active' }, + sprint: { status: 'OPEN' }, + }) + }) +}) diff --git a/__tests__/api/ideas.test.ts b/__tests__/api/ideas.test.ts new file mode 100644 index 0000000..448cc6b --- /dev/null +++ b/__tests__/api/ideas.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + product: { findFirst: vi.fn() }, + idea: { + findFirst: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + ideaLog: { findMany: vi.fn() }, + $transaction: vi.fn(), + }, +})) +vi.mock('@/lib/api-auth', () => ({ + authenticateApiRequest: vi.fn(), +})) +vi.mock('@/lib/idea-code-server', () => ({ + nextIdeaCode: vi.fn().mockResolvedValue('IDEA-001'), +})) + +import { prisma } from '@/lib/prisma' +import { authenticateApiRequest } from '@/lib/api-auth' +import { GET as getIdeas, POST as postIdea } from '@/app/api/ideas/route' +import { GET as getIdea, PATCH as patchIdea } from '@/app/api/ideas/[id]/route' + +type M = { + product: { findFirst: ReturnType } + idea: { findFirst: ReturnType; findMany: ReturnType; create: ReturnType; update: ReturnType } + ideaLog: { findMany: ReturnType } + $transaction: ReturnType +} +const m = prisma as unknown as M +const mockAuth = authenticateApiRequest as ReturnType + +const NOW = new Date('2026-05-04T19:00:00Z') + +const IDEA_ROW = { + id: 'idea-1', + user_id: 'user-1', + code: 'IDEA-001', + title: 'Plant-watering reminder', + description: null, + status: 'DRAFT' as const, + product_id: null, + product: null, + pbi: null, + pbi_id: null, + archived: false, + grill_md: null, + plan_md: null, + created_at: NOW, + updated_at: NOW, +} + +function makeRequest(method: 'GET' | 'POST' | 'PATCH', url: string, body?: unknown): Request { + return new Request(`http://localhost${url}`, { + method, + headers: { + Authorization: 'Bearer test-token', + 'Content-Type': 'application/json', + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false }) + m.$transaction.mockImplementation(async (arg: unknown) => { + if (typeof arg === 'function') return (arg as (tx: unknown) => unknown)(m) + return arg + }) +}) + +describe('GET /api/ideas', () => { + it('returns user ideas (DTO shape)', async () => { + m.idea.findMany.mockResolvedValueOnce([IDEA_ROW]) + const res = await getIdeas(makeRequest('GET', '/api/ideas')) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.ideas).toHaveLength(1) + expect(body.ideas[0]).toMatchObject({ + id: 'idea-1', + code: 'IDEA-001', + status: 'draft', + has_grill_md: false, + }) + }) + + it('rejects unauthenticated', async () => { + mockAuth.mockResolvedValueOnce({ error: 'Unauthorized', status: 401 }) + const res = await getIdeas(makeRequest('GET', '/api/ideas')) + expect(res.status).toBe(401) + }) + + it('filters by archived=false param', async () => { + m.idea.findMany.mockResolvedValueOnce([]) + await getIdeas(makeRequest('GET', '/api/ideas?archived=false')) + expect(m.idea.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ archived: false, user_id: 'user-1' }), + }), + ) + }) +}) + +describe('POST /api/ideas', () => { + it('creates idea and returns 201', async () => { + m.idea.create.mockResolvedValueOnce(IDEA_ROW) + const res = await postIdea(makeRequest('POST', '/api/ideas', { title: 'Plant-watering reminder' })) + expect(res.status).toBe(201) + const body = await res.json() + expect(body.idea).toMatchObject({ id: 'idea-1', code: 'IDEA-001', status: 'draft' }) + }) + + it('rejects demo with 403', async () => { + mockAuth.mockResolvedValueOnce({ userId: 'demo-1', isDemo: true }) + const res = await postIdea(makeRequest('POST', '/api/ideas', { title: 'x' })) + expect(res.status).toBe(403) + }) + + it('rejects empty title with 422', async () => { + const res = await postIdea(makeRequest('POST', '/api/ideas', { title: '' })) + expect(res.status).toBe(422) + }) + + it('rejects malformed JSON with 400', async () => { + const req = new Request('http://localhost/api/ideas', { + method: 'POST', + headers: { Authorization: 'Bearer test', 'Content-Type': 'application/json' }, + body: 'not-json', + }) + const res = await postIdea(req) + expect(res.status).toBe(400) + }) + + it('returns 404 when product_id refers to a foreign product', async () => { + m.product.findFirst.mockResolvedValueOnce(null) + const res = await postIdea( + makeRequest('POST', '/api/ideas', { + title: 'x', + product_id: 'cmohrysyj0000rd17clnjy4tc', + }), + ) + expect(res.status).toBe(404) + }) +}) + +describe('GET /api/ideas/[id]', () => { + it('returns idea + logs', async () => { + m.idea.findFirst.mockResolvedValueOnce(IDEA_ROW) + m.ideaLog.findMany.mockResolvedValueOnce([ + { id: 'l-1', type: 'NOTE', content: 'x', metadata: null, created_at: NOW }, + ]) + const ctx = { params: Promise.resolve({ id: 'idea-1' }) } + const res = await getIdea(makeRequest('GET', '/api/ideas/idea-1'), ctx) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.idea).toMatchObject({ id: 'idea-1' }) + expect(body.logs).toHaveLength(1) + }) + + it('returns 404 (not 403) for foreign user — anti-enumeration', async () => { + m.idea.findFirst.mockResolvedValueOnce(null) + const ctx = { params: Promise.resolve({ id: 'idea-1' }) } + const res = await getIdea(makeRequest('GET', '/api/ideas/idea-1'), ctx) + expect(res.status).toBe(404) + }) +}) + +describe('PATCH /api/ideas/[id]', () => { + const ctx = { params: Promise.resolve({ id: 'idea-1' }) } + + it('updates editable idea', async () => { + m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'DRAFT' }) + m.idea.update.mockResolvedValueOnce({ ...IDEA_ROW, title: 'Updated' }) + const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'Updated' }), ctx) + expect(res.status).toBe(200) + }) + + it('blocks demo with 403', async () => { + mockAuth.mockResolvedValueOnce({ userId: 'demo-1', isDemo: true }) + const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'x' }), ctx) + expect(res.status).toBe(403) + }) + + it('blocks update on PLANNED with 422', async () => { + m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'PLANNED' }) + const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'x' }), ctx) + expect(res.status).toBe(422) + }) +}) diff --git a/__tests__/api/next-story.test.ts b/__tests__/api/next-story.test.ts index cc5a86d..fc549d8 100644 --- a/__tests__/api/next-story.test.ts +++ b/__tests__/api/next-story.test.ts @@ -25,7 +25,7 @@ const mockPrisma = prisma as unknown as { } const mockAuth = authenticateApiRequest as ReturnType -const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'ACTIVE' } +const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'OPEN' } const STORY = { id: 'story-1', title: 'Account aanmaken', @@ -95,7 +95,7 @@ describe('GET /api/products/:id/next-story', () => { expect(data.tasks[0]).toMatchObject({ id: 'task-1', status: 'todo' }) }) - it('queries story ordered by priority then sort_order', async () => { + it('queries story ordered by sort_order only', async () => { mockPrisma.sprint.findFirst.mockResolvedValue(SPRINT) mockPrisma.story.findFirst.mockResolvedValue(STORY) @@ -103,7 +103,7 @@ describe('GET /api/products/:id/next-story', () => { expect(mockPrisma.story.findFirst).toHaveBeenCalledWith( expect.objectContaining({ - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + orderBy: [{ sort_order: 'asc' }], }) ) }) diff --git a/__tests__/api/notifications-stream.test.ts b/__tests__/api/notifications-stream.test.ts index 53fc590..59fd1a8 100644 --- a/__tests__/api/notifications-stream.test.ts +++ b/__tests__/api/notifications-stream.test.ts @@ -10,6 +10,7 @@ vi.mock('@/lib/prisma', () => ({ prisma: { product: { findMany: vi.fn() }, claudeQuestion: { findMany: vi.fn() }, + idea: { findMany: vi.fn().mockResolvedValue([]) }, }, })) diff --git a/__tests__/api/push-send.test.ts b/__tests__/api/push-send.test.ts new file mode 100644 index 0000000..44bc616 --- /dev/null +++ b/__tests__/api/push-send.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('server-only', () => ({})) + +const { mockSendPushToUser } = vi.hoisted(() => ({ + mockSendPushToUser: vi.fn(), +})) + +vi.mock('@/lib/push-server', () => ({ + sendPushToUser: mockSendPushToUser, + enabled: true, +})) + +vi.hoisted(() => { + process.env.INTERNAL_PUSH_SECRET = 'a-valid-secret-that-is-at-least-32-chars' +}) + +import { POST } from '@/app/api/internal/push/send/route' + +const VALID_BODY = { + userId: 'user-1', + payload: { title: 'Hello', body: 'World', url: '/dashboard' }, +} +const SECRET = 'a-valid-secret-that-is-at-least-32-chars' + +function makeRequest(body: unknown, bearer?: string) { + return new Request('http://localhost/api/internal/push/send', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(bearer !== undefined ? { Authorization: bearer } : {}), + }, + body: JSON.stringify(body), + }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockSendPushToUser.mockResolvedValue(undefined) +}) + +describe('POST /api/internal/push/send', () => { + it('returns 401 without authorization header', async () => { + const res = await POST(makeRequest(VALID_BODY)) + expect(res.status).toBe(401) + expect(mockSendPushToUser).not.toHaveBeenCalled() + }) + + it('returns 401 with wrong bearer secret', async () => { + const res = await POST(makeRequest(VALID_BODY, 'Bearer wrong-secret')) + expect(res.status).toBe(401) + }) + + it('returns 422 with invalid body', async () => { + const res = await POST(makeRequest({ userId: '', payload: {} }, `Bearer ${SECRET}`)) + expect(res.status).toBe(422) + expect(mockSendPushToUser).not.toHaveBeenCalled() + }) + + it('returns 204 and calls sendPushToUser on success', async () => { + const res = await POST(makeRequest(VALID_BODY, `Bearer ${SECRET}`)) + expect(res.status).toBe(204) + expect(mockSendPushToUser).toHaveBeenCalledWith('user-1', VALID_BODY.payload) + }) + + it('returns 400 for invalid JSON', async () => { + const req = new Request('http://localhost/api/internal/push/send', { + method: 'POST', + headers: { Authorization: `Bearer ${SECRET}`, 'Content-Type': 'application/json' }, + body: 'not-json', + }) + const res = await POST(req) + expect(res.status).toBe(400) + }) +}) diff --git a/__tests__/api/reorder.test.ts b/__tests__/api/reorder.test.ts deleted file mode 100644 index cff62ae..0000000 --- a/__tests__/api/reorder.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('@/lib/prisma', () => ({ - prisma: { - story: { - findFirst: vi.fn(), - }, - task: { - update: vi.fn(), - }, - $transaction: vi.fn(), - }, -})) - -vi.mock('@/lib/api-auth', () => ({ - authenticateApiRequest: vi.fn(), -})) - -import { prisma } from '@/lib/prisma' -import { authenticateApiRequest } from '@/lib/api-auth' -import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route' - -const mockPrisma = prisma as unknown as { - story: { findFirst: ReturnType } - task: { update: ReturnType } - $transaction: ReturnType -} -const mockAuth = authenticateApiRequest as ReturnType - -function makeStory(taskIds: string[]) { - return { - id: 'story-1', - product_id: 'prod-1', - tasks: taskIds.map(id => ({ id })), - } -} - -function makeRequest(body: unknown, storyId = 'story-1'): [Request, { params: Promise<{ id: string }> }] { - return [ - new Request(`http://localhost/api/stories/${storyId}/tasks/reorder`, { - method: 'PATCH', - headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - { params: Promise.resolve({ id: storyId }) }, - ] -} - -describe('PATCH /api/stories/:id/tasks/reorder', () => { - beforeEach(() => { - vi.clearAllMocks() - mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false }) - mockPrisma.$transaction.mockResolvedValue([]) - mockPrisma.task.update.mockResolvedValue({ id: 'task-1', sort_order: 1 }) - }) - - // TC-RO-06 — body validation fires before story lookup - it('returns 422 when task_ids is an empty array', async () => { - const res = await patchReorder(...makeRequest({ task_ids: [] })) - expect(res.status).toBe(422) - expect(mockPrisma.story.findFirst).not.toHaveBeenCalled() - }) - - // TC-RO-07 - it('returns 422 when task_ids is not an array', async () => { - const res = await patchReorder(...makeRequest({ task_ids: 'task-1' })) - expect(res.status).toBe(422) - expect(mockPrisma.story.findFirst).not.toHaveBeenCalled() - }) - - it('returns 422 when task_ids is missing entirely', async () => { - const res = await patchReorder(...makeRequest({})) - expect(res.status).toBe(422) - }) - - // TC-RO-08 - it('returns 422 when task_ids contains an ID not belonging to the story', async () => { - mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2'])) - - const res = await patchReorder(...makeRequest({ task_ids: ['task-1', 'task-from-other-story'] })) - const data = await res.json() - - expect(res.status).toBe(422) - expect(data.error).toContain('task-from-other-story') - }) - - // TC-RO-09 - it('reorders tasks and returns 200 with success: true', async () => { - mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2', 'task-3'])) - - const res = await patchReorder(...makeRequest({ task_ids: ['task-3', 'task-1', 'task-2'] })) - const data = await res.json() - - expect(res.status).toBe(200) - expect(data).toEqual({ success: true }) - expect(mockPrisma.$transaction).toHaveBeenCalled() - }) - - it('updates each task with its new sort_order index', async () => { - mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2'])) - - await patchReorder(...makeRequest({ task_ids: ['task-2', 'task-1'] })) - - expect(mockPrisma.task.update).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: 'task-2' }, data: { sort_order: 1 } }) - ) - expect(mockPrisma.task.update).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: 'task-1' }, data: { sort_order: 2 } }) - ) - }) -}) diff --git a/__tests__/api/security.test.ts b/__tests__/api/security.test.ts index 4d37fdd..9a1d508 100644 --- a/__tests__/api/security.test.ts +++ b/__tests__/api/security.test.ts @@ -8,10 +8,13 @@ vi.mock('@/lib/prisma', () => ({ }, sprint: { findFirst: vi.fn(), + findUniqueOrThrow: vi.fn(), + update: vi.fn(), }, story: { findFirst: vi.fn(), findUniqueOrThrow: vi.fn(), + findMany: vi.fn(), update: vi.fn(), }, task: { @@ -19,6 +22,19 @@ vi.mock('@/lib/prisma', () => ({ update: vi.fn(), findMany: vi.fn(), }, + pbi: { + findUniqueOrThrow: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + claudeJob: { + findFirst: vi.fn(), + updateMany: vi.fn(), + }, + sprintRun: { + findUnique: vi.fn(), + update: vi.fn(), + }, storyLog: { create: vi.fn(), }, @@ -38,17 +54,20 @@ import { authenticateApiRequest } from '@/lib/api-auth' import { GET as getProducts } from '@/app/api/products/route' import { GET as getNextStory } from '@/app/api/products/[id]/next-story/route' import { GET as getSprintTasks } from '@/app/api/sprints/[id]/tasks/route' -import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route' import { POST as postStoryLog } from '@/app/api/stories/[id]/log/route' import { PATCH as patchTask } from '@/app/api/tasks/[id]/route' -import { POST as postTodo } from '@/app/api/todos/route' const mockPrisma = prisma as unknown as { product: { findMany: ReturnType; findFirst: ReturnType } - sprint: { findFirst: ReturnType } + sprint: { + findFirst: ReturnType + findUniqueOrThrow: ReturnType + update: ReturnType + } story: { findFirst: ReturnType findUniqueOrThrow: ReturnType + findMany: ReturnType update: ReturnType } task: { @@ -56,6 +75,19 @@ const mockPrisma = prisma as unknown as { update: ReturnType findMany: ReturnType } + pbi: { + findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + claudeJob: { + findFirst: ReturnType + updateMany: ReturnType + } + sprintRun: { + findUnique: ReturnType + update: ReturnType + } storyLog: { create: ReturnType } todo: { create: ReturnType } $transaction: ReturnType @@ -164,7 +196,7 @@ describe('GET /api/products/:id/next-story', () => { expect.objectContaining({ where: expect.objectContaining({ product_id: 'prod-other', - status: 'ACTIVE', + status: 'OPEN', product: expect.objectContaining({ OR: expect.arrayContaining([{ user_id: 'user-1' }]), }), @@ -243,56 +275,6 @@ describe('GET /api/sprints/:id/tasks', () => { }) }) -// ─── PATCH /api/stories/:id/tasks/reorder ──────────────────────────────────── - -describe('PATCH /api/stories/:id/tasks/reorder', () => { - const VALID_BODY = { task_ids: ['task-x'] } - - // TC-RO-01 - it('returns 401 when no valid token provided', async () => { - mockAuth.mockResolvedValue(UNAUTHORIZED) - const res = await patchReorder( - makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY), - routeCtx('story-1') - ) - expect(res.status).toBe(401) - }) - - // TC-RO-03 - it('returns 403 for demo users', async () => { - mockAuth.mockResolvedValue(DEMO_AUTH) - const res = await patchReorder( - makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY), - routeCtx('story-1') - ) - expect(res.status).toBe(403) - const data = await res.json() - expect(data.error).toBe('Niet beschikbaar in demo-modus') - }) - - // TC-RO-04 / TC-RO-05 - it('returns 404 when story is not accessible to the authenticated user', async () => { - mockAuth.mockResolvedValue(USER_2_AUTH) - mockPrisma.story.findFirst.mockResolvedValue(null) - - const res = await patchReorder( - makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY), - routeCtx('story-1') - ) - expect(res.status).toBe(404) - expect(mockPrisma.story.findFirst).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - id: 'story-1', - product: expect.objectContaining({ - OR: expect.arrayContaining([{ user_id: 'user-2' }]), - }), - }), - }) - ) - }) -}) - // ─── POST /api/stories/:id/log ──────────────────────────────────────────────── describe('POST /api/stories/:id/log', () => { @@ -410,7 +392,14 @@ describe('PATCH /api/tasks/:id', () => { implementation_plan: null, }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'DONE', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' }) const res = await patchTask( makePatch('http://localhost/api/tasks/task-1', { status: 'done' }), @@ -419,46 +408,3 @@ describe('PATCH /api/tasks/:id', () => { expect(res.status).toBe(200) }) }) - -// ─── POST /api/todos ────────────────────────────────────────────────────────── - -describe('POST /api/todos', () => { - // product_id is required by the Zod schema (z.string().min(1)) - const VALID_BODY = { title: 'Test todo', product_id: 'prod-1' } - - // TC-TD-01 - it('returns 401 when no valid token provided', async () => { - mockAuth.mockResolvedValue(UNAUTHORIZED) - const res = await postTodo(makePost('http://localhost/api/todos', VALID_BODY)) - expect(res.status).toBe(401) - }) - - // TC-TD-03 - it('returns 403 for demo users', async () => { - mockAuth.mockResolvedValue(DEMO_AUTH) - const res = await postTodo(makePost('http://localhost/api/todos', VALID_BODY)) - expect(res.status).toBe(403) - const data = await res.json() - expect(data.error).toBe('Niet beschikbaar in demo-modus') - }) - - // TC-TD-08 - it('returns 404 when product_id belongs to another user', async () => { - mockAuth.mockResolvedValue(USER_2_AUTH) - mockPrisma.product.findFirst.mockResolvedValue(null) - - const res = await postTodo( - makePost('http://localhost/api/todos', { title: 'Todo', product_id: 'prod-owned-by-user-1' }) - ) - expect(res.status).toBe(404) - // Verify it queries by user_id, not productAccessFilter - expect(mockPrisma.product.findFirst).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - id: 'prod-owned-by-user-1', - user_id: 'user-2', - }), - }) - ) - }) -}) diff --git a/__tests__/api/sprint-membership-summary.test.ts b/__tests__/api/sprint-membership-summary.test.ts new file mode 100644 index 0000000..c526210 --- /dev/null +++ b/__tests__/api/sprint-membership-summary.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + product: { findFirst: vi.fn() }, + story: { groupBy: vi.fn() }, + }, +})) + +vi.mock('@/lib/api-auth', () => ({ + authenticateApiRequest: vi.fn(), +})) + +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), +})) + +import { prisma } from '@/lib/prisma' +import { authenticateApiRequest } from '@/lib/api-auth' +import { GET } from '@/app/api/products/[id]/sprint-membership-summary/route' + +const mockPrisma = prisma as unknown as { + product: { findFirst: ReturnType } + story: { groupBy: ReturnType } +} +const mockAuth = authenticateApiRequest as unknown as ReturnType + +function makeRequest(url: string) { + return new Request(url) +} + +describe('GET /api/products/[id]/sprint-membership-summary', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPrisma.product.findFirst.mockReset() + mockPrisma.story.groupBy.mockReset() + mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' }) + }) + + it('returns counts per PBI for happy path', async () => { + mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) + mockPrisma.story.groupBy + .mockResolvedValueOnce([ + { pbi_id: 'pbiA', _count: { _all: 5 } }, + { pbi_id: 'pbiB', _count: { _all: 3 } }, + ]) + .mockResolvedValueOnce([{ pbi_id: 'pbiA', _count: { _all: 2 } }]) + + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ + pbiA: { total: 5, inSprint: 2 }, + pbiB: { total: 3, inSprint: 0 }, + }) + }) + + it('rejects when pbiIds is missing', async () => { + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(400) + }) + + it('rejects when pbiIds is empty', async () => { + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(400) + }) + + it('rejects when sprintId is missing', async () => { + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?pbiIds=pbiA', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(400) + }) + + it('returns 404 when product is not accessible', async () => { + mockPrisma.product.findFirst.mockResolvedValue(null) + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(404) + }) + + it('returns auth error when authenticate fails', async () => { + mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 }) + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(401) + }) + + it('returns zero counts for PBIs without stories', async () => { + mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) + mockPrisma.story.groupBy + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + + const body = await res.json() + expect(body).toEqual({ + pbiA: { total: 0, inSprint: 0 }, + pbiB: { total: 0, inSprint: 0 }, + }) + }) +}) diff --git a/__tests__/api/sprint-tasks.test.ts b/__tests__/api/sprint-tasks.test.ts index c496e0d..c3ac8a9 100644 --- a/__tests__/api/sprint-tasks.test.ts +++ b/__tests__/api/sprint-tasks.test.ts @@ -25,7 +25,7 @@ const mockPrisma = prisma as unknown as { } const mockAuth = authenticateApiRequest as ReturnType -const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'ACTIVE' } +const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'OPEN' } function makeTask(n: number) { return { diff --git a/__tests__/api/story-log.test.ts b/__tests__/api/story-log.test.ts index 2ba3025..0a9b5df 100644 --- a/__tests__/api/story-log.test.ts +++ b/__tests__/api/story-log.test.ts @@ -129,7 +129,7 @@ describe('POST /api/stories/:id/log', () => { const res = await postStoryLog( ...makeRequest({ type: 'TEST_RESULT', content: 'Test gefaald.', status: 'FAILED' }) ) - const data = await res.json() + await res.json() expect(res.status).toBe(201) expect(mockPrisma.storyLog.create).toHaveBeenCalledWith( diff --git a/__tests__/api/tasks.test.ts b/__tests__/api/tasks.test.ts index 3b08da7..5862615 100644 --- a/__tests__/api/tasks.test.ts +++ b/__tests__/api/tasks.test.ts @@ -9,6 +9,24 @@ vi.mock('@/lib/prisma', () => ({ }, story: { findUniqueOrThrow: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + pbi: { + findUniqueOrThrow: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + sprint: { + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + claudeJob: { + findFirst: vi.fn(), + updateMany: vi.fn(), + }, + sprintRun: { + findUnique: vi.fn(), update: vi.fn(), }, $transaction: vi.fn(), @@ -31,6 +49,24 @@ const mockPrisma = prisma as unknown as { } story: { findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + pbi: { + findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + sprint: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + claudeJob: { + findFirst: ReturnType + updateMany: ReturnType + } + sprintRun: { + findUnique: ReturnType update: ReturnType } $transaction: ReturnType @@ -75,7 +111,14 @@ describe('PATCH /api/tasks/:id', () => { }) // Default sibling state: only this task, already DONE → no story-promotion mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'DONE', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' }) // Pass-through for $transaction so tests behave as if Prisma ran the run-fn directly. mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise) => { return run(prisma) @@ -190,7 +233,14 @@ describe('PATCH /api/tasks/:id', () => { story_id: 'story-1', }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) const res = await patchTask(...makeRequest({ status: 'done' })) expect(res.status).toBe(200) diff --git a/__tests__/api/todos.test.ts b/__tests__/api/todos.test.ts deleted file mode 100644 index abded32..0000000 --- a/__tests__/api/todos.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('@/lib/prisma', () => ({ - prisma: { - product: { - findFirst: vi.fn(), - }, - todo: { - create: vi.fn(), - }, - }, -})) - -vi.mock('@/lib/api-auth', () => ({ - authenticateApiRequest: vi.fn(), -})) - -import { prisma } from '@/lib/prisma' -import { authenticateApiRequest } from '@/lib/api-auth' -import { POST as postTodo } from '@/app/api/todos/route' - -const mockPrisma = prisma as unknown as { - product: { findFirst: ReturnType } - todo: { create: ReturnType } -} -const mockAuth = authenticateApiRequest as ReturnType - -const PRODUCT = { id: 'prod-1', name: 'DevPlanner', archived: false, user_id: 'user-1' } -const TODO_RESULT = { id: 'todo-1', title: 'Test todo', created_at: new Date('2026-04-30T10:00:00Z') } - -function makeRequest(body: unknown): Request { - return new Request('http://localhost/api/todos', { - method: 'POST', - headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) -} - -describe('POST /api/todos', () => { - beforeEach(() => { - vi.clearAllMocks() - mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false }) - mockPrisma.product.findFirst.mockResolvedValue(PRODUCT) - mockPrisma.todo.create.mockResolvedValue(TODO_RESULT) - }) - - // TC-TD-04 - it('returns 422 when title is missing', async () => { - const res = await postTodo(makeRequest({ product_id: 'prod-1' })) - expect(res.status).toBe(422) - }) - - // TC-TD-05 - it('returns 422 when title is empty string', async () => { - const res = await postTodo(makeRequest({ title: '', product_id: 'prod-1' })) - expect(res.status).toBe(422) - }) - - it('returns 422 when product_id is missing', async () => { - // product_id is required by the Zod schema (z.string().min(1)) - const res = await postTodo(makeRequest({ title: 'My todo' })) - expect(res.status).toBe(422) - }) - - it('returns 422 when product_id is empty string', async () => { - const res = await postTodo(makeRequest({ title: 'My todo', product_id: '' })) - expect(res.status).toBe(422) - }) - - // TC-TD-07 - it('creates todo with valid product_id and returns 201', async () => { - const res = await postTodo(makeRequest({ title: 'Test todo', product_id: 'prod-1' })) - const data = await res.json() - - expect(res.status).toBe(201) - expect(data).toMatchObject({ id: 'todo-1', title: 'Test todo' }) - expect(data).toHaveProperty('created_at') - expect(mockPrisma.todo.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - user_id: 'user-1', - product_id: 'prod-1', - title: 'Test todo', - }), - }) - ) - }) - - it('queries product by user_id (not productAccessFilter) to enforce ownership', async () => { - await postTodo(makeRequest({ title: 'Test todo', product_id: 'prod-1' })) - - expect(mockPrisma.product.findFirst).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - id: 'prod-1', - user_id: 'user-1', - archived: false, - }), - }) - ) - }) - - it('returns 404 when product does not exist or is archived', async () => { - mockPrisma.product.findFirst.mockResolvedValue(null) - - const res = await postTodo(makeRequest({ title: 'My todo', product_id: 'nonexistent' })) - expect(res.status).toBe(404) - }) -}) diff --git a/__tests__/app/api/jobs/job-by-id-route.test.ts b/__tests__/app/api/jobs/job-by-id-route.test.ts new file mode 100644 index 0000000..a9d2c1a --- /dev/null +++ b/__tests__/app/api/jobs/job-by-id-route.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockGetSession, mockFindFirstJob, mockFindManyPrice } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockFindFirstJob: vi.fn(), + mockFindManyPrice: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/prisma', () => ({ + prisma: { + claudeJob: { findFirst: mockFindFirstJob }, + modelPrice: { findMany: mockFindManyPrice }, + }, +})) + +import { GET } from '@/app/api/jobs/[id]/route' + +function makeParams(id = 'job-1'): { params: Promise<{ id: string }> } { + return { params: Promise.resolve({ id }) } +} + +function makeRequest(id = 'job-1'): Request { + return new Request(`http://localhost/api/jobs/${id}`) +} + +const RAW_JOB = { + id: 'job-1', + kind: 'TASK_IMPLEMENTATION' as const, + status: 'DONE' as const, + model_id: 'claude-sonnet-4-6', + input_tokens: 100, + output_tokens: 50, + cache_read_tokens: 0, + cache_write_tokens: 0, + branch: 'feat/test', + pr_url: null, + error: null, + summary: 'Done', + verify_result: 'ALIGNED' as const, + started_at: new Date('2026-01-01T10:00:00Z'), + finished_at: new Date('2026-01-01T10:05:00Z'), + created_at: new Date('2026-01-01T09:59:00Z'), + sprint_run_id: null, + task: { + code: 'T-42', + title: 'Some task', + description: null, + implementation_plan: 'Do the thing', + story: { code: 'S-10', pbi: { code: 'PBI-5' } }, + }, + idea: null, + product: { name: 'Scrum4Me', code: 'SCR' }, + sprint_run: null, +} + +describe('GET /api/jobs/:id', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ userId: 'user-1' }) + mockFindFirstJob.mockResolvedValue(RAW_JOB) + mockFindManyPrice.mockResolvedValue([]) + }) + + it('returns 401 when not logged in', async () => { + mockGetSession.mockResolvedValue({ userId: undefined }) + const res = await GET(makeRequest() as never, makeParams()) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error).toBeTruthy() + }) + + it('returns 404 when job not found', async () => { + mockFindFirstJob.mockResolvedValue(null) + const res = await GET(makeRequest() as never, makeParams()) + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error).toBeTruthy() + }) + + it('queries with user_id filter to prevent cross-user access', async () => { + await GET(makeRequest() as never, makeParams()) + expect(mockFindFirstJob).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'job-1', user_id: 'user-1' }, + }) + ) + }) + + it('returns 200 with mapped job shape including breadcrumb codes', async () => { + const res = await GET(makeRequest() as never, makeParams()) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toMatchObject({ + id: 'job-1', + kind: 'TASK_IMPLEMENTATION', + status: 'DONE', + taskCode: 'T-42', + taskTitle: 'Some task', + productCode: 'SCR', + storyCode: 'S-10', + pbiCode: 'PBI-5', + branch: 'feat/test', + }) + }) +}) diff --git a/__tests__/components/backlog/backlog-split-pane.test.tsx b/__tests__/components/backlog/backlog-split-pane.test.tsx index f57e53f..27d7626 100644 --- a/__tests__/components/backlog/backlog-split-pane.test.tsx +++ b/__tests__/components/backlog/backlog-split-pane.test.tsx @@ -1,9 +1,21 @@ // @vitest-environment jsdom -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { render, screen } from '@testing-library/react' -import { useSelectionStore } from '@/stores/selection-store' + +vi.mock('@/actions/user-settings', () => ({ + updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), +})) + +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane' +function setSelection(pbiId: string | null, storyId: string | null) { + useProductWorkspaceStore.setState((s) => { + s.context.activePbiId = pbiId + s.context.activeStoryId = storyId + }) +} + const PANES = [
PBI pane
,
Stories pane
, @@ -22,7 +34,7 @@ function renderPane() { } beforeEach(() => { - useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null }) + setSelection(null, null) // Force mobile viewport Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) window.dispatchEvent(new Event('resize')) @@ -37,7 +49,7 @@ describe('BacklogSplitPane auto-switch', () => { it('auto-switches to tab 1 when PBI is selected', () => { const { rerender } = renderPane() - useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: null }) + setSelection('pbi-1', null) rerender( { it('auto-switches to tab 2 when story is selected', () => { const { rerender } = renderPane() - useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' }) + setSelection('pbi-1', 'story-1') rerender( { it('switches to tab 1 on cascade-reset (story cleared when new PBI selected)', () => { // Start with story selected (tab 2) - useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' }) + setSelection('pbi-1', 'story-1') const { rerender } = renderPane() // Cascade-reset: new PBI → story clears - useSelectionStore.setState({ selectedPbiId: 'pbi-2', selectedStoryId: null }) + setSelection('pbi-2', null) rerender( ({ - reorderStoriesAction: vi.fn().mockResolvedValue({ success: true }), reorderPbisAction: vi.fn().mockResolvedValue({ success: true }), updatePbiPriorityAction: vi.fn().mockResolvedValue({ success: true }), })) vi.mock('@/actions/pbis', () => ({ deletePbiAction: vi.fn().mockResolvedValue({ success: true }) })) -vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) })) +vi.mock('@/actions/user-settings', () => ({ + updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), +})) vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) -// Mock dnd-kit +// Mock dnd-kit (still needed for PBI panel which supports drag-and-drop) vi.mock('@dnd-kit/core', () => ({ DndContext: ({ children }: { children: React.ReactNode }) => <>{children}, PointerSensor: class {}, @@ -61,19 +65,40 @@ const PBI_ID = 'pbi-1' const ALT_PBI_ID = 'pbi-2' const STORY_ID = 'story-1' -const STORIES = [ - { id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: PBI_ID, created_at: new Date() }, +const STORIES: BacklogStory[] = [ + { id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, sort_order: 1, status: 'OPEN', pbi_id: PBI_ID, sprint_id: null, created_at: new Date() }, ] -const TASKS = [ - { id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, +const TASKS: BacklogTask[] = [ + { id: 'task-1', code: null, title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, ] function resetStores() { - useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null }) - useBacklogStore.setState({ - pbis: [], - storiesByPbi: { [PBI_ID]: STORIES }, - tasksByStory: { [STORY_ID]: TASKS }, + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = null + s.context.activePbiId = null + s.context.activeStoryId = null + s.context.activeTaskId = null + s.entities.pbisById = {} + s.entities.storiesById = Object.fromEntries(STORIES.map((st) => [st.id, st])) + s.entities.tasksById = Object.fromEntries(TASKS.map((t) => [t.id, t])) + s.relations.pbiIds = [] + s.relations.storyIdsByPbi = { [PBI_ID]: STORIES.map((st) => st.id) } + s.relations.taskIdsByStory = { [STORY_ID]: TASKS.map((t) => t.id) } + }) +} + +function selectPbi(pbiId: string | null) { + useProductWorkspaceStore.setState((s) => { + s.context.activePbiId = pbiId + s.context.activeStoryId = null + s.context.activeTaskId = null + }) +} + +function selectStory(pbiId: string | null, storyId: string | null) { + useProductWorkspaceStore.setState((s) => { + s.context.activePbiId = pbiId + s.context.activeStoryId = storyId }) } @@ -89,42 +114,40 @@ describe('Backlog 3-pane integration', () => { }) it('StoryPanel shows stories when PBI is selected', () => { - useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null }) + selectPbi(PBI_ID) render() expect(screen.getByText('Eerste story')).toBeTruthy() }) - it('clicking a story dispatches selectStory to the store', () => { - useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null }) + it('clicking a story dispatches setActiveStory to the workspace-store', () => { + selectPbi(PBI_ID) render() fireEvent.click(screen.getByText('Eerste story')) - expect(useSelectionStore.getState().selectedStoryId).toBe(STORY_ID) + expect(useProductWorkspaceStore.getState().context.activeStoryId).toBe(STORY_ID) }) - it('cascade-reset: selecting different PBI clears selectedStoryId', () => { - useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) - useSelectionStore.getState().selectPbi(ALT_PBI_ID) - expect(useSelectionStore.getState().selectedStoryId).toBeNull() + it('cascade-reset: selecting different PBI clears activeStoryId', () => { + selectStory(PBI_ID, STORY_ID) + useProductWorkspaceStore.getState().setActivePbi(ALT_PBI_ID) + expect(useProductWorkspaceStore.getState().context.activeStoryId).toBeNull() }) it('TaskPanel shows tasks after story is selected', () => { - useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) + selectStory(PBI_ID, STORY_ID) render() expect(screen.getByText('Eerste taak')).toBeTruthy() }) it('TaskPanel shows empty state after cascade-reset', () => { - useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) + selectStory(PBI_ID, STORY_ID) render() - // Reset via selectPbi - useSelectionStore.getState().selectPbi(ALT_PBI_ID) - // Re-render reflects new store state + useProductWorkspaceStore.getState().setActivePbi(ALT_PBI_ID) render() expect(screen.getAllByText('Selecteer een story om de taken te bekijken.').length).toBeGreaterThan(0) }) it('selected story card has isSelected highlight class applied', () => { - useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) + selectStory(PBI_ID, STORY_ID) const { container } = render() // bg-primary-container is applied when isSelected const selected = container.querySelector('.bg-primary-container') diff --git a/__tests__/components/backlog/new-sprint-trigger.test.tsx b/__tests__/components/backlog/new-sprint-trigger.test.tsx new file mode 100644 index 0000000..72c669e --- /dev/null +++ b/__tests__/components/backlog/new-sprint-trigger.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import type { ReactNode } from 'react' + +const workflowMock: { + value: { pendingSprintDraft?: Record } | undefined +} = { value: undefined } + +vi.mock('@/stores/user-settings/store', () => ({ + useUserSettingsStore: ( + selector: (s: { + entities: { + settings: { + workflow: { pendingSprintDraft?: Record } | undefined + } + } + }) => unknown, + ) => selector({ entities: { settings: { workflow: workflowMock.value } } }), +})) + +vi.mock('./new-sprint-metadata-dialog', () => ({ + NewSprintMetadataDialog: () => null, +})) + +vi.mock('@/components/shared/demo-tooltip', () => ({ + DemoTooltip: ({ children }: { children: ReactNode }) => children, +})) + +import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger' + +beforeEach(() => { + workflowMock.value = undefined +}) + +describe('NewSprintTrigger', () => { + it('renders the button on an active product without a draft', () => { + render() + expect(screen.getByText('Nieuwe sprint')).toBeInTheDocument() + }) + + it('renders nothing on a non-active product (G6)', () => { + const { container } = render( + , + ) + expect(container).toBeEmptyDOMElement() + }) + + it('renders nothing when a sprint draft is pending', () => { + workflowMock.value = { pendingSprintDraft: { p1: { goal: 'X' } } } + const { container } = render( + , + ) + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/__tests__/components/backlog/task-panel.test.tsx b/__tests__/components/backlog/task-panel.test.tsx index 97a5894..fc5cf7a 100644 --- a/__tests__/components/backlog/task-panel.test.tsx +++ b/__tests__/components/backlog/task-panel.test.tsx @@ -1,44 +1,40 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' -import { useSelectionStore } from '@/stores/selection-store' -import { useBacklogStore } from '@/stores/backlog-store' +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import type { BacklogTask } from '@/stores/product-workspace/types' + +function resetWorkspace() { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = null + s.context.activePbiId = null + s.context.activeStoryId = null + s.context.activeTaskId = null + s.entities.pbisById = {} + s.entities.storiesById = {} + s.entities.tasksById = {} + s.relations.pbiIds = [] + s.relations.storyIdsByPbi = {} + s.relations.taskIdsByStory = {} + }) +} + +function setActiveStoryAndTasks(storyId: string | null, tasks: BacklogTask[] = []) { + useProductWorkspaceStore.setState((s) => { + s.context.activeStoryId = storyId + if (storyId) { + s.relations.taskIdsByStory[storyId] = tasks.map((t) => t.id) + for (const task of tasks) s.entities.tasksById[task.id] = task + } + }) +} // Mock next/navigation const mockPush = vi.fn() vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) })) -// Mock reorderTasksAction -vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) -// Mock dnd-kit to avoid jsdom drag complexity -vi.mock('@dnd-kit/core', () => ({ - DndContext: ({ children }: { children: React.ReactNode }) => <>{children}, - PointerSensor: class {}, - KeyboardSensor: class {}, - useSensor: vi.fn(), - useSensors: vi.fn(() => []), - closestCenter: vi.fn(), - DragOverlay: () => null, -})) -vi.mock('@dnd-kit/sortable', () => ({ - SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}, - useSortable: () => ({ - attributes: {}, listeners: {}, setNodeRef: vi.fn(), - transform: null, transition: undefined, isDragging: false, - }), - rectSortingStrategy: {}, - sortableKeyboardCoordinates: {}, - arrayMove: (arr: unknown[], from: number, to: number) => { - const next = [...arr] - next.splice(from, 1) - next.splice(to, 0, arr[from]) - return next - }, -})) -vi.mock('@dnd-kit/utilities', () => ({ CSS: { Transform: { toString: () => '' } } })) - import { TaskPanel } from '@/components/backlog/task-panel' const PRODUCT_ID = 'prod-1' @@ -46,8 +42,8 @@ const STORY_ID = 'story-1' const CLOSE_PATH = `/products/${PRODUCT_ID}` const TASKS = [ - { id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, - { id: 'task-2', title: 'Tweede taak', description: null, priority: 3, status: 'IN_PROGRESS', sort_order: 2, story_id: STORY_ID, created_at: new Date() }, + { id: 'task-1', code: null, title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, + { id: 'task-2', code: null, title: 'Tweede taak', description: null, priority: 3, status: 'IN_PROGRESS', sort_order: 2, story_id: STORY_ID, created_at: new Date() }, ] function renderPanel(isDemo = false) { @@ -57,8 +53,7 @@ function renderPanel(isDemo = false) { describe('TaskPanel', () => { beforeEach(() => { mockPush.mockClear() - useSelectionStore.setState({ selectedStoryId: null, selectedPbiId: null }) - useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} }) + resetWorkspace() }) it('shows empty state when no story is selected', () => { @@ -67,40 +62,35 @@ describe('TaskPanel', () => { }) it('shows empty state with action when story selected but no tasks', () => { - useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) - useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) + setActiveStoryAndTasks(STORY_ID, []) renderPanel() expect(screen.getByText('Nog geen taken voor deze story.')).toBeTruthy() expect(screen.getAllByText('+ Nieuwe taak').length).toBeGreaterThanOrEqual(1) }) it('renders task cards when tasks are present', () => { - useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) - useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + setActiveStoryAndTasks(STORY_ID, TASKS) renderPanel() expect(screen.getByText('Eerste taak')).toBeTruthy() expect(screen.getByText('Tweede taak')).toBeTruthy() }) it('renders status badges on task cards', () => { - useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) - useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + setActiveStoryAndTasks(STORY_ID, TASKS) renderPanel() expect(screen.getByText('To Do')).toBeTruthy() expect(screen.getByText('Bezig')).toBeTruthy() }) it('task cards are rendered inside a grid container', () => { - useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) - useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + setActiveStoryAndTasks(STORY_ID, TASKS) const { container } = renderPanel() const grid = container.querySelector('.grid') expect(grid).toBeTruthy() }) it('clicking + button calls router.push with newTask params', () => { - useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) - useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) + setActiveStoryAndTasks(STORY_ID, []) renderPanel() const buttons = screen.getAllByText('+ Nieuwe taak') fireEvent.click(buttons[0]) @@ -108,29 +98,18 @@ describe('TaskPanel', () => { }) it('clicking task card calls router.push with editTask param', () => { - useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) - useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + setActiveStoryAndTasks(STORY_ID, TASKS) renderPanel() fireEvent.click(screen.getByText('Eerste taak')) expect(mockPush).toHaveBeenCalledWith(`${CLOSE_PATH}?editTask=task-1`) }) it('+ button is disabled in demo mode', () => { - useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) - useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) + setActiveStoryAndTasks(STORY_ID, []) renderPanel(true) const btn = screen.getAllByText('+ Nieuwe taak')[0].closest('button') expect(btn).toBeTruthy() expect((btn as HTMLButtonElement).disabled).toBe(true) }) - it('cards have no drag listeners in demo mode (whole-card drag disabled)', () => { - useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) - useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) - // In demo mode, listeners ({} from useSortable mock) are not spread onto the card. - // The mock always returns empty listeners, so we just verify the cards render without error. - renderPanel(true) - expect(screen.getByText('Eerste taak')).toBeTruthy() - expect(screen.getByText('Tweede taak')).toBeTruthy() - }) }) diff --git a/__tests__/components/dialogs/answer-modal.test.tsx b/__tests__/components/dialogs/answer-modal.test.tsx new file mode 100644 index 0000000..26aad0f --- /dev/null +++ b/__tests__/components/dialogs/answer-modal.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import React from 'react' + +vi.mock('@/actions/questions', () => ({ + answerQuestion: vi.fn(), +})) +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +vi.mock('@/stores/notifications-store', () => ({ + useNotificationsStore: { + getState: () => ({ remove: vi.fn() }), + }, +})) +vi.mock('next/link', () => ({ + default: ({ href, children }: { href: string; children: React.ReactNode }) => ( + {children} + ), +})) + +import { AnswerModal } from '@/components/notifications/answer-modal' +import { answerQuestion } from '@/actions/questions' +import { toast } from 'sonner' +import type { NotificationQuestion } from '@/stores/notifications-store' + +const mockAnswerQuestion = answerQuestion as ReturnType +const mockToast = toast as unknown as { + success: ReturnType + error: ReturnType +} + +const QUESTION: NotificationQuestion = { + kind: 'idea', + id: 'q-1', + product_id: 'prod-1', + idea_id: 'idea-1', + idea_code: 'IDEA-42', + idea_title: 'Mijn Idee', + question: 'Wat denk jij?', + options: ['Optie A', 'Optie B'], + created_at: '2026-01-01T00:00:00Z', + expires_at: '2026-12-31T00:00:00Z', +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('AnswerModal — met opties', () => { + it('toont optieknoppen, textarea en Verstuur-knop', () => { + render() + expect(screen.getByRole('button', { name: 'Optie A' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Optie B' })).toBeTruthy() + expect(screen.getByLabelText(/Antwoord op Claude/)).toBeTruthy() + expect(screen.getByRole('button', { name: 'Verstuur' })).toBeTruthy() + }) + + it('roept answerQuestion aan met optiewaarde bij klik op optieknop', async () => { + mockAnswerQuestion.mockResolvedValue({ ok: true }) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Optie A' })) + + await waitFor(() => { + expect(mockAnswerQuestion).toHaveBeenCalledWith('q-1', 'Optie A') + }) + }) + + it('roept answerQuestion aan met getypte tekst bij klik op Verstuur', async () => { + mockAnswerQuestion.mockResolvedValue({ ok: true }) + render() + + fireEvent.change(screen.getByLabelText(/Antwoord op Claude/), { + target: { value: 'Mijn eigen antwoord' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Verstuur' })) + + await waitFor(() => { + expect(mockAnswerQuestion).toHaveBeenCalledWith('q-1', 'Mijn eigen antwoord') + }) + }) + + it('Verstuur-knop is disabled zolang het tekstveld leeg is', () => { + render() + expect(screen.getByRole('button', { name: 'Verstuur' })).toHaveProperty('disabled', true) + }) +}) + +describe('AnswerModal — demo-modus', () => { + it('textarea is disabled en Verstuur is disabled bij isDemo=true', () => { + render() + expect(screen.getByLabelText(/Antwoord op Claude/)).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: 'Verstuur' })).toHaveProperty('disabled', true) + }) +}) + +describe('AnswerModal — geen vraag', () => { + it('rendert niets wanneer question null is', () => { + const { container } = render( + , + ) + expect(container.firstChild).toBeNull() + }) +}) diff --git a/__tests__/components/ideas/idea-list.test.tsx b/__tests__/components/ideas/idea-list.test.tsx new file mode 100644 index 0000000..0e0a351 --- /dev/null +++ b/__tests__/components/ideas/idea-list.test.tsx @@ -0,0 +1,277 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' +import React from 'react' + +// --- Navigation mock --- +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }), +})) + +// --- Actions mocks --- +vi.mock('@/actions/ideas', () => ({ + createIdeaAction: vi.fn(), + archiveIdeaAction: vi.fn(), +})) + +vi.mock('@/actions/user-settings', () => ({ + updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), +})) + +// --- Sonner mock --- +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})) + +// --- IdeaRowActions mock (complex component with many deps) --- +vi.mock('@/components/ideas/idea-row-actions', () => ({ + IdeaRowActions: () =>
, +})) + +// --- DemoTooltip mock --- +vi.mock('@/components/shared/demo-tooltip', () => ({ + DemoTooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +// --- Popover mock — controlled via open prop --- +vi.mock('@/components/ui/popover', () => { + const PopoverCtx = React.createContext<{ + open: boolean + onOpenChange: (v: boolean) => void + }>({ open: false, onOpenChange: () => {} }) + + return { + Popover: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode + open?: boolean + onOpenChange?: (v: boolean) => void + }) => ( + {}) }}> + {children} + + ), + PopoverTrigger: ({ render: renderEl }: { render: React.ReactElement<{ onClick?: (e: React.MouseEvent) => void }> }) => { + const { open, onOpenChange } = React.useContext(PopoverCtx) + return React.cloneElement(renderEl, { + onClick: (e: React.MouseEvent) => { + onOpenChange(!open) + renderEl.props.onClick?.(e) + }, + }) + }, + PopoverContent: ({ children }: { children: React.ReactNode }) => { + const { open } = React.useContext(PopoverCtx) + return open ?
{children}
: null + }, + } +}) + +// Import after mocks +import { useUserSettingsStore } from '@/stores/user-settings/store' +import { IdeaList } from '@/components/ideas/idea-list' +import { createIdeaAction } from '@/actions/ideas' +import type { IdeaDto } from '@/lib/idea-dto' + +const PRODUCTS = [ + { id: 'prod-1', name: 'Product A', repo_url: null }, + // repo_url ingesteld zodat de optietekst gewoon "Product B" is (zonder "(geen repo)" suffix) + { id: 'prod-2', name: 'Product B', repo_url: 'https://github.com/org/prod-b' }, +] + +// Minimal IdeaDto factory +function makeIdea(overrides: Partial = {}): IdeaDto { + return { + id: 'idea-1', + code: 'ID-1', + title: 'Test Idee', + description: null, + status: 'draft', + product_id: null, + product: null, + pbi_id: null, + pbi: null, + secondary_products: [], + archived: false, + has_grill_md: false, + has_plan_md: false, + created_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-01T00:00:00.000Z', + ...overrides, + } +} + +const IDEAS: IdeaDto[] = [ + makeIdea({ id: 'idea-1', code: 'ID-1', title: 'Idee Concept', status: 'draft' }), + makeIdea({ id: 'idea-2', code: 'ID-2', title: 'Idee Gegrilld', status: 'grilled' }), + makeIdea({ id: 'idea-3', code: 'ID-3', title: 'Idee Gepland', status: 'planned' }), +] + +beforeEach(() => { + vi.clearAllMocks() + useUserSettingsStore.getState().hydrate({}, false) +}) + +describe('IdeaList — filterpopover', () => { + it('toont de "Filters"-knop in de toolbar (geen inline chip-rij)', () => { + render() + + // Filters-knop aanwezig + expect(screen.getByText('Filters')).toBeInTheDocument() + + // Status-labels zoals "Concept" mogen NIET los zichtbaar zijn zonder popover te openen + // (anders was de oude inline chip-rij er nog) + expect(screen.queryByRole('button', { name: 'Concept' })).not.toBeInTheDocument() + }) + + it('klik op "Filters" opent de popover en toont 11 statusopties', () => { + render() + + // Popover nog niet open: content niet zichtbaar + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('Filters')) + + // Content verschijnt + expect(screen.getByTestId('popover-content')).toBeInTheDocument() + + // 11 statusopties + "Alle" = 12 buttons in de popover + // Controleer specifiek de 11 status-labels + const statusLabels = [ + 'Concept', 'Grillen', 'Gegrilld', 'Plannen', 'Plan klaar', + 'Plan beoordelen', 'Gepland', 'Grill mislukt', 'Plan mislukt', + 'Beoordeling mislukt', 'Plan beoordeeld', + ] + for (const label of statusLabels) { + expect(screen.getByRole('button', { name: label })).toBeInTheDocument() + } + }) + + it('klik op een statuschip schrijft de status naar de store', () => { + render() + + fireEvent.click(screen.getByText('Filters')) + fireEvent.click(screen.getByRole('button', { name: 'Concept' })) + + const stored = + useUserSettingsStore.getState().entities.settings.views?.ideasList?.filterStatuses + expect(stored).toContain('draft') + }) + + it('gehydrateerde filter toont "Filters (1)" en filtert de tabel', () => { + useUserSettingsStore + .getState() + .hydrate({ views: { ideasList: { filterStatuses: ['draft'] } } }, false) + + render() + + // Trigger toont het actieve filteraantal + expect(screen.getByText('Filters (1)')).toBeInTheDocument() + + // Alleen het concept-idee is zichtbaar; de andere twee worden weggefilterd + expect(screen.getByText('Idee Concept')).toBeInTheDocument() + expect(screen.queryByText('Idee Gegrilld')).not.toBeInTheDocument() + expect(screen.queryByText('Idee Gepland')).not.toBeInTheDocument() + }) + + it('"Wis filters" is disabled wanneer geen filter actief is', () => { + render() + + fireEvent.click(screen.getByText('Filters')) + + const wisButton = screen.getByRole('button', { name: 'Wis filters' }) + expect(wisButton).toBeDisabled() + }) + + it('"Wis filters" is enabled en wist de filter wanneer een filter actief is', () => { + useUserSettingsStore + .getState() + .hydrate({ views: { ideasList: { filterStatuses: ['draft'] } } }, false) + + render() + + fireEvent.click(screen.getByText('Filters (1)')) + + const wisButton = screen.getByRole('button', { name: 'Wis filters' }) + expect(wisButton).not.toBeDisabled() + + fireEvent.click(wisButton) + + const stored = + useUserSettingsStore.getState().entities.settings.views?.ideasList?.filterStatuses + expect(stored).toEqual([]) + }) +}) + +describe('IdeaList — activeProductId voorvullen', () => { + // Hulpfunctie: vind een knop op basis van gedeeltelijke tekstinhoud. + // getByText() werkt hier betrouwbaarder dan getByRole({name}) voor knoppen + // met SVG-icoon omdat de accessible-name-berekening van Base UI knoppen in + // jsdom soms afwijkt van wat we verwachten. + function clickButton(label: string) { + const btn = Array.from(document.querySelectorAll('button')).find( + (b) => b.textContent?.trim().includes(label) + ) + if (!btn) throw new Error(`Knop met tekst "${label}" niet gevonden`) + fireEvent.click(btn) + } + + it('AC1: "Nieuw idee"-select is voorgevuld met het actieve product', async () => { + render( + + ) + + clickButton('Nieuw idee') + + // Wacht tot het formulier verschijnt; create-form-select toont "Product B" (waarde 'prod-2'). + // De toolbar-select toont "Alle producten" (waarde 'all'), zodat displayValue uniek is. + const createFormSelect = await waitFor(() => screen.getByDisplayValue('Product B')) + expect(createFormSelect).toHaveValue('prod-2') + }) + + it('AC2: "Nieuw idee"-select staat op leeg wanneer activeProductId null is', async () => { + render( + + ) + + clickButton('Nieuw idee') + + // Toolbar-select toont "Alle producten"; create-form-select toont de placeholder (waarde ''). + const createFormSelect = await waitFor(() => + screen.getByDisplayValue('Geen product (kan later worden gekoppeld)') + ) + expect(createFormSelect).toHaveValue('') + }) + + it('AC3: "Snel idee" stuurt product_id gelijk aan activeProductId mee', async () => { + vi.mocked(createIdeaAction).mockResolvedValue({ data: { code: 'ID-99', id: 'idea-99' } } as never) + + render( + + ) + + // Open "Snel idee"-formulier en wacht tot het verschijnt + clickButton('Snel idee') + await waitFor(() => screen.getByPlaceholderText('Titel *')) + + // Vul de verplichte titel in + fireEvent.change(screen.getByPlaceholderText('Titel *'), { + target: { value: 'Mijn snel idee' }, + }) + + // Klik Opslaan — startTransition roept createIdeaAction synchroon aan + clickButton('Opslaan') + + await waitFor(() => { + expect(createIdeaAction).toHaveBeenCalledWith({ + title: 'Mijn snel idee', + description: null, + product_id: 'prod-2', + }) + }) + }) +}) diff --git a/__tests__/components/jobs/job-card.test.tsx b/__tests__/components/jobs/job-card.test.tsx new file mode 100644 index 0000000..09bc3a2 --- /dev/null +++ b/__tests__/components/jobs/job-card.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import JobCard from '@/components/jobs/job-card' + +const BASE_PROPS = { + id: 'job-1', + kind: 'TASK_IMPLEMENTATION' as const, + status: 'RUNNING' as const, + productName: 'Scrum4Me', + productCode: 'S4M', + pbiCode: 'PBI-1', + storyCode: 'ST-1', + createdAt: new Date('2026-01-01T10:00:00Z'), +} + +describe('JobCard breadcrumb', () => { + it('TASK-job toont productCode, pbiCode en storyCode in de breadcrumb', () => { + render() + const breadcrumb = screen.getByText('S4M PBI-1 ST-1') + expect(breadcrumb).toBeInTheDocument() + }) + + it('TASK-job zonder productCode valt terug op productName in de breadcrumb', () => { + render() + expect(screen.getByText('Scrum4Me PBI-1 ST-1')).toBeInTheDocument() + }) + + it('TASK-job laat ontbrekende codes weg uit de breadcrumb', () => { + render() + expect(screen.getByText('S4M')).toBeInTheDocument() + }) + + it('GRILL-job toont productCode en ideaCode', () => { + render( + , + ) + expect(screen.getByText('S4M IDEA-5')).toBeInTheDocument() + }) + + it('SPRINT-job toont productCode en sprintCode', () => { + render( + , + ) + expect(screen.getByText('S4M SP-3')).toBeInTheDocument() + }) +}) + +describe('JobCard datumweergave', () => { + it('toont finishedAt als die beschikbaar is', () => { + const finishedAt = new Date('2026-03-15T14:30:00Z') + render() + const formatted = finishedAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }) + expect(screen.getByText(formatted)).toBeInTheDocument() + }) + + it('toont startedAt als finishedAt ontbreekt', () => { + const startedAt = new Date('2026-03-10T09:00:00Z') + render() + const formatted = startedAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }) + expect(screen.getByText(formatted)).toBeInTheDocument() + }) + + it('toont createdAt als zowel finishedAt als startedAt ontbreken', () => { + const createdAt = new Date('2026-01-01T10:00:00Z') + render() + const formatted = createdAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }) + expect(screen.getByText(formatted)).toBeInTheDocument() + }) +}) diff --git a/__tests__/components/jobs/job-detail-pane.test.tsx b/__tests__/components/jobs/job-detail-pane.test.tsx new file mode 100644 index 0000000..9a5d0f6 --- /dev/null +++ b/__tests__/components/jobs/job-detail-pane.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' +import type { JobWithRelations } from '@/actions/jobs-page' + +vi.mock('@/actions/claude-jobs', () => ({ + restartClaudeJobAction: vi.fn(), +})) + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +import { restartClaudeJobAction } from '@/actions/claude-jobs' +import JobDetailPane from '@/components/jobs/job-detail-pane' + +const mockAction = restartClaudeJobAction as ReturnType + +function makeJob(status: JobWithRelations['status']): JobWithRelations { + return { + id: 'job-1', + kind: 'TASK_IMPLEMENTATION', + status, + taskCode: 'T-1', + taskTitle: 'Test taak', + ideaCode: null, + ideaTitle: null, + sprintGoal: null, + sprintCode: null, + productName: 'Scrum4Me', + productCode: null, + storyCode: null, + pbiCode: null, + modelId: null, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + costUsd: null, + branch: null, + prUrl: null, + error: null, + summary: null, + description: null, + verifyResult: null, + startedAt: null, + finishedAt: null, + createdAt: new Date('2026-01-01'), + sprintRunId: null, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockAction.mockResolvedValue({ success: true }) +}) + +describe('JobDetailPane restart button', () => { + it('toont de knop voor FAILED-jobs', () => { + render() + expect(screen.getByRole('button', { name: /opnieuw starten/i })).toBeInTheDocument() + }) + + it('toont de knop niet voor DONE-jobs', () => { + render() + expect(screen.queryByRole('button', { name: /opnieuw starten/i })).not.toBeInTheDocument() + }) + + it('roept restartClaudeJobAction aan met het juiste id bij klik', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /opnieuw starten/i })) + expect(mockAction).toHaveBeenCalledWith('job-1') + }) + + it('knop is disabled in demo-modus', () => { + render() + expect(screen.getByRole('button', { name: /opnieuw starten/i })).toBeDisabled() + }) +}) diff --git a/__tests__/components/shared/nav-bar.test.tsx b/__tests__/components/shared/nav-bar.test.tsx new file mode 100644 index 0000000..28e9037 --- /dev/null +++ b/__tests__/components/shared/nav-bar.test.tsx @@ -0,0 +1,179 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' +import React from 'react' + +const pushMock = vi.fn() +const refreshMock = vi.fn() +const pathnameMock = vi.fn(() => '/dashboard') + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: pushMock, refresh: refreshMock }), + usePathname: () => pathnameMock(), +})) + +vi.mock('@/actions/active-product', () => ({ + setActiveProductAction: vi.fn(), +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})) + +vi.mock('@/components/ui/dropdown-menu', () => { + type Props = React.HTMLAttributes & { + children?: React.ReactNode + onClick?: () => void + } + const PassThrough = ({ children }: Props) => <>{children} + const Forwarding = ({ children, ...rest }: Props) =>
{children}
+ return { + DropdownMenu: PassThrough, + DropdownMenuTrigger: Forwarding, + DropdownMenuContent: PassThrough, + DropdownMenuItem: ({ children, onClick, className }: Props) => ( + + ), + DropdownMenuSeparator: () => null, + } +}) + +vi.mock('@/components/ui/tooltip', () => { + type Props = { children?: React.ReactNode } + const PassThrough = ({ children }: Props) => <>{children} + return { + Tooltip: PassThrough, + TooltipContent: PassThrough, + TooltipProvider: PassThrough, + TooltipTrigger: PassThrough, + } +}) + +vi.mock('@/components/shared/app-icon', () => ({ AppIcon: () => null })) +vi.mock('@/components/shared/user-menu', () => ({ UserMenu: () => null })) +vi.mock('@/components/shared/notifications-bell', () => ({ NotificationsBell: () => null })) +vi.mock('@/components/solo/nav-status-indicators', () => ({ + SoloNavStatusIndicators: () => null, +})) + +import { setActiveProductAction } from '@/actions/active-product' +import { toast } from 'sonner' +import { NavBar } from '@/components/shared/nav-bar' + +const actionMock = setActiveProductAction as unknown as ReturnType +const toastSuccess = toast.success as unknown as ReturnType + +const products = [ + { id: 'A', name: 'Alpha' }, + { id: 'B', name: 'Beta' }, +] + +function renderNavBar(overrides: { isDemo?: boolean; activeProductId?: string } = {}) { + const isDemo = overrides.isDemo ?? false + const activeId = overrides.activeProductId ?? 'A' + const activeProduct = products.find(p => p.id === activeId) ?? null + return render( + , + ) +} + +beforeEach(() => { + vi.clearAllMocks() + actionMock.mockResolvedValue({ success: true }) + pathnameMock.mockReturnValue('/dashboard') +}) + +describe('NavBar — product switch', () => { + it('demo: clicking another product navigates via router.push without calling the action', () => { + renderNavBar({ isDemo: true, activeProductId: 'A' }) + fireEvent.click(screen.getByText('Beta')) + expect(pushMock).toHaveBeenCalledWith('/products/B') + expect(actionMock).not.toHaveBeenCalled() + expect(toastSuccess).not.toHaveBeenCalled() + }) + + it('non-demo: clicking another product calls setActiveProductAction', async () => { + renderNavBar({ isDemo: false, activeProductId: 'A' }) + fireEvent.click(screen.getByText('Beta')) + await Promise.resolve() + expect(actionMock).toHaveBeenCalledWith('B') + }) + + it('non-demo: on /products/A navigates to /products/B', async () => { + pathnameMock.mockReturnValue('/products/A') + renderNavBar({ isDemo: false, activeProductId: 'A' }) + fireEvent.click(screen.getByText('Beta')) + await Promise.resolve() + await Promise.resolve() + expect(pushMock).toHaveBeenCalledWith('/products/B') + expect(toastSuccess).toHaveBeenCalled() + }) + + it('non-demo: on /products/A/sprint/SPR1 navigates to /products/B/sprint', async () => { + pathnameMock.mockReturnValue('/products/A/sprint/SPR1') + renderNavBar({ isDemo: false, activeProductId: 'A' }) + fireEvent.click(screen.getByText('Beta')) + await Promise.resolve() + await Promise.resolve() + expect(pushMock).toHaveBeenCalledWith('/products/B/sprint') + expect(toastSuccess).toHaveBeenCalled() + }) + + it('non-demo: on /products/A/solo navigates to /products/B/solo', async () => { + pathnameMock.mockReturnValue('/products/A/solo') + renderNavBar({ isDemo: false, activeProductId: 'A' }) + fireEvent.click(screen.getByText('Beta')) + await Promise.resolve() + await Promise.resolve() + expect(pushMock).toHaveBeenCalledWith('/products/B/solo') + expect(toastSuccess).toHaveBeenCalled() + }) + + it('non-demo: on /dashboard calls router.refresh and not router.push', async () => { + pathnameMock.mockReturnValue('/dashboard') + renderNavBar({ isDemo: false, activeProductId: 'A' }) + fireEvent.click(screen.getByText('Beta')) + await Promise.resolve() + await Promise.resolve() + expect(refreshMock).toHaveBeenCalled() + expect(pushMock).not.toHaveBeenCalled() + expect(toastSuccess).toHaveBeenCalled() + }) +}) + +describe('NavBar — URL-derived active product (demo only)', () => { + it('demo: label and dropdown highlight follow pathname, not the activeProduct prop', () => { + pathnameMock.mockReturnValue('/products/B/sprint') + const { container } = renderNavBar({ isDemo: true, activeProductId: 'A' }) + const trigger = container.querySelector('[data-debug-id="nav-bar__product-switcher"]') + expect(trigger?.textContent).toContain('Beta') + expect(trigger?.textContent).not.toContain('Alpha') + const items = screen.getAllByTestId('dd-item') + const itemB = items.find(el => el.textContent?.includes('Beta')) + expect(itemB?.className).toContain('bg-primary-container') + const itemA = items.find(el => el.textContent?.includes('Alpha')) + expect(itemA?.className ?? '').not.toContain('bg-primary-container') + }) + + it('non-demo: pathname does NOT override the activeProduct prop', () => { + pathnameMock.mockReturnValue('/products/B/sprint') + renderNavBar({ isDemo: false, activeProductId: 'A' }) + // Label still reflects server-rendered activeProduct (Alpha) + const items = screen.getAllByTestId('dd-item') + const itemA = items.find(el => el.textContent?.includes('Alpha')) + expect(itemA?.className).toContain('bg-primary-container') + }) +}) diff --git a/__tests__/components/shared/sprint-switcher.test.tsx b/__tests__/components/shared/sprint-switcher.test.tsx new file mode 100644 index 0000000..8af2df1 --- /dev/null +++ b/__tests__/components/shared/sprint-switcher.test.tsx @@ -0,0 +1,174 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' +import React from 'react' + +const pushMock = vi.fn() +const refreshMock = vi.fn() +const pathnameMock = vi.fn(() => '/products/p1/sprint') + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: pushMock, refresh: refreshMock }), + usePathname: () => pathnameMock(), +})) + +vi.mock('@/actions/active-sprint', () => ({ + setActiveSprintAction: vi.fn(), + switchActiveSprintAction: vi.fn(), + clearActiveSprintAction: vi.fn(), +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})) + +const isDemoMock = { value: false } +const workflowMock: { + value: + | { pendingSprintDraft?: Record } + | undefined +} = { value: undefined } +// Mock-state shape moet alle paden dekken die SprintSwitcher selecteert: +// - s.context.isDemo (oude code) +// - s.entities.settings.workflow?.pendingSprintDraft?.[productId]?.goal (PBI-79) +type MockStoreState = { + context: { isDemo: boolean } + entities: { + settings: { + workflow?: { + pendingSprintDraft?: Record + } + } + } +} +vi.mock('@/stores/user-settings/store', () => ({ + useUserSettingsStore: (selector: (s: MockStoreState) => unknown) => + selector({ + context: { isDemo: isDemoMock.value }, + entities: { settings: { workflow: workflowMock.value } }, + }), +})) + +vi.mock('@/components/ui/dropdown-menu', () => { + type Props = { children?: React.ReactNode; onClick?: () => void; className?: string } + const PassThrough = ({ children }: Props) => <>{children} + return { + DropdownMenu: PassThrough, + DropdownMenuTrigger: PassThrough, + DropdownMenuContent: PassThrough, + DropdownMenuItem: ({ children, onClick, className }: Props) => ( + + ), + DropdownMenuSeparator: () => null, + } +}) + +vi.mock('@/components/ui/tooltip', () => { + type Props = { children?: React.ReactNode } + const PassThrough = ({ children }: Props) => <>{children} + return { + Tooltip: PassThrough, + TooltipContent: PassThrough, + TooltipProvider: PassThrough, + TooltipTrigger: PassThrough, + } +}) + +import { switchActiveSprintAction } from '@/actions/active-sprint' +import { toast } from 'sonner' +import { SprintSwitcher } from '@/components/shared/sprint-switcher' + +const actionMock = switchActiveSprintAction as unknown as ReturnType +const toastError = toast.error as unknown as ReturnType +const toastSuccess = toast.success as unknown as ReturnType + +const sprints = [ + { id: 's1', code: 'SP-1', sprint_goal: 'Goal 1', status: 'open' as const }, + { id: 's2', code: 'SP-2', sprint_goal: 'Goal 2', status: 'open' as const }, +] + +beforeEach(() => { + vi.clearAllMocks() + isDemoMock.value = false + workflowMock.value = undefined + actionMock.mockResolvedValue({ success: true }) + pathnameMock.mockReturnValue('/products/p1/sprint') +}) + +describe('SprintSwitcher', () => { + it('demo: clicking another sprint navigates via router.push without calling the action', () => { + isDemoMock.value = true + render( + , + ) + fireEvent.click(screen.getByText('Goal 2')) + expect(pushMock).toHaveBeenCalledWith('/products/p1/sprint/s2') + expect(actionMock).not.toHaveBeenCalled() + expect(toastError).not.toHaveBeenCalled() + expect(toastSuccess).not.toHaveBeenCalled() + }) + + it('non-demo: clicking another sprint calls setActiveSprintAction', async () => { + isDemoMock.value = false + render( + , + ) + fireEvent.click(screen.getByText('Goal 2')) + // Wait microtask for the transition to flush. + await Promise.resolve() + expect(actionMock).toHaveBeenCalledWith('p1', 's2') + }) + + it('clicking the already-active sprint does nothing', () => { + isDemoMock.value = true + render( + , + ) + fireEvent.click(screen.getByText('Goal 1')) + expect(pushMock).not.toHaveBeenCalled() + expect(actionMock).not.toHaveBeenCalled() + }) + + it('shows the concept-sprint on the trigger when a draft is pending (G5)', () => { + workflowMock.value = { pendingSprintDraft: { p1: { goal: 'Test goal' } } } + render( + , + ) + expect(screen.getByText('⚙ Concept — Test goal')).toBeInTheDocument() + }) + + it('shows no concept label on the trigger when no draft is pending', () => { + render( + , + ) + expect(screen.queryByText(/⚙ Concept/)).not.toBeInTheDocument() + }) +}) diff --git a/__tests__/components/solo/solo-board-batch-enqueue.test.tsx b/__tests__/components/solo/solo-board-batch-enqueue.test.tsx index 392bf6e..d47242d 100644 --- a/__tests__/components/solo/solo-board-batch-enqueue.test.tsx +++ b/__tests__/components/solo/solo-board-batch-enqueue.test.tsx @@ -94,6 +94,9 @@ const TODO_TASK = { story_code: 'ST-1', story_title: 'Story 1', task_code: 'ST-1.1', + pbi_code: null, + pbi_title: null, + pbi_description: null, } const DEFAULT_PROPS = { diff --git a/__tests__/components/solo/solo-task-card.test.tsx b/__tests__/components/solo/solo-task-card.test.tsx new file mode 100644 index 0000000..f7a8493 --- /dev/null +++ b/__tests__/components/solo/solo-task-card.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom' +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import type { SoloTask } from '@/components/solo/solo-board' + +vi.mock('@/components/ui/tooltip', () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children?: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => {children}, +})) +vi.mock('@dnd-kit/core', () => ({ + useDraggable: () => ({ attributes: {}, listeners: {}, setNodeRef: vi.fn(), transform: null, isDragging: false }), +})) +vi.mock('@/stores/solo-store', () => ({ + useSoloStore: () => null, +})) +vi.mock('@/components/shared/code-badge', () => ({ + CodeBadge: ({ code }: { code: string }) => {code}, +})) + +import { SoloTaskCard, SoloTaskCardOverlay } from '@/components/solo/solo-task-card' + +function makeSoloTask(overrides: Partial = {}): SoloTask { + return { + id: 'task-1', + title: 'Taak titel', + description: 'Omschrijving van de taak die langer is dan tachtig tekens voor test', + implementation_plan: null, + priority: 2, + sort_order: 0, + status: 'TO_DO', + verify_only: false, + verify_required: 'ALIGNED', + story_id: 'story-1', + story_code: 'ST-1', + story_title: 'Story titel', + task_code: 'T-1', + pbi_code: 'PBI-1', + pbi_title: 'PBI titel', + pbi_description: 'PBI omschrijving', + ...overrides, + } +} + +describe('SoloTaskCard', () => { + it('toont taaknaam, task_code, pbi_code, story_code, story_title', () => { + render() + expect(screen.getAllByText('Taak titel').length).toBeGreaterThan(0) + expect(screen.getAllByText('T-1').length).toBeGreaterThan(0) + expect(screen.getAllByText('PBI-1').length).toBeGreaterThan(0) + expect(screen.getByText('ST-1')).toBeInTheDocument() + expect(screen.getByText('Story titel')).toBeInTheDocument() + }) + + it('verbergt pbi_code badge als pbi_code null is', () => { + render() + const badges = screen.queryAllByTestId('code-badge') + const codes = badges.map(b => b.textContent) + expect(codes).not.toContain('PBI-1') + }) + + it('verbergt description als description null is', () => { + const task = makeSoloTask({ description: null }) + render() + expect(screen.queryByText(/Omschrijving/)).toBeNull() + }) + + it('toont description als tekst', () => { + render() + expect(screen.getAllByText('Omschrijving van de taak die langer is dan tachtig tekens voor test').length).toBeGreaterThan(0) + }) +}) + +describe('SoloTaskCardOverlay', () => { + it('toont taaknaam en codes zonder tooltip-wrappers', () => { + render() + expect(screen.getByText('Taak titel')).toBeInTheDocument() + expect(screen.getByText('T-1')).toBeInTheDocument() + expect(screen.getByText('PBI-1')).toBeInTheDocument() + expect(screen.queryAllByTestId('tooltip-content')).toHaveLength(0) + }) +}) diff --git a/__tests__/components/solo/task-detail-dialog.test.tsx b/__tests__/components/solo/task-detail-dialog.test.tsx index 3b767fc..6c56a22 100644 --- a/__tests__/components/solo/task-detail-dialog.test.tsx +++ b/__tests__/components/solo/task-detail-dialog.test.tsx @@ -65,6 +65,9 @@ const baseTask: SoloTask = { story_code: 'ST-100', story_title: 'Test Story', task_code: 'ST-100.1', + pbi_code: null, + pbi_title: null, + pbi_description: null, } const DEFAULT_PROPS = { diff --git a/__tests__/components/split-pane.test.tsx b/__tests__/components/split-pane.test.tsx index cd166c0..40cb515 100644 --- a/__tests__/components/split-pane.test.tsx +++ b/__tests__/components/split-pane.test.tsx @@ -1,28 +1,35 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' -import { SplitPane } from '@/components/split-pane/split-pane' -// Helper to set a cookie -function setCookie(key: string, value: string) { - Object.defineProperty(document, 'cookie', { - writable: true, - configurable: true, - value: `sp:${key}=${encodeURIComponent(value)}`, +vi.mock('@/actions/user-settings', () => ({ + updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), +})) + +import { SplitPane } from '@/components/split-pane/split-pane' +import { useUserSettingsStore } from '@/stores/user-settings/store' + +function seedPositions(key: string, positions: number[]) { + useUserSettingsStore.setState((s) => { + s.entities.settings = { + layout: { + splitPanePositions: { [key]: positions }, + }, + } }) } -function clearCookies() { - Object.defineProperty(document, 'cookie', { - writable: true, - configurable: true, - value: '', +function resetStore() { + useUserSettingsStore.setState((s) => { + s.entities.settings = {} + s.context.hydrated = false + s.context.isDemo = false }) } describe('SplitPane', () => { beforeEach(() => { - clearCookies() + resetStore() // Default: desktop viewport Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1440 }) window.dispatchEvent(new Event('resize')) @@ -64,9 +71,8 @@ describe('SplitPane', () => { expect(dividers).toHaveLength(2) }) - it('restores splits from cookie on mount', () => { - const stored = JSON.stringify([40, 60]) - setCookie('test-restore', stored) + it('restores splits from user-settings store on mount', () => { + seedPositions('test-restore', [40, 60]) const { container } = render( { expect(paneDiv).toBeTruthy() }) - it('falls back to defaultSplit when cookie is invalid', () => { - setCookie('test-invalid', 'not-valid-json') + it('falls back to defaultSplit when persisted positions are invalid', () => { + // Wrong number of values for a 2-pane layout + seedPositions('test-invalid', [10, 30, 60]) const { container } = render( ({ useRouter: () => ({ push: vi.fn() }) })) +vi.mock('@/actions/tasks', () => ({ + saveTask: vi.fn(), + deleteTask: vi.fn(), +})) +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +import { SprintTaskDialogMount } from '@/components/sprint/sprint-task-dialog-mount' +import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' +import type { SprintWorkspaceTaskDetail } from '@/stores/sprint-workspace/types' + +const TASK_DETAIL: SprintWorkspaceTaskDetail = { + id: 't1', + code: 'T-1', + title: 'Mijn taak', + description: 'Beschrijving', + priority: 2, + sort_order: 1, + status: 'in_progress', + story_id: 'story-1', + sprint_id: 'sprint-1', + created_at: new Date('2026-01-15'), + _detail: true, + implementation_plan: 'Stap 1\nStap 2', +} + +function resetStore() { + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = null + s.context.activeSprintId = null + s.context.activeStoryId = null + s.context.activeTaskId = null + s.entities.sprintsById = {} + s.entities.storiesById = {} + s.entities.tasksById = {} + s.relations.sprintIdsByProduct = {} + s.relations.storyIdsBySprint = {} + s.relations.taskIdsByStory = {} + s.loading.loadedProductSprintsIds = {} + s.loading.loadingProductId = null + s.loading.loadedSprintIds = {} + s.loading.loadingSprintId = null + s.loading.loadedStoryIds = {} + s.loading.loadedTaskIds = {} + s.loading.activeRequestId = null + s.pendingMutations = {} + }) +} + +beforeEach(() => { + resetStore() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('SprintTaskDialogMount', () => { + it('rendert niets wanneer er geen active task is', () => { + const { container } = render( + , + ) + expect(container.textContent).toBe('') + }) + + it('rendert niets wanneer active task geen _detail heeft', () => { + useSprintWorkspaceStore.setState((s) => { + s.entities.tasksById['t1'] = { + id: 't1', + code: 'T-1', + title: 'Mijn taak', + description: null, + priority: 2, + sort_order: 1, + status: 'todo', + story_id: 'story-1', + sprint_id: 'sprint-1', + created_at: new Date(), + } + s.context.activeTaskId = 't1' + }) + + const { container } = render( + , + ) + expect(container.textContent).toBe('') + }) + + it('rendert TaskDialog met titel "Taak bewerken" wanneer detail aanwezig is', () => { + useSprintWorkspaceStore.setState((s) => { + s.entities.tasksById['t1'] = TASK_DETAIL + s.context.activeTaskId = 't1' + }) + + render() + + expect(screen.getByText('Taak bewerken')).toBeTruthy() + expect((screen.getByLabelText(/Titel/) as HTMLInputElement).value).toBe('Mijn taak') + }) + + it('clear activeTaskId wanneer Annuleren wordt geklikt', async () => { + useSprintWorkspaceStore.setState((s) => { + s.entities.tasksById['t1'] = TASK_DETAIL + s.context.activeTaskId = 't1' + }) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Annuleren' })) + + await waitFor(() => { + expect(useSprintWorkspaceStore.getState().context.activeTaskId).toBeNull() + }) + }) +}) diff --git a/__tests__/hooks/use-jobs-realtime.test.tsx b/__tests__/hooks/use-jobs-realtime.test.tsx new file mode 100644 index 0000000..49b9817 --- /dev/null +++ b/__tests__/hooks/use-jobs-realtime.test.tsx @@ -0,0 +1,147 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useJobsStore } from '@/stores/jobs-store' +import useJobsRealtime from '@/hooks/use-jobs-realtime' + +type Listener = (event: { data: string }) => void + +class MockEventSource { + static instance: MockEventSource | null = null + private listeners: Record = {} + onerror: (() => void) | null = null + + constructor(_url: string) { + MockEventSource.instance = this + } + + addEventListener(type: string, listener: Listener) { + if (!this.listeners[type]) this.listeners[type] = [] + this.listeners[type].push(listener) + } + + dispatch(type: string, data: unknown) { + for (const l of this.listeners[type] ?? []) { + l({ data: JSON.stringify(data) }) + } + } + + close() {} +} + +const fullJob = { + id: 'job-unknown-1', + kind: 'TASK_IMPLEMENTATION', + status: 'RUNNING', + taskCode: 'T-1', + taskTitle: 'Test', + ideaCode: null, + ideaTitle: null, + sprintGoal: null, + sprintCode: null, + productName: 'Scrum4Me', + productCode: null, + storyCode: null, + pbiCode: null, + modelId: null, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + costUsd: null, + branch: null, + prUrl: null, + error: null, + summary: null, + description: null, + verifyResult: null, + startedAt: null, + finishedAt: null, + createdAt: new Date('2026-01-01'), + sprintRunId: null, +} + +beforeEach(() => { + vi.stubGlobal('EventSource', MockEventSource) + MockEventSource.instance = null + + // Lege store + useJobsStore.setState({ activeJobs: [], doneJobs: [], selectedJobId: null }) + + // fetch resolveert naar de volledige job + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(async () => ({ + ok: true, + json: async () => fullJob, + })) + ) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('useJobsRealtime: fetch-on-unknown', () => { + it('haalt onbekende job op via REST bij message-event', async () => { + renderHook(() => useJobsRealtime()) + const es = MockEventSource.instance! + + // Dispatch twee events met hetzelfde onbekende job_id gelijktijdig + act(() => { + es.dispatch('message', { job_id: 'job-unknown-1', status: 'RUNNING' }) + es.dispatch('message', { job_id: 'job-unknown-1', status: 'RUNNING' }) + }) + + // Wacht op alle microtasks / fetch-promises + await act(async () => { + await Promise.resolve() + }) + + expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch).toHaveBeenCalledWith('/api/jobs/job-unknown-1') + + const { activeJobs } = useJobsStore.getState() + expect(activeJobs.some(j => j.id === 'job-unknown-1')).toBe(true) + expect(activeJobs.find(j => j.id === 'job-unknown-1')?.taskTitle).toBe('Test') + }) + + it('gebruikt partial-upsert voor bekende jobs bij message-event', async () => { + // Zet een bekende job in de store + useJobsStore.setState({ + activeJobs: [{ ...fullJob, id: 'job-known-1', status: 'QUEUED' } as never], + doneJobs: [], + selectedJobId: null, + }) + + renderHook(() => useJobsRealtime()) + const es = MockEventSource.instance! + + act(() => { + es.dispatch('message', { job_id: 'job-known-1', status: 'RUNNING', branch: 'feat/x' }) + }) + + await act(async () => { await Promise.resolve() }) + + expect(fetch).not.toHaveBeenCalled() + const { activeJobs } = useJobsStore.getState() + expect(activeJobs.find(j => j.id === 'job-known-1')?.status).toBe('RUNNING') + }) + + it('haalt onbekende job op via REST bij jobs_initial-event', async () => { + renderHook(() => useJobsRealtime()) + const es = MockEventSource.instance! + + act(() => { + es.dispatch('jobs_initial', [{ job_id: 'job-unknown-1', status: 'RUNNING' }]) + }) + + await act(async () => { await Promise.resolve() }) + + expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch).toHaveBeenCalledWith('/api/jobs/job-unknown-1') + const { activeJobs } = useJobsStore.getState() + expect(activeJobs.some(j => j.id === 'job-unknown-1')).toBe(true) + }) +}) diff --git a/__tests__/lib/active-sprint.test.ts b/__tests__/lib/active-sprint.test.ts new file mode 100644 index 0000000..b2de7ef --- /dev/null +++ b/__tests__/lib/active-sprint.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + sprint: { findFirst: vi.fn() }, + user: { + findUnique: vi.fn(), + update: vi.fn().mockResolvedValue({}), + }, + $executeRaw: vi.fn().mockResolvedValue(1), + }, +})) + +import { prisma } from '@/lib/prisma' +import type { UserSettings } from '@/lib/user-settings' +import { + clearActiveSprintInSettings, + readStoredActiveSprintState, + resolveActiveSprint, +} from '@/lib/active-sprint' + +const mockPrisma = prisma as unknown as { + sprint: { findFirst: ReturnType } + user: { + findUnique: ReturnType + update: ReturnType + } + $executeRaw: ReturnType +} + +function withSettings(settings: UserSettings) { + mockPrisma.user.findUnique.mockResolvedValueOnce({ settings }) +} + +describe('readStoredActiveSprintState', () => { + it('returns unset when activeSprints map is absent', () => { + expect(readStoredActiveSprintState({}, 'p1')).toEqual({ kind: 'unset' }) + }) + + it('returns unset when productId key is absent', () => { + const settings: UserSettings = { + layout: { activeSprints: { p2: 'sprint-2' } }, + } + expect(readStoredActiveSprintState(settings, 'p1')).toEqual({ + kind: 'unset', + }) + }) + + it('returns cleared when key is present with null value', () => { + const settings: UserSettings = { + layout: { activeSprints: { p1: null } }, + } + expect(readStoredActiveSprintState(settings, 'p1')).toEqual({ + kind: 'cleared', + }) + }) + + it('returns set when key is present with string value', () => { + const settings: UserSettings = { + layout: { activeSprints: { p1: 'sprint-1' } }, + } + expect(readStoredActiveSprintState(settings, 'p1')).toEqual({ + kind: 'set', + sprintId: 'sprint-1', + }) + }) +}) + +describe('resolveActiveSprint', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns null without fallback when key is explicitly null (cleared)', async () => { + withSettings({ layout: { activeSprints: { p1: null } } }) + + const result = await resolveActiveSprint('p1', 'user-1') + + expect(result).toBeNull() + expect(mockPrisma.sprint.findFirst).not.toHaveBeenCalled() + }) + + it('returns the stored sprint when key is set and sprint exists', async () => { + withSettings({ layout: { activeSprints: { p1: 'sprint-1' } } }) + mockPrisma.sprint.findFirst.mockResolvedValueOnce({ + id: 'sprint-1', + code: 'SP-1', + status: 'OPEN', + }) + + const result = await resolveActiveSprint('p1', 'user-1') + + expect(result).toEqual({ id: 'sprint-1', code: 'SP-1', status: 'OPEN' }) + expect(mockPrisma.sprint.findFirst).toHaveBeenCalledTimes(1) + }) + + it('falls back when stored sprint is not found in DB', async () => { + withSettings({ layout: { activeSprints: { p1: 'stale-id' } } }) + mockPrisma.sprint.findFirst + .mockResolvedValueOnce(null) // stored lookup misses + .mockResolvedValueOnce({ id: 'sprint-open', code: 'SP-O', status: 'OPEN' }) + + const result = await resolveActiveSprint('p1', 'user-1') + + expect(result).toEqual({ + id: 'sprint-open', + code: 'SP-O', + status: 'OPEN', + }) + }) + + it('falls back to first OPEN sprint when key is absent', async () => { + withSettings({}) + mockPrisma.sprint.findFirst.mockResolvedValueOnce({ + id: 'sprint-open', + code: 'SP-O', + status: 'OPEN', + }) + + const result = await resolveActiveSprint('p1', 'user-1') + + expect(result).toEqual({ + id: 'sprint-open', + code: 'SP-O', + status: 'OPEN', + }) + }) + + it('falls back to recent CLOSED sprint when no OPEN exists', async () => { + withSettings({}) + mockPrisma.sprint.findFirst + .mockResolvedValueOnce(null) // no OPEN + .mockResolvedValueOnce({ + id: 'sprint-closed', + code: 'SP-C', + status: 'CLOSED', + }) + + const result = await resolveActiveSprint('p1', 'user-1') + + expect(result).toEqual({ + id: 'sprint-closed', + code: 'SP-C', + status: 'CLOSED', + }) + }) + + it('returns null when key absent and no sprints exist', async () => { + withSettings({}) + mockPrisma.sprint.findFirst.mockResolvedValue(null) + + const result = await resolveActiveSprint('p1', 'user-1') + + expect(result).toBeNull() + }) +}) + +describe('clearActiveSprintInSettings', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('writes null instead of deleting the key', async () => { + withSettings({ + layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } }, + }) + + await clearActiveSprintInSettings('user-1', 'p1') + + expect(mockPrisma.user.update).toHaveBeenCalledTimes(1) + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: UserSettings } + } + expect(updateArg.data.settings.layout?.activeSprints).toEqual({ + p1: null, + p2: 'sprint-2', + }) + }) + + it('adds the key with null when previously unset', async () => { + withSettings({}) + + await clearActiveSprintInSettings('user-1', 'p1') + + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: UserSettings } + } + expect(updateArg.data.settings.layout?.activeSprints).toEqual({ p1: null }) + }) +}) diff --git a/__tests__/lib/auth-guard.test.ts b/__tests__/lib/auth-guard.test.ts index b162921..ebfa9a5 100644 --- a/__tests__/lib/auth-guard.test.ts +++ b/__tests__/lib/auth-guard.test.ts @@ -3,10 +3,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' const getSessionMock = vi.fn() const isPairedSessionExpiredMock = vi.fn() const redirectMock = vi.fn(() => { throw new Error('REDIRECT_CALLED') }) +const prismaUserRoleFindFirstMock = vi.fn() vi.mock('@/lib/auth', () => ({ getSession: getSessionMock })) vi.mock('@/lib/auth/pairing', () => ({ isPairedSessionExpired: isPairedSessionExpiredMock })) vi.mock('next/navigation', () => ({ redirect: redirectMock })) +vi.mock('@/lib/prisma', () => ({ + prisma: { userRole: { findFirst: prismaUserRoleFindFirstMock } }, +})) describe('requireSession', () => { beforeEach(() => { diff --git a/__tests__/lib/chart-colors.test.ts b/__tests__/lib/chart-colors.test.ts index b8d0be2..dc316bd 100644 --- a/__tests__/lib/chart-colors.test.ts +++ b/__tests__/lib/chart-colors.test.ts @@ -34,7 +34,7 @@ describe('chart-colors', () => { it('JOB_STATUS_COLORS has all ClaudeJobStatus keys and non-empty values', () => { const keys: (keyof typeof JOB_STATUS_COLORS)[] = [ - 'queued', 'claimed', 'running', 'done', 'failed', 'cancelled', + 'queued', 'claimed', 'running', 'done', 'failed', 'cancelled', 'skipped', ] for (const key of keys) { expect(JOB_STATUS_COLORS[key]).toBeTruthy() diff --git a/__tests__/lib/code.test.ts b/__tests__/lib/code.test.ts new file mode 100644 index 0000000..7b83640 --- /dev/null +++ b/__tests__/lib/code.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' + +import { parseCodeNumber } from '@/lib/code' + +describe('parseCodeNumber', () => { + it('parses a standard story code', () => { + expect(parseCodeNumber('ST-001')).toBe(1) + }) + + it('parses a task code', () => { + expect(parseCodeNumber('T-42')).toBe(42) + }) + + it('parses a large number', () => { + expect(parseCodeNumber('ST-1000')).toBe(1000) + }) + + it('returns MAX_SAFE_INTEGER for a code with no trailing digits', () => { + expect(parseCodeNumber('FOO')).toBe(Number.MAX_SAFE_INTEGER) + }) + + it('returns MAX_SAFE_INTEGER for an empty string', () => { + expect(parseCodeNumber('')).toBe(Number.MAX_SAFE_INTEGER) + }) +}) diff --git a/__tests__/lib/debug.test.ts b/__tests__/lib/debug.test.ts new file mode 100644 index 0000000..12a1e33 --- /dev/null +++ b/__tests__/lib/debug.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, vi } from 'vitest' + +import { debugProps } from '@/lib/debug' + +describe('debugProps', () => { + it('returns data-debug-id attr in dev mode', () => { + const result = debugProps('sprint-board', 'SprintBoard', 'components/sprint/sprint-board.tsx') + expect(result).toEqual({ + 'data-debug-id': 'sprint-board', + }) + }) + + it('returns empty object in production mode', () => { + const original = process.env.NODE_ENV + try { + vi.stubEnv('NODE_ENV', 'production') + const result = debugProps('sprint-board', 'SprintBoard', 'components/sprint/sprint-board.tsx') + expect(result).toEqual({}) + } finally { + vi.stubEnv('NODE_ENV', original ?? 'test') + } + }) +}) diff --git a/__tests__/lib/idea-code.test.ts b/__tests__/lib/idea-code.test.ts new file mode 100644 index 0000000..f0a9150 --- /dev/null +++ b/__tests__/lib/idea-code.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest' + +import { formatIdeaCode } from '@/lib/idea-code' + +describe('formatIdeaCode', () => { + it('pads to 3 digits', () => { + expect(formatIdeaCode(1)).toBe('IDEA-001') + expect(formatIdeaCode(42)).toBe('IDEA-042') + expect(formatIdeaCode(999)).toBe('IDEA-999') + }) + + it('does not truncate beyond pad-width', () => { + expect(formatIdeaCode(1000)).toBe('IDEA-1000') + expect(formatIdeaCode(99999)).toBe('IDEA-99999') + }) +}) + +// Integration-style concurrency-test op nextIdeaCode is in +// __tests__/integration/ tests die de echte DB raken (zie M12 verificatie-stap). +// Hier alleen de pure formatter; de increment-logica leunt op Prisma's +// row-lock in $transaction die we per-database vertrouwen. diff --git a/__tests__/lib/idea-plan-parser.test.ts b/__tests__/lib/idea-plan-parser.test.ts new file mode 100644 index 0000000..c279ea8 --- /dev/null +++ b/__tests__/lib/idea-plan-parser.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest' + +import { parsePlanMd } from '@/lib/idea-plan-parser' + +const VALID = `--- +pbi: + title: Test PBI + priority: 2 +stories: + - title: Eerste flow + priority: 2 + tasks: + - title: Setup + priority: 2 + implementation_plan: | + 1. Doe X + 2. Doe Y +--- + +# Overwegingen + +Dit is de body, niet geparsed. +` + +describe('parsePlanMd', () => { + it('parses a valid plan', () => { + const r = parsePlanMd(VALID) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.plan.pbi.title).toBe('Test PBI') + expect(r.plan.stories).toHaveLength(1) + expect(r.plan.stories[0].tasks).toHaveLength(1) + expect(r.plan.stories[0].tasks[0].implementation_plan).toContain('Doe X') + expect(r.body).toContain('# Overwegingen') + } + }) + + it('rejects when frontmatter is missing', () => { + const r = parsePlanMd('# Just markdown\n\nNo frontmatter here.') + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.errors[0].line).toBe(1) + expect(r.errors[0].message).toMatch(/frontmatter/i) + } + }) + + it('reports yaml syntax error with line info', () => { + const broken = `--- +pbi: + title: Test + priority: [unclosed +stories: + - foo +--- + +body +` + const r = parsePlanMd(broken) + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.errors[0].message.length).toBeGreaterThan(0) + } + }) + + it('hints when markdown sneaks into frontmatter', () => { + // "1. **...**: [unclosed" triggers a YAMLParseError at the markdown line + // (plain-list-with-bold parses as valid YAML without an unclosed flow) + const broken = `--- +pbi: + title: Test + priority: 2 +stories: +1. **Toggle zichtbaar in productie**: [unclosed +--- + +body +` + const r = parsePlanMd(broken) + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.errors[0].hint).toMatch(/markdown/i) + expect(r.errors[0].line).toBeGreaterThan(1) + } + }) + + it('omits hint for non-markdown yaml errors', () => { + const broken = `--- +pbi: + title: Test + priority: [unclosed +stories: + - foo +--- +` + const r = parsePlanMd(broken) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.errors[0].hint).toBeUndefined() + }) + + it('reports schema-validation error when pbi-section missing', () => { + const noPbi = `--- +stories: + - title: x + priority: 2 + tasks: + - title: y + priority: 2 +--- + +body +` + const r = parsePlanMd(noPbi) + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.errors.some((e) => e.message.includes('pbi'))).toBe(true) + } + }) + + it('rejects empty stories array', () => { + const noStories = `--- +pbi: + title: x + priority: 2 +stories: [] +--- + +body +` + const r = parsePlanMd(noStories) + expect(r.ok).toBe(false) + }) + + it('handles CRLF line endings', () => { + const crlf = VALID.replace(/\n/g, '\r\n') + const r = parsePlanMd(crlf) + expect(r.ok).toBe(true) + }) +}) diff --git a/__tests__/lib/idea-schemas.test.ts b/__tests__/lib/idea-schemas.test.ts new file mode 100644 index 0000000..637ce1c --- /dev/null +++ b/__tests__/lib/idea-schemas.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest' + +import { + ideaCreateSchema, + ideaUpdateSchema, + ideaPlanMdFrontmatterSchema, +} from '@/lib/schemas/idea' + +describe('ideaCreateSchema', () => { + it('accepts minimal valid input', () => { + const r = ideaCreateSchema.safeParse({ title: 'Plant-watering reminder' }) + expect(r.success).toBe(true) + }) + + it('trims and enforces non-empty title', () => { + const r = ideaCreateSchema.safeParse({ title: ' ' }) + expect(r.success).toBe(false) + }) + + it('rejects oversized title and description', () => { + expect(ideaCreateSchema.safeParse({ title: 'x'.repeat(201) }).success).toBe(false) + expect( + ideaCreateSchema.safeParse({ title: 'ok', description: 'x'.repeat(4001) }).success, + ).toBe(false) + }) + + it('accepts cuid-like product_id', () => { + const r = ideaCreateSchema.safeParse({ + title: 'Idee', + product_id: 'cmohrysyj0000rd17clnjy4tc', + }) + expect(r.success).toBe(true) + }) + + it('rejects non-cuid product_id', () => { + const r = ideaCreateSchema.safeParse({ title: 'Idee', product_id: 'not-a-cuid' }) + expect(r.success).toBe(false) + }) +}) + +describe('ideaUpdateSchema', () => { + it('allows empty object (no-op update)', () => { + expect(ideaUpdateSchema.safeParse({}).success).toBe(true) + }) + + it('allows partial title update', () => { + expect(ideaUpdateSchema.safeParse({ title: 'Updated' }).success).toBe(true) + }) +}) + +describe('ideaPlanMdFrontmatterSchema', () => { + const validPlan = { + pbi: { title: 'Test PBI', priority: 2 }, + stories: [ + { + title: 'Eerste flow', + priority: 2, + tasks: [ + { title: 'Setup', priority: 2, implementation_plan: '1. Doe X' }, + ], + }, + ], + } + + it('accepts a minimal valid plan', () => { + expect(ideaPlanMdFrontmatterSchema.safeParse(validPlan).success).toBe(true) + }) + + it('requires at least one story', () => { + const r = ideaPlanMdFrontmatterSchema.safeParse({ ...validPlan, stories: [] }) + expect(r.success).toBe(false) + }) + + it('requires at least one task per story', () => { + const r = ideaPlanMdFrontmatterSchema.safeParse({ + ...validPlan, + stories: [{ ...validPlan.stories[0], tasks: [] }], + }) + expect(r.success).toBe(false) + }) + + it('validates priority bounds 1-4', () => { + expect( + ideaPlanMdFrontmatterSchema.safeParse({ + ...validPlan, + pbi: { ...validPlan.pbi, priority: 5 }, + }).success, + ).toBe(false) + expect( + ideaPlanMdFrontmatterSchema.safeParse({ + ...validPlan, + pbi: { ...validPlan.pbi, priority: 0 }, + }).success, + ).toBe(false) + }) + + it('accepts optional verify_required + verify_only', () => { + const r = ideaPlanMdFrontmatterSchema.safeParse({ + ...validPlan, + stories: [ + { + ...validPlan.stories[0], + tasks: [ + { + title: 'Verify-only task', + priority: 2, + verify_required: 'ALIGNED_OR_PARTIAL', + verify_only: true, + }, + ], + }, + ], + }) + expect(r.success).toBe(true) + }) + + it('rejects invalid verify_required enum', () => { + const r = ideaPlanMdFrontmatterSchema.safeParse({ + ...validPlan, + stories: [ + { + ...validPlan.stories[0], + tasks: [ + { title: 't', priority: 2, verify_required: 'INVALID' }, + ], + }, + ], + }) + expect(r.success).toBe(false) + }) + + it('accepts plan with task.priority omitted (inherits story-priority via materialize)', () => { + const r = ideaPlanMdFrontmatterSchema.safeParse({ + ...validPlan, + stories: [ + { + title: 'Story zonder task-priorities', + priority: 2, + tasks: [ + { title: 'Taak 1' }, // geen priority — moet geaccepteerd + { title: 'Taak 2', verify_required: 'ALIGNED' }, + ], + }, + ], + }) + expect(r.success).toBe(true) + }) +}) diff --git a/__tests__/lib/idea-status.test.ts b/__tests__/lib/idea-status.test.ts new file mode 100644 index 0000000..b72692c --- /dev/null +++ b/__tests__/lib/idea-status.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest' + +import { + ideaStatusToApi, + ideaStatusFromApi, + canTransition, + isIdeaEditable, + isGrillMdEditable, + isPlanMdEditable, + IDEA_STATUS_API_VALUES, +} from '@/lib/idea-status' + +describe('idea-status mappers', () => { + it('round-trips every API value', () => { + for (const api of IDEA_STATUS_API_VALUES) { + const db = ideaStatusFromApi(api) + expect(db).not.toBeNull() + expect(ideaStatusToApi(db!)).toBe(api) + } + }) + + it('returns null for invalid input', () => { + expect(ideaStatusFromApi('NOT_A_STATUS')).toBeNull() + }) + + it('is case-insensitive on the API side', () => { + expect(ideaStatusFromApi('PLAN_READY')).toBe('PLAN_READY') + expect(ideaStatusFromApi('Plan_Ready')).toBe('PLAN_READY') + }) +}) + +describe('canTransition', () => { + it('allows valid forward transitions', () => { + expect(canTransition('DRAFT', 'GRILLING')).toBe(true) + expect(canTransition('GRILLING', 'GRILLED')).toBe(true) + expect(canTransition('GRILLED', 'PLANNING')).toBe(true) + expect(canTransition('PLANNING', 'PLAN_READY')).toBe(true) + expect(canTransition('PLAN_READY', 'PLANNED')).toBe(true) + }) + + it('allows re-grill from GRILLED and PLAN_READY-ish states', () => { + expect(canTransition('GRILLED', 'GRILLING')).toBe(true) + expect(canTransition('PLAN_FAILED', 'PLANNING')).toBe(true) + expect(canTransition('PLAN_READY', 'GRILLING')).toBe(true) + }) + + it('allows fail-side transitions', () => { + expect(canTransition('GRILLING', 'GRILL_FAILED')).toBe(true) + expect(canTransition('PLANNING', 'PLAN_FAILED')).toBe(true) + }) + + it('allows recovery from failed states', () => { + expect(canTransition('GRILL_FAILED', 'GRILLING')).toBe(true) + expect(canTransition('PLAN_FAILED', 'GRILLED')).toBe(true) + }) + + it('allows PLANNED → PLAN_READY (relink) and PLANNED → GRILLING (re-grill)', () => { + expect(canTransition('PLANNED', 'PLAN_READY')).toBe(true) + expect(canTransition('PLANNED', 'GRILLING')).toBe(true) + expect(canTransition('PLANNED', 'DRAFT')).toBe(false) + }) + + it('canTransition to GRILLING from all statuses that allow re-grill', () => { + // GRILL_TRIGGERABLE_FROM in actions/ideas.ts — alle statussen die re-grill ondersteunen. + const regrill = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLAN_READY', 'PLANNED'] as const + for (const status of regrill) { + expect(canTransition(status, 'GRILLING')).toBe(true) + } + }) + + it('rejects invalid jumps', () => { + expect(canTransition('DRAFT', 'PLANNED')).toBe(false) + expect(canTransition('DRAFT', 'PLAN_READY')).toBe(false) + expect(canTransition('GRILLING', 'PLANNED')).toBe(false) + }) +}) + +describe('isIdeaEditable', () => { + it('allows edit in non-running, non-PLANNED states', () => { + expect(isIdeaEditable('DRAFT')).toBe(true) + expect(isIdeaEditable('GRILLED')).toBe(true) + expect(isIdeaEditable('GRILL_FAILED')).toBe(true) + expect(isIdeaEditable('PLAN_FAILED')).toBe(true) + expect(isIdeaEditable('PLAN_READY')).toBe(true) + }) + + it('blocks edit while a job is running or after PLANNED', () => { + expect(isIdeaEditable('GRILLING')).toBe(false) + expect(isIdeaEditable('PLANNING')).toBe(false) + expect(isIdeaEditable('PLANNED')).toBe(false) + }) +}) + +describe('isGrillMdEditable / isPlanMdEditable', () => { + it('grill_md only editable in GRILLED or PLAN_READY', () => { + expect(isGrillMdEditable('GRILLED')).toBe(true) + expect(isGrillMdEditable('PLAN_READY')).toBe(true) + expect(isGrillMdEditable('DRAFT')).toBe(false) + expect(isGrillMdEditable('PLANNED')).toBe(false) + }) + + it('plan_md only editable in PLAN_READY', () => { + expect(isPlanMdEditable('PLAN_READY')).toBe(true) + expect(isPlanMdEditable('GRILLED')).toBe(false) + expect(isPlanMdEditable('PLAN_FAILED')).toBe(false) + expect(isPlanMdEditable('PLANNED')).toBe(false) + }) +}) diff --git a/__tests__/lib/insights/agent-throughput.test.ts b/__tests__/lib/insights/agent-throughput.test.ts index 3465dd4..31bf46d 100644 --- a/__tests__/lib/insights/agent-throughput.test.ts +++ b/__tests__/lib/insights/agent-throughput.test.ts @@ -48,7 +48,7 @@ describe('getJobsPerDay', () => { // All days should have zero counts except the three we seeded const nonZero = result.perDay.filter( - d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled > 0, + d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled + d.skipped > 0, ) expect(nonZero).toHaveLength(3) diff --git a/__tests__/lib/insights/token-history.test.ts b/__tests__/lib/insights/token-history.test.ts new file mode 100644 index 0000000..39439b8 --- /dev/null +++ b/__tests__/lib/insights/token-history.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockQueryRaw } = vi.hoisted(() => ({ mockQueryRaw: vi.fn() })) + +vi.mock('@/lib/prisma', () => ({ + prisma: { $queryRaw: mockQueryRaw }, +})) + +import { + getSprintTokenHistory, + getDayTokenData, + getPbiTokenAggregates, +} from '@/lib/insights/token-history' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('getSprintTokenHistory', () => { + it('returns mapped sprint rows', async () => { + mockQueryRaw.mockResolvedValueOnce([ + { sprint_id: 'sp-1', sprint_goal: 'Goal A', total_tokens: BigInt(5000), total_cost: 0.1, job_count: BigInt(2) }, + ]) + const rows = await getSprintTokenHistory('user-1') + expect(rows).toHaveLength(1) + expect(rows[0].sprintId).toBe('sp-1') + expect(rows[0].totalTokens).toBe(5000) + expect(rows[0].totalCostUsd).toBe(0.1) + expect(rows[0].jobCount).toBe(2) + }) + + it('returns zero cost when total_cost is null', async () => { + mockQueryRaw.mockResolvedValueOnce([ + { sprint_id: 'sp-2', sprint_goal: 'Goal B', total_tokens: BigInt(0), total_cost: null, job_count: BigInt(0) }, + ]) + const rows = await getSprintTokenHistory('user-1') + expect(rows[0].totalCostUsd).toBe(0) + }) +}) + +describe('getDayTokenData', () => { + it('returns empty array for empty sprintId', async () => { + const rows = await getDayTokenData('user-1', '') + expect(rows).toHaveLength(0) + expect(mockQueryRaw).not.toHaveBeenCalled() + }) + + it('maps day rows with ISO date string', async () => { + mockQueryRaw.mockResolvedValueOnce([ + { day: new Date('2026-05-01T00:00:00Z'), total_tokens: BigInt(2000), total_cost: 0.05 }, + ]) + const rows = await getDayTokenData('user-1', 'sprint-1') + expect(rows).toHaveLength(1) + expect(rows[0].day).toBe('2026-05-01') + expect(rows[0].totalTokens).toBe(2000) + }) +}) + +describe('getPbiTokenAggregates', () => { + it('returns empty array for empty sprintId', async () => { + const rows = await getPbiTokenAggregates('user-1', '') + expect(rows).toHaveLength(0) + expect(mockQueryRaw).not.toHaveBeenCalled() + }) + + it('maps pbi rows', async () => { + mockQueryRaw.mockResolvedValueOnce([ + { pbi_id: 'pbi-1', pbi_code: 'M1', pbi_title: 'First PBI', total_tokens: BigInt(3000), total_cost: 0.08 }, + ]) + const rows = await getPbiTokenAggregates('user-1', 'sprint-1') + expect(rows[0].pbiCode).toBe('M1') + expect(rows[0].totalTokens).toBe(3000) + }) +}) diff --git a/__tests__/lib/insights/token-stats.test.ts b/__tests__/lib/insights/token-stats.test.ts new file mode 100644 index 0000000..8614292 --- /dev/null +++ b/__tests__/lib/insights/token-stats.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockQueryRaw } = vi.hoisted(() => ({ mockQueryRaw: vi.fn() })) + +vi.mock('@/lib/prisma', () => ({ + prisma: { $queryRaw: mockQueryRaw }, +})) + +import { getTokenStats } from '@/lib/insights/token-stats' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('getTokenStats', () => { + it('returns empty result for empty sprintId', async () => { + const result = await getTokenStats('user-1', '') + + expect(result.kpi.totalTokens).toBe(0) + expect(result.kpi.totalCostUsd).toBe(0) + expect(result.kpi.avgCostPerJob).toBe(0) + expect(result.kpi.jobCount).toBe(0) + expect(result.jobs).toHaveLength(0) + expect(mockQueryRaw).not.toHaveBeenCalled() + }) + + it('maps kpi rows correctly', async () => { + const kpiRows = [{ total_tokens: BigInt(10000), total_cost: 0.15, avg_cost: 0.05, job_count: BigInt(3) }] + const jobRows: unknown[] = [] + mockQueryRaw.mockResolvedValueOnce(kpiRows).mockResolvedValueOnce(jobRows) + + const result = await getTokenStats('user-1', 'sprint-1') + + expect(result.kpi.totalTokens).toBe(10000) + expect(result.kpi.totalCostUsd).toBe(0.15) + expect(result.kpi.avgCostPerJob).toBe(0.05) + expect(result.kpi.jobCount).toBe(3) + }) + + it('maps job rows and handles null token data', async () => { + const kpiRows = [{ total_tokens: BigInt(0), total_cost: null, avg_cost: null, job_count: BigInt(0) }] + const jobRows = [ + { + job_id: 'job-1', + task_title: 'My Task', + idea_code: null, + model_id: 'claude-sonnet-4-6', + input_tokens: null, + output_tokens: null, + cache_read_tokens: null, + cache_write_tokens: null, + cost_usd: null, + duration_seconds: 42, + }, + ] + mockQueryRaw.mockResolvedValueOnce(kpiRows).mockResolvedValueOnce(jobRows) + + const result = await getTokenStats('user-1', 'sprint-1') + + expect(result.jobs).toHaveLength(1) + const job = result.jobs[0] + expect(job.jobId).toBe('job-1') + expect(job.taskTitle).toBe('My Task') + expect(job.costUsd).toBeNull() + expect(job.durationSeconds).toBe(42) + }) +}) diff --git a/__tests__/lib/job-config.test.ts b/__tests__/lib/job-config.test.ts new file mode 100644 index 0000000..16b90b5 --- /dev/null +++ b/__tests__/lib/job-config.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest' +import { + getKindDefault, + resolveJobConfig, + mapBudgetToEffort, +} from '@/lib/job-config' + +describe('mapBudgetToEffort', () => { + it.each([ + [0, null], + [-1, null], + [1, 'medium'], + [3000, 'medium'], + [6000, 'medium'], + [6001, 'high'], + [9000, 'high'], + [12000, 'high'], + [12001, 'xhigh'], + [18000, 'xhigh'], + [24000, 'xhigh'], + [24001, 'max'], + [50000, 'max'], + [100000, 'max'], + ])('budget %i → %s', (budget, expected) => { + expect(mapBudgetToEffort(budget)).toBe(expected) + }) +}) + +describe('KIND_DEFAULTS.allowed_tools — sync met scrum4me-mcp', () => { + it('TASK_IMPLEMENTATION bevat geen claim-tools', () => { + const cfg = getKindDefault('TASK_IMPLEMENTATION') + expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job') + expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__check_queue_empty') + expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__get_idea_context') + }) + + it('TASK_IMPLEMENTATION bevat de essentiële task-tools', () => { + const cfg = getKindDefault('TASK_IMPLEMENTATION') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_status') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_task_against_plan') + expect(cfg.allowed_tools).toContain('Bash') + expect(cfg.allowed_tools).toContain('Edit') + expect(cfg.allowed_tools).toContain('Write') + }) + + it('SPRINT_IMPLEMENTATION bevat sprint-specifieke tools maar GEEN job_heartbeat (runner doet die)', () => { + const cfg = getKindDefault('SPRINT_IMPLEMENTATION') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_execution') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_sprint_task') + expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__job_heartbeat') + }) + + it('IDEA_GRILL bevat update_idea_grill_md en geen wait_for_job', () => { + const cfg = getKindDefault('IDEA_GRILL') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_grill_md') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status') + expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job') + }) + + it('IDEA_MAKE_PLAN bevat update_idea_plan_md en geen wait_for_job', () => { + const cfg = getKindDefault('IDEA_MAKE_PLAN') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_plan_md') + expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision') + expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job') + }) + + it('alle kinds hebben non-null allowed_tools', () => { + for (const kind of [ + 'IDEA_GRILL', + 'IDEA_MAKE_PLAN', + 'PLAN_CHAT', + 'TASK_IMPLEMENTATION', + 'SPRINT_IMPLEMENTATION', + ]) { + const cfg = getKindDefault(kind) + expect(cfg.allowed_tools).not.toBeNull() + expect(Array.isArray(cfg.allowed_tools)).toBe(true) + } + }) +}) + +describe('resolveJobConfig — cascade (regression)', () => { + it('task.requires_opus overrult product.preferred_model', () => { + const cfg = resolveJobConfig( + { kind: 'TASK_IMPLEMENTATION' }, + { preferred_model: 'claude-sonnet-4-6' }, + { requires_opus: true }, + ) + expect(cfg.model).toBe('claude-opus-4-7') + }) + + it('product.preferred_permission_mode overrult bypassPermissions', () => { + const cfg = resolveJobConfig( + { kind: 'TASK_IMPLEMENTATION' }, + { preferred_permission_mode: 'acceptEdits' }, + ) + expect(cfg.permission_mode).toBe('acceptEdits') + }) +}) diff --git a/__tests__/lib/job-status.test.ts b/__tests__/lib/job-status.test.ts index db8d1ab..dee082e 100644 --- a/__tests__/lib/job-status.test.ts +++ b/__tests__/lib/job-status.test.ts @@ -27,13 +27,14 @@ describe('job-status mappers', () => { expect(jobStatusFromApi('QUEUED')).toBe('QUEUED') }) - it('maps all 6 DB statuses to API', () => { + it('maps all 7 DB statuses to API', () => { expect(jobStatusToApi('QUEUED')).toBe('queued') expect(jobStatusToApi('CLAIMED')).toBe('claimed') expect(jobStatusToApi('RUNNING')).toBe('running') expect(jobStatusToApi('DONE')).toBe('done') expect(jobStatusToApi('FAILED')).toBe('failed') expect(jobStatusToApi('CANCELLED')).toBe('cancelled') + expect(jobStatusToApi('SKIPPED')).toBe('skipped') }) it('ACTIVE_JOB_STATUSES contains exactly QUEUED, CLAIMED, RUNNING', () => { diff --git a/__tests__/lib/jobs-time-filter.test.ts b/__tests__/lib/jobs-time-filter.test.ts new file mode 100644 index 0000000..3e1be4b --- /dev/null +++ b/__tests__/lib/jobs-time-filter.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' + +import { isWithinTimeWindow } from '@/lib/jobs-time-filter' + +const HOUR_MS = 60 * 60 * 1000 + +describe('isWithinTimeWindow', () => { + it("returns true for filter='all' regardless of age", () => { + const old = new Date(0) + expect(isWithinTimeWindow(old, 'all')).toBe(true) + }) + + describe("filter='1h'", () => { + const now = Date.now() + + it('returns true for a job created 30 minutes ago', () => { + const createdAt = new Date(now - 30 * 60 * 1000) + expect(isWithinTimeWindow(createdAt, '1h', now)).toBe(true) + }) + + it('returns false for a job created 90 minutes ago', () => { + const createdAt = new Date(now - 90 * 60 * 1000) + expect(isWithinTimeWindow(createdAt, '1h', now)).toBe(false) + }) + }) + + describe("filter='24h'", () => { + const now = Date.now() + + it('returns true for a job created 23 hours ago', () => { + const createdAt = new Date(now - 23 * HOUR_MS) + expect(isWithinTimeWindow(createdAt, '24h', now)).toBe(true) + }) + + it('returns false for a job created 25 hours ago', () => { + const createdAt = new Date(now - 25 * HOUR_MS) + expect(isWithinTimeWindow(createdAt, '24h', now)).toBe(false) + }) + }) + + describe('accepts both Date and ISO string for createdAt', () => { + const now = Date.now() + const recent = new Date(now - 30 * 60 * 1000) + + it('accepts a Date object', () => { + expect(isWithinTimeWindow(recent, '1h', now)).toBe(true) + }) + + it('accepts an ISO string', () => { + expect(isWithinTimeWindow(recent.toISOString(), '1h', now)).toBe(true) + }) + }) + + it('returns true for an invalid date string (fail-open)', () => { + expect(isWithinTimeWindow('not-a-date', '1h')).toBe(true) + }) +}) diff --git a/__tests__/lib/product-switch-path.test.ts b/__tests__/lib/product-switch-path.test.ts new file mode 100644 index 0000000..02983e9 --- /dev/null +++ b/__tests__/lib/product-switch-path.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest' +import { resolveProductSwitchTarget } from '@/lib/product-switch-path' + +describe('resolveProductSwitchTarget', () => { + it('returns null for non-product pages', () => { + expect(resolveProductSwitchTarget('/dashboard', 'new-id')).toBeNull() + expect(resolveProductSwitchTarget('/insights', 'new-id')).toBeNull() + expect(resolveProductSwitchTarget('/ideas', 'new-id')).toBeNull() + expect(resolveProductSwitchTarget('/jobs', 'new-id')).toBeNull() + expect(resolveProductSwitchTarget('/', 'new-id')).toBeNull() + }) + + it('maps /products/ to /products/', () => { + expect(resolveProductSwitchTarget('/products/old-id', 'new-id')).toBe('/products/new-id') + }) + + it('maps /products// to /products/', () => { + expect(resolveProductSwitchTarget('/products/old-id/', 'new-id')).toBe('/products/new-id') + }) + + it('maps /products//sprint to /products//sprint', () => { + expect(resolveProductSwitchTarget('/products/old-id/sprint', 'new-id')).toBe( + '/products/new-id/sprint', + ) + }) + + it('maps /products//sprint/ to /products//sprint', () => { + expect(resolveProductSwitchTarget('/products/old-id/sprint/abc123', 'new-id')).toBe( + '/products/new-id/sprint', + ) + }) + + it('maps /products//sprint/.../planning to /products//sprint', () => { + expect(resolveProductSwitchTarget('/products/old-id/sprint/abc123/planning', 'new-id')).toBe( + '/products/new-id/sprint', + ) + }) + + it('maps /products//solo to /products//solo', () => { + expect(resolveProductSwitchTarget('/products/old-id/solo', 'new-id')).toBe( + '/products/new-id/solo', + ) + }) + + it('falls back to /products/ for /products//settings', () => { + expect(resolveProductSwitchTarget('/products/old-id/settings', 'new-id')).toBe( + '/products/new-id', + ) + }) + + it('falls back to /products/ for unknown sub-segments', () => { + expect(resolveProductSwitchTarget('/products/old-id/unknown/deep', 'new-id')).toBe( + '/products/new-id', + ) + }) +}) diff --git a/__tests__/lib/push-client.test.ts b/__tests__/lib/push-client.test.ts new file mode 100644 index 0000000..761b6e1 --- /dev/null +++ b/__tests__/lib/push-client.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from 'vitest' + +vi.mock('@/actions/push', () => ({ + subscribeToPushAction: vi.fn(), + unsubscribeFromPushAction: vi.fn(), +})) + +import { urlBase64ToUint8Array } from '@/lib/push-client' + +describe('urlBase64ToUint8Array', () => { + it('converts a base64url-encoded VAPID public key to Uint8Array', () => { + // 65-byte uncompressed EC public key encoded as base64url (no padding) + const base64url = 'BNMxB-LJm6XvGGiJSsYLdumcYiM7q9s_1aM9i5lI8lVzZ7GYJw1QkQFmrknwFsI4dI-e1iyvUhYHjNpHJKJD3oc' + const result = urlBase64ToUint8Array(base64url) + expect(result).toBeInstanceOf(Uint8Array) + expect(result.length).toBe(65) + expect(result[0]).toBe(0x04) // uncompressed EC point prefix + }) + + it('handles base64url with padding', () => { + // simple known vector: "hello" = aGVsbG8= in base64 + const result = urlBase64ToUint8Array('aGVsbG8') + expect(result).toBeInstanceOf(Uint8Array) + expect(Array.from(result)).toEqual([104, 101, 108, 108, 111]) // "hello" + }) + + it('converts - and _ characters correctly', () => { + // base64url uses - and _ instead of + and / + const base64standard = 'AB+/AA==' + const base64url = 'AB-_AA' + const fromStd = urlBase64ToUint8Array(base64standard) + const fromUrl = urlBase64ToUint8Array(base64url) + expect(Array.from(fromStd)).toEqual(Array.from(fromUrl)) + }) +}) diff --git a/__tests__/lib/push-server.test.ts b/__tests__/lib/push-server.test.ts new file mode 100644 index 0000000..87af039 --- /dev/null +++ b/__tests__/lib/push-server.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('server-only', () => ({})) + +const { mockSendNotification } = vi.hoisted(() => ({ + mockSendNotification: vi.fn(), +})) + +vi.mock('web-push', () => ({ + default: { + setVapidDetails: vi.fn(), + sendNotification: mockSendNotification, + }, +})) + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY = 'pk' + process.env.VAPID_PRIVATE_KEY = 'sk' + process.env.VAPID_SUBJECT = 'mailto:test@example.com' +}) + +const { mockPushSubscription } = vi.hoisted(() => ({ + mockPushSubscription: { + findMany: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { pushSubscription: mockPushSubscription }, +})) + +import { sendPushToUser } from '@/lib/push-server' + +const SUB = { id: 'sub-1', endpoint: 'https://push.example.com/1', p256dh: 'p256dh', auth: 'auth' } +const PAYLOAD = { title: 'Test', body: 'Body', url: '/test' } + +beforeEach(() => { + vi.clearAllMocks() + mockPushSubscription.findMany.mockResolvedValue([SUB]) + mockPushSubscription.update.mockResolvedValue(SUB) + mockPushSubscription.delete.mockResolvedValue(SUB) +}) + +describe('sendPushToUser', () => { + it('sends notification and updates last_used_at on success', async () => { + mockSendNotification.mockResolvedValue({ statusCode: 201 }) + await sendPushToUser('user-1', PAYLOAD) + expect(mockSendNotification).toHaveBeenCalledOnce() + expect(mockPushSubscription.update).toHaveBeenCalledWith({ + where: { id: SUB.id }, + data: { last_used_at: expect.any(Date) }, + }) + }) + + it('deletes subscription on 410 (expired)', async () => { + mockSendNotification.mockRejectedValue({ statusCode: 410 }) + await sendPushToUser('user-1', PAYLOAD) + expect(mockPushSubscription.delete).toHaveBeenCalledWith({ where: { id: SUB.id } }) + expect(mockPushSubscription.update).not.toHaveBeenCalled() + }) + + it('deletes subscription on 404 (not found)', async () => { + mockSendNotification.mockRejectedValue({ statusCode: 404 }) + await sendPushToUser('user-1', PAYLOAD) + expect(mockPushSubscription.delete).toHaveBeenCalledWith({ where: { id: SUB.id } }) + }) + + it('logs error but does not delete on other error status', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + mockSendNotification.mockRejectedValue({ statusCode: 500 }) + await sendPushToUser('user-1', PAYLOAD) + expect(mockPushSubscription.delete).not.toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) +}) diff --git a/__tests__/lib/rate-limit.test.ts b/__tests__/lib/rate-limit.test.ts new file mode 100644 index 0000000..aa9c636 --- /dev/null +++ b/__tests__/lib/rate-limit.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { checkRateLimit, enforceUserRateLimit, _resetRateLimit } from '@/lib/rate-limit' + +beforeEach(() => { + _resetRateLimit() +}) + +describe('checkRateLimit (legacy auth-keys)', () => { + it('staat de eerste request toe', () => { + expect(checkRateLimit('login:1.2.3.4')).toBe(true) + }) + + it('blokkeert na exceeding max (login: 10/min)', () => { + for (let i = 0; i < 10; i++) checkRateLimit('login:1.2.3.4') + expect(checkRateLimit('login:1.2.3.4')).toBe(false) + }) + + it('register heeft eigen lagere limiet (5/uur)', () => { + for (let i = 0; i < 5; i++) checkRateLimit('register:9.9.9.9') + expect(checkRateLimit('register:9.9.9.9')).toBe(false) + }) + + it('verschillende keys hebben hun eigen counter', () => { + for (let i = 0; i < 10; i++) checkRateLimit('login:1.1.1.1') + expect(checkRateLimit('login:1.1.1.1')).toBe(false) + expect(checkRateLimit('login:2.2.2.2')).toBe(true) + }) +}) + +describe('enforceUserRateLimit (v1-readiness #3 mutation-scopes)', () => { + it('returnt null bij eerste call', () => { + expect(enforceUserRateLimit('create-pbi', 'user-1')).toBeNull() + }) + + it('returnt 429-shape na exceeding limiet', () => { + // create-product limiet = 5/min + for (let i = 0; i < 5; i++) enforceUserRateLimit('create-product', 'user-1') + const result = enforceUserRateLimit('create-product', 'user-1') + expect(result).not.toBeNull() + expect(result?.code).toBe(429) + expect(result?.error).toContain('Te veel acties') + }) + + it('scope is per (action, user) — andere user heeft eigen quota', () => { + for (let i = 0; i < 5; i++) enforceUserRateLimit('create-product', 'user-A') + expect(enforceUserRateLimit('create-product', 'user-A')).not.toBeNull() + expect(enforceUserRateLimit('create-product', 'user-B')).toBeNull() + }) + + it('verschillende scopes voor dezelfde user vullen apart', () => { + for (let i = 0; i < 5; i++) enforceUserRateLimit('create-product', 'user-1') + expect(enforceUserRateLimit('create-product', 'user-1')).not.toBeNull() + // create-task heeft eigen counter + expect(enforceUserRateLimit('create-task', 'user-1')).toBeNull() + }) + + it('create-task limiet (100) is hoger dan create-pbi (30)', () => { + for (let i = 0; i < 30; i++) enforceUserRateLimit('create-pbi', 'u') + expect(enforceUserRateLimit('create-pbi', 'u')).not.toBeNull() + // create-task is nog niet hit + for (let i = 0; i < 30; i++) enforceUserRateLimit('create-task', 'u') + expect(enforceUserRateLimit('create-task', 'u')).toBeNull() + }) +}) diff --git a/__tests__/lib/sprint-conflicts.test.ts b/__tests__/lib/sprint-conflicts.test.ts new file mode 100644 index 0000000..bf6edbe --- /dev/null +++ b/__tests__/lib/sprint-conflicts.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, vi } from 'vitest' +import type { StoryStatus } from '@prisma/client' + +import { + getBlockingSprintMap, + isEligibleForSprint, + partitionByEligibility, +} from '@/lib/sprint-conflicts' + +function mockPrisma(stories: Array>) { + return { + story: { + findMany: vi.fn().mockResolvedValue(stories), + }, + } as unknown as Parameters[0] +} + +describe('isEligibleForSprint', () => { + it('returns true for OPEN story without sprint', () => { + expect( + isEligibleForSprint({ sprint_id: null, status: 'OPEN' as StoryStatus }), + ).toBe(true) + }) + + it('returns true for IN_SPRINT story without sprint_id (edge: restoration)', () => { + expect( + isEligibleForSprint({ + sprint_id: null, + status: 'IN_SPRINT' as StoryStatus, + }), + ).toBe(true) + }) + + it('returns false for DONE story without sprint', () => { + expect( + isEligibleForSprint({ sprint_id: null, status: 'DONE' as StoryStatus }), + ).toBe(false) + }) + + it('returns false when story is in an OPEN sprint', () => { + expect( + isEligibleForSprint({ + sprint_id: 'abc', + status: 'IN_SPRINT' as StoryStatus, + sprint: { status: 'OPEN' }, + }), + ).toBe(false) + }) + + it('returns false when story is DONE (sprint_id irrelevant)', () => { + expect( + isEligibleForSprint({ + sprint_id: 'abc', + status: 'DONE' as StoryStatus, + sprint: { status: 'CLOSED' }, + }), + ).toBe(false) + }) + + it('returns true when story is in a CLOSED sprint (released back to planning)', () => { + expect( + isEligibleForSprint({ + sprint_id: 'abc', + status: 'IN_SPRINT' as StoryStatus, + sprint: { status: 'CLOSED' }, + }), + ).toBe(true) + }) + + it('returns true when story is in an ARCHIVED sprint', () => { + expect( + isEligibleForSprint({ + sprint_id: 'abc', + status: 'IN_SPRINT' as StoryStatus, + sprint: { status: 'ARCHIVED' }, + }), + ).toBe(true) + }) + + it('returns true when story is in a FAILED sprint', () => { + expect( + isEligibleForSprint({ + sprint_id: 'abc', + status: 'IN_SPRINT' as StoryStatus, + sprint: { status: 'FAILED' }, + }), + ).toBe(true) + }) + + it('returns false when sprint_id is set but sprint relation is missing (defensive)', () => { + // Zonder sprint-data weten we niet of die OPEN is, dus blijven we + // conservatief — niet eligible. + expect( + isEligibleForSprint({ + sprint_id: 'abc', + status: 'IN_SPRINT' as StoryStatus, + }), + ).toBe(false) + }) +}) + +describe('partitionByEligibility', () => { + it('returns empty partition for empty input', async () => { + const prisma = mockPrisma([]) + const result = await partitionByEligibility(prisma, []) + expect(result).toEqual({ eligible: [], notEligible: [], crossSprint: [] }) + }) + + it('classifies all eligible when stories are free + OPEN', async () => { + const prisma = mockPrisma([ + { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, + { id: 's2', sprint_id: null, status: 'IN_SPRINT', sprint: null }, + ]) + const result = await partitionByEligibility(prisma, ['s1', 's2']) + expect(result.eligible).toEqual(['s1', 's2']) + expect(result.notEligible).toEqual([]) + expect(result.crossSprint).toEqual([]) + }) + + it('marks DONE stories as notEligible with reason=DONE', async () => { + const prisma = mockPrisma([ + { id: 's1', sprint_id: null, status: 'DONE', sprint: null }, + ]) + const result = await partitionByEligibility(prisma, ['s1']) + expect(result.eligible).toEqual([]) + expect(result.notEligible).toEqual([{ storyId: 's1', reason: 'DONE' }]) + }) + + it('marks stories in other OPEN sprint as crossSprint + notEligible', async () => { + const prisma = mockPrisma([ + { + id: 's1', + sprint_id: 'sprint-other', + status: 'IN_SPRINT', + sprint: { id: 'sprint-other', code: 'SP-2', status: 'OPEN' }, + }, + ]) + const result = await partitionByEligibility(prisma, ['s1']) + expect(result.crossSprint).toEqual([ + { storyId: 's1', sprintId: 'sprint-other', sprintName: 'SP-2' }, + ]) + expect(result.notEligible).toEqual([ + { storyId: 's1', reason: 'IN_OTHER_SPRINT' }, + ]) + expect(result.eligible).toEqual([]) + }) + + it('classifies story in CLOSED sprint with status=OPEN as eligible (status reset already happened)', async () => { + const prisma = mockPrisma([ + { + id: 's1', + sprint_id: null, + status: 'OPEN', + sprint: null, + }, + ]) + const result = await partitionByEligibility(prisma, ['s1']) + expect(result.eligible).toEqual(['s1']) + }) + + it('frees stories from a CLOSED sprint — they become eligible again', async () => { + const prisma = mockPrisma([ + { + id: 's1', + sprint_id: 'sprint-closed', + status: 'IN_SPRINT', + sprint: { id: 'sprint-closed', code: 'SP-C', status: 'CLOSED' }, + }, + ]) + const result = await partitionByEligibility(prisma, ['s1']) + expect(result.eligible).toEqual(['s1']) + expect(result.crossSprint).toEqual([]) + expect(result.notEligible).toEqual([]) + }) + + it('frees stories from ARCHIVED and FAILED sprints', async () => { + const prisma = mockPrisma([ + { + id: 's1', + sprint_id: 'sprint-arch', + status: 'IN_SPRINT', + sprint: { id: 'sprint-arch', code: 'SP-A', status: 'ARCHIVED' }, + }, + { + id: 's2', + sprint_id: 'sprint-fail', + status: 'IN_SPRINT', + sprint: { id: 'sprint-fail', code: 'SP-F', status: 'FAILED' }, + }, + ]) + const result = await partitionByEligibility(prisma, ['s1', 's2']) + expect(result.eligible).toEqual(['s1', 's2']) + expect(result.notEligible).toEqual([]) + }) + + it('a DONE story in a CLOSED sprint is notEligible because DONE (sprint inactive)', async () => { + // Volgorde: niet-actieve sprint blokkeert niet meer, dus de DONE-check + // bepaalt de reason. Vroeger werd dit 'IN_OTHER_SPRINT' — dat was misleidend + // omdat de sprint helemaal niet meer actief was. + const prisma = mockPrisma([ + { + id: 's1', + sprint_id: 'sprint-closed', + status: 'DONE', + sprint: { id: 'sprint-closed', code: 'SP-C', status: 'CLOSED' }, + }, + ]) + const result = await partitionByEligibility(prisma, ['s1']) + expect(result.crossSprint).toEqual([]) + expect(result.notEligible).toEqual([{ storyId: 's1', reason: 'DONE' }]) + expect(result.eligible).toEqual([]) + }) + + it('respects excludeSprintId — story in same sprint is eligible', async () => { + const prisma = mockPrisma([ + { + id: 's1', + sprint_id: 'sprint-active', + status: 'IN_SPRINT', + sprint: { id: 'sprint-active', code: 'SP-A', status: 'OPEN' }, + }, + ]) + const result = await partitionByEligibility(prisma, ['s1'], 'sprint-active') + expect(result.eligible).toEqual(['s1']) + expect(result.crossSprint).toEqual([]) + }) +}) + +describe('getBlockingSprintMap', () => { + it('returns empty map for empty input', async () => { + const prisma = mockPrisma([]) + const result = await getBlockingSprintMap(prisma, 'p1', []) + expect(result.size).toBe(0) + }) + + it('returns blocking sprint info for stories in OPEN sprints', async () => { + const prisma = mockPrisma([ + { + id: 's1', + sprint_id: 'sprint-x', + sprint: { id: 'sprint-x', code: 'SP-X', status: 'OPEN' }, + }, + ]) + const result = await getBlockingSprintMap(prisma, 'p1', ['s1']) + expect(result.get('s1')).toEqual({ + sprintId: 'sprint-x', + sprintName: 'SP-X', + }) + }) + + it('excludes the active sprint from blocking', async () => { + const prisma = mockPrisma([ + { + id: 's1', + sprint_id: 'sprint-active', + sprint: { id: 'sprint-active', code: 'SP-A', status: 'OPEN' }, + }, + ]) + const result = await getBlockingSprintMap( + prisma, + 'p1', + ['s1'], + 'sprint-active', + ) + expect(result.size).toBe(0) + }) + + it('does not include CLOSED sprints (filtered at DB query level)', async () => { + // The prisma mock receives WHERE sprint.status='OPEN' so CLOSED stories + // are already filtered out before reaching this function's mapping logic. + const prisma = mockPrisma([]) + const result = await getBlockingSprintMap(prisma, 'p1', ['s1']) + expect(result.size).toBe(0) + }) +}) diff --git a/__tests__/lib/task-status.test.ts b/__tests__/lib/task-status.test.ts index 870b632..9f08a85 100644 --- a/__tests__/lib/task-status.test.ts +++ b/__tests__/lib/task-status.test.ts @@ -78,8 +78,8 @@ describe('task-status mappers', () => { expect(pbiStatusFromApi('todo')).toBeNull() }) - it('exposes exactly three API values', () => { - expect(PBI_STATUS_API_VALUES).toEqual(['ready', 'blocked', 'done']) + it('exposes alle vier API values', () => { + expect(PBI_STATUS_API_VALUES).toEqual(['ready', 'blocked', 'failed', 'done']) }) }) }) diff --git a/__tests__/lib/tasks-status-update.test.ts b/__tests__/lib/tasks-status-update.test.ts index 418caa7..ccaa2f6 100644 --- a/__tests__/lib/tasks-status-update.test.ts +++ b/__tests__/lib/tasks-status-update.test.ts @@ -8,6 +8,23 @@ vi.mock('@/lib/prisma', () => ({ }, story: { findUniqueOrThrow: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + pbi: { + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + sprint: { + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + claudeJob: { + findFirst: vi.fn(), + updateMany: vi.fn(), + }, + sprintRun: { + findUnique: vi.fn(), update: vi.fn(), }, $transaction: vi.fn(), @@ -15,27 +32,35 @@ vi.mock('@/lib/prisma', () => ({ })) import { prisma } from '@/lib/prisma' -import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' +import { propagateStatusUpwards } from '@/lib/tasks-status-update' -const mockPrisma = prisma as unknown as { - task: { - update: ReturnType - findMany: ReturnType - } +type MockedPrisma = { + task: { update: ReturnType; findMany: ReturnType } story: { findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + pbi: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + sprint: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + claudeJob: { + findFirst: ReturnType + updateMany: ReturnType + } + sprintRun: { + findUnique: ReturnType update: ReturnType } $transaction: ReturnType } -beforeEach(() => { - vi.clearAllMocks() - // Pass-through: $transaction(run) just calls run with the mocked prisma client. - mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise) => { - return run(prisma) - }) -}) +const mockPrisma = prisma as unknown as MockedPrisma const TASK_BASE = { id: 'task-1', @@ -44,110 +69,267 @@ const TASK_BASE = { implementation_plan: null, } -describe('updateTaskStatusWithStoryPromotion', () => { - it('promotes story to DONE when last sibling task transitions to DONE', async () => { +beforeEach(() => { + vi.clearAllMocks() + mockPrisma.$transaction.mockImplementation( + async (run: (tx: typeof prisma) => Promise) => run(prisma), + ) +}) + +describe('propagateStatusUpwards — story-niveau', () => { + it('zet story op DONE wanneer alle siblings DONE zijn', async () => { mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) mockPrisma.task.findMany.mockResolvedValue([ { status: 'DONE' }, { status: 'DONE' }, ]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }]) - const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + const result = await propagateStatusUpwards('task-1', 'DONE') - expect(result.storyStatusChange).toBe('promoted') - expect(result.storyId).toBe('story-1') + expect(result.storyChanged).toBe(true) expect(mockPrisma.story.update).toHaveBeenCalledWith({ where: { id: 'story-1' }, data: { status: 'DONE' }, }) }) - it('does not promote when story is already DONE (idempotent)', async () => { - mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) - mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + it('zet story op FAILED wanneer een task FAILED is, ongeacht andere tasks', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'FAILED' }) + mockPrisma.task.findMany.mockResolvedValue([ + { status: 'FAILED' }, + { status: 'DONE' }, + { status: 'TO_DO' }, + ]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findMany.mockResolvedValue([{ status: 'FAILED' }]) - const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + const result = await propagateStatusUpwards('task-1', 'FAILED') - expect(result.storyStatusChange).toBe(null) - expect(mockPrisma.story.update).not.toHaveBeenCalled() + expect(result.storyChanged).toBe(true) + expect(mockPrisma.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'FAILED' }, + }) }) - it('does not promote when not all siblings are DONE', async () => { + it('houdt story op IN_SPRINT als nog niet alle tasks DONE en geen FAILED', async () => { mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) mockPrisma.task.findMany.mockResolvedValue([ { status: 'DONE' }, - { status: 'IN_PROGRESS' }, + { status: 'TO_DO' }, ]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: 'sprint-1', + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => { + if (args.where?.pbi_id) return [{ status: 'IN_SPRINT' }] + if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }] + return [] + }) + mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'OPEN' }) + ;(mockPrisma.pbi as unknown as { findMany: ReturnType }).findMany = vi.fn().mockResolvedValue([{ status: 'READY' }]) - const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + const result = await propagateStatusUpwards('task-1', 'DONE') - expect(result.storyStatusChange).toBe(null) + expect(result.storyChanged).toBe(false) expect(mockPrisma.story.update).not.toHaveBeenCalled() }) - it('demotes story to IN_SPRINT when a task moves out of DONE on a DONE story', async () => { - mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) + it('demoot story uit DONE als een task terug naar TO_DO gaat', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'TO_DO' }) mockPrisma.task.findMany.mockResolvedValue([ - { status: 'IN_PROGRESS' }, + { status: 'TO_DO' }, { status: 'DONE' }, ]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'DONE', + pbi_id: 'pbi-1', + sprint_id: 'sprint-1', + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' }) + mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => { + if (args.where?.pbi_id) return [{ status: 'IN_SPRINT' }, { status: 'DONE' }] + if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }] + return [] + }) + mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'CLOSED' }) + ;(mockPrisma.pbi as unknown as { findMany: ReturnType }).findMany = vi.fn().mockResolvedValue([{ status: 'READY' }]) - const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') + const result = await propagateStatusUpwards('task-1', 'TO_DO') - expect(result.storyStatusChange).toBe('demoted') + expect(result.storyChanged).toBe(true) expect(mockPrisma.story.update).toHaveBeenCalledWith({ where: { id: 'story-1' }, data: { status: 'IN_SPRINT' }, }) }) - it('does not demote when story is not DONE', async () => { + it('zet story op OPEN als sprint_id null is en niet DONE/FAILED', async () => { mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) - - const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') - - expect(result.storyStatusChange).toBe(null) - expect(mockPrisma.story.update).not.toHaveBeenCalled() - }) - - it('updates the task regardless of story-status change', async () => { - mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) - mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) - - await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') - - expect(mockPrisma.task.update).toHaveBeenCalledWith({ - where: { id: 'task-1' }, - data: { status: 'IN_PROGRESS' }, - select: expect.any(Object), + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: null, }) - }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findMany.mockResolvedValue([{ status: 'OPEN' }]) - it('uses the provided transaction client when passed', async () => { - const tx = { - task: { update: vi.fn(), findMany: vi.fn() }, - story: { findUniqueOrThrow: vi.fn(), update: vi.fn() }, - } - tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) - tx.task.findMany.mockResolvedValue([{ status: 'DONE' }]) - tx.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + const result = await propagateStatusUpwards('task-1', 'IN_PROGRESS') - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE', tx as any) - - expect(result.storyStatusChange).toBe('promoted') - // $transaction should NOT be called when caller already provides a tx. - expect(mockPrisma.$transaction).not.toHaveBeenCalled() - expect(tx.story.update).toHaveBeenCalledWith({ + expect(result.storyChanged).toBe(true) + expect(mockPrisma.story.update).toHaveBeenCalledWith({ where: { id: 'story-1' }, - data: { status: 'DONE' }, + data: { status: 'OPEN' }, }) }) }) + +describe('propagateStatusUpwards — PBI BLOCKED met rust laten', () => { + it('overschrijft een handmatig BLOCKED PBI niet', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: null, + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'BLOCKED' }) + + const result = await propagateStatusUpwards('task-1', 'DONE') + + expect(result.pbiChanged).toBe(false) + expect(mockPrisma.pbi.update).not.toHaveBeenCalled() + }) +}) + +describe('propagateStatusUpwards — sprint cascade tot SprintRun', () => { + it('zet bij FAILED de hele keten op FAILED en cancelt sibling-jobs', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'FAILED' }) + mockPrisma.task.findMany.mockResolvedValue([ + { status: 'FAILED' }, + { status: 'DONE' }, + ]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: 'sprint-1', + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => { + if (args.where?.pbi_id) return [{ status: 'FAILED' }] + if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }] + return [] + }) + mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'OPEN' }) + // findMany on pbi: + ;(mockPrisma.pbi as unknown as { findMany: ReturnType }).findMany = vi.fn().mockResolvedValue([{ status: 'FAILED' }]) + mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' }) + mockPrisma.sprintRun.findUnique.mockResolvedValue({ id: 'run-1', status: 'RUNNING' }) + + const result = await propagateStatusUpwards('task-1', 'FAILED') + + expect(result.storyChanged).toBe(true) + expect(result.pbiChanged).toBe(true) + expect(result.sprintChanged).toBe(true) + expect(result.sprintRunChanged).toBe(true) + + expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'run-1' }, + data: expect.objectContaining({ status: 'FAILED', failed_task_id: 'task-1' }), + })) + expect(mockPrisma.claudeJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + sprint_run_id: 'run-1', + status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, + id: { not: 'job-1' }, + }), + data: expect.objectContaining({ status: 'CANCELLED' }), + })) + }) + + it('zet bij alle DONE de SprintRun op DONE en Sprint op COMPLETED', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'IN_SPRINT', + pbi_id: 'pbi-1', + sprint_id: 'sprint-1', + }) + mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => { + if (args.where?.pbi_id) return [{ status: 'DONE' }] + if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }] + return [] + }) + mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'OPEN' }) + ;(mockPrisma.pbi as unknown as { findMany: ReturnType }).findMany = vi.fn().mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' }) + mockPrisma.sprintRun.findUnique.mockResolvedValue({ id: 'run-1', status: 'RUNNING' }) + + const result = await propagateStatusUpwards('task-1', 'DONE') + + expect(result.sprintRunChanged).toBe(true) + expect(mockPrisma.sprint.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'sprint-1' }, + data: expect.objectContaining({ status: 'CLOSED' }), + })) + expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'run-1' }, + data: expect.objectContaining({ status: 'DONE' }), + })) + }) +}) + +describe('propagateStatusUpwards — transactionele aanroep', () => { + it('gebruikt de meegegeven transaction client', async () => { + const tx = { + task: { update: vi.fn(), findMany: vi.fn() }, + story: { findUniqueOrThrow: vi.fn(), findMany: vi.fn(), update: vi.fn() }, + pbi: { findUniqueOrThrow: vi.fn(), findMany: vi.fn(), update: vi.fn() }, + sprint: { findUniqueOrThrow: vi.fn(), update: vi.fn() }, + claudeJob: { findFirst: vi.fn(), updateMany: vi.fn() }, + sprintRun: { findUnique: vi.fn(), update: vi.fn() }, + } + tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) + tx.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) + tx.story.findUniqueOrThrow.mockResolvedValue({ + id: 'story-1', + status: 'OPEN', + pbi_id: 'pbi-1', + sprint_id: null, + }) + tx.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + tx.story.findMany.mockResolvedValue([{ status: 'OPEN' }]) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await propagateStatusUpwards('task-1', 'IN_PROGRESS', tx as any) + + expect(result.storyChanged).toBe(false) + // $transaction wordt niet aangeroepen wanneer caller al een tx meegeeft. + expect(mockPrisma.$transaction).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/lib/user-settings-migration.test.ts b/__tests__/lib/user-settings-migration.test.ts new file mode 100644 index 0000000..38346b4 --- /dev/null +++ b/__tests__/lib/user-settings-migration.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + buildMigrationPatch, + clearLegacyStorage, +} from '@/lib/user-settings-migration' + +function clearAllCookies() { + for (const part of document.cookie.split(';')) { + const eq = part.indexOf('=') + const name = (eq < 0 ? part : part.slice(0, eq)).trim() + if (name) document.cookie = `${name}=; max-age=0; path=/` + } +} + +beforeEach(() => { + localStorage.clear() + clearAllCookies() +}) + +afterEach(() => { + localStorage.clear() + clearAllCookies() +}) + +describe('buildMigrationPatch', () => { + it('returns no data when nothing is stored', () => { + const result = buildMigrationPatch() + expect(result.hasData).toBe(false) + expect(result.patch).toEqual({}) + expect(result.legacyKeys).toEqual([]) + }) + + it('skips after marker is set to current version', () => { + localStorage.setItem('scrum4me:sprint_pb_filter_status', 'all') + localStorage.setItem('scrum4me:settings_migrated', 'v2') + const result = buildMigrationPatch() + expect(result.hasData).toBe(false) + }) + + it('still runs when only the v1 marker is set (re-migration)', () => { + localStorage.setItem('scrum4me:sprint_pb_filter_status', 'all') + localStorage.setItem('scrum4me:settings_migrated', 'v1') + const result = buildMigrationPatch() + expect(result.hasData).toBe(true) + }) + + it('extracts split-pane cookies into layout', () => { + document.cookie = `sp:backlog-p1=${encodeURIComponent(JSON.stringify([25, 35, 40]))}; path=/` + const result = buildMigrationPatch() + expect(result.patch.layout?.splitPanePositions).toEqual({ 'backlog-p1': [25, 35, 40] }) + expect(result.legacyCookies).toContain('sp:backlog-p1') + }) + + it('ignores split-pane cookies that do not sum to 100', () => { + document.cookie = `sp:bad=${encodeURIComponent(JSON.stringify([10, 20]))}; path=/` + const result = buildMigrationPatch() + expect(result.patch.layout).toBeUndefined() + }) + + it('extracts active-sprint cookies into layout.activeSprints', () => { + document.cookie = `active_sprint_prod-1=sprint-abc; path=/` + document.cookie = `active_sprint_prod-2=sprint-xyz; path=/` + const result = buildMigrationPatch() + expect(result.patch.layout?.activeSprints).toEqual({ + 'prod-1': 'sprint-abc', + 'prod-2': 'sprint-xyz', + }) + expect(result.legacyCookies).toContain('active_sprint_prod-1') + }) + + it('extracts sprint backlog prefs into nested patch', () => { + localStorage.setItem('scrum4me:sprint_pb_filter_status', 'all') + localStorage.setItem('scrum4me:sprint_pb_sort', 'priority') + localStorage.setItem('scrum4me:sprint_pb_sort_dir', 'desc') + localStorage.setItem('scrum4me:sprint_pb_collapsed', JSON.stringify(['pbi-1', 'pbi-2'])) + localStorage.setItem('scrum4me:sprint_pb_filter_popover_open', 'true') + + const result = buildMigrationPatch() + + expect(result.hasData).toBe(true) + expect(result.patch.views?.sprintBacklog).toEqual({ + filterStatus: 'all', + sort: 'priority', + sortDir: 'desc', + collapsedPbis: ['pbi-1', 'pbi-2'], + filterPopoverOpen: true, + }) + expect(result.legacyKeys).toContain('scrum4me:sprint_pb_filter_status') + expect(result.legacyKeys).toContain('scrum4me:sprint_pb_collapsed') + }) + + it('extracts pbi-list prefs', () => { + localStorage.setItem('scrum4me:pbi_sort', 'date') + localStorage.setItem('scrum4me:pbi_filter_priority', '2') + + const result = buildMigrationPatch() + expect(result.patch.views?.pbiList).toEqual({ sort: 'date', filterPriority: 2 }) + }) + + it('extracts story_sort', () => { + localStorage.setItem('scrum4me:story_sort', 'code') + const result = buildMigrationPatch() + expect(result.patch.views?.storyPanel).toEqual({ sort: 'code' }) + }) + + it('extracts debug-mode', () => { + localStorage.setItem('scrum4me:debug-mode', 'true') + const result = buildMigrationPatch() + expect(result.patch.devTools).toEqual({ debugMode: true }) + }) + + it('extracts jobs-column dynamic prefixes from CSV values', () => { + localStorage.setItem('queue_filter_kind', 'TASK_IMPLEMENTATION,SPRINT_IMPLEMENTATION') + localStorage.setItem('queue_filter_status', 'queued,running') + + const result = buildMigrationPatch() + expect(result.patch.views?.jobsColumns?.['queue']).toEqual({ + kinds: ['TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION'], + statuses: ['queued', 'running'], + }) + }) + + it('ignores invalid enum values', () => { + localStorage.setItem('scrum4me:sprint_pb_filter_status', 'BOGUS') + const result = buildMigrationPatch() + expect(result.hasData).toBe(false) + }) +}) + +describe('clearLegacyStorage', () => { + it('removes given keys and cookies and sets the v2 marker', () => { + localStorage.setItem('scrum4me:sprint_pb_sort', 'code') + document.cookie = 'sp:x=foo; path=/' + + clearLegacyStorage(['scrum4me:sprint_pb_sort'], ['sp:x']) + + expect(localStorage.getItem('scrum4me:sprint_pb_sort')).toBeNull() + expect(document.cookie).not.toContain('sp:x=foo') + expect(localStorage.getItem('scrum4me:settings_migrated')).toBe('v2') + }) + + it('sets marker even with empty lists (no-op migration)', () => { + clearLegacyStorage([], []) + expect(localStorage.getItem('scrum4me:settings_migrated')).toBe('v2') + }) +}) diff --git a/__tests__/lib/user-settings.test.ts b/__tests__/lib/user-settings.test.ts new file mode 100644 index 0000000..2e694d7 --- /dev/null +++ b/__tests__/lib/user-settings.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest' + +import { + DEFAULT_USER_SETTINGS, + UserSettingsSchema, + mergeSettings, + parseUserSettings, + type UserSettings, +} from '@/lib/user-settings' + +describe('mergeSettings', () => { + it('returns the patch when previous is empty', () => { + const result = mergeSettings({}, { views: { sprintBacklog: { sort: 'code' } } }) + expect(result).toEqual({ views: { sprintBacklog: { sort: 'code' } } }) + }) + + it('preserves existing keys when patch only sets new ones', () => { + const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } } + const result = mergeSettings(prev, { + views: { pbiList: { sort: 'date' } }, + }) + expect(result).toEqual({ + views: { + sprintBacklog: { sort: 'code' }, + pbiList: { sort: 'date' }, + }, + }) + }) + + it('merges nested objects without overwriting siblings', () => { + const prev: UserSettings = { + views: { sprintBacklog: { sort: 'code', sortDir: 'asc' } }, + } + const result = mergeSettings(prev, { + views: { sprintBacklog: { sort: 'priority' } }, + }) + expect(result).toEqual({ + views: { sprintBacklog: { sort: 'priority', sortDir: 'asc' } }, + }) + }) + + it('replaces arrays instead of appending', () => { + const prev: UserSettings = { + views: { sprintBacklog: { collapsedPbis: ['a', 'b'] } }, + } + const result = mergeSettings(prev, { + views: { sprintBacklog: { collapsedPbis: ['c'] } }, + }) + expect(result.views?.sprintBacklog?.collapsedPbis).toEqual(['c']) + }) + + it('does not mutate the previous object', () => { + const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } } + const snapshot = JSON.parse(JSON.stringify(prev)) + mergeSettings(prev, { views: { sprintBacklog: { sortDir: 'desc' } } }) + expect(prev).toEqual(snapshot) + }) + + it('skips undefined values in the patch', () => { + const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } } + const result = mergeSettings(prev, { views: undefined }) + expect(result).toEqual(prev) + }) +}) + +describe('parseUserSettings', () => { + it('returns defaults for null', () => { + expect(parseUserSettings(null)).toEqual(DEFAULT_USER_SETTINGS) + }) + + it('returns defaults for undefined', () => { + expect(parseUserSettings(undefined)).toEqual(DEFAULT_USER_SETTINGS) + }) + + it('returns defaults for invalid input', () => { + expect(parseUserSettings({ views: { sprintBacklog: { filterStatus: 'BOGUS' } } })) + .toEqual(DEFAULT_USER_SETTINGS) + }) + + it('passes valid settings through', () => { + const valid = { views: { sprintBacklog: { sort: 'code' as const } } } + expect(parseUserSettings(valid)).toEqual(valid) + }) +}) + +describe('UserSettingsSchema', () => { + it('rejects unknown top-level keys', () => { + const result = UserSettingsSchema.safeParse({ unknown: 1 }) + expect(result.success).toBe(false) + }) + + it('accepts an empty object', () => { + expect(UserSettingsSchema.safeParse({}).success).toBe(true) + }) + + it('accepts the full shape', () => { + const result = UserSettingsSchema.safeParse({ + views: { + sprintBacklog: { + filterPriority: 1, + filterStatus: 'OPEN', + sort: 'code', + sortDir: 'asc', + collapsedPbis: ['x'], + filterPopoverOpen: true, + }, + pbiList: { sort: 'priority', filterPriority: 'all', filterStatus: 'ready', sortDir: 'desc' }, + storyPanel: { sort: 'date' }, + jobsColumns: { 'queue:active': { kinds: ['TASK_IMPLEMENTATION'], statuses: [] } }, + jobs: { timeFilter: '24h' }, + ideasList: { filterStatuses: ['draft', 'planned'] }, + }, + devTools: { debugMode: true }, + layout: { + splitPanePositions: { 'backlog-pid': [25, 35, 40] }, + activeSprints: { 'product-1': 'sprint-1' }, + }, + }) + expect(result.success).toBe(true) + }) + + it('accepts views.jobs.timeFilter and returns it via parseUserSettings', () => { + const input = { views: { jobs: { timeFilter: '1h' as const } } } + const result = parseUserSettings(input) + expect(result).toEqual(input) + }) + + it('rejects an invalid views.jobs.timeFilter value', () => { + const result = UserSettingsSchema.safeParse({ views: { jobs: { timeFilter: 'BOGUS' } } }) + expect(result.success).toBe(false) + }) + + it('accepts layout-only settings', () => { + expect(UserSettingsSchema.safeParse({ + layout: { splitPanePositions: { x: [50, 50] }, activeSprints: { p: 's' } }, + }).success).toBe(true) + }) + + it('accepts null values in activeSprints (explicit "no active sprint")', () => { + const result = UserSettingsSchema.safeParse({ + layout: { activeSprints: { 'product-1': null, 'product-2': 'sprint-2' } }, + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.layout?.activeSprints).toEqual({ + 'product-1': null, + 'product-2': 'sprint-2', + }) + } + }) + + it('accepts pendingSprintDraft with per-PBI intent and overrides', () => { + const result = UserSettingsSchema.safeParse({ + workflow: { + pendingSprintDraft: { + 'product-1': { + goal: 'Sprint goal', + pbiIntent: { pbiA: 'all', pbiB: 'none' }, + storyOverrides: { + pbiA: { add: [], remove: ['story-1'] }, + pbiB: { add: ['story-2'], remove: [] }, + }, + }, + }, + }, + }) + expect(result.success).toBe(true) + }) + + it('fills empty defaults for pbiIntent and storyOverrides in draft', () => { + const result = UserSettingsSchema.safeParse({ + workflow: { pendingSprintDraft: { 'product-1': { goal: 'g' } } }, + }) + expect(result.success).toBe(true) + if (result.success) { + const draft = result.data.workflow?.pendingSprintDraft?.['product-1'] + expect(draft?.pbiIntent).toEqual({}) + expect(draft?.storyOverrides).toEqual({}) + } + }) + + it('rejects pendingSprintDraft with empty goal', () => { + const result = UserSettingsSchema.safeParse({ + workflow: { pendingSprintDraft: { 'p': { goal: '' } } }, + }) + expect(result.success).toBe(false) + }) + + it('rejects an invalid ideasList.filterStatuses value', () => { + const result = UserSettingsSchema.safeParse({ views: { ideasList: { filterStatuses: ['BOGUS'] } } }) + expect(result.success).toBe(false) + }) + + it('accepts an empty ideasList.filterStatuses array', () => { + const result = UserSettingsSchema.safeParse({ views: { ideasList: { filterStatuses: [] } } }) + expect(result.success).toBe(true) + }) + + it('rejects unknown intent value', () => { + const result = UserSettingsSchema.safeParse({ + workflow: { + pendingSprintDraft: { + p: { goal: 'x', pbiIntent: { a: 'partial' } }, + }, + }, + }) + expect(result.success).toBe(false) + }) +}) diff --git a/__tests__/proxy/demo-guard.test.ts b/__tests__/proxy/demo-guard.test.ts index f229a8f..1ae94a2 100644 --- a/__tests__/proxy/demo-guard.test.ts +++ b/__tests__/proxy/demo-guard.test.ts @@ -30,6 +30,26 @@ beforeEach(() => { }) describe('proxy demo-guard', () => { + it('demo + POST /api/ideas → 403 (M12)', async () => { + mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true }) + const req = makeRequest('POST', '/api/ideas', true) + const res = await proxy(req) + expect(res?.status).toBe(403) + }) + + it('demo + PATCH /api/ideas/abc → 403 (M12)', async () => { + mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true }) + const req = makeRequest('PATCH', '/api/ideas/abc', true) + const res = await proxy(req) + expect(res?.status).toBe(403) + }) + + it('demo + GET /api/ideas → passthrough (M12)', async () => { + const req = makeRequest('GET', '/api/ideas', true) + const res = await proxy(req) + expect(res?.status).not.toBe(403) + }) + it('demo + POST /api/todos → 403', async () => { mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true }) const req = makeRequest('POST', '/api/todos', true) diff --git a/__tests__/realtime/payload-contract.test.ts b/__tests__/realtime/payload-contract.test.ts deleted file mode 100644 index b36bc09..0000000 --- a/__tests__/realtime/payload-contract.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { useBacklogStore } from '@/stores/backlog-store' -import type { BacklogPbi, BacklogStory, BacklogTask } from '@/stores/backlog-store' - -const PBI: BacklogPbi = { - id: 'pbi-1', - code: 'PBI-1', - title: 'Realtime PBI', - priority: 2, - description: 'desc', - created_at: new Date('2024-01-01T00:00:00Z'), - status: 'ready', -} - -const STORY: BacklogStory = { - id: 'story-1', - code: 'ST-1', - title: 'Realtime story', - description: null, - acceptance_criteria: null, - priority: 2, - status: 'OPEN', - pbi_id: 'pbi-1', - created_at: new Date('2024-01-01T00:00:00Z'), -} - -const TASK: BacklogTask = { - id: 'task-1', - title: 'Realtime task', - description: null, - priority: 2, - status: 'TO_DO', - sort_order: 1, - story_id: 'story-1', - created_at: new Date('2024-01-01T00:00:00Z'), -} - -beforeEach(() => { - useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} }) -}) - -// --------------------------------------------------------------------------- -// PBI -// --------------------------------------------------------------------------- - -describe('PBI payload contract', () => { - it('INSERT: entity appears in pbis with correct title and status', () => { - useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI }) - const state = useBacklogStore.getState() - expect(state.pbis).toHaveLength(1) - expect(state.pbis[0].id).toBe('pbi-1') - expect(state.pbis[0].title).toBe('Realtime PBI') - expect(state.pbis[0].status).toBe('ready') - }) - - it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => { - useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI }) - useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI }) - expect(useBacklogStore.getState().pbis).toHaveLength(1) - }) - - it('UPDATE: changed_fields partial merges into existing entity', () => { - useBacklogStore.setState({ pbis: [{ ...PBI }], storiesByPbi: {}, tasksByStory: {} }) - useBacklogStore.getState().applyChange('pbi', 'U', { id: 'pbi-1', title: 'Updated PBI', status: 'in_sprint' as const }) - const pbi = useBacklogStore.getState().pbis[0] - expect(pbi.title).toBe('Updated PBI') - expect(pbi.status).toBe('in_sprint') - expect(pbi.priority).toBe(2) // unchanged field retained - }) - - it('DELETE: entity is removed from pbis', () => { - useBacklogStore.setState({ pbis: [{ ...PBI }], storiesByPbi: {}, tasksByStory: {} }) - useBacklogStore.getState().applyChange('pbi', 'D', { id: 'pbi-1' }) - expect(useBacklogStore.getState().pbis).toHaveLength(0) - }) -}) - -// --------------------------------------------------------------------------- -// Story -// --------------------------------------------------------------------------- - -describe('Story payload contract', () => { - it('INSERT: entity appears in storiesByPbi[pbi_id] with correct title and status', () => { - useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} }) - useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) - const bucket = useBacklogStore.getState().storiesByPbi['pbi-1'] - expect(bucket).toHaveLength(1) - expect(bucket[0].id).toBe('story-1') - expect(bucket[0].title).toBe('Realtime story') - expect(bucket[0].status).toBe('OPEN') - }) - - it('INSERT: creates bucket when pbi_id was not yet in storiesByPbi', () => { - useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) - expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1) - }) - - it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => { - useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) - useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) - expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1) - }) - - it('UPDATE: changed_fields partial merges into existing story', () => { - useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [{ ...STORY }] }, tasksByStory: {} }) - useBacklogStore.getState().applyChange('story', 'U', { id: 'story-1', title: 'Updated story', status: 'IN_SPRINT' }) - const story = useBacklogStore.getState().storiesByPbi['pbi-1'][0] - expect(story.title).toBe('Updated story') - expect(story.status).toBe('IN_SPRINT') - expect(story.priority).toBe(2) // unchanged field retained - }) - - it('DELETE: entity is removed from its pbi bucket', () => { - useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [{ ...STORY }] }, tasksByStory: {} }) - useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' }) - expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0) - }) -}) - -// --------------------------------------------------------------------------- -// Task -// --------------------------------------------------------------------------- - -describe('Task payload contract', () => { - it('INSERT: entity appears in tasksByStory[story_id] with correct title and status', () => { - useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [] } }) - useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) - const bucket = useBacklogStore.getState().tasksByStory['story-1'] - expect(bucket).toHaveLength(1) - expect(bucket[0].id).toBe('task-1') - expect(bucket[0].title).toBe('Realtime task') - expect(bucket[0].status).toBe('TO_DO') - }) - - it('INSERT: creates bucket when story_id was not yet in tasksByStory', () => { - useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) - expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(1) - }) - - it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => { - useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) - useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) - expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(1) - }) - - it('UPDATE: changed_fields partial merges into existing task', () => { - useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [{ ...TASK }] } }) - useBacklogStore.getState().applyChange('task', 'U', { id: 'task-1', title: 'Updated task', status: 'IN_PROGRESS' }) - const task = useBacklogStore.getState().tasksByStory['story-1'][0] - expect(task.title).toBe('Updated task') - expect(task.status).toBe('IN_PROGRESS') - expect(task.sort_order).toBe(1) // unchanged field retained - }) - - it('DELETE: entity is removed from its story bucket', () => { - useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [{ ...TASK }] } }) - useBacklogStore.getState().applyChange('task', 'D', { id: 'task-1' }) - expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(0) - }) -}) diff --git a/__tests__/realtime/use-workspace-resync.test.tsx b/__tests__/realtime/use-workspace-resync.test.tsx new file mode 100644 index 0000000..cbc50a5 --- /dev/null +++ b/__tests__/realtime/use-workspace-resync.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHook } from '@testing-library/react' + +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import { useWorkspaceResync } from '@/lib/realtime/use-workspace-resync' + +let resyncSpy: ReturnType + +beforeEach(() => { + resyncSpy = vi.fn().mockResolvedValue(undefined) + useProductWorkspaceStore.setState((s) => { + s.resyncActiveScopes = resyncSpy as unknown as typeof s.resyncActiveScopes + }) + // visibilitychange handler leest document.visibilityState — default is 'visible' + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('useWorkspaceResync', () => { + it('triggert resyncActiveScopes("visible") op visibilitychange hidden→visible', () => { + renderHook(() => useWorkspaceResync()) + + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }) + document.dispatchEvent(new Event('visibilitychange')) + + expect(resyncSpy).toHaveBeenCalledWith('visible') + }) + + it('triggert resyncActiveScopes("reconnect") op online-event', () => { + renderHook(() => useWorkspaceResync()) + window.dispatchEvent(new Event('online')) + expect(resyncSpy).toHaveBeenCalledWith('reconnect') + }) + + it('triggert geen resync bij visibilitychange naar hidden', () => { + renderHook(() => useWorkspaceResync()) + + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + writable: true, + configurable: true, + }) + document.dispatchEvent(new Event('visibilitychange')) + + expect(resyncSpy).not.toHaveBeenCalled() + }) + + it('cleanup verwijdert listeners bij unmount', () => { + const { unmount } = renderHook(() => useWorkspaceResync()) + unmount() + + window.dispatchEvent(new Event('online')) + document.dispatchEvent(new Event('visibilitychange')) + + expect(resyncSpy).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/review-plan-job.test.ts b/__tests__/review-plan-job.test.ts new file mode 100644 index 0000000..2b298dc --- /dev/null +++ b/__tests__/review-plan-job.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect } from 'vitest' + +/** + * Review-Plan Job Tests + * + * Tests for the IDEA_REVIEW_PLAN job kind and review-log schema validation. + */ + +// Sample review-log structure for testing +const sampleReviewLog = { + plan_file: 'I-042', + created_at: new Date().toISOString(), + rounds: [ + { + round: 0, + model: 'claude-3-5-haiku', + role: 'Structure Review', + focus: 'YAML parsing, format, syntax', + plan_before: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---', + plan_after: + '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n priority: 2\n---', + issues: [ + { + category: 'structure', + severity: 'warning', + suggestion: 'Add priority field to story', + }, + ], + score: 75, + plan_diff_lines: 1, + converged: false, + timestamp: new Date().toISOString(), + }, + { + round: 1, + model: 'claude-3-5-sonnet', + role: 'Logic & Patterns', + focus: 'Logic gaps, missing patterns, architecture fit', + plan_before: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---', + plan_after: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---', + issues: [ + { + category: 'logic', + severity: 'info', + suggestion: 'Consider adding acceptance criteria', + }, + ], + score: 80, + plan_diff_lines: 0, + converged: false, + timestamp: new Date().toISOString(), + }, + { + round: 2, + model: 'claude-opus-4-7', + role: 'Risk Assessment', + focus: 'Risk assessment, edge cases, refactoring', + plan_before: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---', + plan_after: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---', + issues: [], + score: 85, + plan_diff_lines: 0, + converged: true, + timestamp: new Date().toISOString(), + }, + ], + convergence: { + stable_at_round: 2, + final_diff_pct: 0.5, + convergence_metric: 'plan_stability', + }, + approval: { + status: 'approved', + timestamp: new Date().toISOString(), + }, + summary: 'Plan reviewed across three rounds. Minor structure improvements suggested. Plan approved.', +} + +describe('review-plan-job', () => { + describe('ReviewLog Schema', () => { + it('should have required top-level fields', () => { + expect(sampleReviewLog).toHaveProperty('plan_file') + expect(sampleReviewLog).toHaveProperty('created_at') + expect(sampleReviewLog).toHaveProperty('rounds') + expect(sampleReviewLog).toHaveProperty('convergence') + expect(sampleReviewLog).toHaveProperty('approval') + expect(sampleReviewLog).toHaveProperty('summary') + }) + + it('should have valid plan_file format', () => { + expect(typeof sampleReviewLog.plan_file).toBe('string') + expect(sampleReviewLog.plan_file.length).toBeGreaterThan(0) + }) + + it('should have valid ISO timestamps', () => { + const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ + expect(sampleReviewLog.created_at).toMatch(isoRegex) + expect(sampleReviewLog.approval.timestamp).toMatch(isoRegex) + }) + + it('should have at least one round', () => { + expect(sampleReviewLog.rounds.length).toBeGreaterThan(0) + }) + + it('should have valid round structure', () => { + for (const round of sampleReviewLog.rounds) { + expect(round).toHaveProperty('round') + expect(round).toHaveProperty('model') + expect(round).toHaveProperty('role') + expect(round).toHaveProperty('focus') + expect(round).toHaveProperty('plan_before') + expect(round).toHaveProperty('plan_after') + expect(round).toHaveProperty('issues') + expect(round).toHaveProperty('score') + expect(round).toHaveProperty('plan_diff_lines') + expect(round).toHaveProperty('converged') + expect(round).toHaveProperty('timestamp') + + expect(typeof round.round).toBe('number') + expect(round.round).toBeGreaterThanOrEqual(0) + expect(typeof round.score).toBe('number') + expect(round.score).toBeGreaterThanOrEqual(0) + expect(round.score).toBeLessThanOrEqual(100) + expect(typeof round.plan_diff_lines).toBe('number') + expect(round.plan_diff_lines).toBeGreaterThanOrEqual(0) + } + }) + + it('should have valid issue structure per round', () => { + for (const round of sampleReviewLog.rounds) { + for (const issue of round.issues) { + expect(issue).toHaveProperty('category') + expect(issue).toHaveProperty('severity') + expect(issue).toHaveProperty('suggestion') + + expect(['structure', 'logic', 'risk', 'pattern']).toContain(issue.category) + expect(['error', 'warning', 'info']).toContain(issue.severity) + expect(typeof issue.suggestion).toBe('string') + expect(issue.suggestion.length).toBeGreaterThan(0) + } + } + }) + + it('should have valid convergence structure when present', () => { + if (sampleReviewLog.convergence) { + expect(sampleReviewLog.convergence).toHaveProperty('stable_at_round') + expect(sampleReviewLog.convergence).toHaveProperty('final_diff_pct') + expect(sampleReviewLog.convergence).toHaveProperty('convergence_metric') + + expect(typeof sampleReviewLog.convergence.stable_at_round).toBe('number') + expect(sampleReviewLog.convergence.stable_at_round).toBeGreaterThanOrEqual(0) + expect(typeof sampleReviewLog.convergence.final_diff_pct).toBe('number') + expect(sampleReviewLog.convergence.final_diff_pct).toBeGreaterThanOrEqual(0) + expect(sampleReviewLog.convergence.final_diff_pct).toBeLessThanOrEqual(100) + } + }) + + it('should have valid approval status', () => { + expect(['pending', 'approved', 'rejected']).toContain(sampleReviewLog.approval.status) + if (sampleReviewLog.approval.status !== 'pending') { + expect(sampleReviewLog.approval.timestamp).toBeDefined() + } + }) + + it('should have non-empty summary', () => { + expect(typeof sampleReviewLog.summary).toBe('string') + expect(sampleReviewLog.summary.length).toBeGreaterThan(0) + }) + }) + + describe('Convergence Detection', () => { + it('should detect convergence when diff_pct < 5% for two consecutive rounds', () => { + // Simulate convergence: round 0 has 1 diff line, rounds 1-2 have 0 diffs + const totalLines = 50 + const diff0 = 1 + const diff1 = 0 + const diff2 = 0 + + const pct0 = (diff0 / totalLines) * 100 // 2% + const pct1 = (diff1 / totalLines) * 100 // 0% + const pct2 = (diff2 / totalLines) * 100 // 0% + + expect(pct0).toBeLessThan(5) // Should converge + expect(pct1).toBeLessThan(5) // Should converge + expect(pct2).toBeLessThan(5) // Should converge + }) + + it('should not detect convergence when diff_pct >= 5%', () => { + const totalLines = 50 + const diff = 3 // 6% change + + const pct = (diff / totalLines) * 100 + expect(pct).toBeGreaterThanOrEqual(5) + }) + }) + + describe('Status Transitions', () => { + it('should transition REVIEWING_PLAN → PLAN_REVIEWED when approved', () => { + const log = { ...sampleReviewLog, approval: { status: 'approved', timestamp: new Date().toISOString() } } + expect(log.approval.status).toBe('approved') + // In actual implementation: update_idea_plan_reviewed({ approval_status: 'approved' }) + // → idea.status = 'PLAN_REVIEWED' + }) + + it('should transition REVIEWING_PLAN → PLAN_REVIEW_FAILED when rejected', () => { + const log = { ...sampleReviewLog, approval: { status: 'rejected' } } + expect(log.approval.status).toBe('rejected') + // In actual implementation: update_idea_plan_reviewed({ approval_status: 'rejected' }) + // → idea.status = 'PLAN_REVIEW_FAILED' + }) + }) +}) diff --git a/__tests__/stores/idea-store.test.ts b/__tests__/stores/idea-store.test.ts new file mode 100644 index 0000000..37d7413 --- /dev/null +++ b/__tests__/stores/idea-store.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeEach } from 'vitest' + +import { useIdeaStore } from '@/stores/idea-store' + +beforeEach(() => { + // Reset store between tests — Zustand persists state across tests otherwise. + useIdeaStore.setState({ + jobByIdea: {}, + ideaStatuses: {}, + openQuestionsByIdea: {}, + }) +}) + +describe('useIdeaStore — handleIdeaJobEvent', () => { + it('queued IDEA_GRILL → ideaStatuses[id] = grilling', () => { + useIdeaStore.getState().handleIdeaJobEvent({ + type: 'claude_job_enqueued', + job_id: 'job-1', + idea_id: 'idea-1', + user_id: 'u-1', + kind: 'IDEA_GRILL', + status: 'queued', + }) + const s = useIdeaStore.getState() + expect(s.jobByIdea['idea-1']?.status).toBe('queued') + expect(s.ideaStatuses['idea-1']).toBe('grilling') + }) + + it('failed IDEA_GRILL → ideaStatuses[id] = grill_failed', () => { + useIdeaStore.getState().handleIdeaJobEvent({ + type: 'claude_job_status', + job_id: 'job-1', + idea_id: 'idea-1', + user_id: 'u-1', + kind: 'IDEA_GRILL', + status: 'failed', + error: 'oops', + }) + expect(useIdeaStore.getState().ideaStatuses['idea-1']).toBe('grill_failed') + expect(useIdeaStore.getState().jobByIdea['idea-1']?.error).toBe('oops') + }) + + it('failed IDEA_MAKE_PLAN → plan_failed', () => { + useIdeaStore.getState().handleIdeaJobEvent({ + type: 'claude_job_status', + job_id: 'job-2', + idea_id: 'idea-2', + user_id: 'u-1', + kind: 'IDEA_MAKE_PLAN', + status: 'failed', + }) + expect(useIdeaStore.getState().ideaStatuses['idea-2']).toBe('plan_failed') + }) + + it('done does NOT auto-derive status (server is source-of-truth)', () => { + useIdeaStore.getState().setIdeaStatus('idea-3', 'grilled') + useIdeaStore.getState().handleIdeaJobEvent({ + type: 'claude_job_status', + job_id: 'job-3', + idea_id: 'idea-3', + user_id: 'u-1', + kind: 'IDEA_GRILL', + status: 'done', + }) + expect(useIdeaStore.getState().ideaStatuses['idea-3']).toBe('grilled') + }) +}) + +describe('useIdeaStore — handleIdeaQuestionEvent', () => { + it('non-open status removes question from list', () => { + useIdeaStore.getState().initQuestions('idea-1', [ + { + id: 'q-1', + idea_id: 'idea-1', + question: 'Q', + options: null, + status: 'open', + created_at: '', + expires_at: '', + }, + ]) + useIdeaStore.getState().handleIdeaQuestionEvent({ + op: 'U', + entity: 'question', + id: 'q-1', + product_id: 'p-1', + story_id: null, + idea_id: 'idea-1', + status: 'answered', + }) + expect(useIdeaStore.getState().openQuestionsByIdea['idea-1']).toEqual([]) + }) + + it('open status keeps existing list (no detail in payload)', () => { + const q = { + id: 'q-1', + idea_id: 'idea-1', + question: 'Q', + options: null, + status: 'open' as const, + created_at: '', + expires_at: '', + } + useIdeaStore.getState().initQuestions('idea-1', [q]) + useIdeaStore.getState().handleIdeaQuestionEvent({ + op: 'I', + entity: 'question', + id: 'q-2', + product_id: 'p-1', + story_id: null, + idea_id: 'idea-1', + status: 'open', + }) + // List length blijft 1 (server-fetch leveert de detail) + expect(useIdeaStore.getState().openQuestionsByIdea['idea-1']).toHaveLength(1) + }) +}) + +describe('useIdeaStore — clearForIdea', () => { + it('removes job + status + questions for one idea, leaves others', () => { + const s = useIdeaStore.getState() + s.setJobStatus({ + job_id: 'j-1', + idea_id: 'idea-1', + kind: 'IDEA_GRILL', + status: 'running', + }) + s.setJobStatus({ + job_id: 'j-2', + idea_id: 'idea-2', + kind: 'IDEA_GRILL', + status: 'running', + }) + s.setIdeaStatus('idea-1', 'grilling') + s.setIdeaStatus('idea-2', 'grilling') + + s.clearForIdea('idea-1') + + const after = useIdeaStore.getState() + expect(after.jobByIdea['idea-1']).toBeUndefined() + expect(after.jobByIdea['idea-2']).toBeDefined() + expect(after.ideaStatuses['idea-1']).toBeUndefined() + expect(after.ideaStatuses['idea-2']).toBe('grilling') + }) +}) diff --git a/__tests__/stores/product-workspace/restore.test.ts b/__tests__/stores/product-workspace/restore.test.ts new file mode 100644 index 0000000..baa8120 --- /dev/null +++ b/__tests__/stores/product-workspace/restore.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' + +import { + clearHints, + readHints, + writePbiHint, + writeProductHint, + writeStoryHint, + writeTaskHint, +} from '@/stores/product-workspace/restore' + +describe('readHints', () => { + it('retourneert lege defaults wanneer localStorage leeg is', () => { + const hints = readHints() + expect(hints.lastActiveProductId).toBeNull() + expect(hints.perProduct).toEqual({}) + }) + + it('herstelt hints uit localStorage', () => { + localStorage.setItem( + 'product-workspace-hints', + JSON.stringify({ + lastActiveProductId: 'p1', + perProduct: { p1: { lastActivePbiId: 'pbi-1' } }, + }), + ) + const hints = readHints() + expect(hints.lastActiveProductId).toBe('p1') + expect(hints.perProduct.p1.lastActivePbiId).toBe('pbi-1') + }) + + it('valt terug op defaults bij ongeldige JSON', () => { + localStorage.setItem('product-workspace-hints', '{not-json') + const hints = readHints() + expect(hints.lastActiveProductId).toBeNull() + expect(hints.perProduct).toEqual({}) + }) + + it('valt terug op defaults bij verkeerde shape', () => { + localStorage.setItem('product-workspace-hints', '"just a string"') + const hints = readHints() + expect(hints.perProduct).toEqual({}) + }) +}) + +describe('writeProductHint', () => { + it('schrijft lastActiveProductId', () => { + writeProductHint('p1') + expect(readHints().lastActiveProductId).toBe('p1') + }) + + it('overschrijft bestaande waarde', () => { + writeProductHint('p1') + writeProductHint('p2') + expect(readHints().lastActiveProductId).toBe('p2') + }) + + it('accepteert null om hint te wissen', () => { + writeProductHint('p1') + writeProductHint(null) + expect(readHints().lastActiveProductId).toBeNull() + }) +}) + +describe('writePbiHint', () => { + it('schrijft lastActivePbiId per productId', () => { + writePbiHint('prod-1', 'pbi-a') + writePbiHint('prod-2', 'pbi-b') + const hints = readHints() + expect(hints.perProduct['prod-1'].lastActivePbiId).toBe('pbi-a') + expect(hints.perProduct['prod-2'].lastActivePbiId).toBe('pbi-b') + }) + + it('null wist child story- en task-hints', () => { + writePbiHint('prod-1', 'pbi-1') + writeStoryHint('prod-1', 's-1') + writeTaskHint('prod-1', 't-1') + writePbiHint('prod-1', null) + const hints = readHints() + expect(hints.perProduct['prod-1'].lastActivePbiId).toBeNull() + expect(hints.perProduct['prod-1'].lastActiveStoryId).toBeNull() + expect(hints.perProduct['prod-1'].lastActiveTaskId).toBeNull() + }) +}) + +describe('writeStoryHint', () => { + it('schrijft lastActiveStoryId per productId', () => { + writeStoryHint('prod-1', 's-1') + expect(readHints().perProduct['prod-1'].lastActiveStoryId).toBe('s-1') + }) + + it('null wist child task-hint', () => { + writeStoryHint('prod-1', 's-1') + writeTaskHint('prod-1', 't-1') + writeStoryHint('prod-1', null) + expect(readHints().perProduct['prod-1'].lastActiveStoryId).toBeNull() + expect(readHints().perProduct['prod-1'].lastActiveTaskId).toBeNull() + }) +}) + +describe('writeTaskHint', () => { + it('schrijft lastActiveTaskId per productId', () => { + writeTaskHint('prod-1', 't-1') + expect(readHints().perProduct['prod-1'].lastActiveTaskId).toBe('t-1') + }) +}) + +describe('clearHints', () => { + it('verwijdert alle hints', () => { + writeProductHint('p1') + writePbiHint('p1', 'pbi-1') + clearHints() + const hints = readHints() + expect(hints.lastActiveProductId).toBeNull() + expect(hints.perProduct).toEqual({}) + }) +}) diff --git a/__tests__/stores/product-workspace/screen-state.test.ts b/__tests__/stores/product-workspace/screen-state.test.ts new file mode 100644 index 0000000..7463fff --- /dev/null +++ b/__tests__/stores/product-workspace/screen-state.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest' +import { + deriveScreenState, + type ScreenStateInput, +} from '@/stores/product-workspace/screen-state' + +const base: ScreenStateInput = { + activeSprintItem: null, + buildingSprintIds: [], + hasPendingDraft: false, + pendingAdds: [], + pendingRemoves: [], +} + +describe('deriveScreenState', () => { + it('returns NO_SPRINT without draft or active sprint', () => { + expect(deriveScreenState(base)).toEqual({ kind: 'NO_SPRINT' }) + }) + + it('returns DRAFT when a pending draft exists', () => { + expect(deriveScreenState({ ...base, hasPendingDraft: true })).toEqual({ + kind: 'DRAFT', + }) + }) + + it('lets a draft win over an active sprint with pending changes', () => { + expect( + deriveScreenState({ + ...base, + hasPendingDraft: true, + activeSprintItem: { id: 's1' }, + pendingAdds: ['x'], + }), + ).toEqual({ kind: 'DRAFT' }) + }) + + it('returns ACTIVE for an active sprint with no pending changes', () => { + expect( + deriveScreenState({ ...base, activeSprintItem: { id: 's1' } }), + ).toEqual({ kind: 'ACTIVE', building: false }) + }) + + it('flags building when the active sprint is in buildingSprintIds', () => { + expect( + deriveScreenState({ + ...base, + activeSprintItem: { id: 's1' }, + buildingSprintIds: ['s1'], + }), + ).toEqual({ kind: 'ACTIVE', building: true }) + }) + + it('returns EDITING when there are pending adds', () => { + expect( + deriveScreenState({ + ...base, + activeSprintItem: { id: 's1' }, + pendingAdds: ['x'], + }), + ).toEqual({ kind: 'EDITING', building: false }) + }) + + it('returns EDITING when there are pending removes', () => { + expect( + deriveScreenState({ + ...base, + activeSprintItem: { id: 's1' }, + pendingRemoves: ['y'], + }), + ).toEqual({ kind: 'EDITING', building: false }) + }) + + it('flags building on EDITING when the active sprint is building', () => { + expect( + deriveScreenState({ + ...base, + activeSprintItem: { id: 's1' }, + pendingAdds: ['x'], + buildingSprintIds: ['s1'], + }), + ).toEqual({ kind: 'EDITING', building: true }) + }) +}) diff --git a/__tests__/stores/product-workspace/sprint-membership.test.ts b/__tests__/stores/product-workspace/sprint-membership.test.ts new file mode 100644 index 0000000..6f271de --- /dev/null +++ b/__tests__/stores/product-workspace/sprint-membership.test.ts @@ -0,0 +1,341 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import { + selectIsDirty, + selectPbiTriState, + selectPendingCount, + selectStoryEffectiveInSprint, + selectStoryIsBlocked, +} from '@/stores/product-workspace/selectors' +import type { BacklogStory } from '@/stores/product-workspace/types' + +function resetMembership() { + useProductWorkspaceStore.setState((s) => { + s.entities.storiesById = {} + s.relations.storyIdsByPbi = {} + s.sprintMembership = { + pbiSummary: {}, + crossSprintBlocks: {}, + pending: { adds: [], removes: [] }, + loadedSummaryForSprintId: null, + } + }) +} + +function seedStory(id: string, pbiId: string, sprintId: string | null): BacklogStory { + return { + id, + code: id, + title: id, + description: null, + acceptance_criteria: null, + priority: 2, + sort_order: 1, + status: sprintId ? 'IN_SPRINT' : 'OPEN', + pbi_id: pbiId, + sprint_id: sprintId, + created_at: new Date('2026-01-01'), + } +} + +beforeEach(() => { + resetMembership() +}) + +describe('toggleStorySprintMembership', () => { + it('adds storyId to pending.adds when currently not in sprint', () => { + useProductWorkspaceStore.getState().toggleStorySprintMembership('s1', false) + const pending = useProductWorkspaceStore.getState().sprintMembership.pending + expect(pending.adds).toEqual(['s1']) + expect(pending.removes).toEqual([]) + }) + + it('adds storyId to pending.removes when currently in sprint', () => { + useProductWorkspaceStore.getState().toggleStorySprintMembership('s1', true) + const pending = useProductWorkspaceStore.getState().sprintMembership.pending + expect(pending.removes).toEqual(['s1']) + expect(pending.adds).toEqual([]) + }) + + it('cancels out: toggle add → toggle remove same story (in-sprint) clears pending', () => { + const store = useProductWorkspaceStore.getState() + store.toggleStorySprintMembership('s1', false) // adds + // Story now appears to be "in sprint" via pending; calling with true should cancel + store.toggleStorySprintMembership('s1', false) // second click with same baseline + const pending = useProductWorkspaceStore.getState().sprintMembership.pending + expect(pending.adds).toEqual([]) + expect(pending.removes).toEqual([]) + }) + + it('removes from pending.removes when toggled back', () => { + const store = useProductWorkspaceStore.getState() + store.toggleStorySprintMembership('s1', true) + store.toggleStorySprintMembership('s1', true) + const pending = useProductWorkspaceStore.getState().sprintMembership.pending + expect(pending.removes).toEqual([]) + expect(pending.adds).toEqual([]) + }) + + it('resetSprintMembershipPending empties both arrays', () => { + const store = useProductWorkspaceStore.getState() + store.toggleStorySprintMembership('s1', false) + store.toggleStorySprintMembership('s2', true) + store.resetSprintMembershipPending() + const pending = useProductWorkspaceStore.getState().sprintMembership.pending + expect(pending.adds).toEqual([]) + expect(pending.removes).toEqual([]) + }) +}) + +describe('selectPbiTriState', () => { + function seedSummary(pbiId: string, total: number, inSprint: number) { + useProductWorkspaceStore.setState((s) => { + s.sprintMembership.pbiSummary[pbiId] = { + totalStoryCount: total, + inActiveSprintStoryCount: inSprint, + } + }) + } + + it('returns empty for PBI without summary', () => { + expect( + selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), + ).toBe('empty') + }) + + it('returns empty when totalStoryCount == 0', () => { + seedSummary('pbi-1', 0, 0) + expect( + selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), + ).toBe('empty') + }) + + it('returns full when all stories in sprint (no pending)', () => { + seedSummary('pbi-1', 3, 3) + expect( + selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), + ).toBe('full') + }) + + it('returns partial when some stories in sprint', () => { + seedSummary('pbi-1', 3, 2) + expect( + selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), + ).toBe('partial') + }) + + it('returns empty when inSprint == 0', () => { + seedSummary('pbi-1', 3, 0) + expect( + selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), + ).toBe('empty') + }) + + it('applies pending adds when stories are loaded for the PBI', () => { + seedSummary('pbi-1', 3, 1) + useProductWorkspaceStore.setState((s) => { + s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3'] + s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-1') + s.entities.storiesById['s2'] = seedStory('s2', 'pbi-1', null) + s.entities.storiesById['s3'] = seedStory('s3', 'pbi-1', null) + s.sprintMembership.pending.adds = ['s2', 's3'] + }) + expect( + selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), + ).toBe('full') + }) + + it('applies pending removes when stories are loaded for the PBI', () => { + seedSummary('pbi-1', 3, 3) + useProductWorkspaceStore.setState((s) => { + s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3'] + s.sprintMembership.pending.removes = ['s2'] + }) + expect( + selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), + ).toBe('partial') + }) + + it('ignores pending entries for stories of other PBIs', () => { + seedSummary('pbi-1', 3, 3) + useProductWorkspaceStore.setState((s) => { + s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3'] + s.sprintMembership.pending.removes = ['s99'] // not in pbi-1 + }) + expect( + selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), + ).toBe('full') + }) +}) + +describe('selectStoryEffectiveInSprint', () => { + it('returns true when story.sprint_id matches activeSprintId and no pending', () => { + useProductWorkspaceStore.setState((s) => { + s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A') + }) + expect( + selectStoryEffectiveInSprint( + useProductWorkspaceStore.getState(), + 's1', + 'sprint-A', + ), + ).toBe(true) + }) + + it('returns false when story.sprint_id is null', () => { + useProductWorkspaceStore.setState((s) => { + s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', null) + }) + expect( + selectStoryEffectiveInSprint( + useProductWorkspaceStore.getState(), + 's1', + 'sprint-A', + ), + ).toBe(false) + }) + + it('returns true when story in pending.adds even if DB says no', () => { + useProductWorkspaceStore.setState((s) => { + s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', null) + s.sprintMembership.pending.adds = ['s1'] + }) + expect( + selectStoryEffectiveInSprint( + useProductWorkspaceStore.getState(), + 's1', + 'sprint-A', + ), + ).toBe(true) + }) + + it('returns false when story in pending.removes even if DB says yes', () => { + useProductWorkspaceStore.setState((s) => { + s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A') + s.sprintMembership.pending.removes = ['s1'] + }) + expect( + selectStoryEffectiveInSprint( + useProductWorkspaceStore.getState(), + 's1', + 'sprint-A', + ), + ).toBe(false) + }) + + it('returns false when activeSprintId is null', () => { + useProductWorkspaceStore.setState((s) => { + s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A') + }) + expect( + selectStoryEffectiveInSprint( + useProductWorkspaceStore.getState(), + 's1', + null, + ), + ).toBe(false) + }) +}) + +describe('selectStoryIsBlocked', () => { + it('returns null when no block', () => { + expect( + selectStoryIsBlocked(useProductWorkspaceStore.getState(), 's1'), + ).toBeNull() + }) + + it('returns block info when story is in another sprint', () => { + useProductWorkspaceStore.setState((s) => { + s.sprintMembership.crossSprintBlocks['s1'] = { + sprintId: 'sprint-x', + sprintName: 'SP-X', + } + }) + expect( + selectStoryIsBlocked(useProductWorkspaceStore.getState(), 's1'), + ).toEqual({ sprintId: 'sprint-x', sprintName: 'SP-X' }) + }) +}) + +describe('selectIsDirty + selectPendingCount', () => { + it('clean by default', () => { + expect(selectIsDirty(useProductWorkspaceStore.getState())).toBe(false) + expect(selectPendingCount(useProductWorkspaceStore.getState())).toBe(0) + }) + + it('counts adds + removes', () => { + useProductWorkspaceStore.setState((s) => { + s.sprintMembership.pending = { + adds: ['a1', 'a2'], + removes: ['r1'], + } + }) + expect(selectIsDirty(useProductWorkspaceStore.getState())).toBe(true) + expect(selectPendingCount(useProductWorkspaceStore.getState())).toBe(3) + }) +}) + +describe('fetch helpers', () => { + it('fetchSprintMembershipSummary populates store and gates by sprintId', async () => { + const originalFetch = globalThis.fetch + const responseBody = { + pbiA: { totalStoryCount: 5, inActiveSprintStoryCount: 2 }, + } + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(responseBody), { status: 200 }), + ) as unknown as typeof fetch + try { + await useProductWorkspaceStore + .getState() + .fetchSprintMembershipSummary('prod-1', 'sprint-A', ['pbiA']) + + const slice = useProductWorkspaceStore.getState().sprintMembership + expect(slice.pbiSummary.pbiA).toEqual({ + totalStoryCount: 5, + inActiveSprintStoryCount: 2, + }) + expect(slice.loadedSummaryForSprintId).toBe('sprint-A') + } finally { + globalThis.fetch = originalFetch + } + }) + + it('fetchCrossSprintBlocks populates store', async () => { + const originalFetch = globalThis.fetch + const responseBody = { + 's1': { sprintId: 'sprint-x', sprintName: 'SP-X' }, + } + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(responseBody), { status: 200 }), + ) as unknown as typeof fetch + try { + await useProductWorkspaceStore + .getState() + .fetchCrossSprintBlocks('prod-1', 'sprint-A', ['pbiA']) + + const slice = useProductWorkspaceStore.getState().sprintMembership + expect(slice.crossSprintBlocks['s1']).toEqual({ + sprintId: 'sprint-x', + sprintName: 'SP-X', + }) + } finally { + globalThis.fetch = originalFetch + } + }) + + it('fetchSprintMembershipSummary is a no-op for empty pbiIds', async () => { + const fetchSpy = vi.fn() + const originalFetch = globalThis.fetch + globalThis.fetch = fetchSpy as unknown as typeof fetch + try { + await useProductWorkspaceStore + .getState() + .fetchSprintMembershipSummary('prod-1', 'sprint-A', []) + expect(fetchSpy).not.toHaveBeenCalled() + } finally { + globalThis.fetch = originalFetch + } + }) +}) diff --git a/__tests__/stores/product-workspace/store.test.ts b/__tests__/stores/product-workspace/store.test.ts new file mode 100644 index 0000000..ff86cfc --- /dev/null +++ b/__tests__/stores/product-workspace/store.test.ts @@ -0,0 +1,890 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import type { + BacklogPbi, + BacklogStory, + BacklogTask, + ProductBacklogSnapshot, + TaskDetail, +} from '@/stores/product-workspace/types' + +// G5: snapshot original actions on module-load; restore in beforeEach. +// vi.fn-spies on actions could leak across tests otherwise. +const originalActions = (() => { + const s = useProductWorkspaceStore.getState() + return { + hydrateSnapshot: s.hydrateSnapshot, + setActiveProduct: s.setActiveProduct, + setActivePbi: s.setActivePbi, + setActiveStory: s.setActiveStory, + setActiveTask: s.setActiveTask, + ensureProductLoaded: s.ensureProductLoaded, + ensurePbiLoaded: s.ensurePbiLoaded, + ensureStoryLoaded: s.ensureStoryLoaded, + ensureTaskLoaded: s.ensureTaskLoaded, + applyRealtimeEvent: s.applyRealtimeEvent, + resyncActiveScopes: s.resyncActiveScopes, + resyncLoadedScopes: s.resyncLoadedScopes, + applyOptimisticMutation: s.applyOptimisticMutation, + rollbackMutation: s.rollbackMutation, + settleMutation: s.settleMutation, + setRealtimeStatus: s.setRealtimeStatus, + } +})() + +function resetStore() { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = null + s.context.activePbiId = null + s.context.activeStoryId = null + s.context.activeTaskId = null + s.entities.pbisById = {} + s.entities.storiesById = {} + s.entities.tasksById = {} + s.relations.pbiIds = [] + s.relations.storyIdsByPbi = {} + s.relations.taskIdsByStory = {} + s.loading.loadedProductId = null + s.loading.loadingProductId = null + s.loading.loadedPbiIds = {} + s.loading.loadedStoryIds = {} + s.loading.loadedTaskIds = {} + s.loading.activeRequestId = null + s.sync.realtimeStatus = 'connecting' + s.sync.lastEventAt = null + s.sync.lastResyncAt = null + s.sync.resyncReason = null + s.pendingMutations = {} + s.sprintMembership = { + pbiSummary: {}, + crossSprintBlocks: {}, + pending: { adds: [], removes: [] }, + loadedSummaryForSprintId: null, + } + Object.assign(s, originalActions) + }) +} + +beforeEach(() => { + resetStore() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +function makePbi(overrides: Partial & { id: string }): BacklogPbi { + return { + id: overrides.id, + code: overrides.code ?? overrides.id, + title: overrides.title ?? `PBI ${overrides.id}`, + priority: overrides.priority ?? 2, + sort_order: overrides.sort_order ?? 1, + description: overrides.description ?? null, + created_at: overrides.created_at ?? new Date('2026-01-01'), + status: overrides.status ?? 'ready', + } +} + +function makeStory(overrides: Partial & { id: string; pbi_id: string }): BacklogStory { + return { + id: overrides.id, + code: overrides.code ?? overrides.id, + title: overrides.title ?? `Story ${overrides.id}`, + description: overrides.description ?? null, + acceptance_criteria: overrides.acceptance_criteria ?? null, + priority: overrides.priority ?? 2, + sort_order: overrides.sort_order ?? 1, + status: overrides.status ?? 'OPEN', + pbi_id: overrides.pbi_id, + sprint_id: overrides.sprint_id ?? null, + created_at: overrides.created_at ?? new Date('2026-01-01'), + } +} + +function makeTask(overrides: Partial & { id: string; story_id: string }): BacklogTask { + return { + id: overrides.id, + code: overrides.code ?? null, + title: overrides.title ?? `Task ${overrides.id}`, + description: overrides.description ?? null, + priority: overrides.priority ?? 2, + sort_order: overrides.sort_order ?? 1, + status: overrides.status ?? 'TO_DO', + story_id: overrides.story_id, + created_at: overrides.created_at ?? new Date('2026-01-01'), + } +} + +function snapshotWith( + pbis: BacklogPbi[], + storiesByPbi: Record = {}, + tasksByStory: Record = {}, + product?: { id: string; name: string }, +): ProductBacklogSnapshot { + return { product, pbis, storiesByPbi, tasksByStory } +} + +// G7: mock fetch — never let it fall through to real network +// G8: mockImplementation per call so each fetch gets a fresh Response +function mockFetchSequence( + responses: Array unknown)>, +) { + let i = 0 + return vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string, init?: RequestInit) => { + const r = responses[Math.min(i, responses.length - 1)] + i += 1 + const body = typeof r === 'function' ? (r as (u: string, i?: RequestInit) => unknown)(url, init) : r + return new Response(JSON.stringify(body ?? null), { status: 200 }) + }) as unknown as typeof fetch) +} + +// ───────────────────────────────────────────────────────────────────────── +// hydrateSnapshot +// ───────────────────────────────────────────────────────────────────────── + +describe('hydrateSnapshot', () => { + it('vult entities en relations met gesorteerde id-lijsten', () => { + const pbiA = makePbi({ id: 'pbi-a', priority: 2, sort_order: 2 }) + const pbiB = makePbi({ id: 'pbi-b', priority: 1, sort_order: 5 }) + const pbiC = makePbi({ id: 'pbi-c', priority: 2, sort_order: 1 }) + const storyB1 = makeStory({ id: 'st-1', pbi_id: 'pbi-b', sort_order: 2 }) + const storyB2 = makeStory({ id: 'st-2', pbi_id: 'pbi-b', sort_order: 1 }) + const taskA = makeTask({ id: 'tk-2', story_id: 'st-1', sort_order: 2 }) + const taskB = makeTask({ id: 'tk-1', story_id: 'st-1', sort_order: 1 }) + + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + [pbiA, pbiB, pbiC], + { 'pbi-b': [storyB1, storyB2] }, + { 'st-1': [taskA, taskB] }, + { id: 'prod-1', name: 'Product 1' }, + ), + ) + + const s = useProductWorkspaceStore.getState() + expect(s.entities.pbisById['pbi-a']).toBe(pbiA) + expect(s.entities.pbisById['pbi-b']).toBe(pbiB) + expect(s.entities.pbisById['pbi-c']).toBe(pbiC) + // pbi-b heeft priority 1 (komt eerst), dan pbi-c (sort_order 1) en pbi-a (sort_order 2) + expect(s.relations.pbiIds).toEqual(['pbi-b', 'pbi-c', 'pbi-a']) + expect(s.relations.storyIdsByPbi['pbi-b']).toEqual(['st-2', 'st-1']) + expect(s.relations.taskIdsByStory['st-1']).toEqual(['tk-1', 'tk-2']) + expect(s.context.activeProduct).toEqual({ id: 'prod-1', name: 'Product 1' }) + expect(s.loading.loadedProductId).toBe('prod-1') + }) + + it('normaliseert API-statussen naar het interne store-contract', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + [makePbi({ id: 'pbi-1', status: 'READY' as BacklogPbi['status'] })], + { + 'pbi-1': [ + makeStory({ id: 'st-1', pbi_id: 'pbi-1', status: 'in_sprint' }), + ], + }, + { + 'st-1': [makeTask({ id: 'tk-1', story_id: 'st-1', status: 'todo' })], + }, + ), + ) + + const s = useProductWorkspaceStore.getState() + expect(s.entities.pbisById['pbi-1'].status).toBe('ready') + expect(s.entities.storiesById['st-1'].status).toBe('IN_SPRINT') + expect(s.entities.tasksById['tk-1'].status).toBe('TO_DO') + }) + + it('reset bestaande entities en relations bij her-hydratie', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([makePbi({ id: 'old-pbi' })]), + ) + expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['old-pbi']) + + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([makePbi({ id: 'new-pbi' })]), + ) + const s = useProductWorkspaceStore.getState() + expect(s.entities.pbisById['old-pbi']).toBeUndefined() + expect(s.entities.pbisById['new-pbi']).toBeDefined() + expect(s.relations.pbiIds).toEqual(['new-pbi']) + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// Selection cascade +// ───────────────────────────────────────────────────────────────────────── + +describe('selection cascade', () => { + it('setActivePbi reset story+task; setActiveStory reset task', () => { + useProductWorkspaceStore.setState((s) => { + s.context.activePbiId = 'pbi-old' + s.context.activeStoryId = 'st-old' + s.context.activeTaskId = 'tk-old' + }) + + useProductWorkspaceStore.getState().setActivePbi('pbi-new') + let s = useProductWorkspaceStore.getState() + expect(s.context.activePbiId).toBe('pbi-new') + expect(s.context.activeStoryId).toBeNull() + expect(s.context.activeTaskId).toBeNull() + + useProductWorkspaceStore.setState((draft) => { + draft.context.activeStoryId = 'st-old' + draft.context.activeTaskId = 'tk-old' + }) + useProductWorkspaceStore.getState().setActiveStory('st-new') + s = useProductWorkspaceStore.getState() + expect(s.context.activeStoryId).toBe('st-new') + expect(s.context.activeTaskId).toBeNull() + }) + + it('setActiveProduct(null) ruimt entities en relations op', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + [makePbi({ id: 'p-1' })], + { 'p-1': [makeStory({ id: 's-1', pbi_id: 'p-1' })] }, + { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] }, + { id: 'prod-1', name: 'Product 1' }, + ), + ) + + useProductWorkspaceStore.getState().setActiveProduct(null) + const s = useProductWorkspaceStore.getState() + expect(s.context.activeProduct).toBeNull() + expect(s.context.activePbiId).toBeNull() + expect(s.context.activeStoryId).toBeNull() + expect(s.context.activeTaskId).toBeNull() + expect(s.entities.pbisById).toEqual({}) + expect(s.entities.storiesById).toEqual({}) + expect(s.entities.tasksById).toEqual({}) + expect(s.relations.pbiIds).toEqual([]) + expect(s.relations.storyIdsByPbi).toEqual({}) + expect(s.relations.taskIdsByStory).toEqual({}) + expect(s.loading.loadedProductId).toBeNull() + }) + + it('setActiveProduct kan alleen context zetten zonder full backlog load', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + [makePbi({ id: 'p-1' })], + { 'p-1': [makeStory({ id: 's-1', pbi_id: 'p-1' })] }, + { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] }, + { id: 'prod-1', name: 'Product 1' }, + ), + ) + useProductWorkspaceStore.setState((s) => { + s.context.activePbiId = 'p-1' + s.context.activeStoryId = 's-1' + }) + const fetchSpy = vi.spyOn(globalThis, 'fetch') + + useProductWorkspaceStore + .getState() + .setActiveProduct( + { id: 'prod-1', name: 'Product 1' }, + { load: false, preserveSelection: true }, + ) + + const s = useProductWorkspaceStore.getState() + expect(fetchSpy).not.toHaveBeenCalled() + expect(s.context.activePbiId).toBe('p-1') + expect(s.context.activeStoryId).toBe('s-1') + expect(s.entities.pbisById['p-1']).toBeDefined() + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// applyRealtimeEvent +// ───────────────────────────────────────────────────────────────────────── + +describe('applyRealtimeEvent — pbi', () => { + beforeEach(() => { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + }) + + it('I — voegt PBI toe en sorteert', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([makePbi({ id: 'a', priority: 2, sort_order: 5 })]), + ) + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'pbi', + op: 'I', + id: 'b', + product_id: 'prod-1', + code: 'B', + title: 'New PBI', + priority: 1, + sort_order: 1, + created_at: new Date('2026-02-01').toISOString(), + status: 'ready', + }) + const s = useProductWorkspaceStore.getState() + expect(s.entities.pbisById['b']).toBeDefined() + expect(s.relations.pbiIds).toEqual(['b', 'a']) + }) + + it('I — idempotent voor bestaande id', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([makePbi({ id: 'a' })]), + ) + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'pbi', + op: 'I', + id: 'a', + product_id: 'prod-1', + title: 'mutated', + }) + const s = useProductWorkspaceStore.getState() + expect(s.entities.pbisById['a'].title).toBe('PBI a') // niet overschreven + expect(s.relations.pbiIds).toEqual(['a']) + }) + + it('U — patch + her-sorteert', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([ + makePbi({ id: 'a', priority: 2, sort_order: 1 }), + makePbi({ id: 'b', priority: 2, sort_order: 2 }), + ]), + ) + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'pbi', + op: 'U', + id: 'b', + product_id: 'prod-1', + priority: 1, + }) + const s = useProductWorkspaceStore.getState() + expect(s.relations.pbiIds).toEqual(['b', 'a']) + }) + + it('D — verwijdert PBI inclusief child stories en tasks', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + [makePbi({ id: 'p1' })], + { p1: [makeStory({ id: 's1', pbi_id: 'p1' })] }, + { s1: [makeTask({ id: 't1', story_id: 's1' })] }, + ), + ) + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'pbi', + op: 'D', + id: 'p1', + product_id: 'prod-1', + }) + const s = useProductWorkspaceStore.getState() + expect(s.entities.pbisById['p1']).toBeUndefined() + expect(s.entities.storiesById['s1']).toBeUndefined() + expect(s.entities.tasksById['t1']).toBeUndefined() + expect(s.relations.pbiIds).toEqual([]) + expect(s.relations.storyIdsByPbi['p1']).toBeUndefined() + expect(s.relations.taskIdsByStory['s1']).toBeUndefined() + }) + + it('D — clear actieve PBI selectie als die onder de verwijderde PBI viel', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([makePbi({ id: 'p1' })]), + ) + useProductWorkspaceStore.setState((s) => { + s.context.activePbiId = 'p1' + s.context.activeStoryId = 's-x' + s.context.activeTaskId = 't-x' + }) + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'pbi', + op: 'D', + id: 'p1', + product_id: 'prod-1', + }) + const s = useProductWorkspaceStore.getState() + expect(s.context.activePbiId).toBeNull() + expect(s.context.activeStoryId).toBeNull() + expect(s.context.activeTaskId).toBeNull() + }) +}) + +describe('applyRealtimeEvent — story parent-move', () => { + beforeEach(() => { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + [makePbi({ id: 'p1' }), makePbi({ id: 'p2' })], + { + p1: [makeStory({ id: 's1', pbi_id: 'p1' })], + p2: [], + }, + ), + ) + }) + + it('U met andere pbi_id verplaatst story naar nieuwe parent-lijst', () => { + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'story', + op: 'U', + id: 's1', + product_id: 'prod-1', + pbi_id: 'p2', + }) + const s = useProductWorkspaceStore.getState() + expect(s.relations.storyIdsByPbi['p1']).toEqual([]) + expect(s.relations.storyIdsByPbi['p2']).toEqual(['s1']) + expect(s.entities.storiesById['s1'].pbi_id).toBe('p2') + }) +}) + +describe('applyRealtimeEvent — task parent-move', () => { + beforeEach(() => { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + [makePbi({ id: 'p1' })], + { p1: [makeStory({ id: 's1', pbi_id: 'p1' }), makeStory({ id: 's2', pbi_id: 'p1' })] }, + { + s1: [makeTask({ id: 't1', story_id: 's1' })], + s2: [], + }, + ), + ) + }) + + it('U met andere story_id verplaatst task naar nieuwe parent', () => { + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'task', + op: 'U', + id: 't1', + product_id: 'prod-1', + story_id: 's2', + }) + const s = useProductWorkspaceStore.getState() + expect(s.relations.taskIdsByStory['s1']).toEqual([]) + expect(s.relations.taskIdsByStory['s2']).toEqual(['t1']) + expect(s.entities.tasksById['t1'].story_id).toBe('s2') + }) +}) + +describe('applyRealtimeEvent — andere product genegeerd', () => { + it('event met ander product_id raakt de store niet', () => { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([makePbi({ id: 'a' })]), + ) + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'pbi', + op: 'I', + id: 'b', + product_id: 'prod-2', + title: 'Other product', + priority: 1, + sort_order: 1, + }) + const s = useProductWorkspaceStore.getState() + expect(s.entities.pbisById['b']).toBeUndefined() + expect(s.relations.pbiIds).toEqual(['a']) + }) +}) + +describe('applyRealtimeEvent — unknown entity → resync trigger', () => { + function withSpy(): ReturnType { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + const spy = vi.fn().mockResolvedValue(undefined) + useProductWorkspaceStore.setState((s) => { + s.resyncActiveScopes = spy as unknown as typeof s.resyncActiveScopes + }) + return spy + } + + it('unknown entity (b.v. comment) met matching product triggert resync', () => { + const spy = withSpy() + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'comment', + op: 'I', + id: 'cm-1', + product_id: 'prod-1', + } as unknown as Record) + expect(spy).toHaveBeenCalledWith('unknown-event') + }) + + it('unknown entity met ander product_id triggert geen resync', () => { + const spy = withSpy() + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'comment', + op: 'I', + id: 'cm-1', + product_id: 'prod-2', + } as unknown as Record) + expect(spy).not.toHaveBeenCalled() + }) + + it('claude_job_status (type-veld) triggert geen resync', () => { + const spy = withSpy() + useProductWorkspaceStore.getState().applyRealtimeEvent({ + type: 'claude_job_status', + job_id: 'job-1', + product_id: 'prod-1', + status: 'queued', + } as unknown as Record) + expect(spy).not.toHaveBeenCalled() + }) + + it('worker_heartbeat (type-veld) triggert geen resync', () => { + const spy = withSpy() + useProductWorkspaceStore.getState().applyRealtimeEvent({ + type: 'worker_heartbeat', + worker_id: 'w-1', + product_id: 'prod-1', + } as unknown as Record) + expect(spy).not.toHaveBeenCalled() + }) + + it('claude_job_enqueued (type-veld) triggert geen resync', () => { + const spy = withSpy() + useProductWorkspaceStore.getState().applyRealtimeEvent({ + type: 'claude_job_enqueued', + job_id: 'job-2', + product_id: 'prod-1', + kind: 'PER_TASK', + } as unknown as Record) + expect(spy).not.toHaveBeenCalled() + }) + + it('payload zonder entity en zonder type wordt genegeerd', () => { + const spy = withSpy() + useProductWorkspaceStore.getState().applyRealtimeEvent({ + product_id: 'prod-1', + something: 'else', + } as unknown as Record) + expect(spy).not.toHaveBeenCalled() + }) + + it('question-event met entity-veld maar zonder pbi/story/task triggert resync', () => { + // question is geen pbi/story/task entity dus telt als unknown wanneer + // hij geen 'type' draagt — dat zou een nieuwe entiteit kunnen zijn die + // we nog niet kennen. + const spy = withSpy() + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'question', + op: 'I', + id: 'q-1', + product_id: 'prod-1', + } as unknown as Record) + expect(spy).toHaveBeenCalledWith('unknown-event') + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// ensure*Loaded fetches + race-safe guard + sortering +// ───────────────────────────────────────────────────────────────────────── + +describe('ensureProductLoaded', () => { + it('fetcht backlog snapshot en hydreert met sortering', async () => { + const snapshot: ProductBacklogSnapshot = { + product: { id: 'prod-1', name: 'Product 1' }, + pbis: [ + makePbi({ id: 'a', priority: 2, sort_order: 5 }), + makePbi({ id: 'b', priority: 1, sort_order: 9 }), + ], + storiesByPbi: {}, + tasksByStory: {}, + } + const fetchSpy = mockFetchSequence([snapshot]) + + await useProductWorkspaceStore.getState().ensureProductLoaded('prod-1') + + expect(fetchSpy).toHaveBeenCalledWith( + '/api/products/prod-1/backlog', + expect.objectContaining({ cache: 'no-store' }), + ) + const s = useProductWorkspaceStore.getState() + expect(s.relations.pbiIds).toEqual(['b', 'a']) + expect(s.loading.loadedProductId).toBe('prod-1') + expect(s.loading.loadedPbiIds['a']).toBe(true) + expect(s.loading.loadedPbiIds['b']).toBe(true) + }) +}) + +describe('race-safe ensure*Loaded — activeRequestId guard', () => { + it('oudere in-flight ensurePbiLoaded mag nieuwere selectie niet overschrijven', async () => { + let resolveOld: ((stories: BacklogStory[]) => void) | null = null + + vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string) => { + if (url === '/api/pbis/pbi-old/stories') { + const stories = await new Promise((resolve) => { + resolveOld = resolve + }) + return new Response(JSON.stringify(stories), { status: 200 }) + } + if (url === '/api/pbis/pbi-new/stories') { + return new Response( + JSON.stringify([makeStory({ id: 'new-st', pbi_id: 'pbi-new' })]), + { status: 200 }, + ) + } + return new Response('null', { status: 200 }) + }) as unknown as typeof fetch) + + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + s.context.activePbiId = 'pbi-old' + s.loading.activeRequestId = 'req-old' + }) + const oldPromise = useProductWorkspaceStore + .getState() + .ensurePbiLoaded('pbi-old', 'req-old') + + // gebruiker selecteert ondertussen pbi-new + useProductWorkspaceStore.setState((s) => { + s.context.activePbiId = 'pbi-new' + s.loading.activeRequestId = 'req-new' + }) + await useProductWorkspaceStore.getState().ensurePbiLoaded('pbi-new', 'req-new') + + expect(useProductWorkspaceStore.getState().entities.storiesById['new-st']).toBeDefined() + + // resolve de oude fetch — guard moet de stale data weigeren + resolveOld!([makeStory({ id: 'old-st', pbi_id: 'pbi-old' })]) + await oldPromise + + const s = useProductWorkspaceStore.getState() + expect(s.context.activePbiId).toBe('pbi-new') + expect(s.entities.storiesById['old-st']).toBeUndefined() + expect(s.entities.storiesById['new-st']).toBeDefined() + }) +}) + +describe('ensureTaskLoaded — zet detail-flag', () => { + it('verrijkt task naar TaskDetail met _detail: true', async () => { + mockFetchSequence([ + { + id: 't-1', + title: 'Task 1', + description: 'desc', + priority: 1, + sort_order: 1, + status: 'todo', + story_id: 's-1', + created_at: new Date('2026-02-01').toISOString(), + implementation_plan: 'detailed plan here', + }, + ]) + + await useProductWorkspaceStore.getState().ensureTaskLoaded('t-1') + const task = useProductWorkspaceStore.getState().entities.tasksById['t-1'] as TaskDetail + expect(task._detail).toBe(true) + expect(task.status).toBe('TO_DO') + expect(task.implementation_plan).toBe('detailed plan here') + expect(useProductWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true) + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// resyncActiveScopes +// ───────────────────────────────────────────────────────────────────────── + +describe('resyncActiveScopes', () => { + it('triggert ensure-keten voor alle actieve scopes en zet sync velden', async () => { + const fetchSpy = mockFetchSequence([ + // ensureProductLoaded + { product: { id: 'prod-1', name: 'P' }, pbis: [], storiesByPbi: {}, tasksByStory: {} }, + // ensurePbiLoaded + [], + // ensureStoryLoaded + [], + // ensureTaskLoaded + { + id: 't-1', + title: 'T', + description: null, + priority: 1, + sort_order: 1, + status: 'todo', + story_id: 's-1', + created_at: '2026-02-01', + }, + ]) + + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'P' } + s.context.activePbiId = 'pbi-1' + s.context.activeStoryId = 's-1' + s.context.activeTaskId = 't-1' + }) + + await useProductWorkspaceStore.getState().resyncActiveScopes('manual') + + const calls = fetchSpy.mock.calls.map(([url]) => url) + expect(calls).toContain('/api/products/prod-1/backlog') + expect(calls).toContain('/api/pbis/pbi-1/stories') + expect(calls).toContain('/api/stories/s-1/tasks') + expect(calls).toContain('/api/tasks/t-1') + + const s = useProductWorkspaceStore.getState() + expect(s.sync.lastResyncAt).toBeTypeOf('number') + expect(s.sync.resyncReason).toBe('manual') + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// Optimistic mutations +// ───────────────────────────────────────────────────────────────────────── + +// ───────────────────────────────────────────────────────────────────────── +// Restore-hint integratie (Story 4) +// ───────────────────────────────────────────────────────────────────────── + +describe('restore-hint flow — setters persisteren hints', () => { + it('setActiveProduct schrijft lastActiveProductId', () => { + useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) + const raw = localStorage.getItem('product-workspace-hints') + expect(raw).not.toBeNull() + const hints = JSON.parse(raw!) + expect(hints.lastActiveProductId).toBe('prod-1') + }) + + it('setActivePbi schrijft lastActivePbiId per product', () => { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'P1' } + }) + useProductWorkspaceStore.getState().setActivePbi('pbi-a') + const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!) + expect(hints.perProduct['prod-1'].lastActivePbiId).toBe('pbi-a') + }) + + it('setActiveStory schrijft lastActiveStoryId per product', () => { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'P1' } + }) + useProductWorkspaceStore.getState().setActiveStory('story-a') + const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!) + expect(hints.perProduct['prod-1'].lastActiveStoryId).toBe('story-a') + }) + + it('setActiveTask schrijft lastActiveTaskId per product', () => { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'P1' } + }) + useProductWorkspaceStore.getState().setActiveTask('task-a') + const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!) + expect(hints.perProduct['prod-1'].lastActiveTaskId).toBe('task-a') + }) +}) + +describe('restore-hint flow — chain triggert na ensure*Loaded', () => { + it('hint die NIET in entities zit wordt genegeerd', async () => { + // Schrijf een hint voor een PBI die niet bestaat + localStorage.setItem( + 'product-workspace-hints', + JSON.stringify({ + lastActiveProductId: 'prod-1', + perProduct: { 'prod-1': { lastActivePbiId: 'ghost-pbi' } }, + }), + ) + // Mock ensureProductLoaded zodat hij een lege snapshot terugstuurt — geen + // ghost-pbi in entities. + mockFetchSequence([ + { product: { id: 'prod-1', name: 'P1' }, pbis: [], storiesByPbi: {}, tasksByStory: {} }, + ]) + + useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) + // Wacht tot async restore-flow afgewikkeld is. + await new Promise((r) => setTimeout(r, 20)) + + expect(useProductWorkspaceStore.getState().context.activePbiId).toBeNull() + }) + + it('hint die wel in entities zit wordt toegepast', async () => { + const validPbi = makePbi({ id: 'pbi-known' }) + localStorage.setItem( + 'product-workspace-hints', + JSON.stringify({ + lastActiveProductId: 'prod-1', + perProduct: { 'prod-1': { lastActivePbiId: 'pbi-known' } }, + }), + ) + mockFetchSequence([ + // ensureProductLoaded levert pbi-known + { + product: { id: 'prod-1', name: 'P1' }, + pbis: [validPbi], + storiesByPbi: {}, + tasksByStory: {}, + }, + // ensurePbiLoaded triggered door setActivePbi(hint) — geen stories + [], + ]) + + useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) + await new Promise((r) => setTimeout(r, 30)) + + expect(useProductWorkspaceStore.getState().context.activePbiId).toBe('pbi-known') + }) +}) + +describe('optimistic mutations', () => { + it('rollback herstelt vorige pbi-order', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([ + makePbi({ id: 'a', priority: 2, sort_order: 1 }), + makePbi({ id: 'b', priority: 2, sort_order: 2 }), + ]), + ) + const prevOrder = [...useProductWorkspaceStore.getState().relations.pbiIds] + + const id = useProductWorkspaceStore.getState().applyOptimisticMutation({ + kind: 'pbi-order', + prevPbiIds: prevOrder, + }) + // simuleer de optimistic order-wijziging buiten de mutation + useProductWorkspaceStore.setState((s) => { + s.relations.pbiIds = ['b', 'a'] + }) + expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['b', 'a']) + + useProductWorkspaceStore.getState().rollbackMutation(id) + expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(prevOrder) + expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeUndefined() + }) + + it('settle ruimt pending op zonder state te wijzigen', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([makePbi({ id: 'a' })]), + ) + const id = useProductWorkspaceStore.getState().applyOptimisticMutation({ + kind: 'pbi-order', + prevPbiIds: ['a'], + }) + expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeDefined() + + useProductWorkspaceStore.getState().settleMutation(id) + expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeUndefined() + expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['a']) + }) + + it('SSE-echo van een al-bestaande PBI is idempotent', () => { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'P' } + }) + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([makePbi({ id: 'a', title: 'Origineel' })]), + ) + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'pbi', + op: 'I', + id: 'a', + product_id: 'prod-1', + title: 'echo', + }) + expect(useProductWorkspaceStore.getState().entities.pbisById['a'].title).toBe('Origineel') + expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['a']) + }) +}) diff --git a/__tests__/stores/solo-store-realtime.test.ts b/__tests__/stores/solo-store-realtime.test.ts index f61a7f8..2047a77 100644 --- a/__tests__/stores/solo-store-realtime.test.ts +++ b/__tests__/stores/solo-store-realtime.test.ts @@ -17,6 +17,9 @@ const baseTask = (id: string, overrides: Partial = {}): SoloTask => ({ story_code: 'ST-100', story_title: 'Original Story', task_code: 'ST-100.1', + pbi_code: null, + pbi_title: null, + pbi_description: null, ...overrides, }) diff --git a/__tests__/stores/solo-workspace/store.test.ts b/__tests__/stores/solo-workspace/store.test.ts new file mode 100644 index 0000000..b31000d --- /dev/null +++ b/__tests__/stores/solo-workspace/store.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSoloStore } from '@/stores/solo-store' +import type { RealtimeEvent } from '@/stores/solo-store' +import type { SoloTask, SoloWorkspaceSnapshot } from '@/stores/solo-workspace/types' + +function baseTask(id: string, overrides: Partial = {}): SoloTask { + return { + id, + title: `Task ${id}`, + description: null, + implementation_plan: null, + priority: 1, + sort_order: 1, + status: 'TO_DO', + verify_only: false, + verify_required: 'ALIGNED_OR_PARTIAL', + story_id: 'story-1', + story_code: 'ST-1', + story_title: 'Story 1', + task_code: `ST-1.${id}`, + pbi_code: null, + pbi_title: null, + pbi_description: null, + ...overrides, + } +} + +function snapshot(tasks: SoloTask[]): SoloWorkspaceSnapshot { + return { + product: { id: 'prod-1', name: 'Product 1' }, + sprint: { id: 'sprint-1', sprint_goal: 'Goal' }, + activeUserId: 'user-1', + tasks, + unassignedStories: [ + { id: 'story-b', code: 'ST-2', title: 'Story B', tasks: [] }, + { id: 'story-a', code: 'ST-1', title: 'Story A', tasks: [] }, + ], + } +} + +function taskEvent(overrides: Partial): RealtimeEvent { + return { + op: 'U', + entity: 'task', + id: 'task-1', + story_id: 'story-1', + product_id: 'prod-1', + sprint_id: 'sprint-1', + assignee_id: 'user-1', + ...overrides, + } +} + +beforeEach(() => { + useSoloStore.setState({ + context: { activeProduct: null, activeSprint: null, activeUserId: null }, + entities: { tasksById: {}, unassignedStoriesById: {}, jobsByTaskId: {} }, + relations: { + taskIdsByColumn: { TO_DO: [], IN_PROGRESS: [], DONE: [] }, + unassignedStoryIds: [], + }, + loading: { + loadedProductId: null, + loadedSprintId: null, + loadingSprintId: null, + activeRequestId: null, + }, + sync: { + realtimeStatus: 'connecting', + showConnectingIndicator: false, + lastEventAt: null, + lastResyncAt: null, + resyncReason: null, + }, + pendingOps: new Set(), + tasks: {}, + unassignedStoriesById: {}, + claudeJobsByTaskId: {}, + }) + vi.restoreAllMocks() +}) + +describe('solo workspace store', () => { + it('hydrateert genormaliseerde taken, kolomrelaties en unassigned stories', () => { + useSoloStore.getState().hydrateSnapshot( + snapshot([ + baseTask('task-2', { status: 'DONE', sort_order: 2 }), + baseTask('task-1', { status: 'TO_DO', sort_order: 1 }), + baseTask('task-3', { status: 'REVIEW', sort_order: 3 }), + ]), + ) + + const s = useSoloStore.getState() + expect(s.context.activeSprint?.id).toBe('sprint-1') + expect(s.relations.taskIdsByColumn.TO_DO).toEqual(['task-1']) + expect(s.relations.taskIdsByColumn.IN_PROGRESS).toEqual(['task-3']) + expect(s.relations.taskIdsByColumn.DONE).toEqual(['task-2']) + expect(s.relations.unassignedStoryIds).toEqual(['story-a', 'story-b']) + }) + + it('past realtime task updates toe en herbouwt kolomrelaties', () => { + useSoloStore.getState().hydrateSnapshot(snapshot([baseTask('task-1')])) + useSoloStore.getState().handleRealtimeEvent( + taskEvent({ status: 'DONE', sort_order: 5, title: 'Done task' }), + ) + + const s = useSoloStore.getState() + expect(s.tasks['task-1'].status).toBe('DONE') + expect(s.tasks['task-1'].title).toBe('Done task') + expect(s.relations.taskIdsByColumn.TO_DO).toEqual([]) + expect(s.relations.taskIdsByColumn.DONE).toEqual(['task-1']) + }) + + it('resynct actieve scopes via de solo-workspace route', async () => { + useSoloStore.getState().hydrateSnapshot(snapshot([baseTask('task-1')])) + const next = snapshot([baseTask('task-1', { status: 'IN_PROGRESS' })]) + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(next), { status: 200 }), + ) + + await useSoloStore.getState().resyncActiveScopes('manual') + + expect(fetchSpy).toHaveBeenCalledWith( + '/api/products/prod-1/solo-workspace?sprint_id=sprint-1', + expect.objectContaining({ cache: 'no-store' }), + ) + const s = useSoloStore.getState() + expect(s.tasks['task-1'].status).toBe('IN_PROGRESS') + expect(s.sync.resyncReason).toBe('manual') + }) +}) diff --git a/__tests__/stores/sprint-workspace/restore.test.ts b/__tests__/stores/sprint-workspace/restore.test.ts new file mode 100644 index 0000000..66c626f --- /dev/null +++ b/__tests__/stores/sprint-workspace/restore.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' + +import { + clearHints, + readHints, + writeProductHint, + writeSprintHint, + writeStoryHint, + writeTaskHint, +} from '@/stores/sprint-workspace/restore' + +describe('readHints', () => { + it('retourneert lege defaults wanneer localStorage leeg is', () => { + const hints = readHints() + expect(hints.lastActiveProductId).toBeNull() + expect(hints.perProduct).toEqual({}) + expect(hints.perSprint).toEqual({}) + }) + + it('herstelt hints uit localStorage', () => { + localStorage.setItem( + 'sprint-workspace-hints', + JSON.stringify({ + lastActiveProductId: 'p1', + perProduct: { p1: { lastActiveSprintId: 'sp-1' } }, + perSprint: { 'sp-1': { lastActiveStoryId: 's-1' } }, + }), + ) + const hints = readHints() + expect(hints.lastActiveProductId).toBe('p1') + expect(hints.perProduct.p1.lastActiveSprintId).toBe('sp-1') + expect(hints.perSprint['sp-1'].lastActiveStoryId).toBe('s-1') + }) + + it('valt terug op defaults bij ongeldige JSON', () => { + localStorage.setItem('sprint-workspace-hints', '{not-json') + const hints = readHints() + expect(hints.lastActiveProductId).toBeNull() + expect(hints.perProduct).toEqual({}) + expect(hints.perSprint).toEqual({}) + }) + + it('valt terug op defaults bij verkeerde shape', () => { + localStorage.setItem('sprint-workspace-hints', '"just a string"') + const hints = readHints() + expect(hints.perProduct).toEqual({}) + expect(hints.perSprint).toEqual({}) + }) +}) + +describe('writeProductHint', () => { + it('schrijft lastActiveProductId', () => { + writeProductHint('p1') + expect(readHints().lastActiveProductId).toBe('p1') + }) + + it('overschrijft bestaande waarde', () => { + writeProductHint('p1') + writeProductHint('p2') + expect(readHints().lastActiveProductId).toBe('p2') + }) + + it('accepteert null om hint te wissen', () => { + writeProductHint('p1') + writeProductHint(null) + expect(readHints().lastActiveProductId).toBeNull() + }) +}) + +describe('writeSprintHint', () => { + it('schrijft lastActiveSprintId per productId', () => { + writeSprintHint('prod-1', 'sp-a') + writeSprintHint('prod-2', 'sp-b') + const hints = readHints() + expect(hints.perProduct['prod-1'].lastActiveSprintId).toBe('sp-a') + expect(hints.perProduct['prod-2'].lastActiveSprintId).toBe('sp-b') + }) + + it('accepteert null om sprint-hint te wissen', () => { + writeSprintHint('prod-1', 'sp-a') + writeSprintHint('prod-1', null) + expect(readHints().perProduct['prod-1'].lastActiveSprintId).toBeNull() + }) +}) + +describe('writeStoryHint', () => { + it('schrijft lastActiveStoryId per sprintId', () => { + writeStoryHint('sp-1', 's-1') + expect(readHints().perSprint['sp-1'].lastActiveStoryId).toBe('s-1') + }) + + it('null wist child task-hint', () => { + writeStoryHint('sp-1', 's-1') + writeTaskHint('sp-1', 't-1') + writeStoryHint('sp-1', null) + expect(readHints().perSprint['sp-1'].lastActiveStoryId).toBeNull() + expect(readHints().perSprint['sp-1'].lastActiveTaskId).toBeNull() + }) +}) + +describe('writeTaskHint', () => { + it('schrijft lastActiveTaskId per sprintId', () => { + writeTaskHint('sp-1', 't-1') + expect(readHints().perSprint['sp-1'].lastActiveTaskId).toBe('t-1') + }) +}) + +describe('clearHints', () => { + it('verwijdert alle hints', () => { + writeProductHint('p1') + writeSprintHint('p1', 'sp-1') + writeStoryHint('sp-1', 's-1') + clearHints() + const hints = readHints() + expect(hints.lastActiveProductId).toBeNull() + expect(hints.perProduct).toEqual({}) + expect(hints.perSprint).toEqual({}) + }) +}) diff --git a/__tests__/stores/sprint-workspace/store.test.ts b/__tests__/stores/sprint-workspace/store.test.ts new file mode 100644 index 0000000..5fa0502 --- /dev/null +++ b/__tests__/stores/sprint-workspace/store.test.ts @@ -0,0 +1,875 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' +import type { + SprintWorkspaceSnapshot, + SprintWorkspaceSprint, + SprintWorkspaceStory, + SprintWorkspaceTask, + SprintWorkspaceTaskDetail, +} from '@/stores/sprint-workspace/types' + +// G5: snapshot original actions on module-load; restore in beforeEach. +const originalActions = (() => { + const s = useSprintWorkspaceStore.getState() + return { + hydrateSnapshot: s.hydrateSnapshot, + hydrateProductSprints: s.hydrateProductSprints, + setActiveProduct: s.setActiveProduct, + setActiveSprint: s.setActiveSprint, + setActiveStory: s.setActiveStory, + setActiveTask: s.setActiveTask, + ensureProductSprintsLoaded: s.ensureProductSprintsLoaded, + ensureSprintLoaded: s.ensureSprintLoaded, + ensureStoryLoaded: s.ensureStoryLoaded, + ensureTaskLoaded: s.ensureTaskLoaded, + applyRealtimeEvent: s.applyRealtimeEvent, + resyncActiveScopes: s.resyncActiveScopes, + resyncLoadedScopes: s.resyncLoadedScopes, + applyOptimisticMutation: s.applyOptimisticMutation, + rollbackMutation: s.rollbackMutation, + settleMutation: s.settleMutation, + setRealtimeStatus: s.setRealtimeStatus, + } +})() + +function resetStore() { + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = null + s.context.activeSprintId = null + s.context.activeStoryId = null + s.context.activeTaskId = null + s.entities.sprintsById = {} + s.entities.storiesById = {} + s.entities.tasksById = {} + s.relations.sprintIdsByProduct = {} + s.relations.storyIdsBySprint = {} + s.relations.taskIdsByStory = {} + s.loading.loadedProductSprintsIds = {} + s.loading.loadingProductId = null + s.loading.loadedSprintIds = {} + s.loading.loadingSprintId = null + s.loading.loadedStoryIds = {} + s.loading.loadedTaskIds = {} + s.loading.activeRequestId = null + s.sync.realtimeStatus = 'connecting' + s.sync.lastEventAt = null + s.sync.lastResyncAt = null + s.sync.resyncReason = null + s.pendingMutations = {} + Object.assign(s, originalActions) + }) +} + +beforeEach(() => { + resetStore() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +function makeSprint( + overrides: Partial & { id: string; product_id: string }, +): SprintWorkspaceSprint { + return { + id: overrides.id, + product_id: overrides.product_id, + code: overrides.code ?? `S-${overrides.id}`, + sprint_goal: overrides.sprint_goal ?? `Goal ${overrides.id}`, + status: overrides.status ?? 'OPEN', + start_date: overrides.start_date ?? '2026-04-01', + end_date: overrides.end_date ?? '2026-04-14', + created_at: overrides.created_at ?? new Date('2026-03-15'), + completed_at: overrides.completed_at ?? null, + } +} + +function makeStory( + overrides: Partial & { id: string; pbi_id: string }, +): SprintWorkspaceStory { + return { + id: overrides.id, + code: overrides.code ?? overrides.id, + title: overrides.title ?? `Story ${overrides.id}`, + description: overrides.description ?? null, + acceptance_criteria: overrides.acceptance_criteria ?? null, + priority: overrides.priority ?? 2, + sort_order: overrides.sort_order ?? 1, + status: overrides.status ?? 'OPEN', + pbi_id: overrides.pbi_id, + sprint_id: overrides.sprint_id ?? null, + created_at: overrides.created_at ?? new Date('2026-01-01'), + } +} + +function makeTask( + overrides: Partial & { id: string; story_id: string }, +): SprintWorkspaceTask { + return { + id: overrides.id, + code: overrides.code ?? null, + title: overrides.title ?? `Task ${overrides.id}`, + description: overrides.description ?? null, + priority: overrides.priority ?? 2, + sort_order: overrides.sort_order ?? 1, + status: overrides.status ?? 'TO_DO', + story_id: overrides.story_id, + sprint_id: overrides.sprint_id ?? null, + created_at: overrides.created_at ?? new Date('2026-01-01'), + } +} + +function snapshotWith( + sprint: SprintWorkspaceSprint | undefined, + stories: SprintWorkspaceStory[] = [], + tasksByStory: Record = {}, + product?: { id: string; name: string }, +): SprintWorkspaceSnapshot { + return { product, sprint, stories, tasksByStory } +} + +// G7/G8: mock fetch via mockImplementation (vers Response per call) +function mockFetchSequence( + responses: Array unknown)>, +) { + let i = 0 + return vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string, init?: RequestInit) => { + const r = responses[Math.min(i, responses.length - 1)] + i += 1 + const body = typeof r === 'function' ? (r as (u: string, i?: RequestInit) => unknown)(url, init) : r + return new Response(JSON.stringify(body ?? null), { status: 200 }) + }) as unknown as typeof fetch) +} + +// ───────────────────────────────────────────────────────────────────────── +// hydrateSnapshot +// ───────────────────────────────────────────────────────────────────────── + +describe('hydrateSnapshot', () => { + it('vult entities, relations en loaded-marker', () => { + const sprint = makeSprint({ id: 'sp-1', product_id: 'prod-1' }) + const storyA = makeStory({ id: 's-a', pbi_id: 'pbi-1', sprint_id: 'sp-1', sort_order: 2 }) + const storyB = makeStory({ id: 's-b', pbi_id: 'pbi-1', sprint_id: 'sp-1', sort_order: 1 }) + const taskA = makeTask({ id: 't-a', story_id: 's-a', sort_order: 2 }) + const taskB = makeTask({ id: 't-b', story_id: 's-a', sort_order: 1 }) + + useSprintWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + sprint, + [storyA, storyB], + { 's-a': [taskA, taskB] }, + { id: 'prod-1', name: 'Product 1' }, + ), + ) + + const s = useSprintWorkspaceStore.getState() + expect(s.entities.sprintsById['sp-1']).toEqual(sprint) + expect(s.entities.storiesById['s-a']).toEqual(storyA) + expect(s.entities.storiesById['s-b']).toEqual(storyB) + // sort_order: storyB (1) before storyA (2) + expect(s.relations.storyIdsBySprint['sp-1']).toEqual(['s-b', 's-a']) + // sort_order: taskB (1) before taskA (2) + expect(s.relations.taskIdsByStory['s-a']).toEqual(['t-b', 't-a']) + expect(s.context.activeProduct).toEqual({ id: 'prod-1', name: 'Product 1' }) + expect(s.loading.loadedSprintIds['sp-1']).toBe(true) + }) + + it('normaliseert API-statussen naar het interne store-contract', () => { + useSprintWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + makeSprint({ id: 'sp-1', product_id: 'prod-1' }), + [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1', status: 'in_sprint' })], + { 's-1': [makeTask({ id: 't-1', story_id: 's-1', status: 'todo' })] }, + ), + ) + + const s = useSprintWorkspaceStore.getState() + expect(s.entities.storiesById['s-1'].status).toBe('IN_SPRINT') + expect(s.entities.tasksById['t-1'].status).toBe('TO_DO') + }) +}) + +describe('hydrateProductSprints', () => { + it('sorteert OPEN voor CLOSED, dan op start_date desc', () => { + const closedOld = makeSprint({ + id: 'sp-closed-old', + product_id: 'prod-1', + status: 'CLOSED', + start_date: '2026-01-01', + }) + const openNew = makeSprint({ + id: 'sp-open-new', + product_id: 'prod-1', + status: 'OPEN', + start_date: '2026-04-01', + }) + const openOld = makeSprint({ + id: 'sp-open-old', + product_id: 'prod-1', + status: 'OPEN', + start_date: '2026-02-01', + }) + + useSprintWorkspaceStore + .getState() + .hydrateProductSprints('prod-1', [closedOld, openOld, openNew]) + + const s = useSprintWorkspaceStore.getState() + expect(s.relations.sprintIdsByProduct['prod-1']).toEqual([ + 'sp-open-new', + 'sp-open-old', + 'sp-closed-old', + ]) + expect(s.loading.loadedProductSprintsIds['prod-1']).toBe(true) + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// Selection cascade +// ───────────────────────────────────────────────────────────────────────── + +describe('selection cascade', () => { + it('setActiveSprint reset story+task; setActiveStory reset task', () => { + useSprintWorkspaceStore.setState((s) => { + s.context.activeSprintId = 'sp-old' + s.context.activeStoryId = 's-old' + s.context.activeTaskId = 't-old' + }) + + useSprintWorkspaceStore.getState().setActiveSprint('sp-new') + let s = useSprintWorkspaceStore.getState() + expect(s.context.activeSprintId).toBe('sp-new') + expect(s.context.activeStoryId).toBeNull() + expect(s.context.activeTaskId).toBeNull() + + useSprintWorkspaceStore.setState((draft) => { + draft.context.activeStoryId = 's-old' + draft.context.activeTaskId = 't-old' + }) + useSprintWorkspaceStore.getState().setActiveStory('s-new') + s = useSprintWorkspaceStore.getState() + expect(s.context.activeStoryId).toBe('s-new') + expect(s.context.activeTaskId).toBeNull() + }) + + it('setActiveProduct(null) ruimt entities en relations op', () => { + useSprintWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + makeSprint({ id: 'sp-1', product_id: 'prod-1' }), + [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], + { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] }, + { id: 'prod-1', name: 'Product 1' }, + ), + ) + + useSprintWorkspaceStore.getState().setActiveProduct(null) + const s = useSprintWorkspaceStore.getState() + expect(s.context.activeProduct).toBeNull() + expect(s.context.activeSprintId).toBeNull() + expect(s.entities.sprintsById).toEqual({}) + expect(s.entities.storiesById).toEqual({}) + expect(s.entities.tasksById).toEqual({}) + expect(s.relations.sprintIdsByProduct).toEqual({}) + expect(s.relations.storyIdsBySprint).toEqual({}) + expect(s.relations.taskIdsByStory).toEqual({}) + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// applyRealtimeEvent +// ───────────────────────────────────────────────────────────────────────── + +describe('applyRealtimeEvent — sprint', () => { + beforeEach(() => { + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + }) + + it('I — voegt sprint toe aan product-lijst', () => { + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'sprint', + op: 'I', + id: 'sp-new', + product_id: 'prod-1', + code: 'S-1', + sprint_goal: 'New Sprint', + status: 'OPEN', + start_date: '2026-05-01', + end_date: '2026-05-14', + created_at: new Date('2026-04-15').toISOString(), + }) + const s = useSprintWorkspaceStore.getState() + expect(s.entities.sprintsById['sp-new']).toBeDefined() + expect(s.relations.sprintIdsByProduct['prod-1']).toContain('sp-new') + }) + + it('I — idempotent voor bestaande id', () => { + useSprintWorkspaceStore + .getState() + .hydrateProductSprints('prod-1', [ + makeSprint({ id: 'sp-1', product_id: 'prod-1', sprint_goal: 'Origineel' }), + ]) + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'sprint', + op: 'I', + id: 'sp-1', + product_id: 'prod-1', + sprint_goal: 'echo', + }) + expect(useSprintWorkspaceStore.getState().entities.sprintsById['sp-1'].sprint_goal).toBe( + 'Origineel', + ) + }) + + it('U — patch + her-sorteert', () => { + useSprintWorkspaceStore + .getState() + .hydrateProductSprints('prod-1', [ + makeSprint({ + id: 'sp-a', + product_id: 'prod-1', + status: 'OPEN', + start_date: '2026-04-01', + }), + makeSprint({ + id: 'sp-b', + product_id: 'prod-1', + status: 'OPEN', + start_date: '2026-03-01', + }), + ]) + // sp-a (newer) komt eerst + expect( + useSprintWorkspaceStore.getState().relations.sprintIdsByProduct['prod-1'], + ).toEqual(['sp-a', 'sp-b']) + + // Sluit sp-a → moet naar achteren + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'sprint', + op: 'U', + id: 'sp-a', + product_id: 'prod-1', + status: 'CLOSED', + }) + expect( + useSprintWorkspaceStore.getState().relations.sprintIdsByProduct['prod-1'], + ).toEqual(['sp-b', 'sp-a']) + }) + + it('D — verwijdert sprint inclusief child stories en tasks', () => { + useSprintWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + makeSprint({ id: 'sp-1', product_id: 'prod-1' }), + [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], + { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] }, + { id: 'prod-1', name: 'Product 1' }, + ), + ) + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'sprint', + op: 'D', + id: 'sp-1', + product_id: 'prod-1', + }) + const s = useSprintWorkspaceStore.getState() + expect(s.entities.sprintsById['sp-1']).toBeUndefined() + expect(s.entities.storiesById['s-1']).toBeUndefined() + expect(s.entities.tasksById['t-1']).toBeUndefined() + expect(s.relations.storyIdsBySprint['sp-1']).toBeUndefined() + expect(s.relations.taskIdsByStory['s-1']).toBeUndefined() + }) + + it('D — clear actieve sprint selectie als die de verwijderde sprint was', () => { + useSprintWorkspaceStore.getState().hydrateSnapshot( + snapshotWith(makeSprint({ id: 'sp-1', product_id: 'prod-1' }), []), + ) + useSprintWorkspaceStore.setState((s) => { + s.context.activeSprintId = 'sp-1' + s.context.activeStoryId = 's-x' + s.context.activeTaskId = 't-x' + }) + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'sprint', + op: 'D', + id: 'sp-1', + product_id: 'prod-1', + }) + const s = useSprintWorkspaceStore.getState() + expect(s.context.activeSprintId).toBeNull() + expect(s.context.activeStoryId).toBeNull() + expect(s.context.activeTaskId).toBeNull() + }) +}) + +describe('applyRealtimeEvent — story sprint-move', () => { + beforeEach(() => { + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + useSprintWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + makeSprint({ id: 'sp-1', product_id: 'prod-1' }), + [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], + ), + ) + }) + + it('U met andere sprint_id verplaatst story uit sprint-relatie', () => { + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'story', + op: 'U', + id: 's-1', + product_id: 'prod-1', + pbi_id: 'pbi-1', + sprint_id: 'sp-other', + }) + const s = useSprintWorkspaceStore.getState() + expect(s.relations.storyIdsBySprint['sp-1']).toEqual([]) + expect(s.relations.storyIdsBySprint['sp-other']).toEqual(['s-1']) + expect(s.entities.storiesById['s-1'].sprint_id).toBe('sp-other') + }) + + it('U met sprint_id=null haalt story uit sprint-relatie', () => { + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'story', + op: 'U', + id: 's-1', + product_id: 'prod-1', + pbi_id: 'pbi-1', + sprint_id: null, + }) + expect(useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1']).toEqual([]) + expect(useSprintWorkspaceStore.getState().entities.storiesById['s-1'].sprint_id).toBeNull() + }) +}) + +describe('applyRealtimeEvent — task parent-move', () => { + beforeEach(() => { + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + useSprintWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + makeSprint({ id: 'sp-1', product_id: 'prod-1' }), + [ + makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' }), + makeStory({ id: 's-2', pbi_id: 'pbi-1', sprint_id: 'sp-1' }), + ], + { + 's-1': [makeTask({ id: 't-1', story_id: 's-1' })], + 's-2': [], + }, + ), + ) + }) + + it('U met andere story_id verplaatst task naar nieuwe parent', () => { + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'task', + op: 'U', + id: 't-1', + product_id: 'prod-1', + story_id: 's-2', + }) + const s = useSprintWorkspaceStore.getState() + expect(s.relations.taskIdsByStory['s-1']).toEqual([]) + expect(s.relations.taskIdsByStory['s-2']).toEqual(['t-1']) + expect(s.entities.tasksById['t-1'].story_id).toBe('s-2') + }) +}) + +describe('applyRealtimeEvent — andere product genegeerd', () => { + it('event met ander product_id raakt de store niet', () => { + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + useSprintWorkspaceStore + .getState() + .hydrateProductSprints('prod-1', [makeSprint({ id: 'sp-1', product_id: 'prod-1' })]) + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'sprint', + op: 'I', + id: 'sp-other', + product_id: 'prod-2', + code: 'X', + }) + const s = useSprintWorkspaceStore.getState() + expect(s.entities.sprintsById['sp-other']).toBeUndefined() + }) +}) + +describe('applyRealtimeEvent — unknown entity → resync trigger', () => { + function withSpy(): ReturnType { + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + const spy = vi.fn().mockResolvedValue(undefined) + useSprintWorkspaceStore.setState((s) => { + s.resyncActiveScopes = spy as unknown as typeof s.resyncActiveScopes + }) + return spy + } + + it('unknown entity met matching product triggert resync', () => { + const spy = withSpy() + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'comment', + op: 'I', + id: 'cm-1', + product_id: 'prod-1', + }) + expect(spy).toHaveBeenCalledWith('unknown-event') + }) + + it('unknown entity met ander product_id triggert geen resync', () => { + const spy = withSpy() + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'comment', + op: 'I', + id: 'cm-1', + product_id: 'prod-2', + }) + expect(spy).not.toHaveBeenCalled() + }) + + it('claude_job_status (type-veld) triggert geen resync', () => { + const spy = withSpy() + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + type: 'claude_job_status', + job_id: 'job-1', + product_id: 'prod-1', + status: 'queued', + }) + expect(spy).not.toHaveBeenCalled() + }) + + it('worker_heartbeat (type-veld) triggert geen resync', () => { + const spy = withSpy() + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + type: 'worker_heartbeat', + worker_id: 'w-1', + product_id: 'prod-1', + }) + expect(spy).not.toHaveBeenCalled() + }) + + it('payload zonder entity en zonder type wordt genegeerd', () => { + const spy = withSpy() + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + product_id: 'prod-1', + something: 'else', + }) + expect(spy).not.toHaveBeenCalled() + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// ensure*Loaded +// ───────────────────────────────────────────────────────────────────────── + +describe('ensureProductSprintsLoaded', () => { + it('fetcht sprint-list en hydreert met sortering', async () => { + const sprints = [ + makeSprint({ + id: 'sp-old', + product_id: 'prod-1', + status: 'CLOSED', + start_date: '2026-01-01', + }), + makeSprint({ + id: 'sp-new', + product_id: 'prod-1', + status: 'OPEN', + start_date: '2026-04-01', + }), + ] + const fetchSpy = mockFetchSequence([sprints]) + + await useSprintWorkspaceStore.getState().ensureProductSprintsLoaded('prod-1') + + expect(fetchSpy).toHaveBeenCalledWith( + '/api/products/prod-1/sprints', + expect.objectContaining({ cache: 'no-store' }), + ) + const s = useSprintWorkspaceStore.getState() + expect(s.relations.sprintIdsByProduct['prod-1']).toEqual(['sp-new', 'sp-old']) + expect(s.loading.loadedProductSprintsIds['prod-1']).toBe(true) + }) +}) + +describe('ensureSprintLoaded', () => { + it('fetcht sprint-snapshot en hydreert', async () => { + const sprint = makeSprint({ id: 'sp-1', product_id: 'prod-1' }) + const snapshot: SprintWorkspaceSnapshot = { + product: { id: 'prod-1', name: 'P1' }, + sprint, + stories: [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], + tasksByStory: { + 's-1': [makeTask({ id: 't-1', story_id: 's-1' })], + }, + } + const fetchSpy = mockFetchSequence([snapshot]) + + await useSprintWorkspaceStore.getState().ensureSprintLoaded('sp-1') + + expect(fetchSpy).toHaveBeenCalledWith( + '/api/sprints/sp-1/workspace', + expect.objectContaining({ cache: 'no-store' }), + ) + const s = useSprintWorkspaceStore.getState() + expect(s.entities.sprintsById['sp-1']).toBeDefined() + expect(s.relations.storyIdsBySprint['sp-1']).toEqual(['s-1']) + expect(s.relations.taskIdsByStory['s-1']).toEqual(['t-1']) + expect(s.loading.loadedSprintIds['sp-1']).toBe(true) + }) +}) + +describe('race-safe ensure*Loaded — activeRequestId guard', () => { + it('oudere in-flight ensureSprintLoaded mag nieuwere selectie niet overschrijven', async () => { + let resolveOld: ((snap: SprintWorkspaceSnapshot) => void) | null = null + + vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string) => { + if (url === '/api/sprints/sp-old/workspace') { + const snap = await new Promise((resolve) => { + resolveOld = resolve + }) + return new Response(JSON.stringify(snap), { status: 200 }) + } + if (url === '/api/sprints/sp-new/workspace') { + return new Response( + JSON.stringify({ + sprint: makeSprint({ id: 'sp-new', product_id: 'prod-1' }), + stories: [makeStory({ id: 'new-st', pbi_id: 'p', sprint_id: 'sp-new' })], + tasksByStory: {}, + }), + { status: 200 }, + ) + } + return new Response('null', { status: 200 }) + }) as unknown as typeof fetch) + + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + s.context.activeSprintId = 'sp-old' + s.loading.activeRequestId = 'req-old' + }) + const oldPromise = useSprintWorkspaceStore + .getState() + .ensureSprintLoaded('sp-old', 'req-old') + + useSprintWorkspaceStore.setState((s) => { + s.context.activeSprintId = 'sp-new' + s.loading.activeRequestId = 'req-new' + }) + await useSprintWorkspaceStore + .getState() + .ensureSprintLoaded('sp-new', 'req-new') + + expect(useSprintWorkspaceStore.getState().entities.storiesById['new-st']).toBeDefined() + + resolveOld!({ + sprint: makeSprint({ id: 'sp-old', product_id: 'prod-1' }), + stories: [makeStory({ id: 'old-st', pbi_id: 'p', sprint_id: 'sp-old' })], + tasksByStory: {}, + }) + await oldPromise + + const s = useSprintWorkspaceStore.getState() + expect(s.context.activeSprintId).toBe('sp-new') + expect(s.entities.storiesById['old-st']).toBeUndefined() + expect(s.entities.storiesById['new-st']).toBeDefined() + }) +}) + +describe('ensureTaskLoaded — zet detail-flag', () => { + it('verrijkt task naar TaskDetail met _detail: true', async () => { + mockFetchSequence([ + { + id: 't-1', + code: 'C1', + title: 'Task 1', + description: 'desc', + priority: 1, + sort_order: 1, + status: 'todo', + story_id: 's-1', + sprint_id: 'sp-1', + created_at: new Date('2026-02-01').toISOString(), + implementation_plan: 'detailed plan here', + }, + ]) + + await useSprintWorkspaceStore.getState().ensureTaskLoaded('t-1') + const task = useSprintWorkspaceStore.getState().entities.tasksById[ + 't-1' + ] as SprintWorkspaceTaskDetail + expect(task._detail).toBe(true) + expect(task.status).toBe('TO_DO') + expect(task.implementation_plan).toBe('detailed plan here') + expect(useSprintWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true) + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// resyncActiveScopes +// ───────────────────────────────────────────────────────────────────────── + +describe('resyncActiveScopes', () => { + it('triggert ensure-keten voor alle actieve scopes en zet sync velden', async () => { + const fetchSpy = mockFetchSequence([ + // ensureProductSprintsLoaded + [], + // ensureSprintLoaded + { + sprint: makeSprint({ id: 'sp-1', product_id: 'prod-1' }), + stories: [], + tasksByStory: {}, + }, + // ensureStoryLoaded + [], + // ensureTaskLoaded + { + id: 't-1', + title: 'T', + description: null, + priority: 1, + sort_order: 1, + status: 'todo', + story_id: 's-1', + sprint_id: 'sp-1', + created_at: '2026-02-01', + }, + ]) + + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'P' } + s.context.activeSprintId = 'sp-1' + s.context.activeStoryId = 's-1' + s.context.activeTaskId = 't-1' + }) + + await useSprintWorkspaceStore.getState().resyncActiveScopes('manual') + + const calls = fetchSpy.mock.calls.map(([url]) => url) + expect(calls).toContain('/api/products/prod-1/sprints') + expect(calls).toContain('/api/sprints/sp-1/workspace') + expect(calls).toContain('/api/stories/s-1/tasks') + expect(calls).toContain('/api/tasks/t-1') + + const s = useSprintWorkspaceStore.getState() + expect(s.sync.lastResyncAt).toBeTypeOf('number') + expect(s.sync.resyncReason).toBe('manual') + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// Restore-hint flow +// ───────────────────────────────────────────────────────────────────────── + +describe('restore-hint flow — setters persisteren hints', () => { + it('setActiveProduct schrijft lastActiveProductId', () => { + useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) + const raw = localStorage.getItem('sprint-workspace-hints') + expect(raw).not.toBeNull() + const hints = JSON.parse(raw!) + expect(hints.lastActiveProductId).toBe('prod-1') + }) + + it('setActiveSprint schrijft lastActiveSprintId per product', () => { + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'P1' } + }) + useSprintWorkspaceStore.getState().setActiveSprint('sp-a') + const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!) + expect(hints.perProduct['prod-1'].lastActiveSprintId).toBe('sp-a') + }) + + it('setActiveStory schrijft lastActiveStoryId per sprint', () => { + useSprintWorkspaceStore.setState((s) => { + s.context.activeSprintId = 'sp-1' + }) + useSprintWorkspaceStore.getState().setActiveStory('s-a') + const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!) + expect(hints.perSprint['sp-1'].lastActiveStoryId).toBe('s-a') + }) + + it('setActiveTask schrijft lastActiveTaskId per sprint', () => { + useSprintWorkspaceStore.setState((s) => { + s.context.activeSprintId = 'sp-1' + }) + useSprintWorkspaceStore.getState().setActiveTask('t-a') + const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!) + expect(hints.perSprint['sp-1'].lastActiveTaskId).toBe('t-a') + }) +}) + +describe('restore-hint flow — chain triggert na ensure*Loaded', () => { + it('hint die NIET in entities zit wordt genegeerd', async () => { + localStorage.setItem( + 'sprint-workspace-hints', + JSON.stringify({ + lastActiveProductId: 'prod-1', + perProduct: { 'prod-1': { lastActiveSprintId: 'ghost-sprint' } }, + perSprint: {}, + }), + ) + mockFetchSequence([[]]) + + useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) + await new Promise((r) => setTimeout(r, 20)) + + expect(useSprintWorkspaceStore.getState().context.activeSprintId).toBeNull() + }) + + it('hint die wel in entities zit wordt toegepast', async () => { + const validSprint = makeSprint({ id: 'sp-known', product_id: 'prod-1' }) + localStorage.setItem( + 'sprint-workspace-hints', + JSON.stringify({ + lastActiveProductId: 'prod-1', + perProduct: { 'prod-1': { lastActiveSprintId: 'sp-known' } }, + perSprint: {}, + }), + ) + mockFetchSequence([ + // ensureProductSprintsLoaded — levert sp-known + [validSprint], + // ensureSprintLoaded triggered door setActiveSprint(hint) + { sprint: validSprint, stories: [], tasksByStory: {} }, + ]) + + useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) + await new Promise((r) => setTimeout(r, 30)) + + expect(useSprintWorkspaceStore.getState().context.activeSprintId).toBe('sp-known') + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// Optimistic mutations +// ───────────────────────────────────────────────────────────────────────── + +describe('optimistic mutations', () => { + it('SSE-echo van een al-bestaande sprint is idempotent', () => { + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'P' } + }) + useSprintWorkspaceStore + .getState() + .hydrateProductSprints('prod-1', [ + makeSprint({ id: 'sp-1', product_id: 'prod-1', sprint_goal: 'Origineel' }), + ]) + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'sprint', + op: 'I', + id: 'sp-1', + product_id: 'prod-1', + sprint_goal: 'echo', + }) + expect( + useSprintWorkspaceStore.getState().entities.sprintsById['sp-1'].sprint_goal, + ).toBe('Origineel') + }) +}) diff --git a/__tests__/stores/user-settings.test.ts b/__tests__/stores/user-settings.test.ts new file mode 100644 index 0000000..e159bf8 --- /dev/null +++ b/__tests__/stores/user-settings.test.ts @@ -0,0 +1,240 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const updateAction = vi.fn() +const setDraftAction = vi.fn() +const clearDraftAction = vi.fn() + +vi.mock('@/actions/user-settings', () => ({ + updateUserSettingsAction: (...args: unknown[]) => updateAction(...args), +})) + +vi.mock('@/actions/sprint-draft', () => ({ + setPendingSprintDraftAction: (...args: unknown[]) => setDraftAction(...args), + clearPendingSprintDraftAction: (...args: unknown[]) => + clearDraftAction(...args), +})) + +import { useUserSettingsStore } from '@/stores/user-settings/store' +import type { PendingSprintDraft } from '@/lib/user-settings' + +function resetStore() { + useUserSettingsStore.setState((s) => { + s.entities.settings = {} + s.context.hydrated = false + s.context.isDemo = false + s.pendingMutations = {} + }) +} + +beforeEach(() => { + resetStore() + updateAction.mockReset() + setDraftAction.mockReset() + clearDraftAction.mockReset() +}) + +afterEach(() => { + resetStore() +}) + +describe('useUserSettingsStore', () => { + it('hydrate sets entities and context', () => { + useUserSettingsStore.getState().hydrate( + { views: { sprintBacklog: { sort: 'code' } } }, + false, + ) + const s = useUserSettingsStore.getState() + expect(s.entities.settings.views?.sprintBacklog?.sort).toBe('code') + expect(s.context.hydrated).toBe(true) + expect(s.context.isDemo).toBe(false) + }) + + it('setPref updates state optimistically and settles on success', async () => { + useUserSettingsStore.getState().hydrate({}, false) + updateAction.mockResolvedValueOnce({ + success: true, + settings: { views: { sprintBacklog: { filterStatus: 'all' } } }, + }) + + await useUserSettingsStore + .getState() + .setPref(['views', 'sprintBacklog', 'filterStatus'], 'all') + + const s = useUserSettingsStore.getState() + expect(s.entities.settings.views?.sprintBacklog?.filterStatus).toBe('all') + expect(Object.keys(s.pendingMutations)).toHaveLength(0) + expect(updateAction).toHaveBeenCalledWith({ + views: { sprintBacklog: { filterStatus: 'all' } }, + }) + }) + + it('setPref rolls back on server error', async () => { + useUserSettingsStore.getState().hydrate( + { views: { sprintBacklog: { sort: 'code' } } }, + false, + ) + updateAction.mockResolvedValueOnce({ error: 'boom', code: 422 }) + + await useUserSettingsStore + .getState() + .setPref(['views', 'sprintBacklog', 'sort'], 'priority') + + const s = useUserSettingsStore.getState() + expect(s.entities.settings.views?.sprintBacklog?.sort).toBe('code') + expect(Object.keys(s.pendingMutations)).toHaveLength(0) + }) + + it('setPref skips server-call for demo accounts', async () => { + useUserSettingsStore.getState().hydrate({}, true) + + await useUserSettingsStore + .getState() + .setPref(['devTools', 'debugMode'], true) + + const s = useUserSettingsStore.getState() + expect(s.entities.settings.devTools?.debugMode).toBe(true) + expect(updateAction).not.toHaveBeenCalled() + }) + + it('setPendingSprintDraft persists draft lokaal (session-only, geen server-call)', async () => { + useUserSettingsStore.getState().hydrate({}, false) + + const draft: PendingSprintDraft = { + goal: 'Sprint 1', + pbiIntent: { pbiA: 'all' }, + storyOverrides: {}, + } + await useUserSettingsStore + .getState() + .setPendingSprintDraft('product-1', draft) + + const s = useUserSettingsStore.getState() + expect( + s.entities.settings.workflow?.pendingSprintDraft?.['product-1'], + ).toMatchObject({ goal: 'Sprint 1' }) + expect(setDraftAction).not.toHaveBeenCalled() + }) + + it('hydrate strips workflow.pendingSprintDraft uit legacy server-state', () => { + useUserSettingsStore.getState().hydrate( + { + workflow: { + pendingSprintDraft: { + 'product-1': { + goal: 'Legacy draft', + pbiIntent: {}, + storyOverrides: {}, + }, + }, + }, + }, + false, + ) + + const s = useUserSettingsStore.getState() + expect(s.entities.settings.workflow?.pendingSprintDraft).toBeUndefined() + }) + + it('clearPendingSprintDraft verwijdert de key lokaal zonder server-call', async () => { + useUserSettingsStore.getState().hydrate({}, false) + await useUserSettingsStore.getState().setPendingSprintDraft('product-1', { + goal: 'Old', + pbiIntent: {}, + storyOverrides: {}, + }) + + await useUserSettingsStore + .getState() + .clearPendingSprintDraft('product-1') + + const s = useUserSettingsStore.getState() + expect( + s.entities.settings.workflow?.pendingSprintDraft?.['product-1'], + ).toBeUndefined() + expect(clearDraftAction).not.toHaveBeenCalled() + }) + + it('upsertPbiIntent updates intent and wipes storyOverrides for that PBI', async () => { + useUserSettingsStore.getState().hydrate({}, false) + await useUserSettingsStore.getState().setPendingSprintDraft('product-1', { + goal: 'g', + pbiIntent: { pbiA: 'none' }, + storyOverrides: { + pbiA: { add: ['s-1'], remove: [] }, + pbiB: { add: [], remove: ['s-2'] }, + }, + }) + + await useUserSettingsStore + .getState() + .upsertPbiIntent('product-1', 'pbiA', 'all') + + const draft = + useUserSettingsStore.getState().entities.settings.workflow + ?.pendingSprintDraft?.['product-1'] + expect(draft?.pbiIntent.pbiA).toBe('all') + expect(draft?.storyOverrides.pbiA).toBeUndefined() + expect(draft?.storyOverrides.pbiB).toEqual({ add: [], remove: ['s-2'] }) + }) + + it('upsertStoryOverride add adds to add[] and removes from remove[]', async () => { + useUserSettingsStore.getState().hydrate({}, false) + await useUserSettingsStore.getState().setPendingSprintDraft('product-1', { + goal: 'g', + pbiIntent: {}, + storyOverrides: { + pbiA: { add: [], remove: ['story-1'] }, + }, + }) + + await useUserSettingsStore + .getState() + .upsertStoryOverride('product-1', 'pbiA', 'story-1', 'add') + + const draft = + useUserSettingsStore.getState().entities.settings.workflow + ?.pendingSprintDraft?.['product-1'] + expect(draft?.storyOverrides.pbiA).toEqual({ + add: ['story-1'], + remove: [], + }) + }) + + it('upsertStoryOverride clear removes from both arrays and drops empty entry', async () => { + useUserSettingsStore.getState().hydrate({}, false) + await useUserSettingsStore.getState().setPendingSprintDraft('product-1', { + goal: 'g', + pbiIntent: {}, + storyOverrides: { + pbiA: { add: ['story-1'], remove: [] }, + }, + }) + + await useUserSettingsStore + .getState() + .upsertStoryOverride('product-1', 'pbiA', 'story-1', 'clear') + + const draft = + useUserSettingsStore.getState().entities.settings.workflow + ?.pendingSprintDraft?.['product-1'] + expect(draft?.storyOverrides.pbiA).toBeUndefined() + }) + + it('applyServerPatch merges without optimistic state', () => { + useUserSettingsStore.getState().hydrate( + { views: { sprintBacklog: { sort: 'code' } } }, + false, + ) + + useUserSettingsStore.getState().applyServerPatch({ + views: { sprintBacklog: { sortDir: 'desc' } }, + }) + + const s = useUserSettingsStore.getState() + expect(s.entities.settings.views?.sprintBacklog).toEqual({ + sort: 'code', + sortDir: 'desc', + }) + expect(Object.keys(s.pendingMutations)).toHaveLength(0) + }) +}) diff --git a/actions/active-sprint.ts b/actions/active-sprint.ts new file mode 100644 index 0000000..e774376 --- /dev/null +++ b/actions/active-sprint.ts @@ -0,0 +1,164 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { z } from 'zod' +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' +import { productAccessFilter } from '@/lib/product-access' +import { + clearActiveSprintInSettings, + setActiveSelectionInSettings, + setActiveSprintInSettings, +} from '@/lib/active-sprint' + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +const setSchema = z.object({ + productId: z.string().min(1), + sprintId: z.string().min(1), +}) + +const clearSchema = z.object({ + productId: z.string().min(1), +}) + +export async function setActiveSprintAction(productId: string, sprintId: string) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = setSchema.safeParse({ productId, sprintId }) + if (!parsed.success) return { error: 'Ongeldig product- of sprint-id' } + + const sprint = await prisma.sprint.findFirst({ + where: { + id: parsed.data.sprintId, + product_id: parsed.data.productId, + product: productAccessFilter(session.userId), + }, + select: { id: true }, + }) + if (!sprint) return { error: 'Sprint niet gevonden of niet toegankelijk' } + + await setActiveSprintInSettings(session.userId, parsed.data.productId, parsed.data.sprintId) + revalidatePath('/', 'layout') + return { success: true, sprintId: parsed.data.sprintId } +} + +export async function clearActiveSprintAction(productId: string) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = clearSchema.safeParse({ productId }) + if (!parsed.success) return { error: 'Ongeldig product-id' } + + const product = await prisma.product.findFirst({ + where: { id: parsed.data.productId, ...productAccessFilter(session.userId) }, + select: { id: true }, + }) + if (!product) return { error: 'Product niet gevonden of niet toegankelijk' } + + await clearActiveSprintInSettings(session.userId, parsed.data.productId) + revalidatePath('/', 'layout') + return { success: true } +} + +const selectionSchema = z.object({ + productId: z.string().min(1), + sprintId: z.string().min(1), +}) + +/** + * PBI-79: kies een sprint en auto-select zijn enige PBI/story (indien + * singleton). Resultaat wordt server-side bepaald + atomair in user-settings + * weggeschreven (sprint+pbi+story) zodat cross-device-restore klopt. + */ +export async function switchActiveSprintAction( + productId: string, + sprintId: string, +): Promise< + | { + success: true + sprintId: string + pbiId: string | null + storyId: string | null + } + | { error: string } +> { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = selectionSchema.safeParse({ productId, sprintId }) + if (!parsed.success) return { error: 'Ongeldig product- of sprint-id' } + + const sprint = await prisma.sprint.findFirst({ + where: { + id: parsed.data.sprintId, + product_id: parsed.data.productId, + product: productAccessFilter(session.userId), + }, + select: { id: true }, + }) + if (!sprint) return { error: 'Sprint niet gevonden of niet toegankelijk' } + + // Auto-select: alleen wanneer sprint exact één PBI heeft. Story-auto-select + // alleen wanneer die PBI exact één story binnen deze sprint heeft. + const sprintStories = await prisma.story.findMany({ + where: { + sprint_id: parsed.data.sprintId, + product_id: parsed.data.productId, + }, + select: { id: true, pbi_id: true }, + }) + const uniquePbiIds = Array.from(new Set(sprintStories.map((s) => s.pbi_id))) + let autoPbiId: string | null = null + let autoStoryId: string | null = null + if (uniquePbiIds.length === 1) { + autoPbiId = uniquePbiIds[0] + const storiesForPbi = sprintStories.filter((s) => s.pbi_id === autoPbiId) + if (storiesForPbi.length === 1) { + autoStoryId = storiesForPbi[0].id + } + } + + await setActiveSelectionInSettings(session.userId, parsed.data.productId, { + sprintId: parsed.data.sprintId, + pbiId: autoPbiId, + storyId: autoStoryId, + }) + revalidatePath('/', 'layout') + + return { + success: true, + sprintId: parsed.data.sprintId, + pbiId: autoPbiId, + storyId: autoStoryId, + } +} + +export async function syncActiveSprintCookieAction(productId: string, sprintId: string) { + const session = await getSession() + if (!session.userId) return + if (session.isDemo) return + + const parsed = setSchema.safeParse({ productId, sprintId }) + if (!parsed.success) return + + const sprint = await prisma.sprint.findFirst({ + where: { + id: parsed.data.sprintId, + product_id: parsed.data.productId, + product: productAccessFilter(session.userId), + }, + select: { id: true }, + }) + if (!sprint) return + + await setActiveSprintInSettings(session.userId, parsed.data.productId, parsed.data.sprintId) +} diff --git a/actions/admin/jobs.ts b/actions/admin/jobs.ts new file mode 100644 index 0000000..e6a81e0 --- /dev/null +++ b/actions/admin/jobs.ts @@ -0,0 +1,41 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { prisma } from '@/lib/prisma' +import { requireAdmin } from '@/lib/auth-guard' + +const cuidSchema = z.string().cuid() + +export async function cancelJobAction(jobId: string) { + await requireAdmin() + + const parsed = cuidSchema.safeParse(jobId) + if (!parsed.success) throw new Error('Ongeldig job-id') + + const job = await prisma.claudeJob.findUnique({ + where: { id: parsed.data }, + select: { status: true }, + }) + + if (!job) throw new Error('Job niet gevonden') + if (job.status === 'DONE' || job.status === 'FAILED' || job.status === 'CANCELLED' || job.status === 'SKIPPED') { + throw new Error('Job is al in eindstatus') + } + + await prisma.claudeJob.update({ + where: { id: parsed.data }, + data: { status: 'CANCELLED', finished_at: new Date() }, + }) + revalidatePath('/admin/jobs') +} + +export async function deleteJobAction(jobId: string) { + await requireAdmin() + + const parsed = cuidSchema.safeParse(jobId) + if (!parsed.success) throw new Error('Ongeldig job-id') + + await prisma.claudeJob.delete({ where: { id: parsed.data } }) + revalidatePath('/admin/jobs') +} diff --git a/actions/admin/products.ts b/actions/admin/products.ts new file mode 100644 index 0000000..b6f4ad0 --- /dev/null +++ b/actions/admin/products.ts @@ -0,0 +1,86 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { prisma } from '@/lib/prisma' +import { requireAdmin } from '@/lib/auth-guard' + +const adminProductSchema = z.object({ + name: z.string().min(1).max(100), + description: z.string().optional(), + repo_url: z.string().url().optional().or(z.literal('')), + definition_of_done: z.string().min(1), + auto_pr: z.boolean().default(false), + owner_user_id: z.string().cuid(), +}) + +const adminProductUpdateSchema = adminProductSchema.omit({ owner_user_id: true }) + +export async function adminCreateProductAction(data: unknown) { + await requireAdmin() + + const parsed = adminProductSchema.safeParse(data) + if (!parsed.success) throw new Error(parsed.error.message) + + const owner = await prisma.user.findUnique({ where: { id: parsed.data.owner_user_id } }) + if (!owner) throw new Error('Eigenaar niet gevonden') + + await prisma.product.create({ + data: { + user_id: parsed.data.owner_user_id, + name: parsed.data.name, + description: parsed.data.description, + repo_url: parsed.data.repo_url || null, + definition_of_done: parsed.data.definition_of_done, + auto_pr: parsed.data.auto_pr, + }, + }) + revalidatePath('/admin/products') +} + +export async function adminUpdateProductAction(productId: string, data: unknown) { + await requireAdmin() + + const parsed = adminProductUpdateSchema.safeParse(data) + if (!parsed.success) throw new Error(parsed.error.message) + + await prisma.product.update({ + where: { id: productId }, + data: { + name: parsed.data.name, + description: parsed.data.description, + repo_url: parsed.data.repo_url || null, + definition_of_done: parsed.data.definition_of_done, + auto_pr: parsed.data.auto_pr, + }, + }) + revalidatePath('/admin/products') +} + +export async function adminArchiveProductAction(productId: string, archived: boolean) { + await requireAdmin() + await prisma.product.update({ where: { id: productId }, data: { archived } }) + revalidatePath('/admin/products') +} + +export async function adminDeleteProductAction(productId: string) { + await requireAdmin() + await prisma.product.delete({ where: { id: productId } }) + revalidatePath('/admin/products') +} + +export async function adminAddMemberAction(productId: string, userId: string) { + await requireAdmin() + await prisma.productMember.upsert({ + where: { product_id_user_id: { product_id: productId, user_id: userId } }, + create: { product_id: productId, user_id: userId }, + update: {}, + }) + revalidatePath(`/admin/products/${productId}`) +} + +export async function adminRemoveMemberAction(productId: string, userId: string) { + await requireAdmin() + await prisma.productMember.deleteMany({ where: { product_id: productId, user_id: userId } }) + revalidatePath(`/admin/products/${productId}`) +} diff --git a/actions/admin/users.ts b/actions/admin/users.ts new file mode 100644 index 0000000..c7698fa --- /dev/null +++ b/actions/admin/users.ts @@ -0,0 +1,43 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { Role } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import { requireAdmin } from '@/lib/auth-guard' + +export async function deleteUserAction(userId: string) { + const session = await requireAdmin() + if (userId === session.userId) { + throw new Error('Zelfverwijdering niet toegestaan') + } + await prisma.user.delete({ where: { id: userId } }) + revalidatePath('/admin/users') +} + +const rolesSchema = z.array(z.nativeEnum(Role)) + +export async function updateUserRolesAction(userId: string, roles: Role[]) { + const session = await requireAdmin() + + const parsed = rolesSchema.safeParse(roles) + if (!parsed.success) { + throw new Error('Ongeldige rol-waarden') + } + + if (userId === session.userId && !parsed.data.includes(Role.ADMIN)) { + throw new Error('Kan eigen ADMIN-rol niet verwijderen') + } + + await prisma.$transaction([ + prisma.userRole.deleteMany({ where: { user_id: userId } }), + ...parsed.data.map((role) => prisma.userRole.create({ data: { user_id: userId, role } })), + ]) + revalidatePath('/admin/users') +} + +export async function setMustResetPasswordAction(userId: string, value: boolean) { + await requireAdmin() + await prisma.user.update({ where: { id: userId }, data: { must_reset_password: value } }) + revalidatePath('/admin/users') +} diff --git a/actions/api-tokens.ts b/actions/api-tokens.ts index 3964342..136692b 100644 --- a/actions/api-tokens.ts +++ b/actions/api-tokens.ts @@ -6,6 +6,7 @@ import { getIronSession } from 'iron-session' import { createHash, randomBytes } from 'crypto' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' +import { enforceUserRateLimit } from '@/lib/rate-limit' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -16,6 +17,9 @@ export async function createApiTokenAction(_prevState: unknown, formData: FormDa if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + const limited = enforceUserRateLimit('create-token', session.userId) + if (limited) return limited + const label = (formData.get('label') as string | null)?.trim() || null // Max 10 active tokens diff --git a/actions/auth.ts b/actions/auth.ts index 575178e..a08c502 100644 --- a/actions/auth.ts +++ b/actions/auth.ts @@ -4,10 +4,12 @@ import { redirect } from 'next/navigation' import { cookies, headers } from 'next/headers' import { getIronSession } from 'iron-session' import { z } from 'zod' -import { registerUser, verifyUser } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { registerUser, verifyUser, hashPassword } from '@/lib/auth' import { SessionData, sessionOptions } from '@/lib/session' import { checkRateLimit } from '@/lib/rate-limit' import { isPhoneUA } from '@/lib/user-agent' +import { requireSession } from '@/lib/auth-guard' async function getClientIp(): Promise { const h = await headers() @@ -45,6 +47,7 @@ export async function registerAction(_prevState: unknown, formData: FormData) { const session = await getIronSession(await cookies(), sessionOptions) session.userId = result.user!.id session.isDemo = false + session.isAdmin = false await session.save() redirect('/dashboard') @@ -70,9 +73,13 @@ export async function loginAction(_prevState: unknown, formData: FormData) { return { error: 'Onjuiste gebruikersnaam of wachtwoord' } } + const adminRole = await prisma.userRole.findFirst({ + where: { user_id: user.id, role: 'ADMIN' }, + }) const session = await getIronSession(await cookies(), sessionOptions) session.userId = user.id session.isDemo = user.is_demo + session.isAdmin = !!adminRole await session.save() // PBI-11 / ST-1135: telefoon-UA's krijgen de mobile-shell. @@ -90,3 +97,39 @@ export async function logoutAction() { session.destroy() redirect('/login') } + +const resetPasswordSchema = z + .object({ + password: z.string().min(8, 'Wachtwoord moet minimaal 8 tekens bevatten'), + confirm: z.string(), + }) + .superRefine((data, ctx) => { + if (data.password !== data.confirm) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Wachtwoorden komen niet overeen', + path: ['confirm'], + }) + } + }) + +export async function resetPasswordAction(_prevState: unknown, formData: FormData) { + const session = await requireSession() + + const parsed = resetPasswordSchema.safeParse({ + password: formData.get('password'), + confirm: formData.get('confirm'), + }) + + if (!parsed.success) { + return { error: parsed.error.flatten().fieldErrors } + } + + const hash = await hashPassword(parsed.data.password) + await prisma.user.update({ + where: { id: session.userId }, + data: { password_hash: hash, must_reset_password: false }, + }) + + redirect('/dashboard') +} diff --git a/actions/claude-jobs.ts b/actions/claude-jobs.ts index d7ba1e3..258fd1a 100644 --- a/actions/claude-jobs.ts +++ b/actions/claude-jobs.ts @@ -1,9 +1,9 @@ 'use server' import { revalidatePath } from 'next/cache' +import { type ClaudeJobStatus } from '@prisma/client' import { prisma } from '@/lib/prisma' import { getSession } from '@/lib/auth' -import { productAccessFilter } from '@/lib/product-access' import { ACTIVE_JOB_STATUSES, jobStatusToApi } from '@/lib/job-status' type EnqueueResult = @@ -16,6 +16,9 @@ type EnqueueAllResult = type CancelResult = { success: true } | { error: string } +type RestartResult = { success: true } | { error: string } +const RESTARTABLE_STATUSES: ClaudeJobStatus[] = ['FAILED', 'CANCELLED', 'SKIPPED'] + export type PreviewTask = { id: string title: string @@ -29,267 +32,49 @@ type PreflightResult = | { error: string } | { tasks: PreviewTask[]; blockerIndex: number | null; blockerReason: 'task-review' | 'pbi-blocked' | null } -export async function enqueueClaudeJobAction(taskId: string): Promise { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - if (!taskId) return { error: 'task_id is verplicht' } - - // Resolve task + product access in one query - const task = await prisma.task.findFirst({ - where: { - id: taskId, - story: { product: productAccessFilter(session.userId) }, - }, - select: { id: true, story: { select: { product_id: true } } }, - }) - if (!task) return { error: 'Task niet gevonden' } - - const productId = task.story.product_id - - // Idempotency: weiger als er al een actieve job voor deze task bestaat - const existing = await prisma.claudeJob.findFirst({ - where: { task_id: taskId, status: { in: ACTIVE_JOB_STATUSES } }, - select: { id: true }, - }) - if (existing) { - return { error: 'Er loopt al een agent voor deze task', jobId: existing.id } +/** + * @deprecated Vervangen door startSprintRunAction in actions/sprint-runs.ts. + * Per-task starts zijn niet meer toegestaan — een sprint draait nu als geheel. + * Wordt verwijderd zodra de UI is omgebouwd (F4). + */ +export async function enqueueClaudeJobAction(_taskId: string): Promise { + return { + error: + 'Per-task starten is niet meer mogelijk. Gebruik "Start Sprint" voor de hele actieve sprint.', } - - const job = await prisma.claudeJob.create({ - data: { user_id: session.userId, product_id: productId, task_id: taskId, status: 'QUEUED' }, - }) - - await prisma.$executeRaw` - SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ - type: 'claude_job_enqueued', - job_id: job.id, - task_id: taskId, - user_id: session.userId, - product_id: productId, - status: 'queued', - })}::text) - ` - - revalidatePath(`/products/${productId}/solo`) - return { success: true, jobId: job.id } } -export async function enqueueAllTodoJobsAction(productId: string): Promise { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - if (!productId) return { error: 'product_id is verplicht' } - - const product = await prisma.product.findFirst({ - where: { id: productId, ...productAccessFilter(session.userId) }, - select: { id: true }, - }) - if (!product) return { error: 'Geen toegang tot dit product' } - - const userId = session.userId - - // Match het scope dat de gebruiker op het Solo Paneel ziet: - // alleen TO_DO-taken in de actieve sprint, in stories die aan deze - // gebruiker zijn toegewezen. Anders queue je per ongeluk taken die - // niet in de huidige sprint zitten of aan iemand anders toebehoren. - const sprint = await prisma.sprint.findFirst({ - where: { product_id: productId, status: 'ACTIVE' }, - select: { id: true }, - }) - if (!sprint) return { success: true, count: 0 } - - const tasks = await prisma.task.findMany({ - where: { - status: 'TO_DO', - story: { sprint_id: sprint.id, assignee_id: userId }, - claude_jobs: { none: { status: { in: ACTIVE_JOB_STATUSES } } }, - }, - select: { id: true }, - }) - - if (tasks.length === 0) return { success: true, count: 0 } - - const created = await prisma.$transaction( - tasks.map(t => - prisma.claudeJob.create({ - data: { user_id: userId, product_id: productId, task_id: t.id, status: 'QUEUED' }, - select: { id: true, task_id: true }, - }) - ) - ) - - for (const job of created) { - await prisma.$executeRaw` - SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ - type: 'claude_job_enqueued', - job_id: job.id, - task_id: job.task_id, - user_id: userId, - product_id: productId, - status: 'queued', - })}::text) - ` +/** + * @deprecated Vervangen door startSprintRunAction in actions/sprint-runs.ts. + */ +export async function enqueueAllTodoJobsAction(_productId: string): Promise { + return { + error: + '"Alle TO_DO als jobs queueen" is vervangen door "Start Sprint". Gebruik startSprintRunAction.', } - - revalidatePath(`/products/${productId}/solo`) - return { success: true, count: created.length } } -export async function previewEnqueueAllAction(productId: string): Promise { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - if (!productId) return { error: 'product_id is verplicht' } - - const product = await prisma.product.findFirst({ - where: { id: productId, ...productAccessFilter(session.userId) }, - select: { id: true }, - }) - if (!product) return { error: 'Geen toegang tot dit product' } - - const userId = session.userId - - const sprint = await prisma.sprint.findFirst({ - where: { product_id: productId, status: 'ACTIVE' }, - select: { id: true }, - }) - if (!sprint) return { tasks: [], blockerIndex: null, blockerReason: null } - - const rawTasks = await prisma.task.findMany({ - where: { - story: { sprint_id: sprint.id, assignee_id: userId }, - claude_jobs: { none: { status: { in: ACTIVE_JOB_STATUSES } } }, - }, - select: { - id: true, - title: true, - status: true, - story: { - select: { - id: true, - title: true, - code: true, - pbi: { select: { id: true, status: true, priority: true, sort_order: true } }, - }, - }, - }, - orderBy: [ - { story: { pbi: { priority: 'asc' } } }, - { story: { pbi: { sort_order: 'asc' } } }, - { story: { sort_order: 'asc' } }, - { priority: 'asc' }, - { sort_order: 'asc' }, - ], - }) - - let blockerIndex: number | null = null - let blockerReason: 'task-review' | 'pbi-blocked' | null = null - - for (let i = 0; i < rawTasks.length; i++) { - const t = rawTasks[i] - if (t.status === 'REVIEW') { - blockerIndex = i - blockerReason = 'task-review' - break - } - if (t.story.pbi.status === 'BLOCKED') { - blockerIndex = i - blockerReason = 'pbi-blocked' - break - } +/** + * @deprecated Vervangen door pre-flight in startSprintRunAction (actions/sprint-runs.ts). + */ +export async function previewEnqueueAllAction(_productId: string): Promise { + return { + error: + 'Per-product preview is vervangen door de pre-flight check in startSprintRunAction.', } - - const displayTasks = blockerIndex !== null ? rawTasks.slice(0, blockerIndex + 1) : rawTasks - - const tasks: PreviewTask[] = displayTasks.map(t => ({ - id: t.id, - title: t.title, - status: t.status, - story_title: t.story.title, - pbi_id: t.story.pbi.id, - pbi_status: t.story.pbi.status, - })) - - return { tasks, blockerIndex, blockerReason } } +/** + * @deprecated Vervangen door startSprintRunAction in actions/sprint-runs.ts. + */ export async function enqueueClaudeJobsBatchAction( - productId: string, - taskIds: string[] + _productId: string, + _taskIds: string[] ): Promise { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - if (!productId) return { error: 'product_id is verplicht' } - if (!taskIds.length) return { success: true, count: 0 } - - const product = await prisma.product.findFirst({ - where: { id: productId, ...productAccessFilter(session.userId) }, - select: { id: true }, - }) - if (!product) return { error: 'Geen toegang tot dit product' } - - const userId = session.userId - - const sprint = await prisma.sprint.findFirst({ - where: { product_id: productId, status: 'ACTIVE' }, - select: { id: true }, - }) - if (!sprint) return { error: 'Geen actieve sprint gevonden' } - - const authorizedTasks = await prisma.task.findMany({ - where: { - id: { in: taskIds }, - story: { sprint_id: sprint.id, assignee_id: userId }, - }, - select: { - id: true, - claude_jobs: { - where: { status: { in: ACTIVE_JOB_STATUSES } }, - select: { id: true }, - }, - }, - }) - - if (authorizedTasks.length !== taskIds.length) { - return { error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' } + return { + error: + 'Batch-queue per task is vervangen door "Start Sprint". Gebruik startSprintRunAction.', } - - const queueable = authorizedTasks.filter(t => t.claude_jobs.length === 0) - if (queueable.length === 0) return { success: true, count: 0 } - - const queueableIds = new Set(queueable.map(t => t.id)) - const orderedQueueable = taskIds.filter(id => queueableIds.has(id)) - - const created = await prisma.$transaction( - orderedQueueable.map(taskId => - prisma.claudeJob.create({ - data: { user_id: userId, product_id: productId, task_id: taskId, status: 'QUEUED' }, - select: { id: true, task_id: true }, - }) - ) - ) - - for (const job of created) { - await prisma.$executeRaw` - SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ - type: 'claude_job_enqueued', - job_id: job.id, - task_id: job.task_id, - user_id: userId, - product_id: productId, - status: 'queued', - })}::text) - ` - } - - revalidatePath(`/products/${productId}/solo`) - return { success: true, count: created.length } } export async function cancelClaudeJobAction(jobId: string): Promise { @@ -328,3 +113,76 @@ export async function cancelClaudeJobAction(jobId: string): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (!jobId) return { error: 'job_id is verplicht' } + + const job = await prisma.claudeJob.findFirst({ + where: { id: jobId, user_id: session.userId }, + select: { id: true, status: true, kind: true, task_id: true, idea_id: true, sprint_run_id: true, product_id: true }, + }) + if (!job) return { error: 'Job niet gevonden' } + if (!RESTARTABLE_STATUSES.includes(job.status)) { + return { error: 'Alleen mislukte, geannuleerde of overgeslagen jobs kunnen opnieuw gestart worden' } + } + + const updated = await prisma.$transaction(async (tx) => { + const result = await tx.claudeJob.updateMany({ + where: { id: jobId, status: { in: RESTARTABLE_STATUSES } }, + data: { + status: 'QUEUED', + retry_count: { increment: 1 }, + claimed_by_token_id: null, + claimed_at: null, + started_at: null, + finished_at: null, + pushed_at: null, + verify_result: null, + error: null, + summary: null, + branch: null, + head_sha: null, + lease_until: null, + }, + }) + if (result.count === 0) return 0 + if (job.kind === 'SPRINT_IMPLEMENTATION') { + await tx.sprintTaskExecution.updateMany({ + where: { sprint_job_id: jobId }, + data: { + status: 'PENDING', + verify_result: null, + verify_summary: null, + skip_reason: null, + head_sha: null, + started_at: null, + finished_at: null, + }, + }) + } + return result.count + }) + if (updated === 0) { + return { error: 'Job-status is gewijzigd; herlaad en probeer opnieuw' } + } + + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_status', + job_id: jobId, + kind: job.kind, + task_id: job.task_id, + idea_id: job.idea_id, + sprint_run_id: job.sprint_run_id, + user_id: session.userId, + product_id: job.product_id, + status: jobStatusToApi('QUEUED'), + })}::text) + ` + + revalidatePath('/jobs') + return { success: true } +} diff --git a/actions/ideas.ts b/actions/ideas.ts new file mode 100644 index 0000000..63bae6d --- /dev/null +++ b/actions/ideas.ts @@ -0,0 +1,862 @@ +'use server' + +// Server-actions voor de Idea-entity (M12). Volgt docs/patterns/server-action.md: +// auth → demo-guard → rate-limit → zod-validate → user_id-scope-check → write +// → revalidatePath. Idee is strikt user_id-only (zie M12 grill-keuze 8) — er +// is GEEN productAccessFilter; idee is privé voor de eigenaar, ook als-ie +// gekoppeld is aan een team-product. + +import { revalidatePath } from 'next/cache' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' + +import { z } from 'zod' + +import { prisma } from '@/lib/prisma' +import { getJobConfigSnapshot } from '@/lib/job-config-snapshot' +import { SessionData, sessionOptions } from '@/lib/session' +import { enforceUserRateLimit } from '@/lib/rate-limit' +import { ideaCreateSchema, ideaUpdateSchema } from '@/lib/schemas/idea' +import { canTransition, isGrillMdEditable, isIdeaEditable, isPlanMdEditable } from '@/lib/idea-status' +import { nextIdeaCode } from '@/lib/idea-code-server' +import { parsePlanMd } from '@/lib/idea-plan-parser' +import { ACTIVE_JOB_STATUSES } from '@/lib/job-status' +import { parseCodeNumber } from '@/lib/code' + +import type { ClaudeJobKind, Idea, IdeaStatus } from '@prisma/client' + +// Worker-presence: aligned met /api/realtime/solo. +const WORKER_FRESH_MS = 15_000 +async function countActiveWorkers(userId: string): Promise { + return prisma.claudeWorker.count({ + where: { + user_id: userId, + last_seen_at: { gt: new Date(Date.now() - WORKER_FRESH_MS) }, + }, + }) +} + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +// Standaard error-shape voor consistente UI-rendering — zie ook actions/todos.ts. +type ActionResult = + | { success: true; data?: T } + | { error: string; code?: number; details?: unknown } + +// --------------------------------------------------------------------------- +// CRUD + +export async function createIdeaAction(input: { + title: string + description?: string | null + product_id?: string | null +}): Promise> { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const limited = enforceUserRateLimit('create-idea', session.userId) + if (limited) return limited + + const parsed = ideaCreateSchema.safeParse(input) + if (!parsed.success) { + return { error: 'Validatie mislukt', code: 422, details: parsed.error.flatten().fieldErrors } + } + + const userId = session.userId + // Atomair: code + create in dezelfde transactie zodat een crash tussenin geen + // counter-gat veroorzaakt zonder bijbehorend idee. + const idea = await prisma.$transaction(async (tx) => { + const code = await nextIdeaCode(userId, tx) + return tx.idea.create({ + data: { + user_id: userId, + product_id: parsed.data.product_id ?? null, + code, + title: parsed.data.title, + description: parsed.data.description ?? null, + status: 'DRAFT', + }, + select: { id: true, code: true }, + }) + }) + + revalidatePath('/ideas') + return { success: true, data: idea } +} + +export async function updateIdeaAction( + id: string, + input: { title?: string; description?: string | null; product_id?: string | null }, +): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const parsed = ideaUpdateSchema.safeParse(input) + if (!parsed.success) { + return { error: 'Validatie mislukt', code: 422, details: parsed.error.flatten().fieldErrors } + } + + const idea = await prisma.idea.findFirst({ + where: { id, user_id: session.userId }, + select: { id: true, status: true }, + }) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + if (!isIdeaEditable(idea.status)) { + return { error: `Idee is niet bewerkbaar in status ${idea.status}`, code: 422 } + } + + await prisma.idea.update({ + where: { id }, + data: { + ...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}), + ...(parsed.data.description !== undefined ? { description: parsed.data.description } : {}), + ...(parsed.data.product_id !== undefined ? { product_id: parsed.data.product_id } : {}), + }, + }) + revalidatePath('/ideas') + revalidatePath(`/ideas/${id}`) + return { success: true } +} + +export async function archiveIdeaAction(id: string): Promise { + return setArchived(id, true) +} + +export async function unarchiveIdeaAction(id: string): Promise { + return setArchived(id, false) +} + +async function setArchived(id: string, archived: boolean): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const found = await prisma.idea.findFirst({ + where: { id, user_id: session.userId }, + select: { id: true }, + }) + if (!found) return { error: 'Idee niet gevonden', code: 404 } + + await prisma.idea.update({ where: { id }, data: { archived } }) + revalidatePath('/ideas') + revalidatePath(`/ideas/${id}`) + return { success: true } +} + +export async function deleteIdeaAction(id: string): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const idea = await prisma.idea.findFirst({ + where: { id, user_id: session.userId }, + select: { id: true, pbi_id: true }, + }) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + if (idea.pbi_id !== null) { + return { + error: 'Verwijder eerst de gekoppelde PBI; daarna kun je het idee weggooien.', + code: 422, + } + } + + await prisma.idea.delete({ where: { id } }) + revalidatePath('/ideas') + return { success: true } +} + +// --------------------------------------------------------------------------- +// Secondary products + +const secondaryProductsSchema = z.object({ + ideaId: z.string().cuid(), + productIds: z.array(z.string().cuid()).max(10), +}) + +export async function updateSecondaryProductsAction( + ideaId: string, + productIds: string[], +): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const parsed = secondaryProductsSchema.safeParse({ ideaId, productIds }) + if (!parsed.success) return { error: 'Ongeldige invoer', code: 422 } + + const idea = await prisma.idea.findFirst({ + where: { id: parsed.data.ideaId, user_id: session.userId }, + select: { id: true, product_id: true }, + }) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + + // Verwijder primair product uit de lijst (mag niet dubbel) + const filtered = parsed.data.productIds.filter((pid) => pid !== idea.product_id) + + // Valideer dat alle gevraagde producten toegankelijk zijn voor de user + if (filtered.length > 0) { + const { productAccessFilter } = await import('@/lib/product-access') + const accessible = await prisma.product.findMany({ + where: { id: { in: filtered }, ...productAccessFilter(session.userId) }, + select: { id: true }, + }) + if (accessible.length !== filtered.length) + return { error: 'Een of meer producten zijn niet toegankelijk', code: 403 } + } + + // Atomisch: verwijder alle bestaande, voeg nieuwe in + await prisma.$transaction([ + prisma.ideaProduct.deleteMany({ where: { idea_id: idea.id } }), + ...(filtered.length > 0 + ? [ + prisma.ideaProduct.createMany({ + data: filtered.map((pid) => ({ idea_id: idea.id, product_id: pid })), + skipDuplicates: true, + }), + ] + : []), + ]) + + revalidatePath('/ideas/' + idea.id, 'page') + revalidatePath('/ideas', 'page') + return { success: true } +} + +// --------------------------------------------------------------------------- +// Markdown-edits (grill_md & plan_md handmatig fine-tunen) + +export async function updateGrillMdAction( + id: string, + markdown: string, +): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const limited = enforceUserRateLimit('edit-idea-md', session.userId) + if (limited) return limited + + const idea = await loadOwnedIdea(id, session.userId, ['status']) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + if (!isGrillMdEditable(idea.status)) { + return { + error: `grill_md alleen bewerkbaar in GRILLED of PLAN_READY (huidige status: ${idea.status})`, + code: 422, + } + } + + await prisma.$transaction([ + prisma.idea.update({ where: { id }, data: { grill_md: markdown } }), + prisma.ideaLog.create({ + data: { + idea_id: id, + type: 'NOTE', + content: 'User-edited grill_md', + metadata: { length: markdown.length }, + }, + }), + ]) + + revalidatePath(`/ideas/${id}`) + return { success: true } +} + +export async function updatePlanMdAction( + id: string, + markdown: string, +): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const limited = enforceUserRateLimit('edit-idea-md', session.userId) + if (limited) return limited + + const idea = await loadOwnedIdea(id, session.userId, ['status']) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + if (!isPlanMdEditable(idea.status)) { + return { + error: `plan_md alleen bewerkbaar in PLAN_READY (huidige status: ${idea.status})`, + code: 422, + } + } + + // Validate frontmatter — voorkomt dat een onparseerbaar plan in de DB belandt + // en bij Materialiseer pas faalt. + const parsed = parsePlanMd(markdown) + if (!parsed.ok) { + return { + error: 'plan_md is niet parseerbaar', + code: 422, + details: parsed.errors, + } + } + + await prisma.$transaction([ + prisma.idea.update({ where: { id }, data: { plan_md: markdown } }), + prisma.ideaLog.create({ + data: { + idea_id: id, + type: 'NOTE', + content: 'User-edited plan_md', + metadata: { length: markdown.length }, + }, + }), + ]) + + revalidatePath(`/ideas/${id}`) + return { success: true } +} + +// --------------------------------------------------------------------------- +// Upload — gebruiker plakt/uploadt zelf een plan.md in plaats van de Make-Plan +// AI-flow. Skipt grill als gewenst. Status springt direct naar PLAN_READY. +// Bij parse-failure: NIET opslaan (return 422), zodat een onparseerbaar plan +// nooit in de DB belandt. Geen worker nodig — synchrone parser. + +const UPLOAD_PLAN_FROM: IdeaStatus[] = ['DRAFT', 'GRILLED', 'PLAN_FAILED', 'PLAN_READY'] +const MAX_PLAN_MD_LENGTH = 100_000 + +export async function uploadPlanMdAction( + id: string, + markdown: string, +): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const limited = enforceUserRateLimit('upload-idea-plan', session.userId) + if (limited) return limited + + if (typeof markdown !== 'string' || markdown.trim().length === 0) { + return { error: 'plan_md is leeg', code: 422 } + } + if (markdown.length > MAX_PLAN_MD_LENGTH) { + return { + error: `plan_md is te groot (${markdown.length} > ${MAX_PLAN_MD_LENGTH} chars)`, + code: 422, + } + } + + const idea = await loadOwnedIdea(id, session.userId, ['status']) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + if (!UPLOAD_PLAN_FROM.includes(idea.status)) { + return { + error: `Upload plan alleen toegestaan vanuit ${UPLOAD_PLAN_FROM.join('/')} (huidige status: ${idea.status})`, + code: 422, + } + } + + const parsed = parsePlanMd(markdown) + if (!parsed.ok) { + return { + error: 'plan_md is niet parseerbaar', + code: 422, + details: parsed.errors, + } + } + + await prisma.$transaction([ + prisma.idea.update({ + where: { id }, + data: { plan_md: markdown, status: 'PLAN_READY' }, + }), + prisma.ideaLog.create({ + data: { + idea_id: id, + type: 'NOTE', + content: 'User-uploaded plan_md', + metadata: { length: markdown.length, from_status: idea.status }, + }, + }), + ]) + + revalidatePath(`/ideas/${id}`) + return { success: true } +} + +// --------------------------------------------------------------------------- +// Download — geeft de raw markdown terug; UI bouwt een Blob. + +export async function downloadIdeaMdAction( + id: string, + kind: 'grill' | 'plan', +): Promise> { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + // Demo MAG downloaden — read-only operatie, geen mutatie. + + const idea = await loadOwnedIdea(id, session.userId, ['code', 'grill_md', 'plan_md']) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + + const md = kind === 'grill' ? idea.grill_md : idea.plan_md + if (!md) { + return { error: `Geen ${kind}_md beschikbaar voor dit idee`, code: 404 } + } + + return { + success: true, + data: { filename: `${idea.code}-${kind}.md`, markdown: md }, + } +} + +// --------------------------------------------------------------------------- +// Job-triggers (Grill Me / Make Plan / Cancel) + +const GRILL_TRIGGERABLE_FROM: IdeaStatus[] = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLAN_READY', 'PLANNED'] +const MAKE_PLAN_TRIGGERABLE_FROM: IdeaStatus[] = ['GRILLED', 'PLAN_FAILED', 'PLAN_READY'] +const REVIEW_PLAN_TRIGGERABLE_FROM: IdeaStatus[] = ['PLAN_READY', 'PLAN_REVIEWED'] + +export async function startGrillJobAction(id: string): Promise> { + return startIdeaJob(id, 'IDEA_GRILL', 'GRILLING', GRILL_TRIGGERABLE_FROM) +} + +export async function startMakePlanJobAction(id: string): Promise> { + return startIdeaJob(id, 'IDEA_MAKE_PLAN', 'PLANNING', MAKE_PLAN_TRIGGERABLE_FROM) +} + +export async function startReviewPlanJobAction(id: string): Promise> { + return startIdeaJob(id, 'IDEA_REVIEW_PLAN', 'REVIEWING_PLAN', REVIEW_PLAN_TRIGGERABLE_FROM) +} + +async function startIdeaJob( + id: string, + kind: ClaudeJobKind, + newStatus: IdeaStatus, + allowedFrom: IdeaStatus[], +): Promise> { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const limited = enforceUserRateLimit('start-idea-job', session.userId) + if (limited) return limited + + // Laad idee + product (voor repo_url-validatie) + const idea = await prisma.idea.findFirst({ + where: { id, user_id: session.userId }, + select: { + id: true, + status: true, + product_id: true, + product: { select: { id: true, repo_url: true } }, + }, + }) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + if (!allowedFrom.includes(idea.status)) { + return { + error: `Actie niet toegestaan in status ${idea.status}`, + code: 422, + } + } + if (!canTransition(idea.status, newStatus)) { + return { error: `Status-transitie ${idea.status}→${newStatus} ongeldig`, code: 422 } + } + + // Product-met-repo verplicht (M12 grill-keuze 3) + if (!idea.product_id || !idea.product?.repo_url) { + return { + error: 'Idee moet gekoppeld zijn aan een product met repo_url voordat je dit kunt starten.', + code: 422, + } + } + + // Idempotency: weiger als er al een actieve job loopt voor dit idee. + const existing = await prisma.claudeJob.findFirst({ + where: { idea_id: id, status: { in: ACTIVE_JOB_STATUSES } }, + select: { id: true }, + }) + if (existing) { + return { + error: 'Er loopt al een actieve agent voor dit idee.', + code: 409, + details: { job_id: existing.id }, + } + } + + // Worker-presence — server-side check, naast UI-side disabled-rule. + const workers = await countActiveWorkers(session.userId) + if (workers === 0) { + return { + error: 'Geen Claude-worker actief. Start een lokale wait_for_job-loop en probeer opnieuw.', + code: 422, + } + } + + const ideaSnapshot = await getJobConfigSnapshot({ kind, productId: idea.product_id! }) + + // Atomic: create job + flip idea-status + log. + const job = await prisma.$transaction(async (tx) => { + const j = await tx.claudeJob.create({ + data: { + user_id: session.userId, + product_id: idea.product_id!, + idea_id: id, + kind, + status: 'QUEUED', + ...ideaSnapshot, + }, + select: { id: true }, + }) + await tx.idea.update({ where: { id }, data: { status: newStatus } }) + await tx.ideaLog.create({ + data: { + idea_id: id, + type: 'JOB_EVENT', + content: `${kind} queued`, + metadata: { job_id: j.id, kind }, + }, + }) + return j + }) + + // Manual pg_notify zoals enqueueClaudeJobAction in actions/claude-jobs.ts. + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_enqueued', + job_id: job.id, + idea_id: id, + user_id: session.userId, + product_id: idea.product_id, + kind, + status: 'queued', + })}::text) + ` + + revalidatePath('/ideas') + revalidatePath(`/ideas/${id}`) + return { success: true, data: { job_id: job.id } } +} + +export async function cancelIdeaJobAction(id: string): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const idea = await prisma.idea.findFirst({ + where: { id, user_id: session.userId }, + select: { id: true, status: true, grill_md: true, plan_md: true }, + }) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + + // Vind de actieve job — meest recente in QUEUED|CLAIMED|RUNNING. + const job = await prisma.claudeJob.findFirst({ + where: { idea_id: id, status: { in: ACTIVE_JOB_STATUSES } }, + orderBy: { created_at: 'desc' }, + select: { id: true, kind: true }, + }) + if (!job) return { error: 'Geen actieve job om te annuleren', code: 404 } + + // Bepaal terugval-status. Bij een lopende grill: terug naar GRILLED als er + // al eerder grill_md was, anders DRAFT. Bij plan-job: PLAN_READY als er al + // plan_md was (re-plan-cancel), anders GRILLED. Bij review-plan: terug naar + // PLAN_READY (review kan altijd opnieuw gestart worden). + let revertStatus: IdeaStatus + if (job.kind === 'IDEA_GRILL') { + revertStatus = idea.grill_md ? 'GRILLED' : 'DRAFT' + } else if (job.kind === 'IDEA_MAKE_PLAN') { + revertStatus = idea.plan_md ? 'PLAN_READY' : 'GRILLED' + } else if (job.kind === 'IDEA_REVIEW_PLAN') { + revertStatus = 'PLAN_READY' + } else { + return { error: `Job kind ${job.kind} hoort niet bij een idee`, code: 422 } + } + + await prisma.$transaction([ + prisma.claudeJob.update({ + where: { id: job.id }, + data: { status: 'CANCELLED', finished_at: new Date(), error: 'user_cancelled' }, + }), + prisma.idea.update({ where: { id }, data: { status: revertStatus } }), + prisma.ideaLog.create({ + data: { + idea_id: id, + type: 'JOB_EVENT', + content: `${job.kind} cancelled by user`, + metadata: { job_id: job.id, revert_status: revertStatus }, + }, + }), + ]) + + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_status', + job_id: job.id, + idea_id: id, + user_id: session.userId, + kind: job.kind, + status: 'cancelled', + })}::text) + ` + + revalidatePath('/ideas') + revalidatePath(`/ideas/${id}`) + return { success: true } +} + +// --------------------------------------------------------------------------- +// Materialize: parse plan_md → INSERT PBI + stories + taken (atomic) + +const PBI_AUTO_RE = /^PBI-(\d+)$/ +const STORY_AUTO_RE = /^ST-(\d+)$/ +const TASK_AUTO_RE = /^T-(\d+)$/ + +function nextNumber(existing: (string | null)[], re: RegExp): number { + let max = 0 + for (const c of existing) { + if (!c) continue + const m = c.match(re) + if (m) { + const n = Number.parseInt(m[1], 10) + if (!Number.isNaN(n) && n > max) max = n + } + } + return max + 1 +} + +export async function materializeIdeaPlanAction( + id: string, + options?: { allowAlongside?: boolean }, +): Promise> { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const limited = enforceUserRateLimit('materialize-idea', session.userId) + if (limited) return limited + + const idea = await prisma.idea.findFirst({ + where: { id, user_id: session.userId }, + select: { id: true, status: true, product_id: true, plan_md: true, pbi_id: true }, + }) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + if (idea.status !== 'PLAN_READY') { + return { + error: `Materialiseren alleen toegestaan in PLAN_READY (huidige status: ${idea.status})`, + code: 422, + } + } + if (!idea.product_id) { + return { error: 'Idee mist een gekoppeld product', code: 422 } + } + if (!idea.plan_md) { + return { error: 'Idee heeft geen plan_md', code: 422 } + } + + const parsed = parsePlanMd(idea.plan_md) + if (!parsed.ok) { + return { error: 'plan_md is niet parseerbaar', code: 422, details: parsed.errors } + } + + const productId = idea.product_id + const plan = parsed.plan + + let oldPbiId: string | null = null + if (idea.pbi_id) { + const executedCount = await prisma.task.count({ + where: { + story: { pbi_id: idea.pbi_id }, + status: { in: ['DONE', 'IN_PROGRESS'] }, + }, + }) + if (executedCount > 0 && !options?.allowAlongside) { + const existingPbi = await prisma.pbi.findUnique({ + where: { id: idea.pbi_id }, + select: { code: true }, + }) + return { + error: `PBI_HAS_ACTIVE_TASKS:${existingPbi?.code ?? idea.pbi_id}`, + code: 409, + } + } + if (executedCount === 0) { + oldPbiId = idea.pbi_id + } + // executedCount > 0 && allowAlongside: doorgaan zonder delete + } + + try { + const result = await prisma.$transaction(async (tx) => { + if (oldPbiId) { + await tx.pbi.delete({ where: { id: oldPbiId } }) + } + + // Codes: één keer SELECT max per type binnen de transactie. Bij P2002 + // (race met andere materialize) abort de transactie en gooien we 409. + const [existingPbis, existingStories, existingTasks] = await Promise.all([ + tx.pbi.findMany({ where: { product_id: productId }, select: { code: true } }), + tx.story.findMany({ where: { product_id: productId }, select: { code: true } }), + tx.task.findMany({ where: { product_id: productId }, select: { code: true } }), + ]) + let nextPbiN = nextNumber(existingPbis.map((p) => p.code), PBI_AUTO_RE) + let nextStoryN = nextNumber(existingStories.map((s) => s.code), STORY_AUTO_RE) + let nextTaskN = nextNumber(existingTasks.map((t) => t.code), TASK_AUTO_RE) + + // sort_order: vraag de huidige max binnen het product op (per priority) + const lastPbi = await tx.pbi.findFirst({ + where: { product_id: productId, priority: plan.pbi.priority }, + orderBy: { sort_order: 'desc' }, + select: { sort_order: true }, + }) + const pbiSortOrder = (lastPbi?.sort_order ?? 0) + 1.0 + + const pbi = await tx.pbi.create({ + data: { + product_id: productId, + code: `PBI-${nextPbiN++}`, + title: plan.pbi.title, + description: plan.pbi.description ?? null, + priority: plan.pbi.priority, + sort_order: pbiSortOrder, + }, + select: { id: true, code: true }, + }) + + const storyIds: string[] = [] + const taskIds: string[] = [] + + for (let si = 0; si < plan.stories.length; si++) { + const s = plan.stories[si] + const storyCode = `ST-${String(nextStoryN++).padStart(3, '0')}` + const story = await tx.story.create({ + data: { + pbi_id: pbi.id, + product_id: productId, + code: storyCode, + title: s.title, + description: s.description ?? null, + acceptance_criteria: s.acceptance_criteria ?? null, + priority: s.priority, + sort_order: parseCodeNumber(storyCode), + status: 'OPEN', + }, + select: { id: true }, + }) + storyIds.push(story.id) + + for (let ti = 0; ti < s.tasks.length; ti++) { + const t = s.tasks[ti] + const taskCode = `T-${nextTaskN++}` + const task = await tx.task.create({ + data: { + story_id: story.id, + product_id: productId, + code: taskCode, + title: t.title, + description: t.description ?? null, + implementation_plan: t.implementation_plan ?? null, + // Erf priority van de story zodat YAML-volgorde gerespecteerd + // blijft. Worker sorteert op `priority ASC, sort_order ASC`; + // gemixte task-priorities binnen één story zouden anders de + // YAML-volgorde verstoren (zie plan-fix task-volgorde-na-upload). + priority: s.priority, + sort_order: parseCodeNumber(taskCode), + status: 'TO_DO', + verify_required: t.verify_required ?? 'ALIGNED_OR_PARTIAL', + verify_only: t.verify_only ?? false, + }, + select: { id: true }, + }) + taskIds.push(task.id) + } + } + + // Link idea → PBI + status PLANNED + await tx.idea.update({ + where: { id }, + data: { pbi_id: pbi.id, status: 'PLANNED' }, + }) + + // Audit log + await tx.ideaLog.create({ + data: { + idea_id: id, + type: 'PLAN_RESULT', + content: `Materialized into ${pbi.code} (${plan.stories.length} stories, ${taskIds.length} tasks)`, + metadata: { + pbi_id: pbi.id, + pbi_code: pbi.code, + story_count: storyIds.length, + task_count: taskIds.length, + }, + }, + }) + + return { pbi_id: pbi.id, pbi_code: pbi.code, story_ids: storyIds, task_ids: taskIds } + }) + + revalidatePath(`/ideas/${id}`) + revalidatePath(`/products/${productId}/backlog`) + return { success: true, data: result } + } catch (err) { + // P2002 op code = race met andere materialize. Andere fouten = bug. + const msg = err instanceof Error ? err.message : String(err) + if (msg.includes('P2002') || msg.includes('Unique constraint')) { + return { + error: 'Code-conflict tijdens materialiseren (race). Probeer opnieuw.', + code: 409, + } + } + throw err + } +} + +// --------------------------------------------------------------------------- +// Re-link: een idee in PLANNED waarvan de PBI handmatig is verwijderd +// (Pbi.id → null door de SetNull-FK). Gebruiker klikt expliciet "Re-link plan" +// om terug naar PLAN_READY te gaan en eventueel opnieuw te materialiseren. + +export async function relinkIdeaPlanAction(id: string): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const idea = await prisma.idea.findFirst({ + where: { id, user_id: session.userId }, + select: { id: true, status: true, pbi_id: true }, + }) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + if (idea.status !== 'PLANNED' || idea.pbi_id !== null) { + return { + error: 'Re-link kan alleen wanneer status=PLANNED én PBI is verwijderd', + code: 422, + } + } + + await prisma.$transaction([ + prisma.idea.update({ where: { id }, data: { status: 'PLAN_READY' } }), + prisma.ideaLog.create({ + data: { + idea_id: id, + type: 'NOTE', + content: 'PBI was deleted; relinked to PLAN_READY', + }, + }), + ]) + + revalidatePath(`/ideas/${id}`) + return { success: true } +} + +// --------------------------------------------------------------------------- +// Helpers + +type IdeaSelect = Array + +async function loadOwnedIdea( + id: string, + userId: string, + fields: S, +): Promise | null> { + const select = Object.fromEntries(fields.map((f) => [f, true])) as { + [K in S[number]]: true + } + return prisma.idea.findFirst({ + where: { id, user_id: userId }, + select, + }) as Promise | null> +} diff --git a/actions/jobs-page.ts b/actions/jobs-page.ts new file mode 100644 index 0000000..22876a5 --- /dev/null +++ b/actions/jobs-page.ts @@ -0,0 +1,35 @@ +'use server' + +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { JOB_INCLUDE, mapJob, buildPriceMap } from '@/lib/jobs-mapper' +import type { RawJob, JobWithRelations, PriceRow } from '@/lib/jobs-mapper' + +export type { JobWithRelations } from '@/lib/jobs-mapper' + +export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelations[]; doneJobs: JobWithRelations[] } | null> { + const session = await getSession() + if (!session.userId) return null + + const [active, done, prices] = await Promise.all([ + prisma.claudeJob.findMany({ + where: { user_id: session.userId, status: { notIn: ['DONE'] } }, + include: JOB_INCLUDE, + orderBy: { created_at: 'desc' }, + }), + prisma.claudeJob.findMany({ + where: { user_id: session.userId, status: 'DONE' }, + include: JOB_INCLUDE, + orderBy: { created_at: 'desc' }, + take: 100, + }), + prisma.modelPrice.findMany(), + ]) + + const priceMap = buildPriceMap(prices as unknown as PriceRow[]) + + return { + activeJobs: active.map((j) => mapJob(j as unknown as RawJob, priceMap)), + doneJobs: done.map((j) => mapJob(j as unknown as RawJob, priceMap)), + } +} diff --git a/actions/pbis.ts b/actions/pbis.ts index 407fa16..2d7aeb1 100644 --- a/actions/pbis.ts +++ b/actions/pbis.ts @@ -10,6 +10,7 @@ import { isValidCode, normalizeCode } from '@/lib/code' import { createWithCodeRetry, generateNextPbiCode } from '@/lib/code-server' import { pbiStatusFromApi } from '@/lib/task-status' import { createPbiSchema, updatePbiSchema } from '@/lib/schemas/pbi' +import { enforceUserRateLimit } from '@/lib/rate-limit' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -22,6 +23,9 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) { if (!session.userId) return { error: 'Niet ingelogd', code: 403 } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + const limited = enforceUserRateLimit('create-pbi', session.userId) + if (limited) return limited + const parsed = createPbiSchema.safeParse({ productId: formData.get('productId'), code: (formData.get('code') as string) || undefined, diff --git a/actions/products.ts b/actions/products.ts index 7024c3b..4292269 100644 --- a/actions/products.ts +++ b/actions/products.ts @@ -11,6 +11,7 @@ import { Role } from '@prisma/client' import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code' import { productAccessFilter } from '@/lib/product-access' import { productSchema as productInput, type ProductInput } from '@/lib/schemas/product' +import { enforceUserRateLimit } from '@/lib/rate-limit' // Legacy FormData schema for ProductForm components (other constraints than dialog) const productFormDataSchema = z.object({ @@ -49,6 +50,9 @@ export async function createProductAction(data: ProductInput): Promise + +export async function subscribeToPushAction(input: SubscribeToPushInput): Promise { + const session = await getSession() + if (!session.userId) return + if (session.isDemo) return + + const parsed = subscribeSchema.safeParse(input) + if (!parsed.success) return + + const { endpoint, keys, userAgent } = parsed.data + await prisma.pushSubscription.upsert({ + where: { endpoint }, + create: { + user_id: session.userId, + endpoint, + p256dh: keys.p256dh, + auth: keys.auth, + user_agent: userAgent ?? null, + }, + update: { + user_id: session.userId, + p256dh: keys.p256dh, + auth: keys.auth, + last_used_at: new Date(), + }, + }) +} + +export async function unsubscribeFromPushAction(args: { endpoint: string }): Promise { + const session = await getSession() + if (!session.userId) return + + await prisma.pushSubscription.deleteMany({ + where: { endpoint: args.endpoint, user_id: session.userId }, + }) +} diff --git a/actions/questions.ts b/actions/questions.ts index a14af59..5a35f10 100644 --- a/actions/questions.ts +++ b/actions/questions.ts @@ -16,6 +16,7 @@ import { prisma } from '@/lib/prisma' import { getSession } from '@/lib/auth' import { productAccessFilter } from '@/lib/product-access' import { answerQuestionSchema } from '@/lib/schemas/question-answer' +import { enforceUserRateLimit } from '@/lib/rate-limit' type ActionResult = { ok: true } | { ok: false; error: string } @@ -27,24 +28,52 @@ export async function answerQuestion( if (!session.userId) return { ok: false, error: 'Niet ingelogd' } if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' } + const limited = enforceUserRateLimit('answer-question', session.userId) + if (limited) return { ok: false, error: limited.error } + const parsed = answerQuestionSchema.safeParse({ questionId, answer }) if (!parsed.success) { const first = parsed.error.issues[0]?.message ?? 'Ongeldige invoer' return { ok: false, error: first } } - // Access-check: gebruiker moet toegang hebben tot het product van de vraag. - // Iedereen met product-membership mag antwoorden — niet alleen de story- - // assignee — consistent met Scrum self-organizing. + // Access-check (M12-aware): + // - Story-questions: iedereen met product-membership mag antwoorden + // (consistent met Scrum self-organizing). + // - Idea-questions: strikt user_id-only — alleen de eigenaar van het + // gekoppelde idee mag antwoorden (M12 grill-keuze 8). + // App-level routing omdat Prisma 7 `{ not: null }` filters in WHERE niet + // accepteert; we fetchen de relevante FK-keys en checken in TS. const question = await prisma.claudeQuestion.findFirst({ - where: { - id: parsed.data.questionId, - product: productAccessFilter(session.userId), + where: { id: parsed.data.questionId }, + select: { + id: true, + story_id: true, + idea_id: true, + product_id: true, + idea: { select: { user_id: true } }, }, - select: { id: true }, }) if (!question) return { ok: false, error: 'Vraag niet gevonden of geen toegang' } + if (question.idea_id) { + // Idea-question: alleen idea-eigenaar. + if (question.idea?.user_id !== session.userId) { + return { ok: false, error: 'Vraag niet gevonden of geen toegang' } + } + } else if (question.story_id) { + // Story-question: bestaand product-access-pad. + const productAccess = await prisma.product.findFirst({ + where: { id: question.product_id, ...productAccessFilter(session.userId) }, + select: { id: true }, + }) + if (!productAccess) { + return { ok: false, error: 'Vraag niet gevonden of geen toegang' } + } + } else { + return { ok: false, error: 'Vraag heeft geen story of idea' } + } + // Atomic state-transitie: alleen open + niet-verlopen vragen worden beantwoord. // Concurrent dubbele submit: PostgreSQL row-locking laat één caller count=1 // zien, de rest count=0 → disambiguatie hieronder. diff --git a/actions/settings.ts b/actions/settings.ts new file mode 100644 index 0000000..17b8a8e --- /dev/null +++ b/actions/settings.ts @@ -0,0 +1,49 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' +import { minQuotaPctSchema } from '@/lib/schemas/user' + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +export async function updateRolesAction(roles: string[]) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const validRoles = ['PRODUCT_OWNER', 'SCRUM_MASTER', 'DEVELOPER'] + const filtered = roles.filter(r => validRoles.includes(r)) + if (filtered.length === 0) return { error: 'Minimaal één rol is verplicht' } + + await prisma.$transaction([ + prisma.userRole.deleteMany({ where: { user_id: session.userId } }), + prisma.userRole.createMany({ + data: filtered.map(role => ({ user_id: session.userId, role: role as 'PRODUCT_OWNER' | 'SCRUM_MASTER' | 'DEVELOPER' })), + }), + ]) + + revalidatePath('/settings') + return { success: true } +} + +export async function updateMinQuotaPctAction(value: number) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', status: 403 } + + const parsed = minQuotaPctSchema.safeParse(value) + if (!parsed.success) return { error: 'Waarde moet tussen 1 en 100 liggen', status: 422 } + + await prisma.user.update({ + where: { id: session.userId }, + data: { min_quota_pct: parsed.data }, + }) + + revalidatePath('/settings') + return { success: true } +} diff --git a/actions/sprint-draft.ts b/actions/sprint-draft.ts new file mode 100644 index 0000000..37beb54 --- /dev/null +++ b/actions/sprint-draft.ts @@ -0,0 +1,121 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { z } from 'zod' +import type { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' +import { productAccessFilter } from '@/lib/product-access' +import { + mergeSettings, + parseUserSettings, + type PendingSprintDraft, + type UserSettings, +} from '@/lib/user-settings' + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +const StoryOverridesSchema = z.object({ + add: z.array(z.string()), + remove: z.array(z.string()), +}).strict() + +const DraftSchema = z.object({ + goal: z.string().min(1), + startAt: z.string().date().optional(), + endAt: z.string().date().optional(), + pbiIntent: z.record(z.string(), z.enum(['all', 'none'])).default({}), + storyOverrides: z.record(z.string(), StoryOverridesSchema).default({}), +}).strict() + +const SetSchema = z.object({ + productId: z.string().min(1), + draft: DraftSchema, +}) + +const ClearSchema = z.object({ + productId: z.string().min(1), +}) + +async function ensureProductAccess(userId: string, productId: string) { + return prisma.product.findFirst({ + where: { id: productId, ...productAccessFilter(userId) }, + select: { id: true }, + }) +} + +async function readUserSettings(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { settings: true }, + }) + return parseUserSettings(user?.settings) +} + +async function writeUserSettings(userId: string, next: UserSettings) { + await prisma.user.update({ + where: { id: userId }, + data: { settings: next as unknown as Prisma.InputJsonValue }, + }) +} + +export async function setPendingSprintDraftAction( + productId: string, + draft: PendingSprintDraft, +) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = SetSchema.safeParse({ productId, draft }) + if (!parsed.success) { + return { error: 'Ongeldige draft', issues: parsed.error.issues } + } + + const product = await ensureProductAccess(session.userId, parsed.data.productId) + if (!product) return { error: 'Product niet gevonden of niet toegankelijk' } + + const current = await readUserSettings(session.userId) + const patch: Partial = { + workflow: { + pendingSprintDraft: { + ...(current.workflow?.pendingSprintDraft ?? {}), + [parsed.data.productId]: parsed.data.draft, + }, + }, + } + await writeUserSettings(session.userId, mergeSettings(current, patch)) + revalidatePath('/', 'layout') + return { success: true } +} + +export async function clearPendingSprintDraftAction(productId: string) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = ClearSchema.safeParse({ productId }) + if (!parsed.success) return { error: 'Ongeldig product-id' } + + const product = await ensureProductAccess(session.userId, parsed.data.productId) + if (!product) return { error: 'Product niet gevonden of niet toegankelijk' } + + const current = await readUserSettings(session.userId) + const existingMap = current.workflow?.pendingSprintDraft + if (!existingMap || !(parsed.data.productId in existingMap)) { + return { success: true } + } + const nextMap = { ...existingMap } + delete nextMap[parsed.data.productId] + const next: UserSettings = { + ...current, + workflow: { ...current.workflow, pendingSprintDraft: nextMap }, + } + await writeUserSettings(session.userId, next) + revalidatePath('/', 'layout') + return { success: true } +} diff --git a/actions/sprint-runs.ts b/actions/sprint-runs.ts new file mode 100644 index 0000000..8b232d0 --- /dev/null +++ b/actions/sprint-runs.ts @@ -0,0 +1,494 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { z } from 'zod' +import { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' +import { parsePauseContext } from '@/lib/pause-context' +import { getJobConfigSnapshot } from '@/lib/job-config-snapshot' + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +export type PreFlightBlockerType = + | 'task_no_plan' + | 'open_question' + | 'pbi_blocked' + | 'task_cross_repo' + +export interface PreFlightBlocker { + type: PreFlightBlockerType + id: string + label: string +} + +const StartSprintRunInput = z.object({ sprint_id: z.string().min(1) }) +const ResumeSprintInput = z.object({ sprint_id: z.string().min(1) }) +const CancelSprintRunInput = z.object({ sprint_run_id: z.string().min(1) }) + +interface StartResultOk { + ok: true + sprint_run_id: string + jobs_count: number +} + +interface StartResultBlocked { + ok: false + error: 'PRE_FLIGHT_BLOCKED' + blockers: PreFlightBlocker[] +} + +interface ErrorResult { + ok: false + error: string + code: number +} + +type StartResult = StartResultOk | StartResultBlocked | ErrorResult + +// startSprintRunCore is gedeeld tussen startSprintRunAction en resumeSprintAction. +// Voert de pre-flight uit, maakt een SprintRun + ClaudeJobs (in PBI→Story→Task +// volgorde) binnen één transactie. Aanroeper levert sprint_id, user_id en de +// transactionele Prisma-client. +async function startSprintRunCore( + tx: Prisma.TransactionClient, + sprint_id: string, + user_id: string, +): Promise { + const sprint = await tx.sprint.findUnique({ + where: { id: sprint_id }, + include: { product: true }, + }) + if (!sprint) return { ok: false, error: 'SPRINT_NOT_FOUND', code: 404 } + if (sprint.status !== 'OPEN') + return { ok: false, error: 'SPRINT_NOT_ACTIVE', code: 400 } + + const activeRun = await tx.sprintRun.findFirst({ + where: { + sprint_id, + status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] }, + }, + }) + if (activeRun) + return { ok: false, error: 'SPRINT_RUN_ALREADY_ACTIVE', code: 409 } + + const stories = await tx.story.findMany({ + where: { sprint_id, status: { not: 'DONE' } }, + include: { + pbi: true, + tasks: { + // EXCLUDED-taken worden hier impliciet uitgesloten: de filter is strikt + // TO_DO, dus EXCLUDED/IN_PROGRESS/REVIEW/DONE/FAILED tasks komen niet + // terecht in pre-flight blockers, jobs of SprintTaskExecution-rijen. + where: { status: 'TO_DO' }, + orderBy: [{ sort_order: 'asc' }], + }, + }, + orderBy: [{ sort_order: 'asc' }], + }) + + const blockers: PreFlightBlocker[] = [] + + for (const s of stories) { + for (const t of s.tasks) { + if (!t.implementation_plan) { + blockers.push({ + type: 'task_no_plan', + id: t.id, + label: `${t.code}: ${t.title}`, + }) + } + } + } + + const openQuestions = await tx.claudeQuestion.findMany({ + where: { story: { sprint_id }, status: 'open' }, + select: { id: true, question: true }, + }) + for (const q of openQuestions) { + blockers.push({ + type: 'open_question', + id: q.id, + label: q.question.slice(0, 80), + }) + } + + const seenPbi = new Set() + for (const s of stories) { + if (seenPbi.has(s.pbi.id)) continue + seenPbi.add(s.pbi.id) + if (s.pbi.status === 'BLOCKED' || s.pbi.status === 'FAILED') { + blockers.push({ + type: 'pbi_blocked', + id: s.pbi.id, + label: `${s.pbi.code}: ${s.pbi.title}`, + }) + } + } + + // PBI-50: SPRINT_BATCH cross-repo blocker. Eén product-worktree = + // alle tasks moeten in product.repo_url werken; task.repo_url-override + // is incompatibel met deze flow. + if (sprint.product.pr_strategy === 'SPRINT_BATCH') { + for (const s of stories) { + for (const t of s.tasks) { + if (t.repo_url && t.repo_url !== sprint.product.repo_url) { + blockers.push({ + type: 'task_cross_repo', + id: t.id, + label: `${t.code}: ${t.title}`, + }) + } + } + } + } + + if (blockers.length > 0) { + return { ok: false, error: 'PRE_FLIGHT_BLOCKED', blockers } + } + + const sprintRun = await tx.sprintRun.create({ + data: { + sprint_id, + started_by_id: user_id, + status: 'QUEUED', + pr_strategy: sprint.product.pr_strategy, + started_at: new Date(), + }, + }) + + const orderedTasks = stories + .slice() + .sort( + (a, b) => + a.pbi.priority - b.pbi.priority || + a.pbi.sort_order - b.pbi.sort_order || + a.sort_order - b.sort_order, + ) + .flatMap((s) => s.tasks) + + // PBI-50: SPRINT_BATCH levert één SPRINT_IMPLEMENTATION-job die alle + // tasks in één claude-sessie afhandelt. SprintTaskExecution-rows worden + // server-side bij claim aangemaakt zodat order/base_sha consistent zijn + // met de worktree-state op claim-tijd. + if (sprint.product.pr_strategy === 'SPRINT_BATCH') { + const sprintSnapshot = await getJobConfigSnapshot({ + kind: 'SPRINT_IMPLEMENTATION', + productId: sprint.product_id, + }) + await tx.claudeJob.create({ + data: { + user_id, + product_id: sprint.product_id, + task_id: null, + idea_id: null, + sprint_run_id: sprintRun.id, + kind: 'SPRINT_IMPLEMENTATION', + status: 'QUEUED', + ...sprintSnapshot, + }, + }) + return { ok: true, sprint_run_id: sprintRun.id, jobs_count: 1 } + } + + // STORY / SPRINT (per-task): bestaand pad. Snapshot per task zodat + // task.requires_opus de cascade kan overrulen. + for (const t of orderedTasks) { + const taskSnapshot = await getJobConfigSnapshot({ + kind: 'TASK_IMPLEMENTATION', + productId: sprint.product_id, + taskId: t.id, + }) + await tx.claudeJob.create({ + data: { + user_id, + product_id: sprint.product_id, + task_id: t.id, + sprint_run_id: sprintRun.id, + kind: 'TASK_IMPLEMENTATION', + status: 'QUEUED', + ...taskSnapshot, + }, + }) + } + + return { ok: true, sprint_run_id: sprintRun.id, jobs_count: orderedTasks.length } +} + +export async function startSprintRunAction(input: unknown): Promise { + const session = await getSession() + if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 } + if (session.isDemo) + return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 } + + const parsed = StartSprintRunInput.safeParse(input) + if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 } + + const userId = session.userId + const result = await prisma.$transaction((tx) => + startSprintRunCore(tx, parsed.data.sprint_id, userId), + ) + + if (result.ok) { + revalidatePath(`/sprints/${parsed.data.sprint_id}`) + } + return result +} + +export async function resumeSprintAction(input: unknown): Promise { + const session = await getSession() + if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 } + if (session.isDemo) + return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 } + + const parsed = ResumeSprintInput.safeParse(input) + if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 } + + const userId = session.userId + const sprint_id = parsed.data.sprint_id + + const result = await prisma.$transaction(async (tx) => { + const sprint = await tx.sprint.findUnique({ where: { id: sprint_id } }) + if (!sprint) + return { ok: false as const, error: 'SPRINT_NOT_FOUND', code: 404 } + if (sprint.status !== 'FAILED') + return { ok: false as const, error: 'SPRINT_NOT_FAILED', code: 400 } + + // Sprint terug naar ACTIVE + await tx.sprint.update({ + where: { id: sprint_id }, + data: { status: 'OPEN', completed_at: null }, + }) + + // FAILED stories binnen sprint terug naar IN_SPRINT (DONE blijft) + await tx.story.updateMany({ + where: { sprint_id, status: 'FAILED' }, + data: { status: 'IN_SPRINT' }, + }) + + // PBIs van die stories: FAILED → READY (BLOCKED met rust laten) + const storyPbiIds = ( + await tx.story.findMany({ + where: { sprint_id }, + select: { pbi_id: true }, + distinct: ['pbi_id'], + }) + ).map((s) => s.pbi_id) + await tx.pbi.updateMany({ + where: { id: { in: storyPbiIds }, status: 'FAILED' }, + data: { status: 'READY' }, + }) + + // FAILED tasks → TO_DO (DONE blijft) + await tx.task.updateMany({ + where: { story: { sprint_id }, status: 'FAILED' }, + data: { status: 'TO_DO' }, + }) + + return startSprintRunCore(tx, sprint_id, userId) + }) + + if (result.ok) { + revalidatePath(`/sprints/${sprint_id}`) + } + return result +} + +const ResumePausedSprintRunInput = z.object({ sprint_run_id: z.string().min(1) }) + +interface ResumePausedResultOk { + ok: true +} + +type ResumePausedResult = ResumePausedResultOk | ErrorResult + +export async function resumePausedSprintRunAction( + input: unknown, +): Promise { + const session = await getSession() + if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 } + if (session.isDemo) + return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 } + + const parsed = ResumePausedSprintRunInput.safeParse(input) + if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 } + + const sprint_run_id = parsed.data.sprint_run_id + + const userId = session.userId + const result = await prisma.$transaction(async (tx) => { + const run = await tx.sprintRun.findUnique({ + where: { id: sprint_run_id }, + select: { + id: true, + status: true, + sprint_id: true, + pr_strategy: true, + branch: true, + pause_context: true, + }, + }) + if (!run) return { ok: false as const, error: 'SPRINT_RUN_NOT_FOUND', code: 404 } + if (run.status !== 'PAUSED') + return { ok: false as const, error: 'SPRINT_RUN_NOT_PAUSED', code: 400 } + + const ctx = parsePauseContext(run.pause_context) + if (ctx) { + await tx.claudeQuestion.updateMany({ + where: { id: ctx.claude_question_id, status: 'open' }, + data: { status: 'closed' }, + }) + } + + // PBI-50: SPRINT_BATCH resume-pad — als de SprintRun hangt aan een + // SPRINT_IMPLEMENTATION-job en er nog onafgemaakte SprintTaskExecution-rows + // zijn (PENDING/RUNNING), maak NIEUWE SprintRun met previous_run_id + + // hergebruikte branch + nieuwe SPRINT_IMPLEMENTATION-job. Oude SprintRun + // gaat naar CANCELLED. + const sprintJob = await tx.claudeJob.findFirst({ + where: { sprint_run_id, kind: 'SPRINT_IMPLEMENTATION' }, + select: { id: true, product_id: true }, + }) + if (sprintJob) { + const remaining = await tx.sprintTaskExecution.count({ + where: { + sprint_job_id: sprintJob.id, + status: { in: ['PENDING', 'RUNNING'] }, + }, + }) + if (remaining > 0) { + const newRun = await tx.sprintRun.create({ + data: { + sprint_id: run.sprint_id, + started_by_id: userId, + status: 'QUEUED', + pr_strategy: run.pr_strategy, + branch: run.branch, + previous_run_id: run.id, + started_at: new Date(), + }, + }) + const resumeSnapshot = await getJobConfigSnapshot({ + kind: 'SPRINT_IMPLEMENTATION', + productId: sprintJob.product_id, + }) + await tx.claudeJob.create({ + data: { + user_id: userId, + product_id: sprintJob.product_id, + task_id: null, + idea_id: null, + sprint_run_id: newRun.id, + kind: 'SPRINT_IMPLEMENTATION', + status: 'QUEUED', + ...resumeSnapshot, + }, + }) + await tx.sprintRun.update({ + where: { id: sprint_run_id }, + data: { + status: 'CANCELLED', + pause_context: Prisma.JsonNull, + finished_at: new Date(), + }, + }) + return { ok: true as const, sprint_id: run.sprint_id, finalStatus: 'QUEUED' as const } + } + } + + const activeClaims = await tx.claudeJob.count({ + where: { sprint_run_id, status: { in: ['CLAIMED', 'RUNNING'] } }, + }) + const queuedJobs = await tx.claudeJob.count({ + where: { sprint_run_id, status: 'QUEUED' }, + }) + + // PBI-49 P0: een STORY auto-merge MERGE_CONFLICT komt NA dat alle tasks + // al DONE zijn. Terug naar QUEUED zou de SprintRun voor altijd laten + // hangen — geen QUEUED job. Bij volledige scope-completion transitie + // direct naar DONE; de dev heeft het conflict opgelost, de PR is van hen. + let nextStatus: 'RUNNING' | 'QUEUED' | 'DONE' + let finishedAt: Date | undefined + if (activeClaims === 0 && queuedJobs === 0) { + nextStatus = 'DONE' + finishedAt = new Date() + } else if (activeClaims > 0) { + nextStatus = 'RUNNING' + } else { + nextStatus = 'QUEUED' + } + + await tx.sprintRun.update({ + where: { id: sprint_run_id }, + data: { + status: nextStatus, + pause_context: Prisma.JsonNull, + ...(finishedAt ? { finished_at: finishedAt } : {}), + }, + }) + + return { ok: true as const, sprint_id: run.sprint_id, finalStatus: nextStatus } + }) + + if (result.ok && 'sprint_id' in result) { + revalidatePath(`/sprints/${result.sprint_id}`) + return { ok: true } + } + return result +} + +interface CancelResultOk { + ok: true +} + +type CancelResult = CancelResultOk | ErrorResult + +export async function cancelSprintRunAction(input: unknown): Promise { + const session = await getSession() + if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 } + if (session.isDemo) + return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 } + + const parsed = CancelSprintRunInput.safeParse(input) + if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 } + + const sprint_run_id = parsed.data.sprint_run_id + + const result = await prisma.$transaction(async (tx) => { + const run = await tx.sprintRun.findUnique({ where: { id: sprint_run_id } }) + if (!run) + return { ok: false as const, error: 'SPRINT_RUN_NOT_FOUND', code: 404 } + if (!['QUEUED', 'RUNNING', 'PAUSED'].includes(run.status)) + return { ok: false as const, error: 'SPRINT_RUN_NOT_CANCELLABLE', code: 400 } + + await tx.sprintRun.update({ + where: { id: sprint_run_id }, + data: { status: 'CANCELLED', finished_at: new Date() }, + }) + + // Cancel openstaande task-jobs binnen deze run. + // Tasks/Stories/PBIs/Sprint blijven hun status — cancel ≠ fail. + await tx.claudeJob.updateMany({ + where: { + sprint_run_id, + status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, + }, + data: { + status: 'CANCELLED', + finished_at: new Date(), + }, + }) + + return { ok: true as const, sprint_id: run.sprint_id } + }) + + if (result.ok && 'sprint_id' in result) { + revalidatePath(`/sprints/${result.sprint_id}`) + return { ok: true } + } + return result +} diff --git a/actions/sprints.ts b/actions/sprints.ts index 60f109c..8ccc80e 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -11,6 +11,361 @@ import { updateSprintDatesSchema, updateSprintGoalSchema, } from '@/lib/schemas/sprint' +import { enforceUserRateLimit } from '@/lib/rate-limit' +import { propagateStatusUpwards } from '@/lib/tasks-status-update' +import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server' +import { setActiveSprintInSettings } from '@/lib/active-sprint' +import { partitionByEligibility } from '@/lib/sprint-conflicts' +import { z } from 'zod' + +const StoryOverrideSchema = z.object({ + add: z.array(z.string()), + remove: z.array(z.string()), +}) + +const createSprintWithSelectionSchema = z.object({ + productId: z.string().min(1), + metadata: z.object({ + goal: z.string().min(1).max(2000), + startAt: z.string().date().optional(), + endAt: z.string().date().optional(), + }), + pbiIntent: z.record(z.string(), z.enum(['all', 'none'])).default({}), + storyOverrides: z.record(z.string(), StoryOverrideSchema).default({}), +}) + +export type CreateSprintWithSelectionInput = z.infer< + typeof createSprintWithSelectionSchema +> + +type SprintCreateConflicts = { + notEligible: { storyId: string; reason: 'DONE' | 'IN_OTHER_SPRINT' }[] + crossSprint: { storyId: string; sprintId: string; sprintName: string }[] +} + +export type CreateSprintWithSelectionResult = + | { + success: true + sprintId: string + affectedStoryIds: string[] + affectedPbiIds: string[] + affectedTaskIds: string[] + conflicts: SprintCreateConflicts + } + | { error: string; code: number } + +const updateSprintSchema = z.object({ + sprintId: z.string().min(1), + fields: z + .object({ + goal: z.string().min(1).max(2000).optional(), + startAt: z.string().date().nullable().optional(), + endAt: z.string().date().nullable().optional(), + }) + .refine( + (data) => Object.keys(data).length > 0, + 'Minstens één veld vereist', + ), +}) + +export type UpdateSprintInput = z.infer + +export type UpdateSprintResult = + | { success: true; sprintId: string } + | { error: string; code: number } + +export async function updateSprintAction( + input: UpdateSprintInput, +): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 403 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const parsed = updateSprintSchema.safeParse(input) + if (!parsed.success) return { error: 'Validatie mislukt', code: 422 } + + const sprint = await prisma.sprint.findFirst({ + where: { + id: parsed.data.sprintId, + product: productAccessFilter(session.userId), + }, + select: { id: true, product_id: true }, + }) + if (!sprint) return { error: 'Sprint niet gevonden', code: 403 } + + const data: { sprint_goal?: string; start_date?: Date | null; end_date?: Date | null } = {} + if (parsed.data.fields.goal !== undefined) { + data.sprint_goal = parsed.data.fields.goal + } + if (parsed.data.fields.startAt !== undefined) { + data.start_date = parseDate(parsed.data.fields.startAt) + } + if (parsed.data.fields.endAt !== undefined) { + data.end_date = parseDate(parsed.data.fields.endAt) + } + + await prisma.sprint.update({ + where: { id: parsed.data.sprintId }, + data, + }) + revalidatePath(`/products/${sprint.product_id}`, 'layout') + + return { success: true, sprintId: parsed.data.sprintId } +} + +const commitSprintMembershipSchema = z.object({ + activeSprintId: z.string().min(1), + adds: z.array(z.string()), + removes: z.array(z.string()), +}) + +export type CommitSprintMembershipInput = z.infer< + typeof commitSprintMembershipSchema +> + +type CommitConflicts = { + notEligible: { storyId: string; reason: 'DONE' | 'IN_OTHER_SPRINT' }[] + alreadyRemoved: string[] +} + +export type CommitSprintMembershipResult = + | { + success: true + affectedStoryIds: string[] + affectedPbiIds: string[] + affectedTaskIds: string[] + conflicts: CommitConflicts + } + | { error: string; code: number } + +export async function commitSprintMembershipAction( + input: CommitSprintMembershipInput, +): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 403 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const parsed = commitSprintMembershipSchema.safeParse(input) + if (!parsed.success) return { error: 'Validatie mislukt', code: 422 } + + // Sprint moet bestaan en bereikbaar zijn via product-access. + const sprint = await prisma.sprint.findFirst({ + where: { + id: parsed.data.activeSprintId, + product: productAccessFilter(session.userId), + }, + select: { id: true, product_id: true }, + }) + if (!sprint) { + return { error: 'Sprint niet gevonden of niet toegankelijk', code: 403 } + } + + // Filter adds via eligibility (sprint_id IS NULL en niet DONE; andere OPEN + // sprint → conflicts.notEligible + crossSprint). + const addPartition = await partitionByEligibility( + prisma, + parsed.data.adds, + parsed.data.activeSprintId, + ) + const eligibleAdds = addPartition.eligible + const notEligibleAdds = addPartition.notEligible + + // Race-safety voor removes: alleen stories die feitelijk in de actieve + // sprint zitten worden verwijderd. + const removeRows = + parsed.data.removes.length > 0 + ? await prisma.story.findMany({ + where: { + id: { in: parsed.data.removes }, + sprint_id: parsed.data.activeSprintId, + }, + select: { id: true }, + }) + : [] + const validRemoves = removeRows.map((r) => r.id) + const validRemoveSet = new Set(validRemoves) + const alreadyRemoved = parsed.data.removes.filter( + (id) => !validRemoveSet.has(id), + ) + + if (eligibleAdds.length === 0 && validRemoves.length === 0) { + // Geen werk te doen — geef toch een success-shape terug zodat de client + // pending buffer kan resetten + conflicts kan tonen. + return { + success: true, + affectedStoryIds: [], + affectedPbiIds: [], + affectedTaskIds: [], + conflicts: { notEligible: notEligibleAdds, alreadyRemoved }, + } + } + + await prisma.$transaction(async (tx) => { + if (eligibleAdds.length > 0) { + await tx.story.updateMany({ + where: { id: { in: eligibleAdds } }, + data: { sprint_id: parsed.data.activeSprintId, status: 'IN_SPRINT' }, + }) + await tx.task.updateMany({ + where: { story_id: { in: eligibleAdds } }, + data: { sprint_id: parsed.data.activeSprintId }, + }) + } + if (validRemoves.length > 0) { + await tx.story.updateMany({ + where: { id: { in: validRemoves } }, + data: { sprint_id: null, status: 'OPEN' }, + }) + await tx.task.updateMany({ + where: { story_id: { in: validRemoves } }, + data: { sprint_id: null }, + }) + } + }) + + const affectedStoryIds = [...eligibleAdds, ...validRemoves] + const affectedStories = + affectedStoryIds.length > 0 + ? await prisma.story.findMany({ + where: { id: { in: affectedStoryIds } }, + select: { pbi_id: true }, + }) + : [] + const affectedPbiIds = Array.from( + new Set(affectedStories.map((s) => s.pbi_id)), + ) + const affectedTasks = + affectedStoryIds.length > 0 + ? await prisma.task.findMany({ + where: { story_id: { in: affectedStoryIds } }, + select: { id: true }, + }) + : [] + const affectedTaskIds = affectedTasks.map((t) => t.id) + + revalidatePath(`/products/${sprint.product_id}`, 'layout') + + return { + success: true, + affectedStoryIds, + affectedPbiIds, + affectedTaskIds, + conflicts: { notEligible: notEligibleAdds, alreadyRemoved }, + } +} + +export async function createSprintWithSelectionAction( + input: CreateSprintWithSelectionInput, +): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 403 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const limited = enforceUserRateLimit('create-sprint', session.userId) + if (limited) return { error: limited.error, code: limited.code } + + const parsed = createSprintWithSelectionSchema.safeParse(input) + if (!parsed.success) return { error: 'Validatie mislukt', code: 422 } + + const product = await getAccessibleProduct(parsed.data.productId, session.userId) + if (!product) return { error: 'Product niet gevonden', code: 403 } + + // Resolveer intent + per-PBI overrides naar concrete story-IDs. + const allPbiAllIds = Object.entries(parsed.data.pbiIntent) + .filter(([, intent]) => intent === 'all') + .map(([pbiId]) => pbiId) + + // Stap 1: alle child-stories voor PBI's met intent='all'. + let candidate: string[] = [] + if (allPbiAllIds.length > 0) { + const rows = await prisma.story.findMany({ + where: { pbi_id: { in: allPbiAllIds }, product_id: parsed.data.productId }, + select: { id: true, pbi_id: true }, + }) + const removedSet = new Set() + for (const [pbiId, override] of Object.entries(parsed.data.storyOverrides)) { + for (const id of override.remove) removedSet.add(`${pbiId}:${id}`) + } + candidate = rows + .filter((row) => !removedSet.has(`${row.pbi_id}:${row.id}`)) + .map((row) => row.id) + } + + // Stap 2: storyOverrides.add — werkt voor zowel intent='none' als 'all' (extra + // toevoegingen). Dedupliceren met candidates uit stap 1. + const candidateSet = new Set(candidate) + for (const override of Object.values(parsed.data.storyOverrides)) { + for (const id of override.add) candidateSet.add(id) + } + const candidateIds = Array.from(candidateSet) + + // Eligibility-filter (incl. cross-sprint guard). + const partition = await partitionByEligibility(prisma, candidateIds) + + if (partition.eligible.length === 0) { + return { + error: 'Geen eligible stories voor deze sprint', + code: 422, + } + } + + const sprint = await createWithCodeRetry( + () => generateNextSprintCode(parsed.data.productId), + (code) => + prisma.$transaction(async (tx) => { + const created = await tx.sprint.create({ + data: { + product_id: parsed.data.productId, + code, + sprint_goal: parsed.data.metadata.goal, + status: 'OPEN', + start_date: parseDate(parsed.data.metadata.startAt), + end_date: parseDate(parsed.data.metadata.endAt), + }, + }) + await tx.story.updateMany({ + where: { id: { in: partition.eligible } }, + data: { sprint_id: created.id, status: 'IN_SPRINT' }, + }) + await tx.task.updateMany({ + where: { story_id: { in: partition.eligible } }, + data: { sprint_id: created.id }, + }) + return created + }), + ) + + // Snapshot affected pbi/task IDs voor client-store patches. + const affectedStories = await prisma.story.findMany({ + where: { id: { in: partition.eligible } }, + select: { pbi_id: true }, + }) + const affectedPbiIds = Array.from(new Set(affectedStories.map((s) => s.pbi_id))) + const affectedTasks = await prisma.task.findMany({ + where: { story_id: { in: partition.eligible } }, + select: { id: true }, + }) + const affectedTaskIds = affectedTasks.map((t) => t.id) + + await setActiveSprintInSettings( + session.userId, + parsed.data.productId, + sprint.id, + ) + revalidatePath(`/products/${parsed.data.productId}`, 'layout') + + return { + success: true, + sprintId: sprint.id, + affectedStoryIds: partition.eligible, + affectedPbiIds, + affectedTaskIds, + conflicts: { + notEligible: partition.notEligible, + crossSprint: partition.crossSprint, + }, + } +} async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -27,11 +382,15 @@ export async function createSprintAction(_prevState: unknown, formData: FormData if (!session.userId) return { error: 'Niet ingelogd', code: 403 } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + const limited = enforceUserRateLimit('create-sprint', session.userId) + if (limited) return limited + const parsed = createSprintSchema.safeParse({ productId: formData.get('productId'), sprint_goal: formData.get('sprint_goal'), start_date: formData.get('start_date'), end_date: formData.get('end_date'), + pbi_id: formData.get('pbi_id'), }) if (!parsed.success) { return { @@ -44,22 +403,57 @@ export async function createSprintAction(_prevState: unknown, formData: FormData const product = await getAccessibleProduct(parsed.data.productId, session.userId) if (!product) return { error: 'Product niet gevonden', code: 403 } - const existing = await prisma.sprint.findFirst({ - where: { product_id: parsed.data.productId, status: 'ACTIVE' }, - }) - if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id, code: 422 } + // PBI-79 / ST-1342: multi-OPEN sprints toegestaan. Bestaande OPEN sprints + // op hetzelfde product zijn geen reden meer om aanmaak te blokkeren — + // cross-sprint-conflicts worden per-story afgevangen in de membership- + // commit-flow. - const sprint = await prisma.sprint.create({ - data: { - product_id: parsed.data.productId, - sprint_goal: parsed.data.sprint_goal, - status: 'ACTIVE', - start_date: parsed.data.start_date, - end_date: parsed.data.end_date, - }, - }) + const sprint = await createWithCodeRetry( + () => generateNextSprintCode(parsed.data.productId), + (code) => + prisma.sprint.create({ + data: { + product_id: parsed.data.productId, + code, + sprint_goal: parsed.data.sprint_goal, + status: 'OPEN', + start_date: parsed.data.start_date, + end_date: parsed.data.end_date, + }, + }), + ) - revalidatePath(`/products/${parsed.data.productId}`) + if (parsed.data.pbi_id) { + const pbi = await prisma.pbi.findFirst({ + where: { id: parsed.data.pbi_id, product_id: parsed.data.productId }, + select: { id: true }, + }) + if (pbi) { + const stories = await prisma.story.findMany({ + where: { pbi_id: pbi.id, sprint_id: null }, + orderBy: [{ sort_order: 'asc' }], + select: { id: true }, + }) + if (stories.length > 0) { + const storyIds = stories.map(s => s.id) + await prisma.$transaction([ + ...stories.map((s, i) => + prisma.story.update({ + where: { id: s.id }, + data: { sprint_id: sprint.id, status: 'IN_SPRINT' }, + }), + ), + prisma.task.updateMany({ + where: { story_id: { in: storyIds }, sprint_id: null }, + data: { sprint_id: sprint.id }, + }), + ]) + } + } + } + + await setActiveSprintInSettings(session.userId, parsed.data.productId, sprint.id) + revalidatePath(`/products/${parsed.data.productId}`, 'layout') return { success: true, sprintId: sprint.id } } @@ -137,14 +531,9 @@ export async function addStoryToSprintAction(sprintId: string, storyId: string) if (!story) return { error: 'Story niet gevonden' } if (story.product_id !== sprint.product_id) return { error: 'Story hoort niet bij deze Sprint' } - const last = await prisma.story.findFirst({ - where: { sprint_id: sprintId }, - orderBy: { sort_order: 'desc' }, - }) - await prisma.story.update({ where: { id: storyId }, - data: { sprint_id: sprintId, status: 'IN_SPRINT', sort_order: (last?.sort_order ?? 0) + 1.0 }, + data: { sprint_id: sprintId, status: 'IN_SPRINT' }, }) revalidatePath(`/products/${sprint.product_id}/sprint`) @@ -173,32 +562,6 @@ export async function removeStoryFromSprintAction(storyId: string) { return { success: true } } -export async function reorderSprintStoriesAction(sprintId: string, orderedIds: string[]) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige Sprint Backlog-volgorde' } - - const sprint = await prisma.sprint.findFirst({ - where: { id: sprintId, product: productAccessFilter(session.userId) }, - }) - if (!sprint) return { error: 'Sprint niet gevonden' } - - const stories = await prisma.story.findMany({ - where: { id: { in: orderedIds }, sprint_id: sprintId, product_id: sprint.product_id }, - select: { id: true }, - }) - if (stories.length !== orderedIds.length) return { error: 'Ongeldige Sprint Backlog-volgorde' } - - await prisma.$transaction( - orderedIds.map((id, i) => - prisma.story.update({ where: { id }, data: { sort_order: i + 1.0 } }) - ) - ) - - revalidatePath(`/products/${sprint.product_id}/sprint`) - return { success: true } -} export async function completeSprintAction( sprintId: string, @@ -260,7 +623,7 @@ export async function completeSprintAction( ), prisma.sprint.update({ where: { id: sprintId }, - data: { status: 'COMPLETED', completed_at: new Date() }, + data: { status: 'CLOSED', completed_at: new Date() }, }), ]) @@ -268,3 +631,104 @@ export async function completeSprintAction( revalidatePath(`/products/${sprint.product_id}/sprint`) return { success: true } } + +export async function setAllSprintTasksDoneAction( + sprintId: string, +): Promise<{ ok: true } | { ok: false; error: string }> { + const session = await getSession() + if (!session.userId) return { ok: false, error: 'Niet ingelogd' } + if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' } + + const sprint = await prisma.sprint.findFirst({ + where: { id: sprintId, product: productAccessFilter(session.userId) }, + select: { id: true, product_id: true }, + }) + if (!sprint) return { ok: false, error: 'Sprint niet gevonden' } + + const tasks = await prisma.task.findMany({ + where: { sprint_id: sprintId, product_id: sprint.product_id }, + select: { id: true }, + }) + + await prisma.$transaction(async (tx) => { + for (const task of tasks) { + await propagateStatusUpwards(task.id, 'DONE', tx) + } + }) + + revalidatePath(`/products/${sprint.product_id}/sprint`) + return { ok: true } +} + +const createSprintWithPbisSchema = z.object({ + productId: z.string().min(1), + sprint_goal: z.string().min(1).max(2000), + start_date: z.string().nullable().optional(), + end_date: z.string().nullable().optional(), + pbi_ids: z.array(z.string().min(1)).min(1), +}) + +function parseDate(value: string | null | undefined): Date | null { + if (!value) return null + const d = new Date(value) + return Number.isNaN(d.getTime()) ? null : d +} + +export async function createSprintWithPbisAction(input: { + productId: string + sprint_goal: string + start_date?: string | null + end_date?: string | null + pbi_ids: string[] +}): Promise<{ success: true; sprintId: string } | { error: string; code: number }> { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 403 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const limited = enforceUserRateLimit('create-sprint', session.userId) + if (limited) return { error: limited.error, code: limited.code } + + const parsed = createSprintWithPbisSchema.safeParse(input) + if (!parsed.success) return { error: 'Validatie mislukt', code: 422 } + + const product = await getAccessibleProduct(parsed.data.productId, session.userId) + if (!product) return { error: 'Product niet gevonden', code: 403 } + + const pbis = await prisma.pbi.findMany({ + where: { id: { in: parsed.data.pbi_ids }, product_id: parsed.data.productId }, + select: { id: true }, + }) + if (pbis.length !== parsed.data.pbi_ids.length) { + return { error: "Een of meer PBI's behoren niet tot dit product", code: 422 } + } + + const sprint = await createWithCodeRetry( + () => generateNextSprintCode(parsed.data.productId), + (code) => + prisma.$transaction(async (tx) => { + const created = await tx.sprint.create({ + data: { + product_id: parsed.data.productId, + code, + sprint_goal: parsed.data.sprint_goal, + status: 'OPEN', + start_date: parseDate(parsed.data.start_date), + end_date: parseDate(parsed.data.end_date), + }, + }) + await tx.story.updateMany({ + where: { pbi_id: { in: parsed.data.pbi_ids } }, + data: { sprint_id: created.id, status: 'IN_SPRINT' }, + }) + await tx.task.updateMany({ + where: { story: { pbi_id: { in: parsed.data.pbi_ids } } }, + data: { sprint_id: created.id }, + }) + return created + }), + ) + + await setActiveSprintInSettings(session.userId, parsed.data.productId, sprint.id) + revalidatePath(`/products/${parsed.data.productId}`, 'layout') + return { success: true, sprintId: sprint.id } +} diff --git a/actions/stories.ts b/actions/stories.ts index d980f56..dbac04a 100644 --- a/actions/stories.ts +++ b/actions/stories.ts @@ -7,9 +7,10 @@ import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access' import { requireProductWriter } from '@/lib/auth' -import { isValidCode, normalizeCode } from '@/lib/code' +import { isValidCode, normalizeCode, parseCodeNumber } from '@/lib/code' import { createWithCodeRetry, generateNextStoryCode } from '@/lib/code-server' import { createStorySchema, updateStorySchema } from '@/lib/schemas/story' +import { enforceUserRateLimit } from '@/lib/rate-limit' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -33,6 +34,9 @@ export async function createStoryAction(_prevState: unknown, formData: FormData) if (!session.userId) return { error: 'Niet ingelogd', code: 403 } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + const limited = enforceUserRateLimit('create-story', session.userId) + if (limited) return limited + const parsed = createStorySchema.safeParse({ pbiId: formData.get('pbiId'), productId: formData.get('productId'), @@ -74,12 +78,6 @@ export async function createStoryAction(_prevState: unknown, formData: FormData) } } - const last = await prisma.story.findFirst({ - where: { pbi_id: parsed.data.pbiId, priority: parsed.data.priority }, - orderBy: { sort_order: 'desc' }, - }) - const sort_order = (last?.sort_order ?? 0) + 1.0 - const insert = (code: string) => prisma.story.create({ data: { @@ -90,7 +88,7 @@ export async function createStoryAction(_prevState: unknown, formData: FormData) description: parsed.data.description ?? null, acceptance_criteria: parsed.data.acceptance_criteria ?? null, priority: parsed.data.priority, - sort_order, + sort_order: parseCodeNumber(code), status: 'OPEN', }, }) @@ -163,7 +161,7 @@ export async function updateStoryAction(_prevState: unknown, formData: FormData) await prisma.story.update({ where: { id: parsed.data.id }, data: { - ...(code ? { code } : {}), + ...(code ? { code, sort_order: parseCodeNumber(code) } : {}), title: parsed.data.title, description: parsed.data.description ?? null, acceptance_criteria: parsed.data.acceptance_criteria ?? null, @@ -345,7 +343,7 @@ export async function claimAllUnassignedInActiveSprintAction(productId: string) const userId = session.userId const sprint = await prisma.sprint.findFirst({ - where: { product_id: productId, status: 'ACTIVE' }, + where: { product_id: productId, status: 'OPEN' }, }) if (!sprint) return { error: 'Geen actieve sprint gevonden' } @@ -359,43 +357,3 @@ export async function claimAllUnassignedInActiveSprintAction(productId: string) return { success: true, count: result.count } } -export async function reorderStoriesAction( - pbiId: string, - productId: string, - orderedIds: string[], - newPriority?: number -) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige story-volgorde' } - if (newPriority !== undefined && (!Number.isInteger(newPriority) || newPriority < 1 || newPriority > 4)) { - return { error: 'Ongeldige prioriteit' } - } - - const pbi = await prisma.pbi.findFirst({ - where: { id: pbiId, product: productAccessFilter(session.userId) }, - }) - if (!pbi) return { error: 'PBI niet gevonden' } - - const stories = await prisma.story.findMany({ - where: { id: { in: orderedIds }, pbi_id: pbiId, product_id: pbi.product_id }, - select: { id: true }, - }) - if (stories.length !== orderedIds.length) return { error: 'Ongeldige story-volgorde' } - - await prisma.$transaction( - orderedIds.map((id, i) => - prisma.story.update({ - where: { id }, - data: { - sort_order: i + 1.0, - ...(newPriority !== undefined ? { priority: newPriority } : {}), - }, - }) - ) - ) - - revalidatePath(`/products/${pbi.product_id}`) - return { success: true } -} diff --git a/actions/tasks.ts b/actions/tasks.ts index d9d1080..1a4ef45 100644 --- a/actions/tasks.ts +++ b/actions/tasks.ts @@ -9,9 +9,10 @@ import { SessionData, sessionOptions } from '@/lib/session' import { productAccessFilter } from '@/lib/product-access' import { requireProductWriter } from '@/lib/auth' import { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/task' -import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' -import { normalizeCode } from '@/lib/code' +import { propagateStatusUpwards } from '@/lib/tasks-status-update' +import { normalizeCode, parseCodeNumber } from '@/lib/code' import { createWithCodeRetry, generateNextTaskCode, isCodeUniqueConflict } from '@/lib/code-server' +import { enforceUserRateLimit } from '@/lib/rate-limit' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -22,6 +23,7 @@ export type SaveTaskResult = | { ok: true; task: { id: string; title: string; status: string } } | { ok: false; code: 422; error: 'validation'; fieldErrors: Record } | { ok: false; code: 403; error: 'demo_readonly' | 'forbidden' } + | { ok: false; code: 429; error: 'rate_limited' } | { ok: false; code: 500; error: 'server_error' } export type DeleteTaskResult = @@ -39,6 +41,12 @@ export async function saveTask( if (!session.userId) return { ok: false, code: 403, error: 'forbidden' } if (session.isDemo) return { ok: false, code: 403, error: 'demo_readonly' } + // Rate-limit alleen op create-path; edits zijn laag-volume. + if (!context.taskId) { + const limited = enforceUserRateLimit('create-task', session.userId) + if (limited) return { ok: false, code: 429, error: 'rate_limited' } + } + const parsed = sharedTaskSchema.safeParse(input) if (!parsed.success) { return { @@ -72,12 +80,13 @@ export async function saveTask( description: description ?? null, implementation_plan: implementation_plan ?? null, priority, + ...(inputCode ? { code: inputCode, sort_order: parseCodeNumber(inputCode) } : {}), }, select: { id: true, title: true, status: true }, }) if (statusChanged) { - const result = await updateTaskStatusWithStoryPromotion(taskId, status, tx) + const result = await propagateStatusUpwards(taskId, status, tx) return { id: result.task.id, title: result.task.title, status: result.task.status } } return updated @@ -98,15 +107,8 @@ export async function saveTask( }) if (!story) return { ok: false, code: 403, error: 'forbidden' } - const last = await prisma.task.findFirst({ - where: { story_id: context.storyId }, - orderBy: { sort_order: 'desc' }, - select: { sort_order: true }, - }) - const productId = story.product_id const sprintId = story.sprint_id ?? null - const sortOrder = (last?.sort_order ?? 0) + 1.0 const storyId = context.storyId const task = await createWithCodeRetry( @@ -122,7 +124,7 @@ export async function saveTask( description: description ?? null, implementation_plan: implementation_plan ?? null, priority, - sort_order: sortOrder, + sort_order: parseCodeNumber(code), status: 'TO_DO', }, select: { id: true, title: true, status: true }, @@ -181,6 +183,9 @@ export async function createTaskAction(_prevState: unknown, formData: FormData) if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + const limited = enforceUserRateLimit('create-task', session.userId) + if (limited) return limited + const storyId = formData.get('storyId') as string const sprintId = formData.get('sprintId') as string @@ -196,11 +201,6 @@ export async function createTaskAction(_prevState: unknown, formData: FormData) }) if (!story) return { error: 'Story niet gevonden' } - const last = await prisma.task.findFirst({ - where: { story_id: storyId }, - orderBy: { sort_order: 'desc' }, - }) - const productId = story.product_id const task = await createWithCodeRetry( () => generateNextTaskCode(productId), @@ -214,7 +214,7 @@ export async function createTaskAction(_prevState: unknown, formData: FormData) title: parsed.data.title, description: parsed.data.description ?? null, priority: parsed.data.priority, - sort_order: (last?.sort_order ?? 0) + 1.0, + sort_order: parseCodeNumber(code), status: 'TO_DO', }, }), @@ -263,7 +263,7 @@ export async function updateTaskStatusAction(id: string, status: 'TO_DO' | 'IN_P }) if (!task) return { error: 'Taak niet gevonden' } - await updateTaskStatusWithStoryPromotion(id, status) + await propagateStatusUpwards(id, status) // /solo bewust niet revalideren: dat zou de page soft-navigaten en de // open SSE-stream sluiten. De Solo Paneel-flow leunt op optimistic @@ -322,22 +322,3 @@ export async function updateTaskPlanAction(taskId: string, productId: string, im return { success: true } } -export async function reorderTasksAction(storyId: string, orderedIds: string[]) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const story = await prisma.story.findFirst({ - where: { id: storyId, product: productAccessFilter(session.userId) }, - }) - if (!story) return { error: 'Story niet gevonden' } - - await prisma.$transaction( - orderedIds.map((id, i) => - prisma.task.update({ where: { id }, data: { sort_order: i + 1.0 } }) - ) - ) - - revalidatePath(`/products/${story.product_id}/sprint/planning`) - return { success: true } -} diff --git a/actions/todos.ts b/actions/todos.ts deleted file mode 100644 index 04e3fae..0000000 --- a/actions/todos.ts +++ /dev/null @@ -1,256 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' -import { cookies } from 'next/headers' -import { getIronSession } from 'iron-session' -import { z } from 'zod' -import { prisma } from '@/lib/prisma' -import { SessionData, sessionOptions } from '@/lib/session' -import { productAccessFilter } from '@/lib/product-access' -import { generateNextPbiCode, generateNextStoryCode } from '@/lib/code-server' - -async function getSession() { - return getIronSession(await cookies(), sessionOptions) -} - -export async function createTodoAction(_prevState: unknown, formData: FormData) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const title = (formData.get('title') as string)?.trim() - const description = (formData.get('description') as string)?.trim() || null - const raw = (formData.get('productId') as string)?.trim() - const productId = (raw && raw !== 'all') ? raw : null - - if (!title) return { error: 'Titel is verplicht' } - if (description && description.length > 2000) return { error: 'Beschrijving is langer dan 2000 tekens' } - - if (productId) { - const product = await prisma.product.findFirst({ - where: { id: productId, ...productAccessFilter(session.userId), archived: false }, - }) - if (!product) return { error: 'Product niet gevonden' } - } - - await prisma.todo.create({ - data: { user_id: session.userId, product_id: productId, title, description }, - }) - revalidatePath('/todos') - return { success: true } -} - -export async function toggleTodoAction(id: string, done: boolean) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - - const todo = await prisma.todo.findFirst({ where: { id, user_id: session.userId } }) - if (!todo) return { error: 'Todo niet gevonden' } - - await prisma.todo.update({ where: { id }, data: { done } }) - revalidatePath('/todos') - return { success: true } -} - -export async function archiveCompletedTodosAction() { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - - await prisma.todo.updateMany({ - where: { user_id: session.userId, done: true, archived: false }, - data: { archived: true }, - }) - revalidatePath('/todos') - return { success: true } -} - -export async function updateTodoAction(_prevState: unknown, formData: FormData) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const id = (formData.get('id') as string)?.trim() - const title = (formData.get('title') as string)?.trim() - const description = (formData.get('description') as string)?.trim() || null - const raw = (formData.get('productId') as string)?.trim() - const productId = raw || null - const done = formData.get('done') === 'on' - - if (!id) return { error: 'Ongeldige todo' } - if (!title) return { error: 'Titel is verplicht' } - if (description && description.length > 2000) return { error: 'Beschrijving is langer dan 2000 tekens' } - - const todo = await prisma.todo.findFirst({ - where: { id, user_id: session.userId }, - }) - if (!todo) return { error: 'Todo niet gevonden' } - - if (productId) { - const product = await prisma.product.findFirst({ - where: { id: productId, ...productAccessFilter(session.userId), archived: false }, - }) - if (!product) return { error: 'Product niet gevonden' } - } - - await prisma.todo.update({ - where: { id }, - data: { title, description, product_id: productId, done }, - }) - revalidatePath('/todos') - return { success: true } -} - -export async function archiveSelectedTodosAction(ids: string[]) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - if (!ids.length) return { error: 'Geen todos geselecteerd' } - - const owned = await prisma.todo.findMany({ - where: { id: { in: ids }, user_id: session.userId }, - select: { id: true }, - }) - if (owned.length !== ids.length) return { error: 'Ongeldige selectie' } - - await prisma.todo.updateMany({ - where: { id: { in: ids }, user_id: session.userId }, - data: { archived: true }, - }) - revalidatePath('/todos') - return { success: true } -} - -const promotePbiSchema = z.object({ - todoId: z.string(), - productId: z.string(), - title: z.string().min(1).max(200), - priority: z.coerce.number().int().min(1).max(4), -}) - -export async function promoteTodoToPbiAction(_prevState: unknown, formData: FormData) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const parsed = promotePbiSchema.safeParse({ - todoId: formData.get('todoId'), - productId: formData.get('productId'), - title: formData.get('title'), - priority: formData.get('priority'), - }) - if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } - - const product = await prisma.product.findFirst({ - where: { id: parsed.data.productId, ...productAccessFilter(session.userId) }, - }) - if (!product) return { error: 'Product niet gevonden' } - - const todo = await prisma.todo.findFirst({ - where: { id: parsed.data.todoId, user_id: session.userId }, - }) - if (!todo) return { error: 'Todo niet gevonden' } - - const last = await prisma.pbi.findFirst({ - where: { product_id: parsed.data.productId, priority: parsed.data.priority }, - orderBy: { sort_order: 'desc' }, - }) - - const pbiCode = await generateNextPbiCode(parsed.data.productId) - - await prisma.$transaction([ - prisma.pbi.create({ - data: { - product_id: parsed.data.productId, - code: pbiCode, - title: parsed.data.title, - priority: parsed.data.priority, - sort_order: (last?.sort_order ?? 0) + 1.0, - }, - }), - prisma.todo.deleteMany({ where: { id: parsed.data.todoId, user_id: session.userId } }), - ]) - - revalidatePath('/todos') - revalidatePath(`/products/${parsed.data.productId}`) - return { success: true } -} - -const promoteStorySchema = z.object({ - todoId: z.string(), - productId: z.string(), - pbiId: z.string(), - title: z.string().min(1).max(200), - priority: z.coerce.number().int().min(1).max(4), -}) - -export async function promoteTodoToStoryAction(_prevState: unknown, formData: FormData) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const parsed = promoteStorySchema.safeParse({ - todoId: formData.get('todoId'), - productId: formData.get('productId'), - pbiId: formData.get('pbiId'), - title: formData.get('title'), - priority: formData.get('priority'), - }) - if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } - - const todo = await prisma.todo.findFirst({ - where: { id: parsed.data.todoId, user_id: session.userId }, - }) - if (!todo) return { error: 'Todo niet gevonden' } - - const pbi = await prisma.pbi.findFirst({ - where: { id: parsed.data.pbiId, product: productAccessFilter(session.userId) }, - }) - if (!pbi) return { error: 'PBI niet gevonden' } - if (todo.product_id !== null && todo.product_id !== pbi.product_id) return { error: 'Todo hoort niet bij dit product' } - - const last = await prisma.story.findFirst({ - where: { pbi_id: parsed.data.pbiId, priority: parsed.data.priority }, - orderBy: { sort_order: 'desc' }, - }) - - const storyCode = await generateNextStoryCode(pbi.product_id) - - await prisma.$transaction([ - prisma.story.create({ - data: { - pbi_id: parsed.data.pbiId, - product_id: pbi.product_id, - code: storyCode, - title: parsed.data.title, - priority: parsed.data.priority, - sort_order: (last?.sort_order ?? 0) + 1.0, - status: 'OPEN', - }, - }), - prisma.todo.deleteMany({ where: { id: parsed.data.todoId, user_id: session.userId } }), - ]) - - revalidatePath('/todos') - revalidatePath(`/products/${pbi.product_id}`) - return { success: true } -} - -export async function updateRolesAction(roles: string[]) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const validRoles = ['PRODUCT_OWNER', 'SCRUM_MASTER', 'DEVELOPER'] - const filtered = roles.filter(r => validRoles.includes(r)) - if (filtered.length === 0) return { error: 'Minimaal één rol is verplicht' } - - await prisma.$transaction([ - prisma.userRole.deleteMany({ where: { user_id: session.userId } }), - prisma.userRole.createMany({ - data: filtered.map(role => ({ user_id: session.userId, role: role as 'PRODUCT_OWNER' | 'SCRUM_MASTER' | 'DEVELOPER' })), - }), - ]) - - revalidatePath('/settings') - return { success: true } -} diff --git a/actions/user-questions.ts b/actions/user-questions.ts new file mode 100644 index 0000000..d43c6d7 --- /dev/null +++ b/actions/user-questions.ts @@ -0,0 +1,106 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { z } from 'zod' + +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' +import { enforceUserRateLimit } from '@/lib/rate-limit' +import { ACTIVE_JOB_STATUSES } from '@/lib/job-status' +import { getJobConfigSnapshot } from '@/lib/job-config-snapshot' + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +type ActionResult = + | { success: true; data?: T } + | { error: string; code?: number } + +const createSchema = z.object({ + ideaId: z.string().cuid(), + question: z.string().min(1).max(2000), +}) + +export async function createUserQuestionAction( + ideaId: string, + question: string, +): Promise> { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) return { error: 'Demo-gebruikers kunnen geen vragen stellen', code: 403 } + + const limited = enforceUserRateLimit('create-user-question', session.userId) + if (limited) return limited + + const parsed = createSchema.safeParse({ ideaId, question }) + if (!parsed.success) return { error: 'Ongeldige invoer', code: 422 } + + const idea = await prisma.idea.findFirst({ + where: { id: parsed.data.ideaId, user_id: session.userId }, + select: { id: true, plan_md: true, product_id: true }, + }) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + if (!idea.plan_md) return { error: 'Er is nog geen plan voor dit idee', code: 422 } + if (!idea.product_id) return { error: 'Koppel eerst een product aan dit idee', code: 422 } + + // Idempotency: weiger als er al een actieve PLAN_CHAT job loopt voor dit idee. + const existing = await prisma.claudeJob.findFirst({ + where: { + idea_id: parsed.data.ideaId, + kind: 'PLAN_CHAT', + status: { in: ACTIVE_JOB_STATUSES }, + }, + select: { id: true }, + }) + if (existing) return { error: 'Er loopt al een actieve PLAN_CHAT voor dit idee', code: 409 } + + const snapshot = await getJobConfigSnapshot({ kind: 'PLAN_CHAT', productId: idea.product_id }) + + const [uq, job] = await prisma.$transaction([ + prisma.userQuestion.create({ + data: { + idea_id: parsed.data.ideaId, + user_id: session.userId, + question: parsed.data.question, + }, + }), + prisma.claudeJob.create({ + data: { + user_id: session.userId, + product_id: idea.product_id, + idea_id: parsed.data.ideaId, + kind: 'PLAN_CHAT', + status: 'QUEUED', + ...snapshot, + }, + }), + ]) + + await prisma.ideaLog.create({ + data: { + idea_id: parsed.data.ideaId, + type: 'JOB_EVENT', + content: 'PLAN_CHAT queued', + metadata: { job_id: job.id, kind: 'PLAN_CHAT', user_question_id: uq.id }, + }, + }) + + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_enqueued', + job_id: job.id, + idea_id: parsed.data.ideaId, + user_id: session.userId, + product_id: idea.product_id, + kind: 'PLAN_CHAT', + status: 'queued', + })}::text) + ` + + revalidatePath('/ideas/' + parsed.data.ideaId, 'page') + + return { success: true, data: { questionId: uq.id, jobId: job.id } } +} diff --git a/actions/user-settings.ts b/actions/user-settings.ts new file mode 100644 index 0000000..e3a9cbb --- /dev/null +++ b/actions/user-settings.ts @@ -0,0 +1,62 @@ +'use server' + +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' +import { + UserSettingsSchema, + mergeSettings, + parseUserSettings, + type UserSettings, +} from '@/lib/user-settings' + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +export type UpdateUserSettingsResult = + | { success: true; settings: UserSettings } + | { error: string; code: 401 | 403 | 422; fieldErrors?: Record } + +export async function updateUserSettingsAction( + patch: Partial, +): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const parsed = UserSettingsSchema.partial().safeParse(patch) + if (!parsed.success) { + return { + error: 'Ongeldige settings-patch', + code: 422, + fieldErrors: parsed.error.flatten().fieldErrors as Record, + } + } + + const merged = await prisma.$transaction(async (tx) => { + const user = await tx.user.findUnique({ + where: { id: session.userId! }, + select: { settings: true }, + }) + const current = parseUserSettings(user?.settings) + const next = mergeSettings(current, parsed.data) + await tx.user.update({ + where: { id: session.userId! }, + data: { settings: next as unknown as Prisma.InputJsonValue }, + }) + return next + }) + + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + kind: 'user_settings', + userId: session.userId, + patch: parsed.data, + })}::text) + ` + + return { success: true, settings: merged } +} diff --git a/app/(app)/admin/jobs/page.tsx b/app/(app)/admin/jobs/page.tsx new file mode 100644 index 0000000..8a3ba85 --- /dev/null +++ b/app/(app)/admin/jobs/page.tsx @@ -0,0 +1,54 @@ +import { requireAdmin } from '@/lib/auth-guard' +import { prisma } from '@/lib/prisma' +import { JobsTable } from '@/components/admin/jobs-table' + +export default async function AdminJobsPage() { + await requireAdmin() + + const jobs = await prisma.claudeJob.findMany({ + orderBy: { created_at: 'desc' }, + take: 100, + select: { + id: true, + kind: true, + status: true, + created_at: true, + branch: true, + pr_url: true, + error: true, + model_id: true, + input_tokens: true, + output_tokens: true, + cache_read_tokens: true, + cache_write_tokens: true, + actual_thinking_tokens: true, + requested_model: true, + requested_thinking_budget: true, + requested_permission_mode: true, + user: { select: { username: true } }, + product: { select: { name: true } }, + }, + }) + + const prices = await prisma.modelPrice.findMany() + const priceMap = new Map(prices.map((p) => [p.model_id, p])) + + const jobsWithCost = jobs.map((job) => { + const p = job.model_id ? priceMap.get(job.model_id) : undefined + if (!p || job.input_tokens == null) return { ...job, cost_usd: null } + const cost = + (job.input_tokens ?? 0) * Number(p.input_price_per_1m) / 1_000_000 + + (job.output_tokens ?? 0) * Number(p.output_price_per_1m) / 1_000_000 + + (job.cache_read_tokens ?? 0) * Number(p.cache_read_price_per_1m) / 1_000_000 + + (job.cache_write_tokens ?? 0) * Number(p.cache_write_price_per_1m) / 1_000_000 + + (job.actual_thinking_tokens ?? 0) * Number(p.input_price_per_1m) / 1_000_000 + return { ...job, cost_usd: cost } + }) + + return ( +
+

Claude Jobs

+ +
+ ) +} diff --git a/app/(app)/admin/layout.tsx b/app/(app)/admin/layout.tsx new file mode 100644 index 0000000..6c2c912 --- /dev/null +++ b/app/(app)/admin/layout.tsx @@ -0,0 +1,16 @@ +import { requireAdmin } from '@/lib/auth-guard' +import Link from 'next/link' + +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + await requireAdmin() + return ( +
+ +
{children}
+
+ ) +} diff --git a/app/(app)/admin/page.tsx b/app/(app)/admin/page.tsx new file mode 100644 index 0000000..f07ba33 --- /dev/null +++ b/app/(app)/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function AdminPage() { + redirect('/admin/users') +} diff --git a/app/(app)/admin/products/page.tsx b/app/(app)/admin/products/page.tsx new file mode 100644 index 0000000..11081d3 --- /dev/null +++ b/app/(app)/admin/products/page.tsx @@ -0,0 +1,26 @@ +import { requireAdmin } from '@/lib/auth-guard' +import { prisma } from '@/lib/prisma' +import { ProductsTable } from '@/components/admin/products-table' + +export default async function AdminProductsPage() { + await requireAdmin() + + const products = await prisma.product.findMany({ + orderBy: { created_at: 'desc' }, + select: { + id: true, + name: true, + archived: true, + created_at: true, + user: { select: { username: true } }, + _count: { select: { members: true, pbis: true } }, + }, + }) + + return ( +
+

Producten

+ +
+ ) +} diff --git a/app/(app)/admin/users/page.tsx b/app/(app)/admin/users/page.tsx new file mode 100644 index 0000000..6d3543d --- /dev/null +++ b/app/(app)/admin/users/page.tsx @@ -0,0 +1,19 @@ +import { requireAdmin } from '@/lib/auth-guard' +import { prisma } from '@/lib/prisma' +import { UsersTable } from '@/components/admin/users-table' + +export default async function AdminUsersPage() { + const session = await requireAdmin() + + const users = await prisma.user.findMany({ + include: { roles: { select: { role: true } } }, + orderBy: { created_at: 'desc' }, + }) + + return ( +
+

Gebruikers

+ +
+ ) +} diff --git a/app/(app)/dashboard/loading.tsx b/app/(app)/dashboard/loading.tsx index 656e323..40800ab 100644 --- a/app/(app)/dashboard/loading.tsx +++ b/app/(app)/dashboard/loading.tsx @@ -1,10 +1,12 @@ +import { Skeleton } from '@/components/ui/skeleton' + export default function Loading() { return ( -
-
+
+
- {[1, 2, 3].map(i => ( -
+ {[1, 2, 3].map((i) => ( + ))}
diff --git a/app/(app)/ideas/[id]/page.tsx b/app/(app)/ideas/[id]/page.tsx new file mode 100644 index 0000000..80d946c --- /dev/null +++ b/app/(app)/ideas/[id]/page.tsx @@ -0,0 +1,140 @@ +import { cookies } from 'next/headers' +import { notFound } from 'next/navigation' +import { getIronSession } from 'iron-session' + +import { SessionData, sessionOptions } from '@/lib/session' +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' +import { ideaToDto } from '@/lib/idea-dto' +import { IdeaDetailLayout } from '@/components/ideas/idea-detail-layout' +import { loadIdeaSyncData } from './sync-tab-server' +import type { ReviewLog } from '@/components/ideas/review-log-viewer' + +export const dynamic = 'force-dynamic' + +interface PageProps { + params: Promise<{ id: string }> + searchParams: Promise<{ tab?: string }> +} + +export default async function IdeaDetailPage({ params, searchParams }: PageProps) { + const session = await getIronSession(await cookies(), sessionOptions) + if (!session.userId) notFound() // proxy.ts redirect zou ons al moeten hebben + + const { id } = await params + const { tab } = await searchParams + + // M12: strikt user_id-only — 404 (niet 403) voor andere users (anti-enum). + const idea = await prisma.idea.findFirst({ + where: { id, user_id: session.userId }, + select: { + id: true, + user_id: true, + product_id: true, + code: true, + title: true, + description: true, + status: true, + pbi_id: true, + archived: true, + grill_md: true, + plan_md: true, + plan_review_log: true, + reviewed_at: true, + created_at: true, + updated_at: true, + product: { select: { id: true, name: true, repo_url: true } }, + pbi: { select: { id: true, code: true, title: true } }, + secondary_products: { select: { id: true, product_id: true, product: { select: { id: true, name: true } } } }, + }, + }) + if (!idea) notFound() + + // Producten voor de "koppel product"-dropdown in de form-tab. + const products = await prisma.product.findMany({ + where: { ...productAccessFilter(session.userId), archived: false }, + orderBy: { name: 'asc' }, + select: { id: true, name: true, repo_url: true }, + }) + + // Recent logs (laatste 100) voor de Timeline-tab. + const logs = await prisma.ideaLog.findMany({ + where: { idea_id: id }, + orderBy: { created_at: 'desc' }, + take: 100, + select: { + id: true, + type: true, + content: true, + metadata: true, + created_at: true, + }, + }) + + // Open vragen voor dit idee — voor de Timeline-tab. + const questions = await prisma.claudeQuestion.findMany({ + where: { idea_id: id }, + orderBy: { created_at: 'desc' }, + take: 50, + select: { + id: true, + question: true, + options: true, + status: true, + answer: true, + created_at: true, + expires_at: true, + }, + }) + + const userQuestionsRaw = await prisma.userQuestion.findMany({ + where: { idea_id: id }, + orderBy: { created_at: 'asc' }, + take: 100, + select: { id: true, question: true, answer: true, status: true, created_at: true }, + }) + + // Sync-tab data — alleen geladen als idea PLANNED is en pbi_id gevuld. + // loadIdeaSyncData past zelf user_id-scope toe en retourneert null als + // het idee geen pbi heeft. + const syncData = + idea.status === 'PLANNED' && idea.pbi_id + ? await loadIdeaSyncData(id, session.userId) + : null + + return ( + ({ + id: l.id, + type: l.type, + content: l.content, + metadata: l.metadata, + created_at: l.created_at.toISOString(), + }))} + questions={questions.map((q) => ({ + id: q.id, + question: q.question, + options: Array.isArray(q.options) ? (q.options as string[]) : null, + status: q.status as 'open' | 'answered' | 'cancelled' | 'expired', + answer: q.answer ?? null, + created_at: q.created_at.toISOString(), + expires_at: q.expires_at.toISOString(), + }))} + userQuestions={userQuestionsRaw.map((uq) => ({ + id: uq.id, + question: uq.question, + answer: uq.answer, + status: uq.status as 'pending' | 'answered', + created_at: uq.created_at.toISOString(), + }))} + isDemo={session.isDemo ?? false} + initialTab={tab ?? 'idee'} + syncData={syncData} + /> + ) +} diff --git a/app/(app)/ideas/[id]/sync-tab-server.ts b/app/(app)/ideas/[id]/sync-tab-server.ts new file mode 100644 index 0000000..ae93465 --- /dev/null +++ b/app/(app)/ideas/[id]/sync-tab-server.ts @@ -0,0 +1,85 @@ +import 'server-only' +import { prisma } from '@/lib/prisma' + +// Server-only loader voor de Sync-tab op /ideas/[id]. +// Joint Idea → PBI → Stories → Tasks → ClaudeJobs + StoryLog. +// Auth-scope: strikt user_id-only conform M12-keuze 2. +// +// Returns null wanneer: +// - idea bestaat niet of behoort niet aan user +// - idea heeft geen pbi_id (status !== PLANNED, dus sync-tab niet relevant) +// +// Caller (page.tsx) moet de tab niet renderen als deze null retourneert. +export async function loadIdeaSyncData(ideaId: string, userId: string) { + const idea = await prisma.idea.findFirst({ + where: { id: ideaId, user_id: userId }, + select: { + id: true, + code: true, + title: true, + status: true, + pbi_id: true, + product: { select: { id: true, name: true, repo_url: true } }, + pbi: { + select: { + id: true, + code: true, + title: true, + pr_url: true, + pr_merged_at: true, + stories: { + orderBy: { sort_order: 'asc' }, + select: { + id: true, + code: true, + title: true, + status: true, + tasks: { + orderBy: { sort_order: 'asc' }, + select: { + id: true, + code: true, + title: true, + status: true, + claude_jobs: { + where: { kind: 'TASK_IMPLEMENTATION' }, + orderBy: { created_at: 'desc' }, + select: { + id: true, + status: true, + branch: true, + pushed_at: true, + pr_url: true, + error: true, + summary: true, + created_at: true, + finished_at: true, + }, + }, + }, + }, + logs: { + orderBy: { created_at: 'desc' }, + take: 20, + select: { + id: true, + type: true, + content: true, + status: true, + commit_hash: true, + commit_message: true, + created_at: true, + }, + }, + }, + }, + }, + }, + }, + }) + + if (!idea || !idea.pbi) return null + return idea +} + +export type IdeaSyncData = NonNullable>> diff --git a/app/(app)/ideas/page.tsx b/app/(app)/ideas/page.tsx new file mode 100644 index 0000000..1c4fd5e --- /dev/null +++ b/app/(app)/ideas/page.tsx @@ -0,0 +1,62 @@ +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' + +import { SessionData, sessionOptions } from '@/lib/session' +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' +import { ideaToDto } from '@/lib/idea-dto' +import { IdeaList } from '@/components/ideas/idea-list' + +export const dynamic = 'force-dynamic' + +export default async function IdeasPage() { + const session = await getIronSession(await cookies(), sessionOptions) + + // M12: idee is strikt user_id-only (geen productAccessFilter — Q8). + const ideas = await prisma.idea.findMany({ + where: { user_id: session.userId, archived: false }, + orderBy: { created_at: 'desc' }, + include: { + product: { select: { id: true, name: true, repo_url: true } }, + secondary_products: { include: { product: { select: { id: true, name: true } } } }, + }, + take: 200, + }) + + // Productenlijst voor de filter-dropdown + voor "Nieuw idee"-form. + // Producten zijn product-scoped (kan team-shared zijn) — productAccessFilter + // is hier dus wél juist. + const products = await prisma.product.findMany({ + where: { ...productAccessFilter(session.userId), archived: false }, + orderBy: { name: 'asc' }, + select: { id: true, name: true, repo_url: true }, + }) + + const user = await prisma.user.findUnique({ + where: { id: session.userId }, + select: { active_product_id: true }, + }) + + const activeProductId = + user?.active_product_id && products.some((p) => p.id === user.active_product_id) + ? user.active_product_id + : null + + return ( +
+
+

Ideeën

+

+ Lichtgewicht voorstellen die je via Grill Me en Make Plan tot een PBI laat groeien. +

+
+ + ideaToDto(i))} + products={products} + isDemo={session.isDemo ?? false} + activeProductId={activeProductId} + /> +
+ ) +} diff --git a/app/(app)/insights/components/agent-throughput.tsx b/app/(app)/insights/components/agent-throughput.tsx index 820e64f..a43dd96 100644 --- a/app/(app)/insights/components/agent-throughput.tsx +++ b/app/(app)/insights/components/agent-throughput.tsx @@ -33,7 +33,7 @@ function formatDuration(seconds: number | null): string { return m > 0 ? `${m}m ${s}s` : `${s}s` } -const STACKED_STATUSES = ['queued', 'claimed', 'running', 'done', 'failed', 'cancelled'] as const +const STACKED_STATUSES = ['queued', 'claimed', 'running', 'done', 'failed', 'cancelled', 'skipped'] as const export function AgentThroughputCard({ data, productList, currentProductId }: Props) { const router = useRouter() @@ -44,7 +44,7 @@ export function AgentThroughputCard({ data, productList, currentProductId }: Pro const { perDay, kpi } = data const isEmpty = perDay.every( - d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled === 0, + d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled + d.skipped === 0, ) function handleProductChange(value: string | null) { diff --git a/app/(app)/insights/components/alignment-trend.tsx b/app/(app)/insights/components/alignment-trend.tsx index 45375d1..1718188 100644 --- a/app/(app)/insights/components/alignment-trend.tsx +++ b/app/(app)/insights/components/alignment-trend.tsx @@ -15,7 +15,7 @@ interface Props { } interface TooltipPayload { - payload?: { total: number; alignedRatio: number; sprintGoal: string } + payload?: { total: number; alignedRatio: number; sprintCode: string; sprintGoal: string } } function CustomTooltip({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) { @@ -25,7 +25,10 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Toolti const aligned = Math.round((d.alignedRatio / 100) * d.total) return (
-

{d.sprintGoal}

+

+ {d.sprintCode} + {d.sprintGoal} +

{aligned} / {d.total} aligned ({d.alignedRatio}%)

@@ -33,10 +36,6 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Toolti ) } -function sprintLabel(goal: string): string { - return goal.length > 20 ? goal.slice(0, 18) + '…' : goal -} - export function AlignmentTrend({ trend }: Props) { if (trend.length === 0) { return ( @@ -48,7 +47,7 @@ export function AlignmentTrend({ trend }: Props) { const data = trend.map(p => ({ ...p, - label: sprintLabel(p.sprintGoal), + label: p.sprintCode, })) return ( diff --git a/app/(app)/insights/components/cost-analysis.tsx b/app/(app)/insights/components/cost-analysis.tsx new file mode 100644 index 0000000..f147ae3 --- /dev/null +++ b/app/(app)/insights/components/cost-analysis.tsx @@ -0,0 +1,272 @@ +'use client' + +import { useTransition } from 'react' +import { useRouter, usePathname, useSearchParams } from 'next/navigation' +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + PieChart, + Pie, + Cell, + ResponsiveContainer, +} from 'recharts' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import type { + Period, + CostKpi, + CostByDayRow, + CostByModelRow, + CostByKindRow, + CacheEfficiency, +} from '@/lib/insights/cost-analysis' + +interface Props { + period: Period + kpi: CostKpi + byDay: CostByDayRow[] + byModel: CostByModelRow[] + byKind: CostByKindRow[] + cache: CacheEfficiency +} + +const PERIOD_LABELS: Record = { + '7d': 'Laatste 7 dagen', + '30d': 'Laatste 30 dagen', + '90d': 'Laatste 90 dagen', + mtd: 'Deze maand', +} + +const KIND_LABELS: Record = { + TASK_IMPLEMENTATION: 'Task impl.', + IDEA_GRILL: 'Idea grill', + IDEA_MAKE_PLAN: 'Idea plan', + PLAN_CHAT: 'Plan chat', + SPRINT_IMPLEMENTATION: 'Sprint impl.', +} + +function fmtUsd(n: number, decimals = 2): string { + return '$' + n.toFixed(decimals) +} + +function shortenModel(modelId: string): string { + return modelId.replace(/^claude-/, '') +} + +export function CostAnalysisCard({ period, kpi, byDay, byModel, byKind, cache }: Props) { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + const [isPending, startTransition] = useTransition() + + function handlePeriodChange(value: string | null) { + if (value === null) return + startTransition(() => { + const params = new URLSearchParams(searchParams.toString()) + params.set('period', value) + router.replace(`${pathname}?${params.toString()}`) + }) + } + + const periodSelector = ( + + ) + + if (kpi.jobCount === 0) { + return ( +
+
+

Geen jobs in deze periode.

+ {periodSelector} +
+
+ ) + } + + const cacheData = [ + { name: 'Cache', value: cache.cacheReadTokens }, + { name: 'Uncached input', value: cache.uncachedInputTokens }, + ] + const cacheColors = ['var(--status-done)', 'var(--muted-foreground)'] + + const modelData = byModel.map(m => ({ ...m, label: shortenModel(m.modelId) })) + const kindData = byKind.map(k => ({ ...k, label: KIND_LABELS[k.kind] ?? k.kind })) + + return ( +
+ {/* KPI strip + period selector */} +
+
+
+
{fmtUsd(kpi.totalCostUsd)}
+
Totaal kosten
+
+
+
{fmtUsd(kpi.avgPerDayUsd)}
+
Gem. per dag
+
+
+
+ {fmtUsd(kpi.cacheSavingsUsd)} +
+
Cache-besparing
+
+
+
+ {kpi.topModelId ? fmtUsd(kpi.topModelCostUsd) : '—'} +
+
+ Top model{kpi.topModelId ? `: ${shortenModel(kpi.topModelId)}` : ''} +
+
+
+ {periodSelector} +
+ +
+ {/* Daily cost */} +
+
Kosten per dag
+ {byDay.length === 0 ? ( +

Geen data

+ ) : ( + + + (v as string).slice(5)} + /> + fmtUsd(v as number, 2)} + /> + [fmtUsd(Number(value), 4), 'Kosten']} + /> + + + + )} +
+ + {/* Per model */} +
+
Kosten per model
+ {modelData.length === 0 ? ( +

Geen data

+ ) : ( + + + fmtUsd(v as number, 2)} + /> + + [fmtUsd(Number(value), 4), 'Kosten']} /> + + + + )} +
+ + {/* Per kind */} +
+
Kosten per job-kind
+ {kindData.length === 0 ? ( +

Geen data

+ ) : ( + + + fmtUsd(v as number, 2)} + /> + + [fmtUsd(Number(value), 4), 'Kosten']} /> + + + + )} +
+ + {/* Cache efficiency */} +
+
Cache efficiency
+ {cache.cacheReadTokens + cache.uncachedInputTokens === 0 ? ( +

Geen data

+ ) : ( + <> + + + + {cacheData.map((_, i) => ( + + ))} + + [ + Number(value).toLocaleString() + ' tokens', + String(name), + ]} + /> + + +

+ {(cache.cacheHitRatio * 100).toFixed(1)}%{' '} + cache hit ·{' '} + + {fmtUsd(cache.savingsUsd)} + {' '} + bespaard +

+ + )} +
+
+
+ ) +} diff --git a/app/(app)/insights/components/sprint-info-strip.tsx b/app/(app)/insights/components/sprint-info-strip.tsx index 3d85a33..ed3c15b 100644 --- a/app/(app)/insights/components/sprint-info-strip.tsx +++ b/app/(app)/insights/components/sprint-info-strip.tsx @@ -2,6 +2,7 @@ interface SprintInfo { sprintId: string + sprintCode: string productName: string sprintGoal: string taskCount: number @@ -33,6 +34,7 @@ export function SprintInfoStrip({ sprints }: Props) { className="flex items-center gap-3 rounded-lg border border-border bg-surface-container px-3 py-2 text-sm" > {s.productName} + {s.sprintCode} {truncate(s.sprintGoal, 60)} {s.daysLeft > 0 ? `${s.daysLeft}d over` : `${Math.abs(s.daysLeft)}d over tijd`} diff --git a/app/(app)/insights/components/token-usage.tsx b/app/(app)/insights/components/token-usage.tsx new file mode 100644 index 0000000..4bb4f90 --- /dev/null +++ b/app/(app)/insights/components/token-usage.tsx @@ -0,0 +1,109 @@ +'use client' + +import { useState, useMemo } from 'react' +import type { TokenKpi, TokenJobRow } from '@/lib/insights/token-stats' + +export interface TokenUsageCardProps { + kpi: TokenKpi + jobs: TokenJobRow[] +} + +type SortKey = 'cost' | 'duration' + +function fmt(n: number | null, decimals = 0): string { + if (n === null) return '—' + return n.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) +} + +function fmtCost(n: number | null): string { + if (n === null) return '—' + return '$' + n.toFixed(4) +} + +function jobLabel(job: TokenJobRow): string { + const label = job.taskTitle ?? job.ideaCode ?? job.jobId + return label.length > 40 ? label.slice(0, 37) + '…' : label +} + +export function TokenUsageCard({ kpi, jobs }: TokenUsageCardProps) { + const [sortKey, setSortKey] = useState('cost') + + const sorted = useMemo(() => { + return [...jobs].sort((a, b) => { + if (sortKey === 'cost') return (b.costUsd ?? -Infinity) - (a.costUsd ?? -Infinity) + return (b.durationSeconds ?? -Infinity) - (a.durationSeconds ?? -Infinity) + }) + }, [jobs, sortKey]) + + if (kpi.jobCount === 0) { + return

Geen token-data

+ } + + return ( +
+ {/* KPI strip */} +
+
+
+ {kpi.totalTokens.toLocaleString()} +
+
Totaal tokens
+
+
+
+ ${kpi.totalCostUsd.toFixed(4)} +
+
Kosten (USD)
+
+
+
+ {kpi.avgCostPerJob ? '$' + kpi.avgCostPerJob.toFixed(4) : '—'} +
+
Gem. per job
+
+
+ + {/* Sortable table */} +
+ + + + + + + + + + + + + + + {sorted.map(job => ( + + + + + + + + + + + ))} + +
TaakModelInputOutputCache-RCache-W setSortKey('cost')} + > + Kosten (USD) {sortKey === 'cost' ? '▾' : ''} + setSortKey('duration')} + > + Duur (s) {sortKey === 'duration' ? '▾' : ''} +
{jobLabel(job)}{job.modelId ?? '—'}{fmt(job.inputTokens)}{fmt(job.outputTokens)}{fmt(job.cacheReadTokens)}{fmt(job.cacheWriteTokens)}{fmtCost(job.costUsd)}{fmt(job.durationSeconds, 1)}
+
+
+ ) +} diff --git a/app/(app)/insights/components/velocity-chart.tsx b/app/(app)/insights/components/velocity-chart.tsx index 7cd2d9e..a05df7f 100644 --- a/app/(app)/insights/components/velocity-chart.tsx +++ b/app/(app)/insights/components/velocity-chart.tsx @@ -35,11 +35,9 @@ export function VelocityChart({ data }: Props) { type Row = { sprintLabel: string } & Record const grouped = new Map() for (const s of sprints) { - const label = - s.sprintGoal.length > 14 ? s.sprintGoal.slice(0, 14) + '…' : s.sprintGoal const key = `${s.sprintId}` if (!grouped.has(key)) { - grouped.set(key, { sprintLabel: label }) + grouped.set(key, { sprintLabel: s.sprintCode }) } grouped.get(key)![s.productName] = s.doneCount } diff --git a/app/(app)/insights/page.tsx b/app/(app)/insights/page.tsx index 77164d5..802258c 100644 --- a/app/(app)/insights/page.tsx +++ b/app/(app)/insights/page.tsx @@ -7,6 +7,15 @@ import { getBurndownData } from '@/lib/insights/burndown' import { getSprintStatusBreakdown } from '@/lib/insights/sprint-status' import { getVerifyResultStats, getAlignmentTrend } from '@/lib/insights/verify-stats' import { getJobsPerDay } from '@/lib/insights/agent-throughput' +import { getTokenStats } from '@/lib/insights/token-stats' +import { + getCostKpi, + getCostByDay, + getCostByModel, + getCostByKind, + getCacheEfficiency, + type Period, +} from '@/lib/insights/cost-analysis' import { getVelocity } from '@/lib/insights/velocity' import { getBacklogHealth } from '@/lib/insights/backlog-health' import { SprintInfoStrip } from './components/sprint-info-strip' @@ -15,6 +24,8 @@ import { SprintStatusDonut } from './components/sprint-status-donut' import { PlanQualityCard } from './components/plan-quality' import { AlignmentTrend } from './components/alignment-trend' import { AgentThroughputCard } from './components/agent-throughput' +import { CostAnalysisCard } from './components/cost-analysis' +import { TokenUsageCard } from './components/token-usage' import { VelocityChart } from './components/velocity-chart' import { BacklogHealthCard } from './components/backlog-health' @@ -22,7 +33,13 @@ const DAY_MS = 86_400_000 const ASSUMED_SPRINT_DAYS = 14 interface InsightsPageProps { - searchParams: Promise<{ product?: string }> + searchParams: Promise<{ product?: string; period?: string }> +} + +const VALID_PERIODS = ['7d', '30d', '90d', 'mtd'] as const + +function parsePeriod(raw: string | undefined): Period { + return (VALID_PERIODS as readonly string[]).includes(raw ?? '') ? (raw as Period) : '30d' } function MissingDatesNotice({ productId, productName }: { productId: string; productName: string }) { @@ -39,7 +56,8 @@ function MissingDatesNotice({ productId, productName }: { productId: string; pro export default async function InsightsPage({ searchParams }: InsightsPageProps) { const session = await getIronSession(await cookies(), sessionOptions) const userId = session.userId! - const { product: filterProductId } = await searchParams + const { product: filterProductId, period: rawPeriod } = await searchParams + const period = parsePeriod(rawPeriod) const [ burndownSprints, @@ -51,13 +69,19 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps) jobsPerDay, velocity, backlogHealth, + costKpi, + costByDay, + costByModel, + costByKind, + cacheEff, ] = await Promise.all([ getBurndownData(userId), getSprintStatusBreakdown(userId), prisma.sprint.findMany({ - where: { status: 'ACTIVE', product: productAccessFilter(userId) }, + where: { status: 'OPEN', product: productAccessFilter(userId) }, select: { id: true, + code: true, sprint_goal: true, created_at: true, product: { select: { id: true, name: true } }, @@ -74,13 +98,24 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps) getJobsPerDay(userId, 14, filterProductId), getVelocity(userId, 5), getBacklogHealth(userId), + getCostKpi(userId, period), + getCostByDay(userId, period), + getCostByModel(userId, period), + getCostByKind(userId, period), + getCacheEfficiency(userId, period), ]) + const activeSprintId = activeSprints.find(s => s.product.id === filterProductId)?.id ?? '' + const tokenStats = await (activeSprints.length > 0 && filterProductId + ? getTokenStats(userId, activeSprintId) + : Promise.resolve({ kpi: { totalTokens: 0, totalCostUsd: 0, avgCostPerJob: 0, jobCount: 0 }, jobs: [] })) + // Date.now is an impure call but used once per request — safe in a Server Component. // eslint-disable-next-line react-hooks/purity const nowMs = Date.now() const sprintInfos = activeSprints.map(s => ({ sprintId: s.id, + sprintCode: s.code, productId: s.product.id, productName: s.product.name, sprintGoal: s.sprint_goal, @@ -125,6 +160,19 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps) )} + {/* Cost analyse */} +
+

Cost analyse

+ +
+ {/* Plan-quality */}

Plan-quality

@@ -142,6 +190,12 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps) />
+ {/* Token usage */} +
+

Token gebruik

+ +
+ {/* Velocity */}

Velocity

diff --git a/app/(app)/jobs/page.tsx b/app/(app)/jobs/page.tsx new file mode 100644 index 0000000..25c571b --- /dev/null +++ b/app/(app)/jobs/page.tsx @@ -0,0 +1,27 @@ +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { fetchJobsPageData } from '@/actions/jobs-page' +import JobsBoard from '@/components/jobs/jobs-board' +import JobsTimeFilter from '@/components/jobs/jobs-time-filter' + +export const metadata = { title: 'Jobs — Scrum4Me' } + +export default async function JobsPage() { + const session = await getSession() + if (!session.userId) redirect('/login') + + const data = await fetchJobsPageData() + if (!data) redirect('/login') + + return ( +
+
+

Jobs

+ +
+
+ +
+
+ ) +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 8f55e55..15eab5a 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -2,11 +2,14 @@ import { redirect } from 'next/navigation' import { requireSession } from '@/lib/auth-guard' import { prisma } from '@/lib/prisma' import { productAccessFilter } from '@/lib/product-access' +import { resolveActiveSprint } from '@/lib/active-sprint' import { NavBar } from '@/components/shared/nav-bar' import { MinWidthBanner } from '@/components/shared/min-width-banner' import { StatusBar } from '@/components/shared/status-bar' import { SoloRealtimeBridge } from '@/components/solo/realtime-bridge' import { NotificationsBridge } from '@/components/notifications/notifications-bridge' +import { UserSettingsBridge } from '@/components/shared/user-settings-bridge' +import { parseUserSettings } from '@/lib/user-settings' import { AlertToast } from '@/components/shared/alert-toast' import { Suspense } from 'react' @@ -16,7 +19,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod const [user, userRoles, accessibleProducts] = await Promise.all([ prisma.user.findUnique({ where: { id: session.userId }, - select: { username: true, email: true, active_product_id: true }, + select: { username: true, email: true, active_product_id: true, min_quota_pct: true, settings: true }, }), prisma.userRole.findMany({ where: { user_id: session.userId }, @@ -44,11 +47,8 @@ export default async function AppLayout({ children }: { children: React.ReactNod }) if (product) { activeProduct = product - const sprint = await prisma.sprint.findFirst({ - where: { product_id: product.id, status: 'ACTIVE' }, - select: { id: true }, - }) - hasActiveSprint = !!sprint + const resolved = await resolveActiveSprint(product.id, session.userId) + hasActiveSprint = !!resolved } else { await prisma.user.update({ where: { id: session.userId }, @@ -72,6 +72,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod activeProduct={activeProduct} products={accessibleProducts} hasActiveSprint={hasActiveSprint} + minQuotaPct={user.min_quota_pct} />
@@ -80,6 +81,10 @@ export default async function AppLayout({ children }: { children: React.ReactNod + diff --git a/app/(app)/manual/[[...slug]]/page.tsx b/app/(app)/manual/[[...slug]]/page.tsx new file mode 100644 index 0000000..ccc330b --- /dev/null +++ b/app/(app)/manual/[[...slug]]/page.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { getManualChapter, getManualToc } from '@/lib/manual-server' +import { MarkdownView } from '../_components/markdown-view' + +type Params = { slug?: string[] } + +export async function generateStaticParams(): Promise { + return getManualToc().map((entry) => ({ + slug: entry.slug.length > 0 ? [...entry.slug] : undefined, + })) +} + +export async function generateMetadata({ + params, +}: { + params: Promise +}): Promise { + const { slug = [] } = await params + const chapter = getManualChapter(slug) + if (!chapter) return { title: 'Manual — not found' } + return { + title: `${chapter.entry.title} — Scrum4Me Manual`, + description: chapter.entry.description.slice(0, 200), + } +} + +export default async function ManualChapterPage({ + params, +}: { + params: Promise +}) { + const { slug = [] } = await params + const chapter = getManualChapter(slug) + if (!chapter) notFound() + + return ( +
+ +
+ ) +} diff --git a/app/(app)/manual/_components/manual-sidebar.tsx b/app/(app)/manual/_components/manual-sidebar.tsx new file mode 100644 index 0000000..9643ed3 --- /dev/null +++ b/app/(app)/manual/_components/manual-sidebar.tsx @@ -0,0 +1,51 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { cn } from '@/lib/utils' +import type { ManualEntry } from '@/lib/manual.generated' + +type Props = { + toc: readonly ManualEntry[] +} + +function entryHref(entry: ManualEntry): string { + if (entry.slug.length === 0) return '/manual' + return '/manual/' + entry.slug.join('/') +} + +export function ManualSidebar({ toc }: Props) { + const pathname = usePathname() + + return ( + + ) +} diff --git a/app/(app)/manual/_components/markdown-view.tsx b/app/(app)/manual/_components/markdown-view.tsx new file mode 100644 index 0000000..421477f --- /dev/null +++ b/app/(app)/manual/_components/markdown-view.tsx @@ -0,0 +1,42 @@ +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import rehypeSlug from 'rehype-slug' +import rehypeAutolinkHeadings from 'rehype-autolink-headings' +import { MermaidBlock } from './mermaid-block' + +type Props = { + markdown: string +} + +export function MarkdownView({ markdown }: Props) { + return ( +
+ + } + return ( + {children} + ) + }, + }} + > + {markdown} + +
+ ) +} diff --git a/app/(app)/manual/_components/mermaid-block.tsx b/app/(app)/manual/_components/mermaid-block.tsx new file mode 100644 index 0000000..66e52db --- /dev/null +++ b/app/(app)/manual/_components/mermaid-block.tsx @@ -0,0 +1,73 @@ +'use client' + +import { useEffect, useId, useRef, useState } from 'react' + +type Props = { + source: string +} + +let mermaidPromise: Promise | null = null + +function loadMermaid() { + if (!mermaidPromise) { + mermaidPromise = import('mermaid').then((mod) => { + const mermaid = mod.default + mermaid.initialize({ + startOnLoad: false, + theme: 'default', + securityLevel: 'strict', + fontFamily: 'inherit', + }) + return mermaid + }) + } + return mermaidPromise +} + +export function MermaidBlock({ source }: Props) { + const id = useId().replace(/[^a-zA-Z0-9]/g, '') + const containerRef = useRef(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + loadMermaid() + .then(async (mermaid) => { + if (cancelled) return + try { + const { svg } = await mermaid.render(`mermaid-${id}`, source) + if (cancelled) return + if (containerRef.current) containerRef.current.innerHTML = svg + setError(null) + } catch (err) { + if (cancelled) return + setError(err instanceof Error ? err.message : String(err)) + } + }) + .catch((err) => { + if (cancelled) return + setError(err instanceof Error ? err.message : String(err)) + }) + return () => { + cancelled = true + } + }, [id, source]) + + if (error) { + return ( +
+        
+          {`Mermaid render failed: ${error}\n\n${source}`}
+        
+      
+ ) + } + + return ( +
+ ) +} diff --git a/app/(app)/manual/layout.tsx b/app/(app)/manual/layout.tsx new file mode 100644 index 0000000..06ebc7e --- /dev/null +++ b/app/(app)/manual/layout.tsx @@ -0,0 +1,16 @@ +import { getManualToc } from '@/lib/manual-server' +import { ManualSidebar } from './_components/manual-sidebar' + +export default function ManualLayout({ + children, +}: { + children: React.ReactNode +}) { + const toc = getManualToc() + return ( +
+ +
{children}
+
+ ) +} diff --git a/app/(app)/products/[id]/loading.tsx b/app/(app)/products/[id]/loading.tsx index 795b2c5..8dcb9c1 100644 --- a/app/(app)/products/[id]/loading.tsx +++ b/app/(app)/products/[id]/loading.tsx @@ -1,34 +1 @@ -export default function Loading() { - return ( -
- {/* Header skeleton */} -
-
-
-
-
-
-
- - {/* Split pane skeleton */} -
- {/* Left */} -
-
- {[1, 2, 3, 4, 5].map(i => ( -
- ))} -
- {/* Right */} -
-
-
- {[1, 2, 3].map(i => ( -
- ))} -
-
-
-
- ) -} +export { default } from '@/components/loading/backlog-page-skeleton' diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index d635e16..161b4dc 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -4,18 +4,25 @@ import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' import { prisma } from '@/lib/prisma' import { pbiStatusToApi } from '@/lib/task-status' +import { getSprintSwitcherData } from '@/lib/sprint-switcher-data' import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane' import { PbiList } from '@/components/backlog/pbi-list' import { StoryPanel } from '@/components/backlog/story-panel' import type { Story } from '@/components/backlog/story-panel' import { TaskPanel } from '@/components/backlog/task-panel' import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper' +import { UrlTaskSync } from '@/components/backlog/url-task-sync' import { TaskDialog } from '@/app/_components/tasks/task-dialog' import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader' import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton' -import { StartSprintButton } from '@/components/sprint/start-sprint-button' +import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger' +import { SprintDraftBanner } from '@/components/backlog/sprint-draft-banner' +import { SprintDraftLeaveGuard } from '@/components/backlog/sprint-draft-leave-guard' +import { SaveSprintButton } from '@/components/backlog/save-sprint-button' +import { ActiveSelectionHydrator } from '@/components/backlog/active-selection-hydrator' import { ActivateProductButton } from '@/components/shared/activate-product-button' import { EditProductButton } from '@/components/products/edit-product-button' +import { SprintSwitcher } from '@/components/shared/sprint-switcher' import Link from 'next/link' interface Props { @@ -33,10 +40,13 @@ export default async function ProductBacklogPage({ params, searchParams }: Props const product = await getAccessibleProduct(id, session.userId) if (!product) notFound() - const [activeSprint, user] = await Promise.all([ - prisma.sprint.findFirst({ where: { product_id: id, status: 'ACTIVE' } }), + const [user, switcherData] = await Promise.all([ prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }), + getSprintSwitcherData(id, { userId: session.userId }), ]) + const { sprintItems, buildingSprintIds, activeSprintItem } = switcherData + const hasOpenSprint = sprintItems.some(s => s.status === 'open') + const isActiveProduct = user?.active_product_id === id const pbis = await prisma.pbi.findMany({ where: { product_id: id }, @@ -46,7 +56,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props const [stories, tasks] = await Promise.all([ prisma.story.findMany({ where: { product_id: id }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], select: { id: true, code: true, @@ -54,8 +64,10 @@ export default async function ProductBacklogPage({ params, searchParams }: Props description: true, acceptance_criteria: true, priority: true, + sort_order: true, status: true, pbi_id: true, + sprint_id: true, created_at: true, }, }), @@ -63,6 +75,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props where: { story: { pbi: { product_id: id } } }, select: { id: true, + code: true, title: true, description: true, priority: true, @@ -71,11 +84,11 @@ export default async function ProductBacklogPage({ params, searchParams }: Props story_id: true, created_at: true, }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], }), ]) - // Group stories by PBI id + // Group stories by PBI id (status uit DB blijft UPPER_SNAKE in dit hydratie-pad) const storiesByPbi: Record = {} for (const story of stories) { if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = [] @@ -93,18 +106,37 @@ export default async function ProductBacklogPage({ params, searchParams }: Props return (
- {/* Product header — actions only; product-naam zit al in NavBar */} -
-
- {user?.active_product_id !== id && ( + {/* Product header — sprint-switcher gecentreerd, actions rechts */} +
+
+
+ {isActiveProduct && ( + + )} +
+
+ {!isActiveProduct && ( )} - {activeSprint ? ( + {hasOpenSprint && ( Sprint actief → - ) : ( - !isDemo && + )} + {activeSprintItem && !isDemo && ( + + )} + {!isDemo && ( + )} {!isDemo && product.user_id === session.userId && (
+ {/* Sprint definition banner (state A′) + beforeunload-guard */} + + + {/* Split pane */}
({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), + pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, sort_order: p.sort_order, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), storiesByPbi, tasksByStory, }} > + + , ,
+
+
+

PR-strategie

+

+ Bepaalt hoe de sprint zijn werk oplevert: één PR voor de hele sprint + of een PR per story die automatisch wordt gemerged na groene CI. +

+
+ +
+

Team

diff --git a/app/(app)/products/[id]/solo/page.tsx b/app/(app)/products/[id]/solo/page.tsx index 868e579..b19ec4a 100644 --- a/app/(app)/products/[id]/solo/page.tsx +++ b/app/(app)/products/[id]/solo/page.tsx @@ -1,11 +1,12 @@ import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' -import { prisma } from '@/lib/prisma' +import { getSprintSwitcherData } from '@/lib/sprint-switcher-data' +import { getSoloWorkspaceSnapshot } from '@/lib/solo-workspace-server' import { SoloBoard } from '@/components/solo/solo-board' +import { SoloHydrationWrapper } from '@/components/solo/solo-hydration-wrapper' import { NoActiveSprint } from '@/components/solo/no-active-sprint' -import type { SoloTask } from '@/components/solo/solo-board' -import type { UnassignedStory } from '@/components/solo/unassigned-stories-sheet' +import { SprintSwitcher } from '@/components/shared/sprint-switcher' interface Props { params: Promise<{ id: string }> @@ -19,97 +20,45 @@ export default async function SoloProductPage({ params }: Props) { const product = await getAccessibleProduct(id, session.userId) if (!product) notFound() - const sprint = await prisma.sprint.findFirst({ - where: { product_id: id, status: 'ACTIVE' }, + const initialData = await getSoloWorkspaceSnapshot(id, session.userId) + const switcherData = await getSprintSwitcherData(id, { + activeSprintId: initialData?.sprint.id ?? null, }) - if (!sprint) { + const switcherBar = ( +
+ +
+ ) + + if (!initialData) { return (
+ {switcherBar}
) } - const [rawTasks, rawUnassigned] = await Promise.all([ - prisma.task.findMany({ - where: { - story: { - sprint_id: sprint.id, - assignee_id: session.userId, - }, - }, - include: { - story: { - select: { - id: true, - code: true, - title: true, - tasks: { select: { id: true }, orderBy: { sort_order: 'asc' } }, - }, - }, - }, - orderBy: [ - { story: { pbi: { priority: 'asc' } } }, - { story: { pbi: { sort_order: 'asc' } } }, - { story: { sort_order: 'asc' } }, - { priority: 'asc' }, - { sort_order: 'asc' }, - ], - }), - prisma.story.findMany({ - where: { sprint_id: sprint.id, assignee_id: null }, - select: { - id: true, - code: true, - title: true, - tasks: { - select: { id: true, title: true, description: true, priority: true, status: true }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], - }, - }, - orderBy: { sort_order: 'asc' }, - }), - ]) - - const tasks: SoloTask[] = rawTasks.map(t => ({ - id: t.id, - title: t.title, - description: t.description, - implementation_plan: t.implementation_plan, - priority: t.priority, - sort_order: t.sort_order, - status: t.status as SoloTask['status'], - verify_only: t.verify_only, - verify_required: t.verify_required as SoloTask['verify_required'], - story_id: t.story.id, - story_code: t.story.code, - story_title: t.story.title, - task_code: t.code, - })) - - const unassignedStories: UnassignedStory[] = rawUnassigned.map(s => ({ - id: s.id, - code: s.code, - title: s.title, - tasks: s.tasks.map(t => ({ - id: t.id, - title: t.title, - description: t.description, - priority: t.priority, - status: t.status, - })), - })) - return ( - +
+ {switcherBar} +
+ + + +
+
) } diff --git a/app/(app)/products/[id]/sprint/[sprintId]/loading.tsx b/app/(app)/products/[id]/sprint/[sprintId]/loading.tsx new file mode 100644 index 0000000..8dcb9c1 --- /dev/null +++ b/app/(app)/products/[id]/sprint/[sprintId]/loading.tsx @@ -0,0 +1 @@ +export { default } from '@/components/loading/backlog-page-skeleton' diff --git a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx new file mode 100644 index 0000000..2981c47 --- /dev/null +++ b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx @@ -0,0 +1,245 @@ +import { notFound, redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { getAccessibleProduct } from '@/lib/product-access' +import { prisma } from '@/lib/prisma' +import { pbiStatusToApi } from '@/lib/task-status' +import { SprintBoardClient } from '@/components/sprint/sprint-board-client' +import { + SprintHydrationWrapper, + type SprintHydrationData, +} from '@/components/sprint/sprint-hydration-wrapper' +import { SprintTaskDialogMount } from '@/components/sprint/sprint-task-dialog-mount' +import { SprintUrlTaskSync } from '@/components/sprint/sprint-url-task-sync' +import { SyncActiveSprintCookie } from '@/components/sprint/sync-active-sprint-cookie' +import { getSprintSwitcherData } from '@/lib/sprint-switcher-data' +import { SprintHeader } from '@/components/sprint/sprint-header' +import { SprintRunControls } from '@/components/sprint/sprint-run-controls' +import { parsePauseContext } from '@/lib/pause-context' +import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog' +import type { SprintWorkspaceTask } from '@/stores/sprint-workspace/types' +import { TaskDialog } from '@/app/_components/tasks/task-dialog' +import Link from 'next/link' + +interface Props { + params: Promise<{ id: string; sprintId: string }> + searchParams: Promise<{ + newTask?: string + storyId?: string + }> +} + +export default async function SprintBoardPage({ params, searchParams }: Props) { + const { id, sprintId } = await params + const { newTask, storyId: storyIdParam } = await searchParams + + const session = await getSession() + if (!session.userId) redirect('/login') + + const product = await getAccessibleProduct(id, session.userId) + if (!product) notFound() + + const sprint = await prisma.sprint.findFirst({ + where: { id: sprintId, product_id: id }, + select: { + id: true, + code: true, + sprint_goal: true, + status: true, + start_date: true, + end_date: true, + }, + }) + if (!sprint) notFound() + + const switcherData = await getSprintSwitcherData(id, { activeSprintId: sprint.id }) + + const activeSprintRun = await prisma.sprintRun.findFirst({ + where: { + sprint_id: sprint.id, + status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] }, + }, + select: { id: true, status: true, pause_context: true }, + orderBy: { created_at: 'desc' }, + }) + const pauseContext = + activeSprintRun?.status === 'PAUSED' + ? parsePauseContext(activeSprintRun.pause_context) + : null + + // Sprint stories with full task data and assignee + const [sprintStories, productMembers] = await Promise.all([ + prisma.story.findMany({ + where: { sprint_id: sprint.id }, + orderBy: { sort_order: 'asc' }, + include: { + tasks: { orderBy: [{ sort_order: 'asc' }] }, + assignee: { select: { id: true, username: true } }, + }, + }), + prisma.productMember.findMany({ + where: { product_id: id }, + include: { user: { select: { id: true, username: true } } }, + }), + ]) + + // All members who can be assigned: owner + product members + const members: ProductMember[] = [ + { userId: product.user_id, username: (await prisma.user.findUnique({ where: { id: product.user_id }, select: { username: true } }))?.username ?? 'Eigenaar' }, + ...productMembers.map(m => ({ userId: m.user_id, username: m.user.username })), + ] + + const sprintStoryItems: SprintStory[] = sprintStories.map(s => ({ + id: s.id, + code: s.code, + title: s.title, + description: s.description, + acceptance_criteria: s.acceptance_criteria, + pbi_id: s.pbi_id, + sprint_id: s.sprint_id, + created_at: s.created_at, + priority: s.priority, + sort_order: s.sort_order, + status: s.status, + taskCount: s.tasks.length, + doneCount: s.tasks.filter(t => t.status === 'DONE').length, + assignee_id: s.assignee_id, + assignee_username: s.assignee?.username ?? null, + })) + + const tasksByStoryWorkspace: Record = {} + for (const story of sprintStories) { + tasksByStoryWorkspace[story.id] = story.tasks.map(t => ({ + id: t.id, + code: t.code, + title: t.title, + description: t.description, + priority: t.priority, + sort_order: t.sort_order, + status: t.status, + story_id: t.story_id, + sprint_id: t.sprint_id, + created_at: t.created_at, + })) + } + + // All PBIs with their stories for the left (product backlog) panel + const pbis = await prisma.pbi.findMany({ + where: { product_id: id }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + include: { + stories: { + orderBy: [{ sort_order: 'asc' }], + }, + }, + }) + + const pbisWithStories: PbiWithStories[] = pbis + .filter(pbi => pbi.stories.length > 0) + .map(pbi => ({ + id: pbi.id, + code: pbi.code, + title: pbi.title, + priority: pbi.priority, + status: pbiStatusToApi(pbi.status), + description: pbi.description, + stories: pbi.stories.map(s => ({ + id: s.id, + code: s.code, + title: s.title, + description: s.description, + acceptance_criteria: s.acceptance_criteria, + pbi_id: s.pbi_id, + sprint_id: s.sprint_id, + created_at: s.created_at, + priority: s.priority, + sort_order: s.sort_order, + status: s.status, + taskCount: 0, + doneCount: 0, + assignee_id: null, + assignee_username: null, + })), + })) + + const isDemo = session.isDemo ?? false + const closePath = `/products/${id}/sprint/${sprint.id}` + + const hydrationData: SprintHydrationData = { + sprint: { + id: sprint.id, + product_id: id, + code: sprint.code, + sprint_goal: sprint.sprint_goal, + status: sprint.status as 'OPEN' | 'CLOSED', + start_date: sprint.start_date ? sprint.start_date.toISOString().slice(0, 10) : null, + end_date: sprint.end_date ? sprint.end_date.toISOString().slice(0, 10) : null, + created_at: new Date(), + completed_at: null, + }, + stories: sprintStoryItems, + tasksByStory: tasksByStoryWorkspace, + } + + return ( +
+ + + +
+ +
+ +
+ + + + + +
+ +
+ + ← Product Backlog + +
+ + {newTask && ( + + )} +
+ ) +} diff --git a/app/(app)/products/[id]/sprint/[sprintId]/planning/loading.tsx b/app/(app)/products/[id]/sprint/[sprintId]/planning/loading.tsx new file mode 100644 index 0000000..8dcb9c1 --- /dev/null +++ b/app/(app)/products/[id]/sprint/[sprintId]/planning/loading.tsx @@ -0,0 +1 @@ +export { default } from '@/components/loading/backlog-page-skeleton' diff --git a/app/(app)/products/[id]/sprint/planning/page.tsx b/app/(app)/products/[id]/sprint/[sprintId]/planning/page.tsx similarity index 50% rename from app/(app)/products/[id]/sprint/planning/page.tsx rename to app/(app)/products/[id]/sprint/[sprintId]/planning/page.tsx index 8256d6f..d2a017a 100644 --- a/app/(app)/products/[id]/sprint/planning/page.tsx +++ b/app/(app)/products/[id]/sprint/[sprintId]/planning/page.tsx @@ -1,10 +1,10 @@ import { redirect } from 'next/navigation' interface Props { - params: Promise<{ id: string }> + params: Promise<{ id: string; sprintId: string }> } export default async function SprintPlanningRedirect({ params }: Props) { - const { id } = await params - redirect(`/products/${id}/sprint`) + const { id, sprintId } = await params + redirect(`/products/${id}/sprint/${sprintId}`) } diff --git a/app/(app)/products/[id]/sprint/loading.tsx b/app/(app)/products/[id]/sprint/loading.tsx deleted file mode 100644 index 795b2c5..0000000 --- a/app/(app)/products/[id]/sprint/loading.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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/page.tsx b/app/(app)/products/[id]/sprint/page.tsx index 3e3a36c..5f0e6ab 100644 --- a/app/(app)/products/[id]/sprint/page.tsx +++ b/app/(app)/products/[id]/sprint/page.tsx @@ -1,192 +1,17 @@ -import { Suspense } from 'react' -import { notFound, redirect } from 'next/navigation' -import { getSession } from '@/lib/auth' -import { getAccessibleProduct } from '@/lib/product-access' -import { prisma } from '@/lib/prisma' -import { pbiStatusToApi } from '@/lib/task-status' -import { SprintBoardClient } from '@/components/sprint/sprint-board-client' -import { SprintHeader } from '@/components/sprint/sprint-header' -import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog' -import type { Task } from '@/components/sprint/task-list' -import { TaskDialog } from '@/app/_components/tasks/task-dialog' -import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader' -import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton' -import Link from 'next/link' +import { redirect } from 'next/navigation' +import { requireSession } from '@/lib/auth-guard' +import { resolveActiveSprint } from '@/lib/active-sprint' interface Props { params: Promise<{ id: string }> - searchParams: Promise<{ - newTask?: string - storyId?: string - editTask?: string - }> } -export default async function SprintBoardPage({ params, searchParams }: Props) { +export default async function SprintRedirectPage({ params }: Props) { const { id } = await params - const { newTask, storyId: storyIdParam, editTask } = await searchParams - - const session = await getSession() - if (!session.userId) redirect('/login') - - const product = await getAccessibleProduct(id, session.userId) - if (!product) notFound() - - const sprint = await prisma.sprint.findFirst({ - where: { product_id: id, status: 'ACTIVE' }, - select: { - id: true, - sprint_goal: true, - status: true, - start_date: true, - end_date: true, - }, - }) - if (!sprint) redirect(`/products/${id}`) - - // Sprint stories with full task data and assignee - const [sprintStories, productMembers] = await Promise.all([ - prisma.story.findMany({ - where: { sprint_id: sprint.id }, - orderBy: { sort_order: 'asc' }, - include: { - tasks: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }] }, - assignee: { select: { id: true, username: true } }, - }, - }), - prisma.productMember.findMany({ - where: { product_id: id }, - include: { user: { select: { id: true, username: true } } }, - }), - ]) - - // All members who can be assigned: owner + product members - const members: ProductMember[] = [ - { userId: product.user_id, username: (await prisma.user.findUnique({ where: { id: product.user_id }, select: { username: true } }))?.username ?? 'Eigenaar' }, - ...productMembers.map(m => ({ userId: m.user_id, username: m.user.username })), - ] - - const sprintStoryItems: SprintStory[] = sprintStories.map(s => ({ - id: s.id, - code: s.code, - title: s.title, - description: s.description, - acceptance_criteria: s.acceptance_criteria, - pbi_id: s.pbi_id, - created_at: s.created_at, - priority: s.priority, - status: s.status, - taskCount: s.tasks.length, - doneCount: s.tasks.filter(t => t.status === 'DONE').length, - assignee_id: s.assignee_id, - assignee_username: s.assignee?.username ?? null, - })) - - const tasksByStory: Record = {} - for (const story of sprintStories) { - tasksByStory[story.id] = story.tasks.map(t => ({ - id: t.id, - code: t.code, - title: t.title, - description: t.description, - priority: t.priority, - status: t.status, - story_id: t.story_id, - sprint_id: t.sprint_id, - })) + const session = await requireSession() + const active = await resolveActiveSprint(id, session.userId) + if (!active) { + redirect(`/products/${id}?alert=no_sprint`) } - - // All PBIs with their stories for the left (product backlog) panel - const pbis = await prisma.pbi.findMany({ - where: { product_id: id }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], - include: { - stories: { - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], - }, - }, - }) - - const pbisWithStories: PbiWithStories[] = pbis - .filter(pbi => pbi.stories.length > 0) - .map(pbi => ({ - id: pbi.id, - code: pbi.code, - title: pbi.title, - priority: pbi.priority, - status: pbiStatusToApi(pbi.status), - description: pbi.description, - stories: pbi.stories.map(s => ({ - id: s.id, - code: s.code, - title: s.title, - description: s.description, - acceptance_criteria: s.acceptance_criteria, - pbi_id: s.pbi_id, - created_at: s.created_at, - priority: s.priority, - status: s.status, - taskCount: 0, - doneCount: 0, - assignee_id: null, - assignee_username: null, - })), - })) - - const sprintStoryIdList = sprintStories.map(s => s.id) - const isDemo = session.isDemo ?? false - const closePath = `/products/${id}/sprint` - - return ( -
- - -
- -
- -
- - ← Product Backlog - -
- - {newTask && ( - - )} - - {editTask && !newTask && ( - }> - - - )} -
- ) + redirect(`/products/${id}/sprint/${active.id}`) } diff --git a/app/(app)/products/[id]/sprint/planning/loading.tsx b/app/(app)/products/[id]/sprint/planning/loading.tsx deleted file mode 100644 index 795b2c5..0000000 --- a/app/(app)/products/[id]/sprint/planning/loading.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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/loading.tsx b/app/(app)/settings/loading.tsx index 07f3dd9..11488b0 100644 --- a/app/(app)/settings/loading.tsx +++ b/app/(app)/settings/loading.tsx @@ -1,9 +1,11 @@ +import { Skeleton } from '@/components/ui/skeleton' + export default function Loading() { return ( -
-
- {[1, 2, 3, 4].map(i => ( -
+
+ + {[1, 2, 3, 4].map((i) => ( + ))}
) diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index b299e45..88daf20 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma' import { RoleManager } from '@/components/settings/role-manager' import { LeaveProductButton } from '@/components/settings/leave-product-button' import { ProfileEditor } from '@/components/settings/profile-editor' +import { MinQuotaEditor } from '@/components/settings/min-quota-editor' import { ActivateProductButton } from '@/components/shared/activate-product-button' import Link from 'next/link' @@ -14,7 +15,7 @@ export default async function SettingsPage() { const [user, userRoles, ownedProducts, memberships] = await Promise.all([ prisma.user.findUnique({ where: { id: session.userId }, - select: { username: true, email: true, bio: true, bio_detail: true, avatar_data: true, updated_at: true, active_product_id: true }, + select: { username: true, email: true, bio: true, bio_detail: true, avatar_data: true, updated_at: true, active_product_id: true, min_quota_pct: true }, }), prisma.userRole.findMany({ where: { user_id: session.userId } }), prisma.product.findMany({ @@ -157,6 +158,19 @@ export default async function SettingsPage() { )}
+
+
+

Worker-instellingen

+

+ Drempelwaarden voor de Claude-worker. +

+
+ +
+

API Tokens

diff --git a/app/(app)/todos/loading.tsx b/app/(app)/todos/loading.tsx deleted file mode 100644 index 61d4ec9..0000000 --- a/app/(app)/todos/loading.tsx +++ /dev/null @@ -1,19 +0,0 @@ -export default function Loading() { - return ( -
-
-
-
-
-
-
-
-
- {[1, 2, 3, 4, 5].map(i => ( -
- ))} -
-
-
- ) -} diff --git a/app/(app)/todos/page.tsx b/app/(app)/todos/page.tsx deleted file mode 100644 index 27f07f4..0000000 --- a/app/(app)/todos/page.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { cookies } from 'next/headers' -import { getIronSession } from 'iron-session' -import { SessionData, sessionOptions } from '@/lib/session' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' -import { TodoList } from '@/components/todos/todo-list' - -export default async function TodosPage() { - const session = await getIronSession(await cookies(), sessionOptions) - - const todos = await prisma.todo.findMany({ - where: { user_id: session.userId, archived: false }, - orderBy: { created_at: 'asc' }, - include: { product: { select: { name: true } } }, - }) - - const products = await prisma.product.findMany({ - where: { ...productAccessFilter(session.userId), archived: false }, - orderBy: { name: 'asc' }, - include: { - pbis: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], select: { id: true, title: true } }, - }, - }) - - return ( -
-

Todo's

- ({ - id: t.id, - title: t.title, - description: t.description ?? null, - done: t.done, - created_at: t.created_at.toISOString(), - product_id: t.product_id ?? null, - product_name: t.product?.name ?? null, - }))} - products={products.map(p => ({ - id: p.id, - name: p.name, - pbis: p.pbis, - }))} - isDemo={session.isDemo ?? false} - /> -
- ) -} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 4a225f9..2e0afcb 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -5,7 +5,7 @@ import { QrLoginButton } from './qr-login-button' export default function LoginPage() { return ( -
+
{/* Logo / titel */} @@ -42,6 +42,6 @@ export default function LoginPage() {
-
+
) } diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index ee1c6aa..5a948df 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -4,7 +4,7 @@ import { AuthForm } from '@/components/auth/auth-form' export default function RegisterPage() { return ( -
+
{/* Logo / titel */} @@ -26,6 +26,6 @@ export default function RegisterPage() {
-
+
) } diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..c38ec5f --- /dev/null +++ b/app/(auth)/reset-password/page.tsx @@ -0,0 +1,37 @@ +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { ResetPasswordForm } from './reset-form' + +export default async function ResetPasswordPage() { + const session = await getSession() + if (!session.userId) { + redirect('/login') + } + + const user = await prisma.user.findUnique({ + where: { id: session.userId }, + select: { must_reset_password: true }, + }) + + if (!user?.must_reset_password) { + redirect('/dashboard') + } + + return ( +
+
+
+

Wachtwoord wijzigen

+

+ Kies een nieuw wachtwoord om verder te gaan. +

+
+ +
+ +
+
+
+ ) +} diff --git a/app/(auth)/reset-password/reset-form.tsx b/app/(auth)/reset-password/reset-form.tsx new file mode 100644 index 0000000..85f44f2 --- /dev/null +++ b/app/(auth)/reset-password/reset-form.tsx @@ -0,0 +1,74 @@ +'use client' + +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { resetPasswordAction } from '@/actions/auth' + +type ActionResult = { error: string | Record } | undefined + +function SubmitButton() { + const { pending } = useFormStatus() + return ( + + ) +} + +function getErrorMessage(error: ActionResult): string | null { + if (!error) return null + if (typeof error.error === 'string') return error.error + const first = Object.values(error.error).flat()[0] + return first ?? null +} + +export function ResetPasswordForm() { + const [state, formAction] = useActionState(resetPasswordAction, undefined) + const errorMessage = getErrorMessage(state) + + return ( +
+
+ + +
+ +
+ + +
+ + {errorMessage && ( +
+ {errorMessage} +
+ )} + + + + ) +} diff --git a/app/(mobile)/m/products/[id]/page.tsx b/app/(mobile)/m/products/[id]/page.tsx index 136188d..4aa5815 100644 --- a/app/(mobile)/m/products/[id]/page.tsx +++ b/app/(mobile)/m/products/[id]/page.tsx @@ -15,6 +15,7 @@ import { StoryPanel } from '@/components/backlog/story-panel' import type { Story } from '@/components/backlog/story-panel' import { TaskPanel } from '@/components/backlog/task-panel' import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper' +import { UrlTaskSync } from '@/components/backlog/url-task-sync' import { TaskDialog } from '@/app/_components/tasks/task-dialog' import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader' import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton' @@ -41,7 +42,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }: const [stories, tasks] = await Promise.all([ prisma.story.findMany({ where: { product_id: id }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], select: { id: true, code: true, @@ -49,8 +50,10 @@ export default async function MobileProductBacklogPage({ params, searchParams }: description: true, acceptance_criteria: true, priority: true, + sort_order: true, status: true, pbi_id: true, + sprint_id: true, created_at: true, }, }), @@ -58,6 +61,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }: where: { story: { pbi: { product_id: id } } }, select: { id: true, + code: true, title: true, description: true, priority: true, @@ -66,7 +70,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }: story_id: true, created_at: true, }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], }), ]) @@ -88,12 +92,14 @@ export default async function MobileProductBacklogPage({ params, searchParams }:
({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), + pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, sort_order: p.sort_order, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), storiesByPbi, tasksByStory, }} > + @@ -24,11 +23,9 @@ export default async function MobileSoloProductPage({ params }: Props) { const product = await getAccessibleProduct(id, session.userId) if (!product) notFound() - const sprint = await prisma.sprint.findFirst({ - where: { product_id: id, status: 'ACTIVE' }, - }) + const initialData = await getSoloWorkspaceSnapshot(id, session.userId) - if (!sprint) { + if (!initialData) { return (
@@ -36,85 +33,15 @@ export default async function MobileSoloProductPage({ params }: Props) { ) } - const [rawTasks, rawUnassigned] = await Promise.all([ - prisma.task.findMany({ - where: { - story: { - sprint_id: sprint.id, - assignee_id: session.userId, - }, - }, - include: { - story: { - select: { - id: true, - code: true, - title: true, - tasks: { select: { id: true }, orderBy: { sort_order: 'asc' } }, - }, - }, - }, - orderBy: [ - { story: { pbi: { priority: 'asc' } } }, - { story: { pbi: { sort_order: 'asc' } } }, - { story: { sort_order: 'asc' } }, - { priority: 'asc' }, - { sort_order: 'asc' }, - ], - }), - prisma.story.findMany({ - where: { sprint_id: sprint.id, assignee_id: null }, - select: { - id: true, - code: true, - title: true, - tasks: { - select: { id: true, title: true, description: true, priority: true, status: true }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], - }, - }, - orderBy: { sort_order: 'asc' }, - }), - ]) - - const tasks: SoloTask[] = rawTasks.map(t => ({ - id: t.id, - title: t.title, - description: t.description, - implementation_plan: t.implementation_plan, - priority: t.priority, - sort_order: t.sort_order, - status: t.status as SoloTask['status'], - verify_only: t.verify_only, - verify_required: t.verify_required as SoloTask['verify_required'], - story_id: t.story.id, - story_code: t.story.code, - story_title: t.story.title, - task_code: t.code, - })) - - const unassignedStories: UnassignedStory[] = rawUnassigned.map(s => ({ - id: s.id, - code: s.code, - title: s.title, - tasks: s.tasks.map(t => ({ - id: t.id, - title: t.title, - description: t.description, - priority: t.priority, - status: t.status, - })), - })) - return ( - + + + ) } diff --git a/app/_components/tasks/status-select.tsx b/app/_components/tasks/status-select.tsx index 5ba794d..298c350 100644 --- a/app/_components/tasks/status-select.tsx +++ b/app/_components/tasks/status-select.tsx @@ -14,9 +14,12 @@ const STATUS_CONFIG: Record = { IN_PROGRESS: { label: 'Bezig', dot: 'bg-status-in-progress' }, REVIEW: { label: 'Review', dot: 'bg-status-review' }, DONE: { label: 'Klaar', dot: 'bg-status-done' }, + FAILED: { label: 'Gefaald', dot: 'bg-status-failed' }, + EXCLUDED: { label: 'Uitgesloten', dot: 'bg-muted-foreground/40' }, } -const STATUS_ORDER: TaskStatus[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE'] +// FAILED ontbreekt bewust: alleen via sprint-cascade gezet, niet handmatig kiesbaar. +const STATUS_ORDER: TaskStatus[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE', 'EXCLUDED'] function StatusIndicator({ status }: { status: TaskStatus }) { return ( diff --git a/app/_components/tasks/task-dialog-skeleton.tsx b/app/_components/tasks/task-dialog-skeleton.tsx index bb6a66b..823abb7 100644 --- a/app/_components/tasks/task-dialog-skeleton.tsx +++ b/app/_components/tasks/task-dialog-skeleton.tsx @@ -1,5 +1,11 @@ import { Skeleton } from '@/components/ui/skeleton' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { + entityDialogBodyClasses, + entityDialogContentClasses, + entityDialogFooterClasses, + entityDialogHeaderClasses, +} from '@/components/shared/entity-dialog-layout' import { cn } from '@/lib/utils' export function TaskDialogSkeleton() { @@ -8,32 +14,54 @@ export function TaskDialogSkeleton() { Taak laden… - {/* Header */} -
- +
+
+ + +
+
- {/* Body — 3 bars mimicking title + description + plan */} -
- - - +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
- {/* Footer */} -
-
- - +
+
+ +
+ + +
diff --git a/app/_components/tasks/task-dialog.tsx b/app/_components/tasks/task-dialog.tsx index 4e25f27..638d834 100644 --- a/app/_components/tasks/task-dialog.tsx +++ b/app/_components/tasks/task-dialog.tsx @@ -60,7 +60,9 @@ interface TaskDialogProps { task?: TaskDialogTask storyId?: string productId: string - closePath: string + closePath?: string + onClose?: () => void + onSaved?: (taskId: string) => void isDemo?: boolean } @@ -75,13 +77,13 @@ function CharCount({ value, max }: { value: string; max: number }) { } const textareaClass = cn( - 'flex w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm', + 'flex w-full rounded-lg border border-border bg-input-background px-2.5 py-2 text-sm', 'transition-colors outline-none placeholder:text-muted-foreground resize-none', 'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50', 'overflow-y-auto', ) -export function TaskDialog({ task, storyId, productId, closePath, isDemo = false }: TaskDialogProps) { +export function TaskDialog({ task, storyId, productId, closePath, onClose, onSaved, isDemo = false }: TaskDialogProps) { const router = useRouter() const [isPending, startTransition] = useTransition() const [confirmDelete, setConfirmDelete] = useState(false) @@ -100,11 +102,12 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false }, }) - function handleClose() { - router.push(closePath) + function close() { + if (onClose) { onClose(); return } + if (closePath) router.push(closePath) } - const closeGuard = useDirtyCloseGuard(form.formState.isDirty, handleClose) + const closeGuard = useDirtyCloseGuard(form.formState.isDirty, close) const handleKeyDown = useDialogSubmitShortcut(() => form.handleSubmit(onSubmit)()) function onSubmit(data: TaskInput) { @@ -117,7 +120,8 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false if (result.ok) { toast.success(isEdit ? 'Taak opgeslagen' : 'Taak aangemaakt') - router.push(closePath) + onSaved?.(result.task.id) + close() return } @@ -152,7 +156,7 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false const result = await deleteTask(task.id, { productId }) if (result.ok) { toast.success('Taak verwijderd') - router.push(closePath) + close() return } if (result.code === 403) { @@ -220,10 +224,11 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false {/* Title */}
-