# 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` — 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 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: 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 ```bash 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. ```