feat(api): RBAC Phase 2 - row-level data scoping for groomer role
Filter query results at the route handler level when the authenticated staff role is 'groomer': - GET /api/appointments: WHERE staffId = <groomer id> - GET /api/appointments/🆔 403 if not assigned to groomer - GET /api/clients: clients with ≥1 appointment for this groomer - GET /api/clients/🆔 403 if no appointment linkage - GET /api/pets: pets owned by groomer-linked clients - GET /api/pets/:petId: 403 if no appointment linkage Managers and receptionists: no change. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -18,10 +18,11 @@ import {
|
||||
services,
|
||||
staff,
|
||||
} from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||
|
||||
export const appointmentsRouter = new Hono();
|
||||
export const appointmentsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createAppointmentSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
@@ -66,6 +67,7 @@ const updateAppointmentSchema = z.object({
|
||||
// List appointments, optionally filtered by date range or staffId
|
||||
appointmentsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const currentStaff = c.get("staff");
|
||||
const from = c.req.query("from");
|
||||
const to = c.req.query("to");
|
||||
const staffId = c.req.query("staffId");
|
||||
@@ -75,6 +77,11 @@ appointmentsRouter.get("/", async (c) => {
|
||||
if (to) conditions.push(lte(appointments.startTime, new Date(to)));
|
||||
if (staffId) conditions.push(eq(appointments.staffId, staffId));
|
||||
|
||||
// Row-level scoping: groomers see only their own appointments
|
||||
if (currentStaff.role === "groomer") {
|
||||
conditions.push(eq(appointments.staffId, currentStaff.id));
|
||||
}
|
||||
|
||||
const rows =
|
||||
conditions.length > 0
|
||||
? await db
|
||||
@@ -92,11 +99,18 @@ appointmentsRouter.get("/", async (c) => {
|
||||
|
||||
appointmentsRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const currentStaff = c.get("staff");
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, c.req.param("id")));
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
// Row-level scoping: groomers can only view their own appointments
|
||||
if (currentStaff.role === "groomer" && row.staffId !== currentStaff.id) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { eq, getDb, clients } from "@groombook/db";
|
||||
import { and, eq, inArray, getDb, clients, appointments } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const clientsRouter = new Hono();
|
||||
export const clientsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createClientSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
@@ -15,9 +16,33 @@ const createClientSchema = z.object({
|
||||
|
||||
|
||||
// List clients — defaults to active only, ?includeDisabled=true shows all
|
||||
// Groomers see only clients with at least one appointment assigned to them
|
||||
clientsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const currentStaff = c.get("staff");
|
||||
const includeDisabled = c.req.query("includeDisabled") === "true";
|
||||
|
||||
// Row-level scoping: groomers see only clients with ≥1 appointment for them
|
||||
if (currentStaff.role === "groomer") {
|
||||
const groomerAppointments = await db
|
||||
.select({ clientId: appointments.clientId })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.staffId, currentStaff.id));
|
||||
|
||||
const clientIds = [...new Set(groomerAppointments.map((a) => a.clientId))];
|
||||
if (clientIds.length === 0) return c.json([]);
|
||||
|
||||
const conditions = [inArray(clients.id, clientIds)];
|
||||
if (!includeDisabled) conditions.push(eq(clients.status, "active"));
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(and(...conditions))
|
||||
.orderBy(clients.name);
|
||||
return c.json(rows);
|
||||
}
|
||||
|
||||
const query = includeDisabled
|
||||
? db.select().from(clients).orderBy(clients.name)
|
||||
: db.select().from(clients).where(eq(clients.status, "active")).orderBy(clients.name);
|
||||
@@ -25,14 +50,33 @@ clientsRouter.get("/", async (c) => {
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
// Get a single client
|
||||
// Get a single client — groomers get 403 if no appointment links them to this client
|
||||
clientsRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const currentStaff = c.get("staff");
|
||||
const clientId = c.req.param("id");
|
||||
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.id, c.req.param("id")));
|
||||
.where(eq(clients.id, clientId));
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
// Row-level scoping: groomers can only see clients linked via an appointment
|
||||
if (currentStaff.role === "groomer") {
|
||||
const linked = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.clientId, clientId),
|
||||
eq(appointments.staffId, currentStaff.id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (linked.length === 0) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { eq, getDb, pets } from "@groombook/db";
|
||||
import { and, eq, inArray, getDb, pets, appointments } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import {
|
||||
getPresignedUploadUrl,
|
||||
@@ -30,7 +30,33 @@ const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
|
||||
|
||||
petsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const currentStaff = c.get("staff");
|
||||
const clientId = c.req.query("clientId");
|
||||
|
||||
// Row-level scoping: groomers see only pets owned by their linked clients
|
||||
if (currentStaff.role === "groomer") {
|
||||
const groomerAppointments = await db
|
||||
.select({ clientId: appointments.clientId })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.staffId, currentStaff.id));
|
||||
|
||||
const clientIds = [...new Set(groomerAppointments.map((a) => a.clientId))];
|
||||
if (clientIds.length === 0) return c.json([]);
|
||||
|
||||
// If clientId is explicitly specified, verify it belongs to the groomer's scope
|
||||
if (clientId) {
|
||||
if (!clientIds.includes(clientId)) return c.json([]);
|
||||
const rows = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
||||
return c.json(rows);
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pets)
|
||||
.where(inArray(pets.clientId, clientIds));
|
||||
return c.json(rows);
|
||||
}
|
||||
|
||||
const query = db.select().from(pets);
|
||||
if (clientId) {
|
||||
const rows = await query.where(eq(pets.clientId, clientId));
|
||||
@@ -42,11 +68,30 @@ petsRouter.get("/", async (c) => {
|
||||
|
||||
petsRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const currentStaff = c.get("staff");
|
||||
const petId = c.req.param("id");
|
||||
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(pets)
|
||||
.where(eq(pets.id, c.req.param("id")));
|
||||
.where(eq(pets.id, petId));
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
// Row-level scoping: groomers can only see pets linked via an appointment
|
||||
if (currentStaff.role === "groomer") {
|
||||
const linked = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.clientId, row.clientId),
|
||||
eq(appointments.staffId, currentStaff.id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (linked.length === 0) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import postgres from "postgres";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
export * from "./schema.js";
|
||||
export { and, asc, desc, eq, gte, gt, ilike, lt, lte, ne, or, sql } from "drizzle-orm";
|
||||
export { and, asc, desc, eq, gte, gt, ilike, inArray, lt, lte, ne, or, sql } from "drizzle-orm";
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user