Compare commits

..

1 Commits

Author SHA1 Message Date
Flea Flicker 08e15dafd5 fix(GRO-639): replace N+1 per-appointment queries with single JOIN query
Replace the per-appointment N+1 queries (client, pet, service, staff)
with a single JOIN query that fetches all appointment data at once.
Batch-fetch already-sent reminder IDs in a single query too.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 04:37:40 +00:00
11 changed files with 289 additions and 338 deletions
+2 -3
View File
@@ -195,11 +195,10 @@ describe("POST /clients", () => {
expect(insertedValues[0]!.name).toBe("Charlie"); expect(insertedValues[0]!.name).toBe("Charlie");
}); });
it("creates a client with name and email", async () => { it("creates a client with only required name field", async () => {
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" }); const res = await jsonRequest("POST", "/clients", { name: "Dana" });
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(insertedValues[0]!.name).toBe("Dana"); expect(insertedValues[0]!.name).toBe("Dana");
expect(insertedValues[0]!.email).toBe("dana@example.com");
}); });
it("rejects empty name", async () => { it("rejects empty name", async () => {
+8 -4
View File
@@ -204,11 +204,15 @@ export async function initAuth(): Promise<void> {
const userInfoUrl = discovery.userinfo_endpoint; const userInfoUrl = discovery.userinfo_endpoint;
if (authzUrl && tokenUrl && userInfoUrl) { if (authzUrl && tokenUrl && userInfoUrl) {
const authzUrlObj = new URL(authzUrl); const authzUrlObj = new URL(authzUrl);
// Only validate authorizationUrl hostname against issuer — token/userinfo const tokenUrlObj = new URL(tokenUrl);
// may legitimately use internal hostnames (OIDC_INTERNAL_BASE) for server-to-server calls. const userInfoUrlObj = new URL(userInfoUrl);
if (authzUrlObj.hostname !== issuerHostname) { if (
authzUrlObj.hostname !== issuerHostname ||
tokenUrlObj.hostname !== issuerHostname ||
userInfoUrlObj.hostname !== issuerHostname
) {
throw new Error( throw new Error(
`[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}'. This may indicate a man-in-the-middle attack.` `[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}', '${tokenUrlObj.hostname}', or '${userInfoUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
); );
} }
oidcConfig = { oidcConfig = {
-45
View File
@@ -1,45 +0,0 @@
import type { MiddlewareHandler } from "hono";
import { getDb, impersonationAuditLogs } from "@groombook/db";
import type { PortalEnv } from "./portalSession.js";
/**
* Server-side audit logging middleware for portal routes.
* Applied after validatePortalSession in the middleware chain.
*
* After the route handler completes (await next()), inserts an audit log entry
* into impersonationAuditLogs:
* - sessionId: from c.get("portalSessionId")
* - action: "{METHOD} {routePath}" (e.g., "GET /portal/appointments")
* - pageVisited: c.req.path
* - metadata: { method, statusCode: c.res.status }
*
* Log entries are written for both success and error responses.
* Does NOT throw if audit logging fails — errors are logged but the user's
* request is not affected.
*/
export const portalAudit: MiddlewareHandler<PortalEnv> = async (c, next) => {
await next();
const sessionId = c.get("portalSessionId");
if (!sessionId) return;
const method = c.req.method;
const routePath = c.req.path;
const pageVisited = c.req.path;
const statusCode = c.res.status;
try {
const db = getDb();
await db
.insert(impersonationAuditLogs)
.values({
sessionId,
action: `${method} ${routePath}`,
pageVisited,
metadata: { method, statusCode },
})
.returning();
} catch (err) {
console.error("[portalAudit] Failed to write audit log:", err);
}
};
-40
View File
@@ -1,40 +0,0 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, impersonationSessions } from "@groombook/db";
export interface PortalEnv {
Variables: {
portalClientId: string;
portalSessionId: string;
};
}
/**
* Validates the X-Impersonation-Session-Id header against the impersonationSessions table.
* Must be applied to all portal routes.
*
* Reads x-session-id from request headers, queries impersonationSessions for a row where
* id = sessionId AND status = 'active', and checks session.expiresAt > new Date().
* Returns 401 if session is invalid/missing/expired.
* On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id).
*/
export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, next) => {
const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) {
return c.json({ error: "Unauthorized" }, 401);
}
const db = getDb();
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);
}
c.set("portalClientId", session.clientId);
c.set("portalSessionId", session.id);
await next();
};
+33 -24
View File
@@ -338,35 +338,44 @@ async function sendConfirmationEmail(
db: ReturnType<typeof getDb>, db: ReturnType<typeof getDb>,
appt: typeof appointments.$inferSelect appt: typeof appointments.$inferSelect
): Promise<void> { ): Promise<void> {
const [row] = await db const [client] = await db
.select({ .select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
clientName: clients.name, .from(clients)
clientEmail: clients.email, .where(eq(clients.id, appt.clientId))
clientEmailOptOut: clients.emailOptOut,
petName: pets.name,
serviceName: services.name,
groomerName: staff.name,
})
.from(appointments)
.innerJoin(clients, eq(clients.id, appointments.clientId))
.innerJoin(pets, eq(pets.id, appointments.petId))
.innerJoin(services, eq(services.id, appointments.serviceId))
.leftJoin(staff, eq(staff.id, appointments.staffId))
.where(eq(appointments.id, appt.id))
.limit(1); .limit(1);
if (!row) return; if (!client || !client.email || client.emailOptOut) return;
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
if (!clientEmail || clientEmailOptOut) return; const [pet] = await db
if (!petName || !serviceName) return; .select({ name: pets.name })
.from(pets)
.where(eq(pets.id, appt.petId))
.limit(1);
const [service] = await db
.select({ name: services.name })
.from(services)
.where(eq(services.id, appt.serviceId))
.limit(1);
let groomerName: string | null = null;
if (appt.staffId) {
const [groomer] = await db
.select({ name: staff.name })
.from(staff)
.where(eq(staff.id, appt.staffId))
.limit(1);
groomerName = groomer?.name ?? null;
}
if (!pet || !service) return;
const sent = await sendEmail( const sent = await sendEmail(
buildConfirmationEmail(clientEmail, { buildConfirmationEmail(client.email, {
clientName, clientName: client.name,
petName, petName: pet.name,
serviceName, serviceName: service.name,
groomerName: groomerName ?? null, groomerName,
startTime: appt.startTime, startTime: appt.startTime,
}) })
); );
+1 -1
View File
@@ -8,7 +8,7 @@ export const clientsRouter = new Hono<AppEnv>();
const createClientSchema = z.object({ const createClientSchema = z.object({
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
email: z.string().email(), email: z.string().email().optional(),
phone: z.string().max(50).optional(), phone: z.string().max(50).optional(),
address: z.string().max(500).optional(), address: z.string().max(500).optional(),
notes: z.string().max(2000).optional(), notes: z.string().max(2000).optional(),
+122 -23
View File
@@ -1,22 +1,33 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3"; import { z } from "zod/v3";
import { eq, inArray } from "@groombook/db"; import { and, eq, inArray } from "@groombook/db";
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
import { validatePortalSession } from "../middleware/portalSession.js"; import type { AppEnv } from "../middleware/rbac.js";
import { portalAudit } from "../middleware/portalAudit.js";
import type { PortalEnv } from "../middleware/portalSession.js";
export const portalRouter = new Hono<PortalEnv>(); export const portalRouter = new Hono<AppEnv>();
// Apply middleware to all portal routes // ─── Session helper ───────────────────────────────────────────────────────────
portalRouter.use("/*", validatePortalSession, portalAudit);
async function getClientIdFromSession(sessionId: string | null | undefined): Promise<string | null> {
if (!sessionId) return null;
const db = getDb();
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 null;
return session.clientId;
}
// ─── GET routes ────────────────────────────────────────────────────────────── // ─── GET routes ──────────────────────────────────────────────────────────────
portalRouter.get("/me", async (c) => { portalRouter.get("/me", async (c) => {
const db = getDb(); const db = getDb();
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1); const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
if (!client) return c.json({ error: "Not found" }, 404); if (!client) return c.json({ error: "Not found" }, 404);
@@ -38,7 +49,9 @@ portalRouter.get("/services", async (c) => {
portalRouter.get("/appointments", async (c) => { portalRouter.get("/appointments", async (c) => {
const db = getDb(); const db = getDb();
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const now = new Date(); const now = new Date();
const allAppts = await db const allAppts = await db
@@ -88,7 +101,9 @@ portalRouter.get("/appointments", async (c) => {
portalRouter.get("/pets", async (c) => { portalRouter.get("/pets", async (c) => {
const db = getDb(); const db = getDb();
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId)); const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes }))); return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
@@ -96,7 +111,9 @@ portalRouter.get("/pets", async (c) => {
portalRouter.get("/invoices", async (c) => { portalRouter.get("/invoices", async (c) => {
const db = getDb(); const db = getDb();
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId)); const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
const invoiceIds = clientInvoices.map(i => i.id); const invoiceIds = clientInvoices.map(i => i.id);
@@ -131,7 +148,12 @@ portalRouter.patch(
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const body = c.req.valid("json"); const body = c.req.valid("json");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [appt] = await db const [appt] = await db
.select() .select()
@@ -174,7 +196,12 @@ portalRouter.patch(
portalRouter.post("/appointments/:id/confirm", async (c) => { portalRouter.post("/appointments/:id/confirm", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [appt] = await db const [appt] = await db
.select() .select()
@@ -223,7 +250,12 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
portalRouter.post("/appointments/:id/cancel", async (c) => { portalRouter.post("/appointments/:id/cancel", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const clientId = c.get("portalClientId");
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [appt] = await db const [appt] = await db
.select() .select()
@@ -287,7 +319,28 @@ portalRouter.post(
async (c) => { async (c) => {
const db = getDb(); const db = getDb();
const body = c.req.valid("json"); const body = c.req.valid("json");
const clientId = c.get("portalClientId"); 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 const [entry] = await db
.insert(waitlistEntries) .insert(waitlistEntries)
@@ -311,7 +364,26 @@ portalRouter.patch(
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const body = c.req.valid("json"); const body = c.req.valid("json");
const clientId = c.get("portalClientId"); 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 const [existing] = await db
.select() .select()
@@ -320,7 +392,7 @@ portalRouter.patch(
.limit(1); .limit(1);
if (!existing) return c.json({ error: "Not found" }, 404); if (!existing) return c.json({ error: "Not found" }, 404);
if (existing.clientId !== clientId) { if (existing.clientId !== session.clientId) {
return c.json({ error: "Forbidden" }, 403); return c.json({ error: "Forbidden" }, 403);
} }
@@ -342,7 +414,26 @@ portalRouter.patch(
portalRouter.delete("/waitlist/:id", async (c) => { portalRouter.delete("/waitlist/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const clientId = c.get("portalClientId"); 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 const [entry] = await db
.select() .select()
@@ -351,7 +442,7 @@ portalRouter.delete("/waitlist/:id", async (c) => {
.limit(1); .limit(1);
if (!entry) return c.json({ error: "Not found" }, 404); if (!entry) return c.json({ error: "Not found" }, 404);
if (entry.clientId !== clientId) { if (entry.clientId !== session.clientId) {
return c.json({ error: "Forbidden" }, 403); return c.json({ error: "Forbidden" }, 403);
} }
@@ -384,7 +475,9 @@ portalRouter.post(
async (c) => { async (c) => {
const db = getDb(); const db = getDb();
const body = c.req.valid("json"); const body = c.req.valid("json");
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const invoiceRows = await db const invoiceRows = await db
.select() .select()
@@ -421,7 +514,9 @@ portalRouter.post(
); );
portalRouter.get("/payment-methods", async (c) => { portalRouter.get("/payment-methods", async (c) => {
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const methods = await listPaymentMethods(clientId); const methods = await listPaymentMethods(clientId);
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503); if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
@@ -429,7 +524,9 @@ portalRouter.get("/payment-methods", async (c) => {
}); });
portalRouter.post("/payment-methods", async (c) => { portalRouter.post("/payment-methods", async (c) => {
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? ""; const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
const customerId = await getOrCreateStripeCustomer(clientId); const customerId = await getOrCreateStripeCustomer(clientId);
@@ -442,7 +539,9 @@ portalRouter.post("/payment-methods", async (c) => {
}); });
portalRouter.delete("/payment-methods/:id", async (c) => { portalRouter.delete("/payment-methods/:id", async (c) => {
const clientId = c.get("portalClientId"); const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const paymentMethodId = c.req.param("id"); const paymentMethodId = c.req.param("id");
+83 -74
View File
@@ -6,6 +6,7 @@ import {
getDb, getDb,
gte, gte,
lt, lt,
sql,
appointments, appointments,
clients, clients,
pets, pets,
@@ -31,6 +32,8 @@ function getReminderWindows(): { label: string; hours: number }[] {
]; ];
} }
// Checks for upcoming appointments that need reminders and sends them.
// Runs every minute — idempotent via reminder_logs unique constraint.
export async function runReminderCheck(): Promise<void> { export async function runReminderCheck(): Promise<void> {
const db = getDb(); const db = getDb();
const now = new Date(); const now = new Date();
@@ -59,69 +62,74 @@ export async function runReminderCheck(): Promise<void> {
) )
); );
const appointmentIds: string[] = upcoming.map((a) => a.id as string);
if (appointmentIds.length === 0) continue;
// Batch-fetch already-sent appointment IDs (both EMAIL and SMS channels)
const sentAppointmentIds = new Set(
(
await db
.select({ appointmentId: reminderLogs.appointmentId })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.reminderType, window.label),
appointmentIds.length === 1
? eq(reminderLogs.appointmentId, appointmentIds[0]!)
: sql`${reminderLogs.appointmentId} = ANY(${appointmentIds})`
)
)
).map((r) => r.appointmentId)
);
// Batch-fetch all appointment data with related joins in a single query
const joinedRows = await db
.select({
appointmentId: appointments.id,
startTime: appointments.startTime,
clientId: appointments.clientId,
petId: appointments.petId,
serviceId: appointments.serviceId,
staffId: appointments.staffId,
confirmationToken: appointments.confirmationToken,
clientName: clients.name,
clientEmail: clients.email,
clientEmailOptOut: clients.emailOptOut,
clientPhone: clients.phone,
clientSmsOptIn: clients.smsOptIn,
petName: pets.name,
serviceName: services.name,
staffName: staff.name,
})
.from(appointments)
.innerJoin(clients, eq(appointments.clientId, clients.id))
.innerJoin(pets, eq(appointments.petId, pets.id))
.innerJoin(services, eq(appointments.serviceId, services.id))
.leftJoin(staff, eq(appointments.staffId, staff.id))
.where(
and(
sql`${appointments.id} = ANY(${appointmentIds})`,
gte(appointments.startTime, windowStart),
lt(appointments.startTime, windowEnd),
eq(appointments.status, "scheduled")
)
);
const appointmentMap = new Map<string, typeof joinedRows[number]>();
for (const row of joinedRows) {
appointmentMap.set(row.appointmentId, row);
}
for (const appt of upcoming) { for (const appt of upcoming) {
const [emailLog] = await db // Already sent a reminder for this appointment in this window
.select({ id: reminderLogs.id }) if (sentAppointmentIds.has(appt.id)) continue;
.from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "email")
)
)
.limit(1);
const [smsLog] = await db const row = appointmentMap.get(appt.id);
.select({ id: reminderLogs.id }) if (!row) continue;
.from(reminderLogs) if (!row.petName || !row.serviceName) continue;
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "sms")
)
)
.limit(1);
const [client] = await db
.select({
name: clients.name,
email: clients.email,
emailOptOut: clients.emailOptOut,
smsOptIn: clients.smsOptIn,
phone: clients.phone,
})
.from(clients)
.where(eq(clients.id, appt.clientId))
.limit(1);
if (!client || !client.email || client.emailOptOut) continue;
const [pet] = await db
.select({ name: pets.name })
.from(pets)
.where(eq(pets.id, appt.petId))
.limit(1);
const [service] = await db
.select({ name: services.name })
.from(services)
.where(eq(services.id, appt.serviceId))
.limit(1);
let groomerName: string | null = null;
if (appt.staffId) {
const [groomer] = await db
.select({ name: staff.name })
.from(staff)
.where(eq(staff.id, appt.staffId))
.limit(1);
groomerName = groomer?.name ?? null;
}
if (!pet || !service) continue;
// Generate confirmation token if missing
let confirmationToken = appt.confirmationToken; let confirmationToken = appt.confirmationToken;
if (!confirmationToken) { if (!confirmationToken) {
confirmationToken = randomBytes(32).toString("hex"); confirmationToken = randomBytes(32).toString("hex");
@@ -131,22 +139,22 @@ export async function runReminderCheck(): Promise<void> {
.where(eq(appointments.id, appt.id)); .where(eq(appointments.id, appt.id));
} }
if (!emailLog) { const clientName = row.clientName;
const petName = row.petName;
const serviceName = row.serviceName;
const groomerName = row.staffName ?? null;
const startTime = appt.startTime;
// EMAIL reminder
if (row.clientEmail && !row.clientEmailOptOut) {
const sent = await sendEmail( const sent = await sendEmail(
buildReminderEmail( buildReminderEmail(
client.email, row.clientEmail,
{ { clientName, petName, serviceName, groomerName, startTime },
clientName: client.name,
petName: pet.name,
serviceName: service.name,
groomerName,
startTime: appt.startTime,
},
window.hours, window.hours,
confirmationToken confirmationToken
) )
); );
if (sent) { if (sent) {
await db await db
.insert(reminderLogs) .insert(reminderLogs)
@@ -155,20 +163,21 @@ export async function runReminderCheck(): Promise<void> {
} }
} }
if (!smsLog && client.smsOptIn && client.phone) { // SMS reminder
if (row.clientPhone && row.clientSmsOptIn) {
const apiUrl = process.env.API_URL ?? "http://localhost:3000"; const apiUrl = process.env.API_URL ?? "http://localhost:3000";
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`; const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`; const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`; const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
const smsBody = [ const smsBody = [
`Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`, `Hi ${clientName}, just a reminder: ${petName}'s grooming appointment is ${when}.`,
`Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`, `Service: ${serviceName}${groomerName ? ` with ${groomerName}` : ""}`,
`Confirm: ${confirmUrl}`, `Confirm: ${confirmUrl}`,
`Cancel: ${cancelUrl}`, `Cancel: ${cancelUrl}`,
TCPA_OPT_OUT, TCPA_OPT_OUT,
].join(". "); ].join(". ");
try { try {
const smsOk = await smsSend(client.phone, smsBody); const smsOk = await smsSend(row.clientPhone, smsBody);
if (smsOk) { if (smsOk) {
await db await db
.insert(reminderLogs) .insert(reminderLogs)
@@ -1,20 +0,0 @@
-- Migration: 0029_db_indexes_constraints.sql
-- Add missing indexes on appointments, pets, clients tables and NOT NULL constraint on clients.email
-- Backfill NULL emails before setting NOT NULL
UPDATE clients SET email = concat('unknown-', id::text, '@placeholder.local') WHERE email IS NULL;
-- Add indexes on appointments table
CREATE INDEX idx_appointments_client_id ON appointments(client_id);
CREATE INDEX idx_appointments_staff_id ON appointments(staff_id);
CREATE INDEX idx_appointments_start_time ON appointments(start_time);
CREATE INDEX idx_appointments_status ON appointments(status);
-- Add index on pets table
CREATE INDEX idx_pets_client_id ON pets(client_id);
-- Add index on clients table
CREATE INDEX idx_clients_email ON clients(email);
-- Set NOT NULL on clients.email (after backfill)
ALTER TABLE clients ALTER COLUMN email SET NOT NULL;
+40 -48
View File
@@ -102,55 +102,47 @@ export const verification = pgTable("verification", {
// ─── Tables ─────────────────────────────────────────────────────────────────── // ─── Tables ───────────────────────────────────────────────────────────────────
export const clients = pgTable( export const clients = pgTable("clients", {
"clients", id: uuid("id").primaryKey().defaultRandom(),
{ name: text("name").notNull(),
id: uuid("id").primaryKey().defaultRandom(), email: text("email"),
name: text("name").notNull(), phone: text("phone"),
email: text("email").notNull(), address: text("address"),
phone: text("phone"), notes: text("notes"),
address: text("address"), emailOptOut: boolean("email_opt_out").notNull().default(false),
notes: text("notes"), smsOptIn: boolean("sms_opt_in").notNull().default(false),
emailOptOut: boolean("email_opt_out").notNull().default(false), smsConsentDate: timestamp("sms_consent_date"),
smsOptIn: boolean("sms_opt_in").notNull().default(false), smsOptOutDate: timestamp("sms_opt_out_date"),
smsConsentDate: timestamp("sms_consent_date"), smsConsentText: text("sms_consent_text"),
smsOptOutDate: timestamp("sms_opt_out_date"), stripeCustomerId: text("stripe_customer_id"),
smsConsentText: text("sms_consent_text"), status: clientStatusEnum("status").notNull().default("active"),
stripeCustomerId: text("stripe_customer_id"), disabledAt: timestamp("disabled_at"),
status: clientStatusEnum("status").notNull().default("active"), createdAt: timestamp("created_at").notNull().defaultNow(),
disabledAt: timestamp("disabled_at"), updatedAt: timestamp("updated_at").notNull().defaultNow(),
createdAt: timestamp("created_at").notNull().defaultNow(), });
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [index("idx_clients_email").on(t.email)]
);
export const pets = pgTable( export const pets = pgTable("pets", {
"pets", id: uuid("id").primaryKey().defaultRandom(),
{ clientId: uuid("client_id")
id: uuid("id").primaryKey().defaultRandom(), .notNull()
clientId: uuid("client_id") .references(() => clients.id, { onDelete: "cascade" }),
.notNull() name: text("name").notNull(),
.references(() => clients.id, { onDelete: "cascade" }), species: text("species").notNull(),
name: text("name").notNull(), breed: text("breed"),
species: text("species").notNull(), weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
breed: text("breed"), dateOfBirth: timestamp("date_of_birth"),
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }), healthAlerts: text("health_alerts"),
dateOfBirth: timestamp("date_of_birth"), groomingNotes: text("grooming_notes"),
healthAlerts: text("health_alerts"), cutStyle: text("cut_style"),
groomingNotes: text("grooming_notes"), shampooPreference: text("shampoo_preference"),
cutStyle: text("cut_style"), specialCareNotes: text("special_care_notes"),
shampooPreference: text("shampoo_preference"), customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
specialCareNotes: text("special_care_notes"), photoKey: text("photo_key"),
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}), photoUploadedAt: timestamp("photo_uploaded_at"),
photoKey: text("photo_key"), image: text("image"),
photoUploadedAt: timestamp("photo_uploaded_at"), createdAt: timestamp("created_at").notNull().defaultNow(),
image: text("image"), updatedAt: timestamp("updated_at").notNull().defaultNow(),
createdAt: timestamp("created_at").notNull().defaultNow(), });
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [index("idx_pets_client_id").on(t.clientId)]
);
export const services = pgTable("services", { export const services = pgTable("services", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
-56
View File
@@ -462,37 +462,6 @@ async function seedKnownUsers() {
} }
} }
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
for (let i = 0; i < groomerCount; i++) {
const email = groomerEmails[i]!;
const name = groomerNames[i]!;
// Use deterministic IDs in the 00000000-0000-0000-0000-000000000005+ range
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
const [existingGroomer] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, email))
.limit(1);
if (existingGroomer) {
console.log(`✓ Staff groomer '${existingGroomer.name}' already exists — skipping`);
} else {
await db.insert(schema.staff).values({
id: staffId,
name,
email,
oidcSub: email,
role: "groomer",
isSuperUser: false,
active: true,
});
console.log(`✓ Created staff groomer '${name}' (${email})`);
}
}
// ── Services: idempotent upsert using name as unique key ───────────────────── // ── Services: idempotent upsert using name as unique key ─────────────────────
// UNIQUE constraint on services.name (migration 0020) must exist first. // UNIQUE constraint on services.name (migration 0020) must exist first.
// Uses b0000001-... IDs to match main seed servicesDef for same-named services. // Uses b0000001-... IDs to match main seed servicesDef for same-named services.
@@ -660,31 +629,6 @@ async function seed() {
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`); console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
} }
// ── UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
for (let i = 0; i < groomerCount; i++) {
const email = groomerEmails[i]!;
const name = groomerNames[i]!;
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
await db.insert(schema.staff)
.values({
id: staffId,
name,
email,
oidcSub: email,
role: "groomer",
isSuperUser: false,
active: true,
})
.onConflictDoUpdate({
target: schema.staff.email,
set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true },
});
console.log(`✓ Upserted groomer '${name}' (${email})`);
}
// ── Services ── // ── Services ──
// Upsert services using name as unique key. With deterministic IDs in // Upsert services using name as unique key. With deterministic IDs in
// servicesDef and TRUNCATE clearing downstream tables first, this is // servicesDef and TRUNCATE clearing downstream tables first, this is