"use server"; import { db } from "@/db"; import { services, tags } from "@/db/schema"; import { revalidatePath } from "next/cache"; import { eq, and } from "drizzle-orm"; import { z } from "zod"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { addPoolValue, renamePoolValue } from "@/lib/taxonomy"; const serviceSchema = z.object({ name: z.string().min(1, "Nome richiesto"), description: z.string().optional(), unit_price: z.coerce.number().min(0.01, "Prezzo deve essere maggiore di 0"), category: z.string().optional(), }); async function requireAdmin() { const session = await getServerSession(authOptions); if (!session) throw new Error("Non autorizzato"); } export async function createService(formData: FormData) { await requireAdmin(); const parsed = serviceSchema.safeParse({ name: formData.get("name"), description: formData.get("description") ?? "", unit_price: formData.get("unit_price"), category: formData.get("category") ?? "", }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); // New rows created from /admin/catalog are NOT migrated — migrated_from/migrated_id stay null await db.insert(services).values({ name: parsed.data.name, description: parsed.data.description ?? null, unit_price: parsed.data.unit_price.toFixed(2), category: parsed.data.category || null, }); revalidatePath("/admin/catalog"); } export async function updateService(serviceId: string, formData: FormData) { await requireAdmin(); const parsed = serviceSchema.safeParse({ name: formData.get("name"), description: formData.get("description") ?? "", unit_price: formData.get("unit_price"), category: formData.get("category") ?? "", }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); await db .update(services) .set({ name: parsed.data.name, description: parsed.data.description ?? null, unit_price: parsed.data.unit_price.toFixed(2), category: parsed.data.category || null, }) .where(eq(services.id, serviceId)); revalidatePath("/admin/catalog"); } export async function toggleServiceActive(serviceId: string, active: boolean) { await requireAdmin(); await db.update(services).set({ active }).where(eq(services.id, serviceId)); revalidatePath("/admin/catalog"); } // ── Inline-edit, tag, and quick-add actions (Phase 11 database-view) ──────── const EDITABLE_FIELDS = ["name", "description", "category", "fase", "unit_price", "active"] as const; type EditableField = (typeof EDITABLE_FIELDS)[number]; export async function updateServiceField( serviceId: string, fieldName: EditableField, value: string | boolean ) { await requireAdmin(); if (!EDITABLE_FIELDS.includes(fieldName)) { throw new Error(`Campo non editabile: ${fieldName}`); } if (fieldName === "name") { const s = String(value).trim(); if (s.length === 0) throw new Error("Nome richiesto"); await db.update(services).set({ name: s }).where(eq(services.id, serviceId)); } else if (fieldName === "description") { const s = String(value).trim(); await db.update(services).set({ description: s || null }).where(eq(services.id, serviceId)); } else if (fieldName === "category") { const s = String(value).trim(); await db.update(services).set({ category: s || null }).where(eq(services.id, serviceId)); } else if (fieldName === "fase") { const s = String(value).trim(); await db.update(services).set({ fase: s || null }).where(eq(services.id, serviceId)); if (s) await addPoolValue("service_fase", s); } else if (fieldName === "unit_price") { // Normalize locale-formatted input (WR-04): the cell displays it-IT (€1.234,50), // so an admin may type "1.234,50". When a comma is present, treat "." as thousands // separators and "," as the decimal mark; otherwise parse "." as the decimal mark. // Number() (not parseFloat) rejects trailing garbage like "12abc". const rawInput = String(value).trim(); const normalized = rawInput.includes(",") ? rawInput.replace(/\./g, "").replace(",", ".") : rawInput; const num = Number(normalized); if (!Number.isFinite(num) || num < 0) throw new Error("Prezzo invalido"); await db.update(services).set({ unit_price: num.toFixed(2) }).where(eq(services.id, serviceId)); } else if (fieldName === "active") { const boolValue = typeof value === "boolean" ? value : value === "true"; await db.update(services).set({ active: boolValue }).where(eq(services.id, serviceId)); } revalidatePath("/admin/catalog"); } // ── Multi-select option fields (Notion-style shared pools) ────────────────── // "tag" and "pacchetto" are stored in the polymorphic `tags` table, scoped by // entity_type so the two pools stay separate. const MULTI_ENTITY: Record = { tag: "services", pacchetto: "services.pacchetto", }; const MULTI_FIELDS = ["tag", "pacchetto"] as const; export type MultiSelectField = (typeof MULTI_FIELDS)[number]; type SingleSelectField = "categoria" | "fase"; export async function addServiceOption( field: MultiSelectField, serviceId: string, value: string ) { await requireAdmin(); if (!MULTI_FIELDS.includes(field)) throw new Error(`Campo non valido: ${field}`); const trimmed = value.trim(); if (trimmed.length === 0) throw new Error("Valore richiesto"); await db .insert(tags) .values({ entity_type: MULTI_ENTITY[field], entity_id: serviceId, name: trimmed }) .onConflictDoNothing(); // Register into the persistent pool so it survives unassignment / shows in settings. await addPoolValue(field === "tag" ? "service_offerta" : "service_pacchetto", trimmed); revalidatePath("/admin/catalog"); } export async function removeServiceOption( field: MultiSelectField, serviceId: string, value: string ) { await requireAdmin(); if (!MULTI_FIELDS.includes(field)) throw new Error(`Campo non valido: ${field}`); await db .delete(tags) .where( and( eq(tags.entity_type, MULTI_ENTITY[field]), eq(tags.entity_id, serviceId), eq(tags.name, value) ) ); revalidatePath("/admin/catalog"); } // ── Rename an option everywhere it is used (propagates across all services) ─── // Works for both multi-select pools (tag/pacchetto) and single-select columns // (categoria/fase). This is what makes the dropdown options feel persistent & // editable like Notion select properties. export async function renameServiceOption( field: MultiSelectField | SingleSelectField, oldValue: string, newValue: string ) { await requireAdmin(); const next = newValue.trim(); if (next.length === 0) throw new Error("Nuovo nome richiesto"); if (next === oldValue) return; // renamePoolValue owns the propagation (tags / services.fase / project phases); // duplicating the UPDATE here would just run it twice. if (field === "tag" || field === "pacchetto") { await renamePoolValue(field === "tag" ? "service_offerta" : "service_pacchetto", oldValue, next); } else if (field === "categoria") { // No taxonomy pool backs services.category — this one propagates by hand. await db.update(services).set({ category: next }).where(eq(services.category, oldValue)); } else if (field === "fase") { await renamePoolValue("service_fase", oldValue, next); } else { throw new Error(`Campo non valido: ${field}`); } revalidatePath("/admin/catalog"); } // ── Quick-add: create a service from the bottom row with all scalar fields set // and active=true in one Enter. Tags/pacchetto are assigned afterwards (they // need the new row's id). Only `name` is required. export type QuickAddPayload = { name: string; description?: string; category?: string; fase?: string; unit_price?: string; }; export async function quickAddService(payload: QuickAddPayload | string) { await requireAdmin(); // Back-compat: accept a bare name string. const data: QuickAddPayload = typeof payload === "string" ? { name: payload } : payload; const name = data.name.trim(); if (name.length === 0) throw new Error("Nome richiesto"); let unit_price = "0.00"; if (data.unit_price && data.unit_price.trim().length > 0) { const raw = data.unit_price.trim(); const normalized = raw.includes(",") ? raw.replace(/\./g, "").replace(",", ".") : raw; const num = Number(normalized); if (!Number.isFinite(num) || num < 0) throw new Error("Prezzo invalido"); unit_price = num.toFixed(2); } const fase = data.fase?.trim() || null; await db.insert(services).values({ name, description: data.description?.trim() || null, category: data.category?.trim() || null, fase, unit_price, active: true, }); if (fase) await addPoolValue("service_fase", fase); revalidatePath("/admin/catalog"); }