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:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user