Files
clienthub/.planning/phases/08-offer-phases-quote-builder/08-PLAN.md
T

21 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
08-offer-phases-quote-builder 01 execute 1
07-01
07-02
src/db/schema.ts
src/db/migrations/0003_offer_phases_quote_templates.sql
src/lib/admin-queries.ts
src/types/offer.ts
true
OFFER-B-01
OFFER-B-02
OFFER-B-03
truths artifacts key_links
Admin can view offer micro structure broken down into phases
Each offer phase can have services assigned from unified services table
Quotes are stored as separate header records with items linked back
Quote pages can copy offer structure to show phases + services to leads
path provides contains
src/db/schema.ts offer_phases, offer_phase_services, quotes table definitions + types export const offer_phases, offer_phase_services, quotes
path provides creates
src/db/migrations/0003_offer_phases_quote_templates.sql SQL migration adding all new tables with zero data loss offer_phases, offer_phase_services, quotes + FK updates
path provides exports
src/lib/admin-queries.ts getOfferPhases, getOfferPhaseServices queries for builder UI
getOfferPhases(microId)
getOfferPhaseServices(phaseId)
path provides exports
src/types/offer.ts TypeScript types for offer phases, quote records
OfferPhase
OfferPhaseService
Quote
NewQuote
from to via pattern
offer_phases offer_micros foreign key micro_id micro_id.*references.*offer_micros
from to via pattern
offer_phase_services services foreign key service_id service_id.*references.*services
from to via pattern
quotes offer_micros offer_micro_id field (nullable, tracks which tier was quoted) offer_micro_id.*references.*offer_micros
Add hierarchical offer phases structure and quote headers to ClientHub. This schema layer enables Phase 9 (quote builder UI + public routes) and Phase 11 (auto-provisioning on win).

Purpose: Offer micros currently have no phase structure. Phase 8 introduces offer_phases (Discovery → Strategy → Execution), offer_phase_services (services assigned to each phase), and quotes (quote headers separate from line items). This enables:

  • Quote builder to pre-populate from offer structure
  • Public quote pages to show phases + services to leads
  • Auto-provisioning to copy offer phases → project phases on win

Output: Three new database tables (offer_phases, offer_phase_services, quotes), updated quote_items schema, migration script, types, and query layer.

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

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/research/ARCHITECTURE.md @.planning/research/FEATURES_v2.0.md @.planning/phases/07-unified-service-catalog/07-02-SUMMARY.md Task 1: Add offer_phases, offer_phase_services, and quotes tables to schema src/db/schema.ts, src/types/offer.ts Add three new table definitions to schema.ts (after existing offer_micros section):
  1. offer_phases table — hierarchical breakdown of an offer micro into phases:

    • id (text PK, nanoid)
    • micro_id (text FK → offer_micros, onDelete cascade)
    • title (text, not null) — "Discovery", "Strategy", "Execution", etc.
    • description (text, nullable)
    • sort_order (integer, default 0) — display order
    • duration_weeks (integer, nullable) — optional duration for timeline
    • created_at (timestamp) Note: NOT an edit of existing offer_micro_services; this is new. Offer micros previously had flat offer_services. Now they have hierarchical phases + services within phases.
  2. offer_phase_services junction table — assigns services to offer phases:

    • phase_id (text FK → offer_phases, onDelete cascade)
    • service_id (text FK → services, onDelete cascade)
    • Primary key: (phase_id, service_id) Purpose: Replaces the old flat offer_micro_services path (that remains for backward compat). New builder uses phases.
  3. quotes table — separate quote header from line items:

    • id (text PK, nanoid)
    • lead_id (text FK → leads, onDelete cascade, NULLABLE — for standalone quotes to clients)
    • client_id (text FK → clients, onDelete cascade, NULLABLE — for direct client quotes)
    • token (text, not null, unique) — nanoid for public page access (/quote/[token])
    • offer_micro_id (text FK → offer_micros, onDelete set null, NULLABLE) — which tier was quoted (for audit)
    • total_amount (numeric(10,2), not null) — calculated sum of quote_items
    • status (text, default 'draft') — draft | sent | viewed | accepted | rejected
    • accepted_at (timestamp, nullable, immutable once set)
    • accepted_by_email (text, nullable) — lead/client email on acceptance
    • accepted_by_name (text, nullable) — signer name on acceptance
    • created_at (timestamp)
    • updated_at (timestamp)

