56dd18b0c2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
662 lines
27 KiB
Markdown
662 lines
27 KiB
Markdown
---
|
|
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>
|