diff --git a/apps/api/src/routes/appointmentGroups.ts b/apps/api/src/routes/appointmentGroups.ts index 8ecbb45..d28cdf6 100644 --- a/apps/api/src/routes/appointmentGroups.ts +++ b/apps/api/src/routes/appointmentGroups.ts @@ -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(); // ─── 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() }) diff --git a/apps/api/src/routes/appointments.ts b/apps/api/src/routes/appointments.ts index 6ed72e2..24d6b3c 100644 --- a/apps/api/src/routes/appointments.ts +++ b/apps/api/src/routes/appointments.ts @@ -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 = { ...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) diff --git a/apps/api/src/routes/groomingLogs.ts b/apps/api/src/routes/groomingLogs.ts index 81eeaf4..1f7f85a 100644 --- a/apps/api/src/routes/groomingLogs.ts +++ b/apps/api/src/routes/groomingLogs.ts @@ -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(); 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 }); });