--- title: "Project Structure, Stores, Realtime & Job Queue" status: active audience: [maintainer, contributor] language: nl last_updated: 2026-05-03 related: [data-model.md](./data-model.md) --- ## Projectstructuur ``` scrum4me/ ├── app/ │ ├── (auth)/ │ │ ├── login/page.tsx │ │ └── register/page.tsx │ ├── (app)/ # Beschermde routes (desktop + tablets) │ │ ├── layout.tsx # Auth-check (requireSession) + navigatie │ │ ├── dashboard/page.tsx # Productenlijst │ │ ├── products/ │ │ │ ├── new/page.tsx │ │ │ └── [id]/ │ │ │ ├── layout.tsx # Zet actief product in Zustand store │ │ │ ├── page.tsx # Product Backlog (gesplitst scherm) │ │ │ ├── solo/page.tsx # Solo board (Kanban per ingelogde gebruiker) │ │ │ ├── sprint/ │ │ │ │ ├── page.tsx # Sprint Backlog (drie-paneel scherm) │ │ │ │ └── planning/page.tsx # Redirect → /sprint │ │ ├── todos/page.tsx │ │ └── settings/ │ │ ├── page.tsx # Profiel, account, PB-overzicht, rollen, tokens │ │ └── tokens/page.tsx │ ├── (mobile)/ # Mobile-shell route group (telefoon-UA) │ │ ├── layout.tsx # Auth via gedeelde requireSession; geen NavBar/StatusBar │ │ └── m/ │ │ ├── settings/page.tsx # Account + product-selector + QR-instructie + logout │ │ ├── pair/ # QR-pairing (verhuisd uit (app)/ — URL ongewijzigd) │ │ │ ├── page.tsx │ │ │ └── pair-confirmation.tsx │ │ └── products/[id]/ │ │ ├── page.tsx # Mobile Product Backlog (tab-mode op <1024px) │ │ └── solo/page.tsx # Mobile Solo (3-koloms-kanban) │ ├── api/ # REST API voor Claude Code │ │ ├── products/ │ │ │ └── [id]/ │ │ │ └── next-story/route.ts │ │ ├── profile/ │ │ │ └── avatar/route.ts # POST upload + GET serve profielfoto │ │ ├── sprints/ │ │ │ └── [id]/ │ │ │ └── tasks/route.ts │ │ ├── stories/ │ │ │ └── [id]/ │ │ │ ├── log/route.ts │ │ │ └── tasks/reorder/route.ts │ │ ├── tasks/ │ │ │ └── [id]/route.ts │ │ └── todos/route.ts ├── components/ │ ├── ui/ # shadcn/ui primitieven │ ├── split-pane/ # Gesplitst scherm component │ ├── backlog/ # PBI- en story-componenten │ ├── sprint/ # Sprint-componenten │ ├── products/ # ProductForm, TeamManager, ArchiveProductButton │ ├── settings/ # RoleManager, ProfileEditor, LeaveProductButton │ ├── mobile/ # LandscapeGuard, MobileTabBar, LogoutButton │ └── dnd/ # dnd-kit wrappers ├── lib/ │ ├── prisma.ts # Prisma Client singleton │ ├── session.ts # iron-session configuratie │ ├── auth.ts # login/register/token helpers │ ├── api-auth.ts # Bearer token middleware voor API │ ├── product-access.ts # productAccessFilter helper (eigenaar of teamlid) │ └── env.ts # Zod-gevalideerde env vars ├── stores/ # Zustand stores │ ├── backlog-store.ts # PBI/story/task hydration + applyChange (SSE) │ ├── planner-store.ts # Optimistische drag-and-drop volgorde │ ├── selection-store.ts # Geselecteerd PBI / story (cascade-reset) │ ├── sprint-store.ts # Sprint Backlog taakvolgordes │ ├── solo-store.ts # Solo board optimistische taakstatus │ └── product-store.ts # Actief product (naam + id) voor navbar ├── prisma/ │ ├── schema.prisma │ ├── migrations/ │ └── seed.ts # Testdata uit Product Backlog document ├── proxy.ts # Next.js 16 proxy voor route protection ├── prisma.config.ts # Prisma v7 config (DATABASE_URL) └── .env.example ``` --- ## Sleutelarchitectuurbeslissingen ### Beslissing: iron-session in plaats van Auth.js / Supabase Auth **Keuze:** iron-session voor versleutelde server-side sessiecookies **Rationale:** Scrum4Me gebruikt username/wachtwoord zonder e-mail — een flow die Auth.js/NextAuth met Credentials Provider ondersteunt, maar met onnodige complexiteit (JWT-callbacks, adapter-configuratie). iron-session is minimaal: sla een gesigneerde, versleutelde cookie op met `{ userId, isDemo }` en klaar. Geen externe afhankelijkheid, geen database-adapter voor sessies. **Trade-off:** Geen ingebouwde OAuth of magic links. Dat is bewust — v1 heeft die niet nodig. ### Beslissing: Route Handlers naast Server Actions **Keuze:** Server Actions voor UI-mutaties; Route Handlers voor de Claude Code REST API **Rationale:** Server Actions zijn ideaal voor form-submits en UI-interacties (CSRF-bescherming, progressive enhancement). Maar Claude Code heeft echte HTTP-endpoints nodig — Bearer token, JSON body, programmatisch aanroepbaar. Die twee aanpakken leven naast elkaar zonder conflict. **Trade-off:** Duplicatie in validatie-logica. Opgelost door gedeelde service-functies in `lib/` die beide aanroepen. ### Beslissing: Float voor sort_order **Keuze:** `Float` in plaats van `Int` voor volgorde van PBI's, stories en taken **Rationale:** Bij drag-and-drop tussenvoeging kan de nieuwe positie worden berekend als het gemiddelde van de buurwaarden (bijv. `(1.0 + 2.0) / 2 = 1.5`). Hierdoor is nooit een herindexering van alle items nodig. Herindexering is alleen nodig als de float-precisie opraakt (in de praktijk na duizenden bewegingen). **Trade-off:** Kleine kans op precisieverlies bij extreme fragmentatie. Opgelost door periodieke herindexering als de minimale afstand onder een drempelwaarde valt. ### Beslissing: Denormalisatie van `product_id` op `stories` en `sprint_id` op `tasks` **Keuze:** `product_id` opslaan op zowel `pbis` als `stories`; `sprint_id` op zowel `stories` als `tasks` **Rationale:** Veel queries in de gesplitste schermen filteren op product of Sprint zonder de volledige hiërarchie te doorlopen. Directe foreign keys voorkomen onnodige joins en N+1-risico's. **Trade-off:** Redundante data vereist consistente updates. Gehandhaafd via Prisma-transacties in de service-laag. ### Beslissing: Zustand voor client-side state management **Keuze:** Vijf Zustand-stores naast Server Components **Rationale:** De gesplitste schermen met dnd-kit vereisen client-side staat die twee panelen tegelijk aanstuurt. `useState` per component leidt tot prop drilling; Context API veroorzaakt onnodige re-renders bij 60fps drag-events. Zustand's selector-gebaseerde subscriptions updaten alleen de componenten die de gewijzigde slice observeren. De gouden regel: Zustand beheert uitsluitend ephemere UI-staat — nooit server-data. Server-data blijft in Server Components en wordt opgehaald via Prisma. **Trade-off:** Extra abstractielaag die geïnitialiseerd moet worden vanuit server-data. Opgelost via een patroon waarbij het Server Component de initiële ids doorgeeft aan een Client Component dat de store hydrateert. ### Beslissing: Eigen route group `(mobile)` voor mobile-shell (PBI-11) **Keuze:** Telefoon-routes leven onder `app/(mobile)/m/*` met eigen `layout.tsx`, niet als nested directory in `(app)/m/*`. **Rationale:** Next.js layouts erven naar binnen — een nested layout in `(app)/m/` zou de NavBar/StatusBar/MinWidthBanner/SoloRealtimeBridge/NotificationsBridge erven van `(app)/layout.tsx` zonder die te kunnen onderdrukken. De mobile-shell heeft die chrome niet nodig (alleen bottom-tab-bar). Een eigen route group geeft een schone parent-layout. De auth-check is geëxtraheerd naar `lib/auth-guard.ts` `requireSession()` zodat `(app)/layout.tsx` en `(mobile)/layout.tsx` dezelfde guard delen. **Trade-off:** Twee layouts om te onderhouden, maar elk met een duidelijk afgebakende verantwoordelijkheid. Content-componenten (PbiList, StoryPanel, TaskPanel, SoloBoard, alle entity-dialogen) blijven volledig gedeeld — geen dubbele implementatie. ### Beslissing: UA-redirect via `Mobi`-substring (PBI-11) **Keuze:** `lib/user-agent.ts` `isPhoneUA()` test op `Mobi` in de UA-string. `loginAction` (`actions/auth.ts`) leest de header na `session.save()`; phone-UA → `/m/products/[active]/solo` (zonder actief product → `/m/settings`); tablet-UA en desktop → `/dashboard`. **Rationale:** `Mobi` is de standaard-heuristiek — aanwezig in iPhone Safari Mobile en Android Chrome op telefoons, afwezig op iPad en Android-tablet. Exact wat we willen: alleen telefoons krijgen de mobile-shell, tablets behouden de desktop-flow. **Trade-off:** Heuristieken zijn nooit 100%; wie via een mobile-emulatie (DevTools) wil testen kan UA spoofen. ### Beslissing: Gedeelde `entityDialogContentClasses` voor mobile-fullscreen (PBI-11) **Keuze:** Eén Tailwind-class-string in `components/shared/entity-dialog-layout.ts` met `max-sm:w-screen max-sm:h-screen max-sm:max-w-none max-sm:rounded-none` dekt alle entity-dialogen (PbiDialog, StoryDialog, TaskDialog, TaskDetailDialog). **Rationale:** Dialog-fullscreen op mobile op vier plekken bewaken zou drift introduceren. De gedeelde constant geeft één bron van waarheid. Het regressie-vangnet (`__tests__/components/shared/entity-dialog-layout.test.ts`) verifieert dat elke dialog deze constant blijft gebruiken. **Trade-off:** Eén dialog kan niet afwijken zonder de constant te verlaten — bewuste keuze voor consistentie. ### Beslissing: Gescheiden SplitPane cookie-key voor mobile (PBI-11) **Keuze:** `BacklogSplitPane` op `app/(mobile)/m/products/[id]/page.tsx` gebruikt `cookieKey={\`backlog-${id}-mobile\`}` (versus desktop `backlog-${id}`). **Rationale:** Op mobile rendert de `SplitPane` in tab-mode (`<1024px`), waar split-percentages niet aangepast worden. Zonder gescheiden key zou dezelfde cookie hergebruikt worden — telefoon-rotaties of orientatie-wisselingen hadden anders ongewenste interactie met de desktop-split-state. **Trade-off:** Gebruikers die zowel mobile als desktop gebruiken hebben twee onafhankelijke split-instellingen, wat juist gewenst is. --- ## Zustand stores ### `usePlannerStore` — optimistische drag-and-drop volgorde Beheert de lokale volgorde van PBI's, stories en taken tijdens en na drag-and-drop, voordat de server bevestigt. Houdt de UI vloeiend op 60fps ongeacht netwerklatency. ```ts // stores/planner-store.ts import { create } from 'zustand' interface PlannerStore { // Optimistische volgorde per container (id-arrays) pbiOrder: Record // productId → pbi-ids storyOrder: Record // pbiId → story-ids taskOrder: Record // storyId → taak-ids // Initialiseren vanuit server-data (bij mount) initPbis: (productId: string, ids: string[]) => void initStories: (pbiId: string, ids: string[]) => void initTasks: (storyId: string, ids: string[]) => void // Optimistisch updaten (vóór server-bevestiging) reorderPbis: (productId: string, newOrder: string[]) => void reorderStories: (pbiId: string, newOrder: string[]) => void reorderTasks: (storyId: string, newOrder: string[]) => void // Terugdraaien bij server-fout rollbackPbis: (productId: string, prevOrder: string[]) => void rollbackStories: (pbiId: string, prevOrder: string[]) => void rollbackTasks: (storyId: string, prevOrder: string[]) => void } ``` **Gebruikspatroon:** ```ts // 1. Server Component geeft ids door // app/(app)/products/[id]/page.tsx const pbis = await prisma.pbi.findMany({ where: { product_id: id }, orderBy: [...] }) return p.id)} pbis={pbis} /> // 2. Client Component hydrateert store // components/backlog/backlog-panel.tsx 'use client' const { initPbis, reorderPbis, rollbackPbis } = usePlannerStore() useEffect(() => { initPbis(productId, initialPbiIds) }, []) // 3. dnd-kit onDragEnd → optimistisch updaten + Server Action const prevOrder = usePlannerStore(s => s.pbiOrder[productId]) reorderPbis(productId, newOrder) const result = await reorderPbisAction(productId, newOrder) if (!result.success) rollbackPbis(productId, prevOrder) ``` --- ### `useSelectionStore` — navigatieselectie Beheert welk PBI of story geselecteerd is in het linkerpaneel, zodat beide panelen en de navigatiebar synchroon reageren zonder prop drilling. ```ts // stores/selection-store.ts interface SelectionStore { selectedPbiId: string | null selectedStoryId: string | null selectPbi: (id: string | null) => void selectStory: (id: string | null) => void clearSelection: () => void } ``` --- ### `useSprintStore` — Sprint Backlog interacties Beheert optimistische toevoegingen en verwijderingen van stories aan de Sprint Backlog tijdens drag-and-drop tussen de twee panelen. ```ts // stores/sprint-store.ts interface SprintStore { // Stories per Sprint (optimistisch, op volgorde) sprintStoryIds: Record // sprintId → story-ids initSprint: (sprintId: string, ids: string[]) => void addStoryToSprint: (sprintId: string, storyId: string, atIndex: number) => void removeStoryFromSprint: (sprintId: string, storyId: string) => void reorderSprintStories: (sprintId: string, newOrder: string[]) => void rollbackSprint: (sprintId: string, prevIds: string[]) => void } ``` --- ### `useSoloStore` — Solo board optimistische taakstatus Beheert de taakstatus van de ingelogde gebruiker op het solo Kanban-board. Ondersteunt optimistische verplaatsingen tussen kolommen met rollback bij serverfout. ```ts // stores/solo-store.ts interface SoloStore { tasks: Record initTasks: (tasks: SoloTask[]) => void optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null rollback: (taskId: string, prevStatus: TaskStatus) => void updatePlan: (taskId: string, plan: string | null) => void } ``` --- ### `useProductStore` — Actief product voor navbar Houdt het actief geselecteerde product (id + naam) bij zodat de navbar de productnaam kan tonen zonder prop drilling door de layout-hiërarchie. ```ts // stores/product-store.ts interface ProductStore { currentProduct: { id: string; name: string } | null setCurrentProduct: (id: string, name: string) => void clearCurrentProduct: () => void } ``` --- ## Data flow architectuur ``` ┌─────────────────────────────────────────┐ │ Server Component (page.tsx) │ │ Prisma query → initiële data + ids │ │ → props naar Client Component │ └──────────────────┬──────────────────────┘ │ initialIds, initialData ▼ ┌─────────────────────────────────────────┐ │ Client Component (panel.tsx) │ │ useEffect → store.init(ids) │ │ dnd-kit drag → store.reorder() │ │ → Server Action (async) │ │ → bij fout: store.rollback()│ └──────────────────┬──────────────────────┘ │ selecteert state via selector ▼ ┌─────────────────────────────────────────┐ │ Zustand Stores │ │ usePlannerStore useSelectionStore │ │ useSprintStore │ │ │ │ Alleen ephemere UI-staat │ │ Nooit server-data of business logic │ └─────────────────────────────────────────┘ ``` **Keuze:** API-tokens opgeslagen als SHA-256 hashes in de `api_tokens` tabel **Rationale:** Het token zelf wordt eenmalig getoond aan de gebruiker en nooit opgeslagen. De hash is voldoende voor lookup en verificatie. Redis of een aparte token-store zou overkill zijn voor v1-schaal. **Trade-off:** Tokens kunnen niet worden verlengd of geroteerd zonder een nieuw token aan te maken. --- ## Realtime updates (M8) Het Solo Paneel update live als andere gebruikers, scripts of admin-tools een task of story muteren. De pijplijn: ``` ┌─────────────────────────┐ │ Mutatie (Prisma write) │ PATCH /api/tasks/:id └────────────┬────────────┘ Server Action, MCP, etc. ▼ ┌─────────────────────────┐ │ Postgres row trigger │ AFTER INSERT/UPDATE/DELETE │ scrum4me_notify_change()│ bouwt JSON payload └────────────┬────────────┘ ▼ pg_notify('scrum4me_changes', json) ┌─────────────────────────┐ │ /api/realtime/solo │ Node runtime, dedicated pg.Client │ LISTEN scrum4me_changes │ filtert op product + sprint + assignee └────────────┬────────────┘ ▼ text/event-stream ┌─────────────────────────┐ │ EventSource (browser) │ beheerd door useSoloRealtime │ → solo-store.handleEvent│ via flushSync + startViewTransition └────────────┬────────────┘ ▼ ┌─────────────────────────┐ │ SoloBoard re-render │ kanban-kaartje animeert naar │ (View Transitions API) │ zijn nieuwe kolom └─────────────────────────┘ ``` **Keuze:** Postgres LISTEN/NOTIFY in plaats van polling, websockets of een externe broker (Pusher, Ably, Supabase Realtime). **Rationale:** Eén Neon-database is al een verplichte dependency; LISTEN/NOTIFY voegt geen nieuwe infrastructuur toe. Polling zou voor één gebruiker prima werken maar schaalt slecht; een externe broker introduceert kosten, een tweede auth-laag, en synchronisatie-races tussen DB-writes en push-events. **Trade-off:** Vereist een direct (unpooled) connection per open tab — Neon pooler ondersteunt LISTEN niet. Bij veel gelijktijdige gebruikers een keer her-evalueren. ### Mutaties die NOTIFY triggeren De row trigger zit op `task` en `story`. Elke INSERT/UPDATE/DELETE op die tabellen — onafhankelijk van de bron (Prisma, MCP-server, raw SQL) — vuurt een NOTIFY met de geüpdate kolommen. Andere tabellen (Sprint, Product, etc.) doen dat niet; die hebben geen live-view in M8. ### Server-side filter `/api/realtime/solo?product_id=...` filtert NOTIFY-payloads op: - `product_id` matcht de query-param - `sprint_id` matcht de actieve sprint van het product (resolve éénmaal per connect) - `assignee_id` is gelijk aan de ingelogde `userId` (of `null` voor unassigned-story-claims) Niet-matchende events worden server-side gedropt zodat de browser geen irrelevante data ontvangt en de solo-store geen onnodige diff-checks doet. ### Connection lifecycle - **Open**: `EventSource('/api/realtime/solo?product_id=...')` zodra de gebruiker een actief product heeft. `SoloRealtimeBridge` mount in `(app)/layout` en krijgt het `productId` via prop, zodat de stream over de hele app open staat — niet alleen op `/solo`. Zo kunnen de Live-status-dot en worker-presence-indicator in de NavBar overal werken. Buiten `/solo` is de solo-store leeg en zijn binnenkomende task-events no-ops (`stores/solo-store.ts handleRealtimeEvent` skipt onbekende ids), dus de stream gedraagt zich automatisch als lichte presence-stream tot `SoloBoard` mount. - **Reconnect**: exponential backoff bij `onerror` (1s → 30s, reset bij `ready` event). - **Pause op tab-hidden**: `document.visibilityState === 'hidden'` sluit de stream actief. Bij `visible` wordt opnieuw verbonden. Dit voorkomt dat inactieve tabs DB-connecties open houden. - **Hard close**: server sluit zelf na 240s (Vercel `maxDuration` is 300s); client herconnect transparant. - **Heartbeat**: server stuurt elke 25s een `: heartbeat`-comment om proxies te keep-alive'n. **Bekende beperking M8**: events die binnenkomen terwijl de tab `hidden` is, worden niet vervangen bij heropening. De gebruiker ziet de meest recente Postgres-state pas bij een page-refresh of een nieuwe mutatie. Voor v1 acceptabel; in M9+ overwegen we een replay-fetch op visibility-resume. ### Animatie Voor `task UPDATE`-events wordt de store-update gewikkeld in `document.startViewTransition(() => flushSync(() => handleEvent(payload)))`. `flushSync` dwingt React om binnen de transition-callback synchroon te renderen, zodat View Transitions de oude en nieuwe DOM correct snapshot. Vereist `view-transition-name` op de task-cards (gezet op task-id). INSERT/DELETE-events animeren niet — die mutaties komen typisch met een page-load. ### Auth Iron-session cookie of Bearer-token (demo). De auth-check loopt éénmalig bij de connect-request; tijdens de stream zelf is er geen herauth, dus een ingetrokken sessie blijft live tot de stream sluit (heartbeat-fail of hard-close). Voor M8 acceptabel — sessies expireren na 30 dagen. --- ## Realtime — Backlog SSE (ST-1115) De Product Backlog-pagina (`/products/[id]`) update live als PBI's, stories of taken worden gemuteerd. De pijplijn is gelijk aan de Solo-SSE (M8), maar met een eenvoudiger server-side filter: alleen `product_id`-scope, geen sprint- of user-scope. ``` ┌─────────────────────────┐ │ Mutatie (Prisma write) │ Server Action, MCP, etc. └────────────┬────────────┘ ▼ ┌─────────────────────────┐ │ Postgres row trigger │ AFTER INSERT/UPDATE/DELETE │ scrum4me_notify_change()│ entity: 'pbi' | 'story' | 'task' └────────────┬────────────┘ ▼ pg_notify('scrum4me_changes', json) ┌─────────────────────────┐ │ /api/realtime/backlog │ Node runtime, dedicated pg.Client │ LISTEN scrum4me_changes │ filtert op entity ∈ {pbi,story,task} │ │ én product_id matcht query-param └────────────┬────────────┘ ▼ text/event-stream ┌─────────────────────────┐ │ EventSource (browser) │ beheerd door useBacklogRealtime │ → backlog-store.apply │ via applyChange(entity, op, data) │ Change(entity,op,data)│ └────────────┬────────────┘ ▼ ┌─────────────────────────┐ │ PbiList / StoryPanel / │ re-render op basis van Zustand state │ TaskPanel re-render │ └─────────────────────────┘ ``` ### Hydration en SSE-mount De pagina is een Server Component die alle data parallel fetcht. Resultaten worden doorgegeven aan `BacklogHydrationWrapper` (client component), die: 1. `useBacklogStore.setInitialData(...)` aanroept op mount (eenmalig). 2. `useBacklogRealtime(productId)` mount — opent de SSE-stream. Alle client-componenten (PbiList, StoryPanel, TaskPanel) lezen uitsluitend uit de Zustand store; ze accepteren geen data-props meer. ### backlog-store en applyChange ```ts // stores/backlog-store.ts applyChange(entity: 'pbi' | 'story' | 'task', op: 'I' | 'U' | 'D', data: Record) ``` - **I (Insert):** voegt het nieuwe object toe aan de juiste sub-array - **U (Update):** patcht de bestaande entry in-place via spread (`{ ...existing, ...data }`) - **D (Delete):** filtert de entry weg op `id`; doorzoekt alle sub-arrays omdat de parent-ID afwezig kan zijn in het delete-payload ### Server-side filter (backlog) `/api/realtime/backlog?product_id=...` filtert op: - `entity ∈ {pbi, story, task}` — job/worker-events en questions worden genegeerd - `product_id` matcht de query-param Demo-gebruikers mogen lezen (geen 403). Overige lifecycle-kenmerken (heartbeat, hard-close, backoff, visibility-pause) zijn identiek aan de Solo SSE. ---