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:
@@ -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>
|
||||
Reference in New Issue
Block a user