diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c940e0d..e53c365 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -6,6 +6,7 @@ import { clientsRouter } from "./routes/clients.js"; import { petsRouter } from "./routes/pets.js"; import { servicesRouter } from "./routes/services.js"; import { appointmentsRouter } from "./routes/appointments.js"; +import { waitlistRouter } from "./routes/waitlist.js"; import { portalRouter } from "./routes/portal.js"; import { staffRouter } from "./routes/staff.js"; import { invoicesRouter } from "./routes/invoices.js"; @@ -109,6 +110,7 @@ api.route("/pets", petsRouter); api.route("/services", servicesRouter); api.route("/appointments", appointmentsRouter); api.route("/portal", portalRouter); +api.route("/waitlist", waitlistRouter); api.route("/staff", staffRouter); api.route("/invoices", invoicesRouter); api.route("/reports", reportsRouter); diff --git a/apps/api/src/routes/waitlist.ts b/apps/api/src/routes/waitlist.ts new file mode 100644 index 0000000..abb7697 --- /dev/null +++ b/apps/api/src/routes/waitlist.ts @@ -0,0 +1,198 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { + and, + eq, + getDb, + waitlistEntries, + clients, + pets, + services, + impersonationSessions, +} from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; + +export const waitlistRouter = new Hono(); + +const waitlistStatusEnum = z.enum(["active", "notified", "expired", "cancelled"]); + +const createWaitlistEntrySchema = z.object({ + petId: z.string().uuid(), + serviceId: z.string().uuid(), + preferredDate: z.string(), + preferredTime: z.string(), +}); + +const updateWaitlistEntrySchema = z.object({ + status: waitlistStatusEnum.optional(), + preferredDate: z.string().optional(), + preferredTime: z.string().optional(), +}); + +waitlistRouter.get("/", async (c) => { + const db = getDb(); + const date = c.req.query("date"); + + const conditions = []; + if (date) { + conditions.push(eq(waitlistEntries.preferredDate, date)); + } + + const rows = + conditions.length > 0 + ? await db + .select() + .from(waitlistEntries) + .where(and(...conditions)) + .orderBy(waitlistEntries.createdAt) + : await db + .select() + .from(waitlistEntries) + .orderBy(waitlistEntries.createdAt); + + const enriched = await Promise.all( + rows.map(async (entry) => { + const [client] = await db + .select({ name: clients.name, email: clients.email }) + .from(clients) + .where(eq(clients.id, entry.clientId)) + .limit(1); + const [pet] = await db + .select({ name: pets.name }) + .from(pets) + .where(eq(pets.id, entry.petId)) + .limit(1); + const [service] = await db + .select({ name: services.name }) + .from(services) + .where(eq(services.id, entry.serviceId)) + .limit(1); + return { + ...entry, + clientName: client?.name ?? null, + clientEmail: client?.email ?? null, + petName: pet?.name ?? null, + serviceName: service?.name ?? null, + }; + }) + ); + + return c.json(enriched); +}); + +waitlistRouter.get("/:id", async (c) => { + const db = getDb(); + const [row] = await db + .select() + .from(waitlistEntries) + .where(eq(waitlistEntries.id, c.req.param("id"))) + .limit(1); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json(row); +}); + +waitlistRouter.post( + "/", + zValidator("json", createWaitlistEntrySchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const sessionId = c.req.header("X-Impersonation-Session-Id"); + + let clientId: string | null = null; + if (sessionId) { + const [session] = await db + .select() + .from(impersonationSessions) + .where( + and( + eq(impersonationSessions.id, sessionId), + eq(impersonationSessions.status, "active") + ) + ) + .limit(1); + if (session && session.expiresAt > new Date()) { + clientId = session.clientId; + } + } + + if (!clientId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [entry] = await db + .insert(waitlistEntries) + .values({ + clientId, + petId: body.petId, + serviceId: body.serviceId, + preferredDate: body.preferredDate, + preferredTime: body.preferredTime, + }) + .returning(); + + return c.json(entry, 201); + } +); + +waitlistRouter.patch( + "/:id", + zValidator("json", updateWaitlistEntrySchema), + async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const body = c.req.valid("json"); + + const updateData: Record = { updatedAt: new Date() }; + if (body.status !== undefined) updateData.status = body.status; + if (body.preferredDate !== undefined) updateData.preferredDate = body.preferredDate; + if (body.preferredTime !== undefined) updateData.preferredTime = body.preferredTime; + + const [updated] = await db + .update(waitlistEntries) + .set(updateData) + .where(eq(waitlistEntries.id, id)) + .returning(); + + if (!updated) return c.json({ error: "Not found" }, 404); + return c.json(updated); + } +); + +waitlistRouter.delete("/:id", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const sessionId = c.req.header("X-Impersonation-Session-Id"); + + if (sessionId) { + const [session] = await db + .select() + .from(impersonationSessions) + .where( + and( + eq(impersonationSessions.id, sessionId), + eq(impersonationSessions.status, "active") + ) + ) + .limit(1); + if (session && session.expiresAt > new Date()) { + const [entry] = await db + .select() + .from(waitlistEntries) + .where(eq(waitlistEntries.id, id)) + .limit(1); + if (entry && entry.clientId !== session.clientId) { + return c.json({ error: "Forbidden" }, 403); + } + } + } + + const [deleted] = await db + .delete(waitlistEntries) + .where(eq(waitlistEntries.id, id)) + .returning(); + + if (!deleted) return c.json({ error: "Not found" }, 404); + return c.json({ ok: true }); +}); diff --git a/packages/db/migrations/0015_waitlist.sql b/packages/db/migrations/0015_waitlist.sql new file mode 100644 index 0000000..fcbc6a7 --- /dev/null +++ b/packages/db/migrations/0015_waitlist.sql @@ -0,0 +1,18 @@ +CREATE TABLE waitlist_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + pet_id UUID NOT NULL REFERENCES pets(id) ON DELETE CASCADE, + service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE, + preferred_date DATE NOT NULL, + preferred_time TIME NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + notified_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_waitlist_client_id ON waitlist_entries (client_id); +CREATE INDEX idx_waitlist_preferred_date ON waitlist_entries (preferred_date); +CREATE INDEX idx_waitlist_status ON waitlist_entries (status) WHERE status = 'active'; +CREATE UNIQUE INDEX idx_waitlist_active_unique ON waitlist_entries (client_id, pet_id, service_id, preferred_date, preferred_time) WHERE status = 'active'; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index b719b92..676602b 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -312,3 +312,38 @@ export const groomingVisitLogs = pgTable("grooming_visit_logs", { groomedAt: timestamp("groomed_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(), }); + +export const waitlistStatusEnum = pgEnum("waitlist_status", [ + "active", + "notified", + "expired", + "cancelled", +]); + +export const waitlistEntries = pgTable( + "waitlist_entries", + { + id: uuid("id").primaryKey().defaultRandom(), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + petId: uuid("pet_id") + .notNull() + .references(() => pets.id, { onDelete: "cascade" }), + serviceId: uuid("service_id") + .notNull() + .references(() => services.id, { onDelete: "cascade" }), + preferredDate: text("preferred_date").notNull(), + preferredTime: text("preferred_time").notNull(), + status: waitlistStatusEnum("status").notNull().default("active"), + notifiedAt: timestamp("notified_at"), + expiresAt: timestamp("expires_at"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + index("idx_waitlist_client_id").on(t.clientId), + index("idx_waitlist_preferred_date").on(t.preferredDate), + index("idx_waitlist_status").on(t.status), + ] +);