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 77cfe1d..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 }, @@ -66,6 +68,9 @@ export default async function ProductBacklogPage({ params }: Props) { )}
+ {user?.active_product_id !== id && ( + + )} {activeSprint ? ( Sprint actief → 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 eaec7ae..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', diff --git a/components/dashboard/product-list.tsx b/components/dashboard/product-list.tsx index 4b41d54..fa1280b 100644 --- a/components/dashboard/product-list.tsx +++ b/components/dashboard/product-list.tsx @@ -5,8 +5,10 @@ import { useRouter } from 'next/navigation' import { useTransition } from 'react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' import { CodeBadge } from '@/components/shared/code-badge' import { restoreProductAction } from '@/actions/products' +import { setActiveProductAction } from '@/actions/active-product' interface Product { id: string @@ -20,9 +22,10 @@ interface ProductListProps { products: Product[] isDemo: boolean showArchived?: boolean + activeProductId: string | null } -export function ProductList({ products, isDemo, showArchived = false }: ProductListProps) { +export function ProductList({ products, isDemo, showArchived = false, activeProductId }: ProductListProps) { const router = useRouter() const [, startTransition] = useTransition() @@ -34,6 +37,15 @@ export function ProductList({ products, isDemo, showArchived = false }: ProductL }) } + function handleActivate(id: string) { + if (isDemo) { toast.error('Niet beschikbaar in demo-modus'); return } + startTransition(async () => { + const result = await setActiveProductAction(id) + if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Activeren mislukt') + else router.push(`/products/${id}`) + }) + } + if (products.length === 0) { return (
@@ -87,6 +99,18 @@ export function ProductList({ products, isDemo, showArchived = false }: ProductL Repo )} + {!showArchived && ( + product.id === activeProductId + ? Actief + : ( + + ) + )} {showArchived && !isDemo && ( + ) +} diff --git a/components/shared/alert-toast.tsx b/components/shared/alert-toast.tsx new file mode 100644 index 0000000..630892d --- /dev/null +++ b/components/shared/alert-toast.tsx @@ -0,0 +1,28 @@ +'use client' + +import { useEffect } from 'react' +import { useSearchParams, useRouter, usePathname } from 'next/navigation' +import { toast } from 'sonner' + +const ALERT_MESSAGES: Record = { + product_unavailable: 'Je actieve product is niet meer beschikbaar', +} + +export function AlertToast() { + const searchParams = useSearchParams() + const router = useRouter() + const pathname = usePathname() + const alert = searchParams.get('alert') + + useEffect(() => { + if (!alert || !ALERT_MESSAGES[alert]) return + toast.error(ALERT_MESSAGES[alert]) + const params = new URLSearchParams(searchParams.toString()) + params.delete('alert') + const next = params.toString() ? `${pathname}?${params}` : pathname + router.replace(next) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [alert]) + + return null +} diff --git a/components/shared/nav-bar.tsx b/components/shared/nav-bar.tsx index f52685a..9f38668 100644 --- a/components/shared/nav-bar.tsx +++ b/components/shared/nav-bar.tsx @@ -1,13 +1,23 @@ 'use client' import Link from 'next/link' -import { usePathname } from 'next/navigation' +import { usePathname, useRouter } from 'next/navigation' +import { useTransition } from 'react' +import { ChevronDown } from 'lucide-react' +import { toast } from 'sonner' import { Badge } from '@/components/ui/badge' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { AppIcon } from '@/components/shared/app-icon' import { UserMenu } from '@/components/shared/user-menu' import { cn } from '@/lib/utils' -import { useProductStore } from '@/stores/product-store' +import { setActiveProductAction } from '@/actions/active-product' interface NavBarProps { isDemo: boolean @@ -15,23 +25,84 @@ interface NavBarProps { userId: string username: string email: string | null + activeProduct: { id: string; name: string } | null + products: { id: string; name: string }[] + hasActiveSprint: boolean } -export function NavBar({ isDemo, roles, userId, username, email }: NavBarProps) { +export function NavBar({ + isDemo, + roles, + userId, + username, + email, + activeProduct, + products, + hasActiveSprint, +}: NavBarProps) { const pathname = usePathname() - const currentProduct = useProductStore(s => s.currentProduct) + const router = useRouter() + const [isPending, startTransition] = useTransition() - const productMatch = pathname.match(/^\/products\/([^/]+)/) - const productId = productMatch ? productMatch[1] : null + function handleSwitchProduct(productId: string) { + startTransition(async () => { + const result = await setActiveProductAction(productId) + if (result?.error) { + toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt') + } else { + router.push(`/products/${productId}`) + } + }) + } - const sprintHref = productId ? `/products/${productId}/sprint` : null + const activeId = activeProduct?.id ?? null - const navLinks = [ - { href: '/dashboard', label: 'Producten', active: pathname.startsWith('/dashboard') || (pathname.startsWith('/products') && !pathname.includes('/solo')) }, - { href: sprintHref, label: 'Sprint', active: pathname.includes('/sprint') }, - { href: '/solo', label: 'Solo', active: pathname.includes('/solo') }, - { href: '/todos', label: "Todo's", active: pathname.startsWith('/todos') }, - ] + // Nav link helpers + const disabledSpan = (label: string) => ( + + {label} + + ) + + const navLink = (href: string, label: string, isActive: boolean) => ( + + {label} + + ) + + const sprintNode = () => { + if (!activeId) return disabledSpan('Sprint') + const href = `/products/${activeId}/sprint` + const isActive = pathname.includes('/sprint') + if (!hasActiveSprint) { + return ( + + + + Sprint + + Geen actieve sprint + + + ) + } + return navLink(href, 'Sprint', isActive) + } return (
@@ -48,50 +119,63 @@ export function NavBar({ isDemo, roles, userId, username, email }: NavBarProps)
- {/* Midden: productnaam */} + {/* Midden: actief product dropdown */}
- {currentProduct && ( - - - - }> - {currentProduct.name.length > 20 - ? currentProduct.name.slice(0, 20) + '…' - : currentProduct.name} - - {currentProduct.name} - - + {activeProduct ? ( + + + + {activeProduct.name.length > 22 + ? activeProduct.name.slice(0, 22) + '…' + : activeProduct.name} + + + + + {products.map(p => ( + p.id !== activeProduct.id && handleSwitchProduct(p.id)} + className={cn( + p.id === activeProduct.id && 'bg-primary-container text-primary-container-foreground font-medium' + )} + > + {p.name} + + ))} + + + + Producten beheren → + + + + + ) : ( + Geen actief product )}
diff --git a/components/sprint/sprint-backlog.tsx b/components/sprint/sprint-backlog.tsx index bffc534..5d4ceb7 100644 --- a/components/sprint/sprint-backlog.tsx +++ b/components/sprint/sprint-backlog.tsx @@ -1,7 +1,7 @@ 'use client' import { useState, useTransition } from 'react' -import { Trash2, MoreHorizontal } from 'lucide-react' +import { Trash2, MoreHorizontal, ChevronsUp, ChevronsDown, ListFilter } from 'lucide-react' import { useDroppable, useDraggable } from '@dnd-kit/core' import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' @@ -15,6 +15,8 @@ import { import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { UserAvatar } from '@/components/shared/user-avatar' import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { PRIORITY_BORDER } from '@/components/backlog/backlog-card' import { useSprintStore } from '@/stores/sprint-store' import { claimStoryAction, unclaimStoryAction, reassignStoryAction, claimAllUnassignedInActiveSprintAction } from '@/actions/stories' import { cn } from '@/lib/utils' @@ -26,14 +28,6 @@ const STATUS_COLORS: Record = { } const STATUS_LABELS: Record = { OPEN: 'Open', IN_SPRINT: 'In Sprint', DONE: 'Klaar' } -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', -} -const PRIORITY_LABELS: Record = { 1: 'Kritiek', 2: 'Hoog', 3: 'Gemiddeld', 4: 'Laag' } - export interface SprintStory { id: string code: string | null @@ -124,86 +118,89 @@ function SortableSprintRow({ ref={setNodeRef} style={style} onClick={onSelect} - className={cn( - 'group flex items-start gap-3 px-4 py-2.5 border-b border-border cursor-pointer transition-colors', - isSelected - ? 'bg-primary-container text-primary-container-foreground' - : 'hover:bg-surface-container' - )} + className="group px-2 py-1 cursor-pointer" > - {!isDemo && ( - e.stopPropagation()} - > - ⠿ - - )} -
-
-

{story.title}

- {story.code && } -
-
- - {PRIORITY_LABELS[story.priority]} - - {story.doneCount}/{story.taskCount} klaar -
-
- {story.assignee_id ? ( - <> - - {story.assignee_username} - - ) : ( - Niet geclaimd - )} -
-
-
e.stopPropagation()}> - - - - - - e.stopPropagation()}> - {story.assignee_id !== currentUserId && ( - Pak op - )} - {story.assignee_id && ( - Geef terug aan team - )} - - Wijs toe aan - - {members.map(m => ( - handleReassign(e, m.userId, m.username)}> - - {m.username} - - ))} - - - - - +
{!isDemo && ( - + ⠿ + )} +
+
+

{story.title}

+ {story.code && } +
+
+
+ + {STATUS_LABELS[story.status]} + + {story.doneCount}/{story.taskCount} klaar + {story.assignee_id ? ( +
+ + {story.assignee_username} +
+ ) : ( + Niet geclaimd + )} +
+
e.stopPropagation()}> + + + + + + e.stopPropagation()}> + {story.assignee_id !== currentUserId && ( + Pak op + )} + {story.assignee_id && ( + Geef terug aan team + )} + + Wijs toe aan + + {members.map(m => ( + handleReassign(e, m.userId, m.username)}> + + {m.username} + + ))} + + + + + + {!isDemo && ( + + )} +
+
+
) @@ -324,37 +321,46 @@ function DraggablePbiStoryRow({
- {!isDemo && ( - e.stopPropagation()} - > - ⠿ - - )} -
-
-

{story.title}

- {story.code && } +
+ {!isDemo && ( + + ⠿ + + )} +
+
+

{story.title}

+ {story.code && } +
+
+ + {STATUS_LABELS[story.status]} + +
- - {STATUS_LABELS[story.status]} - + {!isDemo && ( + + )}
- {!isDemo && ( - + toevoegen - )}
) } @@ -367,7 +373,15 @@ interface SprintBacklogRightProps { } export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, onAdd }: SprintBacklogRightProps) { - const [collapsed, setCollapsed] = useState>(new Set()) + const [collapsed, setCollapsed] = useState>(() => { + const auto = new Set() + for (const pbi of pbisWithStories) { + if (pbi.stories.length > 0 && pbi.stories.every(s => s.status === 'DONE')) { + auto.add(pbi.id) + } + } + return auto + }) const { setNodeRef, isOver } = useDroppable({ id: 'backlog-zone' }) function toggle(pbiId: string) { @@ -378,9 +392,50 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, on }) } + function collapseAll() { + setCollapsed(new Set(pbisWithStories.map(p => p.id))) + } + + function expandAll() { + setCollapsed(new Set()) + } + + function onlyNotDone() { + const auto = new Set() + for (const pbi of pbisWithStories) { + if (pbi.stories.length > 0 && pbi.stories.every(s => s.status === 'DONE')) { + auto.add(pbi.id) + } + } + setCollapsed(auto) + } + + const collapseActions = ( + + + + + + Alles inklappen + + + + + + Alles uitklappen + + + + + + Alleen niet klaar + + + ) + return (
- +
{!collapsed.has(pbi.id) && pbi.stories.map(story => { const inSprint = sprintStoryIds.has(story.id) if (inSprint) { return ( -
-
-
-
-

{story.title}

- {story.code && } +
+
+
+
+
+

{story.title}

+ {story.code && } +
+
+ + {STATUS_LABELS[story.status]} + +
- - {STATUS_LABELS[story.status]} - + In Sprint
- In Sprint
) } diff --git a/components/sprint/task-list.tsx b/components/sprint/task-list.tsx index 6dfb0ce..3261322 100644 --- a/components/sprint/task-list.tsx +++ b/components/sprint/task-list.tsx @@ -17,6 +17,7 @@ import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { CodeBadge } from '@/components/shared/code-badge' import { PanelNavBar } from '@/components/shared/panel-nav-bar' +import { PRIORITY_BORDER } from '@/components/backlog/backlog-card' import { deriveTaskCode } from '@/lib/code' import { useSprintStore } from '@/stores/sprint-store' import { @@ -37,7 +38,6 @@ const STATUS_COLORS: Record = { } const STATUS_LABELS: Record = { TO_DO: 'To Do', IN_PROGRESS: 'Bezig', DONE: 'Klaar' } -const PRIORITY_LABELS: Record = { 1: 'Kritiek', 2: 'Hoog', 3: 'Gemiddeld', 4: 'Laag' } export interface Task { id: string @@ -76,45 +76,53 @@ function SortableTaskRow({ if (editing) { return ( -
-
- - - -
- - -
-
+
+
+
+ + + +
+ + +
+
+
) } 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 56ea814..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 @@ -550,6 +550,40 @@ Filtering server-side: alleen events binnen de actieve sprint van een product wa 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/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 = {