a98fe7a9f3
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>
275 lines
11 KiB
Markdown
275 lines
11 KiB
Markdown
---
|
||
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>
|