feat(05-02): add offer-queries.ts and server actions for offer catalog CRUD
- Create src/lib/offer-queries.ts with getCatalogWithMicros, getAllOfferServices, getMicroAssignedServiceIds - Create src/app/admin/offers/actions.ts with createMacro, deleteMacro, createMicro, deleteMicro, createOfferService, toggleOfferServiceActive, updateMicroOfferServices - All exported server actions guard with requireAdmin() + revalidatePath(/admin/offers)
This commit is contained in:
@@ -0,0 +1,132 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { db } from "@/db";
|
||||||
|
import {
|
||||||
|
offer_macros,
|
||||||
|
offer_micros,
|
||||||
|
offer_services,
|
||||||
|
offer_micro_services,
|
||||||
|
} from "@/db/schema";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) throw new Error("Non autorizzato");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Macro-offer CRUD ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const macroSchema = z.object({
|
||||||
|
internal_name: z.string().min(1, "Nome interno richiesto"),
|
||||||
|
public_name: z.string().min(1, "Nome pubblico richiesto"),
|
||||||
|
transformation_promise: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createMacro(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const parsed = macroSchema.safeParse({
|
||||||
|
internal_name: formData.get("internal_name"),
|
||||||
|
public_name: formData.get("public_name"),
|
||||||
|
transformation_promise: formData.get("transformation_promise") ?? "",
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
transformation_promise: parsed.data.transformation_promise || null,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/offers");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMacro(macroId: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
await db.delete(offer_macros).where(eq(offer_macros.id, macroId));
|
||||||
|
revalidatePath("/admin/offers");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Micro-offer CRUD ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const microSchema = z.object({
|
||||||
|
macro_id: z.string().min(1, "Macro offerta richiesta"),
|
||||||
|
internal_name: z.string().min(1, "Nome interno richiesto"),
|
||||||
|
public_name: z.string().min(1, "Nome pubblico richiesto"),
|
||||||
|
transformation_promise: z.string().optional(),
|
||||||
|
duration_months: z.coerce.number().int().min(1, "Durata minima 1 mese"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createMicro(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const parsed = microSchema.safeParse({
|
||||||
|
macro_id: formData.get("macro_id"),
|
||||||
|
internal_name: formData.get("internal_name"),
|
||||||
|
public_name: formData.get("public_name"),
|
||||||
|
transformation_promise: formData.get("transformation_promise") ?? "",
|
||||||
|
duration_months: formData.get("duration_months"),
|
||||||
|
});
|
||||||
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||||
|
await db.insert(offer_micros).values({
|
||||||
|
macro_id: parsed.data.macro_id,
|
||||||
|
internal_name: parsed.data.internal_name,
|
||||||
|
public_name: parsed.data.public_name,
|
||||||
|
transformation_promise: parsed.data.transformation_promise || null,
|
||||||
|
duration_months: parsed.data.duration_months,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/offers");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMicro(microId: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
// offer_micro_services will cascade; project_offers will restrict (DB constraint)
|
||||||
|
await db.delete(offer_micros).where(eq(offer_micros.id, microId));
|
||||||
|
revalidatePath("/admin/offers");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Offer service CRUD ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const offerServiceSchema = z.object({
|
||||||
|
name: z.string().min(1, "Nome richiesto"),
|
||||||
|
price: z.coerce.number().min(0, "Prezzo non può essere negativo"),
|
||||||
|
transformation_description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createOfferService(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const parsed = offerServiceSchema.safeParse({
|
||||||
|
name: formData.get("name"),
|
||||||
|
price: formData.get("price"),
|
||||||
|
transformation_description: formData.get("transformation_description") ?? "",
|
||||||
|
});
|
||||||
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||||
|
await db.insert(offer_services).values({
|
||||||
|
name: parsed.data.name,
|
||||||
|
price: parsed.data.price.toFixed(2),
|
||||||
|
transformation_description: parsed.data.transformation_description || null,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/offers");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleOfferServiceActive(serviceId: string, active: boolean) {
|
||||||
|
await requireAdmin();
|
||||||
|
await db.update(offer_services).set({ active }).where(eq(offer_services.id, serviceId));
|
||||||
|
revalidatePath("/admin/offers");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-select service assignment ─────────────────────────────────────────
|
||||||
|
|
||||||
|
// Replaces all assignments for a micro with the provided serviceIds (upsert-replace pattern)
|
||||||
|
export async function updateMicroOfferServices(microId: string, serviceIds: string[]) {
|
||||||
|
await requireAdmin();
|
||||||
|
// Delete existing assignments for this micro
|
||||||
|
await db.delete(offer_micro_services).where(eq(offer_micro_services.micro_id, microId));
|
||||||
|
// Re-insert with new set (empty array = unassign all)
|
||||||
|
if (serviceIds.length > 0) {
|
||||||
|
await db.insert(offer_micro_services).values(
|
||||||
|
serviceIds.map((serviceId) => ({ micro_id: microId, service_id: serviceId }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/offers");
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { db } from "@/db";
|
||||||
|
import {
|
||||||
|
offer_macros,
|
||||||
|
offer_micros,
|
||||||
|
offer_services,
|
||||||
|
offer_micro_services,
|
||||||
|
} from "@/db/schema";
|
||||||
|
import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema";
|
||||||
|
import { eq, asc, inArray, sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
export type MicroWithServices = OfferMicro & {
|
||||||
|
services: Array<{ id: string; name: string; price: string }>;
|
||||||
|
cumulative_price: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MacroWithMicros = OfferMacro & {
|
||||||
|
micros: MicroWithServices[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCatalogWithMicros(): Promise<MacroWithMicros[]> {
|
||||||
|
const macros = await db
|
||||||
|
.select()
|
||||||
|
.from(offer_macros)
|
||||||
|
.orderBy(asc(offer_macros.sort_order), asc(offer_macros.created_at));
|
||||||
|
|
||||||
|
if (macros.length === 0) return [];
|
||||||
|
|
||||||
|
const macroIds = macros.map((m) => m.id);
|
||||||
|
|
||||||
|
const micros = await db
|
||||||
|
.select()
|
||||||
|
.from(offer_micros)
|
||||||
|
.where(inArray(offer_micros.macro_id, macroIds))
|
||||||
|
.orderBy(asc(offer_micros.sort_order));
|
||||||
|
|
||||||
|
const microIds = micros.map((m) => m.id);
|
||||||
|
|
||||||
|
if (microIds.length === 0) {
|
||||||
|
return macros.map((m) => ({ ...m, micros: [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch junction rows + service data in one query
|
||||||
|
const assignments = await db
|
||||||
|
.select({
|
||||||
|
micro_id: offer_micro_services.micro_id,
|
||||||
|
service_id: offer_services.id,
|
||||||
|
name: offer_services.name,
|
||||||
|
price: offer_services.price,
|
||||||
|
})
|
||||||
|
.from(offer_micro_services)
|
||||||
|
.innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id))
|
||||||
|
.where(inArray(offer_micro_services.micro_id, microIds));
|
||||||
|
|
||||||
|
// Cumulative price per micro
|
||||||
|
const cumulRows = await db
|
||||||
|
.select({
|
||||||
|
micro_id: offer_micro_services.micro_id,
|
||||||
|
cumulative_price: sql<string>`coalesce(sum(${offer_services.price}::numeric), 0)`,
|
||||||
|
})
|
||||||
|
.from(offer_micro_services)
|
||||||
|
.innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id))
|
||||||
|
.where(inArray(offer_micro_services.micro_id, microIds))
|
||||||
|
.groupBy(offer_micro_services.micro_id);
|
||||||
|
|
||||||
|
const cumulMap = new Map(cumulRows.map((r) => [r.micro_id, r.cumulative_price]));
|
||||||
|
|
||||||
|
const microsWithServices: MicroWithServices[] = micros.map((micro) => ({
|
||||||
|
...micro,
|
||||||
|
services: assignments
|
||||||
|
.filter((a) => a.micro_id === micro.id)
|
||||||
|
.map((a) => ({ id: a.service_id, name: a.name, price: String(a.price) })),
|
||||||
|
cumulative_price: cumulMap.get(micro.id) ?? "0",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return macros.map((macro) => ({
|
||||||
|
...macro,
|
||||||
|
micros: microsWithServices.filter((m) => m.macro_id === macro.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllOfferServices(): Promise<OfferService[]> {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(offer_services)
|
||||||
|
.where(eq(offer_services.active, true))
|
||||||
|
.orderBy(asc(offer_services.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns assigned service IDs for a given micro-offer (used by ServiceCheckboxList)
|
||||||
|
export async function getMicroAssignedServiceIds(microId: string): Promise<string[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ service_id: offer_micro_services.service_id })
|
||||||
|
.from(offer_micro_services)
|
||||||
|
.where(eq(offer_micro_services.micro_id, microId));
|
||||||
|
return rows.map((r) => r.service_id);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user