From d21011cdfa6348e76e4d44e24487b8151a201015 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 11 May 2026 16:58:15 +0200 Subject: [PATCH] feat(PBI-79/ST-1339): createSprintWithSelectionAction + banner wire-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions/sprints.ts: - Nieuwe createSprintWithSelectionAction(productId, metadata, pbiIntent, storyOverrides). - Server-side intent-resolve: 1. Voor elke PBI met intent='all': fetch child-story-IDs minus storyOverrides[pbi].remove. 2. Plus storyOverrides[*].add (cross-PBI cherrypick toegestaan). - Eligibility-filter via partitionByEligibility (sprint_id IS NULL + status != DONE; stories in andere OPEN sprint → conflicts.crossSprint). - Transactie wrapt sprint.create + story.updateMany (status='IN_SPRINT') + task.updateMany (sprint_id cascade) — alles atomair. - setActiveSprintInSettings na success. - Return: { success, sprintId, affectedStoryIds, affectedPbiIds, affectedTaskIds, conflicts: { notEligible, crossSprint } } of error. components/backlog/sprint-definition-banner.tsx: - 'Sprint aanmaken'-knop sluit aan op createSprintWithSelectionAction; toast bij conflicts, success-toast anders, router.refresh() voor SSR cycle. Pending draft wordt door de action zelf nog niet expliciet gewist — dat gebeurt via revalidatePath en kan in ST-1340 finetuned worden. Tests: __tests__/actions/create-sprint-with-selection.test.ts (6 cases) dekken intent-resolve, override-respect, cross-sprint conflict, transactie- binding van story.status + task.sprint_id, return-shape, en error-pad. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../create-sprint-with-selection.test.ts | 300 ++++++++++++++++++ actions/sprints.ts | 150 +++++++++ .../backlog/sprint-definition-banner.tsx | 34 +- 3 files changed, 480 insertions(+), 4 deletions(-) create mode 100644 __tests__/actions/create-sprint-with-selection.test.ts 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/actions/sprints.ts b/actions/sprints.ts index 9471b51..7e01145 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -15,8 +15,158 @@ 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 } + +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) } diff --git a/components/backlog/sprint-definition-banner.tsx b/components/backlog/sprint-definition-banner.tsx index e013881..df3fdf3 100644 --- a/components/backlog/sprint-definition-banner.tsx +++ b/components/backlog/sprint-definition-banner.tsx @@ -1,6 +1,7 @@ 'use client' import { useMemo, useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { @@ -16,6 +17,7 @@ import { import { useUserSettingsStore } from '@/stores/user-settings/store' import { useProductWorkspaceStore } from '@/stores/product-workspace/store' import type { PendingSprintDraft } from '@/lib/user-settings' +import { createSprintWithSelectionAction } from '@/actions/sprints' import { debugProps } from '@/lib/debug' interface SprintDefinitionBannerProps { @@ -74,6 +76,7 @@ export function SprintDefinitionBanner({ (s) => s.clearPendingSprintDraft, ) const pbiSummary = useProductWorkspaceStore((s) => s.sprintMembership.pbiSummary) + const router = useRouter() const [isPending, startTransition] = useTransition() const [confirmCancel, setConfirmCancel] = useState(false) @@ -100,10 +103,33 @@ export function SprintDefinitionBanner({ } function handleCreate() { - // PBI-79 ST-1339 wires de createSprintWithSelectionAction in. - toast.info( - 'Sprint aanmaken is nog niet aangesloten (wordt afgerond in ST-1339).', - ) + startTransition(async () => { + const result = await createSprintWithSelectionAction({ + productId, + metadata: { + goal: draft.goal, + startAt: draft.startAt, + endAt: draft.endAt, + }, + pbiIntent: draft.pbiIntent, + storyOverrides: draft.storyOverrides, + }) + if ('error' in result) { + toast.error(result.error) + return + } + const { conflicts } = result + if (conflicts.notEligible.length > 0) { + toast.warning( + `${conflicts.notEligible.length} stor${ + conflicts.notEligible.length === 1 ? 'y is' : 'ies zijn' + } overgeslagen (al in een andere sprint of afgerond).`, + ) + } else { + toast.success('Sprint aangemaakt') + } + router.refresh() + }) } const storyLabel = counts.hasUnknownTotal