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:
@@ -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>
|
||||
Reference in New Issue
Block a user