docs(09): phase 9 planning complete — 3 plans in 2 waves (schema + admin builder + public quote page)
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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
<objective>
|
||||
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)
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/REQUIREMENTS.md
|
||||
@.planning/phases/09-quote-builder-client-acceptance/09-RESEARCH.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add offer_phases, quotes, quote_items tables to schema</name>
|
||||
<files>src/db/schema.ts</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run build 2>&1 | grep -c "error" | grep -q 0 && echo "TypeScript compile pass"</automated>
|
||||
</verify>
|
||||
<done>Schema file updated with 3 new tables, relations added, types exported. npm run build passes. No old quote_items data touched — backward compatible.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create Drizzle migration and validate schema</name>
|
||||
<files>src/db/migrations</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>ls src/db/migrations/ | grep phase-8 | head -1</automated>
|
||||
</verify>
|
||||
<done>Migration file generated and located in src/db/migrations/. SQL is syntactically valid and matches schema.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Add quote validators (Zod schemas) and public query layer</name>
|
||||
<files>src/lib/quote-validators.ts, src/lib/quote-service.ts</files>
|
||||
<action>
|
||||
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<PublicQuoteView | null> {
|
||||
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<boolean> {
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run build 2>&1 | grep -c "error" | grep -q 0 && echo "Validators compile"</automated>
|
||||
</verify>
|
||||
<done>Quote validators and service layer created. Schemas handle email/notes validation per WCAG. Public query layer explicitly excludes quote_items. npm run build passes.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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) |
|
||||
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After execution, create `.planning/phases/09-quote-builder-client-acceptance/09-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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
|
||||
</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-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;
|
||||
};
|
||||
```
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create server action for quote creation (createQuote)</name>
|
||||
<files>src/lib/quote-actions.ts</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run build 2>&1 | grep -c "error TS" | xargs test 0 -eq && echo "✓ Actions compile"</automated>
|
||||
</verify>
|
||||
<done>createQuote action handles validation, server-side total recalculation, and atomic quote+items creation. Error messages localized to Italian. Returns token and public link.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create QuoteBuilderForm component (two-column layout)</name>
|
||||
<files>src/components/admin/quotes/QuoteBuilderForm.tsx, src/components/admin/quotes/OfferSelector.tsx, src/components/admin/quotes/PriceOverrideInput.tsx, src/components/admin/quotes/QuotePreview.tsx <action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run build 2>&1 | grep -c "error TS" | xargs test 0 -eq && echo "✓ Components compile"</automated>
|
||||
</verify>
|
||||
<done>Four component files created. Two-column form renders with client/offer selection, price input, and live preview. Form submission calls createQuote action.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Create /admin/quotes/new page and wire form</name>
|
||||
<files>src/app/admin/quotes/new/page.tsx, src/app/admin/quotes/new/actions.ts <action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>curl -s http://localhost:3000/admin/quotes/new 2>&1 | grep -q "Crea Preventivo" && echo "✓ Page loads"</automated>
|
||||
</verify>
|
||||
<done>Page and form wired. Admin can navigate to /admin/quotes/new and see the two-column builder form.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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) |
|
||||
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `/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)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After execution, create `.planning/phases/09-quote-builder-client-acceptance/09-02-SUMMARY.md`
|
||||
</output>
|
||||
@@ -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"
|
||||
---
|
||||
|
||||
<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>
|
||||
Reference in New Issue
Block a user