diff --git a/packages/db/migrations/0031_buffer_rules.sql b/packages/db/migrations/0031_buffer_rules.sql new file mode 100644 index 0000000..5bfd90a --- /dev/null +++ b/packages/db/migrations/0031_buffer_rules.sql @@ -0,0 +1,31 @@ +-- Migration: 0031_buffer_rules.sql +-- Buffer rules CRUD: pet size/coat enums, bufferRules table, services.defaultBufferMinutes + +-- ─── Enums ─────────────────────────────────────────────────────────────────── + +CREATE TYPE "pet_size_category" AS ENUM ('small', 'medium', 'large', 'xlarge'); +CREATE TYPE "coat_type" AS ENUM ('smooth', 'double', 'wire', 'curly', 'long', 'hairless'); + +-- ─── Alter pets columns to use new enums ───────────────────────────────────── + +ALTER TABLE "pets" ALTER COLUMN "coat_type" TYPE "coat_type" USING "coat_type"::text::"coat_type"; +ALTER TABLE "pets" ALTER COLUMN "pet_size_category" TYPE "pet_size_category" USING "pet_size_category"::text::"pet_size_category"; + +-- ─── Services: add defaultBufferMinutes ─────────────────────────────────────── + +ALTER TABLE "services" ADD COLUMN "default_buffer_minutes" integer; + +-- ─── Buffer Rules table ─────────────────────────────────────────────────────── + +CREATE TABLE "buffer_rules" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "service_id" uuid NOT NULL REFERENCES "services"("id") ON DELETE CASCADE, + "size_category" "pet_size_category", + "coat_type" "coat_type", + "buffer_minutes" integer NOT NULL, + "created_at" timestamp NOT NULL DEFAULT now(), + "updated_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "uq_buffer_rules_service_size_coat" UNIQUE ("service_id", "size_category", "coat_type") +); + +CREATE INDEX "idx_buffer_rules_service_id" ON "buffer_rules"("service_id"); \ No newline at end of file diff --git a/packages/db/src/factories.ts b/packages/db/src/factories.ts index 00de0c4..cac71f7 100644 --- a/packages/db/src/factories.ts +++ b/packages/db/src/factories.ts @@ -121,6 +121,7 @@ export function buildService(overrides: Partial = {}): ServiceRow { description: "A grooming service", basePriceCents: 6500, durationMinutes: 60, + defaultBufferMinutes: null, active: true, createdAt: new Date("2025-01-01T00:00:00Z"), updatedAt: new Date("2025-01-01T00:00:00Z"), diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 7d93afc..7ab60eb 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -48,6 +48,22 @@ export const clientStatusEnum = pgEnum("client_status", [ "disabled", ]); +export const petSizeCategoryEnum = pgEnum("pet_size_category", [ + "small", + "medium", + "large", + "xlarge", +]); + +export const coatTypeEnum = pgEnum("coat_type", [ + "smooth", + "double", + "wire", + "curly", + "long", + "hairless", +]); + // ─── Better-Auth Tables ────────────────────────────────────────────────────── export const user = pgTable("user", { @@ -142,8 +158,8 @@ export const pets = pgTable( cutStyle: text("cut_style"), shampooPreference: text("shampoo_preference"), specialCareNotes: text("special_care_notes"), - coatType: text("coat_type"), - petSizeCategory: text("pet_size_category"), + coatType: coatTypeEnum("coat_type"), + petSizeCategory: petSizeCategoryEnum("pet_size_category"), customFields: jsonb("custom_fields").$type>().notNull().default({}), photoKey: text("photo_key"), photoUploadedAt: timestamp("photo_uploaded_at"), @@ -160,11 +176,34 @@ export const services = pgTable("services", { description: text("description"), basePriceCents: integer("base_price_cents").notNull(), durationMinutes: integer("duration_minutes").notNull(), + defaultBufferMinutes: integer("default_buffer_minutes"), active: boolean("active").notNull().default(true), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); +export const bufferRules = pgTable( + "buffer_rules", + { + id: uuid("id").primaryKey().defaultRandom(), + serviceId: uuid("service_id") + .notNull() + .references(() => services.id, { onDelete: "cascade" }), + sizeCategory: petSizeCategoryEnum("size_category"), + coatType: coatTypeEnum("coat_type"), + bufferMinutes: integer("buffer_minutes").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + unique("uq_buffer_rules_service_size_coat").on( + t.serviceId, + t.sizeCategory, + t.coatType + ), + ] +); + export const staff = pgTable("staff", { id: uuid("id").primaryKey().defaultRandom(), name: text("name").notNull(), diff --git a/src/index.ts b/src/index.ts index 1ed08f2..6acee60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import { impersonationRouter } from "./routes/impersonation.js"; import { settingsRouter } from "./routes/settings.js"; import { authProviderRouter } from "./routes/authProvider.js"; import { searchRouter } from "./routes/search.js"; +import { bufferRulesRouter } from "./routes/buffer-rules.js"; import { getObject } from "./lib/s3.js"; import { calendarRouter } from "./routes/calendar.js"; import { setupRouter } from "./routes/setup.js"; @@ -269,6 +270,7 @@ api.route("/admin/settings", settingsRouter); api.route("/admin/auth-provider", authProviderRouter); api.route("/admin/seed", adminSeedRouter); api.route("/search", searchRouter); +api.route("/buffer-rules", bufferRulesRouter); const port = Number(process.env.PORT ?? 3000); await initAuth(); diff --git a/src/routes/book.ts b/src/routes/book.ts index 5b9fd27..828c11c 100644 --- a/src/routes/book.ts +++ b/src/routes/book.ts @@ -193,8 +193,8 @@ bookRouter.post( name: body.petName, species: body.petSpecies, breed: body.petBreed ?? null, - coatType: body.petCoatType ?? null, - petSizeCategory: body.petSizeCategory ?? null, + coatType: (body.petCoatType ?? null) as "smooth" | "double" | "wire" | "curly" | "long" | "hairless" | null, + petSizeCategory: (body.petSizeCategory ?? null) as "small" | "medium" | "large" | "xlarge" | null, }) .returning(); const pet = petInserted[0]; diff --git a/src/routes/buffer-rules.ts b/src/routes/buffer-rules.ts new file mode 100644 index 0000000..fb3ff2b --- /dev/null +++ b/src/routes/buffer-rules.ts @@ -0,0 +1,128 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { + and, + eq, + isNull, + getDb, + bufferRules, + services, +} from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; +import { requireRole } from "../middleware/rbac.js"; + +export const bufferRulesRouter = new Hono(); + +// Apply manager role guard to all routes +bufferRulesRouter.use("*", requireRole("manager")); + +const createBufferRuleSchema = z.object({ + serviceId: z.string().uuid(), + sizeCategory: z.enum(["small", "medium", "large", "xlarge"]).optional(), + coatType: z.enum(["smooth", "double", "wire", "curly", "long", "hairless"]).optional(), + bufferMinutes: z.number().int().positive(), +}); + +const updateBufferRuleSchema = z.object({ + bufferMinutes: z.number().int().positive(), +}); + +bufferRulesRouter.get("/", async (c) => { + const db = getDb(); + const serviceId = c.req.query("serviceId"); + + const conditions = []; + if (serviceId) conditions.push(eq(bufferRules.serviceId, serviceId)); + + const rows = await db + .select({ + id: bufferRules.id, + serviceId: bufferRules.serviceId, + serviceName: services.name, + sizeCategory: bufferRules.sizeCategory, + coatType: bufferRules.coatType, + bufferMinutes: bufferRules.bufferMinutes, + createdAt: bufferRules.createdAt, + updatedAt: bufferRules.updatedAt, + }) + .from(bufferRules) + .leftJoin(services, eq(bufferRules.serviceId, services.id)) + .where(conditions.length > 0 ? and(...conditions) : undefined); + + return c.json(rows); +}); + +bufferRulesRouter.post( + "/", + zValidator("json", createBufferRuleSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + + // Validate serviceId exists + const [svc] = await db + .select({ id: services.id }) + .from(services) + .where(eq(services.id, body.serviceId)) + .limit(1); + if (!svc) return c.json({ error: "Service not found" }, 404); + + // Check for duplicate — sizeCategory/coatType are nullable, use isNull for null check + const conditions = [eq(bufferRules.serviceId, body.serviceId)]; + if (body.sizeCategory) { + conditions.push(eq(bufferRules.sizeCategory, body.sizeCategory)); + } else { + conditions.push(isNull(bufferRules.sizeCategory)); + } + if (body.coatType) { + conditions.push(eq(bufferRules.coatType, body.coatType)); + } else { + conditions.push(isNull(bufferRules.coatType)); + } + const [existing] = await db + .select({ id: bufferRules.id }) + .from(bufferRules) + .where(and(...conditions)) + .limit(1); + if (existing) return c.json({ error: "Duplicate rule for this service and attributes" }, 409); + + const [row] = await db + .insert(bufferRules) + .values({ + serviceId: body.serviceId, + sizeCategory: body.sizeCategory, + coatType: body.coatType, + bufferMinutes: body.bufferMinutes, + }) + .returning(); + + return c.json(row, 201); + } +); + +bufferRulesRouter.patch( + "/:id", + zValidator("json", updateBufferRuleSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const [row] = await db + .update(bufferRules) + .set({ bufferMinutes: body.bufferMinutes, updatedAt: new Date() }) + .where(eq(bufferRules.id, c.req.param("id"))) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json(row); + } +); + +bufferRulesRouter.delete("/:id", async (c) => { + const db = getDb(); + const [row] = await db + .delete(bufferRules) + .where(eq(bufferRules.id, c.req.param("id"))) + .returning(); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json({ ok: true }); +}); \ No newline at end of file diff --git a/src/routes/pets.ts b/src/routes/pets.ts index 2264e6c..9642450 100644 --- a/src/routes/pets.ts +++ b/src/routes/pets.ts @@ -24,6 +24,8 @@ const createPetSchema = z.object({ shampooPreference: z.string().max(500).optional(), specialCareNotes: z.string().max(2000).optional(), customFields: z.record(z.string(), z.string()).optional(), + sizeCategory: z.enum(["small", "medium", "large", "xlarge"]).optional(), + coatType: z.enum(["smooth", "double", "wire", "curly", "long", "hairless"]).optional(), }); const updatePetSchema = createPetSchema.partial().omit({ clientId: true }); diff --git a/src/routes/services.ts b/src/routes/services.ts index 659dee2..7cf8112 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -10,6 +10,7 @@ const createServiceSchema = z.object({ description: z.string().max(2000).optional(), basePriceCents: z.number().int().positive(), durationMinutes: z.number().int().positive().max(480), + defaultBufferMinutes: z.number().int().min(0).optional(), active: z.boolean().default(true), });