feat(02-04): add POST /api/client/approve and POST /api/client/comment API routes
- 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)
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user