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
+136
View File
@@ -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<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,
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<boolean> {
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));
}
+25
View File
@@ -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<typeof createQuoteSchema>;
export type AcceptQuoteInput = z.infer<typeof acceptQuoteSchema>;
export type QuoteStep2Input = z.infer<typeof quoteStep2Schema>;