diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 1ce816c..6105889 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -68,9 +68,14 @@ Decimal phases appear between their surrounding integers in numeric order. 1. L'admin può aggiungere, modificare e disattivare voci nel catalogo servizi (nome, descrizione, prezzo unitario) 2. L'admin può comporre un preventivo per un cliente selezionando voci dal catalogo; il sistema calcola il totale 3. Dopo la finalizzazione del preventivo, `accepted_total` è scritto sulla riga cliente e la dashboard del cliente mostra il totale corretto; i `quote_items` non sono mai esposti al cliente -**Plans**: TBD +**Plans**: 4 plans +**Plan list**: + - [ ] 03-01-PLAN.md — Schema changes (service_id nullable, custom_label) + drizzle-kit push [BLOCKING] + - [ ] 03-02-PLAN.md — Service Catalog: /admin/catalog page + CRUD actions + ServiceTable + NavBar link + - [ ] 03-03-PLAN.md — Quote Builder: QuoteTab + quote-actions + client detail page wiring + - [ ] 03-04-PLAN.md — E2E verification: catalog CRUD, quote round-trip, accepted_total, security check **UI hint**: yes -**Status**: Pending planning +**Status**: Planned — ready for execution ### Phase 4: Claude AI Onboarding (v2) **Goal**: L'admin può usare un flusso chat guidato per onboardare un nuovo cliente e generare un piano a fasi e un preventivo assistito da Claude @@ -94,5 +99,5 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 |-------|-------|--------|-----------| | 1. Foundation & Client Dashboard | 5/5 | ✅ Done | 2026-05-14 | | 2. Admin Area & Interactive Features | 4/4 | Planned | - | -| 3. Service Catalog & Quote Builder | 0/TBD | Pending | - | -| 4. Claude AI Onboarding (v2) | 0/TBD | Pending | - | +| 3. Service Catalog & Quote Builder | 4/4 | Planned | - | +| 4. Claude AI Onboarding (v2) | 0/TBD | Pending | - | \ No newline at end of file diff --git a/.planning/phases/03-service-catalog-quote-builder/03-01-PLAN.md b/.planning/phases/03-service-catalog-quote-builder/03-01-PLAN.md new file mode 100644 index 0000000..8a058eb --- /dev/null +++ b/.planning/phases/03-service-catalog-quote-builder/03-01-PLAN.md @@ -0,0 +1,216 @@ +--- +phase: "03" +plan: "01" +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/db/schema.ts +autonomous: true +requirements: + - CAT-01 + - CAT-02 + - ADMIN-03 + +must_haves: + truths: + - "quote_items.service_id is nullable in the database (free-form items can be inserted without a catalog reference)" + - "quote_items.custom_label column exists in the database (free-form label storage)" + - "TypeScript QuoteItem type reflects both changes (no compile errors when service_id is null or custom_label is set)" + - "drizzle-kit push completes without errors against the live Neon database" + artifacts: + - path: "src/db/schema.ts" + provides: "Updated quote_items table definition with nullable service_id and custom_label column" + contains: "custom_label: text(\"custom_label\")" + key_links: + - from: "src/db/schema.ts quote_items.service_id" + to: "Neon Postgres quote_items table" + via: "drizzle-kit push" + pattern: "service_id.*references.*service_catalog" +--- + + +Make the two schema changes required for free-form quote items — make `service_id` nullable and add `custom_label text` — then push the changes to the live Neon database. + +Purpose: All subsequent plans (Wave 2) reference `custom_label` and insert rows with `service_id = null`. Without this push, the DB will reject those inserts with a column-not-found or NOT NULL constraint error. +Output: Updated `src/db/schema.ts` and a successful `drizzle-kit push` confirmation. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@/Users/simonecavalli/IAMCAVALLI/.planning/PROJECT.md +@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md + + + +```typescript +export const quote_items = pgTable("quote_items", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + client_id: text("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + service_id: text("service_id") + .notNull() // <-- REMOVE .notNull() + .references(() => service_catalog.id, { onDelete: "restrict" }), + quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(), + unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), + subtotal: numeric("subtotal", { precision: 10, scale: 2 }).notNull(), + // custom_label missing — ADD after subtotal +}); +``` + + +```typescript +export const quote_items = pgTable("quote_items", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + client_id: text("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + service_id: text("service_id") + .references(() => service_catalog.id, { onDelete: "restrict" }), // nullable — no .notNull() + quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(), + unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), + subtotal: numeric("subtotal", { precision: 10, scale: 2 }).notNull(), + custom_label: text("custom_label"), // new field +}); +``` + + +```typescript +export const quoteItemsRelations = relations(quote_items, ({ one }) => ({ + client: one(clients, { fields: [quote_items.client_id], references: [clients.id] }), + service: one(service_catalog, { + fields: [quote_items.service_id], + references: [service_catalog.id], + }), +})); +``` + + + +```typescript +export type QuoteItem = typeof quote_items.$inferSelect; +// QuoteItem.service_id will be: string | null +// QuoteItem.custom_label will be: string | null +``` + + + + + + + Task 1: Update quote_items schema — make service_id nullable and add custom_label + + - /Users/simonecavalli/IAMCAVALLI/src/db/schema.ts (full file — understand current definition before editing) + + src/db/schema.ts + +Edit `src/db/schema.ts` — two targeted changes to the `quote_items` table definition (lines 159-172): + +**Change 1 — Remove `.notNull()` from service_id (per D-03 from CONTEXT.md):** +Before: +```typescript + service_id: text("service_id") + .notNull() + .references(() => service_catalog.id, { onDelete: "restrict" }), +``` +After: +```typescript + service_id: text("service_id") + .references(() => service_catalog.id, { onDelete: "restrict" }), +``` + +**Change 2 — Add custom_label field after the `subtotal` line:** +```typescript + custom_label: text("custom_label"), +``` + +No other changes to the file. The `quoteItemsRelations` block does NOT need to change. +After the edit, run `npx tsc --noEmit` to confirm zero TypeScript errors before pushing. + + + cd /Users/simonecavalli/IAMCAVALLI && grep -v '^//' src/db/schema.ts | grep -c 'custom_label: text("custom_label")' + Expected: 1 + + cd /Users/simonecavalli/IAMCAVALLI && grep -A3 'service_id: text("service_id")' src/db/schema.ts | grep -c 'notNull' + Expected: 0 (notNull must be gone from service_id) + + cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20 + Expected: no output (zero errors) + + + `src/db/schema.ts` compiles with zero TypeScript errors. `service_id` has no `.notNull()`. `custom_label: text("custom_label")` is present in the quote_items table definition. + + + + + Task 2: [BLOCKING] Push schema changes to Neon database + + - /Users/simonecavalli/IAMCAVALLI/.env.local (verify DATABASE_URL is set before running push) + - /Users/simonecavalli/IAMCAVALLI/drizzle.config.ts (verify push config points to correct schema) + + — (no source files modified; runs drizzle-kit against live DB) + +Run drizzle-kit push with the .env.local DATABASE_URL loaded: + +```bash +cd /Users/simonecavalli/IAMCAVALLI +set -a && source .env.local && set +a && npx drizzle-kit push +``` + +When prompted to confirm schema changes, accept all changes. The push will: +1. DROP NOT NULL constraint from `quote_items.service_id` +2. ADD COLUMN `custom_label text` to `quote_items` + +If the push fails with "column already exists" for `custom_label`, the column was already added in a prior run — this is safe to ignore. Verify the column exists by checking the push output or running a quick query. + +Do NOT skip this task. Wave 2 plans cannot execute correctly without the DB columns existing. + + + cd /Users/simonecavalli/IAMCAVALLI && set -a && source .env.local && set +a && npx drizzle-kit push 2>&1 | tail -5 + Expected: Output contains "No changes" or "Changes applied" — either confirms the schema is in sync. + + + `drizzle-kit push` exits without error. The live Neon DB has `quote_items.service_id` as nullable and `quote_items.custom_label text` column present. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Schema file → Neon DB | drizzle-kit push executes DDL against the live database; misconfigured DATABASE_URL would push to wrong environment | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-03-01-01 | Tampering | drizzle-kit push | mitigate | Always load DATABASE_URL from `.env.local` (not hardcoded); verify `.env.local` exists before running push | +| T-03-01-02 | Denial of Service | Neon DB DDL | accept | Schema changes are additive (ADD COLUMN, DROP NOT NULL) — no data loss risk; onDelete: "restrict" prevents orphaned quote_items | + + + +After both tasks complete: +1. `grep 'custom_label' src/db/schema.ts` returns the field definition +2. `grep -A3 'service_id: text' src/db/schema.ts` shows no `.notNull()` on service_id +3. `npx tsc --noEmit` exits 0 +4. `npx drizzle-kit push` reports "No changes" (schema is in sync with DB) + + + +- `src/db/schema.ts` has nullable `service_id` and `custom_label: text("custom_label")` in quote_items +- TypeScript compiles with zero errors +- drizzle-kit push confirms schema is synced to Neon DB +- Wave 2 plans can safely reference `custom_label` and insert rows with `service_id = null` + + + +After completion, create `.planning/phases/03-service-catalog-quote-builder/03-01-SUMMARY.md` + \ No newline at end of file diff --git a/.planning/phases/03-service-catalog-quote-builder/03-02-PLAN.md b/.planning/phases/03-service-catalog-quote-builder/03-02-PLAN.md new file mode 100644 index 0000000..7d1e613 --- /dev/null +++ b/.planning/phases/03-service-catalog-quote-builder/03-02-PLAN.md @@ -0,0 +1,647 @@ +--- +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) => ( + + ))} + +
NomeDescrizionePrezzoStato
+
+ ); +} +``` + +**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` + \ No newline at end of file diff --git a/.planning/phases/03-service-catalog-quote-builder/03-03-PLAN.md b/.planning/phases/03-service-catalog-quote-builder/03-03-PLAN.md new file mode 100644 index 0000000..53ab9ff --- /dev/null +++ b/.planning/phases/03-service-catalog-quote-builder/03-03-PLAN.md @@ -0,0 +1,725 @@ +--- +phase: "03" +plan: "03" +type: execute +wave: 2 +depends_on: + - "03-01" +files_modified: + - src/app/admin/clients/[id]/quote-actions.ts + - src/components/admin/tabs/QuoteTab.tsx + - src/app/admin/clients/[id]/page.tsx + - src/lib/admin-queries.ts +autonomous: true +requirements: + - CAT-02 + - ADMIN-03 + +must_haves: + truths: + - "Admin can see a 'Preventivo' tab in /admin/clients/[id] — the 5th tab after Commenti" + - "Admin can select an active catalog service from a dropdown and add it (with qty) to the quote — the item appears in the table with snapshotted unit_price" + - "Admin can toggle to 'Voce libera' mode and add a custom label + price + qty item (service_id = null in DB)" + - "Admin can click 'Rimuovi' to delete a quote item — it disappears from the table" + - "The table footer shows 'Totale calcolato' as the sum of all subtotals" + - "Admin can set a separate 'Totale accettato dal cliente' via an editable input + Salva button — this writes to clients.accepted_total" + - "quote_items are NEVER returned by any client-facing route — only clients.accepted_total is visible to clients" + artifacts: + - path: "src/app/admin/clients/[id]/quote-actions.ts" + provides: "Server Actions: addQuoteItem, removeQuoteItem, updateAcceptedTotal" + exports: ["addQuoteItem", "removeQuoteItem", "updateAcceptedTotal"] + - path: "src/components/admin/tabs/QuoteTab.tsx" + provides: "Quote builder UI — add items (catalog + freeform), items table, accepted total editor" + contains: "QuoteTab" + - path: "src/app/admin/clients/[id]/page.tsx" + provides: "Client detail page with 5th Preventivo tab wired to QuoteTab" + contains: "Preventivo" + - path: "src/lib/admin-queries.ts" + provides: "getClientFullDetail extended to include quoteItems and activeServices" + contains: "quoteItems" + key_links: + - from: "src/components/admin/tabs/QuoteTab.tsx add-item form" + to: "src/app/admin/clients/[id]/quote-actions.ts addQuoteItem" + via: "form action (Server Action)" + pattern: "addQuoteItem" + - from: "src/components/admin/tabs/QuoteTab.tsx remove button" + to: "src/app/admin/clients/[id]/quote-actions.ts removeQuoteItem" + via: "form action" + pattern: "removeQuoteItem" + - from: "src/components/admin/tabs/QuoteTab.tsx accepted total form" + to: "src/app/admin/clients/[id]/quote-actions.ts updateAcceptedTotal" + via: "form action" + pattern: "updateAcceptedTotal" + - from: "src/app/admin/clients/[id]/page.tsx" + to: "src/lib/admin-queries.ts getClientFullDetail" + via: "await getClientFullDetail(id)" + pattern: "getClientFullDetail" +--- + + +Deliver the "Preventivo" tab in the admin client detail page. This is the quote builder vertical slice: Server Actions for quote item CRUD + accepted_total write, the QuoteTab component (catalog dropdown + freeform toggle + items table + accepted total editor), and the wiring of both into the existing client detail page. + +Purpose: Fulfills CAT-02 (catalog as quote generation base) and ADMIN-03 (full quote detail visible to admin only). The client sees only `clients.accepted_total` — this constraint is enforced at the query layer. +Output: 4 new/modified files — a fully operational quote builder tab. + + + +@$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 +// src/app/admin/clients/[id]/page.tsx (current) + + + Fasi & Task + Pagamenti + Documenti + Commenti + {/* ADD: Preventivo */} + + {/* ADD: */} + +``` + + +```typescript +export type ClientFullDetail = { + client: Client; + phases: Array }>; + payments: Payment[]; + documents: Document[]; + notes: Note[]; + comments: Comment[]; + // ADD: + // quoteItems: QuoteItemWithLabel[]; + // activeServices: ServiceCatalog[]; +}; +``` + + +```typescript +// src/components/admin/tabs/PaymentsTab.tsx pattern +export async function PaymentsTab({ payments, acceptedTotal, clientId }: Props) { + return ( +
+
+

