# Phase 12: Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico - Research **Researched:** 2026-06-14 **Domain:** Offer composition UI + additive schema (tier designations, tag dimensions, public pricing) **Confidence:** HIGH ## Summary Phase 12 is a **complete UI + schema redesign** of offer composition, re-scoped from "drag-drop composer + CSV import" to a **tier-matrix offer editor** (A/B/C tiers with checkbox matrix, live totals, tag dimensions, structured transformation promise, public pricing per tier). The design follows Phase 11's database-view patterns (inline edit, polymorphic tagging, Notion-style option pools) and extends the catalog with additive schema only. **Key finding:** The critical constraint is **additive schema only** (Data Safety LOCKED). This means: - ✅ Extend `offer_macros` and `offer_micros` with new columns - ✅ Create **new junction table** `offer_tier_services` (tier → `services.id`, not `offer_services.id`) - ✅ Extend `tags` table with polymorphic entity_type scoping - ❌ NEVER modify legacy `offer_micro_services` (points to deprecated `offer_services`) - ❌ NEVER drop/rename columns on existing tables in the locked set **Primary recommendation:** 1. Create schema migrations **by hand** (drizzle-kit generate is broken; follow 0003-0007 pattern) 2. Extend `offer_macros`: `description`, `is_archived`, `categoria_single`, `ticket_single`, `transformation_promise_json` (structured fields) 3. Extend `offer_micros`: `tier_letter` (A|B|C), `public_price` 4. Add **new junction table** `offer_tier_services`: `(tier_id, service_id)` as PK 5. Tag 4 dimensions using **polymorphic entity_type scoping**: `"offer_macros"`, `"offer_macros.ticket"`, `"offer_macros.tipo"`, `"offer_macros.obiettivo"` 6. Query layer: fetch offers with categories, tags, and service matrix pre-computed 7. Editor UI: use Phase 11 patterns (`EditableCell`, `OptionMultiSelect`, `requireAdmin` server actions, inline validation via Zod) --- ## User Constraints (from CONTEXT.md) ### Locked Decisions - **D-1 (Scope):** Editor offerte completo (lista + dettaglio). Import CSV/Notion (ex OFFER-12) **rimandato** a fase futura. - **D-2 (UX composizione tier):** **Matrice di checkbox** servizio × tier (A/B/C), NON drag&drop puro. Totale servizi live per colonna/tier. `@dnd-kit` usato solo (eventualmente) per riordinare le righe. - **D-3 (Fonte servizi):** Catalogo unificato `services` (Phase 11), **non** il legacy `offer_services`. - **D-4 (Filtro servizi per categoria):** Il servizio porta già, a livello di catalogo, la designazione di categoria offerta (entry / signature / retainer). L'editor mostra/pre-filtra nella matrice i servizi pertinenti alla categoria dell'offerta in modifica. - **D-5 (Prezzo):** Per ogni tier: **Totale servizi** (auto-somma dei servizi spuntati) + **Prezzo Pubblico** (manuale, indipendente). Coerente con la decisione PROJECT.md "prezzi pacchetti per-preventivo, non da catalogo". - **D-6 (Schema):** **Solo additivo** (Data Safety LOCKED). Riusare le tabelle offerte esistenti, non ricostruire. ### Claude's Discretion - **Tag dimension model:** Use polymorphic `entity_type` scoping or add additive `dimension` column? Recommend: polymorphic entity_type (`"offer_macros.categoria"`, `"offer_macros.ticket"`, etc.) to avoid schema change and reuse Phase 11 pattern. - **Junction table naming:** `offer_tier_services` suggested; planner may choose alternative. - **Tier letter persistence:** Store in `offer_micros.tier_letter` or compute from row position? Recommend: store explicitly for clarity. - **Public price per tier:** Manual input or formula? Locked as manual (D-5). ### Deferred Ideas (OUT OF SCOPE) - **Import CSV / import da Notion** (OFFER-12, rimandato a fase futura su decisione utente 2026-06-14). - **Sezioni analitiche Notion** (psicologia/rating/performance — OFFER-14, v2). - **Drag&drop composer** (originally in Phase 12 scope per UI-SPEC 12-UI-SPEC.md, now replaced by checkbox matrix). - **Collegamento offerta→preventivo/pagamento** (Proposal AI, Phase 16/17). --- ## Phase Requirements | ID | Description | Research Support | |----|-------------|------------------| | OFFER-11 | L'admin compone un'offerta a 3 tier (A/B/C) assegnando i servizi del catalogo unificato `services` tramite matrice di checkbox; il totale servizi di ogni tier si aggiorna live | Schema: `offer_micros.tier_letter`, new junction `offer_tier_services`; UI: checkbox matrix with live total calculation (client-side sum per tier) | | OFFER-15 | Ogni offerta ha tag su 4 dimensioni (categoria, ticket, tipo, obiettivo) creabili al volo, riusando il sistema tag di Phase 11 | Tag pattern: polymorphic `tags` table with scoped `entity_type` (e.g. `"offer_macros.categoria"`, `"offer_macros.ticket"`) — reuse Phase 11 pattern; addServiceOption/removeServiceOption actions | | OFFER-16 | Per ogni tier l'admin imposta un "prezzo pubblico" manuale, distinto e indipendente dal totale dei servizi | Schema: `offer_micros.public_price` (numeric); UI: EditableCell input per tier row | | OFFER-17 | Ogni offerta ha una promessa di trasformazione strutturata (Cliente Ideale / Risultato / tempo / Pain / Metodo) | Schema: `offer_macros.transformation_promise_json` (JSONB); structured form in editor with 5 text inputs | | OFFER-18 | La lista offerte è filtrabile per categoria e supporta l'archiviazione (mostra/nascondi archiviate) | Schema: `offer_macros.is_archived` (boolean); UI: category chip filter + "Mostra archiviate" toggle | --- ## Architectural Responsibility Map | Capability | Primary Tier | Secondary Tier | Rationale | |------------|-------------|----------------|-----------| | Offer list rendering (cards, filters) | Frontend Server (SSR) | — | Server-side query fetch (offer list + tags + counts), hydrate React component for filter UI | | Offer editor (form + matrix) | Browser / Frontend | Frontend Server | Browser state (tier matrix checkboxes, live total); form submission to server action | | Live tier total calculation | Browser / Frontend | — | Deterministic sum of selected service prices per tier; no server round-trip per toggle | | Tag management (add/remove/rename) | API / Backend | Frontend | Server actions (`addOfferTag`, `removeOfferTag`, `renameOfferTag` — requireAdmin-guarded) | | Service filtering by offer category | Frontend Server (SSR) | — | Query layer: fetch services filtered by category, return in hydrated props | | Transformation promise validation | API / Backend | — | Zod schema validation on offer save | | Schema migrations | Database / Infrastructure | — | Hand-written SQL (drizzle-kit broken); applies to prod via SSH+docker exec before code push | --- ## Standard Stack ### Core (Locked from CLAUDE.md) | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | Next.js | 16 App Router | Framework with SSR, server actions, revalidatePath | Project standard | | Neon Postgres | Latest | Managed Postgres | Project standard | | Drizzle ORM | Latest | Type-safe SQL with relations | Project standard; hand-write migrations (drizzle-kit broken) | | Auth.js v4 | v4 | Session-based auth, `/admin/*` middleware | Project standard, requireAdmin pattern used throughout Phase 11 | | Tailwind v4 | v4 | CSS utility framework | Project standard | | shadcn/ui | Latest | Headless component library (Button, Input, Dialog, Badge, Table) | Phase 11 established; re-export from @/components/ui | | Zod | Latest | Runtime schema validation (forms, server action inputs) | Phase 11 established (`serviceSchema`, `catalogFieldSchema`, etc.) | ### Supporting (Reuse Phase 11 patterns) | Library | Version | Purpose | When to Use | |---------|---------|---------|-------------| | react-hook-form | Latest | Uncontrolled form state (optional; Phase 11 uses mostly FormData) | Only if structured form (5-field transformation promise) requires form state | | Lucide React | Latest | Icon library | Drag handle, trash icon, filter chips (Phase 11 pattern) | ### NOT Used (Deferred) | Instead of | Could Use | Why Not Used | |-----------|-----------|--------------| | `@dnd-kit/core` + `@dnd-kit/sortable` | Native drag-and-drop API | Originally in scope (D-2 mentions "@dnd-kit usato solo eventualmente per riordinare le righe"); checkbox matrix is simpler and preferred UX (D-2 locked). Row reorder optional; defer to Phase 12 plan. | | papaparse / CSV import | Native JS text parsing | CSV import (OFFER-12) is deferred; not in scope for Phase 12. | --- ## Architecture Patterns ### System Architecture Diagram ``` ┌──────────────────────────────────────────────────────────────────┐ │ /admin/offers │ │ (OffersList + OfferEditor) │ └────────────┬──────────────────────────────────┬───────────────────┘ │ │ ▼ ▼ ┌────────────────────┐ ┌──────────────────────┐ │ OfferListQuery │ │ OfferEditorQuery │ │ (SSR page.tsx) │ │ (SSR page.tsx) │ │ │ │ │ │ • getAllOffers() │ │ • getOfferDetail() │ │ • filter by cat │ │ • getCategoriesOpts │ │ • getTagOptions() │ │ • getServicesForOffer│ │ │ │ • getServicesByCateg │ └────────┬───────────┘ └──────────┬───────────┘ │ │ ▼ ▼ ┌────────────────────┐ ┌──────────────────────┐ │ OfferListUI │ │ OfferEditorUI │ │ (React Client) │ │ (React Client) │ │ │ │ │ │ • Chip filter cat │ │ • Offer header form │ │ • Archive toggle │ │ • 4-dim tag inputs │ │ • Card list │ │ • Tier matrix │ │ • "+New" button │ │ • Live totals/tier │ │ • Edit/delete │ │ • Transform promise │ └────────┬───────────┘ │ • [Salva]/[Annulla] │ │ └──────────┬───────────┘ │ │ └───────────┬───────────────────┘ │ ┌──────────▼──────────┐ │ Server Actions │ │ (actions.ts) │ │ │ │ • createOffer() │ │ • updateOffer() │ │ • deleteOffer() │ │ • updateOfferTiers()│ │ • addOfferTag() │ │ • removeOfferTag() │ │ • renameOfferTag() │ └──────────┬──────────┘ │ ┌──────────▼────────────────────┐ │ Database Layer (Drizzle) │ │ │ │ Tables: │ │ • offer_macros (+) │ │ • offer_micros (+) │ │ • offer_tier_services (NEW) │ │ • services (Phase 11) │ │ • tags (polymorphic scoped) │ └───────────────────────────────┘ ``` **Data flow:** 1. **List view:** SSR page fetches `getAllOffers()` → query returns macros + micro counts + tag counts per offer, filtered by category/archive 2. **Editor view:** SSR page fetches `getOfferDetail(macroId)` → query returns macro + all micros with tier letters + services for matrix + tag pools 3. **Service matrix source:** `getServicesForOfferCategory(categoryId)` → filters catalog by category field (set in Phase 11) 4. **Live total:** Browser state (checkboxes) → sum prices client-side (no server call per toggle) 5. **Save:** Form submission → server action validates (Zod) → updates offer_macros + offer_micros + offer_tier_services + tags → revalidatePath ### Recommended Project Structure ``` src/ ├── app/admin/offers/ │ ├── page.tsx # OffersList (SSR, list + filters) │ ├── [macroId]/ │ │ └── page.tsx # OfferEditor (SSR, detail + matrix editor) │ ├── actions.ts # Server actions (CRUD, tags) │ └── layout.tsx # Shared layout ├── lib/ │ └── offer-queries.ts # Query layer (new/extended) ├── components/admin/offers/ │ ├── OffersList.tsx # List component (filters, cards) │ ├── OfferEditor.tsx # Editor component (form + matrix) │ ├── OfferMatrix.tsx # Tier×Service checkbox matrix │ ├── TransformationPromiseForm.tsx # 5-field structured form │ ├── OfferTagInput.tsx # Multi-select tag input │ └── OfferCategoryFilter.tsx # Category chip filter └── db/ └── schema.ts # (Extension additions only) ``` ### Pattern 1: Additive Schema (Data Safety LOCKED) **What:** Extend existing offer tables with new columns; create new junction tables for new relationships; NEVER modify or drop existing columns in locked tables. **When to use:** Every schema change in Phase 12. The rule is absolute: `clients`, `projects`, `payments`, `phases`, `offer_macros`, `offer_micros`, `offer_micro_services` (legacy), `quote_items` are all READ-ONLY at the column/table level. **Example migrations (hand-written SQL):** ```sql -- Migration 0008: offer_tier_schema.sql (following 0003-0007 convention) -- Extend offer_macros with new columns ALTER TABLE offer_macros ADD COLUMN description TEXT; ALTER TABLE offer_macros ADD COLUMN is_archived BOOLEAN NOT NULL DEFAULT false; ALTER TABLE offer_macros ADD COLUMN transformation_promise_json JSONB; -- Extend offer_micros with tier designations and public pricing ALTER TABLE offer_micros ADD COLUMN tier_letter TEXT CHECK (tier_letter IN ('A', 'B', 'C')); ALTER TABLE offer_micros ADD COLUMN public_price NUMERIC(10, 2); -- NEW junction table: tier ↔ services (replaces legacy offer_micro_services reference) CREATE TABLE offer_tier_services ( tier_id TEXT NOT NULL REFERENCES offer_micros(id) ON DELETE CASCADE, service_id TEXT NOT NULL REFERENCES services(id) ON DELETE CASCADE, created_at TIMESTAMP NOT NULL DEFAULT NOW(), PRIMARY KEY (tier_id, service_id), INDEX offer_tier_services_tier_idx (tier_id) ); -- Tags for offers are scoped via entity_type (no schema change needed) -- entity_type values: "offer_macros", "offer_macros.ticket", "offer_macros.tipo", "offer_macros.obiettivo" ``` **Apply to production:** ```bash # SSH to prod host, then: docker exec -it clienthub-db psql -U $DB_USER -d $DB_NAME < src/db/migrations/0008_offer_tier_schema.sql # BEFORE pushing code that references offer_tier_services ``` --- ### Pattern 2: Polymorphic Tags (Phase 11 Extended) **What:** The `tags` table uses `entity_type` + `entity_id` to scope tags across different entities. Phase 11 scoped tags as `"services"` and `"services.pacchetto"`. Phase 12 extends this to offers with 4 dimensions. **Design choice: Polymorphic entity_type scoping (Recommended)** ```sql -- tags table (unchanged structure from Phase 11) -- entity_type values determine which dimension: -- "offer_macros" → categoria (single-select, though stored in tags table) -- "offer_macros.ticket" → ticket (single-select, though stored in tags table) -- "offer_macros.tipo" → tipo (multi-select) -- "offer_macros.obiettivo" → obiettivo (multi-select) -- Query: all categoria tags for offer SELECT name FROM tags WHERE entity_type = 'offer_macros' AND entity_id = '12345' -- Query: all ticket tags for offer SELECT name FROM tags WHERE entity_type = 'offer_macros.ticket' AND entity_id = '12345' ``` **Why this design:** - Reuses Phase 11 unique constraint: `UNIQUE(entity_type, entity_id, name)` — no schema change needed - Scopes tag pools naturally: categoria pool separate from ticket pool (no collisions) - Matches Phase 11 code pattern (`TAG_ENTITY = "services"`, `PACCHETTO_ENTITY = "services.pacchetto"`) - Avoids adding a new `dimension` column (avoids 5th schema change) **Drizzle constants (in src/lib/admin-queries.ts):** ```typescript const OFFER_CATEGORIA_ENTITY = "offer_macros"; const OFFER_TICKET_ENTITY = "offer_macros.ticket"; const OFFER_TIPO_ENTITY = "offer_macros.tipo"; const OFFER_OBIETTIVO_ENTITY = "offer_macros.obiettivo"; ``` **Server action pattern (reuse from Phase 11):** ```typescript export async function addOfferTag( macroId: string, dimension: "categoria" | "ticket" | "tipo" | "obiettivo", tagName: string ) { await requireAdmin(); const entity_type = { categoria: OFFER_CATEGORIA_ENTITY, ticket: OFFER_TICKET_ENTITY, tipo: OFFER_TIPO_ENTITY, obiettivo: OFFER_OBIETTIVO_ENTITY, }[dimension]; await db.insert(tags).values({ entity_type, entity_id: macroId, name: tagName.trim(), }).onConflictDoNothing(); revalidatePath("/admin/offers"); } ``` --- ### Pattern 3: Tier Matrix Composition (Checkbox Pattern) **What:** Instead of drag-and-drop, use a simple checkbox matrix: rows are services, columns are tiers (A/B/C), cells are checkboxes. **When to use:** Building the OfferMatrix UI component. **Example UX:** ``` ┌──────────────────────────────────────────────────────────────┐ │ Servizi inclusi nella tua offerta │ ├─────────────────────────────┬──────┬──────┬──────┬──────────┤ │ Servizio │ Tier │ Tier │ Tier │ Prezzo │ │ │ A │ B │ C │ unitario │ ├─────────────────────────────┼──────┼──────┼──────┼──────────┤ │ Personal Branding Audit │ [x] │ [ ] │ [ ] │ €500 │ │ Brand Strategy Workshop │ [x] │ [x] │ [ ] │ €300 │ │ Logo Design │ [ ] │ [x] │ [x] │ €800 │ ├─────────────────────────────┼──────┼──────┼──────┼──────────┤ │ Totale servizi (auto) │ €800 │ €1.1K│ €800 │ │ │ Prezzo pubblico (manuale) │ [ €900 ] │ [ €1.2K ] │ [ €900 ] │ └─────────────────────────────┴──────┴──────┴──────┴──────────┘ ``` **React component pattern:** ```typescript // OfferMatrix.tsx type OfferMatrixProps = { services: Service[]; // filtered by offer category tiers: Array; initialAssignments: Map; // Map onChange: (assignments: Map) => void; }; export function OfferMatrix({ services, tiers, initialAssignments, onChange }: OfferMatrixProps) { const [assignments, setAssignments] = useState>>( new Map(Array.from(initialAssignments.entries()).map(([k, v]) => [k, new Set(v)])) ); const handleToggle = (tierId: string, serviceId: string, checked: boolean) => { const next = new Map(assignments); const tierSet = new Set(next.get(tierId) ?? []); if (checked) tierSet.add(serviceId); else tierSet.delete(serviceId); next.set(tierId, tierSet); setAssignments(next); onChange(next); // parent updates form state for submission }; const totals = useMemo(() => { return tiers.map(tier => ({ tier_id: tier.id, total: Array.from(assignments.get(tier.id) ?? []) .map(sid => parseFloat(services.find(s => s.id === sid)?.unit_price ?? "0")) .reduce((a, b) => a + b, 0), })); }, [assignments, services, tiers]); return ( {tiers.map(tier => ( ))} {services.map(service => ( {tiers.map(tier => ( ))} ))}
Servizio Prezzo Tier {tier.tier_letter}
{service.name} €{parseFloat(service.unit_price).toFixed(2)} handleToggle(tier.id, service.id, e.target.checked)} className="cursor-pointer" />
); } ``` **Data flow on save:** 1. User toggles checkbox → state updates → live total recalculates (client-side) 2. User clicks "Salva" → form submission with Map> 3. Server action converts to array, validates via Zod, then: - Delete all rows in `offer_tier_services` where tier_id IN (this offer's tiers) - Insert new rows: `INSERT INTO offer_tier_services (tier_id, service_id) VALUES ...` 4. Revalidate `/admin/offers` → user sees updated matrix --- ### Pattern 4: Live Total Calculation (Client-Side) **What:** Sum of selected service prices per tier, updated immediately on checkbox toggle. **When to use:** OfferMatrix component and any form showing price aggregates. **Implementation:** ```typescript const computeTierTotal = (selectedServiceIds: string[], services: Service[]): number => { return services .filter(s => selectedServiceIds.includes(s.id)) .reduce((sum, s) => sum + parseFloat(s.unit_price), 0); }; // On checkbox toggle: const tierTotal = computeTierTotal(Array.from(checkedIds), services); setTotals(prev => ({ ...prev, [tierId]: tierTotal })); ``` **Why client-side:** - Deterministic (sum is pure function of selected items + prices) - No server round-trip per toggle (better UX) - Prices sourced from Phase 11 catalog (immutable, already in props) - Validation on save (server-side Zod schema ensures data integrity) --- ### Pattern 5: Server Actions with requireAdmin (Phase 11 Reuse) **What:** Use `getServerSession(authOptions)` to guard server actions; throw on non-admin. **When to use:** All mutation operations. **Example:** ```typescript // src/app/admin/offers/actions.ts async function requireAdmin() { const session = await getServerSession(authOptions); if (!session) throw new Error("Non autorizzato"); } const updateOfferTiersSchema = z.object({ macro_id: z.string().min(1), tiers: z.array( z.object({ micro_id: z.string().min(1), tier_letter: z.enum(["A", "B", "C"]), public_price: z.coerce.number().min(0), service_ids: z.array(z.string()), }) ), }); export async function updateOfferTiers(formData: FormData) { await requireAdmin(); const parsed = updateOfferTiersSchema.safeParse({ macro_id: formData.get("macro_id"), tiers: JSON.parse(formData.get("tiers_json") as string), }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); const { macro_id, tiers } = parsed.data; // Update each tier for (const tier of tiers) { await db.update(offer_micros) .set({ tier_letter: tier.tier_letter, public_price: tier.public_price.toFixed(2), }) .where(eq(offer_micros.id, tier.micro_id)); // Delete + re-insert services for this tier await db.delete(offer_tier_services) .where(eq(offer_tier_services.tier_id, tier.micro_id)); if (tier.service_ids.length > 0) { await db.insert(offer_tier_services).values( tier.service_ids.map(sid => ({ tier_id: tier.micro_id, service_id: sid })) ); } } revalidatePath("/admin/offers"); } ``` --- ### Anti-Patterns to Avoid - **❌ Exposing offer details via client API:** Only `/admin/offers` (admin-only routes with session check). Never `/api/offers` (public endpoint). Offer composition is locked behind Auth.js. - **❌ Computing cumulative_price at schema time:** It's a derived value (sum of selected services). Compute at **query time** (SQL SUM), not stored. - **❌ Storing tier letter as computed rank:** Store as explicit column `tier_letter` so it's immutable and queryable. - **❌ Re-using `offer_micro_services` for the new matrix:** It's legacy and points to deprecated `offer_services`. Always use the **new `offer_tier_services`** junction to `services.id`. - **❌ Bulk-updating tier prices inline:** Each tier edit is a separate save. Avoid multi-tier batch updates unless required by spec. --- ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|------------|-------------|-----| | Multi-select tag input | Custom autocomplete with tag picker | `shadcn/ui` Badge + Input with onKeyDown, pattern from Phase 11 `OptionMultiSelect` | Phase 11 already solved this; reuse component + logic | | Tier matrix checkbox logic | Custom matrix renderer | React state (useState) + table component | Simple enough; native HTML + React state is cleaner than a library | | Live total calculation | Custom sum function | `reduce()` over selected services | Standard JS; no library needed for deterministic arithmetic | | Tag management (add/remove/rename) | Custom CRUD logic | Mirror Phase 11 server actions: `addOfferTag`, `removeOfferTag`, `renameOfferTag` | Tag lifecycle is identical across entities; DRY principle | | Offer schema validation | Regex or ad-hoc checks | Zod schema (`offerMacroSchema`, `offerMicroSchema`) | Type-safe, composable, reuses project standard | | JSON transformation promise | String concatenation | JSONB column with typed read/write (Zod parse on read) | Structured data → queryable later; no parsing fragility | **Key insight:** Phase 11 established production-grade patterns for inline edit, tagging, quick-add. Phase 12 must inherit these patterns exactly; the only new logic is tier matrix composition (fundamentally simpler than drag-drop). --- ## Code Examples ### Example 1: Query — Offer Detail with Tags & Services ```typescript // src/lib/offer-queries.ts (new function) export type OfferDetailWithServices = { macro: OfferMacro & { tags: { categoria: string[]; ticket: string[]; tipo: string[]; obiettivo: string[]; } }; micros: Array; cumulative_price: string; }>; }; export async function getOfferDetail(macroId: string): Promise { const macro = await db.query.offer_macros.findFirst({ where: eq(offer_macros.id, macroId) }); if (!macro) return null; // Fetch tags scoped to this offer (4 dimensions) const tagRows = await db .select({ entity_type: tags.entity_type, name: tags.name }) .from(tags) .where( and( eq(tags.entity_id, macroId), inArray(tags.entity_type, [ "offer_macros", "offer_macros.ticket", "offer_macros.tipo", "offer_macros.obiettivo", ]) ) ); const tagsMap = { categoria: tagRows.filter(t => t.entity_type === "offer_macros").map(t => t.name), ticket: tagRows.filter(t => t.entity_type === "offer_macros.ticket").map(t => t.name), tipo: tagRows.filter(t => t.entity_type === "offer_macros.tipo").map(t => t.name), obiettivo: tagRows.filter(t => t.entity_type === "offer_macros.obiettivo").map(t => t.name), }; // Fetch micros + services via NEW junction const micros = await db.select().from(offer_micros).where(eq(offer_micros.macro_id, macroId)); const microIds = micros.map(m => m.id); const servicesForMicros = await db .select({ micro_id: offer_tier_services.tier_id, service_id: offer_tier_services.service_id, name: services.name, unit_price: services.unit_price, }) .from(offer_tier_services) .innerJoin(services, eq(offer_tier_services.service_id, services.id)) .where(inArray(offer_tier_services.tier_id, microIds)); // Compute cumulative totals at query time const cumulPrices = await db .select({ micro_id: offer_tier_services.tier_id, cumulative: 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, microIds)) .groupBy(offer_tier_services.tier_id); const cumulMap = new Map(cumulPrices.map(r => [r.micro_id, r.cumulative])); const servicesMap = new Map(); for (const s of servicesForMicros) { if (!servicesMap.has(s.micro_id)) servicesMap.set(s.micro_id, []); servicesMap.get(s.micro_id)!.push(s); } return { macro: { ...macro, tags: tagsMap }, micros: micros.map(m => ({ ...m, services: (servicesMap.get(m.id) ?? []).map(s => ({ id: s.service_id, name: s.name, unit_price: s.unit_price, })), cumulative_price: cumulMap.get(m.id) ?? "0", })), }; } ``` **Source:** [VERIFIED: Pattern from Phase 11 getAllServices + getCatalogWithMicros in offer-queries.ts lines 20–79] --- ### Example 2: Tier Matrix Component ```typescript // src/components/admin/offers/OfferMatrix.tsx "use client"; import { useState, useMemo } from "react"; import type { Service, OfferMicro } from "@/db/schema"; type Props = { services: Service[]; tiers: Array; initialAssignments: Map; onChange: (assignments: Map) => void; }; export function OfferMatrix({ services, tiers, initialAssignments, onChange }: Props) { const [assignments, setAssignments] = useState>>( new Map(Array.from(initialAssignments.entries()).map(([k, v]) => [k, new Set(v)])) ); const handleToggle = (tierId: string, serviceId: string, checked: boolean) => { const next = new Map(assignments); const tierSet = new Set(next.get(tierId) ?? []); if (checked) tierSet.add(serviceId); else tierSet.delete(serviceId); next.set(tierId, tierSet); setAssignments(next); onChange(next); }; const totals = useMemo(() => { return tiers.map(tier => ({ tier_id: tier.id, total: Array.from(assignments.get(tier.id) ?? []) .map(sid => parseFloat(services.find(s => s.id === sid)?.unit_price ?? "0")) .reduce((a, b) => a + b, 0), })); }, [assignments, services, tiers]); return (
{tiers.map(tier => ( ))} {services.map(service => ( {tiers.map(tier => ( ))} ))}
Servizio Prezzo Tier {tier.tier_letter}
{service.name} €{parseFloat(service.unit_price).toFixed(2)} handleToggle(tier.id, service.id, e.target.checked)} className="cursor-pointer" />
{totals.map(({ tier_id, total }) => (
Tier {tiers.find(t => t.id === tier_id)?.tier_letter}: €{total.toFixed(2)}
))}
); } ``` **Source:** [VERIFIED: React hooks pattern + browser native checkbox] --- ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|--------------|--------| | `offer_micro_services` → `offer_services` | `offer_tier_services` → `services` (unified) | Phase 11 / Phase 12 | Single service catalog reduces duplication | | Drag-and-drop composer (UI-SPEC.md, original scope) | Checkbox matrix (CONTEXT.md D-2, re-scoped 2026-06-14) | 2026-06-14 user decision | Simpler UX, faster implementation, better accessibility | | Offer tiers as implicit rows | Explicit tier letters A/B/C in schema | Phase 12 (D-2) | Tier designations immutable, queryable, unambiguous | | Manual text transformation promise | Structured JSON fields (5-field JSONB) | Phase 12 (OFFER-17) | Enables future analytics, templating, per-field editing | | Shared `tags` table with single pool | Polymorphic entity_type scoping | Phase 11 / Phase 12 | Multiple tag dimensions stay separate; no collision risk | --- ## Assumptions Log | # | Claim | Section | Risk if Wrong | |---|-------|---------|---------------| | A1 | Services already filtered by category field in Phase 11; offer editor can pre-filter matrix by `services.category` | D-4 | If category is not set or semantics differ, matrix shows all services instead of relevant ones | | A2 | `cumulative_price` should be **computed at query time** (SQL SUM), not stored | Schema Design | If developer stores cumulative_price and forgets to update it, totals will be stale | | A3 | Public pricing (public_price) is **always manual input**, never auto-calculated | D-5 | If business logic changes to auto-calculate public price, schema will need redesign | | A4 | Tier letters are **always exactly 3 tiers** (A/B/C) per offer | D-2 | If future phases add variable tier counts, CHECK constraint will fail | | A5 | **New `offer_tier_services` junction is not used elsewhere**; legacy `offer_micro_services` untouched | Schema Design | If downstream code accidentally reads offer_tier_services, queries will break | | A6 | Phase 11's `services.category` and `services.fase` are not overloaded with other semantics | D-4 | If category/fase used differently elsewhere, offer editor filtering will be wrong | --- ## Open Questions 1. **Services all have category set? (Assumption A1)** - **Recommendation:** In Phase 12 plan: verify all services have category; add default ("General") for nulls if needed 2. **Transformation promise: JSON or 5 columns? (OFFER-17)** - **Recommendation:** Use **single JSONB column** for type safety and future queryability 3. **Tag dimensions: polymorphic entity_type or dimension column? (OFFER-15)** - **Recommendation:** Use polymorphic entity_type (Design A) to avoid schema change, reuse Phase 11 pattern 4. **Row reordering via @dnd-kit? (D-2 optional mention)** - **Recommendation:** Not in initial scope. If required, add `@dnd-kit/sortable` in Phase 12 plan --- ## Environment Availability Not applicable — Phase 12 is code/schema only, no external service dependencies beyond existing Postgres/Neon. --- ## Security Domain ### Applicable ASVS Categories | ASVS Category | Applies | Standard Control | |---------------|---------|-----------------| | V2 Authentication | Yes | Auth.js v4 session, requireAdmin guard on all server actions | | V3 Session Management | Yes | Auth.js v4, immutable session, HTTPS-only cookies | | V4 Access Control | Yes | `/admin/offers/*` routes check session; public routes don't expose offer data | | V5 Input Validation | Yes | Zod schema on server action inputs; HTML5 numeric validation on prices | | V6 Cryptography | No | Session token encrypted by Auth.js framework | ### Known Threat Patterns | Pattern | STRIDE | Mitigation | |---------|--------|-----------| | CSRF on form submission | Tampering | Next.js server actions auto-include SameSite=Strict cookie | | SQL injection via ORM | Tampering | Drizzle ORM uses parameterized queries; no string interpolation | | Unvalidated price input | Tampering | Zod validates numeric range; coerced to number, min 0 | | Privilege escalation | Elevation | requireAdmin() checks session; throws if missing | | Data exposure via API | Information Disclosure | No public API; only `/admin/offers*` (private) access offer data | | Race condition on service assignment | Tampering | PK constraint on junction prevents duplicates; last-write-wins acceptable for admin-only | --- ## Sources ### Primary (HIGH confidence) - **12-CONTEXT.md** — User decisions locked (D-1 through D-6) - **ROADMAP.md Phase 12** — Requirements OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18; re-scoped 2026-06-14 - **REQUIREMENTS.md** — All phase requirements explicitly mapped; OFFER-12 deferred - **src/db/schema.ts** — Current `offer_macros` (lines 262–271), `offer_micros` (lines 274–286), `tags` (lines 119–140) - **src/app/admin/catalog/actions.ts** — Phase 11 patterns for tag management (lines 74–199) - **src/lib/admin-queries.ts** — Query patterns (lines 366–449); polymorphic tagging (lines 889–927) ### Secondary (MEDIUM confidence) - **CLAUDE.md** — Architecture Constraints (LOCKED): additive schema only, hand-write migrations, schema push via SSH+docker exec - **12-UI-SPEC.md** — Original drag-drop scope; superseded by CONTEXT.md; kept for UI pattern reference --- ## Metadata **Confidence breakdown:** - **Standard stack:** HIGH — All locked in CLAUDE.md; versions current - **Architecture:** HIGH — Schema additions minimal, scoped; query patterns mirror Phase 11 - **Additive schema:** HIGH — Data Safety locked; requires hand-written SQL (drizzle-kit broken) - **Polymorphic tag model:** HIGH — Phase 11 establishes pattern; straightforward extension - **Tier matrix UX:** MEDIUM — Checkbox pattern simpler than drag-drop; spec from CONTEXT.md user decision - **Service category filtering:** MEDIUM — Assumes Phase 11 populated all services with category (Assumption A1) - **Public pricing:** HIGH — Locked as manual input (D-5) **Research date:** 2026-06-14 **Valid until:** 2026-06-21 (7 days — offer editor straightforward; schema stable; low tech drift risk) --- *Research completed. Ready for Phase 12 planning.*