Files
clienthub/.planning/research/ARCHITECTURE.md
T
simone 6239eed6e6 docs(research): complete v2.0 stack, features, architecture, and pitfalls analysis
Synthesized research outputs from 4 parallel researcher agents:
- STACK.md: 7 new npm packages (dnd-kit, TanStack Table, RHF, BullMQ), React 19 compat verified
- FEATURES_v2.0.md: Feature landscape (table stakes, differentiators, anti-features), MVP breakdown
- ARCHITECTURE.md: Schema additions (services, offer_phases, leads, quotes), 5-phase build order
- PITFALLS.md: Critical/moderate/minor pitfalls + prevention strategies

Ready for roadmap planning and phase-by-phase execution.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-11 05:56:55 +02:00

877 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 711)
### Phase 7: Catalog Migration (Foundation)
**Duration:** 34 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:** 23 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:** 45 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:** 34 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:** 23 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 711):** 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 |