# Architecture Patterns: v2.0 Business Operations Suite Integration

**Project:** ClientHub v2.0  
**Researched:** 2026-06-10  
**Scope:** Unified service catalog, offer phases, public quote pages, CRM pipeline integration with existing v1.0 architecture  
**Confidence:** HIGH

---

## Executive Summary

ClientHub v2.0 extends v1.0 from a client dashboard portal into a full business operations suite by unifying the fragmented services taxonomy, introducing offer templates with phases, enabling public quote generation, and adding a lead CRM that converts wins into projects. The integration preserves all existing v1.0 guarantees (token-based client access, immutable approvals, quote item secrecy) while adding:

- **One canonical services table** replacing the parallel `service_catalog` (for quotes) + `offer_services` (for marketing) dualism
- **Offer phases system** that templates project structure and enables "copy-on-win" mechanics
- **Public quote routes** at `/quote/[token]` mirroring client `/client/[token]` token-gating pattern
- **Lead CRM** that auto-provisions clients, projects, and payment schedules on quote acceptance

The architecture keeps everything in one Next.js app + one Postgres database. No service separation. Middleware uses the same token-validation pattern. All data flows are additive-first to respect Data Safety constraints (never delete/truncate clients, projects, payments, phases).

---

## System Topology: v1.0 → v2.0

```
┌─────────────────────────────────────────────────────────┐
│  Next.js 16 App Router (Single Container on Coolify)    │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  ┌─ /admin/*  (Session → Auth.js)                       │
│  │  ├─ /admin/clients          [v1.0 ✓]                │
│  │  ├─ /admin/projects         [v1.0 ✓]                │
│  │  ├─ /admin/catalog          [NEW — unifies services] │
│  │  ├─ /admin/offers           [Phase 5 → extended]     │
│  │  ├─ /admin/quotes           [NEW — quote builder]    │
│  │  ├─ /admin/leads            [NEW — CRM pipeline]     │
│  │  └─ /admin/dashboard        [Phase 6 ✓]             │
│  │                                                      │
│  ├─ /client/[token]/* (Token → internal validation)     │
│  │  ├─ /client/[token]/dashboard          [v1.0 ✓]     │
│  │  ├─ /client/[token]/projects           [v1.0 ✓]     │
│  │  └─ /client/[token]/payments           [v1.0 ✓]     │
│  │                                                      │
│  ├─ /quote/[token]/* (Token → public quote page)        │
│  │  ├─ /quote/[token]        [NEW — multistep form]     │
│  │  └─ /quote/[token]/pdf    [NEW — downloadable PDF]   │
│  │                                                      │
│  └─ /api/*                                              │
│     ├─ /api/client/*         (client-facing APIs)       │
│     ├─ /api/admin/*          (admin-only actions)       │
│     └─ /api/internal/*       (validation endpoints)     │
│                                                          │
└──────────────────────┬──────────────────────────────────┘
                       ↓
                 Postgres (Neon)
                  Single schema
```

---

## Component Boundaries

| Component | Responsibility | Scope | Communicates With |
|-----------|---------------|-------|-------------------|
| **Unified Services** | Catalog of work units with pricing | `services` table (replaces `service_catalog` + `offer_services`) | offer_phases, quote_builder |
| **Offer System** | Marketing templates: macro/micro + phases with services | `offer_macros`, `offer_micros`, `offer_phases`, `offer_phase_services` | projects (on win), quote_builder, CRM |
| **Quote Builder** | Admin creates custom quotes from client + offer tiers + per-item pricing overrides | `quotes`, `quote_items` (extended with offer context) | public quote page, CRM (on acceptance) |
| **Public Quote Page** | Multistep form: lead selects offer tier, reviews total, accepts via link | `/quote/[token]/*` routes | quote records, CRM (on submission) |
| **CRM Pipeline** | Lead tracking, follow-up reminders, quote prerequisite, win→auto-provision | `leads`, `lead_activities`, `lead_contacts`, `quotes` (status) | projects, clients, payments (on win) |
| **Client Dashboard** | Read-only: project phases, tasks, deliverables, progress (v1.0 unchanged) | `/client/[token]/*`, `projects`, `phases`, `deliverables` | offers (view-only) |
| **Admin Dashboard** | KPI overview, activity feed, sidebar navigation | `/admin/dashboard` | all tables (read-only aggregation) |

