From 3b1f49d0597ff395707da9777525c04d279890e5 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Thu, 11 Jun 2026 06:01:59 +0200 Subject: [PATCH] =?UTF-8?q?docs(v2.0):=20finalize=20roadmap=20+=20requirem?= =?UTF-8?q?ents=20=E2=80=94=20phases=207-11=20approved=20for=20planning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .planning/MILESTONES.md | 32 + .planning/PROJECT.md | 98 +- .planning/REQUIREMENTS.md | 88 +- .planning/ROADMAP.md | 138 ++- .planning/STATE.md | 32 +- .planning/research/PITFALLS_CHECKLIST.md | 210 ++++ .planning/research/PITFALLS_SUMMARY.md | 302 +++++ .planning/research/PITFALLS_V2.md | 1269 ++++++++++++++++++++++ 8 files changed, 2085 insertions(+), 84 deletions(-) create mode 100644 .planning/MILESTONES.md create mode 100644 .planning/research/PITFALLS_CHECKLIST.md create mode 100644 .planning/research/PITFALLS_SUMMARY.md create mode 100644 .planning/research/PITFALLS_V2.md diff --git a/.planning/MILESTONES.md b/.planning/MILESTONES.md new file mode 100644 index 0000000..2ed0061 --- /dev/null +++ b/.planning/MILESTONES.md @@ -0,0 +1,32 @@ +# Milestones + +## v1.0 Client Portal & Offer System (Shipped: 2026-06-10) + +**Phases completed:** 6 phases, 24 plans, 29 tasks + +**Key accomplishments:** + +- Next.js 16.2.6 App Router con TypeScript strict, Tailwind v4, Drizzle ORM + postgres-js per Coolify Postgres, e 10 componenti shadcn/ui installati e pronti. +- Schema Drizzle ORM completo con 10 tabelle, migration SQL generata e schema live sul database Postgres 16 (Hetzner/Coolify). TypeScript strict compila senza errori. +- Edge proxy con validazione token, ClientView type system che esclude quote_items, e Server Component che fetcha tutti i dati cliente senza esporre segreti admin. Build Next.js 16 senza errori TypeScript. +- Tutti i componenti UI della dashboard cliente renderizzati come Server Components con design light & clean in Tailwind v4: header con logo iamcavalli + brand name cliente, progress bar globale, timeline laterale delle fasi con barre per fase e task list, sezione pagamenti con badge stato (zero importi singoli), link documenti esterni e log note read-only. +- Task 1: scripts/seed.ts +- 1. [Rule 1 - Bug] Next.js 16 proxy export name is 'proxy', not 'middleware' +- Admin home shows all clients with Acconto/Saldo payment badges; new client form inserts client row + two payment stubs via Zod-validated Server Action with nanoid token auto-generation +- Full-featured admin client workspace with Radix Tabs covering phases/tasks, payments, documents, and comments — all mutations via inline Server Actions with server-side validation. +- Client-facing approval and comment API routes with token validation, ownership verification, approved_at immutability enforcement, and inline ApproveButton/CommentForm/CommentList wired into the Phase 1 dashboard via PhaseTimeline. +- Added `custom_label text` column and made `service_id` nullable in `quote_items` via Drizzle schema edit + Neon DDL push — unblocking Wave 2 free-form quote items +- Vertical slice completo `/admin/catalog`: NavBar link + pagina catalogo + tabella con edit inline + form aggiunta servizio + soft-delete toggle, con Server Actions protetti da Zod e requireAdmin() +- Admin quote builder tab with catalog dropdown, freeform toggle, items table with calculated total, and accepted_total editor — all backed by Zod-validated Server Actions with requireAdmin guard. +- Verifica umana completa: catalogo servizi → preventivo → accepted_total → dashboard cliente, con conferma security constraint quote_items mai esposti +- NavBar +- `/admin/projects/[id]` +- `/api/internal/validate-slug/route.ts` +- Five Offer System tables added to schema.ts and production DB via direct SQL migration, with Phase 4 projects table bootstrapped from existing client data. +- Full admin CRUD for macro-offers, micro-offers, and offer services at /admin/offers with a client-side checkbox list for service-to-micro assignments and NavBar updated with Forecast and Offerte links. +- OffersTab client component for assigning micro-offers to projects with inline accepted_total editing, plus active-offers summary section on the client detail page showing public_name per project. +- Client dashboard now shows active offers per project (public_name + cumulative service price + accepted_total), and /admin/forecast displays a 12-month server-side revenue projection spread from project_offers.accepted_total across duration_months. +- AdminSidebar.tsx +- dashboard-queries.ts + +--- diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 0147115..f4fc0d8 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -2,45 +2,56 @@ ## What This Is -Strumento personale in due parti per gestire i clienti di consulenza: una dashboard web (Vercel) dove ogni cliente accede con un link segreto per vedere il suo progetto, e un flusso Claude per aggiungere clienti step-by-step e generare piani + preventivi. Fatto per un professionista del personal branding con clienti attivi da gestire subito. +Suite operativa per un consulente di personal branding, live su hub.iamcavalli.net (Coolify/Hetzner): un'area admin (`/admin/*`) per gestire clienti, progetti, offerte e pagamenti, e una dashboard cliente via link segreto (`/client/[token]`) dove ogni cliente vede lo stato del suo progetto. Con la v2.0 si espande in suite completa: catalogo servizi unificato, builder offerte con fasi, preventivi pubblici multistep e CRM con pipeline lead. ## Core Value Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti. +## Current Milestone: v2.0 Business Operations Suite + +**Goal:** Trasformare ClientHub da portale clienti a suite operativa completa: catalogo servizi unificato + builder offerte con fasi, generazione preventivi pubblici, e CRM con pipeline lead che al "Vinto" crea automaticamente cliente e progetto nell'hub. + +**Target features:** +- **Catalogo & Offerte** — tabella `services` unificata (sostituisce `service_catalog` + `offer_services`); builder offerte con fasi e drag&drop dei servizi tra fasi; tag tipo offerta (Entry, Signature, Retainer, custom); tier indipendenti (es. Signature A/B/C) +- **Preventivi** — pagina pubblica multistep HTML/CSS generata da cliente + offerte + prezzi scelti di volta in volta; obiettivo delivery entro 2 ore dalla call +- **CRM** — pipeline lead (→ Vinto/Perso, preventivo prerequisito); reminder follow-up in dashboard; al "Vinto" auto-crea cliente + progetto con fasi copiate dall'offerta scelta (modificabili, visibili nella pagina pubblica cliente), importo accettato, 1-4 pagamenti configurabili caso per caso + ## Requirements ### Validated -(None yet — ship to validate) +Shipped in v1.0 (Phases 1–6, in produzione su hub.iamcavalli.net): + +- ✓ Ogni cliente ha un URL segreto univoco, nessun login (token rotatable + slug opzionale) — Phase 1/4 +- ✓ Dashboard cliente: brand, brief, fasi/task con stato, progress, multi-progetto — Phase 1/4 +- ✓ Il cliente approva deliverable (approved_at immutabile) e lascia commenti — Phase 2 +- ✓ Il cliente vede solo il totale accettato, mai i prezzi dei singoli servizi — Phase 2/3 +- ✓ Stato pagamenti acconto/saldo visibile al cliente — Phase 1 +- ✓ Link a documenti esterni + storico note/decisioni — Phase 1/2 +- ✓ Area admin completa: clienti, progetti, fasi, task, pagamenti, documenti — Phase 2/4 +- ✓ Catalogo servizi + quote builder admin (quote_items mai esposti al cliente) — Phase 3 +- ✓ Sistema offerte: macro/micro/servizi, assegnazione a progetti, forecast 12 mesi — Phase 5 +- ✓ Offerte attive visibili nella dashboard cliente (solo public_name) — Phase 5 +- ✓ UX admin: sidebar + dashboard operativa con KPI e activity feed — Phase 6 ### Active -**Dashboard cliente (priorità v1):** -- [ ] Ogni cliente ha un URL segreto univoco (nessun login richiesto) -- [ ] La dashboard mostra nome cliente, nome brand, brief del progetto e stato attuale -- [ ] Il piano è strutturato per fasi con milestone e task all'interno di ogni fase -- [ ] I task hanno stato visibile (da fare / in corso / fatto) -- [ ] Il cliente può approvare i deliverable dalla sua area -- [ ] Il cliente può lasciare commenti su task e deliverable -- [ ] Il cliente vede solo il totale del preventivo accettato (non i prezzi dei singoli servizi) -- [ ] Il cliente vede lo stato dei pagamenti: acconto 50% (da saldare / inviata / saldato) e saldo 50% (da saldare / inviata / saldato) -- [ ] Link a documenti e file (Google Drive, PDF, deliverable) -- [ ] Storico note e decisioni prese nel tempo +**Catalogo & Offerte (priorità 1):** +- [ ] Catalogo servizi unificato (nome + prezzo unitario) usato da offerte e preventivi +- [ ] Offerta = nome + tag tipo (Entry/Signature/Retainer/custom) + fasi ordinate +- [ ] Ogni fase di un'offerta contiene servizi assegnati, spostabili via drag&drop tra fasi +- [ ] Migrazione dati: consolidare `service_catalog` + `offer_services` senza perdita -**Area amministratore (tu):** -- [ ] Vista di tutti i clienti con stato sintetico -- [ ] Gestione completa di ogni cliente: fasi, task, documenti, pagamenti -- [ ] Preventivo completo con dettaglio servizi (non visibile al cliente) +**Preventivi (priorità 2):** +- [ ] Generazione preventivo: cliente + 1-3 offerte + prezzi impostati di volta in volta +- [ ] Pagina pubblica multistep (HTML/CSS) consultabile dal lead via link +- [ ] Delivery del preventivo entro 2 ore dalla call (flusso assistito) -**Catalogo servizi:** -- [ ] File/database dei servizi con prezzi e cosa è incluso -- [ ] Usato come base per la generazione assistita dei preventivi - -**Flusso Claude (v2):** -- [ ] Onboarding guidato step-by-step via chat per aggiungere un nuovo cliente -- [ ] Generazione del piano a fasi basato sul brief -- [ ] Generazione preventivo assistita (Claude suggerisce, tu approvi prima di finalizzare) +**CRM (priorità 3):** +- [ ] Pipeline lead con stati fino a Vinto/Perso (preventivo inviato come prerequisito del Vinto) +- [ ] Reminder follow-up in dashboard: ultima interazione, chi contattare oggi +- [ ] Al "Vinto": auto-creazione cliente + progetto con fasi copiate dall'offerta scelta, importo accettato, 1-4 pagamenti scelti caso per caso ### Out of Scope @@ -48,32 +59,37 @@ Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa - App mobile nativa — solo web responsive - Multi-utente con team — solo tu come admin per ora - Prezzi singoli visibili al cliente — vede solo il totale accettato +- Email OTP per accesso cliente — design pronto ma deferito a batch successivo su richiesta utente +- File hosting — documenti solo come URL esterni (v1 constraint, ancora valido) ## Context -- Il professionista lavora nel personal branding e content creation (cfr. SparklingOrbit) -- Ha clienti attivi ora — la dashboard è la priorità immediata prima del flusso Claude -- I clienti accedono via link segreto fisso (no account, no password) per semplicità massima -- Il preventivo ha sempre struttura acconto 50% + saldo 50% -- Il catalogo servizi va costruito da zero durante il progetto -- Piattaforma di deploy: Vercel +- Produzione: hub.iamcavalli.net su Coolify (Hetzner), Postgres self-hosted, deploy via webhook Gitea +- Stack: Next.js 16 App Router, Drizzle ORM, Auth.js v4 (admin), token middleware (clienti), Tailwind v4, shadcn/ui +- Tutto sotto la stessa app: `/admin/*` (sessione Auth.js) + `/client/[token]/*` (token) + future pagine pubbliche preventivo +- La sidebar admin (Phase 6) è l'hub di navigazione per le nuove sezioni (CRM, Preventivi, Offerte) +- `project_offers` (Phase 5) già copre l'attribuzione di offerte a progetti esistenti — da rifinire, non ricostruire +- Il flusso commerciale reale: call con lead → preventivo 3 tier (es. Signature A/B/C) in giornata → accettazione → onboarding automatico nell'hub ## Constraints -- **Urgenza**: Clienti attivi da gestire subito — la dashboard cliente deve arrivare per prima -- **Semplicità accesso cliente**: Link segreto senza login — nessuna friction per il cliente -- **Privacy preventivo**: Il cliente vede solo il totale, mai il dettaglio dei servizi -- **Deploy**: Vercel su sottodominio `welcomeclient.iamcavalli.net` -- **Dominio**: sottodominio di iamcavalli.net — richiede configurazione DNS su dominio esistente +- **Data Safety (LOCKED)**: migration solo additive su `clients`, `projects`, `payments`, `phases` — la migrazione del catalogo servizi va pianificata per consolidare senza drop/truncate +- **Architettura (LOCKED)**: `clients.token` separato e rotatable; `quote_items` mai esposti via client API; `deliverables.approved_at` immutabile; no file hosting +- **Stesso DB**: tutte le nuove aree (CRM, preventivi, offerte) leggono/scrivono lo stesso Postgres — nessun servizio separato +- **Numerazione fasi**: v2.0 parte da Phase 7 (v1.0 ha chiuso alla 6) ## Key Decisions | Decision | Rationale | Outcome | |----------|-----------|---------| -| Link segreto senza login per i clienti | Massima semplicità — nessun account da creare, zero friction | — Pending | -| Dashboard prima del flusso Claude | Clienti attivi ora, la visibilità al cliente è il valore immediato | — Pending | -| Preventivo: cliente vede solo il totale | Il dettaglio dei prezzi è informazione commerciale riservata | — Pending | -| Catalogo servizi da costruire da zero | Nessun listino esistente — parte del progetto stesso | — Pending | +| Link segreto senza login per i clienti | Massima semplicità — nessun account da creare, zero friction | ✓ Good | +| Dashboard prima del flusso Claude | Clienti attivi ora, la visibilità al cliente è il valore immediato | ✓ Good | +| Preventivo: cliente vede solo il totale | Il dettaglio dei prezzi è informazione commerciale riservata | ✓ Good | +| Suite unica sotto /admin/* invece di 3 app separate | Stesso DB, stesso deploy, auth unica — overhead di 3 app ingiustificato per singolo admin | — Pending | +| Catalogo servizi unificato (una tabella `services`) | Due cataloghi paralleli (service_catalog + offer_services) duplicano manutenzione prezzi | — Pending | +| Tier offerte indipendenti (A/B/C separati, stesso tag) | Più semplice di un meccanismo di ereditarietà; ogni tier configurato a sé | — Pending | +| Prezzi pacchetti per-preventivo, non da catalogo | Permette di alzare i prezzi nel tempo senza toccare il catalogo | — Pending | +| Al "Vinto" le fasi dell'offerta sono COPIATE nel progetto | Il progetto resta modificabile senza toccare il template offerta | — Pending | ## Evolution @@ -93,4 +109,4 @@ This document evolves at phase transitions and milestone boundaries. 4. Update Context with current state --- -*Last updated: 2026-05-15 — Phase 2 complete (admin area + client interactions)* +*Last updated: 2026-06-10 — Milestone v2.0 started (Business Operations Suite)* diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index fab552e..1c6e188 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -1,4 +1,4 @@ -# Requirements — ClientHub +# Requirements — ClientHub v2.0 Business Operations Suite ## Dashboard Cliente (v1) @@ -51,20 +51,69 @@ | OFFER-05 | La dashboard cliente mostra le offerte attive con nome pubblico, prezzo cumulativo dei servizi inclusi e prezzo finale accettato in evidenza; se ha più offerte le vede tutte | Pending | | OFFER-06 | Admin ha una vista revenue forecast dei 12 mesi successivi basata su offerte attive, durata e accepted_total; breakdown mensile con totale fatturato per mese | Pending | -## Flusso Claude (v2 — deferred to Phase 6) +## Catalogo Unificato & Offer Builder (Phase 7–8) + +| ID | Requirement | Status | +|----|-------------|--------| +| CAT-U-01 | Un'unica tabella `services` unifica `service_catalog` + `offer_services`; migrazione zero data loss con audit trail (`migrated_from`) | Phase 7 | +| CAT-U-02 | Admin `/admin/catalog` list/add/edit/soft-delete per i servizi singoli (nome, prezzo_unitario, attivo) | Phase 7 | +| OFFER-B-01 | Offer = nome + tag tipo (Entry/Signature/Retainer/custom) + fasi ordinate; fasi indipendenti l'una dall'altra (es. Signature A/B/C non ereditate) | Phase 8 | +| OFFER-B-02 | `/admin/offers/[id]/edit` two-column builder: left colonna servizi (ricerca), right colonna fasi con drag&drop tra fasi e dentro fase (reorder) | Phase 8 | +| OFFER-B-03 | Drag&drop salva per singolo drop con optimistic update client-side + validazione server; versioning previene race condition da multi-tab edit | Phase 8 | + +## Quote Builder & Public Proposal Pages (Phase 9) + +| ID | Requirement | Status | +|----|-------------|--------| +| QUOTE-01 | `/admin/quotes/new`: select cliente + 1-3 offerte + override prezzo per ciascuna offerta + genera link pubblico `/quote/[token]` | Phase 9 | +| QUOTE-02 | Public `/quote/[token]` pagina multistep (Zod validazione): Step 1 (overview) → Step 2 (tier A/B/C selection + prezzi + fasi) → Step 3 (summary + accept/decline) | Phase 9 | +| QUOTE-03 | Accept CTA → POST `/api/public/quote/accept?token=X` con cattura email (opzionale) + note opzionali; registra timestamp accepted_at immutabile | Phase 9 | +| QUOTE-04 | Quote token: nanoid 21 char (~122 bits), unico, validato in proxy come `/client/[token]` (nessun login sessione); rate limit 3 views/min per IP | Phase 9 | +| QUOTE-05 | Mai esporre `quote_items` (prezzi per servizio) via public API; client API vede solo `accepted_total` (server-side ClientView type enforces) | Phase 9 | + +## CRM Pipeline & Activity Logging (Phase 10) + +| ID | Requirement | Status | +|----|-------------|--------| +| CRM-01 | Lead CRUD: `/admin/leads` list table (name, email, company, status, last_contact_date, next_action) con create/edit/delete | Phase 10 | +| CRM-02 | Lead detail page `/admin/leads/[id]`: full profilo, activity log (reverse chronological), quote(s) sent, actions menu (log activity, send quote, mark won) | Phase 10 | +| CRM-03 | Pipeline stages: Contacted → Qualified → Proposal Sent → Negotiating → Won / Lost; transizioni via dropdown (manual di default) | Phase 10 | +| CRM-04 | Activity logging: tipi (call, email, meeting, note); ogni attività: type, date, durata (opz), notes; auto-aggiorna lead.last_contact_date | Phase 10 | +| CRM-05 | "Log activity" modal: <10 sec UX (type dropdown, date picker, notes textarea, save) — bassa tolleranza per data entry lunghi | Phase 10 | +| CRM-06 | Follow-up reminders widget in dashboard: "Follow up today" (leads dove last_contact_date < oggi - 7 giorni); click → jump lead detail | Phase 10 | +| CRM-07 | "Send Quote" button in lead detail: select existing quote o crea nuovo; update lead.last_quote_sent + status → "Proposal Sent" | Phase 10 | + +## Auto-Onboarding on Win (Phase 11) + +| ID | Requirement | Status | +|----|-------------|--------| +| WIN-01 | "Mark as Won" button in lead detail (o auto-triggered da public quote accept); idempotency: call 2 volte = idempotent, stesso client token | Phase 11 | +| WIN-02 | winLead(leadId, quoteId): crea client (name da lead, token = nanoid), crea project (name = company/offer name), copia offer fasi → project fasi | Phase 11 | +| WIN-03 | 1-4 pagamenti: user sceglie split template (50/50, 3-part, 4-part, custom); registra importo accepted_total da quote | Phase 11 | +| WIN-04 | Generate client dashboard link (`/client/[token]`), invia via email al lead (copy-paste OK, email automation deferred Phase 12+) | Phase 11 | +| WIN-05 | Quote accept da public link `/quote/[token]` auto-aggiorna lead.status → "Proposal Sent" + trigga win (auto-onboarding) | Phase 11 | +| WIN-06 | Copy offer phases → project phases: tutte le fasi copiate con status = "upcoming", le fasi modificabili dopo (template immutabile, instance editable) | Phase 11 | + +## Flusso Claude (v3 — deferred to Phase 13+) | ID | Requirement | Status | |----|-------------|--------| | CLAUDE-01 | Onboarding guidato step-by-step via chat per aggiungere un nuovo cliente | Deferred | -| CLAUDE-02 | Generazione del piano a fasi basato sul brief | Deferred | -| CLAUDE-03 | Generazione preventivo assistita (Claude suggerisce, tu approvi prima di finalizzare) | Deferred | +| CLAUDE-02 | Generazione del piano a fasi basato dal brief | Deferred | +| CLAUDE-03 | Generazione preventivo assistita (Claude suggerisce struttura offerta, tu approvi) | Deferred | -## Out of Scope +## Out of Scope (v2.0) - Fatturazione e invio fatture (solo stato pagamenti) - App mobile nativa (solo web responsive) - Multi-utente con team (solo admin singolo) - Prezzi singoli visibili al cliente (solo totale accettato) +- Email automation (manual copy-paste OK per MVP) +- Lead scoring / predictive analytics +- Team collaboration +- Lead de-duplication (manual link on Win) +- Proposal expiry logic (open indefinitely) +- BullMQ persistent reminders (in-app computed, persistent deferred Phase 12+) ## Traceability @@ -96,6 +145,29 @@ | OFFER-04 | Phase 5 | Pending | | OFFER-05 | Phase 5 | Pending | | OFFER-06 | Phase 5 | Pending | -| CLAUDE-01 | Phase 6 (v2) | Deferred | -| CLAUDE-02 | Phase 6 (v2) | Deferred | -| CLAUDE-03 | Phase 6 (v2) | Deferred | \ No newline at end of file +| CAT-U-01 | Phase 7 | Phase 7 | +| CAT-U-02 | Phase 7 | Phase 7 | +| OFFER-B-01 | Phase 8 | Phase 8 | +| OFFER-B-02 | Phase 8 | Phase 8 | +| OFFER-B-03 | Phase 8 | Phase 8 | +| QUOTE-01 | Phase 9 | Phase 9 | +| QUOTE-02 | Phase 9 | Phase 9 | +| QUOTE-03 | Phase 9 | Phase 9 | +| QUOTE-04 | Phase 9 | Phase 9 | +| QUOTE-05 | Phase 9 | Phase 9 | +| CRM-01 | Phase 10 | Phase 10 | +| CRM-02 | Phase 10 | Phase 10 | +| CRM-03 | Phase 10 | Phase 10 | +| CRM-04 | Phase 10 | Phase 10 | +| CRM-05 | Phase 10 | Phase 10 | +| CRM-06 | Phase 10 | Phase 10 | +| CRM-07 | Phase 10 | Phase 10 | +| WIN-01 | Phase 11 | Phase 11 | +| WIN-02 | Phase 11 | Phase 11 | +| WIN-03 | Phase 11 | Phase 11 | +| WIN-04 | Phase 11 | Phase 11 | +| WIN-05 | Phase 11 | Phase 11 | +| WIN-06 | Phase 11 | Phase 11 | +| CLAUDE-01 | Phase 13 (v3) | Deferred | +| CLAUDE-02 | Phase 13 (v3) | Deferred | +| CLAUDE-03 | Phase 13 (v3) | Deferred | \ No newline at end of file diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 3344e23..f19c197 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -1,8 +1,10 @@ -# Roadmap: ClientHub +# Roadmap: ClientHub v2.0 Business Operations Suite ## Overview -ClientHub si costruisce dall'esterno verso l'interno: prima la dashboard che un cliente reale può aprire su mobile, poi l'area admin per creare e gestire i dati, poi il catalogo servizi e il preventivo interno. Il flusso Claude AI è v2 e dipende da CRUD stabile e catalogo completo. Ogni fase consegna una capacità end-to-end verificabile. +**v1.0 (Phases 1–5, Complete)**: ClientHub si costruisce dall'esterno verso l'interno: prima la dashboard cliente, poi l'area admin CRUD, poi catalogo servizi, progetti multi-progetto e sistema offerte. + +**v2.0 (Phases 7–11, Planning)**: Trasformazione da portale clienti a suite operativa completa. Catalogo servizi unificato → template offerte con fasi → generazione preventivi pubblici in 2 ore → CRM pipeline lead → auto-onboarding Won leads con copiat fasi e pagamenti. Obiettivo: delivery completa di proposta, accettazione, e provisioning in workflow continuo. ## Phases @@ -12,12 +14,20 @@ ClientHub si costruisce dall'esterno verso l'interno: prima la dashboard che un Decimal phases appear between their surrounding integers in numeric order. +**v1.0 (Completed):** - [x] **Phase 1: Foundation & Client Dashboard** - DB schema, token API, dashboard read-only per il cliente con link segreto condivisibile -- [ ] **Phase 2: Admin Area & Interactive Features** - Auth admin, CRUD completo clienti/fasi/task/deliverable/pagamenti, approvazioni e commenti -- [ ] **Phase 3: Service Catalog & Quote Builder** - Catalogo servizi riutilizzabile e costruttore preventivi (admin-only, cliente vede solo il totale) -- [ ] **Phase 4: Progetti — Multi-Project per Cliente** - Modello dati multi-progetto per cliente; dashboard con tabs; /admin/projects; slug link; analytics €/h +- [x] **Phase 2: Admin Area & Interactive Features** - Auth admin, CRUD completo clienti/fasi/task/deliverable/pagamenti, approvazioni e commenti +- [x] **Phase 3: Service Catalog & Quote Builder** - Catalogo servizi riutilizzabile e costruttore preventivi (admin-only, cliente vede solo il totale) +- [x] **Phase 4: Progetti — Multi-Project per Cliente** - Modello dati multi-progetto per cliente; dashboard con tabs; /admin/projects; slug link; analytics €/h - [x] **Phase 5: Offer System** - Catalogo macro/micro offerte con servizi assegnabili, assegnazione offerte a progetti, dashboard cliente con offerte attive e revenue forecast 12 mesi per l'admin -- [ ] **Phase 6: Claude AI Onboarding (v2)** - Flusso guidato step-by-step per onboarding cliente e generazione assistita del piano/preventivo +- [x] **Phase 6: UX Overhaul** - Admin sidebar + dashboard operativa con KPI e activity feed + +**v2.0 (Planning):** +- [ ] **Phase 7: Unified Service Catalog** - Migrazione `service_catalog` + `offer_services` → unica tabella `services`; zero data loss, audit trail per rollback +- [ ] **Phase 8: Offer Phases & Quote Templates** - Tabelle `offer_phases`, `quotes`, `offer_phase_services`; struttura hierarchica offer (macro → micro → phase → services) +- [ ] **Phase 9: Quote Builder & Public Routes** - Builder preventivi (select offer + override prezzi); `/quote/[token]` pagina pubblica multistep; accettazione +- [ ] **Phase 10: CRM Pipeline** - Lead CRUD, attività, follow-up reminders; pipeline stages (5 step); activity log per lead +- [ ] **Phase 11: Auto-Provisioning on Win** - Lead → quote accept → auto-crea client + progetto (copia fasi) + 1-4 pagamenti; launch client dashboard ## Phase Details @@ -138,23 +148,94 @@ Decimal phases appear between their surrounding integers in numeric order. **UI hint**: yes **Status**: Planning complete -### Phase 7: Claude AI Onboarding (v2) -**Goal**: L'admin può usare un flusso chat guidato per onboardare un nuovo cliente e generare un piano a fasi e un preventivo assistito da Claude -**Mode:** mvp -**Depends on**: Phase 6 -**Requirements**: CLAUDE-01, CLAUDE-02, CLAUDE-03 +### Phase 7: Unified Service Catalog +**Goal**: Consolidare `service_catalog` + `offer_services` in un'unica tabella `services`; fondazione per offer builder e quote generator +**Mode:** migration +**Depends on**: Phase 5 (offer_services exists) +**Requirements**: CAT-U-01, CAT-U-02 **Success Criteria** (what must be TRUE): - 1. L'admin avvia il flusso Claude inserendo il brief del cliente; Claude guida step-by-step la raccolta delle informazioni necessarie - 2. Al termine del flusso, Claude propone un piano strutturato per fasi che l'admin può accettare o modificare prima di salvarlo - 3. Claude suggerisce un preventivo basato sul catalogo servizi; l'admin approva o modifica le voci prima della finalizzazione -**Plans**: TBD -**UI hint**: yes -**Status**: Pending planning (v2 — may defer indefinitely) + 1. Una sola tabella `services` con nome, prezzo_unitario, attivo, created_at + 2. Migrazione zero data loss: campi audit (`migrated_from`, `migrated_id`) abilitano rollback sicuro + 3. `/admin/catalog` list/add/edit/soft-delete funzionante con nuova tabella + 4. Query vecchie servizi falliscono esplicitamente (no silent fallback) +**Plans**: 1 (schema migration + admin page) +**Durata**: 3–4 giorni +**Status**: Pending planning +**Risk**: SQL deduplication logic; validate on staging before prod + +### Phase 8: Offer Phases & Quote Templates +**Goal**: Struttura hierarchica offer (macro → micro → phase → services) con drag&drop builder +**Mode:** schema + ui +**Depends on**: Phase 7 +**Requirements**: OFFER-B-01, OFFER-B-02, OFFER-B-03 +**Success Criteria** (what must be TRUE): + 1. Nuove tabelle: `offer_phases`, `quotes`, `offer_phase_services` + 2. `/admin/offers/[id]/edit` builder two-column: left servizi (ricerca), right fasi con drag&drop + 3. Drag&drop tra fasi e dentro fase (reorder); salva per singolo drop con optimistic update + 4. Versioning previene race condition da multi-tab edit (409 Conflict su version mismatch) +**Plans**: 2 (schema + builder UI) +**Durata**: 2–3 giorni +**Status**: Pending planning + +### Phase 9: Quote Builder & Public Routes +**Goal**: Generazione preventivi 2-click (select offer → override prezzi → link pubblico); pagina multistep pubblico per lead +**Mode:** UI + routes +**Depends on**: Phase 8 +**Requirements**: QUOTE-01, QUOTE-02, QUOTE-03, QUOTE-04, QUOTE-05 +**Success Criteria** (what must be TRUE): + 1. `/admin/quotes/new`: select cliente + 1-3 offerte + override prezzi → genera `/quote/[token]` link pubblico + 2. Pagina pubblica `/quote/[token]` multistep (Step 1 overview → Step 2 tier selection → Step 3 summary + accept) + 3. Accept CTA → `/api/public/quote/accept?token=X` con cattura email + note + 4. Token: nanoid 21 char, unico, rate limit 3 views/min per IP + 5. Mai esporre `quote_items` (prezzi per servizio) via public API +**Plans**: 3 (quote builder + public routes + security) +**Durata**: 4–5 giorni +**Status**: Pending planning +**Risk**: RHF + React 19 compatibility (dev test prima del merge); PDF generation approach TBD + +### Phase 10: CRM Pipeline & Activity Logging +**Goal**: Lead CRUD, activity log, follow-up reminders, quote assignment; pipeline stages +**Mode:** UI + CRUD +**Depends on**: Phase 5 (project_offers) + Phase 9 (quotes) +**Requirements**: CRM-01 through CRM-07 +**Success Criteria** (what must be TRUE): + 1. `/admin/leads` list (name, email, company, status, last_contact_date, next_action) + 2. `/admin/leads/[id]` detail: profilo, activity log reverse-chronological, quote(s) sent, action menu + 3. Activity types: call, email, meeting, note; auto-aggiorna lead.last_contact_date + 4. Pipeline stages: Contacted → Qualified → Proposal Sent → Negotiating → Won / Lost + 5. Follow-up widget in dashboard: "Follow up today" (leads dove last_contact_date < oggi - 7 giorni) + 6. "Log activity" modal: <10 sec UX (type dropdown, date picker, notes, save) + 7. "Send Quote" button: select/create quote → update lead.status → "Proposal Sent" +**Plans**: 3 (lead CRUD + activity logging + follow-up widget) +**Durata**: 3–4 giorni +**Status**: Pending planning + +### Phase 11: Auto-Provisioning on Win +**Goal**: Lead → quote accept → auto-crea client + progetto (copia fasi) + 1-4 pagamenti; lancia dashboard cliente +**Mode:** automation + integration +**Depends on**: Phase 9 (quotes) + Phase 10 (CRM) +**Requirements**: WIN-01 through WIN-06 +**Success Criteria** (what must be TRUE): + 1. "Mark as Won" button in lead detail; idempotency: call 2x = stesso client token + 2. `winLead(leadId, quoteId)`: create client (token=nanoid) + project (name=company) + copy offer phases → project.phases + 3. 1-4 pagamenti: user sceglie split template (50/50, 3-part, 4-part, custom); registra accepted_total + 4. Generate client link + email (copy-paste MVP; email automation deferred Phase 12+) + 5. Quote accept da public `/quote/[token]` auto-trigga win (auto-onboarding) + 6. Copy offer phases: status="upcoming", phases modificabili dopo (template immutabile, instance editable) +**Plans**: 2 (win action + email + BullMQ optional) +**Durata**: 2–3 giorni +**Status**: Pending planning +**Risk**: Idempotency key implementation; Redis + BullMQ ops (persistent reminders optional MVP) + +### Phase 12+: Deferred Features (Future Milestones) + +- **Phase 12: Email Automation & Polish** — Send quote link via email, email reminders, lead de-duplication UI +- **Phase 13: Claude AI Onboarding (v3)** — Chat-guided client onboarding, assisted quote generation, plan suggestion ## Progress -**Execution Order:** -Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 +**v1.0 Execution (Complete):** +Phases 1 → 2 → 3 → 4 → 5 executed in order. Phase 6 (UX Overhaul) executed May 31. | Phase | Plans | Status | Completed | |-------|-------|--------|-----------| @@ -163,5 +244,20 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 | 3. Service Catalog & Quote Builder | 4/4 | ✅ Done | 2026-05-19 | | 4. Progetti — Multi-Project per Cliente | 5/5 | ✅ Done | 2026-05-23 | | 5. Offer System | 4/4 | ✅ Done | 2026-05-30 | -| 6. UX Overhaul — Sidebar + Dashboard | 0/2 | Pending | - | -| 7. Claude AI Onboarding (v2) | 0/TBD | Pending | - | +| 6. UX Overhaul — Sidebar + Dashboard | 2/2 | ✅ Done | 2026-05-31 | + +**v2.0 Execution (Planning):** +Phases 7 → 8 → 9 → 10 → 11 planned for sequential execution. Research complete (2026-06-11), requirements/roadmap finalized. Ready for phase-by-phase planning + execution. + +| Phase | Plans | Status | Estimated Duration | Blocking? | +|-------|-------|--------|-------------------|-----------| +| 7. Unified Service Catalog | 1 | Pending planning | 3–4 giorni | Foundation | +| 8. Offer Phases & Quote Templates | 2 | Pending planning | 2–3 giorni | Foundation | +| 9. Quote Builder & Public Routes | 3 | Pending planning | 4–5 giorni | RHF compat test | +| 10. CRM Pipeline & Activity Logging | 3 | Pending planning | 3–4 giorni | No | +| 11. Auto-Provisioning on Win | 2 | Pending planning | 2–3 giorni | BullMQ ops (optional) | +| **Total v2.0 MVP** | **11** | Pending | **14–19 giorni** | — | + +**v2.0 Deferred (Phase 12+):** +- Phase 12: Email Automation & Polish +- Phase 13: Claude AI Onboarding (v3) diff --git a/.planning/STATE.md b/.planning/STATE.md index 1599bfd..f4a3583 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,17 +1,16 @@ --- gsd_state_version: 1.0 -milestone: v1.0 -milestone_name: milestone -status: completed -stopped_at: Phase 5 fully executed — Offer System live on hub.iamcavalli.net -last_updated: "2026-05-30" -last_activity: 2026-05-30 -- Phase 5 confirmed complete (05-01 through 05-04) +milestone: v2.0 +milestone_name: Business Operations Suite +status: planning +last_updated: "2026-06-10T20:03:26.545Z" +last_activity: 2026-06-10 progress: - total_phases: 5 - completed_phases: 5 - total_plans: 21 - completed_plans: 21 - percent: 100 + total_phases: 0 + completed_phases: 0 + total_plans: 0 + completed_plans: 0 + percent: 0 --- # Project State @@ -24,9 +23,10 @@ See: .planning/PROJECT.md (updated 2026-05-09) ## Current Position -Phase: 5 — COMPLETE -Plan: All plans done (05-01 through 05-04) -Status: Production ready — deploy pending +Phase: Not started (defining requirements) +Plan: — +Status: Defining requirements +Last activity: 2026-06-10 — Milestone v2.0 started ## What Was Built (Phase 5) @@ -76,3 +76,7 @@ Status: Production ready — deploy pending Last session: 2026-05-27 All Phase 4 plans complete. Next: Authelia infra or new feature scope. + +## Operator Next Steps + +- Start the next milestone with /gsd-new-milestone diff --git a/.planning/research/PITFALLS_CHECKLIST.md b/.planning/research/PITFALLS_CHECKLIST.md new file mode 100644 index 0000000..eb6bd7c --- /dev/null +++ b/.planning/research/PITFALLS_CHECKLIST.md @@ -0,0 +1,210 @@ +# Pitfalls Checklist: Business Operations Suite v2.0 + +Quick reference for preventing the 9 major pitfalls during Phase 7–9 implementation. + +--- + +## Critical Pitfalls (Before Code) + +### 1. Catalog Consolidation Breaks Quotes +- [ ] Migration strategy documented (Expand-Contract, not drop) +- [ ] Mapping layer between old/new tables designed +- [ ] Backfill script tested on production backup +- [ ] Referential integrity test query written +- [ ] Dual-write period defined (2-3 weeks) +- [ ] Rollback plan documented (drop new table, revert code) + +### 2. Offer Template Mutation +- [ ] Offer/project hierarchy mapping documented (1 page) +- [ ] Copy function uses `db.transaction()` (all-or-nothing) +- [ ] Uses `structuredClone()` for deep copy, not `{...spread}` +- [ ] Idempotency key added to CRM leads table +- [ ] Integration test: copy offer, verify structure consistent across instances +- [ ] Test: edit project phase, verify offer template unchanged + +### 3. Public Quote Token Leakage +- [ ] Token length ≥ 32 chars (nanoid(32) or nanoid(64)) +- [ ] Expiration field added (default 7 days) +- [ ] Email validation on public page (token + email required) +- [ ] Rate limiting implemented (max 3 views/token/minute) +- [ ] Code audit: quote_items NOT in public API response +- [ ] Test: brute-force 1000 guesses, verify rate limit activates +- [ ] Test: enumerate tokens with different emails, verify 401 + +--- + +## Moderate Pitfalls (During Implementation) + +### 4. Drag-and-Drop Race Condition +- [ ] `version` field added to offer_phases +- [ ] Update mutation checks version (returns 409 if mismatch) +- [ ] Server recomputes all sort_orders atomically +- [ ] Frontend handles 409 Conflict (refresh UI) +- [ ] Test: concurrent drag in 2 tabs, verify final state correct + +### 5. CRM "Win" Double-Click +- [ ] Idempotency key added to crm_leads (unique) +- [ ] "Win" mutation checks if key already processed +- [ ] All sub-steps in single `db.transaction()` +- [ ] Button disabled until success +- [ ] Idempotency key stored in localStorage +- [ ] Test: double-click "Win", verify only 1 client created + +### 6. Offer Copy Missing Tasks/Deliverables +- [ ] Mapping documented: offer_micro → project_phase → project_task → deliverable +- [ ] Copy function recursive (phases → tasks → deliverables) +- [ ] Validation test: assert counts match expected +- [ ] Integration test exists (copy, verify structure) +- [ ] Query test: no orphaned deliverables (task_id IS NULL) + +--- + +## Minor Pitfalls (Polish) + +### 7. Offer State Not Synced Between Tabs +- [ ] `version` field on offer_micros +- [ ] Update returns 409 on version mismatch +- [ ] UI shows "Offer changed, reload?" dialog + +### 8. CRM Schema Backward Incompatibility +- [ ] New columns added as NULLABLE +- [ ] Code handles NULL defensively (e.g., `lead.budget ?? 0`) +- [ ] Backfill script written (sets defaults for existing rows) +- [ ] NOT NULL constraint added only after backfill + validation +- [ ] Test: production data copied to test db, code runs without errors + +### 9. CRM Scope Creep +- [ ] Must-have features defined for v2.0 (lead pipeline, quote, auto-onboard) +- [ ] Should-have features listed for v2.1+ (email, calls, source) +- [ ] Won't-have features documented (team, multi-user, integrations) +- [ ] Scope document signed off by product +- [ ] Success metric defined: "Can go from lead to won project in <30 min" + +--- + +## Phase 7 Checklist (Catalog & Offers) + +**Before code:** +- [ ] Catalog consolidation migration plan reviewed +- [ ] Offer/project hierarchy documented +- [ ] Drag-drop version/locking strategy designed + +**During implementation:** +- [ ] Migration backfill tested on production backup +- [ ] Drag-drop version field added +- [ ] Offer phase copy function atomic (transaction) +- [ ] Idempotency key on CRM leads +- [ ] Integration tests for copy consistency + +**Before go-live:** +- [ ] Dry-run migration on production DB +- [ ] Verify referential integrity (no orphaned quote_items) +- [ ] Concurrent drag-drop test passes +- [ ] Offer copy count validation passes + +--- + +## Phase 8 Checklist (Public Quote Pages) + +**Before code:** +- [ ] Token security requirements finalized +- [ ] API response schema designed (no quote_items) +- [ ] Rate limiting strategy defined + +**During implementation:** +- [ ] Token generation (nanoid(32)) +- [ ] Expiration + email validation +- [ ] Rate limiting middleware +- [ ] Code audit for quote_items exposure +- [ ] Brute-force test (>1000 guesses/sec) + +**Before go-live:** +- [ ] Brute-force test activates rate limit +- [ ] Enumeration test returns 401 for wrong email +- [ ] Expiration test returns 401 after expiration +- [ ] Code review: quote_items not in response + +--- + +## Phase 9 Checklist (CRM Won Automation) + +**Before code:** +- [ ] Scope document (Must/Should/Could/Won't) reviewed +- [ ] Idempotency strategy designed +- [ ] Payment plan algorithm defined +- [ ] Offer snapshot structure (JSONB) designed + +**During implementation:** +- [ ] Idempotency key on crm_leads +- [ ] "Win" mutation atomic (client + project + phases + payments) +- [ ] Offer snapshot stored on win +- [ ] Button disabled until success +- [ ] Payment consistency tests + +**Before go-live:** +- [ ] Double-click test: only 1 client created +- [ ] Network timeout test: retry returns same project +- [ ] Partial failure test: rollback on error +- [ ] Payment consistency test: correct number created +- [ ] Idempotency test: 10x "Win" with same key = 1 client + +--- + +## Key Questions Before Each Phase + +**Phase 7:** +- ✓ Is catalog consolidation migration safe (Expand-Contract, dual-write)? +- ✓ Does offer/project hierarchy mapping make sense (1 micro = 1 phase)? +- ✓ Is copy function atomic (all-or-nothing)? +- ✓ Do we have integration tests for offer phase structure consistency? + +**Phase 8:** +- ✓ Is token length ≥ 32 chars and properly random? +- ✓ Do we validate both token AND email on public page? +- ✓ Is rate limiting active (max 3 views/token/min)? +- ✓ Can we prove quote_items never appears in public response? + +**Phase 9:** +- ✓ Is "Win" action atomic (transaction)? +- ✓ Does idempotency key prevent duplicates on retry? +- ✓ Is button disabled until success? +- ✓ Have we defined Must/Should/Won't scope with user? + +--- + +## Testing Validation Matrix + +| Pitfall | Test Case | Expected Outcome | Status | +|---------|-----------|------------------|--------| +| 1. Catalog consolidation | Backfill migration on prod backup | Zero orphaned quote_items | [ ] | +| 2. Offer template mutation | Edit project phase, check offer unchanged | Offer template immutable | [ ] | +| 3. Token leakage | Brute-force 1000 guesses/sec | Rate limit activates (429) | [ ] | +| 3. Token leakage | Enumerate with wrong email | Returns 401 Unauthorized | [ ] | +| 4. Drag-drop race | Concurrent drag in 2 tabs | Final sort_order is coherent | [ ] | +| 5. Double-click clients | Click "Win" twice | Only 1 client created | [ ] | +| 5. Double-click clients | Network timeout + retry | Same project returned | [ ] | +| 6. Missing deliverables | Copy offer to project | All tasks and deliverables copied | [ ] | +| 8. Schema backward compat | Copy production data to test DB | No crashes, NULL handled | [ ] | +| 9. Payment consistency | "Win" with partial failure | Rollback, no orphaned payments | [ ] | + +--- + +## Red Flags During Development + +🚨 **Stop and review if:** +- Offer/project hierarchy mapping is unclear (should be 1 page, explicit) +- Drag-drop sort_order updates without version check +- "Win" mutation has any non-transactional steps (client created outside txn) +- Copy function uses shallow spread (`{...obj}`) instead of `structuredClone()` +- Token length < 32 chars +- Public API response includes quote_items or per_service_price +- CRM leads table has no idempotency_key column +- Schema changes add NOT NULL without backfill test +- "Win" button not disabled until response received +- No rate limiting on public quote page + +--- + +**Owner:** Development team +**Review:** Before each phase kickoff +**Update:** As testing results come in diff --git a/.planning/research/PITFALLS_SUMMARY.md b/.planning/research/PITFALLS_SUMMARY.md new file mode 100644 index 0000000..cf80822 --- /dev/null +++ b/.planning/research/PITFALLS_SUMMARY.md @@ -0,0 +1,302 @@ +# Pitfalls Research Summary: Business Operations Suite (v2.0) + +**Project:** ClientHub v2.0 — Adding Business Operations Suite to Production +**Research Date:** 2026-06-10 +**Confidence:** HIGH (domain-specific research backed by schema review + security analysis) + +--- + +## Overview + +Adding a complex Business Operations Suite (catalog consolidation + offer builder + public quotes + CRM automation) to a production app with real client data creates integration risks not present when building from scratch. This research documents the most dangerous pitfalls (rewrites/data loss) and operational friction points, with concrete prevention strategies for each. + +**Key insight:** Most pitfalls cluster in four areas of high integration complexity: +1. **Schema consolidation** — breaking existing quote_items referential integrity +2. **Template-to-instance copy semantics** — offer phases mutating templates +3. **Optimistic UI + concurrent edits** — sort order conflicts +4. **Multi-step workflow atomicity** — CRM automation creating duplicates or partial state + +--- + +## Critical Pitfalls (Require Design Changes Before Code) + +### Pitfall 1: Catalog Consolidation Breaks Quotes (Data Loss Risk) + +**Severity:** CRITICAL — Can orphan quote_items, break billing calculations + +**Problem:** +Two parallel service tables (`service_catalog` for costs, `offer_services` for marketing pricing) need consolidation. Naive merge breaks `quote_items` referential integrity or loses price history. + +**Prevention Strategy:** +- **Expand-Contract migration:** Add new unified `services` table alongside old ones, backfill in phases, only drop old tables after validation +- **Immutable snapshot:** Store `quote_snapshot: JSONB` in project_offers (freeze prices at time of win) +- **Referential integrity test:** Query for orphaned quote_items before/after migration +- **Backward compatibility:** Old quotes read from old tables, new quotes use new table, transition over 2-3 weeks + +**Phase:** 7 (Catalog & Offers) — Plan migration design BEFORE any code changes + +**Validation checklist:** +- [ ] Migration mapping layer documented (catalog ↔ services ↔ offer_services) +- [ ] Backfill strategy written (small batches, checksum validation) +- [ ] Dual-write period defined (how long to run old+new in parallel) +- [ ] Referential integrity test query exists + +--- + +### Pitfall 2: Offer Template Mutation on Copy (Data Corruption Risk) + +**Severity:** CRITICAL — Templates silently mutate when project phases edited + +**Problem:** +Copying offer phases to project via shallow copy = shared references. Admin edits project phase, template phase mutates too. Next deal uses corrupted template. Also: partial copies on retry create duplicate phases. + +**Prevention Strategy:** +- **Deep copy with atomic transaction:** Use `db.transaction()` for entire copy operation (all-or-nothing); copy at database level, not in JS +- **Immutable template flag:** `offer_micros.is_template = true`, prevent UPDATE on templates +- **Idempotency key:** "Win" request includes idempotency key; retry returns same project (no duplicates) +- **Explicit hierarchy mapping:** Document what offer_micro → project_phase, what offer_service → project_task/deliverable + +**Phase:** 7 (Offers) for design, 9 (CRM) for "Win" automation + +**Validation checklist:** +- [ ] Hierarchy mapping documented (offer ↔ project structures) +- [ ] Atomic copy function implemented (single transaction, all-or-nothing) +- [ ] Idempotency key added to CRM leads +- [ ] Integration tests verify copy structure consistency across multiple instances + +--- + +### Pitfall 3: Public Quote Token Leakage (Security Risk) + +**Severity:** CRITICAL — Token enumeration exposes all pricing + +**Problem:** +Public quote page with nanoid 21-char token can be brute-forced or enumerated. Attacker builds pricing database, breaches commercial confidentiality. + +**Prevention Strategy:** +- **Longer token:** nanoid(32) instead of 21 (~190 bits vs ~122 bits entropy) +- **Access control:** Require both token + email (validate recipient match) +- **Expiration:** token_expires_at (default 7 days) +- **Rate limiting:** Max 3 views/token/minute (blocks enumeration) +- **Never expose quote_items:** Public API response excludes line items, shows TOTAL PRICE ONLY +- **Activation state:** Token valid only AFTER admin sends it (not auto-generated on create) + +**Phase:** 8 (Public Quote Pages) — Implement security controls IN PARALLEL with feature + +**Validation checklist:** +- [ ] Token length ≥ 32 chars, rate limiting ≥ 3/min +- [ ] Email validation on public page (token + email both required) +- [ ] Expiration enforced (test expired token returns 401) +- [ ] Code audit: quote_items never in public API response +- [ ] Brute-force test: verify rate limit activates + +--- + +## Moderate Pitfalls (Major Refactoring / Data Inconsistency) + +### Pitfall 4: Drag-and-Drop Sort Order Race Condition + +**Severity:** MODERATE — Phases display in wrong order, user frustration + +**Problem:** +Concurrent drag-and-drop edits in multiple tabs cause sort_order conflicts. Last write wins, earlier update is lost. No version conflict detection. + +**Prevention Strategy:** +- **Optimistic locking:** Add `version` field to offer_phases, update only if version matches (return 409 Conflict if mismatch) +- **Server-side recomputation:** Don't trust client's sort_order, recompute all orders atomically based on actual position +- **Optimistic UI with reconciliation:** Update frontend instantly, reconcile with server result (409 = refresh) + +**Phase:** 7 (Offer Builder — Drag & Drop) + +**Validation checklist:** +- [ ] `version` field added to offer_phases +- [ ] Update mutation includes version check +- [ ] Server recomputes all sort_orders atomically +- [ ] Concurrent edit test exists (open 2 tabs, drag in both) + +--- + +### Pitfall 5: CRM "Win" Double-Click Creates Duplicate Clients + +**Severity:** MODERATE — Duplicate clients, broken billing, audit confusion + +**Problem:** +Multi-step "Win" automation (create client → project → phases → payments) has no idempotency. Double-click creates two clients, two projects. Network timeouts are invisible to user. + +**Prevention Strategy:** +- **Idempotency key:** Every lead has unique idempotency_key; "Win" request checks if key already processed (if yes, return existing client_id) +- **Atomic transaction:** All steps (client, project, phases, payments) in single transaction — all succeed or all rollback +- **UI feedback:** Button disabled until success (prevents accidental double-click) +- **Browser persistence:** Store idempotency_key in localStorage, preserve across refresh + +**Phase:** 9 (CRM Won Automation) + +**Validation checklist:** +- [ ] CRM leads table has idempotency_key column (unique) +- [ ] "Win" mutation checks for existing key before creating +- [ ] All substeps in single db.transaction() +- [ ] Button disabled until response received +- [ ] Idempotency key stored in localStorage +- [ ] Test: double-click "Win", verify only 1 client created + +--- + +### Pitfall 6: Offer Phase Copy Misses Tasks/Deliverables + +**Severity:** MODERATE — Client dashboard shows incomplete phases (no tasks to approve) + +**Problem:** +Copy offer → project phases but forget to copy tasks or deliverables. Client sees phase with no work items. + +**Prevention Strategy:** +- **Explicit mapping:** Document offer_micro → project_phase, offer_service → project_task/deliverable mapping +- **Full-tree copy function:** Copy phases, then tasks for each phase, then deliverables for each task (all in one transaction) +- **Validation test:** Assert phase_count, task_count, deliverable_count match expected values after copy + +**Phase:** 7 (Offers structure design), 9 (Copy implementation) + +**Validation checklist:** +- [ ] Mapping documented (offer vs project hierarchies) +- [ ] Copy function handles entire tree (phases → tasks → deliverables) +- [ ] Integration test: copy offer, verify counts match expected +- [ ] Query test: check no deliverables are orphaned (task_id IS NULL) + +--- + +## Minor Pitfalls (Operational Friction) + +### Pitfall 7: Offer State Not Synced Between Tabs + +**Problem:** Admin edits offer in tab 1, tab 2 doesn't know, last save wins (first admin's work lost) + +**Prevention:** Add `version` field, return 409 on version mismatch, show "Offer changed, reload?" + +--- + +### Pitfall 8: CRM Schema Backward Incompatibility + +**Problem:** Add new fields (budget, source) as NULL, code assumes populated, crashes + +**Prevention:** Expand-Contract pattern — add as NULL, handle NULL defensively in code, backfill, then add NOT NULL constraint + +--- + +### Pitfall 9: CRM Scope Creep (Feature Bloat) + +**Problem:** "Just add email templates" → 30 hours. "Add calls" → 40 hours. CRM never ships. + +**Prevention:** Explicit Must/Should/Could/Won't scope document. Must-haves only for v2.0: lead pipeline, quote attachment, auto-onboarding. Defer rest. + +--- + +## Key Design Decisions for Phase 7–9 + +| Decision | Why | Validation | +|----------|-----|-----------| +| Expand-Contract migration for catalog | Zero-downtime, safe rollback, no data loss | Dry-run on prod backup before go-live | +| Deep copy + atomic transaction for offer phases | Prevents template mutation, partial copies | Integration test, structure consistency query | +| Idempotency key on "Win" action | Safe retry, prevents duplicate clients | Double-click test, query for duplicates | +| Public quote token security layers (length, expiration, email, rate limit) | Blocks enumeration, limits leakage | Brute-force test, code audit for quote_items | +| Offer snapshot (immutable JSONB) | Preserves what was promised vs. what's executing | Store at win time, display in client dashboard | +| Version field on offer_phases + optimistic locking | Detects concurrent edit conflicts | Concurrent drag test, 409 Conflict handling | + +--- + +## Roadmap Implications + +**Phase 7 (Catalog & Offers):** +- Design catalog consolidation migration before any code +- Implement drag-drop with version fields from start +- Document offer/project hierarchy mapping explicitly +- Write copy function atomically (no partial copies) + +**Phase 8 (Public Quote Pages):** +- Implement token security controls in parallel (not after) +- Rate limiting + email validation + expiration +- Code audit: quote_items never in response +- Test brute-force + enumeration + +**Phase 9 (CRM — Won Automation):** +- Add idempotency_key to leads table +- Implement atomic "Win" transaction +- Store offer snapshot at win time +- Define Must/Should/Won't scope before design +- Integration tests for payment consistency + +**Phase 10+ (Future):** +- Defer: email templates, call logging, team features, integrations +- Solo consultant doesn't need multi-user or GoHighLevel-scale features + +--- + +## Testing Strategy for Confidence + +### Phase 7 (Catalog & Offers) +1. Dry-run consolidation migration on production database backup +2. Verify all quote_items still resolve to services (no orphans) +3. Regenerate quotes from old projects, compare prices to originals +4. Concurrent drag-drop test: open offer in 2 tabs, drag in both, verify final state +5. Copy offer phases to test project, verify phase/task/deliverable counts + +### Phase 8 (Public Quote Pages) +1. Brute-force token space: 1000 guesses/sec, verify rate limit activates +2. Enumerate tokens: generate 100 quotes, try to access one meant for different email, verify 401 +3. Expiration test: set token_expires_at to past, verify access fails +4. Code audit: search response JSON for price, quote_items, per_service_price (should be zero matches) +5. Authorized access test: correct email + token, verify success + +### Phase 9 (CRM — Won Automation) +1. Double-click "Win" button, verify only 1 client created +2. Network failure during "Win": simulate timeout after client creation, retry, verify same project returned +3. Partial failure: mock project creation failure, verify client not created (rollback) +4. Payment consistency: verify 2–4 payments created based on payment plan +5. Idempotency: call "Win" 10x with same idempotency_key, verify only 1 project, 1 set of payments + +--- + +## Confidence Assessment + +| Area | Level | Reason | +|------|-------|--------| +| Catalog consolidation risks | HIGH | Schema review shows existing quote_items dependencies; migration pattern verified via Drizzle docs | +| Offer copy semantics | HIGH | JavaScript shallow copy / deep copy distinction well-understood; transaction guarantees verified | +| Token security | HIGH | Brute-force math straightforward; nanoid 32 vs 21 entropy difference verified | +| CRM idempotency | HIGH | Idempotency pattern researched across multiple sources; double-click problem well-documented | +| Scope creep prevention | MEDIUM | CRM feature bloat is common, but solo consultant constraint makes Must/Should/Won't achievable | +| Concurrent edit handling | MEDIUM | Optimistic locking pattern standard, but requires careful implementation in Next.js + Drizzle | + +--- + +## Next Steps for Phase Planning + +1. **Before Phase 7 design starts:** + - Finalize catalog consolidation migration plan (Expand-Contract timeline) + - Document offer/project hierarchy mapping (1 page, code-level documentation) + - Design offer copy function signature and transaction strategy + +2. **Before Phase 7 code starts:** + - Implement drag-drop versioning (version field + optimistic locking) + - Write integration tests for offer phase copy + +3. **Before Phase 8 design starts:** + - Token security requirements: length (32), expiration (7d), email validation, rate limit (3/min) + - API response schema: exclude quote_items, include total_price only + +4. **Before Phase 9 design starts:** + - Finalize Must/Should/Won't scope (document with user/consultant) + - Design "Win" workflow: idempotency key + atomic transaction + - Define payment plan algorithm (2–4 payments based on offer tier) + +--- + +## References for Implementation + +- **Drizzle migrations:** https://dev.to/whoffagents/zero-downtime-postgres-migrations-with-drizzle-orm-22ga +- **Idempotency pattern:** https://codefarm0.medium.com/the-double-click-problem-how-idempotency-saved-our-checkout-system-a704be65d207 +- **Optimistic updates:** https://www.nirtamir.com/articles/optimistic-updates-state-vs-render/ +- **Schema evolution:** https://www.dataexpert.io/blog/backward-compatibility-schema-evolution-guide +- **Token security:** https://workos.com/blog/oauth-common-attacks-and-how-to-prevent-them/ + +--- + +**Document location:** `/Users/simonecavalli/Vault/IAMCAVALLI/.planning/research/PITFALLS_V2.md` (detailed pitfall reference) diff --git a/.planning/research/PITFALLS_V2.md b/.planning/research/PITFALLS_V2.md new file mode 100644 index 0000000..edb05fd --- /dev/null +++ b/.planning/research/PITFALLS_V2.md @@ -0,0 +1,1269 @@ +# 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.

