From 04147f3e6c11806508829de70a4910e150bda772 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 14 Apr 2026 14:41:26 +0000 Subject: [PATCH] fix(reminders): replace N+1 per-appointment queries with single JOIN query Replace the per-appointment sequential queries (client, pet, service, staff) in runReminderCheck with a single JOIN query that fetches all appointment data in one round-trip. Group results in memory using a Map for O(1) lookups. Before: 1 initial query + N*(1 existing + 4 related) = 1 + 5N queries After: 1 initial + 1 sent-check + 1 JOIN = 3 queries total Co-Authored-By: Paperclip --- apps/api/src/services/reminders.ts | 112 ++++++++++++++++------------- 1 file changed, 62 insertions(+), 50 deletions(-) diff --git a/apps/api/src/services/reminders.ts b/apps/api/src/services/reminders.ts index 442b6c3..d5d83db 100644 --- a/apps/api/src/services/reminders.ts +++ b/apps/api/src/services/reminders.ts @@ -6,6 +6,7 @@ import { getDb, gte, lt, + sql, appointments, clients, pets, @@ -64,56 +65,68 @@ export async function runReminderCheck(): Promise { ) ); - for (const appt of upcoming) { - // Check if reminder already sent (unique constraint prevents double-send) - const existing = await db - .select({ id: reminderLogs.id }) - .from(reminderLogs) - .where( - and( - eq(reminderLogs.appointmentId, appt.id), - eq(reminderLogs.reminderType, window.label) + const appointmentIds: string[] = upcoming.map((a) => a.id as string); + + if (appointmentIds.length === 0) continue; + + 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) + ); + + 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, + 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( + gte(appointments.startTime, windowStart), + lt(appointments.startTime, windowEnd), + eq(appointments.status, "scheduled") ) - .limit(1); + ); - if (existing.length > 0) continue; // already sent + const appointmentMap = new Map(); + for (const row of joinedRows) { + appointmentMap.set(row.appointmentId, row); + } - // Fetch related records for the email - const [client] = await db - .select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut }) - .from(clients) - .where(eq(clients.id, appt.clientId)) - .limit(1); + for (const appt of upcoming) { + if (sentAppointmentIds.has(appt.id)) continue; - if (!client || !client.email || client.emailOptOut) continue; + const row = appointmentMap.get(appt.id); + if (!row) continue; + if (!row.clientEmail || row.clientEmailOptOut) continue; + if (!row.petName || !row.serviceName) 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; - - // Ensure the appointment has a confirmation token before sending the reminder. - // Generate one if it doesn't have one yet (e.g. pre-existing appointments). let confirmationToken = appt.confirmationToken; if (!confirmationToken) { confirmationToken = randomBytes(32).toString("hex"); @@ -125,12 +138,12 @@ export async function runReminderCheck(): Promise { const sent = await sendEmail( buildReminderEmail( - client.email, + row.clientEmail, { - clientName: client.name, - petName: pet.name, - serviceName: service.name, - groomerName, + clientName: row.clientName, + petName: row.petName, + serviceName: row.serviceName, + groomerName: row.staffName ?? null, startTime: appt.startTime, }, window.hours, @@ -139,7 +152,6 @@ export async function runReminderCheck(): Promise { ); if (sent) { - // Record send — ignore conflicts (race condition between instances) await db .insert(reminderLogs) .values({ appointmentId: appt.id, reminderType: window.label })