Files
clienthub/.planning/phases/12-offer-composition-drag-drop-csv-import/12-01-PLAN.md
T
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

24 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
12 01 execute 1
src/db/schema.ts
src/db/migrations/0008_offer_tier_schema.sql
src/db/migrations/meta/_journal.json
scripts/push-12-offer-tier-schema.ts
true
OFFER-11
OFFER-15
OFFER-16
OFFER-17
OFFER-18
truths artifacts key_links
The Drizzle schema exposes tier_letter and public_price on offer_micros, and category, ticket, is_archived + 5 transformation-promise fields on offer_macros
A new offer_tier_services junction table exists in the schema, referencing offer_micros and services (NOT the legacy offer_services)
A hand-written, idempotent, additive-only SQL migration file exists that can be applied to prod via the existing push-script convention
path provides contains
src/db/schema.ts offer_macros additive columns (description, category, ticket, is_archived, cliente_ideale, risultato, tempo, pain, metodo), offer_micros additive columns (tier_letter, public_price), new offer_tier_services table + relations + types offer_tier_services
path provides contains
src/db/migrations/0008_offer_tier_schema.sql Additive ALTER TABLE + CREATE TABLE statements, IF NOT EXISTS / guarded DO block, no DROP/TRUNCATE CREATE TABLE IF NOT EXISTS offer_tier_services
path provides exports
scripts/push-12-offer-tier-schema.ts Idempotent script to apply migration 0008 to prod via DATABASE_URL (SSH+docker exec target)
push
from to via pattern
src/db/schema.ts (offer_tier_services) services.id / offer_micros.id foreign key references with onDelete cascade references(() => (services|offer_micros).id
Extend the Drizzle schema additively for the Phase 12 Offer Editor: add tier designation (`tier_letter`) and manual public pricing (`public_price`) to `offer_micros`; add archive flag, single-select category/ticket dimensions, and structured transformation-promise fields to `offer_macros`; create a new junction table `offer_tier_services` (tier ↔ unified `services` catalog) WITHOUT touching the legacy `offer_micro_services`/`offer_services` tables. Hand-write the corresponding SQL migration (0008) following the 0003-0007 convention (drizzle-kit generate is broken in this repo), and create the idempotent push script that Plan 02 will run against production via SSH+docker exec.

Purpose: Lay the additive data-model foundation (D-6, OFFER-11/15/16/17/18) so the Wave 2/3 query layer, server actions, and editor UI have real columns/tables to read and write — including the category/ticket single-select dimensions needed for list filtering (OFFER-18) and matrix pre-filtering by offer category (D-4). Output: Updated src/db/schema.ts (types + relations), src/db/migrations/0008_offer_tier_schema.sql, updated _journal.json, and scripts/push-12-offer-tier-schema.ts.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/12-offer-composition-drag-drop-csv-import/12-CONTEXT.md @.planning/phases/12-offer-composition-drag-drop-csv-import/12-RESEARCH.md @src/db/schema.ts @src/db/migrations/0006_add_tags_table.sql @src/db/migrations/0007_add_services_fase.sql @src/db/migrations/meta/_journal.json @scripts/push-11b-fase-column.ts

From src/db/schema.ts (offer_macros, current — lines 262-271):

export const offer_macros = pgTable("offer_macros", {
  id: text("id").primaryKey().$defaultFn(() => nanoid()),
  internal_name: text("internal_name").notNull(),
  public_name: text("public_name").notNull(),
  transformation_promise: text("transformation_promise"),
  sort_order: integer("sort_order").notNull().default(0),
  created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

From src/db/schema.ts (offer_micros, current — lines 274-286):

export const offer_micros = pgTable("offer_micros", {
  id: text("id").primaryKey().$defaultFn(() => nanoid()),
  macro_id: text("macro_id").notNull().references(() => offer_macros.id, { onDelete: "cascade" }),
  internal_name: text("internal_name").notNull(),
  public_name: text("public_name").notNull(),
  transformation_promise: text("transformation_promise"),
  duration_months: integer("duration_months").notNull().default(1),
  sort_order: integer("sort_order").notNull().default(0),
});

From src/db/schema.ts (services, Phase 11 unified catalog — lines 212-225):

export const services = pgTable("services", {
  id: text("id").primaryKey().$defaultFn(() => nanoid()),
  name: text("name").notNull(),
  description: text("description"),
  unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
  category: text("category"),
  fase: text("fase"),
  active: boolean("active").notNull().default(true),
  migrated_from: text("migrated_from"),
  migrated_id: text("migrated_id"),
  created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

From src/db/schema.ts (tags, polymorphic — lines 119-140, NO schema change needed for tag dimensions — Phase 12 reuses this table with new entity_type string values for the two MULTI-select dimensions only: "offer_macros.tipo" | "offer_macros.obiettivo". The two SINGLE-select dimensions, Categoria and Ticket, become plain columns on offer_macros in this plan — see Task 1 step 1):

export const tags = pgTable("tags", {
  id: text("id").primaryKey().$defaultFn(() => nanoid()),
  entity_type: text("entity_type").notNull(),
  entity_id: text("entity_id").notNull(),
  name: text("name").notNull(),
  created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
  entityTagUnique: uniqueIndex("tags_entity_name_unique").on(t.entity_type, t.entity_id, t.name),
  entityIdx: index("tags_entity_idx").on(t.entity_type, t.entity_id),
}));

Existing offer_micro_services junction (legacy — DO NOT MODIFY, lines 300-313):

export const offer_micro_services = pgTable("offer_micro_services", {
  micro_id: text("micro_id").notNull().references(() => offer_micros.id, { onDelete: "cascade" }),
  service_id: text("service_id").notNull().references(() => offer_services.id, { onDelete: "cascade" }),
}, (t) => ({ pk: primaryKey({ columns: [t.micro_id, t.service_id] }) }));
Task 1: Extend offer_macros and offer_micros, add offer_tier_services junction in schema.ts src/db/schema.ts - src/db/schema.ts (full file — read once; note existing imports, offer_macros lines 262-271, offer_micros lines 274-286, offer_micro_services lines 300-313 for the "do not touch" pattern, services lines 212-225, relations section ~545-578, type exports section ~633-646) - .planning/phases/12-offer-composition-drag-drop-csv-import/12-CONTEXT.md (D-1..D-6 — locked decisions) - .planning/phases/12-offer-composition-drag-drop-csv-import/12-RESEARCH.md (Pattern 1: Additive Schema, section "Example migrations") Make the following ADDITIVE-ONLY changes to `src/db/schema.ts`. Do not remove, rename, or alter any existing column on `offer_macros`, `offer_micros`, `offer_micro_services`, `offer_services`, `clients`, `projects`, `payments`, or `phases` (Data Safety LOCKED).
1. **Extend `offer_macros`** (after `transformation_promise`, before `sort_order` —
   order within the object is cosmetic but keep additive fields grouped together for
   readability) with:
   - `description: text("description")` — short description shown on offer cards (D-1 UI)
   - `category: text("category")` — single-select offer category (Entry Offer / Signature
     Offer / Retainer Offer), OFFER-15 "Categoria" dimension + OFFER-18 list filter. Same
     Notion-style single-select pool pattern as `services.category`/`services.fase`
     (editable via `OptionSelect` + `renameServiceOption`-equivalent in Plan 03, scoped by
     a distinct `entity_type` in the `tags` pool table — e.g. `"offer_macros.categoria"` —
     so the option pool is independent of the catalog's `services.category` pool, even
     though admins are expected to use matching label text per D-4).
   - `ticket: text("ticket")` — single-select "Ticket" dimension (Low/Mid/High Ticket),
     OFFER-15. Same pattern as `category` above, pool entity_type `"offer_macros.ticket"`.
   - `is_archived: boolean("is_archived").notNull().default(false)` — OFFER-18 archive flag
   - Five structured transformation-promise fields (OFFER-17), each nullable text,
     additive alongside the existing legacy `transformation_promise` column (leave
     `transformation_promise` untouched — it remains on `offer_micros` only as legacy):
     - `cliente_ideale: text("cliente_ideale")` — "Aiuto: [Cliente Ideale]"
     - `risultato: text("risultato")` — "A ottenere: [Risultato]"
     - `tempo: text("tempo")` — "In: [tempo]"
     - `pain: text("pain")` — "Senza: [Pain]"
     - `metodo: text("metodo")` — "Grazie a: [Metodo]"

   Add a comment block directly above `offer_macros` documenting that these are Phase 12
   additive columns (mirror the existing comment style used for `services.migrated_from`).

2. **Extend `offer_micros`** with:
   - `tier_letter: text("tier_letter")` — nullable text, values constrained to 'A'|'B'|'C'
     via a CHECK constraint at the SQL level (Task 2 migration), NOT enforced in Drizzle
     (Drizzle pg-core has no native CHECK helper in this project's version — validate in
     Zod at the server-action layer in Plan 03). Add a TS comment noting the CHECK
     constraint lives in the migration.
   - `public_price: numeric("public_price", { precision: 10, scale: 2 })` — nullable,
     manual public price per tier (D-5/OFFER-16); independent of the computed services
     total (computed at query time in Plan 03, never stored).

3. **Add new table `offer_tier_services`** (place it directly after
   `offer_micro_services` for proximity, with a comment block explaining it is the
   Phase 12 additive replacement junction — NOT a modification of
   `offer_micro_services`, which stays untouched and points to the legacy
   `offer_services`):
   ```typescript
   // ============ OFFER TIER SERVICES (Phase 12 — junction: offer_micros <-> services) ============
   // Additive replacement for the legacy offer_micro_services (which points to the
   // deprecated offer_services table). This junction connects a tier (offer_micros row
   // with tier_letter set) to the unified services catalog (Phase 11). Do NOT modify
   // offer_micro_services — it remains untouched for legacy data.
   export const offer_tier_services = pgTable(
     "offer_tier_services",
     {
       tier_id: text("tier_id")
         .notNull()
         .references(() => offer_micros.id, { onDelete: "cascade" }),
       service_id: text("service_id")
         .notNull()
         .references(() => services.id, { onDelete: "cascade" }),
       created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
     },
     (t) => ({
       pk: primaryKey({ columns: [t.tier_id, t.service_id] }),
       tierIdx: index("offer_tier_services_tier_idx").on(t.tier_id),
     })
   );
   ```

4. **Add relations** for `offer_tier_services` (near `offerMicroServicesRelations`,
   ~line 559-562):
   ```typescript
   export const offerTierServicesRelations = relations(offer_tier_services, ({ one }) => ({
     tier: one(offer_micros, { fields: [offer_tier_services.tier_id], references: [offer_micros.id] }),
     service: one(services, { fields: [offer_tier_services.service_id], references: [services.id] }),
   }));
   ```
   Also extend `offerMicrosRelations` (~line 549-553) to add a `tierServices: many(offer_tier_services)`
   entry alongside the existing `services: many(offer_micro_services)` (keep both — do not
   remove the legacy relation).

5. **Add TypeScript types** in the type-exports section (~line 633-646), following the
   exact `$inferSelect`/`$inferInsert` pattern used for every other table:
   ```typescript
   export type OfferTierService = typeof offer_tier_services.$inferSelect;
   export type NewOfferTierService = typeof offer_tier_services.$inferInsert;
   ```
   `OfferMacro`/`NewOfferMacro` and `OfferMicro`/`NewOfferMicro` types already exist and
   automatically pick up the new columns via `$inferSelect` — no changes needed to those
   two type aliases themselves.
cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30 - `npx tsc --noEmit` passes with zero errors (full project) - `grep -c "offer_tier_services" src/db/schema.ts` >= 4 (table def, relations, both type exports) - `grep -c "tier_letter\|public_price" src/db/schema.ts` == 2 (both new offer_micros columns present exactly once) - `grep -c "is_archived\|cliente_ideale\|risultato\|tempo\|pain\|metodo" src/db/schema.ts` == 6 (all 6 new offer_macros transformation/archive columns present) - `grep -cE '"(category|ticket)"' src/db/schema.ts` >= 2 (new offer_macros category/ticket columns present — note `services.category` also matches "category", so this is a >= check, not ==) - `git diff HEAD -- src/db/schema.ts | grep -E '^-' | grep -v '^---'` returns empty (no existing lines removed — additive diff only) schema.ts compiles, exposes all new additive columns/table/types, and the legacy offer_micro_services/offer_services definitions are byte-identical to their pre-task state. Task 2: Hand-write migration 0008 SQL + journal entry + idempotent push script src/db/migrations/0008_offer_tier_schema.sql, src/db/migrations/meta/_journal.json, scripts/push-12-offer-tier-schema.ts - src/db/migrations/0007_add_services_fase.sql (simplest additive precedent: `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`) - src/db/migrations/0006_add_tags_table.sql (CREATE TABLE + indexes precedent, Drizzle output format with `--> statement-breakpoint`) - src/db/migrations/meta/_journal.json (current entries — note it already has gaps for 0003-0005/0007; append 0008 entry following the 0006 entry's shape) - scripts/push-11-tags-migration.ts (idempotent CREATE TABLE IF NOT EXISTS + indexes pattern, with the PRODUCTION DEPLOY NOTE comment header) - scripts/push-11b-fase-column.ts (idempotent ADD COLUMN IF NOT EXISTS pattern, shorter form) 1. Create `src/db/migrations/0008_offer_tier_schema.sql` — hand-written, additive-only, idempotent (every statement uses `IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`), no DROP/TRUNCATE/RENAME. Content:
   ```sql
   -- Phase 12: Offer Editor — additive schema for tier designations, public pricing,
   -- archive flag, category/ticket dimensions, structured transformation promise, and
   -- tier<->services junction. All statements additive/idempotent. No drops/truncates
   -- (Data Safety LOCKED).

   -- offer_macros: archive flag + short description + category/ticket dimensions +
   -- structured transformation promise
   ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS description text;
   ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS category text;
   ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS ticket text;
   ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS is_archived boolean NOT NULL DEFAULT false;
   ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS cliente_ideale text;
   ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS risultato text;
   ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS tempo text;
   ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS pain text;
   ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS metodo text;

   -- offer_micros: tier designation (A/B/C) + manual public price
   ALTER TABLE offer_micros ADD COLUMN IF NOT EXISTS tier_letter text;
   ALTER TABLE offer_micros ADD COLUMN IF NOT EXISTS public_price numeric(10, 2);

   -- CHECK constraint for tier_letter, added separately so the ADD COLUMN above stays
   -- idempotent even if the constraint already exists (guarded via DO block).
   DO $$
   BEGIN
     IF NOT EXISTS (
       SELECT 1 FROM pg_constraint WHERE conname = 'offer_micros_tier_letter_check'
     ) THEN
       ALTER TABLE offer_micros
         ADD CONSTRAINT offer_micros_tier_letter_check
         CHECK (tier_letter IS NULL OR tier_letter IN ('A', 'B', 'C'));
     END IF;
   END $$;

   -- New junction table: tier (offer_micros) <-> unified services catalog.
   -- Additive replacement for legacy offer_micro_services (untouched, points to offer_services).
   CREATE TABLE IF NOT EXISTS 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 with time zone DEFAULT now() NOT NULL,
     PRIMARY KEY (tier_id, service_id)
   );

   CREATE INDEX IF NOT EXISTS offer_tier_services_tier_idx ON offer_tier_services USING btree (tier_id);
   ```

2. Append a journal entry for `0008_offer_tier_schema` to `src/db/migrations/meta/_journal.json`,
   following the exact shape of the existing `0006_add_tags_table` entry (idx: 8, version: "7",
   a `when` timestamp larger than the 0006 entry's, tag: "0008_offer_tier_schema", breakpoints: true).
   This file already has gaps (no entries for 0003-0005/0007) — do not attempt to backfill
   those; only append the new 0008 entry to the `entries` array.

3. Create `scripts/push-12-offer-tier-schema.ts` — idempotent push script following the
   `push-11-tags-migration.ts` / `push-11b-fase-column.ts` pattern: reads `DATABASE_URL`
   from env, uses the `postgres` package, runs each statement from migration 0008 (the 11
   `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` statements, the DO-block CHECK constraint
   guard, `CREATE TABLE IF NOT EXISTS offer_tier_services`, and
   `CREATE INDEX IF NOT EXISTS offer_tier_services_tier_idx`), logs success per statement
   group, and exits 0/1. Include the same "PRODUCTION DEPLOY NOTE" comment header as
   `push-11-tags-migration.ts`, referencing this script and migration 0008, and stating
   that Plan 02 (BLOCKING, SSH+docker exec) must run this before Wave 3 code (query layer
   reading `offer_tier_services`/`tier_letter`/`public_price`/`category`/`ticket`) is
   exercised against production data.

   Catch and tolerate "already exists" / duplicate-column / duplicate-constraint errors
   per-statement (same try/catch-per-block resilience as the precedent scripts) so the
   script is safely re-runnable. Export an async `push()` function as the module's main
   entry point (called at the bottom of the file, matching precedent scripts).
cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -20; test -f src/db/migrations/0008_offer_tier_schema.sql && echo "MIGRATION_EXISTS"; node -e "const j=require('./src/db/migrations/meta/_journal.json'); console.log(j.entries.find(e=>e.tag==='0008_offer_tier_schema') ? 'JOURNAL_OK' : 'JOURNAL_MISSING')" - `test -f src/db/migrations/0008_offer_tier_schema.sql` succeeds - The migration file contains zero occurrences of `DROP`, `TRUNCATE`, or `RENAME` (case-insensitive grep returns no matches) - Every `ALTER TABLE ... ADD COLUMN` statement includes `IF NOT EXISTS`: `grep -c "ADD COLUMN IF NOT EXISTS" src/db/migrations/0008_offer_tier_schema.sql` == 11 - `grep -c "CREATE TABLE IF NOT EXISTS offer_tier_services" src/db/migrations/0008_offer_tier_schema.sql` == 1 - `_journal.json` parses as valid JSON and contains an entry with `"tag": "0008_offer_tier_schema"` - `npx tsc --noEmit` (full project, including the new script) passes with zero errors - `scripts/push-12-offer-tier-schema.ts` contains a "PRODUCTION DEPLOY NOTE" comment referencing migration 0008 and the BLOCKING SSH+docker exec apply step Migration 0008 SQL file exists, is additive/idempotent (IF NOT EXISTS guards throughout, no destructive statements), journal has a corresponding entry, and the push script typechecks and is ready for Plan 02 to execute against production.

<threat_model>

Trust Boundaries

Boundary Description
Migration script -> Production DB scripts/push-12-offer-tier-schema.ts executes raw SQL via DATABASE_URL; the only "input" is the hardcoded migration SQL (no user input at this layer)
schema.ts -> downstream query/action code (Plan 03+) New columns/table become part of the type-safe Drizzle surface consumed by admin-only server actions

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-12-01 Tampering 0008_offer_tier_schema.sql accidentally includes a destructive statement mitigate Acceptance criteria grep-gate explicitly rejects DROP/TRUNCATE/RENAME before this plan is considered done; CLAUDE.md Data Safety LOCKED reviewed before writing the migration
T-12-02 Tampering offer_micros.tier_letter accepts arbitrary strings at the DB layer mitigate SQL-level CHECK constraint (tier_letter IN ('A','B','C') or NULL) added via guarded DO block; Zod enum validation added at the server-action layer in Plan 03 as defense-in-depth
T-12-03 Repudiation Push script silently no-ops on errors, masking a failed migration accept Script logs per-statement success/failure and exits non-zero on unexpected errors (only "already exists"/duplicate errors are tolerated); operator (Plan 02) reviews script output before proceeding
T-12-04 Information Disclosure offer_tier_services/new columns become queryable but must remain admin-only (/admin/offers) accept No client-facing route reads these tables in this plan; enforcement of admin-only access happens in Plan 03 (requireAdmin) — tracked, not this plan's scope
</threat_model>
1. `npx tsc --noEmit` passes for the full project (schema.ts changes compile cleanly with all existing consumers). 2. `src/db/migrations/0008_offer_tier_schema.sql` exists, is additive-only, and every ALTER/CREATE statement is idempotent (`IF NOT EXISTS` / guarded DO block). 3. `scripts/push-12-offer-tier-schema.ts` typechecks and documents the BLOCKING manual prod-apply step for Plan 02. 4. `git diff HEAD -- src/db/schema.ts` shows ONLY additions (new columns, new table, new relations, new types) — no removed or renamed lines for `offer_macros`, `offer_micros`, `offer_micro_services`, `offer_services`, `clients`, `projects`, `payments`, `phases`.

<success_criteria>

  • offer_macros and offer_micros expose all Phase 12 additive columns in the Drizzle schema and TypeScript types.
  • offer_tier_services junction table is defined, related, and typed — pointing at services (not offer_services).
  • Migration 0008 + push script are ready for Plan 02's BLOCKING production-apply step.
  • No existing table/column was dropped, renamed, or altered destructively. </success_criteria>
After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-01-SUMMARY.md`