Add TypeScript types in src/types/offer.ts (new file or extend if exists):

  • OfferPhase, NewOfferPhase
  • OfferPhaseService, NewOfferPhaseService
  • Quote, NewQuote Infer from Drizzle tables using $inferSelect/$inferInsert pattern (per CLAUDE.md style).

Update existing quote_items table schema (NO DATA CHANGES):

  • Add quote_id (text FK → quotes, onDelete cascade) — link items to quote header
  • Add offer_micro_id (text FK → offer_micros, onDelete set null, NULLABLE) — which tier (audit)
  • Add offer_phase_id (text FK → offer_phases, onDelete set null, NULLABLE) — which phase within tier (audit)
  • Keep existing: project_id, service_id, quantity, unit_price, subtotal, custom_label

Update projects table schema (NO DATA CHANGES):

  • Add offer_id (text FK → offer_micros, onDelete set null, NULLABLE) — tracks template origin
  • Add created_from_lead_id (text, NULLABLE) — which lead this project came from (for audit)

Update phases table schema (NO DATA CHANGES):

  • Add offer_phase_id (text FK → offer_phases, onDelete set null, NULLABLE) — audit trail to original template phase

Add relations in schema.ts:

  • offerPhasesRelations: micro (one) → micro.id, phaseServices (many)
  • offerPhaseServicesRelations: phase (one), service (one) → services
  • quotesRelations: leadId (one) → leads, clientId (one) → clients, micro (one) → offer_micros, items (many) → quote_items
  • update quote_items relations to include quote (one), offerMicro (one), offerPhase (one)
  • update projects relations to include offer (one), created_from_lead (implicit, no direct relation needed)
  • update phases relations to include offerPhase (one)

Validation: npm run build must pass with no TypeScript errors. npm run build 2>&1 | grep -E "(error|ERR)" || echo "Build passed" All four tables defined in schema.ts with correct FK relationships. All TypeScript types exported from types/offer.ts. No data modifications (additive-only). Build passes.

Task 2: Create Drizzle migration file (SQL + idempotent validation) src/db/migrations/0003_offer_phases_quote_templates.sql, scripts/validate-phase8-migration.ts Create SQL migration file `src/db/migrations/0003_offer_phases_quote_templates.sql` following Phase 7 pattern (additive-first, zero data loss, rollback-safe):
-- Phase 8: Offer Phases & Quote Templates (Additive-First Migration)

-- Create offer_phases table (hierarchical breakdown of offer micros)
CREATE TABLE IF NOT EXISTS offer_phases (
  id TEXT PRIMARY KEY,
  micro_id TEXT NOT NULL REFERENCES offer_micros(id) ON DELETE CASCADE,
  title TEXT NOT NULL,
  description TEXT,
  sort_order INTEGER NOT NULL DEFAULT 0,
  duration_weeks INTEGER,
  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE(micro_id, title)
);
CREATE INDEX IF NOT EXISTS idx_offer_phases_micro_id ON offer_phases(micro_id);

-- Create offer_phase_services junction (replaces flat offer_micro_services path)
CREATE TABLE IF NOT EXISTS offer_phase_services (
  phase_id TEXT NOT NULL REFERENCES offer_phases(id) ON DELETE CASCADE,
  service_id TEXT NOT NULL REFERENCES services(id) ON DELETE CASCADE,
  PRIMARY KEY (phase_id, service_id)
);
CREATE INDEX IF NOT EXISTS idx_offer_phase_services_service_id ON offer_phase_services(service_id);

