feat(gro-48): row-level data scoping for groomer role (RBAC Phase 2)

Filter query results at the route handler level when staff role is groomer:

- GET /api/appointments: WHERE staffId = groomer OR batherStaffId = groomer
- GET /api/appointments/🆔 403 if not assigned to groomer (as staff or bather)
- GET /api/clients: Clients with ≥1 appointment for this groomer (via exists subquery)
- GET /api/clients/🆔 403 if no appointment linkage
- GET /api/pets: Pets owned by groomer-linked clients (via exists subquery)
- GET /api/pets/:petId: 403 if no appointment linkage

Managers and receptionists: no change.

Added exists to @groombook/db exports (was missing from re-export).
Added groomerIsolation unit tests for role guard and filter logic.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Flea Flicker
2026-03-27 01:08:54 +00:00
parent cfbbc507a8
commit 87c9cac180
5 changed files with 236 additions and 19 deletions
+23 -2
View File
@@ -10,6 +10,7 @@ import {
lt,
lte,
ne,
or,
appointments,
clients,
pets,
@@ -20,8 +21,9 @@ import {
} from "@groombook/db";
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
import type { AppEnv } from "../middleware/rbac.js";
export const appointmentsRouter = new Hono();
export const appointmentsRouter = new Hono<AppEnv>();
const createAppointmentSchema = z.object({
clientId: z.string().uuid(),
@@ -63,18 +65,31 @@ const updateAppointmentSchema = z.object({
cascadeMode: z.enum(["this_only", "this_and_future", "all"]).optional(),
});
// List appointments, optionally filtered by date range or staffId
// List appointments, optionally filtered by date range or staffId.
// Groomers see only their own appointments (staffId or batherStaffId).
appointmentsRouter.get("/", async (c) => {
const db = getDb();
const from = c.req.query("from");
const to = c.req.query("to");
const staffId = c.req.query("staffId");
const staffRow = c.get("staff");
const isGroomer = staffRow.role === "groomer";
const conditions = [];
if (from) conditions.push(gte(appointments.startTime, new Date(from)));
if (to) conditions.push(lte(appointments.startTime, new Date(to)));
if (staffId) conditions.push(eq(appointments.staffId, staffId));
// Groomer: restrict to their own appointments (as groomer or bather)
if (isGroomer) {
conditions.push(
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
);
}
const rows =
conditions.length > 0
? await db
@@ -92,11 +107,17 @@ appointmentsRouter.get("/", async (c) => {
appointmentsRouter.get("/:id", async (c) => {
const db = getDb();
const staffRow = c.get("staff");
const isGroomer = staffRow.role === "groomer";
const [row] = await db
.select()
.from(appointments)
.where(eq(appointments.id, c.req.param("id")));
if (!row) return c.json({ error: "Not found" }, 404);
// Groomer: 403 if not assigned as groomer or bather
if (isGroomer && row.staffId !== staffRow.id && row.batherStaffId !== staffRow.id) {
return c.json({ error: "Forbidden" }, 403);
}
return c.json(row);
});