Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 |
|
|
true |
|
|
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;
}
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> |
<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>