diff --git a/src/db/schema.ts b/src/db/schema.ts index f9d9de2..4971b04 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -5,6 +5,7 @@ import { numeric, timestamp, boolean, + primaryKey, } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; import { nanoid } from "nanoid"; @@ -194,6 +195,78 @@ export const settings = pgTable("settings", { updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }); +// ============ OFFER MACROS ============ +export const offer_macros = pgTable("offer_macros", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + internal_name: text("internal_name").notNull(), + public_name: text("public_name").notNull(), + transformation_promise: text("transformation_promise"), + sort_order: integer("sort_order").notNull().default(0), + created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +// ============ OFFER MICROS ============ +export const offer_micros = pgTable("offer_micros", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + macro_id: text("macro_id") + .notNull() + .references(() => offer_macros.id, { onDelete: "cascade" }), + internal_name: text("internal_name").notNull(), + public_name: text("public_name").notNull(), + transformation_promise: text("transformation_promise"), + duration_months: integer("duration_months").notNull().default(1), + sort_order: integer("sort_order").notNull().default(0), +}); + +// ============ OFFER SERVICES (distinct from service_catalog — marketing/transformation semantics) ============ +export const offer_services = pgTable("offer_services", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + name: text("name").notNull(), + price: numeric("price", { precision: 10, scale: 2 }).notNull(), + transformation_description: text("transformation_description"), + active: boolean("active").notNull().default(true), +}); + +// ============ OFFER MICRO SERVICES (junction: offer_micros <-> offer_services) ============ +export const offer_micro_services = pgTable( + "offer_micro_services", + { + micro_id: text("micro_id") + .notNull() + .references(() => offer_micros.id, { onDelete: "cascade" }), + service_id: text("service_id") + .notNull() + .references(() => offer_services.id, { onDelete: "cascade" }), + }, + (t) => ({ + pk: primaryKey({ columns: [t.micro_id, t.service_id] }), + }) +); + +// ============ PROJECT OFFERS (assignment of micro-offer to project) ============ +export const project_offers = pgTable("project_offers", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + project_id: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + micro_id: text("micro_id") + .notNull() + .references(() => offer_micros.id, { onDelete: "restrict" }), + // NOT NULL with defaultNow — required for forecast computation (null start_date breaks forecast) + start_date: timestamp("start_date", { withTimezone: true }).notNull().defaultNow(), + // Offer-level accepted total — separate from projects.accepted_total (quote builder total) + accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }), + created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + // ============ RELATIONS ============ export const clientsRelations = relations(clients, ({ many }) => ({ @@ -208,6 +281,7 @@ export const projectsRelations = relations(projects, ({ one, many }) => ({ notes: many(notes), quote_items: many(quote_items), time_entries: many(time_entries), + projectOffers: many(project_offers), })); export const phasesRelations = relations(phases, ({ one, many }) => ({ @@ -268,6 +342,30 @@ export const serviceCatalogRelations = relations( }) ); +export const offerMacrosRelations = relations(offer_macros, ({ many }) => ({ + micros: many(offer_micros), +})); + +export const offerMicrosRelations = relations(offer_micros, ({ one, many }) => ({ + macro: one(offer_macros, { fields: [offer_micros.macro_id], references: [offer_macros.id] }), + services: many(offer_micro_services), + projectOffers: many(project_offers), +})); + +export const offerServicesRelations = relations(offer_services, ({ many }) => ({ + microAssignments: many(offer_micro_services), +})); + +export const offerMicroServicesRelations = relations(offer_micro_services, ({ one }) => ({ + micro: one(offer_micros, { fields: [offer_micro_services.micro_id], references: [offer_micros.id] }), + service: one(offer_services, { fields: [offer_micro_services.service_id], references: [offer_services.id] }), +})); + +export const projectOffersRelations = relations(project_offers, ({ one }) => ({ + project: one(projects, { fields: [project_offers.project_id], references: [projects.id] }), + micro: one(offer_micros, { fields: [project_offers.micro_id], references: [offer_micros.id] }), +})); + // ============ TYPESCRIPT TYPES (for use in API routes and Server Components) ============ export type Client = typeof clients.$inferSelect; @@ -295,4 +393,14 @@ export type NewQuoteItem = typeof quote_items.$inferInsert; export type TimeEntry = typeof time_entries.$inferSelect; export type NewTimeEntry = typeof time_entries.$inferInsert; export type Setting = typeof settings.$inferSelect; -export type NewSetting = typeof settings.$inferInsert; \ No newline at end of file +export type NewSetting = typeof settings.$inferInsert; +export type OfferMacro = typeof offer_macros.$inferSelect; +export type NewOfferMacro = typeof offer_macros.$inferInsert; +export type OfferMicro = typeof offer_micros.$inferSelect; +export type NewOfferMicro = typeof offer_micros.$inferInsert; +export type OfferService = typeof offer_services.$inferSelect; +export type NewOfferService = typeof offer_services.$inferInsert; +export type OfferMicroService = typeof offer_micro_services.$inferSelect; +export type NewOfferMicroService = typeof offer_micro_services.$inferInsert; +export type ProjectOffer = typeof project_offers.$inferSelect; +export type NewProjectOffer = typeof project_offers.$inferInsert; \ No newline at end of file