feat(PBI-74): sprint-workspace-store (Story 9) (#181)

* feat(PBI-74): sprint-workspace-store skelet (Story 9 / T-879)

- stores/sprint-workspace/{types,store,selectors,restore}.ts conform
  product-workspace blueprint
- ContextSlice: activeProduct, activeSprintId, activeStoryId, activeTaskId
- EntitiesSlice: sprintsById, storiesById, tasksById
- RelationsSlice: sprintIdsByProduct, storyIdsBySprint, taskIdsByStory
- LoadingSlice met activeRequestId voor race-safe ensure*Loaded
- SyncSlice: realtimeStatus, lastResyncAt, resyncReason
- Realtime applyRealtimeEvent voor sprint/story/task entities + unknown-event
  fallback, parent-move handling, child-cleanup bij D op sprint/story
- Optimistic mutations: sprint-story-order, sprint-task-order, entity-patch
- LocalStorage hints (storage key sprint-workspace-hints) per product/sprint
- 45 unit-tests groen — verplicht 13 cases uit workspace-store.md §Tests

* feat(PBI-74): sprint hydratie + realtime SSE (Story 9 / T-880)

- app/api/realtime/sprint/route.ts: SSE-stream LISTEN/NOTIFY op
  scrum4me_changes, filter entity ∈ {sprint, story, task} per product_id;
  ready-event, heartbeat 25s, hard-close 240s
- lib/realtime/use-sprint-realtime.ts: client-hook met backoff-reconnect;
  ready-cycle telt; geen close op hidden; setRealtimeStatus
- lib/realtime/use-sprint-workspace-resync.ts: visibility + online triggers
  resyncActiveScopes('visible' | 'reconnect')
- components/sprint/sprint-hydration-wrapper.tsx: hydrateSnapshot via
  useEffect met fingerprint-check; mount realtime + resync
- app/(app)/products/[id]/sprint/[sprintId]/page.tsx: wrap SprintBoardClient
  in SprintHydrationWrapper; bouw SprintWorkspaceTask-shape voor
  tasksByStoryWorkspace en SprintHydrationData voor de wrapper

Schaduw-fase: useSprintStore blijft parallel werken in board components
totdat T-881 die migreert en T-883 de oude store opruimt.

* feat(PBI-74): migreer sprint-board componenten naar workspace-store (Story 9 / T-881)

- TaskList: leest tasks via selectTasksForStory met useShallow; DnD via
  applyOptimisticMutation('sprint-task-order') + settle/rollback
- SprintBacklogLeft: leest stories via selectStoriesForActiveSprint met
  useShallow; props 'stories' verwijderd
- SprintBoardClient: leest sprintStories uit selector i.p.v. lokale state;
  add/remove via direct setState met manuele snapshot-rollback;
  reorder via applyOptimisticMutation('sprint-story-order'); assignee-
  change via store entity-mutation; tasksByStory en sprintStoryIdList
  props weg
- app/(app)/.../sprint/[sprintId]/page.tsx: bouwt SprintHydrationData voor
  wrapper; geeft alleen non-store props door aan SprintBoardClient

useSprintStore wordt nergens meer geïmporteerd — alleen comment-referentie
in SprintHydrationWrapper. Cleanup van het bestand zelf in T-883.

Verify groen (671 tests, typecheck, lint clean).

* feat(PBI-74): read-routes voor sprint-workspace + cache-headers (Story 9 / T-882)

- GET /api/products/[id]/sprints — lijst sprints per product
  (ensureProductSprintsLoaded). force-dynamic, productAccessFilter,
  start_date/end_date naar ISO-date string.
- GET /api/sprints/[id]/workspace — sprint snapshot met sprint-meta,
  stories (incl. taskCount/doneCount/assignee), tasks gegroepeerd per
  story (ensureSprintLoaded). force-dynamic, productAccessFilter via
  product, status-vertaling via taskStatusToApi/storyStatusToApi.

Race-safe loaders (activeRequestId-guard), restore-flow (cascade-restore
via writeProductHint/writeSprintHint/writeStoryHint/writeTaskHint),
resync-laag (useSprintWorkspaceResync visibility + online), unknown-event
filter (isUnknownEntityEvent → resyncActiveScopes('unknown-event')) zijn
allemaal in T-879/T-880 al ingebouwd; T-882 sluit het loop met de
ontbrekende API-endpoints + cache-headers (cache: 'no-store' op fetches,
force-dynamic op routes).

* feat(PBI-74): cleanup oude sprint-store (Story 9 / T-883)

- rm stores/sprint-store.ts — alle componenten lezen nu via
  useSprintWorkspaceStore (T-881 voltooide imports-migratie)
- update SprintHydrationWrapper-comment: schaduw-fase referenties
  verwijderd

Verify: 671 tests groen, typecheck clean, build groen.
Grep useSprintStore = 0.

* docs(PBI-74): update Story 9 status in implementatieplan (T-884)

- Frontmatter: ready-to-execute → in-progress; revision 1 → 2;
  last_updated 2026-05-09 → 2026-05-10
- Stories-tabel: kolom Status toegevoegd (Stories 1-8 DONE via PR #180,
  Story 9 met T-884 op review)
- §Story 9: per-taak status + acceptatie-checklist voor T-884 manuele
  staging-checks
- Aanbeveling-blokje: noteert dat Story 9 vroeger gestart is dan het
  ontwerpdoc adviseerde
This commit is contained in:
Janpeter Visser 2026-05-10 06:53:04 +02:00 committed by GitHub
parent 5df04feb11
commit 98ee05d458
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 3115 additions and 219 deletions

View file

@ -0,0 +1,59 @@
// PBI-74 / Story 9 / T-882: GET /api/products/:id/sprints
//
// Levert een lijst sprints voor een product (sprint-workspace
// ensureProductSprintsLoaded). Auth + access-control consistent met andere
// product-routes.
import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
export const dynamic = 'force-dynamic'
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await authenticateApiRequest(request)
if ('error' in auth) {
return Response.json({ error: auth.error }, { status: auth.status })
}
const { id } = await params
const product = await prisma.product.findFirst({
where: { id, ...productAccessFilter(auth.userId) },
select: { id: true },
})
if (!product) {
return Response.json({ error: 'Product niet gevonden' }, { status: 404 })
}
const sprints = await prisma.sprint.findMany({
where: { product_id: id },
orderBy: [
{ status: 'asc' }, // OPEN < CLOSED alfabetisch — workspace-store her-sorteert
{ start_date: 'desc' },
{ created_at: 'desc' },
],
select: {
id: true,
product_id: true,
code: true,
sprint_goal: true,
status: true,
start_date: true,
end_date: true,
created_at: true,
completed_at: true,
},
})
return Response.json(
sprints.map((s) => ({
...s,
start_date: s.start_date ? s.start_date.toISOString().slice(0, 10) : null,
end_date: s.end_date ? s.end_date.toISOString().slice(0, 10) : null,
})),
)
}

