# 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: 1. **Schema consolidation (Catalog):** Merging two parallel service tables without breaking existing quote_items referential integrity 2. **Template-to-instance copy semantics (Offers):** Offer phases copied to project phases at "Won" can mutate templates or copy partially 3. **Optimistic UI state management (Drag-drop):** Frontend and server get out of sync on sort_order conflicts in concurrent edits 4. **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_items - `offer_services` (35 rows): marketing pricing, used by offer_micro_services You want a unified `services` table. Two bad approaches: **Approach A (Naive consolidation):** 1. Create new `services` table 2. INSERT all rows from both catalogs 3. Update quote_items to point to new services by name matching 4. 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):** 1. Manually consolidate the tables in SQL 2. Delete old tables after data looks "right" 3. Discover months later that quote_items have NULL service_id or point to wrong service 4. prices are wrong (offer_services had different prices than service_catalog) 5. 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_id` points to deleted service - `projects.accepted_total` calculations 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:** 1. **Keep both tables; add a new unified `services` table 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 ``` 2. **Create explicit migration mapping:** ```typescript // 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; ``` 3. **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: ```sql 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 ); ``` 4. **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 5. **Immutable snapshot for historical quotes:** - Store `quote_snapshot: JSONB` in 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 **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):** ```typescript // 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.phases` inserts 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:** 1. **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 2. **Use structuredClone for deep copy:** ```typescript // Instead of shallow spread const deepCopy = structuredClone(offer); deepCopy.micros[0].tasks[0].title = 'X'; // offer is unaffected ``` 3. **Never copy from application layer; let database do it atomically:** ```typescript 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. 4. **Immutable flag on templates:** ```sql 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 ``` 5. **Idempotency key prevents double-copy:** ```typescript // 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; }); } ``` 6. **Validation: count check after copy:** ```typescript 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: 1. Brute-forces quote tokens: 10 guesses/sec × 86400 sec/day ≈ 864K guesses/day 2. After a few days, has enumerated all active quotes 3. Builds pricing database: "This consultant charges €2500 for personal branding tier 1, €5000 for tier 2" 4. 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:** 1. **Token security hardening:** - Use `nanoid(32)` or `nanoid(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" ```typescript // 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)); } ``` 2. **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 ```typescript 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 ``` 3. **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" ```typescript const rateLimitKey = `quote:${token}:views`; const views = await redis.incr(rateLimitKey); if (views > 3) { return 429; // Too Many Requests } ``` 4. **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_items` table: NEVER serialized to public API response - Server-side validation: assert response.quote_items === undefined before sending ```typescript // 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); ``` 5. **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) ```typescript 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'] }); ``` 6. **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.com` should 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_order` is 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:** 1. **Explicit versioning (optimistic locking):** ```sql ALTER TABLE offer_phases ADD COLUMN version INTEGER NOT NULL DEFAULT 0; -- Every UPDATE increments version ``` ```typescript // 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 `version` alongside phase data: ```typescript 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'); } }; ``` 2. **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 ```typescript 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; }); } ``` 3. **Optimistic updates with reconciliation:** ```typescript 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); } }; ``` 4. **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 ```typescript 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:** 1. **Idempotency key pattern (CRITICAL for all multi-step workflows):** ```sql -- 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; ``` ```typescript 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. 2. **Idempotency on browser side (preserve key across retries):** ```typescript 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 } }; ``` 3. **UI pattern: disable button until success** ```typescript const [loading, setLoading] = useState(false); return ( ); ``` Button disabled immediately after click, prevents double-click. Only re-enabled on success (not on error). 4. **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 ```typescript // Check for partial onboarding const lead = await getLeadWithClient(leadId); if (lead.client_id && !lead.project_id) { return (
Onboarding incomplete. Client created, but project missing.