diff --git a/apps/api/src/routes/appointmentGroups.ts b/apps/api/src/routes/appointmentGroups.ts index 8ecbb45..859f64e 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)] @@ -79,7 +82,7 @@ appointmentGroupsRouter.get("/", async (c) => { groupApptMap.get(appt.groupId)!.push(appt); } - const result = groups + let result = groups .map((g) => ({ ...g, appointments: (groupApptMap.get(g.id) ?? []).sort( @@ -88,6 +91,15 @@ appointmentGroupsRouter.get("/", async (c) => { })) .filter((g) => !from || g.appointments.length > 0); + // Groomer: filter to groups where at least one appointment is assigned to them + if (isGroomer) { + result = result.filter((g) => + g.appointments.some( + (a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id + ) + ); + } + return c.json(result); }); @@ -96,6 +108,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() @@ -112,6 +126,7 @@ appointmentGroupsRouter.get("/:id", async (c) => { serviceName: services.name, staffId: appointments.staffId, staffName: staff.name, + batherStaffId: appointments.batherStaffId, status: appointments.status, startTime: appointments.startTime, endTime: appointments.endTime, @@ -125,6 +140,14 @@ appointmentGroupsRouter.get("/:id", async (c) => { .where(eq(appointments.groupId, id)) .orderBy(appointments.startTime); + // Groomer: verify at least one appointment in the group is assigned to them + if (isGroomer) { + const hasAccess = groupAppts.some( + (a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id + ); + if (!hasAccess) return c.json({ error: "Forbidden" }, 403); + } + const [client] = await db .select({ name: clients.name, email: clients.email }) .from(clients) @@ -140,6 +163,14 @@ appointmentGroupsRouter.post( zValidator("json", createGroupSchema), async (c) => { const db = getDb(); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + // Only managers and receptionists can create group bookings + if (isGroomer) { + 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 +275,27 @@ 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"; + + // Verify group exists + const [group] = await db + .select() + .from(appointmentGroups) + .where(eq(appointmentGroups.id, id)); + if (!group) return c.json({ error: "Not found" }, 404); + + // Groomer: verify at least one appointment in the group is assigned to them + if (isGroomer) { + const groupAppts = await db + .select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId }) + .from(appointments) + .where(eq(appointments.groupId, id)); + const hasAccess = groupAppts.some( + (a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id + ); + if (!hasAccess) return c.json({ error: "Forbidden" }, 403); + } const [updated] = await db .update(appointmentGroups) @@ -251,7 +303,6 @@ appointmentGroupsRouter.patch( .where(eq(appointmentGroups.id, id)) .returning(); - if (!updated) return c.json({ error: "Not found" }, 404); return c.json(updated); } ); @@ -261,13 +312,27 @@ 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 }) + .select() .from(appointmentGroups) .where(eq(appointmentGroups.id, id)); if (!group) return c.json({ error: "Not found" }, 404); + // Groomer: verify at least one appointment in the group is assigned to them + if (isGroomer) { + const groupAppts = await db + .select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId }) + .from(appointments) + .where(eq(appointments.groupId, id)); + const hasAccess = groupAppts.some( + (a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id + ); + if (!hasAccess) 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..e908efc 100644 --- a/apps/api/src/routes/appointments.ts +++ b/apps/api/src/routes/appointments.ts @@ -41,6 +41,10 @@ const createAppointmentSchema = z.object({ frequencyWeeks: z.number().int().min(1).max(52), count: z.number().int().min(2).max(52), }) + .refine( + (r) => r.frequencyWeeks * r.count <= 52, + { message: "Recurrence series must not exceed 1 year" } + ) .optional(), }); @@ -163,6 +167,29 @@ appointmentsRouter.post( } } + // Check batherStaffId conflicts if set + if (apptFields.batherStaffId) { + const conflicts = 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 (conflicts.length > 0) { + throw Object.assign(new Error("conflict"), { statusCode: 409 }); + } + } + if (!recurrence) { // Single appointment const [inserted] = await tx @@ -461,6 +488,34 @@ appointmentsRouter.patch( } } + // Check batherStaffId conflicts if being updated or already set + const batherStaffId = + updateFields.batherStaffId !== undefined + ? updateFields.batherStaffId + : current.batherStaffId; + if (batherStaffId) { + const conflicts = 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 (conflicts.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/book.ts b/apps/api/src/routes/book.ts index d82823f..f74405c 100644 --- a/apps/api/src/routes/book.ts +++ b/apps/api/src/routes/book.ts @@ -102,7 +102,10 @@ bookRouter.get("/availability", async (c) => { const bookingSchema = z.object({ serviceId: z.string().uuid(), - startTime: z.string().datetime(), + startTime: z.string().datetime().refine( + (dt) => new Date(dt) > new Date(), + { message: "Appointment must be in the future" } + ), clientName: z.string().min(1).max(200), clientEmail: z.string().email(), clientPhone: z.string().max(50).optional(), diff --git a/apps/api/src/routes/groomingLogs.ts b/apps/api/src/routes/groomingLogs.ts index 81eeaf4..6e0aadb 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, appointments, desc, eq, getDb, groomingVisitLogs, 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(), @@ -19,7 +20,29 @@ const createLogSchema = z.object({ groomingLogsRouter.get("/", async (c) => { const db = getDb(); const petId = c.req.query("petId"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + if (!petId) return c.json({ error: "petId is required" }, 400); + + // Groomer: verify they have at least one appointment for this pet + if (isGroomer) { + const [hasAppt] = 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 (!hasAppt) return c.json({ error: "Forbidden" }, 403); + } + const rows = await db .select() .from(groomingVisitLogs) @@ -33,7 +56,46 @@ groomingLogsRouter.post( zValidator("json", createLogSchema), async (c) => { const db = getDb(); - const { groomedAt, ...rest } = c.req.valid("json"); + const { groomedAt, appointmentId, ...rest } = c.req.valid("json"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + // Groomer: verify they have at least one appointment for this pet + if (isGroomer) { + const [hasAppt] = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.petId, rest.petId), + or( + eq(appointments.staffId, staffRow.id), + eq(appointments.batherStaffId, staffRow.id) + ) + ) + ) + .limit(1); + if (!hasAppt) return c.json({ error: "Forbidden" }, 403); + + // If appointmentId is provided, verify groomer is assigned to that specific appointment + 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); + } + } + const [row] = await db .insert(groomingVisitLogs) .values({ @@ -47,10 +109,38 @@ 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"; + + // Fetch the log to get the petId + const [log] = await db + .select() + .from(groomingVisitLogs) + .where(eq(groomingVisitLogs.id, id)); + if (!log) return c.json({ error: "Not found" }, 404); + + // Groomer: verify the log's petId links to an appointment where groomer is assigned + if (isGroomer) { + const [hasAppt] = 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 (!hasAppt) return c.json({ error: "Forbidden" }, 403); + } + + await db .delete(groomingVisitLogs) - .where(eq(groomingVisitLogs.id, c.req.param("id"))) - .returning(); - if (!row) return c.json({ error: "Not found" }, 404); + .where(eq(groomingVisitLogs.id, id)); + return c.json({ ok: true }); }); diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 96c3e0e..0f65a34 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -44,53 +44,61 @@ const updateInvoiceSchema = z.object({ }); // List invoices -invoicesRouter.get("/", async (c) => { - const db = getDb(); - const clientId = c.req.query("clientId"); - const appointmentId = c.req.query("appointmentId"); - const status = c.req.query("status"); - const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200); - const offset = parseInt(c.req.query("offset") || "0", 10); - - const conditions = []; - if (clientId) conditions.push(eq(invoices.clientId, clientId)); - if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId)); - if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void")); - - const whereClause = conditions.length > 0 ? and(...conditions) : undefined; - - const [totalResult] = await db - .select({ count: sql`count(*)` }) - .from(invoices) - .where(whereClause); - - const rows = await db - .select({ - id: invoices.id, - appointmentId: invoices.appointmentId, - clientId: invoices.clientId, - clientName: clients.name, - subtotalCents: invoices.subtotalCents, - taxCents: invoices.taxCents, - tipCents: invoices.tipCents, - totalCents: invoices.totalCents, - status: invoices.status, - paymentMethod: invoices.paymentMethod, - paidAt: invoices.paidAt, - notes: invoices.notes, - createdAt: invoices.createdAt, - updatedAt: invoices.updatedAt, - }) - .from(invoices) - .leftJoin(clients, eq(invoices.clientId, clients.id)) - .where(whereClause) - .orderBy(invoices.createdAt) - .limit(limit) - .offset(offset); - - return c.json({ data: rows, total: totalResult?.count ?? 0 }); +const listInvoicesQuerySchema = z.object({ + clientId: z.string().uuid().optional(), + appointmentId: z.string().uuid().optional(), + status: z.enum(["draft", "pending", "paid", "void"]).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), + offset: z.coerce.number().int().min(0).default(0), }); +invoicesRouter.get( + "/", + zValidator("query", listInvoicesQuerySchema), + async (c) => { + const db = getDb(); + const { clientId, appointmentId, status, limit, offset } = c.req.valid("query"); + + const conditions = []; + if (clientId) conditions.push(eq(invoices.clientId, clientId)); + if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId)); + if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void")); + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const [totalResult] = await db + .select({ count: sql`count(*)` }) + .from(invoices) + .where(whereClause); + + const rows = await db + .select({ + id: invoices.id, + appointmentId: invoices.appointmentId, + clientId: invoices.clientId, + clientName: clients.name, + subtotalCents: invoices.subtotalCents, + taxCents: invoices.taxCents, + tipCents: invoices.tipCents, + totalCents: invoices.totalCents, + status: invoices.status, + paymentMethod: invoices.paymentMethod, + paidAt: invoices.paidAt, + notes: invoices.notes, + createdAt: invoices.createdAt, + updatedAt: invoices.updatedAt, + }) + .from(invoices) + .leftJoin(clients, eq(invoices.clientId, clients.id)) + .where(whereClause) + .orderBy(invoices.createdAt) + .limit(limit) + .offset(offset); + + return c.json({ data: rows, total: totalResult?.count ?? 0 }); + } +); + // Get single invoice with line items and tip splits invoicesRouter.get("/:id", async (c) => { const db = getDb(); diff --git a/apps/api/src/routes/services.ts b/apps/api/src/routes/services.ts index e9ccc44..659dee2 100644 --- a/apps/api/src/routes/services.ts +++ b/apps/api/src/routes/services.ts @@ -9,7 +9,7 @@ const createServiceSchema = z.object({ name: z.string().min(1).max(200), description: z.string().max(2000).optional(), basePriceCents: z.number().int().positive(), - durationMinutes: z.number().int().positive(), + durationMinutes: z.number().int().positive().max(480), active: z.boolean().default(true), }); diff --git a/apps/api/src/routes/stripe-webhooks.ts b/apps/api/src/routes/stripe-webhooks.ts index 4a948a1..fa7c8ef 100644 --- a/apps/api/src/routes/stripe-webhooks.ts +++ b/apps/api/src/routes/stripe-webhooks.ts @@ -1,5 +1,6 @@ import { Hono } from "hono"; import Stripe from "stripe"; +import { z } from "zod/v3"; import { eq, getDb, invoices } from "@groombook/db"; import { getStripeClient } from "../services/payment.js"; @@ -44,10 +45,13 @@ webhooksRouter.post("/stripe", async (c) => { const invoiceIds = pi.metadata.groombook_invoice_ids.split(","); for (const invoiceId of invoiceIds) { if (!invoiceId) continue; + const parsed = z.string().uuid().safeParse(invoiceId.trim()); + if (!parsed.success) continue; + const invoiceIdTrimmed = invoiceId.trim(); const [inv] = await db .select() .from(invoices) - .where(eq(invoices.id, invoiceId)) + .where(eq(invoices.id, invoiceIdTrimmed)) .limit(1); if (!inv) continue; if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue; @@ -60,7 +64,7 @@ webhooksRouter.post("/stripe", async (c) => { stripePaymentIntentId: pi.id, updatedAt: new Date(), }) - .where(eq(invoices.id, invoiceId)); + .where(eq(invoices.id, invoiceIdTrimmed)); } } } else if (event.type === "payment_intent.payment_failed") { @@ -69,13 +73,16 @@ webhooksRouter.post("/stripe", async (c) => { const invoiceIds = pi.metadata.groombook_invoice_ids.split(","); for (const invoiceId of invoiceIds) { if (!invoiceId) continue; + const parsed = z.string().uuid().safeParse(invoiceId.trim()); + if (!parsed.success) continue; + const invoiceIdTrimmed = invoiceId.trim(); await db .update(invoices) .set({ paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed", updatedAt: new Date(), }) - .where(eq(invoices.id, invoiceId)); + .where(eq(invoices.id, invoiceIdTrimmed)); } } } else if (event.type === "charge.refunded") {