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