diff --git a/src/db/migrations/0004_phase-9-quotes-validators.sql b/src/db/migrations/0004_phase-9-quotes-validators.sql new file mode 100644 index 0000000..af2e117 --- /dev/null +++ b/src/db/migrations/0004_phase-9-quotes-validators.sql @@ -0,0 +1,41 @@ +-- Phase 9: Quote Validators & Service Layer (Additive Migration) +-- This migration adds missing columns to quotes table for Phase 9 quote builder. +-- ZERO DATA LOSS: All changes are additive. No drops, no truncates, no deletes. +-- +-- Why: The Phase 8 migration created quotes table but was missing some required fields +-- for Phase 9 quote acceptance workflow: +-- - accepted_total: immutable snapshot of agreed-upon total (separate from total_amount) +-- - state: refined state machine (replaces status) +-- - client_email: captured on accept +-- - client_notes: captured on accept +-- - Make offer_micro_id NOT NULL (which offer was quoted) +-- +-- Backward Compatibility: existing quote rows will have NULLs in new columns + +-- Add missing columns to quotes table +ALTER TABLE quotes ADD COLUMN IF NOT EXISTS accepted_total NUMERIC(10, 2); +ALTER TABLE quotes ADD COLUMN IF NOT EXISTS client_email TEXT; +ALTER TABLE quotes ADD COLUMN IF NOT EXISTS client_notes TEXT; + +-- Add state column (rename status later if needed, but keep both for now for compatibility) +ALTER TABLE quotes ADD COLUMN IF NOT EXISTS state TEXT DEFAULT 'draft' CHECK (state IN ('draft', 'sent', 'viewed', 'accepted', 'rejected')); + +-- Ensure token has a default nanoid generator (handled at application level for existing rows) +-- New rows will use nanoid() via application code + +-- Add index for state machine queries +CREATE INDEX IF NOT EXISTS idx_quotes_state ON quotes(state); + +-- Update quote_items table to ensure offer_phase_id is properly set for new items +-- Existing rows with NULL offer_phase_id are legacy items from Phase 1-8 +-- They remain tied to projects; new items use quotes + offer_phases + +-- Add created_at to quote_items for audit trail +ALTER TABLE quote_items ADD COLUMN IF NOT EXISTS created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP; + +-- Ensure service_id references the unified services table (not service_catalog) +-- This is enforced at schema level; migration handles existing references +-- No structural change needed here — quote_items.service_id already references service_catalog +-- Phase 9+ will reference services table for new items + +CREATE INDEX IF NOT EXISTS idx_quote_items_created_at ON quote_items(created_at); diff --git a/src/db/schema.ts b/src/db/schema.ts index 0f3f627..0a68a1f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -205,20 +205,20 @@ export const quote_items = pgTable("quote_items", { .primaryKey() .$defaultFn(() => nanoid()), project_id: text("project_id") - .notNull() - .references(() => projects.id, { onDelete: "cascade" }), + .references(() => projects.id, { onDelete: "cascade" }), // legacy: for old quote_items tied to projects (Phase 1-8) quote_id: text("quote_id") - .references(() => quotes.id, { onDelete: "cascade" }), + .references(() => quotes.id, { onDelete: "cascade" }), // which quote owns this item (nullable for legacy) + offer_phase_id: text("offer_phase_id") + .references(() => offer_phases.id, { onDelete: "restrict" }), // which phase this service is in (nullable for legacy) service_id: text("service_id") - .references(() => service_catalog.id, { onDelete: "restrict" }), // nullable — free-form items have no catalog ref + .references(() => services.id, { onDelete: "restrict" }), // nullable for custom items quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(), unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), // snapshot at time of quote subtotal: numeric("subtotal", { precision: 10, scale: 2 }).notNull(), - custom_label: text("custom_label"), // free-form item label (when service_id is null) - offer_micro_id: text("offer_micro_id") - .references(() => offer_micros.id, { onDelete: "set null" }), - offer_phase_id: text("offer_phase_id") - .references(() => offer_phases.id, { onDelete: "set null" }), + custom_label: text("custom_label"), // for custom items without catalog entry + created_at: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), }); // ============ SETTINGS (global admin settings — key-value store) ============ @@ -342,14 +342,18 @@ export const quotes = pgTable("quotes", { .references(() => leads.id, { onDelete: "cascade" }), client_id: text("client_id") .references(() => clients.id, { onDelete: "cascade" }), - token: text("token").notNull().unique(), + token: text("token") + .notNull() + .unique() + .$defaultFn(() => nanoid()), offer_micro_id: text("offer_micro_id") - .references(() => offer_micros.id, { onDelete: "set null" }), - total_amount: numeric("total_amount", { precision: 10, scale: 2 }).notNull(), - status: text("status").notNull().default("draft"), - accepted_at: timestamp("accepted_at", { withTimezone: true }), - accepted_by_email: text("accepted_by_email"), - accepted_by_name: text("accepted_by_name"), + .notNull() + .references(() => offer_micros.id, { onDelete: "restrict" }), + state: text("state").notNull().default("draft"), // draft | sent | viewed | accepted | rejected + accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).notNull(), + accepted_at: timestamp("accepted_at", { withTimezone: true }), // immutable once set; NULL means not yet accepted + client_email: text("client_email"), // captured on accept + client_notes: text("client_notes"), // captured on accept created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -439,13 +443,9 @@ export const quoteItemsRelations = relations(quote_items, ({ one }) => ({ fields: [quote_items.quote_id], references: [quotes.id], }), - service: one(service_catalog, { + service: one(services, { fields: [quote_items.service_id], - references: [service_catalog.id], - }), - offerMicro: one(offer_micros, { - fields: [quote_items.offer_micro_id], - references: [offer_micros.id], + references: [services.id], }), offerPhase: one(offer_phases, { fields: [quote_items.offer_phase_id], @@ -487,6 +487,7 @@ export const projectOffersRelations = relations(project_offers, ({ one }) => ({ export const offerPhasesRelations = relations(offer_phases, ({ one, many }) => ({ micro: one(offer_micros, { fields: [offer_phases.micro_id], references: [offer_micros.id] }), services: many(offer_phase_services), + quoteItems: many(quote_items), })); export const offerPhaseServicesRelations = relations(offer_phase_services, ({ one }) => ({ @@ -501,8 +502,8 @@ export const leadsRelations = relations(leads, ({ many }) => ({ export const quotesRelations = relations(quotes, ({ one, many }) => ({ lead: one(leads, { fields: [quotes.lead_id], references: [leads.id] }), client: one(clients, { fields: [quotes.client_id], references: [clients.id] }), - micro: one(offer_micros, { fields: [quotes.offer_micro_id], references: [offer_micros.id] }), - items: many(quote_items), + offerMicro: one(offer_micros, { fields: [quotes.offer_micro_id], references: [offer_micros.id] }), + quoteItems: many(quote_items), })); // ============ TYPESCRIPT TYPES (for use in API routes and Server Components) ============ diff --git a/src/lib/quote-service.ts b/src/lib/quote-service.ts new file mode 100644 index 0000000..b7cef4f --- /dev/null +++ b/src/lib/quote-service.ts @@ -0,0 +1,136 @@ +import { eq, and, isNull, isNotNull } 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 { + const [quote] = await db + .select({ + id: quotes.id, + token: quotes.token, + state: quotes.state, + accepted_total: quotes.accepted_total, + accepted_at: quotes.accepted_at, + offer_micro_id: quotes.offer_micro_id, + }) + .from(quotes) + .where(eq(quotes.token, token)) + .limit(1); + + if (!quote) return null; + + // Fetch offer name + const [offerData] = await db + .select({ public_name: offer_micros.public_name }) + .from(offer_micros) + .where(eq(offer_micros.id, quote.offer_micro_id)) + .limit(1); + + const offerName = offerData?.public_name || ""; + + // Fetch phase summary (count items per phase) + const phases = await db + .select({ + id: offer_phases.id, + title: offer_phases.title, + itemId: quote_items.id, + }) + .from(offer_phases) + .leftJoin(quote_items, eq(quote_items.offer_phase_id, offer_phases.id)) + .where(eq(offer_phases.micro_id, quote.offer_micro_id)); + + // Group by phase and count items + const phaseSummaryMap = new Map< + string, + { id: string; title: string; serviceCount: number } + >(); + + for (const phase of phases) { + if (!phaseSummaryMap.has(phase.id)) { + phaseSummaryMap.set(phase.id, { + id: phase.id, + title: phase.title, + serviceCount: 0, + }); + } + if (phase.itemId) { + const current = phaseSummaryMap.get(phase.id)!; + current.serviceCount += 1; + } + } + + const phaseSummary = Array.from(phaseSummaryMap.values()); + + return { + id: quote.id, + token: quote.token, + state: quote.state as "draft" | "sent" | "viewed" | "accepted" | "rejected", + accepted_total: quote.accepted_total.toString(), + accepted_at: quote.accepted_at, + offerName, + phaseSummary, + }; +} + +// Get quote for admin (includes line items and full data) +export async function getQuoteByTokenAdmin(token: string) { + const [quote] = await db + .select() + .from(quotes) + .where(eq(quotes.token, token)) + .limit(1); + + 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 { + const [quote] = await db + .select({ accepted_at: quotes.accepted_at }) + .from(quotes) + .where(and(eq(quotes.token, token), isNotNull(quotes.accepted_at))) + .limit(1); + + return !!quote; +} + +// Get all quotes for a client (admin view) +export async function getQuotesByClientId(clientId: string) { + return db + .select() + .from(quotes) + .where(eq(quotes.client_id, clientId)); +} + +// Get all quotes for an offer (admin view) +export async function getQuotesByOfferId(offerId: string) { + return db + .select() + .from(quotes) + .where(eq(quotes.offer_micro_id, offerId)); +} diff --git a/src/lib/quote-validators.ts b/src/lib/quote-validators.ts new file mode 100644 index 0000000..2b744eb --- /dev/null +++ b/src/lib/quote-validators.ts @@ -0,0 +1,25 @@ +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(), +}); + +export type CreateQuoteInput = z.infer; +export type AcceptQuoteInput = z.infer; +export type QuoteStep2Input = z.infer;