From b71a1a7328d51bfbe8f32f3fecaafc1881762410 Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Fri, 24 Apr 2026 11:56:29 +0200 Subject: [PATCH] feat: ST-401-ST-410 M4 REST API, tokenbeleer en activiteitenlog - api-auth.ts was al aanwezig; demo-check toegevoegd per endpoint (ST-401) - Token aanmaken (SHA-256 hash, eenmalig tonen), intrekken, max 10 (ST-402) - GET /api/products actieve productenlijst (ST-403) - GET /api/products/:id/next-story hoogst geprioriteerde open story (ST-404) - GET /api/sprints/:id/tasks met limit parameter (ST-405) - PATCH /api/stories/:id/tasks/reorder met ID-validatie (ST-406) - POST /api/stories/:id/log met discriminatedUnion per type (ST-407) - PATCH /api/tasks/:id status bijwerken met cross-user bescherming (ST-408) - POST /api/todos via API aanmaken (ST-409) - StoryLog component met kleurcodering per type in story slide-over (ST-410) Co-Authored-By: Claude Sonnet 4.6 --- actions/api-tokens.ts | 60 ++++++++ actions/stories.ts | 30 ++++ app/(app)/settings/page.tsx | 34 +++++ app/(app)/settings/tokens/page.tsx | 37 +++++ app/api/products/[id]/next-story/route.ts | 44 ++++++ app/api/products/route.ts | 17 +++ app/api/sprints/[id]/tasks/route.ts | 37 +++++ app/api/stories/[id]/log/route.ts | 59 ++++++++ app/api/stories/[id]/tasks/reorder/route.ts | 50 +++++++ app/api/tasks/[id]/route.ts | 46 ++++++ app/api/todos/route.ts | 29 ++++ components/backlog/story-panel.tsx | 18 ++- components/settings/token-manager.tsx | 150 ++++++++++++++++++++ components/shared/story-log.tsx | 103 ++++++++++++++ 14 files changed, 713 insertions(+), 1 deletion(-) create mode 100644 actions/api-tokens.ts create mode 100644 app/(app)/settings/page.tsx create mode 100644 app/(app)/settings/tokens/page.tsx create mode 100644 app/api/products/[id]/next-story/route.ts create mode 100644 app/api/products/route.ts create mode 100644 app/api/sprints/[id]/tasks/route.ts create mode 100644 app/api/stories/[id]/log/route.ts create mode 100644 app/api/stories/[id]/tasks/reorder/route.ts create mode 100644 app/api/tasks/[id]/route.ts create mode 100644 app/api/todos/route.ts create mode 100644 components/settings/token-manager.tsx create mode 100644 components/shared/story-log.tsx diff --git a/actions/api-tokens.ts b/actions/api-tokens.ts new file mode 100644 index 0000000..3964342 --- /dev/null +++ b/actions/api-tokens.ts @@ -0,0 +1,60 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { createHash, randomBytes } from 'crypto' +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +export async function createApiTokenAction(_prevState: unknown, formData: FormData) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const label = (formData.get('label') as string | null)?.trim() || null + + // Max 10 active tokens + const activeCount = await prisma.apiToken.count({ + where: { user_id: session.userId, revoked_at: null }, + }) + if (activeCount >= 10) return { error: 'Maximaal 10 actieve tokens toegestaan' } + + const rawToken = randomBytes(32).toString('hex') + const tokenHash = createHash('sha256').update(rawToken).digest('hex') + + await prisma.apiToken.create({ + data: { + user_id: session.userId, + token_hash: tokenHash, + label, + }, + }) + + revalidatePath('/settings/tokens') + // Return the raw token once — it won't be retrievable later + return { success: true, token: rawToken } +} + +export async function revokeApiTokenAction(id: string) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const token = await prisma.apiToken.findFirst({ + where: { id, user_id: session.userId }, + }) + if (!token) return { error: 'Token niet gevonden' } + + await prisma.apiToken.update({ + where: { id }, + data: { revoked_at: new Date() }, + }) + + revalidatePath('/settings/tokens') + return { success: true } +} diff --git a/actions/stories.ts b/actions/stories.ts index d6b4345..118be3c 100644 --- a/actions/stories.ts +++ b/actions/stories.ts @@ -163,6 +163,36 @@ export async function updatePbiPriorityAction(pbiId: string, priority: number, p return { success: true } } +export async function getStoryLogsAction(storyId: string) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + + const story = await prisma.story.findFirst({ + where: { id: storyId, product: { user_id: session.userId } }, + include: { product: { select: { repo_url: true } } }, + }) + if (!story) return { error: 'Story niet gevonden' } + + const logs = await prisma.storyLog.findMany({ + where: { story_id: storyId }, + orderBy: { created_at: 'asc' }, + }) + + return { + success: true, + logs: logs.map(l => ({ + id: l.id, + type: l.type, + content: l.content, + status: l.status, + commit_hash: l.commit_hash, + commit_message: l.commit_message, + created_at: l.created_at.toISOString(), + })), + repoUrl: story.product.repo_url, + } +} + export async function reorderStoriesAction( pbiId: string, productId: string, diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx new file mode 100644 index 0000000..0bb283c --- /dev/null +++ b/app/(app)/settings/page.tsx @@ -0,0 +1,34 @@ +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { SessionData, sessionOptions } from '@/lib/session' +import Link from 'next/link' + +export default async function SettingsPage() { + const session = await getIronSession(await cookies(), sessionOptions) + + return ( +
+

