--- phase: 12 plan: 01 type: execute wave: 1 depends_on: [] files_modified: - 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 autonomous: true requirements: [OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18] must_haves: truths: - "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" artifacts: - path: "src/db/schema.ts" provides: "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" contains: "offer_tier_services" - path: "src/db/migrations/0008_offer_tier_schema.sql" provides: "Additive ALTER TABLE + CREATE TABLE statements, IF NOT EXISTS / guarded DO block, no DROP/TRUNCATE" contains: "CREATE TABLE IF NOT EXISTS offer_tier_services" - path: "scripts/push-12-offer-tier-schema.ts" provides: "Idempotent script to apply migration 0008 to prod via DATABASE_URL (SSH+docker exec target)" exports: ["push"] key_links: - from: "src/db/schema.ts (offer_tier_services)" to: "services.id / offer_micros.id" via: "foreign key references with onDelete cascade" pattern: "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`. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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): ```typescript 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): ```typescript 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): ```typescript 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): ```typescript 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): ```typescript 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. ## 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 | 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`. - `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. After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-01-SUMMARY.md`