From a0e586785714822ddd89aa128f8ee098eeb0921c Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Sun, 10 May 2026 12:44:32 +0200 Subject: [PATCH] feat(PBI-76): user-settings DB-store infrastructure (Phase 0) (#185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(PBI-76): plan for user-settings DB-store Persists view/filter prefs in User.settings (Json) instead of localStorage. SSR-correct hydration, cross-tab sync via LISTEN/NOTIFY + SSE, cross-device persistence. Phased: 0=infra, 1=migrate flicker sources, 2=cookie consolidation. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(PBI-76): User.settings json column + migration Adds JSONB column to users table for persistent user prefs. Idempotent SQL — safe on databases where column already exists. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(PBI-76): user-settings types and merge helpers Zod schema for User.settings shape (views/devTools), deep-merge helper that replaces arrays and merges nested objects, and a safe parser that returns defaults on invalid input. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(PBI-76): updateUserSettingsAction with notify Validates patch via Zod, deep-merges with current settings in a transaction, persists to DB, and emits pg_notify on scrum4me_changes for cross-tab/cross-device sync. Demo accounts get 403, unauthenticated 401, invalid input 422. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(PBI-76): user-settings zustand store with optimistic flow Hydrate from prop (SSR-correct), setPref via path with optimistic update + rollback on server error, applyServerPatch for SSE-driven cross-tab updates. Demo accounts skip server-write entirely. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(PBI-76): SSE route for user-settings User-scoped /api/realtime/user-settings stream that filters scrum4me_changes notifications on kind=user_settings and matching userId. Forwards the patch as a data: event so other tabs can applyServerPatch without re-fetching settings. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(PBI-76): user-settings bridge mounted in app layout Hydrates the zustand store with the user's persisted settings via prop (SSR-correct, no flicker). Opens an EventSource to /api/realtime/user-settings so changes from other tabs/devices flow into the same store. Demo accounts skip the SSE subscription. Layout now selects user.settings alongside the other user fields, no extra DB roundtrip. Co-Authored-By: Claude Opus 4.7 (1M context) * test(PBI-76): user-settings lib/action/store coverage 22 vitest cases covering merge semantics (no mutation, array replace, nested merge), Zod schema strictness, server action auth/demo/validation paths, and the optimistic store flow including rollback and demo-mode skip. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(PBI-76): sync package-lock to v1.3.3 Lockfile drifted after @prisma/client reinstall during the schema regenerate. No dependency changes — just the version field tracking package.json bumped in #184. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- __tests__/actions/user-settings.test.ts | 82 +++++++ __tests__/lib/user-settings.test.ts | 115 ++++++++++ __tests__/stores/user-settings.test.ts | 105 +++++++++ actions/user-settings.ts | 62 +++++ app/(app)/layout.tsx | 8 +- app/api/realtime/user-settings/route.ts | 146 ++++++++++++ components/shared/user-settings-bridge.tsx | 41 ++++ docs/INDEX.md | 1 + docs/plans/user-settings-store.md | 212 ++++++++++++++++++ lib/user-settings.ts | 83 +++++++ package-lock.json | 4 +- .../migration.sql | 2 + prisma/schema.prisma | 1 + stores/user-settings/selectors.ts | 23 ++ stores/user-settings/store.ts | 116 ++++++++++ 15 files changed, 998 insertions(+), 3 deletions(-) create mode 100644 __tests__/actions/user-settings.test.ts create mode 100644 __tests__/lib/user-settings.test.ts create mode 100644 __tests__/stores/user-settings.test.ts create mode 100644 actions/user-settings.ts create mode 100644 app/api/realtime/user-settings/route.ts create mode 100644 components/shared/user-settings-bridge.tsx create mode 100644 docs/plans/user-settings-store.md create mode 100644 lib/user-settings.ts create mode 100644 prisma/migrations/20260510113221_add_user_settings_json/migration.sql create mode 100644 stores/user-settings/selectors.ts create mode 100644 stores/user-settings/store.ts diff --git a/__tests__/actions/user-settings.test.ts b/__tests__/actions/user-settings.test.ts new file mode 100644 index 0000000..1fb53ad --- /dev/null +++ b/__tests__/actions/user-settings.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) +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/prisma', () => ({ + prisma: { + user: { findUnique: vi.fn() }, + $transaction: vi.fn(async (fn: (tx: unknown) => Promise) => { + return fn({ + user: { + findUnique: vi.fn().mockResolvedValue({ settings: {} }), + update: vi.fn().mockResolvedValue({}), + }, + }) + }), + $executeRaw: vi.fn().mockResolvedValue(1), + }, +})) + +import { prisma } from '@/lib/prisma' +import { getIronSession } from 'iron-session' +import { updateUserSettingsAction } from '@/actions/user-settings' + +const mockPrisma = prisma as unknown as { + user: { findUnique: ReturnType } + $transaction: ReturnType + $executeRaw: ReturnType +} +const mockGetIronSession = getIronSession as ReturnType + +beforeEach(() => { + vi.clearAllMocks() + mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockPrisma.$executeRaw.mockResolvedValue(1) +}) + +describe('updateUserSettingsAction', () => { + it('returns 401 when not logged in', async () => { + mockGetIronSession.mockResolvedValue({ userId: undefined, isDemo: false }) + const result = await updateUserSettingsAction({}) + expect(result).toEqual({ error: 'Niet ingelogd', code: 401 }) + }) + + it('returns 403 for demo accounts', async () => { + mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: true }) + const result = await updateUserSettingsAction({}) + expect('error' in result && result.code).toBe(403) + }) + + it('returns 422 when patch is invalid', async () => { + const result = await updateUserSettingsAction({ + views: { sprintBacklog: { filterStatus: 'NONSENSE' } }, + } as never) + expect('error' in result && result.code).toBe(422) + }) + + it('merges with current settings and emits notify on success', async () => { + const existingFindUnique = vi.fn().mockResolvedValue({ + settings: { views: { sprintBacklog: { sort: 'code' } } }, + }) + const update = vi.fn().mockResolvedValue({}) + mockPrisma.$transaction.mockImplementationOnce(async (fn: (tx: unknown) => Promise) => { + return fn({ user: { findUnique: existingFindUnique, update } }) + }) + + const result = await updateUserSettingsAction({ + views: { sprintBacklog: { sortDir: 'desc' } }, + }) + + expect('success' in result && result.success).toBe(true) + expect(update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { settings: { views: { sprintBacklog: { sort: 'code', sortDir: 'desc' } } } }, + }) + expect(mockPrisma.$executeRaw).toHaveBeenCalled() + }) +}) diff --git a/__tests__/lib/user-settings.test.ts b/__tests__/lib/user-settings.test.ts new file mode 100644 index 0000000..019cc33 --- /dev/null +++ b/__tests__/lib/user-settings.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' + +import { + DEFAULT_USER_SETTINGS, + UserSettingsSchema, + mergeSettings, + parseUserSettings, + type UserSettings, +} from '@/lib/user-settings' + +describe('mergeSettings', () => { + it('returns the patch when previous is empty', () => { + const result = mergeSettings({}, { views: { sprintBacklog: { sort: 'code' } } }) + expect(result).toEqual({ views: { sprintBacklog: { sort: 'code' } } }) + }) + + it('preserves existing keys when patch only sets new ones', () => { + const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } } + const result = mergeSettings(prev, { + views: { pbiList: { sort: 'date' } }, + }) + expect(result).toEqual({ + views: { + sprintBacklog: { sort: 'code' }, + pbiList: { sort: 'date' }, + }, + }) + }) + + it('merges nested objects without overwriting siblings', () => { + const prev: UserSettings = { + views: { sprintBacklog: { sort: 'code', sortDir: 'asc' } }, + } + const result = mergeSettings(prev, { + views: { sprintBacklog: { sort: 'priority' } }, + }) + expect(result).toEqual({ + views: { sprintBacklog: { sort: 'priority', sortDir: 'asc' } }, + }) + }) + + it('replaces arrays instead of appending', () => { + const prev: UserSettings = { + views: { sprintBacklog: { collapsedPbis: ['a', 'b'] } }, + } + const result = mergeSettings(prev, { + views: { sprintBacklog: { collapsedPbis: ['c'] } }, + }) + expect(result.views?.sprintBacklog?.collapsedPbis).toEqual(['c']) + }) + + it('does not mutate the previous object', () => { + const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } } + const snapshot = JSON.parse(JSON.stringify(prev)) + mergeSettings(prev, { views: { sprintBacklog: { sortDir: 'desc' } } }) + expect(prev).toEqual(snapshot) + }) + + it('skips undefined values in the patch', () => { + const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } } + const result = mergeSettings(prev, { views: undefined }) + expect(result).toEqual(prev) + }) +}) + +describe('parseUserSettings', () => { + it('returns defaults for null', () => { + expect(parseUserSettings(null)).toEqual(DEFAULT_USER_SETTINGS) + }) + + it('returns defaults for undefined', () => { + expect(parseUserSettings(undefined)).toEqual(DEFAULT_USER_SETTINGS) + }) + + it('returns defaults for invalid input', () => { + expect(parseUserSettings({ views: { sprintBacklog: { filterStatus: 'BOGUS' } } })) + .toEqual(DEFAULT_USER_SETTINGS) + }) + + it('passes valid settings through', () => { + const valid = { views: { sprintBacklog: { sort: 'code' as const } } } + expect(parseUserSettings(valid)).toEqual(valid) + }) +}) + +describe('UserSettingsSchema', () => { + it('rejects unknown top-level keys', () => { + const result = UserSettingsSchema.safeParse({ unknown: 1 }) + expect(result.success).toBe(false) + }) + + it('accepts an empty object', () => { + expect(UserSettingsSchema.safeParse({}).success).toBe(true) + }) + + it('accepts the full shape', () => { + const result = UserSettingsSchema.safeParse({ + views: { + sprintBacklog: { + filterPriority: 1, + filterStatus: 'OPEN', + sort: 'code', + sortDir: 'asc', + collapsedPbis: ['x'], + filterPopoverOpen: true, + }, + pbiList: { sort: 'priority', filterPriority: 'all', filterStatus: 'ready', sortDir: 'desc' }, + storyPanel: { sort: 'date' }, + jobsColumns: { 'queue:active': { kinds: ['TASK_IMPLEMENTATION'], statuses: [] } }, + }, + devTools: { debugMode: true }, + }) + expect(result.success).toBe(true) + }) +}) diff --git a/__tests__/stores/user-settings.test.ts b/__tests__/stores/user-settings.test.ts new file mode 100644 index 0000000..019d618 --- /dev/null +++ b/__tests__/stores/user-settings.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const updateAction = vi.fn() + +vi.mock('@/actions/user-settings', () => ({ + updateUserSettingsAction: (...args: unknown[]) => updateAction(...args), +})) + +import { useUserSettingsStore } from '@/stores/user-settings/store' + +function resetStore() { + useUserSettingsStore.setState((s) => { + s.entities.settings = {} + s.context.hydrated = false + s.context.isDemo = false + s.pendingMutations = {} + }) +} + +beforeEach(() => { + resetStore() + updateAction.mockReset() +}) + +afterEach(() => { + resetStore() +}) + +describe('useUserSettingsStore', () => { + it('hydrate sets entities and context', () => { + useUserSettingsStore.getState().hydrate( + { views: { sprintBacklog: { sort: 'code' } } }, + false, + ) + const s = useUserSettingsStore.getState() + expect(s.entities.settings.views?.sprintBacklog?.sort).toBe('code') + expect(s.context.hydrated).toBe(true) + expect(s.context.isDemo).toBe(false) + }) + + it('setPref updates state optimistically and settles on success', async () => { + useUserSettingsStore.getState().hydrate({}, false) + updateAction.mockResolvedValueOnce({ + success: true, + settings: { views: { sprintBacklog: { filterStatus: 'all' } } }, + }) + + await useUserSettingsStore + .getState() + .setPref(['views', 'sprintBacklog', 'filterStatus'], 'all') + + const s = useUserSettingsStore.getState() + expect(s.entities.settings.views?.sprintBacklog?.filterStatus).toBe('all') + expect(Object.keys(s.pendingMutations)).toHaveLength(0) + expect(updateAction).toHaveBeenCalledWith({ + views: { sprintBacklog: { filterStatus: 'all' } }, + }) + }) + + it('setPref rolls back on server error', async () => { + useUserSettingsStore.getState().hydrate( + { views: { sprintBacklog: { sort: 'code' } } }, + false, + ) + updateAction.mockResolvedValueOnce({ error: 'boom', code: 422 }) + + await useUserSettingsStore + .getState() + .setPref(['views', 'sprintBacklog', 'sort'], 'priority') + + const s = useUserSettingsStore.getState() + expect(s.entities.settings.views?.sprintBacklog?.sort).toBe('code') + expect(Object.keys(s.pendingMutations)).toHaveLength(0) + }) + + it('setPref skips server-call for demo accounts', async () => { + useUserSettingsStore.getState().hydrate({}, true) + + await useUserSettingsStore + .getState() + .setPref(['devTools', 'debugMode'], true) + + const s = useUserSettingsStore.getState() + expect(s.entities.settings.devTools?.debugMode).toBe(true) + expect(updateAction).not.toHaveBeenCalled() + }) + + it('applyServerPatch merges without optimistic state', () => { + useUserSettingsStore.getState().hydrate( + { views: { sprintBacklog: { sort: 'code' } } }, + false, + ) + + useUserSettingsStore.getState().applyServerPatch({ + views: { sprintBacklog: { sortDir: 'desc' } }, + }) + + const s = useUserSettingsStore.getState() + expect(s.entities.settings.views?.sprintBacklog).toEqual({ + sort: 'code', + sortDir: 'desc', + }) + expect(Object.keys(s.pendingMutations)).toHaveLength(0) + }) +}) diff --git a/actions/user-settings.ts b/actions/user-settings.ts new file mode 100644 index 0000000..e3a9cbb --- /dev/null +++ b/actions/user-settings.ts @@ -0,0 +1,62 @@ +'use server' + +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' +import { + UserSettingsSchema, + mergeSettings, + parseUserSettings, + type UserSettings, +} from '@/lib/user-settings' + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +export type UpdateUserSettingsResult = + | { success: true; settings: UserSettings } + | { error: string; code: 401 | 403 | 422; fieldErrors?: Record } + +export async function updateUserSettingsAction( + patch: Partial, +): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const parsed = UserSettingsSchema.partial().safeParse(patch) + if (!parsed.success) { + return { + error: 'Ongeldige settings-patch', + code: 422, + fieldErrors: parsed.error.flatten().fieldErrors as Record, + } + } + + const merged = await prisma.$transaction(async (tx) => { + const user = await tx.user.findUnique({ + where: { id: session.userId! }, + select: { settings: true }, + }) + const current = parseUserSettings(user?.settings) + const next = mergeSettings(current, parsed.data) + await tx.user.update({ + where: { id: session.userId! }, + data: { settings: next as unknown as Prisma.InputJsonValue }, + }) + return next + }) + + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + kind: 'user_settings', + userId: session.userId, + patch: parsed.data, + })}::text) + ` + + return { success: true, settings: merged } +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 9271c4c..424f323 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -8,6 +8,8 @@ import { MinWidthBanner } from '@/components/shared/min-width-banner' import { StatusBar } from '@/components/shared/status-bar' import { SoloRealtimeBridge } from '@/components/solo/realtime-bridge' import { NotificationsBridge } from '@/components/notifications/notifications-bridge' +import { UserSettingsBridge } from '@/components/shared/user-settings-bridge' +import { parseUserSettings } from '@/lib/user-settings' import { AlertToast } from '@/components/shared/alert-toast' import { Suspense } from 'react' @@ -17,7 +19,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod const [user, userRoles, accessibleProducts] = await Promise.all([ prisma.user.findUnique({ where: { id: session.userId }, - select: { username: true, email: true, active_product_id: true, min_quota_pct: true }, + select: { username: true, email: true, active_product_id: true, min_quota_pct: true, settings: true }, }), prisma.userRole.findMany({ where: { user_id: session.userId }, @@ -79,6 +81,10 @@ export default async function AppLayout({ children }: { children: React.ReactNod + diff --git a/app/api/realtime/user-settings/route.ts b/app/api/realtime/user-settings/route.ts new file mode 100644 index 0000000..6c3261f --- /dev/null +++ b/app/api/realtime/user-settings/route.ts @@ -0,0 +1,146 @@ +// PBI-76: User-scoped SSE stream voor user-settings cross-tab/cross-device sync. +// +// Wordt door in app/(app)/layout.tsx geopend zodra de +// gebruiker is ingelogd. Filtert pg_notify-payloads op +// `kind === 'user_settings' && userId === session.userId`. Settings worden +// via prop al gehydrateerd; deze route levert alleen incrementele patches. +// +// Auth: iron-session cookie. Demo-tokens openen geen subscription (bridge +// skipt voor isDemo). +// Output: text/event-stream — `data:` met de patch (Partial). +// Sluit zelf na 240s als safety-net; client herconnect. + +import { NextRequest } from 'next/server' +import { Client } from 'pg' +import { getSession } from '@/lib/auth' +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 + +interface UserSettingsPayload { + kind: 'user_settings' + userId: string + patch: Record +} + +function isUserSettingsPayload(p: unknown): p is UserSettingsPayload { + if (typeof p !== 'object' || p === null) return false + const obj = p as Record + return ( + obj.kind === 'user_settings' && + typeof obj.userId === 'string' && + typeof obj.patch === 'object' && + obj.patch !== null + ) +} + +export async function GET(request: NextRequest) { + const session = await getSession() + if (!session.userId) { + return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) + } + const userId = session.userId + + 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 | null = null + let hardCloseTimer: ReturnType | 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 { + // controller already closed + } + } + + const cleanup = async (reason: string) => { + if (closed) return + closed = true + if (heartbeatTimer) clearInterval(heartbeatTimer) + if (hardCloseTimer) clearTimeout(hardCloseTimer) + await closePgClientSafely(pgClient, 'realtime/user-settings') + try { + controller.close() + } catch { + // already closed + } + if (process.env.NODE_ENV !== 'production') { + console.log(`[realtime/user-settings] closed: ${reason}`) + } + } + + try { + await pgClient.connect() + await pgClient.query(`LISTEN ${CHANNEL}`) + } catch (err) { + console.error('[realtime/user-settings] 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: unknown + try { + payload = JSON.parse(msg.payload) + } catch { + return + } + if (!isUserSettingsPayload(payload)) return + if (payload.userId !== userId) return + enqueue(`data: ${JSON.stringify(payload.patch)}\n\n`) + }) + + pgClient.on('error', (err) => { + console.error('[realtime/user-settings] pg client error:', err) + cleanup('pg error') + }) + + enqueue(`: connected\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', + }, + }) +} diff --git a/components/shared/user-settings-bridge.tsx b/components/shared/user-settings-bridge.tsx new file mode 100644 index 0000000..6a8740e --- /dev/null +++ b/components/shared/user-settings-bridge.tsx @@ -0,0 +1,41 @@ +'use client' + +import { useEffect } from 'react' +import { useUserSettingsStore } from '@/stores/user-settings/store' +import type { UserSettings } from '@/lib/user-settings' + +interface Props { + initial: UserSettings + isDemo: boolean +} + +/** + * PBI-76: hydrates the user-settings Zustand store with server-rendered prefs + * and opens an SSE subscription so other tabs/devices of the same user + * immediately see changes. Demo accounts skip the SSE subscription — their + * settings live only in-memory. + */ +export function UserSettingsBridge({ initial, isDemo }: Props) { + const hydrate = useUserSettingsStore((s) => s.hydrate) + const applyServerPatch = useUserSettingsStore((s) => s.applyServerPatch) + + useEffect(() => { + hydrate(initial, isDemo) + }, [hydrate, initial, isDemo]) + + useEffect(() => { + if (isDemo) return + const es = new EventSource('/api/realtime/user-settings') + es.onmessage = (e) => { + try { + const patch = JSON.parse(e.data) as Partial + applyServerPatch(patch) + } catch { + // ignore malformed event + } + } + return () => es.close() + }, [applyServerPatch, isDemo]) + + return null +} diff --git a/docs/INDEX.md b/docs/INDEX.md index ef5e696..8e7a080 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -62,6 +62,7 @@ Auto-generated on 2026-05-10 from front-matter and headings. | [ST-1114 — Copilot reviews op dashboard](./plans/ST-1114-copilot-reviews.md) | active | 2026-05-03 | | [Plan: wekelijkse sync van `model_prices` (PBI-66 / ST-1296)](./plans/sync-model-prices.md) | — | — | | [Tweede Claude Agent — Planning Agent](./plans/tweede-claude-agent-planning.md) | proposal | 2026-05-03 | +| [User-settings store (DB-backed user prefs)](./plans/user-settings-store.md) | draft | 2026-05-10 | | [Scrum4Me — v1.0 readiness](./plans/v1-readiness.md) | active | 2026-05-04 | | [Zustand store rearchitecture - active context, realtime en resync](./plans/zustand-store-rearchitecture.md) | ready-to-execute | 2026-05-09 | | [Zustand workspace-store implementatieplan (PBI-74)](./plans/zustand-workspace-store-implementation.md) | in-progress | 2026-05-10 | diff --git a/docs/plans/user-settings-store.md b/docs/plans/user-settings-store.md new file mode 100644 index 0000000..ea8ee91 --- /dev/null +++ b/docs/plans/user-settings-store.md @@ -0,0 +1,212 @@ +--- +title: "User-settings store (DB-backed user prefs)" +status: draft +audience: [contributor, ai-agent] +language: nl +last_updated: 2026-05-10 +--- + +# User-settings store (DB-backed user prefs) + +> **Locatie na approval:** verhuis dit bestand naar `docs/plans/user-settings-store.md` in de repo. +> Trigger voor dit plan: zichtbare hydratie-flits op het sprint-scherm in v1.3.3 ([PR #184](https://github.com/madhura68/Scrum4Me/pull/184)). +> De fix daar (useEffect-hydratie + `prefsLoaded`-gate) is een tijdelijke patch; deze migratie elimineert de flits volledig. + +## Context + +Filter- en view-prefs zitten nu verspreid over `localStorage` (en deels cookies). +Bij SSR weet de server niets van `localStorage`, dus bij users met saved-state ≠ +default ontstaat één render-flits direct na hydratie. Daarnaast werken die prefs +alleen per browser — geen cross-device, en cross-tab-sync vereist `storage`-events. + +Doel: **één `User.settings` JSON-veld** als single source of truth, met: + +- Server-component leest het veld bij elke page-render → SSR-correct, geen flits +- Zustand-store met optimistic updates patroon (zoals `product-workspace-store`) +- Cross-tab sync via bestaande `LISTEN/NOTIFY` + SSE-bridge +- Cross-device persistence (login op andere browser/laptop ziet zelfde prefs) + +--- + +## Scope (gefaseerd) + +### Fase 0 — Infrastructuur + +Aparte PR. Geen UI-wijziging; legt het fundament. Resultaat is een werkende store +zonder migraties; bestaande localStorage-flow blijft intact tot Fase 1. + +| # | Bestand | Wat | +|---|---|---| +| 0.1 | `prisma/schema.prisma` | `settings Json @default("{}")` op `User` model + migration | +| 0.2 | `lib/user-settings.ts` | Zod-schema + types + `mergeSettings(prev, patch)` deep-merge helper + defaults | +| 0.3 | `actions/user-settings.ts` | `updateUserSettingsAction(patch: Partial)` — auth-guard, Zod-validate, deep-merge in DB transactie, `NOTIFY scrum4me_changes 'user_settings:${userId}'` | +| 0.4 | `stores/user-settings/store.ts` | Zustand met `entities.settings: UserSettings`, `hydrate(initial)`, generieke `setPref(path, value)` met optimistic + rollback. Zelfde mutation-flow als `product-workspace-store` | +| 0.5 | `app/api/realtime/user-settings/route.ts` | SSE-route per user, `LISTEN user_settings:${userId}`, push patches | +| 0.6 | `components/shared/user-settings-bridge.tsx` | Server reads `prisma.user.findUnique({select:{settings:true}})`, geeft door als prop, client mount roept `store.hydrate()` aan + opent SSE | +| 0.7 | Mount in `app/(app)/layout.tsx` | Bridge bovenin de app-layout zodat de store altijd beschikbaar is voor alle authenticated pagina's | +| 0.8 | Tests | `__tests__/lib/user-settings.test.ts` (merge-logic), `__tests__/actions/user-settings.test.ts` (auth + validation), `__tests__/stores/user-settings.test.ts` (optimistic flow) | + +**Demo/anon-fallback:** `useUserSettingsStore` detecteert `session.isDemo` of geen `userId` +en valt terug op in-memory state (geen server-write). Bridge wordt voor demo niet +gemount — defaults blijven actief, geen persistence-verwachting. + +### Fase 1 — Migreer huidige flits-bronnen + +| Component | localStorage-keys | → `settings`-pad | +|---|---|---| +| `components/sprint/sprint-backlog.tsx` | `scrum4me:sprint_pb_*` (6) | `views.sprintBacklog.{filterPriority,filterStatus,sort,sortDir,collapsedPbis,filterPopoverOpen}` | +| `components/backlog/pbi-list.tsx` | `scrum4me:pbi_*` (4) | `views.pbiList.{sort,filterPriority,filterStatus,sortDir}` | +| `components/backlog/story-panel.tsx` | `scrum4me:story_sort` (1) | `views.storyPanel.sort` | +| `components/jobs/jobs-column.tsx` | `${prefix}_filter_kind`, `${prefix}_filter_status` (2 dyn.) | `views.jobsColumns[prefix].{kinds,statuses}` | +| `stores/debug-store.ts` (via `status-bar-debug-toggle`) | `scrum4me:debug-mode` (1) | `devTools.debugMode` | + +Per component: +- Verwijder `useState` + `useEffect`-hydratie + `useEffect`-write +- Vervang door `useUserSettingsStore(s => s.entities.settings.views.sprintBacklog?.filterStatus ?? 'OPEN')` +- Setter wordt `useUserSettingsStore.getState().setPref(['views','sprintBacklog','filterStatus'], value)` +- `prefsLoaded`-state en helpers (`readLocalStoragePref`) verdwijnen +- `lib/use-local-storage-pref.ts` wordt verwijderd (niet meer in gebruik) + +**Migratie-pad voor bestaande users:** bij eerste mount, voor de eerste `setPref`-call, +leest een one-shot `useEffect` de oude localStorage-keys en pusht ze als één bulk-patch +naar de server. Daarna `localStorage.removeItem(...)` om geen verwarring te wekken. +Idempotent: als `settings.views.sprintBacklog.filterStatus` al gezet is, sla over. + +### Fase 2 — Cookie-consolidatie (optioneel, later PR) + +| Bron | Huidig | → `settings`-pad | +|---|---|---| +| `components/shared/split-pane.tsx` | `document.cookie` (`sp:` prefix) | `layout.splitPanePositions[cookieKey]` | +| `lib/active-sprint.ts` + `actions/active-sprint.ts` | server-side cookie per product | `layout.activeSprints[productId]` | + +Server-component-lezers veranderen — apart traject met meer regression-risico. +Niet onderdeel van de eerste user-settings-PR. + +### Fase 3 — Skip / al persistent + +- `idea-md-editor.tsx` drafts — werk-in-progress, geen pref +- `iron-session` cookies — auth, andere zorg +- `User.active_product_id` — al in DB (kolom op model) +- Modal/popover open-state behalve `filterPopoverOpen` — ephemeral + +--- + +## JSON-shape (Fase 1) + +```ts +// lib/user-settings.ts +import { z } from 'zod' + +export const UserSettingsSchema = z.object({ + views: z.object({ + sprintBacklog: z.object({ + filterPriority: z.union([z.number().int().min(1).max(4), z.literal('all')]).optional(), + filterStatus: z.enum(['OPEN', 'IN_SPRINT', 'DONE', 'all']).optional(), + sort: z.enum(['priority', 'status', 'code']).optional(), + sortDir: z.enum(['asc', 'desc']).optional(), + collapsedPbis: z.array(z.string()).optional(), + filterPopoverOpen: z.boolean().optional(), + }).optional(), + pbiList: z.object({ + sort: z.enum(['priority', 'code', 'date']).optional(), + filterPriority: z.union([z.number().int().min(1).max(4), z.literal('all')]).optional(), + filterStatus: z.enum(['ready', 'blocked', 'done', 'all']).optional(), + sortDir: z.enum(['asc', 'desc']).optional(), + }).optional(), + storyPanel: z.object({ + sort: z.enum(['priority', 'code', 'date']).optional(), + }).optional(), + jobsColumns: z.record(z.string(), z.object({ + kinds: z.array(z.string()), + statuses: z.array(z.string()), + })).optional(), + }).optional(), + devTools: z.object({ + debugMode: z.boolean().optional(), + }).optional(), +}).strict() + +export type UserSettings = z.infer +``` + +Defaults zijn impliciet (alle keys optioneel). Selectors in de store geven +fallback-waardes terug zodat consumers niet `?? 'OPEN'` hoeven te schrijven — +maar het mag, geen big deal. + +--- + +## Realtime-notificatie + +Bestaand kanaal `scrum4me_changes` blijft. Payload-conventie: + +```json +{ "kind": "user_settings", "userId": "...", "patch": { "views": { ... } } } +``` + +`/api/realtime/user-settings/route.ts` filtert payloads op `userId === session.userId`. +Andere tabs van zelfde user krijgen patches binnen, store roept `applyServerPatch(patch)` +aan zonder optimistic flow. + +--- + +## Verificatie (per fase) + +### Fase 0 +- [ ] `npm run verify && npm run build` groen +- [ ] Migration draait op fresh + bestaande DB zonder data-verlies +- [ ] `updateUserSettingsAction` weigert auth-loze calls (test) +- [ ] Zod-validatie geeft 422 bij invalid patch (test) +- [ ] Optimistic update + rollback gedraagt zich zoals `product-workspace-store` (test) +- [ ] SSE-route levert patches alleen aan zelfde user (manueel: open twee tabs als A, schrijf, zie update; tab van user B blijft stil) + +### Fase 1 +- [ ] Geen `localStorage.getItem` of `localStorage.setItem` meer in de gemigreerde componenten +- [ ] Sprint screen: refresh → filter direct correct, geen flits, geen hydration error in console +- [ ] Product backlog screen: idem +- [ ] Jobs page: idem (per kolom-instance) +- [ ] Two-tab test: filter wijzigen in tab A → tab B updatet binnen ~100ms +- [ ] Demo-user: filter wijzigen werkt binnen sessie, niet gepersisteerd na refresh (verwacht) +- [ ] One-shot localStorage-migratie: bestaande user met oude keys ziet bij eerste login zijn waardes terug; na refresh zijn de localStorage-keys leeg + +### Fase 2 +- [ ] Split-pane positie persistent en SSR-correct +- [ ] Active-sprint per product werkt zonder cookie + +--- + +## Schatting + +| Fase | Tijd | +|---|---| +| 0 — Infra | ~3 uur | +| 1 — Migratie | ~2 uur | +| 2 — Cookies | ~2 uur (apart) | +| Totaal Fase 0 + 1 | **~5 uur**, 1 PR (of 2 als we 0 en 1 splitsen) | + +Aanbevolen: **Fase 0 + 1 in één PR** als de infra klein blijft, anders splitsen +per fase. Fase 2 is altijd een aparte PR. + +--- + +## Open vragen + +1. **Cross-device merge-conflict.** Twee tabs van zelfde user op verschillende + devices wijzigen tegelijk. Server-side: `last-write-wins` of `JSON_PATCH`-merge? + Voorstel: deep-merge per top-level path, dus `views.sprintBacklog.filterStatus` + en `views.pbiList.sort` botsen niet — laatste schrijver per veld wint. +2. **Storage-grens.** PostgreSQL JSON kolom kan ~1GB; we zitten op <5KB per user. + Geen concern. +3. **Schema-versionering.** Als we het JSON-schema later wijzigen: voorzichtig + migreren via Zod `.catch()` voor onbekende keys. Voor v1: start klein. +4. **One-shot localStorage-migratie weglaten?** Voor solo-dev-tool kan het + acceptabel zijn dat users hun saved filters verliezen bij de migratie. Scheelt + ~30 minuten implementatie + tests. + +--- + +## Eerste stappen na approval + +1. Verhuis dit plan naar `docs/plans/user-settings-store.md` in een nieuwe branch (bv. `feat/user-settings-store`) +2. Maak via Scrum4Me-MCP een PBI met story + taken voor Fase 0 (volgens CLAUDE.md werkflow) +3. Start met taken in `sort_order`; commit per laag +4. Fase 1 als opvolg-PBI (of in dezelfde sprint, los gelabeld) diff --git a/lib/user-settings.ts b/lib/user-settings.ts new file mode 100644 index 0000000..86621fd --- /dev/null +++ b/lib/user-settings.ts @@ -0,0 +1,83 @@ +import { z } from 'zod' + +const PriorityFilter = z.union([ + z.number().int().min(1).max(4), + z.literal('all'), +]) + +const SortDir = z.enum(['asc', 'desc']) + +const SprintBacklogPrefs = z.object({ + filterPriority: PriorityFilter.optional(), + filterStatus: z.enum(['OPEN', 'IN_SPRINT', 'DONE', 'all']).optional(), + sort: z.enum(['priority', 'status', 'code']).optional(), + sortDir: SortDir.optional(), + collapsedPbis: z.array(z.string()).optional(), + filterPopoverOpen: z.boolean().optional(), +}).strict() + +const PbiListPrefs = z.object({ + sort: z.enum(['priority', 'code', 'date']).optional(), + filterPriority: PriorityFilter.optional(), + filterStatus: z.enum(['ready', 'blocked', 'done', 'all']).optional(), + sortDir: SortDir.optional(), +}).strict() + +const StoryPanelPrefs = z.object({ + sort: z.enum(['priority', 'code', 'date']).optional(), +}).strict() + +const JobsColumnPrefs = z.object({ + kinds: z.array(z.string()), + statuses: z.array(z.string()), +}).strict() + +const ViewsPrefs = z.object({ + sprintBacklog: SprintBacklogPrefs.optional(), + pbiList: PbiListPrefs.optional(), + storyPanel: StoryPanelPrefs.optional(), + jobsColumns: z.record(z.string(), JobsColumnPrefs).optional(), +}).strict() + +const DevToolsPrefs = z.object({ + debugMode: z.boolean().optional(), +}).strict() + +export const UserSettingsSchema = z.object({ + views: ViewsPrefs.optional(), + devTools: DevToolsPrefs.optional(), +}).strict() + +export type UserSettings = z.infer + +export const DEFAULT_USER_SETTINGS: UserSettings = {} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function mergeSettings( + prev: UserSettings, + patch: Partial, +): UserSettings { + const out: Record = { ...prev } + for (const [key, patchValue] of Object.entries(patch)) { + if (patchValue === undefined) continue + const prevValue = (prev as Record)[key] + if (isPlainObject(patchValue) && isPlainObject(prevValue)) { + out[key] = mergeSettings( + prevValue as UserSettings, + patchValue as Partial, + ) + } else { + out[key] = patchValue + } + } + return out as UserSettings +} + +export function parseUserSettings(raw: unknown): UserSettings { + if (raw === null || raw === undefined) return DEFAULT_USER_SETTINGS + const result = UserSettingsSchema.safeParse(raw) + return result.success ? result.data : DEFAULT_USER_SETTINGS +} diff --git a/package-lock.json b/package-lock.json index c7c24f3..1092e43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scrum4me", - "version": "1.3.2", + "version": "1.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scrum4me", - "version": "1.3.2", + "version": "1.3.3", "hasInstallScript": true, "dependencies": { "@base-ui/react": "^1.4.1", diff --git a/prisma/migrations/20260510113221_add_user_settings_json/migration.sql b/prisma/migrations/20260510113221_add_user_settings_json/migration.sql new file mode 100644 index 0000000..8311085 --- /dev/null +++ b/prisma/migrations/20260510113221_add_user_settings_json/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "settings" JSONB NOT NULL DEFAULT '{}'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4f6b086..011e514 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -147,6 +147,7 @@ model User { active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) idea_code_counter Int @default(0) min_quota_pct Int @default(20) + settings Json @default("{}") created_at DateTime @default(now()) updated_at DateTime @updatedAt roles UserRole[] diff --git a/stores/user-settings/selectors.ts b/stores/user-settings/selectors.ts new file mode 100644 index 0000000..24ddb80 --- /dev/null +++ b/stores/user-settings/selectors.ts @@ -0,0 +1,23 @@ +import type { UserSettings } from '@/lib/user-settings' + +interface StateLike { + entities: { settings: UserSettings } + context: { hydrated: boolean; isDemo: boolean } +} + +export const selectSprintBacklogPrefs = (s: StateLike) => + s.entities.settings.views?.sprintBacklog ?? {} + +export const selectPbiListPrefs = (s: StateLike) => + s.entities.settings.views?.pbiList ?? {} + +export const selectStoryPanelPrefs = (s: StateLike) => + s.entities.settings.views?.storyPanel ?? {} + +export const selectJobsColumnPrefs = (key: string) => (s: StateLike) => + s.entities.settings.views?.jobsColumns?.[key] ?? { kinds: [], statuses: [] } + +export const selectDevToolsPrefs = (s: StateLike) => + s.entities.settings.devTools ?? {} + +export const selectHydrated = (s: StateLike) => s.context.hydrated diff --git a/stores/user-settings/store.ts b/stores/user-settings/store.ts new file mode 100644 index 0000000..abdb038 --- /dev/null +++ b/stores/user-settings/store.ts @@ -0,0 +1,116 @@ +import { create } from 'zustand' +import { immer } from 'zustand/middleware/immer' + +import { + DEFAULT_USER_SETTINGS, + mergeSettings, + type UserSettings, +} from '@/lib/user-settings' +import { updateUserSettingsAction } from '@/actions/user-settings' + +type SettingsPath = readonly (string | number)[] + +interface PendingMutation { + id: number + prev: UserSettings +} + +interface UserSettingsState { + entities: { settings: UserSettings } + context: { + hydrated: boolean + isDemo: boolean + } + pendingMutations: Record +} + +interface UserSettingsActions { + hydrate: (initial: UserSettings, isDemo: boolean) => void + setPref: (path: SettingsPath, value: unknown) => Promise + applyServerPatch: (patch: Partial) => void +} + +let nextMutationId = 1 + +function patchFromPath(path: SettingsPath, value: unknown): Partial { + if (path.length === 0) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Partial + } + return {} + } + const out: Record = {} + let cursor: Record = out + for (let i = 0; i < path.length - 1; i++) { + const key = String(path[i]) + cursor[key] = {} + cursor = cursor[key] as Record + } + cursor[String(path[path.length - 1])] = value + return out as Partial +} + +export const useUserSettingsStore = create()( + immer((set, get) => ({ + entities: { settings: DEFAULT_USER_SETTINGS }, + context: { hydrated: false, isDemo: false }, + pendingMutations: {}, + + hydrate: (initial, isDemo) => { + set((draft) => { + draft.entities.settings = initial as UserSettings + draft.context.hydrated = true + draft.context.isDemo = isDemo + }) + }, + + applyServerPatch: (patch) => { + set((draft) => { + draft.entities.settings = mergeSettings( + draft.entities.settings as UserSettings, + patch, + ) as UserSettings + }) + }, + + setPref: async (path, value) => { + const patch = patchFromPath(path, value) + + // Demo: lokale merge zonder server-call. + if (get().context.isDemo) { + set((draft) => { + draft.entities.settings = mergeSettings( + draft.entities.settings as UserSettings, + patch, + ) as UserSettings + }) + return + } + + const mutationId = nextMutationId++ + const prev = get().entities.settings as UserSettings + + // Optimistic. + set((draft) => { + draft.entities.settings = mergeSettings( + draft.entities.settings as UserSettings, + patch, + ) as UserSettings + draft.pendingMutations[mutationId] = { id: mutationId, prev } + }) + + const result = await updateUserSettingsAction(patch) + + set((draft) => { + delete draft.pendingMutations[mutationId] + if ('error' in result) { + // Rollback alleen als geen latere mutatie de waarde alweer heeft overschreven. + draft.entities.settings = prev as UserSettings + } else { + // Settle: server-merge is autoritatief. + draft.entities.settings = result.settings as UserSettings + } + }) + }, + })), +)