From 88dca4102c22315141945c0dedc5ad9a6c774ddc Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Mon, 27 Apr 2026 20:25:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(M9):=20active=20product=20backlog=20?= =?UTF-8?q?=E2=80=94=20persistent=20active=20PB,=20NavBar=20splits,=20spri?= =?UTF-8?q?nt=20card=20styling=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tooling): extend backlog parser to support PBI-x milestone headers Co-Authored-By: Claude Sonnet 4.6 * chore(backlog): mark ST-801–806 as done Co-Authored-By: Claude Sonnet 4.6 * feat(backlog): sorteer PBI's en stories op prio/code/datum, onthoud keuze in localStorage; vergroot sprint-afronden dialoog Co-Authored-By: Claude Sonnet 4.6 * feat(ST-901): add user.active_product_id with FK to Product - Nullable relation User → Product with onDelete: SetNull - Index on active_product_id for join performance - Migration: 20260427165329_add_user_active_product_id - Install @tanstack/react-table (was missing from node_modules) - Fix PRIORITY_COLORS ref removed in earlier refactor - Note: User schema change affects vendor/scrum4me-mcp submodule — run prisma generate + tsc --noEmit there after merge Co-Authored-By: Claude Sonnet 4.6 * fix: restore priority color on PBI filter pill Co-Authored-By: Claude Sonnet 4.6 * feat(ST-902): add setActiveProduct + clearActiveProduct server actions - actions/active-product.ts: setActiveProductAction validates access via productAccessFilter, rejects archived products and demo users - archiveProductAction: clears active_product_id for all affected users in transaction - removeProductMemberAction: clears active_product_id for removed member - leaveProductAction: clears active_product_id for leaving user Co-Authored-By: Claude Sonnet 4.6 * feat(ST-903): load active product in layout, replace cookie with DB lookup in solo - layout.tsx: fetch active_product_id, resolve product, clear stale ref server-side - NavBar: add activeProduct prop (rendering changes in ST-904) - solo/page.tsx: redirect via user.active_product_id instead of lastProductId cookie - proxy.ts: remove lastProductId cookie logic - lib/cookies.ts: deleted (no longer used) Co-Authored-By: Claude Sonnet 4.6 * feat(ST-904): split NavBar into 5 tabs with disabled-states and product-switcher dropdown Co-Authored-By: Claude Sonnet 4.6 * feat(ST-905): add Activeer button per product row in dashboard and product header * feat(ST-906): redirect to dashboard with toast when active product becomes inaccessible * feat(ST-907): tests for active-product actions and functional spec update for M9 * docs(M9): add implementation plan document and link from backlog * feat: active PB indicator, Maak actief button and new product link in settings * feat: apply priority-color card style to sprint story rows * fix: move add-to-sprint click from entire card to + Toevoegen button * feat: apply priority-color card style to sprint task rows * fix(sprint-backlog): prevent text selection on PBI collapse button * chore: bump version to 0.4.0 (M9 active product backlog) * fix(landing): align logged-in nav left to match app NavBar --------- Co-authored-by: Claude Sonnet 4.6 --- CLAUDE.md | 1 + __tests__/actions/active-product.test.ts | 101 ++++++ actions/active-product.ts | 51 +++ actions/products.ts | 28 +- app/(app)/dashboard/page.tsx | 14 +- app/(app)/layout.tsx | 42 ++- app/(app)/products/[id]/page.tsx | 14 +- app/(app)/settings/page.tsx | 104 ++++-- app/(app)/solo/page.tsx | 17 +- app/page.tsx | 88 +++-- components/backlog/pbi-list.tsx | 105 +++--- components/backlog/story-panel.tsx | 91 +++-- components/dashboard/product-list.tsx | 26 +- components/shared/activate-product-button.tsx | 39 +++ components/shared/alert-toast.tsx | 28 ++ components/shared/nav-bar.tsx | 190 ++++++++--- components/sprint/sprint-backlog.tsx | 313 +++++++++++------- components/sprint/sprint-header.tsx | 2 +- components/sprint/task-list.tsx | 79 +++-- docs/plans/M9-active-product-backlog.md | 161 +++++++++ docs/scrum4me-backlog.md | 48 ++- docs/scrum4me-functional-spec.md | 32 ++ lib/cookies.ts | 19 -- package.json | 6 +- .../migration.sql | 8 + prisma/schema.prisma | 36 +- prisma/seed-data/parse-backlog.ts | 4 +- proxy.ts | 18 +- 28 files changed, 1184 insertions(+), 481 deletions(-) create mode 100644 __tests__/actions/active-product.test.ts create mode 100644 actions/active-product.ts create mode 100644 components/shared/activate-product-button.tsx create mode 100644 components/shared/alert-toast.tsx create mode 100644 docs/plans/M9-active-product-backlog.md delete mode 100644 lib/cookies.ts create mode 100644 prisma/migrations/20260427165329_add_user_active_product_id/migration.sql diff --git a/CLAUDE.md b/CLAUDE.md index 251ef41..77d0877 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,7 @@ Lees het relevante document voordat je aan een feature begint. Nooit gokken over | `docs/API.md` | REST-API contract voor Claude Code — endpoints, status-enums, foutcodes, voorbeeld-curls | | `docs/scrum4me-styling.md` | **Lees dit voor elk component** — MD3-kleuren, shadcn patronen | | `docs/agent-instruction-audit.md` | Waarom de agent-instructies zijn aangescherpt; checklist voor toekomstige wijzigingen | +| `docs/plans/-*.md` | Implementatieplan per milestone — Bestanden, Stappen, Aandachtspunten, Verificatie. Lees vóór je aan een ST begint. Milestone-key matcht backlog-header (`M9`, `M3.5`, `PBI-9`, …). | | [`madhura68/scrum4me-mcp`](https://github.com/madhura68/scrum4me-mcp) | MCP-server repo: native tools voor Claude Code, schema-sync via git submodule | --- diff --git a/__tests__/actions/active-product.test.ts b/__tests__/actions/active-product.test.ts new file mode 100644 index 0000000..a0191dd --- /dev/null +++ b/__tests__/actions/active-product.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +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/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + product: { findFirst: vi.fn() }, + user: { update: vi.fn() }, + }, +})) + +import { prisma } from '@/lib/prisma' +import { getIronSession } from 'iron-session' +import { setActiveProductAction, clearActiveProductAction } from '@/actions/active-product' + +const mockPrisma = prisma as unknown as { + product: { findFirst: ReturnType } + user: { update: ReturnType } +} +const mockGetIronSession = getIronSession as ReturnType + +const PRODUCT = { id: 'product-1', name: 'Test Product', archived: false } + +beforeEach(() => { + vi.clearAllMocks() + mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockPrisma.product.findFirst.mockResolvedValue(PRODUCT) + mockPrisma.user.update.mockResolvedValue({}) +}) + +describe('setActiveProductAction', () => { + it('sets active_product_id for authenticated user', async () => { + const result = await setActiveProductAction('product-1') + expect(result).toEqual({ success: true, productId: 'product-1' }) + expect(mockPrisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { active_product_id: 'product-1' }, + }) + }) + + it('returns error when not logged in', async () => { + mockGetIronSession.mockResolvedValue({ userId: undefined, isDemo: false }) + const result = await setActiveProductAction('product-1') + expect(result).toEqual({ error: 'Niet ingelogd' }) + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) + + it('returns error for demo user', async () => { + mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: true }) + const result = await setActiveProductAction('product-1') + expect(result).toEqual({ error: 'Niet beschikbaar in demo-modus' }) + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) + + it('returns error when product is archived or inaccessible', async () => { + mockPrisma.product.findFirst.mockResolvedValue(null) + const result = await setActiveProductAction('product-1') + expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' }) + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) + + it('returns error for empty product id', async () => { + const result = await setActiveProductAction('') + expect(result).toEqual({ error: 'Ongeldig product-id' }) + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) +}) + +describe('clearActiveProductAction', () => { + it('clears active_product_id for authenticated user', async () => { + const result = await clearActiveProductAction() + expect(result).toEqual({ success: true }) + expect(mockPrisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + data: { active_product_id: null }, + }) + }) + + it('returns error when not logged in', async () => { + mockGetIronSession.mockResolvedValue({ userId: undefined, isDemo: false }) + const result = await clearActiveProductAction() + expect(result).toEqual({ error: 'Niet ingelogd' }) + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) + + it('returns error for demo user', async () => { + mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: true }) + const result = await clearActiveProductAction() + expect(result).toEqual({ error: 'Niet beschikbaar in demo-modus' }) + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) +}) diff --git a/actions/active-product.ts b/actions/active-product.ts new file mode 100644 index 0000000..a3b026a --- /dev/null +++ b/actions/active-product.ts @@ -0,0 +1,51 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { z } from 'zod' +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' +import { productAccessFilter } from '@/lib/product-access' + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +const setSchema = z.object({ productId: z.string().min(1) }) + +export async function setActiveProductAction(productId: string) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = setSchema.safeParse({ productId }) + if (!parsed.success) return { error: 'Ongeldig product-id' } + + const product = await prisma.product.findFirst({ + where: { id: parsed.data.productId, archived: false, ...productAccessFilter(session.userId) }, + }) + if (!product) return { error: 'Product niet gevonden of niet toegankelijk' } + + await prisma.user.update({ + where: { id: session.userId }, + data: { active_product_id: parsed.data.productId }, + }) + + revalidatePath('/', 'layout') + return { success: true, productId: parsed.data.productId } +} + +export async function clearActiveProductAction() { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + await prisma.user.update({ + where: { id: session.userId }, + data: { active_product_id: null }, + }) + + revalidatePath('/', 'layout') + return { success: true } +} diff --git a/actions/products.ts b/actions/products.ts index 6eae56a..08f7385 100644 --- a/actions/products.ts +++ b/actions/products.ts @@ -148,9 +148,16 @@ export async function archiveProductAction(id: string) { }) if (!product) return { error: 'Product niet gevonden' } - await prisma.product.update({ where: { id }, data: { archived: true } }) + await prisma.$transaction([ + // Clear active_product_id for all users who had this product active + prisma.user.updateMany({ + where: { active_product_id: id }, + data: { active_product_id: null }, + }), + prisma.product.update({ where: { id }, data: { archived: true } }), + ]) - revalidatePath('/dashboard') + revalidatePath('/', 'layout') redirect('/dashboard') } @@ -215,7 +222,13 @@ export async function removeProductMemberAction(productId: string, memberId: str const product = await prisma.product.findFirst({ where: { id: productId, user_id: session.userId } }) if (!product) return { error: 'Product niet gevonden of geen eigenaar' } - await prisma.productMember.deleteMany({ where: { product_id: productId, user_id: memberId } }) + await prisma.$transaction([ + prisma.user.updateMany({ + where: { id: memberId, active_product_id: productId }, + data: { active_product_id: null }, + }), + prisma.productMember.deleteMany({ where: { product_id: productId, user_id: memberId } }), + ]) revalidatePath(`/products/${productId}/settings`) return { success: true } @@ -225,8 +238,15 @@ export async function leaveProductAction(productId: string) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } - await prisma.productMember.deleteMany({ where: { product_id: productId, user_id: session.userId } }) + await prisma.$transaction([ + prisma.user.updateMany({ + where: { id: session.userId, active_product_id: productId }, + data: { active_product_id: null }, + }), + prisma.productMember.deleteMany({ where: { product_id: productId, user_id: session.userId } }), + ]) + revalidatePath('/', 'layout') revalidatePath('/settings') return { success: true } } diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index d8ec087..9497e54 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -16,10 +16,15 @@ export default async function DashboardPage({ searchParams }: Props) { const { archived } = await searchParams const showArchived = archived === '1' - const products = await prisma.product.findMany({ - where: { archived: showArchived, ...productAccessFilter(session.userId) }, - orderBy: { created_at: 'desc' }, - }) + const [products, user] = await Promise.all([ + prisma.product.findMany({ + where: { archived: showArchived, ...productAccessFilter(session.userId) }, + orderBy: { created_at: 'desc' }, + }), + session.userId + ? prisma.user.findUnique({ where: { id: session.userId }, select: { active_product_id: true } }) + : null, + ]) return (
@@ -47,6 +52,7 @@ export default async function DashboardPage({ searchParams }: Props) { products={products} isDemo={session.isDemo ?? false} showArchived={showArchived} + activeProductId={user?.active_product_id ?? null} />
) diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 22bad22..3366094 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -3,10 +3,13 @@ import { cookies } from 'next/headers' import { getIronSession } from 'iron-session' import { SessionData, sessionOptions } from '@/lib/session' import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' import { NavBar } from '@/components/shared/nav-bar' import { MinWidthBanner } from '@/components/shared/min-width-banner' import { StatusBar } from '@/components/shared/status-bar' import { SoloRealtimeBridge } from '@/components/solo/realtime-bridge' +import { AlertToast } from '@/components/shared/alert-toast' +import { Suspense } from 'react' export default async function AppLayout({ children }: { children: React.ReactNode }) { const session = await getIronSession(await cookies(), sessionOptions) @@ -15,15 +18,20 @@ export default async function AppLayout({ children }: { children: React.ReactNod redirect('/login') } - const [user, userRoles] = await Promise.all([ + const [user, userRoles, accessibleProducts] = await Promise.all([ prisma.user.findUnique({ where: { id: session.userId }, - select: { username: true, email: true }, + select: { username: true, email: true, active_product_id: true }, }), prisma.userRole.findMany({ where: { user_id: session.userId }, select: { role: true }, }), + prisma.product.findMany({ + where: { archived: false, ...productAccessFilter(session.userId) }, + orderBy: { name: 'asc' }, + select: { id: true, name: true }, + }), ]) const roles = userRoles.map(r => r.role as string) @@ -31,6 +39,30 @@ export default async function AppLayout({ children }: { children: React.ReactNod redirect('/login') } + // Resolve active product — clear stale reference if archived or inaccessible + let activeProduct: { id: string; name: string } | null = null + let hasActiveSprint = false + if (user.active_product_id) { + const product = await prisma.product.findFirst({ + where: { id: user.active_product_id, archived: false, ...productAccessFilter(session.userId) }, + select: { id: true, name: true }, + }) + if (product) { + activeProduct = product + const sprint = await prisma.sprint.findFirst({ + where: { product_id: product.id, status: 'ACTIVE' }, + select: { id: true }, + }) + hasActiveSprint = !!sprint + } else { + await prisma.user.update({ + where: { id: session.userId }, + data: { active_product_id: null }, + }) + redirect('/dashboard?alert=product_unavailable') + } + } + return ( ) } diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index 92a0e87..b6e110b 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -7,6 +7,7 @@ import { PbiList } from '@/components/backlog/pbi-list' import { StoryPanel } from '@/components/backlog/story-panel' import type { Story } from '@/components/backlog/story-panel' import { StartSprintButton } from '@/components/sprint/start-sprint-button' +import { ActivateProductButton } from '@/components/shared/activate-product-button' import Link from 'next/link' interface Props { @@ -21,9 +22,10 @@ export default async function ProductBacklogPage({ params }: Props) { const product = await getAccessibleProduct(id, session.userId) if (!product) notFound() - const activeSprint = await prisma.sprint.findFirst({ - where: { product_id: id, status: 'ACTIVE' }, - }) + const [activeSprint, user] = await Promise.all([ + prisma.sprint.findFirst({ where: { product_id: id, status: 'ACTIVE' } }), + prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }), + ]) const pbis = await prisma.pbi.findMany({ where: { product_id: id }, @@ -42,6 +44,7 @@ export default async function ProductBacklogPage({ params }: Props) { priority: true, status: true, pbi_id: true, + created_at: true, }, }) @@ -65,6 +68,9 @@ export default async function ProductBacklogPage({ params }: Props) { )}
+ {user?.active_product_id !== id && ( + + )} {activeSprint ? ( Sprint actief → @@ -88,7 +94,7 @@ export default async function ProductBacklogPage({ params }: Props) { left={ ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description }))} + pbis={pbis.map((p: (typeof pbis)[number]) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at }))} isDemo={isDemo} /> } diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 70e0466..07b05a8 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma' import { RoleManager } from '@/components/settings/role-manager' import { LeaveProductButton } from '@/components/settings/leave-product-button' import { ProfileEditor } from '@/components/settings/profile-editor' +import { ActivateProductButton } from '@/components/shared/activate-product-button' import Link from 'next/link' export default async function SettingsPage() { @@ -13,7 +14,7 @@ export default async function SettingsPage() { const [user, userRoles, ownedProducts, memberships] = await Promise.all([ prisma.user.findUnique({ where: { id: session.userId }, - select: { username: true, email: true, bio: true, bio_detail: true, avatar_data: true, updated_at: true }, + select: { username: true, email: true, bio: true, bio_detail: true, avatar_data: true, updated_at: true, active_product_id: true }, }), prisma.userRole.findMany({ where: { user_id: session.userId } }), prisma.product.findMany({ @@ -33,7 +34,9 @@ export default async function SettingsPage() { | { kind: 'owner'; id: string; name: string } | { kind: 'member'; id: string; name: string; ownerUsername: string } - const productBacklogs: PbEntry[] = [ + const activeProductId = user?.active_product_id ?? null + + const allBacklogs: PbEntry[] = [ ...ownedProducts.map(p => ({ kind: 'owner' as const, id: p.id, name: p.name })), ...memberships.map(m => ({ kind: 'member' as const, @@ -43,6 +46,12 @@ export default async function SettingsPage() { })), ] + // Active product floats to the top + const productBacklogs = [ + ...allBacklogs.filter(pb => pb.id === activeProductId), + ...allBacklogs.filter(pb => pb.id !== activeProductId), + ] + return (

