diff --git a/__tests__/api/security.test.ts b/__tests__/api/security.test.ts index 4d37fdd..f37166a 100644 --- a/__tests__/api/security.test.ts +++ b/__tests__/api/security.test.ts @@ -41,8 +41,6 @@ import { GET as getSprintTasks } from '@/app/api/sprints/[id]/tasks/route' import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route' import { POST as postStoryLog } from '@/app/api/stories/[id]/log/route' import { PATCH as patchTask } from '@/app/api/tasks/[id]/route' -import { POST as postTodo } from '@/app/api/todos/route' - const mockPrisma = prisma as unknown as { product: { findMany: ReturnType; findFirst: ReturnType } sprint: { findFirst: ReturnType } @@ -57,7 +55,6 @@ const mockPrisma = prisma as unknown as { findMany: ReturnType } storyLog: { create: ReturnType } - todo: { create: ReturnType } $transaction: ReturnType } const mockAuth = authenticateApiRequest as ReturnType @@ -419,46 +416,3 @@ describe('PATCH /api/tasks/:id', () => { expect(res.status).toBe(200) }) }) - -// ─── POST /api/todos ────────────────────────────────────────────────────────── - -describe('POST /api/todos', () => { - // product_id is required by the Zod schema (z.string().min(1)) - const VALID_BODY = { title: 'Test todo', product_id: 'prod-1' } - - // TC-TD-01 - it('returns 401 when no valid token provided', async () => { - mockAuth.mockResolvedValue(UNAUTHORIZED) - const res = await postTodo(makePost('http://localhost/api/todos', VALID_BODY)) - expect(res.status).toBe(401) - }) - - // TC-TD-03 - it('returns 403 for demo users', async () => { - mockAuth.mockResolvedValue(DEMO_AUTH) - const res = await postTodo(makePost('http://localhost/api/todos', VALID_BODY)) - expect(res.status).toBe(403) - const data = await res.json() - expect(data.error).toBe('Niet beschikbaar in demo-modus') - }) - - // TC-TD-08 - it('returns 404 when product_id belongs to another user', async () => { - mockAuth.mockResolvedValue(USER_2_AUTH) - mockPrisma.product.findFirst.mockResolvedValue(null) - - const res = await postTodo( - makePost('http://localhost/api/todos', { title: 'Todo', product_id: 'prod-owned-by-user-1' }) - ) - expect(res.status).toBe(404) - // Verify it queries by user_id, not productAccessFilter - expect(mockPrisma.product.findFirst).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - id: 'prod-owned-by-user-1', - user_id: 'user-2', - }), - }) - ) - }) -}) diff --git a/__tests__/api/todos.test.ts b/__tests__/api/todos.test.ts deleted file mode 100644 index abded32..0000000 --- a/__tests__/api/todos.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('@/lib/prisma', () => ({ - prisma: { - product: { - findFirst: vi.fn(), - }, - todo: { - create: vi.fn(), - }, - }, -})) - -vi.mock('@/lib/api-auth', () => ({ - authenticateApiRequest: vi.fn(), -})) - -import { prisma } from '@/lib/prisma' -import { authenticateApiRequest } from '@/lib/api-auth' -import { POST as postTodo } from '@/app/api/todos/route' - -const mockPrisma = prisma as unknown as { - product: { findFirst: ReturnType } - todo: { create: ReturnType } -} -const mockAuth = authenticateApiRequest as ReturnType - -const PRODUCT = { id: 'prod-1', name: 'DevPlanner', archived: false, user_id: 'user-1' } -const TODO_RESULT = { id: 'todo-1', title: 'Test todo', created_at: new Date('2026-04-30T10:00:00Z') } - -function makeRequest(body: unknown): Request { - return new Request('http://localhost/api/todos', { - method: 'POST', - headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) -} - -describe('POST /api/todos', () => { - beforeEach(() => { - vi.clearAllMocks() - mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false }) - mockPrisma.product.findFirst.mockResolvedValue(PRODUCT) - mockPrisma.todo.create.mockResolvedValue(TODO_RESULT) - }) - - // TC-TD-04 - it('returns 422 when title is missing', async () => { - const res = await postTodo(makeRequest({ product_id: 'prod-1' })) - expect(res.status).toBe(422) - }) - - // TC-TD-05 - it('returns 422 when title is empty string', async () => { - const res = await postTodo(makeRequest({ title: '', product_id: 'prod-1' })) - expect(res.status).toBe(422) - }) - - it('returns 422 when product_id is missing', async () => { - // product_id is required by the Zod schema (z.string().min(1)) - const res = await postTodo(makeRequest({ title: 'My todo' })) - expect(res.status).toBe(422) - }) - - it('returns 422 when product_id is empty string', async () => { - const res = await postTodo(makeRequest({ title: 'My todo', product_id: '' })) - expect(res.status).toBe(422) - }) - - // TC-TD-07 - it('creates todo with valid product_id and returns 201', async () => { - const res = await postTodo(makeRequest({ title: 'Test todo', product_id: 'prod-1' })) - const data = await res.json() - - expect(res.status).toBe(201) - expect(data).toMatchObject({ id: 'todo-1', title: 'Test todo' }) - expect(data).toHaveProperty('created_at') - expect(mockPrisma.todo.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - user_id: 'user-1', - product_id: 'prod-1', - title: 'Test todo', - }), - }) - ) - }) - - it('queries product by user_id (not productAccessFilter) to enforce ownership', async () => { - await postTodo(makeRequest({ title: 'Test todo', product_id: 'prod-1' })) - - expect(mockPrisma.product.findFirst).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - id: 'prod-1', - user_id: 'user-1', - archived: false, - }), - }) - ) - }) - - it('returns 404 when product does not exist or is archived', async () => { - mockPrisma.product.findFirst.mockResolvedValue(null) - - const res = await postTodo(makeRequest({ title: 'My todo', product_id: 'nonexistent' })) - expect(res.status).toBe(404) - }) -}) diff --git a/app/(app)/todos/loading.tsx b/app/(app)/todos/loading.tsx deleted file mode 100644 index 61d4ec9..0000000 --- a/app/(app)/todos/loading.tsx +++ /dev/null @@ -1,19 +0,0 @@ -export default function Loading() { - return ( -
-
-
-
-
-
-
-
-
- {[1, 2, 3, 4, 5].map(i => ( -
- ))} -
-
-
- ) -} diff --git a/app/(app)/todos/page.tsx b/app/(app)/todos/page.tsx deleted file mode 100644 index 27f07f4..0000000 --- a/app/(app)/todos/page.tsx +++ /dev/null @@ -1,47 +0,0 @@ -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 { TodoList } from '@/components/todos/todo-list' - -export default async function TodosPage() { - const session = await getIronSession(await cookies(), sessionOptions) - - const todos = await prisma.todo.findMany({ - where: { user_id: session.userId, archived: false }, - orderBy: { created_at: 'asc' }, - include: { product: { select: { name: true } } }, - }) - - const products = await prisma.product.findMany({ - where: { ...productAccessFilter(session.userId), archived: false }, - orderBy: { name: 'asc' }, - include: { - pbis: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], select: { id: true, title: true } }, - }, - }) - - return ( -
-

Todo's

- ({ - id: t.id, - title: t.title, - description: t.description ?? null, - done: t.done, - created_at: t.created_at.toISOString(), - product_id: t.product_id ?? null, - product_name: t.product?.name ?? null, - }))} - products={products.map(p => ({ - id: p.id, - name: p.name, - pbis: p.pbis, - }))} - isDemo={session.isDemo ?? false} - /> -
- ) -} diff --git a/app/api/todos/route.ts b/app/api/todos/route.ts deleted file mode 100644 index 6a682e5..0000000 --- a/app/api/todos/route.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { z } from 'zod' - -const bodySchema = z.object({ - title: z.string().min(1, 'Titel is verplicht').max(500), - description: z.string().max(2000, 'Beschrijving mag maximaal 2000 tekens bevatten').optional(), - product_id: z.string().min(1, 'Product is verplicht'), -}) - -export async function POST(request: Request) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - if (auth.isDemo) { - return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) - } - - let body: unknown - try { - body = await request.json() - } catch { - return Response.json({ error: 'Malformed JSON' }, { status: 400 }) - } - const parsed = bodySchema.safeParse(body) - if (!parsed.success) { - return Response.json({ error: parsed.error.flatten() }, { status: 422 }) - } - - const product = await prisma.product.findFirst({ - where: { id: parsed.data.product_id, user_id: auth.userId, archived: false }, - }) - if (!product) { - return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) - } - - const description = parsed.data.description?.trim() || null - - const todo = await prisma.todo.create({ - data: { - user_id: auth.userId, - product_id: parsed.data.product_id, - title: parsed.data.title, - description, - }, - }) - - return Response.json( - { id: todo.id, title: todo.title, description: todo.description, created_at: todo.created_at }, - { status: 201 }, - ) -} diff --git a/components/todos/todo-list.tsx b/components/todos/todo-list.tsx deleted file mode 100644 index 5db21b3..0000000 --- a/components/todos/todo-list.tsx +++ /dev/null @@ -1,687 +0,0 @@ -'use client' - -import { useState, useTransition, useMemo, useEffect, useRef, useCallback } from 'react' -import { useActionState } from 'react' -import { useFormStatus } from 'react-dom' -import { useRouter } from 'next/navigation' -import { - useReactTable, - getCoreRowModel, - getPaginationRowModel, - flexRender, - type ColumnDef, - type RowSelectionState, - type PaginationState, -} from '@tanstack/react-table' -import { toast } from 'sonner' -import { cn } from '@/lib/utils' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { Input } from '@/components/ui/input' -import { Textarea } from '@/components/ui/textarea' -import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { - createTodoAction, - updateTodoAction, - archiveSelectedTodosAction, - promoteTodoToPbiAction, - promoteTodoToStoryAction, - promoteTodoToIdeaAction, -} from '@/actions/todos' - -interface Todo { - id: string - title: string - description: string | null - done: boolean - created_at: string - product_id: string | null - product_name: string | null -} - -interface Pbi { - id: string - title: string -} - -interface Product { - id: string - name: string - pbis: Pbi[] -} - -interface TodoListProps { - todos: Todo[] - products: Product[] - isDemo: boolean -} - -// Checkbox with indeterminate support for TanStack row selection -function IndeterminateCheckbox({ - indeterminate, - className, - ...props -}: React.InputHTMLAttributes & { indeterminate?: boolean }) { - const ref = useRef(null) - useEffect(() => { - if (ref.current) ref.current.indeterminate = indeterminate ?? false - }, [indeterminate]) - return ( - - ) -} - -function SaveButton() { - const { pending } = useFormStatus() - return ( - - ) -} - -// --- Promote to PBI dialog --- -function PromotePbiDialog({ - todo, - products, - onClose, -}: { todo: Todo; products: Product[]; onClose: () => void }) { - const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose]) - useEffect(() => { - document.addEventListener('keydown', handleKey) - return () => document.removeEventListener('keydown', handleKey) - }, [handleKey]) - - const [state, formAction] = useActionState( - async (_prev: unknown, fd: FormData) => { - const result = await promoteTodoToPbiAction(_prev, fd) - if (result?.success) { toast.success('Todo gepromoveerd naar PBI'); onClose() } - return result - }, - undefined - ) - - return ( -
-
-

