7.7 KiB
2026-05-03 — Email Reminders (5e)
Companion to
BuildLog.md. Predecessor:2026-05-02-stripe-scaffold.md. Closes Phase 5e.
Milestone
Booking reminders are wired end-to-end: every CONFIRMED booking schedules a 24h-before reminder via pg-boss; the worker process picks it up at the scheduled time, re-validates the booking is still active, and sends a templated email. Idempotent against webhook replay, duplicate scheduling, handler retries, and out-of-order events. v1 is feature-complete for soft launch — modulo live Stripe verification.
What landed
| Path | Role |
|---|---|
src/lib/jobs.ts |
pg-boss singleton + QUEUES constants + BookingReminderPayload type. Lazy-initialized. Configurable schema (pgboss). |
src/lib/reminders.ts |
scheduleReminderForBooking(bookingId, startsAt) — best-effort, fire-and-forget. handleReminderJob(db, payload) — re-validates status + dedupes via Notification table. |
src/lib/email.ts |
+sendBookingReminder template (24h-before framing, "Reminder: X tomorrow at Y"). |
scripts/worker.ts |
Long-lived worker process — registers handlers for booking-reminder (event-driven) + expire-stale-holds (cron * * * * *). Graceful shutdown on SIGTERM/SIGINT. |
package.json |
+pnpm worker script. |
compose.yaml |
+worker service under prod profile, sharing the app image with command: ["pnpm", "worker"]. |
src/app/book/confirm/page.tsx |
Schedules reminder after immediate-confirm AND after reschedule. |
src/app/admin/bookings/new/page.tsx |
Schedules reminder after admin-initiated booking. |
src/lib/payments.ts confirmAfterPayment |
Schedules reminder after webhook flips HOLD→CONFIRMED. |
scripts/book-on-behalf.ts |
Schedules reminder after CLI booking. |
test/reminders.test.ts |
6 tests for the handler. |
What's verified
pnpm test— 92/92 green (was 86; +6 new reminder tests)pnpm lint— cleanpnpm exec tsc --noEmit— cleanpnpm worker— boots, runspg-boss start(), creates queues, registers handlers, idles, shuts down on SIGTERM- End-to-end smoke:
pnpm tsx scripts/book-on-behalf.ts robin@example.com "60-minute Swedish" 2026-05-12T11:00- Booking confirmed, email sent
pgboss.jobtable containsbooking-reminderjob withstart_after = 2026-05-11 11:00 Detroit— exactly 24h before the appointment
Decisions ratified
| Decision | Resolution |
|---|---|
| Job library | pg-boss 12 — Postgres-only, no Redis. Already chosen at bootstrap. |
| Queue creation | pg-boss 12+ requires boss.createQueue(name) before send/work/schedule. We call it idempotently from both producer (scheduleReminderForBooking) and consumer (worker). |
| Reminder lead time | 24 hours before startsAt. Hardcoded in REMINDER_LEAD_MS. |
| Cancellation handling | Don't deschedule jobs. Handler re-validates status before sending; cancelled bookings get a no-op handler invocation. Reason: simpler, no need to track job ids on bookings. |
| Reschedule handling | Don't move jobs, schedule a new one for the new booking. The old booking's reminder fires, sees status=CANCELLED, no-ops. Reason: same simplicity argument. |
| Dedupe at handler | Query Notification.template='booking_reminder' for the booking; skip if sent or queued exists. Survives webhook replay, double-scheduling, handler retries. |
| Singleton key for scheduling | reminder:${bookingId} — pg-boss dedupes within the queue. Belt-and-suspenders with the handler-side dedupe. |
| Retry policy | 3 retries, 60s base, exponential backoff. Reason: SMTP can hiccup; reminders matter. |
| Best-effort scheduling | Failures to schedule (pg-boss down, etc.) are caught and logged — they don't break the booking confirmation. Reason: confirming the booking is more important than the reminder. |
| Hold expiry | Recurring * * * * * job runs expireStaleHolds(). Becomes load-bearing once Stripe is live (HOLDs are created at /book/pay and need cleanup if the customer abandons checkout). |
| Worker process model | Separate process (and separate container under prod profile in compose). Sharing same DB connection pool would be fine functionally, but separation lets worker crash without taking down the web tier. |
| Reminder for past bookings | Handler's "past" check skips them. Doesn't try to be clever about "send a courtesy email anyway." |
| Reminder for HOLD bookings | Handler's status check skips them. By design — HOLD means payment is pending, no point reminding. |
Gotchas hit
pg-boss 12 changed the producer API
Previously you could just call boss.send() after start() and the queue would be auto-created. v12 requires explicit boss.createQueue(name) first or the send/schedule call throws Queue X not found. Fix: call createQueue idempotently in both producer (just before send) and worker (at startup).
import PgBoss from "pg-boss" doesn't work
v12 dropped the default export. Fix: import { PgBoss } from "pg-boss".
noScheduling: false is no longer a valid option
v12 removed the noScheduling constructor option. Just don't pass it.
CLI script was missing the schedule call
After wiring scheduling into /book/confirm, /admin/bookings/new, and payments.confirmAfterPayment, I forgot the CLI script (book-on-behalf.ts). It uses the same booking helpers but didn't call scheduleReminderForBooking. Caught during smoke test (queue was empty after CLI booking). Fix: added the call + stopJobs() in the finally block so the process exits cleanly.
Open questions
- Customer-visible brand name (still pending)
- Currency
- Stripe account ownership (for 5d live verification)
- NEW: should reminders be configurable per-customer (e.g. opt-out, different lead time)? Defer until requested. Today: every CONFIRMED booking gets one 24h reminder.
- NEW: SMS reminders (planned for v1.1 per BuildLog.md). Same pg-boss pattern; just a different sender. Defer.
Roadmap status
- Backend 1–4 done
- 5a + 5b + 5c.1 + 5c.2 done
- UX phases A–E done
- 5d Stripe — scaffolded; awaits real keys for live verification
- 5e Email reminders — done 2026-05-03 (this session)
v1 is feature-complete for soft launch — modulo confirming Stripe in test mode end-to-end.
Recommended next step
Three orthogonal options:
- Verify Stripe live in test mode — user provides
STRIPE_*env vars; we run the deposit happy path with card4242…and the failure path with4000…0002. ~30 min once keys are in. - Production prep — write a
Dockerfilefor the app+worker images (compose currently referencestouchbase/app:devbut we never built it), tighten the env story, add apnpm db:migrate:deployrunbook for production migrations, decide where to deploy. ~half-day to a full day. - Soft launch checklist — practice TZ confirmed (Detroit ✓), brand name resolved (still TouchBase placeholder), currency confirmed (USD assumed), legal/policy copy (cancellation + ToS), real domain + TLS via Caddy. Mostly your call, not code.
My recommendation: (2) Production prep next, then come back to (1) once you have Stripe keys. (3) is parallel.
How to resume
cd /Users/noise/Documents/code/touchbase
docker-compose up -d postgres mailpit
pnpm db:seed
pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-12T10:00
# Reminder is queued for 2026-05-11 10:00 Detroit. Verify:
docker exec touchbase-postgres-1 psql -U touchbase -d touchbase_dev \
-c "SELECT name, data, start_after FROM pgboss.job ORDER BY created_on DESC LIMIT 3;"
# Run the worker (in another terminal):
pnpm worker
# Idles waiting for the scheduled time. To force a reminder to fire now,
# UPDATE pgboss.job SET start_after = now() WHERE name='booking-reminder';
# Then watch the worker log.