chore: merge executor worktree (worktree-agent-acd37263857560e21)
This commit is contained in:
@@ -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 `<behavior>`)
|
||||||
|
- `scripts/verify-12-03-actions.ts` - Typecheck-only documentation of the 9 server-action test cases (Tests 1-9 from Task 2's `<behavior>`)
|
||||||
|
|
||||||
|
## 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)
|
||||||
@@ -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 <behavior> 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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
offer_micros,
|
offer_micros,
|
||||||
offer_services,
|
offer_services,
|
||||||
offer_micro_services,
|
offer_micro_services,
|
||||||
|
offer_tier_services,
|
||||||
|
tags,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
@@ -130,3 +132,245 @@ export async function updateMicroOfferServices(microId: string, serviceIds: stri
|
|||||||
}
|
}
|
||||||
revalidatePath("/admin/offers");
|
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<typeof saveOfferEditorSchema>;
|
||||||
|
|
||||||
|
// 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<OfferTagDimension, string> = {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|||||||
+188
-1
@@ -4,9 +4,12 @@ import {
|
|||||||
offer_micros,
|
offer_micros,
|
||||||
offer_services,
|
offer_services,
|
||||||
offer_micro_services,
|
offer_micro_services,
|
||||||
|
offer_tier_services,
|
||||||
|
services,
|
||||||
|
tags,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import type { OfferMacro, OfferMicro, OfferService } 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 & {
|
export type MicroWithServices = OfferMicro & {
|
||||||
services: Array<{ id: string; name: string; price: string }>;
|
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));
|
.where(eq(offer_micro_services.micro_id, microId));
|
||||||
return rows.map((r) => r.service_id);
|
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