Files
clienthub/.planning/phases/05-offer-system/05-01-PLAN.md
T
simone cd55cba56c docs(05): create Phase 5 Offer System plans — 4 plans in 3 waves
Wave 1: 05-01 schema migration (5 new tables, additive only)
Wave 2: 05-02 offer catalog CRUD (/admin/offers + NavBar)
Wave 3: 05-03 project offer assignment (OffersTab) + 05-04 client dashboard offers + /admin/forecast

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 15:07:52 +02:00

12 KiB

plan_id, phase, wave, title, type, depends_on, files_modified, requirements_addressed, autonomous, must_haves
plan_id phase wave title type depends_on files_modified requirements_addressed autonomous must_haves
05-01 5 1 Schema migration — 5 new offer tables + Drizzle relations execute
src/db/schema.ts
OFFER-01
OFFER-02
OFFER-03
OFFER-04
true
truths artifacts key_links
Five new tables exist in the database: offer_macros, offer_micros, offer_services, offer_micro_services, project_offers
No existing table (clients, projects, payments, phases) is modified, dropped, or truncated
Drizzle relations are defined for all new tables
TypeScript types are exported for all new tables
path provides contains
src/db/schema.ts Five new pgTable definitions appended after existing tables offer_macros, offer_micros, offer_services, offer_micro_services, project_offers
from to via pattern
src/db/schema.ts Postgres DB npx drizzle-kit push offer_macros|offer_micros|offer_services|offer_micro_services|project_offers
Add five new tables for the Offer System to the Drizzle schema and push to the database.

Purpose: Every subsequent plan in Phase 5 reads from or writes to these tables. This plan is the blocking dependency for all other Phase 5 work. Output: Updated src/db/schema.ts with five new table definitions, Drizzle relations, exported TypeScript types, and a successful drizzle-kit push.

<execution_context> @/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/workflows/execute-plan.md @/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/templates/summary.md </execution_context>

@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md @/Users/simonecavalli/IAMCAVALLI/.planning/STATE.md @/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-RESEARCH.md Task 1: Append five new table definitions and relations to schema.ts src/db/schema.ts

<read_first> - src/db/schema.ts — read the FULL file before touching it (existing tables, import block, relations block, TypeScript types block) </read_first>

Open `src/db/schema.ts`. At the top of the file, the existing import from `"drizzle-orm/pg-core"` is:
import {
  pgTable,
  text,
  integer,
  numeric,
  timestamp,
  boolean,
} from "drizzle-orm/pg-core";

Add primaryKey, date to this import (needed for offer_micro_services composite PK and project_offers.start_date).

Append the following five table definitions AFTER the settings table definition and BEFORE the // ============ RELATIONS ============ section. Use the exact column names from the research (not the phase_scope variant — research is authoritative):

// ============ OFFER MACROS ============
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(),
});

// ============ OFFER MICROS ============
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),
});

// ============ OFFER SERVICES (distinct from service_catalog — marketing/transformation semantics) ============
export const offer_services = pgTable("offer_services", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => nanoid()),
  name: text("name").notNull(),
  price: numeric("price", { precision: 10, scale: 2 }).notNull(),
  transformation_description: text("transformation_description"),
  active: boolean("active").notNull().default(true),
});

// ============ OFFER MICRO SERVICES (junction: offer_micros <-> offer_services) ============
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] }),
  })
);

// ============ PROJECT OFFERS (assignment of micro-offer to project) ============
export const project_offers = pgTable("project_offers", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => nanoid()),
  project_id: text("project_id")
    .notNull()
    .references(() => projects.id, { onDelete: "cascade" }),
  micro_id: text("micro_id")
    .notNull()
    .references(() => offer_micros.id, { onDelete: "restrict" }),
  // NOT NULL with defaultNow — required for forecast computation (null start_date breaks forecast)
  start_date: timestamp("start_date", { withTimezone: true }).notNull().defaultNow(),
  // Offer-level accepted total — separate from projects.accepted_total (quote builder total)
  accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }),
  created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

Then add Drizzle relations at the end of the // ============ RELATIONS ============ section (after serviceCatalogRelations):

export const offerMacrosRelations = relations(offer_macros, ({ many }) => ({
  micros: many(offer_micros),
}));

export const offerMicrosRelations = relations(offer_micros, ({ one, many }) => ({
  macro: one(offer_macros, { fields: [offer_micros.macro_id], references: [offer_macros.id] }),
  services: many(offer_micro_services),
  projectOffers: many(project_offers),
}));

export const offerServicesRelations = relations(offer_services, ({ many }) => ({
  microAssignments: many(offer_micro_services),
}));

