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
+245 -1
View File
@@ -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<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");
}