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.
40 KiB
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_macrosandoffer_microswith new columns - ✅ Create new junction table
offer_tier_services(tier →services.id, notoffer_services.id) - ✅ Extend
tagstable with polymorphic entity_type scoping - ❌ NEVER modify legacy
offer_micro_services(points to deprecatedoffer_services) - ❌ NEVER drop/rename columns on existing tables in the locked set
Primary recommendation:
- Create schema migrations by hand (drizzle-kit generate is broken; follow 0003-0007 pattern)
- Extend
offer_macros:description,is_archived,categoria_single,ticket_single,transformation_promise_json(structured fields) - Extend
offer_micros:tier_letter(A|B|C),public_price - Add new junction table
offer_tier_services:(tier_id, service_id)as PK - Tag 4 dimensions using polymorphic entity_type scoping:
"offer_macros","offer_macros.ticket","offer_macros.tipo","offer_macros.obiettivo" - Query layer: fetch offers with categories, tags, and service matrix pre-computed
- Editor UI: use Phase 11 patterns (
EditableCell,OptionMultiSelect,requireAdminserver 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-kitusato solo (eventualmente) per riordinare le righe. - D-3 (Fonte servizi): Catalogo unificato
services(Phase 11), non il legacyoffer_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_typescoping or add additivedimensioncolumn? 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_servicessuggested; planner may choose alternative. - Tier letter persistence: Store in
offer_micros.tier_letteror 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:
- List view: SSR page fetches
getAllOffers()→ query returns macros + micro counts + tag counts per offer, filtered by category/archive - Editor view: SSR page fetches
getOfferDetail(macroId)→ query returns macro + all micros with tier letters + services for matrix + tag pools - Service matrix source:
getServicesForOfferCategory(categoryId)→ filters catalog by category field (set in Phase 11) - Live total: Browser state (checkboxes) → sum prices client-side (no server call per toggle)
- 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):
-- 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:
# 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)
-- 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
dimensioncolumn (avoids 5th schema change)
Drizzle constants (in src/lib/admin-queries.ts):
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):
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:
// 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:
- User toggles checkbox → state updates → live total recalculates (client-side)
- User clicks "Salva" → form submission with Map<tier_id, Set<service_id>>
- Server action converts to array, validates via Zod, then:
- Delete all rows in
offer_tier_serviceswhere tier_id IN (this offer's tiers) - Insert new rows:
INSERT INTO offer_tier_services (tier_id, service_id) VALUES ...
- Delete all rows in
- 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:
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:
// 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_letterso it's immutable and queryable. - ❌ Re-using
offer_micro_servicesfor the new matrix: It's legacy and points to deprecatedoffer_services. Always use the newoffer_tier_servicesjunction toservices.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
// 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
// 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
-
Services all have category set? (Assumption A1)
- Recommendation: In Phase 12 plan: verify all services have category; add default ("General") for nulls if needed
-
Transformation promise: JSON or 5 columns? (OFFER-17)
- Recommendation: Use single JSONB column for type safety and future queryability
-
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
-
Row reordering via @dnd-kit? (D-2 optional mention)
- Recommendation: Not in initial scope. If required, add
@dnd-kit/sortablein Phase 12 plan
- Recommendation: Not in initial scope. If required, add
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.