Promoveer naar PBI

-

Let op: dit kan niet ongedaan worden gemaakt.

-
- -
- - -
-
- - {products.length === 0 ? ( -

Maak eerst een product aan.

- ) : ( - - )} -
-
- - -
- {typeof state?.error === 'string' &&

{state.error}

} -
- - -
-
-
-
- ) -} - -// --- Promote to Story dialog --- -function PromoteStoryDialog({ - todo, - products, - onClose, -}: { todo: Todo; products: Product[]; onClose: () => void }) { - const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose]) - useEffect(() => { - document.addEventListener('keydown', handleKey) - return () => document.removeEventListener('keydown', handleKey) - }, [handleKey]) - - const [selectedProductId, setSelectedProductId] = useState(todo.product_id ?? products[0]?.id ?? '') - const selectedProduct = products.find(p => p.id === selectedProductId) - - const [state, formAction] = useActionState( - async (_prev: unknown, fd: FormData) => { - const result = await promoteTodoToStoryAction(_prev, fd) - if (result?.success) { toast.success('Todo gepromoveerd naar Story'); onClose() } - return result - }, - undefined - ) - - return ( -
-
-

Promoveer naar Story

-

Let op: dit kan niet ongedaan worden gemaakt.

-
- - -
- - -
-
- - {products.length === 0 ? ( -

Maak eerst een product aan.

- ) : ( - - )} -
-
- - {!selectedProduct?.pbis.length ? ( -

Maak eerst een PBI aan in dit product.

- ) : ( - - )} -
-
- - -
- {typeof state?.error === 'string' &&

{state.error}

} -
- - -
-
-
-
- ) -} - -// --- Promote to Idea dialog (M12 T-514) --- -// Geen extra inputs nodig — title/description komen uit de todo, en -// promoteTodoToIdeaAction archiveert de todo automatisch. -function PromoteIdeaDialog({ - todo, - onClose, -}: { todo: Todo; onClose: () => void }) { - const router = useRouter() - const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose]) - useEffect(() => { - document.addEventListener('keydown', handleKey) - return () => document.removeEventListener('keydown', handleKey) - }, [handleKey]) - - const [pending, startTransition] = useTransition() - - function handleConfirm() { - startTransition(async () => { - const r = await promoteTodoToIdeaAction(todo.id) - if ('error' in r) { - toast.error(r.error) - return - } - toast.success(`Idee aangemaakt (${r.idea_code})`) - onClose() - router.push(`/ideas/${r.idea_id}`) - }) - } - - return ( -
-
-

