# Phase 3: Service Catalog & Quote Builder — Research **Researched:** 2026-05-17 **Domain:** Admin service catalog management, quote builder UI, server actions, database schema migration **Confidence:** HIGH ## Summary Phase 3 builds the admin service catalog and quote builder—two tightly integrated features that allow the admin to manage reusable service line items and compose client-specific quotes. The service catalog is a simple admin-only CRUD table (add, edit, soft-delete via `active` flag); the quote builder is a new admin tab that lets the admin mix catalog items and freeform entries, calculate totals, and commit an `accepted_total` to the client row (which the client dashboard displays). The core architectural decision is that **quote_items are never exposed to the client API** — only the denormalized `clients.accepted_total` field is visible to clients. This constraint is already enforced in Phase 1 design and persists through Phase 3. **Key findings:** 1. Database schema is 95% complete — only two fields need to be added to `quote_items`: make `service_id` nullable and add `custom_label` text field (for freeform items). 2. Component patterns are stable and reusable: existing tab system (PaymentsTab, DocumentsTab) provides the exact UI structure to follow. 3. Server Actions pattern is established in `actions.ts` — quote CRUD will follow the same async form handling + Zod validation pattern. 4. No external libraries or complex state management needed — plain React forms + Server Actions suffice. 5. One navigation change required: add "Catalogo" link to NavBar. **Primary recommendation:** Implement as two distinct features with clear separation of concerns: (1) `/admin/catalog` page with catalog CRUD; (2) new "Preventivo" tab in existing client detail page. Both use the same Server Actions pattern and share no client-side state. ## User Constraints (from CONTEXT.md) ### Locked Decisions 1. **Service Catalog — Location: /admin/catalog** - Dedicated page with NavBar link (Clienti | Statistiche | Catalogo) - Table with columns: Nome, Descrizione, Prezzo unitario, Stato (Attivo/Disattivato) - Full CRUD: add, inline edit, disable/enable (soft delete via `active = false`) - Inactive items remain visible in list (toggle filter) but not in quote selectors 2. **Quote Builder — Location: Tab "Preventivo" in /admin/clients/[id]** - New 5th tab in client detail page (after Documenti) - Shows quote items with calculated total - Admin can add items from catalog (dropdown + qty) OR freeform items (label + price + qty) - No locking after finalization — items always editable - Schema change: `service_id` becomes nullable, add `custom_label` text field 3. **Accepted Total — Admin-controlled, not auto-calculated** - Builder shows calculated sum as reference - Separate editable field "Totale accettato dal cliente" with Save button - Admin can set any value (commercial round number may differ from analytical sum) - Finalization writes only `accepted_total`; no automatic payment update 4. **Security Constraint (immutable from Phase 1)** - `quote_items` are admin-only — NEVER exposed by client-facing API routes - `clients.accepted_total` is the only price visible to clients ### Claude's Discretion None — all major decisions are locked from the discuss phase. ### Deferred Ideas (OUT OF SCOPE) - Phase 4: Claude AI onboarding with assisted quote generation - Future: Payment auto-sync when quote is finalized - Future: Quote versioning / history tracking ## Phase Requirements | ID | Description | Research Support | |----|-------------|------------------| | CAT-01 | File/database dei servizi con prezzi e cosa è incluso | Schema complete; CRUD on `service_catalog` table with name, description, unit_price, active fields | | CAT-02 | Usato come base per la generazione assistita dei preventivi | Quote builder queries active catalog items via dropdown; items are snapshotted at add time (unit_price stored in quote_items) | | ADMIN-03 | Preventivo completo con dettaglio servizi (non visibile al cliente) | Quote builder UI + Server Actions in `quote-actions.ts` + API constraint enforced at route layer to prevent quote_items exposure | ## Architectural Responsibility Map | Capability | Primary Tier | Secondary Tier | Rationale | |------------|-------------|----------------|-----------| | Service catalog CRUD | API / Backend (Server Actions) | Database | Admin form submissions trigger Server Actions; Drizzle handles persistence | | Catalog visibility/filtering | API / Backend (query) | Frontend (display) | Active filter logic lives in query layer; UI just renders results | | Quote item management | API / Backend (Server Actions) | Frontend (form) | Add/remove/update quote items via Server Actions; client-side form for UX only | | Quote total calculation | Frontend (display) | — | Pure calculation in component (no state needed); accepted_total write is Server Action | | Client API security (quote_items never exposed) | API / Backend (route guard) | — | Route handlers explicitly exclude quote_items from responses; enforced at query level | ## Standard Stack ### Core | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | Next.js | 16.2.6 | App Router, Server Actions | Established in Phase 1; Server Actions reduce client-side complexity | | Drizzle ORM | 0.45.2 | Query builder, migrations | Already in use; `drizzle-kit push` for schema migrations | | Postgres (Neon) | Via postgres npm | Serverless DB | Existing connection, no changes | | React | 19.2.4 | Client component library | Existing; hooks pattern already established | | Tailwind v4 | ^4 | Styling | Brand system (#1A463C, #DEF168) already in place | | shadcn/ui | Via npm | Form inputs, buttons, tabs, label | Radix UI primitives + Tailwind styling; consistent with existing admin UI | | Zod | ^4.4.3 | Form validation | Already in use in Phase 2 Server Actions | ### Supporting | Library | Version | Purpose | When to Use | |---------|---------|---------|-------------| | React Hook Form | ^7.75.0 | Form state (client-side) | Optional — existing PaymentsTab uses plain form without RHF; follow that pattern for consistency | | nanoid | ^5.1.11 | ID generation | Already used; catalog and quote items get nanoid PKs | ### Alternatives Considered | Instead of | Could Use | Tradeoff | |------------|-----------|----------| | Server Actions for CRUD | API route handlers | Server Actions reduce boilerplate; form serialization is automatic | | Inline edit (existing pattern) | Modal dialog | UI spec explicitly says "prefer inline editing" — matches existing admin style | | Drizzle schema push | Migrations framework | Drizzle-kit is simpler for this schema scope; no need for Prisma/Liquibase | **Installation/Verification:** All dependencies are already in package.json. No new packages needed for Phase 3. ```bash # Verify Drizzle and schema tooling npm list drizzle-orm drizzle-kit # Output should show: drizzle-orm@0.45.2, drizzle-kit@0.31.10 # Verify schema migration command works npx drizzle-kit push # Will prompt for database URL — must be set in .env.local before push ``` ## Architecture Patterns ### System Architecture Diagram ``` Admin /admin/catalog (Service Catalog Page) ↓ NavBar → Link to /admin/catalog ↓ ServiceTable (Server Component) ↓ queries service_catalog table (all rows) ↓ render in read mode ↓ inline edit: expand row → editable inputs → Server Action ↓ disable/enable: toggle button → Server Action ↓ ServiceForm (Client Component inside ServiceTable) ↓ add row at top OR modal ↓ submit → Server Action → revalidatePath Admin /admin/clients/[id] (Client Detail Page) ↓ Tabs: Fasi | Pagamenti | Documenti | Commenti | Preventivo (NEW) ↓ QuoteTab (Client Component — NEW) ├─ Section 1: Add items │ ├─ Dropdown: catalog items (active only, sorted by name) │ ├─ OR toggle: "Voce libera" → text input + price + qty │ ├─ Add button → Server Action → append to quote_items │ └─ ├─ Section 2: Quote items table │ ├─ Columns: Voce | Qty | Unit Price | Subtotal | Delete button │ ├─ Delete button → Server Action → remove from quote_items │ └─ Footer: "Totale calcolato" (sum of subtotals) └─ Section 3: Accepted Total ├─ Label: "Totale accettato dal cliente" ├─ Editable EUR input (separate from calculated sum) ├─ Save button → Server Action → update clients.accepted_total └─ Helper text: "Il cliente vede solo questo importo" Data Layer ↓ All writes via Server Actions in /admin/clients/[id]/quote-actions.ts ├─ addQuoteItem(clientId, serviceId | null, customLabel | null, qty, unitPrice) ├─ updateQuoteItem(quoteItemId, qty) ├─ removeQuoteItem(quoteItemId, clientId) ├─ updateAcceptedTotal(clientId, amount) ├─ createService(name, description, unitPrice) ├─ updateService(serviceId, name, description, unitPrice) └─ toggleServiceActive(serviceId, active) Client API (immutable constraint) ↓ GET /api/client/[clientId] ├─ Returns: clients.{id, name, brand_name, brief, accepted_total, ...} └─ NEVER includes quote_items ``` ### Recommended Project Structure ``` src/ ├── app/admin/ │ ├── catalog/ │ │ ├── page.tsx # Service catalog page │ │ └── actions.ts # createService, updateService, toggleServiceActive │ ├── clients/[id]/ │ │ ├── page.tsx # Existing; add QuoteTab to Tabs │ │ ├── actions.ts # Existing; no changes │ │ └── quote-actions.ts # NEW — addQuoteItem, removeQuoteItem, updateAcceptedTotal │ └── ... ├── components/admin/ │ ├── tabs/ │ │ └── QuoteTab.tsx # NEW — quote builder UI │ ├── catalog/ # NEW │ │ ├── ServiceTable.tsx # NEW — catalog table + inline edit │ │ └── ServiceForm.tsx # NEW — add service form │ ├── NavBar.tsx # MODIFIED — add /admin/catalog link │ └── ... └── ... ``` ### Pattern 1: Server Actions + Form Serialization **What:** Server Actions receive FormData directly from forms; no JSON serialization overhead. **When to use:** All admin CRUD operations (catalog, quote items, payments, documents). **Example:** ```typescript // actions.ts "use server"; import { db } from "@/db"; import { service_catalog } from "@/db/schema"; import { z } from "zod"; import { revalidatePath } from "next/cache"; const serviceSchema = z.object({ name: z.string().min(1, "Nome richiesto"), description: z.string().optional(), unit_price: z.coerce.number().positive("Prezzo deve essere positivo"), }); export async function createService(formData: FormData) { 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(parsed.data); revalidatePath("/admin/catalog"); } // Component.tsx
{ "use server"; await createService(fd); }} >
``` [Source: Phase 2 established in actions.ts; Zod validation pattern from existing paymentStatus/updateAcceptedTotal] ### Pattern 2: Quote Item Snapshots **What:** When adding a quote item from catalog, capture the current `unit_price` from the service row. If the service price changes later, existing quote items keep their snapshotted price. **When to use:** Any time a catalog item is referenced in a transaction (quote, order, invoice). **Example:** ```typescript export async function addQuoteItem( clientId: string, serviceId: string | null, customLabel: string | null, quantity: number, unitPrice: number ) { const subtotal = quantity * unitPrice; await db.insert(quote_items).values({ client_id: clientId, service_id: serviceId, // null if custom label custom_label: customLabel, // null if from catalog quantity, unit_price: unitPrice, // snapshot of price at time of quote subtotal, }); revalidatePath(`/admin/clients/${clientId}`); } ``` [Source: CONTEXT.md locked decision; Phase 1 schema design] ### Pattern 3: Nullable Foreign Key + Custom Label **What:** `service_id` is nullable in `quote_items`. If null, use `custom_label` for the line item name. If not null, look up the service name from `service_catalog`. **When to use:** Supporting both catalog items and freeform items in the same table. **Example:** ```typescript // Query side const items = await db .select({ id: quote_items.id, label: sql`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`, 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, clientId)); // UI side — QuoteTab component {items.map(item => ( {item.label} {item.quantity} €{item.unit_price.toFixed(2)} €{item.subtotal.toFixed(2)} ))} ``` [Source: CONTEXT.md § 3 (Voci Preventivo — Catalogo + Free-form)] ### Anti-Patterns to Avoid - **Calculating accepted_total on the backend:** This is intentional — admin must be free to set any value (commercial rounding). Don't auto-sync from quote items sum. - **Exposing quote_items in client API routes:** Even by accident. Add explicit `.select()` clauses that exclude quote_items; never do `SELECT *` on routes that touch clients. - **Freezing quote items after finalization:** Spec says "sempre editabili" — no soft lock, no approval state. The quote is internal-only; client never sees it. - **Storing display labels in quote_items.label field:** Use `service_id` FK when possible; only use `custom_label` for freeform items. This keeps the data model clean and auditable. ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | Nullable FK + custom value display | Custom display logic in component | Drizzle `leftJoin` + `COALESCE` in query | Single source of truth; query-level logic is easier to test and reuse | | Price snapshots | Manual price tracking logic | Store `unit_price` in quote_items row at insert time | Immutable snapshot prevents accidental price sync bugs | | Form validation | Custom validators in component | Zod schema in Server Action | Type-safe, reusable, server-side security | | Catalog filtering (active items) | Client-side filter state | `.where(eq(service_catalog.active, true))` in query | Prevents exposing inactive items if query is accidentally exposed | **Key insight:** The quote builder looks simple (add item, remove item, save total), but the detail is in the data model. A sloppy implementation exposes quote_items to the client API or breaks when prices change. The patterns above are proven in Phase 2 and directly applicable here. ## Runtime State Inventory **Trigger:** Phase 3 does not involve rename, rebrand, refactor, or migration of existing strings. **Status:** SKIPPED — This is a new feature phase (greenfield catalog + new tab). No runtime state needs to be discovered or migrated. The schema changes (nullable service_id, new custom_label field) are additive only. ## Common Pitfalls ### Pitfall 1: Accidentally Exposing quote_items to Client **What goes wrong:** A developer adds a new client API route (e.g., `GET /api/client/[token]/quote`) without realizing the security constraint, or modifies `getClientFullDetail()` query to include quote_items "for completeness." **Why it happens:** The constraint is documented in CLAUDE.md and Phase 1 decisions, but it's easy to forget when working on a new feature. The quote_items table exists in the schema; it's tempting to include it. **How to avoid:** - Before any `.select()` on a client-facing route, explicitly list columns: `.select({ id: clients.id, name: clients.name, accepted_total: clients.accepted_total, ... })` — never `SELECT *`. - Add a comment in the route handler: `// quote_items NEVER exposed — security constraint from Phase 1`. - Test the client API with curl or Postman; verify the response does NOT contain quote_items or service_id references. **Warning signs:** - `SELECT * FROM ...clients...` in any client-facing route. - A PR review comment suggesting "but the client should see the quote breakdown." ### Pitfall 2: Confusing calculated_total vs. accepted_total **What goes wrong:** The UI shows "Totale calcolato: €1,250" and "Totale accettato: €1,500", but the admin saves only the accepted total. Later, the admin forgets which one was finalized and manually overwrites the calculated total, breaking the audit trail. **Why it happens:** Two fields look similar on the form. The calculated total is read-only (it's the sum), but nothing visually prevents someone from thinking "maybe I should update the calculation." **How to avoid:** - Make the calculated total visually distinct: gray background, read-only input, or bold text label ("Questo è calcolato; non modificare"). - The accepted_total input should have a clear Save button; the calculated total should have none. - Add helper text: "Il totale calcolato è la somma delle voci. Il cliente vede solo il totale accettato." **Warning signs:** - A UI where the two fields look identical in styling. - Missing explanation of why they are separate. ### Pitfall 3: Not Snapshotting Prices **What goes wrong:** Admin adds a quote item with current catalog price €100. Two weeks later, the service is updated to €150. The quote_items row still shows €100 (good), but the admin forgets this and thinks the quote is stale. **Why it happens:** If the code accidentally queries `service_catalog.unit_price` instead of the snapshotted `quote_items.unit_price` when rendering the quote, it will show the new price, not the quote price. **How to avoid:** - Always display `quote_items.unit_price` in the quote table — never join back to `service_catalog.unit_price`. - Add a migration test: change a service price, reload the quote, verify the quote price hasn't changed. **Warning signs:** - Quote item price changing after the quote was created. - Confusion in the admin about "which price is this?" ### Pitfall 4: Schema Migration Not Run **What goes wrong:** Code is deployed with references to `quote_items.custom_label` or nullable `service_id`, but the database schema hasn't been pushed. The app crashes with column-not-found errors. **Why it happens:** The developer forgets to run `drizzle-kit push` before deploying, or the DB connection is misconfigured (DATABASE_URL not set in production environment). **How to avoid:** - Add a pre-deployment checklist: (1) schema.ts updated, (2) `drizzle-kit push` run locally and output captured, (3) production DATABASE_URL verified in CI/CD secrets, (4) push output included in deploy notes. - Include this step in PLAN.md: "Wave 0: Schema push (drizzle-kit push)". **Warning signs:** - Deploy succeeds, but admin page crashes with "column \"custom_label\" does not exist." - Local dev works, production fails (classic local-vs-prod mismatch). ## Code Examples ### Example 1: Create Service (Server Action) ```typescript // src/app/admin/catalog/actions.ts "use server"; import { db } from "@/db"; import { service_catalog } from "@/db/schema"; import { revalidatePath } from "next/cache"; import { z } from "zod"; 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"), }); export async function createService(formData: FormData) { 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(parsed.data); revalidatePath("/admin/catalog"); } export async function updateService( serviceId: string, formData: FormData ) { 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(parsed.data) .where(eq(service_catalog.id, serviceId)); revalidatePath("/admin/catalog"); } export async function toggleServiceActive( serviceId: string, active: boolean ) { await db .update(service_catalog) .set({ active }) .where(eq(service_catalog.id, serviceId)); revalidatePath("/admin/catalog"); } ``` [Source: Phase 2 pattern established in `clients/[id]/actions.ts`; Zod validation matches `docSchema`, `clientSchema`] ### Example 2: Add Quote Item (Server Action) ```typescript // src/app/admin/clients/[id]/quote-actions.ts "use server"; import { db } from "@/db"; import { quote_items, service_catalog } from "@/db/schema"; import { revalidatePath } from "next/cache"; import { eq } from "drizzle-orm"; import { z } from "zod"; 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) { const parsed = quoteItemSchema.safeParse({ service_id: formData.get("service_id") || null, custom_label: formData.get("custom_label") || null, quantity: formData.get("quantity"), unit_price: formData.get("unit_price"), }); if (!parsed.success) { throw new Error(parsed.error.issues[0].message); } const { service_id, custom_label, quantity, unit_price } = parsed.data; const subtotal = Number(quantity) * Number(unit_price); await db.insert(quote_items).values({ client_id: clientId, service_id, custom_label, quantity: String(quantity), unit_price: String(unit_price), subtotal: String(subtotal), }); revalidatePath(`/admin/clients/${clientId}`); } export async function removeQuoteItem(quoteItemId: string, clientId: string) { await db.delete(quote_items).where(eq(quote_items.id, quoteItemId)); revalidatePath(`/admin/clients/${clientId}`); } export async function updateAcceptedTotal( clientId: string, formData: FormData ) { const raw = formData.get("accepted_total") as string; 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}`); } ``` [Source: Phase 2 pattern from `clients/[id]/actions.ts`; numeric precision matches schema] ### Example 3: Quote Tab Component ```typescript // src/components/admin/tabs/QuoteTab.tsx "use client"; import { addQuoteItem, removeQuoteItem, updateAcceptedTotal } from "@/app/admin/clients/[id]/quote-actions"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import type { ServiceCatalog, QuoteItem } from "@/db/schema"; import { useState } from "react"; type Props = { clientId: string; items: Array; services: ServiceCatalog[]; acceptedTotal: string; }; export function QuoteTab({ clientId, items, services, acceptedTotal }: Props) { const [showCustom, setShowCustom] = useState(false); const activeServices = services.filter(s => s.active); const total = items.reduce((sum, item) => sum + parseFloat(item.subtotal), 0); return (
{/* Add items section */}

Aggiungi voci

{!showCustom ? (
{ "use server"; await addQuoteItem(clientId, fd); }} className="flex items-end gap-3" >
) : (
{ "use server"; await addQuoteItem(clientId, fd); }} className="space-y-3" >
)}
{/* Quote items table */}
{items.length === 0 ? (

Nessuna voce aggiunta. Seleziona dal catalogo per iniziare.

) : ( <> {items.map(item => ( ))}
Voce Qty Prezzo unit. Subtotale
{item.custom_label || item.serviceName} {item.quantity} €{parseFloat(item.unit_price).toFixed(2)} €{parseFloat(item.subtotal).toFixed(2)}
{ "use server"; await removeQuoteItem(item.id, clientId); }} >

Totale calcolato: €{total.toFixed(2)}

)}
{/* Accepted total */}

