diff --git a/.planning/phases/12-offer-composition-drag-drop-csv-import/12-03-SUMMARY.md b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-03-SUMMARY.md new file mode 100644 index 0000000..48caece --- /dev/null +++ b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-03-SUMMARY.md @@ -0,0 +1,129 @@ +--- +phase: 12-offer-composition-drag-drop-csv-import +plan: 03 +subsystem: api +tags: [drizzle, postgres, zod, server-actions, next.js] + +# Dependency graph +requires: + - phase: 12-offer-composition-drag-drop-csv-import (Plan 01) + provides: Additive offer_macros/offer_micros columns (category, ticket, is_archived, transformation-promise fields, tier_letter, public_price) and offer_tier_services junction table, applied to prod via Plan 02 +provides: + - getOfferEditorData(macroId) — full editor data shape (macro fields, A/B/C tiers with assignedServiceIds + computed servicesTotal, category-filtered availableServices, tipoTags/obiettivoTags) + - getOfferListCards() — list-page cards with category + archive status + - getOfferFieldOptions() — categoria/ticket/tipo/obiettivo select pools + - saveOfferEditor(macroId, payload) — single-call persistence of macro scalars, per-tier upsert + offer_tier_services replace, and Tipo/Obiettivo tag replace + - toggleOfferArchived, addOfferTag, removeOfferTag, renameOfferOption, createOfferMacro server actions +affects: [12-04-offer-list-page, 12-05-offer-editor-page] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Polymorphic tags table reused for offer_macros Tipo/Obiettivo dimensions via entity_type = 'offer_macros.tipo' | 'offer_macros.obiettivo', mirroring the Phase 11 services/leads D-06 pattern" + - "Computed servicesTotal via coalesce(sum(unit_price::numeric), 0) joined through offer_tier_services -> services, mirroring getCatalogWithMicros's cumulMap pattern" + - "Delete-then-reinsert for many-to-many replacement (offer_tier_services per tier, Tipo/Obiettivo tags per macro), mirroring updateMicroOfferServices" + - "Zod enum tier_letter validation (A/B/C) as defense-in-depth alongside the Plan 01 DB CHECK constraint" + +key-files: + created: + - scripts/verify-12-03-queries.ts + - scripts/verify-12-03-actions.ts + modified: + - src/lib/offer-queries.ts + - src/app/admin/offers/actions.ts + +key-decisions: + - "No test runner configured (no scripts.test in package.json) — wrote typecheck-only verification scripts documenting the 14 spec'd test cases (5 query + 9 action), per plan's explicit fallback; not executed against prod DB" + - "saveOfferEditor uses sequential awaits (no transaction wrapper) per macro update -> tier upserts -> tag replace, matching the existing per-statement pattern in this codebase (no interactive tx helper elsewhere)" + +patterns-established: + - "OFFER_TIPO_ENTITY/OFFER_OBIETTIVO_ENTITY constants ('offer_macros.tipo'/'offer_macros.obiettivo') as the canonical entity_type values for offer-level tag dimensions — reuse in Plans 04/05 UI" + +requirements-completed: [OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18] + +# Metrics +duration: 12min +completed: 2026-06-15 +--- + +# Phase 12 Plan 03: Offer Editor Query Layer & Server Actions Summary + +**Query layer (`getOfferEditorData`, `getOfferListCards`, `getOfferFieldOptions`) and server actions (`saveOfferEditor` + archive/tag/rename/create actions) giving Plans 04/05 a complete, type-safe, admin-only data contract for the offer editor.** + +## Performance + +- **Duration:** 12 min +- **Started:** 2026-06-15T08:15:00Z (approx) +- **Completed:** 2026-06-15T08:17:07Z +- **Tasks:** 2 completed +- **Files modified:** 4 (2 source, 2 new verification scripts) + +## Accomplishments + +- Added `getOfferListCards()` to `src/lib/offer-queries.ts`: returns `{ id, internal_name, description, category, is_archived }` per `offer_macros` row, ordered by `sort_order`, including archived offers (client-side filter per UI-SPEC) +- Added `getOfferEditorData(macroId)`: returns `{ macro, tiers, availableServices, tipoTags, obiettivoTags }` — tiers ordered A→B→C via SQL `CASE` expression, each with `assignedServiceIds` and a computed `servicesTotal` (`coalesce(sum(unit_price::numeric), 0)` joined through `offer_tier_services`), available services pre-filtered by `services.category === macro.category` (D-4, best-effort when category unset) +- Added `getOfferFieldOptions()`: `{ categoria, ticket, tipo, obiettivo }` distinct-value pools, mirroring `getCatalogFieldOptions`'s null-filter + alphabetical sort pattern +- Added `saveOfferEditor(macroId, payload)` to `src/app/admin/offers/actions.ts`: Zod-validates the full editor payload (`tier_letter` restricted to `["A","B","C"]`), updates `offer_macros` scalars, upserts each tier (update if `id` present, insert + `.returning({id})` if not), delete-then-reinserts `offer_tier_services` per tier, and delete-then-reinserts Tipo/Obiettivo `tags` +- Added `toggleOfferArchived`, `addOfferTag`/`removeOfferTag` (tipo/obiettivo dimensions), `renameOfferOption` (categoria/ticket/tipo/obiettivo), and `createOfferMacro` (Plan 04 "+ Nuova Offerta" target, defaults `public_name` to `internal_name`) +- All new actions call `requireAdmin()` first; existing exports in both files left untouched + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Query layer — getOfferEditorData, getOfferListCards, getOfferFieldOptions** - `0d742f2` (feat) +2. **Task 2: Server actions — saveOfferEditor, toggleOfferArchived, offer tag CRUD, createOfferMacro** - `a372c61` (feat) + +**Plan metadata:** (this commit, follows) + +## Files Created/Modified + +- `src/lib/offer-queries.ts` - Added `getOfferListCards`, `getOfferEditorData`, `getOfferFieldOptions` + `OfferListCard`/`OfferTierData`/`OfferEditorData`/`OfferFieldOptions` types; imports `offer_tier_services`, `services`, `tags`, `and` from drizzle-orm +- `src/app/admin/offers/actions.ts` - Added `saveOfferEditor`, `toggleOfferArchived`, `addOfferTag`, `removeOfferTag`, `renameOfferOption`, `createOfferMacro` + `SaveOfferEditorPayload`/`tierSchema`/`saveOfferEditorSchema` types; imports `offer_tier_services`, `tags`, `and`, `inArray` +- `scripts/verify-12-03-queries.ts` - Typecheck-only documentation of the 5 query-layer test cases (Tests 1-5 from Task 1's ``) +- `scripts/verify-12-03-actions.ts` - Typecheck-only documentation of the 9 server-action test cases (Tests 1-9 from Task 2's ``) + +## Decisions Made + +- No test runner configured in this repo (`package.json` has no `scripts.test`), and `.env.local`'s `DATABASE_URL` points at production per project memory — followed the plan's explicit fallback and wrote `scripts/verify-12-03-queries.ts` / `scripts/verify-12-03-actions.ts` as typecheck-only documentation of all 14 spec'd test cases, not executed against the live DB +- `saveOfferEditor` uses sequential `await`s (macro update → per-tier upsert/replace → tag replace) with no transaction wrapper, matching the existing per-statement pattern used by `updateMicroOfferServices` and the rest of this codebase + +## Deviations from Plan + +None - plan executed exactly as written. Both tasks implemented the documented function signatures, return shapes, and validation rules verbatim; existing exports in `offer-queries.ts` and `actions.ts` were not modified. + +## Issues Encountered + +None. + +## User Setup Required + +None - no external service configuration required. This plan is pure source-code (query/action functions); migration 0008 was already live on production (verified per Plan 02), and no data-mutating scripts were run. + +## Next Phase Readiness + +- Plan 04 (offer list page) can call `getOfferListCards()` and `createOfferMacro` for the "+ Nuova Offerta" button +- Plan 05 (offer editor page) can call `getOfferEditorData(macroId)` to populate the full editor (macro fields, tier matrix with live totals, Tipo/Obiettivo tags, category-filtered service catalog) and persist all of it via a single `saveOfferEditor(macroId, payload)` call; archive toggling via `toggleOfferArchived`; tag/option CRUD via `addOfferTag`/`removeOfferTag`/`renameOfferOption` +- `npx tsc --noEmit` passes with zero errors across the full project +- No legacy `offer_micro_services`/`offer_services`/existing-export code paths were touched + +--- + +*Phase: 12-offer-composition-drag-drop-csv-import* +*Completed: 2026-06-15* + +## Self-Check: PASSED + +All created/modified files verified present on disk: + +- FOUND: src/lib/offer-queries.ts +- FOUND: src/app/admin/offers/actions.ts +- FOUND: scripts/verify-12-03-queries.ts +- FOUND: scripts/verify-12-03-actions.ts +- FOUND: .planning/phases/12-offer-composition-drag-drop-csv-import/12-03-SUMMARY.md + +All task commits verified present in git log: + +- FOUND: 0d742f2 (Task 1) +- FOUND: a372c61 (Task 2) diff --git a/scripts/verify-12-03-actions.ts b/scripts/verify-12-03-actions.ts new file mode 100644 index 0000000..32f6adb --- /dev/null +++ b/scripts/verify-12-03-actions.ts @@ -0,0 +1,242 @@ +// Phase 12 Plan 03 — Task 2 verification script for actions.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 or server actions (server actions +// also require an Auth.js session, unavailable outside a request context). It +// typechecks against the real exports/types from +// src/app/admin/offers/actions.ts and documents the 9 expected behaviors from +// the plan's block, for manual run against a dev DB later if desired. +// +// Run (manual, dev DB only, inside a request context with admin session): +// npx tsx scripts/verify-12-03-actions.ts + +import type { SaveOfferEditorPayload } from "@/app/admin/offers/actions"; +import { + saveOfferEditor, + toggleOfferArchived, + addOfferTag, + removeOfferTag, + renameOfferOption, + createOfferMacro, +} from "@/app/admin/offers/actions"; + +// ── Test 1: tier_letter Zod validation ─────────────────────────────────────── +// saveOfferEditor(macroId, payload) rejects (throws) when payload.tiers contains +// a tier_letter not in ["A","B","C"] — Zod enum validation, defense-in-depth +// alongside the DB CHECK constraint from Plan 01. +async function test1_invalidTierLetter(macroId: string) { + const badPayload = { + internal_name: "Test", + public_name: "Test", + tiers: [ + { + tier_letter: "D", // invalid — not in ["A","B","C"] + internal_name: "Tier X", + public_name: "Tier X", + duration_months: 3, + assignedServiceIds: [], + }, + ], + tipoTags: [], + obiettivoTags: [], + } as unknown as SaveOfferEditorPayload; + // Expected: saveOfferEditor(macroId, badPayload) throws (Zod enum mismatch + // surfaces as parsed.error.issues[0].message before any DB write). + return () => saveOfferEditor(macroId, badPayload); +} + +// ── Test 2: macro scalar field update ──────────────────────────────────────── +// saveOfferEditor updates offer_macros scalars (internal_name, description, +// category, ticket, cliente_ideale, risultato, tempo, pain, metodo) in one +// db.update(offer_macros)...where(eq(id, macroId)). +async function test2_macroScalars(macroId: string) { + const payload: SaveOfferEditorPayload = { + internal_name: "Offerta Test", + public_name: "Offerta Pubblica", + description: "Una descrizione", + category: "Signature Offer", + ticket: "Mid Ticket", + cliente_ideale: "Coach", + risultato: "Lead qualificati", + tempo: "90 giorni", + pain: "Mancanza di processo", + metodo: "Sistema X", + tiers: [], + tipoTags: [], + obiettivoTags: [], + }; + // Expected: after call, offer_macros row for macroId has all 9 scalar + // fields set to the payload values. + return () => saveOfferEditor(macroId, payload); +} + +// ── Test 3: tier upsert (update existing id, insert when no id) ────────────── +// If tier.id is provided -> db.update(offer_micros).set({...}); if no id -> +// db.insert(offer_micros).values({..., macro_id: macroId}). +async function test3_tierUpsert(macroId: string, existingTierId: string) { + const payload: SaveOfferEditorPayload = { + internal_name: "Offerta Test", + public_name: "Offerta Pubblica", + tiers: [ + { + id: existingTierId, // -> update path + tier_letter: "A", + internal_name: "Tier A", + public_name: "Tier A Pubblico", + duration_months: 3, + public_price: 1500, + assignedServiceIds: [], + }, + { + // no id -> insert path (supports macros with < 3 tiers) + tier_letter: "B", + internal_name: "Tier B", + public_name: "Tier B Pubblico", + duration_months: 6, + public_price: 2500, + assignedServiceIds: [], + }, + ], + tipoTags: [], + obiettivoTags: [], + }; + // Expected: existingTierId row updated in place; a new offer_micros row + // created for tier B with macro_id = macroId. + return () => saveOfferEditor(macroId, payload); +} + +// ── Test 4: offer_tier_services delete-then-reinsert ───────────────────────── +// A tier with assignedServiceIds: ["svc1","svc2"] -> after call, +// offer_tier_services has exactly 2 rows for that tier_id, both svc1/svc2. +async function test4_tierServicesReplace(macroId: string, tierId: string) { + const payload: SaveOfferEditorPayload = { + internal_name: "Offerta Test", + public_name: "Offerta Pubblica", + tiers: [ + { + id: tierId, + tier_letter: "A", + internal_name: "Tier A", + public_name: "Tier A Pubblico", + duration_months: 3, + assignedServiceIds: ["svc1", "svc2"], + }, + ], + tipoTags: [], + obiettivoTags: [], + }; + // Expected: offer_tier_services has exactly 2 rows where tier_id === tierId, + // service_id IN ("svc1", "svc2"). + return () => saveOfferEditor(macroId, payload); +} + +// ── Test 5: Tipo/Obiettivo tags delete-then-reinsert ───────────────────────── +// saveOfferEditor replaces tags where entity_type IN +// ("offer_macros.tipo","offer_macros.obiettivo") and entity_id = macroId. +async function test5_tagsReplace(macroId: string) { + const payload: SaveOfferEditorPayload = { + internal_name: "Offerta Test", + public_name: "Offerta Pubblica", + tiers: [], + tipoTags: ["Audit", "Coaching"], + obiettivoTags: ["Lead Generation"], + }; + // Expected: tags table has 2 rows entity_type="offer_macros.tipo" (Audit, + // Coaching) and 1 row entity_type="offer_macros.obiettivo" (Lead + // Generation), all entity_id = macroId; any prior rows for this macroId + + // these two entity_types are removed first. + return () => saveOfferEditor(macroId, payload); +} + +// ── Test 6: toggleOfferArchived ────────────────────────────────────────────── +// toggleOfferArchived(macroId, archived: boolean) sets offer_macros.is_archived = archived. +async function test6_toggleArchived(macroId: string) { + // Expected: offer_macros.is_archived === true after toggleOfferArchived(macroId, true); + // === false after toggleOfferArchived(macroId, false). + return [ + () => toggleOfferArchived(macroId, true), + () => toggleOfferArchived(macroId, false), + ]; +} + +// ── Test 7: addOfferTag / removeOfferTag ───────────────────────────────────── +// addOfferTag(dimension, macroId, value) / removeOfferTag(dimension, macroId, +// value) work for dimension in ("tipo","obiettivo"). Reject any other +// dimension value. +async function test7_tagCrud(macroId: string) { + // Expected: addOfferTag("tipo", macroId, "Audit") inserts a tags row + // (entity_type="offer_macros.tipo", entity_id=macroId, name="Audit"); + // removeOfferTag("tipo", macroId, "Audit") deletes it. + // addOfferTag("invalid" as any, macroId, "x") throws. + return { + addTipo: () => addOfferTag("tipo", macroId, "Audit"), + removeTipo: () => removeOfferTag("tipo", macroId, "Audit"), + addObiettivo: () => addOfferTag("obiettivo", macroId, "Lead Generation"), + removeObiettivo: () => removeOfferTag("obiettivo", macroId, "Lead Generation"), + // @ts-expect-error -- intentional invalid dimension to verify the throw path + invalid: () => addOfferTag("invalid", macroId, "x"), + }; +} + +// ── Test 8: renameOfferOption ──────────────────────────────────────────────── +// renameOfferOption(field, oldValue, newValue) for field in ("categoria", +// "ticket") updates offer_macros.category/ticket for all matching rows; for +// field in ("tipo","obiettivo") updates tags.name. +async function test8_renameOption() { + // Expected: + // renameOfferOption("categoria", "Entry Offer", "Offerta Base") updates + // all offer_macros rows where category = "Entry Offer" -> "Offerta Base" + // renameOfferOption("ticket", "Low Ticket", "Ticket Basso") updates + // all offer_macros rows where ticket = "Low Ticket" -> "Ticket Basso" + // renameOfferOption("tipo", "Audit", "Diagnosi") updates all tags rows + // where entity_type="offer_macros.tipo" and name="Audit" -> "Diagnosi" + // renameOfferOption("obiettivo", "Lead Generation", "Acquisizione Lead") + // updates tags rows where entity_type="offer_macros.obiettivo" + return { + renameCategoria: () => renameOfferOption("categoria", "Entry Offer", "Offerta Base"), + renameTicket: () => renameOfferOption("ticket", "Low Ticket", "Ticket Basso"), + renameTipo: () => renameOfferOption("tipo", "Audit", "Diagnosi"), + renameObiettivo: () => renameOfferOption("obiettivo", "Lead Generation", "Acquisizione Lead"), + }; +} + +// ── Test 9: createOfferMacro ───────────────────────────────────────────────── +// createOfferMacro(formData) creates a new offer_macros row from internal_name +// (required) + optional public_name/description/category. If public_name is +// omitted, defaults to internal_name. +async function test9_createOfferMacro() { + const fd1 = new FormData(); + fd1.append("internal_name", "Nuova Offerta Interna"); + fd1.append("description", "Descrizione breve"); + fd1.append("category", "Entry Offer"); + // Expected: new offer_macros row with internal_name="Nuova Offerta Interna", + // public_name="Nuova Offerta Interna" (defaulted), description set, + // category="Entry Offer". + + const fd2 = new FormData(); + fd2.append("internal_name", "Altra Offerta"); + fd2.append("public_name", "Nome Pubblico Esplicito"); + // Expected: new offer_macros row with public_name="Nome Pubblico Esplicito" + // (not defaulted, since explicitly provided). + + return { + withDefaultPublicName: () => createOfferMacro(fd1), + withExplicitPublicName: () => createOfferMacro(fd2), + }; +} + +// Not executed automatically (production DB + no request context for +// requireAdmin) — typecheck-only verification. + +export { + test1_invalidTierLetter, + test2_macroScalars, + test3_tierUpsert, + test4_tierServicesReplace, + test5_tagsReplace, + test6_toggleArchived, + test7_tagCrud, + test8_renameOption, + test9_createOfferMacro, +}; 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/app/admin/offers/actions.ts b/src/app/admin/offers/actions.ts index de55405..d6147b8 100644 --- a/src/app/admin/offers/actions.ts +++ b/src/app/admin/offers/actions.ts @@ -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; + +// 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 = { + 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"); +} 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) + ), + }; +}