-- Create quotes table (separate header from line items)
CREATE TABLE IF NOT EXISTS quotes (
  id TEXT PRIMARY KEY,
  lead_id TEXT REFERENCES leads(id) ON DELETE CASCADE,
  client_id TEXT REFERENCES clients(id) ON DELETE CASCADE,
  token TEXT NOT NULL UNIQUE,
  offer_micro_id TEXT REFERENCES offer_micros(id) ON DELETE SET NULL,
  total_amount NUMERIC(10, 2) NOT NULL,
  status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'sent', 'viewed', 'accepted', 'rejected')),
  accepted_at TIMESTAMP WITH TIME ZONE,
  accepted_by_email TEXT,
  accepted_by_name TEXT,
  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_quotes_token ON quotes(token);
CREATE INDEX IF NOT EXISTS idx_quotes_client_id ON quotes(client_id);
CREATE INDEX IF NOT EXISTS idx_quotes_lead_id ON quotes(lead_id);
CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status);

-- Update quote_items: add quote_id + offer context FKs (additive, no drops)
-- IF quote_id column doesn't exist, add it
ALTER TABLE quote_items ADD COLUMN IF NOT EXISTS quote_id TEXT REFERENCES quotes(id) ON DELETE CASCADE;
ALTER TABLE quote_items ADD COLUMN IF NOT EXISTS offer_micro_id TEXT REFERENCES offer_micros(id) ON DELETE SET NULL;
ALTER TABLE quote_items ADD COLUMN IF NOT EXISTS offer_phase_id TEXT REFERENCES offer_phases(id) ON DELETE SET NULL;

-- Create indexes on new FK columns
CREATE INDEX IF NOT EXISTS idx_quote_items_quote_id ON quote_items(quote_id);
CREATE INDEX IF NOT EXISTS idx_quote_items_offer_micro_id ON quote_items(offer_micro_id);
CREATE INDEX IF NOT EXISTS idx_quote_items_offer_phase_id ON quote_items(offer_phase_id);

-- Update projects: add offer_id + created_from_lead_id (additive, no drops)
ALTER TABLE projects ADD COLUMN IF NOT EXISTS offer_id TEXT REFERENCES offer_micros(id) ON DELETE SET NULL;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS created_from_lead_id TEXT;

CREATE INDEX IF NOT EXISTS idx_projects_offer_id ON projects(offer_id);
CREATE INDEX IF NOT EXISTS idx_projects_created_from_lead_id ON projects(created_from_lead_id);

-- Update phases: add offer_phase_id (additive, no drops)
ALTER TABLE phases ADD COLUMN IF NOT EXISTS offer_phase_id TEXT REFERENCES offer_phases(id) ON DELETE SET NULL;

CREATE INDEX IF NOT EXISTS idx_phases_offer_phase_id ON phases(offer_phase_id);

-- NOTE: leads table is not created here (Phase 10 CRM). This migration assumes leads exists when quotes.lead_id is used.
-- For Phase 8 standalone, lead_id is nullable; Phase 10 will populate it.

Create validation script scripts/validate-phase8-migration.ts that:

  • Checks all four new tables exist (offer_phases, offer_phase_services, quotes)
  • Verifies indexes created
  • Counts rows in each table (expect mostly empty except offer_phases if backfilled from offer_micros)
  • Verifies FKs intact (sample query: SELECT * FROM offer_phases WHERE micro_id IS NULL should return 0)
  • Confirms quote_items.quote_id, offer_micro_id, offer_phase_id columns added
  • Confirms projects.offer_id, created_from_lead_id columns added
  • Confirms phases.offer_phase_id column added
  • Exit code 0 if all checks pass, 1 if any fail

