From 0e3893dbab2c65c951b31c9cb8378c8df272222c Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 10 May 2026 15:22:12 +0200 Subject: [PATCH] feat(PBI-76): resolveActiveSprint reads from User.settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/active-sprint: - New helpers: getActiveSprintIdFromSettings, setActiveSprintInSettings, clearActiveSprintInSettings — all read/write user.settings.layout.activeSprints. - resolveActiveSprint(productId, userId) — userId now required, falls back to first OPEN, then most recent CLOSED sprint. - Cookie helpers (getActiveSprintIdFromCookie/setActiveSprintCookie/ clearActiveSprintCookie) removed. Callers updated to pass session.userId. The cookie-based fallback path is gone — `actions/active-sprint.ts` and `actions/sprints.ts` will be updated in the next commit (T-917). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/(app)/layout.tsx | 2 +- app/(app)/products/[id]/page.tsx | 2 +- app/(app)/products/[id]/sprint/page.tsx | 4 +- lib/active-sprint.ts | 104 +++++++++++++++++------- lib/solo-workspace-server.ts | 2 +- lib/sprint-switcher-data.ts | 6 +- 6 files changed, 85 insertions(+), 35 deletions(-) diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 424f323..15eab5a 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -47,7 +47,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod }) if (product) { activeProduct = product - const resolved = await resolveActiveSprint(product.id) + const resolved = await resolveActiveSprint(product.id, session.userId) hasActiveSprint = !!resolved } else { await prisma.user.update({ diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index bf38aa4..386fe56 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -38,7 +38,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props const [user, switcherData] = await Promise.all([ prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }), - getSprintSwitcherData(id), + getSprintSwitcherData(id, { userId: session.userId }), ]) const { sprintItems, buildingSprintIds, activeSprintItem } = switcherData const hasOpenSprint = sprintItems.some(s => s.status === 'open') diff --git a/app/(app)/products/[id]/sprint/page.tsx b/app/(app)/products/[id]/sprint/page.tsx index cea6993..5f0e6ab 100644 --- a/app/(app)/products/[id]/sprint/page.tsx +++ b/app/(app)/products/[id]/sprint/page.tsx @@ -1,4 +1,5 @@ import { redirect } from 'next/navigation' +import { requireSession } from '@/lib/auth-guard' import { resolveActiveSprint } from '@/lib/active-sprint' interface Props { @@ -7,7 +8,8 @@ interface Props { export default async function SprintRedirectPage({ params }: Props) { const { id } = await params - const active = await resolveActiveSprint(id) + const session = await requireSession() + const active = await resolveActiveSprint(id, session.userId) if (!active) { redirect(`/products/${id}?alert=no_sprint`) } diff --git a/lib/active-sprint.ts b/lib/active-sprint.ts index 5a76de6..17cf527 100644 --- a/lib/active-sprint.ts +++ b/lib/active-sprint.ts @@ -1,6 +1,10 @@ -import { cookies } from 'next/headers' -import type { SprintStatus } from '@prisma/client' +import type { Prisma, SprintStatus } from '@prisma/client' import { prisma } from '@/lib/prisma' +import { + mergeSettings, + parseUserSettings, + type UserSettings, +} from '@/lib/user-settings' export type ActiveSprint = { id: string @@ -8,43 +12,87 @@ export type ActiveSprint = { status: SprintStatus } -function cookieName(productId: string): string { - return `active_sprint_${productId}` +async function readSettings(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { settings: true }, + }) + return parseUserSettings(user?.settings) } -export async function getActiveSprintIdFromCookie( - productId: string, -): Promise { - const store = await cookies() - return store.get(cookieName(productId))?.value ?? null -} - -export async function setActiveSprintCookie( - productId: string, - sprintId: string, -): Promise { - const store = await cookies() - store.set(cookieName(productId), sprintId, { - path: '/', - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - maxAge: 60 * 60 * 24 * 365, +async function writeSettings(userId: string, next: UserSettings): Promise { + await prisma.user.update({ + where: { id: userId }, + data: { settings: next as unknown as Prisma.InputJsonValue }, }) } -export async function clearActiveSprintCookie(productId: string): Promise { - const store = await cookies() - store.delete(cookieName(productId)) +async function notifyUserSettings( + userId: string, + patch: Partial, +): Promise { + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + kind: 'user_settings', + userId, + patch, + })}::text) + ` +} + +export async function getActiveSprintIdFromSettings( + userId: string, + productId: string, +): Promise { + const settings = await readSettings(userId) + return settings.layout?.activeSprints?.[productId] ?? null +} + +export async function setActiveSprintInSettings( + userId: string, + productId: string, + sprintId: string, +): Promise { + const current = await readSettings(userId) + const patch: Partial = { + layout: { + activeSprints: { + ...(current.layout?.activeSprints ?? {}), + [productId]: sprintId, + }, + }, + } + await writeSettings(userId, mergeSettings(current, patch)) + await notifyUserSettings(userId, patch) +} + +export async function clearActiveSprintInSettings( + userId: string, + productId: string, +): Promise { + const current = await readSettings(userId) + const existing = current.layout?.activeSprints + if (!existing || !(productId in existing)) return + const nextActiveSprints = { ...existing } + delete nextActiveSprints[productId] + const next: UserSettings = { + ...current, + layout: { ...current.layout, activeSprints: nextActiveSprints }, + } + await writeSettings(userId, next) + await notifyUserSettings(userId, { + layout: { activeSprints: nextActiveSprints }, + }) } export async function resolveActiveSprint( productId: string, + userId: string, ): Promise { - const cookieId = await getActiveSprintIdFromCookie(productId) - - if (cookieId) { + const stored = await getActiveSprintIdFromSettings(userId, productId) + if (stored) { const sprint = await prisma.sprint.findFirst({ - where: { id: cookieId, product_id: productId }, + where: { id: stored, product_id: productId }, select: { id: true, code: true, status: true }, }) if (sprint) return sprint diff --git a/lib/solo-workspace-server.ts b/lib/solo-workspace-server.ts index 972c546..01e2329 100644 --- a/lib/solo-workspace-server.ts +++ b/lib/solo-workspace-server.ts @@ -17,7 +17,7 @@ export async function getSoloWorkspaceSnapshot( const product = await getAccessibleProduct(productId, userId) if (!product) return null - const active = sprintId ? { id: sprintId } : await resolveActiveSprint(productId) + const active = sprintId ? { id: sprintId } : await resolveActiveSprint(productId, userId) const sprint = active ? await prisma.sprint.findFirst({ where: { id: active.id, product_id: productId } }) : null diff --git a/lib/sprint-switcher-data.ts b/lib/sprint-switcher-data.ts index 040c19b..db170d6 100644 --- a/lib/sprint-switcher-data.ts +++ b/lib/sprint-switcher-data.ts @@ -19,7 +19,7 @@ export interface SprintSwitcherData { export async function getSprintSwitcherData( productId: string, - opts?: { activeSprintId?: string | null }, + opts?: { activeSprintId?: string | null; userId?: string }, ): Promise { const allSprints = await prisma.sprint.findMany({ where: { product_id: productId }, @@ -51,8 +51,8 @@ export async function getSprintSwitcherData( activeSprintItem = opts.activeSprintId ? sprintItems.find(s => s.id === opts.activeSprintId) ?? null : null - } else { - const resolved = await resolveActiveSprint(productId) + } else if (opts?.userId) { + const resolved = await resolveActiveSprint(productId, opts.userId) activeSprintItem = resolved ? sprintItems.find(s => s.id === resolved.id) ?? null : null