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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user