feat: multi-pet client group booking (closes #10) (#31)

* feat: multi-pet client group booking (closes groombook/groombook#10) (GRO-27)

- Add appointment_groups table: links multiple appointments from one client visit
- Add group_id FK on appointments (nullable, backward-compatible)
- Add GET/POST/PATCH/DELETE /api/appointment-groups endpoints
  - POST creates group record + one appointment per pet atomically (with conflict checks)
  - DELETE soft-cancels all appointments in the group
- Add GroupBooking.tsx page at /group-bookings with:
  - Dynamic pet-slot form (min 2 pets, each with their own groomer/service/end time)
  - Auto-calculates end time from service duration
  - Group card list showing all pets, groomers, and statuses side-by-side
  - Client filter and cancel-all action
- Wire into nav and routing in App.tsx
- Export AppointmentGroup type; add groupId field to Appointment type

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix: remove eslint-disable for uninstalled react-hooks plugin; remove unused clientMap (GRO-27)

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Groom Book CTO <cto@groombook.app>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #31.
This commit is contained in:
groombook-paperclip[bot]
2026-03-17 21:36:03 +00:00
committed by GitHub
parent e63ce83400
commit f47717dfd8
7 changed files with 901 additions and 0 deletions
+2
View File
@@ -10,6 +10,7 @@ import { staffRouter } from "./routes/staff.js";
import { invoicesRouter } from "./routes/invoices.js";
import { bookRouter } from "./routes/book.js";
import { reportsRouter } from "./routes/reports.js";
import { appointmentGroupsRouter } from "./routes/appointmentGroups.js";
import { authMiddleware } from "./middleware/auth.js";
import { startReminderScheduler } from "./services/reminders.js";
@@ -42,6 +43,7 @@ api.route("/appointments", appointmentsRouter);
api.route("/staff", staffRouter);
api.route("/invoices", invoicesRouter);
api.route("/reports", reportsRouter);
api.route("/appointment-groups", appointmentGroupsRouter);
const port = Number(process.env.PORT ?? 3000);
console.log(`API server listening on port ${port}`);
+277
View File
@@ -0,0 +1,277 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
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<string, typeof appointments.$inferSelect[]>();
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 });
});