email reminders
This commit is contained in:
21
compose.yaml
21
compose.yaml
@@ -47,6 +47,27 @@ services:
|
|||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "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:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
profiles: [prod]
|
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:studio": "prisma studio",
|
||||||
"db:test:reset": "dotenv -e .env.test -- prisma migrate reset --force",
|
"db:test:reset": "dotenv -e .env.test -- prisma migrate reset --force",
|
||||||
"db:bootstrap": "scripts/db-bootstrap.sh",
|
"db:bootstrap": "scripts/db-bootstrap.sh",
|
||||||
"db:seed": "tsx prisma/seed.ts"
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"worker": "tsx scripts/worker.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.2",
|
"@auth/prisma-adapter": "^2.11.2",
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"next-auth": "5.0.0-beta.31",
|
"next-auth": "5.0.0-beta.31",
|
||||||
"nodemailer": "^8.0.7",
|
"nodemailer": "^8.0.7",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
|
"pg-boss": "^12.18.2",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"stripe": "^22.1.0",
|
"stripe": "^22.1.0",
|
||||||
|
|||||||
59
pnpm-lock.yaml
generated
59
pnpm-lock.yaml
generated
@@ -41,6 +41,9 @@ importers:
|
|||||||
pg:
|
pg:
|
||||||
specifier: ^8.20.0
|
specifier: ^8.20.0
|
||||||
version: 8.20.0
|
version: 8.20.0
|
||||||
|
pg-boss:
|
||||||
|
specifier: ^12.18.2
|
||||||
|
version: 12.18.2
|
||||||
react:
|
react:
|
||||||
specifier: 19.2.4
|
specifier: 19.2.4
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
@@ -1421,6 +1424,10 @@ packages:
|
|||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
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:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -2188,6 +2195,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
|
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
|
||||||
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
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:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
@@ -2289,6 +2300,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==}
|
resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
non-error@0.1.0:
|
||||||
|
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
oauth4webapi@3.8.6:
|
oauth4webapi@3.8.6:
|
||||||
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
|
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
|
||||||
|
|
||||||
@@ -2367,6 +2382,11 @@ packages:
|
|||||||
perfect-debounce@2.1.0:
|
perfect-debounce@2.1.0:
|
||||||
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
|
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:
|
pg-cloudflare@1.3.0:
|
||||||
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
|
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
|
||||||
|
|
||||||
@@ -2584,6 +2604,10 @@ packages:
|
|||||||
seq-queue@0.0.5:
|
seq-queue@0.0.5:
|
||||||
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
|
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:
|
set-function-length@1.2.2:
|
||||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2727,6 +2751,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
tagged-tag@1.0.0:
|
||||||
|
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
tailwindcss@4.2.4:
|
tailwindcss@4.2.4:
|
||||||
resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==}
|
resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==}
|
||||||
|
|
||||||
@@ -2778,6 +2806,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
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:
|
typed-array-buffer@1.0.3:
|
||||||
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
|
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4184,6 +4216,10 @@ snapshots:
|
|||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
|
cron-parser@5.5.0:
|
||||||
|
dependencies:
|
||||||
|
luxon: 3.7.2
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -5073,6 +5109,8 @@ snapshots:
|
|||||||
|
|
||||||
lru.min@1.1.4: {}
|
lru.min@1.1.4: {}
|
||||||
|
|
||||||
|
luxon@3.7.2: {}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@@ -5165,6 +5203,8 @@ snapshots:
|
|||||||
|
|
||||||
nodemailer@8.0.7: {}
|
nodemailer@8.0.7: {}
|
||||||
|
|
||||||
|
non-error@0.1.0: {}
|
||||||
|
|
||||||
oauth4webapi@3.8.6: {}
|
oauth4webapi@3.8.6: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
@@ -5250,6 +5290,14 @@ snapshots:
|
|||||||
|
|
||||||
perfect-debounce@2.1.0: {}
|
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:
|
pg-cloudflare@1.3.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -5479,6 +5527,11 @@ snapshots:
|
|||||||
|
|
||||||
seq-queue@0.0.5: {}
|
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:
|
set-function-length@1.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
define-data-property: 1.1.4
|
define-data-property: 1.1.4
|
||||||
@@ -5669,6 +5722,8 @@ snapshots:
|
|||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
|
tagged-tag@1.0.0: {}
|
||||||
|
|
||||||
tailwindcss@4.2.4: {}
|
tailwindcss@4.2.4: {}
|
||||||
|
|
||||||
tapable@2.3.3: {}
|
tapable@2.3.3: {}
|
||||||
@@ -5714,6 +5769,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
|
|
||||||
|
type-fest@5.6.0:
|
||||||
|
dependencies:
|
||||||
|
tagged-tag: 1.0.0
|
||||||
|
|
||||||
typed-array-buffer@1.0.3:
|
typed-array-buffer@1.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { findSlots } from "../src/lib/availability";
|
|||||||
import { loadAvailabilityState } from "../src/lib/availability-loader";
|
import { loadAvailabilityState } from "../src/lib/availability-loader";
|
||||||
import { confirmHold, createHold } from "../src/lib/booking";
|
import { confirmHold, createHold } from "../src/lib/booking";
|
||||||
import { sendBookingConfirmation } from "../src/lib/email";
|
import { sendBookingConfirmation } from "../src/lib/email";
|
||||||
|
import { scheduleReminderForBooking } from "../src/lib/reminders";
|
||||||
|
import { stopJobs } from "../src/lib/jobs";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const [customerEmail, serviceName, localIso] = process.argv.slice(2);
|
const [customerEmail, serviceName, localIso] = process.argv.slice(2);
|
||||||
@@ -92,10 +94,13 @@ async function main() {
|
|||||||
await confirmHold(db, hold.id);
|
await confirmHold(db, hold.id);
|
||||||
|
|
||||||
const result = await sendBookingConfirmation({ db, bookingId: hold.id });
|
const result = await sendBookingConfirmation({ db, bookingId: hold.id });
|
||||||
|
await scheduleReminderForBooking(hold.id, startsAt);
|
||||||
console.log(` Booking: ${hold.id} (CONFIRMED)`);
|
console.log(` Booking: ${hold.id} (CONFIRMED)`);
|
||||||
console.log(` Email: ${result.status} (notification ${result.notificationId})`);
|
console.log(` Email: ${result.status} (notification ${result.notificationId})`);
|
||||||
|
console.log(` Reminder: scheduled for 24h before`);
|
||||||
console.log(` View: http://localhost:8025`);
|
console.log(` View: http://localhost:8025`);
|
||||||
} finally {
|
} finally {
|
||||||
|
await stopJobs();
|
||||||
await db.$disconnect();
|
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 { loadAvailabilityState } from "@/lib/availability-loader";
|
||||||
import { BookingConflictError, confirmHold, createHold } from "@/lib/booking";
|
import { BookingConflictError, confirmHold, createHold } from "@/lib/booking";
|
||||||
import { sendBookingConfirmation } from "@/lib/email";
|
import { sendBookingConfirmation } from "@/lib/email";
|
||||||
|
import { scheduleReminderForBooking } from "@/lib/reminders";
|
||||||
|
|
||||||
export const metadata = { title: "New booking — TouchBase" };
|
export const metadata = { title: "New booking — TouchBase" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -120,6 +121,7 @@ async function bookSlotAction(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
await confirmHold(db, hold.id);
|
await confirmHold(db, hold.id);
|
||||||
await sendBookingConfirmation({ db, bookingId: hold.id });
|
await sendBookingConfirmation({ db, bookingId: hold.id });
|
||||||
|
await scheduleReminderForBooking(hold.id, startsAt);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg =
|
const msg =
|
||||||
e instanceof BookingConflictError
|
e instanceof BookingConflictError
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "@/lib/email";
|
} from "@/lib/email";
|
||||||
import { stripeConfigured } from "@/lib/stripe";
|
import { stripeConfigured } from "@/lib/stripe";
|
||||||
import { createDepositIntentForBooking } from "@/lib/payments";
|
import { createDepositIntentForBooking } from "@/lib/payments";
|
||||||
|
import { scheduleReminderForBooking } from "@/lib/reminders";
|
||||||
|
|
||||||
export const metadata = { title: "Confirm booking — TouchBase" };
|
export const metadata = { title: "Confirm booking — TouchBase" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -124,6 +125,7 @@ async function confirmBookingAction(formData: FormData): Promise<void> {
|
|||||||
oldBookingId: result.oldBookingId,
|
oldBookingId: result.oldBookingId,
|
||||||
newBookingId: result.newBookingId,
|
newBookingId: result.newBookingId,
|
||||||
});
|
});
|
||||||
|
await scheduleReminderForBooking(result.newBookingId, startsAt);
|
||||||
redirect(`/book/done?bookingId=${result.newBookingId}`);
|
redirect(`/book/done?bookingId=${result.newBookingId}`);
|
||||||
} else {
|
} else {
|
||||||
const hold = await createHold(db, {
|
const hold = await createHold(db, {
|
||||||
@@ -144,6 +146,7 @@ async function confirmBookingAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
await confirmHold(db, hold.id);
|
await confirmHold(db, hold.id);
|
||||||
await sendBookingConfirmation({ db, bookingId: hold.id });
|
await sendBookingConfirmation({ db, bookingId: hold.id });
|
||||||
|
await scheduleReminderForBooking(hold.id, startsAt);
|
||||||
redirect(`/book/done?bookingId=${hold.id}`);
|
redirect(`/book/done?bookingId=${hold.id}`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -160,6 +160,70 @@ function formatLocal(d: Date, tz: string): string {
|
|||||||
}).format(d);
|
}).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
|
// 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 type { PrismaClient } from "@/generated/prisma/client";
|
||||||
import { stripe } from "@/lib/stripe";
|
import { stripe } from "@/lib/stripe";
|
||||||
import { sendBookingConfirmation } from "@/lib/email";
|
import { sendBookingConfirmation } from "@/lib/email";
|
||||||
|
import { scheduleReminderForBooking } from "@/lib/reminders";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Stripe PaymentIntent for the booking's deposit and persist its id.
|
* 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 };
|
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(
|
export async function confirmAfterPayment(
|
||||||
db: PrismaClient,
|
db: PrismaClient,
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const booking = await db.booking.findUnique({
|
||||||
|
where: { id: bookingId },
|
||||||
|
select: { startsAt: true },
|
||||||
|
});
|
||||||
|
if (!booking) return;
|
||||||
await sendBookingConfirmation({ db, bookingId });
|
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