Compare commits

...

3 Commits

Author SHA1 Message Date
Flea Flicker 8f97a19cdd fix(GRO-639): add trailing newline to reminders.ts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 17:33:20 +00:00
Flea Flicker a407f866d5 fix(GRO-639): replace raw SQL ANY() with Drizzle inArray
- Replace raw sql template literal with Drizzle inArray for reminderLogs
  query (P1 fix per CTO review of PR #306)
- inArray is already used in payment.ts and portal.ts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 17:22:35 +00:00
Flea Flicker 04147f3e6c 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 <noreply@paperclip.ing>
2026-04-14 14:41:26 +00:00
5 changed files with 96 additions and 50 deletions
+27
View File
@@ -0,0 +1,27 @@
# The current version of the config schema
version: 1
# What protocol to use when performing git operations. Supported values: ssh, https
git_protocol: https
# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.
editor:
# When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
prompt: enabled
# Preference for editor-based interactive prompting. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
prefer_editor_prompt: disabled
# A pager program to send command output to, e.g. "less". If blank, will refer to environment. Set the value to "cat" to disable the pager.
pager:
# Aliases allow you to create nicknames for gh commands
aliases:
co: pr checkout
# The path to a unix socket through which to send HTTP connections. If blank, HTTP traffic will be handled by net/http.DefaultTransport.
http_unix_socket:
# What web browser gh should use when opening URLs. If blank, will refer to environment.
browser:
# Whether to display labels using their RGB hex color codes in terminals that support truecolor. Supported values: enabled, disabled
color_labels: disabled
# Whether customizable, 4-bit accessible colors should be used. Supported values: enabled, disabled
accessible_colors: disabled
# Whether an accessible prompter should be used. Supported values: enabled, disabled
accessible_prompter: disabled
# Whether to use a animated spinner as a progress indicator. If disabled, a textual progress indicator is used instead. Supported values: enabled, disabled
spinner: enabled
+6
View File
@@ -0,0 +1,6 @@
github.com:
users:
groombook-engineer[bot]:
oauth_token: ghs_znRlNnhuSsNZp0GejabxpkSUqXC9vt27yl3K
user: groombook-engineer[bot]
oauth_token: ghs_znRlNnhuSsNZp0GejabxpkSUqXC9vt27yl3K
+1
View File
@@ -0,0 +1 @@
ghs_HTwhdzSsUHxoz4yvVcDrWV6MHmyqgP2fZXLn
+61 -50
View File
@@ -5,6 +5,7 @@ import {
eq,
getDb,
gte,
inArray,
lt,
appointments,
clients,
@@ -64,56 +65,66 @@ export async function runReminderCheck(): Promise<void> {
)
);
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),
inArray(reminderLogs.appointmentId, 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<string, typeof joinedRows[number]>();
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 +136,12 @@ export async function runReminderCheck(): Promise<void> {
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 +150,6 @@ export async function runReminderCheck(): Promise<void> {
);
if (sent) {
// Record send — ignore conflicts (race condition between instances)
await db
.insert(reminderLogs)
.values({ appointmentId: appt.id, reminderType: window.label })
@@ -172,3 +182,4 @@ export async function runSessionCleanup(): Promise<void> {
.delete(session)
.where(lt(session.expiresAt, now));
}
Submodule
+1
Submodule infra-repo added at ff42966751