From db81829b85e9073f288cd0a8355f60de2ecdd803 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Sun, 17 May 2026 11:42:44 +0200 Subject: [PATCH] feat(03-03): Server Actions quote CRUD + extend getClientFullDetail - Create quote-actions.ts: addQuoteItem, removeQuoteItem, updateAcceptedTotal - All three actions guarded by requireAdmin() + Zod validation - Extend admin-queries.ts: QuoteItemWithLabel type, quoteItems + activeServices queries - quote_items NEVER exposed via client-facing routes (security constraint enforced) --- src/app/admin/clients/[id]/quote-actions.ts | 79 +++++++++++++++++++++ src/lib/admin-queries.ts | 40 +++++++++++ 2 files changed, 119 insertions(+) create mode 100644 src/app/admin/clients/[id]/quote-actions.ts diff --git a/src/app/admin/clients/[id]/quote-actions.ts b/src/app/admin/clients/[id]/quote-actions.ts new file mode 100644 index 0000000..f76391d --- /dev/null +++ b/src/app/admin/clients/[id]/quote-actions.ts @@ -0,0 +1,79 @@ +"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}`); +} diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts index ab50a5c..085962f 100644 --- a/src/lib/admin-queries.ts +++ b/src/lib/admin-queries.ts @@ -9,6 +9,8 @@ import { documents, notes, time_entries, + quote_items, + service_catalog, } from "@/db/schema"; import { eq, inArray, asc, isNull, sql } from "drizzle-orm"; import type { @@ -20,6 +22,7 @@ import type { Document, Note, Comment, + ServiceCatalog, } from "@/db/schema"; export type ClientWithPayments = { @@ -108,6 +111,17 @@ export async function getClientById(id: string) { // ── ClientFullDetail — used by /admin/clients/[id] workspace ───────────────── +// quote_items NEVER exposed via client API — admin workspace query only +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; +}; + export type ClientFullDetail = { client: Client; phases: Array }>; @@ -115,6 +129,8 @@ export type ClientFullDetail = { documents: Document[]; notes: Note[]; comments: Comment[]; + quoteItems: QuoteItemWithLabel[]; + activeServices: ServiceCatalog[]; }; export async function getClientFullDetail(id: string): Promise { @@ -180,6 +196,28 @@ export async function getClientFullDetail(id: string): Promise`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)); + return { client, phases: phasesWithTasks, @@ -187,5 +225,7 @@ export async function getClientFullDetail(id: string): Promise