feat(PBI-79/ST-1334): user-settings pendingSprintDraft-slot

- lib/user-settings.ts: nieuw workflow.pendingSprintDraft veld met
  compacte intent-shape (pbiIntent + per-PBI storyOverrides).
- actions/sprint-draft.ts: setPendingSprintDraftAction +
  clearPendingSprintDraftAction met product-membership-check + Zod-validatie.
- stores/user-settings/store.ts: setPendingSprintDraft / clearPendingSprintDraft
  optimistic acties + fine-grained mutators upsertPbiIntent / upsertStoryOverride.
  Sprint-draft actions worden dynamisch geïmporteerd zodat jsdom-tests
  zonder DATABASE_URL niet falen.
- Tests: nieuwe sprint-draft.test.ts (action-laag), uitbreiding
  user-settings store-tests (5 nieuwe cases) en schema-tests (4 cases).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-11 13:43:32 +02:00
parent 2af6f24598
commit 56c55e1813
6 changed files with 636 additions and 0 deletions

View file

@ -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<typeof vi.fn> }
user: {
findUnique: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
}
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' })
})
})

View file

@ -135,4 +135,52 @@ describe('UserSettingsSchema', () => {
})
}
})
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)
})
})

View file

@ -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,164 @@ describe('useUserSettingsStore', () => {
expect(updateAction).not.toHaveBeenCalled()
})
it('setPendingSprintDraft persists draft optimistically + calls action', async () => {
useUserSettingsStore.getState().hydrate({}, false)
setDraftAction.mockResolvedValueOnce({ success: true })
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).toHaveBeenCalledWith('product-1', draft)
})
it('setPendingSprintDraft rolls back on server error', async () => {
useUserSettingsStore.getState().hydrate({}, false)
setDraftAction.mockResolvedValueOnce({ error: 'boom' })
const draft: PendingSprintDraft = {
goal: 'Sprint X',
pbiIntent: {},
storyOverrides: {},
}
await useUserSettingsStore
.getState()
.setPendingSprintDraft('product-1', draft)
const s = useUserSettingsStore.getState()
expect(s.entities.settings.workflow?.pendingSprintDraft).toBeUndefined()
})
it('clearPendingSprintDraft removes key optimistically', async () => {
useUserSettingsStore.getState().hydrate(
{
workflow: {
pendingSprintDraft: {
'product-1': {
goal: 'Old',
pbiIntent: {},
storyOverrides: {},
},
},
},
},
false,
)
clearDraftAction.mockResolvedValueOnce({ success: true })
await useUserSettingsStore
.getState()
.clearPendingSprintDraft('product-1')
const s = useUserSettingsStore.getState()
expect(
s.entities.settings.workflow?.pendingSprintDraft?.['product-1'],
).toBeUndefined()
expect(clearDraftAction).toHaveBeenCalledWith('product-1')
})
it('upsertPbiIntent updates intent and wipes storyOverrides for that PBI', async () => {
useUserSettingsStore.getState().hydrate(
{
workflow: {
pendingSprintDraft: {
'product-1': {
goal: 'g',
pbiIntent: { pbiA: 'none' },
storyOverrides: {
pbiA: { add: ['s-1'], remove: [] },
pbiB: { add: [], remove: ['s-2'] },
},
},
},
},
},
false,
)
setDraftAction.mockResolvedValue({ success: true })
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(
{
workflow: {
pendingSprintDraft: {
'product-1': {
goal: 'g',
pbiIntent: {},
storyOverrides: {
pbiA: { add: [], remove: ['story-1'] },
},
},
},
},
},
false,
)
setDraftAction.mockResolvedValue({ success: true })
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(
{
workflow: {
pendingSprintDraft: {
'product-1': {
goal: 'g',
pbiIntent: {},
storyOverrides: {
pbiA: { add: ['story-1'], remove: [] },
},
},
},
},
},
false,
)
setDraftAction.mockResolvedValue({ success: true })
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' } } },

121
actions/sprint-draft.ts Normal file
View file

@ -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<SessionData>(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().datetime().optional(),
endAt: z.string().datetime().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<UserSettings> {
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<UserSettings> = {
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 }
}

View file

@ -48,13 +48,36 @@ const LayoutPrefs = z.object({
activeSprints: z.record(z.string(), z.string().nullable()).optional(),
}).strict()
const PbiIntent = z.enum(['all', 'none'])
const StoryOverrides = z.object({
add: z.array(z.string()),
remove: z.array(z.string()),
}).strict()
const PendingSprintDraftSchema = z.object({
goal: z.string().min(1),
startAt: z.string().datetime().optional(),
endAt: z.string().datetime().optional(),
pbiIntent: z.record(z.string(), PbiIntent).default({}),
storyOverrides: z.record(z.string(), StoryOverrides).default({}),
}).strict()
const WorkflowPrefs = z.object({
pendingSprintDraft: z.record(z.string(), PendingSprintDraftSchema).optional(),
}).strict()
export const UserSettingsSchema = z.object({
views: ViewsPrefs.optional(),
devTools: DevToolsPrefs.optional(),
layout: LayoutPrefs.optional(),
workflow: WorkflowPrefs.optional(),
}).strict()
export type UserSettings = z.infer<typeof UserSettingsSchema>
export type PendingSprintDraft = z.infer<typeof PendingSprintDraftSchema>
export type PbiIntent = z.infer<typeof PbiIntent>
export type StoryOverrides = z.infer<typeof StoryOverrides>
export const DEFAULT_USER_SETTINGS: UserSettings = {}

View file

@ -4,6 +4,8 @@ import { immer } from 'zustand/middleware/immer'
import {
DEFAULT_USER_SETTINGS,
mergeSettings,
type PbiIntent,
type PendingSprintDraft,
type UserSettings,
} from '@/lib/user-settings'
import { updateUserSettingsAction } from '@/actions/user-settings'
@ -28,6 +30,22 @@ interface UserSettingsActions {
hydrate: (initial: UserSettings, isDemo: boolean) => void
setPref: (path: SettingsPath, value: unknown) => Promise<void>
applyServerPatch: (patch: Partial<UserSettings>) => void
setPendingSprintDraft: (
productId: string,
draft: PendingSprintDraft,
) => Promise<void>
clearPendingSprintDraft: (productId: string) => Promise<void>
upsertPbiIntent: (
productId: string,
pbiId: string,
intent: PbiIntent,
) => Promise<void>
upsertStoryOverride: (
productId: string,
pbiId: string,
storyId: string,
kind: 'add' | 'remove' | 'clear',
) => Promise<void>
}
let nextMutationId = 1
@ -73,6 +91,96 @@ export const useUserSettingsStore = create<UserSettingsState & UserSettingsActio
})
},
setPendingSprintDraft: async (productId, draft) => {
const prev = get().entities.settings as UserSettings
set((s) => {
if (!s.entities.settings.workflow) s.entities.settings.workflow = {}
if (!s.entities.settings.workflow.pendingSprintDraft) {
s.entities.settings.workflow.pendingSprintDraft = {}
}
s.entities.settings.workflow.pendingSprintDraft[productId] = draft
})
if (get().context.isDemo) return
const { setPendingSprintDraftAction } = await import(
'@/actions/sprint-draft'
)
const result = await setPendingSprintDraftAction(productId, draft)
if ('error' in result) {
set((s) => {
s.entities.settings = prev as UserSettings
})
}
},
clearPendingSprintDraft: async (productId) => {
const prev = get().entities.settings as UserSettings
set((s) => {
const map = s.entities.settings.workflow?.pendingSprintDraft
if (map) delete map[productId]
})
if (get().context.isDemo) return
const { clearPendingSprintDraftAction } = await import(
'@/actions/sprint-draft'
)
const result = await clearPendingSprintDraftAction(productId)
if ('error' in result) {
set((s) => {
s.entities.settings = prev as UserSettings
})
}
},
upsertPbiIntent: async (productId, pbiId, intent) => {
const current =
get().entities.settings.workflow?.pendingSprintDraft?.[productId]
if (!current) return
const nextOverrides = { ...current.storyOverrides }
delete nextOverrides[pbiId]
const next: PendingSprintDraft = {
...current,
pbiIntent: { ...current.pbiIntent, [pbiId]: intent },
storyOverrides: nextOverrides,
}
await get().setPendingSprintDraft(productId, next)
},
upsertStoryOverride: async (productId, pbiId, storyId, kind) => {
const current =
get().entities.settings.workflow?.pendingSprintDraft?.[productId]
if (!current) return
const existing = current.storyOverrides[pbiId] ?? { add: [], remove: [] }
const dropFrom = (arr: string[]) => arr.filter((id) => id !== storyId)
let nextEntry: { add: string[]; remove: string[] }
switch (kind) {
case 'add':
nextEntry = {
add: existing.add.includes(storyId) ? existing.add : [...existing.add, storyId],
remove: dropFrom(existing.remove),
}
break
case 'remove':
nextEntry = {
add: dropFrom(existing.add),
remove: existing.remove.includes(storyId)
? existing.remove
: [...existing.remove, storyId],
}
break
case 'clear':
default:
nextEntry = { add: dropFrom(existing.add), remove: dropFrom(existing.remove) }
break
}
const nextOverrides = { ...current.storyOverrides }
if (nextEntry.add.length === 0 && nextEntry.remove.length === 0) {
delete nextOverrides[pbiId]
} else {
nextOverrides[pbiId] = nextEntry
}
const next: PendingSprintDraft = { ...current, storyOverrides: nextOverrides }
await get().setPendingSprintDraft(productId, next)
},
setPref: async (path, value) => {
const patch = patchFromPath(path, value)