Files
clienthub/.planning/milestones/v2.0-phases/09-quote-builder-client-acceptance/09-03-PLAN.md
T
simone 03898f2a59 docs(planning): v2.1 milestone setup + Phase 11 context/patterns
- Archive v2.0 phases (07-10) under .planning/milestones/
- v2.1 REQUIREMENTS/ROADMAP (Phases 11-17: Offer Studio + Proposal AI)
- DESIGN-SYSTEM.md (database-view UI contract for Phases 11-14)
- Phase 11 CONTEXT, DISCUSSION-LOG, PATTERNS

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:07:06 +02:00

14 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup must_haves
09 03 execute 2
09-01
09-02
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
true
QUOTE-02
QUOTE-03
QUOTE-04
QUOTE-05
truths artifacts key_links
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
path provides contains
src/app/quote/[token]/page.tsx Public quote page entry point QuoteMultistep
path provides min_lines
src/components/public/quote/QuoteMultistep.tsx Multi-step form state and step navigation 100
path provides pattern
src/middleware.ts Token validation and rate limiting for /quote/[token] quote.*token
path provides exports
src/lib/rate-limit.ts Rate limit check function (3 views/min per IP)
checkRateLimit
path provides exports
src/app/quote/[token]/actions.ts acceptQuote server action
acceptQuote
from to via pattern
src/middleware.ts src/lib/rate-limit.ts rate limit check checkRateLimit
from to via pattern
src/app/quote/[token]/page.tsx src/components/public/quote/QuoteMultistep.tsx import and render import.*QuoteMultistep
from to via pattern
src/components/public/quote/QuoteMultistep.tsx src/app/quote/[token]/actions.ts acceptQuote server action call await acceptQuote
from to via pattern
src/app/quote/[token]/actions.ts src/db/schema.ts quote update (accepted_at) 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

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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):

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):

type AcceptQuoteInput = {
  token: string;
  email?: string;
  notes?: string;
};

Quote Accept Response:

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<string, { count: number; resetAt: number }>

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:

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

<threat_model>

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

</threat_model>

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

<success_criteria>

  • /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 </success_criteria>
After execution, create `.planning/phases/09-quote-builder-client-acceptance/09-03-SUMMARY.md`