feat(GRO-635): implement groomer data isolation in appointmentGroups, groomingLogs + fix batherStaffId conflict check

- appointmentGroups: use Hono<AppEnv>(), add groomer isolation on all endpoints
- groomingLogs: use Hono<AppEnv>(), add groomer isolation on all endpoints
- appointments: add batherStaffId conflict check in POST and PATCH handlers
- Non-groomer roles retain full access on all endpoints

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip
2026-04-14 13:50:03 +00:00
parent c438f5772c
commit 5e24678fa5
3 changed files with 217 additions and 11 deletions
+69 -4
View File
@@ -16,8 +16,9 @@ import {
services,
staff,
} from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const appointmentGroupsRouter = new Hono();
export const appointmentGroupsRouter = new Hono<AppEnv>();
// ─── Schemas ──────────────────────────────────────────────────────────────────
@@ -49,6 +50,8 @@ appointmentGroupsRouter.get("/", async (c) => {
const clientId = c.req.query("clientId");
const from = c.req.query("from");
const to = c.req.query("to");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const groupConditions = clientId
? [eq(appointmentGroups.clientId, clientId)]
@@ -79,7 +82,7 @@ appointmentGroupsRouter.get("/", async (c) => {
groupApptMap.get(appt.groupId)!.push(appt);
}
const result = groups
let result = groups
.map((g) => ({
...g,
appointments: (groupApptMap.get(g.id) ?? []).sort(
@@ -88,6 +91,15 @@ appointmentGroupsRouter.get("/", async (c) => {
}))
.filter((g) => !from || g.appointments.length > 0);
// Groomer: filter to groups where at least one appointment is assigned to them
if (isGroomer) {
result = result.filter((g) =>
g.appointments.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
);
}
return c.json(result);
});
@@ -96,6 +108,8 @@ appointmentGroupsRouter.get("/", async (c) => {
appointmentGroupsRouter.get("/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db
.select()
@@ -112,6 +126,7 @@ appointmentGroupsRouter.get("/:id", async (c) => {
serviceName: services.name,
staffId: appointments.staffId,
staffName: staff.name,
batherStaffId: appointments.batherStaffId,
status: appointments.status,
startTime: appointments.startTime,
endTime: appointments.endTime,
@@ -125,6 +140,14 @@ appointmentGroupsRouter.get("/:id", async (c) => {
.where(eq(appointments.groupId, id))
.orderBy(appointments.startTime);
// Groomer: verify at least one appointment in the group is assigned to them
if (isGroomer) {
const hasAccess = groupAppts.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
);
if (!hasAccess) return c.json({ error: "Forbidden" }, 403);
}
const [client] = await db
.select({ name: clients.name, email: clients.email })
.from(clients)
@@ -140,6 +163,14 @@ appointmentGroupsRouter.post(
zValidator("json", createGroupSchema),
async (c) => {
const db = getDb();
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
// Only managers and receptionists can create group bookings
if (isGroomer) {
return c.json({ error: "Forbidden: groomers cannot create group bookings" }, 403);
}
const body = c.req.valid("json");
const startTime = new Date(body.startTime);
@@ -244,6 +275,27 @@ appointmentGroupsRouter.patch(
const db = getDb();
const id = c.req.param("id");
const body = c.req.valid("json");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
// Verify group exists
const [group] = await db
.select()
.from(appointmentGroups)
.where(eq(appointmentGroups.id, id));
if (!group) return c.json({ error: "Not found" }, 404);
// Groomer: verify at least one appointment in the group is assigned to them
if (isGroomer) {
const groupAppts = await db
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
.from(appointments)
.where(eq(appointments.groupId, id));
const hasAccess = groupAppts.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
);
if (!hasAccess) return c.json({ error: "Forbidden" }, 403);
}
const [updated] = await db
.update(appointmentGroups)
@@ -251,7 +303,6 @@ appointmentGroupsRouter.patch(
.where(eq(appointmentGroups.id, id))
.returning();
if (!updated) return c.json({ error: "Not found" }, 404);
return c.json(updated);
}
);
@@ -261,13 +312,27 @@ appointmentGroupsRouter.patch(
appointmentGroupsRouter.delete("/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db
.select({ id: appointmentGroups.id })
.select()
.from(appointmentGroups)
.where(eq(appointmentGroups.id, id));
if (!group) return c.json({ error: "Not found" }, 404);
// Groomer: verify at least one appointment in the group is assigned to them
if (isGroomer) {
const groupAppts = await db
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
.from(appointments)
.where(eq(appointments.groupId, id));
const hasAccess = groupAppts.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
);
if (!hasAccess) return c.json({ error: "Forbidden" }, 403);
}
await db
.update(appointments)
.set({ status: "cancelled", updatedAt: new Date() })
+51
View File
@@ -163,6 +163,29 @@ appointmentsRouter.post(
}
}
// Check batherStaffId conflicts if set
if (apptFields.batherStaffId) {
const conflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
or(
eq(appointments.staffId, apptFields.batherStaffId),
eq(appointments.batherStaffId, apptFields.batherStaffId)
),
lt(appointments.startTime, end),
gte(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
)
.limit(1);
if (conflicts.length > 0) {
throw Object.assign(new Error("conflict"), { statusCode: 409 });
}
}
if (!recurrence) {
// Single appointment
const [inserted] = await tx
@@ -461,6 +484,34 @@ appointmentsRouter.patch(
}
}
// Check batherStaffId conflicts if being updated or already set
const batherStaffId =
updateFields.batherStaffId !== undefined
? updateFields.batherStaffId
: current.batherStaffId;
if (batherStaffId) {
const conflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
or(
eq(appointments.staffId, batherStaffId),
eq(appointments.batherStaffId, batherStaffId)
),
lt(appointments.startTime, end),
gte(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, id),
)
)
.limit(1);
if (conflicts.length > 0) {
throw Object.assign(new Error("conflict"), { statusCode: 409 });
}
}
const [updated] = await tx
.update(appointments)
.set(update)
+97 -7
View File
@@ -1,9 +1,10 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
import { and, appointments, desc, eq, getDb, groomingVisitLogs, or } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const groomingLogsRouter = new Hono();
export const groomingLogsRouter = new Hono<AppEnv>();
const createLogSchema = z.object({
petId: z.string().uuid(),
@@ -19,7 +20,29 @@ const createLogSchema = z.object({
groomingLogsRouter.get("/", async (c) => {
const db = getDb();
const petId = c.req.query("petId");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
if (!petId) return c.json({ error: "petId is required" }, 400);
// Groomer: verify they have at least one appointment for this pet
if (isGroomer) {
const [hasAppt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.petId, petId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!hasAppt) return c.json({ error: "Forbidden" }, 403);
}
const rows = await db
.select()
.from(groomingVisitLogs)
@@ -33,7 +56,46 @@ groomingLogsRouter.post(
zValidator("json", createLogSchema),
async (c) => {
const db = getDb();
const { groomedAt, ...rest } = c.req.valid("json");
const { groomedAt, appointmentId, ...rest } = c.req.valid("json");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
// Groomer: verify they have at least one appointment for this pet
if (isGroomer) {
const [hasAppt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.petId, rest.petId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!hasAppt) return c.json({ error: "Forbidden" }, 403);
// If appointmentId is provided, verify groomer is assigned to that specific appointment
if (appointmentId) {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.id, appointmentId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
}
}
const [row] = await db
.insert(groomingVisitLogs)
.values({
@@ -47,10 +109,38 @@ groomingLogsRouter.post(
groomingLogsRouter.delete("/:id", async (c) => {
const db = getDb();
const [row] = await db
const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
// Fetch the log to get the petId
const [log] = await db
.select()
.from(groomingVisitLogs)
.where(eq(groomingVisitLogs.id, id));
if (!log) return c.json({ error: "Not found" }, 404);
// Groomer: verify the log's petId links to an appointment where groomer is assigned
if (isGroomer) {
const [hasAppt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.petId, log.petId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!hasAppt) return c.json({ error: "Forbidden" }, 403);
}
await db
.delete(groomingVisitLogs)
.where(eq(groomingVisitLogs.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
.where(eq(groomingVisitLogs.id, id));
return c.json({ ok: true });
});