feat(12-03): add offer editor server actions

- 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)
This commit is contained in:
2026-06-15 10:17:07 +02:00
parent 0d742f2328
commit a372c613e2
2 changed files with 487 additions and 1 deletions
+242
View File
@@ -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 <behavior> 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,
};
+245 -1
View File
@@ -6,9 +6,11 @@ import {
offer_micros, offer_micros,
offer_services, offer_services,
offer_micro_services, offer_micro_services,
offer_tier_services,
tags,
} from "@/db/schema"; } from "@/db/schema";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
@@ -130,3 +132,245 @@ export async function updateMicroOfferServices(microId: string, serviceIds: stri
} }
revalidatePath("/admin/offers"); 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<typeof saveOfferEditorSchema>;
// 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<OfferTagDimension, string> = {
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");
}