email reminders
This commit is contained in:
21
compose.yaml
21
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 <noreply@touchbase.local>}
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
profiles: [prod]
|
||||
|
||||
111
docs/progress/2026-05-03-reminders.md
Normal file
111
docs/progress/2026-05-03-reminders.md
Normal 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 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.
|
||||
```
|
||||
@@ -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",
|
||||
|
||||
59
pnpm-lock.yaml
generated
59
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
78
scripts/worker.ts
Normal file
78
scripts/worker.ts
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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<void> {
|
||||
});
|
||||
await confirmHold(db, hold.id);
|
||||
await sendBookingConfirmation({ db, bookingId: hold.id });
|
||||
await scheduleReminderForBooking(hold.id, startsAt);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof BookingConflictError
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
|
||||
await confirmHold(db, hold.id);
|
||||
await sendBookingConfirmation({ db, bookingId: hold.id });
|
||||
await scheduleReminderForBooking(hold.id, startsAt);
|
||||
redirect(`/book/done?bookingId=${hold.id}`);
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -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<SendResult> {
|
||||
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
|
||||
// ============================================================
|
||||
|
||||
42
src/lib/jobs.ts
Normal file
42
src/lib/jobs.ts
Normal file
@@ -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<void> {
|
||||
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;
|
||||
};
|
||||
@@ -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<void> {
|
||||
const booking = await db.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
select: { startsAt: true },
|
||||
});
|
||||
if (!booking) return;
|
||||
await sendBookingConfirmation({ db, bookingId });
|
||||
await scheduleReminderForBooking(bookingId, booking.startsAt);
|
||||
}
|
||||
|
||||
87
src/lib/reminders.ts
Normal file
87
src/lib/reminders.ts
Normal file
@@ -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<void> {
|
||||
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 };
|
||||
}
|
||||
130
test/reminders.test.ts
Normal file
130
test/reminders.test.ts
Normal file
@@ -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 <noreply@touchbase.local>";
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user