+ +
+ ); + } + ``` + +**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:** + +1. **Define hierarchies explicitly IN WRITING (document as code):** + ```typescript + // 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 + ``` + +2. **Explicit copy function with validation:** + ```typescript + 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; + }); + } + ``` + +3. **Validation: count check (integration test):** + ```typescript + 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)); + ``` + +4. **Test scenario in integration tests:** + ```typescript + 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: + ```sql + 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: + ```sql + 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:** + +1. **Conflict detection on save:** + - Load offer with `version` field (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?" + +2. **Session-level locking (optional, for future multi-user):** + - Add `locked_by: uuid | null` to 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" + +3. **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:** + +1. **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 + ``` + +2. **Defensive code (handles NULL gracefully):** + ```typescript + // 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); + ``` + +3. **Schema versioning:** + - Track in `settings` table: `{ key: 'schema_version', value: '9' }` + - Code checks version and handles old shapes + - Prevents schema-and-code mismatches + +4. **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 + +**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:** + +1. **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) + +2. **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) + +3. **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) + +4. **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](http://migravion.com/blog/data-migration-testing-guide) +- [Maintaining Data Integrity During Migration — Plane Blog](https://plane.so/blog/maintaining-data-integrity-during-migration) +- [Optimistic Updates: State-Based and Render-Based Approaches](https://www.nirtamir.com/articles/optimistic-updates-state-vs-render/) +- [Optimistic UI Without Lying: React 19 Blueprint](https://www.7tech.co.in/react-optimistic-ui-blueprint-conflict-safe-mutations/) +- [The Double-Click Problem: How Idempotency Saved Our Checkout System](https://codefarm0.medium.com/the-double-click-problem-how-idempotency-saved-our-checkout-system-a704be65d207) +- [Idempotency in software: Designing Systems to Safely Retry](https://emmanuelvalverderamos.substack.com/p/idempotency-in-software-designing) +- [Zero-Downtime Postgres Migrations with Drizzle ORM](https://dev.to/whoffagents/zero-downtime-postgres-migrations-with-drizzle-orm-22ga) +- [Backward Compatibility in Schema Evolution: Guide](https://www.dataexpert.io/blog/backward-compatibility-schema-evolution-guide) +- [How to Avoid Scope Creep and Misalignment When Launching MVP](https://medium.com/swlh/the-one-map-you-need-to-avoid-scope-creep-and-align-stakeholders-c4763789eeda) +- [Best CRM for Consultants & Freelancers (2026 Guide)](https://www.authencio.com/blog/best-crm-for-consultants-freelancers-guide) +- [Defending OAuth: Common Attacks and Prevention](https://workos.com/blog/oauth-common-attacks-and-how-to-prevent-them/) +- [Token Leakage Via Referer — Pentest Vulnerability Wiki](https://www.cobalt.io/vulnerability-wiki/v4-access-control/token-leakage-referer) +- [Transactional Locking to Prevent Race Conditions](https://sqlfordevs.com/transaction-locking-prevent-race-condition)