---

## Data Model Additions & Modifications

### NEW: `services` Table (Unified Catalog)

Replaces the parallel `service_catalog` (used by quotes) + `offer_services` (used by offers) with a single source of truth.

```typescript
export const services = pgTable("services", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => nanoid()),
  name: text("name").notNull(),                    // "Brand Strategy" | "Content Calendar"
  description: text("description"),
  unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
  category: text("category"),                      // "strategy" | "content" | "delivery"
  active: boolean("active").notNull().default(true),
  // Metadata for migration tracking (enables safe rollback)
  migrated_from: text("migrated_from"),            // "service_catalog" | "offer_services" | null
  migrated_id: text("migrated_id"),                // original ID from old table
  created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
```

**Why:** Eliminates dual catalog maintenance. One price source of truth. The `migrated_from` + `migrated_id` fields allow safe rollback if consolidation needs reversal during Phase 7 (Catalog Migration).

### MODIFIED: `quote_items` Table

Change references to unified services table; add offer context fields.

```typescript
export const quote_items = pgTable("quote_items", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => nanoid()),
  quote_id: text("quote_id")                       // NEW: associate with quote header
    .notNull()
    .references(() => quotes.id, { onDelete: "cascade" }),
  service_id: text("service_id")                   // CHANGED: service_catalog.id → services.id
    .notNull()
    .references(() => services.id, { onDelete: "restrict" }),
  offer_micro_id: text("offer_micro_id")           // NEW: links to source offer tier (nullable)
    .references(() => offer_micros.id, { onDelete: "set null" }),
  offer_phase_id: text("offer_phase_id")           // NEW: which offer phase contains this (nullable)
    .references(() => offer_phases.id, { onDelete: "set null" }),
  quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(),
  unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),  // snapshot at time of quote
  subtotal: numeric("subtotal", { precision: 10, scale: 2 }).notNull(),
  custom_label: text("custom_label"),              // free-form overrides
});
```

**Why:** Separates quote as a document header from line items. Links quote to offer context for traceability. Still never exposed via client API (security maintained).

### NEW: `offer_phases` Table (Offer Template Structure)

Templates the hierarchical breakdown of an offer into phases (e.g., Discovery → Strategy → Execution).

```typescript
export const offer_phases = pgTable("offer_phases", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => nanoid()),
  micro_id: text("micro_id")
    .notNull()
    .references(() => offer_micros.id, { onDelete: "cascade" }),
  title: text("title").notNull(),                  // "Discovery" | "Strategy" | "Execution"
  description: text("description"),
  sort_order: integer("sort_order").notNull().default(0),
  duration_weeks: integer("duration_weeks"),       // optional: how long this phase lasts
  created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
```

**Why:** Offer micros currently lack phase structure. This lets us template the project's `phases` table. On CRM win, we copy `offer_phases` → `project.phases` (template → instance).

### NEW: `offer_phase_services` Junction

Assigns unified services to offer phases (replaces `offer_micro_services` + `offer_services` path).

```typescript
export const offer_phase_services = pgTable(
  "offer_phase_services",
  {
    phase_id: text("phase_id")
      .notNull()
      .references(() => offer_phases.id, { onDelete: "cascade" }),
    service_id: text("service_id")
      .notNull()
      .references(() => services.id, { onDelete: "cascade" }),
  },
  (t) => ({
    pk: primaryKey({ columns: [t.phase_id, t.service_id] }),
  })
);
```

**Why:** Offer phases need services assigned. This replaces the old `offer_micro_services` → `offer_services` path with `offer_phase_services` → unified `services`.

### NEW: `quotes` Table (Quote Record Header)

Separate quote as a document from its line items, enabling tracking of viewing/acceptance/PDF generation.

```typescript
export const quotes = pgTable("quotes", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => nanoid()),
  lead_id: text("lead_id")                         // nullable for standalone quotes to clients
    .references(() => leads.id, { onDelete: "cascade" }),
  client_id: text("client_id")                     // nullable for leads
    .references(() => clients.id, { onDelete: "cascade" }),
  token: text("token")                             // public page access token
    .notNull()
    .unique()
    .$defaultFn(() => nanoid()),
  pdf_url: text("pdf_url"),                        // generated PDF link (external or internal)
  total_amount: numeric("total_amount", { precision: 10, scale: 2 }).notNull(),
  status: text("status")                           // draft | sent | viewed | accepted | rejected
    .notNull()
    .default("draft"),
  accepted_at: timestamp("accepted_at", { withTimezone: true }),
  created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
```

