diff --git a/CHANGELOG.md b/CHANGELOG.md index 89a9b97..40a8e6c 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/plans/v1-readiness.md`. +- v1.0 readiness checklist in `docs/old/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/backlog/index.md](./docs/backlog/index.md). +Voor de volledige milestone-historie zie [docs/old/backlog/index.md](./docs/old/backlog/index.md). --- diff --git a/CLAUDE.md b/CLAUDE.md index 9e93517..06dc2fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ title: "CLAUDE.md — Scrum4Me" status: active audience: [ai-agent] language: nl -last_updated: 2026-05-08 +last_updated: 2026-05-11 --- # CLAUDE.md — Scrum4Me @@ -19,19 +19,16 @@ Desktop-first Scrum-app voor solo developers en kleine teams. Hiërarchie: produ | `docs/INDEX.md` | Gegenereerde index van alle docs — begin hier | | `docs/specs/functional.md` | Acceptatiecriteria, user flows | | `docs/architecture.md` | Breadcrumb → 6 topische arch-bestanden | -| `docs/backlog/index.md` | Implementatievolgorde, "done when"-criteria | | `docs/api/rest-contract.md` | REST API contract voor Claude Code | | `docs/design/styling.md` | **Lees vóór elk component** — MD3-tokens, shadcn | -| `docs/plans/-*.md` | Implementatieplan per milestone | | `docs/adr/` | Architecture Decision Records — tech-keuzes (base-ui vs Radix, sort-order, demo-policy, …) | -| `docs/manual/` | 7-delige gebruiks- en operationele handleiding (workflow, git, docker, troubleshooting) | | `docs/architecture/` | 6 topische architecture-bestanden (data-model, auth, sprint-execution, …) — uitwerking van `docs/architecture.md` | +| `docs/runbooks/plan-to-pbi-flow.md` | **Na goedgekeurd plan** — PBI/Story/Task aanmaken via MCP, zónder direct uitvoeren | --- ## Hoe werk vinden -**Track A — MCP (aanbevolen):** 1. Branch aanmaken: `git checkout -b feat/` — nog **geen** `gh pr create` 2. `mcp__scrum4me__get_claude_context` → pak de next story 3. Voer taken uit in `sort_order`; update status per taak @@ -41,11 +38,6 @@ Desktop-first Scrum-app voor solo developers en kleine teams. Hiërarchie: produ 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) --- @@ -56,6 +48,7 @@ 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 @@ -69,11 +62,11 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo | Laag | Technologie | |---|---| -| Framework | Next.js 16 (App Router) + React 19 | +| Framework | Next.js 16.2 (App Router) + React 19.2 — PPR/Cache Components beschikbaar | | Taal | TypeScript strict | -| Styling | Tailwind CSS + shadcn/ui + MD3 via `app/styles/theme.css` | +| Styling | Tailwind CSS v4 + shadcn/ui + MD3 via `app/styles/theme.css` | | State | Zustand + dnd-kit | -| DB | Prisma v7 + PostgreSQL (Neon) | +| DB | Prisma v7.8 + PostgreSQL (Neon) | | Auth | iron-session + bcryptjs | | Test | Vitest (`__tests__/`, config in `vitest.config.ts`) | | Utilities | Zod, Sonner, Sharp, Vercel Analytics | @@ -121,6 +114,7 @@ Volledig schema: `lib/env.ts`. Canonieke lijst: `.env.example` — bevat ook web ## 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 @@ -141,3 +135,20 @@ npm run verify && npm run build # verify = lint + typecheck + test ``` Worker job-status protocol (wanneer `DONE` / `SKIPPED` / `FAILED`): zie [docs/runbooks/worker-idempotency.md](./docs/runbooks/worker-idempotency.md). + +### Scripts + +| Commando | Doel | +|---|---| +| `npm run dev` | Next dev op poort 3000 (`predev` kill-port draait automatisch) | +| `npm test` | Vitest eenmalig (`vitest run`) | +| `npm run test:watch` | Vitest watch-mode | +| `npm test -- ` | Eén bestand draaien — bv. `npm test -- lib/env` | +| `npm run seed` | Prisma seed via `prisma/seed.ts` | +| `npm run create-admin` | Admin-user toevoegen (`scripts/create-admin.ts`) | +| `npm run db:insert-milestone` | Milestone-script (`scripts/insert-milestone.ts`) | +| `npm run db:sync-model-prices` | Sync Anthropic-model-prijzen — vereist `ANTHROPIC_API_KEY` | +| `npm run docs` | Regenereer `docs/INDEX.md` + check links | +| `npm run diagrams` | Mermaid → SVG (`public/diagrams/architecture-{light,dark}.svg`) | + +> Vitest sluit `.claude/**` uit (relevant voor worktrees). `server-only` wordt via alias gemockt naar `tests/stubs/server-only.ts`, zodat `*-server.ts` modules laadbaar zijn in jsdom-tests. diff --git a/README.md b/README.md index 385284f..7cf3a14 100644 --- a/README.md +++ b/README.md @@ -287,5 +287,4 @@ De productieomgeving is gericht op Vercel + Neon. - [Functionele specificatie](docs/specs/functional.md) - [Technische architectuur](docs/architecture.md) -- [Backlog](docs/backlog/index.md) - [Agent-instructie audit](docs/decisions/agent-instructions-history.md) diff --git a/__tests__/actions/active-sprint-action.test.ts b/__tests__/actions/active-sprint-action.test.ts new file mode 100644 index 0000000..b87a767 --- /dev/null +++ b/__tests__/actions/active-sprint-action.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ + cookies: vi.fn().mockResolvedValue({ + set: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }), +})) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + sprint: { findFirst: vi.fn() }, + product: { findFirst: vi.fn() }, + user: { + findUnique: vi.fn(), + update: vi.fn().mockResolvedValue({}), + }, + $executeRaw: vi.fn().mockResolvedValue(1), + }, +})) + +import { prisma } from '@/lib/prisma' +import { clearActiveSprintAction } from '@/actions/active-sprint' + +const mockPrisma = prisma as unknown as { + product: { findFirst: ReturnType } + user: { + findUnique: ReturnType + update: ReturnType + } +} + +describe('clearActiveSprintAction', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('writes null instead of deleting the key', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + mockPrisma.user.findUnique.mockResolvedValueOnce({ + settings: { layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } } }, + }) + + const result = await clearActiveSprintAction('p1') + + expect(result).toEqual({ success: true }) + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: { layout?: { activeSprints?: Record } } } + } + expect(updateArg.data.settings.layout?.activeSprints).toEqual({ + p1: null, + p2: 'sprint-2', + }) + }) + + it('preserves other product keys when clearing one', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + mockPrisma.user.findUnique.mockResolvedValueOnce({ + settings: { + layout: { + activeSprints: { p1: 'sprint-1', p2: 'sprint-2', p3: null }, + }, + }, + }) + + await clearActiveSprintAction('p1') + + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: { layout?: { activeSprints?: Record } } } + } + expect(updateArg.data.settings.layout?.activeSprints).toEqual({ + p1: null, + p2: 'sprint-2', + p3: null, + }) + }) + + it('rejects when product is not accessible', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce(null) + + const result = await clearActiveSprintAction('p1') + + expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' }) + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) + + it('rejects invalid productId', async () => { + const result = await clearActiveSprintAction('') + + expect(result).toEqual({ error: 'Ongeldig product-id' }) + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/actions/commit-sprint-membership.test.ts b/__tests__/actions/commit-sprint-membership.test.ts new file mode 100644 index 0000000..af80547 --- /dev/null +++ b/__tests__/actions/commit-sprint-membership.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ + cookies: vi.fn().mockResolvedValue({ + set: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }), +})) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), + getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }), +})) +vi.mock('@/lib/rate-limit', () => ({ + enforceUserRateLimit: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/code-server', () => ({ + createWithCodeRetry: vi.fn(), + generateNextSprintCode: vi.fn(), +})) +vi.mock('@/lib/active-sprint', () => ({ + setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/lib/prisma', () => { + const txClient = { + sprint: { create: vi.fn() }, + story: { updateMany: vi.fn() }, + task: { updateMany: vi.fn() }, + } + return { + prisma: { + sprint: { findFirst: vi.fn() }, + story: { + findMany: vi.fn(), + updateMany: vi.fn(), + }, + task: { + findMany: vi.fn(), + updateMany: vi.fn(), + }, + $transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)), + __txClient: txClient, + }, + } +}) + +import { prisma } from '@/lib/prisma' +import { commitSprintMembershipAction } from '@/actions/sprints' + +type Mocked = { + sprint: { findFirst: ReturnType } + story: { + findMany: ReturnType + updateMany: ReturnType + } + task: { + findMany: ReturnType + updateMany: ReturnType + } + $transaction: ReturnType + __txClient: { + sprint: { create: ReturnType } + story: { updateMany: ReturnType } + task: { updateMany: ReturnType } + } +} +const mockPrisma = prisma as unknown as Mocked + +beforeEach(() => { + vi.clearAllMocks() + mockPrisma.sprint.findFirst.mockReset().mockResolvedValue({ + id: 'sprint-active', + product_id: 'product-1', + }) + mockPrisma.story.findMany.mockReset() + mockPrisma.story.updateMany.mockReset() + mockPrisma.task.findMany.mockReset() + mockPrisma.task.updateMany.mockReset() + mockPrisma.$transaction.mockImplementation( + async (fn: (tx: typeof mockPrisma.__txClient) => unknown) => + fn(mockPrisma.__txClient), + ) + mockPrisma.__txClient.story.updateMany.mockReset().mockResolvedValue({ count: 0 }) + mockPrisma.__txClient.task.updateMany.mockReset().mockResolvedValue({ count: 0 }) +}) + +describe('commitSprintMembershipAction', () => { + it('happy path: eligible adds + valid removes → transactie commits', async () => { + // adds-partition: alle eligible (sprint_id=null + niet DONE) + mockPrisma.story.findMany + // partition lookup + .mockResolvedValueOnce([ + { id: 's-add-1', sprint_id: null, status: 'OPEN', sprint: null }, + ]) + // removes-filter (sprint_id == activeSprintId) + .mockResolvedValueOnce([{ id: 's-rem-1' }]) + // affectedStories + .mockResolvedValueOnce([ + { pbi_id: 'pbiA' }, + { pbi_id: 'pbiB' }, + ]) + mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }]) + + const result = await commitSprintMembershipAction({ + activeSprintId: 'sprint-active', + adds: ['s-add-1'], + removes: ['s-rem-1'], + }) + + expect('success' in result).toBe(true) + if ('success' in result) { + expect(result.affectedStoryIds.sort()).toEqual(['s-add-1', 's-rem-1']) + expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB']) + expect(result.affectedTaskIds).toEqual(['t1']) + expect(result.conflicts.notEligible).toEqual([]) + expect(result.conflicts.alreadyRemoved).toEqual([]) + } + expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1) + expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledTimes(2) + expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledTimes(2) + }) + + it('add met status=DONE → conflicts.notEligible, story niet ge-update', async () => { + mockPrisma.story.findMany + .mockResolvedValueOnce([ + { id: 's-done', sprint_id: null, status: 'DONE', sprint: null }, + ]) + // removes-filter (geen removes) + .mockResolvedValueOnce([]) + + const result = await commitSprintMembershipAction({ + activeSprintId: 'sprint-active', + adds: ['s-done'], + removes: [], + }) + + expect('success' in result).toBe(true) + if ('success' in result) { + expect(result.affectedStoryIds).toEqual([]) + expect(result.conflicts.notEligible).toEqual([ + { storyId: 's-done', reason: 'DONE' }, + ]) + } + // Geen transaction omdat er niets te commiten valt. + expect(mockPrisma.$transaction).not.toHaveBeenCalled() + }) + + it('add met sprint_id in andere OPEN sprint → conflicts.notEligible IN_OTHER_SPRINT', async () => { + mockPrisma.story.findMany + .mockResolvedValueOnce([ + { + id: 's-elsewhere', + sprint_id: 'sprint-other', + status: 'IN_SPRINT', + sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' }, + }, + ]) + .mockResolvedValueOnce([]) + + const result = await commitSprintMembershipAction({ + activeSprintId: 'sprint-active', + adds: ['s-elsewhere'], + removes: [], + }) + + if ('success' in result) { + expect(result.conflicts.notEligible).toEqual([ + { storyId: 's-elsewhere', reason: 'IN_OTHER_SPRINT' }, + ]) + } + }) + + it('remove voor story die niet in actieve sprint zit → conflicts.alreadyRemoved', async () => { + mockPrisma.story.findMany + // adds-partition (geen adds) + .mockResolvedValueOnce([]) + // removes-filter — race scenario: story zit niet meer in active sprint + .mockResolvedValueOnce([]) + + const result = await commitSprintMembershipAction({ + activeSprintId: 'sprint-active', + adds: [], + removes: ['s-was-removed'], + }) + + if ('success' in result) { + expect(result.affectedStoryIds).toEqual([]) + expect(result.conflicts.alreadyRemoved).toEqual(['s-was-removed']) + } + }) + + it('transactie: story.status=IN_SPRINT bij add, =OPEN bij remove', async () => { + mockPrisma.story.findMany + .mockResolvedValueOnce([ + { id: 's-add', sprint_id: null, status: 'OPEN', sprint: null }, + ]) + .mockResolvedValueOnce([{ id: 's-rem' }]) + .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) + mockPrisma.task.findMany.mockResolvedValueOnce([]) + + await commitSprintMembershipAction({ + activeSprintId: 'sprint-active', + adds: ['s-add'], + removes: ['s-rem'], + }) + + const calls = mockPrisma.__txClient.story.updateMany.mock.calls + // Add: status=IN_SPRINT + sprint_id=sprint-active + expect(calls[0][0].data).toEqual({ + sprint_id: 'sprint-active', + status: 'IN_SPRINT', + }) + // Remove: status=OPEN + sprint_id=null + expect(calls[1][0].data).toEqual({ sprint_id: null, status: 'OPEN' }) + }) + + it('task.sprint_id wordt in dezelfde transactie ge-update', async () => { + mockPrisma.story.findMany + .mockResolvedValueOnce([ + { id: 's-add', sprint_id: null, status: 'OPEN', sprint: null }, + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) + mockPrisma.task.findMany.mockResolvedValueOnce([]) + + await commitSprintMembershipAction({ + activeSprintId: 'sprint-active', + adds: ['s-add'], + removes: [], + }) + + expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { story_id: { in: ['s-add'] } }, + data: { sprint_id: 'sprint-active' }, + }), + ) + }) + + it('return: affectedStoryIds + affectedPbiIds + affectedTaskIds + conflicts', async () => { + mockPrisma.story.findMany + .mockResolvedValueOnce([ + { id: 's-add', sprint_id: null, status: 'OPEN', sprint: null }, + ]) + .mockResolvedValueOnce([{ id: 's-rem' }]) + .mockResolvedValueOnce([ + { pbi_id: 'pbiA' }, + { pbi_id: 'pbiB' }, + ]) + mockPrisma.task.findMany.mockResolvedValueOnce([ + { id: 't1' }, + { id: 't2' }, + ]) + + const result = await commitSprintMembershipAction({ + activeSprintId: 'sprint-active', + adds: ['s-add'], + removes: ['s-rem'], + }) + + expect(result).toMatchObject({ + success: true, + affectedStoryIds: expect.arrayContaining(['s-add', 's-rem']), + affectedPbiIds: expect.arrayContaining(['pbiA', 'pbiB']), + affectedTaskIds: expect.arrayContaining(['t1', 't2']), + }) + }) + + it('rejects when sprint is not accessible', async () => { + mockPrisma.sprint.findFirst.mockResolvedValue(null) + + const result = await commitSprintMembershipAction({ + activeSprintId: 'sprint-active', + adds: [], + removes: [], + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.code).toBe(403) + } + }) +}) diff --git a/__tests__/actions/create-sprint-with-selection.test.ts b/__tests__/actions/create-sprint-with-selection.test.ts new file mode 100644 index 0000000..444008a --- /dev/null +++ b/__tests__/actions/create-sprint-with-selection.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ + cookies: vi.fn().mockResolvedValue({ + set: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }), +})) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), + getAccessibleProduct: vi.fn().mockResolvedValue({ + id: 'product-1', + user_id: 'user-1', + }), +})) +vi.mock('@/lib/rate-limit', () => ({ + enforceUserRateLimit: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/code-server', () => ({ + createWithCodeRetry: vi.fn(async (_gen, fn) => fn('SP-1')), + generateNextSprintCode: vi.fn().mockResolvedValue('SP-1'), +})) +vi.mock('@/lib/active-sprint', () => ({ + setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/lib/prisma', () => { + const txClient = { + sprint: { create: vi.fn() }, + story: { updateMany: vi.fn() }, + task: { updateMany: vi.fn() }, + } + return { + prisma: { + sprint: { + create: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + }, + story: { + findMany: vi.fn(), + updateMany: vi.fn(), + }, + task: { + findMany: vi.fn(), + updateMany: vi.fn(), + }, + pbi: { findMany: vi.fn() }, + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + $transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)), + __txClient: txClient, + }, + } +}) + +import { prisma } from '@/lib/prisma' +import { + createSprintWithSelectionAction, + type CreateSprintWithSelectionInput, +} from '@/actions/sprints' + +type Mocked = { + sprint: { + create: ReturnType + findFirst: ReturnType + update: ReturnType + } + story: { + findMany: ReturnType + updateMany: ReturnType + } + task: { + findMany: ReturnType + updateMany: ReturnType + } + $transaction: ReturnType + __txClient: { + sprint: { create: ReturnType } + story: { updateMany: ReturnType } + task: { updateMany: ReturnType } + } +} +const mockPrisma = prisma as unknown as Mocked + +function baseInput( + overrides: Partial = {}, +): CreateSprintWithSelectionInput { + return { + productId: 'product-1', + metadata: { goal: 'Sprint 1' }, + pbiIntent: {}, + storyOverrides: {}, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockPrisma.sprint.create.mockReset() + mockPrisma.story.findMany.mockReset() + mockPrisma.story.updateMany.mockReset() + mockPrisma.task.findMany.mockReset() + mockPrisma.task.updateMany.mockReset() + mockPrisma.$transaction.mockImplementation( + async (fn: (tx: typeof mockPrisma.__txClient) => unknown) => + fn(mockPrisma.__txClient), + ) + mockPrisma.__txClient.sprint.create + .mockReset() + .mockResolvedValue({ id: 'sprint-1', code: 'SP-1' }) + mockPrisma.__txClient.story.updateMany + .mockReset() + .mockResolvedValue({ count: 0 }) + mockPrisma.__txClient.task.updateMany + .mockReset() + .mockResolvedValue({ count: 0 }) +}) + +describe('createSprintWithSelectionAction', () => { + it('resolves intent=all naar alle child-stories en weert overrides.remove', async () => { + // Stap 1: stories voor PBI-A (intent=all). Plus eligibility-fetch. + mockPrisma.story.findMany + // resolve step (only for pbis with intent='all') + .mockResolvedValueOnce([ + { id: 's1', pbi_id: 'pbiA' }, + { id: 's2', pbi_id: 'pbiA' }, + { id: 's3', pbi_id: 'pbiA' }, + ]) + // partitionByEligibility — alle eligible + .mockResolvedValueOnce([ + { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, + { id: 's3', sprint_id: null, status: 'OPEN', sprint: null }, + ]) + // affectedStories + .mockResolvedValueOnce([ + { pbi_id: 'pbiA' }, + { pbi_id: 'pbiA' }, + ]) + mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }]) + + const result = await createSprintWithSelectionAction( + baseInput({ + pbiIntent: { pbiA: 'all' }, + storyOverrides: { pbiA: { add: [], remove: ['s2'] } }, + }), + ) + + expect('success' in result).toBe(true) + if ('success' in result) { + expect(result.affectedStoryIds).toEqual(['s1', 's3']) + expect(result.conflicts.notEligible).toEqual([]) + } + }) + + it('voegt storyOverrides.add toe over PBI heen (zelfs intent=none)', async () => { + // Geen PBI met intent=all → stap 1 wordt niet uitgevoerd. + mockPrisma.story.findMany + // partition + .mockResolvedValueOnce([ + { id: 's10', sprint_id: null, status: 'OPEN', sprint: null }, + ]) + // affectedStories + .mockResolvedValueOnce([{ pbi_id: 'pbiB' }]) + mockPrisma.task.findMany.mockResolvedValueOnce([]) + + const result = await createSprintWithSelectionAction( + baseInput({ + pbiIntent: { pbiB: 'none' }, + storyOverrides: { pbiB: { add: ['s10'], remove: [] } }, + }), + ) + + expect('success' in result).toBe(true) + if ('success' in result) { + expect(result.affectedStoryIds).toEqual(['s10']) + } + }) + + it('eligibility-filter classificeert DONE en cross-sprint stories', async () => { + mockPrisma.story.findMany + // resolve + .mockResolvedValueOnce([ + { id: 's1', pbi_id: 'pbiA' }, + { id: 's2', pbi_id: 'pbiA' }, + { id: 's3', pbi_id: 'pbiA' }, + ]) + // partition: s1=DONE, s2=eligible, s3=in andere OPEN sprint + .mockResolvedValueOnce([ + { id: 's1', sprint_id: null, status: 'DONE', sprint: null }, + { id: 's2', sprint_id: null, status: 'OPEN', sprint: null }, + { + id: 's3', + sprint_id: 'sprint-other', + status: 'IN_SPRINT', + sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' }, + }, + ]) + // affectedStories + .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) + mockPrisma.task.findMany.mockResolvedValueOnce([]) + + const result = await createSprintWithSelectionAction( + baseInput({ pbiIntent: { pbiA: 'all' } }), + ) + + expect('success' in result).toBe(true) + if ('success' in result) { + expect(result.affectedStoryIds).toEqual(['s2']) + expect(result.conflicts.notEligible.map((n) => n.storyId).sort()).toEqual( + ['s1', 's3'], + ) + expect(result.conflicts.crossSprint.map((c) => c.storyId)).toEqual(['s3']) + } + }) + + it('zet story.status=IN_SPRINT en task.sprint_id mee in dezelfde transactie', async () => { + mockPrisma.story.findMany + .mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }]) + .mockResolvedValueOnce([ + { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, + ]) + .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) + mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }]) + + await createSprintWithSelectionAction( + baseInput({ pbiIntent: { pbiA: 'all' } }), + ) + + expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1) + expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + sprint_id: 'sprint-1', + status: 'IN_SPRINT', + }), + }), + ) + expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: { sprint_id: 'sprint-1' }, + }), + ) + }) + + it('returnt affectedStoryIds + affectedPbiIds + affectedTaskIds', async () => { + mockPrisma.story.findMany + .mockResolvedValueOnce([ + { id: 's1', pbi_id: 'pbiA' }, + { id: 's2', pbi_id: 'pbiB' }, + ]) + .mockResolvedValueOnce([ + { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, + { id: 's2', sprint_id: null, status: 'OPEN', sprint: null }, + ]) + .mockResolvedValueOnce([{ pbi_id: 'pbiA' }, { pbi_id: 'pbiB' }]) + mockPrisma.task.findMany.mockResolvedValueOnce([ + { id: 't1' }, + { id: 't2' }, + ]) + + const result = await createSprintWithSelectionAction( + baseInput({ pbiIntent: { pbiA: 'all', pbiB: 'all' } }), + ) + + expect('success' in result).toBe(true) + if ('success' in result) { + expect(result.affectedStoryIds.sort()).toEqual(['s1', 's2']) + expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB']) + expect(result.affectedTaskIds.sort()).toEqual(['t1', 't2']) + } + }) + + it('returnt error wanneer geen eligible stories overblijven', async () => { + mockPrisma.story.findMany + .mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }]) + // s1 is DONE → notEligible + .mockResolvedValueOnce([ + { id: 's1', sprint_id: null, status: 'DONE', sprint: null }, + ]) + + const result = await createSprintWithSelectionAction( + baseInput({ pbiIntent: { pbiA: 'all' } }), + ) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.code).toBe(422) + } + }) +}) diff --git a/__tests__/actions/sprint-draft.test.ts b/__tests__/actions/sprint-draft.test.ts new file mode 100644 index 0000000..f6fa3b1 --- /dev/null +++ b/__tests__/actions/sprint-draft.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ + cookies: vi.fn().mockResolvedValue({ + set: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }), +})) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + product: { findFirst: vi.fn() }, + user: { + findUnique: vi.fn(), + update: vi.fn().mockResolvedValue({}), + }, + $executeRaw: vi.fn().mockResolvedValue(1), + }, +})) + +import { prisma } from '@/lib/prisma' +import { + clearPendingSprintDraftAction, + setPendingSprintDraftAction, +} from '@/actions/sprint-draft' +import type { PendingSprintDraft, UserSettings } from '@/lib/user-settings' + +const mockPrisma = prisma as unknown as { + product: { findFirst: ReturnType } + user: { + findUnique: ReturnType + update: ReturnType + } +} + +const validDraft: PendingSprintDraft = { + goal: 'Sprint 1', + pbiIntent: { pbiA: 'all' }, + storyOverrides: { pbiA: { add: [], remove: ['story-1'] } }, +} + +describe('setPendingSprintDraftAction', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPrisma.product.findFirst.mockReset() + mockPrisma.user.findUnique.mockReset() + mockPrisma.user.update.mockReset().mockResolvedValue({}) + }) + + it('persists draft for accessible product', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} }) + + const result = await setPendingSprintDraftAction('p1', validDraft) + + expect(result).toEqual({ success: true }) + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: UserSettings } + } + expect(updateArg.data.settings.workflow?.pendingSprintDraft?.p1).toMatchObject({ + goal: 'Sprint 1', + pbiIntent: { pbiA: 'all' }, + }) + }) + + it('preserves drafts for other products', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + mockPrisma.user.findUnique.mockResolvedValueOnce({ + settings: { + workflow: { + pendingSprintDraft: { + p2: { goal: 'P2 draft', pbiIntent: {}, storyOverrides: {} }, + }, + }, + }, + }) + + await setPendingSprintDraftAction('p1', validDraft) + + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: UserSettings } + } + const drafts = updateArg.data.settings.workflow?.pendingSprintDraft + expect(Object.keys(drafts ?? {})).toEqual(expect.arrayContaining(['p1', 'p2'])) + }) + + it('rejects invalid draft (empty goal)', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + + const result = await setPendingSprintDraftAction('p1', { + ...validDraft, + goal: '', + } as PendingSprintDraft) + + expect(result).toHaveProperty('error') + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) + + it('rejects when product not accessible', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce(null) + + const result = await setPendingSprintDraftAction('p1', validDraft) + + expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' }) + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) +}) + +describe('clearPendingSprintDraftAction', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPrisma.product.findFirst.mockReset() + mockPrisma.user.findUnique.mockReset() + mockPrisma.user.update.mockReset().mockResolvedValue({}) + }) + + it('removes draft key for product', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + mockPrisma.user.findUnique.mockResolvedValueOnce({ + settings: { + workflow: { + pendingSprintDraft: { + p1: { goal: 'gone', pbiIntent: {}, storyOverrides: {} }, + p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} }, + }, + }, + }, + }) + + await clearPendingSprintDraftAction('p1') + + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: UserSettings } + } + expect(updateArg.data.settings.workflow?.pendingSprintDraft).toEqual({ + p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} }, + }) + }) + + it('is a no-op when there is no draft for the product', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} }) + + const result = await clearPendingSprintDraftAction('p1') + + expect(result).toEqual({ success: true }) + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) + + it('rejects when product not accessible', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce(null) + + const result = await clearPendingSprintDraftAction('p1') + + expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' }) + }) +}) diff --git a/__tests__/actions/update-sprint.test.ts b/__tests__/actions/update-sprint.test.ts new file mode 100644 index 0000000..f51219d --- /dev/null +++ b/__tests__/actions/update-sprint.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ + cookies: vi.fn().mockResolvedValue({ + set: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }), +})) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), + getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }), +})) +vi.mock('@/lib/rate-limit', () => ({ + enforceUserRateLimit: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/code-server', () => ({ + createWithCodeRetry: vi.fn(), + generateNextSprintCode: vi.fn(), +})) +vi.mock('@/lib/active-sprint', () => ({ + setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + sprint: { + findFirst: vi.fn(), + update: vi.fn(), + }, + story: { + findMany: vi.fn(), + updateMany: vi.fn(), + }, + task: { + findMany: vi.fn(), + updateMany: vi.fn(), + }, + $transaction: vi.fn(), + }, +})) + +import { prisma } from '@/lib/prisma' +import { updateSprintAction } from '@/actions/sprints' + +type Mocked = { + sprint: { + findFirst: ReturnType + update: ReturnType + } +} +const mockPrisma = prisma as unknown as Mocked + +beforeEach(() => { + vi.clearAllMocks() + mockPrisma.sprint.findFirst.mockReset().mockResolvedValue({ + id: 'sprint-1', + product_id: 'product-1', + }) + mockPrisma.sprint.update.mockReset().mockResolvedValue({}) +}) + +describe('updateSprintAction', () => { + it('updates sprint_goal alone', async () => { + const result = await updateSprintAction({ + sprintId: 'sprint-1', + fields: { goal: 'Nieuw doel' }, + }) + + expect('success' in result).toBe(true) + expect(mockPrisma.sprint.update).toHaveBeenCalledWith({ + where: { id: 'sprint-1' }, + data: { sprint_goal: 'Nieuw doel' }, + }) + }) + + it('updates dates only', async () => { + await updateSprintAction({ + sprintId: 'sprint-1', + fields: { startAt: '2026-06-01', endAt: '2026-06-14' }, + }) + + expect(mockPrisma.sprint.update).toHaveBeenCalledWith({ + where: { id: 'sprint-1' }, + data: { + start_date: new Date('2026-06-01'), + end_date: new Date('2026-06-14'), + }, + }) + }) + + it('accepts null to clear a date', async () => { + await updateSprintAction({ + sprintId: 'sprint-1', + fields: { startAt: null }, + }) + + expect(mockPrisma.sprint.update).toHaveBeenCalledWith({ + where: { id: 'sprint-1' }, + data: { start_date: null }, + }) + }) + + it('rejects when sprint not accessible', async () => { + mockPrisma.sprint.findFirst.mockResolvedValue(null) + + const result = await updateSprintAction({ + sprintId: 'sprint-1', + fields: { goal: 'x' }, + }) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.code).toBe(403) + } + expect(mockPrisma.sprint.update).not.toHaveBeenCalled() + }) + + it('rejects empty goal', async () => { + const result = await updateSprintAction({ + sprintId: 'sprint-1', + fields: { goal: '' }, + }) + + expect('error' in result).toBe(true) + expect(mockPrisma.sprint.update).not.toHaveBeenCalled() + }) + + it('rejects when no fields are supplied', async () => { + const result = await updateSprintAction({ + sprintId: 'sprint-1', + fields: {}, + }) + + // Schema-refine should reject; OR action treats empty data as no-op success. + // Current implementation: refine forces minstens één veld → 422 error. + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.code).toBe(422) + } + }) +}) diff --git a/__tests__/api/cross-sprint-blocks.test.ts b/__tests__/api/cross-sprint-blocks.test.ts new file mode 100644 index 0000000..5447900 --- /dev/null +++ b/__tests__/api/cross-sprint-blocks.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + product: { findFirst: vi.fn() }, + story: { findMany: vi.fn() }, + }, +})) + +vi.mock('@/lib/api-auth', () => ({ + authenticateApiRequest: vi.fn(), +})) + +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), +})) + +import { prisma } from '@/lib/prisma' +import { authenticateApiRequest } from '@/lib/api-auth' +import { GET } from '@/app/api/products/[id]/cross-sprint-blocks/route' + +const mockPrisma = prisma as unknown as { + product: { findFirst: ReturnType } + story: { findMany: ReturnType } +} +const mockAuth = authenticateApiRequest as unknown as ReturnType + +function makeRequest(url: string) { + return new Request(url) +} + +describe('GET /api/products/[id]/cross-sprint-blocks', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPrisma.product.findFirst.mockReset() + mockPrisma.story.findMany.mockReset() + mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' }) + }) + + it('returns blocking sprint info per story for happy path', async () => { + mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) + mockPrisma.story.findMany.mockResolvedValue([ + { + id: 'story-1', + sprint: { id: 'sprint-x', code: 'SP-X' }, + }, + { + id: 'story-2', + sprint: { id: 'sprint-y', code: 'SP-Y' }, + }, + ]) + + const req = makeRequest( + 'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1&pbiIds=pbiA', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ + 'story-1': { sprintId: 'sprint-x', sprintName: 'SP-X' }, + 'story-2': { sprintId: 'sprint-y', sprintName: 'SP-Y' }, + }) + }) + + it('rejects when pbiIds is missing', async () => { + const req = makeRequest( + 'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(400) + }) + + it('rejects when pbiIds is empty', async () => { + const req = makeRequest( + 'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(400) + }) + + it('returns 404 when product is not accessible', async () => { + mockPrisma.product.findFirst.mockResolvedValue(null) + const req = makeRequest( + 'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(404) + }) + + it('returns auth error when authenticate fails', async () => { + mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 }) + const req = makeRequest( + 'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(401) + }) + + it('passes NOT excludeSprintId to prisma when provided', async () => { + mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) + mockPrisma.story.findMany.mockResolvedValue([]) + + const req = makeRequest( + 'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-active&pbiIds=pbiA', + ) + await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + + const callArg = mockPrisma.story.findMany.mock.calls[0][0] as { + where: Record + } + expect(callArg.where).toMatchObject({ + pbi_id: { in: ['pbiA'] }, + product_id: 'p1', + sprint_id: { not: null }, + NOT: { sprint_id: 'sp-active' }, + sprint: { status: 'OPEN' }, + }) + }) +}) diff --git a/__tests__/api/sprint-membership-summary.test.ts b/__tests__/api/sprint-membership-summary.test.ts new file mode 100644 index 0000000..c526210 --- /dev/null +++ b/__tests__/api/sprint-membership-summary.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + product: { findFirst: vi.fn() }, + story: { groupBy: vi.fn() }, + }, +})) + +vi.mock('@/lib/api-auth', () => ({ + authenticateApiRequest: vi.fn(), +})) + +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), +})) + +import { prisma } from '@/lib/prisma' +import { authenticateApiRequest } from '@/lib/api-auth' +import { GET } from '@/app/api/products/[id]/sprint-membership-summary/route' + +const mockPrisma = prisma as unknown as { + product: { findFirst: ReturnType } + story: { groupBy: ReturnType } +} +const mockAuth = authenticateApiRequest as unknown as ReturnType + +function makeRequest(url: string) { + return new Request(url) +} + +describe('GET /api/products/[id]/sprint-membership-summary', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPrisma.product.findFirst.mockReset() + mockPrisma.story.groupBy.mockReset() + mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' }) + }) + + it('returns counts per PBI for happy path', async () => { + mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) + mockPrisma.story.groupBy + .mockResolvedValueOnce([ + { pbi_id: 'pbiA', _count: { _all: 5 } }, + { pbi_id: 'pbiB', _count: { _all: 3 } }, + ]) + .mockResolvedValueOnce([{ pbi_id: 'pbiA', _count: { _all: 2 } }]) + + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ + pbiA: { total: 5, inSprint: 2 }, + pbiB: { total: 3, inSprint: 0 }, + }) + }) + + it('rejects when pbiIds is missing', async () => { + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(400) + }) + + it('rejects when pbiIds is empty', async () => { + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(400) + }) + + it('rejects when sprintId is missing', async () => { + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?pbiIds=pbiA', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(400) + }) + + it('returns 404 when product is not accessible', async () => { + mockPrisma.product.findFirst.mockResolvedValue(null) + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(404) + }) + + it('returns auth error when authenticate fails', async () => { + mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 }) + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(401) + }) + + it('returns zero counts for PBIs without stories', async () => { + mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) + mockPrisma.story.groupBy + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + + const body = await res.json() + expect(body).toEqual({ + pbiA: { total: 0, inSprint: 0 }, + pbiB: { total: 0, inSprint: 0 }, + }) + }) +}) diff --git a/__tests__/lib/active-sprint.test.ts b/__tests__/lib/active-sprint.test.ts new file mode 100644 index 0000000..b2de7ef --- /dev/null +++ b/__tests__/lib/active-sprint.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + sprint: { findFirst: vi.fn() }, + user: { + findUnique: vi.fn(), + update: vi.fn().mockResolvedValue({}), + }, + $executeRaw: vi.fn().mockResolvedValue(1), + }, +})) + +import { prisma } from '@/lib/prisma' +import type { UserSettings } from '@/lib/user-settings' +import { + clearActiveSprintInSettings, + readStoredActiveSprintState, + resolveActiveSprint, +} from '@/lib/active-sprint' + +const mockPrisma = prisma as unknown as { + sprint: { findFirst: ReturnType } + user: { + findUnique: ReturnType + update: ReturnType + } + $executeRaw: ReturnType +} + +function withSettings(settings: UserSettings) { + mockPrisma.user.findUnique.mockResolvedValueOnce({ settings }) +} + +describe('readStoredActiveSprintState', () => { + it('returns unset when activeSprints map is absent', () => { + expect(readStoredActiveSprintState({}, 'p1')).toEqual({ kind: 'unset' }) + }) + + it('returns unset when productId key is absent', () => { + const settings: UserSettings = { + layout: { activeSprints: { p2: 'sprint-2' } }, + } + expect(readStoredActiveSprintState(settings, 'p1')).toEqual({ + kind: 'unset', + }) + }) + + it('returns cleared when key is present with null value', () => { + const settings: UserSettings = { + layout: { activeSprints: { p1: null } }, + } + expect(readStoredActiveSprintState(settings, 'p1')).toEqual({ + kind: 'cleared', + }) + }) + + it('returns set when key is present with string value', () => { + const settings: UserSettings = { + layout: { activeSprints: { p1: 'sprint-1' } }, + } + expect(readStoredActiveSprintState(settings, 'p1')).toEqual({ + kind: 'set', + sprintId: 'sprint-1', + }) + }) +}) + +describe('resolveActiveSprint', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns null without fallback when key is explicitly null (cleared)', async () => { + withSettings({ layout: { activeSprints: { p1: null } } }) + + const result = await resolveActiveSprint('p1', 'user-1') + + expect(result).toBeNull() + expect(mockPrisma.sprint.findFirst).not.toHaveBeenCalled() + }) + + it('returns the stored sprint when key is set and sprint exists', async () => { + withSettings({ layout: { activeSprints: { p1: 'sprint-1' } } }) + mockPrisma.sprint.findFirst.mockResolvedValueOnce({ + id: 'sprint-1', + code: 'SP-1', + status: 'OPEN', + }) + + const result = await resolveActiveSprint('p1', 'user-1') + + expect(result).toEqual({ id: 'sprint-1', code: 'SP-1', status: 'OPEN' }) + expect(mockPrisma.sprint.findFirst).toHaveBeenCalledTimes(1) + }) + + it('falls back when stored sprint is not found in DB', async () => { + withSettings({ layout: { activeSprints: { p1: 'stale-id' } } }) + mockPrisma.sprint.findFirst + .mockResolvedValueOnce(null) // stored lookup misses + .mockResolvedValueOnce({ id: 'sprint-open', code: 'SP-O', status: 'OPEN' }) + + const result = await resolveActiveSprint('p1', 'user-1') + + expect(result).toEqual({ + id: 'sprint-open', + code: 'SP-O', + status: 'OPEN', + }) + }) + + it('falls back to first OPEN sprint when key is absent', async () => { + withSettings({}) + mockPrisma.sprint.findFirst.mockResolvedValueOnce({ + id: 'sprint-open', + code: 'SP-O', + status: 'OPEN', + }) + + const result = await resolveActiveSprint('p1', 'user-1') + + expect(result).toEqual({ + id: 'sprint-open', + code: 'SP-O', + status: 'OPEN', + }) + }) + + it('falls back to recent CLOSED sprint when no OPEN exists', async () => { + withSettings({}) + mockPrisma.sprint.findFirst + .mockResolvedValueOnce(null) // no OPEN + .mockResolvedValueOnce({ + id: 'sprint-closed', + code: 'SP-C', + status: 'CLOSED', + }) + + const result = await resolveActiveSprint('p1', 'user-1') + + expect(result).toEqual({ + id: 'sprint-closed', + code: 'SP-C', + status: 'CLOSED', + }) + }) + + it('returns null when key absent and no sprints exist', async () => { + withSettings({}) + mockPrisma.sprint.findFirst.mockResolvedValue(null) + + const result = await resolveActiveSprint('p1', 'user-1') + + expect(result).toBeNull() + }) +}) + +describe('clearActiveSprintInSettings', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('writes null instead of deleting the key', async () => { + withSettings({ + layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } }, + }) + + await clearActiveSprintInSettings('user-1', 'p1') + + expect(mockPrisma.user.update).toHaveBeenCalledTimes(1) + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: UserSettings } + } + expect(updateArg.data.settings.layout?.activeSprints).toEqual({ + p1: null, + p2: 'sprint-2', + }) + }) + + it('adds the key with null when previously unset', async () => { + withSettings({}) + + await clearActiveSprintInSettings('user-1', 'p1') + + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: UserSettings } + } + expect(updateArg.data.settings.layout?.activeSprints).toEqual({ p1: null }) + }) +}) diff --git a/__tests__/lib/sprint-conflicts.test.ts b/__tests__/lib/sprint-conflicts.test.ts new file mode 100644 index 0000000..9eb3a5d --- /dev/null +++ b/__tests__/lib/sprint-conflicts.test.ts @@ -0,0 +1,195 @@ +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 any sprint (open status)', () => { + expect( + isEligibleForSprint({ + sprint_id: 'abc', + status: 'OPEN' as StoryStatus, + }), + ).toBe(false) + }) + + it('returns false when story is in any sprint (done status)', () => { + expect( + isEligibleForSprint({ + sprint_id: 'abc', + status: 'DONE' 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('does NOT mark crossSprint for stories in CLOSED other sprint', async () => { + 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: 'IN_OTHER_SPRINT' }, + ]) + }) + + 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/user-settings.test.ts b/__tests__/lib/user-settings.test.ts index c7cd5d9..1bff8ea 100644 --- a/__tests__/lib/user-settings.test.ts +++ b/__tests__/lib/user-settings.test.ts @@ -122,4 +122,65 @@ describe('UserSettingsSchema', () => { 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 unknown intent value', () => { + const result = UserSettingsSchema.safeParse({ + workflow: { + pendingSprintDraft: { + p: { goal: 'x', pbiIntent: { a: 'partial' } }, + }, + }, + }) + expect(result.success).toBe(false) + }) }) diff --git a/__tests__/stores/product-workspace/sprint-membership.test.ts b/__tests__/stores/product-workspace/sprint-membership.test.ts new file mode 100644 index 0000000..6f271de --- /dev/null +++ b/__tests__/stores/product-workspace/sprint-membership.test.ts @@ -0,0 +1,341 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import { + selectIsDirty, + selectPbiTriState, + selectPendingCount, + selectStoryEffectiveInSprint, + selectStoryIsBlocked, +} from '@/stores/product-workspace/selectors' +import type { BacklogStory } from '@/stores/product-workspace/types' + +function resetMembership() { + useProductWorkspaceStore.setState((s) => { + s.entities.storiesById = {} + s.relations.storyIdsByPbi = {} + s.sprintMembership = { + pbiSummary: {}, + crossSprintBlocks: {}, + pending: { adds: [], removes: [] }, + loadedSummaryForSprintId: null, + } + }) +} + +function seedStory(id: string, pbiId: string, sprintId: string | null): BacklogStory { + return { + id, + code: id, + title: id, + description: null, + acceptance_criteria: null, + priority: 2, + sort_order: 1, + status: sprintId ? 'IN_SPRINT' : 'OPEN', + pbi_id: pbiId, + sprint_id: sprintId, + created_at: new Date('2026-01-01'), + } +} + +beforeEach(() => { + resetMembership() +}) + +describe('toggleStorySprintMembership', () => { + it('adds storyId to pending.adds when currently not in sprint', () => { + useProductWorkspaceStore.getState().toggleStorySprintMembership('s1', false) + const pending = useProductWorkspaceStore.getState().sprintMembership.pending + expect(pending.adds).toEqual(['s1']) + expect(pending.removes).toEqual([]) + }) + + it('adds storyId to pending.removes when currently in sprint', () => { + useProductWorkspaceStore.getState().toggleStorySprintMembership('s1', true) + const pending = useProductWorkspaceStore.getState().sprintMembership.pending + expect(pending.removes).toEqual(['s1']) + expect(pending.adds).toEqual([]) + }) + + it('cancels out: toggle add → toggle remove same story (in-sprint) clears pending', () => { + const store = useProductWorkspaceStore.getState() + store.toggleStorySprintMembership('s1', false) // adds + // Story now appears to be "in sprint" via pending; calling with true should cancel + store.toggleStorySprintMembership('s1', false) // second click with same baseline + const pending = useProductWorkspaceStore.getState().sprintMembership.pending + expect(pending.adds).toEqual([]) + expect(pending.removes).toEqual([]) + }) + + it('removes from pending.removes when toggled back', () => { + const store = useProductWorkspaceStore.getState() + store.toggleStorySprintMembership('s1', true) + store.toggleStorySprintMembership('s1', true) + const pending = useProductWorkspaceStore.getState().sprintMembership.pending + expect(pending.removes).toEqual([]) + expect(pending.adds).toEqual([]) + }) + + it('resetSprintMembershipPending empties both arrays', () => { + const store = useProductWorkspaceStore.getState() + store.toggleStorySprintMembership('s1', false) + store.toggleStorySprintMembership('s2', true) + store.resetSprintMembershipPending() + const pending = useProductWorkspaceStore.getState().sprintMembership.pending + expect(pending.adds).toEqual([]) + expect(pending.removes).toEqual([]) + }) +}) + +describe('selectPbiTriState', () => { + function seedSummary(pbiId: string, total: number, inSprint: number) { + useProductWorkspaceStore.setState((s) => { + s.sprintMembership.pbiSummary[pbiId] = { + totalStoryCount: total, + inActiveSprintStoryCount: inSprint, + } + }) + } + + it('returns empty for PBI without summary', () => { + expect( + selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), + ).toBe('empty') + }) + + it('returns empty when totalStoryCount == 0', () => { + seedSummary('pbi-1', 0, 0) + expect( + selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), + ).toBe('empty') + }) + + it('returns full when all stories in sprint (no pending)', () => { + seedSummary('pbi-1', 3, 3) + expect( + selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), + ).toBe('full') + }) + + it('returns partial when some stories in sprint', () => { + seedSummary('pbi-1', 3, 2) + expect( + selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), + ).toBe('partial') + }) + + it('returns empty when inSprint == 0', () => { + seedSummary('pbi-1', 3, 0) + expect( + selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), + ).toBe('empty') + }) + + it('applies pending adds when stories are loaded for the PBI', () => { + seedSummary('pbi-1', 3, 1) + useProductWorkspaceStore.setState((s) => { + s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3'] + s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-1') + s.entities.storiesById['s2'] = seedStory('s2', 'pbi-1', null) + s.entities.storiesById['s3'] = seedStory('s3', 'pbi-1', null) + s.sprintMembership.pending.adds = ['s2', 's3'] + }) + expect( + selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), + ).toBe('full') + }) + + it('applies pending removes when stories are loaded for the PBI', () => { + seedSummary('pbi-1', 3, 3) + useProductWorkspaceStore.setState((s) => { + s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3'] + s.sprintMembership.pending.removes = ['s2'] + }) + expect( + selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), + ).toBe('partial') + }) + + it('ignores pending entries for stories of other PBIs', () => { + seedSummary('pbi-1', 3, 3) + useProductWorkspaceStore.setState((s) => { + s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3'] + s.sprintMembership.pending.removes = ['s99'] // not in pbi-1 + }) + expect( + selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), + ).toBe('full') + }) +}) + +describe('selectStoryEffectiveInSprint', () => { + it('returns true when story.sprint_id matches activeSprintId and no pending', () => { + useProductWorkspaceStore.setState((s) => { + s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A') + }) + expect( + selectStoryEffectiveInSprint( + useProductWorkspaceStore.getState(), + 's1', + 'sprint-A', + ), + ).toBe(true) + }) + + it('returns false when story.sprint_id is null', () => { + useProductWorkspaceStore.setState((s) => { + s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', null) + }) + expect( + selectStoryEffectiveInSprint( + useProductWorkspaceStore.getState(), + 's1', + 'sprint-A', + ), + ).toBe(false) + }) + + it('returns true when story in pending.adds even if DB says no', () => { + useProductWorkspaceStore.setState((s) => { + s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', null) + s.sprintMembership.pending.adds = ['s1'] + }) + expect( + selectStoryEffectiveInSprint( + useProductWorkspaceStore.getState(), + 's1', + 'sprint-A', + ), + ).toBe(true) + }) + + it('returns false when story in pending.removes even if DB says yes', () => { + useProductWorkspaceStore.setState((s) => { + s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A') + s.sprintMembership.pending.removes = ['s1'] + }) + expect( + selectStoryEffectiveInSprint( + useProductWorkspaceStore.getState(), + 's1', + 'sprint-A', + ), + ).toBe(false) + }) + + it('returns false when activeSprintId is null', () => { + useProductWorkspaceStore.setState((s) => { + s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A') + }) + expect( + selectStoryEffectiveInSprint( + useProductWorkspaceStore.getState(), + 's1', + null, + ), + ).toBe(false) + }) +}) + +describe('selectStoryIsBlocked', () => { + it('returns null when no block', () => { + expect( + selectStoryIsBlocked(useProductWorkspaceStore.getState(), 's1'), + ).toBeNull() + }) + + it('returns block info when story is in another sprint', () => { + useProductWorkspaceStore.setState((s) => { + s.sprintMembership.crossSprintBlocks['s1'] = { + sprintId: 'sprint-x', + sprintName: 'SP-X', + } + }) + expect( + selectStoryIsBlocked(useProductWorkspaceStore.getState(), 's1'), + ).toEqual({ sprintId: 'sprint-x', sprintName: 'SP-X' }) + }) +}) + +describe('selectIsDirty + selectPendingCount', () => { + it('clean by default', () => { + expect(selectIsDirty(useProductWorkspaceStore.getState())).toBe(false) + expect(selectPendingCount(useProductWorkspaceStore.getState())).toBe(0) + }) + + it('counts adds + removes', () => { + useProductWorkspaceStore.setState((s) => { + s.sprintMembership.pending = { + adds: ['a1', 'a2'], + removes: ['r1'], + } + }) + expect(selectIsDirty(useProductWorkspaceStore.getState())).toBe(true) + expect(selectPendingCount(useProductWorkspaceStore.getState())).toBe(3) + }) +}) + +describe('fetch helpers', () => { + it('fetchSprintMembershipSummary populates store and gates by sprintId', async () => { + const originalFetch = globalThis.fetch + const responseBody = { + pbiA: { totalStoryCount: 5, inActiveSprintStoryCount: 2 }, + } + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(responseBody), { status: 200 }), + ) as unknown as typeof fetch + try { + await useProductWorkspaceStore + .getState() + .fetchSprintMembershipSummary('prod-1', 'sprint-A', ['pbiA']) + + const slice = useProductWorkspaceStore.getState().sprintMembership + expect(slice.pbiSummary.pbiA).toEqual({ + totalStoryCount: 5, + inActiveSprintStoryCount: 2, + }) + expect(slice.loadedSummaryForSprintId).toBe('sprint-A') + } finally { + globalThis.fetch = originalFetch + } + }) + + it('fetchCrossSprintBlocks populates store', async () => { + const originalFetch = globalThis.fetch + const responseBody = { + 's1': { sprintId: 'sprint-x', sprintName: 'SP-X' }, + } + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(responseBody), { status: 200 }), + ) as unknown as typeof fetch + try { + await useProductWorkspaceStore + .getState() + .fetchCrossSprintBlocks('prod-1', 'sprint-A', ['pbiA']) + + const slice = useProductWorkspaceStore.getState().sprintMembership + expect(slice.crossSprintBlocks['s1']).toEqual({ + sprintId: 'sprint-x', + sprintName: 'SP-X', + }) + } finally { + globalThis.fetch = originalFetch + } + }) + + it('fetchSprintMembershipSummary is a no-op for empty pbiIds', async () => { + const fetchSpy = vi.fn() + const originalFetch = globalThis.fetch + globalThis.fetch = fetchSpy as unknown as typeof fetch + try { + await useProductWorkspaceStore + .getState() + .fetchSprintMembershipSummary('prod-1', 'sprint-A', []) + expect(fetchSpy).not.toHaveBeenCalled() + } finally { + globalThis.fetch = originalFetch + } + }) +}) diff --git a/__tests__/stores/product-workspace/store.test.ts b/__tests__/stores/product-workspace/store.test.ts index bfa6cfd..f2db43b 100644 --- a/__tests__/stores/product-workspace/store.test.ts +++ b/__tests__/stores/product-workspace/store.test.ts @@ -56,6 +56,12 @@ function resetStore() { s.sync.lastResyncAt = null s.sync.resyncReason = null s.pendingMutations = {} + s.sprintMembership = { + pbiSummary: {}, + crossSprintBlocks: {}, + pending: { adds: [], removes: [] }, + loadedSummaryForSprintId: null, + } Object.assign(s, originalActions) }) } diff --git a/__tests__/stores/user-settings.test.ts b/__tests__/stores/user-settings.test.ts index 019d618..e159bf8 100644 --- a/__tests__/stores/user-settings.test.ts +++ b/__tests__/stores/user-settings.test.ts @@ -1,12 +1,21 @@ 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) => { @@ -20,6 +29,8 @@ function resetStore() { beforeEach(() => { resetStore() updateAction.mockReset() + setDraftAction.mockReset() + clearDraftAction.mockReset() }) afterEach(() => { @@ -85,6 +96,130 @@ describe('useUserSettingsStore', () => { 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' } } }, diff --git a/actions/active-sprint.ts b/actions/active-sprint.ts index 7705206..e774376 100644 --- a/actions/active-sprint.ts +++ b/actions/active-sprint.ts @@ -7,7 +7,11 @@ import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { productAccessFilter } from '@/lib/product-access' -import { setActiveSprintInSettings } from '@/lib/active-sprint' +import { + clearActiveSprintInSettings, + setActiveSelectionInSettings, + setActiveSprintInSettings, +} from '@/lib/active-sprint' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -18,6 +22,10 @@ const setSchema = z.object({ 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' } @@ -41,6 +49,99 @@ export async function setActiveSprintAction(productId: string, sprintId: string) 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 diff --git a/actions/sprint-draft.ts b/actions/sprint-draft.ts new file mode 100644 index 0000000..37beb54 --- /dev/null +++ b/actions/sprint-draft.ts @@ -0,0 +1,121 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { z } from 'zod' +import type { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' +import { productAccessFilter } from '@/lib/product-access' +import { + mergeSettings, + parseUserSettings, + type PendingSprintDraft, + type UserSettings, +} from '@/lib/user-settings' + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +const StoryOverridesSchema = z.object({ + add: z.array(z.string()), + remove: z.array(z.string()), +}).strict() + +const DraftSchema = z.object({ + goal: z.string().min(1), + startAt: z.string().date().optional(), + endAt: z.string().date().optional(), + pbiIntent: z.record(z.string(), z.enum(['all', 'none'])).default({}), + storyOverrides: z.record(z.string(), StoryOverridesSchema).default({}), +}).strict() + +const SetSchema = z.object({ + productId: z.string().min(1), + draft: DraftSchema, +}) + +const ClearSchema = z.object({ + productId: z.string().min(1), +}) + +async function ensureProductAccess(userId: string, productId: string) { + return prisma.product.findFirst({ + where: { id: productId, ...productAccessFilter(userId) }, + select: { id: true }, + }) +} + +async function readUserSettings(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { settings: true }, + }) + return parseUserSettings(user?.settings) +} + +async function writeUserSettings(userId: string, next: UserSettings) { + await prisma.user.update({ + where: { id: userId }, + data: { settings: next as unknown as Prisma.InputJsonValue }, + }) +} + +export async function setPendingSprintDraftAction( + productId: string, + draft: PendingSprintDraft, +) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = SetSchema.safeParse({ productId, draft }) + if (!parsed.success) { + return { error: 'Ongeldige draft', issues: parsed.error.issues } + } + + const product = await ensureProductAccess(session.userId, parsed.data.productId) + if (!product) return { error: 'Product niet gevonden of niet toegankelijk' } + + const current = await readUserSettings(session.userId) + const patch: Partial = { + workflow: { + pendingSprintDraft: { + ...(current.workflow?.pendingSprintDraft ?? {}), + [parsed.data.productId]: parsed.data.draft, + }, + }, + } + await writeUserSettings(session.userId, mergeSettings(current, patch)) + revalidatePath('/', 'layout') + return { success: true } +} + +export async function clearPendingSprintDraftAction(productId: string) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = ClearSchema.safeParse({ productId }) + if (!parsed.success) return { error: 'Ongeldig product-id' } + + const product = await ensureProductAccess(session.userId, parsed.data.productId) + if (!product) return { error: 'Product niet gevonden of niet toegankelijk' } + + const current = await readUserSettings(session.userId) + const existingMap = current.workflow?.pendingSprintDraft + if (!existingMap || !(parsed.data.productId in existingMap)) { + return { success: true } + } + const nextMap = { ...existingMap } + delete nextMap[parsed.data.productId] + const next: UserSettings = { + ...current, + workflow: { ...current.workflow, pendingSprintDraft: nextMap }, + } + await writeUserSettings(session.userId, next) + revalidatePath('/', 'layout') + return { success: true } +} diff --git a/actions/sprints.ts b/actions/sprints.ts index 9471b51..499a87e 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -15,8 +15,358 @@ 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) } @@ -53,10 +403,10 @@ export async function createSprintAction(_prevState: unknown, formData: FormData const product = await getAccessibleProduct(parsed.data.productId, session.userId) if (!product) return { error: 'Product niet gevonden', code: 403 } - const existing = await prisma.sprint.findFirst({ - where: { product_id: parsed.data.productId, status: 'OPEN' }, - }) - if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id, code: 422 } + // PBI-79 / ST-1342: multi-OPEN sprints toegestaan. Bestaande OPEN sprints + // op hetzelfde product zijn geen reden meer om aanmaak te blokkeren — + // cross-sprint-conflicts worden per-story afgevangen in de membership- + // commit-flow. const sprint = await createWithCodeRetry( () => generateNextSprintCode(parsed.data.productId), diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index 386fe56..1b645bf 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -15,7 +15,11 @@ import { UrlTaskSync } from '@/components/backlog/url-task-sync' import { TaskDialog } from '@/app/_components/tasks/task-dialog' import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader' import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton' -import { StartSprintButton } from '@/components/sprint/start-sprint-button' +import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger' +import { SprintDraftBanner } from '@/components/backlog/sprint-draft-banner' +import { SprintDraftLeaveGuard } from '@/components/backlog/sprint-draft-leave-guard' +import { SaveSprintButton } from '@/components/backlog/save-sprint-button' +import { ActiveSelectionHydrator } from '@/components/backlog/active-selection-hydrator' import { ActivateProductButton } from '@/components/shared/activate-product-button' import { EditProductButton } from '@/components/products/edit-product-button' import { SprintSwitcher } from '@/components/shared/sprint-switcher' @@ -118,13 +122,15 @@ export default async function ProductBacklogPage({ params, searchParams }: Props {!isActiveProduct && ( )} - {hasOpenSprint ? ( + {hasOpenSprint && ( Sprint actief → - ) : ( - !isDemo && )} + {activeSprintItem && !isDemo && ( + + )} + {!isDemo && } {!isDemo && product.user_id === session.userId && ( + {/* Sprint definition banner (state A′) + beforeunload-guard */} + + + {/* Split pane */}
+ , , 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]/sprint-membership-summary/route.ts b/app/api/products/[id]/sprint-membership-summary/route.ts new file mode 100644 index 0000000..16f6b6d --- /dev/null +++ b/app/api/products/[id]/sprint-membership-summary/route.ts @@ -0,0 +1,87 @@ +// 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/components/backlog/active-selection-hydrator.tsx b/components/backlog/active-selection-hydrator.tsx new file mode 100644 index 0000000..966b672 --- /dev/null +++ b/components/backlog/active-selection-hydrator.tsx @@ -0,0 +1,53 @@ +'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/new-sprint-metadata-dialog.tsx b/components/backlog/new-sprint-metadata-dialog.tsx new file mode 100644 index 0000000..cccf9a9 --- /dev/null +++ b/components/backlog/new-sprint-metadata-dialog.tsx @@ -0,0 +1,203 @@ +'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" + > +
+ +