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