...

+
{ "use server"; await updateAcceptedTotal(clientId, fd); }}> + ... +
+
+
+ ); +} +``` + + +// quote_items NEVER exposed — security constraint from Phase 1 (CLAUDE.md) +// Only clients.accepted_total is visible to client-facing routes + + +```typescript +import { sql, eq, asc } from "drizzle-orm"; +import { quote_items, service_catalog, clients } from "@/db/schema"; + +// Get quote items for a client — service name from catalog OR custom_label +const items = await db + .select({ + id: quote_items.id, + label: sql`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`, + custom_label: quote_items.custom_label, + service_id: quote_items.service_id, + quantity: quote_items.quantity, + unit_price: quote_items.unit_price, // snapshotted — NEVER use service_catalog.unit_price + subtotal: quote_items.subtotal, + }) + .from(quote_items) + .leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id)) + .where(eq(quote_items.client_id, clientId)) + .orderBy(asc(quote_items.id)); +``` + + +```typescript +const qty = parseFloat(formData.get("quantity") as string); +const price = parseFloat(formData.get("unit_price") as string); +const subtotal = (qty * price).toFixed(2); +// Insert: unit_price stored as string with 2dp (matches numeric(10,2) column) +await db.insert(quote_items).values({ + client_id: clientId, + service_id: serviceId ?? null, // null for freeform items + custom_label: customLabel ?? null, + quantity: qty.toFixed(2), + unit_price: price.toFixed(2), + subtotal, +}); +``` + + +```typescript +export type ServiceCatalog = typeof service_catalog.$inferSelect; +// Fields: id: string, name: string, unit_price: string, active: boolean, description: string | null +``` + + +```typescript +export type QuoteItem = typeof quote_items.$inferSelect; +// Fields: id, client_id, service_id: string | null, custom_label: string | null, +// quantity, unit_price, subtotal (all numeric as string) +``` +
+
+ + + + + Task 1: quote-actions.ts Server Actions + extend getClientFullDetail + + - /Users/simonecavalli/IAMCAVALLI/src/app/admin/clients/[id]/actions.ts (pattern: Zod, requireAdmin, revalidatePath) + - /Users/simonecavalli/IAMCAVALLI/src/lib/admin-queries.ts (current getClientFullDetail to extend — add quoteItems and activeServices) + - /Users/simonecavalli/IAMCAVALLI/src/db/schema.ts (confirm custom_label and nullable service_id from 03-01) + - /Users/simonecavalli/IAMCAVALLI/src/lib/client-view.ts (VERIFY this file does NOT query quote_items — if it does, remove that query) + + + src/app/admin/clients/[id]/quote-actions.ts + src/lib/admin-queries.ts + + +**Create `src/app/admin/clients/[id]/quote-actions.ts`** — three Server Actions: + +```typescript +"use server"; + +// quote_items NEVER exposed — security constraint from Phase 1 (CLAUDE.md) +// Only clients.accepted_total is visible to client-facing routes + +import { db } from "@/db"; +import { quote_items, clients, 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"; + +async function requireAdmin() { + const session = await getServerSession(authOptions); + if (!session) throw new Error("Non autorizzato"); +} + +const quoteItemSchema = z.object({ + service_id: z.string().nullable(), + custom_label: z.string().nullable(), + quantity: z.coerce.number().min(0.01, "Quantità deve essere > 0"), + unit_price: z.coerce.number().min(0.01, "Prezzo deve essere > 0"), +}); + +export async function addQuoteItem(clientId: string, formData: FormData) { + await requireAdmin(); + + const rawServiceId = formData.get("service_id") as string | null; + const rawCustomLabel = formData.get("custom_label") as string | null; + + const parsed = quoteItemSchema.safeParse({ + service_id: rawServiceId && rawServiceId !== "" ? rawServiceId : null, + custom_label: rawCustomLabel && rawCustomLabel !== "" ? rawCustomLabel : null, + quantity: formData.get("quantity"), + unit_price: formData.get("unit_price"), + }); + + if (!parsed.success) throw new Error(parsed.error.issues[0].message); + + // Validate: either service_id or custom_label must be present + if (!parsed.data.service_id && !parsed.data.custom_label) { + throw new Error("Seleziona un servizio dal catalogo o inserisci il nome di una voce libera"); + } + + const { service_id, custom_label, quantity, unit_price } = parsed.data; + const subtotal = (quantity * unit_price).toFixed(2); + + await db.insert(quote_items).values({ + client_id: clientId, + service_id: service_id ?? null, + custom_label: custom_label ?? null, + quantity: quantity.toFixed(2), + unit_price: unit_price.toFixed(2), + subtotal, + }); + + revalidatePath(`/admin/clients/${clientId}`); +} + +export async function removeQuoteItem(quoteItemId: string, clientId: string) { + await requireAdmin(); + await db.delete(quote_items).where(eq(quote_items.id, quoteItemId)); + revalidatePath(`/admin/clients/${clientId}`); +} + +export async function updateAcceptedTotal(clientId: string, formData: FormData) { + await requireAdmin(); + const raw = (formData.get("accepted_total") as string)?.trim(); + const val = parseFloat(raw); + if (isNaN(val) || val < 0) throw new Error("Importo non valido"); + await db + .update(clients) + .set({ accepted_total: val.toFixed(2) }) + .where(eq(clients.id, clientId)); + revalidatePath(`/admin/clients/${clientId}`); +} +``` + +**Extend `src/lib/admin-queries.ts`** — add `QuoteItemWithLabel` type and extend `ClientFullDetail` + `getClientFullDetail`: + +1. Add imports at top: `quote_items`, `service_catalog` from `@/db/schema`; `sql` from `drizzle-orm`; `ServiceCatalog` from `@/db/schema`. + +2. Add new type before `ClientFullDetail`: +```typescript +export type QuoteItemWithLabel = { + id: string; + label: string; // COALESCE(service_catalog.name, quote_items.custom_label) + custom_label: string | null; + service_id: string | null; + quantity: string; + unit_price: string; // snapshotted — never joined back to service_catalog.unit_price + subtotal: string; +}; +``` + +3. Add `quoteItems: QuoteItemWithLabel[]` and `activeServices: ServiceCatalog[]` to the `ClientFullDetail` type. + +4. Add two queries inside `getClientFullDetail()` before the `return` statement: + +```typescript + // quote_items NEVER exposed via client API — admin workspace query only + const quoteItemRows: QuoteItemWithLabel[] = await db + .select({ + id: quote_items.id, + label: sql`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`, + custom_label: quote_items.custom_label, + service_id: quote_items.service_id, + quantity: quote_items.quantity, + unit_price: quote_items.unit_price, + subtotal: quote_items.subtotal, + }) + .from(quote_items) + .leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id)) + .where(eq(quote_items.client_id, id)) + .orderBy(asc(quote_items.id)); + + const activeServiceRows = await db + .select() + .from(service_catalog) + .where(eq(service_catalog.active, true)) + .orderBy(asc(service_catalog.name)); +``` + +5. Add `quoteItems: quoteItemRows` and `activeServices: activeServiceRows` to the return object. + +IMPORTANT: Also read `src/lib/client-view.ts` to verify it does NOT query `quote_items`. If it does, remove that query entirely — `accepted_total` is the only field the client sees. + + + cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function addQuoteItem' src/app/admin/clients/\[id\]/quote-actions.ts + Expected: 1 + cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function removeQuoteItem' src/app/admin/clients/\[id\]/quote-actions.ts + Expected: 1 + cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function updateAcceptedTotal' src/app/admin/clients/\[id\]/quote-actions.ts + Expected: 1 + cd /Users/simonecavalli/IAMCAVALLI && grep -c 'quoteItems' src/lib/admin-queries.ts + Expected: 3 or more (type definition, query, return) + cd /Users/simonecavalli/IAMCAVALLI && grep -c 'quote_items' src/lib/client-view.ts 2>/dev/null || echo 0 + Expected: 0 (quote_items must NOT appear in client-view.ts) + cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20 + Expected: no output (zero errors) + + + Three Server Actions exported with `requireAdmin()` guard and Zod validation. `getClientFullDetail` returns `quoteItems` and `activeServices`. `client-view.ts` contains zero references to `quote_items`. TypeScript compiles clean. + + + + + Task 2: QuoteTab component + wire into client detail page + + - /Users/simonecavalli/IAMCAVALLI/src/components/admin/tabs/PaymentsTab.tsx (exact analog structure to follow) + - /Users/simonecavalli/IAMCAVALLI/src/app/admin/clients/[id]/page.tsx (current tab structure to extend) + - /Users/simonecavalli/IAMCAVALLI/src/app/admin/clients/[id]/quote-actions.ts (actions from Task 1) + - /Users/simonecavalli/IAMCAVALLI/src/lib/admin-queries.ts (updated ClientFullDetail type from Task 1) + + + src/components/admin/tabs/QuoteTab.tsx + src/app/admin/clients/[id]/page.tsx + + +**Create `src/components/admin/tabs/QuoteTab.tsx`** — "use client" component with three form sections: + +```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 { addQuoteItem, removeQuoteItem, updateAcceptedTotal } from "@/app/admin/clients/[id]/quote-actions"; +import type { QuoteItemWithLabel } from "@/lib/admin-queries"; +import type { ServiceCatalog } from "@/db/schema"; + +type Props = { + clientId: string; + items: QuoteItemWithLabel[]; + activeServices: ServiceCatalog[]; + acceptedTotal: string; +}; + +export function QuoteTab({ clientId, items, activeServices, acceptedTotal }: Props) { + const [showCustom, setShowCustom] = useState(false); + const [addError, setAddError] = useState(null); + const [totalError, setTotalError] = useState(null); + // For catalog mode: pre-fill unit_price when service is selected + const [selectedServicePrice, setSelectedServicePrice] = useState(""); + const [, startTransition] = useTransition(); + const router = useRouter(); + + const calculatedTotal = items.reduce((sum, item) => sum + parseFloat(item.subtotal), 0); + + function handleAddItem(fd: FormData) { + setAddError(null); + startTransition(async () => { + try { + await addQuoteItem(clientId, fd); + router.refresh(); + } catch (e) { + setAddError(e instanceof Error ? e.message : "Errore nell'aggiunta"); + } + }); + } + + function handleRemove(quoteItemId: string) { + startTransition(async () => { + await removeQuoteItem(quoteItemId, clientId); + router.refresh(); + }); + } + + function handleSaveTotal(fd: FormData) { + setTotalError(null); + startTransition(async () => { + try { + await updateAcceptedTotal(clientId, fd); + router.refresh(); + } catch (e) { + setTotalError(e instanceof Error ? e.message : "Errore nel salvataggio"); + } + }); + } + + return ( +
+ + {/* Section 1: Add items */} +
+