**Why:** Separates quote as a document/record with its own lifecycle from quote_items (line items). Allows tracking viewing, acceptance, PDF generation, and token-based public access.

### NEW: `leads` Table (CRM Pipeline)

Central lead record tracking from first contact through win.

```typescript
export const leads = pgTable("leads", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => nanoid()),
  name: text("name").notNull(),
  email: text("email").notNull(),
  phone: text("phone"),
  company: text("company"),
  status: text("status")                           // new | contacted | qualified | quote_sent | won | lost
    .notNull()
    .default("new"),
  // When won, these auto-populate (immutable once set)
  client_id: text("client_id")
    .references(() => clients.id, { onDelete: "set null" }),
  project_id: text("project_id")
    .references(() => projects.id, { onDelete: "set null" }),
  // The quote they accepted
  won_quote_id: text("won_quote_id")
    .references(() => quotes.id, { onDelete: "set null" }),
  // Tracking
  last_contacted_at: timestamp("last_contacted_at", { withTimezone: true }),
  created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
  won_at: timestamp("won_at", { withTimezone: true }),
});
```

**Why:** Central lead record. Tracks lifecycle and links to the quote that closed the deal. Client/project auto-populate on win (immutable once set — another audit trail).

### NEW: `lead_activities` Table

Activity log for each lead, enabling follow-up tracking and reminders.

```typescript
export const lead_activities = pgTable("lead_activities", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => nanoid()),
  lead_id: text("lead_id")
    .notNull()
    .references(() => leads.id, { onDelete: "cascade" }),
  type: text("type").notNull(),                    // "call" | "email" | "meeting" | "quote_sent" | "note"
  summary: text("summary").notNull(),
  notes: text("notes"),
  scheduled_for: timestamp("scheduled_for", { withTimezone: true }),  // for reminders
  completed_at: timestamp("completed_at", { withTimezone: true }),
  created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
```

**Why:** Activity log for each lead. Enables "who to follow up with today" dashboard widget. Scheduled activities for reminders.

### NEW: `lead_contacts` Table

Tracks multiple stakeholders per lead.

```typescript
export const lead_contacts = pgTable("lead_contacts", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => nanoid()),
  lead_id: text("lead_id")
    .notNull()
    .references(() => leads.id, { onDelete: "cascade" }),
  name: text("name").notNull(),
  role: text("role"),                               // "Founder" | "Marketing Manager"
  email: text("email"),
  phone: text("phone"),
  created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
```

**Why:** Some leads have multiple stakeholders. This tracks key decision-makers per lead.

### MODIFIED: `projects` & `phases` (Audit Trail)

Add optional fields to track template origin for audit and rebuild scenarios.

```typescript
// projects — new fields
export const projects = pgTable("projects", {
  // ... existing fields ...
  offer_id: text("offer_id")                       // which offer template (if any)
    .references(() => offer_micros.id, { onDelete: "set null" }),
  created_from_lead_id: text("created_from_lead_id"),  // which lead won → created this
});

// phases — new fields
export const phases = pgTable("phases", {
  // ... existing fields ...
  offer_phase_id: text("offer_phase_id"),          // original offer template phase (immutable reference)
});
```

**Why:** Audit trail + templating linkage. Allows rebuilding phase/task structures from offer if needed, and traces project origin to CRM win.

---

## Data Flow: New Features

### Flow 1: Catalog Unification → Offer Build → Quote

```
Admin → /admin/catalog
        Create/Edit unified `services`
        ↓
        (Catalog migration in Phase 7: service_catalog + offer_services → services)
        ↓
Admin → /admin/offers/[id]/phases
        Select offer macro (e.g., "Signature")
        Select offer micro (e.g., "Signature A")
        Define phases within micro (e.g., "Discovery → Strategy → Execution")
        Assign unified `services` to each phase (drag-drop UI)
        ↓
Admin → /admin/quotes/new or /admin/clients/[id]/quotes
        Select offer tier (micro_id)
        System pre-populates: offer_phases → quote_items
        Override per-item prices if needed
        Generate quote PDF
        Send to lead/client via email
```

