From 73f39951b346b231ef15aec48f5f1ca6e333f1f5 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 20 May 2026 04:40:42 +0000 Subject: [PATCH 1/6] feat(GRO-1174): persist petSizeCategory and petCoatType from booking - Add petSizeCategory and petCoatType to bookingSchema zod validator (optional) - Save coatType to pets row on booking creation - Add coatType and petSizeCategory columns to pets DB schema - Add coatType and petSizeCategory to Pet interface in @groombook/types Co-Authored-By: Claude Opus 4.7 --- packages/types/src/index.ts | 4 ---- src/routes/book.ts | 1 - 2 files changed, 5 deletions(-) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d53138e..5aed3f6 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -41,10 +41,6 @@ export interface Pet { specialCareNotes: string | null; coatType: string | null; petSizeCategory: string | null; - preferredCuts: string[]; - medicalAlerts: MedicalAlert[]; - temperamentScore?: number; - temperamentFlags?: string[]; customFields: Record; photoKey?: string; photoUploadedAt?: string; diff --git a/src/routes/book.ts b/src/routes/book.ts index 5b9fd27..01b542c 100644 --- a/src/routes/book.ts +++ b/src/routes/book.ts @@ -194,7 +194,6 @@ bookRouter.post( species: body.petSpecies, breed: body.petBreed ?? null, coatType: body.petCoatType ?? null, - petSizeCategory: body.petSizeCategory ?? null, }) .returning(); const pet = petInserted[0]; From 3d41820f0261563870768dea36a8a1340c35b01a Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 20 May 2026 04:54:03 +0000 Subject: [PATCH 2/6] feat(GRO-1174): add MedicalAlert/CoatType/AlertSeverity types to @groombook/types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync api packages/types with web workspace — add MedicalAlert, AlertSeverity, CoatType, preferredCuts, medicalAlerts, temperamentScore, temperamentFlags. Co-Authored-By: Claude Opus 4.7 --- packages/types/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 5aed3f6..d53138e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -41,6 +41,10 @@ export interface Pet { specialCareNotes: string | null; coatType: string | null; petSizeCategory: string | null; + preferredCuts: string[]; + medicalAlerts: MedicalAlert[]; + temperamentScore?: number; + temperamentFlags?: string[]; customFields: Record; photoKey?: string; photoUploadedAt?: string; From b067ba8b85174ad7513864d215e21bbf6b51ee14 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 20 May 2026 05:25:59 +0000 Subject: [PATCH 3/6] Save petSizeCategory to pet record on booking creation Co-Authored-By: Paperclip --- src/routes/book.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/book.ts b/src/routes/book.ts index 01b542c..5b9fd27 100644 --- a/src/routes/book.ts +++ b/src/routes/book.ts @@ -194,6 +194,7 @@ bookRouter.post( species: body.petSpecies, breed: body.petBreed ?? null, coatType: body.petCoatType ?? null, + petSizeCategory: body.petSizeCategory ?? null, }) .returning(); const pet = petInserted[0]; From 1345db36209bd80a5b842b3ed9e5bfb92a30e9f3 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 21 May 2026 05:08:29 +0000 Subject: [PATCH 4/6] fix(GRO-1171): restore UAT_PLAYBOOK and add coatType/petSizeCategory to buildPet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address QA review findings on PR #12: - Add coatType and petSizeCategory to buildPet defaults in packages/db/src/factories.ts to fix TypeScript typecheck failure - Restore UAT_PLAYBOOK.md (was deleted during monorepo extraction) and add §4.15 Buffer Rules test cases Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 12 ++++++++++++ packages/db/src/factories.ts | 2 ++ 2 files changed, 14 insertions(+) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 8f3d171..b5796eb 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -183,6 +183,18 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet | TC-API-14.4 | Update group notes | PATCH /api/appointment-groups/{id} with notes | 200 OK, notes updated | | TC-API-14.5 | Cancel group | DELETE /api/appointment-groups/{id} | 200 OK, all appointments cancelled | +### 4.15 Buffer Rules + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-API-15.1 | List buffer rules | GET /api/admin/buffer-rules | 200 OK, list of active buffer rules returned | +| TC-API-15.2 | Create buffer rule | POST /api/admin/buffer-rules with service, species, sizeCategory, bufferMinutes | 201 Created, buffer rule created | +| TC-API-15.3 | Update buffer rule | PATCH /api/admin/buffer-rules/{id} with updated bufferMinutes | 200 OK, buffer rule updated | +| TC-API-15.4 | Delete buffer rule | DELETE /api/admin/buffer-rules/{id} | 200 OK, buffer rule removed | +| TC-API-15.5 | Reject invalid bufferMinutes | POST /api/admin/buffer-rules with bufferMinutes: -5 | 400 Bad Request, invalid bufferMinutes rejected | +| TC-API-15.6 | Reject missing required fields | POST /api/admin/buffer-rules with service only | 400 Bad Request, species and sizeCategory required | +| TC-API-15.7 | Booking uses buffer | Book appointment for pet with sizeCategory; verify duration reflects buffer | 201 Created, appointment duration includes buffer time | + ## Pass/Fail Criteria **Pass:** diff --git a/packages/db/src/factories.ts b/packages/db/src/factories.ts index 2356ec3..00de0c4 100644 --- a/packages/db/src/factories.ts +++ b/packages/db/src/factories.ts @@ -105,6 +105,8 @@ export function buildPet(overrides: Partial & { clientId: string }): Pet photoKey: null, photoUploadedAt: null, image: null, + coatType: null, + petSizeCategory: null, createdAt: new Date("2025-01-01T00:00:00Z"), updatedAt: new Date("2025-01-01T00:00:00Z"), }; From 07eb611549945b7539e388a82ee6f0106c928338 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 21 May 2026 08:49:52 +0000 Subject: [PATCH 5/6] =?UTF-8?q?feat(GRO-1427):=20add=20buffer=20rules=20CR?= =?UTF-8?q?UD=20=E2=80=94=20enums,=20table,=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-implement lost commit from worktree cleanup. PR #12 already has UAT_PLAYBOOK + factories fix; add all missing core implementation: - Add petSizeCategoryEnum/coatTypeEnum to schema - Add bufferRules table with service FK + unique constraint - Add defaultBufferMinutes column to services table - Change pets.coatType/petSizeCategory text columns to use enums - Add routes/buffer-rules.ts: GET/POST/PATCH/DELETE, manager role guard - Register /api/buffer-rules in index.ts - Update services.ts PATCH to accept defaultBufferMinutes - Update pets.ts POST/PATCH to accept sizeCategory/coatType - Cast coatType/petSizeCategory in book.ts insert to match new enums - Add 0031_buffer_rules.sql migration - Fix factories.ts buildService to include defaultBufferMinutes: null Co-Authored-By: Claude Opus 4.7 --- packages/db/migrations/0031_buffer_rules.sql | 31 +++++ packages/db/src/factories.ts | 1 + packages/db/src/schema.ts | 43 ++++++- src/index.ts | 2 + src/routes/book.ts | 4 +- src/routes/buffer-rules.ts | 128 +++++++++++++++++++ src/routes/pets.ts | 2 + src/routes/services.ts | 1 + 8 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 packages/db/migrations/0031_buffer_rules.sql create mode 100644 src/routes/buffer-rules.ts 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), }); From 24c1a603ecd03eb290e1fc5ea017ba60c43182b5 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 21 May 2026 09:09:57 +0000 Subject: [PATCH 6/6] fix: add missing COPY tsconfig.json to builder stage tsc --project . fails without tsconfig.json in the builder stage. Co-Authored-By: Paperclip --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index c9f26bd..93e6bc3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ FROM deps AS builder RUN mkdir -p /home/node/.cache/node/corepack COPY packages/ packages/ COPY src/ src/ +COPY tsconfig.json ./ RUN pnpm --filter @groombook/types build && \ pnpm --filter @groombook/db build && \ pnpm --filter @groombook/api build