--- phase: "03" plan: "02" type: execute wave: 2 depends_on: - "03-01" files_modified: - src/app/admin/catalog/page.tsx - src/app/admin/catalog/actions.ts - src/components/admin/catalog/ServiceTable.tsx - src/components/admin/catalog/ServiceForm.tsx - src/components/admin/NavBar.tsx autonomous: true requirements: - CAT-01 must_haves: truths: - "Admin can navigate to /admin/catalog from the NavBar ('Catalogo' link visible between Statistiche and Esci)" - "Admin can see a table of all services with columns Nome | Descrizione | Prezzo | Stato | Azioni" - "Admin can add a new service via an inline form (name, optional description, unit price) — it appears in the table after save" - "Admin can click 'Modifica' on a row and edit name, description, price inline — changes persist after save" - "Admin can click 'Disattiva' to soft-delete a service (active=false) — row shows 'Disattivato' badge at 50% opacity" - "Admin can click 'Riattiva' on a disabled service to re-enable it" - "Inactive services remain visible in the table (with badge) but are excluded from the quote builder dropdown" artifacts: - path: "src/app/admin/catalog/page.tsx" provides: "Service catalog page — server component, fetches all services, renders table" contains: "getAllServices" - path: "src/app/admin/catalog/actions.ts" provides: "Server Actions: createService, updateService, toggleServiceActive" exports: ["createService", "updateService", "toggleServiceActive"] - path: "src/components/admin/catalog/ServiceTable.tsx" provides: "Table with per-row inline edit and active toggle" contains: "ServiceTable" - path: "src/components/admin/catalog/ServiceForm.tsx" provides: "Add-new-service form rendered above table" contains: "ServiceForm" - path: "src/components/admin/NavBar.tsx" provides: "NavBar with Catalogo link added" contains: "/admin/catalog" key_links: - from: "src/components/admin/catalog/ServiceForm.tsx" to: "src/app/admin/catalog/actions.ts createService" via: "form action" pattern: "createService" - from: "src/components/admin/catalog/ServiceTable.tsx" to: "src/app/admin/catalog/actions.ts updateService / toggleServiceActive" via: "Server Action calls in useTransition" pattern: "updateService|toggleServiceActive" - from: "src/app/admin/catalog/page.tsx" to: "src/lib/admin-queries.ts getAllServices (new function)" via: "await getAllServices()" pattern: "getAllServices" --- Deliver the complete `/admin/catalog` page: NavBar link, page layout, table with inline edit, add-service form, and soft-delete toggle. This is a self-contained vertical slice — after this plan executes, the admin can manage the service catalog end-to-end. Purpose: Fulfills CAT-01 (service database with prices). Provides the catalog data that Wave 2's Quote Builder (plan 03-03) will query for its dropdown. Output: 5 new/modified files — a fully functional service catalog page. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md @/Users/simonecavalli/IAMCAVALLI/.planning/phases/03-service-catalog-quote-builder/03-01-SUMMARY.md ```typescript // Server component, fetches data, renders table + header export default async function AdminDashboard() { const clients = await getAllClientsWithPayments(); return (

Clienti

