From e3220af9ced044aad482e575fcc812497b12401f Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:51:08 +0000 Subject: [PATCH] fix(gro-38): prod/demo auth and API-based seed (#117) Closes GRO-38. Adds POST /api/admin/seed (manager-only, gated by SEED_KNOWN_USERS_ONLY) and separates dev vs prod seeding paths. Reviewed and approved by CTO and QA. Co-Authored-By: Paperclip --- apps/api/src/__tests__/waitlist.test.ts | 285 +++++++++++++++++++++++ apps/api/src/index.ts | 13 +- apps/api/src/routes/admin/seed.ts | 138 +++++++++++ apps/api/src/routes/appointments.ts | 23 ++ apps/api/src/routes/portal.ts | 158 ++++++++++++- apps/api/src/routes/waitlist.ts | 88 +++++++ apps/api/src/services/email.ts | 52 +++++ apps/api/src/services/waitlistNotify.ts | 63 +++++ packages/db/migrations/0015_waitlist.sql | 20 ++ packages/db/src/schema.ts | 35 +++ packages/db/src/seed.ts | 120 ++++++++++ 11 files changed, 990 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/__tests__/waitlist.test.ts create mode 100644 apps/api/src/routes/admin/seed.ts create mode 100644 apps/api/src/routes/waitlist.ts create mode 100644 apps/api/src/services/waitlistNotify.ts create mode 100644 packages/db/migrations/0015_waitlist.sql diff --git a/apps/api/src/__tests__/waitlist.test.ts b/apps/api/src/__tests__/waitlist.test.ts new file mode 100644 index 0000000..383bc80 --- /dev/null +++ b/apps/api/src/__tests__/waitlist.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +const VALID_UUID_1 = "550e8400-e29b-41d4-a716-446655440001"; +const VALID_UUID_2 = "550e8400-e29b-41d4-a716-446655440002"; +const VALID_UUID_3 = "550e8400-e29b-41d4-a716-446655440003"; +const VALID_UUID_4 = "550e8400-e29b-41d4-a716-446655440004"; +const VALID_UUID_5 = "550e8400-e29b-41d4-a716-446655440005"; + +const WAITLIST_ENTRY = { + id: VALID_UUID_1, + clientId: VALID_UUID_2, + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "10:00", + status: "active", + notifiedAt: null, + expiresAt: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const ACTIVE_SESSION = { + id: VALID_UUID_5, + clientId: VALID_UUID_2, + status: "active" as const, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + createdAt: new Date(), +}; + +const EXPIRED_SESSION = { + id: "660e8400-e29b-41d4-a716-446655440006", + clientId: VALID_UUID_2, + status: "active" as const, + expiresAt: new Date(Date.now() - 60 * 60 * 1000), + createdAt: new Date(), +}; + +let selectRows: Record[] = []; +let selectSessionRow: Record | null = null; +let insertedValues: Record[] = []; +let updatedValues: Record[] = []; + +function resetMock() { + selectRows = []; + selectSessionRow = null; + insertedValues = []; + updatedValues = []; +} + +vi.mock("@groombook/db", () => { + function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + const chain = new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "leftJoin") { + return () => chain; + } + // @ts-expect-error proxy + return target[prop]; + }, + }); + return chain; + } + + const waitlistEntries = new Proxy( + { _name: "waitlistEntries" }, + { get: (t, p) => (p === "_name" ? "waitlistEntries" : { table: "waitlistEntries", column: p }) } + ); + + const impersonationSessions = new Proxy( + { _name: "impersonationSessions" }, + { get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) } + ); + + const clients = new Proxy( + { _name: "clients" }, + { get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) } + ); + + const pets = new Proxy( + { _name: "pets" }, + { get: (t, p) => (p === "_name" ? "pets" : { table: "pets", column: p }) } + ); + + const services = new Proxy( + { _name: "services" }, + { get: (t, p) => (p === "_name" ? "services" : { table: "services", column: p }) } + ); + + const appointments = new Proxy( + { _name: "appointments" }, + { get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "impersonationSessions") { + return makeChainable(selectSessionRow ? [selectSessionRow] : []); + } + if (table._name === "waitlistEntries") { + return makeChainable(selectRows); + } + return makeChainable([]); + }, + }), + insert: () => ({ + values: (vals: Record) => { + insertedValues.push(vals); + return { + returning: () => [{ ...WAITLIST_ENTRY, ...vals, id: "waitlist-uuid-new" }], + }; + }, + }), + update: () => ({ + set: (vals: Record) => ({ + where: () => { + updatedValues.push(vals); + return { + returning: () => + selectRows.length > 0 + ? [{ ...selectRows[0], ...vals }] + : [], + }; + }, + }), + }), + delete: () => ({ + where: () => { + return { + returning: () => + selectRows.length > 0 ? [selectRows[0]] : [], + }; + }, + }), + }), + waitlistEntries, + impersonationSessions, + clients, + pets, + services, + appointments, + eq: vi.fn(), + and: vi.fn(), + lt: vi.fn(), + }; +}); + +const { waitlistRouter } = await import("../routes/waitlist.js"); +const { portalRouter } = await import("../routes/portal.js"); + +const app = new Hono(); +app.route("/waitlist", waitlistRouter); +app.route("/portal", portalRouter); + +function jsonRequest(method: string, path: string, body?: unknown, headers?: Record) { + return app.request(path, { + method, + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +beforeEach(() => resetMock()); + +describe("POST /portal/waitlist", () => { + it("creates entry with valid session", async () => { + selectSessionRow = ACTIVE_SESSION; + const res = await jsonRequest("POST", "/portal/waitlist", { + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "10:00", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.petId).toBe(VALID_UUID_3); + expect(insertedValues).toHaveLength(1); + }); + + it("returns 401 without session", async () => { + const res = await jsonRequest("POST", "/portal/waitlist", { + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "10:00", + }); + expect(res.status).toBe(401); + }); + + it("returns 401 with expired session", async () => { + selectSessionRow = EXPIRED_SESSION; + const res = await jsonRequest("POST", "/portal/waitlist", { + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "10:00", + }, { "X-Impersonation-Session-Id": EXPIRED_SESSION.id }); + expect(res.status).toBe(401); + }); +}); + +describe("DELETE /portal/waitlist/:id", () => { + it("deletes entry with valid session and correct owner", async () => { + selectSessionRow = ACTIVE_SESSION; + selectRows = [WAITLIST_ENTRY]; + const res = await app.request(`/portal/waitlist/${VALID_UUID_1}`, { + method: "DELETE", + headers: { "X-Impersonation-Session-Id": VALID_UUID_5 }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + }); + + it("returns 401 without session", async () => { + const res = await app.request(`/portal/waitlist/${VALID_UUID_1}`, { + method: "DELETE", + }); + expect(res.status).toBe(401); + }); + + it("returns 403 with valid session but wrong owner", async () => { + selectSessionRow = { ...ACTIVE_SESSION, clientId: "other-client-uuid" }; + selectRows = [WAITLIST_ENTRY]; + const res = await app.request(`/portal/waitlist/${VALID_UUID_1}`, { + method: "DELETE", + headers: { "X-Impersonation-Session-Id": VALID_UUID_5 }, + }); + expect(res.status).toBe(403); + }); + + it("returns 404 when entry not found", async () => { + selectSessionRow = ACTIVE_SESSION; + selectRows = []; + const res = await app.request("/portal/waitlist/nonexistent", { + method: "DELETE", + headers: { "X-Impersonation-Session-Id": VALID_UUID_5 }, + }); + expect(res.status).toBe(404); + }); +}); + +describe("PATCH /portal/waitlist/:id", () => { + it("updates entry with valid session and correct owner", async () => { + selectSessionRow = ACTIVE_SESSION; + selectRows = [WAITLIST_ENTRY]; + const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, { + status: "cancelled", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(200); + expect(updatedValues[0]?.status).toBe("cancelled"); + }); + + it("returns 401 without session", async () => { + const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, { + status: "cancelled", + }); + expect(res.status).toBe(401); + }); + + it("returns 403 with valid session but wrong owner", async () => { + selectSessionRow = { ...ACTIVE_SESSION, clientId: "other-client-uuid" }; + selectRows = [WAITLIST_ENTRY]; + const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, { + status: "cancelled", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(403); + }); + + it("returns 404 when entry not found", async () => { + selectSessionRow = ACTIVE_SESSION; + selectRows = []; + const res = await jsonRequest("PATCH", "/portal/waitlist/nonexistent", { + status: "cancelled", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(404); + }); +}); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8de8e51..54c18ea 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"; @@ -20,6 +21,7 @@ import { getDb, businessSettings } from "@groombook/db"; import { authMiddleware } from "./middleware/auth.js"; import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js"; import { devRouter } from "./routes/dev.js"; +import { adminSeedRouter } from "./routes/admin/seed.js"; import { startReminderScheduler } from "./services/reminders.js"; const app = new Hono(); @@ -40,6 +42,9 @@ app.get("/health", (c) => c.json({ status: "ok" })); // Public booking routes — no auth required, must be registered before auth middleware app.route("/api/book", bookRouter); +// Public portal routes — client-facing, authenticated via impersonation session header +app.route("/api/portal", portalRouter); + // Dev/demo routes — config is always public, users endpoint is guarded internally app.route("/api/dev", devRouter); @@ -57,9 +62,6 @@ app.get("/api/branding", async (c) => { }); }); -// Portal routes — no staff auth required, uses impersonation session for client auth -app.route("/api/portal", portalRouter); - // Protected API routes const api = app.basePath("/api"); api.use("*", authMiddleware); @@ -73,9 +75,10 @@ api.use("/reports/*", requireRole("manager")); api.use("/invoices/*", requireRole("manager")); api.use("/impersonation/*", requireRole("manager")); -// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs +// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist api.use("/appointment-groups/*", requireRole("manager", "receptionist")); api.use("/grooming-logs/*", requireRole("manager", "receptionist")); +api.use("/waitlist/*", requireRole("manager", "receptionist")); // Pet photo routes: all staff roles may upload/delete (groomers take photos during grooms) // These must be registered before the general pets write guard. Because Hono path params @@ -111,6 +114,7 @@ api.route("/clients", clientsRouter); api.route("/pets", petsRouter); api.route("/services", servicesRouter); api.route("/appointments", appointmentsRouter); +api.route("/waitlist", waitlistRouter); api.route("/staff", staffRouter); api.route("/invoices", invoicesRouter); api.route("/reports", reportsRouter); @@ -118,6 +122,7 @@ api.route("/appointment-groups", appointmentGroupsRouter); api.route("/grooming-logs", groomingLogsRouter); api.route("/impersonation", impersonationRouter); api.route("/admin/settings", settingsRouter); +api.route("/admin/seed", adminSeedRouter); api.route("/search", searchRouter); const port = Number(process.env.PORT ?? 3000); diff --git a/apps/api/src/routes/admin/seed.ts b/apps/api/src/routes/admin/seed.ts new file mode 100644 index 0000000..58fc0db --- /dev/null +++ b/apps/api/src/routes/admin/seed.ts @@ -0,0 +1,138 @@ +/** + * Admin seed endpoint — populates minimal known-user seed data via the API. + * + * This is the canonical way to seed prod/demo data. The old approach (seed.ts + * writing directly to the DB) bypasses API validation and audit trails. + * + * Security: This endpoint is manager-only (enforced via requireRole in index.ts). + * It is disabled when AUTH_DISABLED=true — dev/test seeding should use the + * direct-DB seed.ts in that mode. + */ + +import { Hono } from "hono"; +import { eq, getDb, staff, clients, pets, services } from "@groombook/db"; + +export const adminSeedRouter = new Hono(); + +const KNOWN_STAFF = { + name: "Demo Manager", + email: "demo-manager@groombook.dev", + oidcSub: "demo-manager-001", + role: "manager" as const, + active: true, +}; + +const KNOWN_CLIENT = { + name: "Demo Client", + email: "demo-client@example.com", + phone: "555-0001", + address: "1 Demo Street, Demo City, CA 90210", +}; + +const DEMO_PET = { + name: "Demo Dog", + species: "Dog", + breed: "Golden Retriever", + weightKg: "30.00", +}; + +const DEMO_SERVICES = [ + { name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 }, + { name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 }, + { name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 }, + { name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 }, +]; + +adminSeedRouter.post("/seed", async (c) => { + // Refuse to run when AUTH_DISABLED — dev environments use direct-DB seeding + if (process.env.AUTH_DISABLED === "true") { + return c.json( + { + error: + "Seed endpoint is not available when AUTH_DISABLED=true. Use direct DB seeding for dev/test environments.", + }, + 403 + ); + } + + const db = getDb(); + const results: string[] = []; + + // ── Staff: Demo Manager ───────────────────────────────────────────────────── + const [existingStaff] = await db + .select() + .from(staff) + .where(eq(staff.email, KNOWN_STAFF.email)); + + if (existingStaff) { + results.push(`Staff '${KNOWN_STAFF.name}' already exists (id: ${existingStaff.id})`); + } else { + const [created] = await db.insert(staff).values(KNOWN_STAFF).returning(); + results.push(`Created staff '${KNOWN_STAFF.name}' (id: ${created!.id}, oidcSub: ${KNOWN_STAFF.oidcSub})`); + } + + // ── Services: only seed if none exist ───────────────────────────────────── + const existingServices = await db.select().from(services).limit(1); + if (existingServices.length > 0) { + results.push("Services already exist — skipping"); + } else { + const created: { id: string; name: string }[] = []; + for (const svc of DEMO_SERVICES) { + const [row] = await db.insert(services).values({ ...svc, active: true }).returning(); + created.push(row!); + } + results.push(`Created ${created.length} services: ${created.map((s) => s.name).join(", ")}`); + } + + // ── Client: Demo Client ─────────────────────────────────────────────────── + const [existingClient] = await db + .select() + .from(clients) + .where(eq(clients.email, KNOWN_CLIENT.email)); + + let clientId: string; + if (existingClient) { + clientId = existingClient.id; + results.push(`Client '${KNOWN_CLIENT.name}' already exists (id: ${clientId})`); + } else { + const [created] = await db.insert(clients).values(KNOWN_CLIENT).returning(); + clientId = created!.id; + results.push(`Created client '${KNOWN_CLIENT.name}' (id: ${clientId})`); + } + + // ── Pet: Demo Dog ────────────────────────────────────────────────────────── + const existingPets = await db + .select() + .from(pets) + .where(eq(pets.clientId, clientId)); + + const demoDog = existingPets.find( + (p) => p.name === DEMO_PET.name && p.species === DEMO_PET.species + ); + + if (demoDog) { + results.push(`Pet '${DEMO_PET.name}' already exists for Demo Client (id: ${demoDog.id})`); + } else { + const [created] = await db + .insert(pets) + .values({ + clientId, + name: DEMO_PET.name, + species: DEMO_PET.species, + breed: DEMO_PET.breed, + weightKg: DEMO_PET.weightKg, + dateOfBirth: new Date("2020-06-15T00:00:00Z"), + }) + .returning(); + results.push(`Created pet '${DEMO_PET.name}' for Demo Client (id: ${created!.id})`); + } + + return c.json({ + message: "Seed complete", + details: results, + credentials: { + note: "For dev-mode access, use X-Dev-User-Id: demo-manager-001 header", + staffOidcSub: KNOWN_STAFF.oidcSub, + }, + }); +}); diff --git a/apps/api/src/routes/appointments.ts b/apps/api/src/routes/appointments.ts index 8b82a88..edecf8d 100644 --- a/apps/api/src/routes/appointments.ts +++ b/apps/api/src/routes/appointments.ts @@ -19,6 +19,7 @@ import { staff, } from "@groombook/db"; import { buildConfirmationEmail, sendEmail } from "../services/email.js"; +import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js"; export const appointmentsRouter = new Hono(); @@ -510,16 +511,38 @@ appointmentsRouter.delete("/:id", async (c) => { .set({ status: "cancelled", updatedAt: new Date() }) .where(eq(appointments.id, id)); } + + const apptDate = current.startTime.toISOString().slice(0, 10); + const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true }); + notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => { + console.error("[appointments] Failed to notify waitlist:", err); + }); + return c.json({ ok: true }); } // Single cancel (default) + const [current] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + if (!current) return c.json({ error: "Not found" }, 404); + + const apptDate = current.startTime.toISOString().slice(0, 10); + const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true }); + const [row] = await db .update(appointments) .set({ status: "cancelled", updatedAt: new Date() }) .where(eq(appointments.id, id)) .returning(); if (!row) return c.json({ error: "Not found" }, 404); + + notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => { + console.error("[appointments] Failed to notify waitlist:", err); + }); + return c.json({ ok: true }); }); diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 5c2b2f1..b7c9fa4 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; -import { and, eq, getDb, appointments, impersonationSessions } from "@groombook/db"; +import { and, eq, getDb, appointments, impersonationSessions, waitlistEntries } from "@groombook/db"; import type { AppEnv } from "../middleware/rbac.js"; export const portalRouter = new Hono(); @@ -75,3 +75,159 @@ portalRouter.patch( }); } ); + +// ─── Client-facing waitlist routes ─────────────────────────────────────────── + +const createWaitlistEntrySchema = z.object({ + petId: z.string().uuid(), + serviceId: z.string().uuid(), + preferredDate: z.string(), + preferredTime: z.string(), +}); + +const updateWaitlistEntrySchema = z.object({ + status: z.literal("cancelled").optional(), + preferredDate: z.string().optional(), + preferredTime: z.string().optional(), +}); + +portalRouter.post( + "/waitlist", + 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); + } +); + +portalRouter.patch( + "/waitlist/:id", + zValidator("json", updateWaitlistEntrySchema), + async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const body = c.req.valid("json"); + const sessionId = c.req.header("X-Impersonation-Session-Id"); + + if (!sessionId) { + return c.json({ error: "Unauthorized" }, 401); + } + + 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()) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [existing] = await db + .select() + .from(waitlistEntries) + .where(eq(waitlistEntries.id, id)) + .limit(1); + + if (!existing) return c.json({ error: "Not found" }, 404); + if (existing.clientId !== session.clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + 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(); + + return c.json(updated); + } +); + +portalRouter.delete("/waitlist/:id", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const sessionId = c.req.header("X-Impersonation-Session-Id"); + + if (!sessionId) { + return c.json({ error: "Unauthorized" }, 401); + } + + 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()) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [entry] = await db + .select() + .from(waitlistEntries) + .where(eq(waitlistEntries.id, id)) + .limit(1); + + if (!entry) return c.json({ error: "Not found" }, 404); + if (entry.clientId !== session.clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + await db + .delete(waitlistEntries) + .where(eq(waitlistEntries.id, id)) + .returning(); + + return c.json({ ok: true }); +}); diff --git a/apps/api/src/routes/waitlist.ts b/apps/api/src/routes/waitlist.ts new file mode 100644 index 0000000..dd2adec --- /dev/null +++ b/apps/api/src/routes/waitlist.ts @@ -0,0 +1,88 @@ +import { Hono } from "hono"; +import { + and, + eq, + lt, + getDb, + waitlistEntries, + clients, + pets, + services, +} from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; + +export const waitlistRouter = new Hono(); + +async function markExpiredEntries(db: ReturnType, rows: { status: string; preferredDate: string }[]) { + const today = new Date().toISOString().slice(0, 10); + const hasExpired = rows.some((r) => r.status === "active" && r.preferredDate < today); + if (hasExpired) { + await db + .update(waitlistEntries) + .set({ status: "expired", updatedAt: new Date() }) + .where(and(eq(waitlistEntries.status, "active"), lt(waitlistEntries.preferredDate, today))); + } +} + +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 = await db + .select({ + id: waitlistEntries.id, + clientId: waitlistEntries.clientId, + petId: waitlistEntries.petId, + serviceId: waitlistEntries.serviceId, + preferredDate: waitlistEntries.preferredDate, + preferredTime: waitlistEntries.preferredTime, + status: waitlistEntries.status, + notifiedAt: waitlistEntries.notifiedAt, + expiresAt: waitlistEntries.expiresAt, + createdAt: waitlistEntries.createdAt, + updatedAt: waitlistEntries.updatedAt, + clientName: clients.name, + clientEmail: clients.email, + petName: pets.name, + serviceName: services.name, + }) + .from(waitlistEntries) + .leftJoin(clients, eq(waitlistEntries.clientId, clients.id)) + .leftJoin(pets, eq(waitlistEntries.petId, pets.id)) + .leftJoin(services, eq(waitlistEntries.serviceId, services.id)) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(waitlistEntries.createdAt); + + await markExpiredEntries(db, rows); + + const today = new Date().toISOString().slice(0, 10); + const enriched = rows.map((row) => ({ + ...row, + status: row.status === "active" && row.preferredDate < today ? "expired" : row.status, + })); + + 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); + + await markExpiredEntries(db, [row]); + const today = new Date().toISOString().slice(0, 10); + const isExpired = row.status === "active" && row.preferredDate < today; + return c.json({ + ...row, + status: isExpired ? "expired" : row.status, + }); +}); diff --git a/apps/api/src/services/email.ts b/apps/api/src/services/email.ts index adcac8d..4cd4be9 100644 --- a/apps/api/src/services/email.ts +++ b/apps/api/src/services/email.ts @@ -149,3 +149,55 @@ ${actionHtml}

— Groom Book

`, }; } + +interface WaitlistNotificationData { + clientName: string; + petName: string; + serviceName: string; + preferredDate: string; + preferredTime: string; +} + +export function buildWaitlistNotificationEmail( + to: string, + data: WaitlistNotificationData +): Mail.Options { + const apiUrl = process.env.API_URL ?? "http://localhost:3000"; + const bookUrl = `${apiUrl}/book`; + return { + to, + subject: `Appointment Cancelled — A slot has opened up for ${data.petName}`, + text: [ + `Hi ${data.clientName},`, + ``, + `Great news! An appointment slot has become available.`, + ``, + `We had a cancellation for:`, + ` Pet: ${data.petName}`, + ` Service: ${data.serviceName}`, + ` Date: ${data.preferredDate}`, + ` Time: ${data.preferredTime}`, + ``, + `If you're still interested, book now before this slot is taken!`, + ``, + `Book your appointment: ${bookUrl}`, + ``, + `— Groom Book`, + ].join("\n"), + html: ` +

Hi ${data.clientName},

+

Great news! An appointment slot has become available.

+

We had a cancellation for:

+ + + + + +
Pet${data.petName}
Service${data.serviceName}
Date${data.preferredDate}
Time${data.preferredTime}
+ +

If you're no longer interested, you can ignore this email or remove yourself from the waitlist in your portal.

+

— Groom Book

`, + }; +} diff --git a/apps/api/src/services/waitlistNotify.ts b/apps/api/src/services/waitlistNotify.ts new file mode 100644 index 0000000..2338515 --- /dev/null +++ b/apps/api/src/services/waitlistNotify.ts @@ -0,0 +1,63 @@ +import { and, eq, getDb, waitlistEntries, clients, pets, services } from "@groombook/db"; +import { buildWaitlistNotificationEmail, sendEmail } from "./email.js"; + +export async function notifyWaitlistForAppointment( + appointmentId: string, + appointmentDate: string, + appointmentTime: string, + serviceId: string +): Promise { + const db = getDb(); + + const matchingEntries = await db + .select() + .from(waitlistEntries) + .where( + and( + eq(waitlistEntries.preferredDate, appointmentDate), + eq(waitlistEntries.preferredTime, appointmentTime), + eq(waitlistEntries.serviceId, serviceId), + eq(waitlistEntries.status, "active") + ) + ); + + for (const entry of matchingEntries) { + const [client] = await db + .select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut }) + .from(clients) + .where(eq(clients.id, entry.clientId)) + .limit(1); + + if (!client?.email || client.emailOptOut) continue; + + 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); + + if (!pet || !service) continue; + + const email = buildWaitlistNotificationEmail(client.email, { + clientName: client.name, + petName: pet.name, + serviceName: service.name, + preferredDate: appointmentDate, + preferredTime: appointmentTime, + }); + + const sent = await sendEmail(email); + if (sent) { + await db + .update(waitlistEntries) + .set({ status: "notified", notifiedAt: new Date(), updatedAt: new Date() }) + .where(eq(waitlistEntries.id, entry.id)); + } + } +} diff --git a/packages/db/migrations/0015_waitlist.sql b/packages/db/migrations/0015_waitlist.sql new file mode 100644 index 0000000..d99ed8a --- /dev/null +++ b/packages/db/migrations/0015_waitlist.sql @@ -0,0 +1,20 @@ +CREATE TYPE waitlist_status AS ENUM ('active', 'notified', 'expired', 'cancelled'); + +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 waitlist_status 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), + ] +); diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 55ea5c6..cd68a31 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -18,6 +18,7 @@ import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; +import { eq } from "drizzle-orm"; import * as schema from "./schema.js"; // ── Deterministic PRNG (Mulberry32) ────────────────────────────────────────── @@ -247,6 +248,119 @@ const servicesDef = [ { name: "Sanitary Trim", desc: "Hygienic trim of paw pads, face, and sanitary areas", price: 2500, dur: 20 }, ]; +// ── Known-users-only seed (prod/demo) ─────────────────────────────────────── + +/** + * Seeds only the minimal known users for prod/demo environments. + * Creates: Demo Manager staff + Demo Client + Demo Dog + basic services. + * Idempotent: skips creation if records already exist. + */ +async function seedKnownUsers() { + const url = process.env.DATABASE_URL; + if (!url) { + console.error("DATABASE_URL is not set"); + process.exit(1); + } + + const client = postgres(url, { max: 5 }); + const db = drizzle(client, { schema }); + + console.log("Seeding known users (prod/demo mode)...\n"); + + const KNOWN_STAFF_ID = "00000000-0000-0000-0000-000000000001"; + const DEMO_CLIENT_ID = "00000000-0000-0000-0000-000000000002"; + const DEMO_PET_ID = "00000000-0000-0000-0000-000000000003"; + + // ── Staff: Demo Manager ── + const [existingStaff] = await db + .select() + .from(schema.staff) + .where(eq(schema.staff.email, "demo-manager@groombook.dev")) + .limit(1); + + if (existingStaff) { + console.log(`✓ Staff '${existingStaff.name}' already exists — skipping`); + } else { + await db.insert(schema.staff).values({ + id: KNOWN_STAFF_ID, + name: "Demo Manager", + email: "demo-manager@groombook.dev", + oidcSub: "demo-manager-001", + role: "manager", + active: true, + }); + console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)"); + } + + // ── Services: only seed if none exist ── + const existingServices = await db.select().from(schema.services).limit(1); + if (existingServices.length > 0) { + console.log("✓ Services already exist — skipping"); + } else { + const demoSvcs = [ + { name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 }, + { name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 }, + { name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 }, + { name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 }, + ]; + for (const svc of demoSvcs) { + await db.insert(schema.services).values({ ...svc, active: true }); + } + console.log(`✓ Created ${demoSvcs.length} services`); + } + + // ── Client: Demo Client ── + const [existingClient] = await db + .select() + .from(schema.clients) + .where(eq(schema.clients.email, "demo-client@example.com")) + .limit(1); + + let clientId: string; + if (existingClient) { + clientId = existingClient.id; + console.log(`✓ Client '${existingClient.name}' already exists — skipping`); + } else { + const [created] = await db + .insert(schema.clients) + .values({ + id: DEMO_CLIENT_ID, + name: "Demo Client", + email: "demo-client@example.com", + phone: "555-0001", + address: "1 Demo Street, Demo City, CA 90210", + }) + .returning(); + clientId = created!.id; + console.log("✓ Created client 'Demo Client'"); + } + + // ── Pet: Demo Dog ── + const [existingPet] = await db + .select() + .from(schema.pets) + .where(eq(schema.pets.id, DEMO_PET_ID)) + .limit(1); + + if (existingPet) { + console.log(`✓ Pet '${existingPet.name}' already exists — skipping`); + } else { + await db.insert(schema.pets).values({ + id: DEMO_PET_ID, + clientId, + name: "Demo Dog", + species: "Dog", + breed: "Golden Retriever", + weightKg: "30.00", + dateOfBirth: new Date("2020-06-15T00:00:00Z"), + }); + console.log("✓ Created pet 'Demo Dog'"); + } + + console.log("\nKnown-users seed complete!"); + await client.end(); +} + // ── Main seed ──────────────────────────────────────────────────────────────── async function seed() { @@ -256,6 +370,12 @@ async function seed() { process.exit(1); } + // Lean prod/demo seed — known users only, no large dataset + if (process.env.SEED_KNOWN_USERS_ONLY === "true") { + await seedKnownUsers(); + return; + } + const client = postgres(url, { max: 5 }); const db = drizzle(client, { schema });