feat(PBI-79/ST-1340): commitSprintMembershipAction + gerichte client-store patches

actions/sprints.ts:
- Nieuwe commitSprintMembershipAction(activeSprintId, adds[], removes[]).
- Eligibility-filter voor adds via partitionByEligibility (sprint_id IS NULL
  en niet DONE; cross-sprint conflicts → notEligible).
- Race-safety voor removes: alleen stories met huidige sprint_id ==
  activeSprintId; rest → conflicts.alreadyRemoved.
- Transactie wrapt twee updateMany-paren (story status mee, task.sprint_id
  cascade). Update-paren overgeslagen wanneer leeg.
- Return: { success, affectedStoryIds, affectedPbiIds, affectedTaskIds,
  conflicts: { notEligible, alreadyRemoved } }.

stores/product-workspace/store.ts:
- applyMembershipCommitResult({ activeSprintId, addedStoryIds,
  removedStoryIds }) patcht entities.storiesById met juiste sprint_id +
  status; ledigt sprintMembership.pending. Geen task-veld omdat
  BacklogTask geen sprint_id-kolom heeft in de store.

Tests: __tests__/actions/commit-sprint-membership.test.ts (8 cases) — happy
path, DONE-conflict, cross-sprint, race-safety voor removes, transactie-
inhoud (status='IN_SPRINT'/'OPEN'), task-cascade, return-shape, auth-fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-11 17:02:05 +02:00
parent d21011cdfa
commit 4c6e99958b
3 changed files with 467 additions and 0 deletions

View file

@ -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<typeof vi.fn> }
story: {
findMany: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
task: {
findMany: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
$transaction: ReturnType<typeof vi.fn>
__txClient: {
sprint: { create: ReturnType<typeof vi.fn> }
story: { updateMany: ReturnType<typeof vi.fn> }
task: { updateMany: ReturnType<typeof vi.fn> }
}
}
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)
}
})
})

View file

@ -54,6 +54,147 @@ export type CreateSprintWithSelectionResult =
}
| { error: string; code: number }
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<CommitSprintMembershipResult> {
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<CreateSprintWithSelectionResult> {

View file

@ -120,6 +120,15 @@ interface Actions {
excludeSprintId: string | null,
pbiIds: string[],
): Promise<void>
// PBI-79 / ST-1340: gericht patchen na server-action commit. Tasks in
// de client-store hebben geen sprint_id-veld dus alleen story-records
// worden gemuteerd.
applyMembershipCommitResult(input: {
activeSprintId: string
addedStoryIds: string[]
removedStoryIds: string[]
}): void
}
export type ProductWorkspaceStore = State & Actions
@ -667,6 +676,33 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
}
})
},
applyMembershipCommitResult({
activeSprintId,
addedStoryIds,
removedStoryIds,
}) {
// Task-records in de client-store hebben geen sprint_id-veld (alleen
// story_id); de sprint-membership wordt afgeleid via story.sprint_id.
// Hier patchen we daarom alleen story-entities + de pending buffer.
set((s) => {
for (const id of addedStoryIds) {
const story = s.entities.storiesById[id]
if (story) {
story.sprint_id = activeSprintId
story.status = 'IN_SPRINT'
}
}
for (const id of removedStoryIds) {
const story = s.entities.storiesById[id]
if (story) {
story.sprint_id = null
story.status = 'OPEN'
}
}
s.sprintMembership.pending = { adds: [], removes: [] }
})
},
})),
)