This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
app/apps/api/src/routes/book.ts
T
Groom Book CTO 71ec6f1d70 Set up unit testing infrastructure
- Extract slot generation logic into apps/api/src/lib/slots.ts for testability
- Add 8 unit tests covering slot generation edge cases (overlap, multi-groomer, boundary)
- Add @testing-library/react + jsdom to apps/web; configure vitest with jsdom environment
- Add 4 component tests for App navigation rendering and active-link highlighting
- Remove passWithNoTests: true from both vitest configs; add coverage thresholds

Closes #39

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-18 01:52:36 +00:00

248 lines
7.1 KiB
TypeScript

import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import {
and,
eq,
gt,
gte,
lt,
ne,
getDb,
services,
staff,
appointments,
clients,
pets,
} from "@groombook/db";
import {
generateAvailableSlots,
BUSINESS_START_HOUR,
BUSINESS_END_HOUR,
} from "../lib/slots.js";
export const bookRouter = new Hono();
// ─── GET /api/book/services ─────────────────────────────────────────────────
// Public: list active services for the booking flow
bookRouter.get("/services", async (c) => {
const db = getDb();
const rows = await db
.select()
.from(services)
.where(eq(services.active, true))
.orderBy(services.name);
return c.json(rows);
});
// ─── GET /api/book/availability ─────────────────────────────────────────────
// Public: return ISO startTime strings for slots where ≥1 groomer is free
// Query params: serviceId (uuid), date (YYYY-MM-DD)
bookRouter.get("/availability", async (c) => {
const serviceId = c.req.query("serviceId");
const dateStr = c.req.query("date");
if (!serviceId || !dateStr) {
return c.json({ error: "serviceId and date are required" }, 400);
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
return c.json({ error: "date must be YYYY-MM-DD" }, 400);
}
const db = getDb();
const [service] = await db
.select()
.from(services)
.where(and(eq(services.id, serviceId), eq(services.active, true)));
if (!service) return c.json({ error: "Service not found" }, 404);
const groomers = await db
.select({ id: staff.id })
.from(staff)
.where(and(eq(staff.active, true), eq(staff.role, "groomer")));
if (groomers.length === 0) return c.json([]);
const dayStart = new Date(`${dateStr}T00:00:00Z`);
dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0);
const dayEnd = new Date(`${dateStr}T00:00:00Z`);
dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0);
// Fetch all active appointments for the day (any groomer)
const booked = await db
.select({
staffId: appointments.staffId,
startTime: appointments.startTime,
endTime: appointments.endTime,
})
.from(appointments)
.where(
and(
gte(appointments.startTime, dayStart),
lt(appointments.startTime, dayEnd),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
);
const slots = generateAvailableSlots({
dateStr,
durationMinutes: service.durationMinutes,
groomerIds: groomers.map((g) => g.id),
booked,
});
return c.json(slots);
});
// ─── POST /api/book/appointments ─────────────────────────────────────────────
// Public: create a booking. Finds or creates client by email, always creates pet.
const bookingSchema = z.object({
serviceId: z.string().uuid(),
startTime: z.string().datetime(),
clientName: z.string().min(1).max(200),
clientEmail: z.string().email(),
clientPhone: z.string().max(50).optional(),
petName: z.string().min(1).max(200),
petSpecies: z.string().min(1).max(100),
petBreed: z.string().max(100).optional(),
notes: z.string().max(2000).optional(),
});
bookRouter.post(
"/appointments",
zValidator("json", bookingSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const start = new Date(body.startTime);
const [service] = await db
.select()
.from(services)
.where(and(eq(services.id, body.serviceId), eq(services.active, true)));
if (!service) return c.json({ error: "Service not found" }, 404);
const end = new Date(start.getTime() + service.durationMinutes * 60_000);
// Find all active groomers
const groomers = await db
.select({ id: staff.id })
.from(staff)
.where(and(eq(staff.active, true), eq(staff.role, "groomer")));
if (groomers.length === 0) {
return c.json({ error: "No groomers available" }, 409);
}
// Find conflicting appointments for this time window
const booked = await db
.select({ staffId: appointments.staffId })
.from(appointments)
.where(
and(
lt(appointments.startTime, end),
gt(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
);
const busyIds = new Set(booked.map((a) => a.staffId));
const freeGroomer = groomers.find(({ id }) => !busyIds.has(id));
if (!freeGroomer) {
return c.json(
{ error: "No groomers available at this time. Please choose another slot." },
409
);
}
// Find or create client by email
let [client] = await db
.select()
.from(clients)
.where(eq(clients.email, body.clientEmail));
if (!client) {
const inserted = await db
.insert(clients)
.values({
name: body.clientName,
email: body.clientEmail,
phone: body.clientPhone ?? null,
})
.returning();
client = inserted[0];
}
if (!client) return c.json({ error: "Failed to create client" }, 500);
// Create pet
const petInserted = await db
.insert(pets)
.values({
clientId: client.id,
name: body.petName,
species: body.petSpecies,
breed: body.petBreed ?? null,
})
.returning();
const pet = petInserted[0];
if (!pet) return c.json({ error: "Failed to create pet" }, 500);
// Insert appointment in a transaction to guard against race conditions
let appointment;
try {
appointment = await db.transaction(async (tx) => {
const conflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.staffId, freeGroomer.id),
lt(appointments.startTime, end),
gt(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 });
}
const apptInserted = await tx
.insert(appointments)
.values({
clientId: client.id,
petId: pet.id,
serviceId: body.serviceId,
staffId: freeGroomer.id,
startTime: start,
endTime: end,
notes: body.notes ?? null,
})
.returning();
return apptInserted[0];
});
} catch (err: unknown) {
const code = (err as Error & { statusCode?: number }).statusCode;
if (code === 409) {
return c.json(
{ error: "This slot was just taken. Please choose another time." },
409
);
}
throw err;
}
if (!appointment) return c.json({ error: "Failed to create appointment" }, 500);
return c.json({ appointment, client, pet }, 201);
}
);