Promoveer naar Idee

-

- Maak een nieuw idee van ‘{todo.title}’. De Todo wordt - gearchiveerd; je kunt hem later terugvinden in de archief-filter. -

-

- Het idee start als DRAFT. Je kunt het daarna grillen, plannen, en - materialiseren tot een PBI. -

-
- - -
-
-
- ) -} - -// --- Detail card --- -function TodoCard({ - mode, - activeTodo, - products, - isDemo, - defaultProductId, - onSuccess, - onPromotePbi, - onPromoteStory, - onPromoteIdea, -}: { - mode: 'idle' | 'create' | 'edit' - activeTodo: Todo | null - products: Product[] - isDemo: boolean - defaultProductId: string - onSuccess: () => void - onPromotePbi: (todo: Todo) => void - onPromoteStory: (todo: Todo) => void - onPromoteIdea: (todo: Todo) => void -}) { - const [createState, createFormAction] = useActionState(createTodoAction, undefined) - const [editState, editFormAction] = useActionState(updateTodoAction, undefined) - - useEffect(() => { - if (createState && 'success' in createState && createState.success) onSuccess() - }, [createState, onSuccess]) - - useEffect(() => { - if (editState && 'success' in editState && editState.success) onSuccess() - }, [editState, onSuccess]) - - if (mode === 'idle') { - return ( -
-

Selecteer een rij of klik op + om te beginnen.

-
- ) - } - - if (mode === 'create') { - return ( -
-

Nieuwe todo

-
-
- - -
-