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:
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
documents,
|
documents,
|
||||||
notes,
|
notes,
|
||||||
time_entries,
|
time_entries,
|
||||||
|
quote_items,
|
||||||
|
service_catalog,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { eq, inArray, asc, isNull, sql } from "drizzle-orm";
|
import { eq, inArray, asc, isNull, sql } from "drizzle-orm";
|
||||||
import type {
|
import type {
|
||||||
@@ -20,6 +22,7 @@ import type {
|
|||||||
Document,
|
Document,
|
||||||
Note,
|
Note,
|
||||||
Comment,
|
Comment,
|
||||||
|
ServiceCatalog,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
|
|
||||||
export type ClientWithPayments = {
|
export type ClientWithPayments = {
|
||||||
@@ -108,6 +111,17 @@ export async function getClientById(id: string) {
|
|||||||
|
|
||||||
// ── ClientFullDetail — used by /admin/clients/[id] workspace ─────────────────
|
// ── 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 = {
|
export type ClientFullDetail = {
|
||||||
client: Client;
|
client: Client;
|
||||||
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
|
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
|
||||||
@@ -115,6 +129,8 @@ export type ClientFullDetail = {
|
|||||||
documents: Document[];
|
documents: Document[];
|
||||||
notes: Note[];
|
notes: Note[];
|
||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
|
quoteItems: QuoteItemWithLabel[];
|
||||||
|
activeServices: ServiceCatalog[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getClientFullDetail(id: string): Promise<ClientFullDetail | null> {
|
export async function getClientFullDetail(id: string): Promise<ClientFullDetail | null> {
|
||||||
@@ -180,6 +196,28 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
client,
|
client,
|
||||||
phases: phasesWithTasks,
|
phases: phasesWithTasks,
|
||||||
@@ -187,5 +225,7 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
|
|||||||
documents: documentsRows,
|
documents: documentsRows,
|
||||||
notes: notesRows,
|
notes: notesRows,
|
||||||
comments: commentsRows,
|
comments: commentsRows,
|
||||||
|
quoteItems: quoteItemRows,
|
||||||
|
activeServices: activeServiceRows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user