promote: uat → main (GRO-1509 OIDC accountLinking fix) #46

Merged
Scrubs McBarkley merged 110 commits from uat into main 2026-05-22 14:03:44 +00:00
5 changed files with 183 additions and 1 deletions
Showing only changes of commit 44da26820b - Show all commits
+3
View File
@@ -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);
+124
View File
@@ -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<AppEnv>();
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 });
});
+1
View File
@@ -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(),
+3 -1
View File
@@ -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();
+52
View File
@@ -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),
]
);