---
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"
---

<objective>
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`.
</objective>

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

<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
</context>

<interfaces>
<!-- Current relevant schema slices the executor will extend. Drizzle import style:
     pgTable / text / integer / numeric / timestamp / boolean / primaryKey / uniqueIndex / index
     are already imported at the top of src/db/schema.ts (lines 1-13). Reuse, do not re-import. -->

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] }) }));
```
</interfaces>

<tasks>

<task type="auto">
  <name>Task 1: Extend offer_macros and offer_micros, add offer_tier_services junction in schema.ts</name>
  <files>src/db/schema.ts</files>
  <read_first>
    - 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")
  </read_first>
  <action>
    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.
  </action>
  <verify>
    <automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30</automated>
  </verify>
  <acceptance_criteria>
    - `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)
  </acceptance_criteria>
  <done>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.</done>
</task>

<task type="auto">
  <name>Task 2: Hand-write migration 0008 SQL + journal entry + idempotent push script</name>
  <files>src/db/migrations/0008_offer_tier_schema.sql, src/db/migrations/meta/_journal.json, scripts/push-12-offer-tier-schema.ts</files>
  <read_first>
    - 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)
  </read_first>
  <action>
    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).
  </action>
  <verify>
    <automated>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')"</automated>
  </verify>
  <acceptance_criteria>
    - `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
  </acceptance_criteria>
  <done>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.</done>
</task>

</tasks>

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

<verification>
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`.
</verification>

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

<output>
After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-01-SUMMARY.md`
</output>
