Files
simone 640f986967 docs(12): create phase plan
Re-scoped Offer Editor (Tier A/B/C, Tag & Prezzo Pubblico) per 2026-06-14
user decision: 5-plan wave structure (schema migration -> prod apply
checkpoint -> query/action layer -> list + detail editor UI), OFFER-12
CSV/Notion import deferred.
2026-06-14 21:00:32 +02:00

820 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 2079]
---
### 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 262271), `offer_micros` (lines 274286), `tags` (lines 119140)
- **src/app/admin/catalog/actions.ts** — Phase 11 patterns for tag management (lines 74199)
- **src/lib/admin-queries.ts** — Query patterns (lines 366449); polymorphic tagging (lines 889927)
### 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.*