6239eed6e6
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>
877 lines
36 KiB
Markdown
877 lines
36 KiB
Markdown
# Architecture Patterns: v2.0 Business Operations Suite Integration
|
||
|
||
**Project:** ClientHub v2.0
|
||
**Researched:** 2026-06-10
|
||
**Scope:** Unified service catalog, offer phases, public quote pages, CRM pipeline integration with existing v1.0 architecture
|
||
**Confidence:** HIGH
|
||
|
||
---
|
||
|
||
## Executive Summary
|
||
|
||
ClientHub v2.0 extends v1.0 from a client dashboard portal into a full business operations suite by unifying the fragmented services taxonomy, introducing offer templates with phases, enabling public quote generation, and adding a lead CRM that converts wins into projects. The integration preserves all existing v1.0 guarantees (token-based client access, immutable approvals, quote item secrecy) while adding:
|
||
|
||
- **One canonical services table** replacing the parallel `service_catalog` (for quotes) + `offer_services` (for marketing) dualism
|
||
- **Offer phases system** that templates project structure and enables "copy-on-win" mechanics
|
||
- **Public quote routes** at `/quote/[token]` mirroring client `/client/[token]` token-gating pattern
|
||
- **Lead CRM** that auto-provisions clients, projects, and payment schedules on quote acceptance
|
||
|
||
The architecture keeps everything in one Next.js app + one Postgres database. No service separation. Middleware uses the same token-validation pattern. All data flows are additive-first to respect Data Safety constraints (never delete/truncate clients, projects, payments, phases).
|
||
|
||
---
|
||
|
||
## System Topology: v1.0 → v2.0
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ Next.js 16 App Router (Single Container on Coolify) │
|
||
├─────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌─ /admin/* (Session → Auth.js) │
|
||
│ │ ├─ /admin/clients [v1.0 ✓] │
|
||
│ │ ├─ /admin/projects [v1.0 ✓] │
|
||
│ │ ├─ /admin/catalog [NEW — unifies services] │
|
||
│ │ ├─ /admin/offers [Phase 5 → extended] │
|
||
│ │ ├─ /admin/quotes [NEW — quote builder] │
|
||
│ │ ├─ /admin/leads [NEW — CRM pipeline] │
|
||
│ │ └─ /admin/dashboard [Phase 6 ✓] │
|
||
│ │ │
|
||
│ ├─ /client/[token]/* (Token → internal validation) │
|
||
│ │ ├─ /client/[token]/dashboard [v1.0 ✓] │
|
||
│ │ ├─ /client/[token]/projects [v1.0 ✓] │
|
||
│ │ └─ /client/[token]/payments [v1.0 ✓] │
|
||
│ │ │
|
||
│ ├─ /quote/[token]/* (Token → public quote page) │
|
||
│ │ ├─ /quote/[token] [NEW — multistep form] │
|
||
│ │ └─ /quote/[token]/pdf [NEW — downloadable PDF] │
|
||
│ │ │
|
||
│ └─ /api/* │
|
||
│ ├─ /api/client/* (client-facing APIs) │
|
||
│ ├─ /api/admin/* (admin-only actions) │
|
||
│ └─ /api/internal/* (validation endpoints) │
|
||
│ │
|
||
└──────────────────────┬──────────────────────────────────┘
|
||
↓
|
||
Postgres (Neon)
|
||
Single schema
|
||
```
|
||
|
||
---
|
||
|
||
## Component Boundaries
|
||
|
||
| Component | Responsibility | Scope | Communicates With |
|
||
|-----------|---------------|-------|-------------------|
|
||
| **Unified Services** | Catalog of work units with pricing | `services` table (replaces `service_catalog` + `offer_services`) | offer_phases, quote_builder |
|
||
| **Offer System** | Marketing templates: macro/micro + phases with services | `offer_macros`, `offer_micros`, `offer_phases`, `offer_phase_services` | projects (on win), quote_builder, CRM |
|
||
| **Quote Builder** | Admin creates custom quotes from client + offer tiers + per-item pricing overrides | `quotes`, `quote_items` (extended with offer context) | public quote page, CRM (on acceptance) |
|
||
| **Public Quote Page** | Multistep form: lead selects offer tier, reviews total, accepts via link | `/quote/[token]/*` routes | quote records, CRM (on submission) |
|
||
| **CRM Pipeline** | Lead tracking, follow-up reminders, quote prerequisite, win→auto-provision | `leads`, `lead_activities`, `lead_contacts`, `quotes` (status) | projects, clients, payments (on win) |
|
||
| **Client Dashboard** | Read-only: project phases, tasks, deliverables, progress (v1.0 unchanged) | `/client/[token]/*`, `projects`, `phases`, `deliverables` | offers (view-only) |
|
||
| **Admin Dashboard** | KPI overview, activity feed, sidebar navigation | `/admin/dashboard` | all tables (read-only aggregation) |
|
||
|
||
---
|
||
|
||
## Data Model Additions & Modifications
|
||
|
||
### NEW: `services` Table (Unified Catalog)
|
||
|
||
Replaces the parallel `service_catalog` (used by quotes) + `offer_services` (used by offers) with a single source of truth.
|
||
|
||
```typescript
|
||
export const services = pgTable("services", {
|
||
id: text("id")
|
||
.primaryKey()
|
||
.$defaultFn(() => nanoid()),
|
||
name: text("name").notNull(), // "Brand Strategy" | "Content Calendar"
|
||
description: text("description"),
|
||
unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
|
||
category: text("category"), // "strategy" | "content" | "delivery"
|
||
active: boolean("active").notNull().default(true),
|
||
// Metadata for migration tracking (enables safe rollback)
|
||
migrated_from: text("migrated_from"), // "service_catalog" | "offer_services" | null
|
||
migrated_id: text("migrated_id"), // original ID from old table
|
||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||
});
|
||
```
|
||
|
||
**Why:** Eliminates dual catalog maintenance. One price source of truth. The `migrated_from` + `migrated_id` fields allow safe rollback if consolidation needs reversal during Phase 7 (Catalog Migration).
|
||
|
||
### MODIFIED: `quote_items` Table
|
||
|
||
Change references to unified services table; add offer context fields.
|
||
|
||
```typescript
|
||
export const quote_items = pgTable("quote_items", {
|
||
id: text("id")
|
||
.primaryKey()
|
||
.$defaultFn(() => nanoid()),
|
||
quote_id: text("quote_id") // NEW: associate with quote header
|
||
.notNull()
|
||
.references(() => quotes.id, { onDelete: "cascade" }),
|
||
service_id: text("service_id") // CHANGED: service_catalog.id → services.id
|
||
.notNull()
|
||
.references(() => services.id, { onDelete: "restrict" }),
|
||
offer_micro_id: text("offer_micro_id") // NEW: links to source offer tier (nullable)
|
||
.references(() => offer_micros.id, { onDelete: "set null" }),
|
||
offer_phase_id: text("offer_phase_id") // NEW: which offer phase contains this (nullable)
|
||
.references(() => offer_phases.id, { onDelete: "set null" }),
|
||
quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(),
|
||
unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), // snapshot at time of quote
|
||
subtotal: numeric("subtotal", { precision: 10, scale: 2 }).notNull(),
|
||
custom_label: text("custom_label"), // free-form overrides
|
||
});
|
||
```
|
||
|
||
**Why:** Separates quote as a document header from line items. Links quote to offer context for traceability. Still never exposed via client API (security maintained).
|
||
|
||
### NEW: `offer_phases` Table (Offer Template Structure)
|
||
|
||
Templates the hierarchical breakdown of an offer into phases (e.g., Discovery → Strategy → Execution).
|
||
|
||
```typescript
|
||
export const offer_phases = pgTable("offer_phases", {
|
||
id: text("id")
|
||
.primaryKey()
|
||
.$defaultFn(() => nanoid()),
|
||
micro_id: text("micro_id")
|
||
.notNull()
|
||
.references(() => offer_micros.id, { onDelete: "cascade" }),
|
||
title: text("title").notNull(), // "Discovery" | "Strategy" | "Execution"
|
||
description: text("description"),
|
||
sort_order: integer("sort_order").notNull().default(0),
|
||
duration_weeks: integer("duration_weeks"), // optional: how long this phase lasts
|
||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||
});
|
||
```
|
||
|
||
**Why:** Offer micros currently lack phase structure. This lets us template the project's `phases` table. On CRM win, we copy `offer_phases` → `project.phases` (template → instance).
|
||
|
||
### NEW: `offer_phase_services` Junction
|
||
|
||
Assigns unified services to offer phases (replaces `offer_micro_services` + `offer_services` path).
|
||
|
||
```typescript
|
||
export const offer_phase_services = pgTable(
|
||
"offer_phase_services",
|
||
{
|
||
phase_id: text("phase_id")
|
||
.notNull()
|
||
.references(() => offer_phases.id, { onDelete: "cascade" }),
|
||
service_id: text("service_id")
|
||
.notNull()
|
||
.references(() => services.id, { onDelete: "cascade" }),
|
||
},
|
||
(t) => ({
|
||
pk: primaryKey({ columns: [t.phase_id, t.service_id] }),
|
||
})
|
||
);
|
||
```
|
||
|
||
**Why:** Offer phases need services assigned. This replaces the old `offer_micro_services` → `offer_services` path with `offer_phase_services` → unified `services`.
|
||
|
||
### NEW: `quotes` Table (Quote Record Header)
|
||
|
||
Separate quote as a document from its line items, enabling tracking of viewing/acceptance/PDF generation.
|
||
|
||
```typescript
|
||
export const quotes = pgTable("quotes", {
|
||
id: text("id")
|
||
.primaryKey()
|
||
.$defaultFn(() => nanoid()),
|
||
lead_id: text("lead_id") // nullable for standalone quotes to clients
|
||
.references(() => leads.id, { onDelete: "cascade" }),
|
||
client_id: text("client_id") // nullable for leads
|
||
.references(() => clients.id, { onDelete: "cascade" }),
|
||
token: text("token") // public page access token
|
||
.notNull()
|
||
.unique()
|
||
.$defaultFn(() => nanoid()),
|
||
pdf_url: text("pdf_url"), // generated PDF link (external or internal)
|
||
total_amount: numeric("total_amount", { precision: 10, scale: 2 }).notNull(),
|
||
status: text("status") // draft | sent | viewed | accepted | rejected
|
||
.notNull()
|
||
.default("draft"),
|
||
accepted_at: timestamp("accepted_at", { withTimezone: true }),
|
||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||
});
|
||
```
|
||
|
||
**Why:** Separates quote as a document/record with its own lifecycle from quote_items (line items). Allows tracking viewing, acceptance, PDF generation, and token-based public access.
|
||
|
||
### NEW: `leads` Table (CRM Pipeline)
|
||
|
||
Central lead record tracking from first contact through win.
|
||
|
||
```typescript
|
||
export const leads = pgTable("leads", {
|
||
id: text("id")
|
||
.primaryKey()
|
||
.$defaultFn(() => nanoid()),
|
||
name: text("name").notNull(),
|
||
email: text("email").notNull(),
|
||
phone: text("phone"),
|
||
company: text("company"),
|
||
status: text("status") // new | contacted | qualified | quote_sent | won | lost
|
||
.notNull()
|
||
.default("new"),
|
||
// When won, these auto-populate (immutable once set)
|
||
client_id: text("client_id")
|
||
.references(() => clients.id, { onDelete: "set null" }),
|
||
project_id: text("project_id")
|
||
.references(() => projects.id, { onDelete: "set null" }),
|
||
// The quote they accepted
|
||
won_quote_id: text("won_quote_id")
|
||
.references(() => quotes.id, { onDelete: "set null" }),
|
||
// Tracking
|
||
last_contacted_at: timestamp("last_contacted_at", { withTimezone: true }),
|
||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||
won_at: timestamp("won_at", { withTimezone: true }),
|
||
});
|
||
```
|
||
|
||
**Why:** Central lead record. Tracks lifecycle and links to the quote that closed the deal. Client/project auto-populate on win (immutable once set — another audit trail).
|
||
|
||
### NEW: `lead_activities` Table
|
||
|
||
Activity log for each lead, enabling follow-up tracking and reminders.
|
||
|
||
```typescript
|
||
export const lead_activities = pgTable("lead_activities", {
|
||
id: text("id")
|
||
.primaryKey()
|
||
.$defaultFn(() => nanoid()),
|
||
lead_id: text("lead_id")
|
||
.notNull()
|
||
.references(() => leads.id, { onDelete: "cascade" }),
|
||
type: text("type").notNull(), // "call" | "email" | "meeting" | "quote_sent" | "note"
|
||
summary: text("summary").notNull(),
|
||
notes: text("notes"),
|
||
scheduled_for: timestamp("scheduled_for", { withTimezone: true }), // for reminders
|
||
completed_at: timestamp("completed_at", { withTimezone: true }),
|
||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||
});
|
||
```
|
||
|
||
**Why:** Activity log for each lead. Enables "who to follow up with today" dashboard widget. Scheduled activities for reminders.
|
||
|
||
### NEW: `lead_contacts` Table
|
||
|
||
Tracks multiple stakeholders per lead.
|
||
|
||
```typescript
|
||
export const lead_contacts = pgTable("lead_contacts", {
|
||
id: text("id")
|
||
.primaryKey()
|
||
.$defaultFn(() => nanoid()),
|
||
lead_id: text("lead_id")
|
||
.notNull()
|
||
.references(() => leads.id, { onDelete: "cascade" }),
|
||
name: text("name").notNull(),
|
||
role: text("role"), // "Founder" | "Marketing Manager"
|
||
email: text("email"),
|
||
phone: text("phone"),
|
||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||
});
|
||
```
|
||
|
||
**Why:** Some leads have multiple stakeholders. This tracks key decision-makers per lead.
|
||
|
||
### MODIFIED: `projects` & `phases` (Audit Trail)
|
||
|
||
Add optional fields to track template origin for audit and rebuild scenarios.
|
||
|
||
```typescript
|
||
// projects — new fields
|
||
export const projects = pgTable("projects", {
|
||
// ... existing fields ...
|
||
offer_id: text("offer_id") // which offer template (if any)
|
||
.references(() => offer_micros.id, { onDelete: "set null" }),
|
||
created_from_lead_id: text("created_from_lead_id"), // which lead won → created this
|
||
});
|
||
|
||
// phases — new fields
|
||
export const phases = pgTable("phases", {
|
||
// ... existing fields ...
|
||
offer_phase_id: text("offer_phase_id"), // original offer template phase (immutable reference)
|
||
});
|
||
```
|
||
|
||
**Why:** Audit trail + templating linkage. Allows rebuilding phase/task structures from offer if needed, and traces project origin to CRM win.
|
||
|
||
---
|
||
|
||
## Data Flow: New Features
|
||
|
||
### Flow 1: Catalog Unification → Offer Build → Quote
|
||
|
||
```
|
||
Admin → /admin/catalog
|
||
Create/Edit unified `services`
|
||
↓
|
||
(Catalog migration in Phase 7: service_catalog + offer_services → services)
|
||
↓
|
||
Admin → /admin/offers/[id]/phases
|
||
Select offer macro (e.g., "Signature")
|
||
Select offer micro (e.g., "Signature A")
|
||
Define phases within micro (e.g., "Discovery → Strategy → Execution")
|
||
Assign unified `services` to each phase (drag-drop UI)
|
||
↓
|
||
Admin → /admin/quotes/new or /admin/clients/[id]/quotes
|
||
Select offer tier (micro_id)
|
||
System pre-populates: offer_phases → quote_items
|
||
Override per-item prices if needed
|
||
Generate quote PDF
|
||
Send to lead/client via email
|
||
```
|
||
|
||
### Flow 2: Public Quote → Acceptance → CRM Win
|
||
|
||
```
|
||
Lead receives /quote/[token] link (via email, Slack, etc.)
|
||
↓
|
||
Opens multistep form:
|
||
Step 1: Review offer structure (phases + services)
|
||
Step 2: Review total amount
|
||
Step 3: Input approval name + email
|
||
↓
|
||
Submit → quote.status = "accepted" + lead.status = "won"
|
||
↓
|
||
Trigger auto-provisioning SERVER ACTION (winLead):
|
||
└─→ Create `clients` (from lead.name + company + auto-slug)
|
||
└─→ Create `projects` (from offer title + client)
|
||
└─→ Copy `offer_phases` → `project.phases` (template → instance)
|
||
└─→ Create `tasks` (generated from phase/service structure)
|
||
└─→ Create `payments` (1-4 installments, configured per offer or case)
|
||
└─→ Update lead: client_id, project_id, won_quote_id, won_at
|
||
↓
|
||
Client auto-receives /client/[slug or token] link
|
||
(with project visibility, onboarding tasks, payment schedule)
|
||
```
|
||
|
||
### Flow 3: Client Dashboard (v1.0 Unchanged)
|
||
|
||
```
|
||
Client visits /client/[token]/dashboard
|
||
↓
|
||
Middleware validates token → routes to ClientDashboard component
|
||
↓
|
||
Display: projects → phases → tasks → deliverables
|
||
Also: view active offers if assigned (via project_offers)
|
||
Also: payment schedule + status
|
||
↓
|
||
Client approves deliverables (approved_at immutable)
|
||
Client leaves comments
|
||
↓
|
||
NEVER: quote item details, individual service prices
|
||
```
|
||
|
||
### Flow 4: Admin Dashboard (Extended)
|
||
|
||
```
|
||
Admin visits /admin/dashboard
|
||
↓
|
||
KPI Section: Total active clients, active projects, revenue forecast
|
||
↓
|
||
Activity Feed:
|
||
├─ Recent leads (new → contacted → quote_sent → won)
|
||
├─ Follow-up reminders: lead_activities.scheduled_for = today & !completed_at
|
||
├─ Quotes pending approval (status = "sent")
|
||
├─ Recent project updates (deliverables approved)
|
||
└─ Upcoming payments due
|
||
```
|
||
|
||
---
|
||
|
||
## Middleware & Route Gating
|
||
|
||
### Existing Patterns (v1.0 — Preserved)
|
||
|
||
```typescript
|
||
// src/proxy.ts — Next.js 16 edge middleware
|
||
|
||
// ✓ /admin/* → Auth.js session check
|
||
if (pathname.startsWith("/admin")) {
|
||
const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET });
|
||
if (!token) {
|
||
return NextResponse.redirect(new URL("/admin/login", request.url));
|
||
}
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// ✓ /client/[slug or token]/* → internal token/slug validation
|
||
if (pathname.startsWith("/client/")) {
|
||
const slugOrToken = extractFromPath();
|
||
const validation = await validateSlugOrToken(slugOrToken, INTERNAL_SECRET);
|
||
if (!validation.ok) return NextResponse.rewrite(new URL("/not-found", request.url));
|
||
return NextResponse.next();
|
||
}
|
||
```
|
||
|
||
### New Pattern: Public Quote Routes
|
||
|
||
```typescript
|
||
// Add to src/proxy.ts (after /client check)
|
||
|
||
// NEW /quote/[token]/* → public quote validation (no session)
|
||
if (pathname.startsWith("/quote/")) {
|
||
const tokenMatch = pathname.match(/^\/quote\/([a-zA-Z0-9_-]+)/);
|
||
if (!tokenMatch) return NextResponse.rewrite(new URL("/not-found", request.url));
|
||
|
||
const token = tokenMatch[1];
|
||
|
||
try {
|
||
// Validate quote exists and is current (via internal API)
|
||
const port = process.env.PORT ?? "3000";
|
||
const res = await fetch(
|
||
`http://localhost:${port}/api/internal/validate-quote-token?token=${encodeURIComponent(token)}`,
|
||
{ headers: { "x-internal-secret": process.env.INTERNAL_SECRET ?? "" } }
|
||
);
|
||
|
||
if (!res.ok) {
|
||
return NextResponse.rewrite(new URL("/not-found", request.url));
|
||
}
|
||
|
||
return NextResponse.next();
|
||
} catch {
|
||
return NextResponse.rewrite(new URL("/not-found", request.url));
|
||
}
|
||
}
|
||
```
|
||
|
||
**Why:** Public quotes are gated by token (like client links) but don't require a session. Anyone with the link can view/accept. Middleware doesn't authenticate; it just validates the quote exists and is not expired.
|
||
|
||
---
|
||
|
||
## New Routes & Components
|
||
|
||
### Admin Routes (New)
|
||
|
||
| Route | Component | Responsibility | Gated By | Phase |
|
||
|-------|-----------|-----------------|----------|-------|
|
||
| `/admin/catalog` | ServiceCatalogPage | CRUD unified services | Auth.js | 7 |
|
||
| `/admin/offers/[id]/phases` | OfferPhaseBuilder | Manage phases + drag-drop services into phases | Auth.js | 8 |
|
||
| `/admin/quotes` | QuoteListPage | List all quotes, filter by status, view PDF | Auth.js | 9 |
|
||
| `/admin/quotes/new` | QuoteBuilderPage | Create quote (select offer, client, override pricing) | Auth.js | 9 |
|
||
| `/admin/leads` | LeadListPage | CRM pipeline (new → contacted → quote_sent → won) | Auth.js | 10 |
|
||
| `/admin/leads/[id]` | LeadDetailPage | View lead, manage contacts, log activities, send quote | Auth.js | 10 |
|
||
|
||
### Public Routes (New)
|
||
|
||
| Route | Component | Responsibility | Gated By | Phase |
|
||
|-------|-----------|-----------------|----------|-------|
|
||
| `/quote/[token]` | QuotePublicPage | Multistep form: review offer → accept → sign | Quote token | 9 |
|
||
| `/quote/[token]/pdf` | QuotePDFRoute | Download PDF | Quote token | 9 |
|
||
| `/api/public/quote/accept` | AcceptQuoteHandler | Form submission (POST) — triggers winLead | Quote token | 11 |
|
||
|
||
### Server Actions (New)
|
||
|
||
All mutations use Server Actions (v1.0 pattern preserved):
|
||
|
||
```typescript
|
||
// src/app/admin/catalog/actions.ts — Service CRUD
|
||
export async function createService(data: NewService) {
|
||
await requireAdmin();
|
||
return db.insert(services).values(data);
|
||
}
|
||
|
||
export async function updateService(id: string, data: Partial<Service>) {
|
||
await requireAdmin();
|
||
return db.update(services).set(data).where(eq(services.id, id));
|
||
}
|
||
|
||
// src/app/admin/leads/actions.ts — Lead & CRM
|
||
export async function createLead(data: NewLead) {
|
||
await requireAdmin();
|
||
return db.insert(leads).values(data);
|
||
}
|
||
|
||
export async function logLeadActivity(leadId: string, activity: NewLeadActivity) {
|
||
await requireAdmin();
|
||
const [updated] = await db
|
||
.update(leads)
|
||
.set({ last_contacted_at: new Date() })
|
||
.where(eq(leads.id, leadId))
|
||
.returning();
|
||
|
||
await db.insert(lead_activities).values({ lead_id: leadId, ...activity });
|
||
return updated;
|
||
}
|
||
|
||
export async function sendQuoteToLead(leadId: string, quoteId: string) {
|
||
await requireAdmin();
|
||
await db.update(quotes).set({ status: "sent" }).where(eq(quotes.id, quoteId));
|
||
await logLeadActivity(leadId, { type: "quote_sent", summary: `Quote sent`, ... });
|
||
// TODO: Email integration (future phase)
|
||
}
|
||
|
||
export async function winLead(leadId: string, quoteId: string) {
|
||
await requireAdmin();
|
||
|
||
// Validate quote status
|
||
const quote = await db.query.quotes.findFirst({ where: eq(quotes.id, quoteId) });
|
||
if (!quote || quote.status !== "accepted") throw new Error("Quote not accepted");
|
||
|
||
// Create client
|
||
const [newClient] = await db.insert(clients).values({
|
||
name: lead.name,
|
||
brand_name: lead.company || lead.name,
|
||
brief: "", // optional
|
||
token: nanoid(),
|
||
slug: generateSlug(lead.name),
|
||
}).returning();
|
||
|
||
// Create project
|
||
const [newProject] = await db.insert(projects).values({
|
||
client_id: newClient.id,
|
||
name: `Project for ${lead.company || lead.name}`,
|
||
offer_id: quote.offer_micro_id, // track origin
|
||
created_from_lead_id: leadId,
|
||
}).returning();
|
||
|
||
// Copy offer_phases → project.phases
|
||
const offerPhases = await getOfferPhasesByMicroId(quote.offer_micro_id);
|
||
for (const offerPhase of offerPhases) {
|
||
const [newPhase] = await db.insert(phases).values({
|
||
project_id: newProject.id,
|
||
title: offerPhase.title,
|
||
description: offerPhase.description,
|
||
sort_order: offerPhase.sort_order,
|
||
offer_phase_id: offerPhase.id, // audit trail
|
||
status: "upcoming",
|
||
}).returning();
|
||
|
||
// TODO: Optionally create tasks from phase services
|
||
}
|
||
|
||
// Create payments (1-4 installments)
|
||
const installments = [
|
||
{ label: "Acconto 50%", amount: quote.total_amount * 0.5 },
|
||
{ label: "Saldo 50%", amount: quote.total_amount * 0.5 },
|
||
];
|
||
for (const payment of installments) {
|
||
await db.insert(payments).values({
|
||
project_id: newProject.id,
|
||
...payment,
|
||
status: "da_saldare",
|
||
});
|
||
}
|
||
|
||
// Update lead
|
||
const [updatedLead] = await db.update(leads).set({
|
||
client_id: newClient.id,
|
||
project_id: newProject.id,
|
||
won_quote_id: quoteId,
|
||
status: "won",
|
||
won_at: new Date(),
|
||
}).where(eq(leads.id, leadId)).returning();
|
||
|
||
return { lead: updatedLead, client: newClient, project: newProject };
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Integration Points & Modified Components
|
||
|
||
### Quote Items (Modified Behavior)
|
||
|
||
**v1.0:** `quote_items` directly references `project_id` and `service_catalog.id`.
|
||
|
||
**v2.0:** `quote_items` now belongs to a `quote` (header) and optionally to offer context via `offer_micro_id` + `offer_phase_id`.
|
||
|
||
**Impact:**
|
||
- Quote builder pre-populates items from offer phases
|
||
- Client API still never exposes quote_items (security unchanged)
|
||
- Quote PDF generation uses quote_items + quote header
|
||
|
||
### Offer System (Extended with Phases)
|
||
|
||
**v1.0:** Offer micros → flat `offer_micro_services` → `offer_services`. No phase structure.
|
||
|
||
**v2.0:** Offer micros → `offer_phases` → `offer_phase_services` → unified `services`.
|
||
|
||
**Impact:**
|
||
- Phase 5 offer builder UI needs redesign: from flat "assign services to micro" → hierarchical "manage phases > assign services to phase"
|
||
- Old `offer_micro_services` deprecated but kept for rollback safety
|
||
- `offer_micro_services` → `offer_phase_services` data migration in Phase 7
|
||
|
||
### Project Phases (Copy-on-Win Mechanic)
|
||
|
||
**v1.0:** Phases created manually per project by admin.
|
||
|
||
**v2.0:** Phases can be copied from offer template on CRM win.
|
||
|
||
**Impact:**
|
||
- `project.phases` now optionally linked to `offer_phases` via `offer_phase_id`
|
||
- Admin can still create/edit phases manually (template copy doesn't lock them)
|
||
- Ensures consistency while allowing customization
|
||
|
||
### Client API (Unchanged Security)
|
||
|
||
**Constraint preserved:** `/client/[token]` endpoints never expose `quote_items`, `lead_id`, `lead` status, or pricing details.
|
||
|
||
**Nothing new exposed.**
|
||
|
||
---
|
||
|
||
## Suggested Build Order (Phases 7–11)
|
||
|
||
### Phase 7: Catalog Migration (Foundation)
|
||
**Duration:** 3–4 days
|
||
**Why first:** All downstream features depend on unified `services` table.
|
||
|
||
**Tasks:**
|
||
1. Create new `services` table with `migrated_from` + `migrated_id` audit fields
|
||
2. Copy `service_catalog` → `services` (with `migrated_from = "service_catalog"`)
|
||
3. Copy `offer_services` → `services` (with `migrated_from = "offer_services"`)
|
||
- Handle duplicates: consolidate by name, keep both if distinct
|
||
4. Update `quote_items.service_id` FK from `service_catalog.id` → `services.id`
|
||
5. Update `offer_phase_services` (new junction) to reference `services.id` instead of old `offer_services.id`
|
||
6. Keep old tables for rollback safety (schema comments mark as "deprecated")
|
||
7. Update Zod validators for all new/updated types
|
||
8. Test: quote_items still load correctly; offer system reads unified services
|
||
|
||
**Outputs:**
|
||
- `services` table + relations
|
||
- Data migration script + rollback plan
|
||
- Zod types
|
||
|
||
**Not done yet:** Old tables still live; soft-delete comes in Phase 12+ cleanup.
|
||
|
||
---
|
||
|
||
### Phase 8: Offer Phases & Quote Headers (Templating)
|
||
**Duration:** 2–3 days
|
||
**Why:** Sets up data model for offer-to-quote-to-project copy mechanics.
|
||
|
||
**Tasks:**
|
||
1. Create `offer_phases` table (belongs to `offer_micros`)
|
||
2. Create `offer_phase_services` junction (new; old `offer_micro_services` deprecated)
|
||
3. Create `quotes` table (header, separate from `quote_items`)
|
||
4. Modify `quote_items` schema: add `quote_id` (FK), `offer_micro_id` (nullable), `offer_phase_id` (nullable)
|
||
5. Modify `projects` schema: add `offer_id`, `created_from_lead_id`
|
||
6. Modify `phases` schema: add `offer_phase_id` for audit trail
|
||
7. Create Drizzle migration file (additive: no drops)
|
||
8. Update all Zod validators
|
||
|
||
**Outputs:**
|
||
- Full schema for offer phases + quote templating
|
||
- Migration script (additive-first)
|
||
- Zod validators
|
||
|
||
**Not done yet:** No UI, no data population; schema only.
|
||
|
||
---
|
||
|
||
### Phase 9: Quote Builder & Public Quote Routes
|
||
**Duration:** 4–5 days
|
||
**Why:** Quote generation + public pages are critical path for both direct quotes + CRM flow.
|
||
|
||
**Tasks:**
|
||
1. Refactor existing quote builder (`/admin/clients/[id]/quote-actions.ts`):
|
||
- Create `quote` header
|
||
- Build `quote_items` from offer phases + override pricing
|
||
- Generate PDF (HTML → image/PDF)
|
||
2. Create `/admin/quotes` list page (filter: draft, sent, accepted, rejected)
|
||
3. Create `/admin/quotes/new` builder page (select client or lead, select offer)
|
||
4. Add middleware validation for `/quote/[token]` routes
|
||
5. Create `/quote/[token]` multistep page (review → accept)
|
||
6. Create `/quote/[token]/pdf` page (download/view)
|
||
7. Implement `/api/public/quote/accept` endpoint (POST to accept quote + trigger winLead on demand)
|
||
8. Server actions: `createQuote`, `updateQuote`, `acceptQuote`, `generatePDF`
|
||
|
||
**Outputs:**
|
||
- Refactored quote builder
|
||
- Quote CRUD endpoints
|
||
- Public quote routes + validation
|
||
- PDF generation utility
|
||
- Integration test: build quote → send to lead → lead accepts via public link
|
||
|
||
**Not done yet:** CRM integration beyond quote link; that's Phase 11.
|
||
|
||
---
|
||
|
||
### Phase 10: CRM Pipeline (Leads & Activities)
|
||
**Duration:** 3–4 days
|
||
**Why:** Leads are entry point to win-→-provision flow.
|
||
|
||
**Tasks:**
|
||
1. Create `leads`, `lead_activities`, `lead_contacts` tables
|
||
2. Create `/admin/leads` list page (sortable by status, last_contacted_at)
|
||
3. Create `/admin/leads/[id]` detail page:
|
||
- Contacts list + add new
|
||
- Activity log (calls, emails, notes, quote_sent)
|
||
- Activity form: date, type, notes, optional reminder
|
||
- "Send Quote" button → creates quote + updates lead.status = "quote_sent"
|
||
- Timeline view
|
||
4. Implement "Send Quote to Lead" flow (quote builder modal + action)
|
||
5. Admin dashboard widget: "Follow-ups due today"
|
||
6. Server actions: `createLead`, `logActivity`, `updateLeadStatus`, `sendQuoteToLead`
|
||
|
||
**Outputs:**
|
||
- CRM data tables + migrations
|
||
- Lead detail UI + activity log
|
||
- Admin dashboard follow-up widget
|
||
- "Send Quote" integration
|
||
|
||
**Not done yet:** Winning leads (that's Phase 11); just the CRM.
|
||
|
||
---
|
||
|
||
### Phase 11: CRM Win → Auto-Provision (Final Integration)
|
||
**Duration:** 2–3 days
|
||
**Why:** The payoff: closing a deal auto-creates a client + project.
|
||
|
||
**Tasks:**
|
||
1. Implement `winLead(leadId, quoteId)` server action (see code above):
|
||
- Create `clients` from lead data
|
||
- Create `projects` from offer title
|
||
- Copy `offer_phases` → `project.phases`
|
||
- Create `payments` (1-4 installments)
|
||
- Update lead: client_id, project_id, won_quote_id, status = "won"
|
||
2. Expose `winLead` via `/admin/leads/[id]` UI (button to manually win)
|
||
3. Trigger `winLead` automatically when quote accepted via `/api/public/quote/accept`
|
||
4. Integration test: lead → quote → accept → check auto-provisioned client + project
|
||
5. Email flow scaffold (not sent yet, but components ready)
|
||
|
||
**Outputs:**
|
||
- `winLead` server action (core logic)
|
||
- Admin UI button + action
|
||
- Auto-trigger on quote acceptance
|
||
- End-to-end integration test
|
||
|
||
**Milestone complete:** v2.0 Business Operations Suite fully functional.
|
||
|
||
---
|
||
|
||
## Migration Strategy: Data Safety
|
||
|
||
### Golden Rules (LOCKED)
|
||
|
||
1. **Never delete or truncate** `clients`, `projects`, `payments`, `phases` rows
|
||
2. **All migrations additive-first** (add columns, create tables, FK links)
|
||
3. **Deprecated tables kept live** until explicit cleanup phase
|
||
4. **Rollback always possible** (audit fields + original data preserved)
|
||
|
||
### Phase 7 Catalog Migration (SQL Example)
|
||
|
||
```sql
|
||
-- Step 1: Create new unified services table
|
||
CREATE TABLE services (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
description TEXT,
|
||
unit_price NUMERIC(10, 2) NOT NULL,
|
||
category TEXT,
|
||
active BOOLEAN NOT NULL DEFAULT true,
|
||
migrated_from TEXT, -- "service_catalog" | "offer_services" | null
|
||
migrated_id TEXT, -- original ID from old table
|
||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||
);
|
||
|
||
-- Step 2: Migrate service_catalog → services
|
||
INSERT INTO services (id, name, description, unit_price, category, active, migrated_from, migrated_id, created_at)
|
||
SELECT id, name, description, unit_price, 'general', active, 'service_catalog', id, COALESCE(created_at, now())
|
||
FROM service_catalog;
|
||
|
||
-- Step 3: Migrate offer_services → services (dedup by name)
|
||
INSERT INTO services (id, name, description, unit_price, category, active, migrated_from, migrated_id, created_at)
|
||
SELECT
|
||
nanoid(),
|
||
os.name,
|
||
os.transformation_description,
|
||
os.price,
|
||
'offer',
|
||
os.active,
|
||
'offer_services',
|
||
os.id,
|
||
now()
|
||
FROM offer_services os
|
||
WHERE NOT EXISTS (
|
||
SELECT 1 FROM services s WHERE s.name = os.name
|
||
);
|
||
|
||
-- Step 4: Update quote_items FK
|
||
ALTER TABLE quote_items
|
||
DROP CONSTRAINT quote_items_service_id_fkey,
|
||
ADD CONSTRAINT quote_items_service_id_fkey
|
||
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE RESTRICT;
|
||
|
||
-- Step 5: Old tables marked deprecated in schema comments (not deleted)
|
||
-- No data lost; rollback possible if needed.
|
||
```
|
||
|
||
**Rollback safety:** Each phase includes:
|
||
- Audit columns tracing data origin
|
||
- No drops — old tables kept as fallback
|
||
- Transaction-safe (all or nothing)
|
||
- Test on staging first
|
||
|
||
---
|
||
|
||
## Anti-Patterns to Avoid
|
||
|
||
### ❌ Separate API Service for CRM
|
||
**Why bad:** Adds operational burden (another container, deploy pipeline). Single-admin overhead unjustified.
|
||
✓ **Instead:** CRM tables in same Postgres, Server Actions in same app.
|
||
|
||
### ❌ Exposing Quote Item Details to Client
|
||
**Why bad:** Reveals internal pricing, breaks confidentiality.
|
||
✓ **Instead:** Keep quote_items admin-only. Client sees only `accepted_total`.
|
||
|
||
### ❌ Hard-linking Offer Phases to Project Phases
|
||
**Why bad:** Can't customize project phases without affecting template.
|
||
✓ **Instead:** Copy offer_phases → project.phases (template → instance, instance editable).
|
||
|
||
### ❌ No Migration Audit Trail
|
||
**Why bad:** Can't debug consolidation issues; rollback impossible.
|
||
✓ **Instead:** `migrated_from` + `migrated_id` on every migrated row.
|
||
|
||
### ❌ Public Quote Routes with Session Auth
|
||
**Why bad:** Requires account/session. Defeats "send link to lead" simplicity.
|
||
✓ **Instead:** Token-gated like `/client/[token]`. Link is enough.
|
||
|
||
---
|
||
|
||
## Scalability Considerations
|
||
|
||
| Concern | At 10 Leads | At 100 Leads | At 1K Leads | Mitigation |
|
||
|---------|------------|-------------|-----------|-----------|
|
||
| Lead activities | Linear inserts | Index on lead_id + created_at | Partition by date | Phase 8 add index |
|
||
| Quote list filtering | Full scan | Add index (status, created_at) | Pagination + cache | Phase 9+ optimization |
|
||
| CRM dashboard KPIs | Fast | May timeout | Cache in settings | Phase 10 widget |
|
||
| PDF generation | Inline OK | Consider async queue | Must queue | Phase 9 scaffold |
|
||
|
||
**Current scope (Phases 7–11):** Assume < 100 leads. Scale signals:
|
||
- Dashboard slow? → Cache KPIs in `settings` table, update on mutations
|
||
- Quote list slow? → Add indexes, implement cursor pagination
|
||
- PDF blocking? → Defer to background job queue (future)
|
||
|
||
---
|
||
|
||
## Sources & Confidence Assessment
|
||
|
||
| Area | Confidence | Source | Notes |
|
||
|------|------------|--------|-------|
|
||
| Schema design | HIGH | Existing v1.0 schema + constraints from PROJECT.md | Additive-first, migrations safe |
|
||
| Offer phases mechanics | HIGH | offer_macros/micros existing, project phases exist | Copy logic straightforward |
|
||
| Public quote routes | HIGH | Existing /client/[token] middleware pattern | Token gating proven, public variant tested |
|
||
| CRM flow | HIGH | Win→auto-provision is explicit requirement in PROJECT.md | Scope well-defined |
|
||
| API security | HIGH | v1.0 client API never exposes quotes/pricing | Pattern established; extend, don't break |
|
||
| Build order | MEDIUM | Phase dependencies mapped; some risk in phase 9 PDF generation | Recommend prototype PDF gen in Phase 9 research |
|
||
|
||
---
|
||
|
||
## Key Decisions (v2.0)
|
||
|
||
| Decision | Rationale | Outcome |
|
||
|----------|-----------|---------|
|
||
| Unified `services` table | Eliminates parallel catalog maintenance (service_catalog + offer_services) | ✓ Single source of truth, easier to manage |
|
||
| Offer phases as copyable templates | Allows on-win auto-provisioning of project phases | ✓ Reduces manual setup, ensures consistency |
|
||
| Public quote routes with token gating | Matches existing /client/[token] pattern; no session required | ✓ Simplicity, "send link to lead" flow works |
|
||
| CRM auto-provisioning on win | Closing a deal auto-creates client + project | ✓ Eliminates data entry, speeds up onboarding |
|
||
| Single app, single DB | No service separation; all routes/tables coexist | ✓ Simpler ops, single deploy, single auth model |
|
||
| Additive-first migrations | Never delete/truncate core tables; audit trail on all moves | ✓ Safe, reversible, satisfies Data Safety constraint |
|
||
|