Aggiungi voci

+ + {!showCustom ? ( + /* Catalog mode */ +
+ +
+
+ + +
+
+ + setSelectedServicePrice(e.target.value)} + placeholder="0.00" + required + /> +
+
+ + +
+ +
+ + {addError &&

{addError}

} +
+ ) : ( + /* Freeform mode */ +
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ {addError &&

{addError}

} +
+ + +
+
+ )} +
+ + {/* Section 2: Quote items table + calculated total */} +
+

Voci preventivo

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

+ Nessuna voce aggiunta. Seleziona dal catalogo per iniziare. +

+ ) : ( + <> +
+ + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
VoceQtyPrezzo unit.Subtotale
{item.label}{parseFloat(item.quantity).toLocaleString("it-IT", { minimumFractionDigits: 2 })} + €{parseFloat(item.unit_price).toLocaleString("it-IT", { minimumFractionDigits: 2 })} + + €{parseFloat(item.subtotal).toLocaleString("it-IT", { minimumFractionDigits: 2 })} + + +
+
+
+

+ Totale calcolato: €{calculatedTotal.toLocaleString("it-IT", { minimumFractionDigits: 2 })} +

+
+ + )} +
+ + {/* Section 3: Accepted total */} +
+

Totale accettato dal cliente

+
+
+ + +
+ +
+ {totalError &&

{totalError}

} +

