526251b63a
CI / Test (push) Successful in 26s
CI / Lint & Typecheck (push) Successful in 27s
CI / E2E Tests (push) Failing after 3m27s
CI / Update Infra Image Tags (push) Has been skipped
CI / Build (push) Successful in 24s
CI / Build & Push Docker Images (push) Has been skipped
CI / Web E2E (Dev) (push) Has been cancelled
CI / Deploy PR to groombook-dev (push) Has been cancelled
- Remove unused gte/lt/ne imports from cascade.ts - Prefix unused params originalEndTime, originalStartTime, newStartTime with underscore in cascade.ts and appointments.ts callers - Remove unused petCoatType query param from book.ts availability route - Align xlarge value: Book.tsx now uses "xlarge" (no hyphen) everywhere to match the Zod booking schema Co-Authored-By: Paperclip <noreply@paperclip.ing>
372 lines
11 KiB
TypeScript
372 lines
11 KiB
TypeScript
import { Hono } from "hono";
|
|
import { zValidator } from "@hono/zod-validator";
|
|
import { z } from "zod/v3";
|
|
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), petSizeCategory, petCoatType
|
|
|
|
bookRouter.get("/availability", async (c) => {
|
|
const serviceId = c.req.query("serviceId");
|
|
const dateStr = c.req.query("date");
|
|
const petSizeCategory = c.req.query("petSizeCategory") ?? undefined;
|
|
|
|
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);
|
|
|
|
// Buffer-aware duration: extra time for large/x-large or complex coats
|
|
const extraBuffer = (petSizeCategory === "large" || petSizeCategory === "xlarge")
|
|
? (service.defaultBufferMinutes ?? 0)
|
|
: 0;
|
|
const durationMinutes = service.durationMinutes + extraBuffer;
|
|
|
|
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,
|
|
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().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(),
|
|
petName: z.string().min(1).max(200),
|
|
petSpecies: z.string().min(1).max(100),
|
|
petBreed: z.string().max(100).optional(),
|
|
petSizeCategory: z
|
|
.enum(["small", "medium", "large", "xlarge"])
|
|
.optional(),
|
|
petCoatType: z
|
|
.enum(["smooth", "double", "curly", "wire", "long", "hairless"])
|
|
.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);
|
|
|
|
let 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 (skip disabled clients)
|
|
let [client] = await db
|
|
.select()
|
|
.from(clients)
|
|
.where(and(eq(clients.email, body.clientEmail), eq(clients.status, "active")));
|
|
|
|
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,
|
|
sizeCategory: body.petSizeCategory ?? null,
|
|
coatType: body.petCoatType ?? null,
|
|
})
|
|
.returning();
|
|
const pet = petInserted[0];
|
|
if (!pet) return c.json({ error: "Failed to create pet" }, 500);
|
|
|
|
// Buffer-aware end time: large/x-large pets add service bufferMinutes
|
|
if (body.petSizeCategory === "large" || body.petSizeCategory === "xlarge") {
|
|
end = new Date(start.getTime() + (service.durationMinutes + (service.defaultBufferMinutes ?? 0)) * 60_000);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
);
|
|
|
|
// ─── GET /api/book/confirm/:token ──────────────────────────────────────────
|
|
// Public: confirm appointment via tokenized email link. Redirects to success/error page.
|
|
|
|
const BASE_URL = () => process.env.APP_URL ?? "http://localhost:5173";
|
|
|
|
bookRouter.get("/confirm/:token", async (c) => {
|
|
const token = c.req.param("token");
|
|
const db = getDb();
|
|
|
|
const [appt] = await db
|
|
.select()
|
|
.from(appointments)
|
|
.where(eq(appointments.confirmationToken, token))
|
|
.limit(1);
|
|
|
|
if (!appt) {
|
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
}
|
|
|
|
if (appt.startTime < new Date()) {
|
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
}
|
|
|
|
if (appt.confirmationStatus === "confirmed") {
|
|
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
|
}
|
|
|
|
if (appt.confirmationStatus === "cancelled") {
|
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
}
|
|
|
|
const updated = await db
|
|
.update(appointments)
|
|
.set({
|
|
confirmationStatus: "confirmed",
|
|
confirmedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(appointments.confirmationToken, token),
|
|
eq(appointments.confirmationStatus, "pending")
|
|
)
|
|
)
|
|
.returning();
|
|
|
|
if (updated.length === 0) {
|
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
}
|
|
|
|
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
|
});
|
|
|
|
// ─── GET /api/book/cancel/:token ───────────────────────────────────────────
|
|
// Public: cancel appointment via tokenized email link. Redirects to success/error page.
|
|
|
|
bookRouter.get("/cancel/:token", async (c) => {
|
|
const token = c.req.param("token");
|
|
const db = getDb();
|
|
|
|
const [appt] = await db
|
|
.select()
|
|
.from(appointments)
|
|
.where(eq(appointments.confirmationToken, token))
|
|
.limit(1);
|
|
|
|
if (!appt) {
|
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
}
|
|
|
|
if (appt.startTime < new Date()) {
|
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
}
|
|
|
|
if (appt.confirmationStatus === "cancelled") {
|
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
}
|
|
|
|
const updated = await db
|
|
.update(appointments)
|
|
.set({
|
|
confirmationStatus: "cancelled",
|
|
cancelledAt: new Date(),
|
|
confirmationToken: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(appointments.confirmationToken, token),
|
|
eq(appointments.confirmationStatus, "pending")
|
|
)
|
|
)
|
|
.returning();
|
|
|
|
if (updated.length === 0) {
|
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
}
|
|
|
|
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
|
});
|