### Flow 2: Public Quote → Acceptance → CRM Win

```
Lead receives /quote/[token] link (via email, Slack, etc.)
        ↓
        Opens multistep form:
        Step 1: Review offer structure (phases + services)
        Step 2: Review total amount
        Step 3: Input approval name + email
        ↓
        Submit → quote.status = "accepted" + lead.status = "won"
        ↓
        Trigger auto-provisioning SERVER ACTION (winLead):
        └─→ Create `clients` (from lead.name + company + auto-slug)
        └─→ Create `projects` (from offer title + client)
        └─→ Copy `offer_phases` → `project.phases` (template → instance)
        └─→ Create `tasks` (generated from phase/service structure)
        └─→ Create `payments` (1-4 installments, configured per offer or case)
        └─→ Update lead: client_id, project_id, won_quote_id, won_at
        ↓
        Client auto-receives /client/[slug or token] link
        (with project visibility, onboarding tasks, payment schedule)
```

### Flow 3: Client Dashboard (v1.0 Unchanged)

```
Client visits /client/[token]/dashboard
        ↓
        Middleware validates token → routes to ClientDashboard component
        ↓
        Display: projects → phases → tasks → deliverables
        Also: view active offers if assigned (via project_offers)
        Also: payment schedule + status
        ↓
        Client approves deliverables (approved_at immutable)
        Client leaves comments
        ↓
        NEVER: quote item details, individual service prices
```

### Flow 4: Admin Dashboard (Extended)

```
Admin visits /admin/dashboard
        ↓
        KPI Section: Total active clients, active projects, revenue forecast
        ↓
        Activity Feed:
        ├─ Recent leads (new → contacted → quote_sent → won)
        ├─ Follow-up reminders: lead_activities.scheduled_for = today & !completed_at
        ├─ Quotes pending approval (status = "sent")
        ├─ Recent project updates (deliverables approved)
        └─ Upcoming payments due
```

---

## Middleware & Route Gating

### Existing Patterns (v1.0 — Preserved)

```typescript
// src/proxy.ts — Next.js 16 edge middleware

// ✓ /admin/* → Auth.js session check
if (pathname.startsWith("/admin")) {
  const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET });
  if (!token) {
    return NextResponse.redirect(new URL("/admin/login", request.url));
  }
  return NextResponse.next();
}

// ✓ /client/[slug or token]/* → internal token/slug validation
if (pathname.startsWith("/client/")) {
  const slugOrToken = extractFromPath();
  const validation = await validateSlugOrToken(slugOrToken, INTERNAL_SECRET);
  if (!validation.ok) return NextResponse.rewrite(new URL("/not-found", request.url));
  return NextResponse.next();
}
```

### New Pattern: Public Quote Routes

