Wave 1: schema push (service_id nullable + custom_label). Wave 2 (parallel): catalog CRUD page + quote builder tab. Wave 3: E2E human verification checkpoint. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
31 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 03 | 03 | execute | 2 |
|
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@/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: */} ```export type ClientFullDetail = {
client: Client;
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
payments: Payment[];
documents: Document[];
notes: Note[];
comments: Comment[];
// ADD:
// quoteItems: QuoteItemWithLabel[];
// activeServices: ServiceCatalog[];
};
// src/components/admin/tabs/PaymentsTab.tsx pattern
export async function PaymentsTab({ payments, acceptedTotal, clientId }: Props) {
return (
<div className="space-y-6 max-w-md">
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h3 className="font-medium text-gray-900 mb-3">...</h3>
<form action={async (fd) => { "use server"; await updateAcceptedTotal(clientId, fd); }}>
...
</form>
</div>
</div>
);
}
// quote_items NEVER exposed — security constraint from Phase 1 (CLAUDE.md) // Only clients.accepted_total is visible to client-facing routes
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<string>`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));
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,
});
export type ServiceCatalog = typeof service_catalog.$inferSelect;
// Fields: id: string, name: string, unit_price: string, active: boolean, description: string | null
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)
"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:
-
Add imports at top:
quote_items,service_catalogfrom@/db/schema;sqlfromdrizzle-orm;ServiceCatalogfrom@/db/schema. -
Add new type before
ClientFullDetail:
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;
};
-
Add
quoteItems: QuoteItemWithLabel[]andactiveServices: ServiceCatalog[]to theClientFullDetailtype. -
Add two queries inside
getClientFullDetail()before thereturnstatement:
// quote_items NEVER exposed via client API — admin workspace query only
const quoteItemRows: QuoteItemWithLabel[] = await db
.select({
id: quote_items.id,
label: sql<string>`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));
- Add
quoteItems: quoteItemRowsandactiveServices: activeServiceRowsto 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.
"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<string | null>(null);
const [totalError, setTotalError] = useState<string | null>(null);
// For catalog mode: pre-fill unit_price when service is selected
const [selectedServicePrice, setSelectedServicePrice] = useState<string>("");
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 (
<div className="space-y-6 max-w-2xl">
{/* Section 1: Add items */}
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-4">
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider">Aggiungi voci</h3>
{!showCustom ? (
/* Catalog mode */
<form action={handleAddItem} className="space-y-3">
<input type="hidden" name="custom_label" value="" />
<div className="flex items-end gap-3 flex-wrap">
<div className="flex-1 min-w-[180px] space-y-1">
<Label htmlFor="service_id">Seleziona dal catalogo</Label>
<select
name="service_id"
id="service_id"
className="w-full border border-[#e5e7eb] rounded-md px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
onChange={(e) => {
const svc = activeServices.find((s) => s.id === e.target.value);
setSelectedServicePrice(svc ? parseFloat(svc.unit_price).toFixed(2) : "");
}}
required
>
<option value="">— Scegli servizio —</option>
{activeServices.map((s) => (
<option key={s.id} value={s.id}>
{s.name} (€{parseFloat(s.unit_price).toLocaleString("it-IT", { minimumFractionDigits: 2 })})
</option>
))}
</select>
</div>
<div className="w-28 space-y-1">
<Label htmlFor="unit_price_catalog">Prezzo unit.</Label>
<Input
id="unit_price_catalog"
name="unit_price"
type="number"
step="0.01"
min="0.01"
value={selectedServicePrice}
onChange={(e) => setSelectedServicePrice(e.target.value)}
placeholder="0.00"
required
/>
</div>
<div className="w-20 space-y-1">
<Label htmlFor="quantity_catalog">Qty</Label>
<Input
id="quantity_catalog"
name="quantity"
type="number"
step="0.01"
min="0.01"
defaultValue="1"
required
/>
</div>
<Button type="submit" size="sm" className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90">
Aggiungi
</Button>
</div>
<button
type="button"
onClick={() => { setShowCustom(true); setAddError(null); }}
className="text-xs text-[#71717a] hover:text-[#1a1a1a] underline"
>
Oppure aggiungi voce libera →
</button>
{addError && <p className="text-xs text-red-600">{addError}</p>}
</form>
) : (
/* Freeform mode */
<form action={handleAddItem} className="space-y-3">
<input type="hidden" name="service_id" value="" />
<div className="space-y-1">
<Label htmlFor="custom_label">Nome voce</Label>
<Input
id="custom_label"
name="custom_label"
placeholder="es. Consulenza extra, Spese viaggi"
required
/>
</div>
<div className="flex gap-3 flex-wrap">
<div className="flex-1 min-w-[120px] space-y-1">
<Label htmlFor="unit_price_custom">Prezzo unitario (€)</Label>
<Input
id="unit_price_custom"
name="unit_price"
type="number"
step="0.01"
min="0.01"
placeholder="0.00"
required
/>
</div>
<div className="w-20 space-y-1">
<Label htmlFor="quantity_custom">Qty</Label>
<Input
id="quantity_custom"
name="quantity"
type="number"
step="0.01"
min="0.01"
defaultValue="1"
required
/>
</div>
</div>
{addError && <p className="text-xs text-red-600">{addError}</p>}
<div className="flex gap-2">
<Button type="submit" size="sm" className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90">
Aggiungi voce libera
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => { setShowCustom(false); setAddError(null); }}
>
Torna al catalogo
</Button>
</div>
</form>
)}
</div>
{/* Section 2: Quote items table + calculated total */}
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4">
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Voci preventivo</h3>
{items.length === 0 ? (
<p className="text-sm text-[#71717a] py-4 text-center">
Nessuna voce aggiunta. Seleziona dal catalogo per iniziare.
</p>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-[#e5e7eb]">
<tr>
<th className="text-left py-2 px-2 font-medium text-[#71717a]">Voce</th>
<th className="text-right py-2 px-2 font-medium text-[#71717a]">Qty</th>
<th className="text-right py-2 px-2 font-medium text-[#71717a]">Prezzo unit.</th>
<th className="text-right py-2 px-2 font-medium text-[#71717a]">Subtotale</th>
<th className="py-2 px-2"></th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b border-[#f4f4f5] hover:bg-[#f9f9f9]">
<td className="py-2 px-2 text-[#1a1a1a]">{item.label}</td>
<td className="py-2 px-2 text-right tabular-nums">{parseFloat(item.quantity).toLocaleString("it-IT", { minimumFractionDigits: 2 })}</td>
<td className="py-2 px-2 text-right tabular-nums font-mono">
€{parseFloat(item.unit_price).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</td>
<td className="py-2 px-2 text-right tabular-nums font-mono font-medium">
€{parseFloat(item.subtotal).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</td>
<td className="py-2 px-2 text-right">
<button
type="button"
onClick={() => handleRemove(item.id)}
className="text-xs text-[#71717a] hover:text-red-600 transition-colors"
>
Rimuovi
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-3 pt-3 border-t border-[#e5e7eb] flex justify-end">
<p className="font-bold text-[#1a1a1a] tabular-nums">
Totale calcolato: €{calculatedTotal.toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</p>
</div>
</>
)}
</div>
{/* Section 3: Accepted total */}
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-3">
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider">Totale accettato dal cliente</h3>
<form action={handleSaveTotal} className="flex items-end gap-3">
<div className="flex-1 max-w-[200px] space-y-1">
<Label htmlFor="accepted_total">Importo (€)</Label>
<Input
id="accepted_total"
name="accepted_total"
type="number"
step="0.01"
min="0"
defaultValue={parseFloat(acceptedTotal).toFixed(2)}
/>
</div>
<Button type="submit" size="sm" className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90">
Salva
</Button>
</form>
{totalError && <p className="text-xs text-red-600">{totalError}</p>}
<p className="text-xs text-[#71717a]">
Il cliente vede solo questo importo, non le singole voci del preventivo.
</p>
</div>
</div>
);
}
Modify src/app/admin/clients/[id]/page.tsx — add QuoteTab as 5th tab:
- Add import at top:
import { QuoteTab } from "@/components/admin/tabs/QuoteTab";
- Update destructure from
getClientFullDetail:
const { client, phases, payments, documents, comments, quoteItems, activeServices } = detail;
- Add 5th TabsTrigger after "Commenti":
<TabsTrigger value="quote">Preventivo</TabsTrigger>
- Add 5th TabsContent after the comments TabsContent:
<TabsContent value="quote">
<QuoteTab
clientId={client.id}
items={quoteItems}
activeServices={activeServices}
acceptedTotal={client.accepted_total ?? "0"}
/>
</TabsContent>
<threat_model>
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" |
| </threat_model> |
<success_criteria>
src/app/admin/clients/[id]/quote-actions.tsexports three Server Actions with requireAdmin + Zod guardsgetClientFullDetailreturnsquoteItems: QuoteItemWithLabel[]andactiveServices: ServiceCatalog[]- QuoteTab renders all three sections: add items (catalog + freeform toggle), items table with calculated total, accepted total editor
client-view.tscontains zero references toquote_items- TypeScript and build both pass clean </success_criteria>