Note: NO DATA MIGRATION in this phase. offer_phases table is empty until Phase 9 quote builder or admin manually populates it. Existing offer_micro_services junction remains untouched (backward compat). ls -lh src/db/migrations/0003_offer_phases_quote_templates.sql && wc -l src/db/migrations/0003_offer_phases_quote_templates.sql Migration file created (additive-only, ~100 lines). Validation script written. Both checked into src/db/migrations/ and scripts/. Ready for Postgres connectivity.

Task 3: Add query layer for offer phases + update quote_items relations src/lib/admin-queries.ts, src/db/schema.ts (relations update) Add new query functions to src/lib/admin-queries.ts:
  1. getOfferPhases(microId: string) → Promise<OfferPhase[]> Query: SELECT * FROM offer_phases WHERE micro_id = ? ORDER BY sort_order ASC Returns: All phases for a given offer micro (used by quote builder, admin phase editor)

  2. getOfferPhaseServices(phaseId: string) → Promise<Service[]> Query: SELECT s.* FROM services s JOIN offer_phase_services ops ON s.id = ops.service_id WHERE ops.phase_id = ? Returns: All unified services assigned to a phase (used by quote builder, public quote page)

  3. getQuoteById(quoteId: string) → Promise<Quote | null> Query: SELECT * FROM quotes WHERE id = ? (with relations: lead, client, micro, items) Returns: Full quote header + items (used by quote detail page)

  4. getQuoteByToken(token: string) → Promise<Quote | null> Query: SELECT * FROM quotes WHERE token = ? (with relations) Returns: Quote by public token (used by /quote/[token] route validation)

  5. getQuotesByClient(clientId: string) → Promise<Quote[]> Query: SELECT * FROM quotes WHERE client_id = ? ORDER BY created_at DESC Returns: All quotes for a client (used by /admin/clients/[id] detail page)

Update schema.ts relations to include:

  • quotesRelations: include lead (one), client (one), micro (one), items (many)
  • quote_items relations update: include quote (one), offerMicro (one), offerPhase (one)
  • offerPhasesRelations: include micro (one), services (many)

Update existing getProjectFullDetail and getClientFullDetail queries to optionally include activeOffers (unchanged from Phase 7 — no breaking changes).

All queries use Drizzle ORM with proper type inference.

Validation: TypeScript build must pass. Query functions properly typed with import/export verified. grep -c "getOfferPhases|getOfferPhaseServices|getQuoteById|getQuoteByToken" src/lib/admin-queries.ts All five query functions added to admin-queries.ts. Relations updated in schema.ts. TypeScript types properly inferred from Drizzle. No breaking changes to existing queries.

<threat_model>

Trust Boundaries

Boundary Description
Admin → Quote builder Admin is trusted to create/edit quotes; quotes are immutable once sent to lead
Public link → Quote view Anyone with quote token can view; acceptance is gated by token verification
Quote acceptance Once accepted_at is set, quote is final (immutable); triggers phase 11 auto-provisioning

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-08-01 Tampering offer_phases table mitigate offer_phase_id is immutable in projects.phases (audit trail, template reference only, phases editable after creation)
T-08-02 Tampering quotes table mitigate quotes.token is unique; quotes.accepted_at immutable once set via DB constraint
T-08-03 Information Disclosure quote_items via quote_id FK mitigate Quote items never exposed via public API; only quote header visible in /quote/[token] (items displayed, but not direct query access)
T-08-04 Spoofing quote.accepted_by_email mitigate Email captured from form submission in Phase 9; no validation in this phase (validation happens on submission, not query)
T-08-05 Tampering quote_items → offer_phase_id accept Nullable audit field; if NULL, phase unknown (acceptable for legacy quote items created before Phase 8)

No new public endpoints in Phase 8 (schema-only). Threat surface limited to admin mutations via Phase 7 patterns (requireAdmin + Auth.js session check).

</threat_model>

