- Upgrade PROJECT.md: v2.0 Business Operations Suite (unified catalog, offers with phases, public quotes, CRM pipeline, auto-provisioning) - Create REQUIREMENTS.md sections: CAT-U, OFFER-B, QUOTE, CRM, WIN requirements for phases 7-11 - Extend ROADMAP.md: 5-phase structure (7-11), 14-19 days total, risk/flag registry - Archive v1.0 milestone (phases 1-6, complete) - Switch STATE.md to v2.0, status=planning - Create research documents: STACK.md, FEATURES_v2.0.md, ARCHITECTURE.md, PITFALLS.md, SUMMARY.md Ready for: /gsd-plan-phase 7 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
51 KiB
Domain Pitfalls: Business Operations Suite (v2.0) — Adding to Production ClientHub
Project: ClientHub v2.0 (Business Operations Suite)
Context: Adding catalog migration, offer builder, public quotes, and CRM to live hub.iamcavalli.net
Researched: 2026-06-10
Overall confidence: HIGH (domain-specific integration pitfalls verified against production data constraints)
Executive Summary
Adding a Business Operations Suite to a production client portal with real data is riskier than building from scratch. The pitfalls cluster in four areas:
- Schema consolidation (Catalog): Merging two parallel service tables without breaking existing quote_items referential integrity
- Template-to-instance copy semantics (Offers): Offer phases copied to project phases at "Won" can mutate templates or copy partially
- Optimistic UI state management (Drag-drop): Frontend and server get out of sync on sort_order conflicts in concurrent edits
- Security + workflow atomicity (Public Quotes, CRM): Token enumeration leaks pricing; double-click creates duplicate clients; partial failures leave inconsistent state
Each pitfall has concrete prevention strategies designed for solo-consultant use (not team-based CRM) and production migration constraints (Data Safety rule: never delete/truncate clients, projects, payments, phases).
CRITICAL PITFALLS (Rewrites / Data Loss Risk)
Pitfall 1: Service Catalog Consolidation Breaks Existing Quotes
What goes wrong:
You have two service tables with different purposes:
service_catalog(21 rows): operational costs, used by quote_itemsoffer_services(35 rows): marketing pricing, used by offer_micro_services
You want a unified services table. Two bad approaches:
Approach A (Naive consolidation):
- Create new
servicestable - INSERT all rows from both catalogs
- Update quote_items to point to new services by name matching
- DROP old tables
Problem: Name matching is fragile (typos, duplicates). Foreign key constraint fails if a quote_item references a service that doesn't exist yet. Orphaned rows. Production is down.
Approach B (Copy-paste merge):
- Manually consolidate the tables in SQL
- Delete old tables after data looks "right"
- Discover months later that quote_items have NULL service_id or point to wrong service
- prices are wrong (offer_services had different prices than service_catalog)
- Historical quotes can't be regenerated
Why it happens:
Two tables evolved independently. Offer services have transformation_description + price (marketing messaging). Catalog services have unit_price (cost). You conflate them, lose the distinction. Also, you can't tell which service_catalog row corresponds to which offer_service row — there's no mapping.
Consequences:
- Quote items become orphaned:
quote_items.service_idpoints to deleted service projects.accepted_totalcalculations fail or show wrong values- Client dashboard payment status breaks (can't compute from broken quote_items)
- Audit trail destroyed: can't reconstruct what was offered in old quotes
- Data Safety violation: quote_items become unreliable, can't be trusted for billing
- Rollback impossible: old tables are gone
Prevention:
-
Keep both tables; add a new unified
servicestable alongside them:Phases: - Phase A (Expand): Add services table, backfill both catalogs into it - Transition period: Old code reads service_catalog, new code reads services - Phase B (Contract): After 2-3 weeks, drop old tables only if safe -
Create explicit migration mapping:
// services table structure: // id, name, unit_price, source_type (catalog | offer), source_id, created_at // Backfill from catalog: INSERT INTO services (id, name, unit_price, source_type, source_id) SELECT nanoid(), name, unit_price, 'catalog', id FROM service_catalog; // Backfill from offer_services (rename if duplicates): INSERT INTO services (id, name, unit_price, source_type, source_id) SELECT nanoid(), CASE WHEN EXISTS(SELECT 1 FROM services WHERE name = offer_services.name) THEN offer_services.name || ' (Offer)' ELSE offer_services.name END, price, 'offer', id FROM offer_services; -
Never update quote_items.service_id immediately:
- Leave quote_items pointing to old service_catalog
- New quote code writes to services table
- Existing quotes remain readable (backward compatible)
- Backfill quote_items in small batches after validation:
UPDATE quote_items SET service_id = (SELECT id FROM services WHERE source_id = quote_items.service_id AND source_type = 'catalog') WHERE service_id IS NOT NULL AND id IN ( SELECT id FROM quote_items ORDER BY id LIMIT 100 );
-
Validation before contract phase:
- Query:
SELECT COUNT(*) FROM quote_items WHERE service_id NOT IN (SELECT id FROM service_catalog)— should be 0 - Checksum: SUM(subtotal) in quote_items == SUM(accepted_total) per project — must match
- Test quote regeneration: pick 5 old projects, regenerate quote, prices must match original
- Query:
-
Immutable snapshot for historical quotes:
- Store
quote_snapshot: JSONBin project_offers row - This is a frozen copy of what was offered (prices, services, terms)
- New code reads from services; historical quote reads from snapshot
- Guarantees: old quotes never change, even if catalog prices change
- Store
Detection:
- Query for orphaned quote_items:
SELECT COUNT(*) FROM quote_items WHERE service_id IS NOT NULL AND service_id NOT IN (SELECT id FROM service_catalog) - Detect price mismatches:
SELECT project_id, SUM(subtotal), projects.accepted_total FROM quote_items JOIN projects USING(project_id) GROUP BY project_id HAVING SUM(subtotal) != projects.accepted_total - Test suite: regenerate quote on historical project, compare prices to original
Phase to address: Phase 7 (Catalog & Offers, specifically "Consolidate catalogs") — design migration BEFORE code changes
Pitfall 2: Template-to-Instance Copy Mutates Template (Offer Phases Bug)
What goes wrong:
When a lead is won, you copy offer phases into project phases. If you copy by reference instead of by value:
Scenario A (Shallow copy):
// Offer phases structure:
const offer = {
micros: [
{ id: 'micro1', title: 'Phase 1', tasks: [{ title: 'Audit' }] }
]
};
// Bad: shallow copy
const copy = { ...offer };
copy.micros[0].tasks[0].title = 'Updated Audit';
// Result: offer.micros[0].tasks[0].title is NOW 'Updated Audit' too!
Admin wins a deal, copies phases, edits a project task (e.g., changes from "5 deliverables" to "3 deliverables"). Next time you use the same offer for another lead, the wrong phase structure is copied — the template has been mutated.
Scenario B (Partial copy on retry): User clicks "Mark as Won," which triggers:
1. Copy offer.micros → project.phases
2. Copy offer.services → project.tasks/deliverables
3. Mark lead.status = 'won'
Network times out or browser tab closes before success page. Admin clicks "Mark as Won" again (thinking it didn't work). Now:
copy.micros → project.phasesinserts new phases (duplicates)- Orphaned old tasks from first copy remain
- Project has 6 phases instead of 3
Scenario C (Copy at wrong level):
You have offer_micros (marketing tiers) and offer_services (actual deliverables). When you copy, do you copy micros as phases? As phase groups? You're confused about the mapping, so you copy wrong level and tasks don't appear in project.
Why it happens:
JavaScript shallow copy is the default. {...obj}, .slice(), Object.assign() all copy references to nested objects. Offer structure is complex (macro → micro → services), and you don't have a clear mapping to project (phases → tasks → deliverables). So you guess the copy strategy.
Also: "Win" action is multi-step (create project, copy phases, copy tasks, create payments). If step 2 fails, step 1 succeeded (orphaned project). No atomic transaction, no idempotency key.
Consequences:
- Same offer structure changes unpredictably across different projects
- Admin sees phase structures diverge: "Why does project A have 5 tasks but project B (same offer) has 6 tasks?"
- Audit trail breaks: can't reconstruct what was actually sold
- Client dashboard shows incomplete phases (missing tasks/deliverables)
- project.accepted_total becomes unreliable if phase structure is different
Prevention:
-
Define hierarchies explicitly (CRITICAL):
Offer side (marketing):
- offer_macros: "Signature", "Entry", "Retainer" (tier names)
- offer_micros: "Signature A", "Signature B", "Signature C" (tier variations)
- offer_services: individual deliverables (what client receives)
- offer_micro_services: junction (which services in which tier)
Project side (execution):
- projects: top-level
- phases: workstreams/timeline ("Discovery", "Design", "Delivery")
- tasks: work items within phase ("Competitor audit", "Wireframes")
- deliverables: client-approvable outputs ("Audit report", "Wireframe set")
Mapping (at "Won"):
- Offer tier (micro) doesn't equal project phase
- Offer services map to project tasks OR deliverables (decide: task or deliverable is the copy target?)
- Simplest: each offer_service → 1 task in phase 1, all tasks created at once
-
Use structuredClone for deep copy:
// Instead of shallow spread const deepCopy = structuredClone(offer); deepCopy.micros[0].tasks[0].title = 'X'; // offer is unaffected -
Never copy from application layer; let database do it atomically:
async function copyOfferToProject(offerId, projectId) { return await db.transaction(async (tx) => { // Fetch offer with all nested data const offer = await tx.query.offer_micros.findMany({ where: eq(offer_micros.macro_id, offerId), with: { services: true, projectOffers: true } }); // Create new phases (1 per offer_micro) const newPhases = []; for (const micro of offer) { const phase = await tx.insert(phases).values({ project_id: projectId, title: micro.public_name, sort_order: micro.sort_order }).returning(); newPhases.push(phase); // Create tasks (1 per service in this micro) for (const service of micro.services) { const task = await tx.insert(tasks).values({ phase_id: phase.id, title: service.name, sort_order: 0 }).returning(); // Create deliverable from service await tx.insert(deliverables).values({ task_id: task.id, title: service.name, description: service.transformation_description }); } } return newPhases; }); }Single transaction = all-or-nothing. No partial copy.
-
Immutable flag on templates:
ALTER TABLE offer_micros ADD COLUMN is_template BOOLEAN NOT NULL DEFAULT TRUE; -- Constraint: if is_template, no UPDATE allowed on title/description -- Only inserts/deletes, no mutations -
Idempotency key prevents double-copy:
// CRM leads table: // id, email, status, offer_id, idempotency_key (unique), project_id async function markLedAsWon(leadId, idempotencyKey) { return await db.transaction(async (tx) => { // Check: is this key already processed? const existing = await tx.select().from(leads) .where(eq(leads.idempotency_key, idempotencyKey)); if (existing.project_id) { return existing; // Already won, return same project } // First time: create project + copy phases const project = await tx.insert(projects).values({ client_id: lead.client_id, name: lead.email }).returning(); await copyOfferToProject(lead.offer_id, project.id); // Mark lead won await tx.update(leads) .set({ status: 'won', project_id: project.id }) .where(eq(leads.id, leadId)); return project; }); } -
Validation: count check after copy:
const offer = await getOfferCounts(offerId); const project = await getProjectCounts(projectId); // Assert counts match assert(offer.microCount === project.phaseCount); assert(offer.serviceCount === project.taskCount); assert(offer.serviceCount === project.deliverableCount);
Detection:
- Query:
SELECT COUNT(DISTINCT phase_count) FROM projects WHERE source_offer_id = 'offer_123'— should be 1 (all instances have same structure) - Check:
SELECT project_id, COUNT(tasks) as task_count FROM projects JOIN phases USING(project_id) JOIN tasks USING(phase_id) GROUP BY project_id— all projects from same offer should have identical task_count - Test: regenerate offer phases in a test project, compare structure to production instance
Phase to address: Phase 7 (Catalog & Offers) for offer structure design; Phase 9 (CRM Won automation) for copy mechanism — finalize mapping BEFORE code
Pitfall 3: Public Quote Page Leaks Pricing via Token Enumeration
What goes wrong:
You create /quote/[quoteToken] (public URL, can be shared with prospects). Quote token is nanoid (21 chars, ~122 bits entropy). An attacker:
- Brute-forces quote tokens: 10 guesses/sec × 86400 sec/day ≈ 864K guesses/day
- After a few days, has enumerated all active quotes
- Builds pricing database: "This consultant charges €2500 for personal branding tier 1, €5000 for tier 2"
- Shares with competitors or uses against you in negotiations
Also: if quote token leaks to wrong recipient (forwarded email, Slack channel, browser history), unauthorized access to pricing meant for one prospect only.
Why it happens:
- nanoid 21 chars is only ~122 bits entropy — decent for single-use tokens but weak against coordinated brute-force
- You're not validating WHO requested the quote, just that token is valid (no email check, no IP restriction)
- No rate limiting, so attacker can guess 1M tokens per day
- No expiration, so old tokens are forever valid
- No audit trail, so you don't know if a quote was accessed by wrong person until much later
Consequences:
- Pricing intelligence leaks to competitors
- Confidential pricing becomes public (prospect #1 knows what prospect #2 was charged)
- Client feels exposed/unprofessional (pricing is discoverable)
- Legal risk: if quote was confidential, unauthorized access is a breach
- Competitive disadvantage: prices visible to all competitors
Prevention:
-
Token security hardening:
- Use
nanoid(32)ornanoid(64)instead of 21 (increases entropy from ~122 bits to ~190 bits) - Add expiration:
quote_token_expires_at(default 7 days, can be extended manually) - Add activation state: token only becomes valid after admin explicitly sends it to contact
- Don't auto-generate tokens on quote creation; generate on-demand when admin clicks "Send quote"
// Quotes table: // id, client_id, status (draft | sent | accepted | rejected) // token (nullable until sent), token_expires_at, sent_to_email, created_at, sent_at const generateQuoteToken = () => nanoid(32); // ~190 bits async function sendQuote(quoteId, contactEmail) { return await db.update(quotes).set({ token: generateQuoteToken(), token_expires_at: addDays(new Date(), 7), status: 'sent', sent_to_email: contactEmail, sent_at: new Date() }).where(eq(quotes.id, quoteId)); } - Use
-
Access control: email + token required:
- Public page requires BOTH token AND email in URL or form
- Email validates that the person accessing is the intended recipient
- Endpoint signature:
/api/quote?token=[token]&email=[email](both required) - Server validates: token belongs to quote AND quote.sent_to_email matches email
GET /api/quote?token=...&email=contact@prospect.com const quote = await db.select().from(quotes) .where(and( eq(quotes.token, token), eq(quotes.sent_to_email, email), gt(quotes.token_expires_at, new Date()), eq(quotes.status, 'sent') )); if (!quote) return 401; // Unauthorized -
Rate limiting (prevents brute-force):
- Max 3 views per token per minute (blocks scanning)
- Max 10 failed attempts per IP per hour (blocks enumeration)
- After rate limit hit, log alert: "Possible quote enumeration detected from IP X"
const rateLimitKey = `quote:${token}:views`; const views = await redis.incr(rateLimitKey); if (views > 3) { return 429; // Too Many Requests } -
Pricing visibility rules (CRITICAL CONSTRAINT):
- Public quote page shows: client name, phase names, deliverables, TOTAL PRICE ONLY
- Per-service prices: NEVER visible (existing LOCKED constraint)
quote_itemstable: NEVER serialized to public API response- Server-side validation: assert response.quote_items === undefined before sending
// WRONG: const quote = await db.select().from(quotes) .where(eq(quotes.token, token)); return NextResponse.json(quote); // Includes quote_items! // RIGHT: const quote = await db.select({ id: quotes.id, clientName: quotes.client_name, totalPrice: quotes.total_price, // sum only phases: phases.title // Do NOT select quote_items }).from(quotes) .where(and(...)) .leftJoin(phases, eq(phases.quote_id, quotes.id)); return NextResponse.json(quote); -
Audit trail for access:
- Log every quote page view:
quote_views(quote_id, accessed_at, email, ip_hash, user_agent) - Alert admin if quote accessed from suspicious location (wrong country, multiple IPs, after hours)
- At minimum: count views per token, alert if >20 views (suggests enumeration)
await db.insert(quote_views).values({ quote_id: quote.id, accessed_at: new Date(), email: params.email, ip_hash: hashIp(req.ip), user_agent: req.headers['user-agent'] }); - Log every quote page view:
-
Expiration enforcement:
- Public page rejects expired tokens (7 days default)
- Admin can extend expiration if needed (e.g., "Give prospect 2 more weeks")
- Shows user: "This quote expired on [date]. Contact sales to request an updated quote."
Detection:
- Test with wrong email:
GET /api/quote?token=X&email=wrong@email.comshould return 401 - Test after expiration: manually set token_expires_at to past date, should return 401
- Test brute force: make 10 requests/second, should hit rate limit
- Audit logs: search for tokens with >20 views, >5 unique emails, multiple IPs
- Schema audit: verify quote_items not in public API response (code review)
Phase to address: Phase 8 (Public Quote Pages) — implement all security controls IN PARALLEL with feature, not after
MODERATE PITFALLS (Major Refactor / Data Inconsistency)
Pitfall 4: Drag-and-Drop Sort Order Race Condition
What goes wrong:
Admin drags service B above service A in an offer phase, frontend sends { serviceId: B, sort_order: 1 }. Meanwhile, another tab deletes service A, which auto-shifts all sort_orders down. The update succeeds, but now B and A have conflicting sort_orders: [1, 1, 3, 4].
Or: two browser tabs open the same offer. Admin in tab 1 drags service B up. Admin in tab 2 drags service C down. Both send updates concurrently. Server processes last write: tab 2's update. Tab 1's reorder is lost. Admin refreshes, sees different order.
Why it happens:
- Optimistic UI updates frontend immediately, sends async update to server
- If server read-replica isn't synced, or if concurrent write happened between read and write, update applies to wrong baseline
sort_orderis a simple integer with no version/conflict detection- No idempotency: if user clicks drag twice (accidental double-click), two updates sent
Consequences:
- Phase services display in wrong order intermittently (depends on which replica you hit)
- Admin refreshes page, order changes again
- After 3-4 drags by different admins, sort_orders become nonsensical:
[1, 3, 2, 5, 4] - Clients see different phase structure depending on page cache; trust breaks
- Audit: "Why did phase structure change?" — no record of who dragged what when
Prevention:
-
Explicit versioning (optimistic locking):
ALTER TABLE offer_phases ADD COLUMN version INTEGER NOT NULL DEFAULT 0; -- Every UPDATE increments version// Update only if version matches: const result = await db.update(offer_phases) .set({ sort_order: newSortOrder, version: sql`version + 1` }) .where(and( eq(offer_phases.id, phaseId), eq(offer_phases.version, expectedVersion) // Must match )) .returning(); if (!result.length) { // Version mismatch: conflict return { status: 409, message: 'Offer changed, please refresh' }; }Frontend stores
versionalongside phase data:const [phases, setPhases] = useState(initialPhases); // includes version const onDrag = async (newOrder) => { const oldVersion = phases[0].version; setPhases([...newOrder]); // Optimistic const response = await updatePhase({ sort_order: newOrder[0].sort_order, version: oldVersion }); if (response.status === 409) { // Conflict, refresh const refreshed = await getPhase(); setPhases(refreshed); showNotification('Offer changed, reloaded latest'); } }; -
Server-side sort_order recomputation (no trusting client):
- Don't trust client's sort_order value — recompute from actual order
- User says: "Move service B before service C"
- Server fetches all services in phase, recomputes all sort_orders atomically
async function reorderService(phaseId, serviceId, newPosition) { return await db.transaction(async (tx) => { // Fetch all services in phase, ordered by sort_order const services = await tx.select() .from(phase_services) .where(eq(phase_services.phase_id, phaseId)) .orderBy(phase_services.sort_order); // Find service and move to newPosition const serviceIndex = services.findIndex(s => s.id === serviceId); if (serviceIndex === -1) return null; const [movedService] = services.splice(serviceIndex, 1); services.splice(newPosition, 0, movedService); // Recompute all sort_orders atomically for (let i = 0; i < services.length; i++) { await tx.update(phase_services) .set({ sort_order: i }) .where(eq(phase_services.id, services[i].id)); } return services; }); } -
Optimistic updates with reconciliation:
const [optimisticPhases, setOptimisticPhases] = useState(phases); const [serverVersion, setServerVersion] = useState(phases.version); const onDrag = async (reordered) => { setOptimisticPhases(reordered); // Instant feedback const response = await updatePhase({ order: reordered.map(s => s.id), version: serverVersion }); if (response.status === 409) { // Refresh from server const current = await getPhase(); setOptimisticPhases(current.services); setServerVersion(current.version); } else { setServerVersion(response.version); } }; -
Serialization: queue drag actions (nuclear option):
- Queue drag-and-drop actions, process one at a time
- User feels slight slowdown if dragging fast
- Guarantees no concurrent updates
const dragQueue = []; let dragging = false; const enqueueReorder = async (action) => { dragQueue.push(action); if (dragging) return; dragging = true; while (dragQueue.length > 0) { const action = dragQueue.shift(); await reorderService(action); } dragging = false; };
Detection:
- Test concurrent updates: open offer in 2 tabs, drag in tab 1, drag in tab 2 simultaneously, save both
- Inspect database:
SELECT sort_order FROM offer_phase_services WHERE offer_phase_id = ? ORDER BY sort_order— should be 0..n-1 with no gaps - Check logs: query for 409 Conflict responses (indicates version mismatch handled correctly)
- Audit query: "Who dragged what when?" — log reorder actions with user/timestamp
Phase to address: Phase 7 (Offer Builder — Drag & Drop) — implement versioning BEFORE UI ships, not after bugs appear
Pitfall 5: CRM "Win" Automation — Double-Click Creates Two Clients
What goes wrong:
Admin clicks "Mark as Won" on a lead, which triggers multi-step automation:
1. Create client (email, name)
2. Create project (client_id)
3. Copy offer phases → project phases
4. Create 1-4 payments
5. Mark lead.status = 'won'
User's connection times out, browser tab closes, or page doesn't load success feedback. Admin thinks it didn't work, clicks "Mark as Won" again. Now:
- Two clients created (same email)
- Two projects created
- Two payment schedules
- Lead shows as "won" (confusing)
Billing chaos: which client to invoice? Which payment schedule is real? Can't issue refund without knowing which one.
Why it happens:
No idempotency key. The "Win" action is a non-idempotent POST that should be safe to retry but isn't. Network is unreliable — timeouts are normal. You can't tell if the first request succeeded or failed just by timeout. Multi-step workflows can partially fail (client creation succeeds, project creation fails), leaving inconsistent state.
Consequences:
- Duplicate clients in system, different tokens (prospect receives two dashboard links)
- Duplicate projects and payments, confusing admin dashboard KPIs
- CRM status shows client as "won" but two projects exist (audit trail broken)
- Refund process impossible: which payment to reverse?
- Data Safety violation: can't trace which project is authoritative
Prevention:
-
Idempotency key pattern (CRITICAL for all multi-step workflows):
-- CRM leads table: -- id, email, status, offer_id, idempotency_key (nanoid, unique), -- client_id (nullable), project_id (nullable), created_at ALTER TABLE crm_leads ADD COLUMN idempotency_key TEXT UNIQUE NOT NULL;async function markLedAsWon(leadId) { const lead = await db.select().from(crm_leads) .where(eq(crm_leads.id, leadId)); const idempotencyKey = lead.idempotency_key; return await db.transaction(async (tx) => { // Check: is this key already processed? const existing = await tx.select().from(crm_leads) .where(and( eq(crm_leads.idempotency_key, idempotencyKey), isNotNull(crm_leads.client_id) )); if (existing.length > 0) { // Already won, return existing IDs return { status: 'already_processed', clientId: existing[0].client_id, projectId: existing[0].project_id }; } // First time: create client + project + phases + payments const client = await tx.insert(clients).values({ name: lead.email, brand_name: lead.company_name, brief: lead.notes, // ... rest of fields }).returning(); const project = await tx.insert(projects).values({ client_id: client.id, name: lead.email, accepted_total: lead.quote_total }).returning(); // Copy phases from offer await copyOfferToProject(lead.offer_id, project.id, tx); // Create payments (e.g., 50% deposit, 50% balance) const paymentPlan = calculatePaymentPlan(lead.quote_total); for (const payment of paymentPlan) { await tx.insert(payments).values({ project_id: project.id, label: payment.label, amount: payment.amount, status: 'da_saldare' }); } // Mark lead won LAST (proof that all steps succeeded) await tx.update(crm_leads).set({ status: 'won', client_id: client.id, project_id: project.id }).where(eq(crm_leads.id, leadId)); return { status: 'success', clientId: client.id, projectId: project.id }; }); }Transaction guarantees: either ALL steps succeed, or ALL rollback. No partial state.
-
Idempotency on browser side (preserve key across retries):
const [idempotencyKey] = useState(() => localStorage.getItem('winLead_key') || nanoid()); const handleWinLead = async () => { localStorage.setItem('winLead_key', idempotencyKey); // Persist key try { const response = await markAsWon(leadId, idempotencyKey); if (response.status === 'success' || response.status === 'already_processed') { localStorage.removeItem('winLead_key'); // Clear key navigate(`/admin/projects/${response.projectId}`); } } catch (e) { showNotification('Failed to mark won. Retrying...'); // Retry will use same key — safe due to idempotency } }; -
UI pattern: disable button until success
const [loading, setLoading] = useState(false); return ( <button onClick={handleWinLead} disabled={loading} > {loading ? 'Processing...' : 'Mark as Won'} </button> );Button disabled immediately after click, prevents double-click. Only re-enabled on success (not on error).
-
Partial failure handling (if some step fails):
- Transaction handles atomic all-or-nothing
- BUT: if transaction itself fails (network before commit), idempotency key ensures retry works
- If you need to investigate failure: client created but project creation failed, don't delete partial data
- Instead: mark client as
status: 'partial_onboarding', show admin a recovery UI: "Complete onboarding?" button
// Check for partial onboarding const lead = await getLeadWithClient(leadId); if (lead.client_id && !lead.project_id) { return ( <div> <p>Onboarding incomplete. Client created, but project missing.</p> <button onClick={() => completeOnboarding(lead)}> Complete Onboarding </button> </div> ); }
Detection:
- Query for duplicate clients by email:
SELECT email, COUNT(*) FROM clients GROUP BY email HAVING COUNT(*) > 1 - Check leads.idempotency_key: every record should have one, none should be NULL
- CRM audit log: "Win" action should appear exactly ONCE per lead, even if endpoint was called 10 times (due to retries)
- Payment audit: project should have exactly 2–4 payments (not 8), based on payment plan
Phase to address: Phase 9 (CRM Won Automation) — implement idempotency in API design BEFORE feature ships, not after data corruption
Pitfall 6: Offer Phase Copy Doesn't Copy Task Deliverables
What goes wrong:
Offer structure: offer_micros (marketing tiers) have no tasks/deliverables (they're just labels for groups of services).
When you "Win" and copy to project, you need to:
offer_micros (Signature A)
→ project_phases (Phase 1)
→ project_tasks (Competitor audit, Design deliverables)
→ deliverables (Audit report.pdf, Wireframes.pdf)
But you copy only phases, forget to copy tasks. Or you copy tasks but forget deliverables. Or you copy deliverables but forget to set task_id correctly (NULL or wrong phase's task ID).
Result: client dashboard shows phase with no tasks (blank), or tasks with no deliverables (can't approve anything).
Why it happens:
Offer structure is marketing (macro → micro → services). Project structure is execution (phases → tasks → deliverables). They don't map 1:1. You're confused about the mapping:
- Is offer_micro a project_phase? (Probably)
- Are offer_services the deliverables? Or are they task descriptions? (Unclear)
- Do you create one task per service, or group services into tasks? (Undefined)
You guess, implement halfway, realize it's broken when client can't approve.
Consequences:
- Client dashboard shows phases with no tasks/deliverables (looks broken)
- Client can't see what they're supposed to approve
- Admin must manually recreate deliverables (tedious, error-prone)
- Audit trail broken: "what was promised in the offer?" vs "what's in the project?" diverge
- Multiple projects from same offer have different deliverable structures (inconsistent)
Prevention:
-
Define hierarchies explicitly IN WRITING (document as code):
// Type definitions make mapping explicit interface OfferStructure { micros: { // e.g., "Signature A", "Signature B" id: string; public_name: string; services: { // e.g., "Competitor audit", "Brand strategy" id: string; name: string; transformation_description: string; }[]; }[]; } interface ProjectStructure { phases: { // e.g., "Discovery", "Design", "Launch" id: string; title: string; tasks: { // e.g., "Conduct competitor audit" id: string; title: string; deliverables: { // e.g., "Audit report" id: string; title: string; description: string; }[]; }[]; }[]; } // Mapping rule (explicit): // 1 offer_micro → 1 project_phase (keep titles) // 1 offer_service → 1 project_task → 1 project_deliverable -
Explicit copy function with validation:
async function copyOfferToProject(offerId: string, projectId: string) { const offer = await getOfferWithAllNested(offerId); return await db.transaction(async (tx) => { const result = { phasesCreated: 0, tasksCreated: 0, deliverablesCreated: 0 }; // For each offer micro: for (const micro of offer.micros) { const phase = await tx.insert(phases).values({ project_id: projectId, title: micro.public_name, sort_order: micro.sort_order }).returning(); result.phasesCreated++; // For each service in this micro, create a task + deliverable: for (const service of micro.services) { const task = await tx.insert(tasks).values({ phase_id: phase.id, title: service.name, description: service.transformation_description, sort_order: 0 }).returning(); result.tasksCreated++; // Service description → 1 deliverable const deliverable = await tx.insert(deliverables).values({ task_id: task.id, title: `${service.name} Deliverable`, description: service.transformation_description, status: 'pending' }).returning(); result.deliverablesCreated++; } } return result; }); } -
Validation: count check (integration test):
const offer = await getOfferWithCounts(offerId); const result = await copyOfferToProject(offerId, projectId); assert(result.phasesCreated === offer.micros.length); assert(result.tasksCreated === offer.totalServices); assert(result.deliverablesCreated === offer.totalServices); // Verify foreign keys: const deliverables = await db.select() .from(deliverables_table) .where(in(deliverables_table.task_id, await db.select().from(tasks).where(eq(tasks.phase_id, ...)))); assert(deliverables.every(d => d.task_id !== null)); -
Test scenario in integration tests:
it('copies offer with 3 micros, 2 services each into project', async () => { const offer = await createTestOffer({ micros: [ { services: ['Audit', 'Strategy'] }, { services: ['Design', 'Copy'] }, { services: ['Launch', 'Training'] } ] }); const project = await createTestProject(); const result = await copyOfferToProject(offer.id, project.id); expect(result.phasesCreated).toBe(3); expect(result.tasksCreated).toBe(6); expect(result.deliverablesCreated).toBe(6); // Verify structure const phases = await getProjectPhases(project.id); expect(phases[0].tasks.length).toBe(2); expect(phases[0].tasks[0].deliverables.length).toBe(1); });
Detection:
-
Query projects from same offer for structure consistency:
SELECT project_id, COUNT(DISTINCT phases) as phase_count, COUNT(tasks) as task_count FROM projects JOIN phases USING(project_id) JOIN tasks USING(phase_id) WHERE source_offer_id = 'offer_123' GROUP BY project_id; -- All rows should have identical phase_count and task_count -
Find projects with missing deliverables:
SELECT p.id FROM projects p JOIN phases ph ON p.id = ph.project_id JOIN tasks t ON ph.id = t.phase_id LEFT JOIN deliverables d ON t.id = d.task_id WHERE d.id IS NULL AND p.source_offer_id IS NOT NULL;
Phase to address: Phase 7 (Catalog & Offers) for offer structure design; Phase 9 (CRM/Quotes) for copy mechanism — define mapping BEFORE implementation
MINOR PITFALLS (Operational Friction)
Pitfall 7: Offer Builder State Not Synchronized Between Tabs
What goes wrong:
Admin edits offer "Signature A" in tab 1, adds a new service to a phase. Tab 2 still shows old offer structure (no service). Admin in tab 2 tries to drag a service, the dragged service doesn't exist in tab 1's state. Or: tabs are editing the same offer simultaneously, last save wins, first admin's work is lost silently.
Why it happens:
No real-time sync or conflict detection. SPA loads offer once, edits in memory, saves. If same user opens offer in another tab, it doesn't know about changes in tab 1.
Consequences:
- Admin's edits silently overwritten
- Offer structure becomes inconsistent
- Frustration: "I added a service, but it's not there"
- For solo consultant (only user), not critical but annoying
Prevention:
-
Conflict detection on save:
- Load offer with
versionfield (integer, incremented on every update) - On save, check:
WHERE id = ? AND version = ? - If version mismatch, return 409 Conflict with server's current version
- UI shows: "Offer changed elsewhere. Reload?"
- Load offer with
-
Session-level locking (optional, for future multi-user):
- Add
locked_by: uuid | nullto offer_micros - When editing, lock:
UPDATE offers SET locked_by = ?, locked_at = NOW() - When saving, unlock
- If another session tries to edit locked offer, show: "Being edited elsewhere"
- Add
-
Message broadcast (optional, for future real-time):
- WebSocket or polling to broadcast changes between tabs
- Only worth it if multi-user editing becomes common
Detection:
- Open offer in 2 tabs, edit in both, save in sequence
- Check: which version is final? (should be tab 2's, not tab 1's)
- Verify version field increments
Phase to address: Phase 7 (Offer Builder) — add versioning when form shipped, not after conflicts discovered
Pitfall 8: Backward Incompatibility When Adding CRM Lead Fields
What goes wrong:
You add new schema fields to CRM leads: source, budget, next_follow_up_date. Existing lead records have NULL for these. New code assumes fields have values, crashes:
const budget = lead.budget * 1000; // TypeError: Cannot read property '*' of null
Or you rename lead_status to status, old API calls still use lead_status, code path breaks.
Why it happens:
Schema changes are additive but code changes aren't. You add a column, then deploy code that assumes the field is populated. Existing records don't have the field populated, so assumptions break.
Consequences:
- Admin dashboard crashes when loading old leads
- API returns unexpected shape
- Code that expects fields gets NULL, calculations fail
- Rollback is hard because you can't easily undo schema changes
Prevention:
-
Expand-Contract migration pattern:
Phase A (Expand): - Add new column as NULLABLE: ALTER TABLE crm_leads ADD COLUMN budget NUMERIC NULL; - Deploy code that handles both: lead.budget ?? lead.legacy_budget ?? 0 - Backfill old records: UPDATE crm_leads SET budget = 0 WHERE budget IS NULL; Phase B (Contract): - Once all records have budget, add NOT NULL: ALTER TABLE crm_leads ALTER COLUMN budget SET NOT NULL; - Remove old code that handles NULL - Only then can you safely drop legacy_budget column -
Defensive code (handles NULL gracefully):
// WRONG: assumes budget always populated and is a number const budgetLabel = (lead.budget / 1000).toFixed(2); // RIGHT: handles NULL and migration const leadBudget = lead.budget ?? lead.legacy_budget ?? 0; const budgetLabel = (leadBudget / 1000).toFixed(2); -
Schema versioning:
- Track in
settingstable:{ key: 'schema_version', value: '9' } - Code checks version and handles old shapes
- Prevents schema-and-code mismatches
- Track in
-
Migration test:
- After adding column, backfill, run query:
SELECT COUNT(*) FROM crm_leads WHERE budget IS NULL— should be 0 - Add NOT NULL constraint only after validation
- Test with production-like data (old NULLs) in test database
- After adding column, backfill, run query:
Detection:
- Test with production data: copy production leads to test db, run new code against them
- Query for NULL fields:
SELECT COUNT(*) FROM crm_leads WHERE budget IS NULL OR source IS NULL - Code review: search for assumptions (e.g.,
lead.budget.toFixed()without null check)
Phase to address: Phase 9 (CRM) — plan all schema changes first, execute Expand-Contract carefully
Pitfall 9: Scope Creep — Building GoHighLevel Instead of Solo-Consultant Tool
What goes wrong:
You start building CRM with: leads, statuses, follow-up reminders. Then:
- "Should add email templates" (30 hours)
- "Should track calls" (40 hours)
- "Should integrate with calendar" (20 hours)
- "Should score leads by engagement" (50 hours)
- "Should have team assignments" (60 hours)
- "Should send bulk emails" (40 hours)
18 months later CRM is 40% done, other features blocked, and you're building Salesforce for one person.
Why it happens:
CRM is unbounded feature space. Every CRM ever adds more features. You imagine the "complete" product and feel like MVP is missing pieces. Also, features are tempting: "just add X" sounds quick (it's not).
Consequences:
- CRM Phases 9–12 never ship (stuck in feature bloat)
- Other priorities (catalog, quotes, public pages) delayed indefinitely
- Code complexity explodes, maintenance burden increases
- Solo consultant doesn't actually use 90% of features (nice-to-haves)
- Real problem (lead pipeline visibility) gets 1/10 of attention it deserves
Prevention:
-
Explicit scope document with Must/Should/Could/Won't (MoSCoW):
MUST HAVE (v2.0 — shipped in Phase 9):
- Lead status pipeline: New → Contacted → Quoted → Won/Lost
- Quote prerequisite: can't win without attaching quote
- Auto-create client + project on Won
- Follow-up reminders in dashboard: "You haven't contacted [prospect] in 5 days"
- Mark as lost (deal rejected)
SHOULD HAVE (v2.1 — later milestone, if time):
- Email template system (for follow-up messages)
- Call logging: date, outcome, next step
- Lead source tracking: website, referral, cold outreach
COULD HAVE (nice-to-haves, only if free time):
- Email campaign automation (use Mailchimp for this)
- Lead scoring/AI ranking (premature optimization)
- Custom fields (overkill for solo practice)
WON'T HAVE (explicitly out of scope for solo consultant v2.0):
- Multi-user team assignments (only you)
- Lead distribution/queuing (not relevant)
- Call recording integration (use Calendly/Zoom for this)
- Bulk import/export (you add leads one at a time)
- Webhook integrations to other tools (complexity tax)
- CRM API for integrations (premature)
- Reporting/dashboards beyond KPI dashboard (already exists)
-
Success criteria by feature, not by feature count:
- MVP v2.0 success: "Can I go from cold lead to won project in <30 minutes without manual work?"
- Success metric: "Did I use this CRM feature >2x per week?"
- If not used, remove it (don't carry dead weight)
-
Ruthless scope boundary questions:
- "Is this essential for lead → quote → won → project workflow?"
- "Will I actually use this weekly?"
- "Does this directly serve the solo consultant (not a 50-person sales team)?"
- If no, defer to v2.1 or later (or never)
-
Feature prioritization by impact:
- Tier 1 (must ship): lead pipeline, quote attachment, auto-onboarding
- Tier 2 (ship if time): follow-up reminders, source tracking
- Tier 3 (defer): templates, scoring, integrations
Detection:
- Review Phase 9 spec: count "must" vs "should" vs "could" vs "won't"
- If "could" list is longer than "must" list, scope creep is happening
- After v2.0 ships, audit actual usage weekly: which CRM features did you use?
- If >15% of features unused, that's scope creep aftermath
Phase to address: Phase 9 (CRM) planning — before design, agree on exact boundaries and document in Must/Should/Won't categories
Phase-Specific Warnings
| Phase | Feature | Likely Pitfall | Mitigation |
|---|---|---|---|
| 7 | Catalog consolidation | Orphaned quote_items, broken price snapshots | Expand-Contract migration; dual writes; verify referential integrity before contract |
| 7 | Offer builder (drag-drop) | Sort order race conditions, conflicting versions | Add version field; use optimistic locking; recompute server-side |
| 7 | Copy offer to offer tier | Template mutation, partial copies, missing tasks | Atomic transaction for entire tree; deep copy; integration tests |
| 8 | Public quote pages | Token enumeration, pricing leakage, unauthorized access | Longer tokens (32+ chars); expiration; email validation; rate limiting; never include quote_items |
| 8 | Quote acceptance | Double-processing (accepted twice) | Add unique constraint on (quote_id, lead_id); prevent concurrent accepts |
| 9 | CRM "Win" automation | Double-click creates duplicate clients | Idempotency key pattern; atomic transaction; disable button until success |
| 9 | CRM schema changes | Backward incompatibility (NULL field handling) | Expand-Contract pattern; defensive code; backfill validation before NOT NULL |
| 9 | CRM scope definition | Feature bloat, never ships | Explicit Must/Should/Could/Won't scope document; measure actual usage; ruthless pruning |
Integration Pitfalls (Cross-Feature)
Data Consistency: Offer Promise vs. Project Execution
Risk: CRM wins, creates project with copied phases. Admin later edits project deliverables (changes requirements). Client dashboard shows updated requirements, but the original "promise" (in offer) is now different. Client approval audit trail is broken.
Mitigation:
- Store immutable offer snapshot in project_offers row:
offer_snapshot: JSONB - Client dashboard shows BOTH offer promise and project execution in parallel
- Admin must explicitly accept client's approval of any changes
- Audit log every project change with reason (scope change, typo fix, etc.)
Token Security Across Client/Public Surfaces
Risk: Two token-based surfaces:
/client/[token](confidential project dashboard, 21 chars)/quote/[quoteToken](public semi-confidential, 32 chars)
If validation is inconsistent, one surface may leak data the other guards.
Mitigation:
- Centralized token validation middleware
- Different token lengths (client 21, quote 32) to distinguish in logs
- Rate-limit both surfaces separately; detect enumeration attempts
- Test both with wrong/expired/tampered tokens
Payment State Consistency After Partial Failure
Risk: CRM "Win" creates client, project, and 1–4 payments in transaction. If payment creation partially succeeds (1 of 3 created, then error), payment schedule is broken.
Mitigation:
- ALL-OR-NOTHING transaction (shown in Pitfall 5)
- Payment initialization is deterministic: same total + plan = same payments
- Retry is idempotent: re-running creates same payments (no duplicates)
- Admin dashboard shows: "Payment setup incomplete, retry?" with clear status
Recommended Research/Validation for Each Phase
| Phase | Topic | Validation | Owner |
|---|---|---|---|
| 7 | Migration safety | Dry-run consolidation on prod DB backup; verify referential integrity | Dev |
| 7 | Offer hierarchies | Document offer vs project structure mapping explicitly | Product + Dev |
| 7 | Drag-drop conflicts | Load test concurrent edits; verify sort_order integrity | QA |
| 8 | Token security | Brute-force test, enumeration test, leakage test on public page | Security review |
| 8 | Public page access | Test token expiration, email validation, rate limiting | QA |
| 9 | CRM idempotency | Test double-click, network failures, partial recovery | QA + Dev |
| 9 | Scope validation | Review Must/Should/Won't with user; mark priorities | Product |
| 9 | Payment consistency | Test partial failures, rollback scenarios, retry idempotency | Dev + QA |
Sources
- Data Migration Testing: A Complete Guide
- Maintaining Data Integrity During Migration — Plane Blog
- Optimistic Updates: State-Based and Render-Based Approaches
- Optimistic UI Without Lying: React 19 Blueprint
- The Double-Click Problem: How Idempotency Saved Our Checkout System
- Idempotency in software: Designing Systems to Safely Retry
- Zero-Downtime Postgres Migrations with Drizzle ORM
- Backward Compatibility in Schema Evolution: Guide
- How to Avoid Scope Creep and Misalignment When Launching MVP
- Best CRM for Consultants & Freelancers (2026 Guide)
- Defending OAuth: Common Attacks and Prevention
- Token Leakage Via Referer — Pentest Vulnerability Wiki
- Transactional Locking to Prevent Race Conditions