feat(12-03): add offer editor query layer
- getOfferListCards() for Plan 04 list page (category + archive status) - getOfferEditorData(macroId) returns macro fields, A/B/C tiers with assignedServiceIds + computed servicesTotal, category-filtered availableServices, and tipoTags/obiettivoTags - getOfferFieldOptions() for categoria/ticket/tipo/obiettivo select pools - scripts/verify-12-03-queries.ts documents the 5 spec'd test cases (typecheck-only, no test runner configured, prod DB not mutated)
This commit is contained in:
@@ -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 <behavior> 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,
|
||||
};
|
||||
+188
-1
@@ -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<strin
|
||||
.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 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<OfferListCard[]> {
|
||||
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<OfferEditorData | null> {
|
||||
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<string, string>();
|
||||
|
||||
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<string>`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<OfferFieldOptions> {
|
||||
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)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user