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 <noreply@anthropic.com>
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 |
|
|
true |
|
|
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()updatesaccepted_atimmutably; triggers Phase 11 auto-provisioning later - Quote items never exposed; only
accepted_totaland 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.mdKey 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;
};
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:- Rate limit works: First 3 requests to /quote/[token] return 200; 4th returns 429
- Page loads:
/quote/[token]renders QuoteMultistep with quote data - Form validates: Zod schemas reject invalid email or empty required fields
- Accept works: Clicking "Accetta Preventivo" calls acceptQuote action, updates accepted_at
- Immutability enforced: Refreshing page after accept shows "Già accettato" message; re-submit returns error
- quote_items not exposed: Inspecting network response shows no quote_items in page or API data
- 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>