export const offerMicroServicesRelations = relations(offer_micro_services, ({ one }) => ({
  micro: one(offer_micros, { fields: [offer_micro_services.micro_id], references: [offer_micros.id] }),
  service: one(offer_services, { fields: [offer_micro_services.service_id], references: [offer_services.id] }),
}));

export const projectOffersRelations = relations(project_offers, ({ one }) => ({
  project: one(projects, { fields: [project_offers.project_id], references: [projects.id] }),
  micro: one(offer_micros, { fields: [project_offers.micro_id], references: [offer_micros.id] }),
}));

Also add the new tables to projectsRelations:

// Add to the existing projectsRelations many() block:
projectOffers: many(project_offers),

Finally, append TypeScript types at the end of the // ============ TYPESCRIPT TYPES ============ section:

export type OfferMacro = typeof offer_macros.$inferSelect;
export type NewOfferMacro = typeof offer_macros.$inferInsert;
export type OfferMicro = typeof offer_micros.$inferSelect;
export type NewOfferMicro = typeof offer_micros.$inferInsert;
export type OfferService = typeof offer_services.$inferSelect;
export type NewOfferService = typeof offer_services.$inferInsert;
export type OfferMicroService = typeof offer_micro_services.$inferSelect;
export type NewOfferMicroService = typeof offer_micro_services.$inferInsert;
export type ProjectOffer = typeof project_offers.$inferSelect;
export type NewProjectOffer = typeof project_offers.$inferInsert;

Data Safety check before push: Confirm the migration only adds tables — grep the generated SQL for DROP or TRUNCATE before accepting.

cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20

<acceptance_criteria> - grep -c "offer_macros\|offer_micros\|offer_services\|offer_micro_services\|project_offers" src/db/schema.ts returns 5 or more (table definitions present) - npx tsc --noEmit exits 0 (no TypeScript errors) - grep "primaryKey" src/db/schema.ts matches (composite PK import present) - grep "onDelete.*restrict" src/db/schema.ts matches (project_offers.micro_id FK is restrict, not cascade) </acceptance_criteria>

schema.ts compiles without errors; five new table definitions present; relations defined; TypeScript types exported

Task 2: Run drizzle-kit push — create tables in database src/db/schema.ts

<read_first> - src/db/schema.ts — verify Task 1 output before pushing - .env (existence check only — do NOT log contents) </read_first>

Run the migration command. Before accepting the push, read the SQL preview that drizzle-kit outputs and verify it contains only CREATE TABLE statements — NO DROP TABLE, NO ALTER TABLE DROP COLUMN, NO TRUNCATE.
npx drizzle-kit push

When drizzle-kit shows the diff, confirm only additions are shown. If any destructive statement appears, ABORT and report to the user immediately.

Expected tables created:

  1. offer_macros
  2. offer_micros
  3. offer_services
  4. offer_micro_services (composite PK: micro_id + service_id)
  5. project_offers

If push fails with "relation does not exist", the definition order in schema.ts is wrong. Fix order: offer_macrosoffer_microsoffer_servicesoffer_micro_servicesproject_offers (each references only tables defined before it).

cd /Users/simonecavalli/IAMCAVALLI && node -e "const { db } = require('./src/db/index.ts'); console.log('db ok')" 2>&1 || npx drizzle-kit push --dry-run 2>&1 | tail -10

<acceptance_criteria> - npx drizzle-kit push exits without error - No DROP TABLE or TRUNCATE appears in the migration output - All five table names appear in the drizzle-kit push output as CREATE TABLE operations </acceptance_criteria>

All five tables created in database; no existing data modified

<threat_model>

Trust Boundaries

Boundary Description
schema.ts → DB Schema changes are applied via drizzle-kit push — only additive changes allowed (CLAUDE.md Data Safety)

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-05-01 Tampering drizzle-kit push mitigate Read migration preview before accepting; abort if DROP TABLE or TRUNCATE appears
T-05-02 Tampering project_offers.micro_id FK mitigate Use onDelete: "restrict" — prevents silent history destruction when a micro-offer is deleted while assigned to projects
</threat_model>
After both tasks complete: - `grep -c "offer_macros" src/db/schema.ts` → at least 3 (table def + relation + type) - `grep "primaryKey" src/db/schema.ts` → matches (composite PK import added) - `npx tsc --noEmit` → exits 0 - DB tables exist (confirmed by drizzle-kit push output)

<success_criteria>

  1. Five new tables in database: offer_macros, offer_micros, offer_services, offer_micro_services, project_offers
  2. Zero modifications to existing tables (clients, projects, payments, phases untouched)
  3. TypeScript compiles without errors
  4. Drizzle relations defined for all five tables </success_criteria>
After completion, create `.planning/phases/05-offer-system/05-01-SUMMARY.md` using the summary template.