fix(reminders): replace N+1 per-appointment queries with single JOIN query #306
@@ -2,9 +2,9 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, dev]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main, dev]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
ref:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
eq,
|
eq,
|
||||||
getDb,
|
getDb,
|
||||||
gte,
|
gte,
|
||||||
|
inArray,
|
||||||
lt,
|
lt,
|
||||||
appointments,
|
appointments,
|
||||||
clients,
|
clients,
|
||||||
@@ -59,68 +60,77 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
)
|
)
|
||||||
|
|
|||||||
);
|
);
|
||||||
|
|
||||||
|
const appointmentIds: string[] = upcoming.map((a) => a.id as string);
|
||||||
|
if (appointmentIds.length === 0) continue;
|
||||||
|
|
||||||
|
// Bulk check: which appointments already have email and SMS reminders sent?
|
||||||
|
const sentRows = await db
|
||||||
|
.select({ appointmentId: reminderLogs.appointmentId, channel: reminderLogs.channel })
|
||||||
|
.from(reminderLogs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(reminderLogs.reminderType, window.label),
|
||||||
|
appointmentIds.length === 1
|
||||||
|
? eq(reminderLogs.appointmentId, appointmentIds[0]!)
|
||||||
|
: inArray(reminderLogs.appointmentId, appointmentIds)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const sentEmail = new Set(
|
||||||
|
sentRows.filter((r) => r.channel === "email").map((r) => r.appointmentId)
|
||||||
|
);
|
||||||
|
const sentSms = new Set(
|
||||||
|
sentRows.filter((r) => r.channel === "sms").map((r) => r.appointmentId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bulk JOIN: fetch all client/pet/service/staff data in one 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,
|
||||||
|
clientSmsOptIn: clients.smsOptIn,
|
||||||
|
clientPhone: clients.phone,
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
const joined = appointmentMap.get(appt.id as string);
|
||||||
.select({ id: reminderLogs.id })
|
if (!joined) 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 { clientName, clientEmail, clientEmailOptOut, clientSmsOptIn, clientPhone, petName, serviceName, staffName } = joined;
|
||||||
.select({ id: reminderLogs.id })
|
|
||||||
.from(reminderLogs)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(reminderLogs.appointmentId, appt.id),
|
|
||||||
eq(reminderLogs.reminderType, window.label),
|
|
||||||
eq(reminderLogs.channel, "sms")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const [client] = await db
|
if (!clientEmail || clientEmailOptOut) continue;
|
||||||
.select({
|
if (!petName || !serviceName) continue;
|
||||||
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 emailSent = sentEmail.has(appt.id as string);
|
||||||
|
const smsSent = sentSms.has(appt.id as string);
|
||||||
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;
|
|
||||||
|
|
||||||
let confirmationToken = appt.confirmationToken;
|
let confirmationToken = appt.confirmationToken;
|
||||||
if (!confirmationToken) {
|
if (!confirmationToken) {
|
||||||
@@ -131,15 +141,15 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
.where(eq(appointments.id, appt.id));
|
.where(eq(appointments.id, appt.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!emailLog) {
|
if (!emailSent) {
|
||||||
const sent = await sendEmail(
|
const sent = await sendEmail(
|
||||||
buildReminderEmail(
|
buildReminderEmail(
|
||||||
client.email,
|
clientEmail,
|
||||||
{
|
{
|
||||||
clientName: client.name,
|
clientName,
|
||||||
petName: pet.name,
|
petName,
|
||||||
serviceName: service.name,
|
serviceName,
|
||||||
groomerName,
|
groomerName: staffName,
|
||||||
startTime: appt.startTime,
|
startTime: appt.startTime,
|
||||||
},
|
},
|
||||||
window.hours,
|
window.hours,
|
||||||
@@ -155,20 +165,20 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!smsLog && client.smsOptIn && client.phone) {
|
if (!smsSent && clientSmsOptIn && clientPhone) {
|
||||||
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}${staffName ? ` with ${staffName}` : ""}`,
|
||||||
`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(clientPhone, smsBody);
|
||||||
if (smsOk) {
|
if (smsOk) {
|
||||||
await db
|
await db
|
||||||
.insert(reminderLogs)
|
.insert(reminderLogs)
|
||||||
|
|||||||
Reference in New Issue
Block a user
inArrayalready available and used elsewhere in the codebaseThe rest of the codebase uses
inArrayfor exactly this pattern —payment.ts:62,portal.ts:64-65, andportal.ts:103,392all import and useinArrayfrom@groombook/db. In fact,inArrayis already exported from@groombook/db(alongsidesql).The current implementation uses a ternary that switches between
eq()for the single-ID case and a rawsqltemplate for the multi-ID case. Drizzle'sinArrayhandles both uniformly and generates a safe parameterizedIN (...)clause, avoiding any ambiguity about how the driver serializes a JavaScript array into a PostgreSQL= ANY($1)literal.If the array parameter isn't serialized correctly by the driver,
sentRowscould silently return an empty set, meaningsentEmailandsentSmswould always be empty — causing duplicate reminders on every scheduler tick.You'd need to add
inArrayto the import at line 7 and drop the now-unusedsqlimport.Prompt To Fix With AI
This optimization skips an appointment only when both
sentEmailandsentSmscontain its ID. For a client withsmsOptIn = false, asmsrow is never written toreminderLogs, sosentSmswill never contain that appointment ID — meaning the early exit never triggers for the common email-only case.No duplicate sends occur (the per-item guards on lines 142 and 167 handle that), but the short-circuit is dead code for email-only clients.
Prompt To Fix With AI