Instellingen

+ +
+

Account

+

+ Ingelogd als {session.userId} + {session.isDemo && (demo)} +

+
+ +
+
+

API Tokens

+ + Beheren → + +
+

+ Gebruik API tokens om Scrum4Me te koppelen aan Claude Code. +

+
+
+ ) +} diff --git a/app/(app)/settings/tokens/page.tsx b/app/(app)/settings/tokens/page.tsx new file mode 100644 index 0000000..3621cfa --- /dev/null +++ b/app/(app)/settings/tokens/page.tsx @@ -0,0 +1,37 @@ +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { SessionData, sessionOptions } from '@/lib/session' +import { prisma } from '@/lib/prisma' +import { TokenManager } from '@/components/settings/token-manager' +import Link from 'next/link' + +export default async function TokensPage() { + const session = await getIronSession(await cookies(), sessionOptions) + + const tokens = await prisma.apiToken.findMany({ + where: { user_id: session.userId }, + orderBy: { created_at: 'desc' }, + }) + + return ( +
+
+ + ← Instellingen + + / +

API Tokens

+
+ + ({ + id: t.id, + label: t.label, + created_at: t.created_at.toISOString(), + revoked_at: t.revoked_at?.toISOString() ?? null, + }))} + isDemo={session.isDemo ?? false} + /> +
+ ) +} diff --git a/app/api/products/[id]/next-story/route.ts b/app/api/products/[id]/next-story/route.ts new file mode 100644 index 0000000..91fd210 --- /dev/null +++ b/app/api/products/[id]/next-story/route.ts @@ -0,0 +1,44 @@ +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + + const { id } = await params + + const sprint = await prisma.sprint.findFirst({ + where: { product_id: id, status: 'ACTIVE', product: { user_id: auth.userId } }, + }) + if (!sprint) { + return Response.json({ error: 'Geen actieve Sprint gevonden' }, { status: 404 }) + } + + const story = await prisma.story.findFirst({ + where: { sprint_id: sprint.id, status: 'IN_SPRINT' }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + include: { + tasks: { + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + select: { id: true, title: true, description: true, priority: true, sort_order: true, status: true }, + }, + }, + }) + + if (!story) { + return Response.json({ error: 'Geen open stories in de Sprint' }, { status: 404 }) + } + + return Response.json({ + id: story.id, + title: story.title, + description: story.description, + acceptance_criteria: story.acceptance_criteria, + tasks: story.tasks, + }) +} diff --git a/app/api/products/route.ts b/app/api/products/route.ts new file mode 100644 index 0000000..fda4582 --- /dev/null +++ b/app/api/products/route.ts @@ -0,0 +1,17 @@ +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' + +export async function GET(request: Request) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + + const products = await prisma.product.findMany({ + where: { user_id: auth.userId, archived: false }, + orderBy: { created_at: 'desc' }, + select: { id: true, name: true, repo_url: true }, + }) + + return Response.json(products) +} diff --git a/app/api/sprints/[id]/tasks/route.ts b/app/api/sprints/[id]/tasks/route.ts new file mode 100644 index 0000000..28465ba --- /dev/null +++ b/app/api/sprints/[id]/tasks/route.ts @@ -0,0 +1,37 @@ +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + + const { id } = await params + const url = new URL(request.url) + const limitParam = parseInt(url.searchParams.get('limit') ?? '10') + const limit = Math.min(Math.max(1, limitParam), 50) + + const sprint = await prisma.sprint.findFirst({ + where: { id, product: { user_id: auth.userId } }, + }) + if (!sprint) { + return Response.json({ error: 'Sprint niet gevonden' }, { status: 404 }) + } + + const tasks = await prisma.task.findMany({ + where: { sprint_id: id }, + orderBy: [ + { story: { sort_order: 'asc' } }, + { priority: 'asc' }, + { sort_order: 'asc' }, + ], + take: limit, + select: { id: true, title: true, story_id: true, priority: true, sort_order: true, status: true }, + }) + + return Response.json(tasks) +} diff --git a/app/api/stories/[id]/log/route.ts b/app/api/stories/[id]/log/route.ts new file mode 100644 index 0000000..d88dca4 --- /dev/null +++ b/app/api/stories/[id]/log/route.ts @@ -0,0 +1,59 @@ +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' +import { z } from 'zod' + +const logSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('IMPLEMENTATION_PLAN'), + content: z.string().min(1), + }), + z.object({ + type: z.literal('TEST_RESULT'), + content: z.string().min(1), + status: z.enum(['PASSED', 'FAILED']), + }), + z.object({ + type: z.literal('COMMIT'), + content: z.string().min(1), + commit_hash: z.string().min(1), + commit_message: z.string().min(1), + }), +]) + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + + const { id: storyId } = await params + + const story = await prisma.story.findFirst({ + where: { id: storyId, product: { user_id: auth.userId } }, + }) + if (!story) { + return Response.json({ error: 'Story niet gevonden' }, { status: 404 }) + } + + const body = await request.json().catch(() => null) + const parsed = logSchema.safeParse(body) + if (!parsed.success) { + return Response.json({ error: parsed.error.flatten() }, { status: 400 }) + } + + const log = await prisma.storyLog.create({ + data: { + story_id: storyId, + type: parsed.data.type, + content: parsed.data.content, + status: 'status' in parsed.data ? parsed.data.status : null, + commit_hash: 'commit_hash' in parsed.data ? parsed.data.commit_hash : null, + commit_message: 'commit_message' in parsed.data ? parsed.data.commit_message : null, + }, + }) + + return Response.json({ id: log.id, created_at: log.created_at }, { status: 201 }) +} diff --git a/app/api/stories/[id]/tasks/reorder/route.ts b/app/api/stories/[id]/tasks/reorder/route.ts new file mode 100644 index 0000000..c288653 --- /dev/null +++ b/app/api/stories/[id]/tasks/reorder/route.ts @@ -0,0 +1,50 @@ +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' +import { z } from 'zod' + +const bodySchema = z.object({ + task_ids: z.array(z.string()).min(1), +}) + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + 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 }) + } + + const { id: storyId } = await params + + const body = await request.json().catch(() => null) + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return Response.json({ error: parsed.error.flatten() }, { status: 400 }) + } + + const story = await prisma.story.findFirst({ + where: { id: storyId, product: { user_id: auth.userId } }, + include: { tasks: { select: { id: true } } }, + }) + if (!story) { + return Response.json({ error: 'Story niet gevonden' }, { status: 404 }) + } + + const storyTaskIds = new Set(story.tasks.map(t => t.id)) + const invalidId = parsed.data.task_ids.find(id => !storyTaskIds.has(id)) + if (invalidId) { + return Response.json({ error: `Ongeldig task_id: ${invalidId}` }, { status: 400 }) + } + + await prisma.$transaction( + parsed.data.task_ids.map((id, i) => + prisma.task.update({ where: { id }, data: { sort_order: i + 1.0 } }) + ) + ) + + return Response.json({ success: true }) +} diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts new file mode 100644 index 0000000..0a9ca1d --- /dev/null +++ b/app/api/tasks/[id]/route.ts @@ -0,0 +1,46 @@ +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' +import { z } from 'zod' + +const patchSchema = z.object({ + status: z.enum(['TO_DO', 'IN_PROGRESS', 'DONE']), +}) + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + 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 }) + } + + const { id } = await params + + const task = await prisma.task.findFirst({ + where: { id }, + include: { story: { include: { product: true } } }, + }) + if (!task) { + return Response.json({ error: 'Taak niet gevonden' }, { status: 404 }) + } + if (task.story.product.user_id !== auth.userId) { + return Response.json({ error: 'Geen toegang' }, { status: 403 }) + } + + const body = await request.json().catch(() => null) + const parsed = patchSchema.safeParse(body) + if (!parsed.success) { + return Response.json({ error: parsed.error.flatten() }, { status: 400 }) + } + + const updated = await prisma.task.update({ + where: { id }, + data: { status: parsed.data.status }, + }) + + return Response.json({ id: updated.id, status: updated.status }) +} diff --git a/app/api/todos/route.ts b/app/api/todos/route.ts new file mode 100644 index 0000000..2a65b32 --- /dev/null +++ b/app/api/todos/route.ts @@ -0,0 +1,29 @@ +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), +}) + +export async function POST(request: Request) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + + const body = await request.json().catch(() => null) + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return Response.json({ error: parsed.error.flatten() }, { status: 400 }) + } + + const todo = await prisma.todo.create({ + data: { + user_id: auth.userId, + title: parsed.data.title, + }, + }) + + return Response.json({ id: todo.id, title: todo.title, created_at: todo.created_at }, { status: 201 }) +} diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx index ed3015f..7e48cdc 100644 --- a/components/backlog/story-panel.tsx +++ b/components/backlog/story-panel.tsx @@ -29,7 +29,8 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { useSelectionStore } from '@/stores/selection-store' import { usePlannerStore } from '@/stores/planner-store' -import { createStoryAction, updateStoryAction, deleteStoryAction, reorderStoriesAction } from '@/actions/stories' +import { createStoryAction, updateStoryAction, deleteStoryAction, reorderStoriesAction, getStoryLogsAction } from '@/actions/stories' +import { StoryLog } from '@/components/shared/story-log' import { cn } from '@/lib/utils' const PRIORITY_LABELS: Record = { 1: 'Kritiek', 2: 'Hoog', 3: 'Gemiddeld', 4: 'Laag' } @@ -125,6 +126,11 @@ function StoryDetailSheet({ }) { const [confirmDelete, setConfirmDelete] = useState(false) const [isDeleting, startDeleteTransition] = useTransition() + const [logs, setLogs] = useState> | null>(null) + + useEffect(() => { + getStoryLogsAction(story.id).then(setLogs) + }, [story.id]) const [state, formAction] = useActionState( async (_prev: unknown, fd: FormData) => { @@ -212,6 +218,16 @@ function StoryDetailSheet({ )} + {/* Activity log */} +
+

Activiteitenlog

+ {logs && 'logs' in logs && logs.logs ? ( + ({ ...l, status: l.status ?? null, commit_hash: l.commit_hash ?? null, commit_message: l.commit_message ?? null }))} repoUrl={logs.repoUrl} /> + ) : ( +

Laden…

+ )} +
+ {!isDemo && (
{confirmDelete ? ( diff --git a/components/settings/token-manager.tsx b/components/settings/token-manager.tsx new file mode 100644 index 0000000..6f48b51 --- /dev/null +++ b/components/settings/token-manager.tsx @@ -0,0 +1,150 @@ +'use client' + +import { useState, useActionState, useTransition } from 'react' +import { useFormStatus } from 'react-dom' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { createApiTokenAction, revokeApiTokenAction } from '@/actions/api-tokens' + +interface Token { + id: string + label: string | null + created_at: string + revoked_at: string | null +} + +interface TokenManagerProps { + tokens: Token[] + isDemo: boolean +} + +function CreateSubmitButton() { + const { pending } = useFormStatus() + return ( + + ) +} + +export function TokenManager({ tokens, isDemo }: TokenManagerProps) { + const [newToken, setNewToken] = useState(null) + const [copied, setCopied] = useState(false) + const [, startRevoke] = useTransition() + + const [state, formAction] = useActionState( + async (_prev: unknown, fd: FormData) => { + const result = await createApiTokenAction(_prev, fd) + if (result.success && result.token) { + setNewToken(result.token) + } + return result + }, + undefined + ) + + function handleCopy() { + if (!newToken) return + navigator.clipboard.writeText(newToken) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + function handleRevoke(id: string) { + startRevoke(async () => { + await revokeApiTokenAction(id) + }) + } + + const activeTokens = tokens.filter(t => !t.revoked_at) + const revokedTokens = tokens.filter(t => t.revoked_at) + + return ( +
+ {/* New token revealed */} + {newToken && ( +
+

+ Token aangemaakt — kopieer het nu. Je ziet het daarna niet meer. +

+
+ + {newToken} + + +
+ +
+ )} + + {/* Create form */} + {!isDemo && ( +
+

Nieuw token aanmaken

+
+ + + + {typeof state?.error === 'string' && ( +

{state.error}

+ )} +

+ Maximaal 10 actieve tokens. Je hebt er nu {activeTokens.length}. +

+
+ )} + + {/* Active tokens */} +
+

Actieve tokens ({activeTokens.length})

+ {activeTokens.length === 0 ? ( +

Geen actieve tokens.

+ ) : ( +
+ {activeTokens.map(token => ( +
+
+

{token.label ?? Geen label}

+

+ Aangemaakt {new Date(token.created_at).toLocaleDateString('nl-NL')} +

+
+ {!isDemo && ( + + )} +
+ ))} +
+ )} +
+ + {/* Revoked tokens */} + {revokedTokens.length > 0 && ( +
+

Ingetrokken tokens

+
+ {revokedTokens.map(token => ( +
+
+

{token.label ?? 'Geen label'}

+

+ Ingetrokken {new Date(token.revoked_at!).toLocaleDateString('nl-NL')} +

+
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/components/shared/story-log.tsx b/components/shared/story-log.tsx new file mode 100644 index 0000000..ae50663 --- /dev/null +++ b/components/shared/story-log.tsx @@ -0,0 +1,103 @@ +interface StoryLogEntry { + id: string + type: string + content: string + status: string | null + commit_hash: string | null + commit_message: string | null + created_at: string +} + +interface StoryLogProps { + logs: StoryLogEntry[] + repoUrl?: string | null +} + +const TYPE_STYLES: Record = { + IMPLEMENTATION_PLAN: { + bg: 'bg-info-container/50 border-info/20', + label: 'Implementatieplan', + labelColor: 'text-info', + }, + TEST_RESULT: { + bg: 'bg-surface-container border-border', + label: 'Testresultaat', + labelColor: 'text-foreground', + }, + COMMIT: { + bg: 'bg-secondary-container/30 border-secondary/20', + label: 'Commit', + labelColor: 'text-secondary', + }, +} + +export function StoryLog({ logs, repoUrl }: StoryLogProps) { + if (logs.length === 0) { + return ( +

+ Nog geen activiteit. Gebruik de REST API om logs toe te voegen. +

+ ) + } + + return ( +
+ {logs.map(log => { + const style = TYPE_STYLES[log.type] ?? TYPE_STYLES.IMPLEMENTATION_PLAN + const isTestResult = log.type === 'TEST_RESULT' + const testPassed = log.status === 'PASSED' + + return ( +
+
+ + {style.label} + {isTestResult && ` — ${testPassed ? 'Geslaagd' : 'Mislukt'}`} + + + {new Date(log.created_at).toLocaleDateString('nl-NL', { + day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', + })} + +
+ +

{log.content}

+ + {log.commit_hash && ( +
+ {repoUrl ? ( + + {log.commit_hash.slice(0, 7)} + + ) : ( + + {log.commit_hash.slice(0, 7)} + + )} + {log.commit_message && ( + {log.commit_message} + )} +
+ )} +
+ ) + })} +
+ ) +}