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>
36 KiB
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_phases → project.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_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.
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_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_servicesdeprecated but kept for rollback safety offer_micro_services→offer_phase_servicesdata 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.phasesnow optionally linked tooffer_phasesviaoffer_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:
- Create new
servicestable withmigrated_from+migrated_idaudit fields - Copy
service_catalog→services(withmigrated_from = "service_catalog") - Copy
offer_services→services(withmigrated_from = "offer_services")- Handle duplicates: consolidate by name, keep both if distinct
- Update
quote_items.service_idFK fromservice_catalog.id→services.id - Update
offer_phase_services(new junction) to referenceservices.idinstead of oldoffer_services.id - Keep old tables for rollback safety (schema comments mark as "deprecated")
- Update Zod validators for all new/updated types
- Test: quote_items still load correctly; offer system reads unified services
Outputs:
servicestable + 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:
- Create
offer_phasestable (belongs tooffer_micros) - Create
offer_phase_servicesjunction (new; oldoffer_micro_servicesdeprecated) - Create
quotestable (header, separate fromquote_items) - Modify
quote_itemsschema: addquote_id(FK),offer_micro_id(nullable),offer_phase_id(nullable) - Modify
projectsschema: addoffer_id,created_from_lead_id - Modify
phasesschema: addoffer_phase_idfor audit trail - Create Drizzle migration file (additive: no drops)
- 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:
- Refactor existing quote builder (
/admin/clients/[id]/quote-actions.ts):- Create
quoteheader - Build
quote_itemsfrom offer phases + override pricing - Generate PDF (HTML → image/PDF)
- Create
- Create
/admin/quoteslist page (filter: draft, sent, accepted, rejected) - Create
/admin/quotes/newbuilder page (select client or lead, select offer) - Add middleware validation for
/quote/[token]routes - Create
/quote/[token]multistep page (review → accept) - Create
/quote/[token]/pdfpage (download/view) - Implement
/api/public/quote/acceptendpoint (POST to accept quote + trigger winLead on demand) - 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:
- Create
leads,lead_activities,lead_contactstables - Create
/admin/leadslist page (sortable by status, last_contacted_at) - 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
- Implement "Send Quote to Lead" flow (quote builder modal + action)
- Admin dashboard widget: "Follow-ups due today"
- 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:
- Implement
winLead(leadId, quoteId)server action (see code above):- Create
clientsfrom lead data - Create
projectsfrom offer title - Copy
offer_phases→project.phases - Create
payments(1-4 installments) - Update lead: client_id, project_id, won_quote_id, status = "won"
- Create
- Expose
winLeadvia/admin/leads/[id]UI (button to manually win) - Trigger
winLeadautomatically when quote accepted via/api/public/quote/accept - Integration test: lead → quote → accept → check auto-provisioned client + project
- Email flow scaffold (not sent yet, but components ready)
Outputs:
winLeadserver 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)
- Never delete or truncate
clients,projects,payments,phasesrows - All migrations additive-first (add columns, create tables, FK links)
- Deprecated tables kept live until explicit cleanup phase
- 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 7–11): Assume < 100 leads. Scale signals:
- Dashboard slow? → Cache KPIs in
settingstable, 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 |