640f986967
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.
402 lines
24 KiB
Markdown
402 lines
24 KiB
Markdown
---
|
|
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>
|