---
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"
---

<objective>
**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).
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
@.planning/phases/02-admin-area-interactive-features/02-03-SUMMARY.md

<interfaces>
<!-- From src/db/schema.ts — types used in this plan -->
```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;
```

<!-- From src/lib/client-view.ts (Phase 1) — ClientView shape for reference -->
```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;
}
```

<!-- ClientView does NOT include comments — they are fetched separately in the page -->
<!-- The page must fetch comments independently using db query on entity_ids from the view -->
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create POST /api/client/approve and POST /api/client/comment API routes</name>
  <files>
    src/app/api/client/approve/route.ts
    src/app/api/client/comment/route.ts
  </files>
  <action>
    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 });
      }
    }
    ```
  </action>
  <verify>
    <automated>test -f src/app/api/client/approve/route.ts && echo "approve route exists"</automated>
    <automated>grep -q "approved_at.*null" src/app/api/client/approve/route.ts && echo "immutability check present"</automated>
    <automated>grep -q "phases.client_id.*clientId\|clientId.*phases.client_id" src/app/api/client/approve/route.ts && echo "ownership verification present"</automated>
    <automated>test -f src/app/api/client/comment/route.ts && echo "comment route exists"</automated>
    <automated>grep -q "author.*client" src/app/api/client/comment/route.ts && echo "author set to client"</automated>
    <automated>grep -v '^#' src/app/api/client/approve/route.ts | grep -c "quote_items" | grep -q "^0$" && echo "quote_items not referenced in approve route"</automated>
    <automated>grep -v '^#' src/app/api/client/comment/route.ts | grep -c "quote_items" | grep -q "^0$" && echo "quote_items not referenced in comment route"</automated>
    <automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
  </verify>
  <done>
    - 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
  </done>
</task>

<task type="auto">
  <name>Task 2: Build ApproveButton + CommentForm/List Client Components; wire into client dashboard page</name>
  <files>
    src/components/client/ApproveButton.tsx
    src/components/client/CommentForm.tsx
    src/components/client/CommentList.tsx
    src/app/c/[token]/page.tsx
  </files>
  <action>
    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<string | null>(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 (
          <span className="text-xs text-green-700 bg-green-50 border border-green-200 px-2 py-1 rounded">
            Approvato il {date}
          </span>
        );
      }

      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 (
        <div>
          <Button
            size="sm"
            variant="outline"
            onClick={handleApprove}
            disabled={loading}
            className="text-xs text-green-700 border-green-300 hover:bg-green-50"
          >
            {loading ? "Approvazione..." : "Approva"}
          </Button>
          {error && <p className="text-xs text-red-500 mt-1">{error}</p>}
        </div>
      );
    }
    ```

    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 (
        <div className="mt-3 space-y-2">
          {comments.map((c) => (
            <div
              key={c.id}
              className={`flex gap-2 ${c.author === "admin" ? "flex-row-reverse" : ""}`}
            >
              <div
                className={`rounded-lg px-3 py-2 text-xs max-w-xs ${
                  c.author === "admin"
                    ? "bg-gray-900 text-white"
                    : "bg-gray-100 text-gray-800"
                }`}
              >
                <p className="font-medium mb-0.5 opacity-60">
                  {c.author === "admin" ? "iamcavalli" : "Tu"}
                </p>
                <p>{c.body}</p>
              </div>
            </div>
          ))}
        </div>
      );
    }
    ```

    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<string | null>(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 (
        <form onSubmit={handleSubmit} className="mt-3 flex gap-2">
          <Textarea
            value={body}
            onChange={(e) => setBody(e.target.value)}
            placeholder="Lascia un commento..."
            rows={2}
            className="text-sm resize-none flex-1"
          />
          <div className="flex flex-col justify-end">
            <Button
              type="submit"
              size="sm"
              disabled={loading || !body.trim()}
              className="text-xs"
            >
              {loading ? "Invio..." : "Invia"}
            </Button>
          </div>
          {error && <p className="text-xs text-red-500 mt-1">{error}</p>}
        </form>
      );
    }
    ```

    Update `src/app/c/[token]/page.tsx` — extend the existing Phase 1 dashboard to:
    1. Fetch comments for all task/deliverable ids in this client's data
    2. Render ApproveButton on each deliverable (pending or submitted)
    3. Render CommentList + CommentForm below each task and deliverable

    Read the existing page first, then extend it. The page must remain a Server Component.
    ApproveButton and CommentForm are Client Components embedded within it.

    Key additions to the existing page (add these imports and sections):
    ```typescript
    // New imports to add:
    import { ApproveButton } from "@/components/client/ApproveButton";
    import { CommentForm } from "@/components/client/CommentForm";
    import { CommentList } from "@/components/client/CommentList";
    import { db } from "@/db";
    import { comments } from "@/db/schema";
    import { inArray } from "drizzle-orm";

    // After getClientView(), fetch comments:
    const allTaskIds = view.phases.flatMap((p) => p.tasks.map((t) => t.id));
    const allDeliverableIds = view.phases.flatMap((p) =>
      p.tasks.flatMap((t) => t.deliverables.map((d) => d.id))
    );
    const allEntityIds = [...allTaskIds, ...allDeliverableIds];

    const allComments = allEntityIds.length > 0
      ? await db
          .select()
          .from(comments)
          .where(inArray(comments.entity_id, allEntityIds))
      : [];

    // Helper to get comments for a specific entity:
    const commentsFor = (entityId: string) =>
      allComments.filter((c) => c.entity_id === entityId);
    ```

    Within the task/deliverable render loop, add below each deliverable:
    ```typescript
    // Within deliverable rendering:
    <ApproveButton
      deliverableId={deliverable.id}
      token={params.token}
      approvedAt={deliverable.approved_at}
    />
    <CommentList comments={commentsFor(deliverable.id)} />
    <CommentForm token={params.token} entityType="deliverable" entityId={deliverable.id} />
    ```

    And below each task:
    ```typescript
    // Within task rendering (after deliverables):
    <CommentList comments={commentsFor(task.id)} />
    <CommentForm token={params.token} entityType="task" entityId={task.id} />
    ```

    The page must read the full Phase 1 client dashboard before modifying it.
    Preserve all existing Phase 1 UI sections. Only add the interactive elements.
  </action>
  <verify>
    <automated>test -f src/components/client/ApproveButton.tsx && grep -q '"use client"' src/components/client/ApproveButton.tsx && echo "ApproveButton is Client Component"</automated>
    <automated>grep -q "router.refresh" src/components/client/ApproveButton.tsx && echo "router.refresh on approval"</automated>
    <automated>grep -q "approvedAt.*null" src/components/client/ApproveButton.tsx && echo "approved_at check present in button"</automated>
    <automated>test -f src/components/client/CommentForm.tsx && grep -q '"use client"' src/components/client/CommentForm.tsx && echo "CommentForm is Client Component"</automated>
    <automated>grep -q "api/client/comment" src/components/client/CommentForm.tsx && echo "CommentForm posts to correct route"</automated>
    <automated>test -f src/components/client/CommentList.tsx && grep -q "iamcavalli" src/components/client/CommentList.tsx && echo "admin author label present"</automated>
    <automated>grep -q "ApproveButton" src/app/c/\[token\]/page.tsx && echo "ApproveButton imported in dashboard page"</automated>
    <automated>grep -q "CommentForm" src/app/c/\[token\]/page.tsx && echo "CommentForm imported in dashboard page"</automated>
    <automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
  </verify>
  <done>
    - ApproveButton renders on deliverables with approved_at=null; shows immutable "Approvato il [date]" once set
    - CommentForm posts to /api/client/comment and calls router.refresh() on success
    - CommentList shows client comments as "Tu", admin comments as "iamcavalli"
    - Client dashboard page fetches comments server-side and renders all three components inline
    - Phase 1 existing UI is preserved — only interactive elements are added
    - npm run build passes cleanly
    - Manual verification: approve a deliverable → refreshed page shows date badge; submit comment → refreshed page shows comment in list
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Client browser → POST /api/client/approve | Unauthenticated client route; token in request body is the only credential |
| Client browser → POST /api/client/comment | Same — token in body is the only credential |
| Token → client ownership | Each API route validates token → client, then verifies entity belongs to that client via DB join |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-15 | Spoofing | /api/client/approve token validation | mitigate | Token validated server-side via DB lookup before any mutation; expired or rotated tokens return 404 |
| T-02-16 | Elevation of Privilege | Cross-client approval | mitigate | Ownership check: deliverable → task → phase → client_id match enforced via innerJoin before update; a client cannot approve another client's deliverable |
| T-02-17 | Tampering | approved_at immutability | mitigate | API route checks approved_at !== null before running UPDATE; once set, the field cannot be overwritten via this route — enforced at application layer |
| T-02-18 | Tampering | Comment injection across clients | mitigate | entity ownership verified via DB join (task/deliverable → phase → client_id) before insert; client can only comment on their own entities |
| T-02-19 | Information Disclosure | CommentList renders all comments | accept | Comments are scoped to entity_ids belonging to the validated client; server-side filtering before rendering |
| T-02-20 | Denial of Service | POST /api/client/comment body length | mitigate | Zod schema enforces max 2000 chars on body; requests exceeding this return 400 |
</threat_model>

<verification>
After plan execution:
1. `npm run build` — no errors
2. Open client dashboard at /c/[valid-token]
3. Locate a deliverable with status=pending or status=submitted → "Approva" button visible
4. Click Approva → page refreshes → button replaced with "Approvato il [date]"
5. Refresh page again → approval still shows (persisted in DB)
6. In admin workspace → CommentsTab shows the approved deliverable's state
7. Open CommentForm under a task → type a message → click Invia
8. Page refreshes → comment appears as "Tu" in the list
9. In admin workspace → CommentsTab shows the comment with author "Cliente"
10. Test invalid token: POST /api/client/approve with wrong token → 404
</verification>

<success_criteria>
- Client can approve deliverables; approved_at is set once and immutable (CLAUDE.md constraint enforced)
- Client can submit comments on tasks and deliverables
- Both API routes validate token and verify entity ownership before writing
- CommentList shows author correctly: "Tu" for client, "iamcavalli" for admin
- Phase 1 client dashboard UI is fully preserved; interactive elements are additive
- npm run build passes cleanly
</success_criteria>

<output>
After completion, create `.planning/phases/02-admin-area-interactive-features/02-04-SUMMARY.md`
</output>
