diff --git a/actions/tasks.ts b/actions/tasks.ts index 4dcbc69..f70c452 100644 --- a/actions/tasks.ts +++ b/actions/tasks.ts @@ -101,8 +101,11 @@ export async function updateTaskStatusAction(id: string, status: 'TO_DO' | 'IN_P await prisma.task.update({ where: { id }, data: { status } }) + // /solo bewust niet revalideren: dat zou de page soft-navigaten en de + // open SSE-stream sluiten. De Solo Paneel-flow leunt op optimistic + // store-updates + realtime echo (M8). Sprint/planning heeft geen + // realtime en moet wèl revalidaten. revalidatePath(`/products/${task.story.product_id}/sprint/planning`) - revalidatePath(`/products/${task.story.product_id}/solo`) return { success: true } } @@ -150,7 +153,7 @@ export async function updateTaskPlanAction(taskId: string, productId: string, im data: { implementation_plan: implementationPlan || null }, }) - revalidatePath(`/products/${productId}/solo`) + // /solo bewust niet revalideren — zie updateTaskStatusAction. revalidatePath(`/products/${productId}/sprint/planning`) return { success: true } } diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 5bb4999..22bad22 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -6,6 +6,7 @@ import { prisma } from '@/lib/prisma' import { NavBar } from '@/components/shared/nav-bar' import { MinWidthBanner } from '@/components/shared/min-width-banner' import { StatusBar } from '@/components/shared/status-bar' +import { SoloRealtimeBridge } from '@/components/solo/realtime-bridge' export default async function AppLayout({ children }: { children: React.ReactNode }) { const session = await getIronSession(await cookies(), sessionOptions) @@ -47,6 +48,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod {children} + ) } diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts index ea6fea8..abe2dd2 100644 --- a/app/api/realtime/solo/route.ts +++ b/app/api/realtime/solo/route.ts @@ -118,10 +118,23 @@ export async function GET(request: NextRequest) { const sprint = await prisma_sprint_findActive(productId) const activeSprintId = sprint?.id ?? null + const isPooled = directUrl.includes('pooler.') + if (process.env.NODE_ENV !== 'production') { + console.log( + `[realtime/solo] connecting (${isPooled ? 'POOLED — LISTEN may not work!' : 'direct'})`, + ) + } + try { await pgClient.connect() await pgClient.query(`LISTEN ${CHANNEL}`) + if (process.env.NODE_ENV !== 'production') { + console.log(`[realtime/solo] LISTEN ${CHANNEL} ready`) + } } catch (err) { + if (process.env.NODE_ENV !== 'production') { + console.error('[realtime/solo] pg connect/listen failed:', err) + } enqueue(`event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`) await cleanup('pg connect failed') return @@ -135,7 +148,13 @@ export async function GET(request: NextRequest) { } catch { return } - if (!shouldEmit(payload, productId, activeSprintId, userId)) return + const emit = shouldEmit(payload, productId, activeSprintId, userId) + if (process.env.NODE_ENV !== 'production') { + console.log( + `[realtime/solo] NOTIFY ${payload.entity}:${payload.id} ${payload.op} → ${emit ? 'EMIT' : 'skip'} (sprint=${payload.sprint_id} assignee=${payload.assignee_id} user=${userId})`, + ) + } + if (!emit) return enqueue(`data: ${msg.payload}\n\n`) }) diff --git a/components/solo/realtime-bridge.tsx b/components/solo/realtime-bridge.tsx new file mode 100644 index 0000000..bd04f69 --- /dev/null +++ b/components/solo/realtime-bridge.tsx @@ -0,0 +1,21 @@ +// SoloRealtimeBridge — mount in de (app)-layout zodat de SSE-verbinding +// blijft staan over Server Action-refreshes van de Solo-page heen. +// +// Leest het huidige product-id uit de URL (`/products/[id]/solo`). +// Wanneer de gebruiker niet op het Solo Paneel zit, wordt de stream +// gesloten — geen onnodige verbinding open houden. + +'use client' + +import { usePathname } from 'next/navigation' +import { useSoloRealtime } from '@/lib/realtime/use-solo-realtime' + +const SOLO_PATH_RE = /^\/products\/([^/]+)\/solo$/ + +export function SoloRealtimeBridge() { + const pathname = usePathname() + const match = pathname?.match(SOLO_PATH_RE) + const productId = match?.[1] ?? null + useSoloRealtime(productId) + return null +} diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx index 9d6ddc5..4589922 100644 --- a/components/solo/solo-board.tsx +++ b/components/solo/solo-board.tsx @@ -7,8 +7,8 @@ import { } from '@dnd-kit/core' import { toast } from 'sonner' import { useSoloStore } from '@/stores/solo-store' -import { updateTaskStatusAction } from '@/actions/tasks' -import { useSoloRealtime, type RealtimeStatus } from '@/lib/realtime/use-solo-realtime' +import type { RealtimeStatus } from '@/stores/solo-store' +import { taskStatusToApi } from '@/lib/task-status' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { SoloColumn, type ColumnStatus } from './solo-column' @@ -90,14 +90,14 @@ export function SoloBoard({ productId, productName, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, }: SoloBoardProps) { const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore() + const realtimeStatus = useSoloStore((s) => s.realtimeStatus) + const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator) const [activeDragId, setActiveDragId] = useState(null) const [selectedTask, setSelectedTask] = useState(null) const [sheetOpen, setSheetOpen] = useState(false) const [unassignedStories, setUnassignedStories] = useState(initialUnassigned) const [, startTransition] = useTransition() - const { status: realtimeStatus, showConnectingIndicator } = useSoloRealtime(productId) - const taskKey = initialTasks.map(t => t.id).join(',') useEffect(() => { initTasks(initialTasks) @@ -137,15 +137,29 @@ export function SoloBoard({ // Onderdruk realtime-echo van onze eigen write — de Postgres-trigger // vuurt en die NOTIFY komt zo terug via SSE; zonder pending-marker // zou de store nogmaals een set() doen of de optimistic state - // overschrijven. clearPending na de Server Action (succes of fail). + // overschrijven. clearPending na de fetch (succes of fail). + // + // We gebruiken bewust een fetch-based Route Handler in plaats van + // de updateTaskStatusAction Server Action — Server Actions + // triggeren een full route-tree refresh die de open SSE-stream van + // /api/realtime/solo zou afkappen, waardoor we elke 5s reconnecten + // en realtime-events missen. markPending(taskId) startTransition(async () => { try { - const result = await updateTaskStatusAction(taskId, toStatus) - if (result && 'error' in result) { + const res = await fetch(`/api/tasks/${taskId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ status: taskStatusToApi(toStatus) }), + }) + if (!res.ok) { rollback(taskId, prevStatus) toast.error('Status bijwerken mislukt — taak teruggeplaatst') } + } catch { + rollback(taskId, prevStatus) + toast.error('Status bijwerken mislukt — taak teruggeplaatst') } finally { clearPending(taskId) } diff --git a/components/solo/task-detail-dialog.tsx b/components/solo/task-detail-dialog.tsx index e2c4afe..b37be68 100644 --- a/components/solo/task-detail-dialog.tsx +++ b/components/solo/task-detail-dialog.tsx @@ -8,7 +8,6 @@ import { Badge } from '@/components/ui/badge' import { Textarea } from '@/components/ui/textarea' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { useSoloStore } from '@/stores/solo-store' -import { updateTaskPlanAction } from '@/actions/tasks' import { cn } from '@/lib/utils' import type { SoloTask } from './solo-board' @@ -56,16 +55,29 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte setSaveState('saving') if (fadeTimer.current) clearTimeout(fadeTimer.current) + // fetch naar Route Handler i.p.v. Server Action — Server Actions + // kappen anders de open SSE-stream van het Solo Paneel af. Zie + // notitie in solo-board.tsx handleDragEnd. startTransition(async () => { - const result = await updateTaskPlanAction(task.id, productId, localPlan) - if (result && 'error' in result) { - setSaveState('idle') - toast.error(typeof result.error === 'string' ? result.error : 'Implementatieplan opslaan mislukt') - } else { + try { + const res = await fetch(`/api/tasks/${task.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ implementation_plan: localPlan }), + }) + if (!res.ok) { + setSaveState('idle') + toast.error('Implementatieplan opslaan mislukt') + return + } savedPlanRef.current = localPlan updatePlan(task.id, localPlan || null) setSaveState('saved') fadeTimer.current = setTimeout(() => setSaveState('idle'), 2000) + } catch { + setSaveState('idle') + toast.error('Implementatieplan opslaan mislukt') } }) } diff --git a/lib/api-auth.ts b/lib/api-auth.ts index 2198480..95e6ea1 100644 --- a/lib/api-auth.ts +++ b/lib/api-auth.ts @@ -1,23 +1,40 @@ import { createHash } from 'crypto' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' import { prisma } from '@/lib/prisma' +import { sessionOptions, type SessionData } from '@/lib/session' +// Probeert eerst Bearer-token (REST/MCP), valt terug op iron-session +// cookie (browser fetches vanuit ingelogde sessie). Cookie-pad is bewust +// voor Solo Paneel-mutations die anders via Server Action zouden gaan — +// maar Server Actions triggeren een page-refresh die SSE-streams sluit. export async function authenticateApiRequest(request: Request) { const authHeader = request.headers.get('Authorization') - if (!authHeader?.startsWith('Bearer ')) { - return { error: 'Unauthorized', status: 401 as const } + + if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.slice(7) + const tokenHash = createHash('sha256').update(token).digest('hex') + + const apiToken = await prisma.apiToken.findUnique({ + where: { token_hash: tokenHash }, + include: { user: true }, + }) + + if (!apiToken || apiToken.revoked_at) { + return { error: 'Unauthorized', status: 401 as const } + } + return { userId: apiToken.user_id, isDemo: apiToken.user.is_demo } } - const token = authHeader.slice(7) - const tokenHash = createHash('sha256').update(token).digest('hex') - - const apiToken = await prisma.apiToken.findUnique({ - where: { token_hash: tokenHash }, - include: { user: true }, - }) - - if (!apiToken || apiToken.revoked_at) { - return { error: 'Unauthorized', status: 401 as const } + // Geen Bearer — probeer iron-session cookie + try { + const session = await getIronSession(await cookies(), sessionOptions) + if (session.userId) { + return { userId: session.userId, isDemo: session.isDemo ?? false } + } + } catch { + // cookies() outside of request-scope kan throwen — laat door naar 401 } - return { userId: apiToken.user_id, isDemo: apiToken.user.is_demo } + return { error: 'Unauthorized', status: 401 as const } } diff --git a/lib/realtime/use-solo-realtime.ts b/lib/realtime/use-solo-realtime.ts index e130c17..a99cd68 100644 --- a/lib/realtime/use-solo-realtime.ts +++ b/lib/realtime/use-solo-realtime.ts @@ -1,42 +1,47 @@ -// ST-803: client-side hook voor de Solo Paneel realtime stream. +// ST-803 + ST-805 (refactor in ST-806-acceptance): client-side hook die de +// Solo Paneel realtime stream beheert. // -// - Opent EventSource('/api/realtime/solo?product_id=...') -// - Reconnect met exponential backoff (1s → 30s, reset bij ready) -// - Pauseert bij document.visibilityState === 'hidden', resumes bij 'visible' -// - Cleanup op unmount -// - Dispatcht events naar de solo-store via handleRealtimeEvent -// -// State exposed: -// status: 'connecting' | 'open' | 'disconnected' -// showConnectingIndicator: true zodra status !== 'open' langer dan 2s duurt -// (UI gebruikt dit zodat micro-disconnects geen flikker veroorzaken) +// - Mount in de (app)-layout via SoloRealtimeBridge zodat hij Server Action- +// refreshes overleeft (anders kapt Next.js' soft-navigation de SSE). +// - Opent EventSource('/api/realtime/solo?product_id=...') wanneer +// productId niet null is; sluit de stream als productId null wordt. +// - Reconnect met exponential backoff (1s → 30s, reset bij ready). +// - Pauseert bij document.visibilityState === 'hidden', resumes bij visible. +// - Cleanup op unmount. +// - Connection-status (status, showConnectingIndicator) wordt naar de +// solo-store geschreven; UI-componenten lezen daar uit. +// - Dispatcht events naar de solo-store via handleRealtimeEvent. Task- +// updates worden in document.startViewTransition + flushSync gewikkeld +// zodat het kanban-kaartje soepel naar zijn nieuwe kolom animeert +// (animatie A — vereist view-transition-name op de cards). 'use client' -import { useEffect, useState, useRef } from 'react' +import { useEffect, useRef } from 'react' import { flushSync } from 'react-dom' import { useSoloStore } from '@/stores/solo-store' -import type { RealtimeEvent } from '@/stores/solo-store' - -export type RealtimeStatus = 'connecting' | 'open' | 'disconnected' +import type { RealtimeEvent, RealtimeStatus } from '@/stores/solo-store' const BACKOFF_START_MS = 1_000 const BACKOFF_MAX_MS = 30_000 const CONNECTING_INDICATOR_DELAY_MS = 2_000 -export function useSoloRealtime(productId: string) { - const [status, setStatus] = useState('connecting') - const [showConnectingIndicator, setShowConnectingIndicator] = useState(false) - - // Refs voor lifecycle die ge-survival moeten zijn over re-renders +export function useSoloRealtime(productId: string | null) { const sourceRef = useRef(null) const backoffRef = useRef(BACKOFF_START_MS) const reconnectTimerRef = useRef | null>(null) const indicatorTimerRef = useRef | null>(null) useEffect(() => { + const setStatus = useSoloStore.getState().setRealtimeStatus const handleEvent = useSoloStore.getState().handleRealtimeEvent + if (!productId) { + // Geen actief product (gebruiker zit niet op /solo) — stream uit + setStatus('disconnected', false) + return + } + const close = () => { if (sourceRef.current) { sourceRef.current.close() @@ -54,17 +59,19 @@ export function useSoloRealtime(productId: string) { indicatorTimerRef.current = null } if (next === 'open') { - setShowConnectingIndicator(false) + setStatus('open', false) } else { + // Status meteen bijwerken, indicator pas na 2s — voorkomt flikker + // bij microscopische disconnects. + setStatus(next, false) indicatorTimerRef.current = setTimeout(() => { - setShowConnectingIndicator(true) + setStatus(useSoloStore.getState().realtimeStatus, true) }, CONNECTING_INDICATOR_DELAY_MS) } } const connect = () => { close() - setStatus('connecting') scheduleIndicator('connecting') const source = new EventSource( @@ -74,7 +81,6 @@ export function useSoloRealtime(productId: string) { source.addEventListener('ready', () => { backoffRef.current = BACKOFF_START_MS - setStatus('open') scheduleIndicator('open') }) @@ -82,13 +88,11 @@ export function useSoloRealtime(productId: string) { if (!e.data) return try { const payload = JSON.parse(e.data) as RealtimeEvent - // ST-805 animatie A: kanban-move animeren via View Transitions API. - // Voor task UPDATE-events wrap'en we de store-update in een view - // transition. De browser snapshot't de DOM voor en na, en animeert - // het verschil — vereist `view-transition-name` op de cards. - // Andere events (task INSERT/DELETE, story-events) krijgen geen - // animatie; die zijn niet zichtbaar als positie-shift in de - // kanban-kolommen. + // Animatie A: kanban-move animeren via View Transitions API. Voor + // task UPDATE-events wrap'en we de store-update in een view + // transition. flushSync forceert React om synchroon te renderen + // tijdens de transition-callback zodat de nieuwe DOM-state wordt + // gesnapshot voor de animatie. const animate = payload.entity === 'task' && payload.op === 'U' && @@ -96,10 +100,6 @@ export function useSoloRealtime(productId: string) { typeof (document as Document & { startViewTransition?: unknown }).startViewTransition === 'function' if (animate) { - // flushSync forceert React om de re-render synchroon te doen - // tijdens de view-transition callback, zodat de nieuwe DOM-state - // wordt gesnapshot voor de animatie. Zonder flushSync rendert - // React asynchroon en captured de browser nog de oude state. ;( document as Document & { startViewTransition: (cb: () => void) => unknown @@ -118,17 +118,11 @@ export function useSoloRealtime(productId: string) { } source.onerror = () => { - // EventSource probeert standaard zelf te reconnecten, maar we willen - // controle over backoff + skip-on-hidden. Dus close + plan zelf. if (sourceRef.current !== source) return close() - setStatus('disconnected') scheduleIndicator('disconnected') - if (document.visibilityState === 'hidden') { - // Niet retryen tot tab weer zichtbaar wordt - return - } + if (document.visibilityState === 'hidden') return const delay = backoffRef.current backoffRef.current = Math.min(backoffRef.current * 2, BACKOFF_MAX_MS) reconnectTimerRef.current = setTimeout(connect, delay) @@ -138,7 +132,6 @@ export function useSoloRealtime(productId: string) { const onVisibility = () => { if (document.visibilityState === 'hidden') { close() - setStatus('disconnected') scheduleIndicator('disconnected') } else if (sourceRef.current === null) { backoffRef.current = BACKOFF_START_MS @@ -157,6 +150,4 @@ export function useSoloRealtime(productId: string) { close() } }, [productId]) - - return { status, showConnectingIndicator } } diff --git a/scripts/realtime-mutate.ts b/scripts/realtime-mutate.ts new file mode 100644 index 0000000..5c126c2 --- /dev/null +++ b/scripts/realtime-mutate.ts @@ -0,0 +1,81 @@ +// Test-helper voor M8 acceptatie. Muteert een task of story rechtstreeks +// in de DB om realtime-events te triggeren — alsof MCP of een andere +// schrijver het zou doen. Niet voor productiegebruik. +// +// Gebruik: +// tsx scripts/realtime-mutate.ts move +// tsx scripts/realtime-mutate.ts touch task +// tsx scripts/realtime-mutate.ts touch story +// tsx scripts/realtime-mutate.ts rename story +// tsx scripts/realtime-mutate.ts list-tasks # toont id + status van assigned tasks + +import * as dotenv from 'dotenv' +import * as path from 'path' +import { Pool } from 'pg' + +const root = path.resolve(__dirname, '..') +dotenv.config({ path: path.join(root, '.env.local'), override: true }) +dotenv.config({ path: path.join(root, '.env') }) + +async function main() { + const url = process.env.DATABASE_URL + if (!url) throw new Error('DATABASE_URL is not set') + const pool = new Pool({ connectionString: url }) + + const [, , cmd, ...rest] = process.argv + + try { + if (cmd === 'move') { + const [taskId, status] = rest + if (!taskId || !status) throw new Error('move requires ') + const r = await pool.query( + 'UPDATE tasks SET status = $1::"TaskStatus", updated_at = NOW() WHERE id = $2 RETURNING id, status', + [status, taskId], + ) + console.log('moved:', r.rows[0]) + } else if (cmd === 'touch') { + const [entity, id] = rest + if (entity !== 'task' && entity !== 'story') throw new Error('touch entity must be task or story') + const table = entity === 'task' ? 'tasks' : 'stories' + const r = await pool.query( + `UPDATE ${table} SET updated_at = NOW() WHERE id = $1 RETURNING id`, + [id], + ) + console.log('touched:', r.rows[0]) + } else if (cmd === 'rename') { + const [entity, id, ...titleParts] = rest + const title = titleParts.join(' ') + if (entity !== 'story') throw new Error('rename only supported for story for now') + if (!id || !title) throw new Error('rename requires ') + const r = await pool.query( + 'UPDATE stories SET title = $1, updated_at = NOW() WHERE id = $2 RETURNING id, title', + [title, id], + ) + console.log('renamed:', r.rows[0]) + } else if (cmd === 'list-tasks') { + const r = await pool.query(` + SELECT t.id, t.title, t.status, s.code AS story_code, s.title AS story_title + FROM tasks t + JOIN stories s ON t.story_id = s.id + WHERE s.assignee_id IS NOT NULL + ORDER BY s.sort_order, t.sort_order + LIMIT 20 + `) + console.table(r.rows) + } else { + console.error('Usage:') + console.error(' tsx scripts/realtime-mutate.ts move ') + console.error(' tsx scripts/realtime-mutate.ts touch task|story ') + console.error(' tsx scripts/realtime-mutate.ts rename story ') + console.error(' tsx scripts/realtime-mutate.ts list-tasks') + process.exit(1) + } + } finally { + await pool.end() + } +} + +main().catch((err) => { + console.error(err.message) + process.exit(1) +}) diff --git a/stores/solo-store.ts b/stores/solo-store.ts index 961716c..39c3500 100644 --- a/stores/solo-store.ts +++ b/stores/solo-store.ts @@ -27,6 +27,8 @@ export interface RealtimeEvent { changed_fields?: string[] } +export type RealtimeStatus = 'connecting' | 'open' | 'disconnected' + interface SoloStore { tasks: Record /** Task-ids die op dit moment een eigen optimistic write in de lucht hebben. @@ -34,6 +36,12 @@ interface SoloStore { * twee keer toegepast wordt of door een latere echo overschreven. */ pendingOps: Set + /** Realtime-connection state, beheerd door useSoloRealtime in de + * (app)-layout. Hier in de store omdat de UI-indicator in SoloBoard zit en + * de hook niet direct in dezelfde subtree draait. */ + realtimeStatus: RealtimeStatus + showConnectingIndicator: boolean + initTasks: (tasks: SoloTask[]) => void optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null rollback: (taskId: string, prevStatus: TaskStatus) => void @@ -42,12 +50,16 @@ interface SoloStore { markPending: (taskId: string) => void clearPending: (taskId: string) => void + setRealtimeStatus: (status: RealtimeStatus, showConnectingIndicator: boolean) => void + handleRealtimeEvent: (event: RealtimeEvent) => void } export const useSoloStore = create((set, get) => ({ tasks: {}, pendingOps: new Set(), + realtimeStatus: 'connecting', + showConnectingIndicator: false, initTasks: (tasks) => set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }), @@ -81,6 +93,14 @@ export const useSoloStore = create((set, get) => ({ return { pendingOps: next } }), + setRealtimeStatus: (status, showConnectingIndicator) => + set((s) => { + if (s.realtimeStatus === status && s.showConnectingIndicator === showConnectingIndicator) { + return s + } + return { realtimeStatus: status, showConnectingIndicator } + }), + handleRealtimeEvent: (event) => { if (event.entity === 'task') { const { id, op } = event