# 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<OfferMicro & { tier_letter: 'A'|'B'|'C' }>;
  initialAssignments: Map<string, string[]>; // Map<tier_id, service_ids>
  onChange: (assignments: Map<string, string[]>) => void;
};

export function OfferMatrix({ services, tiers, initialAssignments, onChange }: OfferMatrixProps) {
  const [assignments, setAssignments] = useState<Map<string, Set<string>>>(
    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 (
    <table className="w-full border-collapse">
      <thead>
        <tr className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
          <th className="text-left px-4 py-2 font-semibold">Servizio</th>
          <th className="text-center px-4 py-2 font-semibold">Prezzo</th>
          {tiers.map(tier => (
            <th key={tier.id} className="text-center px-4 py-2 font-semibold">
              Tier {tier.tier_letter}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {services.map(service => (
          <tr key={service.id} className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9]">
            <td className="px-4 py-3 font-medium">{service.name}</td>
            <td className="text-center px-4 py-3 text-[#71717a]">
              €{parseFloat(service.unit_price).toFixed(2)}
            </td>
            {tiers.map(tier => (
              <td key={tier.id} className="text-center px-4 py-3">
                <input
                  type="checkbox"
                  checked={(assignments.get(tier.id) ?? new Set()).has(service.id)}
                  onChange={e => handleToggle(tier.id, service.id, e.target.checked)}
                  className="cursor-pointer"
                />
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}
```

**Data flow on save:**
1. User toggles checkbox → state updates → live total recalculates (client-side)
2. User clicks "Salva" → form submission with Map<tier_id, Set<service_id>>
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<OfferMicro & {
    services: Array<{ id: string; name: string; unit_price: string }>;
    cumulative_price: string;
  }>;
};

export async function getOfferDetail(macroId: string): Promise<OfferDetailWithServices | null> {
  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<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, microIds))
    .groupBy(offer_tier_services.tier_id);

  const cumulMap = new Map(cumulPrices.map(r => [r.micro_id, r.cumulative]));
  const servicesMap = new Map<string, typeof servicesForMicros>();
  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<OfferMicro & { tier_letter: 'A'|'B'|'C' }>;
  initialAssignments: Map<string, string[]>;
  onChange: (assignments: Map<string, string[]>) => void;
};

export function OfferMatrix({ services, tiers, initialAssignments, onChange }: Props) {
  const [assignments, setAssignments] = useState<Map<string, Set<string>>>(
    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 (
    <div className="overflow-x-auto">
      <table className="w-full border-collapse text-sm">
        <thead>
          <tr className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
            <th className="text-left px-4 py-2 font-semibold">Servizio</th>
            <th className="text-center px-4 py-2 font-semibold">Prezzo</th>
            {tiers.map(tier => (
              <th key={tier.id} className="text-center px-4 py-2 font-semibold">
                Tier {tier.tier_letter}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {services.map(service => (
            <tr key={service.id} className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9]">
              <td className="px-4 py-3 font-medium">{service.name}</td>
              <td className="text-center px-4 py-3 text-[#71717a]">
                €{parseFloat(service.unit_price).toFixed(2)}
              </td>
              {tiers.map(tier => (
                <td key={tier.id} className="text-center px-4 py-3">
                  <input
                    type="checkbox"
                    checked={(assignments.get(tier.id) ?? new Set()).has(service.id)}
                    onChange={e => handleToggle(tier.id, service.id, e.target.checked)}
                    className="cursor-pointer"
                  />
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>

      <div className="mt-4 flex gap-8 font-semibold">
        {totals.map(({ tier_id, total }) => (
          <div key={tier_id}>
            <span className="text-[#71717a]">Tier {tiers.find(t => t.id === tier_id)?.tier_letter}:</span>
            <span className="ml-2">€{total.toFixed(2)}</span>
          </div>
        ))}
      </div>
    </div>
  );
}
```

**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.*