Totale accettato dal cliente

{ "use server"; await updateAcceptedTotal(clientId, fd); }} className="flex items-end gap-3" >

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

); } ``` [Source: Component structure mirrors PaymentsTab and DocumentsTab from Phase 2; inline forms follow same pattern] ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|--------------|--------| | Separate catalog feature added in Phase 4+ | Catalog in Phase 3 (before Claude AI) | Discuss phase (May 16) | Allows Phase 3 to deliver full quote builder; Phase 4 Claude flows become faster with catalog as foundation | | Locking quote after finalization | Always-editable quote | Discuss phase decision | Simpler implementation; quotes are internal-only (client never sees them), so no approval workflow needed | | Auto-syncing accepted_total to payment rows | Manual payment management | Phase 2 design | Admin controls both quote total and payment splits independently; more flexible for commercial negotiations | **Deprecated/outdated:** None in this phase. This is new feature work with no legacy patterns to replace. ## Assumptions Log | # | Claim | Section | Risk if Wrong | |---|-------|---------|---------------| | A1 | All dependencies (Next.js 16, Drizzle, Zod, Tailwind, shadcn/ui) are current and compatible with Phase 2 build | Standard Stack | If versions are stale, build may fail. Risk: LOW — package.json verified 2026-05-17, all versions match live codebase | | A2 | `drizzle-kit push` is the correct method for schema migration in this project | Architecture Patterns | If alternative migration method is required, schema push step will fail. Risk: LOW — Phase 1 and Phase 2 used this method successfully | | A3 | The existing `getClientFullDetail()` query in `lib/admin-queries.ts` does not expose quote_items | Common Pitfalls | If this query accidentally includes quote_items, client API constraint is already broken. Risk: MEDIUM — needs explicit verification during planning | | A4 | Inline edit pattern (used in DocumentsTab) is applicable to ServiceTable | Architecture Patterns | If UI spec requires modal or other pattern, implementation will need revision. Risk: LOW — UI-SPEC explicitly says "prefer inline editing" | | A5 | NavBar component is the only place where top-level navigation links are maintained | Architecture Patterns | If navigation is split across multiple files, Catalogo link addition may be incomplete. Risk: LOW — NavBar examined; it's a single source of truth | **If this table is empty:** All claims were verified via code inspection or official documentation. No user confirmation needed before planning. ## Open Questions 1. **Pricing model for custom items in quote tab** - What we know: UI spec says "voce libera" with "nome + prezzo custom" - What's unclear: Should the freeform price be per-unit or total? Spec shows qty field, suggesting per-unit. - Recommendation: Implement as per-unit (matches catalog pattern). If admin wants a fixed total, they can set qty=1 and price=total. 2. **Filter visibility of inactive services in quote selector** - What we know: Inactive services should not appear in the quote dropdown - What's unclear: Should inactive services be visible in the catalog list with a badge, or completely hidden? - Recommendation: Follow spec: "Items disattivati restano visibili in elenco (filtro toggle) ma non appaiono nel selettore quote." Implement as: catalog table shows all items (toggle to hide inactive), quote selector only shows active. 3. **Snapshot behavior for catalog item updates** - What we know: Quote items snapshot the price at time of quote - What's unclear: If a catalog item is disabled after being quoted, what happens to the quote display? (Should show the service name in the quote, but if service is deleted?) - Recommendation: Use `leftJoin` in query; service deletion is FK restricted (onDelete: "restrict"), so this is prevented at the DB level. Quotes will always resolve to a service or show the custom label. ## Environment Availability All dependencies are npm packages already installed in the project (verified via package.json). No external tools, services, or runtimes are required beyond the existing Next.js 16 + Postgres + Neon stack. **Status:** ✅ All environment requirements met. No gaps. ## Validation Architecture **Note:** `workflow.nyquist_validation` is set to `false` in `.planning/config.json`. Validation section is omitted per configuration. ## Security Domain ### Applicable ASVS Categories | ASVS Category | Applies | Standard Control | |---------------|---------|-----------------| | V2 Authentication | yes | Auth.js session check (already enforced for `/admin/*` routes in Phase 2) | | V3 Session Management | yes | Auth.js v4 session management (middleware validates auth token) | | V4 Access Control | yes | `/admin/catalog` must check session; quote operations only accessible to authenticated admin | | V5 Input Validation | yes | Zod schema validation in Server Actions (price, quantity, text fields) | | V6 Cryptography | no | No new crypto operations; prices stored as numeric strings, not hashed | ### Known Threat Patterns for {Next.js + Drizzle + Postgres} | Pattern | STRIDE | Standard Mitigation | |---------|--------|---------------------| | SQL injection via quote builder | Tampering | Use Drizzle parameterized queries (never string interpolation); Zod validates input types before DB | | Unauthorized quote modification | Spoofing, Tampering | Session check on `/admin/catalog` and quote-actions routes; no CORS bypass | | Accidental quote exposure in client API | Disclosure | Explicit `.select()` columns on client routes; never `SELECT *`; test with curl/Postman to verify no quote_items in response | | Admin price manipulation | Tampering | Accepted_total is intentionally admin-editable (business requirement); audit timestamp via DB or logging if needed | | XSS in service names / custom labels | Tampering | React auto-escapes in JSX; no `dangerouslySetInnerHTML` used in UI components | **Phase 3 adds no new surface area for authentication/authorization.** All routes inherit the session check from Phase 2 middleware. Quote_items constraint is enforced at the query/response layer, not via auth. ## Sources ### Primary (HIGH confidence) - **Existing codebase** (`src/db/schema.ts`, `src/app/admin/clients/[id]/actions.ts`, `src/components/admin/tabs/`) — verified 2026-05-17 - Service catalog table structure confirmed (name, description, unit_price, active fields exist) - Quote items table exists but needs two schema changes (service_id nullable, custom_label text) - Server Actions pattern established in Phase 2 — reusable for Phase 3 CRUD - Tab component pattern established (PaymentsTab, DocumentsTab) — QuoteTab will follow same structure - **CONTEXT.md** (Phase 3 discuss-phase decisions) - All architectural decisions locked: catalog location, quote builder location, schema changes, accepted_total behavior - UI spec provided: inline editing, form fields, styling system - Requirements mapped to capabilities: CAT-01, CAT-02, ADMIN-03 - **CLAUDE.md** (project constraints) - Quote items never exposed to client API — enforced constraint from Phase 1 design - Server-side rendering + Auth.js session management — established patterns ### Secondary (MEDIUM confidence) - **Phase 2 execution artifacts** (commits, merged PRs, component implementations) - Validated that Server Actions + Zod pattern works end-to-end - Verified Tailwind styling system (#1A463C, #DEF168, #e5e7eb colors) is applied consistently - Confirmed `revalidatePath` behavior and next/cache utilities ## Metadata **Confidence breakdown:** - **Standard Stack: HIGH** — All libraries verified in package.json; versions match live codebase; no version mismatches or deprecations detected - **Architecture: HIGH** — Schema is 95% done (only 2 fields need to be added); component patterns from Phase 2 are proven and reusable; no experimental or uncertain technologies - **Pitfalls: HIGH** — Security constraint (quote_items exposure) documented and understood; pitfalls derived from common SaaS quote builder patterns; preventions are concrete and testable **Research date:** 2026-05-17 **Valid until:** 2026-06-17 (30 days — stable domain with no fast-moving dependencies)