From c24bdde60352eb30302955808aae7239f4147600 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Fri, 15 May 2026 21:39:32 +0200 Subject: [PATCH] feat(02-04): add POST /api/client/approve and POST /api/client/comment API routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - approve: validates token, checks deliverable ownership via phase→client join, sets status=approved + approved_at=now() only if approved_at is currently null (CLAUDE.md immutability rule enforced) - comment: validates token, checks entity ownership (task or deliverable) via phase→client chain, inserts comment with author='client' - both routes return 404 on invalid token or unknown entity - neither route references quote_items (CLAUDE.md constraint enforced) - Zod validation on comment body: min 1 char, max 2000 chars (T-02-20 DoS mitigation) --- src/app/api/client/approve/route.ts | 58 +++++++++++++++ src/app/api/client/comment/route.ts | 110 ++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 src/app/api/client/approve/route.ts create mode 100644 src/app/api/client/comment/route.ts diff --git a/src/app/api/client/approve/route.ts b/src/app/api/client/approve/route.ts new file mode 100644 index 0000000..10d941b --- /dev/null +++ b/src/app/api/client/approve/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq, and } from "drizzle-orm"; +import { db } from "@/db"; +import { clients, deliverables, tasks, phases } from "@/db/schema"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { token, deliverableId } = body as { token?: string; deliverableId?: string }; + + if (!token || !deliverableId) { + return NextResponse.json({ error: "token e deliverableId richiesti" }, { status: 400 }); + } + + // Validate token — find the client + const clientRows = await db + .select({ id: clients.id }) + .from(clients) + .where(eq(clients.token, token)) + .limit(1); + + if (clientRows.length === 0) { + return NextResponse.json({ error: "Token non valido" }, { status: 404 }); + } + + const clientId = clientRows[0].id; + + // Verify deliverable belongs to this client (prevents cross-client approval) + // deliverable → task → phase → client + const ownershipCheck = await db + .select({ deliverable_id: deliverables.id, approved_at: deliverables.approved_at }) + .from(deliverables) + .innerJoin(tasks, eq(deliverables.task_id, tasks.id)) + .innerJoin(phases, and(eq(tasks.phase_id, phases.id), eq(phases.client_id, clientId))) + .where(eq(deliverables.id, deliverableId)) + .limit(1); + + if (ownershipCheck.length === 0) { + return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 }); + } + + // IMMUTABILITY RULE (CLAUDE.md): if approved_at is already set, this is a no-op + if (ownershipCheck[0].approved_at !== null) { + return NextResponse.json({ approved: true, message: "Già approvato" }, { status: 200 }); + } + + // Set approved — approved_at is immutable once set, client cannot unset it + await db + .update(deliverables) + .set({ status: "approved", approved_at: new Date() }) + .where(eq(deliverables.id, deliverableId)); + + return NextResponse.json({ approved: true }, { status: 200 }); + } catch (err) { + console.error("/api/client/approve error:", err); + return NextResponse.json({ error: "Errore interno" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/client/comment/route.ts b/src/app/api/client/comment/route.ts new file mode 100644 index 0000000..68c7285 --- /dev/null +++ b/src/app/api/client/comment/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq, inArray } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "@/db"; +import { clients, comments, tasks, phases, deliverables } from "@/db/schema"; + +const commentSchema = z.object({ + token: z.string().min(1), + entity_type: z.enum(["task", "deliverable"]), + entity_id: z.string().min(1), + body: z.string().min(1, "Il commento non può essere vuoto").max(2000), +}); + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const parsed = commentSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0].message }, + { status: 400 } + ); + } + + const { token, entity_type, entity_id, body: commentBody } = parsed.data; + + // Validate token + const clientRows = await db + .select({ id: clients.id }) + .from(clients) + .where(eq(clients.token, token)) + .limit(1); + + if (clientRows.length === 0) { + return NextResponse.json({ error: "Token non valido" }, { status: 404 }); + } + + const clientId = clientRows[0].id; + + // Verify entity belongs to this client (prevent cross-client comment injection) + if (entity_type === "task") { + const phasesForClient = await db + .select({ id: phases.id }) + .from(phases) + .where(eq(phases.client_id, clientId)); + const phaseIds = phasesForClient.map((p) => p.id); + + if (phaseIds.length === 0) { + return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 }); + } + + const taskRows = await db + .select({ id: tasks.id }) + .from(tasks) + .where(inArray(tasks.phase_id, phaseIds)); + + const taskCheck = taskRows.find((r) => r.id === entity_id); + + if (!taskCheck) { + return NextResponse.json({ error: "Task non trovato" }, { status: 404 }); + } + } else { + // deliverable — verify via task → phase → client chain + const phasesForClient = await db + .select({ id: phases.id }) + .from(phases) + .where(eq(phases.client_id, clientId)); + const phaseIds = phasesForClient.map((p) => p.id); + + if (phaseIds.length === 0) { + return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 }); + } + + const taskRows = await db + .select({ id: tasks.id }) + .from(tasks) + .where(inArray(tasks.phase_id, phaseIds)); + + const taskIds = taskRows.map((r) => r.id); + + if (taskIds.length === 0) { + return NextResponse.json({ error: "Nessun task trovato" }, { status: 404 }); + } + + const delivRows = await db + .select({ id: deliverables.id }) + .from(deliverables) + .where(inArray(deliverables.task_id, taskIds)); + + const delivCheck = delivRows.find((r) => r.id === entity_id); + + if (!delivCheck) { + return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 }); + } + } + + await db.insert(comments).values({ + entity_type, + entity_id, + author: "client", + body: commentBody, + }); + + return NextResponse.json({ success: true }, { status: 201 }); + } catch (err) { + console.error("/api/client/comment error:", err); + return NextResponse.json({ error: "Errore interno" }, { status: 500 }); + } +} \ No newline at end of file