From 6023fe5214467415dd093b521175269da3e92350 Mon Sep 17 00:00:00 2001 From: noisedestroyers Date: Sun, 3 May 2026 09:43:10 -0400 Subject: [PATCH] email reminders --- compose.yaml | 21 +++++ docs/progress/2026-05-03-reminders.md | 111 ++++++++++++++++++++++ package.json | 4 +- pnpm-lock.yaml | 59 ++++++++++++ scripts/book-on-behalf.ts | 5 + scripts/worker.ts | 78 ++++++++++++++++ src/app/admin/bookings/new/page.tsx | 2 + src/app/book/confirm/page.tsx | 3 + src/lib/email.ts | 64 +++++++++++++ src/lib/jobs.ts | 42 +++++++++ src/lib/payments.ts | 12 ++- src/lib/reminders.ts | 87 +++++++++++++++++ test/reminders.test.ts | 130 ++++++++++++++++++++++++++ 13 files changed, 616 insertions(+), 2 deletions(-) create mode 100644 docs/progress/2026-05-03-reminders.md create mode 100644 scripts/worker.ts create mode 100644 src/lib/jobs.ts create mode 100644 src/lib/reminders.ts create mode 100644 test/reminders.test.ts diff --git a/compose.yaml b/compose.yaml index 88467ff..1f795f8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -47,6 +47,27 @@ services: expose: - "3000" + worker: + image: touchbase/app:dev + profiles: [prod] + build: + context: . + dockerfile: Dockerfile + command: ["pnpm", "worker"] + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + NODE_ENV: production + DATABASE_URL: postgresql://touchbase:touchbase@postgres:5432/touchbase_dev?schema=public + APP_TZ: ${APP_TZ:-America/Detroit} + SMTP_HOST: ${SMTP_HOST:-mailpit} + SMTP_PORT: ${SMTP_PORT:-1025} + SMTP_USER: ${SMTP_USER:-dev} + SMTP_PASS: ${SMTP_PASS:-dev} + SMTP_FROM: ${SMTP_FROM:-TouchBase } + caddy: image: caddy:2-alpine profiles: [prod] diff --git a/docs/progress/2026-05-03-reminders.md b/docs/progress/2026-05-03-reminders.md new file mode 100644 index 0000000..3b35618 --- /dev/null +++ b/docs/progress/2026-05-03-reminders.md @@ -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 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. +``` diff --git a/package.json b/package.json index 528f90d..7cea98b 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "db:studio": "prisma studio", "db:test:reset": "dotenv -e .env.test -- prisma migrate reset --force", "db:bootstrap": "scripts/db-bootstrap.sh", - "db:seed": "tsx prisma/seed.ts" + "db:seed": "tsx prisma/seed.ts", + "worker": "tsx scripts/worker.ts" }, "dependencies": { "@auth/prisma-adapter": "^2.11.2", @@ -33,6 +34,7 @@ "next-auth": "5.0.0-beta.31", "nodemailer": "^8.0.7", "pg": "^8.20.0", + "pg-boss": "^12.18.2", "react": "19.2.4", "react-dom": "19.2.4", "stripe": "^22.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1dc52a0..f20be6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: pg: specifier: ^8.20.0 version: 8.20.0 + pg-boss: + specifier: ^12.18.2 + version: 12.18.2 react: specifier: 19.2.4 version: 19.2.4 @@ -1421,6 +1424,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cron-parser@5.5.0: + resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2188,6 +2195,10 @@ packages: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2289,6 +2300,10 @@ packages: resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==} engines: {node: '>=6.0.0'} + non-error@0.1.0: + resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==} + engines: {node: '>=20'} + oauth4webapi@3.8.6: resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} @@ -2367,6 +2382,11 @@ packages: perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + pg-boss@12.18.2: + resolution: {integrity: sha512-06kXeWvVWY+BUNsOt2me1okg6NXx2DBnAQHTurA9jtrvbAO9qUOSE3/0ERERQDrokI+FREFM2Twha+JbrFT/8Q==} + engines: {node: '>=22.12.0'} + hasBin: true + pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} @@ -2584,6 +2604,10 @@ packages: seq-queue@0.0.5: resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + serialize-error@13.0.1: + resolution: {integrity: sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==} + engines: {node: '>=20'} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -2727,6 +2751,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwindcss@4.2.4: resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} @@ -2778,6 +2806,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} + engines: {node: '>=20'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -4184,6 +4216,10 @@ snapshots: convert-source-map@2.0.0: {} + cron-parser@5.5.0: + dependencies: + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5073,6 +5109,8 @@ snapshots: lru.min@1.1.4: {} + luxon@3.7.2: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5165,6 +5203,8 @@ snapshots: nodemailer@8.0.7: {} + non-error@0.1.0: {} + oauth4webapi@3.8.6: {} object-assign@4.1.1: {} @@ -5250,6 +5290,14 @@ snapshots: perfect-debounce@2.1.0: {} + pg-boss@12.18.2: + dependencies: + cron-parser: 5.5.0 + pg: 8.20.0 + serialize-error: 13.0.1 + transitivePeerDependencies: + - pg-native + pg-cloudflare@1.3.0: optional: true @@ -5479,6 +5527,11 @@ snapshots: seq-queue@0.0.5: {} + serialize-error@13.0.1: + dependencies: + non-error: 0.1.0 + type-fest: 5.6.0 + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -5669,6 +5722,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tagged-tag@1.0.0: {} + tailwindcss@4.2.4: {} tapable@2.3.3: {} @@ -5714,6 +5769,10 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@5.6.0: + dependencies: + tagged-tag: 1.0.0 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 diff --git a/scripts/book-on-behalf.ts b/scripts/book-on-behalf.ts index 263446e..777b432 100644 --- a/scripts/book-on-behalf.ts +++ b/scripts/book-on-behalf.ts @@ -18,6 +18,8 @@ import { findSlots } from "../src/lib/availability"; import { loadAvailabilityState } from "../src/lib/availability-loader"; import { confirmHold, createHold } from "../src/lib/booking"; import { sendBookingConfirmation } from "../src/lib/email"; +import { scheduleReminderForBooking } from "../src/lib/reminders"; +import { stopJobs } from "../src/lib/jobs"; async function main() { const [customerEmail, serviceName, localIso] = process.argv.slice(2); @@ -92,10 +94,13 @@ async function main() { await confirmHold(db, hold.id); const result = await sendBookingConfirmation({ db, bookingId: hold.id }); + await scheduleReminderForBooking(hold.id, startsAt); console.log(` Booking: ${hold.id} (CONFIRMED)`); console.log(` Email: ${result.status} (notification ${result.notificationId})`); + console.log(` Reminder: scheduled for 24h before`); console.log(` View: http://localhost:8025`); } finally { + await stopJobs(); await db.$disconnect(); } } diff --git a/scripts/worker.ts b/scripts/worker.ts new file mode 100644 index 0000000..798f96f --- /dev/null +++ b/scripts/worker.ts @@ -0,0 +1,78 @@ +// Long-lived worker process. Runs alongside Next.js (separate container in prod). +// Handles: +// - booking-reminder fired 24h before each CONFIRMED booking +// - expire-stale-holds recurring every minute; cancels timed-out HOLD bookings + +import "dotenv/config"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "../src/generated/prisma/client"; +import { jobs, QUEUES, type BookingReminderPayload, stopJobs } from "../src/lib/jobs"; +import { handleReminderJob } from "../src/lib/reminders"; +import { expireStaleHolds } from "../src/lib/booking"; + +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); +const db = new PrismaClient({ adapter }); + +async function main() { + const boss = jobs(); + await boss.start(); + console.log("[worker] pg-boss started"); + + // pg-boss 12+ requires queues to be created before send/schedule/work. + // createQueue is idempotent — safe to call on every boot. + await boss.createQueue(QUEUES.BOOKING_REMINDER); + await boss.createQueue(QUEUES.EXPIRE_STALE_HOLDS); + + // Booking reminders. + await boss.work( + QUEUES.BOOKING_REMINDER, + async (incoming) => { + const items = Array.isArray(incoming) ? incoming : [incoming]; + for (const job of items) { + const data = job.data as BookingReminderPayload; + try { + const result = await handleReminderJob(db, data); + console.log( + `[worker] booking-reminder bookingId=${data.bookingId} ${ + result.sent ? "SENT" : `SKIP(${result.reason})` + }`, + ); + } catch (err) { + console.error( + `[worker] booking-reminder bookingId=${data.bookingId} FAILED:`, + err, + ); + throw err; // pg-boss will retry per the job's retryLimit + } + } + }, + ); + + // Hold expiry — recurring every minute. + await boss.schedule(QUEUES.EXPIRE_STALE_HOLDS, "* * * * *"); + await boss.work(QUEUES.EXPIRE_STALE_HOLDS, async () => { + const count = await expireStaleHolds(db); + if (count > 0) { + console.log(`[worker] expire-stale-holds expired ${count}`); + } + }); + + console.log("[worker] handlers registered, idling"); + + // Graceful shutdown. + const shutdown = async (signal: string) => { + console.log(`[worker] ${signal} received, shutting down`); + await stopJobs(); + await db.$disconnect(); + process.exit(0); + }; + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); +} + +main().catch(async (err) => { + console.error("[worker] fatal:", err); + await stopJobs(); + await db.$disconnect(); + process.exit(1); +}); diff --git a/src/app/admin/bookings/new/page.tsx b/src/app/admin/bookings/new/page.tsx index 91192d8..0604081 100644 --- a/src/app/admin/bookings/new/page.tsx +++ b/src/app/admin/bookings/new/page.tsx @@ -7,6 +7,7 @@ import { findSlots } from "@/lib/availability"; import { loadAvailabilityState } from "@/lib/availability-loader"; import { BookingConflictError, confirmHold, createHold } from "@/lib/booking"; import { sendBookingConfirmation } from "@/lib/email"; +import { scheduleReminderForBooking } from "@/lib/reminders"; export const metadata = { title: "New booking — TouchBase" }; export const dynamic = "force-dynamic"; @@ -120,6 +121,7 @@ async function bookSlotAction(formData: FormData): Promise { }); await confirmHold(db, hold.id); await sendBookingConfirmation({ db, bookingId: hold.id }); + await scheduleReminderForBooking(hold.id, startsAt); } catch (e) { const msg = e instanceof BookingConflictError diff --git a/src/app/book/confirm/page.tsx b/src/app/book/confirm/page.tsx index 5f869b4..2662ad8 100644 --- a/src/app/book/confirm/page.tsx +++ b/src/app/book/confirm/page.tsx @@ -17,6 +17,7 @@ import { } from "@/lib/email"; import { stripeConfigured } from "@/lib/stripe"; import { createDepositIntentForBooking } from "@/lib/payments"; +import { scheduleReminderForBooking } from "@/lib/reminders"; export const metadata = { title: "Confirm booking — TouchBase" }; export const dynamic = "force-dynamic"; @@ -124,6 +125,7 @@ async function confirmBookingAction(formData: FormData): Promise { oldBookingId: result.oldBookingId, newBookingId: result.newBookingId, }); + await scheduleReminderForBooking(result.newBookingId, startsAt); redirect(`/book/done?bookingId=${result.newBookingId}`); } else { const hold = await createHold(db, { @@ -144,6 +146,7 @@ async function confirmBookingAction(formData: FormData): Promise { await confirmHold(db, hold.id); await sendBookingConfirmation({ db, bookingId: hold.id }); + await scheduleReminderForBooking(hold.id, startsAt); redirect(`/book/done?bookingId=${hold.id}`); } } catch (e) { diff --git a/src/lib/email.ts b/src/lib/email.ts index cadd994..a2eea9a 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -160,6 +160,70 @@ function formatLocal(d: Date, tz: string): string { }).format(d); } +// ============================================================ +// Booking reminder (24h before) +// ============================================================ + +export type BookingReminderArgs = { + db: PrismaClient; + bookingId: string; +}; + +export async function sendBookingReminder( + args: BookingReminderArgs, +): Promise { + const booking = await args.db.booking.findUnique({ + where: { id: args.bookingId }, + include: { + customer: true, + service: true, + therapist: { include: { user: true } }, + room: true, + }, + }); + if (!booking) throw new Error(`Booking not found: ${args.bookingId}`); + + const tz = process.env.APP_TZ ?? "America/Detroit"; + const localStart = formatLocal(booking.startsAt, tz); + + const subject = `Reminder: ${booking.service.name} tomorrow at ${formatTimeOnly( + booking.startsAt, + tz, + )}`; + const text = [ + `Hi ${booking.customer.name ?? booking.customer.email},`, + "", + `This is a reminder of your appointment tomorrow:`, + "", + `Service: ${booking.service.name}`, + `When: ${localStart}`, + `Therapist: ${booking.therapist.user.name ?? booking.therapist.user.email}`, + `Room: ${booking.room.name}`, + "", + `Need to reschedule or cancel? Reply to this email and we'll help.`, + "", + `— TouchBase`, + ].join("\n"); + + return sendEmail({ + db: args.db, + to: booking.customer.email, + subject, + text, + template: "booking_reminder", + userId: booking.customerId, + bookingId: booking.id, + }); +} + +function formatTimeOnly(d: Date, tz: string): string { + return new Intl.DateTimeFormat("en-US", { + timeZone: tz, + hour: "numeric", + minute: "2-digit", + }).format(d); +} + // ============================================================ // Booking rescheduled // ============================================================ diff --git a/src/lib/jobs.ts b/src/lib/jobs.ts new file mode 100644 index 0000000..1745438 --- /dev/null +++ b/src/lib/jobs.ts @@ -0,0 +1,42 @@ +// pg-boss singleton — used by both the worker process (long-lived) and by +// the web tier's "send" calls (short-lived). pg-boss's `start()` is the +// boundary between the two: workers call it, web producers can `send()` +// without starting (they just need the same DATABASE_URL). +// +// We keep a per-process instance and lazy-init. + +import { PgBoss } from "pg-boss"; + +let cached: PgBoss | null = null; + +export function jobs(): PgBoss { + if (cached) return cached; + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error("DATABASE_URL is required for pg-boss"); + } + cached = new PgBoss({ + connectionString: url, + // Use a dedicated schema so it's easy to drop for fresh-start dev. + schema: "pgboss", + }); + return cached; +} + +/** Reset the cached instance (used between tests, or in worker shutdown). */ +export async function stopJobs(): Promise { + if (cached) { + await cached.stop({ graceful: true }); + cached = null; + } +} + +// Job queue names — keep in one place to avoid typos. +export const QUEUES = { + BOOKING_REMINDER: "booking-reminder", + EXPIRE_STALE_HOLDS: "expire-stale-holds", +} as const; + +export type BookingReminderPayload = { + bookingId: string; +}; diff --git a/src/lib/payments.ts b/src/lib/payments.ts index ae194fa..c895624 100644 --- a/src/lib/payments.ts +++ b/src/lib/payments.ts @@ -14,6 +14,7 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { stripe } from "@/lib/stripe"; import { sendBookingConfirmation } from "@/lib/email"; +import { scheduleReminderForBooking } from "@/lib/reminders"; /** * Create a Stripe PaymentIntent for the booking's deposit and persist its id. @@ -177,10 +178,19 @@ export async function recordPaymentFailed( return { bookingId: booking.id }; } -/** Convenience used by the webhook to send the confirmation email after payment. */ +/** Convenience used by the webhook after a payment succeeds and the booking + * transitions to CONFIRMED — sends the confirmation email and schedules the + * 24h reminder. + */ export async function confirmAfterPayment( db: PrismaClient, bookingId: string, ): Promise { + const booking = await db.booking.findUnique({ + where: { id: bookingId }, + select: { startsAt: true }, + }); + if (!booking) return; await sendBookingConfirmation({ db, bookingId }); + await scheduleReminderForBooking(bookingId, booking.startsAt); } diff --git a/src/lib/reminders.ts b/src/lib/reminders.ts new file mode 100644 index 0000000..122f4db --- /dev/null +++ b/src/lib/reminders.ts @@ -0,0 +1,87 @@ +// Booking reminder scheduling + handler. +// +// Pattern: "fire-and-forget" — schedule from the web tier when a booking +// transitions to CONFIRMED, then re-validate inside the handler. We don't +// bother cancelling jobs when bookings are cancelled or rescheduled — +// the handler's status check makes those a harmless no-op. + +import type { PrismaClient } from "@/generated/prisma/client"; +import { sendBookingReminder } from "@/lib/email"; +import { jobs, QUEUES, type BookingReminderPayload } from "@/lib/jobs"; + +/** How far in advance to send the reminder. */ +const REMINDER_LEAD_MS = 24 * 60 * 60 * 1000; + +/** + * Schedule a reminder job for a booking. Best-effort — failures are logged + * but don't propagate. Reason: booking confirmation must succeed even if + * jobs are temporarily unavailable. + * + * Idempotent on retry via pg-boss `singletonKey` (one queued job per booking). + */ +export async function scheduleReminderForBooking( + bookingId: string, + startsAt: Date, +): Promise { + const sendAt = new Date(startsAt.getTime() - REMINDER_LEAD_MS); + // If the booking is already within the reminder window, send "now-ish" + // (15 seconds out). For very-soon bookings the reminder may be skipped + // by the handler's "is it actually tomorrow?" check; that's OK. + const startAfter = sendAt > new Date() ? sendAt : new Date(Date.now() + 15_000); + + try { + const boss = jobs(); + await boss.start(); + // Idempotent — pg-boss 12+ requires the queue to exist before send(). + await boss.createQueue(QUEUES.BOOKING_REMINDER); + await boss.send( + QUEUES.BOOKING_REMINDER, + { bookingId } satisfies BookingReminderPayload, + { + startAfter, + singletonKey: `reminder:${bookingId}`, + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, + }, + ); + } catch (err) { + console.error("[reminders] failed to schedule:", err); + } +} + +/** + * Job handler. Runs on the worker. Re-validates the booking is still + * CONFIRMED + future + not-already-reminded before sending. + */ +export async function handleReminderJob( + db: PrismaClient, + payload: BookingReminderPayload, +): Promise<{ sent: boolean; reason?: string }> { + const booking = await db.booking.findUnique({ + where: { id: payload.bookingId }, + select: { id: true, status: true, startsAt: true }, + }); + if (!booking) return { sent: false, reason: "not_found" }; + if (booking.status !== "CONFIRMED") { + return { sent: false, reason: `status:${booking.status}` }; + } + if (booking.startsAt < new Date()) { + return { sent: false, reason: "past" }; + } + + // Dedupe: if a reminder was already sent for this booking, skip. This + // covers webhook replays + duplicate scheduling + handler retries. + const existing = await db.notification.findFirst({ + where: { + bookingId: booking.id, + template: "booking_reminder", + status: { in: ["sent", "queued"] }, + }, + select: { id: true }, + }); + if (existing) return { sent: false, reason: "already_sent" }; + + await sendBookingReminder({ db, bookingId: booking.id }); + return { sent: true }; +} diff --git a/test/reminders.test.ts b/test/reminders.test.ts new file mode 100644 index 0000000..fbb82df --- /dev/null +++ b/test/reminders.test.ts @@ -0,0 +1,130 @@ +// Tests for the reminder *handler* — the pg-boss producer side requires a +// running boss instance and is exercised via the worker smoke test, not here. + +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "@/generated/prisma/client"; +import { confirmHold, createHold } from "@/lib/booking"; +import { handleReminderJob } from "@/lib/reminders"; +import { resetEmailTransport } from "@/lib/email"; +import { seed, type SeedResult } from "@/lib/seed"; + +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); +const db = new PrismaClient({ adapter }); +const MAILPIT = "http://localhost:8025"; + +let fx: SeedResult; +let serviceId: string; +let therapistA: string; +let roomA: string; +let customerId: string; + +beforeAll(async () => { + fx = await seed(db); + serviceId = fx.services.find((s) => s.name === "60-minute Swedish")!.id; + therapistA = fx.therapists[0].id; + roomA = fx.rooms[0].id; + customerId = fx.customers[0].id; + process.env.SMTP_HOST = "localhost"; + process.env.SMTP_PORT = "1025"; + process.env.SMTP_FROM = "TouchBase "; + resetEmailTransport(); +}); + +afterAll(async () => { + await db.$disconnect(); +}); + +beforeEach(async () => { + await db.notification.deleteMany(); + await db.booking.deleteMany(); + await fetch(`${MAILPIT}/api/v1/messages`, { method: "DELETE" }); +}); + +const D = (iso: string) => new Date(iso); + +async function makeConfirmedBooking(startsAt: Date) { + const hold = await createHold(db, { + customerId, + serviceId, + therapistId: therapistA, + roomId: roomA, + startsAt, + }); + await confirmHold(db, hold.id); + return hold; +} + +describe("handleReminderJob", () => { + test("sends and writes a Notification when CONFIRMED + future + not-yet-reminded", async () => { + const future = new Date(Date.now() + 24 * 3600 * 1000); + const hold = await makeConfirmedBooking(future); + + const result = await handleReminderJob(db, { bookingId: hold.id }); + expect(result.sent).toBe(true); + + const noti = await db.notification.findFirst({ + where: { bookingId: hold.id, template: "booking_reminder" }, + }); + expect(noti?.status).toBe("sent"); + expect(noti?.userId).toBe(customerId); + + const res = await fetch(`${MAILPIT}/api/v1/messages`); + const json = (await res.json()) as { messages: { Subject: string }[] }; + expect(json.messages).toHaveLength(1); + expect(json.messages[0].Subject).toMatch(/Reminder:/); + }); + + test("dedupes — second call after a sent reminder is a no-op", async () => { + const future = new Date(Date.now() + 24 * 3600 * 1000); + const hold = await makeConfirmedBooking(future); + await handleReminderJob(db, { bookingId: hold.id }); + const second = await handleReminderJob(db, { bookingId: hold.id }); + expect(second.sent).toBe(false); + expect(second.reason).toBe("already_sent"); + const count = await db.notification.count({ + where: { bookingId: hold.id, template: "booking_reminder" }, + }); + expect(count).toBe(1); + }); + + test("skips CANCELLED bookings", async () => { + const future = new Date(Date.now() + 24 * 3600 * 1000); + const hold = await makeConfirmedBooking(future); + await db.booking.update({ + where: { id: hold.id }, + data: { status: "CANCELLED", cancelledAt: new Date() }, + }); + const result = await handleReminderJob(db, { bookingId: hold.id }); + expect(result.sent).toBe(false); + expect(result.reason).toBe("status:CANCELLED"); + }); + + test("skips bookings whose startsAt is already past", async () => { + const past = D("2026-04-01T14:00:00Z"); + const hold = await makeConfirmedBooking(past); + const result = await handleReminderJob(db, { bookingId: hold.id }); + expect(result.sent).toBe(false); + expect(result.reason).toBe("past"); + }); + + test("returns not_found for unknown bookingId", async () => { + const result = await handleReminderJob(db, { bookingId: "no-such" }); + expect(result.sent).toBe(false); + expect(result.reason).toBe("not_found"); + }); + + test("skips HOLD bookings (only CONFIRMED reminds)", async () => { + const future = new Date(Date.now() + 24 * 3600 * 1000); + const hold = await createHold(db, { + customerId, + serviceId, + therapistId: therapistA, + roomId: roomA, + startsAt: future, + }); + const result = await handleReminderJob(db, { bookingId: hold.id }); + expect(result.sent).toBe(false); + expect(result.reason).toBe("status:HOLD"); + }); +});