diff --git a/.planning/phases/02-admin-area-interactive-features/02-04-SUMMARY.md b/.planning/phases/02-admin-area-interactive-features/02-04-SUMMARY.md new file mode 100644 index 0000000..06b1620 --- /dev/null +++ b/.planning/phases/02-admin-area-interactive-features/02-04-SUMMARY.md @@ -0,0 +1,161 @@ +--- +phase: "02-admin-area-interactive-features" +plan: 04 +subsystem: "client-interactions" +tags: [api-routes, client-components, approvals, comments, immutability] +dependency_graph: + requires: ["02-03"] + provides: ["client-approval-flow", "client-comment-flow"] + affects: ["src/app/c/[token]/page.tsx", "src/components/phase-timeline.tsx"] +tech_stack: + added: [] + patterns: ["token-in-body auth", "router.refresh() for Server Component revalidation", "polymorphic entity comments"] +key_files: + created: + - src/app/api/client/approve/route.ts + - src/app/api/client/comment/route.ts + - src/components/client/ApproveButton.tsx + - src/components/client/CommentForm.tsx + - src/components/client/CommentList.tsx + modified: + - src/app/c/[token]/page.tsx + - src/components/client-dashboard.tsx + - src/components/phase-timeline.tsx +decisions: + - "approved_at immutability enforced at API layer: route checks approved_at !== null before UPDATE; no-op 200 response if already approved" + - "Client components use router.refresh() (not full page reload) to re-fetch Server Component data after mutations" + - "Comments fetched server-side in page.tsx as a single DB query across all entity IDs, passed down as prop — avoids N+1 per entity" + - "revalidate set to 0 on client page — approvals and comments must always be fresh, ISR would serve stale state" + - "PhaseTimeline extended (not replaced) to accept token + comments props — Phase 1 UI preserved, interactive elements additive" + - "ApproveButton renders on all deliverables regardless of status — shows date badge if approved_at set, Approva button otherwise" +metrics: + duration_minutes: 17 + completed_date: "2026-05-15" + tasks_completed: 2 + tasks_total: 2 + files_created: 5 + files_modified: 3 +--- + +# Phase 02 Plan 04: Client Interactions — Approvals + Comments Summary + +**One-liner:** Client-facing approval and comment API routes with token validation, ownership verification, approved_at immutability enforcement, and inline ApproveButton/CommentForm/CommentList wired into the Phase 1 dashboard via PhaseTimeline. + +## Tasks Completed + +| Task | Name | Commit | Key Files | +|------|------|--------|-----------| +| 1 | POST /api/client/approve + POST /api/client/comment | c24bdde | src/app/api/client/approve/route.ts, src/app/api/client/comment/route.ts | +| 2 | ApproveButton, CommentForm, CommentList + dashboard wiring | dc512ec | src/components/client/*, src/app/c/[token]/page.tsx, src/components/phase-timeline.tsx | + +## What Was Built + +### API Routes + +**POST /api/client/approve** (`src/app/api/client/approve/route.ts`): +- Reads `{ token, deliverableId }` from request body +- Validates token → finds clientId via `clients` table +- Verifies deliverable ownership: `deliverables → tasks → phases.client_id = clientId` (innerJoin chain prevents cross-client approval — T-02-16) +- Checks `approved_at !== null` — if already set, returns 200 no-op (T-02-17 immutability) +- Sets `status = 'approved'` and `approved_at = new Date()` atomically +- Returns 404 on invalid token or missing deliverable; 500 on unexpected error + +**POST /api/client/comment** (`src/app/api/client/comment/route.ts`): +- Reads `{ token, entity_type, entity_id, body }` from request body +- Zod schema validates: entity_type enum, body min 1 / max 2000 chars (T-02-20 DoS mitigation) +- Validates token → finds clientId +- For tasks: verifies task belongs to a phase owned by clientId +- For deliverables: verifies deliverable belongs to a task in a phase owned by clientId (T-02-18) +- Inserts comment with `author: 'client'` +- Returns 201 on success; 400 on validation failure; 404 on invalid token/entity + +### Client Components + +**ApproveButton** (`src/components/client/ApproveButton.tsx`): +- `'use client'` directive +- If `approvedAt !== null`: renders immutable green badge "Approvato il [localeDateString it-IT]" +- Otherwise: renders "Approva" button; on click POSTs to `/api/client/approve`, calls `router.refresh()` on success +- Loading state disables button; error message shown below on failure + +**CommentForm** (`src/components/client/CommentForm.tsx`): +- `'use client'` directive +- Textarea + submit button; disabled when body is empty or loading +- POSTs to `/api/client/comment` with `{ token, entity_type, entity_id, body }` +- On success: clears textarea, calls `router.refresh()` to reload comments from server + +**CommentList** (`src/components/client/CommentList.tsx`): +- Pure presentational Server Component (no `'use client'`) +- Renders nothing when empty +- Admin comments: right-aligned, dark bubble, labelled "iamcavalli" +- Client comments: left-aligned, gray bubble, labelled "Tu" + +### Dashboard Wiring + +**page.tsx** — extended to: +- Collect all task IDs and deliverable IDs from the ClientView +- Run single `db.select().from(comments).where(inArray(comments.entity_id, allEntityIds))` query +- Pass `token` and `allComments` to `` +- Changed `revalidate` from 60 (ISR) to 0 (always fresh) + +**client-dashboard.tsx** — updated `ClientDashboardProps` to include `token: string` and `comments: Comment[]`; passes both to `` + +**phase-timeline.tsx** — extended `PhaseTimelineProps` with `token` and `comments`; added `commentsFor()` helper; renders within each task: ApproveButton on each deliverable, CommentList + CommentForm below each deliverable and below each task + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Missing @radix-ui/react-tabs dependency caused build failure** +- **Found during:** Task 1 build verification +- **Issue:** `tabs.tsx` component (from Plan 02-03) imported `@radix-ui/react-tabs` which was listed in `package.json` but not installed in the worktree's node_modules +- **Fix:** Ran `npm install` in the main repo directory to install all declared dependencies +- **Files modified:** None (package install only) +- **Commit:** Resolved before Task 1 commit; no separate commit needed + +**2. [Rule 3 - Blocking] Missing .env.local in worktree caused build page-data collection error** +- **Found during:** Task 1 build verification (second attempt) +- **Issue:** `DATABASE_URL env var is required` error during page data collection; .env.local exists in main repo but not copied to worktree +- **Fix:** Copied `.env.local` from main repo to worktree root (file is gitignored) +- **Files modified:** `.env.local` (worktree only, not committed) +- **Commit:** Not committed (gitignored) + +**3. [Rule 2 - Missing prop type] ClientDashboard and PhaseTimeline needed prop signature updates** +- **Found during:** Task 2 — IDE diagnostic after updating page.tsx +- **Issue:** `ClientDashboard` and `PhaseTimeline` had no `token` or `comments` props in their interfaces — TypeScript error TS2322 +- **Fix:** Updated `ClientDashboardProps` and `PhaseTimelineProps` to include `token: string` and `comments: Comment[]`; updated function signatures and render logic accordingly +- **Files modified:** `src/components/client-dashboard.tsx`, `src/components/phase-timeline.tsx` +- **Commit:** dc512ec (included in Task 2 commit) + +## Known Stubs + +None — all components are fully wired to live API routes and server-fetched data. + +## Threat Surface Scan + +All threat mitigations from the plan's `` are implemented: + +| Threat ID | Status | Implementation | +|-----------|--------|---------------| +| T-02-15 | Mitigated | Token validated via DB lookup before any mutation in both routes | +| T-02-16 | Mitigated | innerJoin chain (deliverable → task → phase → client_id) prevents cross-client approval | +| T-02-17 | Mitigated | `approved_at !== null` check before UPDATE; no-op 200 if already approved | +| T-02-18 | Mitigated | Entity ownership verified via phase → client_id chain before comment insert | +| T-02-19 | Accepted | Comments scoped to entity_ids from validated client's view; server-side filtered | +| T-02-20 | Mitigated | Zod schema enforces `max(2000)` on comment body; returns 400 if exceeded | + +No new threat surface introduced beyond what is documented in the plan. + +## Self-Check: PASSED + +Files exist: +- src/app/api/client/approve/route.ts: FOUND +- src/app/api/client/comment/route.ts: FOUND +- src/components/client/ApproveButton.tsx: FOUND +- src/components/client/CommentForm.tsx: FOUND +- src/components/client/CommentList.tsx: FOUND + +Commits exist: +- c24bdde: FOUND (Task 1) +- dc512ec: FOUND (Task 2) + +Build: PASSED (npm run build — no TypeScript errors, all routes listed in output) \ No newline at end of file diff --git a/src/app/api/client/approve/route.ts b/src/app/api/client/approve/route.ts new file mode 100644 index 0000000..10d941b --- /dev/null +++ b/src/app/api/client/approve/route.ts @@ -0,0 +1,58 @@ +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 }); + } +} \ No newline at end of file diff --git a/src/app/api/client/comment/route.ts b/src/app/api/client/comment/route.ts new file mode 100644 index 0000000..68c7285 --- /dev/null +++ b/src/app/api/client/comment/route.ts @@ -0,0 +1,110 @@ +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 taskRows = await db + .select({ id: tasks.id }) + .from(tasks) + .where(inArray(tasks.phase_id, phaseIds)); + + const taskCheck = taskRows.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 taskRows = await db + .select({ id: tasks.id }) + .from(tasks) + .where(inArray(tasks.phase_id, phaseIds)); + + const taskIds = taskRows.map((r) => r.id); + + if (taskIds.length === 0) { + return NextResponse.json({ error: "Nessun task trovato" }, { status: 404 }); + } + + const delivRows = await db + .select({ id: deliverables.id }) + .from(deliverables) + .where(inArray(deliverables.task_id, taskIds)); + + const delivCheck = delivRows.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 }); + } +} \ No newline at end of file diff --git a/src/app/c/[token]/page.tsx b/src/app/c/[token]/page.tsx index 1133da4..545d612 100644 --- a/src/app/c/[token]/page.tsx +++ b/src/app/c/[token]/page.tsx @@ -2,8 +2,12 @@ import { cache } from 'react'; import { getClientView } from '@/lib/client-view'; import { ClientDashboard } from '@/components/client-dashboard'; import { notFound } from 'next/navigation'; +import { db } from '@/db'; +import { comments } from '@/db/schema'; +import { inArray } from 'drizzle-orm'; +import type { Comment } from '@/db/schema'; -export const revalidate = 60; // ISR: revalidate ogni 60 secondi +export const revalidate = 0; // Always revalidate — comments and approvals must be fresh // React cache deduplicates DB calls within the same render const getCachedClientView = cache(getClientView); @@ -38,5 +42,20 @@ export default async function ClientPage({ notFound(); } - return ; + // Fetch comments for all tasks and deliverables in this client's data + 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: Comment[] = + allEntityIds.length > 0 + ? await db + .select() + .from(comments) + .where(inArray(comments.entity_id, allEntityIds)) + : []; + + return ; } \ No newline at end of file diff --git a/src/components/client-dashboard.tsx b/src/components/client-dashboard.tsx index c5b7390..1605949 100644 --- a/src/components/client-dashboard.tsx +++ b/src/components/client-dashboard.tsx @@ -1,4 +1,5 @@ import type { ClientView } from '@/lib/client-view'; +import type { Comment } from '@/db/schema'; import { Progress } from '@/components/ui/progress'; import { PhaseTimeline } from './phase-timeline'; import { PaymentStatus } from './payment-status'; @@ -7,9 +8,11 @@ import { NotesSection } from './notes-section'; interface ClientDashboardProps { view: ClientView; + token: string; + comments: Comment[]; } -export function ClientDashboard({ view }: ClientDashboardProps) { +export function ClientDashboard({ view, token, comments }: ClientDashboardProps) { return (
{/* Header: logo iamcavalli (piccolo) + brand_name cliente (prominente) */} @@ -54,10 +57,10 @@ export function ClientDashboard({ view }: ClientDashboardProps) { )} - {/* Timeline fasi */} + {/* Timeline fasi — now with interactive ApproveButton + CommentForm/List */}

Fasi del Progetto

- +
{/* Stato pagamenti — sempre visibile (D-10) */} diff --git a/src/components/client/ApproveButton.tsx b/src/components/client/ApproveButton.tsx new file mode 100644 index 0000000..18f58d1 --- /dev/null +++ b/src/components/client/ApproveButton.tsx @@ -0,0 +1,68 @@ +"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(null); + + // Already approved — show immutable confirmation, no button + if (approvedAt !== null) { + const date = new Date(approvedAt).toLocaleDateString("it-IT", { + day: "2-digit", + month: "long", + year: "numeric", + }); + return ( + + Approvato il {date} + + ); + } + + 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 ( +
+ + {error &&

{error}

} +
+ ); +} \ No newline at end of file diff --git a/src/components/client/CommentForm.tsx b/src/components/client/CommentForm.tsx new file mode 100644 index 0000000..d750d50 --- /dev/null +++ b/src/components/client/CommentForm.tsx @@ -0,0 +1,67 @@ +"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(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 ( +
+