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

36 KiB
Raw Blame History

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.

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.

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

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_phasesproject.phases (template → instance).

NEW: offer_phase_services Junction

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

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

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.

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.

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.

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.

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

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

// 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):

// 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_servicesoffer_services. No phase structure.

v2.0: Offer micros → offer_phasesoffer_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_servicesoffer_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_catalogservices (with migrated_from = "service_catalog")
  3. Copy offer_servicesservices (with migrated_from = "offer_services")
    • Handle duplicates: consolidate by name, keep both if distinct
  4. Update quote_items.service_id FK from service_catalog.idservices.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_phasesproject.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)

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