docs(02-admin-area-interactive-features): complete phase 2 planning with 4-plan structure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simone Cavalli
2026-05-15 10:30:27 +02:00
parent 904849178d
commit 56dd18b0c2
7 changed files with 3716 additions and 6 deletions
@@ -0,0 +1,661 @@
---
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>