View file

@ -0,0 +1,141 @@
// SSE endpoint for the sprint workspace (sprint / story / task changes).
// Mirrors /api/realtime/backlog but with entity filter ∈ {sprint, story, task}
// scoped per product. PBI-74 / Story 9.
//
// Auth: iron-session cookie. Demo users may read.
import { NextRequest } from 'next/server'
import { Client } from 'pg'
import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access'
import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 300
const CHANNEL = 'scrum4me_changes'
const HEARTBEAT_MS = 25_000
const HARD_CLOSE_MS = 240_000
type NotifyPayload = Record<string, unknown>
function shouldEmit(payload: NotifyPayload, productId: string): boolean {
if ('type' in payload) return false
const entity = payload.entity as string | undefined
if (!entity || !['sprint', 'story', 'task'].includes(entity)) return false
return payload.product_id === productId
}
export async function GET(request: NextRequest) {
const session = await getSession()
if (!session.userId) {
return Response.json({ error: 'Niet ingelogd' }, { status: 401 })
}
const productId = request.nextUrl.searchParams.get('product_id')
if (!productId) {
return Response.json({ error: 'product_id is verplicht' }, { status: 400 })
}
const product = await getAccessibleProduct(productId, session.userId)
if (!product) {
return Response.json({ error: 'Geen toegang tot dit product' }, { status: 403 })
}
const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL
if (!directUrl) {
return Response.json(
{ error: 'DIRECT_URL/DATABASE_URL niet geconfigureerd' },
{ status: 500 },
)
}
const encoder = new TextEncoder()
const pgClient = new Client({ connectionString: directUrl })
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
let hardCloseTimer: ReturnType<typeof setTimeout> | null = null
let closed = false
const stream = new ReadableStream({
async start(controller) {
const enqueue = (chunk: string) => {
if (closed) return
try {
controller.enqueue(encoder.encode(chunk))
} catch {
// stream already closed
}
}
const cleanup = async (reason: string) => {
if (closed) return
closed = true
if (heartbeatTimer) clearInterval(heartbeatTimer)
if (hardCloseTimer) clearTimeout(hardCloseTimer)
await closePgClientSafely(pgClient, 'realtime/sprint')
try {
controller.close()
} catch {
// already closed
}
if (process.env.NODE_ENV !== 'production') {
console.log(`[realtime/sprint] closed: ${reason}`)
}
}
try {
await pgClient.connect()
await pgClient.query(`LISTEN ${CHANNEL}`)
} catch (err) {
console.error('[realtime/sprint] pg connect/listen failed:', err)
enqueue(
`event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`,
)
await cleanup('pg connect failed')
return
}
pgClient.on('notification', (msg) => {
if (!msg.payload) return
let payload: NotifyPayload
try {
payload = JSON.parse(msg.payload) as NotifyPayload
} catch {
return
}
if (!shouldEmit(payload, productId)) return
enqueue(`data: ${msg.payload}\n\n`)
})
pgClient.on('error', async (err) => {
console.error('[realtime/sprint] pg client error:', err)
await cleanup('pg error')
})
enqueue(`event: ready\ndata: ${JSON.stringify({ product_id: productId })}\n\n`)
heartbeatTimer = setInterval(() => {
enqueue(`: heartbeat\n\n`)
}, HEARTBEAT_MS)
hardCloseTimer = setTimeout(() => {
cleanup('hard close 240s')
}, HARD_CLOSE_MS)
request.signal.addEventListener('abort', () => {
cleanup('client aborted')
})
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
})
}

View file

@ -0,0 +1,110 @@
// PBI-74 / Story 9 / T-882: GET /api/sprints/:id/workspace
//
// Levert een SprintWorkspaceSnapshot (sprint + stories + tasksByStory) voor
// de sprint-workspace-store (ensureSprintLoaded). Auth + access-control via
// product-membership.
import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { storyStatusToApi, taskStatusToApi } from '@/lib/task-status'
export const dynamic = 'force-dynamic'
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await authenticateApiRequest(request)
if ('error' in auth) {
return Response.json({ error: auth.error }, { status: auth.status })
}
const { id } = await params
const sprint = await prisma.sprint.findFirst({
where: { id, product: productAccessFilter(auth.userId) },
select: {
id: true,
product_id: true,
code: true,
sprint_goal: true,
status: true,
start_date: true,
end_date: true,
created_at: true,
completed_at: true,
product: { select: { id: true, name: true } },
},
})
if (!sprint) {
return Response.json({ error: 'Sprint niet gevonden' }, { status: 404 })
}
const [stories, tasks] = await Promise.all([
prisma.story.findMany({
where: { sprint_id: id },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }],
include: {
tasks: { select: { id: true, status: true } },
assignee: { select: { id: true, username: true } },
},
}),
prisma.task.findMany({
where: { sprint_id: id },
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
select: {
id: true,
code: true,
title: true,
description: true,
priority: true,
sort_order: true,
status: true,
story_id: true,
sprint_id: true,
created_at: true,
},
}),
])
const tasksByStory: Record<string, unknown[]> = {}
for (const task of tasks) {
const apiTask = { ...task, status: taskStatusToApi(task.status) }
if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = []
tasksByStory[task.story_id].push(apiTask)
}
return Response.json({
product: sprint.product,
sprint: {
id: sprint.id,
product_id: sprint.product_id,
code: sprint.code,
sprint_goal: sprint.sprint_goal,
status: sprint.status,
start_date: sprint.start_date ? sprint.start_date.toISOString().slice(0, 10) : null,
end_date: sprint.end_date ? sprint.end_date.toISOString().slice(0, 10) : null,
created_at: sprint.created_at,
completed_at: sprint.completed_at,
},
stories: stories.map((s) => ({
id: s.id,
code: s.code,
title: s.title,
description: s.description,
acceptance_criteria: s.acceptance_criteria,
priority: s.priority,
sort_order: s.sort_order,
status: storyStatusToApi(s.status),
pbi_id: s.pbi_id,
sprint_id: s.sprint_id,
created_at: s.created_at,
taskCount: s.tasks.length,
doneCount: s.tasks.filter((t) => t.status === 'DONE').length,
assignee_id: s.assignee_id,
assignee_username: s.assignee?.username ?? null,
})),
tasksByStory,
})
}