email reminders

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

View File

@@ -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]

View File

@@ -0,0 +1,111 @@
# 2026-05-03 — Email Reminders (5e)
> Companion to `BuildLog.md`. Predecessor: `2026-05-02-stripe-scaffold.md`. Closes Phase 5e.
## Milestone
Booking reminders are wired end-to-end: every CONFIRMED booking schedules a 24h-before reminder via pg-boss; the worker process picks it up at the scheduled time, re-validates the booking is still active, and sends a templated email. Idempotent against webhook replay, duplicate scheduling, handler retries, and out-of-order events. **v1 is feature-complete for soft launch** — modulo live Stripe verification.
## What landed
| Path | Role |
|---|---|
| `src/lib/jobs.ts` | pg-boss singleton + `QUEUES` constants + `BookingReminderPayload` type. Lazy-initialized. Configurable schema (`pgboss`). |
| `src/lib/reminders.ts` | `scheduleReminderForBooking(bookingId, startsAt)` — best-effort, fire-and-forget. `handleReminderJob(db, payload)` — re-validates status + dedupes via Notification table. |
| `src/lib/email.ts` | +`sendBookingReminder` template (24h-before framing, "Reminder: X tomorrow at Y"). |
| `scripts/worker.ts` | Long-lived worker process — registers handlers for `booking-reminder` (event-driven) + `expire-stale-holds` (cron `* * * * *`). Graceful shutdown on SIGTERM/SIGINT. |
| `package.json` | +`pnpm worker` script. |
| `compose.yaml` | +`worker` service under `prod` profile, sharing the app image with `command: ["pnpm", "worker"]`. |
| `src/app/book/confirm/page.tsx` | Schedules reminder after immediate-confirm AND after reschedule. |
| `src/app/admin/bookings/new/page.tsx` | Schedules reminder after admin-initiated booking. |
| `src/lib/payments.ts` `confirmAfterPayment` | Schedules reminder after webhook flips HOLD→CONFIRMED. |
| `scripts/book-on-behalf.ts` | Schedules reminder after CLI booking. |
| `test/reminders.test.ts` | 6 tests for the handler. |
## What's verified
- `pnpm test`**92/92 green** (was 86; +6 new reminder tests)
- `pnpm lint` — clean
- `pnpm exec tsc --noEmit` — clean
- `pnpm worker` — boots, runs `pg-boss start()`, creates queues, registers handlers, idles, shuts down on SIGTERM
- End-to-end smoke: `pnpm tsx scripts/book-on-behalf.ts robin@example.com "60-minute Swedish" 2026-05-12T11:00`
- Booking confirmed, email sent
- `pgboss.job` table contains `booking-reminder` job with `start_after = 2026-05-11 11:00 Detroit` — exactly 24h before the appointment
## Decisions ratified
| Decision | Resolution |
|---|---|
| Job library | **pg-boss 12** — Postgres-only, no Redis. Already chosen at bootstrap. |
| Queue creation | pg-boss 12+ requires `boss.createQueue(name)` before send/work/schedule. We call it idempotently from both producer (`scheduleReminderForBooking`) and consumer (worker). |
| Reminder lead time | **24 hours** before `startsAt`. Hardcoded in `REMINDER_LEAD_MS`. |
| Cancellation handling | **Don't deschedule jobs.** Handler re-validates status before sending; cancelled bookings get a no-op handler invocation. Reason: simpler, no need to track job ids on bookings. |
| Reschedule handling | **Don't move jobs**, schedule a new one for the new booking. The old booking's reminder fires, sees status=CANCELLED, no-ops. Reason: same simplicity argument. |
| Dedupe at handler | Query `Notification.template='booking_reminder'` for the booking; skip if `sent` or `queued` exists. Survives webhook replay, double-scheduling, handler retries. |
| Singleton key for scheduling | `reminder:${bookingId}` — pg-boss dedupes within the queue. Belt-and-suspenders with the handler-side dedupe. |
| Retry policy | 3 retries, 60s base, exponential backoff. Reason: SMTP can hiccup; reminders matter. |
| Best-effort scheduling | Failures to schedule (pg-boss down, etc.) are caught and logged — they don't break the booking confirmation. Reason: confirming the booking is more important than the reminder. |
| Hold expiry | Recurring `* * * * *` job runs `expireStaleHolds()`. Becomes load-bearing once Stripe is live (HOLDs are created at /book/pay and need cleanup if the customer abandons checkout). |
| Worker process model | Separate process (and separate container under `prod` profile in compose). Sharing same DB connection pool would be fine functionally, but separation lets `worker` crash without taking down the web tier. |
| Reminder for past bookings | Handler's "past" check skips them. Doesn't try to be clever about "send a courtesy email anyway." |
| Reminder for HOLD bookings | Handler's status check skips them. By design — HOLD means payment is pending, no point reminding. |
## Gotchas hit
### pg-boss 12 changed the producer API
Previously you could just call `boss.send()` after `start()` and the queue would be auto-created. v12 requires explicit `boss.createQueue(name)` first or the send/schedule call throws `Queue X not found`. Fix: call `createQueue` idempotently in both producer (just before send) and worker (at startup).
### `import PgBoss from "pg-boss"` doesn't work
v12 dropped the default export. Fix: `import { PgBoss } from "pg-boss"`.
### `noScheduling: false` is no longer a valid option
v12 removed the `noScheduling` constructor option. Just don't pass it.
### CLI script was missing the schedule call
After wiring scheduling into `/book/confirm`, `/admin/bookings/new`, and `payments.confirmAfterPayment`, I forgot the CLI script (`book-on-behalf.ts`). It uses the same booking helpers but didn't call `scheduleReminderForBooking`. Caught during smoke test (queue was empty after CLI booking). Fix: added the call + `stopJobs()` in the finally block so the process exits cleanly.
## Open questions
1. Customer-visible brand name (still pending)
2. Currency
3. Stripe account ownership (for 5d live verification)
4. **NEW**: should reminders be configurable per-customer (e.g. opt-out, different lead time)? Defer until requested. Today: every CONFIRMED booking gets one 24h reminder.
5. **NEW**: SMS reminders (planned for v1.1 per BuildLog.md). Same pg-boss pattern; just a different sender. Defer.
## Roadmap status
- Backend 14 done
- 5a + 5b + 5c.1 + 5c.2 done
- UX phases AE done
- 5d Stripe — scaffolded; awaits real keys for live verification
- **5e Email reminders — done 2026-05-03 (this session)**
**v1 is feature-complete for soft launch** — modulo confirming Stripe in test mode end-to-end.
## Recommended next step
Three orthogonal options:
1. **Verify Stripe live in test mode** — user provides `STRIPE_*` env vars; we run the deposit happy path with card `4242…` and the failure path with `4000…0002`. ~30 min once keys are in.
2. **Production prep** — write a `Dockerfile` for the app+worker images (compose currently references `touchbase/app:dev` but we never built it), tighten the env story, add a `pnpm db:migrate:deploy` runbook for production migrations, decide where to deploy. ~half-day to a full day.
3. **Soft launch checklist** — practice TZ confirmed (Detroit ✓), brand name resolved (still TouchBase placeholder), currency confirmed (USD assumed), legal/policy copy (cancellation + ToS), real domain + TLS via Caddy. Mostly your call, not code.
My recommendation: **(2) Production prep next**, then come back to (1) once you have Stripe keys. (3) is parallel.
## How to resume
```bash
cd /Users/noise/Documents/code/touchbase
docker-compose up -d postgres mailpit
pnpm db:seed
pnpm tsx scripts/book-on-behalf.ts alex@example.com "60-minute Swedish" 2026-05-12T10:00
# Reminder is queued for 2026-05-11 10:00 Detroit. Verify:
docker exec touchbase-postgres-1 psql -U touchbase -d touchbase_dev \
-c "SELECT name, data, start_after FROM pgboss.job ORDER BY created_on DESC LIMIT 3;"
# Run the worker (in another terminal):
pnpm worker
# Idles waiting for the scheduled time. To force a reminder to fire now,
# UPDATE pgboss.job SET start_after = now() WHERE name='booking-reminder';
# Then watch the worker log.
```

View File

@@ -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
View File

@@ -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

View File

@@ -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
View 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);
});

View File

@@ -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

View File

@@ -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) {

View File

@@ -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
View 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;
};

View File

@@ -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
View 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
View 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");
});
});