import { db } from "@/db"; import { offer_macros, offer_micros, offer_services, offer_micro_services, offer_tier_services, services, tags, } from "@/db/schema"; import { getPool } from "@/lib/taxonomy"; import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema"; import { eq, and, 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 { 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`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 { 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 { 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); } // ── Phase 12: Offer Editor query layer ────────────────────────────────────── // 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"; export type OfferListTier = { tier_letter: string | null; public_name: string; public_price: string | null; servicesTotal: string; }; export type OfferListCard = Pick< OfferMacro, "id" | "internal_name" | "description" | "category" | "is_archived" > & { tiers: OfferListTier[] }; // List-page cards (Plan 04): one row per offer_macros, with category + archive // status for client-side filtering. Archived offers ARE included — the // "Mostra offerte archiviate" toggle filters client-side per UI-SPEC. export async function getOfferListCards(): Promise { const macros = await db .select({ id: offer_macros.id, internal_name: offer_macros.internal_name, description: offer_macros.description, category: offer_macros.category, is_archived: offer_macros.is_archived, }) .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); // Fetch all tiers for these macros, ordered A -> B -> C const tierOrder = sql`CASE ${offer_micros.tier_letter} WHEN 'A' THEN 1 WHEN 'B' THEN 2 WHEN 'C' THEN 3 ELSE 4 END`; const tierRows = await db .select({ id: offer_micros.id, macro_id: offer_micros.macro_id, tier_letter: offer_micros.tier_letter, public_name: offer_micros.public_name, public_price: offer_micros.public_price, }) .from(offer_micros) .where(inArray(offer_micros.macro_id, macroIds)) .orderBy(asc(tierOrder)); const allTierIds = tierRows.map((t) => t.id); // Compute servicesTotal per tier in batch let totalsByTierId = new Map(); if (allTierIds.length > 0) { const totalRows = await db .select({ tier_id: offer_tier_services.tier_id, servicesTotal: sql`coalesce(sum(${services.unit_price}::numeric), 0)`, }) .from(offer_tier_services) .innerJoin(services, eq(offer_tier_services.service_id, services.id)) .where(inArray(offer_tier_services.tier_id, allTierIds)) .groupBy(offer_tier_services.tier_id); totalsByTierId = new Map(totalRows.map((r) => [r.tier_id, r.servicesTotal])); } // Group tiers under each macro const tiersByMacroId = new Map(); for (const t of tierRows) { const list = tiersByMacroId.get(t.macro_id) ?? []; list.push({ tier_letter: t.tier_letter, public_name: t.public_name, public_price: t.public_price, servicesTotal: totalsByTierId.get(t.id) ?? "0", }); tiersByMacroId.set(t.macro_id, list); } return macros.map((m) => ({ ...m, tiers: tiersByMacroId.get(m.id) ?? [] })); } export type OfferTierData = { id: string; tier_letter: string | null; internal_name: string; public_name: string; duration_months: number; public_price: string | null; assignedServiceIds: string[]; servicesTotal: string; }; export type OfferEditorData = { macro: OfferMacro; tiers: OfferTierData[]; availableServices: Array<{ id: string; name: string; unit_price: string; category: string | null; offerTags: string[]; }>; tipoTags: string[]; obiettivoTags: string[]; }; // Full editor data for a single offer (Plan 05): macro fields (incl. // category/ticket/is_archived/transformation-promise), its A/B/C tiers with // assigned service IDs + computed services total, the category-filtered // service catalog for the composition matrix, and Tipo/Obiettivo tags. export async function getOfferEditorData(macroId: string): Promise { const [macro] = await db.select().from(offer_macros).where(eq(offer_macros.id, macroId)); if (!macro) return null; // Tiers ordered A -> B -> C, with null/unrecognized tier_letter sorted last. const tierOrder = sql`CASE ${offer_micros.tier_letter} WHEN 'A' THEN 1 WHEN 'B' THEN 2 WHEN 'C' THEN 3 ELSE 4 END`; const tiers = await db .select() .from(offer_micros) .where(eq(offer_micros.macro_id, macroId)) .orderBy(asc(tierOrder)); const tierIds = tiers.map((t) => t.id); let assignedRows: Array<{ tier_id: string; service_id: string }> = []; let totalsMap = new Map(); if (tierIds.length > 0) { assignedRows = await db .select({ tier_id: offer_tier_services.tier_id, service_id: offer_tier_services.service_id, }) .from(offer_tier_services) .where(inArray(offer_tier_services.tier_id, tierIds)); const totalRows = await db .select({ tier_id: offer_tier_services.tier_id, servicesTotal: sql`coalesce(sum(${services.unit_price}::numeric), 0)`, }) .from(offer_tier_services) .innerJoin(services, eq(offer_tier_services.service_id, services.id)) .where(inArray(offer_tier_services.tier_id, tierIds)) .groupBy(offer_tier_services.tier_id); totalsMap = new Map(totalRows.map((r) => [r.tier_id, r.servicesTotal])); } const tiersData: OfferTierData[] = tiers.map((tier) => ({ id: tier.id, tier_letter: tier.tier_letter, internal_name: tier.internal_name, public_name: tier.public_name, duration_months: tier.duration_months, public_price: tier.public_price, assignedServiceIds: assignedRows .filter((a) => a.tier_id === tier.id) .map((a) => a.service_id), servicesTotal: totalsMap.get(tier.id) ?? "0", })); // Full service catalog with offer membership tags (entity_type = "services"). const allServices = await db .select({ id: services.id, name: services.name, unit_price: services.unit_price, category: services.category, }) .from(services) .where(eq(services.active, true)); const serviceIds = allServices.map((s) => s.id); const offerTagRows = serviceIds.length > 0 ? await db .select({ entity_id: tags.entity_id, name: tags.name }) .from(tags) .where(and(eq(tags.entity_type, "services"), inArray(tags.entity_id, serviceIds))) : []; const tagsByServiceId = new Map(); for (const row of offerTagRows) { const list = tagsByServiceId.get(row.entity_id) ?? []; list.push(row.name); tagsByServiceId.set(row.entity_id, list); } const availableServices = allServices.map((s) => ({ ...s, offerTags: tagsByServiceId.get(s.id) ?? [], })); // Tipo/Obiettivo tags for this macro. const tagRows = await db .select({ name: tags.name, type: tags.entity_type }) .from(tags) .where( and( eq(tags.entity_id, macroId), inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY]) ) ); const tipoTags = tagRows.filter((r) => r.type === OFFER_TIPO_ENTITY).map((r) => r.name); const obiettivoTags = tagRows .filter((r) => r.type === OFFER_OBIETTIVO_ENTITY) .map((r) => r.name); return { macro, tiers: tiersData, availableServices, tipoTags, obiettivoTags, }; } export type OfferFieldOptions = { categoria: string[]; ticket: string[]; tipo: string[]; obiettivo: string[]; }; // Shared option pools for the offer editor's select fields (Notion-style // dropdowns). All four read from the centralized, persistent taxonomy pools // (src/lib/taxonomy.ts), seeded from existing values on first access. export async function getOfferFieldOptions(): Promise { const [categoria, ticket, tipo, obiettivo] = await Promise.all([ getPool("offer_categoria"), getPool("offer_ticket"), getPool("offer_tipo"), getPool("offer_obiettivo"), ]); return { categoria, ticket, tipo, obiettivo }; }