**Phase 8 Completion Checklist:**
  1. Schema definitions:

    • offer_phases table created with micro_id, title, sort_order, duration_weeks
    • offer_phase_services junction created with FK to services (unified catalog)
    • quotes table created with token, status, accepted_at immutable fields
    • quote_items.quote_id, offer_micro_id, offer_phase_id columns added
    • projects.offer_id, created_from_lead_id columns added
    • phases.offer_phase_id column added
    • All FKs point to correct tables with cascade/set null as specified
  2. Relations:

    • offer_phases → offer_micros (one micro to many phases)
    • offer_phase_services → offer_phases + services (many-to-many)
    • quotes → clients, leads, offer_micros (nullable)
    • quote_items → quotes (new FK)
  3. Types:

    • OfferPhase, NewOfferPhase exported from types/offer.ts
    • OfferPhaseService, NewOfferPhaseService exported
    • Quote, NewQuote exported
    • All types properly inferred from Drizzle
  4. Migration:

    • Migration file created in src/db/migrations/0003_*.sql
    • Additive-only (no drops, no truncates)
    • All new tables + columns with proper constraints
    • Indexes created for FKs and common filters (status, token, client_id, micro_id)
  5. Query layer:

    • getOfferPhases(microId) implemented
    • getOfferPhaseServices(phaseId) implemented
    • getQuoteById, getQuoteByToken implemented
    • getQuotesByClient implemented
    • All queries return typed results (OfferPhase[], Service[], Quote, Quote[], etc.)
  6. Build & Type Safety:

    • npm run build passes with zero TypeScript errors
    • No unused imports or exports
    • All new types properly exported and consumed
  7. Backward Compatibility:

    • Existing offer_micro_services junction untouched
    • Existing quote_items functionality unchanged (quote_id/offer_micro_id nullable for legacy items)
    • No breaking changes to existing queries or API surfaces
  8. Data Safety (LOCKED):

    • No ALTER TABLE DROP on existing columns
    • No DELETE or TRUNCATE on clients, projects, payments, phases, quote_items
    • Migration additive-only (ADD COLUMN, CREATE TABLE)
    • Rollback possible: tables remain, old columns preserved

<success_criteria> Phase 8 is complete when:

  1. Database schema extended:

    • Three new tables (offer_phases, offer_phase_services, quotes) exist in schema.ts with all fields, FKs, and indexes
    • quote_items, projects, phases tables extended with new audit columns (quote_id, offer_micro_id, offer_phase_id, offer_id, created_from_lead_id, offer_phase_id)
    • All relations defined in Drizzle
    • npm run build passes
  2. Migration script ready:

    • SQL migration file created in src/db/migrations/0003_offer_phases_quote_templates.sql
    • File is additive-only (no drops/truncates)
    • Validation script in scripts/validate-phase8-migration.ts checks all constraints
    • Both files committed to git
  3. Query layer functional:

    • Five new query functions added to src/lib/admin-queries.ts (getOfferPhases, getOfferPhaseServices, getQuoteById, getQuoteByToken, getQuotesByClient)
    • All queries properly typed with Drizzle relations
    • Existing queries unchanged (backward compatible)
  4. Types exported:

    • OfferPhase, NewOfferPhase, OfferPhaseService, NewOfferPhaseService, Quote, NewQuote in src/types/offer.ts
    • All types properly inferred from schema
  5. Ready for Phase 9:

    • Phase 9 can now reference offer_phases + offer_phase_services in quote builder UI
    • Phase 9 can reference quotes table for quote header management
    • Public quote routes can validate by token via getQuoteByToken
    • Auto-provisioning can copy offer_phases → project.phases via offer_phase_id audit trail
  6. Data Safety verified:

    • No production data deleted or truncated
    • Migration safe to run on staging/prod after connectivity restored
    • Rollback possible at any point (all additive)

</success_criteria>

After completion, create `.planning/phases/08-offer-phases-quote-builder/08-01-SUMMARY.md` with: - What was built (tables, migration, queries, types) - Task completion status (3/3) - Build verification (`npm run build` output snippet) - Migration readiness (script path, validation checklist) - Next phase dependencies (Phase 9 can start after this completes) - Deviations (if any) - Files committed to git