diff --git a/scripts/verify-12-03-queries.ts b/scripts/verify-12-03-queries.ts new file mode 100644 index 0000000..0548077 --- /dev/null +++ b/scripts/verify-12-03-queries.ts @@ -0,0 +1,112 @@ +// Phase 12 Plan 03 — Task 1 verification script for offer-queries.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. It typechecks against the real +// exports/types from src/lib/offer-queries.ts and documents the 5 expected +// behaviors from the plan's block, for manual run against a dev DB +// later if desired. +// +// Run (manual, dev DB only): npx tsx scripts/verify-12-03-queries.ts + +import { + getOfferEditorData, + getOfferListCards, + getOfferFieldOptions, + type OfferEditorData, + type OfferListCard, + type OfferFieldOptions, + type OfferTierData, +} from "@/lib/offer-queries"; + +// ── Test 1: getOfferListCards() ────────────────────────────────────────────── +// Input: 2 offer_macros rows (1 with is_archived=true, 1 with is_archived=false). +// Expected: array of length 2, both present, ordered by sort_order, each row +// shaped { id, internal_name, description, category, is_archived }. Archived +// rows ARE returned — filtering is client-side (UI-SPEC "Mostra offerte archiviate"). +async function test1_getOfferListCards() { + const cards: OfferListCard[] = await getOfferListCards(); + // Expected assertions (manual): + // cards.length === 2 + // cards.some(c => c.is_archived === true) + // cards.some(c => c.is_archived === false) + return cards; +} + +// ── Test 2: getOfferEditorData(macroId) — macro with 0 tiers ───────────────── +// Input: macro with category = "Signature Offer", 0 offer_micros rows, 3 +// services with category="Signature Offer" + 2 with a different category. +// Expected: { macro: {...}, tiers: [], availableServices: [...3 matching...], +// tipoTags: [], obiettivoTags: [] } +async function test2_emptyTiers(macroId: string) { + const data: OfferEditorData | null = await getOfferEditorData(macroId); + // Expected assertions (manual): + // data !== null + // data.tiers.length === 0 + // data.availableServices.length === 3 (only services.category === macro.category) + // data.tipoTags.length === 0 && data.obiettivoTags.length === 0 + return data; +} + +// ── Test 3: getOfferEditorData(macroId) — 3 tiers, tier A has 2 services ───── +// Input: macro with 3 offer_micros rows (tier_letter A/B/C) and 2 +// offer_tier_services rows assigned to tier A. +// Expected: tiers sorted A->B->C; each tier has +// { id, tier_letter, public_price, assignedServiceIds, servicesTotal }; +// tier A's servicesTotal === sum(unit_price) of its 2 assigned services; +// tiers B/C have assignedServiceIds === [] and servicesTotal === "0". +async function test3_tiersWithServices(macroId: string) { + const data: OfferEditorData | null = await getOfferEditorData(macroId); + // Expected assertions (manual): + // data.tiers.map(t => t.tier_letter) === ["A", "B", "C"] + // data.tiers[0].assignedServiceIds.length === 2 + // Number(data.tiers[0].servicesTotal) === sum of the 2 services' unit_price + // data.tiers[1].assignedServiceIds === [] && data.tiers[1].servicesTotal === "0" + // data.tiers[2].assignedServiceIds === [] && data.tiers[2].servicesTotal === "0" + const tierA: OfferTierData | undefined = data?.tiers[0]; + return { data, tierA }; +} + +// ── Test 4: getOfferEditorData(macroId) — tipoTags/obiettivoTags ───────────── +// Input: 2 "tipo" tags + 1 "obiettivo" tag exist for the macro (entity_type = +// "offer_macros.tipo" / "offer_macros.obiettivo", entity_id = macroId). +// Expected: tipoTags.length === 2, obiettivoTags.length === 1. +async function test4_tags(macroId: string) { + const data: OfferEditorData | null = await getOfferEditorData(macroId); + // Expected assertions (manual): + // data?.tipoTags.length === 2 + // data?.obiettivoTags.length === 1 + return data; +} + +// ── Test 5: getOfferFieldOptions() ─────────────────────────────────────────── +// Input: 2 macros with category "Entry Offer"/"Signature Offer", 1 "tipo" tag +// "Audit", 1 "obiettivo" tag "Lead Generation". +// Expected: categoria contains both values; tipo === ["Audit"]; +// obiettivo === ["Lead Generation"]. +async function test5_fieldOptions() { + const options: OfferFieldOptions = await getOfferFieldOptions(); + // Expected assertions (manual): + // options.categoria includes "Entry Offer" and "Signature Offer" + // options.tipo includes "Audit" + // options.obiettivo includes "Lead Generation" + return options; +} + +// Not executed automatically (production DB) — typecheck-only verification. +// To run manually against a dev DB: uncomment the call below. +// void (async () => { +// await test1_getOfferListCards(); +// await test2_emptyTiers("macro-id"); +// await test3_tiersWithServices("macro-id"); +// await test4_tags("macro-id"); +// await test5_fieldOptions(); +// })(); + +export { + test1_getOfferListCards, + test2_emptyTiers, + test3_tiersWithServices, + test4_tags, + test5_fieldOptions, +}; diff --git a/src/lib/offer-queries.ts b/src/lib/offer-queries.ts index 25e262a..65f4dfd 100644 --- a/src/lib/offer-queries.ts +++ b/src/lib/offer-queries.ts @@ -4,9 +4,12 @@ import { offer_micros, offer_services, offer_micro_services, + offer_tier_services, + services, + tags, } from "@/db/schema"; import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema"; -import { eq, asc, inArray, sql } from "drizzle-orm"; +import { eq, and, asc, inArray, sql } from "drizzle-orm"; export type MicroWithServices = OfferMicro & { services: Array<{ id: string; name: string; price: string }>; @@ -94,3 +97,187 @@ export async function getMicroAssignedServiceIds(microId: string): Promise 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 OfferListCard = Pick< + OfferMacro, + "id" | "internal_name" | "description" | "category" | "is_archived" +>; + +// 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 rows = 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)); + return rows; +} + +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; + }>; + 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", + })); + + // Category-filtered service catalog for the composition matrix (D-4 + // pre-filter, best-effort). If macro.category is unset, return all active + // services unfiltered. + const availableServicesQuery = db + .select({ + id: services.id, + name: services.name, + unit_price: services.unit_price, + category: services.category, + }) + .from(services); + + const availableServices = macro.category + ? await availableServicesQuery.where( + and(eq(services.active, true), eq(services.category, macro.category)) + ) + : await availableServicesQuery.where(eq(services.active, true)); + + // 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), mirroring getCatalogFieldOptions from admin-queries.ts. +export async function getOfferFieldOptions(): Promise { + const categoriaRows = await db.selectDistinct({ value: offer_macros.category }).from(offer_macros); + const ticketRows = await db.selectDistinct({ value: offer_macros.ticket }).from(offer_macros); + + const tagRows = await db + .selectDistinct({ name: tags.name, type: tags.entity_type }) + .from(tags) + .where(inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY])); + + const sortUnique = (arr: (string | null)[]) => + Array.from(new Set(arr.filter((v): v is string => !!v && v.trim().length > 0))).sort( + (a, b) => a.localeCompare(b, "it") + ); + + return { + categoria: sortUnique(categoriaRows.map((r) => r.value)), + ticket: sortUnique(ticketRows.map((r) => r.value)), + tipo: sortUnique(tagRows.filter((r) => r.type === OFFER_TIPO_ENTITY).map((r) => r.name)), + obiettivo: sortUnique( + tagRows.filter((r) => r.type === OFFER_OBIETTIVO_ENTITY).map((r) => r.name) + ), + }; +}