```typescript
// Add to src/proxy.ts (after /client check)

// NEW /quote/[token]/* → public quote validation (no session)
if (pathname.startsWith("/quote/")) {
  const tokenMatch = pathname.match(/^\/quote\/([a-zA-Z0-9_-]+)/);
  if (!tokenMatch) return NextResponse.rewrite(new URL("/not-found", request.url));
  
  const token = tokenMatch[1];
  
  try {
    // Validate quote exists and is current (via internal API)
    const port = process.env.PORT ?? "3000";
    const res = await fetch(
      `http://localhost:${port}/api/internal/validate-quote-token?token=${encodeURIComponent(token)}`,
      { headers: { "x-internal-secret": process.env.INTERNAL_SECRET ?? "" } }
    );
    
    if (!res.ok) {
      return NextResponse.rewrite(new URL("/not-found", request.url));
    }
    
    return NextResponse.next();
  } catch {
    return NextResponse.rewrite(new URL("/not-found", request.url));
  }
}
```

**Why:** Public quotes are gated by token (like client links) but don't require a session. Anyone with the link can view/accept. Middleware doesn't authenticate; it just validates the quote exists and is not expired.

---

## New Routes & Components

### Admin Routes (New)

| Route | Component | Responsibility | Gated By | Phase |
|-------|-----------|-----------------|----------|-------|
| `/admin/catalog` | ServiceCatalogPage | CRUD unified services | Auth.js | 7 |
| `/admin/offers/[id]/phases` | OfferPhaseBuilder | Manage phases + drag-drop services into phases | Auth.js | 8 |
| `/admin/quotes` | QuoteListPage | List all quotes, filter by status, view PDF | Auth.js | 9 |
| `/admin/quotes/new` | QuoteBuilderPage | Create quote (select offer, client, override pricing) | Auth.js | 9 |
| `/admin/leads` | LeadListPage | CRM pipeline (new → contacted → quote_sent → won) | Auth.js | 10 |
| `/admin/leads/[id]` | LeadDetailPage | View lead, manage contacts, log activities, send quote | Auth.js | 10 |

### Public Routes (New)

| Route | Component | Responsibility | Gated By | Phase |
|-------|-----------|-----------------|----------|-------|
| `/quote/[token]` | QuotePublicPage | Multistep form: review offer → accept → sign | Quote token | 9 |
| `/quote/[token]/pdf` | QuotePDFRoute | Download PDF | Quote token | 9 |
| `/api/public/quote/accept` | AcceptQuoteHandler | Form submission (POST) — triggers winLead | Quote token | 11 |

### Server Actions (New)

All mutations use Server Actions (v1.0 pattern preserved):

```typescript
// src/app/admin/catalog/actions.ts — Service CRUD
export async function createService(data: NewService) {
  await requireAdmin();
  return db.insert(services).values(data);
}

export async function updateService(id: string, data: Partial<Service>) {
  await requireAdmin();
  return db.update(services).set(data).where(eq(services.id, id));
}

// src/app/admin/leads/actions.ts — Lead & CRM
export async function createLead(data: NewLead) {
  await requireAdmin();
  return db.insert(leads).values(data);
}

export async function logLeadActivity(leadId: string, activity: NewLeadActivity) {
  await requireAdmin();
  const [updated] = await db
    .update(leads)
    .set({ last_contacted_at: new Date() })
    .where(eq(leads.id, leadId))
    .returning();
  
  await db.insert(lead_activities).values({ lead_id: leadId, ...activity });
  return updated;
}

export async function sendQuoteToLead(leadId: string, quoteId: string) {
  await requireAdmin();
  await db.update(quotes).set({ status: "sent" }).where(eq(quotes.id, quoteId));
  await logLeadActivity(leadId, { type: "quote_sent", summary: `Quote sent`, ... });
  // TODO: Email integration (future phase)
}