+ Il cliente vede solo questo importo, non le singole voci del preventivo. +

+
+ +
+ ); +} +``` + +**Modify `src/app/admin/clients/[id]/page.tsx`** — add QuoteTab as 5th tab: + +1. Add import at top: +```typescript +import { QuoteTab } from "@/components/admin/tabs/QuoteTab"; +``` + +2. Update destructure from `getClientFullDetail`: +```typescript +const { client, phases, payments, documents, comments, quoteItems, activeServices } = detail; +``` + +3. Add 5th TabsTrigger after "Commenti": +```typescript +Preventivo +``` + +4. Add 5th TabsContent after the comments TabsContent: +```typescript + + + +``` +
+ + cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export function QuoteTab' src/components/admin/tabs/QuoteTab.tsx + Expected: 1 + cd /Users/simonecavalli/IAMCAVALLI && grep -c 'Preventivo' src/app/admin/clients/\[id\]/page.tsx + Expected: 2 (TabsTrigger text + TabsContent value) + cd /Users/simonecavalli/IAMCAVALLI && grep -c 'quoteItems' src/app/admin/clients/\[id\]/page.tsx + Expected: 1 (destructured from detail) + 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: build succeeds with no errors + + + QuoteTab component renders with three sections. "Preventivo" tab appears in client detail page. TypeScript and build both pass clean. + +
+ +
+ + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Admin browser → quote-actions.ts Server Actions | FormData (clientId, service_id, unit_price, quantity) crosses to server — must be validated before DB write | +| getClientFullDetail → /admin/clients/[id]/page.tsx | quoteItems and activeServices returned ONLY to admin page — never to client-facing routes | +| client-view.ts / client API routes | Must NOT include quote_items in any query result — enforced at query layer | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-03-03-01 | Spoofing | addQuoteItem / removeQuoteItem / updateAcceptedTotal | mitigate | `requireAdmin()` calls `getServerSession(authOptions)` at top of every Server Action — rejects unauthenticated requests | +| T-03-03-02 | Tampering | addQuoteItem formData (unit_price, quantity) | mitigate | Zod `quoteItemSchema` validates both as `z.coerce.number().min(0.01)` — prevents zero/negative values or non-numeric injection | +| T-03-03-03 | Information Disclosure | quote_items exposed via client-facing route | mitigate | `getClientFullDetail` query adds quoteItems ONLY to admin return type; `client-view.ts` and all `/api/client/*` routes must never query `quote_items`; verified via grep gate in Task 1 verify | +| T-03-03-04 | Tampering | IDOR — removeQuoteItem with foreign clientId | mitigate | removeQuoteItem deletes by `quoteItemId` only — the admin must be authenticated (requireAdmin). Phase scope has single admin; if multi-admin added in future, add `AND client_id = clientId` to delete WHERE clause | +| T-03-03-05 | Tampering | XSS in custom_label field | accept | React JSX auto-escapes; custom_label rendered via `{item.label}` — no dangerouslySetInnerHTML; UI-SPEC prohibits it | +| T-03-03-06 | Tampering | Confusing calculated_total vs accepted_total | accept | Visual design enforces separation: calculated total is read-only bold text; accepted_total is distinct editable input with Save button and helper text "Il cliente vede solo questo importo" | + + + +After both tasks complete: +1. `grep -c 'quote_items' src/lib/client-view.ts` returns 0 +2. `npx tsc --noEmit` exits clean +3. `npm run build` succeeds +4. Client detail page at `/admin/clients/[id]` shows "Preventivo" as 5th tab +5. Adding a catalog item: item appears in table with snapshotted unit_price (not pulled from service_catalog) +6. Adding a freeform item: row appears with custom_label, service_id is null in DB +7. Clicking "Salva" on accepted_total updates `clients.accepted_total` — visible in PaymentsTab "Totale preventivo" field + + + +- `src/app/admin/clients/[id]/quote-actions.ts` exports three Server Actions with requireAdmin + Zod guards +- `getClientFullDetail` returns `quoteItems: QuoteItemWithLabel[]` and `activeServices: ServiceCatalog[]` +- QuoteTab renders all three sections: add items (catalog + freeform toggle), items table with calculated total, accepted total editor +- `client-view.ts` contains zero references to `quote_items` +- TypeScript and build both pass clean + + + +After completion, create `.planning/phases/03-service-catalog-quote-builder/03-03-SUMMARY.md` + \ No newline at end of file diff --git a/.planning/phases/03-service-catalog-quote-builder/03-04-PLAN.md b/.planning/phases/03-service-catalog-quote-builder/03-04-PLAN.md new file mode 100644 index 0000000..eebf70d --- /dev/null +++ b/.planning/phases/03-service-catalog-quote-builder/03-04-PLAN.md @@ -0,0 +1,216 @@ +--- +phase: "03" +plan: "04" +type: execute +wave: 3 +depends_on: + - "03-02" + - "03-03" +files_modified: [] +autonomous: false +requirements: + - CAT-01 + - CAT-02 + - ADMIN-03 + +must_haves: + truths: + - "Admin navigates to /admin/catalog — table shows all services with correct columns and status badges" + - "Admin adds a service, edits it inline, and disattiva/riattiva it — all changes persist on page refresh" + - "Admin opens a client's Preventivo tab — adds a catalog item and a freeform item — both appear in the table with correct subtotals and calculated total" + - "Admin saves an accepted_total — the client dashboard shows that exact amount, not the calculated sum" + - "A curl request to the client API returns NO quote_items field and NO service_id references" + artifacts: + - path: "src/app/admin/catalog/page.tsx" + provides: "Verified: catalog page loads and renders table" + - path: "src/components/admin/tabs/QuoteTab.tsx" + provides: "Verified: three sections render correctly, catalog and freeform items work" + - path: "src/lib/client-view.ts" + provides: "Verified: zero quote_items references" + key_links: + - from: "clients.accepted_total (DB)" + to: "client dashboard display" + via: "client-view.ts query → /c/[token] page" + pattern: "accepted_total" +--- + + +End-to-end verification of Phase 3. The admin runs the full workflow — create catalog service, add to quote, set accepted_total — and confirms the client dashboard shows the correct total. Also verifies the security constraint: `quote_items` are never returned by the client API. + +Purpose: Confirms Phase 3 is shippable. Catches any integration issue between catalog, quote builder, and client dashboard before the phase is marked complete. +Output: Human verification sign-off + SUMMARY.md. + + + +@$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-02-SUMMARY.md +@/Users/simonecavalli/IAMCAVALLI/.planning/phases/03-service-catalog-quote-builder/03-03-SUMMARY.md + + + + + + Task 1: Automated security and integration checks + + - /Users/simonecavalli/IAMCAVALLI/src/lib/client-view.ts (must contain zero quote_items references) + - /Users/simonecavalli/IAMCAVALLI/src/app/api (check all client-facing API route files for quote_items leaks) + + + +Run the following automated checks in sequence. Report results for each. + +**Check 1 — TypeScript compiles clean:** +```bash +cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit +``` +Expected: zero output (no errors). + +**Check 2 — Build succeeds:** +```bash +cd /Users/simonecavalli/IAMCAVALLI && npm run build +``` +Expected: "Compiled successfully" with routes listed. No error lines. + +**Check 3 — Security: quote_items not in client-facing code:** +```bash +cd /Users/simonecavalli/IAMCAVALLI && grep -rn 'quote_items' src/lib/client-view.ts src/app/api/ src/app/c/ 2>/dev/null || echo "CLEAN" +``` +Expected: "CLEAN" or no output. If any match appears, that file must be fixed before the checkpoint. + +**Check 4 — Service catalog page references getAllServices:** +```bash +cd /Users/simonecavalli/IAMCAVALLI && grep -c 'getAllServices' src/app/admin/catalog/page.tsx +``` +Expected: 1 + +**Check 5 — NavBar contains Catalogo link:** +```bash +cd /Users/simonecavalli/IAMCAVALLI && grep -c '/admin/catalog' src/components/admin/NavBar.tsx +``` +Expected: 1 + +**Check 6 — Client detail page has Preventivo tab:** +```bash +cd /Users/simonecavalli/IAMCAVALLI && grep -c 'Preventivo' src/app/admin/clients/\[id\]/page.tsx +``` +Expected: 2 + +**Check 7 — quote-actions has requireAdmin in all three actions:** +```bash +cd /Users/simonecavalli/IAMCAVALLI && grep -c 'requireAdmin' src/app/admin/clients/\[id\]/quote-actions.ts +``` +Expected: 3 (one per action) + +**Check 8 — accepted_total security check (client view does NOT expose quote detail):** +```bash +cd /Users/simonecavalli/IAMCAVALLI && grep 'accepted_total\|quote_items\|service_id' src/lib/client-view.ts +``` +Expected: `accepted_total` appears (it's the field clients see), `quote_items` does NOT appear, `service_id` does NOT appear. + +If all 8 checks pass, proceed to the human verification checkpoint. +If any check fails, fix the issue before proceeding. + + + cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit && npm run build 2>&1 | tail -5 + Expected: build output ends with route list — no "Failed to compile" line. + cd /Users/simonecavalli/IAMCAVALLI && grep -rn 'quote_items' src/lib/client-view.ts src/app/c/ 2>/dev/null | wc -l | tr -d ' ' + Expected: 0 + + + All 8 automated checks pass. TypeScript clean, build succeeds, quote_items absent from client-facing code. + + + + + Task 2: Human end-to-end verification of Phase 3 + + Service Catalog CRUD at /admin/catalog, Quote Builder tab in client detail, accepted_total round-trip to client dashboard. + + + Start the dev server: `npm run dev` (port 3000). + + **Test A — Catalog page:** + 1. Open http://localhost:3000/admin/catalog + 2. Confirm the page loads with "Catalogo Servizi" heading and "Aggiungi servizio" button + 3. Click "Aggiungi servizio" — fill in Nome: "Test Servizio", Prezzo: "500" — click Aggiungi + 4. Confirm "Test Servizio" appears in the table with "Attivo" badge and €500,00 price + 5. Click "Modifica" on the row — change price to "750" — click Salva + 6. Confirm price updates to €750,00 without page reload + 7. Click "Disattiva" — confirm badge changes to "Disattivato" and row becomes dimmed (50% opacity) + 8. Click "Riattiva" — confirm badge returns to "Attivo" + + **Test B — NavBar:** + 1. Confirm "Catalogo" link appears in the admin NavBar between "Statistiche" and "Esci" + 2. Click it — confirm it navigates to /admin/catalog + + **Test C — Quote Builder tab:** + 1. Open any existing client at http://localhost:3000/admin/clients/[id] + 2. Confirm "Preventivo" tab appears as 5th tab (after Commenti) + 3. Click the Preventivo tab + 4. Select "Test Servizio" from the dropdown (if inactive, reactivate first) — set qty 1 — click Aggiungi + 5. Confirm item appears in the table with correct unit price and subtotal + 6. Click "Oppure aggiungi voce libera →" — enter Nome: "Extra consulenza", Prezzo: "200", Qty: 2 — click Aggiungi voce libera + 7. Confirm second item appears with "Extra consulenza" label, subtotal €400,00 + 8. Confirm "Totale calcolato" shows the sum (e.g., €1.150,00 if service was €750) + 9. Click "Rimuovi" on one item — confirm it disappears + + **Test D — Accepted total round-trip (critical):** + 1. In the Preventivo tab, set "Totale accettato dal cliente" to 1200 — click Salva + 2. Open the client dashboard at http://localhost:3000/c/[client-token] in a new tab + 3. Confirm the dashboard shows "€1.200,00" (or equivalent) as the accepted total + 4. Back in admin, open the Pagamenti tab — confirm "Totale preventivo" input shows 1200 + + **Test E — Security check (quote_items never exposed):** + 1. In the browser DevTools (Network tab), open the client dashboard /c/[token] + 2. Find any API calls made by that page — inspect their response bodies + 3. Confirm NO response contains "quote_items", "service_id" (from quote context), or individual line item prices + 4. Alternative: run `curl http://localhost:3000/api/client/[client-id-or-token]` if a client API route exists — confirm response has only `accepted_total`, not quote item details + + + Type "approved" if all 5 tests pass. Or describe any failures (e.g., "Test C step 5 fails — items not appearing") so they can be fixed. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Client browser → /c/[token] route | Client sees only what the route explicitly returns — verified here that quote_items are absent | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-03-04-01 | Information Disclosure | Client dashboard API response | mitigate | Check 3 + Test E verify that no quote_items appear in any client-facing response; if found, fix before approving | +| T-03-04-02 | Tampering | Phase 3 shipped without DB push | mitigate | 03-01 is a hard dependency of this wave; if drizzle-kit push was skipped, custom_label column absent causes runtime crash caught in Test C | + + + +Phase 3 complete when: +1. All 8 automated checks in Task 1 pass +2. Human verifies Tests A–E in Task 2 +3. Client dashboard shows correct `accepted_total` after update (Test D) +4. Zero `quote_items` in any client-facing response (Test E) + + + +- Service catalog is fully operational: add, edit, disable, re-enable services +- Quote builder adds catalog items (with snapshotted price) and freeform items (service_id = null) +- accepted_total write in admin is reflected in client dashboard +- Phase 3 roadmap success criteria 1–3 are all TRUE: + 1. Admin can add/edit/disable catalog services + 2. Admin can compose a quote from catalog; system calculates total + 3. After saving accepted_total, client dashboard shows correct total; quote_items never exposed + + + +After completion, create `.planning/phases/03-service-catalog-quote-builder/03-04-SUMMARY.md` + \ No newline at end of file