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)
This commit is contained in:
Simone Cavalli
2026-05-17 11:42:44 +02:00
parent 29bfd88255
commit db81829b85
2 changed files with 119 additions and 0 deletions
@@ -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}`);
}