03898f2a59
- 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>
374 lines
14 KiB
Markdown
374 lines
14 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
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
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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):
|
|
```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;
|
|
};
|
|
```
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Add rate limiting to middleware and token validation</name>
|
|
<files>src/middleware.ts, src/lib/rate-limit.ts <action>
|
|
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+).
|
|
</action>
|
|
<verify>
|
|
<automated>npm run build 2>&1 | grep -c "error TS" | xargs test 0 -eq && echo "✓ Middleware compiles"</automated>
|
|
</verify>
|
|
<done>Rate limit utility and middleware protection added. Public quote route enforces 3 views/min per IP. Returns 429 if exceeded.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create multistep form components (Steps 1-3)</name>
|
|
<files>src/components/public/quote/QuoteMultistep.tsx, src/components/public/quote/QuoteStep1Overview.tsx, src/components/public/quote/QuoteStep2Selection.tsx, src/components/public/quote/QuoteStep3Summary.tsx <action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>npm run build 2>&1 | grep -c "error TS" | xargs test 0 -eq && echo "✓ Components compile"</automated>
|
|
</verify>
|
|
<done>Four multistep form components created. Form state lifted to QuoteMultistep parent. Step navigation working. On submit, form calls acceptQuote action.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Create /quote/[token] page and acceptQuote server action</name>
|
|
<files>src/app/quote/[token]/page.tsx, src/app/quote/[token]/layout.tsx, src/app/quote/[token]/actions.ts <action>
|
|
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).
|
|
</action>
|
|
<verify>
|
|
<automated>npm run build 2>&1 | grep -c "error TS" | xargs test 0 -eq && echo "✓ Page and actions compile"</automated>
|
|
</verify>
|
|
<done>Page structure and acceptQuote server action created. Token-gated route validates token, displays multistep form, accepts quote immutably.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
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
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
After execution, create `.planning/phases/09-quote-builder-client-acceptance/09-03-SUMMARY.md`
|
|
</output>
|