---
phase: 08-offer-phases-quote-builder
plan: 01
type: execute
wave: 1
depends_on: ["07-01", "07-02"]
files_modified:
  - src/db/schema.ts
  - src/db/migrations/0003_offer_phases_quote_templates.sql
  - src/lib/admin-queries.ts
  - src/types/offer.ts
autonomous: true
requirements:
  - OFFER-B-01
  - OFFER-B-02
  - OFFER-B-03
must_haves:
  truths:
    - "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"
  artifacts:
    - path: src/db/schema.ts
      provides: "offer_phases, offer_phase_services, quotes table definitions + types"
      contains: "export const offer_phases, offer_phase_services, quotes"
    - path: src/db/migrations/0003_offer_phases_quote_templates.sql
      provides: "SQL migration adding all new tables with zero data loss"
      creates: "offer_phases, offer_phase_services, quotes + FK updates"
    - path: src/lib/admin-queries.ts
      provides: "getOfferPhases, getOfferPhaseServices queries for builder UI"
      exports: ["getOfferPhases(microId)", "getOfferPhaseServices(phaseId)"]
    - path: src/types/offer.ts
      provides: "TypeScript types for offer phases, quote records"
      exports: ["OfferPhase", "OfferPhaseService", "Quote", "NewQuote"]
  key_links:
    - from: "offer_phases"
      to: "offer_micros"
      via: "foreign key micro_id"
      pattern: "micro_id.*references.*offer_micros"
    - from: "offer_phase_services"
      to: "services"
      via: "foreign key service_id"
      pattern: "service_id.*references.*services"
    - from: "quotes"
      to: "offer_micros"
      via: "offer_micro_id field (nullable, tracks which tier was quoted)"
      pattern: "offer_micro_id.*references.*offer_micros"
---

<objective>
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.
</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/research/ARCHITECTURE.md
@.planning/research/FEATURES_v2.0.md
@.planning/phases/07-unified-service-catalog/07-02-SUMMARY.md
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add offer_phases, offer_phase_services, and quotes tables to schema</name>
  <files>src/db/schema.ts, src/types/offer.ts</files>
  <action>
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.
  </action>
  <verify>
    <automated>npm run build 2>&1 | grep -E "(error|ERR)" || echo "Build passed"</automated>
  </verify>
  <done>
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.
  </done>
</task>

<task type="auto">
  <name>Task 2: Create Drizzle migration file (SQL + idempotent validation)</name>
  <files>src/db/migrations/0003_offer_phases_quote_templates.sql, scripts/validate-phase8-migration.ts</files>
  <action>
Create SQL migration file `src/db/migrations/0003_offer_phases_quote_templates.sql` following Phase 7 pattern (additive-first, zero data loss, rollback-safe):

```sql
-- 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).
  </action>
  <verify>
    <automated>ls -lh src/db/migrations/0003_offer_phases_quote_templates.sql && wc -l src/db/migrations/0003_offer_phases_quote_templates.sql</automated>
  </verify>
  <done>
Migration file created (additive-only, ~100 lines). Validation script written. Both checked into src/db/migrations/ and scripts/. Ready for Postgres connectivity.
  </done>
</task>

<task type="auto">
  <name>Task 3: Add query layer for offer phases + update quote_items relations</name>
  <files>src/lib/admin-queries.ts, src/db/schema.ts (relations update)</files>
  <action>
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.
  </action>
  <verify>
    <automated>grep -c "getOfferPhases\|getOfferPhaseServices\|getQuoteById\|getQuoteByToken" src/lib/admin-queries.ts</automated>
  </verify>
  <done>
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.
  </done>
</task>

</tasks>

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

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

</verification>

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

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