--- phase: "02-admin-area-interactive-features" plan: 04 type: execute wave: 4 depends_on: - "02-03" files_modified: - src/app/api/client/approve/route.ts - src/app/api/client/comment/route.ts - src/app/c/[token]/page.tsx - src/components/client/ApproveButton.tsx - src/components/client/CommentForm.tsx - src/components/client/CommentList.tsx autonomous: true requirements: - DASH-05 - DASH-06 must_haves: truths: - "Client can click 'Approva' on a deliverable and the approved_at timestamp is set immutably in DB" - "The Approva button is hidden once approved_at is set — the approved state shows a timestamp instead" - "Client can submit a comment on a task or deliverable; it appears in the list on reload" - "Comment author is 'client'; admin comments show as 'iamcavalli', client comments show as 'Tu'" - "Both API routes validate the client token from the request body against the DB before writing" - "quote_items is never queried or returned by either API route" artifacts: - path: "src/app/api/client/approve/route.ts" provides: "POST — validates client token, sets deliverable status=approved + approved_at=now() if not already approved" contains: "approved_at" - path: "src/app/api/client/comment/route.ts" provides: "POST — validates client token, inserts comment with author='client'" contains: "author.*client" - path: "src/components/client/ApproveButton.tsx" provides: "Client Component: Approva button that POSTs to /api/client/approve and refreshes the page" contains: "useRouter" - path: "src/components/client/CommentForm.tsx" provides: "Client Component: textarea + submit that POSTs to /api/client/comment" contains: "api/client/comment" - path: "src/app/c/[token]/page.tsx" provides: "Updated client dashboard wiring ApproveButton and CommentForm into deliverable/task sections" contains: "ApproveButton" key_links: - from: "ApproveButton" to: "POST /api/client/approve" via: "fetch('/api/client/approve', { body: JSON.stringify({ token, deliverableId }) })" pattern: "api/client/approve" - from: "POST /api/client/approve" to: "deliverables table" via: "db.update(deliverables).set({ status: 'approved', approved_at: new Date() })" pattern: "approved_at" - from: "CommentForm" to: "POST /api/client/comment" via: "fetch('/api/client/comment', { body: JSON.stringify({ token, entity_type, entity_id, body }) })" pattern: "api/client/comment" - from: "POST /api/client/comment" to: "comments table" via: "db.insert(comments).values({ author: 'client', ... })" pattern: "author.*client" --- **Client Interactions — Approvals + Comments:** Add two API routes for client-side mutations (per D-06 — not Server Actions, because the client has no admin session), then update the client dashboard UI to render ApproveButton on pending/submitted deliverables and CommentForm + CommentList on every task and deliverable. Token is validated server-side in each API route against the clients table before any write. Purpose: Deliver DASH-05 (deliverable approval with immutable approved_at) and DASH-06 (inline comments). The approved_at immutability rule from CLAUDE.md is enforced in the API route: if approved_at is already set, the request is a no-op (returns 200 but does not overwrite). Output: Clients can approve deliverables and leave comments from their dashboard; admin sees both in the workspace (Plan 03 CommentsTab). @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md @.planning/phases/02-admin-area-interactive-features/02-03-SUMMARY.md ```typescript export const deliverables = pgTable("deliverables", { id: text("id").primaryKey(), task_id: text("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), title: text("title").notNull(), url: text("url"), status: text("status").notNull().default("pending"), // pending | submitted | approved approved_at: timestamp("approved_at", { withTimezone: true }), // IMMUTABLE once set }); export const comments = pgTable("comments", { id: text("id").primaryKey(), entity_type: text("entity_type").notNull(), // task | deliverable entity_id: text("entity_id").notNull(), author: text("author").notNull(), // client | admin body: text("body").notNull(), created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); export const clients = pgTable("clients", { id: text("id").primaryKey(), token: text("token").notNull().unique(), // ... other fields }); export type Deliverable = typeof deliverables.$inferSelect; export type Comment = typeof comments.$inferSelect; ``` ```typescript export interface ClientView { client: { id: string; name: string; brand_name: string; brief: string; accepted_total: string; }; phases: Array<{ id: string; title: string; status: string; sort_order: number; progress_pct: number; tasks: Array<{ id: string; title: string; description: string | null; status: string; sort_order: number; deliverables: Array<{ id: string; title: string; url: string | null; status: 'pending' | 'submitted' | 'approved'; approved_at: string | null; }>; }>; }>; payments: Array<{ id: string; label: string; status: string; }>; documents: Array<{ id: string; label: string; url: string; }>; notes: Array<{ id: string; body: string; created_at: string; }>; global_progress_pct: number; } ``` Task 1: Create POST /api/client/approve and POST /api/client/comment API routes src/app/api/client/approve/route.ts src/app/api/client/comment/route.ts Both routes validate the client's token against the DB before any mutation (per D-06). Token comes from the request body JSON. Neither route uses Auth.js — client has no session. Neither route ever queries quote_items (per CLAUDE.md architecture constraint). Create `src/app/api/client/approve/route.ts`: ```typescript 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 }); } } ``` Create `src/app/api/client/comment/route.ts`: ```typescript 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 taskCheck = await db .select({ id: tasks.id }) .from(tasks) .where( inArray(tasks.phase_id, phaseIds) ) .then((rows) => rows.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 taskIds = await db .select({ id: tasks.id }) .from(tasks) .where(inArray(tasks.phase_id, phaseIds)) .then((rows) => rows.map((r) => r.id)); if (taskIds.length === 0) { return NextResponse.json({ error: "Nessun task trovato" }, { status: 404 }); } const delivCheck = await db .select({ id: deliverables.id }) .from(deliverables) .where(inArray(deliverables.task_id, taskIds)) .then((rows) => rows.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 }); } } ``` test -f src/app/api/client/approve/route.ts && echo "approve route exists" grep -q "approved_at.*null" src/app/api/client/approve/route.ts && echo "immutability check present" grep -q "phases.client_id.*clientId\|clientId.*phases.client_id" src/app/api/client/approve/route.ts && echo "ownership verification present" test -f src/app/api/client/comment/route.ts && echo "comment route exists" grep -q "author.*client" src/app/api/client/comment/route.ts && echo "author set to client" grep -v '^#' src/app/api/client/approve/route.ts | grep -c "quote_items" | grep -q "^0$" && echo "quote_items not referenced in approve route" grep -v '^#' src/app/api/client/comment/route.ts | grep -c "quote_items" | grep -q "^0$" && echo "quote_items not referenced in comment route" npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK" - POST /api/client/approve: validates token, verifies deliverable ownership via phase→client chain, sets status=approved + approved_at=now() only if approved_at is currently null - POST /api/client/comment: validates token, validates entity ownership, inserts comment with author='client' - Both routes return 404 on invalid token or missing entity - Neither route references quote_items - npm run build passes Task 2: Build ApproveButton + CommentForm/List Client Components; wire into client dashboard page src/components/client/ApproveButton.tsx src/components/client/CommentForm.tsx src/components/client/CommentList.tsx src/app/c/[token]/page.tsx Create `src/components/client/ApproveButton.tsx` — Client Component (per D-10, no confirm modal): ```typescript "use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; type Props = { deliverableId: string; token: string; approvedAt: string | null; // ISO timestamp or null }; export function ApproveButton({ deliverableId, token, approvedAt }: Props) { const router = useRouter(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Already approved — show immutable confirmation, no button if (approvedAt) { const date = new Date(approvedAt).toLocaleDateString("it-IT", { day: "2-digit", month: "long", year: "numeric", }); return ( Approvato il {date} ); } async function handleApprove() { setLoading(true); setError(null); try { const res = await fetch("/api/client/approve", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, deliverableId }), }); if (!res.ok) { const data = await res.json(); setError(data.error ?? "Errore durante l'approvazione"); return; } router.refresh(); // Re-fetch Server Component data — approved_at now set } catch { setError("Errore di rete"); } finally { setLoading(false); } } return (
{error &&

{error}

}
); } ``` Create `src/components/client/CommentList.tsx` — pure presentational: ```typescript import type { Comment } from "@/db/schema"; type Props = { comments: Comment[] }; export function CommentList({ comments }: Props) { if (comments.length === 0) return null; return (
{comments.map((c) => (

{c.author === "admin" ? "iamcavalli" : "Tu"}

{c.body}

))}
); } ``` Create `src/components/client/CommentForm.tsx` — Client Component (per D-11): ```typescript "use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; type Props = { token: string; entityType: "task" | "deliverable"; entityId: string; }; export function CommentForm({ token, entityType, entityId }: Props) { const router = useRouter(); const [body, setBody] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!body.trim()) return; setLoading(true); setError(null); try { const res = await fetch("/api/client/comment", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, entity_type: entityType, entity_id: entityId, body }), }); if (!res.ok) { const data = await res.json(); setError(data.error ?? "Errore durante l'invio"); return; } setBody(""); router.refresh(); // Re-fetch Server Component to show new comment } catch { setError("Errore di rete"); } finally { setLoading(false); } } return (