diff --git a/.env.example b/.env.example index ede2b3c..d981a5b 100644 --- a/.env.example +++ b/.env.example @@ -15,22 +15,6 @@ NODE_ENV="development" # 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). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8fda6f..e9b47e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,23 +5,11 @@ 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 @@ -61,52 +49,11 @@ 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, 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') - ) + needs: ci + if: github.event_name == 'pull_request' steps: - name: Checkout @@ -133,15 +80,8 @@ jobs: deploy-production: name: Deploy Production (main) runs-on: ubuntu-latest - 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' + needs: ci + if: github.ref == 'refs/heads/main' && github.event_name == 'push' steps: - name: Checkout @@ -170,42 +110,3 @@ 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 fe6b79a..d20df70 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,6 @@ 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 index 40a8e6c..89a9b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,7 +67,7 @@ launch-ready state na de v1-readiness-checklist (Now + Before-launch items). 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`. +- v1.0 readiness checklist in `docs/plans/v1-readiness.md`. ([#82](https://github.com/madhura68/Scrum4Me/pull/82)) ### Changed @@ -95,7 +95,7 @@ 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). +Voor de volledige milestone-historie zie [docs/backlog/index.md](./docs/backlog/index.md). --- diff --git a/CLAUDE.md b/CLAUDE.md index 06dc2fb..566c755 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-11 +last_updated: 2026-05-03 --- # CLAUDE.md — Scrum4Me @@ -19,25 +19,30 @@ 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/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 | +| `docs/plans/-*.md` | Implementatieplan per milestone | --- ## 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 verify && npm run build` — `verify` = lint + typecheck + test +5. Verifieer: `npm run lint && npm test && npm run build` 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) --- @@ -48,13 +53,10 @@ 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 -- **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) +- **Deployment:** `npm run lint && npm test && npm run build` vóór elke PR --- @@ -62,13 +64,12 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo | Laag | Technologie | |---|---| -| Framework | Next.js 16.2 (App Router) + React 19.2 — PPR/Cache Components beschikbaar | +| Framework | Next.js 16 (App Router) + React 19 | | Taal | TypeScript strict | -| Styling | Tailwind CSS v4 + shadcn/ui + MD3 via `app/styles/theme.css` | +| Styling | Tailwind CSS + shadcn/ui + MD3 via `app/styles/theme.css` | | State | Zustand + dnd-kit | -| DB | Prisma v7.8 + PostgreSQL (Neon) | +| DB | Prisma v7 + PostgreSQL (Neon) | | Auth | iron-session + bcryptjs | -| Test | Vitest (`__tests__/`, config in `vitest.config.ts`) | | Utilities | Zod, Sonner, Sharp, Vercel Analytics | --- @@ -81,20 +82,12 @@ 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` | --- @@ -107,18 +100,7 @@ SESSION_SECRET="" # min 32 chars CRON_SECRET="" # Bearer-secret /api/cron/* ``` -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) +Volledig schema: `lib/env.ts`. Canonieke lijst: `.env.example`. --- @@ -131,24 +113,5 @@ PBI (niet: Feature/Epic) · Story (niet: Ticket) · Sprint Goal (niet: Objective ## Verificatie ```bash -npm run verify && npm run build # verify = lint + typecheck + test +npm run lint && npm test && npm run build ``` - -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 7cf3a14..4de7224 100644 --- a/README.md +++ b/README.md @@ -123,12 +123,16 @@ Vul daarna `DATABASE_URL` en `SESSION_SECRET` in. `DIRECT_URL` is optioneel loka npx prisma db push ``` -4. Genereer Prisma Client: +4. Genereer Prisma Client en de ERD: ```bash -npx prisma generate +npm run db:erd ``` +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 @@ -162,9 +166,19 @@ De curl-tests dekken alle 7 API-endpoints: auth (401), demo-blokkering (403), in ## Database -Het schema staat in `prisma/schema.prisma`; uitgebreide documentatie in [`docs/architecture/data-model.md`](./docs/architecture/data-model.md). +![ERD](./docs/assets/erd.svg) -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 databasevisualisatie wordt lokaal gegenereerd uit `prisma/schema.prisma` via `prisma-erd-generator`. + +Handmatige generatie: + +```bash +npm run db:erd +``` + +Optioneel: `npm run db:erd:watch` parallel aan `npm run dev` om bij wijzigingen in `prisma/schema.prisma` `docs/assets/erd.svg` automatisch opnieuw te genereren. + +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`. De app draait standaard op `http://localhost:3000`. @@ -175,6 +189,7 @@ 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 @@ -187,10 +202,6 @@ Zie [.env.example](.env.example). | `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 | @@ -247,20 +258,13 @@ 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` | 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/products/:id/next-story` | Volgende story uit de actieve sprint | | `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 `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`). +| `PATCH` | `/api/tasks/:id` | Taakstatus of implementatieplan bijwerken | +| `POST` | `/api/todos` | Todo aanmaken binnen een productcontext | ### Security-regels @@ -287,4 +291,5 @@ 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 deleted file mode 100644 index b87a767..0000000 --- a/__tests__/actions/active-sprint-action.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -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 7c8dd86..a5a9be7 100644 --- a/__tests__/actions/auth.test.ts +++ b/__tests__/actions/auth.test.ts @@ -7,7 +7,6 @@ const { sessionSaveMock, requireSessionMock, prismaUserUpdateMock, - prismaUserRoleFindFirstMock, } = vi.hoisted(() => ({ redirectMock: vi.fn((path: string) => { throw new Error(`REDIRECT:${path}`) }), verifyUserMock: vi.fn(), @@ -15,7 +14,6 @@ const { sessionSaveMock: vi.fn(), requireSessionMock: vi.fn(), prismaUserUpdateMock: vi.fn(), - prismaUserRoleFindFirstMock: vi.fn().mockResolvedValue(null), })) vi.mock('next/navigation', () => ({ redirect: redirectMock })) @@ -38,10 +36,7 @@ vi.mock('@/lib/auth', () => ({ })) vi.mock('@/lib/auth-guard', () => ({ requireSession: requireSessionMock })) vi.mock('@/lib/prisma', () => ({ - prisma: { - user: { update: prismaUserUpdateMock }, - userRole: { findFirst: prismaUserRoleFindFirstMock }, - }, + prisma: { user: { update: prismaUserUpdateMock } }, })) vi.mock('@/lib/rate-limit', () => ({ checkRateLimit: vi.fn().mockReturnValue(true) })) @@ -65,7 +60,6 @@ beforeEach(() => { sessionSaveMock.mockReset() requireSessionMock.mockReset() prismaUserUpdateMock.mockReset() - prismaUserRoleFindFirstMock.mockResolvedValue(null) }) describe('loginAction UA-redirect', () => { diff --git a/__tests__/actions/claude-jobs-batch.test.ts b/__tests__/actions/claude-jobs-batch.test.ts index 50c9be0..fc4e5e7 100644 --- a/__tests__/actions/claude-jobs-batch.test.ts +++ b/__tests__/actions/claude-jobs-batch.test.ts @@ -1,29 +1,232 @@ /** - * 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. + * 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). */ -import { describe, it, expect, vi } from 'vitest' +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), +})) vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('@/lib/auth', () => ({ getSession: vi.fn() })) -vi.mock('@/lib/prisma', () => ({ prisma: {} })) +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, + }, +})) -import { - previewEnqueueAllAction, - enqueueClaudeJobsBatchAction, -} from '@/actions/claude-jobs' +import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs' -describe('previewEnqueueAllAction (deprecated)', () => { - it('retourneert een deprecation-error', async () => { - const result = await previewEnqueueAllAction('prod-1') - expect(result).toMatchObject({ error: expect.stringContaining('vervangen') }) +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('enqueueClaudeJobsBatchAction (deprecated)', () => { - it('retourneert een deprecation-error', async () => { - const result = await enqueueClaudeJobsBatchAction('prod-1', ['t1', 't2']) - expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') }) +// ============================================================= +// 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() }) }) diff --git a/__tests__/actions/claude-jobs.test.ts b/__tests__/actions/claude-jobs.test.ts index 484f185..120124c 100644 --- a/__tests__/actions/claude-jobs.test.ts +++ b/__tests__/actions/claude-jobs.test.ts @@ -1,46 +1,47 @@ -/** - * 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, - mockUpdateManyJob, - mockUpdateManySprintTaskExecution, - mockTransaction, 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), - } -}) + 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(), +})) 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, - }, - $transaction: mockTransaction, $executeRaw: mockExecuteRaw, + $transaction: mockTransaction, }, })) @@ -48,194 +49,394 @@ import { enqueueClaudeJobAction, enqueueAllTodoJobsAction, cancelClaudeJobAction, - restartClaudeJobAction, + previewEnqueueAllAction, + enqueueClaudeJobsBatchAction, } 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 (deprecated)', () => { - it('retourneert een deprecation-error', async () => { - const result = await enqueueClaudeJobAction('task-1') - expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') }) +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('enqueueAllTodoJobsAction (deprecated)', () => { - it('retourneert een deprecation-error', async () => { - const result = await enqueueAllTodoJobsAction('prod-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('cancelClaudeJobAction', () => { - it('cancelt een actieve job', async () => { + it('happy path: cancels QUEUED job', async () => { mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue({ - id: 'job-1', - status: 'QUEUED', - task_id: 'task-1', - product_id: 'prod-1', - }) - mockUpdateJob.mockResolvedValue(undefined) + mockFindFirstJob.mockResolvedValue(MOCK_JOB_QUEUED) + mockUpdateJob.mockResolvedValue({}) - const result = await cancelClaudeJobAction('job-1') + const result = await cancelClaudeJobAction(JOB_ID) expect(result).toEqual({ success: true }) - expect(mockUpdateJob).toHaveBeenCalledWith({ - where: { id: 'job-1' }, - data: expect.objectContaining({ status: 'CANCELLED' }), - }) + expect(mockUpdateJob).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: JOB_ID }, + data: expect.objectContaining({ status: 'CANCELLED' }), + }) + ) }) - it('weigert demo-sessie', async () => { - mockGetSession.mockResolvedValue({ userId: 'demo', isDemo: true }) + it('demo user is blocked', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) - const result = await cancelClaudeJobAction('job-1') - expect(result).toMatchObject({ error: expect.stringContaining('demo') }) + const result = await cancelClaudeJobAction(JOB_ID) + + expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' }) expect(mockUpdateJob).not.toHaveBeenCalled() }) - it('retourneert error als job niet gevonden', async () => { + it('returns error when job not found (ownership check)', async () => { mockGetSession.mockResolvedValue(SESSION_USER) mockFindFirstJob.mockResolvedValue(null) - const result = await cancelClaudeJobAction('nonexistent') - expect(result).toMatchObject({ error: expect.stringContaining('niet gevonden') }) + const result = await cancelClaudeJobAction(JOB_ID) + + expect(result).toMatchObject({ error: 'Job niet gevonden' }) + expect(mockUpdateJob).not.toHaveBeenCalled() }) - it('weigert wanneer job niet meer actief is', async () => { + it('returns error when cancelling terminal (DONE) job', async () => { mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue({ - id: 'job-1', - status: 'DONE', - task_id: 'task-1', - product_id: 'prod-1', - }) + mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'DONE' as const }) - 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: 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('reset geen SprintTaskExecution-rows bij TASK_IMPLEMENTATION', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue(FAILED_JOB) - mockUpdateManyJob.mockResolvedValue({ count: 1 }) - - await restartClaudeJobAction('job-1') - - expect(mockUpdateManySprintTaskExecution).not.toHaveBeenCalled() + 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' }) }) }) diff --git a/__tests__/actions/commit-sprint-membership.test.ts b/__tests__/actions/commit-sprint-membership.test.ts deleted file mode 100644 index af80547..0000000 --- a/__tests__/actions/commit-sprint-membership.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -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 deleted file mode 100644 index 444008a..0000000 --- a/__tests__/actions/create-sprint-with-selection.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -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 index bf1ba41..525c56f 100644 --- a/__tests__/actions/ideas-crud.test.ts +++ b/__tests__/actions/ideas-crud.test.ts @@ -35,9 +35,7 @@ vi.mock('@/lib/prisma', () => ({ pbi: { findFirst: vi.fn(), findMany: vi.fn(), - findUnique: vi.fn(), create: vi.fn(), - delete: vi.fn(), }, story: { findMany: vi.fn(), @@ -46,11 +44,6 @@ vi.mock('@/lib/prisma', () => ({ 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), @@ -65,7 +58,6 @@ import { deleteIdeaAction, updateGrillMdAction, updatePlanMdAction, - uploadPlanMdAction, downloadIdeaMdAction, startGrillJobAction, startMakePlanJobAction, @@ -79,9 +71,9 @@ type MockIdea = { ideaLog: { create: ReturnType } claudeJob: { findFirst: ReturnType; create: ReturnType; update: ReturnType } claudeWorker: { count: ReturnType } - pbi: { findFirst: ReturnType; findMany: ReturnType; findUnique: ReturnType; create: ReturnType; delete: ReturnType } + pbi: { findFirst: ReturnType; findMany: ReturnType; create: ReturnType } story: { findMany: ReturnType; create: ReturnType } - task: { findMany: ReturnType; create: ReturnType; count: ReturnType } + task: { findMany: ReturnType; create: ReturnType } $transaction: ReturnType $executeRaw: ReturnType } @@ -252,97 +244,6 @@ body }) }) -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', @@ -520,7 +421,7 @@ body .mockResolvedValueOnce({ id: 't-B1' }) }) - it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids; sort_order = parseCodeNumber(code)', async () => { + it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids', async () => { const r = await materializeIdeaPlanAction('idea-1') expect(r).toMatchObject({ success: true, @@ -534,15 +435,6 @@ body 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 () => { @@ -584,69 +476,6 @@ body }) }) -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({ diff --git a/__tests__/actions/push.test.ts b/__tests__/actions/push.test.ts deleted file mode 100644 index 1e74a22..0000000 --- a/__tests__/actions/push.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -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/settings.test.ts b/__tests__/actions/settings.test.ts deleted file mode 100644 index 415b059..0000000 --- a/__tests__/actions/settings.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -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 af2474f..eaa05db 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({ set: vi.fn(), get: vi.fn(), delete: vi.fn() }) })) +vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) vi.mock('iron-session', () => ({ getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), })) @@ -16,22 +16,16 @@ 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; findMany: ReturnType; create: ReturnType; update: ReturnType } } +const mockSprint = prisma as unknown as { sprint: { findFirst: ReturnType; create: ReturnType; update: ReturnType } } function makeFormData(data: Record) { const fd = new FormData() @@ -45,7 +39,6 @@ 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 deleted file mode 100644 index f6fa3b1..0000000 --- a/__tests__/actions/sprint-draft.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -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 deleted file mode 100644 index acf4396..0000000 --- a/__tests__/actions/sprint-runs.test.ts +++ /dev/null @@ -1,407 +0,0 @@ -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 b501959..b302716 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: 'OPEN' } +const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'ACTIVE' } beforeEach(() => { vi.clearAllMocks() diff --git a/__tests__/actions/story-claim.test.ts b/__tests__/actions/story-claim.test.ts index bfcc402..6fba5e5 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: 'OPEN' } +const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'ACTIVE' } beforeEach(() => { vi.clearAllMocks() diff --git a/__tests__/actions/tasks-dialog.test.ts b/__tests__/actions/tasks-dialog.test.ts index bc3236f..877aac5 100644 --- a/__tests__/actions/tasks-dialog.test.ts +++ b/__tests__/actions/tasks-dialog.test.ts @@ -23,24 +23,6 @@ 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(), @@ -62,24 +44,6 @@ 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 @@ -190,14 +154,7 @@ describe('saveTask — edit met status-promotie', () => { implementation_plan: null, }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }]) - 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' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) const result = await saveTask( { ...VALID_INPUT, status: 'DONE' }, diff --git a/__tests__/actions/todos-promote-idea.test.ts b/__tests__/actions/todos-promote-idea.test.ts new file mode 100644 index 0000000..7ddb169 --- /dev/null +++ b/__tests__/actions/todos-promote-idea.test.ts @@ -0,0 +1,114 @@ +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-005'), +})) +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), +})) +vi.mock('@/lib/code-server', () => ({ + generateNextPbiCode: vi.fn(), + generateNextStoryCode: vi.fn(), +})) +vi.mock('@/lib/rate-limit', () => ({ + enforceUserRateLimit: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + todo: { + findFirst: vi.fn(), + update: vi.fn(), + }, + idea: { + create: vi.fn(), + }, + ideaLog: { create: vi.fn() }, + $transaction: vi.fn(), + }, +})) + +import { prisma } from '@/lib/prisma' +import { promoteTodoToIdeaAction } from '@/actions/todos' + +type M = { + todo: { findFirst: ReturnType; update: ReturnType } + idea: { create: ReturnType } + ideaLog: { create: ReturnType } + $transaction: ReturnType +} +const m = prisma as unknown as M + +beforeEach(() => { + vi.clearAllMocks() + mockSession.userId = 'user-1' + mockSession.isDemo = false + m.$transaction.mockImplementation(async (arg: unknown) => { + if (typeof arg === 'function') { + return (arg as (tx: unknown) => unknown)(m) + } + return arg + }) +}) + +describe('promoteTodoToIdeaAction', () => { + it('happy: archives todo, creates DRAFT idea, returns idea_id', async () => { + m.todo.findFirst.mockResolvedValueOnce({ + id: 'todo-1', + title: 'My idea', + description: 'desc', + product_id: null, + archived: false, + }) + m.idea.create.mockResolvedValueOnce({ id: 'idea-9', code: 'IDEA-005' }) + + const r = await promoteTodoToIdeaAction('todo-1') + expect(r).toMatchObject({ success: true, idea_id: 'idea-9', idea_code: 'IDEA-005' }) + expect(m.todo.update).toHaveBeenCalledWith({ + where: { id: 'todo-1' }, + data: { archived: true }, + }) + }) + + it('rejects unauthenticated', async () => { + mockSession.userId = '' + const r = await promoteTodoToIdeaAction('todo-1') + expect(r).toMatchObject({ code: 401 }) + }) + + it('rejects demo-user', async () => { + mockSession.isDemo = true + const r = await promoteTodoToIdeaAction('todo-1') + expect(r).toMatchObject({ code: 403 }) + }) + + it('returns 404 when todo belongs to another user', async () => { + m.todo.findFirst.mockResolvedValueOnce(null) + const r = await promoteTodoToIdeaAction('todo-1') + expect(r).toMatchObject({ code: 404 }) + }) + + it('rejects already-archived todo', async () => { + m.todo.findFirst.mockResolvedValueOnce({ + id: 'todo-1', + title: 'x', + description: null, + product_id: null, + archived: true, + }) + const r = await promoteTodoToIdeaAction('todo-1') + expect(r).toMatchObject({ code: 422 }) + expect(m.idea.create).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/actions/update-sprint.test.ts b/__tests__/actions/update-sprint.test.ts deleted file mode 100644 index f51219d..0000000 --- a/__tests__/actions/update-sprint.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -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 deleted file mode 100644 index 1fb53ad..0000000 --- a/__tests__/actions/user-settings.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -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 new file mode 100644 index 0000000..4898cda --- /dev/null +++ b/__tests__/api/backlog-realtime.test.ts @@ -0,0 +1,131 @@ +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 bd86923..188c558 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/SKIPPED ouder dan 7 dagen', async () => { + it('200 met juiste secret + deleteMany aangeroepen voor FAILED/CANCELLED 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', 'SKIPPED'] }) + expect(arg.where.status).toEqual({ in: ['FAILED', 'CANCELLED'] }) 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 deleted file mode 100644 index 5447900..0000000 --- a/__tests__/api/cross-sprint-blocks.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -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/next-story.test.ts b/__tests__/api/next-story.test.ts index fc549d8..cc5a86d 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: 'OPEN' } +const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'ACTIVE' } 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 sort_order only', async () => { + it('queries story ordered by priority then sort_order', 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: [{ sort_order: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], }) ) }) diff --git a/__tests__/api/push-send.test.ts b/__tests__/api/push-send.test.ts deleted file mode 100644 index 44bc616..0000000 --- a/__tests__/api/push-send.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 new file mode 100644 index 0000000..cff62ae --- /dev/null +++ b/__tests__/api/reorder.test.ts @@ -0,0 +1,111 @@ +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 9a1d508..4d37fdd 100644 --- a/__tests__/api/security.test.ts +++ b/__tests__/api/security.test.ts @@ -8,13 +8,10 @@ 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: { @@ -22,19 +19,6 @@ 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(), }, @@ -54,20 +38,17 @@ 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 - findUniqueOrThrow: ReturnType - update: ReturnType - } + sprint: { findFirst: ReturnType } story: { findFirst: ReturnType findUniqueOrThrow: ReturnType - findMany: ReturnType update: ReturnType } task: { @@ -75,19 +56,6 @@ 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 @@ -196,7 +164,7 @@ describe('GET /api/products/:id/next-story', () => { expect.objectContaining({ where: expect.objectContaining({ product_id: 'prod-other', - status: 'OPEN', + status: 'ACTIVE', product: expect.objectContaining({ OR: expect.arrayContaining([{ user_id: 'user-1' }]), }), @@ -275,6 +243,56 @@ 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', () => { @@ -392,14 +410,7 @@ describe('PATCH /api/tasks/:id', () => { implementation_plan: null, }) mockPrisma.task.findMany.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' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) const res = await patchTask( makePatch('http://localhost/api/tasks/task-1', { status: 'done' }), @@ -408,3 +419,46 @@ 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 deleted file mode 100644 index c526210..0000000 --- a/__tests__/api/sprint-membership-summary.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -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 c3ac8a9..c496e0d 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: 'OPEN' } +const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'ACTIVE' } function makeTask(n: number) { return { diff --git a/__tests__/api/story-log.test.ts b/__tests__/api/story-log.test.ts index 0a9b5df..2ba3025 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' }) ) - await res.json() + const data = 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 5862615..3b08da7 100644 --- a/__tests__/api/tasks.test.ts +++ b/__tests__/api/tasks.test.ts @@ -9,24 +9,6 @@ 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(), @@ -49,24 +31,6 @@ 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 @@ -111,14 +75,7 @@ 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({ - 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' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ 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) @@ -233,14 +190,7 @@ describe('PATCH /api/tasks/:id', () => { story_id: 'story-1', }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }]) - 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' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) 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 new file mode 100644 index 0000000..abded32 --- /dev/null +++ b/__tests__/api/todos.test.ts @@ -0,0 +1,109 @@ +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 deleted file mode 100644 index a9d2c1a..0000000 --- a/__tests__/app/api/jobs/job-by-id-route.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -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 27d7626..f57e53f 100644 --- a/__tests__/components/backlog/backlog-split-pane.test.tsx +++ b/__tests__/components/backlog/backlog-split-pane.test.tsx @@ -1,21 +1,9 @@ // @vitest-environment jsdom -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { render, screen } from '@testing-library/react' - -vi.mock('@/actions/user-settings', () => ({ - updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), -})) - -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import { useSelectionStore } from '@/stores/selection-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
, @@ -34,7 +22,7 @@ function renderPane() { } beforeEach(() => { - setSelection(null, null) + useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null }) // Force mobile viewport Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) window.dispatchEvent(new Event('resize')) @@ -49,7 +37,7 @@ describe('BacklogSplitPane auto-switch', () => { it('auto-switches to tab 1 when PBI is selected', () => { const { rerender } = renderPane() - setSelection('pbi-1', null) + useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: null }) rerender( { it('auto-switches to tab 2 when story is selected', () => { const { rerender } = renderPane() - setSelection('pbi-1', 'story-1') + useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' }) rerender( { it('switches to tab 1 on cascade-reset (story cleared when new PBI selected)', () => { // Start with story selected (tab 2) - setSelection('pbi-1', 'story-1') + useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' }) const { rerender } = renderPane() // Cascade-reset: new PBI → story clears - setSelection('pbi-2', null) + useSelectionStore.setState({ selectedPbiId: 'pbi-2', selectedStoryId: 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/user-settings', () => ({ - updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), -})) +vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) -// Mock dnd-kit (still needed for PBI panel which supports drag-and-drop) +// Mock dnd-kit vi.mock('@dnd-kit/core', () => ({ DndContext: ({ children }: { children: React.ReactNode }) => <>{children}, PointerSensor: class {}, @@ -65,40 +61,19 @@ const PBI_ID = 'pbi-1' const ALT_PBI_ID = 'pbi-2' const STORY_ID = 'story-1' -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 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 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() }, +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() }, ] function resetStores() { - 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 + useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null }) + useBacklogStore.setState({ + pbis: [], + storiesByPbi: { [PBI_ID]: STORIES }, + tasksByStory: { [STORY_ID]: TASKS }, }) } @@ -114,40 +89,42 @@ describe('Backlog 3-pane integration', () => { }) it('StoryPanel shows stories when PBI is selected', () => { - selectPbi(PBI_ID) + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null }) render() expect(screen.getByText('Eerste story')).toBeTruthy() }) - it('clicking a story dispatches setActiveStory to the workspace-store', () => { - selectPbi(PBI_ID) + it('clicking a story dispatches selectStory to the store', () => { + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null }) render() fireEvent.click(screen.getByText('Eerste story')) - expect(useProductWorkspaceStore.getState().context.activeStoryId).toBe(STORY_ID) + expect(useSelectionStore.getState().selectedStoryId).toBe(STORY_ID) }) - 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('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('TaskPanel shows tasks after story is selected', () => { - selectStory(PBI_ID, STORY_ID) + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) render() expect(screen.getByText('Eerste taak')).toBeTruthy() }) it('TaskPanel shows empty state after cascade-reset', () => { - selectStory(PBI_ID, STORY_ID) + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) render() - useProductWorkspaceStore.getState().setActivePbi(ALT_PBI_ID) + // Reset via selectPbi + useSelectionStore.getState().selectPbi(ALT_PBI_ID) + // Re-render reflects new store state render() expect(screen.getAllByText('Selecteer een story om de taken te bekijken.').length).toBeGreaterThan(0) }) it('selected story card has isSelected highlight class applied', () => { - selectStory(PBI_ID, STORY_ID) + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: 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 deleted file mode 100644 index 72c669e..0000000 --- a/__tests__/components/backlog/new-sprint-trigger.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// @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 fc5cf7a..97a5894 100644 --- a/__tests__/components/backlog/task-panel.test.tsx +++ b/__tests__/components/backlog/task-panel.test.tsx @@ -1,40 +1,44 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' -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 - } - }) -} +import { useSelectionStore } from '@/stores/selection-store' +import { useBacklogStore } from '@/stores/backlog-store' // 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' @@ -42,8 +46,8 @@ const STORY_ID = 'story-1' const CLOSE_PATH = `/products/${PRODUCT_ID}` const TASKS = [ - { 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() }, + { 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() }, ] function renderPanel(isDemo = false) { @@ -53,7 +57,8 @@ function renderPanel(isDemo = false) { describe('TaskPanel', () => { beforeEach(() => { mockPush.mockClear() - resetWorkspace() + useSelectionStore.setState({ selectedStoryId: null, selectedPbiId: null }) + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} }) }) it('shows empty state when no story is selected', () => { @@ -62,35 +67,40 @@ describe('TaskPanel', () => { }) it('shows empty state with action when story selected but no tasks', () => { - setActiveStoryAndTasks(STORY_ID, []) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [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', () => { - setActiveStoryAndTasks(STORY_ID, TASKS) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) renderPanel() expect(screen.getByText('Eerste taak')).toBeTruthy() expect(screen.getByText('Tweede taak')).toBeTruthy() }) it('renders status badges on task cards', () => { - setActiveStoryAndTasks(STORY_ID, TASKS) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) renderPanel() expect(screen.getByText('To Do')).toBeTruthy() expect(screen.getByText('Bezig')).toBeTruthy() }) it('task cards are rendered inside a grid container', () => { - setActiveStoryAndTasks(STORY_ID, TASKS) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) const { container } = renderPanel() const grid = container.querySelector('.grid') expect(grid).toBeTruthy() }) it('clicking + button calls router.push with newTask params', () => { - setActiveStoryAndTasks(STORY_ID, []) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) renderPanel() const buttons = screen.getAllByText('+ Nieuwe taak') fireEvent.click(buttons[0]) @@ -98,18 +108,29 @@ describe('TaskPanel', () => { }) it('clicking task card calls router.push with editTask param', () => { - setActiveStoryAndTasks(STORY_ID, TASKS) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [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', () => { - setActiveStoryAndTasks(STORY_ID, []) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [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 deleted file mode 100644 index 26aad0f..0000000 --- a/__tests__/components/dialogs/answer-modal.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -// @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 deleted file mode 100644 index 0e0a351..0000000 --- a/__tests__/components/ideas/idea-list.test.tsx +++ /dev/null @@ -1,277 +0,0 @@ -// @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 deleted file mode 100644 index 09bc3a2..0000000 --- a/__tests__/components/jobs/job-card.test.tsx +++ /dev/null @@ -1,85 +0,0 @@ -// @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 deleted file mode 100644 index 9a5d0f6..0000000 --- a/__tests__/components/jobs/job-detail-pane.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -// @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 deleted file mode 100644 index 28e9037..0000000 --- a/__tests__/components/shared/nav-bar.test.tsx +++ /dev/null @@ -1,179 +0,0 @@ -// @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 deleted file mode 100644 index 8af2df1..0000000 --- a/__tests__/components/shared/sprint-switcher.test.tsx +++ /dev/null @@ -1,174 +0,0 @@ -// @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 d47242d..392bf6e 100644 --- a/__tests__/components/solo/solo-board-batch-enqueue.test.tsx +++ b/__tests__/components/solo/solo-board-batch-enqueue.test.tsx @@ -94,9 +94,6 @@ 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 deleted file mode 100644 index f7a8493..0000000 --- a/__tests__/components/solo/solo-task-card.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -// @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 6c56a22..3b767fc 100644 --- a/__tests__/components/solo/task-detail-dialog.test.tsx +++ b/__tests__/components/solo/task-detail-dialog.test.tsx @@ -65,9 +65,6 @@ 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 40cb515..cd166c0 100644 --- a/__tests__/components/split-pane.test.tsx +++ b/__tests__/components/split-pane.test.tsx @@ -1,35 +1,28 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' - -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 }, - }, - } +// Helper to set a cookie +function setCookie(key: string, value: string) { + Object.defineProperty(document, 'cookie', { + writable: true, + configurable: true, + value: `sp:${key}=${encodeURIComponent(value)}`, }) } -function resetStore() { - useUserSettingsStore.setState((s) => { - s.entities.settings = {} - s.context.hydrated = false - s.context.isDemo = false +function clearCookies() { + Object.defineProperty(document, 'cookie', { + writable: true, + configurable: true, + value: '', }) } describe('SplitPane', () => { beforeEach(() => { - resetStore() + clearCookies() // Default: desktop viewport Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1440 }) window.dispatchEvent(new Event('resize')) @@ -71,8 +64,9 @@ describe('SplitPane', () => { expect(dividers).toHaveLength(2) }) - it('restores splits from user-settings store on mount', () => { - seedPositions('test-restore', [40, 60]) + it('restores splits from cookie on mount', () => { + const stored = JSON.stringify([40, 60]) + setCookie('test-restore', stored) const { container } = render( { expect(paneDiv).toBeTruthy() }) - 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]) + it('falls back to defaultSplit when cookie is invalid', () => { + setCookie('test-invalid', 'not-valid-json') 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 deleted file mode 100644 index 49b9817..0000000 --- a/__tests__/hooks/use-jobs-realtime.test.tsx +++ /dev/null @@ -1,147 +0,0 @@ -// @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 deleted file mode 100644 index b2de7ef..0000000 --- a/__tests__/lib/active-sprint.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -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 ebfa9a5..b162921 100644 --- a/__tests__/lib/auth-guard.test.ts +++ b/__tests__/lib/auth-guard.test.ts @@ -3,14 +3,10 @@ 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 dc316bd..b8d0be2 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', 'skipped', + 'queued', 'claimed', 'running', 'done', 'failed', 'cancelled', ] 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 deleted file mode 100644 index 7b83640..0000000 --- a/__tests__/lib/code.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 12a1e33..0000000 --- a/__tests__/lib/debug.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -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-plan-parser.test.ts b/__tests__/lib/idea-plan-parser.test.ts index c279ea8..30169aa 100644 --- a/__tests__/lib/idea-plan-parser.test.ts +++ b/__tests__/lib/idea-plan-parser.test.ts @@ -62,41 +62,6 @@ body } }) - 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: diff --git a/__tests__/lib/idea-schemas.test.ts b/__tests__/lib/idea-schemas.test.ts index 637ce1c..1514f5d 100644 --- a/__tests__/lib/idea-schemas.test.ts +++ b/__tests__/lib/idea-schemas.test.ts @@ -128,21 +128,4 @@ describe('ideaPlanMdFrontmatterSchema', () => { }) 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 index b72692c..0dfc3dc 100644 --- a/__tests__/lib/idea-status.test.ts +++ b/__tests__/lib/idea-status.test.ts @@ -41,7 +41,6 @@ describe('canTransition', () => { 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', () => { @@ -54,20 +53,12 @@ describe('canTransition', () => { expect(canTransition('PLAN_FAILED', 'GRILLED')).toBe(true) }) - it('allows PLANNED → PLAN_READY (relink) and PLANNED → GRILLING (re-grill)', () => { + it('only allows PLANNED → PLAN_READY (relink path)', () => { expect(canTransition('PLANNED', 'PLAN_READY')).toBe(true) - expect(canTransition('PLANNED', 'GRILLING')).toBe(true) + expect(canTransition('PLANNED', 'GRILLING')).toBe(false) 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) diff --git a/__tests__/lib/insights/agent-throughput.test.ts b/__tests__/lib/insights/agent-throughput.test.ts index 31bf46d..3465dd4 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 + d.skipped > 0, + d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled > 0, ) expect(nonZero).toHaveLength(3) diff --git a/__tests__/lib/insights/token-history.test.ts b/__tests__/lib/insights/token-history.test.ts deleted file mode 100644 index 39439b8..0000000 --- a/__tests__/lib/insights/token-history.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index 8614292..0000000 --- a/__tests__/lib/insights/token-stats.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index 16b90b5..0000000 --- a/__tests__/lib/job-config.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -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 dee082e..db8d1ab 100644 --- a/__tests__/lib/job-status.test.ts +++ b/__tests__/lib/job-status.test.ts @@ -27,14 +27,13 @@ describe('job-status mappers', () => { expect(jobStatusFromApi('QUEUED')).toBe('QUEUED') }) - it('maps all 7 DB statuses to API', () => { + it('maps all 6 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 deleted file mode 100644 index 3e1be4b..0000000 --- a/__tests__/lib/jobs-time-filter.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index 02983e9..0000000 --- a/__tests__/lib/product-switch-path.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 761b6e1..0000000 --- a/__tests__/lib/push-client.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 87af039..0000000 --- a/__tests__/lib/push-server.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -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/sprint-conflicts.test.ts b/__tests__/lib/sprint-conflicts.test.ts deleted file mode 100644 index bf6edbe..0000000 --- a/__tests__/lib/sprint-conflicts.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -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 9f08a85..870b632 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 alle vier API values', () => { - expect(PBI_STATUS_API_VALUES).toEqual(['ready', 'blocked', 'failed', 'done']) + it('exposes exactly three API values', () => { + expect(PBI_STATUS_API_VALUES).toEqual(['ready', 'blocked', 'done']) }) }) }) diff --git a/__tests__/lib/tasks-status-update.test.ts b/__tests__/lib/tasks-status-update.test.ts index ccaa2f6..418caa7 100644 --- a/__tests__/lib/tasks-status-update.test.ts +++ b/__tests__/lib/tasks-status-update.test.ts @@ -8,23 +8,6 @@ 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(), @@ -32,35 +15,27 @@ vi.mock('@/lib/prisma', () => ({ })) import { prisma } from '@/lib/prisma' -import { propagateStatusUpwards } from '@/lib/tasks-status-update' +import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' -type MockedPrisma = { - task: { update: ReturnType; findMany: ReturnType } +const mockPrisma = prisma as unknown as { + 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 } -const mockPrisma = prisma as unknown as MockedPrisma +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 TASK_BASE = { id: 'task-1', @@ -69,267 +44,110 @@ const TASK_BASE = { implementation_plan: null, } -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 () => { +describe('updateTaskStatusWithStoryPromotion', () => { + it('promotes story to DONE when last sibling task transitions to DONE', async () => { mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) mockPrisma.task.findMany.mockResolvedValue([ { status: 'DONE' }, { 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: 'READY' }) - mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) - const result = await propagateStatusUpwards('task-1', 'DONE') + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') - expect(result.storyChanged).toBe(true) + expect(result.storyStatusChange).toBe('promoted') + expect(result.storyId).toBe('story-1') expect(mockPrisma.story.update).toHaveBeenCalledWith({ where: { id: 'story-1' }, data: { 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 propagateStatusUpwards('task-1', 'FAILED') - - expect(result.storyChanged).toBe(true) - expect(mockPrisma.story.update).toHaveBeenCalledWith({ - where: { id: 'story-1' }, - data: { status: 'FAILED' }, - }) - }) - - it('houdt story op IN_SPRINT als nog niet alle tasks DONE en geen FAILED', async () => { + 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' }, - { status: 'TO_DO' }, - ]) - 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' }]) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) - const result = await propagateStatusUpwards('task-1', 'DONE') + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') - expect(result.storyChanged).toBe(false) + expect(result.storyStatusChange).toBe(null) expect(mockPrisma.story.update).not.toHaveBeenCalled() }) - it('demoot story uit DONE als een task terug naar TO_DO gaat', async () => { - mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'TO_DO' }) + it('does not promote when not all siblings are DONE', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) mockPrisma.task.findMany.mockResolvedValue([ - { status: 'TO_DO' }, + { status: 'DONE' }, + { status: 'IN_PROGRESS' }, + ]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + + expect(result.storyStatusChange).toBe(null) + 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' }) + mockPrisma.task.findMany.mockResolvedValue([ + { status: 'IN_PROGRESS' }, { 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' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) - const result = await propagateStatusUpwards('task-1', 'TO_DO') + const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') - expect(result.storyChanged).toBe(true) + expect(result.storyStatusChange).toBe('demoted') expect(mockPrisma.story.update).toHaveBeenCalledWith({ where: { id: 'story-1' }, data: { status: 'IN_SPRINT' }, }) }) - it('zet story op OPEN als sprint_id null is en niet DONE/FAILED', async () => { + it('does not demote when story is not DONE', async () => { mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) - 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' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) - const result = await propagateStatusUpwards('task-1', 'IN_PROGRESS') + const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') - expect(result.storyChanged).toBe(true) - expect(mockPrisma.story.update).toHaveBeenCalledWith({ - where: { id: 'story-1' }, - 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' }), - })) + expect(result.storyStatusChange).toBe(null) + expect(mockPrisma.story.update).not.toHaveBeenCalled() }) - 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' }) + 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' }) - const result = await propagateStatusUpwards('task-1', 'DONE') + await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') - 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' }), - })) + expect(mockPrisma.task.update).toHaveBeenCalledWith({ + where: { id: 'task-1' }, + data: { status: 'IN_PROGRESS' }, + select: expect.any(Object), + }) }) -}) -describe('propagateStatusUpwards — transactionele aanroep', () => { - it('gebruikt de meegegeven transaction client', async () => { + it('uses the provided transaction client when passed', 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() }, + story: { findUniqueOrThrow: 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' }]) + tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + tx.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + tx.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await propagateStatusUpwards('task-1', 'IN_PROGRESS', tx as any) + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE', tx as any) - expect(result.storyChanged).toBe(false) - // $transaction wordt niet aangeroepen wanneer caller al een tx meegeeft. + 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({ + where: { id: 'story-1' }, + data: { status: 'DONE' }, + }) }) }) diff --git a/__tests__/lib/user-settings-migration.test.ts b/__tests__/lib/user-settings-migration.test.ts deleted file mode 100644 index 38346b4..0000000 --- a/__tests__/lib/user-settings-migration.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -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 deleted file mode 100644 index 2e694d7..0000000 --- a/__tests__/lib/user-settings.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -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__/realtime/payload-contract.test.ts b/__tests__/realtime/payload-contract.test.ts new file mode 100644 index 0000000..b36bc09 --- /dev/null +++ b/__tests__/realtime/payload-contract.test.ts @@ -0,0 +1,160 @@ +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 deleted file mode 100644 index cbc50a5..0000000 --- a/__tests__/realtime/use-workspace-resync.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -// @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 deleted file mode 100644 index 2b298dc..0000000 --- a/__tests__/review-plan-job.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -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/product-workspace/restore.test.ts b/__tests__/stores/product-workspace/restore.test.ts deleted file mode 100644 index baa8120..0000000 --- a/__tests__/stores/product-workspace/restore.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index 7463fff..0000000 --- a/__tests__/stores/product-workspace/screen-state.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index 6f271de..0000000 --- a/__tests__/stores/product-workspace/sprint-membership.test.ts +++ /dev/null @@ -1,341 +0,0 @@ -// @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 deleted file mode 100644 index ff86cfc..0000000 --- a/__tests__/stores/product-workspace/store.test.ts +++ /dev/null @@ -1,890 +0,0 @@ -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 2047a77..f61a7f8 100644 --- a/__tests__/stores/solo-store-realtime.test.ts +++ b/__tests__/stores/solo-store-realtime.test.ts @@ -17,9 +17,6 @@ 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 deleted file mode 100644 index b31000d..0000000 --- a/__tests__/stores/solo-workspace/store.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -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 deleted file mode 100644 index 66c626f..0000000 --- a/__tests__/stores/sprint-workspace/restore.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -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 deleted file mode 100644 index 5fa0502..0000000 --- a/__tests__/stores/sprint-workspace/store.test.ts +++ /dev/null @@ -1,875 +0,0 @@ -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 deleted file mode 100644 index e159bf8..0000000 --- a/__tests__/stores/user-settings.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -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 deleted file mode 100644 index e774376..0000000 --- a/actions/active-sprint.ts +++ /dev/null @@ -1,164 +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 { - 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 index e6a81e0..9c9ac14 100644 --- a/actions/admin/jobs.ts +++ b/actions/admin/jobs.ts @@ -19,7 +19,7 @@ export async function cancelJobAction(jobId: string) { }) if (!job) throw new Error('Job niet gevonden') - if (job.status === 'DONE' || job.status === 'FAILED' || job.status === 'CANCELLED' || job.status === 'SKIPPED') { + if (job.status === 'DONE' || job.status === 'FAILED' || job.status === 'CANCELLED') { throw new Error('Job is al in eindstatus') } diff --git a/actions/auth.ts b/actions/auth.ts index a08c502..d40d188 100644 --- a/actions/auth.ts +++ b/actions/auth.ts @@ -47,7 +47,6 @@ 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') @@ -73,13 +72,9 @@ 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. diff --git a/actions/claude-jobs.ts b/actions/claude-jobs.ts index 258fd1a..f39c8ff 100644 --- a/actions/claude-jobs.ts +++ b/actions/claude-jobs.ts @@ -1,10 +1,11 @@ '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' +import { enforceUserRateLimit } from '@/lib/rate-limit' type EnqueueResult = | { success: true; jobId: string } @@ -16,9 +17,6 @@ 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 @@ -32,49 +30,273 @@ type PreflightResult = | { error: string } | { tasks: PreviewTask[]; blockerIndex: number | null; blockerReason: 'task-review' | 'pbi-blocked' | null } -/** - * @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.', +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' } + + const limited = enforceUserRateLimit('enqueue-job', session.userId) + if (limited) return { error: limited.error } + + 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 } } + + 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 } } -/** - * @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.', +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) + ` } + + revalidatePath(`/products/${productId}/solo`) + return { success: true, count: created.length } } -/** - * @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.', +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 + } } + + 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 { - return { - error: - 'Batch-queue per task is vervangen door "Start Sprint". Gebruik startSprintRunAction.', + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const limited = enforceUserRateLimit('enqueue-job', session.userId) + if (limited) return { error: limited.error } + + 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' } } + + 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 { @@ -113,76 +335,3 @@ 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 index 63bae6d..1ae5e47 100644 --- a/actions/ideas.ts +++ b/actions/ideas.ts @@ -10,10 +10,7 @@ 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' @@ -21,7 +18,6 @@ import { canTransition, isGrillMdEditable, isIdeaEditable, isPlanMdEditable } fr 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' @@ -169,63 +165,6 @@ export async function deleteIdeaAction(id: string): Promise { 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) @@ -312,73 +251,6 @@ export async function updatePlanMdAction( 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. @@ -407,9 +279,8 @@ export async function downloadIdeaMdAction( // --------------------------------------------------------------------------- // Job-triggers (Grill Me / Make Plan / Cancel) -const GRILL_TRIGGERABLE_FROM: IdeaStatus[] = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLAN_READY', 'PLANNED'] +const GRILL_TRIGGERABLE_FROM: IdeaStatus[] = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLAN_READY'] 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) @@ -419,10 +290,6 @@ export async function startMakePlanJobAction(id: string): Promise> { - return startIdeaJob(id, 'IDEA_REVIEW_PLAN', 'REVIEWING_PLAN', REVIEW_PLAN_TRIGGERABLE_FROM) -} - async function startIdeaJob( id: string, kind: ClaudeJobKind, @@ -487,8 +354,6 @@ async function startIdeaJob( } } - 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({ @@ -498,7 +363,6 @@ async function startIdeaJob( idea_id: id, kind, status: 'QUEUED', - ...ideaSnapshot, }, select: { id: true }, }) @@ -553,15 +417,12 @@ export async function cancelIdeaJobAction(id: string): Promise { // 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). + // plan_md was (re-plan-cancel), anders GRILLED. 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 } } @@ -620,7 +481,6 @@ function nextNumber(existing: (string | null)[], re: RegExp): number { export async function materializeIdeaPlanAction( id: string, - options?: { allowAlongside?: boolean }, ): Promise> { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd', code: 401 } @@ -631,7 +491,7 @@ export async function materializeIdeaPlanAction( 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 }, + select: { id: true, status: true, product_id: true, plan_md: true }, }) if (!idea) return { error: 'Idee niet gevonden', code: 404 } if (idea.status !== 'PLAN_READY') { @@ -655,36 +515,8 @@ export async function materializeIdeaPlanAction( 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([ @@ -721,17 +553,16 @@ export async function materializeIdeaPlanAction( 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, + code: `ST-${String(nextStoryN++).padStart(3, '0')}`, title: s.title, description: s.description ?? null, acceptance_criteria: s.acceptance_criteria ?? null, priority: s.priority, - sort_order: parseCodeNumber(storyCode), + sort_order: si + 1, // sequential within PBI status: 'OPEN', }, select: { id: true }, @@ -740,21 +571,16 @@ export async function materializeIdeaPlanAction( 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, + code: `T-${nextTaskN++}`, 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), + priority: t.priority, + sort_order: ti + 1, status: 'TO_DO', verify_required: t.verify_required ?? 'ALIGNED_OR_PARTIAL', verify_only: t.verify_only ?? false, diff --git a/actions/jobs-page.ts b/actions/jobs-page.ts deleted file mode 100644 index 22876a5..0000000 --- a/actions/jobs-page.ts +++ /dev/null @@ -1,35 +0,0 @@ -'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/products.ts b/actions/products.ts index 4292269..9a0856b 100644 --- a/actions/products.ts +++ b/actions/products.ts @@ -396,27 +396,3 @@ export async function updateAutoPrAction(id: string, auto_pr: boolean) { revalidatePath(`/products/${id}/settings`) return { success: true } } - -export async function updatePrStrategyAction( - id: string, - pr_strategy: 'SPRINT' | 'STORY' | 'SPRINT_BATCH', -) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const parsed = z - .object({ pr_strategy: z.enum(['SPRINT', 'STORY', 'SPRINT_BATCH']) }) - .safeParse({ pr_strategy }) - if (!parsed.success) return { error: 'Ongeldige waarde voor pr_strategy' } - - const product = await prisma.product.findFirst({ where: { id, user_id: session.userId } }) - if (!product) return { error: 'Product niet gevonden' } - - await prisma.product.update({ - where: { id }, - data: { pr_strategy: parsed.data.pr_strategy }, - }) - revalidatePath(`/products/${id}/settings`) - return { success: true } -} diff --git a/actions/push.ts b/actions/push.ts deleted file mode 100644 index ec9a216..0000000 --- a/actions/push.ts +++ /dev/null @@ -1,52 +0,0 @@ -'use server' - -import { z } from 'zod' -import { prisma } from '@/lib/prisma' -import { getSession } from '@/lib/auth' - -const subscribeSchema = z.object({ - endpoint: z.string().url(), - keys: z.object({ - p256dh: z.string().min(1), - auth: z.string().min(1), - }), - userAgent: z.string().optional(), -}) - -export type SubscribeToPushInput = z.infer - -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/settings.ts b/actions/settings.ts deleted file mode 100644 index 17b8a8e..0000000 --- a/actions/settings.ts +++ /dev/null @@ -1,49 +0,0 @@ -'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 deleted file mode 100644 index 37beb54..0000000 --- a/actions/sprint-draft.ts +++ /dev/null @@ -1,121 +0,0 @@ -'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 deleted file mode 100644 index 8b232d0..0000000 --- a/actions/sprint-runs.ts +++ /dev/null @@ -1,494 +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 '@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 8ccc80e..bf7dac3 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -12,360 +12,6 @@ import { 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) @@ -390,7 +36,6 @@ export async function createSprintAction(_prevState: unknown, formData: FormData 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 { @@ -403,57 +48,22 @@ 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 } - // 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 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 } - 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, - }, - }), - ) + 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, + }, + }) - 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') + revalidatePath(`/products/${parsed.data.productId}`) return { success: true, sprintId: sprint.id } } @@ -531,9 +141,14 @@ 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' }, + data: { sprint_id: sprintId, status: 'IN_SPRINT', sort_order: (last?.sort_order ?? 0) + 1.0 }, }) revalidatePath(`/products/${sprint.product_id}/sprint`) @@ -562,6 +177,32 @@ 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, @@ -623,7 +264,7 @@ export async function completeSprintAction( ), prisma.sprint.update({ where: { id: sprintId }, - data: { status: 'CLOSED', completed_at: new Date() }, + data: { status: 'COMPLETED', completed_at: new Date() }, }), ]) @@ -631,104 +272,3 @@ 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 dbac04a..b66ec01 100644 --- a/actions/stories.ts +++ b/actions/stories.ts @@ -7,7 +7,7 @@ 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, parseCodeNumber } from '@/lib/code' +import { isValidCode, normalizeCode } from '@/lib/code' import { createWithCodeRetry, generateNextStoryCode } from '@/lib/code-server' import { createStorySchema, updateStorySchema } from '@/lib/schemas/story' import { enforceUserRateLimit } from '@/lib/rate-limit' @@ -78,6 +78,12 @@ 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: { @@ -88,7 +94,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: parseCodeNumber(code), + sort_order, status: 'OPEN', }, }) @@ -161,7 +167,7 @@ export async function updateStoryAction(_prevState: unknown, formData: FormData) await prisma.story.update({ where: { id: parsed.data.id }, data: { - ...(code ? { code, sort_order: parseCodeNumber(code) } : {}), + ...(code ? { code } : {}), title: parsed.data.title, description: parsed.data.description ?? null, acceptance_criteria: parsed.data.acceptance_criteria ?? null, @@ -343,7 +349,7 @@ export async function claimAllUnassignedInActiveSprintAction(productId: string) const userId = session.userId const sprint = await prisma.sprint.findFirst({ - where: { product_id: productId, status: 'OPEN' }, + where: { product_id: productId, status: 'ACTIVE' }, }) if (!sprint) return { error: 'Geen actieve sprint gevonden' } @@ -357,3 +363,43 @@ 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 1a4ef45..c0210a6 100644 --- a/actions/tasks.ts +++ b/actions/tasks.ts @@ -9,8 +9,8 @@ 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 { propagateStatusUpwards } from '@/lib/tasks-status-update' -import { normalizeCode, parseCodeNumber } from '@/lib/code' +import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' +import { normalizeCode } from '@/lib/code' import { createWithCodeRetry, generateNextTaskCode, isCodeUniqueConflict } from '@/lib/code-server' import { enforceUserRateLimit } from '@/lib/rate-limit' @@ -80,13 +80,12 @@ 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 propagateStatusUpwards(taskId, status, tx) + const result = await updateTaskStatusWithStoryPromotion(taskId, status, tx) return { id: result.task.id, title: result.task.title, status: result.task.status } } return updated @@ -107,8 +106,15 @@ 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( @@ -124,7 +130,7 @@ export async function saveTask( description: description ?? null, implementation_plan: implementation_plan ?? null, priority, - sort_order: parseCodeNumber(code), + sort_order: sortOrder, status: 'TO_DO', }, select: { id: true, title: true, status: true }, @@ -201,6 +207,11 @@ 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 +225,7 @@ export async function createTaskAction(_prevState: unknown, formData: FormData) title: parsed.data.title, description: parsed.data.description ?? null, priority: parsed.data.priority, - sort_order: parseCodeNumber(code), + sort_order: (last?.sort_order ?? 0) + 1.0, status: 'TO_DO', }, }), @@ -263,7 +274,7 @@ export async function updateTaskStatusAction(id: string, status: 'TO_DO' | 'IN_P }) if (!task) return { error: 'Taak niet gevonden' } - await propagateStatusUpwards(id, status) + await updateTaskStatusWithStoryPromotion(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,3 +333,22 @@ 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 new file mode 100644 index 0000000..02e4864 --- /dev/null +++ b/actions/todos.ts @@ -0,0 +1,316 @@ +'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' +import { enforceUserRateLimit } from '@/lib/rate-limit' + +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 limited = enforceUserRateLimit('create-todo', session.userId) + if (limited) return limited + + 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' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + 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' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + 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 } +} + +// M12: promote a Todo into a DRAFT Idea. Anders dan Todo→PBI/Story (die de +// todo deleteert) ARCHIVEREN we de todo hier — het idee houdt zelf de +// planningsgeschiedenis bij, en de archived todo bewaart het oorspronkelijke +// vertrekpunt. +export async function promoteTodoToIdeaAction(todoId: string): Promise< + { success: true; idea_id: string; idea_code: string } | { error: string; code?: number } +> { + 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 } + + if (!todoId) return { error: 'todoId is verplicht', code: 422 } + + const todo = await prisma.todo.findFirst({ + where: { id: todoId, user_id: session.userId }, + select: { id: true, title: true, description: true, product_id: true, archived: true }, + }) + if (!todo) return { error: 'Todo niet gevonden', code: 404 } + if (todo.archived) return { error: 'Todo is al gearchiveerd', code: 422 } + + const userId = session.userId + // Lazy-import om dit server-only bestand niet te dwingen in een client bundle. + const { nextIdeaCode } = await import('@/lib/idea-code-server') + + const idea = await prisma.$transaction(async (tx) => { + const code = await nextIdeaCode(userId, tx) + const created = await tx.idea.create({ + data: { + user_id: userId, + product_id: todo.product_id, + code, + title: todo.title, + description: todo.description ?? null, + status: 'DRAFT', + }, + select: { id: true, code: true }, + }) + await tx.todo.update({ where: { id: todoId }, data: { archived: true } }) + await tx.ideaLog.create({ + data: { + idea_id: created.id, + type: 'NOTE', + content: `Promoted from Todo ${todoId}`, + metadata: { source_todo_id: todoId }, + }, + }) + return created + }) + + revalidatePath('/ideas') + revalidatePath('/todos') + return { success: true, idea_id: idea.id, idea_code: idea.code } +} + +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 index d43c6d7..3a076fa 100644 --- a/actions/user-questions.ts +++ b/actions/user-questions.ts @@ -9,7 +9,6 @@ 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) @@ -57,8 +56,6 @@ export async function createUserQuestionAction( }) 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: { @@ -74,7 +71,6 @@ export async function createUserQuestionAction( idea_id: parsed.data.ideaId, kind: 'PLAN_CHAT', status: 'QUEUED', - ...snapshot, }, }), ]) diff --git a/actions/user-settings.ts b/actions/user-settings.ts deleted file mode 100644 index e3a9cbb..0000000 --- a/actions/user-settings.ts +++ /dev/null @@ -1,62 +0,0 @@ -'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 deleted file mode 100644 index 8a3ba85..0000000 --- a/app/(app)/admin/jobs/page.tsx +++ /dev/null @@ -1,54 +0,0 @@ -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/products/page.tsx b/app/(app)/admin/products/page.tsx deleted file mode 100644 index 11081d3..0000000 --- a/app/(app)/admin/products/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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)/dashboard/loading.tsx b/app/(app)/dashboard/loading.tsx index 40800ab..656e323 100644 --- a/app/(app)/dashboard/loading.tsx +++ b/app/(app)/dashboard/loading.tsx @@ -1,12 +1,10 @@ -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 index 80d946c..0d25fb7 100644 --- a/app/(app)/ideas/[id]/page.tsx +++ b/app/(app)/ideas/[id]/page.tsx @@ -7,8 +7,6 @@ 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' @@ -27,25 +25,9 @@ export default async function IdeaDetailPage({ params, searchParams }: PageProps // 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, + include: { 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() @@ -87,27 +69,11 @@ export default async function IdeaDetailPage({ params, searchParams }: PageProps }, }) - 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, @@ -125,16 +91,8 @@ export default async function IdeaDetailPage({ params, searchParams }: PageProps 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 deleted file mode 100644 index ae93465..0000000 --- a/app/(app)/ideas/[id]/sync-tab-server.ts +++ /dev/null @@ -1,85 +0,0 @@ -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 index 1c4fd5e..142e376 100644 --- a/app/(app)/ideas/page.tsx +++ b/app/(app)/ideas/page.tsx @@ -16,10 +16,7 @@ export default async function IdeasPage() { 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 } } } }, - }, + include: { product: { select: { id: true, name: true, repo_url: true } } }, take: 200, }) @@ -32,16 +29,6 @@ export default async function IdeasPage() { 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 (
@@ -55,7 +42,6 @@ export default async function IdeasPage() { ideas={ideas.map((i) => 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 a43dd96..820e64f 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', 'skipped'] as const +const STACKED_STATUSES = ['queued', 'claimed', 'running', 'done', 'failed', 'cancelled'] 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 + d.skipped === 0, + d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled === 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 1718188..45375d1 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; sprintCode: string; sprintGoal: string } + payload?: { total: number; alignedRatio: number; sprintGoal: string } } function CustomTooltip({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) { @@ -25,10 +25,7 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Toolti const aligned = Math.round((d.alignedRatio / 100) * d.total) return (
-

- {d.sprintCode} - {d.sprintGoal} -

+

{d.sprintGoal}

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

@@ -36,6 +33,10 @@ 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 ( @@ -47,7 +48,7 @@ export function AlignmentTrend({ trend }: Props) { const data = trend.map(p => ({ ...p, - label: p.sprintCode, + label: sprintLabel(p.sprintGoal), })) return ( diff --git a/app/(app)/insights/components/cost-analysis.tsx b/app/(app)/insights/components/cost-analysis.tsx deleted file mode 100644 index f147ae3..0000000 --- a/app/(app)/insights/components/cost-analysis.tsx +++ /dev/null @@ -1,272 +0,0 @@ -'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 ed3c15b..3d85a33 100644 --- a/app/(app)/insights/components/sprint-info-strip.tsx +++ b/app/(app)/insights/components/sprint-info-strip.tsx @@ -2,7 +2,6 @@ interface SprintInfo { sprintId: string - sprintCode: string productName: string sprintGoal: string taskCount: number @@ -34,7 +33,6 @@ 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 deleted file mode 100644 index 4bb4f90..0000000 --- a/app/(app)/insights/components/token-usage.tsx +++ /dev/null @@ -1,109 +0,0 @@ -'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 a05df7f..7cd2d9e 100644 --- a/app/(app)/insights/components/velocity-chart.tsx +++ b/app/(app)/insights/components/velocity-chart.tsx @@ -35,9 +35,11 @@ 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: s.sprintCode }) + grouped.set(key, { sprintLabel: label }) } grouped.get(key)![s.productName] = s.doneCount } diff --git a/app/(app)/insights/page.tsx b/app/(app)/insights/page.tsx index 802258c..77164d5 100644 --- a/app/(app)/insights/page.tsx +++ b/app/(app)/insights/page.tsx @@ -7,15 +7,6 @@ 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' @@ -24,8 +15,6 @@ 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' @@ -33,13 +22,7 @@ const DAY_MS = 86_400_000 const ASSUMED_SPRINT_DAYS = 14 interface InsightsPageProps { - 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' + searchParams: Promise<{ product?: string }> } function MissingDatesNotice({ productId, productName }: { productId: string; productName: string }) { @@ -56,8 +39,7 @@ 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, period: rawPeriod } = await searchParams - const period = parsePeriod(rawPeriod) + const { product: filterProductId } = await searchParams const [ burndownSprints, @@ -69,19 +51,13 @@ 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: 'OPEN', product: productAccessFilter(userId) }, + where: { status: 'ACTIVE', product: productAccessFilter(userId) }, select: { id: true, - code: true, sprint_goal: true, created_at: true, product: { select: { id: true, name: true } }, @@ -98,24 +74,13 @@ 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, @@ -160,19 +125,6 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps) )} - {/* Cost analyse */} -
-

Cost analyse

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

Plan-quality

@@ -190,12 +142,6 @@ 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 deleted file mode 100644 index 25c571b..0000000 --- a/app/(app)/jobs/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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 15eab5a..8f55e55 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -2,14 +2,11 @@ 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' @@ -19,7 +16,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, min_quota_pct: true, settings: true }, + select: { username: true, email: true, active_product_id: true }, }), prisma.userRole.findMany({ where: { user_id: session.userId }, @@ -47,8 +44,11 @@ export default async function AppLayout({ children }: { children: React.ReactNod }) if (product) { activeProduct = product - const resolved = await resolveActiveSprint(product.id, session.userId) - hasActiveSprint = !!resolved + const sprint = await prisma.sprint.findFirst({ + where: { product_id: product.id, status: 'ACTIVE' }, + select: { id: true }, + }) + hasActiveSprint = !!sprint } else { await prisma.user.update({ where: { id: session.userId }, @@ -72,7 +72,6 @@ export default async function AppLayout({ children }: { children: React.ReactNod activeProduct={activeProduct} products={accessibleProducts} hasActiveSprint={hasActiveSprint} - minQuotaPct={user.min_quota_pct} />
@@ -81,10 +80,6 @@ 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 deleted file mode 100644 index ccc330b..0000000 --- a/app/(app)/manual/[[...slug]]/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 9643ed3..0000000 --- a/app/(app)/manual/_components/manual-sidebar.tsx +++ /dev/null @@ -1,51 +0,0 @@ -'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 deleted file mode 100644 index 421477f..0000000 --- a/app/(app)/manual/_components/markdown-view.tsx +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 66e52db..0000000 --- a/app/(app)/manual/_components/mermaid-block.tsx +++ /dev/null @@ -1,73 +0,0 @@ -'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 deleted file mode 100644 index 06ebc7e..0000000 --- a/app/(app)/manual/layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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 8dcb9c1..795b2c5 100644 --- a/app/(app)/products/[id]/loading.tsx +++ b/app/(app)/products/[id]/loading.tsx @@ -1 +1,34 @@ -export { default } from '@/components/loading/backlog-page-skeleton' +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]/page.tsx b/app/(app)/products/[id]/page.tsx index 161b4dc..d635e16 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -4,25 +4,18 @@ 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 { 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 { StartSprintButton } from '@/components/sprint/start-sprint-button' 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 { @@ -40,13 +33,10 @@ export default async function ProductBacklogPage({ params, searchParams }: Props const product = await getAccessibleProduct(id, session.userId) if (!product) notFound() - const [user, switcherData] = await Promise.all([ + const [activeSprint, user] = await Promise.all([ + prisma.sprint.findFirst({ where: { product_id: id, status: 'ACTIVE' } }), 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 }, @@ -56,7 +46,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props const [stories, tasks] = await Promise.all([ prisma.story.findMany({ where: { product_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], select: { id: true, code: true, @@ -64,10 +54,8 @@ 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, }, }), @@ -75,7 +63,6 @@ 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, @@ -84,11 +71,11 @@ export default async function ProductBacklogPage({ params, searchParams }: Props story_id: true, created_at: true, }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], }), ]) - // Group stories by PBI id (status uit DB blijft UPPER_SNAKE in dit hydratie-pad) + // Group stories by PBI id const storiesByPbi: Record = {} for (const story of stories) { if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = [] @@ -106,37 +93,18 @@ export default async function ProductBacklogPage({ params, searchParams }: Props return (
- {/* Product header — sprint-switcher gecentreerd, actions rechts */} -
-
-
- {isActiveProduct && ( - - )} -
-
- {!isActiveProduct && ( + {/* Product header — actions only; product-naam zit al in NavBar */} +
+
+ {user?.active_product_id !== id && ( )} - {hasOpenSprint && ( + {activeSprint ? ( Sprint actief → - )} - {activeSprintItem && !isDemo && ( - - )} - {!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, sort_order: p.sort_order, 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, 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 b19ec4a..868e579 100644 --- a/app/(app)/products/[id]/solo/page.tsx +++ b/app/(app)/products/[id]/solo/page.tsx @@ -1,12 +1,11 @@ import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' -import { getSprintSwitcherData } from '@/lib/sprint-switcher-data' -import { getSoloWorkspaceSnapshot } from '@/lib/solo-workspace-server' +import { prisma } from '@/lib/prisma' import { SoloBoard } from '@/components/solo/solo-board' -import { SoloHydrationWrapper } from '@/components/solo/solo-hydration-wrapper' import { NoActiveSprint } from '@/components/solo/no-active-sprint' -import { SprintSwitcher } from '@/components/shared/sprint-switcher' +import type { SoloTask } from '@/components/solo/solo-board' +import type { UnassignedStory } from '@/components/solo/unassigned-stories-sheet' interface Props { params: Promise<{ id: string }> @@ -20,45 +19,97 @@ export default async function SoloProductPage({ params }: Props) { const product = await getAccessibleProduct(id, session.userId) if (!product) notFound() - const initialData = await getSoloWorkspaceSnapshot(id, session.userId) - const switcherData = await getSprintSwitcherData(id, { - activeSprintId: initialData?.sprint.id ?? null, + const sprint = await prisma.sprint.findFirst({ + where: { product_id: id, status: 'ACTIVE' }, }) - const switcherBar = ( -
- -
- ) - - if (!initialData) { + if (!sprint) { 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 deleted file mode 100644 index 8dcb9c1..0000000 --- a/app/(app)/products/[id]/sprint/[sprintId]/loading.tsx +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 2981c47..0000000 --- a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx +++ /dev/null @@ -1,245 +0,0 @@ -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 deleted file mode 100644 index 8dcb9c1..0000000 --- a/app/(app)/products/[id]/sprint/[sprintId]/planning/loading.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/components/loading/backlog-page-skeleton' diff --git a/app/(app)/products/[id]/sprint/loading.tsx b/app/(app)/products/[id]/sprint/loading.tsx new file mode 100644 index 0000000..795b2c5 --- /dev/null +++ b/app/(app)/products/[id]/sprint/loading.tsx @@ -0,0 +1,34 @@ +export default function Loading() { + return ( +
+ {/* Header skeleton */} +
+
+
+
+
+
+
+ + {/* Split pane skeleton */} +
+ {/* Left */} +
+
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
+ {/* Right */} +
+
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
+
+
+ ) +} diff --git a/app/(app)/products/[id]/sprint/page.tsx b/app/(app)/products/[id]/sprint/page.tsx index 5f0e6ab..3e3a36c 100644 --- a/app/(app)/products/[id]/sprint/page.tsx +++ b/app/(app)/products/[id]/sprint/page.tsx @@ -1,17 +1,192 @@ -import { redirect } from 'next/navigation' -import { requireSession } from '@/lib/auth-guard' -import { resolveActiveSprint } from '@/lib/active-sprint' +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' interface Props { params: Promise<{ id: string }> + searchParams: Promise<{ + newTask?: string + storyId?: string + editTask?: string + }> } -export default async function SprintRedirectPage({ params }: Props) { +export default async function SprintBoardPage({ params, searchParams }: Props) { const { id } = await params - const session = await requireSession() - const active = await resolveActiveSprint(id, session.userId) - if (!active) { - redirect(`/products/${id}?alert=no_sprint`) + 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, + })) } - redirect(`/products/${id}/sprint/${active.id}`) + + // 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 && ( + }> + + + )} +
+ ) } diff --git a/app/(app)/products/[id]/sprint/planning/loading.tsx b/app/(app)/products/[id]/sprint/planning/loading.tsx new file mode 100644 index 0000000..795b2c5 --- /dev/null +++ b/app/(app)/products/[id]/sprint/planning/loading.tsx @@ -0,0 +1,34 @@ +export default function Loading() { + return ( +
+ {/* Header skeleton */} +
+
+
+
+
+
+
+ + {/* Split pane skeleton */} +
+ {/* Left */} +
+
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
+ {/* Right */} +
+
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
+
+
+ ) +} diff --git a/app/(app)/products/[id]/sprint/[sprintId]/planning/page.tsx b/app/(app)/products/[id]/sprint/planning/page.tsx similarity index 50% rename from app/(app)/products/[id]/sprint/[sprintId]/planning/page.tsx rename to app/(app)/products/[id]/sprint/planning/page.tsx index d2a017a..8256d6f 100644 --- a/app/(app)/products/[id]/sprint/[sprintId]/planning/page.tsx +++ b/app/(app)/products/[id]/sprint/planning/page.tsx @@ -1,10 +1,10 @@ import { redirect } from 'next/navigation' interface Props { - params: Promise<{ id: string; sprintId: string }> + params: Promise<{ id: string }> } export default async function SprintPlanningRedirect({ params }: Props) { - const { id, sprintId } = await params - redirect(`/products/${id}/sprint/${sprintId}`) + const { id } = await params + redirect(`/products/${id}/sprint`) } diff --git a/app/(app)/settings/loading.tsx b/app/(app)/settings/loading.tsx index 11488b0..07f3dd9 100644 --- a/app/(app)/settings/loading.tsx +++ b/app/(app)/settings/loading.tsx @@ -1,11 +1,9 @@ -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 88daf20..b299e45 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -5,7 +5,6 @@ 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' @@ -15,7 +14,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, min_quota_pct: true }, + select: { username: true, email: true, bio: true, bio_detail: true, avatar_data: true, updated_at: true, active_product_id: true }, }), prisma.userRole.findMany({ where: { user_id: session.userId } }), prisma.product.findMany({ @@ -158,19 +157,6 @@ 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 new file mode 100644 index 0000000..61d4ec9 --- /dev/null +++ b/app/(app)/todos/loading.tsx @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..27f07f4 --- /dev/null +++ b/app/(app)/todos/page.tsx @@ -0,0 +1,47 @@ +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/(mobile)/m/products/[id]/page.tsx b/app/(mobile)/m/products/[id]/page.tsx index 4aa5815..136188d 100644 --- a/app/(mobile)/m/products/[id]/page.tsx +++ b/app/(mobile)/m/products/[id]/page.tsx @@ -15,7 +15,6 @@ 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' @@ -42,7 +41,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }: const [stories, tasks] = await Promise.all([ prisma.story.findMany({ where: { product_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], select: { id: true, code: true, @@ -50,10 +49,8 @@ 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, }, }), @@ -61,7 +58,6 @@ export default async function MobileProductBacklogPage({ params, searchParams }: where: { story: { pbi: { product_id: id } } }, select: { id: true, - code: true, title: true, description: true, priority: true, @@ -70,7 +66,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }: story_id: true, created_at: true, }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], }), ]) @@ -92,14 +88,12 @@ export default async function MobileProductBacklogPage({ params, searchParams }:
({ 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) })), + pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), storiesByPbi, tasksByStory, }} > - @@ -23,9 +24,11 @@ export default async function MobileSoloProductPage({ params }: Props) { const product = await getAccessibleProduct(id, session.userId) if (!product) notFound() - const initialData = await getSoloWorkspaceSnapshot(id, session.userId) + const sprint = await prisma.sprint.findFirst({ + where: { product_id: id, status: 'ACTIVE' }, + }) - if (!initialData) { + if (!sprint) { return (
@@ -33,15 +36,85 @@ 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 298c350..5ba794d 100644 --- a/app/_components/tasks/status-select.tsx +++ b/app/_components/tasks/status-select.tsx @@ -14,12 +14,9 @@ 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' }, } -// FAILED ontbreekt bewust: alleen via sprint-cascade gezet, niet handmatig kiesbaar. -const STATUS_ORDER: TaskStatus[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE', 'EXCLUDED'] +const STATUS_ORDER: TaskStatus[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE'] 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 823abb7..bb6a66b 100644 --- a/app/_components/tasks/task-dialog-skeleton.tsx +++ b/app/_components/tasks/task-dialog-skeleton.tsx @@ -1,11 +1,5 @@ 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() { @@ -14,54 +8,32 @@ 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 638d834..b49c0ed 100644 --- a/app/_components/tasks/task-dialog.tsx +++ b/app/_components/tasks/task-dialog.tsx @@ -60,9 +60,7 @@ interface TaskDialogProps { task?: TaskDialogTask storyId?: string productId: string - closePath?: string - onClose?: () => void - onSaved?: (taskId: string) => void + closePath: string isDemo?: boolean } @@ -77,13 +75,13 @@ function CharCount({ value, max }: { value: string; max: number }) { } const textareaClass = cn( - 'flex w-full rounded-lg border border-border bg-input-background px-2.5 py-2 text-sm', + 'flex w-full rounded-lg border border-input bg-transparent 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, onClose, onSaved, isDemo = false }: TaskDialogProps) { +export function TaskDialog({ task, storyId, productId, closePath, isDemo = false }: TaskDialogProps) { const router = useRouter() const [isPending, startTransition] = useTransition() const [confirmDelete, setConfirmDelete] = useState(false) @@ -102,12 +100,11 @@ export function TaskDialog({ task, storyId, productId, closePath, onClose, onSav }, }) - function close() { - if (onClose) { onClose(); return } - if (closePath) router.push(closePath) + function handleClose() { + router.push(closePath) } - const closeGuard = useDirtyCloseGuard(form.formState.isDirty, close) + const closeGuard = useDirtyCloseGuard(form.formState.isDirty, handleClose) const handleKeyDown = useDialogSubmitShortcut(() => form.handleSubmit(onSubmit)()) function onSubmit(data: TaskInput) { @@ -120,8 +117,7 @@ export function TaskDialog({ task, storyId, productId, closePath, onClose, onSav if (result.ok) { toast.success(isEdit ? 'Taak opgeslagen' : 'Taak aangemaakt') - onSaved?.(result.task.id) - close() + router.push(closePath) return } @@ -156,7 +152,7 @@ export function TaskDialog({ task, storyId, productId, closePath, onClose, onSav const result = await deleteTask(task.id, { productId }) if (result.ok) { toast.success('Taak verwijderd') - close() + router.push(closePath) return } if (result.code === 403) { diff --git a/app/api/cron/cleanup-agent-artifacts/route.ts b/app/api/cron/cleanup-agent-artifacts/route.ts index a923867..7dae4c4 100644 --- a/app/api/cron/cleanup-agent-artifacts/route.ts +++ b/app/api/cron/cleanup-agent-artifacts/route.ts @@ -15,7 +15,7 @@ export async function POST(request: Request) { const { count: deleted } = await prisma.claudeJob.deleteMany({ where: { - status: { in: ['FAILED', 'CANCELLED', 'SKIPPED'] }, + status: { in: ['FAILED', 'CANCELLED'] }, finished_at: { lt: cutoff }, }, }) diff --git a/app/api/ideas/route.ts b/app/api/ideas/route.ts index 7da26ac..84d1ad7 100644 --- a/app/api/ideas/route.ts +++ b/app/api/ideas/route.ts @@ -32,10 +32,7 @@ export async function GET(request: Request) { ...(productIdParam ? { product_id: productIdParam } : {}), ...(status ? { status } : {}), }, - include: { - product: { select: { id: true, name: true, repo_url: true } }, - secondary_products: { include: { product: { select: { id: true, name: true } } } }, - }, + include: { product: { select: { id: true, name: true, repo_url: true } } }, orderBy: { created_at: 'desc' }, take: 200, }) diff --git a/app/api/internal/push/send/route.ts b/app/api/internal/push/send/route.ts deleted file mode 100644 index 4891e59..0000000 --- a/app/api/internal/push/send/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { timingSafeEqual } from 'crypto' -import { z } from 'zod' -import { sendPushToUser } from '@/lib/push-server' - -const schema = z.object({ - userId: z.string().min(1), - payload: z.object({ - title: z.string().max(80), - body: z.string().max(300), - url: z.string().startsWith('/').or(z.string().url()), - tag: z.string().optional(), - }), -}) - -export async function POST(req: Request) { - if (!process.env.INTERNAL_PUSH_SECRET) { - return new Response(null, { status: 503 }) - } - - const authHeader = req.headers.get('authorization') ?? '' - const expected = `Bearer ${process.env.INTERNAL_PUSH_SECRET}` - let authorized = false - try { - authorized = - authHeader.length === expected.length && - timingSafeEqual(Buffer.from(authHeader), Buffer.from(expected)) - } catch { - authorized = false - } - if (!authorized) { - return new Response(null, { status: 401 }) - } - - let body: unknown - try { - body = await req.json() - } catch { - return new Response(null, { status: 400 }) - } - - const parsed = schema.safeParse(body) - if (!parsed.success) { - return Response.json({ errors: parsed.error.flatten().fieldErrors }, { status: 422 }) - } - - await sendPushToUser(parsed.data.userId, parsed.data.payload) - return new Response(null, { status: 204 }) -} diff --git a/app/api/internal/push/test-send/route.ts b/app/api/internal/push/test-send/route.ts deleted file mode 100644 index 7359f46..0000000 --- a/app/api/internal/push/test-send/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from 'zod' -import { requireAdmin } from '@/lib/auth-guard' -import { sendPushToUser } from '@/lib/push-server' - -const schema = z.object({ - title: z.string().max(80).optional(), - body: z.string().max(300).optional(), - url: z.string().optional(), -}) - -export async function POST(req: Request) { - const session = await requireAdmin() - - let input: z.infer = {} - try { - const raw = await req.json() - const parsed = schema.safeParse(raw) - if (parsed.success) input = parsed.data - } catch { - // body is optional — use defaults - } - - await sendPushToUser(session.userId, { - title: input.title ?? 'Test push', - body: input.body ?? 'Admin test notification', - url: input.url ?? '/', - }) - - return new Response(null, { status: 204 }) -} diff --git a/app/api/jobs/[id]/route.ts b/app/api/jobs/[id]/route.ts deleted file mode 100644 index fd11b89..0000000 --- a/app/api/jobs/[id]/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { NextRequest } from 'next/server' -import { getSession } from '@/lib/auth' -import { prisma } from '@/lib/prisma' -import { JOB_INCLUDE, buildPriceMap, mapJob } from '@/lib/jobs-mapper' -import type { PriceRow, RawJob } from '@/lib/jobs-mapper' - -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const session = await getSession() - if (!session.userId) { - return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) - } - - const { id } = await params - - const job = await prisma.claudeJob.findFirst({ - where: { id, user_id: session.userId }, - include: JOB_INCLUDE, - }) - - if (!job) { - return Response.json({ error: 'Job niet gevonden' }, { status: 404 }) - } - - const prices = await prisma.modelPrice.findMany() - const priceMap = buildPriceMap(prices as PriceRow[]) - return Response.json(mapJob(job as RawJob, priceMap)) -} diff --git a/app/api/jobs/[id]/sub-tasks/route.ts b/app/api/jobs/[id]/sub-tasks/route.ts deleted file mode 100644 index 7e90822..0000000 --- a/app/api/jobs/[id]/sub-tasks/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { NextRequest } from 'next/server' -import { getSession } from '@/lib/auth' -import { prisma } from '@/lib/prisma' - -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const session = await getSession() - if (!session.userId) { - return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) - } - const userId = session.userId - const { id } = await params - - const job = await prisma.claudeJob.findFirst({ - where: { id, user_id: userId }, - select: { kind: true }, - }) - - if (!job || job.kind !== 'SPRINT_IMPLEMENTATION') { - return Response.json([], { status: 200 }) - } - - const executions = await prisma.sprintTaskExecution.findMany({ - where: { sprint_job_id: id }, - include: { task: { select: { code: true, title: true } } }, - orderBy: { order: 'asc' }, - }) - - return Response.json( - executions.map(e => ({ - id: e.id, - taskCode: e.task.code, - taskTitle: e.task.title, - status: e.status, - })) - ) -} diff --git a/app/api/pbis/[id]/stories/route.ts b/app/api/pbis/[id]/stories/route.ts deleted file mode 100644 index 67de693..0000000 --- a/app/api/pbis/[id]/stories/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -// PBI-74 / T-870: GET /api/pbis/:id/stories -// -// Levert stories binnen een PBI voor ensurePbiLoaded. Access-control via -// product-eigenaarschap van het bovenliggende PBI. -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' -import { storyStatusToApi } from '@/lib/task-status' - -export const dynamic = 'force-dynamic' - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - - const pbi = await prisma.pbi.findFirst({ - where: { id, product: productAccessFilter(auth.userId) }, - select: { id: true }, - }) - if (!pbi) { - return Response.json({ error: 'PBI niet gevonden' }, { status: 404 }) - } - - const stories = await prisma.story.findMany({ - where: { pbi_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - description: true, - acceptance_criteria: true, - priority: true, - sort_order: true, - status: true, - pbi_id: true, - sprint_id: true, - created_at: true, - }, - }) - - return Response.json( - stories.map((s) => ({ ...s, status: storyStatusToApi(s.status) })), - ) -} diff --git a/app/api/products/[id]/backlog/route.ts b/app/api/products/[id]/backlog/route.ts deleted file mode 100644 index 9badc35..0000000 --- a/app/api/products/[id]/backlog/route.ts +++ /dev/null @@ -1,101 +0,0 @@ -// PBI-74 / T-870: GET /api/products/:id/backlog -// -// Levert een volledige ProductBacklogSnapshot voor de workspace-store -// (ensureProductLoaded). Auth + access-control consistent met andere -// product-routes (authenticateApiRequest + productAccessFilter). -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' -import { pbiStatusToApi, storyStatusToApi, taskStatusToApi } from '@/lib/task-status' - -export const dynamic = 'force-dynamic' - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - - const product = await prisma.product.findFirst({ - where: { id, ...productAccessFilter(auth.userId) }, - select: { id: true, name: true }, - }) - if (!product) { - return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) - } - - const [pbis, stories, tasks] = await Promise.all([ - prisma.pbi.findMany({ - where: { product_id: id }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - priority: true, - sort_order: true, - description: true, - created_at: true, - status: true, - }, - }), - prisma.story.findMany({ - where: { product_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - description: true, - acceptance_criteria: true, - priority: true, - sort_order: true, - status: true, - pbi_id: true, - sprint_id: true, - created_at: true, - }, - }), - prisma.task.findMany({ - where: { story: { product_id: id } }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - description: true, - priority: true, - sort_order: true, - status: true, - story_id: true, - created_at: true, - }, - }), - ]) - - const storiesByPbi: Record = {} - for (const story of stories) { - const apiStory = { ...story, status: storyStatusToApi(story.status) } - if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = [] - storiesByPbi[story.pbi_id].push(apiStory) - } - - const tasksByStory: Record = {} - for (const task of tasks) { - const apiTask = { ...task, status: taskStatusToApi(task.status) } - if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = [] - tasksByStory[task.story_id].push(apiTask) - } - - return Response.json({ - product, - pbis: pbis.map((p) => ({ ...p, status: pbiStatusToApi(p.status) })), - storiesByPbi, - tasksByStory, - }) -} diff --git a/app/api/products/[id]/claude-context/route.ts b/app/api/products/[id]/claude-context/route.ts index 556d6d7..5387a0f 100644 --- a/app/api/products/[id]/claude-context/route.ts +++ b/app/api/products/[id]/claude-context/route.ts @@ -29,26 +29,14 @@ export async function GET( return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) } - const [activeSprint, openIdeas] = await Promise.all([ + const [activeSprint, openTodos] = await Promise.all([ prisma.sprint.findFirst({ - where: { product_id: id, status: 'OPEN' }, + where: { product_id: id, status: 'ACTIVE' }, select: { id: true, sprint_goal: true, status: true }, }), - prisma.idea.findMany({ - where: { - user_id: auth.userId, - product_id: id, - archived: false, - status: { not: 'PLANNED' }, - }, - select: { - id: true, - code: true, - title: true, - description: true, - status: true, - created_at: true, - }, + prisma.todo.findMany({ + where: { user_id: auth.userId, product_id: id, done: false, archived: false }, + select: { id: true, title: true, description: true, created_at: true }, orderBy: { created_at: 'asc' }, take: 50, }), @@ -58,7 +46,7 @@ export async function GET( if (activeSprint) { const story = await prisma.story.findFirst({ where: { sprint_id: activeSprint.id, status: 'IN_SPRINT' }, - orderBy: [{ sort_order: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], include: { tasks: { orderBy: { sort_order: 'asc' }, @@ -101,6 +89,6 @@ export async function GET( product, active_sprint: activeSprint, next_story: nextStoryPayload, - open_ideas: openIdeas, + open_todos: openTodos, }) } diff --git a/app/api/products/[id]/cross-sprint-blocks/route.ts b/app/api/products/[id]/cross-sprint-blocks/route.ts deleted file mode 100644 index ba10da2..0000000 --- a/app/api/products/[id]/cross-sprint-blocks/route.ts +++ /dev/null @@ -1,74 +0,0 @@ -// PBI-79 / T-929: GET /api/products/:id/cross-sprint-blocks -// -// Lichte UX-hint voor disabled-vinkjes: welke stories binnen pbiIds zitten in -// een andere OPEN sprint (excludeSprintId expliciet uitgesloten). Server-side -// commit-actions blijven autoritatief — dit endpoint is alleen voor UI. -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' - -export const dynamic = 'force-dynamic' - -function parsePbiIds(raw: string | null): string[] | null { - if (!raw) return null - const ids = raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - return ids.length === 0 ? null : ids -} - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id: productId } = await params - const url = new URL(request.url) - const excludeSprintId = url.searchParams.get('excludeSprintId') ?? undefined - const pbiIds = parsePbiIds(url.searchParams.get('pbiIds')) - - if (!pbiIds) { - return Response.json( - { error: 'pbiIds is verplicht (comma-separated)' }, - { status: 400 }, - ) - } - - const product = await prisma.product.findFirst({ - where: { id: productId, ...productAccessFilter(auth.userId) }, - select: { id: true }, - }) - if (!product) { - return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) - } - - const stories = await prisma.story.findMany({ - where: { - pbi_id: { in: pbiIds }, - product_id: productId, - sprint_id: { not: null }, - ...(excludeSprintId ? { NOT: { sprint_id: excludeSprintId } } : {}), - sprint: { status: 'OPEN' }, - }, - select: { - id: true, - sprint: { select: { id: true, code: true } }, - }, - }) - - const result: Record = {} - for (const story of stories) { - if (!story.sprint) continue - result[story.id] = { - sprintId: story.sprint.id, - sprintName: story.sprint.code, - } - } - - return Response.json(result) -} diff --git a/app/api/products/[id]/next-story/route.ts b/app/api/products/[id]/next-story/route.ts index f2dd414..cbd6944 100644 --- a/app/api/products/[id]/next-story/route.ts +++ b/app/api/products/[id]/next-story/route.ts @@ -15,7 +15,7 @@ export async function GET( const { id } = await params const sprint = await prisma.sprint.findFirst({ - where: { product_id: id, status: 'OPEN', product: productAccessFilter(auth.userId) }, + where: { product_id: id, status: 'ACTIVE', product: productAccessFilter(auth.userId) }, }) if (!sprint) { return Response.json({ error: 'Geen actieve Sprint gevonden' }, { status: 404 }) @@ -23,7 +23,7 @@ export async function GET( const story = await prisma.story.findFirst({ where: { sprint_id: sprint.id, status: 'IN_SPRINT' }, - orderBy: [{ sort_order: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], include: { tasks: { orderBy: { sort_order: 'asc' }, diff --git a/app/api/products/[id]/solo-workspace/route.ts b/app/api/products/[id]/solo-workspace/route.ts deleted file mode 100644 index 36d438e..0000000 --- a/app/api/products/[id]/solo-workspace/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { authenticateApiRequest } from '@/lib/api-auth' -import { getSoloWorkspaceSnapshot } from '@/lib/solo-workspace-server' - -export const dynamic = 'force-dynamic' - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - const url = new URL(request.url) - const sprintId = url.searchParams.get('sprint_id') - const snapshot = await getSoloWorkspaceSnapshot(id, auth.userId, sprintId) - - if (!snapshot) { - return Response.json({ error: 'Solo workspace niet gevonden' }, { status: 404 }) - } - - return Response.json(snapshot) -} diff --git a/app/api/products/[id]/sprint-membership-summary/route.ts b/app/api/products/[id]/sprint-membership-summary/route.ts deleted file mode 100644 index 16f6b6d..0000000 --- a/app/api/products/[id]/sprint-membership-summary/route.ts +++ /dev/null @@ -1,87 +0,0 @@ -// PBI-79 / T-928: GET /api/products/:id/sprint-membership-summary -// -// Levert per PBI {total, inSprint} counts, gescoped op de doorgegeven pbiIds. -// Endpoint weigert product-brede aanroepen (pbiIds is verplicht). Eén groupBy -// + één count-by-sprint waar pbi_id IN (pbiIds). -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' - -export const dynamic = 'force-dynamic' - -function parsePbiIds(raw: string | null): string[] | null { - if (!raw) return null - const ids = raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - return ids.length === 0 ? null : ids -} - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id: productId } = await params - const url = new URL(request.url) - const sprintId = url.searchParams.get('sprintId') - const pbiIds = parsePbiIds(url.searchParams.get('pbiIds')) - - if (!sprintId) { - return Response.json({ error: 'sprintId is verplicht' }, { status: 400 }) - } - if (!pbiIds) { - return Response.json( - { error: 'pbiIds is verplicht (comma-separated)' }, - { status: 400 }, - ) - } - - const product = await prisma.product.findFirst({ - where: { id: productId, ...productAccessFilter(auth.userId) }, - select: { id: true }, - }) - if (!product) { - return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) - } - - const [totals, inSprint] = await Promise.all([ - prisma.story.groupBy({ - by: ['pbi_id'], - where: { pbi_id: { in: pbiIds }, product_id: productId }, - _count: { _all: true }, - }), - prisma.story.groupBy({ - by: ['pbi_id'], - where: { - pbi_id: { in: pbiIds }, - product_id: productId, - sprint_id: sprintId, - }, - _count: { _all: true }, - }), - ]) - - const inSprintByPbi = new Map() - for (const row of inSprint) { - inSprintByPbi.set(row.pbi_id, row._count._all) - } - - const result: Record = {} - for (const pbiId of pbiIds) { - result[pbiId] = { total: 0, inSprint: inSprintByPbi.get(pbiId) ?? 0 } - } - for (const row of totals) { - result[row.pbi_id] = { - total: row._count._all, - inSprint: inSprintByPbi.get(row.pbi_id) ?? 0, - } - } - - return Response.json(result) -} diff --git a/app/api/products/[id]/sprints/route.ts b/app/api/products/[id]/sprints/route.ts deleted file mode 100644 index 50f8bb8..0000000 --- a/app/api/products/[id]/sprints/route.ts +++ /dev/null @@ -1,59 +0,0 @@ -// PBI-74 / Story 9 / T-882: GET /api/products/:id/sprints -// -// Levert een lijst sprints voor een product (sprint-workspace -// ensureProductSprintsLoaded). Auth + access-control consistent met andere -// product-routes. - -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' - -export const dynamic = 'force-dynamic' - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - - const product = await prisma.product.findFirst({ - where: { id, ...productAccessFilter(auth.userId) }, - select: { id: true }, - }) - if (!product) { - return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) - } - - const sprints = await prisma.sprint.findMany({ - where: { product_id: id }, - orderBy: [ - { status: 'asc' }, // OPEN < CLOSED alfabetisch — workspace-store her-sorteert - { start_date: 'desc' }, - { created_at: 'desc' }, - ], - select: { - id: true, - product_id: true, - code: true, - sprint_goal: true, - status: true, - start_date: true, - end_date: true, - created_at: true, - completed_at: true, - }, - }) - - return Response.json( - sprints.map((s) => ({ - ...s, - start_date: s.start_date ? s.start_date.toISOString().slice(0, 10) : null, - end_date: s.end_date ? s.end_date.toISOString().slice(0, 10) : null, - })), - ) -} diff --git a/app/api/realtime/jobs/route.ts b/app/api/realtime/jobs/route.ts deleted file mode 100644 index 67edefd..0000000 --- a/app/api/realtime/jobs/route.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { NextRequest } from 'next/server' -import { Client } from 'pg' -import { getSession } from '@/lib/auth' -import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' -export const maxDuration = 300 - -const CHANNEL = 'scrum4me_changes' -const HEARTBEAT_MS = 25_000 -const HARD_CLOSE_MS = 240_000 - -type JobPayload = { - type: 'claude_job_enqueued' | 'claude_job_status' - job_id: string - task_id?: string | null - idea_id?: string | null - sprint_run_id?: string | null - kind?: string - user_id: string - status: string - branch?: string - pushed_at?: string - pr_url?: string - verify_result?: string - summary?: string - error?: string -} - -function shouldEmit(raw: unknown, userId: string): boolean { - if (!raw || typeof raw !== 'object') return false - const p = raw as Record - return 'type' in p && typeof p.user_id === 'string' && p.user_id === userId -} - -export async function GET(request: NextRequest) { - const session = await getSession() - if (!session.userId) { - return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) - } - const userId = session.userId - - const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL - if (!directUrl) { - return Response.json({ error: 'DIRECT_URL/DATABASE_URL niet geconfigureerd' }, { status: 500 }) - } - - const encoder = new TextEncoder() - const pgClient = new Client({ connectionString: directUrl }) - - let heartbeatTimer: ReturnType | null = null - let hardCloseTimer: ReturnType | null = null - let closed = false - - const stream = new ReadableStream({ - async start(controller) { - const enqueue = (chunk: string) => { - if (closed) return - try { - controller.enqueue(encoder.encode(chunk)) - } catch { - // Stream al gesloten - } - } - - const cleanup = async (reason: string) => { - if (closed) return - closed = true - if (heartbeatTimer) clearInterval(heartbeatTimer) - if (hardCloseTimer) clearTimeout(hardCloseTimer) - await closePgClientSafely(pgClient, 'realtime/jobs') - try { - controller.close() - } catch { - // already closed - } - if (process.env.NODE_ENV !== 'production') { - console.log(`[realtime/jobs] closed: ${reason}`) - } - } - - try { - await pgClient.connect() - await pgClient.query(`LISTEN ${CHANNEL}`) - } catch (err) { - console.error('[realtime/jobs] pg connect/listen failed:', err) - enqueue(`event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`) - await cleanup('pg connect failed') - return - } - - pgClient.on('notification', (msg) => { - if (!msg.payload) return - let payload: unknown - try { - payload = JSON.parse(msg.payload) - } catch { - return - } - if (!shouldEmit(payload, userId)) return - enqueue(`data: ${msg.payload}\n\n`) - }) - - pgClient.on('error', async (err) => { - console.error('[realtime/jobs] pg client error:', err) - await cleanup('pg error') - }) - - enqueue(`event: ready\ndata: ${JSON.stringify({ user_id: userId })}\n\n`) - - const activeJobs = await prisma_jobs_findActive(userId) - if (activeJobs.length > 0) { - enqueue(`event: jobs_initial\ndata: ${JSON.stringify(activeJobs)}\n\n`) - } - - heartbeatTimer = setInterval(() => { - enqueue(`: heartbeat\n\n`) - }, HEARTBEAT_MS) - - hardCloseTimer = setTimeout(() => { - cleanup('hard close 240s') - }, HARD_CLOSE_MS) - - request.signal.addEventListener('abort', () => { - cleanup('client aborted') - }) - }, - }) - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream; charset=utf-8', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }, - }) -} - -async function prisma_jobs_findActive(userId: string): Promise { - const { prisma } = await import('@/lib/prisma') - const jobs = await prisma.claudeJob.findMany({ - where: { user_id: userId, status: { notIn: ['DONE'] } }, - select: { - id: true, - kind: true, - status: true, - task_id: true, - idea_id: true, - sprint_run_id: true, - branch: true, - error: true, - summary: true, - }, - }) - return jobs.map(j => ({ - type: 'claude_job_status' as const, - job_id: j.id, - kind: j.kind, - user_id: userId, - status: j.status, - task_id: j.task_id, - idea_id: j.idea_id, - sprint_run_id: j.sprint_run_id, - branch: j.branch ?? undefined, - error: j.error ?? undefined, - summary: j.summary ?? undefined, - })) -} diff --git a/app/api/realtime/notifications/route.ts b/app/api/realtime/notifications/route.ts index 4f42d05..ca756a6 100644 --- a/app/api/realtime/notifications/route.ts +++ b/app/api/realtime/notifications/route.ts @@ -49,43 +49,40 @@ interface IdeaJobPayload { idea_id: string user_id: string product_id?: string | null - kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' + kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' | 'PLAN_CHAT' status: string } -// Story-log-payloads: emitted by notify_story_log_change trigger op story_logs -// (T-559). Carries product_id voor productAccessFilter en optioneel idea_id -// voor user-private idea-access (M12 keuze 2). log_type is informatief. -interface StoryLogPayload { - op: 'INSERT' - entity: 'story_log' +// UserQuestion-payloads: emitted by app/api/user-questions/[id]/answer and +// actions/user-questions.ts via prisma.$executeRaw pg_notify. +interface UserQuestionPayload { + op: 'I' | 'U' + entity: 'user_question' id: string - story_id: string - product_id: string | null - idea_id: string | null - log_type: 'IMPLEMENTATION_PLAN' | 'COMMIT' | 'TEST_RESULT' | string + idea_id: string + status: 'pending' | 'answered' } -type NotifyPayload = QuestionPayload | IdeaJobPayload | StoryLogPayload +type NotifyPayload = QuestionPayload | IdeaJobPayload | UserQuestionPayload function isQuestionPayload(p: NotifyPayload): p is QuestionPayload { return 'entity' in p && p.entity === 'question' } +function isUserQuestionPayload(p: NotifyPayload): p is UserQuestionPayload { + return 'entity' in p && p.entity === 'user_question' +} + function isIdeaJobPayload(p: NotifyPayload): p is IdeaJobPayload { return ( 'type' in p && (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') && 'idea_id' in p && 'kind' in p && - (p.kind === 'IDEA_GRILL' || p.kind === 'IDEA_MAKE_PLAN') + (p.kind === 'IDEA_GRILL' || p.kind === 'IDEA_MAKE_PLAN' || p.kind === 'PLAN_CHAT') ) } -function isStoryLogPayload(p: NotifyPayload): p is StoryLogPayload { - return 'entity' in p && p.entity === 'story_log' -} - export async function GET(request: NextRequest) { const session = await getSession() if (!session.userId) { @@ -181,18 +178,10 @@ export async function GET(request: NextRequest) { return } - if (isStoryLogPayload(payload)) { - // Sync-tab (PBI-36 ST-1219): story_log-event moet door als óf de - // story bij een user-eigen idee hoort, óf de user productAccess - // heeft (voor non-Idea views). idea_id-pad heeft voorrang — - // sluit aan op M12 strikt user_id-only voor ideas. - if (payload.idea_id && accessibleIdeaIds.has(payload.idea_id)) { - enqueue(`data: ${msg.payload}\n\n`) - return - } - if (payload.product_id && accessibleProductIds.has(payload.product_id)) { - enqueue(`data: ${msg.payload}\n\n`) - } + // UserQuestion (PLAN_CHAT answer-event): user-scoped via idea ownership. + if (isUserQuestionPayload(payload)) { + if (!accessibleIdeaIds.has(payload.idea_id)) return + enqueue(`data: ${msg.payload}\n\n`) return } @@ -296,7 +285,14 @@ export async function GET(request: NextRequest) { }), ].sort((a, b) => (a.created_at < b.created_at ? 1 : -1)) - enqueue(`event: state\ndata: ${JSON.stringify({ questions: stateQuestions })}\n\n`) + const userQuestionsInit = await prisma.userQuestion.findMany({ + where: { idea: { user_id: userId } }, + orderBy: { created_at: 'desc' }, + take: 100, + select: { id: true, idea_id: true, question: true, answer: true, status: true, created_at: true }, + }) + + enqueue(`event: state\ndata: ${JSON.stringify({ questions: stateQuestions, userQuestions: userQuestionsInit.map(uq => ({ ...uq, created_at: uq.created_at.toISOString() })) })}\n\n`) heartbeatTimer = setInterval(() => { enqueue(`: heartbeat\n\n`) diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts index 40a0b01..e514797 100644 --- a/app/api/realtime/solo/route.ts +++ b/app/api/realtime/solo/route.ts @@ -64,17 +64,7 @@ type WorkerPayload = { product_id?: string } -// M13: per-iteration quota-rapport van de worker. Geen product-scope — -// elke heartbeat geldt voor alle producten waar deze user toegang toe heeft. -type WorkerHeartbeatPayload = { - type: 'worker_heartbeat' - user_id: string - token_id: string - last_quota_pct: number - last_quota_check_at: string -} - -type NotifyPayload = EntityPayload | JobPayload | WorkerPayload | WorkerHeartbeatPayload +type NotifyPayload = EntityPayload | JobPayload | WorkerPayload function isJobPayload(p: NotifyPayload): p is JobPayload { return 'type' in p && (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') @@ -84,10 +74,6 @@ function isWorkerPayload(p: NotifyPayload): p is WorkerPayload { return 'type' in p && (p.type === 'worker_connected' || p.type === 'worker_disconnected') } -function isWorkerHeartbeatPayload(p: NotifyPayload): p is WorkerHeartbeatPayload { - return 'type' in p && p.type === 'worker_heartbeat' -} - function shouldEmit( payload: NotifyPayload, productId: string, @@ -104,10 +90,6 @@ function shouldEmit( return payload.user_id === userId } - if (isWorkerHeartbeatPayload(payload)) { - return payload.user_id === userId - } - // M11 (ST-1104): question-events horen op /api/realtime/notifications, niet hier. if (payload.entity === 'question') return false @@ -261,7 +243,7 @@ export async function GET(request: NextRequest) { async function prisma_sprint_findActive(productId: string): Promise<{ id: string } | null> { const { prisma } = await import('@/lib/prisma') return prisma.sprint.findFirst({ - where: { product_id: productId, status: 'OPEN' }, + where: { product_id: productId, status: 'ACTIVE' }, select: { id: true }, orderBy: { created_at: 'desc' }, }) diff --git a/app/api/realtime/sprint/route.ts b/app/api/realtime/sprint/route.ts deleted file mode 100644 index aaaf34c..0000000 --- a/app/api/realtime/sprint/route.ts +++ /dev/null @@ -1,141 +0,0 @@ -// SSE endpoint for the sprint workspace (sprint / story / task changes). -// Mirrors /api/realtime/backlog but with entity filter ∈ {sprint, story, task} -// scoped per product. PBI-74 / Story 9. -// -// Auth: iron-session cookie. Demo users may read. - -import { NextRequest } from 'next/server' -import { Client } from 'pg' -import { getSession } from '@/lib/auth' -import { getAccessibleProduct } from '@/lib/product-access' -import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' -export const maxDuration = 300 - -const CHANNEL = 'scrum4me_changes' -const HEARTBEAT_MS = 25_000 -const HARD_CLOSE_MS = 240_000 - -type NotifyPayload = Record - -function shouldEmit(payload: NotifyPayload, productId: string): boolean { - if ('type' in payload) return false - const entity = payload.entity as string | undefined - if (!entity || !['sprint', 'story', 'task'].includes(entity)) return false - return payload.product_id === productId -} - -export async function GET(request: NextRequest) { - const session = await getSession() - if (!session.userId) { - return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) - } - - const productId = request.nextUrl.searchParams.get('product_id') - if (!productId) { - return Response.json({ error: 'product_id is verplicht' }, { status: 400 }) - } - - const product = await getAccessibleProduct(productId, session.userId) - if (!product) { - return Response.json({ error: 'Geen toegang tot dit product' }, { status: 403 }) - } - - const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL - if (!directUrl) { - return Response.json( - { error: 'DIRECT_URL/DATABASE_URL niet geconfigureerd' }, - { status: 500 }, - ) - } - - const encoder = new TextEncoder() - const pgClient = new Client({ connectionString: directUrl }) - - let heartbeatTimer: ReturnType | null = null - let hardCloseTimer: ReturnType | null = null - let closed = false - - const stream = new ReadableStream({ - async start(controller) { - const enqueue = (chunk: string) => { - if (closed) return - try { - controller.enqueue(encoder.encode(chunk)) - } catch { - // stream already closed - } - } - - const cleanup = async (reason: string) => { - if (closed) return - closed = true - if (heartbeatTimer) clearInterval(heartbeatTimer) - if (hardCloseTimer) clearTimeout(hardCloseTimer) - await closePgClientSafely(pgClient, 'realtime/sprint') - try { - controller.close() - } catch { - // already closed - } - if (process.env.NODE_ENV !== 'production') { - console.log(`[realtime/sprint] closed: ${reason}`) - } - } - - try { - await pgClient.connect() - await pgClient.query(`LISTEN ${CHANNEL}`) - } catch (err) { - console.error('[realtime/sprint] pg connect/listen failed:', err) - enqueue( - `event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`, - ) - await cleanup('pg connect failed') - return - } - - pgClient.on('notification', (msg) => { - if (!msg.payload) return - let payload: NotifyPayload - try { - payload = JSON.parse(msg.payload) as NotifyPayload - } catch { - return - } - if (!shouldEmit(payload, productId)) return - enqueue(`data: ${msg.payload}\n\n`) - }) - - pgClient.on('error', async (err) => { - console.error('[realtime/sprint] pg client error:', err) - await cleanup('pg error') - }) - - enqueue(`event: ready\ndata: ${JSON.stringify({ product_id: productId })}\n\n`) - - heartbeatTimer = setInterval(() => { - enqueue(`: heartbeat\n\n`) - }, HEARTBEAT_MS) - - hardCloseTimer = setTimeout(() => { - cleanup('hard close 240s') - }, HARD_CLOSE_MS) - - request.signal.addEventListener('abort', () => { - cleanup('client aborted') - }) - }, - }) - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream; charset=utf-8', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }, - }) -} diff --git a/app/api/realtime/user-settings/route.ts b/app/api/realtime/user-settings/route.ts deleted file mode 100644 index 6c3261f..0000000 --- a/app/api/realtime/user-settings/route.ts +++ /dev/null @@ -1,146 +0,0 @@ -// PBI-76: User-scoped SSE stream voor user-settings cross-tab/cross-device sync. -// -// Wordt door in app/(app)/layout.tsx geopend zodra de -// gebruiker is ingelogd. Filtert pg_notify-payloads op -// `kind === 'user_settings' && userId === session.userId`. Settings worden -// via prop al gehydrateerd; deze route levert alleen incrementele patches. -// -// Auth: iron-session cookie. Demo-tokens openen geen subscription (bridge -// skipt voor isDemo). -// Output: text/event-stream — `data:` met de patch (Partial). -// Sluit zelf na 240s als safety-net; client herconnect. - -import { NextRequest } from 'next/server' -import { Client } from 'pg' -import { getSession } from '@/lib/auth' -import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' -export const maxDuration = 300 - -const CHANNEL = 'scrum4me_changes' -const HEARTBEAT_MS = 25_000 -const HARD_CLOSE_MS = 240_000 - -interface UserSettingsPayload { - kind: 'user_settings' - userId: string - patch: Record -} - -function isUserSettingsPayload(p: unknown): p is UserSettingsPayload { - if (typeof p !== 'object' || p === null) return false - const obj = p as Record - return ( - obj.kind === 'user_settings' && - typeof obj.userId === 'string' && - typeof obj.patch === 'object' && - obj.patch !== null - ) -} - -export async function GET(request: NextRequest) { - const session = await getSession() - if (!session.userId) { - return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) - } - const userId = session.userId - - const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL - if (!directUrl) { - return Response.json( - { error: 'DIRECT_URL/DATABASE_URL niet geconfigureerd' }, - { status: 500 }, - ) - } - - const encoder = new TextEncoder() - const pgClient = new Client({ connectionString: directUrl }) - - let heartbeatTimer: ReturnType | null = null - let hardCloseTimer: ReturnType | null = null - let closed = false - - const stream = new ReadableStream({ - async start(controller) { - const enqueue = (chunk: string) => { - if (closed) return - try { - controller.enqueue(encoder.encode(chunk)) - } catch { - // controller already closed - } - } - - const cleanup = async (reason: string) => { - if (closed) return - closed = true - if (heartbeatTimer) clearInterval(heartbeatTimer) - if (hardCloseTimer) clearTimeout(hardCloseTimer) - await closePgClientSafely(pgClient, 'realtime/user-settings') - try { - controller.close() - } catch { - // already closed - } - if (process.env.NODE_ENV !== 'production') { - console.log(`[realtime/user-settings] closed: ${reason}`) - } - } - - try { - await pgClient.connect() - await pgClient.query(`LISTEN ${CHANNEL}`) - } catch (err) { - console.error('[realtime/user-settings] pg connect/listen failed:', err) - enqueue( - `event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`, - ) - await cleanup('pg connect failed') - return - } - - pgClient.on('notification', (msg) => { - if (!msg.payload) return - let payload: unknown - try { - payload = JSON.parse(msg.payload) - } catch { - return - } - if (!isUserSettingsPayload(payload)) return - if (payload.userId !== userId) return - enqueue(`data: ${JSON.stringify(payload.patch)}\n\n`) - }) - - pgClient.on('error', (err) => { - console.error('[realtime/user-settings] pg client error:', err) - cleanup('pg error') - }) - - enqueue(`: connected\n\n`) - - heartbeatTimer = setInterval(() => { - enqueue(`: heartbeat\n\n`) - }, HEARTBEAT_MS) - - hardCloseTimer = setTimeout(() => { - cleanup('hard close 240s') - }, HARD_CLOSE_MS) - - request.signal.addEventListener('abort', () => { - cleanup('client aborted') - }) - }, - }) - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream; charset=utf-8', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }, - }) -} diff --git a/app/api/sprints/[id]/tasks/route.ts b/app/api/sprints/[id]/tasks/route.ts index 73f88c0..6d1e2d3 100644 --- a/app/api/sprints/[id]/tasks/route.ts +++ b/app/api/sprints/[id]/tasks/route.ts @@ -28,6 +28,7 @@ export async function GET( where: { sprint_id: id }, orderBy: [ { story: { sort_order: 'asc' } }, + { priority: 'asc' }, { sort_order: 'asc' }, ], take: limit, diff --git a/app/api/sprints/[id]/workspace/route.ts b/app/api/sprints/[id]/workspace/route.ts deleted file mode 100644 index 8d48c7d..0000000 --- a/app/api/sprints/[id]/workspace/route.ts +++ /dev/null @@ -1,110 +0,0 @@ -// PBI-74 / Story 9 / T-882: GET /api/sprints/:id/workspace -// -// Levert een SprintWorkspaceSnapshot (sprint + stories + tasksByStory) voor -// de sprint-workspace-store (ensureSprintLoaded). Auth + access-control via -// product-membership. - -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' -import { storyStatusToApi, taskStatusToApi } from '@/lib/task-status' - -export const dynamic = 'force-dynamic' - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - - const sprint = await prisma.sprint.findFirst({ - where: { id, product: productAccessFilter(auth.userId) }, - select: { - id: true, - product_id: true, - code: true, - sprint_goal: true, - status: true, - start_date: true, - end_date: true, - created_at: true, - completed_at: true, - product: { select: { id: true, name: true } }, - }, - }) - if (!sprint) { - return Response.json({ error: 'Sprint niet gevonden' }, { status: 404 }) - } - - const [stories, tasks] = await Promise.all([ - prisma.story.findMany({ - where: { sprint_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - include: { - tasks: { select: { id: true, status: true } }, - assignee: { select: { id: true, username: true } }, - }, - }), - prisma.task.findMany({ - where: { sprint_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - description: true, - priority: true, - sort_order: true, - status: true, - story_id: true, - sprint_id: true, - created_at: true, - }, - }), - ]) - - const tasksByStory: Record = {} - for (const task of tasks) { - const apiTask = { ...task, status: taskStatusToApi(task.status) } - if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = [] - tasksByStory[task.story_id].push(apiTask) - } - - return Response.json({ - product: sprint.product, - sprint: { - id: sprint.id, - product_id: sprint.product_id, - code: sprint.code, - sprint_goal: sprint.sprint_goal, - status: sprint.status, - 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: sprint.created_at, - completed_at: sprint.completed_at, - }, - stories: stories.map((s) => ({ - id: s.id, - code: s.code, - title: s.title, - description: s.description, - acceptance_criteria: s.acceptance_criteria, - priority: s.priority, - sort_order: s.sort_order, - status: storyStatusToApi(s.status), - pbi_id: s.pbi_id, - sprint_id: s.sprint_id, - created_at: s.created_at, - taskCount: s.tasks.length, - doneCount: s.tasks.filter((t) => t.status === 'DONE').length, - assignee_id: s.assignee_id, - assignee_username: s.assignee?.username ?? null, - })), - tasksByStory, - }) -} diff --git a/app/api/stories/[id]/tasks/reorder/route.ts b/app/api/stories/[id]/tasks/reorder/route.ts new file mode 100644 index 0000000..53aeab5 --- /dev/null +++ b/app/api/stories/[id]/tasks/reorder/route.ts @@ -0,0 +1,56 @@ +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' +import { z } from 'zod' + +const bodySchema = z.object({ + task_ids: z.array(z.string()).min(1), +}) + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + if (auth.isDemo) { + return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) + } + + const { id: storyId } = await params + + let body: unknown + try { + body = await request.json() + } catch { + return Response.json({ error: 'Malformed JSON' }, { status: 400 }) + } + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return Response.json({ error: parsed.error.flatten() }, { status: 422 }) + } + + const story = await prisma.story.findFirst({ + where: { id: storyId, product: productAccessFilter(auth.userId) }, + include: { tasks: { select: { id: true } } }, + }) + if (!story) { + return Response.json({ error: 'Story niet gevonden' }, { status: 404 }) + } + + const storyTaskIds = new Set(story.tasks.map(t => t.id)) + const invalidId = parsed.data.task_ids.find(id => !storyTaskIds.has(id)) + if (invalidId) { + return Response.json({ error: `Ongeldig task_id: ${invalidId}` }, { status: 422 }) + } + + await prisma.$transaction( + parsed.data.task_ids.map((id, i) => + prisma.task.update({ where: { id }, data: { sort_order: i + 1.0 } }) + ) + ) + + return Response.json({ success: true }) +} diff --git a/app/api/stories/[id]/tasks/route.ts b/app/api/stories/[id]/tasks/route.ts deleted file mode 100644 index 2584ff7..0000000 --- a/app/api/stories/[id]/tasks/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -// PBI-74 / T-870: GET /api/stories/:id/tasks -// -// Levert tasks binnen een story voor ensureStoryLoaded. Access-control via -// product-eigenaarschap van de bovenliggende story. -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' -import { taskStatusToApi } from '@/lib/task-status' - -export const dynamic = 'force-dynamic' - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - - const story = await prisma.story.findFirst({ - where: { id, product: productAccessFilter(auth.userId) }, - select: { id: true }, - }) - if (!story) { - return Response.json({ error: 'Story niet gevonden' }, { status: 404 }) - } - - const tasks = await prisma.task.findMany({ - where: { story_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - description: true, - priority: true, - sort_order: true, - status: true, - story_id: true, - created_at: true, - }, - }) - - return Response.json( - tasks.map((t) => ({ ...t, status: taskStatusToApi(t.status) })), - ) -} diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts index 52cce01..b80a811 100644 --- a/app/api/tasks/[id]/route.ts +++ b/app/api/tasks/[id]/route.ts @@ -2,57 +2,7 @@ import { authenticateApiRequest } from '@/lib/api-auth' import { prisma } from '@/lib/prisma' import { z } from 'zod' import { TASK_STATUS_API_VALUES, taskStatusFromApi, taskStatusToApi } from '@/lib/task-status' -import { propagateStatusUpwards } from '@/lib/tasks-status-update' -import { productAccessFilter } from '@/lib/product-access' - -// PBI-74 / T-869: force-dynamic zodat Next geen response-cache hangt aan -// deze route — workspace-store leest hier verse data via ensureTaskLoaded. -export const dynamic = 'force-dynamic' - -// PBI-74 / T-870: GET-handler voor ensureTaskLoaded. Levert TaskDetail-shape -// (extends BacklogTask met implementation_plan etc.). Access-control via -// product van de parent-story. -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - - const task = await prisma.task.findFirst({ - where: { - id, - story: { product: productAccessFilter(auth.userId) }, - }, - select: { - id: true, - title: true, - description: true, - priority: true, - sort_order: true, - status: true, - story_id: true, - created_at: true, - implementation_plan: true, - requires_opus: true, - verify_only: true, - verify_required: true, - }, - }) - if (!task) { - return Response.json({ error: 'Task niet gevonden' }, { status: 404 }) - } - - return Response.json({ - ...task, - status: taskStatusToApi(task.status), - _detail: true, - }) -} +import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' // `review` is a valid TaskStatus in the DB and the kanban-board UI, but the // sprint task list (components/sprint/task-list.tsx) does not yet render it. @@ -161,7 +111,7 @@ export async function PATCH( : null if (dbStatus !== undefined && dbStatus !== null) { - const result = await propagateStatusUpwards(id, dbStatus, tx) + const result = await updateTaskStatusWithStoryPromotion(id, dbStatus, tx) return { id: result.task.id, status: result.task.status, diff --git a/app/api/todos/route.ts b/app/api/todos/route.ts new file mode 100644 index 0000000..6a682e5 --- /dev/null +++ b/app/api/todos/route.ts @@ -0,0 +1,53 @@ +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' +import { z } from 'zod' + +const bodySchema = z.object({ + title: z.string().min(1, 'Titel is verplicht').max(500), + description: z.string().max(2000, 'Beschrijving mag maximaal 2000 tekens bevatten').optional(), + product_id: z.string().min(1, 'Product is verplicht'), +}) + +export async function POST(request: Request) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + if (auth.isDemo) { + return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + return Response.json({ error: 'Malformed JSON' }, { status: 400 }) + } + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return Response.json({ error: parsed.error.flatten() }, { status: 422 }) + } + + const product = await prisma.product.findFirst({ + where: { id: parsed.data.product_id, user_id: auth.userId, archived: false }, + }) + if (!product) { + return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) + } + + const description = parsed.data.description?.trim() || null + + const todo = await prisma.todo.create({ + data: { + user_id: auth.userId, + product_id: parsed.data.product_id, + title: parsed.data.title, + description, + }, + }) + + return Response.json( + { id: todo.id, title: todo.title, description: todo.description, created_at: todo.created_at }, + { status: 201 }, + ) +} diff --git a/app/api/user-questions/[id]/answer/route.ts b/app/api/user-questions/[id]/answer/route.ts new file mode 100644 index 0000000..6b8304b --- /dev/null +++ b/app/api/user-questions/[id]/answer/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' + +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' + +interface RouteContext { + params: Promise<{ id: string }> +} + +const bodySchema = z.object({ + answer: z.string().min(1).max(8000), +}) + +export async function POST(request: NextRequest, ctx: RouteContext) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return NextResponse.json({ error: auth.error }, { status: auth.status }) + } + + const { id } = await ctx.params + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Malformed JSON' }, { status: 400 }) + } + + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: 'Ongeldige invoer', details: parsed.error.flatten() }, { status: 422 }) + } + + const uq = await prisma.userQuestion.findFirst({ + where: { id }, + select: { id: true, idea_id: true, status: true }, + }) + if (!uq) { + return NextResponse.json({ error: 'UserQuestion niet gevonden' }, { status: 404 }) + } + if (uq.status !== 'pending') { + return NextResponse.json({ error: 'Vraag is al beantwoord' }, { status: 409 }) + } + + await prisma.userQuestion.update({ + where: { id }, + data: { answer: parsed.data.answer, status: 'answered' }, + }) + + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + op: 'U', + entity: 'user_question', + id, + idea_id: uq.idea_id, + status: 'answered', + })}::text) + ` + + return NextResponse.json({ ok: true }) +} diff --git a/app/globals.css b/app/globals.css index e8d3b09..9e6d7af 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3,25 +3,3 @@ @plugin "@tailwindcss/typography"; @import "./styles/theme.css"; - -/* Debug-mode overlay (alleen actief wanneer body.debug-mode is gezet door dev-only toggle) */ -body.debug-mode [data-debug-id] { - outline: 2px dashed var(--info); - outline-offset: 1px; - position: relative; -} -body.debug-mode [data-debug-id]:hover::after { - content: attr(data-debug-id); - position: absolute; - top: 0; - left: 0; - background: var(--info-container); - color: var(--info-container-foreground); - font-size: 10px; - line-height: 1.2; - padding: 2px 4px; - white-space: nowrap; - border-radius: 2px; - z-index: 9999; - pointer-events: none; -} diff --git a/app/layout.tsx b/app/layout.tsx index 5cc59ad..78b08fe 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata, Viewport } from "next"; +import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Analytics } from "@vercel/analytics/next"; import { Toaster } from "sonner"; @@ -30,17 +30,6 @@ export const metadata: Metadata = { ], }, manifest: "/manifest.json", - appleWebApp: { - capable: true, - statusBarStyle: 'default', - }, - other: { - 'mobile-web-app-capable': 'yes', - }, -}; - -export const viewport: Viewport = { - themeColor: '#ffffff', }; export default function RootLayout({ diff --git a/app/page.tsx b/app/page.tsx index ccf42d1..78d65d5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -45,6 +45,12 @@ export default async function LandingPage() { > Solo + + Todo's +

- Van idee tot pull request — op je eigen hardware. + Plannen in de cloud. Uitvoeren op je eigen machine.

- Leg een idee vast, laat Claude het kritisch bevragen, accepteer het plan en zet het - door een lokale agent uit. Code, executie en agents draaien op je eigen machine; - alleen metadata loopt via Vercel + Neon. Idee na idee, automatisch omgezet in - commits en pull requests. + De UI draait op Vercel, je code draait op jou. Een gedeelde job-queue laat lokale + Claude Code agents (laptop, NAS of VM) stories autonoom oppakken — zonder dat je + broncode ooit de cloud hoeft te raken.

- {/* ── Van idee tot PR ────────────────────────────────────────── */} -
-
-

Van idee tot pull request

-

- Vier stappen, één queue. Een idee groeit uit tot gemergde code zonder dat jij ertussen - hoeft. -

- -
- {[ - { - step: '1', - title: 'Idee', - chip: 'DRAFT', - chipClass: 'bg-tertiary-container text-tertiary-container-foreground', - desc: 'Leg een idee vast in twee zinnen. Status: DRAFT.', - }, - { - step: '2', - title: 'Grill', - chip: 'GRILLING', - chipClass: 'bg-warning-container text-warning-container-foreground', - desc: 'Claude stelt kritische vragen via het belicoon; je antwoorden vormen de grill_md.', - }, - { - step: '3', - title: 'Plan', - chip: 'PLAN_READY', - chipClass: 'bg-success-container text-success-container-foreground', - desc: 'Claude schrijft een YAML-plan. Materialiseer en je hebt PBI + stories + tasks.', - }, - { - step: '4', - title: 'Execute', - chip: 'DONE → PR', - chipClass: 'bg-primary-container text-primary-container-foreground', - desc: 'Lokale agent claimt de jobs, commit, pusht en opent automatisch een PR.', - }, - ].flatMap((s, i, arr) => { - const card = ( -
-
-
- {s.step} -
-
{s.title}
- - {s.chip} - -
-

{s.desc}

-
- ) - if (i === arr.length - 1) return [card] - return [ - card, -
- → -
, - ] - })} -
- -

- State-machine: DRAFT → GRILLING → GRILLED → PLANNING → PLAN_READY → PLANNED. - Bij materialiseren ontstaat in één atomaire transactie precies één PBI met N stories en M taken - uit het YAML-plan. Op de laatste taak van de laatste story pusht de worker automatisch een - branch en opent of mergt een pull request — geen handwerk meer tussen plan en deploy. -

-
-
- {/* ── Architectuur ───────────────────────────────────────────── */}
@@ -235,7 +161,7 @@ export default async function LandingPage() { }, { title: 'Lokale worker', - desc: 'Jouw machine — laptop, NAS of VM. Claude Code via stdio-MCP, claimt jobs atomisch (FOR UPDATE SKIP LOCKED), executeert lokaal, commit lokaal, push lokaal. Doet drie soorten jobs: bevragen van een idee (GRILL), plan-generatie (PLAN), taak-implementatie (IMPL) — allemaal op dezelfde machine. Meerdere workers parallel veilig.', + desc: 'Jouw machine — laptop, NAS of VM. Claude Code via stdio-MCP, claimt jobs atomisch (FOR UPDATE SKIP LOCKED), executeert lokaal, commit lokaal, push lokaal. Meerdere workers parallel veilig.', }, { title: 'GitHub', @@ -256,55 +182,33 @@ export default async function LandingPage() {

Bekijk Scrum4Me in actie

- Zes weergaven van Scrum4Me — van inkomende ideeën tot persoonlijk Kanban-bord en - voortgangs-inzichten. Elke weergave is desktop-first en gebouwd op MD3-tokens en - shadcn-componenten. + Drie schermen die de kern van Scrum4Me afdekken — van Product Backlog tot persoonlijk + Kanban-bord. Elke weergave is desktop-first en gebouwd op MD3-tokens en shadcn-componenten.

{[ { - src: '/screenshots/ideas-table.png', - alt: 'Ideas-dashboard met idee-kaarten in DRAFT/GRILLED/PLAN_READY-statussen', - title: 'Ideas-dashboard', - caption: - 'Persoonlijk overzicht van je ideeën met status (DRAFT → GRILLED → PLAN_READY → PLANNED). Klik "Grill me" of "Make plan" om een lokale agent te starten; bij materialiseren ontstaat exact één PBI met stories en taken.', - }, - { - src: '/screenshots/producten.png', - alt: 'Producten-dashboard met overzicht van actieve projecten', - title: 'Producten', - caption: - 'Eén overzicht van alle producten waar je toegang toe hebt — eigen producten plus die waar je als developer bent toegevoegd. Vanaf hier spring je naar Backlog, Sprint of Solo.', - }, - { - src: '/screenshots/product-backlog.png', - alt: 'Product Backlog met PBIs gegroepeerd op prioriteit en stories per PBI', - title: 'Product Backlog', - caption: - 'PBIs gegroepeerd op prioriteit (Kritiek → Laag) in het linkerpaneel. Klik op een PBI om de stories rechts te zien, gerangschikt per urgentie en versleepbaar.', - }, - { - src: '/screenshots/sprint.png', + src: '/screenshots/sprint-board.jpg', alt: 'Sprint Board met drie panelen: Product Backlog, Sprint Backlog en Taken', title: 'Sprint Board', caption: 'Drie panelen op één scherm: Product Backlog links, Sprint Backlog in het midden, taken van de geselecteerde story rechts. Stories slepen tussen panelen werkt via dnd-kit.', }, { - src: '/screenshots/solo.png', + src: '/screenshots/product-backlog.jpg', + alt: 'Product Backlog met PBIs gegroepeerd op prioriteit en stories per PBI', + title: 'Product Backlog', + caption: + 'PBIs gegroepeerd op prioriteit (Kritiek → Laag) in het linkerpaneel. Klik op een PBI om de stories rechts te zien, gerangschikt per urgentie en versleepbaar.', + }, + { + src: '/screenshots/solo-paneel.jpg', alt: 'Solo Paneel — persoonlijk Kanban-bord met drie statuskolommen', title: 'Solo Paneel', caption: 'Persoonlijk Kanban-bord per product. Toont alleen taken van stories die jij hebt geclaimd, in drie kolommen (To Do, Bezig, Klaar). Drag-and-drop tussen kolommen verandert de status.', }, - { - src: '/screenshots/insights.png', - alt: 'Insights-dashboard met voortgangsmetrics en agent-throughput', - title: 'Insights', - caption: - 'Voortgang per product: doorlooptijden, agent-throughput en sprintresultaten in één blik. Helpt patronen herkennen — welke stories liepen vast, welke gingen vlot.', - }, ].map((s) => (
{[ { - title: 'Ideas — Grill & Plan', - desc: 'Leg een idee vast in twee zinnen. Claude grilt het met kritische vragen, schrijft een YAML-plan en zet ’t om in PBI + stories + tasks. Alles via een job-queue, asynchroon.', + title: 'Hiërarchisch plannen', + desc: 'Organiseer werk in producten, Product Backlog Items, stories en taken — gegroepeerd op prioriteit en herrangschikbaar via drag-and-drop.', }, { title: 'Sprint Board + Solo Paneel', @@ -350,15 +254,19 @@ export default async function LandingPage() { }, { title: 'Lokale Claude-agents', - desc: 'Een job-queue met "Voer uit"-knop. Lokale Claude Code agents claimen werk atomisch, draaien het op jouw hardware en rapporteren status terug. Na de laatste task pusht de worker automatisch en opent een pull request via SQUASH-merge. Meerdere workers (laptop + NAS) parallel veilig.', + desc: 'Een job-queue met "Voer uit"-knop. Lokale Claude Code agents claimen werk atomisch, draaien het op jouw hardware en rapporteren status terug. Meerdere workers (laptop + NAS) parallel veilig.', }, { title: 'Realtime updates', - desc: 'SSE bovenop Postgres LISTEN/NOTIFY. Wijzigingen vanuit andere tabs of een lokale agent verschijnen binnen 1–2 seconden in je Solo Paneel — geen refresh. De Sync-tab toont per idee de live status van story → push → PR-merge.', + desc: 'SSE bovenop Postgres LISTEN/NOTIFY. Wijzigingen vanuit andere tabs of een lokale agent verschijnen binnen 1–2 seconden in je Solo Paneel — geen refresh.', }, { title: 'Async vraagkanaal', - desc: 'Loopt een agent vast op een keuze? Hij plaatst een vraag via het bel-icoon. Jij beantwoordt hem wanneer het uitkomt; de agent pakt automatisch de draad weer op. Tijdens een Grill stelt Claude vragen via hetzelfde kanaal — antwoorden komen direct terug in de Idea-timeline.', + desc: 'Loopt een agent vast op een keuze? Hij plaatst een vraag via het bel-icoon. Jij beantwoordt hem wanneer het uitkomt; de agent pakt automatisch de draad weer op.', + }, + { + title: "Todo's", + desc: 'Lichtgewicht notities los van de sprint-hiërarchie. Filter, sorteer en archiveer via een tabel-weergave — handig voor invallen die nog geen story zijn.', }, ].map(({ title, desc }) => (
@@ -387,11 +295,6 @@ cd scrum4me-mcp && npm install # 3. Start Claude Code en vraag: # "pak de volgende job uit de Scrum4Me-queue"`} -

- Liever in de UI beginnen? Open /ideas, - druk op "Nieuw idee" en klik "Grill me" — de eerste vraag verschijnt - binnen seconden in je belicoon. -

Liever zonder MCP? Gebruik de{' '} REST API met een Bearer-token @@ -412,21 +315,6 @@ cd scrum4me-mcp && npm install {/* Hiërarchie */}

Hiërarchie

- - {/* Idee-laag */} -
-
-
Idea
-
- DRAFT → GRILLED → PLAN_READY -
-
-
- ↓ materialiseert naar -
-
- - {/* Scrum-laag */}
{[ { label: 'Product', sub: 'Een softwareproject of codebase' }, @@ -452,8 +340,6 @@ cd scrum4me-mcp && npm install

Terminologie

{[ - { term: 'Idea', def: 'Een voorstel of richting voordat ’t een PBI is. Heeft een grill-fase (Claude bevraagt ’t kritisch) en een plan-fase (Claude schrijft een YAML-plan met stories en tasks). Na materialiseren ontstaat exact één PBI.' }, - { term: 'Grill / Plan', def: 'Twee asynchrone Claude-jobsoorten op een idea. Grill produceert grill_md (Q&A-transcript). Plan produceert plan_md (YAML met PBI/stories/tasks-templates) dat strikt geparseerd wordt.' }, { term: 'Product Backlog', def: 'Geordende lijst van alle PBI\'s per product, gesorteerd op prioriteit (kritiek → laag) en positie.' }, { term: 'Sprint', def: 'Actief tijdblok met een Sprint Goal. Per product is er maximaal één actieve Sprint tegelijk.' }, { term: 'Sprint Backlog', def: 'De stories die voor deze Sprint zijn geselecteerd. Stories worden vanuit de Product Backlog gesleept.' }, @@ -509,97 +395,59 @@ cd scrum4me-mcp && npm install step: '1', title: 'Account aanmaken', desc: 'Ga naar Registreren en kies een gebruikersnaam en wachtwoord. Na registratie word je direct doorgestuurd naar het dashboard. Liever passwordless? Paar je telefoon één keer en log voortaan in via QR. Of test eerst met de demo-account (alleen leesrechten).', - ideaRoute: false, }, { step: '2', title: 'Product aanmaken', desc: 'Klik op "Nieuw product" op het dashboard. Vul naam, optionele beschrijving, repo-URL en je Definition of Done in. Het product wordt zichtbaar op het dashboard.', - ideaRoute: false, }, { step: '3', - title: 'Een idee vastleggen', - desc: 'Open /ideas, klik "Nieuw idee", vul titel + één-alinea beschrijving in. Status: DRAFT.', - ideaRoute: true, + title: 'Product Backlog opbouwen', + desc: 'Open het product en voeg PBI\'s toe via het linkerpaneel. Geef elk PBI een prioriteit (Kritiek / Hoog / Gemiddeld / Laag). Klik op een PBI om in het rechterpaneel stories toe te voegen. Versleep PBI\'s en stories om de volgorde aan te passen.', }, { step: '4', - title: 'Laat Claude grillen', - desc: 'Klik "Grill me". Een lokale agent stelt kritische vragen via het belicoon. Beantwoord ze; Claude schrijft een gestructureerde grill_md. Status: GRILLED.', - ideaRoute: true, + title: 'Sprint starten', + desc: 'Klik op "Sprint starten" op de productpagina en voer een Sprint Goal in. Per product is er maximaal één actieve Sprint tegelijk. Het Sprint-scherm wordt zichtbaar via de navigatie.', }, { step: '5', - title: 'Maak het plan + materialiseer', - desc: 'Klik "Make plan". Claude genereert een YAML-plan (PBI + stories + tasks). Klik "Materialiseer" om ’t atomair om te zetten naar je product-backlog. Status: PLANNED.', - ideaRoute: true, + title: 'Sprint Board — stories slepen en taken aanmaken', + desc: 'Open het Sprint-scherm. Drie panelen verschijnen op één view: Product Backlog links, Sprint Backlog in het midden, taken rechts. Sleep stories vanuit links naar het midden om ze in de Sprint te plaatsen. Selecteer een story in het middenpaneel om de taken rechts te tonen en aan te maken.', }, { step: '6', - title: 'Product Backlog finetunen', - desc: 'Optioneel: herorden PBI\'s en stories handmatig via drag-and-drop. Het meeste werk heeft materialise al gedaan — dit is alleen voor bijsturen of toevoegen van werk dat niet uit een idee komt.', - ideaRoute: false, + title: 'Solo Paneel — claim stories en werk persoonlijk', + desc: 'Open Solo via de navigatie. Claim openstaande stories uit de actieve Sprint (knop "Toon openstaande stories") en werk je taken af in drie statuskolommen via drag-and-drop. Klik op een taak voor het detail-dialoog met implementatieplan.', }, { step: '7', - title: 'Sprint starten', - desc: 'Klik op "Sprint starten" op de productpagina en voer een Sprint Goal in. Per product is er maximaal één actieve Sprint tegelijk. Het Sprint-scherm wordt zichtbaar via de navigatie.', - ideaRoute: false, + title: 'API-token aanmaken', + desc: 'Ga naar Instellingen → Tokens. Maak een nieuw token aan en kopieer de waarde direct — die is daarna niet meer zichtbaar. Hetzelfde token werkt voor de MCP-server én voor de REST API.', }, { step: '8', - title: 'Sprint Board — stories slepen en taken aanmaken', - desc: 'Open het Sprint-scherm. Drie panelen verschijnen op één view: Product Backlog links, Sprint Backlog in het midden, taken rechts. Sleep stories vanuit links naar het midden om ze in de Sprint te plaatsen. Selecteer een story in het middenpaneel om de taken rechts te tonen en aan te maken.', - ideaRoute: false, + title: 'Claude Code koppelen', + desc: 'Aanbevolen: installeer de scrum4me-mcp-server (zie Quickstart hierboven) zodat Claude Code de Scrum4Me-tools native ziet. Alternatief: gebruik de REST API direct vanuit Codex, eigen scripts of CI-pipelines met je Bearer-token.', }, { step: '9', - title: 'Solo Paneel — claim stories en werk persoonlijk', - desc: 'Open Solo via de navigatie. Claim openstaande stories uit de actieve Sprint (knop "Toon openstaande stories") en werk je taken af in drie statuskolommen via drag-and-drop. Klik op een taak voor het detail-dialoog met implementatieplan.', - ideaRoute: false, + title: 'Story laten uitvoeren — "Voer uit"-knop', + desc: 'Klik op "Voer uit" bij een story in het Solo Paneel. De story komt in de job-queue. Een lokale Claude-agent op je machine pakt \'m op via wait_for_job, werkt het implementatieplan af, commit naar je repo en zet de status op done. De NavBar toont live hoeveel workers actief zijn.', }, { step: '10', - title: 'Claude Code koppelen', - desc: 'Maak een API-token aan in Instellingen → Tokens — hetzelfde token werkt voor MCP en REST. Aanbevolen: installeer de scrum4me-mcp-server (zie Quickstart hierboven) zodat Claude Code de tools native ziet. Alternatief: gebruik de REST API direct vanuit Codex, eigen scripts of CI-pipelines.', - ideaRoute: false, - }, - { - step: '11', - title: 'Voer uit + Sync-tab volgen', - desc: 'Klik op "Voer uit" bij een story in het Solo Paneel. De story komt in de job-queue. Een lokale agent claimt de jobs, werkt taken af, commit en — na de laatste task — pusht en mergt automatisch een PR via SQUASH. Volg de voortgang in de Sync-tab op het idea-detail.', - ideaRoute: false, - }, - { - step: '12', title: 'Sprint afronden', desc: 'Klik op "Sprint afronden" op het Sprint Board. Voor elke story kies je: markeer als Done of zet terug naar de Product Backlog. Daarna is een nieuwe Sprint aanmaakbaar.', - ideaRoute: false, }, - ].map(({ step, title, desc, ideaRoute }) => ( -
-
+ ].map(({ step, title, desc }) => ( +
+
{step}
-
- {title} - {ideaRoute && ( - - Idea-route - - )} -
+
{title}
{desc}
@@ -629,6 +477,7 @@ curl -H "Authorization: Bearer $TOKEN" \\ { method: 'PATCH', path: '/api/stories/:id/tasks/reorder', desc: 'Taakvolgorde aanpassen (body: { task_ids: string[] })' }, { method: 'POST', path: '/api/stories/:id/log', desc: 'Activiteit vastleggen: implementatieplan, testresultaat of commit' }, { method: 'PATCH', path: '/api/tasks/:id', desc: 'Taakstatus of implementatieplan bijwerken' }, + { method: 'POST', path: '/api/todos', desc: 'Todo aanmaken (body: { title, product_id })' }, ].map(({ method, path, desc }) => (
= { - QUEUED: 'bg-secondary text-secondary-foreground', - CLAIMED: 'bg-status-in-progress text-white border-transparent', - RUNNING: 'bg-warning text-warning-foreground border-transparent', - DONE: 'bg-status-done text-white border-transparent', - FAILED: 'bg-priority-high text-white border-transparent', - CANCELLED: 'bg-muted text-muted-foreground', - SKIPPED: 'bg-muted/60 text-muted-foreground italic border-transparent', -} - -const KIND_LABEL: Record = { - TASK_IMPLEMENTATION: 'Taak', - IDEA_GRILL: 'Idee Grill', - IDEA_MAKE_PLAN: 'Idee Plan', -} - -const ACTIVE_STATUSES = new Set(['QUEUED', 'CLAIMED', 'RUNNING']) - -function JobRow({ job }: { job: Job }) { - const [pending, startTransition] = useTransition() - - function handleCancel() { - startTransition(() => cancelJobAction(job.id)) - } - - function handleDelete() { - startTransition(() => deleteJobAction(job.id)) - } - - return ( - - {job.id.slice(0, 8)} - {job.user.username} - {job.product.name} - {KIND_LABEL[job.kind] ?? job.kind} - - {job.status} - - - {job.branch ?? '—'} - - - {new Date(job.created_at).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })} - - - {job.error && ( - - {job.error} - - )} - - -
- {ACTIVE_STATUSES.has(job.status) && ( - - )} - -
-
-
- ) -} - -function StatusTable({ jobs }: { jobs: Job[] }) { - return ( - - - - ID - Gebruiker - Product - Type - Status - Branch - Aangemaakt - Fout - Acties - - - - {jobs.length === 0 && ( - - - Geen jobs gevonden - - - )} - {jobs.map(job => ( - - ))} - -
- ) -} - -function CostRow({ job }: { job: Job }) { - const [pending, startTransition] = useTransition() - function handleCancel() { startTransition(() => cancelJobAction(job.id)) } - function handleDelete() { startTransition(() => deleteJobAction(job.id)) } - const costLabel = job.cost_usd != null ? `$${job.cost_usd.toFixed(4)}` : '—' - const thinkingLabel = job.actual_thinking_tokens != null ? job.actual_thinking_tokens.toLocaleString('nl-NL') : '—' - const modelMismatch = job.requested_model != null && job.model_id != null && job.requested_model !== job.model_id - const modelTitle = job.requested_model - ? `Aangevraagd: ${job.requested_model}${modelMismatch ? ' (mismatch met actueel)' : ''}` - : undefined - return ( - - {job.id.slice(0, 8)} - {job.user.username} - {job.product.name} - {KIND_LABEL[job.kind] ?? job.kind} - - {job.model_id ?? '—'} - - {thinkingLabel} - {costLabel} - - {new Date(job.created_at).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })} - - -
- {ACTIVE_STATUSES.has(job.status) && ( - - )} - -
-
-
- ) -} - -function CostsTable({ jobs }: { jobs: Job[] }) { - return ( - - - - ID - Gebruiker - Product - Type - Model - Thinking - Kosten (USD) - Aangemaakt - Acties - - - - {jobs.length === 0 && ( - - - Geen jobs gevonden - - - )} - {jobs.map((job) => )} - -
- ) -} - -export function JobsTable({ jobs }: { jobs: Job[] }) { - const [view, setView] = useState<'status' | 'costs'>('status') - - return ( -
-
- - -
- {view === 'status' ? : } -
- ) -} diff --git a/components/admin/products-table.tsx b/components/admin/products-table.tsx deleted file mode 100644 index afcdc63..0000000 --- a/components/admin/products-table.tsx +++ /dev/null @@ -1,129 +0,0 @@ -'use client' - -import { useTransition } from 'react' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, - DialogClose, -} from '@/components/ui/dialog' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { adminArchiveProductAction, adminDeleteProductAction } from '@/actions/admin/products' -import { debugProps } from '@/lib/debug' - -type Product = { - id: string - name: string - archived: boolean - created_at: Date - user: { username: string } - _count: { members: number; pbis: number } -} - -function ArchiveButton({ product }: { product: Product }) { - const [pending, startTransition] = useTransition() - - function handleToggle() { - startTransition(() => adminArchiveProductAction(product.id, !product.archived)) - } - - return ( - - ) -} - -function DeleteDialog({ product }: { product: Product }) { - const [pending, startTransition] = useTransition() - - function handleDelete() { - startTransition(() => adminDeleteProductAction(product.id)) - } - - return ( - - }> - Verwijder - - - - Product verwijderen - -

- Weet je zeker dat je {product.name} wilt verwijderen? - Dit verwijdert ook alle PBI's, stories en taken. Dit kan niet ongedaan worden gemaakt. -

- - }>Annuleer - - -
-
- ) -} - -export function ProductsTable({ products }: { products: Product[] }) { - return ( - - - - Naam - Eigenaar - Leden - PBI's - Status - Aangemaakt - Acties - - - - {products.length === 0 && ( - - - Geen producten gevonden - - - )} - {products.map(product => ( - - {product.name} - {product.user.username} - {product._count.members} - {product._count.pbis} - - {product.archived ? ( - Gearchiveerd - ) : ( - Actief - )} - - - {new Date(product.created_at).toLocaleDateString('nl-NL')} - - -
- - -
-
-
- ))} -
-
- ) -} diff --git a/components/admin/users-table.tsx b/components/admin/users-table.tsx index 32161c5..172cd41 100644 --- a/components/admin/users-table.tsx +++ b/components/admin/users-table.tsx @@ -26,7 +26,6 @@ import { updateUserRolesAction, setMustResetPasswordAction, } from '@/actions/admin/users' -import { debugProps } from '@/lib/debug' type UserWithRoles = { id: string @@ -191,8 +190,8 @@ export function UsersTable({ currentUserId: string }) { return ( - - +
+ Gebruiker Email @@ -202,7 +201,7 @@ export function UsersTable({ Acties - + {users.map(user => ( {user.username} diff --git a/components/auth/auth-form.tsx b/components/auth/auth-form.tsx index 4879c57..6ec179b 100644 --- a/components/auth/auth-form.tsx +++ b/components/auth/auth-form.tsx @@ -4,14 +4,13 @@ import { useActionState } from 'react' import { useFormStatus } from 'react-dom' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { debugProps } from '@/lib/debug' type ActionResult = { error: string | Record } | undefined function SubmitButton({ label }: { label: string }) { const { pending } = useFormStatus() return ( - ) @@ -35,7 +34,7 @@ export function AuthForm({ action, submitLabel }: AuthFormProps) { const errorMessage = getErrorMessage(state) return ( -
+
@@ -66,7 +64,6 @@ export function AuthForm({ action, submitLabel }: AuthFormProps) { minLength={8} placeholder="••••••••" className="bg-input-background border-border focus-visible:ring-primary" - data-debug-id="auth-form__password" /> diff --git a/components/backlog/active-selection-hydrator.tsx b/components/backlog/active-selection-hydrator.tsx deleted file mode 100644 index 966b672..0000000 --- a/components/backlog/active-selection-hydrator.tsx +++ /dev/null @@ -1,53 +0,0 @@ -'use client' - -import { useEffect } from 'react' -import { useUserSettingsStore } from '@/stores/user-settings/store' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' - -interface ActiveSelectionHydratorProps { - productId: string -} - -/** - * PBI-79: hydrateert de workspace-store met de actieve PBI/story die in - * user-settings staan opgeslagen. Loopt na elke (re)hydratatie en bij - * mutaties van de user-settings (bv. na sprint-switch). Wint van de - * localStorage hint-restore — user-settings is de cross-device source of - * truth. - */ -export function ActiveSelectionHydrator({ productId }: ActiveSelectionHydratorProps) { - const hydrated = useUserSettingsStore((s) => s.context.hydrated) - const persistedPbiId = useUserSettingsStore( - (s) => s.entities.settings.layout?.activePbis?.[productId] ?? undefined, - ) - const persistedStoryId = useUserSettingsStore( - (s) => s.entities.settings.layout?.activeStories?.[productId] ?? undefined, - ) - - useEffect(() => { - if (!hydrated) return - const store = useProductWorkspaceStore.getState() - // Schrijf alleen wanneer user-settings expliciet iets gekozen heeft - // (key aanwezig met string-waarde). null-key betekent 'bewust leeg' → - // we wissen lokale state. undefined-key (geen voorkeur) → niets doen. - if (persistedPbiId === undefined && persistedStoryId === undefined) return - - if (persistedPbiId === null) { - store.setActivePbi(null) - return - } - if (persistedPbiId && store.context.activePbiId !== persistedPbiId) { - store.setActivePbi(persistedPbiId) - } - if (persistedStoryId && store.context.activeStoryId !== persistedStoryId) { - // setActivePbi triggert async cascade-restore die de oude hint kan - // herstellen; de daarop volgende setActiveStory bumpt activeRequestId - // en ongeldigt de cascade. - store.setActiveStory(persistedStoryId) - } else if (persistedStoryId === null) { - store.setActiveStory(null) - } - }, [hydrated, persistedPbiId, persistedStoryId]) - - return null -} diff --git a/components/backlog/backlog-card.tsx b/components/backlog/backlog-card.tsx index 26fab89..7a93910 100644 --- a/components/backlog/backlog-card.tsx +++ b/components/backlog/backlog-card.tsx @@ -3,7 +3,6 @@ import { forwardRef } from 'react' import { cn } from '@/lib/utils' import { CodeBadge } from '@/components/shared/code-badge' -import { debugProps } from '@/lib/debug' export const PRIORITY_BORDER: Record = { 1: 'border-l-4 border-l-priority-critical', @@ -39,10 +38,9 @@ export const BacklogCard = forwardRef(function className, )} {...rest} - {...debugProps('backlog-card', 'BacklogCard', 'components/backlog/backlog-card.tsx')} >
-

{title}

+

{title}

{code && }
{(badge || actions) && ( diff --git a/components/backlog/backlog-hydration-wrapper.tsx b/components/backlog/backlog-hydration-wrapper.tsx index 30124d3..4bc5731 100644 --- a/components/backlog/backlog-hydration-wrapper.tsx +++ b/components/backlog/backlog-hydration-wrapper.tsx @@ -1,15 +1,8 @@ 'use client' -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' +import { useBacklogStore, type BacklogPbi, type BacklogStory, type BacklogTask } from '@/stores/backlog-store' import { useBacklogRealtime } from '@/lib/realtime/use-backlog-realtime' -import { useWorkspaceResync } from '@/lib/realtime/use-workspace-resync' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import type { - BacklogPbi, - BacklogStory, - BacklogTask, - ProductBacklogSnapshot, -} from '@/stores/product-workspace/types' interface InitialData { pbis: BacklogPbi[] @@ -20,55 +13,18 @@ interface InitialData { interface BacklogHydrationWrapperProps { initialData: InitialData productId: string - productName?: string children: React.ReactNode } -function fingerprint(data: InitialData): string { - const pbiPart = data.pbis.map((p) => `${p.id}:${p.status}:${p.priority}`).join(',') - const storyPart = Object.entries(data.storiesByPbi) - .flatMap(([, list]) => list.map((s) => `${s.id}:${s.status}:${s.sprint_id ?? 'null'}`)) - .join(',') - const taskPart = Object.entries(data.tasksByStory) - .flatMap(([, list]) => list.map((t) => `${t.id}:${t.status}`)) - .join(',') - return `${pbiPart}|${storyPart}|${taskPart}` -} - -// PBI-74 / Story 8: workspace-store is nu enige bron — dual-dispatch weg. -function toWorkspaceSnapshot( - data: InitialData, - productId: string, - productName: string | undefined, -): ProductBacklogSnapshot { - return { - product: { id: productId, name: productName ?? '' }, - pbis: data.pbis, - storiesByPbi: data.storiesByPbi, - tasksByStory: data.tasksByStory, - } -} - -export function BacklogHydrationWrapper({ - initialData, - productId, - productName, - children, -}: BacklogHydrationWrapperProps) { - const lastFingerprint = useRef('') +export function BacklogHydrationWrapper({ initialData, productId, children }: BacklogHydrationWrapperProps) { + const setInitialData = useBacklogStore((s) => s.setInitialData) useEffect(() => { - const fp = fingerprint(initialData) - if (fp !== lastFingerprint.current) { - lastFingerprint.current = fp - useProductWorkspaceStore - .getState() - .hydrateSnapshot(toWorkspaceSnapshot(initialData, productId, productName)) - } - }, [initialData, productId, productName]) + setInitialData(initialData) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) useBacklogRealtime(productId) - useWorkspaceResync() return <>{children} } diff --git a/components/backlog/backlog-split-pane.tsx b/components/backlog/backlog-split-pane.tsx index 8a82a95..882f13b 100644 --- a/components/backlog/backlog-split-pane.tsx +++ b/components/backlog/backlog-split-pane.tsx @@ -1,16 +1,13 @@ 'use client' import { useState } from 'react' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import { useSelectionStore } from '@/stores/selection-store' import { SplitPane, type SplitPaneProps } from '@/components/split-pane/split-pane' type Props = Omit -// PBI-74 / T-848: leest active PBI/story-ids uit workspace-store. Primitives, -// dus geen useShallow nodig. export function BacklogSplitPane(props: Props) { - const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId) - const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId) + const { selectedPbiId, selectedStoryId } = useSelectionStore() const [activeTab, setActiveTab] = useState(0) // React-recommended "derived state from props" pattern: update state during render diff --git a/components/backlog/empty-panel.tsx b/components/backlog/empty-panel.tsx index 6fd531b..e48f688 100644 --- a/components/backlog/empty-panel.tsx +++ b/components/backlog/empty-panel.tsx @@ -2,7 +2,6 @@ import { Button } from '@/components/ui/button' import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { debugProps } from '@/lib/debug' interface EmptyPanelProps { title?: string @@ -16,8 +15,8 @@ interface EmptyPanelProps { export function EmptyPanel({ title, message, action }: EmptyPanelProps) { return ( -
- {title &&

{title}

} +
+ {title &&

{title}

}

{message}

{action && ( @@ -26,7 +25,6 @@ export function EmptyPanel({ title, message, action }: EmptyPanelProps) { variant="outline" disabled={action.disabled} onClick={action.disabled ? undefined : action.onClick} - {...debugProps('empty-panel__cta')} > {action.label} diff --git a/components/backlog/new-sprint-metadata-dialog.tsx b/components/backlog/new-sprint-metadata-dialog.tsx deleted file mode 100644 index cccf9a9..0000000 --- a/components/backlog/new-sprint-metadata-dialog.tsx +++ /dev/null @@ -1,203 +0,0 @@ -'use client' - -import { useRef, useState, useTransition } from 'react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { Textarea } from '@/components/ui/textarea' -import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' -import { - useDirtyCloseGuard, - DirtyCloseGuardDialog, -} from '@/components/shared/use-dirty-close-guard' -import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut' -import { - entityDialogContentClasses, - entityDialogFooterClasses, - entityDialogHeaderClasses, -} from '@/components/shared/entity-dialog-layout' -import { useUserSettingsStore } from '@/stores/user-settings/store' -import { debugProps } from '@/lib/debug' - -interface NewSprintMetadataDialogProps { - open: boolean - productId: string - onOpenChange: (open: boolean) => void -} - -function todayLocalDate(): string { - return new Date().toLocaleDateString('en-CA') -} - -function plusWeeks(weeks: number): string { - const d = new Date() - d.setDate(d.getDate() + weeks * 7) - return d.toLocaleDateString('en-CA') -} - -export function NewSprintMetadataDialog({ - open, - productId, - onOpenChange, -}: NewSprintMetadataDialogProps) { - const [sprintGoal, setSprintGoal] = useState('') - const [startDate, setStartDate] = useState(todayLocalDate()) - const [endDate, setEndDate] = useState(plusWeeks(2)) - const [error, setError] = useState(null) - const [dirty, setDirty] = useState(false) - const [isPending, startTransition] = useTransition() - const formRef = useRef(null) - const setPendingSprintDraft = useUserSettingsStore( - (s) => s.setPendingSprintDraft, - ) - - function reset() { - setSprintGoal('') - setStartDate(todayLocalDate()) - setEndDate(plusWeeks(2)) - setError(null) - setDirty(false) - } - - const closeGuard = useDirtyCloseGuard(dirty, () => { - onOpenChange(false) - reset() - }) - - function handleSubmit(e: React.FormEvent) { - e.preventDefault() - const goal = sprintGoal.trim() - if (!goal) return - setError(null) - startTransition(async () => { - try { - await setPendingSprintDraft(productId, { - goal, - startAt: startDate || undefined, - endAt: endDate || undefined, - pbiIntent: {}, - storyOverrides: {}, - }) - reset() - onOpenChange(false) - } catch (err) { - const message = - err instanceof Error ? err.message : 'Onbekende fout bij opslaan' - setError(message) - toast.error(message) - } - }) - } - - const handleKeyDown = useDialogSubmitShortcut(() => - formRef.current?.requestSubmit(), - ) - - return ( - <> - { - if (!o) closeGuard.attemptClose() - else onOpenChange(o) - }} - > - -
- - Nieuwe sprint - -

- Geef het sprint-doel en periode op. Je selecteert daarna PBI's - en stories via vinkjes in de backlog. -

-
- - setDirty(true)} - className="flex-1 overflow-y-auto px-6 py-6 space-y-6" - > -
- -