Files
clienthub/.planning/phases/02-admin-area-interactive-features/02-04-PLAN.md
T
2026-05-15 10:30:27 +02:00

27 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
02-admin-area-interactive-features 04 execute 4
02-03
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
true
DASH-05
DASH-06
truths artifacts key_links
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
path provides contains
src/app/api/client/approve/route.ts POST — validates client token, sets deliverable status=approved + approved_at=now() if not already approved approved_at
path provides contains
src/app/api/client/comment/route.ts POST — validates client token, inserts comment with author='client' author.*client
path provides contains
src/components/client/ApproveButton.tsx Client Component: Approva button that POSTs to /api/client/approve and refreshes the page useRouter
path provides contains
src/components/client/CommentForm.tsx Client Component: textarea + submit that POSTs to /api/client/comment api/client/comment
path provides contains
src/app/c/[token]/page.tsx Updated client dashboard wiring ApproveButton and CommentForm into deliverable/task sections ApproveButton
from to via pattern
ApproveButton POST /api/client/approve fetch('/api/client/approve', { body: JSON.stringify({ token, deliverableId }) }) api/client/approve
from to via pattern
POST /api/client/approve deliverables table db.update(deliverables).set({ status: 'approved', approved_at: new Date() }) approved_at
from to via pattern
CommentForm POST /api/client/comment fetch('/api/client/comment', { body: JSON.stringify({ token, entity_type, entity_id, body }) }) api/client/comment
from to via pattern
POST /api/client/comment comments table db.insert(comments).values({ author: 'client', ... }) 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).

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

@.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;


<!-- 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;
}
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<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.
test -f src/components/client/ApproveButton.tsx && grep -q '"use client"' src/components/client/ApproveButton.tsx && echo "ApproveButton is Client Component" grep -q "router.refresh" src/components/client/ApproveButton.tsx && echo "router.refresh on approval" grep -q "approvedAt.*null" src/components/client/ApproveButton.tsx && echo "approved_at check present in button" test -f src/components/client/CommentForm.tsx && grep -q '"use client"' src/components/client/CommentForm.tsx && echo "CommentForm is Client Component" grep -q "api/client/comment" src/components/client/CommentForm.tsx && echo "CommentForm posts to correct route" test -f src/components/client/CommentList.tsx && grep -q "iamcavalli" src/components/client/CommentList.tsx && echo "admin author label present" grep -q "ApproveButton" src/app/c/\[token\]/page.tsx && echo "ApproveButton imported in dashboard page" grep -q "CommentForm" src/app/c/\[token\]/page.tsx && echo "CommentForm imported in dashboard page" npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK" - 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

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

<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>
After completion, create `.planning/phases/02-admin-area-interactive-features/02-04-SUMMARY.md`