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.
+ +