feat(GRO-1427): add buffer rules CRUD — enums, table, routes

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 <noreply@anthropic.com>
This commit is contained in:
Flea Flicker
2026-05-21 08:49:52 +00:00
parent 1345db3620
commit 07eb611549
8 changed files with 208 additions and 4 deletions
+2
View File
@@ -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();
+2 -2
View File
@@ -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];
+128
View File
@@ -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<AppEnv>();
// 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 });
});
+2
View File
@@ -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 });
+1
View File
@@ -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),
});