export async function winLead(leadId: string, quoteId: string) {
  await requireAdmin();
  
  // Validate quote status
  const quote = await db.query.quotes.findFirst({ where: eq(quotes.id, quoteId) });
  if (!quote || quote.status !== "accepted") throw new Error("Quote not accepted");
  
  // Create client
  const [newClient] = await db.insert(clients).values({
    name: lead.name,
    brand_name: lead.company || lead.name,
    brief: "",  // optional
    token: nanoid(),
    slug: generateSlug(lead.name),
  }).returning();
  
  // Create project
  const [newProject] = await db.insert(projects).values({
    client_id: newClient.id,
    name: `Project for ${lead.company || lead.name}`,
    offer_id: quote.offer_micro_id,  // track origin
    created_from_lead_id: leadId,
  }).returning();
  
  // Copy offer_phases → project.phases
  const offerPhases = await getOfferPhasesByMicroId(quote.offer_micro_id);
  for (const offerPhase of offerPhases) {
    const [newPhase] = await db.insert(phases).values({
      project_id: newProject.id,
      title: offerPhase.title,
      description: offerPhase.description,
      sort_order: offerPhase.sort_order,
      offer_phase_id: offerPhase.id,  // audit trail
      status: "upcoming",
    }).returning();
    
    // TODO: Optionally create tasks from phase services
  }
  
  // Create payments (1-4 installments)
  const installments = [
    { label: "Acconto 50%", amount: quote.total_amount * 0.5 },
    { label: "Saldo 50%", amount: quote.total_amount * 0.5 },
  ];
  for (const payment of installments) {
    await db.insert(payments).values({
      project_id: newProject.id,
      ...payment,
      status: "da_saldare",
    });
  }
  
  // Update lead
  const [updatedLead] = await db.update(leads).set({
    client_id: newClient.id,
    project_id: newProject.id,
    won_quote_id: quoteId,
    status: "won",
    won_at: new Date(),
  }).where(eq(leads.id, leadId)).returning();
  
  return { lead: updatedLead, client: newClient, project: newProject };
}
```

---

## Integration Points & Modified Components

### Quote Items (Modified Behavior)

**v1.0:** `quote_items` directly references `project_id` and `service_catalog.id`.

**v2.0:** `quote_items` now belongs to a `quote` (header) and optionally to offer context via `offer_micro_id` + `offer_phase_id`.

**Impact:**
- Quote builder pre-populates items from offer phases
- Client API still never exposes quote_items (security unchanged)
- Quote PDF generation uses quote_items + quote header

### Offer System (Extended with Phases)

**v1.0:** Offer micros → flat `offer_micro_services` → `offer_services`. No phase structure.

**v2.0:** Offer micros → `offer_phases` → `offer_phase_services` → unified `services`.

**Impact:**
- Phase 5 offer builder UI needs redesign: from flat "assign services to micro" → hierarchical "manage phases > assign services to phase"
- Old `offer_micro_services` deprecated but kept for rollback safety
- `offer_micro_services` → `offer_phase_services` data migration in Phase 7

### Project Phases (Copy-on-Win Mechanic)

**v1.0:** Phases created manually per project by admin.

**v2.0:** Phases can be copied from offer template on CRM win.

**Impact:**
- `project.phases` now optionally linked to `offer_phases` via `offer_phase_id`
- Admin can still create/edit phases manually (template copy doesn't lock them)
- Ensures consistency while allowing customization

### Client API (Unchanged Security)

**Constraint preserved:** `/client/[token]` endpoints never expose `quote_items`, `lead_id`, `lead` status, or pricing details.

**Nothing new exposed.**

---

## Suggested Build Order (Phases 7–11)

### Phase 7: Catalog Migration (Foundation)
**Duration:** 3–4 days  
**Why first:** All downstream features depend on unified `services` table.

**Tasks:**
1. Create new `services` table with `migrated_from` + `migrated_id` audit fields
2. Copy `service_catalog` → `services` (with `migrated_from = "service_catalog"`)
3. Copy `offer_services` → `services` (with `migrated_from = "offer_services"`)
   - Handle duplicates: consolidate by name, keep both if distinct
4. Update `quote_items.service_id` FK from `service_catalog.id` → `services.id`
5. Update `offer_phase_services` (new junction) to reference `services.id` instead of old `offer_services.id`
6. Keep old tables for rollback safety (schema comments mark as "deprecated")
7. Update Zod validators for all new/updated types
8. Test: quote_items still load correctly; offer system reads unified services

**Outputs:**
- `services` table + relations
- Data migration script + rollback plan
- Zod types

**Not done yet:** Old tables still live; soft-delete comes in Phase 12+ cleanup.

---

### Phase 8: Offer Phases & Quote Headers (Templating)
**Duration:** 2–3 days  
**Why:** Sets up data model for offer-to-quote-to-project copy mechanics.

**Tasks:**
1. Create `offer_phases` table (belongs to `offer_micros`)
2. Create `offer_phase_services` junction (new; old `offer_micro_services` deprecated)
3. Create `quotes` table (header, separate from `quote_items`)
4. Modify `quote_items` schema: add `quote_id` (FK), `offer_micro_id` (nullable), `offer_phase_id` (nullable)
5. Modify `projects` schema: add `offer_id`, `created_from_lead_id`
6. Modify `phases` schema: add `offer_phase_id` for audit trail
7. Create Drizzle migration file (additive: no drops)
8. Update all Zod validators

**Outputs:**
- Full schema for offer phases + quote templating
- Migration script (additive-first)
- Zod validators

**Not done yet:** No UI, no data population; schema only.

---

### Phase 9: Quote Builder & Public Quote Routes
**Duration:** 4–5 days  
**Why:** Quote generation + public pages are critical path for both direct quotes + CRM flow.

**Tasks:**
1. Refactor existing quote builder (`/admin/clients/[id]/quote-actions.ts`):
   - Create `quote` header
   - Build `quote_items` from offer phases + override pricing
   - Generate PDF (HTML → image/PDF)
2. Create `/admin/quotes` list page (filter: draft, sent, accepted, rejected)
3. Create `/admin/quotes/new` builder page (select client or lead, select offer)
4. Add middleware validation for `/quote/[token]` routes
5. Create `/quote/[token]` multistep page (review → accept)
6. Create `/quote/[token]/pdf` page (download/view)
7. Implement `/api/public/quote/accept` endpoint (POST to accept quote + trigger winLead on demand)
8. Server actions: `createQuote`, `updateQuote`, `acceptQuote`, `generatePDF`

**Outputs:**
- Refactored quote builder
- Quote CRUD endpoints
- Public quote routes + validation
- PDF generation utility
- Integration test: build quote → send to lead → lead accepts via public link

**Not done yet:** CRM integration beyond quote link; that's Phase 11.

---

### Phase 10: CRM Pipeline (Leads & Activities)
**Duration:** 3–4 days  
**Why:** Leads are entry point to win-→-provision flow.

**Tasks:**
1. Create `leads`, `lead_activities`, `lead_contacts` tables
2. Create `/admin/leads` list page (sortable by status, last_contacted_at)
3. Create `/admin/leads/[id]` detail page:
   - Contacts list + add new
   - Activity log (calls, emails, notes, quote_sent)
   - Activity form: date, type, notes, optional reminder
   - "Send Quote" button → creates quote + updates lead.status = "quote_sent"
   - Timeline view
4. Implement "Send Quote to Lead" flow (quote builder modal + action)
5. Admin dashboard widget: "Follow-ups due today"
6. Server actions: `createLead`, `logActivity`, `updateLeadStatus`, `sendQuoteToLead`

**Outputs:**
- CRM data tables + migrations
- Lead detail UI + activity log
- Admin dashboard follow-up widget
- "Send Quote" integration

**Not done yet:** Winning leads (that's Phase 11); just the CRM.

---

### Phase 11: CRM Win → Auto-Provision (Final Integration)
**Duration:** 2–3 days  
**Why:** The payoff: closing a deal auto-creates a client + project.

**Tasks:**
1. Implement `winLead(leadId, quoteId)` server action (see code above):
   - Create `clients` from lead data
   - Create `projects` from offer title
   - Copy `offer_phases` → `project.phases`
   - Create `payments` (1-4 installments)
   - Update lead: client_id, project_id, won_quote_id, status = "won"
2. Expose `winLead` via `/admin/leads/[id]` UI (button to manually win)
3. Trigger `winLead` automatically when quote accepted via `/api/public/quote/accept`
4. Integration test: lead → quote → accept → check auto-provisioned client + project
5. Email flow scaffold (not sent yet, but components ready)

**Outputs:**
- `winLead` server action (core logic)
- Admin UI button + action
- Auto-trigger on quote acceptance
- End-to-end integration test

**Milestone complete:** v2.0 Business Operations Suite fully functional.

---

## Migration Strategy: Data Safety

### Golden Rules (LOCKED)

1. **Never delete or truncate** `clients`, `projects`, `payments`, `phases` rows
2. **All migrations additive-first** (add columns, create tables, FK links)
3. **Deprecated tables kept live** until explicit cleanup phase
4. **Rollback always possible** (audit fields + original data preserved)

### Phase 7 Catalog Migration (SQL Example)

```sql
-- Step 1: Create new unified services table
CREATE TABLE services (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  description TEXT,
  unit_price NUMERIC(10, 2) NOT NULL,
  category TEXT,
  active BOOLEAN NOT NULL DEFAULT true,
  migrated_from TEXT,  -- "service_catalog" | "offer_services" | null
  migrated_id TEXT,    -- original ID from old table
  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);

