From a372c613e2dd3676a53d17e20f3a11343febddd0 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Mon, 15 Jun 2026 10:17:07 +0200 Subject: [PATCH] feat(12-03): add offer editor server actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - saveOfferEditor(macroId, payload): persists macro scalars, per-tier upsert (tier_letter Zod-validated A/B/C), offer_tier_services delete-then-reinsert, and Tipo/Obiettivo tags delete-then-reinsert, all in one call - toggleOfferArchived(macroId, archived) for OFFER-18 - addOfferTag/removeOfferTag/renameOfferOption for Tipo/Obiettivo/ Categoria/Ticket dimensions, mirroring catalog option-pool pattern - createOfferMacro(formData) — Plan 04 "+ Nuova Offerta" target - scripts/verify-12-03-actions.ts documents the 9 spec'd test cases (typecheck-only, requireAdmin needs a request context) --- scripts/verify-12-03-actions.ts | 242 +++++++++++++++++++++++++++++++ src/app/admin/offers/actions.ts | 246 +++++++++++++++++++++++++++++++- 2 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 scripts/verify-12-03-actions.ts diff --git a/scripts/verify-12-03-actions.ts b/scripts/verify-12-03-actions.ts new file mode 100644 index 0000000..32f6adb --- /dev/null +++ b/scripts/verify-12-03-actions.ts @@ -0,0 +1,242 @@ +// Phase 12 Plan 03 — Task 2 verification script for actions.ts additions. +// +// This project has no test runner configured (no `scripts.test` in package.json), +// and `.env.local`'s DATABASE_URL points at PRODUCTION (per project memory) — so +// this script does NOT execute any DB calls or server actions (server actions +// also require an Auth.js session, unavailable outside a request context). It +// typechecks against the real exports/types from +// src/app/admin/offers/actions.ts and documents the 9 expected behaviors from +// the plan's block, for manual run against a dev DB later if desired. +// +// Run (manual, dev DB only, inside a request context with admin session): +// npx tsx scripts/verify-12-03-actions.ts + +import type { SaveOfferEditorPayload } from "@/app/admin/offers/actions"; +import { + saveOfferEditor, + toggleOfferArchived, + addOfferTag, + removeOfferTag, + renameOfferOption, + createOfferMacro, +} from "@/app/admin/offers/actions"; + +// ── Test 1: tier_letter Zod validation ─────────────────────────────────────── +// saveOfferEditor(macroId, payload) rejects (throws) when payload.tiers contains +// a tier_letter not in ["A","B","C"] — Zod enum validation, defense-in-depth +// alongside the DB CHECK constraint from Plan 01. +async function test1_invalidTierLetter(macroId: string) { + const badPayload = { + internal_name: "Test", + public_name: "Test", + tiers: [ + { + tier_letter: "D", // invalid — not in ["A","B","C"] + internal_name: "Tier X", + public_name: "Tier X", + duration_months: 3, + assignedServiceIds: [], + }, + ], + tipoTags: [], + obiettivoTags: [], + } as unknown as SaveOfferEditorPayload; + // Expected: saveOfferEditor(macroId, badPayload) throws (Zod enum mismatch + // surfaces as parsed.error.issues[0].message before any DB write). + return () => saveOfferEditor(macroId, badPayload); +} + +// ── Test 2: macro scalar field update ──────────────────────────────────────── +// saveOfferEditor updates offer_macros scalars (internal_name, description, +// category, ticket, cliente_ideale, risultato, tempo, pain, metodo) in one +// db.update(offer_macros)...where(eq(id, macroId)). +async function test2_macroScalars(macroId: string) { + const payload: SaveOfferEditorPayload = { + internal_name: "Offerta Test", + public_name: "Offerta Pubblica", + description: "Una descrizione", + category: "Signature Offer", + ticket: "Mid Ticket", + cliente_ideale: "Coach", + risultato: "Lead qualificati", + tempo: "90 giorni", + pain: "Mancanza di processo", + metodo: "Sistema X", + tiers: [], + tipoTags: [], + obiettivoTags: [], + }; + // Expected: after call, offer_macros row for macroId has all 9 scalar + // fields set to the payload values. + return () => saveOfferEditor(macroId, payload); +} + +// ── Test 3: tier upsert (update existing id, insert when no id) ────────────── +// If tier.id is provided -> db.update(offer_micros).set({...}); if no id -> +// db.insert(offer_micros).values({..., macro_id: macroId}). +async function test3_tierUpsert(macroId: string, existingTierId: string) { + const payload: SaveOfferEditorPayload = { + internal_name: "Offerta Test", + public_name: "Offerta Pubblica", + tiers: [ + { + id: existingTierId, // -> update path + tier_letter: "A", + internal_name: "Tier A", + public_name: "Tier A Pubblico", + duration_months: 3, + public_price: 1500, + assignedServiceIds: [], + }, + { + // no id -> insert path (supports macros with < 3 tiers) + tier_letter: "B", + internal_name: "Tier B", + public_name: "Tier B Pubblico", + duration_months: 6, + public_price: 2500, + assignedServiceIds: [], + }, + ], + tipoTags: [], + obiettivoTags: [], + }; + // Expected: existingTierId row updated in place; a new offer_micros row + // created for tier B with macro_id = macroId. + return () => saveOfferEditor(macroId, payload); +} + +// ── Test 4: offer_tier_services delete-then-reinsert ───────────────────────── +// A tier with assignedServiceIds: ["svc1","svc2"] -> after call, +// offer_tier_services has exactly 2 rows for that tier_id, both svc1/svc2. +async function test4_tierServicesReplace(macroId: string, tierId: string) { + const payload: SaveOfferEditorPayload = { + internal_name: "Offerta Test", + public_name: "Offerta Pubblica", + tiers: [ + { + id: tierId, + tier_letter: "A", + internal_name: "Tier A", + public_name: "Tier A Pubblico", + duration_months: 3, + assignedServiceIds: ["svc1", "svc2"], + }, + ], + tipoTags: [], + obiettivoTags: [], + }; + // Expected: offer_tier_services has exactly 2 rows where tier_id === tierId, + // service_id IN ("svc1", "svc2"). + return () => saveOfferEditor(macroId, payload); +} + +// ── Test 5: Tipo/Obiettivo tags delete-then-reinsert ───────────────────────── +// saveOfferEditor replaces tags where entity_type IN +// ("offer_macros.tipo","offer_macros.obiettivo") and entity_id = macroId. +async function test5_tagsReplace(macroId: string) { + const payload: SaveOfferEditorPayload = { + internal_name: "Offerta Test", + public_name: "Offerta Pubblica", + tiers: [], + tipoTags: ["Audit", "Coaching"], + obiettivoTags: ["Lead Generation"], + }; + // Expected: tags table has 2 rows entity_type="offer_macros.tipo" (Audit, + // Coaching) and 1 row entity_type="offer_macros.obiettivo" (Lead + // Generation), all entity_id = macroId; any prior rows for this macroId + + // these two entity_types are removed first. + return () => saveOfferEditor(macroId, payload); +} + +// ── Test 6: toggleOfferArchived ────────────────────────────────────────────── +// toggleOfferArchived(macroId, archived: boolean) sets offer_macros.is_archived = archived. +async function test6_toggleArchived(macroId: string) { + // Expected: offer_macros.is_archived === true after toggleOfferArchived(macroId, true); + // === false after toggleOfferArchived(macroId, false). + return [ + () => toggleOfferArchived(macroId, true), + () => toggleOfferArchived(macroId, false), + ]; +} + +// ── Test 7: addOfferTag / removeOfferTag ───────────────────────────────────── +// addOfferTag(dimension, macroId, value) / removeOfferTag(dimension, macroId, +// value) work for dimension in ("tipo","obiettivo"). Reject any other +// dimension value. +async function test7_tagCrud(macroId: string) { + // Expected: addOfferTag("tipo", macroId, "Audit") inserts a tags row + // (entity_type="offer_macros.tipo", entity_id=macroId, name="Audit"); + // removeOfferTag("tipo", macroId, "Audit") deletes it. + // addOfferTag("invalid" as any, macroId, "x") throws. + return { + addTipo: () => addOfferTag("tipo", macroId, "Audit"), + removeTipo: () => removeOfferTag("tipo", macroId, "Audit"), + addObiettivo: () => addOfferTag("obiettivo", macroId, "Lead Generation"), + removeObiettivo: () => removeOfferTag("obiettivo", macroId, "Lead Generation"), + // @ts-expect-error -- intentional invalid dimension to verify the throw path + invalid: () => addOfferTag("invalid", macroId, "x"), + }; +} + +// ── Test 8: renameOfferOption ──────────────────────────────────────────────── +// renameOfferOption(field, oldValue, newValue) for field in ("categoria", +// "ticket") updates offer_macros.category/ticket for all matching rows; for +// field in ("tipo","obiettivo") updates tags.name. +async function test8_renameOption() { + // Expected: + // renameOfferOption("categoria", "Entry Offer", "Offerta Base") updates + // all offer_macros rows where category = "Entry Offer" -> "Offerta Base" + // renameOfferOption("ticket", "Low Ticket", "Ticket Basso") updates + // all offer_macros rows where ticket = "Low Ticket" -> "Ticket Basso" + // renameOfferOption("tipo", "Audit", "Diagnosi") updates all tags rows + // where entity_type="offer_macros.tipo" and name="Audit" -> "Diagnosi" + // renameOfferOption("obiettivo", "Lead Generation", "Acquisizione Lead") + // updates tags rows where entity_type="offer_macros.obiettivo" + return { + renameCategoria: () => renameOfferOption("categoria", "Entry Offer", "Offerta Base"), + renameTicket: () => renameOfferOption("ticket", "Low Ticket", "Ticket Basso"), + renameTipo: () => renameOfferOption("tipo", "Audit", "Diagnosi"), + renameObiettivo: () => renameOfferOption("obiettivo", "Lead Generation", "Acquisizione Lead"), + }; +} + +// ── Test 9: createOfferMacro ───────────────────────────────────────────────── +// createOfferMacro(formData) creates a new offer_macros row from internal_name +// (required) + optional public_name/description/category. If public_name is +// omitted, defaults to internal_name. +async function test9_createOfferMacro() { + const fd1 = new FormData(); + fd1.append("internal_name", "Nuova Offerta Interna"); + fd1.append("description", "Descrizione breve"); + fd1.append("category", "Entry Offer"); + // Expected: new offer_macros row with internal_name="Nuova Offerta Interna", + // public_name="Nuova Offerta Interna" (defaulted), description set, + // category="Entry Offer". + + const fd2 = new FormData(); + fd2.append("internal_name", "Altra Offerta"); + fd2.append("public_name", "Nome Pubblico Esplicito"); + // Expected: new offer_macros row with public_name="Nome Pubblico Esplicito" + // (not defaulted, since explicitly provided). + + return { + withDefaultPublicName: () => createOfferMacro(fd1), + withExplicitPublicName: () => createOfferMacro(fd2), + }; +} + +// Not executed automatically (production DB + no request context for +// requireAdmin) — typecheck-only verification. + +export { + test1_invalidTierLetter, + test2_macroScalars, + test3_tierUpsert, + test4_tierServicesReplace, + test5_tagsReplace, + test6_toggleArchived, + test7_tagCrud, + test8_renameOption, + test9_createOfferMacro, +}; diff --git a/src/app/admin/offers/actions.ts b/src/app/admin/offers/actions.ts index de55405..d6147b8 100644 --- a/src/app/admin/offers/actions.ts +++ b/src/app/admin/offers/actions.ts @@ -6,9 +6,11 @@ import { offer_micros, offer_services, offer_micro_services, + offer_tier_services, + tags, } from "@/db/schema"; import { revalidatePath } from "next/cache"; -import { eq } from "drizzle-orm"; +import { eq, and, inArray } from "drizzle-orm"; import { z } from "zod"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; @@ -130,3 +132,245 @@ export async function updateMicroOfferServices(microId: string, serviceIds: stri } revalidatePath("/admin/offers"); } + +// ── Phase 12: Offer Editor server actions ─────────────────────────────────── +// entity_type values for the polymorphic `tags` table, scoped to offer_macros' +// Tipo/Obiettivo dimensions (D-06 pattern — separate pools from "services"/"leads"). +const OFFER_TIPO_ENTITY = "offer_macros.tipo"; +const OFFER_OBIETTIVO_ENTITY = "offer_macros.obiettivo"; + +const tierSchema = z.object({ + id: z.string().optional(), + tier_letter: z.enum(["A", "B", "C"]), + internal_name: z.string().min(1), + public_name: z.string().min(1), + duration_months: z.coerce.number().int().min(1), + public_price: z.coerce.number().min(0).optional().nullable(), + assignedServiceIds: z.array(z.string()), +}); + +const saveOfferEditorSchema = z.object({ + internal_name: z.string().min(1, "Nome interno richiesto"), + public_name: z.string().min(1, "Nome pubblico richiesto"), + description: z.string().optional(), + category: z.string().optional(), + ticket: z.string().optional(), + cliente_ideale: z.string().optional(), + risultato: z.string().optional(), + tempo: z.string().optional(), + pain: z.string().optional(), + metodo: z.string().optional(), + tiers: z.array(tierSchema).max(3), + tipoTags: z.array(z.string()), + obiettivoTags: z.array(z.string()), +}); + +export type SaveOfferEditorPayload = z.infer; + +// Persists the entire offer editor state in one call: macro scalar fields +// (incl. category/ticket/transformation-promise), per-tier upsert +// (tier_letter validated A/B/C via Zod — defense-in-depth alongside the DB +// CHECK constraint from Plan 01) with delete-then-reinsert +// offer_tier_services, and Tipo/Obiettivo tags (delete-then-reinsert). +export async function saveOfferEditor(macroId: string, payload: SaveOfferEditorPayload) { + await requireAdmin(); + + const parsed = saveOfferEditorSchema.safeParse(payload); + if (!parsed.success) throw new Error(parsed.error.issues[0].message); + const data = parsed.data; + + // 1. Update macro scalar fields. + await db + .update(offer_macros) + .set({ + internal_name: data.internal_name, + public_name: data.public_name, + description: data.description || null, + category: data.category || null, + ticket: data.ticket || null, + cliente_ideale: data.cliente_ideale || null, + risultato: data.risultato || null, + tempo: data.tempo || null, + pain: data.pain || null, + metodo: data.metodo || null, + }) + .where(eq(offer_macros.id, macroId)); + + // 2. Upsert each tier, then replace its offer_tier_services assignments. + for (const tier of data.tiers) { + const publicPrice = tier.public_price != null ? String(tier.public_price) : null; + let tierId: string; + + if (tier.id) { + await db + .update(offer_micros) + .set({ + tier_letter: tier.tier_letter, + internal_name: tier.internal_name, + public_name: tier.public_name, + duration_months: tier.duration_months, + public_price: publicPrice, + }) + .where(eq(offer_micros.id, tier.id)); + tierId = tier.id; + } else { + const [inserted] = await db + .insert(offer_micros) + .values({ + macro_id: macroId, + tier_letter: tier.tier_letter, + internal_name: tier.internal_name, + public_name: tier.public_name, + duration_months: tier.duration_months, + public_price: publicPrice, + }) + .returning({ id: offer_micros.id }); + tierId = inserted.id; + } + + // Delete-then-reinsert offer_tier_services (mirrors updateMicroOfferServices). + await db.delete(offer_tier_services).where(eq(offer_tier_services.tier_id, tierId)); + if (tier.assignedServiceIds.length > 0) { + await db.insert(offer_tier_services).values( + tier.assignedServiceIds.map((serviceId) => ({ tier_id: tierId, service_id: serviceId })) + ); + } + } + + // 3. Delete-then-reinsert Tipo/Obiettivo tags. + await db + .delete(tags) + .where( + and( + eq(tags.entity_id, macroId), + inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY]) + ) + ); + + const tagRows = [ + ...data.tipoTags.map((name) => ({ entity_type: OFFER_TIPO_ENTITY, entity_id: macroId, name })), + ...data.obiettivoTags.map((name) => ({ + entity_type: OFFER_OBIETTIVO_ENTITY, + entity_id: macroId, + name, + })), + ]; + if (tagRows.length > 0) { + await db.insert(tags).values(tagRows).onConflictDoNothing(); + } + + revalidatePath("/admin/offers"); + revalidatePath(`/admin/offers/${macroId}/edit`); +} + +// Toggles the archive flag on an offer macro (OFFER-18). +export async function toggleOfferArchived(macroId: string, archived: boolean) { + await requireAdmin(); + await db.update(offer_macros).set({ is_archived: archived }).where(eq(offer_macros.id, macroId)); + revalidatePath("/admin/offers"); +} + +// ── Tipo/Obiettivo tag CRUD (mirrors addServiceOption/removeServiceOption) ─── + +type OfferTagDimension = "tipo" | "obiettivo"; + +const OFFER_TAG_ENTITY: Record = { + tipo: OFFER_TIPO_ENTITY, + obiettivo: OFFER_OBIETTIVO_ENTITY, +}; + +export async function addOfferTag(dimension: OfferTagDimension, macroId: string, value: string) { + await requireAdmin(); + if (!(dimension in OFFER_TAG_ENTITY)) throw new Error(`Dimensione non valida: ${dimension}`); + const trimmed = value.trim(); + if (trimmed.length === 0) throw new Error("Valore richiesto"); + + await db + .insert(tags) + .values({ entity_type: OFFER_TAG_ENTITY[dimension], entity_id: macroId, name: trimmed }) + .onConflictDoNothing(); + + revalidatePath("/admin/offers"); +} + +export async function removeOfferTag( + dimension: OfferTagDimension, + macroId: string, + value: string +) { + await requireAdmin(); + if (!(dimension in OFFER_TAG_ENTITY)) throw new Error(`Dimensione non valida: ${dimension}`); + + await db + .delete(tags) + .where( + and( + eq(tags.entity_type, OFFER_TAG_ENTITY[dimension]), + eq(tags.entity_id, macroId), + eq(tags.name, value) + ) + ); + + revalidatePath("/admin/offers"); +} + +// ── Rename an option everywhere it is used (mirrors renameServiceOption) ──── +// "categoria"/"ticket" are single-select columns on offer_macros; "tipo"/ +// "obiettivo" are multi-select pools in the polymorphic `tags` table. + +type OfferOptionField = "categoria" | "ticket" | "tipo" | "obiettivo"; + +export async function renameOfferOption( + field: OfferOptionField, + oldValue: string, + newValue: string +) { + await requireAdmin(); + const next = newValue.trim(); + if (next.length === 0) throw new Error("Nuovo nome richiesto"); + if (next === oldValue) return; + + if (field === "categoria") { + await db.update(offer_macros).set({ category: next }).where(eq(offer_macros.category, oldValue)); + } else if (field === "ticket") { + await db.update(offer_macros).set({ ticket: next }).where(eq(offer_macros.ticket, oldValue)); + } else if (field === "tipo" || field === "obiettivo") { + await db + .update(tags) + .set({ name: next }) + .where(and(eq(tags.entity_type, OFFER_TAG_ENTITY[field]), eq(tags.name, oldValue))); + } else { + throw new Error(`Campo non valido: ${field}`); + } + + revalidatePath("/admin/offers"); +} + +// ── Create a new offer macro (Plan 04 "+ Nuova Offerta" target) ───────────── + +const createOfferMacroSchema = z.object({ + internal_name: z.string().min(1, "Nome interno richiesto"), + public_name: z.string().optional(), + description: z.string().optional(), + category: z.string().optional(), +}); + +export async function createOfferMacro(formData: FormData) { + await requireAdmin(); + const parsed = createOfferMacroSchema.safeParse({ + internal_name: formData.get("internal_name"), + public_name: formData.get("public_name") ?? "", + description: formData.get("description") ?? "", + category: formData.get("category") ?? "", + }); + 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?.trim() || parsed.data.internal_name, + description: parsed.data.description || null, + category: parsed.data.category || null, + }); + + revalidatePath("/admin/offers"); +}