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:
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user