From 56f084912a099d06d464661fedafcab7c139b210 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Sat, 30 May 2026 15:35:54 +0200 Subject: [PATCH] feat(05-02): add offer-queries.ts and server actions for offer catalog CRUD - Create src/lib/offer-queries.ts with getCatalogWithMicros, getAllOfferServices, getMicroAssignedServiceIds - Create src/app/admin/offers/actions.ts with createMacro, deleteMacro, createMicro, deleteMicro, createOfferService, toggleOfferServiceActive, updateMicroOfferServices - All exported server actions guard with requireAdmin() + revalidatePath(/admin/offers) --- src/app/admin/offers/actions.ts | 132 ++++++++++++++++++++++++++++++++ src/lib/offer-queries.ts | 96 +++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 src/app/admin/offers/actions.ts create mode 100644 src/lib/offer-queries.ts diff --git a/src/app/admin/offers/actions.ts b/src/app/admin/offers/actions.ts new file mode 100644 index 0000000..de55405 --- /dev/null +++ b/src/app/admin/offers/actions.ts @@ -0,0 +1,132 @@ +"use server"; + +import { db } from "@/db"; +import { + offer_macros, + offer_micros, + offer_services, + offer_micro_services, +} 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"); +} + +// ── Macro-offer CRUD ───────────────────────────────────────────────────────── + +const macroSchema = z.object({ + internal_name: z.string().min(1, "Nome interno richiesto"), + public_name: z.string().min(1, "Nome pubblico richiesto"), + transformation_promise: z.string().optional(), +}); + +export async function createMacro(formData: FormData) { + await requireAdmin(); + const parsed = macroSchema.safeParse({ + internal_name: formData.get("internal_name"), + public_name: formData.get("public_name"), + transformation_promise: formData.get("transformation_promise") ?? "", + }); + if (!parsed.success) throw new Error(parsed.error.issues[0].message); + await db.insert(offer_macros).values({ + internal_name: parsed.data.internal_name, + public_name: parsed.data.public_name, + transformation_promise: parsed.data.transformation_promise || null, + }); + revalidatePath("/admin/offers"); +} + +export async function deleteMacro(macroId: string) { + await requireAdmin(); + await db.delete(offer_macros).where(eq(offer_macros.id, macroId)); + revalidatePath("/admin/offers"); +} + +// ── Micro-offer CRUD ───────────────────────────────────────────────────────── + +const microSchema = z.object({ + macro_id: z.string().min(1, "Macro offerta richiesta"), + internal_name: z.string().min(1, "Nome interno richiesto"), + public_name: z.string().min(1, "Nome pubblico richiesto"), + transformation_promise: z.string().optional(), + duration_months: z.coerce.number().int().min(1, "Durata minima 1 mese"), +}); + +export async function createMicro(formData: FormData) { + await requireAdmin(); + const parsed = microSchema.safeParse({ + macro_id: formData.get("macro_id"), + internal_name: formData.get("internal_name"), + public_name: formData.get("public_name"), + transformation_promise: formData.get("transformation_promise") ?? "", + duration_months: formData.get("duration_months"), + }); + if (!parsed.success) throw new Error(parsed.error.issues[0].message); + await db.insert(offer_micros).values({ + macro_id: parsed.data.macro_id, + internal_name: parsed.data.internal_name, + public_name: parsed.data.public_name, + transformation_promise: parsed.data.transformation_promise || null, + duration_months: parsed.data.duration_months, + }); + revalidatePath("/admin/offers"); +} + +export async function deleteMicro(microId: string) { + await requireAdmin(); + // offer_micro_services will cascade; project_offers will restrict (DB constraint) + await db.delete(offer_micros).where(eq(offer_micros.id, microId)); + revalidatePath("/admin/offers"); +} + +// ── Offer service CRUD ─────────────────────────────────────────────────────── + +const offerServiceSchema = z.object({ + name: z.string().min(1, "Nome richiesto"), + price: z.coerce.number().min(0, "Prezzo non può essere negativo"), + transformation_description: z.string().optional(), +}); + +export async function createOfferService(formData: FormData) { + await requireAdmin(); + const parsed = offerServiceSchema.safeParse({ + name: formData.get("name"), + price: formData.get("price"), + transformation_description: formData.get("transformation_description") ?? "", + }); + if (!parsed.success) throw new Error(parsed.error.issues[0].message); + await db.insert(offer_services).values({ + name: parsed.data.name, + price: parsed.data.price.toFixed(2), + transformation_description: parsed.data.transformation_description || null, + }); + revalidatePath("/admin/offers"); +} + +export async function toggleOfferServiceActive(serviceId: string, active: boolean) { + await requireAdmin(); + await db.update(offer_services).set({ active }).where(eq(offer_services.id, serviceId)); + revalidatePath("/admin/offers"); +} + +// ── Multi-select service assignment ───────────────────────────────────────── + +// Replaces all assignments for a micro with the provided serviceIds (upsert-replace pattern) +export async function updateMicroOfferServices(microId: string, serviceIds: string[]) { + await requireAdmin(); + // Delete existing assignments for this micro + await db.delete(offer_micro_services).where(eq(offer_micro_services.micro_id, microId)); + // Re-insert with new set (empty array = unassign all) + if (serviceIds.length > 0) { + await db.insert(offer_micro_services).values( + serviceIds.map((serviceId) => ({ micro_id: microId, service_id: serviceId })) + ); + } + revalidatePath("/admin/offers"); +} diff --git a/src/lib/offer-queries.ts b/src/lib/offer-queries.ts new file mode 100644 index 0000000..25e262a --- /dev/null +++ b/src/lib/offer-queries.ts @@ -0,0 +1,96 @@ +import { db } from "@/db"; +import { + offer_macros, + offer_micros, + offer_services, + offer_micro_services, +} from "@/db/schema"; +import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema"; +import { eq, asc, inArray, sql } from "drizzle-orm"; + +export type MicroWithServices = OfferMicro & { + services: Array<{ id: string; name: string; price: string }>; + cumulative_price: string; +}; + +export type MacroWithMicros = OfferMacro & { + micros: MicroWithServices[]; +}; + +export async function getCatalogWithMicros(): Promise { + const macros = await db + .select() + .from(offer_macros) + .orderBy(asc(offer_macros.sort_order), asc(offer_macros.created_at)); + + if (macros.length === 0) return []; + + const macroIds = macros.map((m) => m.id); + + const micros = await db + .select() + .from(offer_micros) + .where(inArray(offer_micros.macro_id, macroIds)) + .orderBy(asc(offer_micros.sort_order)); + + const microIds = micros.map((m) => m.id); + + if (microIds.length === 0) { + return macros.map((m) => ({ ...m, micros: [] })); + } + + // Fetch junction rows + service data in one query + const assignments = await db + .select({ + micro_id: offer_micro_services.micro_id, + service_id: offer_services.id, + name: offer_services.name, + price: offer_services.price, + }) + .from(offer_micro_services) + .innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id)) + .where(inArray(offer_micro_services.micro_id, microIds)); + + // Cumulative price per micro + const cumulRows = await db + .select({ + micro_id: offer_micro_services.micro_id, + cumulative_price: sql`coalesce(sum(${offer_services.price}::numeric), 0)`, + }) + .from(offer_micro_services) + .innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id)) + .where(inArray(offer_micro_services.micro_id, microIds)) + .groupBy(offer_micro_services.micro_id); + + const cumulMap = new Map(cumulRows.map((r) => [r.micro_id, r.cumulative_price])); + + const microsWithServices: MicroWithServices[] = micros.map((micro) => ({ + ...micro, + services: assignments + .filter((a) => a.micro_id === micro.id) + .map((a) => ({ id: a.service_id, name: a.name, price: String(a.price) })), + cumulative_price: cumulMap.get(micro.id) ?? "0", + })); + + return macros.map((macro) => ({ + ...macro, + micros: microsWithServices.filter((m) => m.macro_id === macro.id), + })); +} + +export async function getAllOfferServices(): Promise { + return db + .select() + .from(offer_services) + .where(eq(offer_services.active, true)) + .orderBy(asc(offer_services.name)); +} + +// Returns assigned service IDs for a given micro-offer (used by ServiceCheckboxList) +export async function getMicroAssignedServiceIds(microId: string): Promise { + const rows = await db + .select({ service_id: offer_micro_services.service_id }) + .from(offer_micro_services) + .where(eq(offer_micro_services.micro_id, microId)); + return rows.map((r) => r.service_id); +}