From a98fe7a9f3b032a3d4c8e6df6305e39d9a57b897 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Thu, 11 Jun 2026 07:06:47 +0200 Subject: [PATCH] =?UTF-8?q?docs(09):=20phase=209=20planning=20complete=20?= =?UTF-8?q?=E2=80=94=203=20plans=20in=202=20waves=20(schema=20+=20admin=20?= =?UTF-8?q?builder=20+=20public=20quote=20page)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 9 breaks into two parts: - Wave 0: Schema foundation (Phase 8) — offer_phases, quotes, quote_items tables + type system - Wave 1: Admin Quote Builder (/admin/quotes/new) — form, server action, price validation - Wave 2: Public Quote Page (/quote/[token]) — multistep form, acceptance, rate limiting Plans: - 09-01: Database schema (offer_phases, quotes, quote_items) + Drizzle migration + query layer types - 09-02: Admin builder UI (two-column form, client/offer selection, live preview) + createQuote action - 09-03: Public quote page (token-gated route, middleware rate limit 3/min, multistep wizard, acceptQuote action) Key constraints per CLAUDE.md: - quote_items NEVER exposed to client API (public query layer enforces PublicQuoteView type) - Server-side price recalculation prevents tampering (client-side total validated against item sum) - Immutability enforced: accepted_at timestamp immutable once set (DB constraint + app check) - Token: nanoid 21-char (~122-bit entropy), unique, rate-limited at middleware Estimated execution: 4-5 days. Wave 0 can run in parallel with Wave 1 (no blocking). Co-Authored-By: Claude Haiku 4.5 --- .planning/ROADMAP.md | 11 +- .../09-01-PLAN.md | 300 ++++++++++++++ .../09-02-PLAN.md | 274 +++++++++++++ .../09-03-PLAN.md | 373 ++++++++++++++++++ 4 files changed, 955 insertions(+), 3 deletions(-) create mode 100644 .planning/phases/09-quote-builder-client-acceptance/09-01-PLAN.md create mode 100644 .planning/phases/09-quote-builder-client-acceptance/09-02-PLAN.md create mode 100644 .planning/phases/09-quote-builder-client-acceptance/09-03-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 631c507..273977b 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -194,10 +194,15 @@ Decimal phases appear between their surrounding integers in numeric order. 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) +**Plans**: 3 plans in 2 waves +**Plan list**: + - [ ] 09-01-PLAN.md — Phase 8 Foundation: schema (offer_phases, quotes, quote_items) + types + validators + - [ ] 09-02-PLAN.md — Admin Quote Builder: /admin/quotes/new form + createQuote action + two-column preview + - [ ] 09-03-PLAN.md — Public Quote Page: /quote/[token] multistep form + acceptQuote + rate limiting **Durata**: 4–5 giorni -**Status**: Pending planning -**Risk**: RHF + React 19 compatibility (dev test prima del merge); PDF generation approach TBD +**Status**: ✅ Planning complete +**Risk**: RHF + React 19 compatibility (dev test prima del merge); in-memory rate limit resets on cold start (OK for MVP, upgrade to Upstash Redis in Phase 10+) + ### Phase 10: CRM Pipeline & Activity Logging **Goal**: Lead CRUD, activity log, follow-up reminders, quote assignment; pipeline stages diff --git a/.planning/phases/09-quote-builder-client-acceptance/09-01-PLAN.md b/.planning/phases/09-quote-builder-client-acceptance/09-01-PLAN.md new file mode 100644 index 0000000..8af3a7f --- /dev/null +++ b/.planning/phases/09-quote-builder-client-acceptance/09-01-PLAN.md @@ -0,0 +1,300 @@ +--- +phase: 09 +plan: 01 +type: execute +wave: 0 +depends_on: [] +files_modified: + - src/db/schema.ts + - src/lib/quote-validators.ts + - src/lib/quote-service.ts +autonomous: true +requirements: + - QUOTE-01 + - QUOTE-02 + - QUOTE-03 + - QUOTE-04 + - QUOTE-05 +--- + + +Phase 8 Foundation: Add `offer_phases`, `quotes`, and `quote_items` tables to the database schema. Establish immutability constraints and query layer patterns for public/admin separation. + +Purpose: Create the schema foundation required by Phase 9 (Quote Builder UI) and Phase 10 (CRM) phases. These tables enable quote generation, public token-gated access, and acceptance tracking with immutable `accepted_at` timestamps. + +Output: +- `offer_phases` table (phases within an offer_micro, e.g., Discovery → Strategy → Execution) +- `quotes` table (quote header with token, client, offer, total, state, accepted_at) +- Updated `quote_items` table (per Phase 8 schema, tracks line items per quote) +- TypeScript types and query layer (PublicQuoteView, safe queries that filter quote_items) +- Database migrations (drizzle-kit push) + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/REQUIREMENTS.md +@.planning/phases/09-quote-builder-client-acceptance/09-RESEARCH.md + + + + + + Task 1: Add offer_phases, quotes, quote_items tables to schema + src/db/schema.ts + +Add three new tables to the Drizzle schema after the offer_services section (after line 256): + +1. **offer_phases** table (hierarchical phases within an offer_micro): + - id: text PK, nanoid + - micro_id: FK to offer_micros (cascade delete) + - title: text, required (e.g., "Discovery", "Strategy", "Execution") + - description: text, optional + - sort_order: integer, default 0 + - created_at: timestamp, defaultNow + +2. **quotes** table (quote header, one per admin-created quote): + - id: text PK, nanoid + - client_id: FK to clients (cascade delete) — nullable for CRM leads in Phase 10 + - offer_micro_id: FK to offer_micros (restrict delete) — which offer was quoted + - token: text UNIQUE NOT NULL — nanoid 21 char, use nanoid() default (NOT the clients.token, separate token) + - state: text, default "draft" (draft | sent | viewed | accepted | rejected) + - accepted_total: numeric(10,2) NOT NULL — immutable snapshot of agreed-upon total + - accepted_at: timestamp nullable — immutable once set; NULL means not yet accepted + - client_email: text nullable — captured on accept + - client_notes: text nullable — captured on accept + - created_at: timestamp, defaultNow + - updated_at: timestamp, defaultNow (track state transitions, NOT price changes) + +3. **quote_items** table (updated from previous schema): + - id: text PK, nanoid + - quote_id: FK to quotes (cascade delete) — which quote owns this item + - offer_phase_id: FK to offer_phases (restrict delete) — which phase this service is in + - service_id: FK to services (restrict) — nullable for custom items + - quantity: numeric(10,2) NOT NULL + - unit_price: numeric(10,2) NOT NULL — snapshot of service price at quote creation time + - subtotal: numeric(10,2) NOT NULL + - custom_label: text nullable — for custom items without catalog entry + - created_at: timestamp, defaultNow + +Update relations: +- Add quotesRelations: one client (nullable), one offer_micro, many quote_items +- Add quoteItemsRelations: one quote, one offer_phase, one service (nullable) +- Add offerPhasesRelations: one micro, many quote_items +- Remove old quote_items→project relation (Phase 1-8 used projects; Phase 9+ uses quotes) + +Add TypeScript types at the end: +- export type OfferPhase = typeof offer_phases.$inferSelect +- export type NewOfferPhase = typeof offer_phases.$inferInsert +- export type Quote = typeof quotes.$inferSelect +- export type NewQuote = typeof quotes.$inferInsert + +Note: quote_items stays as is but now fk quote_id and offer_phase_id instead of project_id. Previous quote_items (if any exist via Phase 3 quote builder) remain tied to projects; new quote_items use the quotes table. Phase 9 does NOT migrate old quote_items. + + + npm run build 2>&1 | grep -c "error" | grep -q 0 && echo "TypeScript compile pass" + + Schema file updated with 3 new tables, relations added, types exported. npm run build passes. No old quote_items data touched — backward compatible. + + + + Task 2: Create Drizzle migration and validate schema + src/db/migrations + +Run drizzle-kit to auto-generate migration files: + +```bash +npx drizzle-kit generate --name=phase-8-quotes-and-phases +``` + +This creates a new migration file in src/db/migrations/ with SQL for the three new tables. The migration is NOT applied yet (Phase 9 execution will push to DB). + +Validate the generated migration: +- Ensure quotes.token has UNIQUE constraint +- Ensure quotes.accepted_at is nullable (not NOT NULL) +- Ensure offer_phases.micro_id has CASCADE delete +- Ensure quote_items columns renamed from project_id to quote_id + offer_phase_id + +If migration looks incorrect, manually edit the SQL in src/db/migrations/{migration_file}.sql to fix. + + + ls src/db/migrations/ | grep phase-8 | head -1 + + Migration file generated and located in src/db/migrations/. SQL is syntactically valid and matches schema. + + + + Task 3: Add quote validators (Zod schemas) and public query layer + src/lib/quote-validators.ts, src/lib/quote-service.ts + +Create two new library files: + +**src/lib/quote-validators.ts** — Zod schemas for quote operations: +```typescript +import { z } from "zod"; + +// Quote creation (admin builder) +export const createQuoteSchema = z.object({ + client_id: z.string().min(1, "Cliente richiesto"), + offer_micro_id: z.string().min(1, "Offerta richiesta"), + accepted_total: z.string().regex(/^\d+(\.\d{1,2})?$/, "Formato prezzo invalido"), +}); + +// Quote accept (public page, Step 3) +export const acceptQuoteSchema = z.object({ + token: z.string().length(21, "Token invalido"), + email: z.string().email("Email valida").optional().or(z.literal("")), + notes: z.string().max(500, "Max 500 caratteri").optional().or(z.literal("")), +}); + +// Quote step validation (public page Steps 1-2) +export const quoteStep2Schema = z.object({ + tier: z.enum(["A", "B", "C"]).optional(), + priceOverrides: z.record(z.string(), z.number().min(0)).optional(), +}); +``` + +**src/lib/quote-service.ts** — Query layer for quotes: +```typescript +import { eq, and, isNull } from "drizzle-orm"; +import { db } from "@/db"; +import { quotes, quote_items, offer_phases, offer_micros } from "@/db/schema"; + +// Public view: safe for client API (NO line item prices exposed) +export type PublicQuoteView = { + id: string; + token: string; + state: "draft" | "sent" | "viewed" | "accepted" | "rejected"; + accepted_total: string; + accepted_at: Date | null; + offerName: string; + phaseSummary: Array<{ + id: string; + title: string; + serviceCount: number; + }>; +}; + +// Get quote for public page (token-gated, no line items) +export async function getQuoteByToken(token: string): Promise { + const [quote] = await db + .select({ + id: quotes.id, + token: quotes.token, + state: quotes.state, + accepted_total: quotes.accepted_total, + accepted_at: quotes.accepted_at, + }) + .from(quotes) + .where(eq(quotes.token, token)) + .limit(1); + + if (!quote) return null; + + // Fetch offer name and phase summary (separately to avoid line items) + // Phase 9 will wire this up — for now, return skeleton + return { + ...quote, + offerName: "", // fetch from offer_micros via quote.offer_micro_id + phaseSummary: [], // fetch from offer_phases, count items per phase + }; +} + +// Get quote for admin (includes line items and full data) +export async function getQuoteByTokenAdmin(token: string) { + const quote = await db.query.quotes.findFirst({ + where: eq(quotes.token, token), + }); + + if (!quote) return null; + + const items = await db + .select() + .from(quote_items) + .where(eq(quote_items.quote_id, quote.id)); + + return { + ...quote, + items, + }; +} + +// Check if quote is already accepted (immutability guard) +export async function isQuoteAccepted(token: string): Promise { + const [quote] = await db + .select({ accepted_at: quotes.accepted_at }) + .from(quotes) + .where(and(eq(quotes.token, token), isNull(quotes.accepted_at).negate())) + .limit(1); + + return !!quote; +} +``` + +These are skeleton implementations. Phase 9 will flesh out the offer/phase summary logic. + + + npm run build 2>&1 | grep -c "error" | grep -q 0 && echo "Validators compile" + + Quote validators and service layer created. Schemas handle email/notes validation per WCAG. Public query layer explicitly excludes quote_items. npm run build passes. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Client → API | Token-gated route; unauthenticated quote access via unique token | +| Admin → API | Session-authenticated via Auth.js; admin actions return full quote data | +| Public Page → Database | Quote read via token; immutability enforced via DB constraint | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-09-01 | Spoofing | Token guessing / brute force | mitigate | Nanoid 21-char tokens (122-bit entropy); rate limit 3 views/min per IP via middleware (Phase 9 Task 4) | +| T-09-02 | Tampering | Quote price manipulation (client-side total) | mitigate | Server-side recalculation in acceptQuote action (Phase 9 Task 6); reject if mismatch with accepted_total | +| T-09-03 | Tampering | Quote accepted twice | mitigate | Database constraint: `accepted_at IS NOT NULL` prevents re-accept; server action checks before update (Phase 9 Task 6) | +| T-09-04 | Information Disclosure | quote_items exposure via public API | mitigate | Query layer separation: getQuoteByToken returns PublicQuoteView (no line items); admin uses getQuoteByTokenAdmin (Phase 8 complete, enforced in Phase 9) | +| T-09-05 | Information Disclosure | Quote token leaked in logs | mitigate | Never log full tokens; log only last 4 chars for debugging | +| T-09-06 | Denial of Service | Email capture spam | mitigate | Email field optional; rate limit public page (3 views/min) | +| T-09-07 | Elevation | Unauth user marks quote as accepted | mitigate | Token validation required; nanoid unguessable; middleware prevents brute force (Phase 9 Task 4) | + + + + +After Phase 8 Plan 1 execution: + +1. Schema compiles: `npm run build` passes with no TS errors +2. Drizzle migration file generated: `src/db/migrations/` contains new migration +3. Quote validators import cleanly: `import { createQuoteSchema } from '@/lib/quote-validators'` works +4. Public query layer excludes line items: `PublicQuoteView` type has no `quote_items` field +5. Relations updated: No circular dependencies; offer_phases and quote_items properly FK'd + +Data safety check: +- Old quote_items (if any from Phase 3 quote builder) still tied to projects: NOT touched +- New quote_items will use quotes table: backward compatible +- No migrations have been pushed to DB yet; Phase 9 execution will apply schema + + + +- `offer_phases`, `quotes`, `quote_items` tables defined in Drizzle schema +- TypeScript compiles cleanly; Quote/OfferPhase types exported +- Drizzle migration file generated (not yet applied to DB) +- Quote validators (Zod) handle email, notes, token, accepted_total +- Public quote query layer defined; explicitly excludes line items +- Backward compatible: old quote_items from Phase 3 remain functional +- Database schema is ready for Phase 9 UI and routes + + + +After execution, create `.planning/phases/09-quote-builder-client-acceptance/09-01-SUMMARY.md` + diff --git a/.planning/phases/09-quote-builder-client-acceptance/09-02-PLAN.md b/.planning/phases/09-quote-builder-client-acceptance/09-02-PLAN.md new file mode 100644 index 0000000..f83ce67 --- /dev/null +++ b/.planning/phases/09-quote-builder-client-acceptance/09-02-PLAN.md @@ -0,0 +1,274 @@ +--- +phase: 09 +plan: 02 +type: execute +wave: 1 +depends_on: + - 09-01 +files_modified: + - src/app/admin/quotes/new/page.tsx + - src/app/admin/quotes/new/actions.ts + - src/components/admin/quotes/QuoteBuilderForm.tsx + - src/components/admin/quotes/OfferSelector.tsx + - src/components/admin/quotes/PriceOverrideInput.tsx + - src/components/admin/quotes/QuotePreview.tsx + - src/lib/quote-actions.ts +autonomous: true +requirements: + - QUOTE-01 +user_setup: [] +must_haves: + truths: + - "Admin can select a client and offer on /admin/quotes/new" + - "Two-column form shows inputs on left and live preview on right" + - "Form submit calls createQuote server action with validated data" + - "Server action recalculates total and rejects if mismatch" + - "On success, public link /quote/[token] is displayed for copy" + artifacts: + - path: src/lib/quote-actions.ts + provides: "createQuote server action with price validation" + exports: ["createQuote", "getOfferWithPhases"] + - path: src/components/admin/quotes/QuoteBuilderForm.tsx + provides: "Form state management and submission" + min_lines: 80 + - path: src/app/admin/quotes/new/page.tsx + provides: "Admin quote builder page at /admin/quotes/new" + contains: "QuoteBuilderForm" + key_links: + - from: "src/app/admin/quotes/new/page.tsx" + to: "src/components/admin/quotes/QuoteBuilderForm.tsx" + via: "import and render" + pattern: "import.*QuoteBuilderForm" + - from: "src/components/admin/quotes/QuoteBuilderForm.tsx" + to: "src/lib/quote-actions.ts" + via: "server action call" + pattern: "await createQuote" + - from: "src/lib/quote-actions.ts" + to: "src/db/schema.ts" + via: "quote insert and validation" + pattern: "db.insert.*quotes" +--- + + +Implement the admin Quote Builder UI (`/admin/quotes/new`) with two-column form (left: inputs, right: live preview) and server actions for creating quotes. Admin selects client, offer, and overrides pricing; saves generates nanoid token and creates public link. + +Purpose: Enable admins to compose quotes in 2-3 clicks, validate pricing server-side, and generate shareable public links for clients. + +Output: +- `/admin/quotes/new` page with form and live preview +- Server action `createQuote()` validates and saves quote + quote_items to DB +- Generated public link `/quote/[token]` shareable via email (Phase 12) +- Quote saved as draft; state can be updated to "sent" when link shared + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/09-quote-builder-client-acceptance/09-RESEARCH.md +@.planning/phases/09-quote-builder-client-acceptance/09-01-SUMMARY.md + +### Key Interfaces (from Phase 8 schema) + +Quote Header: +```typescript +type Quote = { + id: string; + client_id: string | null; + offer_micro_id: string; + token: string; + state: "draft" | "sent" | "viewed" | "accepted" | "rejected"; + accepted_total: string; + accepted_at: Date | null; + client_email: string | null; + client_notes: string | null; + created_at: Date; + updated_at: Date; +}; +``` + +Quote Item (line): +```typescript +type QuoteItem = { + id: string; + quote_id: string; + offer_phase_id: string; + service_id: string | null; + quantity: string; + unit_price: string; + subtotal: string; + custom_label: string | null; +}; +``` + +OfferMicro (from Phase 5): +```typescript +type OfferMicro = { + id: string; + macro_id: string; + internal_name: string; + public_name: string; + transformation_promise: string | null; + duration_months: number; +}; +``` + +OfferPhase (from Phase 8): +```typescript +type OfferPhase = { + id: string; + micro_id: string; + title: string; + description: string | null; + sort_order: number; + created_at: Date; +}; +``` + + + + + + Task 1: Create server action for quote creation (createQuote) + src/lib/quote-actions.ts + +Create a new server action file with the core quote creation logic. Key responsibilities: + +- Validate input via Zod schema (client, offer, items array) +- Verify client and offer exist in database +- **Recalculate total server-side** by summing all quote_items subtotals (quantity × unit_price) +- Reject if client-submitted total doesn't match calculated total (0.01 tolerance for rounding) +- Generate nanoid(21) token — 21 characters, ~122-bit entropy +- Create atomic transaction: insert quote header, then insert quote_items +- Return success/error + public link `/quote/[token]` + +Security per CLAUDE.md: +- Never trust client-side pricing calculation +- Server-side recalculation is THE security boundary +- If attacker modifies total before submit, server action rejects it +- Tokens are collision-free and unguessable via nanoid + +Error handling: Localize error messages to Italian (client not found, offer not found, total mismatch). + +Use existing patterns from quote-validators.ts where applicable; reference createQuoteSchema and quoteItemSchema for Zod integration. + + + npm run build 2>&1 | grep -c "error TS" | xargs test 0 -eq && echo "✓ Actions compile" + + createQuote action handles validation, server-side total recalculation, and atomic quote+items creation. Error messages localized to Italian. Returns token and public link. + + + + Task 2: Create QuoteBuilderForm component (two-column layout) + src/components/admin/quotes/QuoteBuilderForm.tsx, src/components/admin/quotes/OfferSelector.tsx, src/components/admin/quotes/PriceOverrideInput.tsx, src/components/admin/quotes/QuotePreview.tsx +Create four new component files that make up the two-column form UI: + +**src/components/admin/quotes/QuoteBuilderForm.tsx** — Parent component managing form state: +- Uses React Hook Form + Zod for validation +- Manages three sections: client select, offer select, price overrides +- Left column: form inputs (client dropdown, offer dropdown, price fields) +- Right column: live preview (selected offer summary + calculated total) +- On form submit: call createQuote server action +- On success: display public link in a copiable text box + "Copy Link" button +- Handles form errors (display via Form/FormMessage from shadcn/ui) + +**src/components/admin/quotes/OfferSelector.tsx** — Dropdown to select offer_micro: +- Fetches all offer_macros with their offer_micros (via server component query) +- Displays as grouped dropdown: "Entry Offer" > [Entry A, Entry B, Entry C] +- On selection change: trigger preview update in parent + +**src/components/admin/quotes/PriceOverrideInput.tsx** — Input for final accepted_total: +- Single numeric input field +- On blur: parent calculates preview total and compares to this input +- Visual feedback: green checkmark if matches, red X if mismatch (but allow save — server validates) + +**src/components/admin/quotes/QuotePreview.tsx** — Right column preview: +- Displays selected offer name, public name, transformation promise +- Lists phases (Discovery, Strategy, Execution) +- For each phase, lists the services that will be included +- Shows live calculated total as admin enters price overrides +- Displays estimated revenue and duration + +All components use shadcn/ui (Form, Input, Select, Button, Card) for consistency with existing admin UI. +Use tailwind grid-cols-2 for two-column layout in QuoteBuilderForm. + + + npm run build 2>&1 | grep -c "error TS" | xargs test 0 -eq && echo "✓ Components compile" + + Four component files created. Two-column form renders with client/offer selection, price input, and live preview. Form submission calls createQuote action. + + + + Task 3: Create /admin/quotes/new page and wire form + src/app/admin/quotes/new/page.tsx, src/app/admin/quotes/new/actions.ts +Create the page file and optional actions helper: + +**src/app/admin/quotes/new/page.tsx** — Page component: +- Server component that imports QuoteBuilderForm +- Renders page title "Crea Preventivo" +- Renders QuoteBuilderForm centered in a Card +- Wraps in AdminLayout (existing pattern from /admin/clients, /admin/projects) +- Page has Auth.js guard via middleware (existing /admin/* protection) + +**src/app/admin/quotes/new/actions.ts** — Optional re-export of quote actions: +- Can be empty or re-export createQuote from lib/quote-actions.ts +- Keeps page-level action imports clean if needed by form + +The form will be a client component ("use client") managing form state with React Hook Form. + + + curl -s http://localhost:3000/admin/quotes/new 2>&1 | grep -q "Crea Preventivo" && echo "✓ Page loads" + + Page and form wired. Admin can navigate to /admin/quotes/new and see the two-column builder form. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Admin → Form Input | Form data can be manipulated before submit | +| Client Browser → Server | Pricing total can be modified in network inspector before sending | +| Server → Database | Quote must be immutable once accepted | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-09-01 | Tampering | Price total modified before submit | mitigate | Server-side recalculation in createQuote action; reject if mismatch between item sum and submitted total | +| T-09-02 | Tampering | Quote item quantity/unit_price tampered | mitigate | Zod schema validates numeric fields; server recalculates before saving | +| T-09-03 | Elevation | Non-admin access to /admin/quotes/new | mitigate | Auth.js middleware guards /admin/* routes (existing infrastructure) | +| T-09-04 | Information Disclosure | offer_micro details over-exposed | accept | Offer details are internal-facing; not visible to public (per Phase 9 Task 5) | + + + + +After Phase 9 Plan 2 execution: + +1. Server action compiles: `npm run build` passes +2. Components compile: All four components render without errors +3. Page loads: `/admin/quotes/new` accessible to authenticated admin +4. Form submits: Clicking "Salva Preventivo" calls createQuote +5. Link generated: On success, public link displayed in copyable box +6. Server validation works: Manually modifying total in browser before submit should be rejected +7. Database: Quote + quote_items inserted to DB with correct structure + + + +- `/admin/quotes/new` page accessible and renders form +- Two-column layout (left: inputs, right: preview) visually distinct +- Form validates client and offer selection (required fields) +- Form submit calls createQuote server action +- Server action validates data and recalculates total server-side +- On success: public `/quote/[token]` link displayed and copyable +- Quote saved to DB with state="draft", token unique, accepted_total immutable +- On error: user-friendly error message displayed (Italian localization) + + + +After execution, create `.planning/phases/09-quote-builder-client-acceptance/09-02-SUMMARY.md` + diff --git a/.planning/phases/09-quote-builder-client-acceptance/09-03-PLAN.md b/.planning/phases/09-quote-builder-client-acceptance/09-03-PLAN.md new file mode 100644 index 0000000..8f0b1ef --- /dev/null +++ b/.planning/phases/09-quote-builder-client-acceptance/09-03-PLAN.md @@ -0,0 +1,373 @@ +--- +phase: 09 +plan: 03 +type: execute +wave: 2 +depends_on: + - 09-01 + - 09-02 +files_modified: + - src/app/quote/[token]/layout.tsx + - src/app/quote/[token]/page.tsx + - src/app/quote/[token]/actions.ts + - src/components/public/quote/QuoteMultistep.tsx + - src/components/public/quote/QuoteStep1Overview.tsx + - src/components/public/quote/QuoteStep2Selection.tsx + - src/components/public/quote/QuoteStep3Summary.tsx + - src/middleware.ts + - src/lib/rate-limit.ts +autonomous: true +requirements: + - QUOTE-02 + - QUOTE-03 + - QUOTE-04 + - QUOTE-05 +user_setup: [] +must_haves: + truths: + - "Public `/quote/[token]` route accessible without login via token-gated middleware" + - "Multistep wizard renders three steps: overview, tier/service selection, summary + accept" + - "Rate limit enforced: 3 views per minute per IP" + - "Accept button calls server action with email + notes capture" + - "Server action validates token, updates accepted_at immutably, returns success" + - "quote_items never exposed in public API responses; only accepted_total visible" + artifacts: + - path: src/app/quote/[token]/page.tsx + provides: "Public quote page entry point" + contains: "QuoteMultistep" + - path: src/components/public/quote/QuoteMultistep.tsx + provides: "Multi-step form state and step navigation" + min_lines: 100 + - path: src/middleware.ts + provides: "Token validation and rate limiting for /quote/[token]" + pattern: "quote.*token" + - path: src/lib/rate-limit.ts + provides: "Rate limit check function (3 views/min per IP)" + exports: ["checkRateLimit"] + - path: src/app/quote/[token]/actions.ts + provides: "acceptQuote server action" + exports: ["acceptQuote"] + key_links: + - from: "src/middleware.ts" + to: "src/lib/rate-limit.ts" + via: "rate limit check" + pattern: "checkRateLimit" + - from: "src/app/quote/[token]/page.tsx" + to: "src/components/public/quote/QuoteMultistep.tsx" + via: "import and render" + pattern: "import.*QuoteMultistep" + - from: "src/components/public/quote/QuoteMultistep.tsx" + to: "src/app/quote/[token]/actions.ts" + via: "acceptQuote server action call" + pattern: "await acceptQuote" + - from: "src/app/quote/[token]/actions.ts" + to: "src/db/schema.ts" + via: "quote update (accepted_at)" + pattern: "db.update.*quotes" +--- + + +Implement the public-facing Quote Page (`/quote/[token]`) with token-gated access, rate limiting, and a multistep wizard (overview → tier selection → summary + accept). Client views quote details, accepts or rejects, and optionally provides email + notes. + +Purpose: Enable public sharing of quotes via nanoid tokens; collect client acceptance with email capture; immutably record acceptance timestamp. + +Output: +- `/quote/[token]` route accessible via unique token (no login required) +- Middleware validates token and enforces rate limit (3 views/min per IP) +- Three-step form: overview (read-only) → tier/service selection (read-only or editable) → summary + accept/reject +- Server action `acceptQuote()` updates `accepted_at` immutably; triggers Phase 11 auto-provisioning later +- Quote items never exposed; only `accepted_total` and phase/service count visible to client + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/09-quote-builder-client-acceptance/09-RESEARCH.md +@.planning/phases/09-quote-builder-client-acceptance/09-02-SUMMARY.md + +### Key Interfaces + +PublicQuoteView (from quote-service.ts Phase 9 Plan 1): +```typescript +type PublicQuoteView = { + id: string; + token: string; + state: "draft" | "sent" | "viewed" | "accepted" | "rejected"; + accepted_total: string; + accepted_at: Date | null; + offerName: string; + phaseSummary: Array<{ + id: string; + title: string; + serviceCount: number; + }>; +}; +``` + +Accept Request (Step 3 form data): +```typescript +type AcceptQuoteInput = { + token: string; + email?: string; + notes?: string; +}; +``` + +Quote Accept Response: +```typescript +type AcceptQuoteResponse = { + success: boolean; + message?: string; + error?: string; + quoteId?: string; +}; +``` + + + + + + Task 1: Add rate limiting to middleware and token validation + src/middleware.ts, src/lib/rate-limit.ts +Implement rate limiting in middleware and a reusable rate-limit utility: + +**src/lib/rate-limit.ts** — In-memory rate limit store (basic MVP; production uses Upstash Redis): + +Create a simple rate limiter that tracks requests per IP: +- RATE_LIMIT_WINDOW: 60 seconds (60,000 ms) +- RATE_LIMIT_MAX: 3 views per minute +- ipRequests: Map + +Helper function `checkRateLimit(ip: string): boolean` +- Get current time +- Look up IP in map +- If entry exists and within window: + - If count >= 3: return false (rate limit exceeded) + - Otherwise: increment count, return true +- If entry expired or missing: create new entry with count=1, resetAt=now+60000 + +Export function for use in middleware. + +**src/middleware.ts** — Update existing middleware to protect /quote/[token]: + +- Add new matcher: `/quote/:path*` +- On request to /quote/* route: + - Extract client IP from x-forwarded-for header (fallback to request.socket.remoteAddress) + - Call checkRateLimit(ip) + - If limit exceeded: return 429 Too Many Requests JSON response + - If limit ok: proceed to handler + +Keep existing patterns for /admin/* and /client/* routes intact. + +Note: This is in-memory, so it resets on serverless cold start (limitation noted in RESEARCH). For production, recommend Upstash Redis (Phase 10+). + + + npm run build 2>&1 | grep -c "error TS" | xargs test 0 -eq && echo "✓ Middleware compiles" + + Rate limit utility and middleware protection added. Public quote route enforces 3 views/min per IP. Returns 429 if exceeded. + + + + Task 2: Create multistep form components (Steps 1-3) + src/components/public/quote/QuoteMultistep.tsx, src/components/public/quote/QuoteStep1Overview.tsx, src/components/public/quote/QuoteStep2Selection.tsx, src/components/public/quote/QuoteStep3Summary.tsx +Create four React components for the public quote multistep form: + +**src/components/public/quote/QuoteMultistep.tsx** — Parent managing step state: +- "use client" component +- useState for step (1-3) and form data +- useForm from React Hook Form for shared form state across all steps +- Render appropriate step component based on current step +- Previous/Next buttons with step navigation logic +- On Step 3 submit: call acceptQuote server action + +**src/components/public/quote/QuoteStep1Overview.tsx** — Read-only overview: +- Display offer name (public_name) +- Display accepted_total as large price +- Show transformation promise +- Display phase list (count only, no pricing details) +- "Continua" button to go to Step 2 + +**src/components/public/quote/QuoteStep2Selection.tsx** — Tier/service selection (read-only in MVP): +- Show list of offer_phases with service counts per phase +- Read-only mode: just display what's included (no client edits in Phase 9) +- For Phase 10+, this becomes interactive with tier options +- Display calculated total based on offer structure +- "Continua" and "Indietro" buttons + +**src/components/public/quote/QuoteStep3Summary.tsx** — Final acceptance form: +- Summary of entire quote (offer, total, phases) +- Form fields: email (optional), notes (optional, max 500 chars) +- CTA buttons: "Accetta Preventivo" (submit) and "Rifiuta" (reject with optional note) +- On submit: call acceptQuote server action +- On success: show thank you message or redirect + +All components use shadcn/ui Form, Input, Button, Card for consistency. +Use Zod validation (acceptQuoteSchema from quote-validators.ts) for Step 3 form. + + + npm run build 2>&1 | grep -c "error TS" | xargs test 0 -eq && echo "✓ Components compile" + + Four multistep form components created. Form state lifted to QuoteMultistep parent. Step navigation working. On submit, form calls acceptQuote action. + + + + Task 3: Create /quote/[token] page and acceptQuote server action + src/app/quote/[token]/page.tsx, src/app/quote/[token]/layout.tsx, src/app/quote/[token]/actions.ts +Create page structure for public quote route: + +**src/app/quote/[token]/layout.tsx** — Public quote layout: +- No authenticated header (unlike /admin or /client layouts) +- Simple centered container with quote branding +- Display logo or company name +- No sidebar or admin navigation + +**src/app/quote/[token]/page.tsx** — Quote page entry point: +- Server component that validates token exists in database +- If token not found: return 404 or error page +- If quote already accepted: show read-only "Già accettato" message with accepted_at date +- Otherwise: fetch quote via getQuoteByToken() from quote-service.ts +- Render QuoteMultistep component with token and quote data +- Pass quote data as prop to enable form pre-fill + +**src/app/quote/[token]/actions.ts** — Accept server action: + +```typescript +"use server"; + +import { z } from "zod"; +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { quotes } from "@/db/schema"; +import { acceptQuoteSchema } from "@/lib/quote-validators"; +import { revalidatePath } from "next/cache"; + +export async function acceptQuote( + token: string, + email?: string, + notes?: string +) { + // Validate input + const parsed = acceptQuoteSchema.safeParse({ token, email, notes }); + if (!parsed.success) { + return { + success: false, + error: parsed.error.issues[0]?.message || "Dati invalidi", + }; + } + + const { token: validatedToken, email: validatedEmail, notes: validatedNotes } = parsed.data; + + try { + // Fetch quote + const [quote] = await db + .select() + .from(quotes) + .where(eq(quotes.token, validatedToken)) + .limit(1); + + if (!quote) { + return { success: false, error: "Preventivo non trovato" }; + } + + // Check if already accepted (immutability) + if (quote.accepted_at !== null) { + return { success: false, error: "Preventivo già accettato" }; + } + + // Atomic update: set accepted_at (immutable) + optional email/notes + const updated = await db + .update(quotes) + .set({ + accepted_at: new Date(), + state: "accepted", + client_email: validatedEmail || null, + client_notes: validatedNotes || null, + updated_at: new Date(), + }) + .where(eq(quotes.token, validatedToken)) + .returning(); + + if (!updated[0]) { + return { success: false, error: "Errore nel salvataggio" }; + } + + // Revalidate page to show accepted state + revalidatePath(`/quote/${validatedToken}`); + + // Phase 11 will hook into this event for auto-provisioning + // For now, just return success + return { + success: true, + message: "Preventivo accettato con successo!", + quoteId: quote.id, + }; + } catch (error) { + console.error("acceptQuote error:", error); + return { success: false, error: "Errore del server" }; + } +} +``` + +This action enforces immutability at the database level: once accepted_at is set, the quote cannot be modified by further submissions (Phase 11 will read accepted_at and auto-provision). + + + npm run build 2>&1 | grep -c "error TS" | xargs test 0 -eq && echo "✓ Page and actions compile" + + Page structure and acceptQuote server action created. Token-gated route validates token, displays multistep form, accepts quote immutably. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Client → Token URL | Token in URL; cannot be guessed; rate limited at middleware | +| Client Network → Server | Quote data is public (via token); pricing visible but immutable | +| Client Submit → Server | Email and notes are user-supplied; validated via Zod | +| Server → Database | Acceptance is immutable; accepted_at timestamp cannot be unset | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-09-08 | Spoofing | Token guessing / brute force | mitigate | Nanoid 21-char (122-bit entropy); rate limit 3 views/min per IP via middleware | +| T-09-09 | Tampering | Accept quote twice (re-submission) | mitigate | Database check: `accepted_at IS NOT NULL` before update; server action checks and rejects re-accept | +| T-09-10 | Information Disclosure | quote_items exposed via page source | mitigate | QuoteMultistep never receives quote_items; query layer filters via getQuoteByToken() | +| T-09-11 | Denial of Service | Email field spam / abuse | mitigate | Email optional; rate limit (3 views/min) limits submission volume; Zod validates email format | +| T-09-12 | Information Disclosure | Token in browser history / logs | accept | Token is sensitive but expires after accept; audit trail in accepted_at immutable timestamp | + + + + +After Phase 9 Plan 3 execution: + +1. Rate limit works: First 3 requests to /quote/[token] return 200; 4th returns 429 +2. Page loads: `/quote/[token]` renders QuoteMultistep with quote data +3. Form validates: Zod schemas reject invalid email or empty required fields +4. Accept works: Clicking "Accetta Preventivo" calls acceptQuote action, updates accepted_at +5. Immutability enforced: Refreshing page after accept shows "Già accettato" message; re-submit returns error +6. quote_items not exposed: Inspecting network response shows no quote_items in page or API data +7. Middleware protects: Requests to /quote/* are rate-limited; exceeding limit returns 429 + + + +- `/quote/[token]` route accessible via token (no Auth.js login required) +- Multistep wizard renders and navigates between steps +- Step 1: Read-only overview of quote (offer name, total, transformation promise) +- Step 2: Read-only phase/service listing (count only, no line item prices) +- Step 3: Email + notes form, Accept/Reject buttons +- Rate limit enforced: 3 views/min per IP returns 429 after limit +- acceptQuote action validates token and updates accepted_at immutably +- On success: accepted_at timestamp set; subsequent accepts rejected +- quote_items never transmitted to client; only accepted_total and phase/service summary visible +- Middleware protects route; invalid token returns 404 + + + +After execution, create `.planning/phases/09-quote-builder-client-acceptance/09-03-SUMMARY.md` +