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.
This commit is contained in:
2026-06-14 21:00:32 +02:00
parent 9bff7623da
commit 640f986967
11 changed files with 3303 additions and 28 deletions
@@ -0,0 +1,401 @@
---
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>