Files
touchbase/docs/progress/2026-05-03-reminders.md
2026-05-03 09:43:10 -04:00

7.7 KiB
Raw Permalink Blame History

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 test92/92 green (was 86; +6 new reminder tests)
  • pnpm lint — clean
  • pnpm exec tsc --noEmit — clean
  • pnpm worker — boots, runs pg-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.job table contains booking-reminder job with start_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

  1. Customer-visible brand name (still pending)
  2. Currency
  3. Stripe account ownership (for 5d live verification)
  4. 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.
  5. NEW: SMS reminders (planned for v1.1 per BuildLog.md). Same pg-boss pattern; just a different sender. Defer.

Roadmap status

  • Backend 14 done
  • 5a + 5b + 5c.1 + 5c.2 done
  • UX phases AE 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.

Three orthogonal options:

  1. Verify Stripe live in test mode — user provides STRIPE_* env vars; we run the deposit happy path with card 4242… and the failure path with 4000…0002. ~30 min once keys are in.
  2. Production prep — write a Dockerfile for the app+worker images (compose currently references touchbase/app:dev but we never built it), tighten the env story, add a pnpm db:migrate:deploy runbook for production migrations, decide where to deploy. ~half-day to a full day.
  3. 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.