fix(GRO-639): N+1 queries in reminder scheduler #301
Closed
groombook-engineer[bot] wants to merge 1 commits from
clean-gro-639 into main
pull from: clean-gro-639
merge into: groombook:main
groombook:main
groombook:dev
groombook:flea/gro-1636-better-auth-seed
groombook:pr-434
groombook:uat
groombook:docs/GRO-1502-uat-mcp-migration
groombook:flea/gro-1496-e2e-err-connection-refused
groombook:flea-flicker/gro-1489-lint-fixes
groombook:cpfarhood/gro-1162-pet-buffer
groombook:flea-flicker/gro-1162-pet-buffer
groombook:fix/gro-1368-consent-ts
groombook:fix/ci-e2e-dind-networking-registry-auth
groombook:fix/gro-1369-types-sync
groombook:fix/ci-registry-auth-main
groombook:gitea/migrate-workflows
groombook:flea-flicker/gro-1162-pet-buffer-time
groombook:feat/GRO-106-portal-communication-real
groombook:archived-readme
groombook:feat/GRO-106-stop-help
groombook:fix/gro-1248-path-prefixes
groombook:fix/GRO-1212-portal-test-mock-imports
groombook:fix/GRO-1108-test-mocks
groombook:feat/GRO-106-stop-help-v2
groombook:docs/GRO-1099-uat-playbook-app
groombook:fleaflicker/deploy-telnyx-webhook-secret
groombook:fix/gro-1024-clean
groombook:fix/gro-1021-auth-rate-limit
groombook:fix/gro-1021-auth-rate-limit-v2
groombook:feat/GRO-984-outbound-sms-persistence
groombook:fix/GRO-980-indentation
groombook:docs/GRO-106-10dlc-runbook
groombook:fix/gro-898-demo-sso-env-vars
groombook:fix/gro-609-cherry-pick
groombook:fix/gro-866-uat-seed-personas
groombook:fix/gro-867-logo-proxy
groombook:fix/gro-816-portal-pets-crash
groombook:fix/gro-844-network-policy
groombook:fix/gro-820-e2e-invoices-mock
groombook:feature/gro-609-refund-payment-stats
groombook:fix/gro-765-portal-appointments-service
groombook:fix/gro-805-allow-groomer-invoices
groombook:fix/gro-720-gitignore-hardening
groombook:fix/gro-721-harden-gitignore
groombook:feature/gro-633-db-indexes-constraints
groombook:fix/gro-639-n-plus-one-reminder-scheduler
groombook:ci-dev-trigger2
groombook:fix/gro-624-input-validation
groombook:feature/gro-653-portal-session-middleware
groombook:fix/gro-640-n-plus-one-email
groombook:fix/gro-637-invoice-refund-fixes
groombook:fix/gro-665-staff-auto-link
groombook:fix/gro-636-input-validation-v3
groombook:fix-gro-624-input-validation
groombook:fix/gro-655-corepack-only
groombook:feature/gro-597-payment-admin
groombook:feature/gro-631-graceful-shutdown
groombook:fix/gro-660-uat-seed-manager-superuser
groombook:fix/gro-655-corepack-enoent
groombook:feature/gro-623-groomer-isolation
groombook:feature/gro-632-impersonation-session-hardening
groombook:feature/gro-607-payment-ui
groombook:feature/gro-597-payment-backend
groombook:feature/gro-597-payment-ui
groombook:feature/gro-597-stripe-webhooks
groombook:feature/gro-597-payment-api
groombook:GRO-574-rate-limit-migration
groombook:chore/gro-575-promote-gro-574-to-uat
groombook:fix/gro-566-skip-oobe
groombook:fix/gro-557-e2e-stability
groombook:chore/gro-558-agents-instructions
groombook:fix/gro-531-social-login
groombook:fix/gro-545-social-providers-config
groombook:fix/gro-540-prod-oidc-env-vars
groombook:feat/gro-526-seed-profile-param
Labels
Clear labels
bug
documentation
duplicate
enhancement
feature
good first issue
help wanted
invalid
question
wontfix
Something isn't working
Improvements or additions to documentation
This issue or pull request already exists
New feature or request
New feature
Good for newcomers
Extra attention is needed
This doesn't seem right
Further information is requested
This will not be worked on
No Label
Milestone
No items
No Milestone
Projects
Clear projects
No project
Assignees
ai-review (AI Review)
gb_barkley (Barkley Trimsworth)
cpfarhood (Chris Farhood)
ci (Continuous Integration [bot])
gb_flea (Flea Flicker)
flux (Flux CD)
admin (Gitea Admin)
gb_lint (Lint Roller)
renovate (Mend Renovate)
gb_pawla (Pawla Abdul)
gb_scrubs (Scrubs McBarkley)
gb_shedward (Shedward Scissorhands)
gb_dogfather (The Dogfather)
Clear assignees
No Assignees
No due date set.
Dependencies
No dependencies set.
Reference: groombook/app#301
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "clean-gro-639"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
runReminderCheck()with a single JOIN query= ANY()instead of N individual checksWhat changed
apps/api/src/services/reminders.ts: replaced the per-appointment N+1 loop with:SELECT ... WHERE appointment_id = ANY($ids)for already-sent remindersappointments → clients → pets → services → staff) for all appointment dataConfirmation token logic (lines 118-124) is preserved unchanged.
Test plan
runReminderCheck()function now issues at most 2 queries per reminder window regardless of appointment count (1 for sent-check, 1 for joined data fetch) — verified by code reviewclientName,petName,serviceName,groomerName, andstartTimefrom the JOIN, not individual fetchesconfirmationTokenis null, a new token is generated and persisted before the email is sent — unchanged from original behaviorsqlis imported from@groombook/db(drizzle-orm re-export); no new packages introducedScope
This PR contains only
apps/api/src/services/reminders.ts. Stripe payment integration (GRO-597) is tracked separately.cc @cpfarhood
GRO-639 — Scope corrected
Previous PR #298 was closed due to scope creep (mixed Stripe/payment integration).
This PR contains only
apps/api/src/services/reminders.ts:= ANY()lookup for already-sent remindersStripe payment work is tracked in GRO-597.
Reviewers: please verify the test plan items in the PR description.
QA Review: Changes Requested
Two issues prevent approval:
1. Merge conflict with main
PR is not mergeable —
mergeable_state: dirty. Rebase on latestmainbefore requesting re-review.2. JOIN query missing appointmentId filter (functional correctness)
Location:
apps/api/src/services/reminders.ts, lines 83-101Problem: This JOIN fetches all scheduled appointments in the window — it has no
appointmentId IN (...)filter. Theupcomingvariable (line 69) already contains the filtered appointment list, butjoinedRowsignores it. TheappointmentMap.get(appt.id)guard on line 115 is a workaround, not a fix — it silently discards rows, but the query still fetches N rows from the DB when it only needs the subset fromupcoming.Expected: The JOIN query should be filtered to
appointmentId IN (...)so it only fetches the appointments that passed theupcomingfilter above.Please fix both issues and re-submit.
Fixes applied
Both issues have been resolved:
ffb3cd1). Branch is now clean.Note: while resolving the rebase conflict, I also restored the SMS reminder logic (GRO-598) that was present in but missing from the original PR. The email path is unchanged.
Please re-review.
Fixes applied
Both issues have been resolved:
ffb3cd1). Branch is now clean.Note: while resolving the rebase conflict, I also restored the SMS reminder logic (GRO-598) that was present in main but missing from the original PR. The email path is unchanged.
Please re-review.
Deployed to groombook-dev
Images:
pr-301URL: https://dev.groombook.farh.net
Ready for UAT validation.
@greptile please review
Greptile Summary
This PR replaces the N+1 per-appointment query loop in
runReminderCheck()with two batch queries: one= ANY()check againstreminder_logsto identify already-sent reminders, and one JOIN query acrossappointments → clients → pets → services → staffto fetch all required data in a single round-trip. The structural improvement is sound and the JOIN logic is correct, but the refactor inadvertently collapses the original per-channel deduplication into a per-appointment deduplication, introducing a silent SMS delivery regression.sentAppointmentIdsis built without filtering bychannel, so if an email reminder is logged for an appointment but the SMS send fails (smsOk = falseor a caught exception), the appointment ID still appears in the set. Every subsequent cron tick will hit thecontinueat line 126 and skip SMS permanently for that appointment. The original code checkedemailLogandsmsLogindependently, so SMS was always retried until it succeeded.clientId,petId,serviceId,staffId, andstatusare still selected from theupcomingquery but are never read fromapptafter the refactor; all their values now come fromjoinedRows.ANY()special-casing: ThesentAppointmentIdsquery branches onappointmentIds.length === 1to avoidANY(), but thejoinedRowsquery always usesANY(). PostgreSQL handles single-element arrays identically, making the branch unnecessary; using drizzle'sinArray()for both would be more consistent and idiomatic.Confidence Score: 3/5
Not safe to merge as-is — the channel-level deduplication regression will silently suppress SMS retries whenever email succeeds but SMS temporarily fails.
The N+1 elimination itself is correct and the JOIN query is well-structured. However, the per-channel idempotency guarantee that the original code maintained (independent emailLog / smsLog checks) is a concrete behavioral regression: any transient SMS API failure will cause the appointment to be permanently blacklisted from SMS delivery on the next cron tick. This directly affects a primary user-facing notification channel. A targeted fix is small but required before merge.
apps/api/src/services/reminders.ts — specifically the sentAppointmentIds construction (lines 70–84) and the per-appointment early-exit guard at line 126
Important Files Changed
Comments Outside Diff (1)
apps/api/src/services/reminders.ts, line 45-63 (link)After the refactor, the only fields actually consumed from
apptinside the loop areid(for batching/map lookup),startTime(line 146), andconfirmationToken(line 133). The columnsclientId,petId,serviceId,staffId, andstatusare selected but never referenced fromappt— all their values now come fromjoinedRowsinstead. Removing the unused fields would reduce the payload on the first round-trip query.Prompt To Fix With AI
Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "fix(GRO-639): replace N+1 per-appointmen..." | Re-trigger Greptile
@@ -62,0 +116,4 @@));const appointmentMap = new Map<string, typeof joinedRows[number]>();ANY()special-casing between the two queriesThe
sentAppointmentIdsquery has a branch forappointmentIds.length === 1that falls back to a plaineq(), while thejoinedRowsquery at line 112 always usesANY()without that guard. PostgreSQL handles= ANY('{single-uuid}'::uuid[])identically to= 'single-uuid', so the special case is unnecessary and creates a maintenance inconsistency. Consider usinginArrayfrom drizzle-orm for both queries — it is the idiomatic way to express this and avoids the rawsqltemplate entirely:Prompt To Fix With AI
@@ -62,0 +119,4 @@const appointmentMap = new Map<string, typeof joinedRows[number]>();for (const row of joinedRows) {appointmentMap.set(row.appointmentId, row);}The original code fetched
emailLog(filtered tochannel = "email") andsmsLog(filtered tochannel = "sms") independently, so each channel was only skipped when its own log entry existed. The newsentAppointmentIdsset omits any channel filter and therefore contains the appointment ID as soon as either channel has been logged.Consider this failure scenario (feasible every time the SMS provider returns a non-OK response):
(apptId, "24h", "email")inserted intoreminder_logs.smsOk = false→ no SMS log inserted.sentAppointmentIdscontainsapptId(from the email log) →continuefires → SMS is permanently skipped for this appointment.The old code would retry SMS on every subsequent tick until the provider succeeds. The new code silently drops it.
A minimal fix is to track
appointmentId:channelcomposite keys:Then replace the early-exit check (line 126) and the per-channel checks with:
Prompt To Fix With AI
Closing — superseded by PR #306 (v2 of this fix).