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 <noreply@paperclip.ing>
This commit was merged in pull request #117.
This commit is contained in:
groombook-engineer[bot]
2026-03-26 20:51:08 +00:00
committed by GitHub
parent d0b4baf5aa
commit e3220af9ce
11 changed files with 990 additions and 5 deletions
+285
View File
@@ -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<string, unknown>[] = [];
let selectSessionRow: Record<string, unknown> | null = null;
let insertedValues: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
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<string, unknown>) => {
insertedValues.push(vals);
return {
returning: () => [{ ...WAITLIST_ENTRY, ...vals, id: "waitlist-uuid-new" }],
};
},
}),
update: () => ({
set: (vals: Record<string, unknown>) => ({
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<string, string>) {
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);
});
});
+9 -4
View File
@@ -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);
+138
View File
@@ -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,
},
});
});
+23
View File
@@ -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 });
});
+157 -1
View File
@@ -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<AppEnv>();
@@ -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<string, unknown> = { 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 });
});
+88
View File
@@ -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<AppEnv>();
async function markExpiredEntries(db: ReturnType<typeof getDb>, 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,
});
});
+52
View File
@@ -149,3 +149,55 @@ ${actionHtml}
<p>— Groom Book</p>`,
};
}
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: `
<p>Hi ${data.clientName},</p>
<p>Great news! <strong>An appointment slot has become available</strong>.</p>
<p>We had a cancellation for:</p>
<table style="border-collapse:collapse;margin:1em 0">
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Pet</td><td>${data.petName}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Service</td><td>${data.serviceName}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Date</td><td>${data.preferredDate}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Time</td><td>${data.preferredTime}</td></tr>
</table>
<div style="margin:1.5em 0">
<a href="${bookUrl}" style="display:inline-block;padding:12px 24px;background:#10b981;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;font-size:16px">Book This Slot</a>
</div>
<p>If you're no longer interested, you can ignore this email or remove yourself from the waitlist in your portal.</p>
<p>— Groom Book</p>`,
};
}
+63
View File
@@ -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<void> {
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));
}
}
}
+20
View File
@@ -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';
+35
View File
@@ -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),
]
);
+120
View File
@@ -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 });