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 + + +
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + /> +
+
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + /> +
+ {error && ( +

{error}

+ )} + +
+
+
+
+ ); + } + ``` + + 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 ( + + ); + } + ``` + + 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

+ +
+ + {clients.length === 0 ? ( +
+

Nessun cliente ancora.

+

+ + Crea il primo cliente + +

+
+ ) : ( +
+ + + + + + + + + + + + {clients.map((client) => ( + + ))} + +
ClienteTotaleAccontoSaldoLink
+
+ )} +
+ ); + } + ``` + + 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 + + +
+
+ + +
+
+ + +
+
+ +