email reminders

This commit is contained in:
2026-05-03 09:43:10 -04:00
parent 815d4e0bdd
commit 6023fe5214
13 changed files with 616 additions and 2 deletions

View File

@@ -0,0 +1,111 @@
# 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 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.
## 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.
```