diff --git a/apps/api/src/__tests__/petsExtendedFields.test.ts b/apps/api/src/__tests__/petsExtendedFields.test.ts index a1c64a8..3425234 100644 --- a/apps/api/src/__tests__/petsExtendedFields.test.ts +++ b/apps/api/src/__tests__/petsExtendedFields.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { Hono } from "hono"; import type { AppEnv, StaffRow } from "../middleware/rbac.js"; import { petsRouter } from "../routes/pets.js"; +import { and, eq, exists, or } from "../db/index.js"; // ─── Mock staff fixtures ────────────────────────────────────────────────────── @@ -164,10 +165,10 @@ vi.mock("../db", async (importOriginal) => { }), pets, appointments, -and: (...conds: unknown[]) => conds, - eq: (col: unknown, val: unknown) => ({ col, val }), - exists: (q: unknown) => q, - or: (...conds: unknown[]) => conds, +and: db.and, + eq: db.eq, + exists: db.exists, + or: db.or, }; }); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b9ccd84..0b53141 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -26,6 +26,7 @@ import { getDb, businessSettings, eq, staff } from "./db/index.js"; import { authMiddleware } from "./middleware/auth.js"; import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js"; import { devRouter } from "./routes/dev.js"; +import { bufferRulesRouter } from "./routes/buffer-rules.js"; import { adminSeedRouter } from "./routes/admin/seed.js"; import { startReminderScheduler } from "./services/reminders.js"; import { webhooksRouter } from "./routes/stripe-webhooks.js"; @@ -211,6 +212,7 @@ api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer")); // Staff write routes: manager OR super-user (combined guard — avoids AND stacking) api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager")); api.use("/admin/*", requireRoleOrSuperUser("manager")); +api.use("/buffer-rules/*", requireRole("manager")); api.use("/admin/settings/*", requireSuperUser()); api.use("/reports/*", requireRole("manager")); api.use("/invoices/*", requireRole("manager", "groomer")); @@ -268,6 +270,7 @@ api.route("/impersonation", impersonationRouter); api.route("/admin/settings", settingsRouter); api.route("/admin/auth-provider", authProviderRouter); api.route("/admin/seed", adminSeedRouter); +api.route("/buffer-rules", bufferRulesRouter); api.route("/search", searchRouter); const port = Number(process.env.PORT ?? 3000); diff --git a/apps/api/src/routes/buffer-rules.ts b/apps/api/src/routes/buffer-rules.ts new file mode 100644 index 0000000..296fec2 --- /dev/null +++ b/apps/api/src/routes/buffer-rules.ts @@ -0,0 +1,124 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { and, eq, getDb, isNull } from "../db/index.js"; +import type { AppEnv } from "../middleware/rbac.js"; +import { bufferRules, services } from "../db/index.js"; + +export const bufferRulesRouter = new Hono(); + +const createBufferRuleSchema = z.object({ + serviceId: z.string().uuid(), + sizeCategory: z + .enum(["small", "medium", "large", "extra_large"]) + .optional(), + coatType: z + .enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]) + .optional(), + bufferMinutes: z.number().int().positive(), +}); + +const updateBufferRuleSchema = z.object({ + bufferMinutes: z.number().int().positive(), +}); + +// GET / — list all buffer rules, optionally filtered by serviceId +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, + sizeCategory: bufferRules.sizeCategory, + coatType: bufferRules.coatType, + bufferMinutes: bufferRules.bufferMinutes, + createdAt: bufferRules.createdAt, + updatedAt: bufferRules.updatedAt, + serviceName: services.name, + }) + .from(bufferRules) + .innerJoin(services, eq(bufferRules.serviceId, services.id)) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(bufferRules.createdAt); + + return c.json(rows); +}); + +// POST / — create a buffer rule +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)); + if (!svc) return c.json({ error: "Service not found" }, 404); + + // Check for duplicate (service + size + coat) + const [existing] = await db + .select({ id: bufferRules.id }) + .from(bufferRules) + .where( + and( + eq(bufferRules.serviceId, body.serviceId), + body.sizeCategory !== undefined + ? eq(bufferRules.sizeCategory, body.sizeCategory) + : isNull(bufferRules.sizeCategory), + body.coatType !== undefined + ? eq(bufferRules.coatType, body.coatType) + : isNull(bufferRules.coatType) + ) + ); + if (existing) return c.json({ error: "Duplicate rule for this service+size+coat combination" }, 409); + + const [row] = await db + .insert(bufferRules) + .values({ + serviceId: body.serviceId, + sizeCategory: body.sizeCategory ?? null, + coatType: body.coatType ?? null, + bufferMinutes: body.bufferMinutes, + }) + .returning(); + + return c.json(row, 201); + } +); + +// PATCH /:id — update bufferMinutes only +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); + } +); + +// DELETE /:id — delete a buffer rule +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/apps/api/src/routes/pets.ts b/apps/api/src/routes/pets.ts index 2ac78fd..dbc5418 100644 --- a/apps/api/src/routes/pets.ts +++ b/apps/api/src/routes/pets.ts @@ -24,6 +24,7 @@ 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", "extra_large"]).optional(), coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(), temperamentScore: z.number().int().min(1).max(5).optional(), temperamentFlags: z.array(z.string().max(100)).max(20).optional(), diff --git a/apps/api/src/routes/services.ts b/apps/api/src/routes/services.ts index 993cb96..3e47f4f 100644 --- a/apps/api/src/routes/services.ts +++ b/apps/api/src/routes/services.ts @@ -13,7 +13,9 @@ const createServiceSchema = z.object({ active: z.boolean().default(true), }); -const updateServiceSchema = createServiceSchema.partial(); +const updateServiceSchema = createServiceSchema.partial().extend({ + defaultBufferMinutes: z.number().int().min(0).optional(), +}); servicesRouter.get("/", async (c) => { const db = getDb(); diff --git a/apps/api/src/types/index.ts b/apps/api/src/types/index.ts index 4f60f42..72f26ac 100644 --- a/apps/api/src/types/index.ts +++ b/apps/api/src/types/index.ts @@ -26,6 +26,19 @@ export interface Client { updatedAt: string; } +// ─── Medical Alerts ──────────────────────────────────────────────────────────── + +export type AlertSeverity = "low" | "medium" | "high"; + +export interface MedicalAlert { + type: string; + description: string; + severity: AlertSeverity; +} + +// ─── Pet Profile Summary ──────────────────────────────────────────────────── + +export type CoatType = "short" | "medium" | "long" | "double" | "wire" | "silky" | "curly" | "hairless"; export interface Pet { id: string; clientId: string; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 7ab60eb..bb3c8f6 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -116,6 +116,26 @@ export const verification = pgTable("verification", { updatedAt: timestamp("updated_at").notNull().defaultNow(), }); +// ─── Pet enums ───────────────────────────────────────────────────────────────── + +export const petSizeCategoryEnum = pgEnum("pet_size_category", [ + "small", + "medium", + "large", + "extra_large", +]); + +export const coatTypeEnum = pgEnum("coat_type", [ + "short", + "medium", + "long", + "double", + "wire", + "silky", + "curly", + "hairless", +]); + // ─── Tables ─────────────────────────────────────────────────────────────────── export const clients = pgTable( @@ -178,6 +198,7 @@ export const services = pgTable("services", { durationMinutes: integer("duration_minutes").notNull(), defaultBufferMinutes: integer("default_buffer_minutes"), active: boolean("active").notNull().default(true), + defaultBufferMinutes: integer("default_buffer_minutes").notNull().default(0), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); @@ -640,3 +661,34 @@ export const authProviderConfig = pgTable("auth_provider_config", { createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); + +// ─── Buffer Rules ───────────────────────────────────────────────────────────── + +// Buffer time rules per service + pet size/coat combination. +// Covers service-level defaults and pet-specific overrides. +export const bufferRules = pgTable( + "buffer_rules", + { + id: uuid("id").primaryKey().defaultRandom(), + serviceId: uuid("service_id") + .notNull() + .references(() => services.id, { onDelete: "cascade" }), + // null sizeCategory means "any size" (wildcard) + sizeCategory: petSizeCategoryEnum("size_category"), + // null coatType means "any coat type" (wildcard) + coatType: coatTypeEnum("coat_type"), + // minutes to add to the service duration for this size/coat combo + bufferMinutes: integer("buffer_minutes").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + // One rule per unique (service, size, coat) combination + unique("uq_buffer_rules_service_size_coat").on( + t.serviceId, + t.sizeCategory, + t.coatType + ), + index("idx_buffer_rules_service_id").on(t.serviceId), + ] +);