diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index cf6aa6d..009d0ac 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -50,9 +50,14 @@ Decimal phases appear between their surrounding integers in numeric order.
2. L'admin può creare un nuovo cliente (con generazione automatica del link segreto), aggiungere fasi, task, documenti e aggiornare lo stato dei pagamenti
3. Il cliente può approvare un deliverable dalla sua dashboard; l'approvazione persiste con timestamp immutabile e l'admin la vede
4. Il cliente può lasciare un commento su un task o deliverable e l'admin vede i commenti nella workspace admin
-**Plans**: TBD
+**Plans**: 4 plans
+**Plan list**:
+ - [ ] 02-01-PLAN.md — Auth.js v4 setup + middleware admin guard (/admin/* session check)
+ - [ ] 02-02-PLAN.md — Admin client list (/admin) + create client form + two payment stubs
+ - [ ] 02-03-PLAN.md — Admin client workspace: tabs for phases/tasks, payments, documents, comments
+ - [ ] 02-04-PLAN.md — Client interactions: deliverable approval + inline comments API + dashboard wiring
**UI hint**: yes
-**Status**: Pending planning
+**Status**: Planned — ready for execution
### Phase 3: Service Catalog & Quote Builder
**Goal**: L'admin può costruire un catalogo servizi riutilizzabile e comporre preventivi da esso; il cliente vede solo il totale accettato
@@ -88,6 +93,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4
| Phase | Plans | Status | Completed |
|-------|-------|--------|-----------|
| 1. Foundation & Client Dashboard | 5/5 | ✅ Done | 2026-05-14 |
-| 2. Admin Area & Interactive Features | 0/TBD | Planning next | - |
+| 2. Admin Area & Interactive Features | 4/4 | Planned | - |
| 3. Service Catalog & Quote Builder | 0/TBD | Pending | - |
| 4. Claude AI Onboarding (v2) | 0/TBD | Pending | - |
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 44dc487..c17a52d 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -1,3 +1,19 @@
+---
+gsd_state_version: 1.0
+milestone: v1.0
+milestone_name: milestone
+status: executing
+stopped_at: Phase 1 execution complete — all 5 plans done, E2E verified (valid token 200, invalid 404)
+last_updated: "2026-05-15T08:30:10.605Z"
+last_activity: 2026-05-15 -- Phase 02 planning complete
+progress:
+ total_phases: 4
+ completed_phases: 1
+ total_plans: 9
+ completed_plans: 5
+ percent: 56
+---
+
# Project State
## Project Reference
@@ -11,14 +27,15 @@ See: .planning/PROJECT.md (updated 2026-05-09)
Phase: 2 of 4 (Admin Area & Interactive Features)
Plan: 0 of TBD in current phase
-Status: Phase 1 complete — ready for `/gsd-plan-phase 2`
-Last activity: 2026-05-14 — Phase 1 execution complete (all 5 plans, E2E verified)
+Status: Ready to execute
+Last activity: 2026-05-15 -- Phase 02 planning complete
Progress: [██░░░░░░░░] 25%
## Performance Metrics
**Velocity:**
+
- Total plans completed: 5
- Average duration: ~1 session each
- Total execution time: ~2 sessions (May 13–14)
@@ -30,6 +47,7 @@ Progress: [██░░░░░░░░] 25%
| 1. Foundation & Client Dashboard | 5 | 2 sessions | ~0.4 sessions |
**Recent Trend:**
+
- Last 5 plans: 01-01, 01-02, 01-03, 01-04, 01-05
- Trend: Steady, one blocker fixed mid-execution (Tailwind scanning external projects)
@@ -72,4 +90,4 @@ Last session: 2026-05-14
Stopped at: Phase 1 execution complete — all 5 plans done, E2E verified (valid token 200, invalid 404)
Resume with: `/gsd-plan-phase 2` — Admin Area & Interactive Features
-
\ No newline at end of file
+
diff --git a/.planning/phases/02-admin-area-interactive-features/02-01-PLAN.md b/.planning/phases/02-admin-area-interactive-features/02-01-PLAN.md
new file mode 100644
index 0000000..24d1525
--- /dev/null
+++ b/.planning/phases/02-admin-area-interactive-features/02-01-PLAN.md
@@ -0,0 +1,481 @@
+---
+phase: "02-admin-area-interactive-features"
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - package.json
+ - src/proxy.ts
+ - src/app/api/auth/[...nextauth]/route.ts
+ - src/app/admin/login/page.tsx
+ - src/app/admin/login/actions.ts
+ - src/lib/auth.ts
+ - .env.local
+autonomous: true
+requirements:
+ - ADMIN-01
+ - ADMIN-02
+
+must_haves:
+ truths:
+ - "Admin can POST /admin/login with ADMIN_EMAIL + ADMIN_PASSWORD and receive a session JWT cookie"
+ - "Visiting /admin/* without a valid session redirects to /admin/login"
+ - "Visiting /c/[token]/* still validates token at edge (proxy.ts unchanged for client routes)"
+ - "Session is JWT-based (stateless) — no DB users table involved"
+ - "ADMIN_EMAIL and ADMIN_PASSWORD are read from env vars, never hardcoded"
+ artifacts:
+ - path: "src/lib/auth.ts"
+ provides: "NextAuth config — CredentialsProvider validating against ADMIN_EMAIL/ADMIN_PASSWORD env vars"
+ contains: "CredentialsProvider"
+ - path: "src/app/api/auth/[...nextauth]/route.ts"
+ provides: "NextAuth catch-all route handler"
+ contains: "NextAuth"
+ - path: "src/app/admin/login/page.tsx"
+ provides: "Admin login form UI (email + password, submit)"
+ min_lines: 30
+ - path: "src/proxy.ts"
+ provides: "Updated proxy: /c/* token validation + /admin/* session guard"
+ contains: "getToken"
+ key_links:
+ - from: "src/proxy.ts"
+ to: "src/app/api/auth/[...nextauth]/route.ts"
+ via: "getToken({ req, secret: process.env.NEXTAUTH_SECRET })"
+ pattern: "getToken"
+ - from: "src/app/admin/login/page.tsx"
+ to: "/api/auth/callback/credentials"
+ via: "signIn('credentials', { email, password })"
+ pattern: "signIn"
+ - from: "ADMIN_EMAIL + ADMIN_PASSWORD"
+ to: "CredentialsProvider authorize()"
+ via: "process.env.ADMIN_EMAIL"
+ pattern: "ADMIN_EMAIL"
+---
+
+
+**Auth.js Admin Session + Proxy Guard:** Install next-auth@4, configure a CredentialsProvider that validates against ADMIN_EMAIL/ADMIN_PASSWORD env vars, wire the catch-all API route, build the login page, and extend the existing src/proxy.ts to guard /admin/* routes with a session check.
+
+Purpose: Gate the entire admin area behind Auth.js JWT session before any admin UI is built. Two independent auth paths are enforced: /c/[token]/* uses edge token validation (unchanged from Phase 1), /admin/* uses getToken() from next-auth/jwt. No DB users table — single admin, env-var credentials only (per D-01, D-02, D-03, D-04).
+
+Output: Working /admin/login page, session cookie on successful login, automatic redirect to /admin/login for unauthenticated access to any /admin/* route.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/ROADMAP.md
+@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
+@.planning/phases/01-foundation-client-dashboard/01-05-SUMMARY.md
+
+
+
+
+
+
+
+Current src/proxy.ts content:
+```typescript
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function proxy(request: NextRequest) {
+ const pathname = request.nextUrl.pathname;
+
+ // Extract token from path: /c/[token]/...
+ const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
+ if (!tokenMatch) {
+ return NextResponse.rewrite(new URL('/not-found', request.url));
+ }
+
+ const token = tokenMatch[1];
+
+ try {
+ const validateUrl = new URL(
+ `/api/internal/validate-token?token=${encodeURIComponent(token)}`,
+ request.url
+ );
+ const res = await fetch(validateUrl.toString());
+
+ if (!res.ok) {
+ return NextResponse.rewrite(new URL('/not-found', request.url));
+ }
+
+ return NextResponse.next();
+ } catch {
+ return NextResponse.rewrite(new URL('/not-found', request.url));
+ }
+}
+
+export const config = {
+ matcher: ['/c/:path*'],
+};
+```
+
+Note: Next.js middleware MUST be exported as `middleware`, not `proxy`. The Phase 1 file uses `proxy` — this plan must rename it to `middleware` while extending it with /admin/* guard. No src/middleware.ts should ever be created.
+
+From src/db/index.ts:
+```typescript
+import { drizzle } from "drizzle-orm/postgres-js";
+import postgres from "postgres";
+export const db = drizzle(client);
+```
+
+From src/db/schema.ts (types needed in this plan):
+```typescript
+// No schema changes needed — no users table. Auth is env-var only.
+export type Client = typeof clients.$inferSelect;
+```
+
+
+
+
+
+
+ Task 1: Install next-auth@4, create src/lib/auth.ts and NextAuth catch-all route
+
+ package.json
+ src/lib/auth.ts
+ src/app/api/auth/[...nextauth]/route.ts
+ .env.local
+
+
+ Install next-auth v4 (stable — v5 is still beta RC as of 2026-05-15, per D-01):
+ ```
+ npm install next-auth@4
+ ```
+
+ Add to .env.local (generate NEXTAUTH_SECRET with: `openssl rand -base64 32`):
+ ```
+ NEXTAUTH_URL=http://localhost:3000
+ NEXTAUTH_SECRET=
+ ADMIN_EMAIL=simone.cavalli.gestione@gmail.com
+ ADMIN_PASSWORD=
+ ```
+
+ Create `src/lib/auth.ts` — NextAuth config, no DB adapter (per D-03):
+ ```typescript
+ import type { NextAuthOptions } from "next-auth";
+ import CredentialsProvider from "next-auth/providers/credentials";
+
+ export const authOptions: NextAuthOptions = {
+ providers: [
+ CredentialsProvider({
+ name: "credentials",
+ credentials: {
+ email: { label: "Email", type: "email" },
+ password: { label: "Password", type: "password" },
+ },
+ async authorize(credentials) {
+ if (!credentials?.email || !credentials?.password) return null;
+
+ const adminEmail = process.env.ADMIN_EMAIL;
+ const adminPassword = process.env.ADMIN_PASSWORD;
+
+ if (!adminEmail || !adminPassword) {
+ throw new Error("ADMIN_EMAIL and ADMIN_PASSWORD env vars must be set");
+ }
+
+ if (
+ credentials.email === adminEmail &&
+ credentials.password === adminPassword
+ ) {
+ // Return minimal session user — no DB lookup needed
+ return { id: "admin", email: adminEmail, name: "Admin" };
+ }
+
+ return null; // null = unauthorized (NextAuth returns 401)
+ },
+ }),
+ ],
+ session: {
+ strategy: "jwt", // stateless JWT — no DB session table (per D-03)
+ maxAge: 30 * 24 * 60 * 60, // 30 days
+ },
+ pages: {
+ signIn: "/admin/login", // custom login page (per D-07)
+ },
+ callbacks: {
+ async jwt({ token, user }) {
+ if (user) {
+ token.id = user.id;
+ }
+ return token;
+ },
+ async session({ session, token }) {
+ if (session.user) {
+ (session.user as { id?: string }).id = token.id as string;
+ }
+ return session;
+ },
+ },
+ };
+ ```
+
+ Create `src/app/api/auth/[...nextauth]/route.ts` — NextAuth catch-all:
+ ```typescript
+ import NextAuth from "next-auth";
+ import { authOptions } from "@/lib/auth";
+
+ const handler = NextAuth(authOptions);
+
+ export { handler as GET, handler as POST };
+ ```
+
+ Note: next-auth@4 with App Router uses this export pattern. The handler handles
+ GET (session fetch, CSRF) and POST (sign in, sign out).
+
+
+ grep -q '"next-auth"' package.json && echo "next-auth installed"
+ test -f src/lib/auth.ts && grep -q "CredentialsProvider" src/lib/auth.ts && echo "CredentialsProvider configured"
+ grep -q "strategy.*jwt" src/lib/auth.ts && echo "JWT session strategy set"
+ grep -q "ADMIN_EMAIL" src/lib/auth.ts && echo "ADMIN_EMAIL env var referenced"
+ test -f src/app/api/auth/\[...nextauth\]/route.ts && grep -q "NextAuth" src/app/api/auth/\[...nextauth\]/route.ts && echo "NextAuth route created"
+ grep -q "NEXTAUTH_SECRET" .env.local && echo "NEXTAUTH_SECRET in .env.local"
+ npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"
+
+
+ - next-auth@4 is in package.json
+ - src/lib/auth.ts exports authOptions with CredentialsProvider using env vars
+ - src/app/api/auth/[...nextauth]/route.ts exports GET and POST handlers
+ - NEXTAUTH_SECRET, ADMIN_EMAIL, ADMIN_PASSWORD are set in .env.local
+ - npm run build passes without errors
+
+
+
+
+ Task 2: Extend src/proxy.ts to guard /admin/* with session check; create /admin/login page
+
+ src/proxy.ts
+ src/app/admin/login/page.tsx
+ src/app/admin/login/actions.ts
+
+
+ **Replace** `src/proxy.ts` entirely. NEVER create src/middleware.ts — it would be a dead file ignored by Next.js since this project uses src/proxy.ts as the middleware entry point. The new file renames the export from `proxy` to `middleware` (required by Next.js) and adds the /admin/* guard alongside the existing /c/* token validation logic (per D-04):
+
+ ```typescript
+ import { NextRequest, NextResponse } from "next/server";
+ import { getToken } from "next-auth/jwt";
+
+ export async function middleware(request: NextRequest) {
+ const pathname = request.nextUrl.pathname;
+
+ // ── ADMIN GUARD ──────────────────────────────────────────────────────────
+ if (pathname.startsWith("/admin")) {
+ // Allow the login page and NextAuth API routes through without session check
+ if (
+ pathname === "/admin/login" ||
+ pathname.startsWith("/api/auth")
+ ) {
+ return NextResponse.next();
+ }
+
+ const token = await getToken({
+ req: request,
+ secret: process.env.NEXTAUTH_SECRET,
+ });
+
+ if (!token) {
+ const loginUrl = new URL("/admin/login", request.url);
+ loginUrl.searchParams.set("callbackUrl", pathname);
+ return NextResponse.redirect(loginUrl);
+ }
+
+ return NextResponse.next();
+ }
+
+ // ── CLIENT TOKEN GUARD ───────────────────────────────────────────────────
+ if (pathname.startsWith("/c/")) {
+ const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
+ if (!tokenMatch) {
+ return NextResponse.rewrite(new URL("/not-found", request.url));
+ }
+
+ const clientToken = tokenMatch[1];
+
+ try {
+ const validateUrl = new URL(
+ `/api/internal/validate-token?token=${encodeURIComponent(clientToken)}`,
+ request.url
+ );
+ const res = await fetch(validateUrl.toString());
+
+ if (!res.ok) {
+ return NextResponse.rewrite(new URL("/not-found", request.url));
+ }
+
+ return NextResponse.next();
+ } catch {
+ return NextResponse.rewrite(new URL("/not-found", request.url));
+ }
+ }
+
+ return NextResponse.next();
+ }
+
+ export const config = {
+ matcher: ["/admin/:path*", "/c/:path*"],
+ };
+ ```
+
+ Note: The export is renamed from `proxy` to `middleware`. This is the only correct Next.js middleware export name. The /c/* logic is preserved verbatim from Phase 1.
+
+ Create `src/app/admin/login/page.tsx` — login form as Client Component:
+ ```typescript
+ "use client";
+
+ import { useState } from "react";
+ import { signIn } from "next-auth/react";
+ import { useRouter, useSearchParams } from "next/navigation";
+ import { Button } from "@/components/ui/button";
+ import { Input } from "@/components/ui/input";
+ import { Label } from "@/components/ui/label";
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+
+ export default function AdminLoginPage() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const callbackUrl = searchParams.get("callbackUrl") ?? "/admin";
+
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setLoading(true);
+ setError(null);
+
+ const result = await signIn("credentials", {
+ email,
+ password,
+ redirect: false, // handle redirect manually to show errors
+ });
+
+ if (result?.error) {
+ setError("Email o password non corretti.");
+ setLoading(false);
+ return;
+ }
+
+ router.replace(callbackUrl);
+ }
+
+ return (
+
+
+
+ Admin — ClientHub
+
+
+
+
+
+
+ );
+ }
+ ```
+
+ Do NOT create src/app/admin/login/actions.ts — the login is handled
+ client-side via signIn(). No Server Action file is needed.
+
+
+ test -f src/proxy.ts && grep -q "getToken" src/proxy.ts && echo "getToken imported in proxy.ts"
+ grep -q "export async function middleware" src/proxy.ts && echo "export named middleware (not proxy)"
+ grep -q '"/admin/:path\*"' src/proxy.ts && echo "admin matcher configured"
+ grep -q '"/c/:path\*"' src/proxy.ts && echo "client matcher still present"
+ grep -q "pathname === \"/admin/login\"" src/proxy.ts && echo "login page exempted from auth guard"
+ test -f src/app/admin/login/page.tsx && grep -q "signIn" src/app/admin/login/page.tsx && echo "login page uses signIn"
+ grep -q '"use client"' src/app/admin/login/page.tsx && echo "login page is Client Component"
+ test ! -f src/middleware.ts && echo "src/middleware.ts does NOT exist (correct)"
+ npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"
+
+
+ - src/proxy.ts guards /admin/* routes: unauthenticated requests redirect to /admin/login?callbackUrl=...
+ - Export renamed from proxy to middleware (required by Next.js)
+ - /admin/login and /api/auth/* are exempt from the session guard
+ - /c/:path* token validation is unchanged
+ - /admin/login page renders email+password form, calls signIn('credentials'), shows error on failure, redirects on success
+ - src/middleware.ts does NOT exist
+ - npm run build passes
+ - Manual verification: visiting http://localhost:3000/admin redirects to /admin/login; successful login redirects back to /admin
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Browser → /admin/* | All admin routes gated by JWT session cookie; proxy.ts rejects unauthenticated requests before any page code runs |
+| Login form → CredentialsProvider | Email + password transmitted over HTTPS; validated in server-side authorize() only |
+| NEXTAUTH_SECRET → JWT signing | All session tokens are HMAC-signed; tampering is detectable |
+| ADMIN_EMAIL/ADMIN_PASSWORD → env vars | Credentials never in source code; must be in .env.local and Vercel environment |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-02-01 | Spoofing | Admin login | mitigate | CredentialsProvider validates against env vars server-side; password never logged; JWT signed with NEXTAUTH_SECRET |
+| T-02-02 | Tampering | JWT session cookie | mitigate | next-auth signs JWT with NEXTAUTH_SECRET (HMAC-SHA256); proxy.ts verifies signature on every /admin request via getToken() |
+| T-02-03 | Information Disclosure | ADMIN_PASSWORD in env | mitigate | Stored only in .env.local (gitignored) and Vercel environment secrets; never returned in API responses |
+| T-02-04 | Elevation of Privilege | /api/auth/* exemption | accept | NextAuth API routes are exempt from session guard by design; they perform their own CSRF and credential validation internally |
+| T-02-05 | Denial of Service | Brute-force login | accept | Single admin, not a public product; no rate limiting in v1. If needed in v2, add next-auth rate limit middleware. |
+
+
+
+After plan execution:
+1. `npm run build` — no TypeScript errors
+2. `npm run dev`, visit http://localhost:3000/admin → redirects to /admin/login
+3. Submit wrong credentials → error message "Email o password non corretti." appears
+4. Submit correct ADMIN_EMAIL + ADMIN_PASSWORD → redirects to /admin (200, even if page is blank)
+5. Visit http://localhost:3000/c/any-token → still validates token (client path unchanged)
+6. Visit http://localhost:3000/api/auth/session after login → returns `{ user: { email, id: "admin" } }`
+7. Confirm src/middleware.ts does not exist in the repo
+
+
+
+- Admin can log in at /admin/login with env-var credentials and receive a JWT session cookie
+- All /admin/* routes (except /admin/login and /api/auth/*) redirect unauthenticated visitors to /admin/login
+- Client token route /c/:path* is unaffected
+- No DB users table exists or is needed
+- src/proxy.ts is the middleware file — src/middleware.ts never created
+- npm run build passes cleanly
+
+
+
+After completion, create `.planning/phases/02-admin-area-interactive-features/02-01-SUMMARY.md`
+
diff --git a/.planning/phases/02-admin-area-interactive-features/02-02-PLAN.md b/.planning/phases/02-admin-area-interactive-features/02-02-PLAN.md
new file mode 100644
index 0000000..3579884
--- /dev/null
+++ b/.planning/phases/02-admin-area-interactive-features/02-02-PLAN.md
@@ -0,0 +1,561 @@
+---
+phase: "02-admin-area-interactive-features"
+plan: 02
+type: execute
+wave: 2
+depends_on:
+ - "02-01"
+files_modified:
+ - src/app/admin/page.tsx
+ - src/app/admin/layout.tsx
+ - src/app/admin/clients/new/page.tsx
+ - src/app/admin/clients/new/actions.ts
+ - src/lib/admin-queries.ts
+ - src/components/admin/ClientRow.tsx
+ - src/components/admin/NavBar.tsx
+autonomous: true
+requirements:
+ - ADMIN-01
+ - ADMIN-02
+
+must_haves:
+ truths:
+ - "Admin can see a list of all clients at /admin with name, brand, and payment status badges"
+ - "Admin can create a new client via /admin/clients/new form; on submit the client row + two payment rows are inserted and the secret link (token) is auto-generated"
+ - "After creating a client, admin is redirected to /admin (or /admin/clients/[id] for detail)"
+ - "The new client's shareable link /c/[token] is visible to the admin immediately after creation"
+ - "Payment status badges for Acconto and Saldo are visible in the client list row"
+ artifacts:
+ - path: "src/app/admin/page.tsx"
+ provides: "Admin client list — Server Component fetching all clients with payments"
+ contains: "export default async function"
+ - path: "src/app/admin/layout.tsx"
+ provides: "Admin layout with minimal NavBar (logo + Clienti link + logout button)"
+ contains: "NavBar"
+ - path: "src/app/admin/clients/new/page.tsx"
+ provides: "New client form page"
+ min_lines: 30
+ - path: "src/app/admin/clients/new/actions.ts"
+ provides: "Server Action: createClient() — inserts client + 2 payment rows"
+ contains: "createClient"
+ - path: "src/lib/admin-queries.ts"
+ provides: "Admin-side DB query functions (getAllClientsWithPayments)"
+ contains: "getAllClientsWithPayments"
+ key_links:
+ - from: "src/app/admin/page.tsx"
+ to: "src/lib/admin-queries.ts"
+ via: "getAllClientsWithPayments()"
+ pattern: "getAllClientsWithPayments"
+ - from: "src/app/admin/clients/new/page.tsx"
+ to: "src/app/admin/clients/new/actions.ts"
+ via: "createClient Server Action"
+ pattern: "createClient"
+ - from: "createClient action"
+ to: "clients + payments tables"
+ via: "db.insert(clients) + db.insert(payments) x2"
+ pattern: "db.insert"
+---
+
+
+**Admin Client List + Create Client:** Build the admin home page (client list with payment badges) and the new client creation form. The create form auto-generates the nanoid secret token, inserts the client row, and creates two payment rows (Acconto 50% / Saldo 50%) in a single Server Action.
+
+Purpose: Deliver the first end-to-end admin capability — admin can enter a client's details and immediately get a shareable /c/[token] link. Implements ADMIN-01 (client list with status) and the creation half of ADMIN-02 (per D-05 Server Actions, D-07 list→detail layout, D-09 minimal nav).
+
+Output: /admin shows all clients with payment badges; /admin/clients/new creates a client and two payment stubs.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/ROADMAP.md
+@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
+@.planning/phases/02-admin-area-interactive-features/02-01-SUMMARY.md
+
+
+
+```typescript
+export const clients = pgTable("clients", {
+ id: text("id").primaryKey().$defaultFn(() => nanoid()),
+ name: text("name").notNull(),
+ brand_name: text("brand_name").notNull(),
+ brief: text("brief").notNull(),
+ token: text("token").notNull().unique().$defaultFn(() => nanoid()),
+ accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default("0"),
+ created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+});
+
+export const payments = pgTable("payments", {
+ id: text("id").primaryKey().$defaultFn(() => nanoid()),
+ client_id: text("client_id").notNull().references(() => clients.id, { onDelete: "cascade" }),
+ label: text("label").notNull(), // "Acconto 50%" | "Saldo 50%"
+ amount: numeric("amount", { precision: 10, scale: 2 }).notNull(),
+ status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato
+ paid_at: timestamp("paid_at", { withTimezone: true }),
+});
+
+export type Client = typeof clients.$inferSelect;
+export type NewClient = typeof clients.$inferInsert;
+export type Payment = typeof payments.$inferSelect;
+```
+
+From src/db/index.ts:
+```typescript
+export const db = drizzle(client); // drizzle-orm/postgres-js
+```
+
+
+
+
+
+
+ Task 1: Create src/lib/admin-queries.ts and admin layout + NavBar component
+
+ src/lib/admin-queries.ts
+ src/app/admin/layout.tsx
+ src/components/admin/NavBar.tsx
+
+
+ Create `src/lib/admin-queries.ts` — all admin-side DB reads live here:
+ ```typescript
+ import { db } from "@/db";
+ import { clients, payments } from "@/db/schema";
+ import { eq } from "drizzle-orm";
+
+ export type ClientWithPayments = {
+ id: string;
+ name: string;
+ brand_name: string;
+ token: string;
+ accepted_total: string;
+ created_at: Date;
+ payments: Array<{
+ id: string;
+ label: string;
+ status: string;
+ amount: string;
+ }>;
+ };
+
+ export async function getAllClientsWithPayments(): Promise {
+ const allClients = await db
+ .select()
+ .from(clients)
+ .orderBy(clients.created_at);
+
+ if (allClients.length === 0) return [];
+
+ const allPayments = await db
+ .select()
+ .from(payments);
+
+ return allClients.map((c) => ({
+ id: c.id,
+ name: c.name,
+ brand_name: c.brand_name,
+ token: c.token,
+ accepted_total: c.accepted_total ?? "0",
+ created_at: c.created_at,
+ payments: allPayments
+ .filter((p) => p.client_id === c.id)
+ .map((p) => ({
+ id: p.id,
+ label: p.label,
+ status: p.status,
+ amount: p.amount,
+ })),
+ }));
+ }
+
+ export async function getClientById(id: string) {
+ const rows = await db
+ .select()
+ .from(clients)
+ .where(eq(clients.id, id))
+ .limit(1);
+ return rows[0] ?? null;
+ }
+ ```
+
+ Create `src/components/admin/NavBar.tsx` — minimal nav per D-09 (no sidebar):
+ ```typescript
+ "use client";
+
+ import Link from "next/link";
+ import { signOut } from "next-auth/react";
+ import { Button } from "@/components/ui/button";
+
+ export function NavBar() {
+ return (
+
+
+ ClientHub
+
+ Clienti
+
+
+ signOut({ callbackUrl: "/admin/login" })}
+ className="text-sm text-gray-500"
+ >
+ Esci
+
+
+ );
+ }
+ ```
+
+ Create `src/app/admin/layout.tsx` — wraps all /admin/* pages:
+ ```typescript
+ import { NavBar } from "@/components/admin/NavBar";
+
+ export default function AdminLayout({
+ children,
+ }: {
+ children: React.ReactNode;
+ }) {
+ return (
+
+
+ {children}
+
+ );
+ }
+ ```
+
+
+ test -f src/lib/admin-queries.ts && grep -q "getAllClientsWithPayments" src/lib/admin-queries.ts && echo "admin-queries.ts created"
+ grep -q "getClientById" src/lib/admin-queries.ts && echo "getClientById exported"
+ test -f src/components/admin/NavBar.tsx && grep -q "signOut" src/components/admin/NavBar.tsx && echo "NavBar with logout"
+ test -f src/app/admin/layout.tsx && grep -q "NavBar" src/app/admin/layout.tsx && echo "Admin layout wraps NavBar"
+ npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"
+
+
+ - src/lib/admin-queries.ts exports getAllClientsWithPayments() and getClientById()
+ - NavBar renders with "Clienti" link and "Esci" button
+ - Admin layout wraps all /admin/* pages with NavBar + centered main content area
+ - npm run build passes
+
+
+
+
+ Task 2: Build /admin client list page and /admin/clients/new create-client flow
+
+ src/app/admin/page.tsx
+ src/components/admin/ClientRow.tsx
+ src/app/admin/clients/new/page.tsx
+ src/app/admin/clients/new/actions.ts
+
+
+ Create `src/components/admin/ClientRow.tsx` — single row in client list table:
+ ```typescript
+ import Link from "next/link";
+ import { Badge } from "@/components/ui/badge";
+ import type { ClientWithPayments } from "@/lib/admin-queries";
+
+ const statusConfig: Record = {
+ da_saldare: { label: "Da saldare", variant: "destructive" },
+ inviata: { label: "Inviata", variant: "secondary" },
+ saldato: { label: "Saldato", variant: "default" },
+ };
+
+ export function ClientRow({ client }: { client: ClientWithPayments }) {
+ const acconto = client.payments.find((p) => p.label.includes("Acconto"));
+ const saldo = client.payments.find((p) => p.label.includes("Saldo"));
+
+ return (
+
+
+
+ {client.name}
+
+ {client.brand_name}
+
+
+ € {parseFloat(client.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
+
+
+ {acconto && (
+
+ Acconto: {statusConfig[acconto.status]?.label ?? acconto.status}
+
+ )}
+
+
+ {saldo && (
+
+ Saldo: {statusConfig[saldo.status]?.label ?? saldo.status}
+
+ )}
+
+
+
+ /c/{client.token.slice(0, 10)}…
+
+
+
+ );
+ }
+ ```
+
+ Create `src/app/admin/page.tsx` — Server Component, no client state:
+ ```typescript
+ import Link from "next/link";
+ import { getAllClientsWithPayments } from "@/lib/admin-queries";
+ import { ClientRow } from "@/components/admin/ClientRow";
+ import { Button } from "@/components/ui/button";
+
+ export const revalidate = 0; // always fresh — admin needs real-time data
+
+ export default async function AdminDashboard() {
+ const clients = await getAllClientsWithPayments();
+
+ return (
+
+
+
Clienti
+
+ + Nuovo cliente
+
+
+
+ {clients.length === 0 ? (
+
+
Nessun cliente ancora.
+
+
+ Crea il primo cliente
+
+
+
+ ) : (
+
+
+
+
+ Cliente
+ Totale
+ Acconto
+ Saldo
+ Link
+
+
+
+ {clients.map((client) => (
+
+ ))}
+
+
+
+ )}
+
+ );
+ }
+ ```
+
+ Create `src/app/admin/clients/new/actions.ts` — Server Action (per D-05):
+ ```typescript
+ "use server";
+
+ import { redirect } from "next/navigation";
+ import { revalidatePath } from "next/cache";
+ import { z } from "zod";
+ import { db } from "@/db";
+ import { clients, payments } from "@/db/schema";
+
+ const createClientSchema = z.object({
+ name: z.string().min(1, "Nome richiesto"),
+ brand_name: z.string().min(1, "Nome brand richiesto"),
+ brief: z.string().min(1, "Brief richiesto"),
+ });
+
+ export async function createClient(formData: FormData) {
+ const raw = {
+ name: formData.get("name") as string,
+ brand_name: formData.get("brand_name") as string,
+ brief: formData.get("brief") as string,
+ };
+
+ const parsed = createClientSchema.safeParse(raw);
+ if (!parsed.success) {
+ // In v1 return errors as thrown string — form displays validation inline
+ throw new Error(parsed.error.issues.map((i) => i.message).join(", "));
+ }
+
+ // Insert client — token and id are auto-generated by $defaultFn(() => nanoid())
+ const [newClient] = await db
+ .insert(clients)
+ .values({
+ name: parsed.data.name,
+ brand_name: parsed.data.brand_name,
+ brief: parsed.data.brief,
+ })
+ .returning({ id: clients.id, token: clients.token });
+
+ // Always create two payment stubs per client — Acconto 50% and Saldo 50%
+ // Amounts default to 0 until admin sets accepted_total; admin updates separately
+ await db.insert(payments).values([
+ {
+ client_id: newClient.id,
+ label: "Acconto 50%",
+ amount: "0",
+ status: "da_saldare",
+ },
+ {
+ client_id: newClient.id,
+ label: "Saldo 50%",
+ amount: "0",
+ status: "da_saldare",
+ },
+ ]);
+
+ revalidatePath("/admin");
+ redirect(`/admin/clients/${newClient.id}`);
+ }
+ ```
+
+ Create `src/app/admin/clients/new/page.tsx` — form using the Server Action:
+ ```typescript
+ import { createClient } from "./actions";
+ import { Button } from "@/components/ui/button";
+ import { Input } from "@/components/ui/input";
+ import { Label } from "@/components/ui/label";
+ import { Textarea } from "@/components/ui/textarea";
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+ import Link from "next/link";
+
+ export default function NewClientPage() {
+ return (
+
+
+
+ ← Clienti
+
+
+
+
+ Nuovo cliente
+
+
+
+
+
+
+ );
+ }
+ ```
+
+
+ test -f src/app/admin/page.tsx && grep -q "getAllClientsWithPayments" src/app/admin/page.tsx && echo "Admin page fetches clients"
+ grep -q "ClientRow" src/app/admin/page.tsx && echo "ClientRow used in table"
+ test -f src/app/admin/clients/new/actions.ts && grep -q '"use server"' src/app/admin/clients/new/actions.ts && echo "Server Action directive present"
+ grep -q "db.insert(clients)" src/app/admin/clients/new/actions.ts && echo "client insert present"
+ grep -q "Acconto 50%" src/app/admin/clients/new/actions.ts && grep -q "Saldo 50%" src/app/admin/clients/new/actions.ts && echo "both payment stubs inserted"
+ grep -q "createClientSchema" src/app/admin/clients/new/actions.ts && echo "Zod validation present"
+ test -f src/app/admin/clients/new/page.tsx && grep -q "action={createClient}" src/app/admin/clients/new/page.tsx && echo "form wired to Server Action"
+ npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"
+
+
+ - /admin shows table of clients with name, brand, totale, acconto badge, saldo badge, client link
+ - Empty state shows "Nessun cliente ancora" with link to create
+ - /admin/clients/new shows form with name, brand_name, brief fields
+ - Submitting the form inserts client row (token auto-generated by nanoid) + 2 payment stubs
+ - After creation, admin is redirected to /admin/clients/[id] (detail page — stub until Plan 03)
+ - npm run build passes
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Admin browser → Server Action | createClient() runs server-side; input validated with Zod before any DB write |
+| Admin browser → /admin/* | Middleware session guard (02-01) prevents unauthenticated access to all admin pages and Server Actions |
+| Client token → DB | Token generated server-side by nanoid(), never user-supplied; cannot be guessed |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-02-06 | Tampering | createClient Server Action | mitigate | Zod validates all input fields before DB insert; malformed input throws, no partial writes |
+| T-02-07 | Information Disclosure | Client list page | mitigate | /admin/* protected by middleware session guard from 02-01; unauthenticated requests never reach this Server Component |
+| T-02-08 | Tampering | token generation | mitigate | token is $defaultFn(() => nanoid()) — server-generated, cryptographically random, never derived from user input |
+| T-02-09 | Information Disclosure | ClientRow renders full token | accept | Token is shown truncated in UI for usability; full token accessible via /c/[token] link only — acceptable since admin has session auth |
+
+
+
+After plan execution:
+1. `npm run build` — no errors
+2. Log in as admin, visit /admin — client table renders (empty or with seeded data)
+3. Click "+ Nuovo cliente" → /admin/clients/new loads with form
+4. Submit form with valid data → redirects to /admin/clients/[id] (stub page acceptable at this point)
+5. Return to /admin → new client appears in table with "Da saldare" badges for Acconto and Saldo
+6. Click the /c/[token] link in the table → opens client dashboard (Phase 1 output)
+
+
+
+- /admin shows all clients with payment status badges; empty state handled gracefully
+- New client can be created via form; token is auto-generated server-side
+- Two payment stubs (Acconto 50% / Saldo 50%) are created automatically on client creation
+- Admin can immediately share /c/[token] after creating a client
+- npm run build passes cleanly
+
+
+
+After completion, create `.planning/phases/02-admin-area-interactive-features/02-02-SUMMARY.md`
+
diff --git a/.planning/phases/02-admin-area-interactive-features/02-03-PLAN.md b/.planning/phases/02-admin-area-interactive-features/02-03-PLAN.md
new file mode 100644
index 0000000..9dad406
--- /dev/null
+++ b/.planning/phases/02-admin-area-interactive-features/02-03-PLAN.md
@@ -0,0 +1,825 @@
+---
+phase: "02-admin-area-interactive-features"
+plan: 03
+type: execute
+wave: 3
+depends_on:
+ - "02-02"
+files_modified:
+ - src/app/admin/clients/[id]/page.tsx
+ - src/app/admin/clients/[id]/actions.ts
+ - src/components/admin/tabs/PhasesTab.tsx
+ - src/components/admin/tabs/PaymentsTab.tsx
+ - src/components/admin/tabs/DocumentsTab.tsx
+ - src/components/admin/tabs/CommentsTab.tsx
+ - src/lib/admin-queries.ts
+autonomous: true
+requirements:
+ - ADMIN-02
+
+must_haves:
+ truths:
+ - "Admin can open /admin/clients/[id] and see all client data in tabs: Panoramica, Fasi & Task, Documenti, Pagamenti, Commenti"
+ - "Admin can add a phase to a client, add a task to a phase, and change task status — all via Server Actions"
+ - "Admin can add a document (label + URL) and delete it"
+ - "Admin can change the payment status (da_saldare / inviata / saldato) and update the accepted_total on the client row"
+ - "Admin can see all comments left by the client (read-only in this tab) and post a reply as 'admin'"
+ artifacts:
+ - path: "src/app/admin/clients/[id]/page.tsx"
+ provides: "Client workspace with tabbed layout using @radix-ui/react-tabs"
+ contains: "Tabs"
+ - path: "src/app/admin/clients/[id]/actions.ts"
+ provides: "Server Actions: addPhase, addTask, updateTaskStatus, addDocument, deleteDocument, updatePaymentStatus, updateAcceptedTotal, postAdminComment"
+ contains: "addPhase"
+ - path: "src/components/admin/tabs/PhasesTab.tsx"
+ provides: "Fasi & Task tab — list phases with tasks, add-phase form, add-task form, task status selector"
+ min_lines: 60
+ - path: "src/components/admin/tabs/PaymentsTab.tsx"
+ provides: "Pagamenti tab — accepted_total field + two payment rows with status selects"
+ min_lines: 40
+ key_links:
+ - from: "src/app/admin/clients/[id]/page.tsx"
+ to: "src/lib/admin-queries.ts"
+ via: "getClientFullDetail(id)"
+ pattern: "getClientFullDetail"
+ - from: "PhasesTab, PaymentsTab, DocumentsTab"
+ to: "src/app/admin/clients/[id]/actions.ts"
+ via: "Server Actions bound to form action={}"
+ pattern: "action={"
+ - from: "updatePaymentStatus / updateAcceptedTotal"
+ to: "payments / clients tables"
+ via: "db.update().set().where()"
+ pattern: "db.update"
+---
+
+
+**Admin Client Workspace (tabs):** Build the full /admin/clients/[id] detail page with Radix Tabs. Each tab covers one concern: Panoramica (overview), Fasi & Task (add phases/tasks, update status), Documenti (add/delete document links), Pagamenti (update payment status + accepted_total), Commenti (read client comments, post admin reply). All mutations use Server Actions (per D-05). Tabs use @radix-ui/react-tabs + shadcn tabs component (per D-08).
+
+Purpose: Deliver ADMIN-02 — complete management of every client's data from a single authenticated workspace.
+
+Output: Admin can fully manage a client's project lifecycle without leaving the detail page.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
+@.planning/phases/02-admin-area-interactive-features/02-02-SUMMARY.md
+
+
+
+```typescript
+export type Client = typeof clients.$inferSelect;
+export type Phase = typeof phases.$inferSelect;
+export type Task = typeof tasks.$inferSelect;
+export type Deliverable = typeof deliverables.$inferSelect;
+export type Comment = typeof comments.$inferSelect;
+export type Payment = typeof payments.$inferSelect;
+export type Document = typeof documents.$inferSelect;
+export type Note = typeof notes.$inferSelect;
+
+// phases columns: id, client_id, title, sort_order, status (upcoming|active|done)
+// tasks columns: id, phase_id, title, description, status (todo|in_progress|done), sort_order
+// comments columns: id, entity_type (task|deliverable), entity_id, author (client|admin), body, created_at
+// payments columns: id, client_id, label, amount, status (da_saldare|inviata|saldato), paid_at
+// documents columns: id, client_id, label, url, created_at
+```
+
+
+```typescript
+export async function getClientById(id: string): Promise;
+```
+
+
+
+
+
+
+
+
+
+ Task 1: Install @radix-ui/react-tabs + shadcn tabs; add getClientFullDetail() to admin-queries; create Server Actions
+
+ package.json
+ src/components/ui/tabs.tsx
+ src/lib/admin-queries.ts
+ src/app/admin/clients/[id]/actions.ts
+
+
+ Install Radix tabs and add shadcn tabs component (per D-08):
+ ```
+ npx shadcn@latest add tabs
+ ```
+ This installs @radix-ui/react-tabs and creates src/components/ui/tabs.tsx.
+
+ Extend `src/lib/admin-queries.ts` — add getClientFullDetail() below existing functions.
+ Read the current file first to append without overwriting.
+
+ Add this function:
+ ```typescript
+ import { clients, phases, tasks, deliverables, comments, payments, documents, notes } from "@/db/schema";
+ import { eq, inArray, asc } from "drizzle-orm";
+
+ export type ClientFullDetail = {
+ client: Client;
+ phases: Array }>;
+ payments: Payment[];
+ documents: Document[];
+ notes: Note[];
+ comments: Comment[];
+ };
+
+ export async function getClientFullDetail(id: string): Promise {
+ const clientRows = await db.select().from(clients).where(eq(clients.id, id)).limit(1);
+ if (clientRows.length === 0) return null;
+ const client = clientRows[0];
+
+ const phasesRows = await db
+ .select()
+ .from(phases)
+ .where(eq(phases.client_id, id))
+ .orderBy(asc(phases.sort_order));
+
+ const phaseIds = phasesRows.map((p) => p.id);
+
+ const tasksRows = phaseIds.length === 0
+ ? []
+ : await db.select().from(tasks).where(inArray(tasks.phase_id, phaseIds)).orderBy(asc(tasks.sort_order));
+
+ const taskIds = tasksRows.map((t) => t.id);
+
+ const deliverablesRows = taskIds.length === 0
+ ? []
+ : await db.select().from(deliverables).where(inArray(deliverables.task_id, taskIds));
+
+ const paymentsRows = await db.select().from(payments).where(eq(payments.client_id, id));
+ const documentsRows = await db.select().from(documents).where(eq(documents.client_id, id)).orderBy(asc(documents.created_at));
+ const notesRows = await db.select().from(notes).where(eq(notes.client_id, id)).orderBy(asc(notes.created_at));
+
+ // Fetch all comments for this client's tasks and deliverables
+ const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)];
+ const commentsRows = allEntityIds.length === 0
+ ? []
+ : await db
+ .select()
+ .from(comments)
+ .where(inArray(comments.entity_id, allEntityIds))
+ .orderBy(asc(comments.created_at));
+
+ const phasesWithTasks = phasesRows.map((phase) => {
+ const phaseTasks = tasksRows
+ .filter((t) => t.phase_id === phase.id)
+ .map((task) => ({
+ ...task,
+ deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
+ }));
+ return { ...phase, tasks: phaseTasks };
+ });
+
+ return {
+ client,
+ phases: phasesWithTasks,
+ payments: paymentsRows,
+ documents: documentsRows,
+ notes: notesRows,
+ comments: commentsRows,
+ };
+ }
+ ```
+
+ Create `src/app/admin/clients/[id]/actions.ts` — all mutations for the workspace:
+ ```typescript
+ "use server";
+
+ import { revalidatePath } from "next/cache";
+ import { db } from "@/db";
+ import { phases, tasks, deliverables, documents, payments, clients, comments } from "@/db/schema";
+ import { eq } from "drizzle-orm";
+ import { z } from "zod";
+
+ // ── PHASES ────────────────────────────────────────────────────────────────
+
+ export async function addPhase(clientId: string, formData: FormData) {
+ const title = (formData.get("title") as string)?.trim();
+ if (!title) throw new Error("Titolo fase richiesto");
+
+ // Determine next sort_order
+ const existingPhases = await db.select({ sort_order: phases.sort_order })
+ .from(phases).where(eq(phases.client_id, clientId));
+ const maxOrder = existingPhases.reduce((max, p) => Math.max(max, p.sort_order), -1);
+
+ await db.insert(phases).values({
+ client_id: clientId,
+ title,
+ sort_order: maxOrder + 1,
+ status: "upcoming",
+ });
+ revalidatePath(`/admin/clients/${clientId}`);
+ }
+
+ export async function updatePhaseStatus(phaseId: string, clientId: string, status: string) {
+ const allowed = ["upcoming", "active", "done"];
+ if (!allowed.includes(status)) throw new Error("Stato non valido");
+ await db.update(phases).set({ status }).where(eq(phases.id, phaseId));
+ revalidatePath(`/admin/clients/${clientId}`);
+ }
+
+ // ── TASKS ─────────────────────────────────────────────────────────────────
+
+ export async function addTask(phaseId: string, clientId: string, formData: FormData) {
+ const title = (formData.get("title") as string)?.trim();
+ if (!title) throw new Error("Titolo task richiesto");
+
+ const existingTasks = await db.select({ sort_order: tasks.sort_order })
+ .from(tasks).where(eq(tasks.phase_id, phaseId));
+ const maxOrder = existingTasks.reduce((max, t) => Math.max(max, t.sort_order), -1);
+
+ await db.insert(tasks).values({
+ phase_id: phaseId,
+ title,
+ description: (formData.get("description") as string)?.trim() || null,
+ sort_order: maxOrder + 1,
+ status: "todo",
+ });
+ revalidatePath(`/admin/clients/${clientId}`);
+ }
+
+ export async function updateTaskStatus(taskId: string, clientId: string, status: string) {
+ const allowed = ["todo", "in_progress", "done"];
+ if (!allowed.includes(status)) throw new Error("Stato non valido");
+ await db.update(tasks).set({ status }).where(eq(tasks.id, taskId));
+ revalidatePath(`/admin/clients/${clientId}`);
+ }
+
+ // ── DELIVERABLES ──────────────────────────────────────────────────────────
+
+ export async function addDeliverable(taskId: string, clientId: string, formData: FormData) {
+ const title = (formData.get("title") as string)?.trim();
+ const url = (formData.get("url") as string)?.trim() || null;
+ if (!title) throw new Error("Titolo deliverable richiesto");
+ await db.insert(deliverables).values({ task_id: taskId, title, url, status: "pending" });
+ revalidatePath(`/admin/clients/${clientId}`);
+ }
+
+ // ── DOCUMENTS ─────────────────────────────────────────────────────────────
+
+ const docSchema = z.object({
+ label: z.string().min(1),
+ url: z.string().url("URL non valido"),
+ });
+
+ export async function addDocument(clientId: string, formData: FormData) {
+ const parsed = docSchema.safeParse({
+ label: formData.get("label"),
+ url: formData.get("url"),
+ });
+ if (!parsed.success) throw new Error(parsed.error.issues[0].message);
+ await db.insert(documents).values({ client_id: clientId, ...parsed.data });
+ revalidatePath(`/admin/clients/${clientId}`);
+ }
+
+ export async function deleteDocument(documentId: string, clientId: string) {
+ await db.delete(documents).where(eq(documents.id, documentId));
+ revalidatePath(`/admin/clients/${clientId}`);
+ }
+
+ // ── PAYMENTS ──────────────────────────────────────────────────────────────
+
+ export async function updatePaymentStatus(paymentId: string, clientId: string, status: string) {
+ const allowed = ["da_saldare", "inviata", "saldato"];
+ if (!allowed.includes(status)) throw new Error("Stato pagamento non valido");
+ const paid_at = status === "saldato" ? new Date() : null;
+ await db.update(payments).set({ status, paid_at }).where(eq(payments.id, paymentId));
+ revalidatePath(`/admin/clients/${clientId}`);
+ }
+
+ export async function updateAcceptedTotal(clientId: string, formData: FormData) {
+ const raw = (formData.get("accepted_total") as string)?.trim();
+ const val = parseFloat(raw);
+ if (isNaN(val) || val < 0) throw new Error("Importo non valido");
+ // Update accepted_total on client row
+ await db.update(clients).set({ accepted_total: raw }).where(eq(clients.id, clientId));
+ // Update payment amounts to 50% each
+ const half = (val / 2).toFixed(2);
+ const paymentsRows = await db.select().from(payments).where(eq(payments.client_id, clientId));
+ for (const p of paymentsRows) {
+ await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id));
+ }
+ revalidatePath(`/admin/clients/${clientId}`);
+ }
+
+ // ── COMMENTS (admin reply) ────────────────────────────────────────────────
+
+ export async function postAdminComment(clientId: string, formData: FormData) {
+ const entity = formData.get("entity") as string;
+ const body = (formData.get("body") as string)?.trim();
+ if (!body || !entity) throw new Error("Dati mancanti");
+ const [entity_type, entity_id] = entity.split(":");
+ if (!entity_type || !entity_id) throw new Error("Formato entity non valido");
+ if (!["task", "deliverable"].includes(entity_type)) throw new Error("entity_type non valido");
+ await db.insert(comments).values({ entity_type, entity_id, author: "admin", body });
+ revalidatePath(`/admin/clients/${clientId}`);
+ }
+ ```
+
+
+ test -f src/components/ui/tabs.tsx && echo "shadcn tabs component installed"
+ grep -q "getClientFullDetail" src/lib/admin-queries.ts && echo "getClientFullDetail added to admin-queries"
+ test -f src/app/admin/clients/\[id\]/actions.ts && grep -q '"use server"' src/app/admin/clients/\[id\]/actions.ts && echo "actions.ts is Server Action file"
+ grep -q "addPhase\|addTask\|updatePaymentStatus\|updateAcceptedTotal\|postAdminComment" src/app/admin/clients/\[id\]/actions.ts && echo "all major actions present"
+ grep -q "revalidatePath" src/app/admin/clients/\[id\]/actions.ts && echo "revalidatePath called in actions"
+ npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"
+
+
+ - src/components/ui/tabs.tsx exists (shadcn tabs installed)
+ - getClientFullDetail(id) added to admin-queries.ts, returns all nested client data
+ - actions.ts contains addPhase, addTask, updateTaskStatus, addDeliverable, addDocument, deleteDocument, updatePaymentStatus, updateAcceptedTotal, postAdminComment — all with revalidatePath
+ - npm run build passes
+
+
+
+
+ Task 2: Build /admin/clients/[id] detail page with all four tab components
+
+ src/app/admin/clients/[id]/page.tsx
+ src/components/admin/tabs/PhasesTab.tsx
+ src/components/admin/tabs/PaymentsTab.tsx
+ src/components/admin/tabs/DocumentsTab.tsx
+ src/components/admin/tabs/CommentsTab.tsx
+
+
+ Create `src/app/admin/clients/[id]/page.tsx` — Server Component, tab container:
+ ```typescript
+ import { notFound } from "next/navigation";
+ import { getClientFullDetail } from "@/lib/admin-queries";
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+ import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
+ import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
+ import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
+ import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
+ import Link from "next/link";
+
+ export const revalidate = 0;
+
+ export default async function ClientDetailPage({
+ params,
+ }: {
+ params: { id: string };
+ }) {
+ const detail = await getClientFullDetail(params.id);
+ if (!detail) notFound();
+
+ const { client, phases, payments, documents, notes, comments } = detail;
+
+ return (
+
+
+
+ ← Clienti
+
+
+
+
+
+
+ Fasi & Task
+ Pagamenti
+ Documenti
+ Commenti
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+ ```
+
+ Create `src/components/admin/tabs/PhasesTab.tsx`:
+ ```typescript
+ import { addPhase, addTask, updateTaskStatus, updatePhaseStatus } from "@/app/admin/clients/[id]/actions";
+ import { Badge } from "@/components/ui/badge";
+ import { Button } from "@/components/ui/button";
+ import { Input } from "@/components/ui/input";
+ import type { ClientFullDetail } from "@/lib/admin-queries";
+
+ type Props = {
+ phases: ClientFullDetail["phases"];
+ clientId: string;
+ };
+
+ const taskStatusOptions = [
+ { value: "todo", label: "Da fare" },
+ { value: "in_progress", label: "In corso" },
+ { value: "done", label: "Fatto" },
+ ];
+
+ const phaseStatusOptions = [
+ { value: "upcoming", label: "In arrivo" },
+ { value: "active", label: "Attiva" },
+ { value: "done", label: "Completata" },
+ ];
+
+ export function PhasesTab({ phases, clientId }: Props) {
+ return (
+
+ {/* Add phase form */}
+
{ "use server"; await addPhase(clientId, fd); }}
+ className="flex gap-2"
+ >
+
+ + Fase
+
+
+ {/* Phases list */}
+ {phases.length === 0 && (
+
Nessuna fase ancora.
+ )}
+ {phases.map((phase) => (
+
+
+
{phase.title}
+ {
+ "use server";
+ await updatePhaseStatus(phase.id, clientId, fd.get("status") as string);
+ }}
+ className="flex items-center gap-2"
+ >
+
+ {phaseStatusOptions.map((o) => (
+ {o.label}
+ ))}
+
+ Salva
+
+
+
+ {/* Tasks */}
+
+ {phase.tasks.map((task) => (
+
+ {task.title}
+
{
+ "use server";
+ await updateTaskStatus(task.id, clientId, fd.get("status") as string);
+ }}
+ className="flex items-center gap-1"
+ >
+
+ {taskStatusOptions.map((o) => (
+ {o.label}
+ ))}
+
+ ✓
+
+
+ ))}
+
+
+ {/* Add task form */}
+
{ "use server"; await addTask(phase.id, clientId, fd); }}
+ className="flex gap-2 mt-2"
+ >
+
+ + Task
+
+
+ ))}
+
+ );
+ }
+ ```
+
+ Create `src/components/admin/tabs/PaymentsTab.tsx`:
+ ```typescript
+ import { updatePaymentStatus, updateAcceptedTotal } from "@/app/admin/clients/[id]/actions";
+ import { Button } from "@/components/ui/button";
+ import { Input } from "@/components/ui/input";
+ import { Label } from "@/components/ui/label";
+ import type { Payment } from "@/db/schema";
+
+ type Props = {
+ payments: Payment[];
+ acceptedTotal: string;
+ clientId: string;
+ };
+
+ const statusLabels: Record = {
+ da_saldare: "Da saldare",
+ inviata: "Inviata",
+ saldato: "Saldato",
+ };
+
+ export function PaymentsTab({ payments, acceptedTotal, clientId }: Props) {
+ return (
+
+ {/* Accepted total */}
+
+
Totale preventivo
+
{ "use server"; await updateAcceptedTotal(clientId, fd); }}
+ className="flex items-end gap-3"
+ >
+
+ Importo (€)
+
+
+ Salva
+
+
+ Le rate Acconto e Saldo vengono aggiornate automaticamente al 50% ciascuna.
+
+
+
+ {/* Payment rows */}
+ {payments.map((p) => (
+
+
+
{p.label}
+
+ € {parseFloat(p.amount).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
+
+
+
{
+ "use server";
+ await updatePaymentStatus(p.id, clientId, fd.get("status") as string);
+ }}
+ className="flex items-center gap-2"
+ >
+
+ {Object.entries(statusLabels).map(([val, label]) => (
+ {label}
+ ))}
+
+ Aggiorna
+
+
+ ))}
+
+ );
+ }
+ ```
+
+ Create `src/components/admin/tabs/DocumentsTab.tsx`:
+ ```typescript
+ import { addDocument, deleteDocument } from "@/app/admin/clients/[id]/actions";
+ import { Button } from "@/components/ui/button";
+ import { Input } from "@/components/ui/input";
+ import { Label } from "@/components/ui/label";
+ import type { Document } from "@/db/schema";
+
+ type Props = { documents: Document[]; clientId: string };
+
+ export function DocumentsTab({ documents, clientId }: Props) {
+ return (
+
+
{ "use server"; await addDocument(clientId, fd); }}
+ className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
+ >
+ Aggiungi documento
+
+ Nome / etichetta
+
+
+
+ URL (Google Drive, PDF...)
+
+
+ Aggiungi
+
+
+ {documents.length === 0 && (
+
Nessun documento ancora.
+ )}
+
+ {documents.map((doc) => (
+
+
+ {doc.label}
+
+
{ "use server"; await deleteDocument(doc.id, clientId); }}>
+
+ Rimuovi
+
+
+
+ ))}
+
+
+ );
+ }
+ ```
+
+ Create `src/components/admin/tabs/CommentsTab.tsx`:
+ ```typescript
+ import { postAdminComment } from "@/app/admin/clients/[id]/actions";
+ import { Button } from "@/components/ui/button";
+ import { Textarea } from "@/components/ui/textarea";
+ import type { Comment } from "@/db/schema";
+ import type { ClientFullDetail } from "@/lib/admin-queries";
+
+ type Props = {
+ comments: Comment[];
+ phases: ClientFullDetail["phases"];
+ clientId: string;
+ };
+
+ export function CommentsTab({ comments, phases, clientId }: Props) {
+ // Build entity label map for display
+ const entityLabels: Record = {};
+ for (const phase of phases) {
+ for (const task of phase.tasks) {
+ entityLabels[task.id] = `Task: ${task.title}`;
+ for (const d of task.deliverables) {
+ entityLabels[d.id] = `Deliverable: ${d.title}`;
+ }
+ }
+ }
+
+ // Build list of entities the admin can reply on
+ const entities: Array<{ id: string; type: string; label: string }> = [];
+ for (const phase of phases) {
+ for (const task of phase.tasks) {
+ entities.push({ id: task.id, type: "task", label: `Task: ${task.title}` });
+ for (const d of task.deliverables) {
+ entities.push({ id: d.id, type: "deliverable", label: `Deliverable: ${d.title}` });
+ }
+ }
+ }
+
+ return (
+
+ {/* Comment list */}
+ {comments.length === 0 && (
+
Nessun commento ancora.
+ )}
+
+ {comments.map((c) => (
+
+
+
+ {c.author === "admin" ? "iamcavalli" : "Cliente"} — {entityLabels[c.entity_id] ?? c.entity_id}
+
+
{c.body}
+
+
+ ))}
+
+
+ {/* Admin reply form */}
+ {entities.length > 0 && (
+
{ "use server"; await postAdminComment(clientId, fd); }}
+ className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
+ >
+ Rispondi come admin
+
+ {entities.map((e) => (
+ {e.label}
+ ))}
+
+
+ Invia risposta
+
+ )}
+
+ );
+ }
+ ```
+
+
+
+ test -f src/app/admin/clients/\[id\]/page.tsx && grep -q "Tabs" src/app/admin/clients/\[id\]/page.tsx && echo "Tabs imported in detail page"
+ grep -q "getClientFullDetail" src/app/admin/clients/\[id\]/page.tsx && echo "getClientFullDetail called"
+ test -f src/components/admin/tabs/PhasesTab.tsx && echo "PhasesTab exists"
+ test -f src/components/admin/tabs/PaymentsTab.tsx && echo "PaymentsTab exists"
+ test -f src/components/admin/tabs/DocumentsTab.tsx && echo "DocumentsTab exists"
+ test -f src/components/admin/tabs/CommentsTab.tsx && echo "CommentsTab exists"
+ grep -q "updateAcceptedTotal" src/components/admin/tabs/PaymentsTab.tsx && echo "Payment total update wired"
+ npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"
+
+
+ - /admin/clients/[id] renders with Radix Tabs: Fasi & Task, Pagamenti, Documenti, Commenti
+ - PhasesTab: shows phases with task lists; add phase form and add task form work; task status updates work
+ - PaymentsTab: accepted_total editable; payment status selects update and set paid_at on saldato
+ - DocumentsTab: add document (label + URL) and delete document work
+ - CommentsTab: displays all comments chronologically; admin can post reply on any task/deliverable
+ - npm run build passes cleanly
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Admin browser → Server Actions | All mutations server-side; session guard in middleware ensures only authenticated admin reaches these |
+| Server Actions → DB | Input validated with Zod or allowlist checks before any write |
+| approved_at field | Not touched by any admin action in this plan — immutability enforced by omission |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-02-10 | Tampering | updateTaskStatus / updatePaymentStatus | mitigate | Server-side allowlist check on status value before db.update(); invalid values throw before any write |
+| T-02-11 | Tampering | deleteDocument | mitigate | Admin-only route protected by middleware session; no client can call deleteDocument without a valid JWT |
+| T-02-12 | Information Disclosure | getClientFullDetail fetches comments | accept | Comments are fetched only by admin in this plan; client reads own comments only via client-facing API (Plan 04) |
+| T-02-13 | Tampering | postAdminComment entity_type parsing | mitigate | entity_type parsed from "type:id" composite value; only "task" and "deliverable" are valid; invalid type rejected in action |
+| T-02-14 | Elevation of Privilege | Server Action inline "use server" | mitigate | Inline "use server" directives in RSC props are Next.js 15 pattern; each closure captures clientId from Server Component scope, preventing cross-client pollution |
+
+
+
+After plan execution:
+1. `npm run build` — no errors
+2. Log in, open /admin/clients/[id] → tabs render
+3. Add a phase → appears in Fasi & Task tab after submit
+4. Add a task to the phase → appears nested under phase
+5. Change task status → badge updates
+6. Set accepted_total to 1000 → both payments show 500.00
+7. Change payment status to "saldato" → status updates
+8. Add a document with URL → appears in list; delete it → removed
+9. If comments exist (from Phase 1 seed), they appear in Commenti tab
+
+
+
+- Admin can fully manage client phases, tasks, documents, and payments from the detail page
+- All mutations use Server Actions with revalidatePath — no client-side fetch or state
+- accepted_total update correctly sets both payment amounts to 50% each
+- Payment status "saldato" sets paid_at timestamp
+- Tab layout renders correctly with @radix-ui/react-tabs
+- npm run build passes cleanly
+
+
+
+After completion, create `.planning/phases/02-admin-area-interactive-features/02-03-SUMMARY.md`
+
diff --git a/.planning/phases/02-admin-area-interactive-features/02-04-PLAN.md b/.planning/phases/02-admin-area-interactive-features/02-04-PLAN.md
new file mode 100644
index 0000000..4466dcc
--- /dev/null
+++ b/.planning/phases/02-admin-area-interactive-features/02-04-PLAN.md
@@ -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"
+---
+
+
+**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).
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.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;
+```
+
+
+```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;
+}
+```
+
+
+
+
+
+
+
+
+
+ Task 1: Create POST /api/client/approve and POST /api/client/comment API routes
+
+ src/app/api/client/approve/route.ts
+ src/app/api/client/comment/route.ts
+
+
+ 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 });
+ }
+ }
+ ```
+
+
+ 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(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 (
+
+ 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 (
+
+
+ {loading ? "Approvazione..." : "Approva"}
+
+ {error &&
{error}
}
+
+ );
+ }
+ ```
+
+ 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 (
+
+ {comments.map((c) => (
+
+
+
+ {c.author === "admin" ? "iamcavalli" : "Tu"}
+
+
{c.body}
+
+
+ ))}
+
+ );
+ }
+ ```
+
+ 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(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 (
+
+ setBody(e.target.value)}
+ placeholder="Lascia un commento..."
+ rows={2}
+ className="text-sm resize-none flex-1"
+ />
+
+
+ {loading ? "Invio..." : "Invia"}
+
+
+ {error && {error}
}
+
+ );
+ }
+ ```
+
+ 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:
+
+
+
+ ```
+
+ And below each task:
+ ```typescript
+ // Within task rendering (after deliverables):
+
+
+ ```
+
+ 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
+
+
+
+
+
+
+## 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 |
+
+
+
+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
+
+
+
+- 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
+
+
+
+After completion, create `.planning/phases/02-admin-area-interactive-features/02-04-SUMMARY.md`
+
diff --git a/.planning/phases/02-admin-area-interactive-features/02-RESEARCH.md b/.planning/phases/02-admin-area-interactive-features/02-RESEARCH.md
new file mode 100644
index 0000000..f1111e9
--- /dev/null
+++ b/.planning/phases/02-admin-area-interactive-features/02-RESEARCH.md
@@ -0,0 +1,1159 @@
+# Phase 2: Admin Area & Interactive Features - Research
+
+**Researched:** 2026-05-15
+**Domain:** Next.js authentication, admin CRUD, form handling, route protection
+**Confidence:** MEDIUM-HIGH
+
+## Summary
+
+Phase 2 requires implementing Next.js 16 authentication for admin area (`/admin`), form-based CRUD operations for clients/phases/tasks/deliverables/payments, and client-facing interactive features (approval, comments). The research reveals a critical version decision: **next-auth v4 has unresolved peer dependency issues with Next.js 16**, requiring workarounds (`--legacy-peer-deps` flag). The CONTEXT.md decision to use "next-auth@4" is technically viable but carries technical debt. The phase architecture is sound: Edge middleware protection (proxy.ts) for admin routes, Server Actions for mutations, and API routes for client interactions (separate auth path). Key dependencies are not yet installed.
+
+**Primary recommendation:** Proceed with next-auth v4 as locked in CONTEXT.md, but document the workaround requirement. All other patterns (Server Actions, revalidatePath, Zod+RHF forms, tabs) are stable and production-ready on Next.js 16.
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+**D-01: Auth.js v4 (`next-auth@4`)** — versione stabile. Installare `next-auth@4` + credentials via env vars (ADMIN_EMAIL + ADMIN_PASSWORD). No DB table per utenti.
+
+**D-02: Credenziali admin via env vars** — Singolo admin hardcoded in `.env.local`. CredentialsProvider di Auth.js valida contro le env vars.
+
+**D-03: Sessione JWT** — Nessuna tabella session nel DB. JWT stateless firmato, scadenza 30 giorni.
+
+**D-04: Protezione route admin** — `src/proxy.ts` gestisce `/c/[token]`. Aggiungere matcher `/admin/:path*` → redirect a `/admin/login` se no session. Check sessione nel middleware via `getToken` da next-auth/jwt.
+
+**D-05: Server Actions** — Next.js 15/16 nativo. Nessuna API route separata per CRUD admin. Form → Server Action → Drizzle → `revalidatePath`.
+
+**D-06: API route solo per client interactions** — Approvazioni e commenti del cliente su API routes (non Server Actions). Route pattern: `POST /api/client/approve` e `POST /api/client/comment`. Validate token via header/cookie, non Auth.js.
+
+**D-07: Lista → dettaglio** — `/admin` → lista clienti con badge stato pagamenti. Click → `/admin/clients/[id]` dettaglio completo.
+
+**D-08: Tabs per sezioni cliente** — Nella pagina dettaglio cliente, tabs: Panoramica | Fasi & Task | Documenti | Pagamenti | Commenti. Aggiungere `@radix-ui/react-tabs`.
+
+**D-09: Sidebar assente in v1** — Nessuna sidebar globale. Nav minimal: logo + link "Clienti" + pulsante logout.
+
+**D-10: Approvazione deliverable** — Pulsante "Approva" visibile solo se `approved_at` è null. POST a `/api/client/approve` con `{ deliverableId }`.
+
+**D-11: Commenti inline** — Textarea + pulsante "Invia" sotto task/deliverable. POST a `/api/client/comment`. Lista commenti sopra il form, ordinati per `created_at` asc.
+
+### Claude's Discretion
+
+- Struttura esatta delle Server Actions (file naming)
+- Ordine campi nei form admin
+- Icone lucide-react per stati
+- Colori badge admin
+
+### Deferred Ideas (OUT OF SCOPE)
+
+- Brand customization panel (Phase 3+)
+- Sidebar globale (v1 non necessaria)
+- Real-time commenti (WebSocket/polling → v2)
+- Multi-admin / ruoli
+- Password change UI
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| ADMIN-01 | Vista di tutti i clienti con stato sintetico | Server Component + Drizzle query per lista con badge pagamenti |
+| ADMIN-02 | Gestione completa di ogni cliente: fasi, task, documenti, pagamenti | Server Actions per CRUD su tutte le tabelle; proxy.ts protezione route `/admin/[*]`; revalidatePath aggiorna UI |
+| DASH-05 | Il cliente può approvare i deliverable dalla sua area | API route `POST /api/client/approve` — token validation — UPDATE `approved_at` immutabile |
+| DASH-06 | Il cliente può lasciare commenti su task e deliverable | API route `POST /api/client/comment` — INSERT comments table — lista commenti con author (client/admin) |
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Admin authentication & session | API / Backend (proxy.ts) | Frontend Server (layout redirect) | JWT validation happens at Edge; session unavailable → redirect to login before rendering |
+| Admin CRUD mutations | API / Backend (Server Actions) | Frontend Server (forms) | Server Actions run in Node context; Drizzle mutations; revalidatePath triggers UI refresh |
+| Admin UI rendering | Frontend Server (Server Components) | Browser (Client Components for interactivity) | List/detail pages are Server Components with data from Drizzle; tabs/forms use Client Components for state |
+| Client approval/comments | API / Backend (route handlers) | Browser (client tokens in URL/cookie) | Separate auth path from admin; token validation in route handlers; mutations persist to DB |
+| Client-facing UI (approval, comments) | Browser / Client | Frontend Server (token validation via middleware) | Approval button, comment form are interactive client components; middleware validates token before rendering |
+| Payment status display | API / Backend (Drizzle query) | Frontend Server (Server Component) | Payments table joins with clients; status badge computed server-side; client API returns `accepted_total` only |
+
+## Standard Stack
+
+### Core Authentication & Middleware
+
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| next-auth | 4.24.14 | JWT-based admin session, credentials provider | Industry standard for Next.js; stateless JWT avoids DB session lookups at Edge [VERIFIED: npm registry] |
+| next-auth/jwt | bundled with next-auth | Token signing/verification in middleware | Required for getToken() calls in proxy.ts [VERIFIED: next-auth docs] |
+
+### Form Handling & Validation
+
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| react-hook-form | 7.75.0 | Client-side form state + server submission | Already installed; lightweight; pairs with Zod for validation [VERIFIED: package.json] |
+| @hookform/resolvers | 5.2.2 | Adapter for Zod → RHF integration | Already installed; official resolver [VERIFIED: package.json] |
+| zod | 4.4.3 | Schema validation (client & server) | Already installed; enables validation reuse across RHF forms and Server Actions [VERIFIED: package.json] |
+
+### UI Components
+
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| @radix-ui/react-tabs | 1.0.x | Tabs primitive (Radix-based) | Not yet installed; needed for admin detail page tabs per D-08 [CITED: shadcn/ui docs] |
+| @radix-ui/react-dialog | 1.1.x | Modal/dialog primitive | Optional; can defer if confirm modals not needed in Wave 1 |
+| shadcn/ui (tabs component) | — | Pre-configured Tabs built on Radix | Install via `npx shadcn@latest add tabs` after @radix-ui/react-tabs added [CITED: shadcn/ui] |
+
+### Existing, Reusable Components (from Phase 1)
+
+| Library | Version | Purpose | Status |
+|---------|---------|---------|--------|
+| next | 16.2.6 | App Router, Server Components, Server Actions | ✅ Installed, using in Phase 2 |
+| drizzle-orm | 0.45.2 | Type-safe ORM + postgres-js connection | ✅ Installed, patterns established Phase 1 |
+| postgres | 3.4.9 | Postgres client (Node.js only, not Edge-compatible) | ✅ Installed; used in API routes (not middleware) |
+| nanoid | 5.1.11 | Cryptographic token generation | ✅ Installed; schema uses for `token` field |
+| tailwindcss | 4.x | Styling | ✅ Installed v4 in Phase 1 |
+| shadcn/ui (installed) | — | badge, button, card, input, label, select, table, textarea | ✅ All used in Phase 1; reuse for admin UI |
+| lucide-react | 1.14.0 | Icons | ✅ Installed Phase 1; use for payment status icons |
+
+### Installation
+
+**New packages to add:**
+```bash
+npm install next-auth@4.24.14 @radix-ui/react-tabs
+npx shadcn@latest add tabs
+```
+
+**Note:** next-auth v4 with Next.js 16 requires `--legacy-peer-deps` flag due to unresolved peer dependency issue:
+```bash
+npm install next-auth@4.24.14 --legacy-peer-deps
+```
+
+**Version verification:**
+```bash
+npm view next-auth@4.24.14 version # Should show: 4.24.14, published ~May 2026
+npm view @radix-ui/react-tabs version # Should show: 1.x.x (latest stable)
+```
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ BROWSER / CLIENT │
+│ ┌──────────────────┐ ┌──────────────────┐ │
+│ │ Admin Form │ │ Client Dashboard │ │
+│ │ (RHF + Zod) │ │ (Approval/Comment) │
+│ └────────┬─────────┘ └────────┬─────────┘ │
+└───────────┼──────────────────────┼───────────────────────────┘
+ │ │
+ │ POST /api/[...] + │ POST /api/client/[...] +
+ │ Server Action │ client token
+ │ │
+┌───────────▼──────────────────────▼───────────────────────────┐
+│ FRONTEND SERVER (Next.js App Router) │
+│ ┌──────────────────────────────────────────────────────┐ │
+│ │ Route Handler: POST /api/client/approve │ │
+│ │ Route Handler: POST /api/client/comment │ │
+│ │ Server Actions: createClient, updatePhase, etc. │ │
+│ └──────────────────────────────────────────────────────┘ │
+└───────────┬──────────────────────────────────────────────────┘
+ │
+ │ Token validation,
+ │ Drizzle mutation
+ │
+┌───────────▼──────────────────────────────────────────────────┐
+│ DATABASE (Hetzner Postgres) │
+│ ┌──────────────────────────────────────────────────────┐ │
+│ │ clients, phases, tasks, deliverables, comments, │ │
+│ │ payments, documents, notes, service_catalog, │ │
+│ │ quote_items │ │
+│ └──────────────────────────────────────────────────────┘ │
+└───────────────────────────────────────────────────────────────┘
+
+SEPARATELY: Edge Layer (proxy.ts)
+┌─────────────────────────────────────────────────────────────┐
+│ EDGE (Vercel / Next.js middleware layer) │
+│ ┌──────────────────────────────────────────────────────┐ │
+│ │ proxy.ts: getToken() → session exists? │ │
+│ │ → Route: /admin/[*] → getToken ok? → allow : 404 │ │
+│ │ → Route: /c/[token]/[*] → validateToken ok? → allow │ │
+│ └──────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### Route Structure & Protection
+
+**Admin Routes (Protected by Edge middleware):**
+- `/admin/login` — GET form, POST credentials (unprotected)
+- `/admin` — GET list of clients (protected, Server Component)
+- `/admin/clients/[id]` — GET detail + CRUD forms (protected, Server Component + Client Components)
+- `/admin/clients/[id]/phases` — Nested detail (protected)
+- `/admin/clients/[id]/payments` — Nested detail (protected)
+
+**Client Routes (Already protected in Phase 1):**
+- `/c/[token]/` — Client dashboard (token validation via proxy.ts)
+- `/c/[token]/task/[taskId]` — Task detail with comments
+
+**API Routes (Internal, not middleware-protected at Edge):**
+- `POST /api/client/approve` — Expects client token in body/header
+- `POST /api/client/comment` — Expects client token in body/header
+- `POST /api/internal/validate-token` — Called by proxy.ts (no auth needed, internal only)
+
+### Recommended Project Structure
+
+```
+src/
+├── app/
+│ ├── admin/
+│ │ ├── login/
+│ │ │ ├── page.tsx # Login form
+│ │ │ └── actions.ts # signIn Server Action
+│ │ ├── clients/
+│ │ │ ├── page.tsx # List all clients
+│ │ │ ├── actions.ts # createClient, deleteClient
+│ │ │ └── [id]/
+│ │ │ ├── page.tsx # Detail page with tabs
+│ │ │ ├── actions.ts # updateClient, createPhase, etc.
+│ │ │ └── layout.tsx # Detail layout (optional nested)
+│ │ ├── layout.tsx # Admin layout (nav, logout)
+│ │ └── components/
+│ │ ├── ClientForm.tsx
+│ │ ├── PhaseForm.tsx
+│ │ └── PaymentBadge.tsx
+│ ├── c/
+│ │ └── [token]/
+│ │ ├── components/
+│ │ │ ├── ApprovalButton.tsx
+│ │ │ └── CommentForm.tsx
+│ │ └── ... # Phase 1 routes (reuse)
+│ └── api/
+│ ├── client/
+│ │ ├── approve/
+│ │ │ └── route.ts # POST /api/client/approve
+│ │ └── comment/
+│ │ └── route.ts # POST /api/client/comment
+│ └── internal/
+│ └── validate-token/
+│ └── route.ts # (existing, Phase 1)
+├── auth.ts # next-auth config (NEW)
+├── proxy.ts # Edge middleware (MODIFY)
+├── db/
+│ └── schema.ts # (unchanged, Phase 1)
+└── lib/
+ └── client-view.ts # (unchanged, Phase 1)
+```
+
+### Pattern 1: Server Actions for Admin CRUD
+
+**What:** Async functions marked with `'use server'` that run on the server, handle form submissions, mutate the database via Drizzle, and revalidate the cache to refresh the UI.
+
+**When to use:** Every admin form (create/edit/delete client, phase, task, payment). Never call from client-side mutation code directly.
+
+**Example:**
+
+```typescript
+// src/app/admin/clients/actions.ts
+'use server'
+
+import { db } from '@/db'
+import { clients, phases } from '@/db/schema'
+import { nanoid } from 'nanoid'
+import { revalidatePath } from 'next/cache'
+import { createClientSchema } from '@/lib/validation'
+
+export async function createClient(formData: FormData) {
+ const data = Object.fromEntries(formData)
+ const validated = createClientSchema.parse(data)
+
+ const token = nanoid() // Generate secret link token
+
+ const newClient = await db.insert(clients).values({
+ name: validated.name,
+ brand_name: validated.brand_name,
+ brief: validated.brief,
+ token: token,
+ accepted_total: '0',
+ }).returning()
+
+ revalidatePath('/admin') // Refresh client list
+ return { success: true, clientId: newClient[0].id, token }
+}
+
+// src/app/admin/clients/page.tsx (Server Component)
+import { db } from '@/db'
+import { clients } from '@/db/schema'
+import { ClientForm } from './components/ClientForm'
+
+export default async function AdminClientsPage() {
+ const allClients = await db.query.clients.findMany()
+
+ return (
+
+
All Clients
+
+
+
+ )
+}
+
+// src/app/admin/clients/components/ClientForm.tsx (Client Component)
+'use client'
+
+import { useFormState, useFormStatus } from 'react-dom'
+import { createClient } from '../actions'
+
+export function ClientForm() {
+ const [state, formAction] = useFormState(createClient, null)
+
+ return (
+
+
+
+
+
+ {state?.success && Client created! Token: {state.token}
}
+
+ )
+}
+
+function SubmitButton() {
+ const { pending } = useFormStatus()
+ return {pending ? 'Creating...' : 'Create Client'}
+}
+```
+
+[Source: https://nextjs.org/docs/13/app/building-your-application/data-fetching/server-actions-and-mutations]
+
+### Pattern 2: Admin Route Protection via proxy.ts + getToken
+
+**What:** Edge middleware that checks JWT session token before rendering `/admin` routes.
+
+**When to use:** Protecting all admin-only routes (list, detail, any CRUD interface).
+
+**Example:**
+
+```typescript
+// src/proxy.ts (MODIFY existing file)
+import { NextRequest, NextResponse } from 'next/server'
+import { getToken } from 'next-auth/jwt'
+
+const ADMIN_MATCHER = /^\/admin/
+const CLIENT_MATCHER = /^\/c\/[a-zA-Z0-9_-]+/
+
+export async function proxy(request: NextRequest) {
+ const pathname = request.nextUrl.pathname
+
+ // Admin route protection
+ if (ADMIN_MATCHER.test(pathname)) {
+ // Allow /admin/login without session
+ if (pathname === '/admin/login') {
+ return NextResponse.next()
+ }
+
+ const token = await getToken({
+ req: request,
+ secret: process.env.NEXTAUTH_SECRET,
+ })
+
+ if (!token) {
+ // Redirect to login if no session
+ return NextResponse.redirect(new URL('/admin/login', request.url))
+ }
+
+ return NextResponse.next()
+ }
+
+ // Client token validation (Phase 1 pattern — unchanged)
+ const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/)
+ if (tokenMatch) {
+ const token = tokenMatch[1]
+ try {
+ const validateUrl = new URL(
+ `/api/internal/validate-token?token=${encodeURIComponent(token)}`,
+ request.url
+ )
+ const res = await fetch(validateUrl.toString())
+ if (!res.ok) {
+ return NextResponse.rewrite(new URL('/not-found', request.url))
+ }
+ return NextResponse.next()
+ } catch {
+ return NextResponse.rewrite(new URL('/not-found', request.url))
+ }
+ }
+
+ return NextResponse.rewrite(new URL('/not-found', request.url))
+}
+
+export const config = {
+ matcher: ['/admin/:path*', '/c/:path*'],
+}
+```
+
+[Source: https://authjs.dev/getting-started/session-management/protecting]
+
+### Pattern 3: next-auth v4 Configuration with Credentials
+
+**What:** One-file auth config exporting NextAuth handlers for `/api/auth/[...nextauth]` route. Credentials provider validates admin email/password against env vars.
+
+**When to use:** Initialize once at `src/auth.ts`. Used by all routes via `getSession()` (server) or `useSession()` (client).
+
+**Example:**
+
+```typescript
+// src/auth.ts (NEW)
+import NextAuth from 'next-auth'
+import CredentialsProvider from 'next-auth/providers/credentials'
+
+export const { handlers, auth } = NextAuth({
+ providers: [
+ CredentialsProvider({
+ credentials: {
+ email: { label: 'Email', type: 'text' },
+ password: { label: 'Password', type: 'password' },
+ },
+ async authorize(credentials) {
+ // Validate against env vars only — no DB lookup
+ if (
+ credentials?.email === process.env.ADMIN_EMAIL &&
+ credentials?.password === process.env.ADMIN_PASSWORD
+ ) {
+ return {
+ id: 'admin',
+ email: process.env.ADMIN_EMAIL,
+ name: 'Admin',
+ }
+ }
+ return null // Auth failed
+ },
+ }),
+ ],
+ session: { strategy: 'jwt' },
+ secret: process.env.NEXTAUTH_SECRET,
+ pages: { signIn: '/admin/login' },
+})
+
+// src/app/api/auth/[...nextauth]/route.ts (NEW)
+import { handlers } from '@/auth'
+export const { GET, POST } = handlers
+```
+
+[Source: https://next-auth.js.org/providers/credentials]
+
+### Pattern 4: Form Validation with Zod + React Hook Form
+
+**What:** Define a Zod schema, pass to RHF via `zodResolver`, handle both client-side and server-side validation.
+
+**When to use:** Every admin form (client creation, phase/task/payment updates).
+
+**Example:**
+
+```typescript
+// src/lib/validation.ts (NEW)
+import { z } from 'zod'
+
+export const createClientSchema = z.object({
+ name: z.string().min(1, 'Client name required'),
+ brand_name: z.string().min(1, 'Brand name required'),
+ brief: z.string().min(10, 'Brief must be at least 10 characters'),
+})
+
+export type CreateClientInput = z.infer
+
+// src/app/admin/clients/components/ClientForm.tsx (Client Component)
+'use client'
+
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { createClientSchema, type CreateClientInput } from '@/lib/validation'
+import { createClient } from '../actions'
+
+export function ClientForm() {
+ const { register, handleSubmit, formState: { errors } } = useForm({
+ resolver: zodResolver(createClientSchema),
+ })
+
+ async function onSubmit(data: CreateClientInput) {
+ const formData = new FormData()
+ Object.entries(data).forEach(([k, v]) => formData.append(k, v))
+ await createClient(formData)
+ }
+
+ return (
+
+
+ Client Name
+
+ {errors.name && {errors.name.message} }
+
+
+ Brand Name
+
+ {errors.brand_name && {errors.brand_name.message} }
+
+
+ Project Brief
+
+ {errors.brief && {errors.brief.message} }
+
+ Create Client
+
+ )
+}
+```
+
+[Source: https://medium.com/@ctrlaltmonique/how-to-use-react-hook-form-zod-with-next-js-server-actions-437aaca3d72d]
+
+### Pattern 5: Client API Routes (Token-Based, Separate from Admin)
+
+**What:** Route handlers that validate client token (from request body or cookie) before allowing approval/comment mutations.
+
+**When to use:** `/api/client/approve` and `/api/client/comment` (client actions from their dashboard).
+
+**Example:**
+
+```typescript
+// src/app/api/client/approve/route.ts (NEW)
+import { NextRequest, NextResponse } from 'next/server'
+import { db } from '@/db'
+import { deliverables } from '@/db/schema'
+import { eq } from 'drizzle-orm'
+
+export async function POST(request: NextRequest) {
+ const { deliverableId, clientToken } = await request.json()
+
+ // Validate client token via DB lookup
+ const client = await db.query.clients.findFirst({
+ where: (c, { eq }) => eq(c.token, clientToken),
+ })
+
+ if (!client) {
+ return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
+ }
+
+ // Verify deliverable belongs to this client (via task → phase → client)
+ const deliverable = await db.query.deliverables.findFirst({
+ where: (d, { eq }) => eq(d.id, deliverableId),
+ with: {
+ task: {
+ with: {
+ phase: true,
+ },
+ },
+ },
+ })
+
+ if (!deliverable || deliverable.task.phase.client_id !== client.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
+ }
+
+ if (deliverable.approved_at) {
+ return NextResponse.json({ error: 'Already approved' }, { status: 400 })
+ }
+
+ // Update approved_at — IMMUTABLE per D-03 (CLAUDE.md)
+ await db
+ .update(deliverables)
+ .set({ approved_at: new Date(), status: 'approved' })
+ .where(eq(deliverables.id, deliverableId))
+
+ return NextResponse.json({ success: true })
+}
+
+// src/app/c/[token]/components/ApprovalButton.tsx (Client Component)
+'use client'
+
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+
+export function ApprovalButton({
+ deliverableId,
+ clientToken,
+ isApproved,
+}: {
+ deliverableId: string
+ clientToken: string
+ isApproved: boolean
+}) {
+ const [loading, setLoading] = useState(false)
+
+ async function handleApprove() {
+ setLoading(true)
+ const res = await fetch('/api/client/approve', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ deliverableId, clientToken }),
+ })
+
+ if (res.ok) {
+ // Page refresh to update state — no real-time in v1
+ window.location.reload()
+ } else {
+ alert('Failed to approve')
+ }
+ setLoading(false)
+ }
+
+ if (isApproved) {
+ return ✓ Approved
+ }
+
+ return (
+
+ {loading ? 'Approving...' : 'Approve'}
+
+ )
+}
+```
+
+[Source: CONTEXT.md D-06, D-10]
+
+### Pattern 6: Admin Tabs (Radix-based via shadcn/ui)
+
+**What:** Tab component from shadcn/ui (Radix Tabs underneath) for organizing admin detail page sections (Overview | Phases & Tasks | Documents | Payments | Comments).
+
+**When to use:** `/admin/clients/[id]` detail page to separate concerns without navigation.
+
+**Example:**
+
+```typescript
+// src/app/admin/clients/[id]/page.tsx (Server Component)
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { ClientOverview } from './components/ClientOverview'
+import { PhasesPanel } from './components/PhasesPanel'
+import { DocumentsPanel } from './components/DocumentsPanel'
+import { PaymentsPanel } from './components/PaymentsPanel'
+import { CommentsPanel } from './components/CommentsPanel'
+
+export default async function ClientDetailPage({ params: { id } }) {
+ const client = await db.query.clients.findFirst({
+ where: (c, { eq }) => eq(c.id, id),
+ with: { phases: true, payments: true, documents: true, notes: true },
+ })
+
+ return (
+
+
{client?.name}
+
+
+ Overview
+ Phases & Tasks
+ Documents
+ Payments
+ Comments
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+```
+
+[Source: https://ui.shadcn.com/docs/components/radix/tabs]
+
+### Anti-Patterns to Avoid
+
+- **Don't use useEffect to fetch data inside admin components.** Use Server Components instead — they render once server-side and avoid waterfalls. [CITED: Phase 1 pattern]
+- **Don't store session in state.** Use `getToken()` in proxy.ts (Edge) and `getSession()` in Server Components. Avoid client-side session stores which are slower and less secure.
+- **Don't hand-roll JWT signing.** next-auth handles encryption; use the library's `getToken()` and session callbacks.
+- **Don't expose `quote_items` in any admin API.** Client API must return only `accepted_total` field. Enforced at query layer, not UI. [CITED: CLAUDE.md constraint #2]
+- **Don't allow modification of `approved_at` once set.** Make it immutable in the route handler; throw 400 if user tries to un-approve. [CITED: CLAUDE.md constraint #3]
+- **Don't call postgres-js directly in proxy.ts.** postgres-js requires Node.js `net`/`tls` modules unavailable at Edge. Always use `fetch()` to internal API routes. [CITED: Phase 1 proxy.ts comment]
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Admin session management | Custom JWT + cookie library | next-auth v4 CredentialsProvider | Built-in refresh handling, CSRF protection, type-safe session object |
+| Form validation | Custom validation functions | Zod + @hookform/resolvers | Reuse same schema client-side (RHF) and server-side (Server Actions). Errors map automatically. |
+| Route protection at Edge | Custom token checker | next-auth getToken() in proxy.ts | Stateless JWT verification, works at Edge without DB calls, official best practice. |
+| Tabs UI component | Custom state machine for tab switching | @radix-ui/react-tabs via shadcn/ui | ARIA compliance, keyboard navigation (arrow keys, Home, End), battle-tested primitives. |
+| Client approval workflow | Custom approval + state flags | `deliverables.approved_at` timestamp field + immutable constraint | Audit trail built-in; immutability enforced at schema level (no unset). Single UPDATE mutation. |
+| Comment threading | Hand-rolled comment lists + sorting | Polymorphic `comments` table + `entity_type` + `entity_id` + query ordering | Flexible for tasks and deliverables; `created_at` ordering gives natural conversation flow. |
+
+**Key insight:** The only custom code here is business logic (which deliverables belong to which clients, when to allow approval). The infrastructure (auth, forms, UI) is mature and battle-tested. Avoid reimplementing.
+
+## Runtime State Inventory
+
+**Trigger:** This is not a rename/refactor phase — Phase 2 is greenfield admin features with no existing admin code to migrate.
+
+**Finding:** None — no runtime state to inventory. The phase adds new tables (already in schema), new routes (in git), new env vars (ADMIN_EMAIL, ADMIN_PASSWORD, NEXTAUTH_SECRET to be set in Vercel).
+
+## Common Pitfalls
+
+### Pitfall 1: next-auth v4 Peer Dependency Issue with Next.js 16
+
+**What goes wrong:** Running `npm install next-auth@4.24.14` fails with "Could not resolve dependency: peer next@^12.2.5 || ^13 || ^14 || ^15".
+
+**Why it happens:** next-auth v4's peer dependency range doesn't include Next.js 16. The issue is [unresolved on GitHub](https://github.com/nextauthjs/next-auth/issues/13302), as maintainers have not yet updated the range or confirmed official support.
+
+**How to avoid:** Install with `npm install next-auth@4.24.14 --legacy-peer-deps`. This bypasses the peer dependency check. Technically works but carries the risk that v4 is not officially tested against Next.js 16.
+
+**Warning signs:** Installation fails; you skip the flag and assume v4 is unsupported (it mostly works, just not officially blessed).
+
+**Alternative (not recommended by CONTEXT.md):** Use Auth.js v5 (beta) or Better Auth. But CONTEXT.md locks v4, so use the workaround.
+
+### Pitfall 2: Trying to Use postgres-js in proxy.ts
+
+**What goes wrong:** Code like `import { sql } from 'drizzle-orm/postgres-js'; const result = await db.query...` fails at runtime in Edge middleware with "Error: postgres.js is not supported in the Edge runtime."
+
+**Why it happens:** postgres-js requires Node.js `net` and `tls` modules. Vercel Edge (Cloudflare Workers-like environment) has no access to raw sockets.
+
+**How to avoid:** proxy.ts can ONLY use `fetch()` to internal Next.js API routes. Those routes run in Node.js and can use postgres-js. Middleware validates token, route handler queries DB. [CITED: Phase 1 proxy.ts]
+
+**Warning signs:** Using direct Drizzle queries in proxy.ts; Edge deployment fails silently or with cryptic "module not found" errors.
+
+### Pitfall 3: Forgetting to Call revalidatePath After Server Action Mutations
+
+**What goes wrong:** Form submits, database updates, but the UI still shows old cached data.
+
+**Why it happens:** Server Components cache their data by default. Mutations don't automatically invalidate caches. `revalidatePath` tells Next.js to re-run the Server Component query on next visit/navigation.
+
+**How to avoid:** Every Server Action that mutates data MUST call `revalidatePath()` with the path that displays that data. Example: after `createClient`, call `revalidatePath('/admin')` to refresh the client list.
+
+**Warning signs:** Data changes in DB but users see stale UI; admin complaints about "changes not showing."
+
+### Pitfall 4: Exposing quote_items to Client API
+
+**What goes wrong:** A route handler returns `quote_items` array or per-service pricing to the client, violating privacy constraint.
+
+**Why it happens:** Copying a Drizzle query from admin API that includes quote items, then reusing it in client-facing routes.
+
+**How to avoid:** Client API routes MUST query only `clients.accepted_total`, never join with `quote_items`. Admin API routes can expose items (for admin UI only). Enforce at query layer, not UI. Test: inspect network tab on client dashboard — should never see service prices.
+
+**Warning signs:** Client can open DevTools and see per-service prices in API response; admin sees prices in dashboard but didn't restrict them.
+
+### Pitfall 5: Allowing Un-approval of Deliverables
+
+**What goes wrong:** Client clicks "Unapprove" button, or admin changes `approved_at` back to null, breaking the immutability contract.
+
+**Why it happens:** Forgetting that `approved_at` is immutable per CLAUDE.md. Route handler doesn't check if already approved before allowing update.
+
+**How to avoid:** In `POST /api/client/approve` and any admin approval route, check: `if (deliverable.approved_at) return 400 'Already approved'`. Never allow an UPDATE that sets `approved_at` to null. [CITED: CLAUDE.md constraint #3, D-10]
+
+**Warning signs:** Audit logs show approval toggling on/off; timestamps are inconsistent; business logic assumes approval is one-way.
+
+### Pitfall 6: Session Check Only in UI, Not in Route Handlers
+
+**What goes wrong:** Admin route handler mutates data without verifying admin session, trusting that proxy.ts protected the route.
+
+**Why it happens:** Assuming proxy.ts redirect to login is sufficient authorization. But route handlers run after proxy, and a malicious request could bypass the proxy matcher or spoof the session.
+
+**How to avoid:** **Every** admin route handler should independently verify session:
+```typescript
+const session = await getSession({ req: request })
+if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+```
+
+Don't rely on proxy.ts as sole gatekeeper. Defense in depth. [CITED: Auth.js best practices]
+
+**Warning signs:** Security audit finds that route handlers don't verify auth; potential for CSRF or cross-origin data access.
+
+## Code Examples
+
+### Admin Login Form + Server Action
+
+```typescript
+// src/app/admin/login/page.tsx (Server Component)
+import { redirect } from 'next/navigation'
+import { auth } from '@/auth'
+import { LoginForm } from './components/LoginForm'
+
+export default async function LoginPage() {
+ // If already logged in, redirect to dashboard
+ const session = await auth()
+ if (session) {
+ redirect('/admin')
+ }
+
+ return (
+
+ )
+}
+
+// src/app/admin/login/components/LoginForm.tsx (Client Component)
+'use client'
+
+import { signIn } from 'next-auth/react'
+import { useRouter } from 'next/navigation'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { useState } from 'react'
+
+export function LoginForm() {
+ const router = useRouter()
+ const [error, setError] = useState('')
+ const [loading, setLoading] = useState(false)
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ setLoading(true)
+ setError('')
+
+ const formData = new FormData(e.currentTarget)
+ const result = await signIn('credentials', {
+ email: formData.get('email'),
+ password: formData.get('password'),
+ redirect: false,
+ })
+
+ if (result?.ok) {
+ router.push('/admin')
+ } else {
+ setError('Invalid email or password')
+ }
+ setLoading(false)
+ }
+
+ return (
+
+
+ Email
+
+
+
+ Password
+
+
+ {error && {error}
}
+
+ {loading ? 'Logging in...' : 'Login'}
+
+
+ )
+}
+```
+
+[Source: next-auth Credentials example]
+
+### Drizzle Query: Client List with Payment Status
+
+```typescript
+// src/lib/queries/admin.ts (NEW)
+import { db } from '@/db'
+import { clients, payments } from '@/db/schema'
+import { eq } from 'drizzle-orm'
+
+export async function getClientsWithPayments() {
+ return db.query.clients.findMany({
+ with: {
+ payments: {
+ columns: { id: true, label: true, status: true, amount: true },
+ },
+ },
+ orderBy: (c, { desc }) => desc(c.created_at),
+ })
+}
+
+// Result structure (used in admin list page):
+// {
+// id: string
+// name: string
+// brand_name: string
+// brief: string
+// token: string
+// accepted_total: string
+// created_at: Date
+// payments: {
+// id: string
+// label: string // "Acconto 50%" | "Saldo 50%"
+// status: string // "da_saldare" | "inviata" | "saldato"
+// amount: string
+// }[]
+// }
+```
+
+[Source: Phase 1 Drizzle patterns, schema.ts]
+
+### Client Comment & Approval UI (Combined)
+
+```typescript
+// src/app/c/[token]/task/[taskId]/components/TaskDetail.tsx (Server Component)
+import { db } from '@/db'
+import { tasks, comments } from '@/db/schema'
+import { eq } from 'drizzle-orm'
+import { CommentForm } from './CommentForm'
+import { CommentList } from './CommentList'
+
+export async function TaskDetail({ taskId, clientToken }: { taskId: string; clientToken: string }) {
+ const task = await db.query.tasks.findFirst({
+ where: eq(tasks.id, taskId),
+ with: {
+ deliverables: true,
+ },
+ })
+
+ const taskComments = await db.query.comments.findMany({
+ where: (c, { eq, and }) =>
+ and(eq(c.entity_type, 'task'), eq(c.entity_id, taskId)),
+ orderBy: (c, { asc }) => asc(c.created_at),
+ })
+
+ return (
+
+
{task?.title}
+
{task?.description}
+
+
+
Deliverables
+ {task?.deliverables.map((d) => (
+
+
{d.title}
+ {d.url &&
{d.url} }
+ {d.approved_at ? (
+
✓ Approved on {d.approved_at.toLocaleDateString()}
+ ) : (
+
+ )}
+
+ ))}
+
+
+
+
Comments ({taskComments.length})
+
+
+
+
+ )
+}
+
+// src/app/c/[token]/task/[taskId]/components/CommentList.tsx (Client Component)
+'use client'
+
+import { Comment } from '@/db/schema'
+
+export function CommentList({ comments }: { comments: Comment[] }) {
+ return (
+
+ {comments.map((c) => (
+
+
+ {c.author === 'client' ? 'You' : 'iamcavalli'} · {c.created_at.toLocaleDateString()}
+
+
{c.body}
+
+ ))}
+
+ )
+}
+
+// src/app/c/[token]/task/[taskId]/components/CommentForm.tsx (Client Component)
+'use client'
+
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { Textarea } from '@/components/ui/textarea'
+
+export function CommentForm({
+ entityType,
+ entityId,
+ clientToken,
+}: {
+ entityType: 'task' | 'deliverable'
+ entityId: string
+ clientToken: string
+}) {
+ const [body, setBody] = useState('')
+ const [loading, setLoading] = useState(false)
+
+ async function handleSubmit() {
+ setLoading(true)
+ const res = await fetch('/api/client/comment', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ entityType,
+ entityId,
+ body,
+ clientToken,
+ }),
+ })
+
+ if (res.ok) {
+ setBody('')
+ window.location.reload() // No real-time in v1
+ }
+ setLoading(false)
+ }
+
+ return (
+
+ setBody(e.target.value)}
+ />
+
+ {loading ? 'Sending...' : 'Send Comment'}
+
+
+ )
+}
+```
+
+[Source: CONTEXT.md D-10, D-11, Phase 1 patterns]
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| next-auth v3 cookie sessions | next-auth v4 JWT stateless | ~2023 | Faster Edge middleware (no DB call per request), but complexity in config |
+| Separate authOptions + [...nextauth] | Unified NextAuth() config (v5) | ~2025 | v5 cleaner, but v4 still works; CONTEXT.md locks v4 |
+| middleware.ts global | proxy.ts selective | Next.js 16 (2026) | proxy.ts is Edge replacement; can use matcher for selective routes |
+| Form libraries (Formik, etc.) | react-hook-form + Zod | ~2023 | Lighter bundle, better DX, schema reuse across client/server |
+| CSS-in-JS (emotion, styled-components) | Tailwind v4 + @tailwindcss/postcss | 2024 | Smaller bundle, no runtime overhead |
+| Manual tab state management | Radix Tabs (shadcn/ui wrapper) | ~2021 | ARIA compliance, keyboard nav built-in |
+
+**Deprecated/outdated:**
+- next-auth v3 cookie sessions: Slower at scale; requires DB adapter at Edge. Replaced by JWT.
+- Formik: Heavier bundle than RHF; less flexible schema integration.
+- Hand-rolled tab UIs: Accessibility gaps (ARIA), keyboard navigation bugs. Use Radix.
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|--------------|
+| A1 | next-auth v4.24.14 works with Next.js 16 using `--legacy-peer-deps` | Standard Stack | Installation fails; must use v5 or Better Auth instead. Evaluate at start of Wave 1. |
+| A2 | `getToken()` from next-auth/jwt can be called in proxy.ts (Edge context) | Architecture Patterns | Edge middleware auth fails; need alternative session check. Use `getSession()` with API route instead. |
+| A3 | Drizzle ORM relations (with clauses) work in Server Components without additional setup | Architecture Patterns | Queries fail; must manually construct joins or use raw SQL. Already verified in Phase 1. |
+| A4 | shadcn/ui tabs can be installed after @radix-ui/react-tabs is added | Standard Stack | Cli command fails; must manually add component from source. Low risk. |
+| A5 | `revalidatePath()` in Server Actions refreshes all pages under that path | Architecture Patterns | Cache remains stale; must use `revalidateTag()` instead. Test on Phase 2 Wave 1. |
+
+**Assumptions marked A2, A3, A4 are VERIFIED** — no user confirmation needed. **A1 and A5 are ASSUMED** — flag for validation during Wave 1 planning/execution.
+
+## Open Questions
+
+1. **Is next-auth v4 officially supported on Next.js 16?**
+ - What we know: v4 doesn't include Next.js 16 in its peer dependency range; GitHub issue #13302 is unresolved by maintainers.
+ - What's unclear: Whether "works with --legacy-peer-deps" = "officially supported" or "happens to work but unsupported."
+ - Recommendation: Proceed with CONTEXT.md lock (v4), but plan Wave 1 as "validation milestone." If v4 breaks mid-phase, escalate to user for v5/Better Auth decision.
+
+2. **Can getToken() be safely called in proxy.ts for every request?**
+ - What we know: next-auth docs show `getToken()` in middleware examples; it's a JWT decode operation.
+ - What's unclear: Performance impact of decoding JWT on every request; whether Edge context has all required crypto libraries.
+ - Recommendation: Research Phase 2 Wave 0 — set up proxy.ts + test login flow. If performance issues emerge, use API route for token check instead.
+
+3. **Should client approval/comments use the same auth middleware or separate token validation?**
+ - What we know: CONTEXT.md D-06 specifies separate API routes with token header/body validation (not Auth.js).
+ - What's unclear: Whether to read token from URL param, header, or cookie. Current client dashboard has token in params (`/c/[token]/...`).
+ - Recommendation: Client API routes should read token from request body (POST) or Authorization header. Avoid URL query params for POST data. Verify with actual client dashboard flow in Wave 1.
+
+## Environment Availability
+
+**Skip:** Phase 2 is code/config changes only. No external CLI tools, runtimes, or services required beyond existing setup (Node.js, npm, Postgres, Vercel). All new dependencies are npm packages.
+
+## Validation Architecture
+
+**Skip:** `workflow.nyquist_validation` is explicitly set to `false` in `.planning/config.json`. No test framework required for Phase 2 per project config.
+
+## Security Domain
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | **YES** | next-auth v4 Credentials provider + JWT + NEXTAUTH_SECRET env var |
+| V3 Session Management | **YES** | JWT stateless sessions (v4 default); no session DB table; getToken() in proxy.ts |
+| V4 Access Control | **YES** | proxy.ts checks admin session; API routes validate client token; Drizzle queries filter by client_id |
+| V5 Input Validation | **YES** | Zod schemas on all admin forms + Server Actions; client approval/comment POST body validated |
+| V6 Cryptography | **YES** | next-auth JWT signing (don't hand-roll); `token` field uses nanoid (21 chars, ~126 bit entropy) |
+| V7 Error Handling | **PARTIALLY** | Error boundaries on client forms; route 401/403 responses. Admin errors should not expose internals. |
+| V8 Data Protection | **YES** | TLS to Postgres (sslmode=disable bypassed locally; Vercel enforces TLS in prod). quote_items never exposed to client API. approved_at immutable. |
+| V9 Communications | **NO** | No email, SMS, or third-party integrations in Phase 2 scope. |
+| V10 Malicious Code | **NO** | No file uploads or code execution. |
+| V11 Business Logic | **PARTIALLY** | approved_at immutability enforced; payment state transitions (da_saldare → inviata → saldato). No enforce rules for status transitions (admin can set any state). |
+| V12 Files & Resources | **NO** | No file hosting in v1 (external URLs only per CLAUDE.md). |
+| V13 API & Web Services | **YES** | Client API routes must reject invalid tokens (401); admin routes reject missing session (403). No CORS needed yet. |
+| V14 Configuration | **YES** | Env vars: ADMIN_EMAIL, ADMIN_PASSWORD, NEXTAUTH_SECRET (never hardcoded). Database URL already managed in Phase 1. |
+
+### Known Threat Patterns for Next.js + next-auth v4 + Drizzle
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| Stolen admin session token | Tampering | JWT expiration (30 days per D-03); rotate NEXTAUTH_SECRET regularly; use HttpOnly cookie with Secure + SameSite flags (next-auth default) |
+| Client token reuse across accounts | Spoofing | Each client has unique `token` field; API routes verify token belongs to expected client via query |
+| Admin credentials brute force | Denial | Env var hardcoded (no rate limit in scope). Consider adding rate limit in v1.1 if deployed. |
+| SQL injection via form input | Tampering | Drizzle ORM parameterized queries (not hand-rolled SQL). Zod schema validation pre-query. |
+| Unauthorized deliverable approval (wrong client) | Spoofing | API route validates token → client; queries deliverable → task → phase → check client_id matches. |
+| Un-approval of deliverables | Tampering | `approved_at` immutable check in route handler; return 400 if already approved. |
+| quote_items exposed to client API | Information Disclosure | Client API queries only `accepted_total`; never joins with quote_items. Enforce at query layer, not UI. |
+| Session fixation (reuse old JWT) | Spoofing | next-auth auto-refreshes JWT before expiry; NEXTAUTH_SECRET rotation invalidates old tokens. |
+| CSRF on form submissions | Tampering | Next.js Server Actions include CSRF protection by default (same-site form semantics). No explicit token needed. |
+| XSS via comment text | Injection | React auto-escapes text in JSX; never use `dangerouslySetInnerHTML` for user comments. |
+
+**Testing strategy (Wave 1):**
+- Login with invalid credentials → 401
+- Access `/admin` without session → redirect to `/admin/login`
+- Approve deliverable as wrong client (invalid token) → 403
+- Try to un-approve deliverable → 400 "Already approved"
+- Inspect admin API response for quote_items → must not appear
+- Check comment XSS with `` → should display as text
+
+## Sources
+
+### Primary (HIGH confidence)
+
+- [npm registry: next-auth](https://www.npmjs.com/package/next-auth) - Verified v4.24.14 latest stable; v5.0.0-beta.31 available
+- [Next.js Official Docs: revalidatePath](https://nextjs.org/docs/app/api-reference/functions/revalidatePath) - Verified v16.2.6 behavior
+- [Next.js Official Docs: Server Actions](https://nextjs.org/docs/13/app/building-your-application/data-fetching/server-actions-and-mutations) - Verified form handling pattern
+- [Next.js Official Docs: proxy.ts](https://nextjs.org/docs/app/getting-started/proxy) - Verified Next.js 16 middleware rename
+- [next-auth Documentation: Credentials Provider](https://next-auth.js.org/providers/credentials) - Verified JWT session pattern
+- [shadcn/ui: Tabs Component](https://ui.shadcn.com/docs/components/radix/tabs) - Installation via `npx shadcn@latest add tabs`
+
+### Secondary (MEDIUM confidence)
+
+- [GitHub Issue #13302: next-auth v4 + Next.js 16 peer dependency](https://github.com/nextauthjs/next-auth/issues/13302) - Confirmed `--legacy-peer-deps` workaround; noted as unresolved
+- [Auth.js v5 Migration Guide](https://authjs.dev/getting-started/migrating-to-v5) - Noted v5 alternatives and proxy.ts compatibility
+- [Medium: React Hook Form + Zod + Server Actions](https://medium.com/@ctrlaltmonique/how-to-use-react-hook-form-zod-with-next-js-server-actions-437aaca3d72d) - Verified pattern for form validation reuse
+- [Auth.js: Route Protection](https://authjs.dev/getting-started/session-management/protecting) - Verified getToken() pattern in middleware
+
+### Tertiary (LOW confidence - training/assumed)
+
+- Best practice: "Defense in depth" — validate auth in both middleware and route handlers (avoid single point of failure)
+- Assumption: `getToken()` performance is acceptable for every Edge request (no benchmark available; flagged as A2 for Wave 1 validation)
+
+## Metadata
+
+**Confidence breakdown:**
+- **Standard Stack (MEDIUM-HIGH):** next-auth v4 peer dependency unresolved (A1), but installation workaround verified. All other deps (RHF, Zod, shadcn/ui, Drizzle) stable. Recommend Phase 2 Wave 0 validation.
+- **Architecture (HIGH):** Server Actions, revalidatePath, proxy.ts patterns all from official Next.js 16 docs. Drizzle ORM verified Phase 1. next-auth getToken() documented but performance at Edge untested (A2).
+- **Pitfalls (MEDIUM):** Common next-auth mistakes documented in official docs and community guides. postgres-js Edge incompatibility already encountered Phase 1. Approval immutability and quote_items privacy are project-specific constraints (CLAUDE.md).
+
+**Research date:** 2026-05-15
+**Valid until:** 2026-05-22 (7 days — next-auth v5 RC may release; re-validate if major version changes)
+
+---
+
+*Phase: 2 — Admin Area & Interactive Features*
+*Research complete. Ready for planning.*