import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { and, eq, getDb, gte, lt, lte, ne, appointmentGroups, appointments, clients, pets, services, staff, } from "@groombook/db"; export const appointmentGroupsRouter = new Hono(); // ─── Schemas ────────────────────────────────────────────────────────────────── const petAppointmentSchema = z.object({ petId: z.string().uuid(), serviceId: z.string().uuid(), staffId: z.string().uuid().optional(), // Each pet may have a different end time (e.g. small dog done faster) endTime: z.string().datetime(), priceCents: z.number().int().positive().optional(), }); const createGroupSchema = z.object({ clientId: z.string().uuid(), startTime: z.string().datetime(), // One entry per pet pets: z.array(petAppointmentSchema).min(2, "A group booking requires at least 2 pets"), notes: z.string().max(2000).optional(), }); const updateGroupSchema = z.object({ notes: z.string().max(2000).nullable().optional(), }); // ─── List groups (compact, with appointment count and start time) ───────────── appointmentGroupsRouter.get("/", async (c) => { const db = getDb(); const clientId = c.req.query("clientId"); const from = c.req.query("from"); const to = c.req.query("to"); const groupConditions = clientId ? [eq(appointmentGroups.clientId, clientId)] : []; const groups = await db .select() .from(appointmentGroups) .where(groupConditions.length > 0 ? and(...groupConditions) : undefined) .orderBy(appointmentGroups.createdAt); if (groups.length === 0) return c.json([]); // Fetch appointments for all groups (filter by time range if provided) const apptConditions = []; if (from) apptConditions.push(gte(appointments.startTime, new Date(from))); if (to) apptConditions.push(lte(appointments.startTime, new Date(to))); const allAppts = await db .select() .from(appointments) .where(apptConditions.length > 0 ? and(...apptConditions) : undefined); const groupApptMap = new Map(); for (const appt of allAppts) { if (!appt.groupId) continue; if (!groupApptMap.has(appt.groupId)) groupApptMap.set(appt.groupId, []); groupApptMap.get(appt.groupId)!.push(appt); } const result = groups .map((g) => ({ ...g, appointments: (groupApptMap.get(g.id) ?? []).sort( (a, b) => a.startTime.getTime() - b.startTime.getTime() ), })) .filter((g) => !from || g.appointments.length > 0); return c.json(result); }); // ─── Get single group with its appointments ─────────────────────────────────── appointmentGroupsRouter.get("/:id", async (c) => { const db = getDb(); const id = c.req.param("id"); const [group] = await db .select() .from(appointmentGroups) .where(eq(appointmentGroups.id, id)); if (!group) return c.json({ error: "Not found" }, 404); const groupAppts = await db .select({ id: appointments.id, petId: appointments.petId, petName: pets.name, serviceId: appointments.serviceId, serviceName: services.name, staffId: appointments.staffId, staffName: staff.name, status: appointments.status, startTime: appointments.startTime, endTime: appointments.endTime, priceCents: appointments.priceCents, notes: appointments.notes, }) .from(appointments) .leftJoin(pets, eq(appointments.petId, pets.id)) .leftJoin(services, eq(appointments.serviceId, services.id)) .leftJoin(staff, eq(appointments.staffId, staff.id)) .where(eq(appointments.groupId, id)) .orderBy(appointments.startTime); const [client] = await db .select({ name: clients.name, email: clients.email }) .from(clients) .where(eq(clients.id, group.clientId)); return c.json({ ...group, client, appointments: groupAppts }); }); // ─── Create group booking ───────────────────────────────────────────────────── appointmentGroupsRouter.post( "/", zValidator("json", createGroupSchema), async (c) => { const db = getDb(); const body = c.req.valid("json"); const startTime = new Date(body.startTime); // Verify client exists const [client] = await db .select({ id: clients.id }) .from(clients) .where(eq(clients.id, body.clientId)); if (!client) return c.json({ error: "Client not found" }, 404); // Verify all pets belong to this client const petIds = body.pets.map((p) => p.petId); const petRows = await db .select({ id: pets.id, clientId: pets.clientId }) .from(pets) .where(eq(pets.clientId, body.clientId)); const ownedPetIds = new Set(petRows.map((p) => p.id)); const unauthorized = petIds.filter((id) => !ownedPetIds.has(id)); if (unauthorized.length > 0) { return c.json({ error: `Pet(s) not found for this client: ${unauthorized.join(", ")}` }, 422); } // Deduplicate pets in a single booking if (new Set(petIds).size !== petIds.length) { return c.json({ error: "Each pet can only appear once per group booking" }, 422); } try { const result = await db.transaction(async (tx) => { // Check conflicts for each staff member for (const pet of body.pets) { if (!pet.staffId) continue; const endTime = new Date(pet.endTime); const conflicts = await tx .select({ id: appointments.id }) .from(appointments) .where( and( eq(appointments.staffId, pet.staffId), lt(appointments.startTime, endTime), gte(appointments.endTime, startTime), ne(appointments.status, "cancelled"), ne(appointments.status, "no_show"), ) ) .limit(1); if (conflicts.length > 0) { throw Object.assign( new Error(`Staff conflict for pet ${pet.petId}`), { statusCode: 409, petId: pet.petId, staffId: pet.staffId } ); } } // Create the group record const [group] = await tx .insert(appointmentGroups) .values({ clientId: body.clientId, notes: body.notes ?? null }) .returning(); if (!group) throw new Error("Failed to create appointment group"); // Create one appointment per pet const createdAppts = []; for (const pet of body.pets) { const endTime = new Date(pet.endTime); const [appt] = await tx .insert(appointments) .values({ clientId: body.clientId, petId: pet.petId, serviceId: pet.serviceId, staffId: pet.staffId ?? null, startTime, endTime, priceCents: pet.priceCents ?? null, groupId: group.id, }) .returning(); if (appt) createdAppts.push(appt); } return { group, appointments: createdAppts }; }); return c.json(result, 201); } catch (err: unknown) { const e = err as Error & { statusCode?: number }; if (e.statusCode === 409) { return c.json({ error: "A staff member has a conflicting appointment at this time", detail: e.message }, 409); } throw err; } } ); // ─── Update group notes ─────────────────────────────────────────────────────── appointmentGroupsRouter.patch( "/:id", zValidator("json", updateGroupSchema), async (c) => { const db = getDb(); const id = c.req.param("id"); const body = c.req.valid("json"); const [updated] = await db .update(appointmentGroups) .set({ ...body, updatedAt: new Date() }) .where(eq(appointmentGroups.id, id)) .returning(); if (!updated) return c.json({ error: "Not found" }, 404); return c.json(updated); } ); // ─── Cancel all appointments in a group ────────────────────────────────────── appointmentGroupsRouter.delete("/:id", async (c) => { const db = getDb(); const id = c.req.param("id"); const [group] = await db .select({ id: appointmentGroups.id }) .from(appointmentGroups) .where(eq(appointmentGroups.id, id)); if (!group) return c.json({ error: "Not found" }, 404); await db .update(appointments) .set({ status: "cancelled", updatedAt: new Date() }) .where(eq(appointments.groupId, id)); return c.json({ ok: true }); });