# 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) { 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 |