Instellingen

@@ -78,11 +87,21 @@ export default async function SettingsPage() {
-
-

Product Backlogs

-

- Alle product backlogs waarbij je betrokken bent. -

+
+
+

Product Backlogs

+

+ Alle product backlogs waarbij je betrokken bent. +

+
+ {!session.isDemo && ( + + + Nieuw product + + )}
{productBacklogs.length === 0 ? (

@@ -91,33 +110,52 @@ export default async function SettingsPage() {

) : (
    - {productBacklogs.map(pb => ( -
  • -
    -
    - - {pb.name} - - - {pb.kind === 'owner' ? 'Eigenaar' : 'Developer'} - + {productBacklogs.map(pb => { + const isActive = pb.id === activeProductId + return ( +
  • +
    +
    + + {pb.name} + + + {pb.kind === 'owner' ? 'Eigenaar' : 'Developer'} + + {isActive && ( + + Actief + + )} +
    + {pb.kind === 'member' && ( +

    Eigenaar: {pb.ownerUsername}

    + )}
    - {pb.kind === 'member' && ( -

    Eigenaar: {pb.ownerUsername}

    - )} -
- {pb.kind === 'member' && !session.isDemo && ( - - )} - - ))} +
+ {!isActive && ( + + )} + {pb.kind === 'member' && !session.isDemo && ( + + )} +
+ + ) + })} )}
diff --git a/app/(app)/solo/page.tsx b/app/(app)/solo/page.tsx index 7991495..72623ca 100644 --- a/app/(app)/solo/page.tsx +++ b/app/(app)/solo/page.tsx @@ -1,7 +1,6 @@ import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' -import { getLastProductCookie } from '@/lib/cookies' -import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access' +import { productAccessFilter } from '@/lib/product-access' import { prisma } from '@/lib/prisma' import { ProductPicker } from '@/components/solo/product-picker' @@ -9,11 +8,17 @@ export default async function SoloPage() { const session = await getSession() if (!session.userId) redirect('/login') - const lastProductId = await getLastProductCookie() + const user = await prisma.user.findUnique({ + where: { id: session.userId }, + select: { active_product_id: true }, + }) - if (lastProductId) { - const product = await getAccessibleProduct(lastProductId, session.userId) - if (product && !product.archived) redirect(`/products/${lastProductId}/solo`) + if (user?.active_product_id) { + const product = await prisma.product.findFirst({ + where: { id: user.active_product_id, archived: false, ...productAccessFilter(session.userId) }, + select: { id: true }, + }) + if (product) redirect(`/products/${user.active_product_id}/solo`) } const products = await prisma.product.findMany({ diff --git a/app/page.tsx b/app/page.tsx index aa14371..90cb609 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -31,51 +31,49 @@ export default async function LandingPage() { Scrum4Me - + {isLoggedIn ? ( + + ) : ( + + )}
diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx index 7e7cb20..588706a 100644 --- a/components/backlog/pbi-list.tsx +++ b/components/backlog/pbi-list.tsx @@ -32,6 +32,7 @@ import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories' import { cn } from '@/lib/utils' import { PbiDialog, type PbiDialogState } from './pbi-dialog' import { BacklogCard } from './backlog-card' +import { PRIORITY_COLORS } from '@/components/shared/priority-select' const PRIORITY_LABELS: Record = { 1: 'Kritiek', @@ -40,12 +41,8 @@ const PRIORITY_LABELS: Record = { 4: 'Laag', } -const PRIORITY_COLORS: Record = { - 1: 'bg-priority-critical/15 text-priority-critical border-priority-critical/30', - 2: 'bg-priority-high/15 text-priority-high border-priority-high/30', - 3: 'bg-priority-medium/15 text-priority-medium border-priority-medium/30', - 4: 'bg-priority-low/15 text-priority-low border-priority-low/30', -} + +type SortMode = 'priority' | 'code' | 'date' interface Pbi { id: string @@ -53,6 +50,7 @@ interface Pbi { title: string priority: number description?: string | null + created_at: Date } interface PbiListProps { @@ -129,10 +127,16 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) { const { selectedPbiId, selectPbi } = useSelectionStore() const { pbiOrder, pbiPriority, initPbis, reorderPbis, rollbackPbis, updatePbiPriority } = usePlannerStore() const [filterPriority, setFilterPriority] = useState(null) + const [sortMode, setSortMode] = useState(() => { + const saved = typeof window !== 'undefined' ? localStorage.getItem('scrum4me:pbi_sort') : null + return (saved === 'priority' || saved === 'code' || saved === 'date') ? saved : 'priority' + }) const [dialogState, setDialogState] = useState(null) const [activeDragId, setActiveDragId] = useState(null) const [, startTransition] = useTransition() + useEffect(() => { localStorage.setItem('scrum4me:pbi_sort', sortMode) }, [sortMode]) + // Sync server data into store — use stable string dep to avoid infinite loop const pbiIdKey = pbis.map(p => p.id).join(',') useEffect(() => { @@ -150,14 +154,18 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) { .filter(Boolean) .map(p => ({ ...p, priority: pbiPriority[p.id] ?? p.priority })) - const filtered = filterPriority ? orderedPbis.filter(p => p.priority === filterPriority) : orderedPbis + const base = filterPriority ? orderedPbis.filter(p => p.priority === filterPriority) : orderedPbis - const grouped = [1, 2, 3, 4].reduce>((acc, p) => { - acc[p] = filtered.filter(pbi => pbi.priority === p) - return acc - }, {} as Record) - - const visiblePriorities = [1, 2, 3, 4].filter(p => grouped[p].length > 0) + const filtered = [...base].sort((a, b) => { + if (sortMode === 'code') { + return (a.code ?? '').localeCompare(b.code ?? '', 'nl', { numeric: true }) + } + if (sortMode === 'date') { + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + } + // priority: sort by priority asc, then drag-and-drop sort_order within group + return a.priority !== b.priority ? a.priority - b.priority : 0 + }) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), @@ -231,6 +239,19 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) { × )} + setSortMode(v as SortMode)}> + + + + + Prioriteit + Code + Datum + + - - -
- - -
- +
+
+
+ + + +
+ + +
+
+
) } return ( -
- {!isDemo && ( - - )} -
-
-

- {task.title} -

- {code && } +
+
+ {!isDemo && ( + + )} +
+
+

+ {task.title} +

+ {code && } +
+
+ + {!isDemo && ( +
+ + +
+ )} +
- {PRIORITY_LABELS[task.priority]}
- - {!isDemo && ( -
- - -
- )}
) } @@ -251,7 +259,10 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId, {activeDragId && taskMap[activeDragId] && ( -
+
{taskMap[activeDragId].title}
)} diff --git a/docs/plans/M9-active-product-backlog.md b/docs/plans/M9-active-product-backlog.md new file mode 100644 index 0000000..9a79a80 --- /dev/null +++ b/docs/plans/M9-active-product-backlog.md @@ -0,0 +1,161 @@ +# M9 — Actief Product Backlog + +Eén "actief Product Backlog" per gebruiker, persistent op `User.active_product_id`. NavBar wordt: Producten | Product Backlog | Sprint | Solo | Todo's. Zonder actief PB zijn Backlog/Sprint/Solo disabled. Sprint is alleen klikbaar als er een sprint met status `ACTIVE` bestaat. Vervangt de bestaande `last_product`-cookieflow. + +Backlog-entries: zie [scrum4me-backlog.md § M9](../scrum4me-backlog.md#m9-actief-product-backlog). + +--- + +## ST-901 — Database `user.active_product_id` + +> Status: voltooid in commit `dad9a80`. + +**Bestanden** +- `prisma/schema.prisma` — model `User` uitgebreid + named relation +- `prisma/migrations/20260427165329_add_user_active_product_id/migration.sql` — migratie + +**Stappen** +1. Op `User`: `active_product_id String? @db.Uuid` + relatie `active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)` + `@@index([active_product_id])`. +2. Op `Product`: tegenrelatie `active_for_users User[] @relation("UserActiveProduct")` (anders conflicteert het met de bestaande `Product.user_id`-relatie). +3. `npx prisma migrate dev --name add_user_active_product_id`. + +**Aandachtspunten** +- `vendor/scrum4me`-submodule in repo `scrum4me-mcp` heeft hetzelfde schema. Na merge moet daar `prisma generate && tsc --noEmit` slagen, anders breekt de wekelijkse drift-check (`trig_015FFUnxjz9WMuhhWNGBQKFD`). +- Geen seed-wijziging nodig — `null` is correcte initiële staat. + +**Verificatie** +- `npx prisma migrate dev` slaagt +- `npx prisma validate` zonder fouten +- `prisma studio` toont kolom + +--- + +## ST-902 — Server Actions: actief product zetten/wissen + auto-clear + +**Bestanden** +- `actions/active-product.ts` — nieuw, twee Server Actions +- `actions/products.ts` — uitbreiden bij `archiveProductAction` +- `actions/product-members.ts` — uitbreiden bij `leaveProductAction` en `removeMemberAction` (locatie verifiëren met grep) +- `__tests__/actions/active-product.test.ts` — nieuw + +**Stappen** + +1. **`setActiveProductAction({ productId })`** in `actions/active-product.ts`: + - Volg `docs/patterns/server-action.md` + - Zod: `z.object({ productId: z.string().uuid() })` + - `getSession()` → 401 bij geen sessie + - **Demo-guard**: `if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus.' }` + - Toegangscheck: `prisma.product.findFirst({ where: { id: productId, archived: false, ...productAccessFilter(userId) } })` → `null` levert `{ ok: false, error: 'Product niet gevonden of geen toegang.' }` + - `prisma.user.update({ where: { id: userId }, data: { active_product_id: productId } })` + - `revalidatePath('/', 'layout')` — laat NavBar in alle routes opnieuw renderen + - Return `{ ok: true }` + +2. **`clearActiveProductAction()`** in hetzelfde bestand: + - Geen input + - `getSession()` + demo-guard + - `prisma.user.update({ where: { id: userId }, data: { active_product_id: null } })` + - `revalidatePath('/', 'layout')` + +3. **Auto-clear bij toegangsverlies** — drie call-sites uitbreiden ná de hoofdmutatie: + - `archiveProductAction(productId)`: `prisma.user.updateMany({ where: { active_product_id: productId }, data: { active_product_id: null } })` + - `leaveProductAction(productId)`: `prisma.user.updateMany({ where: { id: userId, active_product_id: productId }, data: { active_product_id: null } })` + - `removeMemberAction(productId, removedUserId)`: `prisma.user.updateMany({ where: { id: removedUserId, active_product_id: productId }, data: { active_product_id: null } })` + - Eigenaarsverwijdering van een product wordt door FK `onDelete: SetNull` automatisch geregeld — geen extra code + +4. **Tests** — `__tests__/actions/active-product.test.ts`: + - setActive met onbekend product → `{ ok: false }` + - setActive met archived product → `{ ok: false }` + - setActive met product zonder access → `{ ok: false }` + - setActive happy path → `users.active_product_id` gezet + - Demo-user setActive → error + geen DB-mutatie + - archiveProductAction op actief product → `active_product_id` gecleared voor alle eigenaren/leden + +**Aandachtspunten** +- Race-condition: setActive winnen ná auto-clear kan voorkomen. Layout-guard in ST-903 vangt dit op bij volgende request. +- `revalidatePath('/', 'layout')` is correct — niet `revalidatePath('/dashboard')` (NavBar zit in root layout van `(app)`). +- Geen `productAccessFilter` op `clearActiveProductAction` — eigen keuze wissen mag altijd. + +**Verificatie** +- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen +- Handmatig: 2 users — A archiveert product, `users.active_product_id` van B wordt `null` in DB + +--- + +## ST-903 — App-layout actief product + redirects + +**Bestanden** +- `app/(app)/layout.tsx` — uitbreiden met activeProduct-fetch + guard +- `app/(app)/solo/page.tsx` — cookie-flow vervangen +- `lib/cookies.ts` — `getLastProductCookie` / `setLastProductCookie` verwijderen +- `components/shared/nav-bar.tsx` — nieuwe prop `activeProduct` accepteren (verdere UI-uitwerking in ST-904) +- `components/solo/product-picker.tsx` — checken of nog gebruikt; anders weg + +**Stappen** + +1. **`app/(app)/layout.tsx`**: + - User-query uitbreiden: + ```ts + prisma.user.findUnique({ + where: { id: session.userId }, + select: { + username: true, + email: true, + active_product_id: true, + active_product: { select: { id: true, name: true, archived: true } }, + }, + }) + ``` + - **Guard**: als `user.active_product_id` is gezet maar (`active_product === null` of `active_product.archived === true` of geen toegang via `productAccessFilter`): + - `prisma.user.update(... active_product_id: null)` server-side + - `redirect('/dashboard?notice=active-cleared')` + - `` als nieuwe prop + +2. **`app/(app)/solo/page.tsx`** — vervang volledig: + ```ts + const session = await getSession() + if (!session.userId) redirect('/login') + const user = await prisma.user.findUnique({ + where: { id: session.userId }, + select: { active_product_id: true }, + }) + if (!user?.active_product_id) redirect('/dashboard?notice=no-active') + redirect(`/products/${user.active_product_id}/solo`) + ``` + +3. **`lib/cookies.ts`**: verwijder `getLastProductCookie` en `setLastProductCookie`. Grep alle call-sites en pas aan/verwijder. + +4. **Toast-handling** (server-redirect → client toast): + - Klein client-component `` dat `useSearchParams` leest, `toast()` aanroept, querystring strippt via `router.replace(pathname)` + - Plaats in `app/(app)/dashboard/page.tsx` (of layout) — alleen geactiveerde notices afhandelen + - Twee waarden: `active-cleared` → "Je actieve product is niet meer beschikbaar."; `no-active` → "Selecteer eerst een actief product." + +**Aandachtspunten** +- Layout-guard draait per request (extra DB-query). Houd 'm in dezelfde Promise.all met de bestaande user/userRoles-fetch. +- ProductPicker-fallback verdwijnt — switcher gebeurt in ST-904 via NavBar-dropdown. +- `app/(app)/solo/page.tsx` blijft Server Component — alleen `redirect()` van `next/navigation`. +- Een vorm van de cookie-helper kan ook door andere code gebruikt worden — verifieer de grep zorgvuldig vóór je verwijdert. + +**Verificatie** +- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen +- Login zonder active → NavBar krijgt `activeProduct={null}` +- Login met active → NavBar krijgt object met id/name +- Bezoek `/solo` met active → redirect naar `/products/[id]/solo` zonder cookie +- Archiveer actief product (script of via andere user) → bij volgende request layout cleart, toast op `/dashboard` + +--- + +## ST-904 — NavBar splits + disabled-states + switcher + +> Plan nog te schrijven. + +## ST-905 — Producten-scherm Activeer-knop + +> Plan nog te schrijven. + +## ST-906 — Edge cases — toegangsverlies en archivering + +> Plan nog te schrijven. + +## ST-907 — Documentatie en tests + +> Plan nog te schrijven. diff --git a/docs/scrum4me-backlog.md b/docs/scrum4me-backlog.md index 3d0350c..089d3ff 100644 --- a/docs/scrum4me-backlog.md +++ b/docs/scrum4me-backlog.md @@ -25,7 +25,7 @@ De MVP is klaar wanneer Lars — de primaire persona — de volledige cyclus kan | M6: Polish & Launch-ready | Foutafhandeling, toegankelijkheid, CI/CD, beveiliging | ST-601 – ST-612 | | M7: MCP-server voor Claude Code | Native MCP-laag bovenop Scrum4Me-DB (aparte repo `scrum4me-mcp`) | ST-701 – ST-710 | | M8: Realtime Solo Paneel | Live updates voor stories/tasks via SSE + Postgres LISTEN/NOTIFY | ST-801 – ST-806 | - +| M9: Actief Product Backlog | Persistente actieve PB-keuze, gesplitste navigatie, disabled-states | ST-901 – ST-907 | --- ## Backlog @@ -524,32 +524,66 @@ Transport: Server-Sent Events (Vercel ondersteunt geen stateful WebSockets). Bro Filtering server-side: alleen events binnen de actieve sprint van een product waar de gebruiker eigenaar of lid van is, plus `assignee_id == userId` (eigen kolommen) of `assignee_id IS NULL` (claim-lijst). -- [ ] **ST-801** Postgres LISTEN/NOTIFY-infrastructuur +- [x] **ST-801** Postgres LISTEN/NOTIFY-infrastructuur - Migratie met `notify_solo_change()`-functie + `AFTER INSERT/UPDATE/DELETE`-triggers op `tasks` en `stories`; payload bevat `op`, `entity`, `id`, `product_id`, `sprint_id`, `assignee_id`, `fields` (gewijzigde kolommen) - Done when: `psql $DIRECT_URL -c "LISTEN scrum4me_solo;"` toont een payload bij een UI-mutatie -- [ ] **ST-802** SSE-route `/api/realtime/solo` +- [x] **ST-802** SSE-route `/api/realtime/solo` - `app/api/realtime/solo/route.ts`, `runtime: 'nodejs'`, `maxDuration: 300`; auth via iron-session, query-param `product_id`, opent `pg.Client` op `DIRECT_URL` met `LISTEN`; heartbeat 25s; hard close 240s; in-handler filtering op product/sprint/assignee - Done when: `curl -N` op localhost levert binnen 1s een event op na een task-mutatie via UI -- [ ] **ST-803** Client hook `useSoloRealtime(productId)` +- [x] **ST-803** Client hook `useSoloRealtime(productId)` - `lib/realtime/use-solo-realtime.ts`; opent `EventSource`, exponential backoff reconnect (1s → 30s); Page Visibility API voor pauseren/hervatten; cleanup op unmount - Done when: tab wisselen sluit/opent connectie zichtbaar in DevTools Network -- [ ] **ST-804** Solo-store realtime-acties +- [x] **ST-804** Solo-store realtime-acties - `applyTaskUpdate`, `applyTaskCreate`, `applyTaskDelete`, `applyStoryAssignment`, `markPending`/`clearPending` om eigen optimistic-echo te onderdrukken - Done when: unit-test op solo-store met gesimuleerde events laat juiste eindstate zien -- [ ] **ST-805** Wire-up in SoloBoard + UI-indicator +- [x] **ST-805** Wire-up in SoloBoard + UI-indicator - `components/solo/solo-board.tsx` roept de hook aan; klein "live"/"verbinden..."-statusindicator; toast bij langer dan 5s disconnected - Done when: twee tabs van Solo Paneel — mutatie in tab A komt binnen 1–2s in tab B zonder refresh -- [ ] **ST-806** Documentatie + acceptatietest +- [x] **ST-806** Documentatie + acceptatietest - Sectie "Realtime updates" in `docs/scrum4me-architecture.md` met diagram en filtering-regels; vermelding in `CLAUDE.md`; korte note over `/api/realtime/solo` in `docs/API.md`; handmatig E2E-scenario's gedraaid (zelfde gebruiker twee tabs, MCP-write, REST-write, story-claim, network-flap) - Done when: alle scenario's lopen door zonder onverwachte gedragingen Volledig plan in `.Plans/2026-04-27-m8-realtime-solo.md` (lokaal, niet gecommit). +### M9: Actief Product Backlog + +**Implementatieplan:** [docs/plans/M9-active-product-backlog.md](plans/M9-active-product-backlog.md) + +Eén "actief Product Backlog" per gebruiker — persistent in DB. De NavBar wordt gesplitst in **Producten** (lijst) en **Product Backlog** (PB-view van actief PB), met **Sprint** en **Solo** als aparte tabs die op het actieve PB werken. Geen actief PB → die drie tabs zijn disabled. Vervangt de bestaande `last_product`-cookieflow. + +- [x] **ST-901** Database — `user.active_product_id` + - Voeg `active_product_id String? @db.Uuid` toe aan `User` met FK naar `Product.id` en `onDelete: SetNull`; migratie `add_user_active_product_id`; index op `active_product_id` voor join-performance + - Done when: `npx prisma migrate dev` slaagt; `prisma studio` toont kolom; `npx prisma validate` zonder fouten; submodule `vendor/scrum4me` in scrum4me-mcp draait `prisma generate` + `tsc --noEmit` zonder fouten + +- [x] **ST-902** Server Actions — actief product zetten en wissen + - `actions/active-product.ts` met `setActiveProduct(productId)` en `clearActiveProduct()`; Zod + auth + `productAccessFilter`; demo-gebruikers mogen wisselen (sessie-effect alleen, geen DB-write); `archiveProduct` en `leaveProduct` zetten `active_product_id` op `null` als het hetzelfde product betreft + - Done when: setActive met onbekend/onbereikbaar product → 422; archiveren van actief product clearet de keuze; demo-flow geeft toast "Niet beschikbaar in demo-modus" + +- [x] **ST-903** App-layout laadt actief product + redirects + - `app/(app)/layout.tsx` haalt `activeProduct` (id, name, archived) op naast user; geef door aan `NavBar`; `app/(app)/solo/page.tsx` gebruikt `user.active_product_id` i.p.v. `getLastProductCookie`; helper `lib/cookies.ts:getLastProductCookie` markeren deprecated of verwijderen plus call-sites opruimen + - Done when: ingelogd zonder actief PB toont NavBar zonder geactiveerde tabs; met actief PB redirect `/solo` → `/products/[active]/solo` zonder cookie te raadplegen + +- [x] **ST-904** NavBar — splits + disabled-states + switcher + - Tabs worden: **Producten** (`/dashboard`) | **Product Backlog** (`/products/[active]`) | **Sprint** (`/products/[active]/sprint`) | **Solo** (`/products/[active]/solo`) | **Todo's** (`/todos`); zonder actief PB zijn de middelste drie disabled-spans (zelfde stijl als huidige Sprint-disabled); productnaam in midden wordt een dropdown-trigger (shadcn `DropdownMenu`) met je producten + "Producten beheren →"; Sprint krijgt `aria-disabled` + tooltip "Geen actieve sprint" als er geen sprint met status `ACTIVE` is + - Done when: handmatige test: zonder PB drie tabs grijs; activeer PB → tabs klikbaar; dropdown wisselt PB en redirect naar Product Backlog; Sprint-tab disabled tot sprint gestart + +- [x] **ST-905** Producten-scherm — Activeer-knop per rij + - `components/dashboard/product-list.tsx`: per rij "Activeer"-knop (verborgen voor reeds actief PB); actieve rij krijgt badge "Actief" (MD3-token `bg-primary-container`); klik op Activeer → `setActiveProduct` + `router.push('/products/[id]')`; ook in `/products/[id]` header een Activeer-knop als dat product nog niet actief is + - Done when: activeer in dashboard markeert juiste rij + landt op Product Backlog; demo-gebruiker krijgt toast en geen DB-mutatie + +- [x] **ST-906** Edge cases — toegangsverlies en archivering + - Wanneer een PB wordt gearchiveerd, ge-leaved, of een productmember wordt verwijderd: `active_product_id` automatisch `null` voor betroffen users (server actions van `archiveProduct`, `leaveProduct`, `removeMember`); guard in `app/(app)/layout.tsx`: als `active_product_id` is gezet maar product is archived/onbereikbaar, server-side clear + redirect naar `/dashboard` met toast "Je actieve product is niet meer beschikbaar" + - Done when: scenario test — eigenaar archiveert → membership-gebruikers landen op dashboard met toast en active is gecleared + +- [x] **ST-907** Documentatie en tests + - Functional spec: nieuw hoofdstuk "Actief Product Backlog" (concept, menugedrag, edge cases); README: navigatie-screenshot bijwerken; `docs/patterns/` indien nieuwe patroon (n.v.t. tenzij dropdown-switcher een herbruikbaar component wordt); jest-tests in `__tests__/actions/active-product.test.ts` voor setActive (toegang, demo, archived); Playwright/manueel scenario: log in → activeer PB → wissel via dropdown → archiveer → verifieer auto-clear + - Done when: `npm run lint && npx tsc --noEmit && npm test && npm run build` groen; spec-secties geschreven; `vendor/scrum4me`-submodule in scrum4me-mcp gesynced + --- ## v2 Backlog (na MVP) diff --git a/docs/scrum4me-functional-spec.md b/docs/scrum4me-functional-spec.md index 6dbe167..06f1168 100644 --- a/docs/scrum4me-functional-spec.md +++ b/docs/scrum4me-functional-spec.md @@ -529,3 +529,35 @@ De app is deployable op Vercel + Neon PostgreSQL en lokaal draaibaar met een Neo 6. Lars navigeert naar de Product Backlog → story staat in de juiste prioriteitsgroep **Resultaat:** Losse gedachte is in drie stappen onderdeel van de formele Product Backlog. + +--- + +## Actief Product Backlog + +### Concept + +Een gebruiker kan één product als "actief" markeren. Dit actieve product wordt in de NavBar centraal getoond en bepaalt welke tabs (Product Backlog, Sprint, Solo) navigeerbaar zijn. Het actieve product wordt opgeslagen in `user.active_product_id` in de database — niet in een cookie. + +### Menugedrag + +- **Producten** — altijd bereikbaar, toont alle producten van de gebruiker +- **Product Backlog** — alleen klikbaar als er een actief product is +- **Sprint** — alleen klikbaar als er een actief product is én een actieve sprint bestaat; anders tooltip "Geen actieve sprint" +- **Solo** — alleen klikbaar als er een actief product is +- **Todo's** — altijd bereikbaar + +In het midden van de NavBar staat een dropdown met de naam van het actieve product. Via deze dropdown kan de gebruiker wisselen tussen producten of naar "Producten beheren" navigeren. + +### Activeren + +- **Dashboard**: elke productrij toont een "Activeer"-knop (verborgen voor het al actieve product). Het actieve product krijgt een "Actief"-badge. Klikken → actief product instellen + navigeer naar Product Backlog. +- **Product Backlog header**: als dit product nog niet actief is, staat er een "Activeer"-knop in de header. + +Demo-gebruikers zien de knoppen maar krijgen een toast "Niet beschikbaar in demo-modus" bij het klikken. + +### Edge cases + +- **Archiveren**: wanneer een eigenaar een product archiveert, wordt `active_product_id` voor alle leden die dit product actief hadden automatisch op `null` gezet (atomisch via `$transaction`). +- **Product verlaten**: wanneer een lid het product verlaat, wordt hun `active_product_id` gecleard. +- **Lid verwijderen**: wanneer een eigenaar een lid verwijdert, wordt dat lid's `active_product_id` gecleard. +- **Stale referentie**: als bij een request `active_product_id` verwijst naar een gearchiveerd of onbereikbaar product (bijv. toegang ingetrokken in een andere sessie), cleared de layout de referentie server-side en redirect naar `/dashboard` met de toast "Je actieve product is niet meer beschikbaar". diff --git a/lib/cookies.ts b/lib/cookies.ts deleted file mode 100644 index c63bdc1..0000000 --- a/lib/cookies.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { cookies } from 'next/headers' - -const LAST_PRODUCT_COOKIE = 'lastProductId' -const THIRTY_DAYS_SECONDS = 60 * 60 * 24 * 30 - -export async function setLastProductCookie(productId: string) { - const store = await cookies() - store.set(LAST_PRODUCT_COOKIE, productId, { - httpOnly: true, - sameSite: 'lax', - maxAge: THIRTY_DAYS_SECONDS, - path: '/', - }) -} - -export async function getLastProductCookie(): Promise { - const store = await cookies() - return store.get(LAST_PRODUCT_COOKIE)?.value ?? null -} diff --git a/package.json b/package.json index 76df464..06ba04b 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "scrum4me", - "version": "0.3.1", + "version": "0.4.0", "private": true, "scripts": { - "predev": "lsof -ti:3000 | xargs kill -9 2>/dev/null || true", - "dev": "concurrently \"next dev -p 3000\" \"npm run db:erd:watch\"", + "predev": "npx --yes kill-port 3000 || exit 0", + "dev": "next dev -p 3000", "build": "next build", "start": "next start", "lint": "eslint", diff --git a/prisma/migrations/20260427165329_add_user_active_product_id/migration.sql b/prisma/migrations/20260427165329_add_user_active_product_id/migration.sql new file mode 100644 index 0000000..705ae5d --- /dev/null +++ b/prisma/migrations/20260427165329_add_user_active_product_id/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "active_product_id" TEXT; + +-- CreateIndex +CREATE INDEX "users_active_product_id_idx" ON "users"("active_product_id"); + +-- AddForeignKey +ALTER TABLE "users" ADD CONSTRAINT "users_active_product_id_fkey" FOREIGN KEY ("active_product_id") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e9d6854..7e88b31 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -47,23 +47,26 @@ enum SprintStatus { } model User { - id String @id @default(cuid()) - username String @unique - email String? @unique - password_hash String - is_demo Boolean @default(false) - bio String? @db.VarChar(160) - bio_detail String? @db.VarChar(2000) - avatar_data Bytes? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - roles UserRole[] - api_tokens ApiToken[] - products Product[] - todos Todo[] - product_members ProductMember[] - assigned_stories Story[] @relation("StoryAssignee") + id String @id @default(cuid()) + username String @unique + email String? @unique + password_hash String + is_demo Boolean @default(false) + bio String? @db.VarChar(160) + bio_detail String? @db.VarChar(2000) + avatar_data Bytes? + active_product_id String? + active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + roles UserRole[] + api_tokens ApiToken[] + products Product[] + todos Todo[] + product_members ProductMember[] + assigned_stories Story[] @relation("StoryAssignee") + @@index([active_product_id]) @@map("users") } @@ -107,6 +110,7 @@ model Product { stories Story[] todos Todo[] members ProductMember[] + active_for_users User[] @relation("UserActiveProduct") @@unique([user_id, name]) @@unique([user_id, code]) diff --git a/prisma/seed-data/parse-backlog.ts b/prisma/seed-data/parse-backlog.ts index 8e6cfa1..3f62204 100644 --- a/prisma/seed-data/parse-backlog.ts +++ b/prisma/seed-data/parse-backlog.ts @@ -26,7 +26,7 @@ export type ParsedMilestone = { stories: ParsedStory[] } -const MILESTONE_HEADER = /^### (M[\d.]+):\s*(.+?)\s*$/ +const MILESTONE_HEADER = /^### (M[\d.]+|PBI-\d+):\s*(.+?)\s*$/ const TASK_BULLET = /^- \[(x| )\] \*\*(ST-\d+)\*\*\s+(.+?)\s*$/ const SUB_BULLET = /^ {2}- (.+?)\s*$/ const NESTED_LINE = /^ {4,}\S/ @@ -72,7 +72,7 @@ const MILESTONE_SPRINT_STATUS: Record M8: 'COMPLETED', } -const MILESTONE_KEY = /^M[\d.]+$/ +const MILESTONE_KEY = /^(?:M[\d.]+|PBI-\d+)$/ type SubBullet = { headLine: string diff --git a/proxy.ts b/proxy.ts index 5771204..0a4e228 100644 --- a/proxy.ts +++ b/proxy.ts @@ -5,9 +5,6 @@ import { sessionOptions } from '@/lib/session' const protectedRoutes = ['/dashboard', '/products', '/todos', '/settings', '/solo'] const authRoutes = ['/login', '/register'] -const SOLO_ROUTE = /^\/products\/([^/]+)\/solo$/ -const THIRTY_DAYS_SECONDS = 60 * 60 * 24 * 30 - export function proxy(request: NextRequest) { const path = request.nextUrl.pathname const isProtected = protectedRoutes.some(r => path.startsWith(r)) @@ -24,20 +21,7 @@ export function proxy(request: NextRequest) { return NextResponse.redirect(new URL('/dashboard', request.url)) } - const response = NextResponse.next() - - // Remember last visited product for /solo redirect - const soloMatch = path.match(SOLO_ROUTE) - if (soloMatch) { - response.cookies.set('lastProductId', soloMatch[1], { - httpOnly: true, - sameSite: 'lax', - maxAge: THIRTY_DAYS_SECONDS, - path: '/', - }) - } - - return response + return NextResponse.next() } export const config = {