{/* table */}
); } ``` ```typescript "use client"; import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; export function DocumentRow({ doc, clientId }) { const [editing, setEditing] = useState(false); const [error, setError] = useState(null); const [, startTransition] = useTransition(); const router = useRouter(); function handleSave(fd: FormData) { setError(null); startTransition(async () => { try { await updateDocument(doc.id, clientId, fd); setEditing(false); router.refresh(); } catch (e) { setError(e instanceof Error ? e.message : "Errore nel salvataggio"); } }); } // ... } ``` ```typescript "use server"; import { z } from "zod"; import { db } from "@/db"; import { revalidatePath } from "next/cache"; const clientSchema = z.object({ name: z.string().min(1, "Nome richiesto"), brand_name: z.string().min(1, "Brand name richiesto"), brief: z.string(), }); export async function updateClient(clientId: string, formData: FormData) { const parsed = clientSchema.safeParse({ name: formData.get("name"), brand_name: formData.get("brand_name"), brief: formData.get("brief") ?? "", }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); await db.update(clients).set(parsed.data).where(eq(clients.id, clientId)); revalidatePath("/admin"); } ``` ```typescript // src/components/admin/NavBar.tsx lines 7-29 export function NavBar() { return ( ); } ``` ```typescript // Table container
Colonna
...
// Status badge — active Attivo // Status badge — inactive Disattivato // Currency display €{parseFloat(amount).toLocaleString("it-IT", { minimumFractionDigits: 2 })} ``` ```typescript export type ServiceCatalog = typeof service_catalog.$inferSelect; // Fields: id: string, name: string, description: string | null, unit_price: string, active: boolean ```
Task 1: Server Actions + getAllServices query - /Users/simonecavalli/IAMCAVALLI/src/app/admin/clients/[id]/actions.ts (Zod + Server Action pattern to replicate) - /Users/simonecavalli/IAMCAVALLI/src/lib/admin-queries.ts (add getAllServices here, following existing function style) - /Users/simonecavalli/IAMCAVALLI/src/db/schema.ts (confirm service_catalog fields after 03-01 changes) src/app/admin/catalog/actions.ts src/lib/admin-queries.ts **Create `src/app/admin/catalog/actions.ts`** — three Server Actions following exact Zod+FormData pattern from `clients/[id]/actions.ts`: ```typescript "use server"; import { db } from "@/db"; import { service_catalog } from "@/db/schema"; import { revalidatePath } from "next/cache"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; const serviceSchema = z.object({ name: z.string().min(1, "Nome richiesto"), description: z.string().optional(), unit_price: z.coerce.number().min(0.01, "Prezzo deve essere maggiore di 0"), }); async function requireAdmin() { const session = await getServerSession(authOptions); if (!session) throw new Error("Non autorizzato"); } export async function createService(formData: FormData) { await requireAdmin(); const parsed = serviceSchema.safeParse({ name: formData.get("name"), description: formData.get("description") ?? "", unit_price: formData.get("unit_price"), }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); await db.insert(service_catalog).values({ name: parsed.data.name, description: parsed.data.description ?? null, unit_price: parsed.data.unit_price.toFixed(2), }); revalidatePath("/admin/catalog"); } export async function updateService(serviceId: string, formData: FormData) { await requireAdmin(); const parsed = serviceSchema.safeParse({ name: formData.get("name"), description: formData.get("description") ?? "", unit_price: formData.get("unit_price"), }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); await db .update(service_catalog) .set({ name: parsed.data.name, description: parsed.data.description ?? null, unit_price: parsed.data.unit_price.toFixed(2), }) .where(eq(service_catalog.id, serviceId)); revalidatePath("/admin/catalog"); } export async function toggleServiceActive(serviceId: string, active: boolean) { await requireAdmin(); await db .update(service_catalog) .set({ active }) .where(eq(service_catalog.id, serviceId)); revalidatePath("/admin/catalog"); } ``` **Add `getAllServices()` to `src/lib/admin-queries.ts`** — append at end of file before the closing exports: ```typescript export async function getAllServices(): Promise { return db .select() .from(service_catalog) .orderBy(asc(service_catalog.name)); } ``` Also add `service_catalog` to the imports at top of admin-queries.ts, and `ServiceCatalog` to the type imports. Add `asc` if not already imported from `drizzle-orm`. cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function createService' src/app/admin/catalog/actions.ts Expected: 1 cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function updateService' src/app/admin/catalog/actions.ts Expected: 1 cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function toggleServiceActive' src/app/admin/catalog/actions.ts Expected: 1 cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function getAllServices' src/lib/admin-queries.ts Expected: 1 cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20 Expected: no output (zero errors) Three Server Actions exported from `catalog/actions.ts`. `getAllServices()` added to `admin-queries.ts`. TypeScript compiles clean. Task 2: Service Catalog page + components (ServiceTable, ServiceForm) + NavBar link - /Users/simonecavalli/IAMCAVALLI/src/app/admin/page.tsx (page structure to mirror) - /Users/simonecavalli/IAMCAVALLI/src/components/admin/DocumentRow.tsx (inline edit pattern to replicate) - /Users/simonecavalli/IAMCAVALLI/src/components/admin/NavBar.tsx (current NavBar to add Catalogo link) - /Users/simonecavalli/IAMCAVALLI/src/app/admin/catalog/actions.ts (actions just created in Task 1) src/app/admin/catalog/page.tsx src/components/admin/catalog/ServiceTable.tsx src/components/admin/catalog/ServiceForm.tsx src/components/admin/NavBar.tsx **Create `src/app/admin/catalog/page.tsx`** — Server Component mirroring `src/app/admin/page.tsx`: ```typescript import { getAllServices } from "@/lib/admin-queries"; import { ServiceTable } from "@/components/admin/catalog/ServiceTable"; import { ServiceForm } from "@/components/admin/catalog/ServiceForm"; export const revalidate = 0; export default async function CatalogPage() { const services = await getAllServices(); return (

Catalogo Servizi

{services.length === 0 ? (

Nessun servizio nel catalogo. Aggiungi il primo servizio qui sopra.

) : ( )}
); } ``` **Create `src/components/admin/catalog/ServiceForm.tsx`** — inline add-new-service form using Server Action: ```typescript "use client"; import { useRef, useState, useTransition } from "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 { createService } from "@/app/admin/catalog/actions"; export function ServiceForm() { const [open, setOpen] = useState(false); const [error, setError] = useState(null); const [, startTransition] = useTransition(); const router = useRouter(); const formRef = useRef(null); function handleSubmit(fd: FormData) { setError(null); startTransition(async () => { try { await createService(fd); formRef.current?.reset(); setOpen(false); router.refresh(); } catch (e) { setError(e instanceof Error ? e.message : "Errore nel salvataggio"); } }); } if (!open) { return ( ); } return (

Nuovo servizio

{error &&

{error}

}
); } ``` **Create `src/components/admin/catalog/ServiceTable.tsx`** — table with per-row inline edit, following DocumentRow pattern: ```typescript "use client"; import { useState, useTransition } from "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 { updateService, toggleServiceActive } from "@/app/admin/catalog/actions"; import type { ServiceCatalog } from "@/db/schema"; function ServiceRow({ service }: { service: ServiceCatalog }) { const [editing, setEditing] = useState(false); const [error, setError] = useState(null); const [, startTransition] = useTransition(); const router = useRouter(); function handleSave(fd: FormData) { setError(null); startTransition(async () => { try { await updateService(service.id, fd); setEditing(false); router.refresh(); } catch (e) { setError(e instanceof Error ? e.message : "Errore nel salvataggio"); } }); } function handleToggle() { startTransition(async () => { await toggleServiceActive(service.id, !service.active); router.refresh(); }); } if (editing) { return (
{error &&

{error}

}
); } return ( {service.name} {service.description ?? "—"} €{parseFloat(service.unit_price).toLocaleString("it-IT", { minimumFractionDigits: 2 })} {service.active ? ( Attivo ) : ( Disattivato )}
); } export function ServiceTable({ services }: { services: ServiceCatalog[] }) { return (
{services.map((s) => ( ))}
Nome Descrizione Prezzo Stato
); } ``` **Modify `src/components/admin/NavBar.tsx`** — add Catalogo link after the Statistiche link: ```typescript Catalogo ``` Insert this line immediately after the existing `Statistiche` line.
cd /Users/simonecavalli/IAMCAVALLI && grep -c '/admin/catalog' src/components/admin/NavBar.tsx Expected: 1 cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export function ServiceTable' src/components/admin/catalog/ServiceTable.tsx Expected: 1 cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export function ServiceForm' src/components/admin/catalog/ServiceForm.tsx Expected: 1 cd /Users/simonecavalli/IAMCAVALLI && grep -c 'getAllServices' src/app/admin/catalog/page.tsx Expected: 1 cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20 Expected: no output (zero errors) cd /Users/simonecavalli/IAMCAVALLI && npm run build 2>&1 | tail -10 Expected: "Compiled successfully" or "Route (app)" output with no errors NavBar shows "Catalogo" link. `/admin/catalog` page renders. ServiceTable and ServiceForm compile. Full `npm run build` passes. Admin can navigate to `/admin/catalog` and see the table.
## Trust Boundaries | Boundary | Description | |----------|-------------| | Admin browser → Server Actions (catalog/actions.ts) | FormData from admin form crosses to server; must be validated before DB write | | /admin/catalog route → Auth.js session | All catalog routes inherit the `/admin/*` middleware session check from Phase 2; no additional guard needed at page level | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-03-02-01 | Spoofing | createService / updateService / toggleServiceActive | mitigate | `requireAdmin()` calls `getServerSession(authOptions)` at the top of every Server Action — rejects if no valid session | | T-03-02-02 | Tampering | serviceSchema Zod validation | mitigate | `unit_price` validated as `z.coerce.number().min(0.01)` — prevents zero/negative prices; `name` requires min length 1 | | T-03-02-03 | Tampering | updateService serviceId parameter | mitigate | serviceId is bound at call site in the Server Action closure — admin can only modify the row ID passed from the server-rendered page | | T-03-02-04 | Information Disclosure | /admin/catalog page | accept | Page is behind Auth.js `/admin/*` middleware (enforced in Phase 2); service prices are admin-internal data, not client-facing | | T-03-02-05 | Tampering | XSS in service name / description | accept | React JSX auto-escapes all string output; no `dangerouslySetInnerHTML` used; UI-SPEC forbids it | After both tasks complete: 1. `grep '/admin/catalog' src/components/admin/NavBar.tsx` returns 1 match 2. `npx tsc --noEmit` exits clean 3. `npm run build` succeeds 4. Navigating to `/admin/catalog` (dev server) shows the catalog page with table headers and "Aggiungi servizio" button 5. Adding a service via the form makes it appear in the table 6. Clicking "Disattiva" changes badge to "Disattivato" and reduces row opacity - `/admin/catalog` route is accessible from NavBar and renders without error - All three Server Actions (createService, updateService, toggleServiceActive) are exported from `catalog/actions.ts` with Zod validation and `requireAdmin()` guard - ServiceTable renders per-row inline edit using the DocumentRow pattern - Inactive services show "Disattivato" badge; active services show "Attivo" badge - TypeScript and build both pass clean After completion, create `.planning/phases/03-service-catalog-quote-builder/03-02-SUMMARY.md`