Merge main into feat/gro-107-ical-feed
Resolves conflict from fix(gro-38) (#117): - index.ts: keep iCal calendar route, accept adminSeedRouter and portal placement from main - waitlist.ts / waitlist.test.ts: accept main's implementation Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -54,7 +54,7 @@ vi.mock("@groombook/db", () => {
|
|||||||
const arr = [...data];
|
const arr = [...data];
|
||||||
const chain = new Proxy(arr, {
|
const chain = new Proxy(arr, {
|
||||||
get(target, prop) {
|
get(target, prop) {
|
||||||
if (prop === "where" || prop === "orderBy" || prop === "limit") {
|
if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "leftJoin") {
|
||||||
return () => chain;
|
return () => chain;
|
||||||
}
|
}
|
||||||
// @ts-expect-error proxy
|
// @ts-expect-error proxy
|
||||||
@@ -89,6 +89,11 @@ vi.mock("@groombook/db", () => {
|
|||||||
{ get: (t, p) => (p === "_name" ? "services" : { table: "services", column: p }) }
|
{ 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 {
|
return {
|
||||||
getDb: () => ({
|
getDb: () => ({
|
||||||
select: () => ({
|
select: () => ({
|
||||||
@@ -137,15 +142,19 @@ vi.mock("@groombook/db", () => {
|
|||||||
clients,
|
clients,
|
||||||
pets,
|
pets,
|
||||||
services,
|
services,
|
||||||
|
appointments,
|
||||||
eq: vi.fn(),
|
eq: vi.fn(),
|
||||||
and: vi.fn(),
|
and: vi.fn(),
|
||||||
|
lt: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const { waitlistRouter } = await import("../routes/waitlist.js");
|
const { waitlistRouter } = await import("../routes/waitlist.js");
|
||||||
|
const { portalRouter } = await import("../routes/portal.js");
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
app.route("/waitlist", waitlistRouter);
|
app.route("/waitlist", waitlistRouter);
|
||||||
|
app.route("/portal", portalRouter);
|
||||||
|
|
||||||
function jsonRequest(method: string, path: string, body?: unknown, headers?: Record<string, string>) {
|
function jsonRequest(method: string, path: string, body?: unknown, headers?: Record<string, string>) {
|
||||||
return app.request(path, {
|
return app.request(path, {
|
||||||
@@ -160,10 +169,10 @@ function jsonRequest(method: string, path: string, body?: unknown, headers?: Rec
|
|||||||
|
|
||||||
beforeEach(() => resetMock());
|
beforeEach(() => resetMock());
|
||||||
|
|
||||||
describe("POST /waitlist", () => {
|
describe("POST /portal/waitlist", () => {
|
||||||
it("creates entry with valid session", async () => {
|
it("creates entry with valid session", async () => {
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
selectSessionRow = ACTIVE_SESSION;
|
||||||
const res = await jsonRequest("POST", "/waitlist", {
|
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||||
petId: VALID_UUID_3,
|
petId: VALID_UUID_3,
|
||||||
serviceId: VALID_UUID_4,
|
serviceId: VALID_UUID_4,
|
||||||
preferredDate: "2026-03-25",
|
preferredDate: "2026-03-25",
|
||||||
@@ -176,7 +185,7 @@ describe("POST /waitlist", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns 401 without session", async () => {
|
it("returns 401 without session", async () => {
|
||||||
const res = await jsonRequest("POST", "/waitlist", {
|
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||||
petId: VALID_UUID_3,
|
petId: VALID_UUID_3,
|
||||||
serviceId: VALID_UUID_4,
|
serviceId: VALID_UUID_4,
|
||||||
preferredDate: "2026-03-25",
|
preferredDate: "2026-03-25",
|
||||||
@@ -187,7 +196,7 @@ describe("POST /waitlist", () => {
|
|||||||
|
|
||||||
it("returns 401 with expired session", async () => {
|
it("returns 401 with expired session", async () => {
|
||||||
selectSessionRow = EXPIRED_SESSION;
|
selectSessionRow = EXPIRED_SESSION;
|
||||||
const res = await jsonRequest("POST", "/waitlist", {
|
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||||
petId: VALID_UUID_3,
|
petId: VALID_UUID_3,
|
||||||
serviceId: VALID_UUID_4,
|
serviceId: VALID_UUID_4,
|
||||||
preferredDate: "2026-03-25",
|
preferredDate: "2026-03-25",
|
||||||
@@ -197,11 +206,11 @@ describe("POST /waitlist", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("DELETE /waitlist/:id", () => {
|
describe("DELETE /portal/waitlist/:id", () => {
|
||||||
it("deletes entry with valid session and correct owner", async () => {
|
it("deletes entry with valid session and correct owner", async () => {
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
selectSessionRow = ACTIVE_SESSION;
|
||||||
selectRows = [WAITLIST_ENTRY];
|
selectRows = [WAITLIST_ENTRY];
|
||||||
const res = await app.request(`/waitlist/${VALID_UUID_1}`, {
|
const res = await app.request(`/portal/waitlist/${VALID_UUID_1}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { "X-Impersonation-Session-Id": VALID_UUID_5 },
|
headers: { "X-Impersonation-Session-Id": VALID_UUID_5 },
|
||||||
});
|
});
|
||||||
@@ -211,7 +220,7 @@ describe("DELETE /waitlist/:id", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns 401 without session", async () => {
|
it("returns 401 without session", async () => {
|
||||||
const res = await app.request(`/waitlist/${VALID_UUID_1}`, {
|
const res = await app.request(`/portal/waitlist/${VALID_UUID_1}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
@@ -220,7 +229,7 @@ describe("DELETE /waitlist/:id", () => {
|
|||||||
it("returns 403 with valid session but wrong owner", async () => {
|
it("returns 403 with valid session but wrong owner", async () => {
|
||||||
selectSessionRow = { ...ACTIVE_SESSION, clientId: "other-client-uuid" };
|
selectSessionRow = { ...ACTIVE_SESSION, clientId: "other-client-uuid" };
|
||||||
selectRows = [WAITLIST_ENTRY];
|
selectRows = [WAITLIST_ENTRY];
|
||||||
const res = await app.request(`/waitlist/${VALID_UUID_1}`, {
|
const res = await app.request(`/portal/waitlist/${VALID_UUID_1}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { "X-Impersonation-Session-Id": VALID_UUID_5 },
|
headers: { "X-Impersonation-Session-Id": VALID_UUID_5 },
|
||||||
});
|
});
|
||||||
@@ -230,7 +239,7 @@ describe("DELETE /waitlist/:id", () => {
|
|||||||
it("returns 404 when entry not found", async () => {
|
it("returns 404 when entry not found", async () => {
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
selectSessionRow = ACTIVE_SESSION;
|
||||||
selectRows = [];
|
selectRows = [];
|
||||||
const res = await app.request("/waitlist/nonexistent", {
|
const res = await app.request("/portal/waitlist/nonexistent", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { "X-Impersonation-Session-Id": VALID_UUID_5 },
|
headers: { "X-Impersonation-Session-Id": VALID_UUID_5 },
|
||||||
});
|
});
|
||||||
@@ -238,20 +247,20 @@ describe("DELETE /waitlist/:id", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("PATCH /waitlist/:id", () => {
|
describe("PATCH /portal/waitlist/:id", () => {
|
||||||
it("updates entry with valid session and correct owner", async () => {
|
it("updates entry with valid session and correct owner", async () => {
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
selectSessionRow = ACTIVE_SESSION;
|
||||||
selectRows = [WAITLIST_ENTRY];
|
selectRows = [WAITLIST_ENTRY];
|
||||||
const res = await jsonRequest("PATCH", `/waitlist/${VALID_UUID_1}`, {
|
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
|
||||||
status: "notified",
|
status: "cancelled",
|
||||||
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(updatedValues[0]?.status).toBe("notified");
|
expect(updatedValues[0]?.status).toBe("cancelled");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 401 without session", async () => {
|
it("returns 401 without session", async () => {
|
||||||
const res = await jsonRequest("PATCH", `/waitlist/${VALID_UUID_1}`, {
|
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
|
||||||
status: "notified",
|
status: "cancelled",
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
@@ -259,8 +268,8 @@ describe("PATCH /waitlist/:id", () => {
|
|||||||
it("returns 403 with valid session but wrong owner", async () => {
|
it("returns 403 with valid session but wrong owner", async () => {
|
||||||
selectSessionRow = { ...ACTIVE_SESSION, clientId: "other-client-uuid" };
|
selectSessionRow = { ...ACTIVE_SESSION, clientId: "other-client-uuid" };
|
||||||
selectRows = [WAITLIST_ENTRY];
|
selectRows = [WAITLIST_ENTRY];
|
||||||
const res = await jsonRequest("PATCH", `/waitlist/${VALID_UUID_1}`, {
|
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
|
||||||
status: "notified",
|
status: "cancelled",
|
||||||
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
});
|
});
|
||||||
@@ -268,8 +277,8 @@ describe("PATCH /waitlist/:id", () => {
|
|||||||
it("returns 404 when entry not found", async () => {
|
it("returns 404 when entry not found", async () => {
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
selectSessionRow = ACTIVE_SESSION;
|
||||||
selectRows = [];
|
selectRows = [];
|
||||||
const res = await jsonRequest("PATCH", "/waitlist/nonexistent", {
|
const res = await jsonRequest("PATCH", "/portal/waitlist/nonexistent", {
|
||||||
status: "notified",
|
status: "cancelled",
|
||||||
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { getDb, businessSettings } from "@groombook/db";
|
|||||||
import { authMiddleware } from "./middleware/auth.js";
|
import { authMiddleware } from "./middleware/auth.js";
|
||||||
import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js";
|
import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js";
|
||||||
import { devRouter } from "./routes/dev.js";
|
import { devRouter } from "./routes/dev.js";
|
||||||
|
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||||
import { startReminderScheduler } from "./services/reminders.js";
|
import { startReminderScheduler } from "./services/reminders.js";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -42,6 +43,9 @@ app.get("/health", (c) => c.json({ status: "ok" }));
|
|||||||
// Public booking routes — no auth required, must be registered before auth middleware
|
// Public booking routes — no auth required, must be registered before auth middleware
|
||||||
app.route("/api/book", bookRouter);
|
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
|
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||||
app.route("/api/dev", devRouter);
|
app.route("/api/dev", devRouter);
|
||||||
|
|
||||||
@@ -61,9 +65,6 @@ app.get("/api/branding", async (c) => {
|
|||||||
|
|
||||||
// Public iCal calendar feed — token auth in URL, no auth middleware required
|
// Public iCal calendar feed — token auth in URL, no auth middleware required
|
||||||
app.route("/api/calendar", calendarRouter);
|
app.route("/api/calendar", calendarRouter);
|
||||||
// Portal routes — no staff auth required, uses impersonation session for client auth
|
|
||||||
app.route("/api/portal", portalRouter);
|
|
||||||
|
|
||||||
// Protected API routes
|
// Protected API routes
|
||||||
const api = app.basePath("/api");
|
const api = app.basePath("/api");
|
||||||
api.use("*", authMiddleware);
|
api.use("*", authMiddleware);
|
||||||
@@ -116,7 +117,6 @@ api.route("/clients", clientsRouter);
|
|||||||
api.route("/pets", petsRouter);
|
api.route("/pets", petsRouter);
|
||||||
api.route("/services", servicesRouter);
|
api.route("/services", servicesRouter);
|
||||||
api.route("/appointments", appointmentsRouter);
|
api.route("/appointments", appointmentsRouter);
|
||||||
api.route("/portal", portalRouter);
|
|
||||||
api.route("/waitlist", waitlistRouter);
|
api.route("/waitlist", waitlistRouter);
|
||||||
api.route("/staff", staffRouter);
|
api.route("/staff", staffRouter);
|
||||||
api.route("/invoices", invoicesRouter);
|
api.route("/invoices", invoicesRouter);
|
||||||
@@ -125,6 +125,7 @@ api.route("/appointment-groups", appointmentGroupsRouter);
|
|||||||
api.route("/grooming-logs", groomingLogsRouter);
|
api.route("/grooming-logs", groomingLogsRouter);
|
||||||
api.route("/impersonation", impersonationRouter);
|
api.route("/impersonation", impersonationRouter);
|
||||||
api.route("/admin/settings", settingsRouter);
|
api.route("/admin/settings", settingsRouter);
|
||||||
|
api.route("/admin/seed", adminSeedRouter);
|
||||||
api.route("/search", searchRouter);
|
api.route("/search", searchRouter);
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
staff,
|
staff,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||||
|
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||||
|
|
||||||
export const appointmentsRouter = new Hono();
|
export const appointmentsRouter = new Hono();
|
||||||
|
|
||||||
@@ -510,16 +511,38 @@ appointmentsRouter.delete("/:id", async (c) => {
|
|||||||
.set({ status: "cancelled", updatedAt: new Date() })
|
.set({ status: "cancelled", updatedAt: new Date() })
|
||||||
.where(eq(appointments.id, id));
|
.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 });
|
return c.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single cancel (default)
|
// 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
|
const [row] = await db
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set({ status: "cancelled", updatedAt: new Date() })
|
.set({ status: "cancelled", updatedAt: new Date() })
|
||||||
.where(eq(appointments.id, id))
|
.where(eq(appointments.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
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 });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
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";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const portalRouter = new Hono<AppEnv>();
|
export const portalRouter = new Hono<AppEnv>();
|
||||||
@@ -76,3 +76,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 });
|
||||||
|
});
|
||||||
|
|||||||
+50
-196
@@ -1,34 +1,28 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
eq,
|
eq,
|
||||||
|
lt,
|
||||||
getDb,
|
getDb,
|
||||||
waitlistEntries,
|
waitlistEntries,
|
||||||
clients,
|
clients,
|
||||||
pets,
|
pets,
|
||||||
services,
|
services,
|
||||||
impersonationSessions,
|
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const waitlistRouter = new Hono<AppEnv>();
|
export const waitlistRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
const waitlistStatusEnum = z.enum(["active", "notified", "expired", "cancelled"]);
|
async function markExpiredEntries(db: ReturnType<typeof getDb>, rows: { status: string; preferredDate: string }[]) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const createWaitlistEntrySchema = z.object({
|
const hasExpired = rows.some((r) => r.status === "active" && r.preferredDate < today);
|
||||||
petId: z.string().uuid(),
|
if (hasExpired) {
|
||||||
serviceId: z.string().uuid(),
|
await db
|
||||||
preferredDate: z.string(),
|
.update(waitlistEntries)
|
||||||
preferredTime: z.string(),
|
.set({ status: "expired", updatedAt: new Date() })
|
||||||
});
|
.where(and(eq(waitlistEntries.status, "active"), lt(waitlistEntries.preferredDate, today)));
|
||||||
|
}
|
||||||
const updateWaitlistEntrySchema = z.object({
|
}
|
||||||
status: waitlistStatusEnum.optional(),
|
|
||||||
preferredDate: z.string().optional(),
|
|
||||||
preferredTime: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
waitlistRouter.get("/", async (c) => {
|
waitlistRouter.get("/", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -39,44 +33,38 @@ waitlistRouter.get("/", async (c) => {
|
|||||||
conditions.push(eq(waitlistEntries.preferredDate, date));
|
conditions.push(eq(waitlistEntries.preferredDate, date));
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows =
|
const rows = await db
|
||||||
conditions.length > 0
|
.select({
|
||||||
? await db
|
id: waitlistEntries.id,
|
||||||
.select()
|
clientId: waitlistEntries.clientId,
|
||||||
.from(waitlistEntries)
|
petId: waitlistEntries.petId,
|
||||||
.where(and(...conditions))
|
serviceId: waitlistEntries.serviceId,
|
||||||
.orderBy(waitlistEntries.createdAt)
|
preferredDate: waitlistEntries.preferredDate,
|
||||||
: await db
|
preferredTime: waitlistEntries.preferredTime,
|
||||||
.select()
|
status: waitlistEntries.status,
|
||||||
.from(waitlistEntries)
|
notifiedAt: waitlistEntries.notifiedAt,
|
||||||
.orderBy(waitlistEntries.createdAt);
|
expiresAt: waitlistEntries.expiresAt,
|
||||||
|
createdAt: waitlistEntries.createdAt,
|
||||||
const enriched = await Promise.all(
|
updatedAt: waitlistEntries.updatedAt,
|
||||||
rows.map(async (entry) => {
|
clientName: clients.name,
|
||||||
const [client] = await db
|
clientEmail: clients.email,
|
||||||
.select({ name: clients.name, email: clients.email })
|
petName: pets.name,
|
||||||
.from(clients)
|
serviceName: services.name,
|
||||||
.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,
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
);
|
.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);
|
return c.json(enriched);
|
||||||
});
|
});
|
||||||
@@ -89,146 +77,12 @@ waitlistRouter.get("/:id", async (c) => {
|
|||||||
.where(eq(waitlistEntries.id, c.req.param("id")))
|
.where(eq(waitlistEntries.id, c.req.param("id")))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
return c.json(row);
|
|
||||||
});
|
await markExpiredEntries(db, [row]);
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
waitlistRouter.post(
|
const isExpired = row.status === "active" && row.preferredDate < today;
|
||||||
"/",
|
return c.json({
|
||||||
zValidator("json", createWaitlistEntrySchema),
|
...row,
|
||||||
async (c) => {
|
status: isExpired ? "expired" : row.status,
|
||||||
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 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);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
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) {
|
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -149,3 +149,55 @@ ${actionHtml}
|
|||||||
<p>— Groom Book</p>`,
|
<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>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import * as schema from "./schema.js";
|
import * as schema from "./schema.js";
|
||||||
|
|
||||||
// ── Deterministic PRNG (Mulberry32) ──────────────────────────────────────────
|
// ── 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 },
|
{ 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 ────────────────────────────────────────────────────────────────
|
// ── Main seed ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function seed() {
|
async function seed() {
|
||||||
@@ -256,6 +370,12 @@ async function seed() {
|
|||||||
process.exit(1);
|
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 client = postgres(url, { max: 5 });
|
||||||
const db = drizzle(client, { schema });
|
const db = drizzle(client, { schema });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user