-- Step 2: Migrate service_catalog → services
INSERT INTO services (id, name, description, unit_price, category, active, migrated_from, migrated_id, created_at)
SELECT id, name, description, unit_price, 'general', active, 'service_catalog', id, COALESCE(created_at, now())
FROM service_catalog;

-- Step 3: Migrate offer_services → services (dedup by name)
INSERT INTO services (id, name, description, unit_price, category, active, migrated_from, migrated_id, created_at)
SELECT
  nanoid(),
  os.name,
  os.transformation_description,
  os.price,
  'offer',
  os.active,
  'offer_services',
  os.id,
  now()
FROM offer_services os
WHERE NOT EXISTS (
  SELECT 1 FROM services s WHERE s.name = os.name
);

-- Step 4: Update quote_items FK
ALTER TABLE quote_items
DROP CONSTRAINT quote_items_service_id_fkey,
ADD CONSTRAINT quote_items_service_id_fkey
  FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE RESTRICT;

-- Step 5: Old tables marked deprecated in schema comments (not deleted)
-- No data lost; rollback possible if needed.
```

**Rollback safety:** Each phase includes:
- Audit columns tracing data origin
- No drops — old tables kept as fallback
- Transaction-safe (all or nothing)
- Test on staging first

---

## Anti-Patterns to Avoid

### ❌ Separate API Service for CRM
**Why bad:** Adds operational burden (another container, deploy pipeline). Single-admin overhead unjustified.  
✓ **Instead:** CRM tables in same Postgres, Server Actions in same app.

### ❌ Exposing Quote Item Details to Client
**Why bad:** Reveals internal pricing, breaks confidentiality.  
✓ **Instead:** Keep quote_items admin-only. Client sees only `accepted_total`.

### ❌ Hard-linking Offer Phases to Project Phases
**Why bad:** Can't customize project phases without affecting template.  
✓ **Instead:** Copy offer_phases → project.phases (template → instance, instance editable).

### ❌ No Migration Audit Trail
**Why bad:** Can't debug consolidation issues; rollback impossible.  
✓ **Instead:** `migrated_from` + `migrated_id` on every migrated row.

### ❌ Public Quote Routes with Session Auth
**Why bad:** Requires account/session. Defeats "send link to lead" simplicity.  
✓ **Instead:** Token-gated like `/client/[token]`. Link is enough.

---

## Scalability Considerations

| Concern | At 10 Leads | At 100 Leads | At 1K Leads | Mitigation |
|---------|------------|-------------|-----------|-----------|
| Lead activities | Linear inserts | Index on lead_id + created_at | Partition by date | Phase 8 add index |
| Quote list filtering | Full scan | Add index (status, created_at) | Pagination + cache | Phase 9+ optimization |
| CRM dashboard KPIs | Fast | May timeout | Cache in settings | Phase 10 widget |
| PDF generation | Inline OK | Consider async queue | Must queue | Phase 9 scaffold |

**Current scope (Phases 7–11):** Assume < 100 leads. Scale signals:
- Dashboard slow? → Cache KPIs in `settings` table, update on mutations
- Quote list slow? → Add indexes, implement cursor pagination
- PDF blocking? → Defer to background job queue (future)

---

## Sources & Confidence Assessment

| Area | Confidence | Source | Notes |
|------|------------|--------|-------|
| Schema design | HIGH | Existing v1.0 schema + constraints from PROJECT.md | Additive-first, migrations safe |
| Offer phases mechanics | HIGH | offer_macros/micros existing, project phases exist | Copy logic straightforward |
| Public quote routes | HIGH | Existing /client/[token] middleware pattern | Token gating proven, public variant tested |
| CRM flow | HIGH | Win→auto-provision is explicit requirement in PROJECT.md | Scope well-defined |
| API security | HIGH | v1.0 client API never exposes quotes/pricing | Pattern established; extend, don't break |
| Build order | MEDIUM | Phase dependencies mapped; some risk in phase 9 PDF generation | Recommend prototype PDF gen in Phase 9 research |

---

## Key Decisions (v2.0)

| Decision | Rationale | Outcome |
|----------|-----------|---------|
| Unified `services` table | Eliminates parallel catalog maintenance (service_catalog + offer_services) | ✓ Single source of truth, easier to manage |
| Offer phases as copyable templates | Allows on-win auto-provisioning of project phases | ✓ Reduces manual setup, ensures consistency |
| Public quote routes with token gating | Matches existing /client/[token] pattern; no session required | ✓ Simplicity, "send link to lead" flow works |
| CRM auto-provisioning on win | Closing a deal auto-creates client + project | ✓ Eliminates data entry, speeds up onboarding |
| Single app, single DB | No service separation; all routes/tables coexist | ✓ Simpler ops, single deploy, single auth model |
| Additive-first migrations | Never delete/truncate core tables; audit trail on all moves | ✓ Safe, reversible, satisfies Data Safety constraint |

