feat(PBI-76): user-settings DB-store infrastructure (Phase 0) (#185)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1f8cbacb0a
commit
a0e5867857
15 changed files with 998 additions and 3 deletions
82
__tests__/actions/user-settings.test.ts
Normal file
82
__tests__/actions/user-settings.test.ts
Normal file
|
|
@ -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<unknown>) => {
|
||||||
|
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<typeof vi.fn> }
|
||||||
|
$transaction: ReturnType<typeof vi.fn>
|
||||||
|
$executeRaw: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
const mockGetIronSession = getIronSession as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
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<unknown>) => {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
115
__tests__/lib/user-settings.test.ts
Normal file
115
__tests__/lib/user-settings.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
105
__tests__/stores/user-settings.test.ts
Normal file
105
__tests__/stores/user-settings.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
62
actions/user-settings.ts
Normal file
62
actions/user-settings.ts
Normal file
|
|
@ -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<SessionData>(await cookies(), sessionOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateUserSettingsResult =
|
||||||
|
| { success: true; settings: UserSettings }
|
||||||
|
| { error: string; code: 401 | 403 | 422; fieldErrors?: Record<string, string[]> }
|
||||||
|
|
||||||
|
export async function updateUserSettingsAction(
|
||||||
|
patch: Partial<UserSettings>,
|
||||||
|
): Promise<UpdateUserSettingsResult> {
|
||||||
|
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<string, string[]>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,8 @@ import { MinWidthBanner } from '@/components/shared/min-width-banner'
|
||||||
import { StatusBar } from '@/components/shared/status-bar'
|
import { StatusBar } from '@/components/shared/status-bar'
|
||||||
import { SoloRealtimeBridge } from '@/components/solo/realtime-bridge'
|
import { SoloRealtimeBridge } from '@/components/solo/realtime-bridge'
|
||||||
import { NotificationsBridge } from '@/components/notifications/notifications-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 { AlertToast } from '@/components/shared/alert-toast'
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
|
|
||||||
|
|
@ -17,7 +19,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||||
const [user, userRoles, accessibleProducts] = await Promise.all([
|
const [user, userRoles, accessibleProducts] = await Promise.all([
|
||||||
prisma.user.findUnique({
|
prisma.user.findUnique({
|
||||||
where: { id: session.userId },
|
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({
|
prisma.userRole.findMany({
|
||||||
where: { user_id: session.userId },
|
where: { user_id: session.userId },
|
||||||
|
|
@ -79,6 +81,10 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
<SoloRealtimeBridge productId={activeProduct?.id ?? null} />
|
<SoloRealtimeBridge productId={activeProduct?.id ?? null} />
|
||||||
<NotificationsBridge userId={session.userId} />
|
<NotificationsBridge userId={session.userId} />
|
||||||
|
<UserSettingsBridge
|
||||||
|
initial={parseUserSettings(user.settings)}
|
||||||
|
isDemo={session.isDemo ?? false}
|
||||||
|
/>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<AlertToast />
|
<AlertToast />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
|
||||||
146
app/api/realtime/user-settings/route.ts
Normal file
146
app/api/realtime/user-settings/route.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
// PBI-76: User-scoped SSE stream voor user-settings cross-tab/cross-device sync.
|
||||||
|
//
|
||||||
|
// Wordt door <UserSettingsBridge /> 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<UserSettings>).
|
||||||
|
// 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<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUserSettingsPayload(p: unknown): p is UserSettingsPayload {
|
||||||
|
if (typeof p !== 'object' || p === null) return false
|
||||||
|
const obj = p as Record<string, unknown>
|
||||||
|
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<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 {
|
||||||
|
// 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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
41
components/shared/user-settings-bridge.tsx
Normal file
41
components/shared/user-settings-bridge.tsx
Normal file
|
|
@ -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<UserSettings>
|
||||||
|
applyServerPatch(patch)
|
||||||
|
} catch {
|
||||||
|
// ignore malformed event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => es.close()
|
||||||
|
}, [applyServerPatch, isDemo])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
@ -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 |
|
| [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) | — | — |
|
| [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 |
|
| [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 |
|
| [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 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 |
|
| [Zustand workspace-store implementatieplan (PBI-74)](./plans/zustand-workspace-store-implementation.md) | in-progress | 2026-05-10 |
|
||||||
|
|
|
||||||
212
docs/plans/user-settings-store.md
Normal file
212
docs/plans/user-settings-store.md
Normal file
|
|
@ -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<UserSettings>)` — 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<typeof UserSettingsSchema>
|
||||||
|
```
|
||||||
|
|
||||||
|
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)
|
||||||
83
lib/user-settings.ts
Normal file
83
lib/user-settings.ts
Normal file
|
|
@ -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<typeof UserSettingsSchema>
|
||||||
|
|
||||||
|
export const DEFAULT_USER_SETTINGS: UserSettings = {}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeSettings(
|
||||||
|
prev: UserSettings,
|
||||||
|
patch: Partial<UserSettings>,
|
||||||
|
): UserSettings {
|
||||||
|
const out: Record<string, unknown> = { ...prev }
|
||||||
|
for (const [key, patchValue] of Object.entries(patch)) {
|
||||||
|
if (patchValue === undefined) continue
|
||||||
|
const prevValue = (prev as Record<string, unknown>)[key]
|
||||||
|
if (isPlainObject(patchValue) && isPlainObject(prevValue)) {
|
||||||
|
out[key] = mergeSettings(
|
||||||
|
prevValue as UserSettings,
|
||||||
|
patchValue as Partial<UserSettings>,
|
||||||
|
)
|
||||||
|
} 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
|
||||||
|
}
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "scrum4me",
|
"name": "scrum4me",
|
||||||
"version": "1.3.2",
|
"version": "1.3.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "scrum4me",
|
"name": "scrum4me",
|
||||||
"version": "1.3.2",
|
"version": "1.3.3",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.1",
|
"@base-ui/react": "^1.4.1",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "settings" JSONB NOT NULL DEFAULT '{}';
|
||||||
|
|
@ -147,6 +147,7 @@ model User {
|
||||||
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
|
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
|
||||||
idea_code_counter Int @default(0)
|
idea_code_counter Int @default(0)
|
||||||
min_quota_pct Int @default(20)
|
min_quota_pct Int @default(20)
|
||||||
|
settings Json @default("{}")
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
roles UserRole[]
|
roles UserRole[]
|
||||||
|
|
|
||||||
23
stores/user-settings/selectors.ts
Normal file
23
stores/user-settings/selectors.ts
Normal file
|
|
@ -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
|
||||||
116
stores/user-settings/store.ts
Normal file
116
stores/user-settings/store.ts
Normal file
|
|
@ -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<number, PendingMutation>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserSettingsActions {
|
||||||
|
hydrate: (initial: UserSettings, isDemo: boolean) => void
|
||||||
|
setPref: (path: SettingsPath, value: unknown) => Promise<void>
|
||||||
|
applyServerPatch: (patch: Partial<UserSettings>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextMutationId = 1
|
||||||
|
|
||||||
|
function patchFromPath(path: SettingsPath, value: unknown): Partial<UserSettings> {
|
||||||
|
if (path.length === 0) {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return value as Partial<UserSettings>
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
const out: Record<string, unknown> = {}
|
||||||
|
let cursor: Record<string, unknown> = out
|
||||||
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
|
const key = String(path[i])
|
||||||
|
cursor[key] = {}
|
||||||
|
cursor = cursor[key] as Record<string, unknown>
|
||||||
|
}
|
||||||
|
cursor[String(path[path.length - 1])] = value
|
||||||
|
return out as Partial<UserSettings>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUserSettingsStore = create<UserSettingsState & UserSettingsActions>()(
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue