feat(09-01): add quote validators and service layer for Phase 9 quote builder

- Updated quotes table schema: added accepted_total, state, client_email, client_notes fields
- Updated quote_items relations: changed service_id FK to unified services table
- Created quote-validators.ts: Zod schemas for quote creation, acceptance, and step validation
- Created quote-service.ts: public query layer (excludes line items) and admin queries
- Added Phase 9 migration: additive columns for quotes state machine and client capture
- Maintained backward compatibility: legacy quote_items tied to projects remain functional
- TypeScript builds successfully with no errors

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 07:30:10 +02:00
parent 9ea905cb45
commit abf37323fe
4 changed files with 227 additions and 24 deletions
+25 -24
View File
@@ -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) ============