fix(GRO-623): implement groomer data isolation in appointmentGroups, groomingLogs + batherStaffId conflict check
- appointmentGroups router: add Hono<AppEnv>(), groomer isolation on GET /:id, PATCH /:id, DELETE /:id; managers/receptionists only for POST / - groomingLogs router: add Hono<AppEnv>(), groomer isolation on GET /, POST /, DELETE /:id - appointments POST/PATCH conflict check: also check batherStaffId conflicts - Non-groomer roles retain full access on all endpoints Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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)]
|
||||
@@ -88,6 +91,16 @@ appointmentGroupsRouter.get("/", async (c) => {
|
||||
}))
|
||||
.filter((g) => !from || g.appointments.length > 0);
|
||||
|
||||
if (isGroomer) {
|
||||
return c.json(
|
||||
result.filter((g) =>
|
||||
g.appointments.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
@@ -96,6 +109,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()
|
||||
@@ -111,6 +126,7 @@ appointmentGroupsRouter.get("/:id", async (c) => {
|
||||
serviceId: appointments.serviceId,
|
||||
serviceName: services.name,
|
||||
staffId: appointments.staffId,
|
||||
batherStaffId: appointments.batherStaffId,
|
||||
staffName: staff.name,
|
||||
status: appointments.status,
|
||||
startTime: appointments.startTime,
|
||||
@@ -125,6 +141,15 @@ appointmentGroupsRouter.get("/:id", async (c) => {
|
||||
.where(eq(appointments.groupId, id))
|
||||
.orderBy(appointments.startTime);
|
||||
|
||||
if (
|
||||
isGroomer &&
|
||||
!groupAppts.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const [client] = await db
|
||||
.select({ name: clients.name, email: clients.email })
|
||||
.from(clients)
|
||||
@@ -140,6 +165,13 @@ appointmentGroupsRouter.post(
|
||||
zValidator("json", createGroupSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const staffRow = c.get("staff");
|
||||
if (staffRow?.role === "groomer") {
|
||||
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 +276,28 @@ 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";
|
||||
|
||||
const [group] = await db
|
||||
.select({ id: appointmentGroups.id })
|
||||
.from(appointmentGroups)
|
||||
.where(eq(appointmentGroups.id, id));
|
||||
if (!group) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (isGroomer) {
|
||||
const groupAppts = await db
|
||||
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.groupId, id));
|
||||
if (
|
||||
!groupAppts.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointmentGroups)
|
||||
@@ -261,6 +315,8 @@ 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 })
|
||||
@@ -268,6 +324,20 @@ appointmentGroupsRouter.delete("/:id", async (c) => {
|
||||
.where(eq(appointmentGroups.id, id));
|
||||
if (!group) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (isGroomer) {
|
||||
const groupAppts = await db
|
||||
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.groupId, id));
|
||||
if (
|
||||
!groupAppts.some(
|
||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||
)
|
||||
) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({ status: "cancelled", updatedAt: new Date() })
|
||||
|
||||
@@ -163,6 +163,28 @@ appointmentsRouter.post(
|
||||
}
|
||||
}
|
||||
|
||||
if (apptFields.batherStaffId) {
|
||||
const bathConflicts = 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 (bathConflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!recurrence) {
|
||||
// Single appointment
|
||||
const [inserted] = await tx
|
||||
@@ -398,7 +420,8 @@ appointmentsRouter.patch(
|
||||
const needsConflictCheck =
|
||||
updateFields.startTime !== undefined ||
|
||||
updateFields.endTime !== undefined ||
|
||||
updateFields.staffId !== undefined;
|
||||
updateFields.staffId !== undefined ||
|
||||
updateFields.batherStaffId !== undefined;
|
||||
|
||||
const update: Record<string, unknown> = {
|
||||
...updateFields,
|
||||
@@ -434,6 +457,11 @@ appointmentsRouter.patch(
|
||||
updateFields.staffId !== undefined
|
||||
? updateFields.staffId
|
||||
: current.staffId;
|
||||
// Use provided batherStaffId (may be null to unassign); fall back to existing
|
||||
const batherStaffId =
|
||||
updateFields.batherStaffId !== undefined
|
||||
? updateFields.batherStaffId
|
||||
: current.batherStaffId;
|
||||
|
||||
if (end <= start) {
|
||||
throw Object.assign(new Error("end before start"), {
|
||||
@@ -461,6 +489,29 @@ appointmentsRouter.patch(
|
||||
}
|
||||
}
|
||||
|
||||
if (batherStaffId) {
|
||||
const bathConflicts = 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 (bathConflicts.length > 0) {
|
||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await tx
|
||||
.update(appointments)
|
||||
.set(update)
|
||||
|
||||
@@ -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, desc, eq, getDb, groomingVisitLogs, appointments, 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(),
|
||||
@@ -20,6 +21,26 @@ groomingLogsRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.query("petId");
|
||||
if (!petId) return c.json({ error: "petId is required" }, 400);
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
if (isGroomer) {
|
||||
const [appt] = 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 (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(groomingVisitLogs)
|
||||
@@ -33,11 +54,50 @@ groomingLogsRouter.post(
|
||||
zValidator("json", createLogSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const { groomedAt, ...rest } = c.req.valid("json");
|
||||
const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json");
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
|
||||
if (isGroomer) {
|
||||
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);
|
||||
} else {
|
||||
const [appt] = 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 (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.insert(groomingVisitLogs)
|
||||
.values({
|
||||
...rest,
|
||||
petId,
|
||||
appointmentId: appointmentId ?? null,
|
||||
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
|
||||
})
|
||||
.returning();
|
||||
@@ -47,10 +107,37 @@ 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";
|
||||
|
||||
const [log] = await db
|
||||
.select()
|
||||
.from(groomingVisitLogs)
|
||||
.where(eq(groomingVisitLogs.id, id))
|
||||
.limit(1);
|
||||
if (!log) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (isGroomer) {
|
||||
const [appt] = 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 (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(groomingVisitLogs)
|
||||
.where(eq(groomingVisitLogs.id, c.req.param("id")))
|
||||
.where(eq(groomingVisitLogs.id, id))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user