From 984cb67ba6997e32ab0b719ec49c7b4c268bab70 Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Tue, 17 Mar 2026 20:45:13 +0000 Subject: [PATCH] feat: automated appointment reminders via email (GRO-23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Phase 1 of groombook/groombook#4 — automated email reminders for upcoming appointments, with booking confirmations sent immediately on creation. - **DB**: new `reminder_logs` table tracks sent reminders per appointment (unique on appointmentId+type prevents duplicates); `clients` gains `email_opt_out` boolean (migration 0004_reminder_logs) - **Email service**: `apps/api/src/services/email.ts` — nodemailer SMTP transport (disabled when SMTP_HOST is unset, so self-hosted installs without email config are unaffected); confirmation and reminder email templates included - **Reminder scheduler**: `apps/api/src/services/reminders.ts` — node-cron job runs every minute, checks for appointments in the upcoming reminder windows (default: 24 h and 2 h), sends emails for opted-in clients, and records sends in reminder_logs (idempotent via ON CONFLICT DO NOTHING) - **Confirmation email**: sent fire-and-forget after successful appointment creation (both single and recurring); never blocks the API response - **Config**: SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASS, SMTP_FROM, REMINDER_HOURS_EARLY, REMINDER_HOURS_LATE env vars documented in .env.example; all optional — feature is silently disabled without them - **Types**: Client.emailOptOut field added to shared types package Co-Authored-By: Paperclip --- .env.example | 14 ++ apps/api/package.json | 4 + apps/api/src/index.ts | 4 + apps/api/src/routes/appointments.ts | 67 ++++++++ apps/api/src/services/email.ts | 128 +++++++++++++++ apps/api/src/services/reminders.ts | 146 ++++++++++++++++++ packages/db/migrations/0004_reminder_logs.sql | 11 ++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 19 +++ packages/types/src/index.ts | 1 + pnpm-lock.yaml | 44 ++++++ 11 files changed, 445 insertions(+) create mode 100644 apps/api/src/services/email.ts create mode 100644 apps/api/src/services/reminders.ts create mode 100644 packages/db/migrations/0004_reminder_logs.sql diff --git a/.env.example b/.env.example index 812d2b0..293f815 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,17 @@ OIDC_AUDIENCE=groombook # ── API ─────────────────────────────────────────────────────────────────────── PORT=3000 CORS_ORIGIN=http://localhost:8080 + +# ── Email Reminders (optional) ──────────────────────────────────────────────── +# Leave SMTP_HOST unset to disable email notifications entirely. +# When configured, appointment confirmation and reminder emails are sent via SMTP. +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=user@example.com +SMTP_PASS=password +SMTP_FROM="Groom Book " + +# Hours before appointment to send reminder emails (defaults: 24 and 2) +REMINDER_HOURS_EARLY=24 +REMINDER_HOURS_LATE=2 diff --git a/apps/api/package.json b/apps/api/package.json index dd128d2..5869606 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,11 +18,15 @@ "@hono/zod-validator": "^0.4.3", "hono": "^4.6.17", "jose": "^5.9.6", + "node-cron": "^3.0.3", + "nodemailer": "^6.9.16", "openid-client": "^6.1.7", "zod": "^3.24.1" }, "devDependencies": { "@types/node": "^22.10.7", + "@types/node-cron": "^3.0.11", + "@types/nodemailer": "^6.4.17", "eslint": "^9.18.0", "tsx": "^4.19.2", "typescript": "^5.7.3", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4f363ef..0640bd0 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -10,6 +10,7 @@ import { staffRouter } from "./routes/staff.js"; import { invoicesRouter } from "./routes/invoices.js"; import { bookRouter } from "./routes/book.js"; import { authMiddleware } from "./middleware/auth.js"; +import { startReminderScheduler } from "./services/reminders.js"; const app = new Hono(); @@ -44,4 +45,7 @@ const port = Number(process.env.PORT ?? 3000); console.log(`API server listening on port ${port}`); serve({ fetch: app.fetch, port }); +// Start background reminder scheduler (runs every minute to check for upcoming appointments) +startReminderScheduler(); + export default app; diff --git a/apps/api/src/routes/appointments.ts b/apps/api/src/routes/appointments.ts index b7cc938..843e532 100644 --- a/apps/api/src/routes/appointments.ts +++ b/apps/api/src/routes/appointments.ts @@ -10,8 +10,14 @@ import { lte, ne, appointments, + clients, + pets, recurringSeries, + reminderLogs, + services, + staff, } from "@groombook/db"; +import { buildConfirmationEmail, sendEmail } from "../services/email.js"; export const appointmentsRouter = new Hono(); @@ -189,10 +195,71 @@ appointmentsRouter.post( throw err; } + // Send confirmation email (fire-and-forget — never fails the request) + sendConfirmationEmail(db, firstRow).catch((err) => { + console.error("[appointments] Failed to send confirmation email:", err); + }); + return c.json(firstRow, 201); } ); +// ─── Confirmation email helper ───────────────────────────────────────────── + +async function sendConfirmationEmail( + db: ReturnType, + appt: typeof appointments.$inferSelect +): Promise { + const [client] = await db + .select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut }) + .from(clients) + .where(eq(clients.id, appt.clientId)) + .limit(1); + + if (!client || !client.email || client.emailOptOut) return; + + const [pet] = await db + .select({ name: pets.name }) + .from(pets) + .where(eq(pets.id, appt.petId)) + .limit(1); + + const [service] = await db + .select({ name: services.name }) + .from(services) + .where(eq(services.id, appt.serviceId)) + .limit(1); + + let groomerName: string | null = null; + if (appt.staffId) { + const [groomer] = await db + .select({ name: staff.name }) + .from(staff) + .where(eq(staff.id, appt.staffId)) + .limit(1); + groomerName = groomer?.name ?? null; + } + + if (!pet || !service) return; + + const sent = await sendEmail( + buildConfirmationEmail(client.email, { + clientName: client.name, + petName: pet.name, + serviceName: service.name, + groomerName, + startTime: appt.startTime, + }) + ); + + if (sent) { + await db + .insert(reminderLogs) + .values({ appointmentId: appt.id, reminderType: "confirmation" }) + .onConflictDoNothing(); + } +} + appointmentsRouter.patch( "/:id", zValidator("json", updateAppointmentSchema), diff --git a/apps/api/src/services/email.ts b/apps/api/src/services/email.ts new file mode 100644 index 0000000..1803e43 --- /dev/null +++ b/apps/api/src/services/email.ts @@ -0,0 +1,128 @@ +import nodemailer from "nodemailer"; +import type Mail from "nodemailer/lib/mailer/index.js"; + +// Returns null when SMTP is not configured — callers skip sending silently. +function createTransport(): nodemailer.Transporter | null { + const host = process.env.SMTP_HOST; + if (!host) return null; + + return nodemailer.createTransport({ + host, + port: Number(process.env.SMTP_PORT ?? 587), + secure: process.env.SMTP_SECURE === "true", + auth: + process.env.SMTP_USER + ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } + : undefined, + }); +} + +let _transport: nodemailer.Transporter | null | undefined; + +function getTransport(): nodemailer.Transporter | null { + if (_transport === undefined) _transport = createTransport(); + return _transport; +} + +const FROM = process.env.SMTP_FROM ?? "Groom Book "; + +export async function sendEmail(opts: Mail.Options): Promise { + const transport = getTransport(); + if (!transport) return false; // SMTP not configured — skip silently + + await transport.sendMail({ from: FROM, ...opts }); + return true; +} + +// ─── Email templates ────────────────────────────────────────────────────────── + +interface AppointmentEmailData { + clientName: string; + petName: string; + serviceName: string; + groomerName: string | null; + startTime: Date; +} + +function formatDateTime(d: Date): string { + return d.toLocaleString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export function buildConfirmationEmail( + to: string, + data: AppointmentEmailData +): Mail.Options { + const time = formatDateTime(data.startTime); + const groomer = data.groomerName ? ` with ${data.groomerName}` : ""; + return { + to, + subject: `Appointment Confirmed — ${data.petName} on ${data.startTime.toLocaleDateString()}`, + text: [ + `Hi ${data.clientName},`, + ``, + `Your appointment has been confirmed!`, + ``, + ` Pet: ${data.petName}`, + ` Service: ${data.serviceName}`, + ` When: ${time}${groomer}`, + ``, + `We look forward to seeing you. If you need to reschedule, please contact us.`, + ``, + `— Groom Book`, + ].join("\n"), + html: ` +

Hi ${data.clientName},

+

Your appointment has been confirmed!

+ + + + +
Pet${data.petName}
Service${data.serviceName}
When${time}${groomer}
+

We look forward to seeing you. If you need to reschedule, please contact us.

+

— Groom Book

`, + }; +} + +export function buildReminderEmail( + to: string, + data: AppointmentEmailData, + hoursAhead: number +): Mail.Options { + const time = formatDateTime(data.startTime); + const groomer = data.groomerName ? ` with ${data.groomerName}` : ""; + const when = hoursAhead >= 24 ? `tomorrow` : `in ${hoursAhead} hours`; + return { + to, + subject: `Reminder: ${data.petName}'s appointment is ${when}`, + text: [ + `Hi ${data.clientName},`, + ``, + `Just a reminder that ${data.petName}'s grooming appointment is ${when}.`, + ``, + ` Pet: ${data.petName}`, + ` Service: ${data.serviceName}`, + ` When: ${time}${groomer}`, + ``, + `See you soon!`, + ``, + `— Groom Book`, + ].join("\n"), + html: ` +

Hi ${data.clientName},

+

Just a reminder that ${data.petName}'s grooming appointment is ${when}.

+ + + + +
Pet${data.petName}
Service${data.serviceName}
When${time}${groomer}
+

See you soon!

+

— Groom Book

`, + }; +} diff --git a/apps/api/src/services/reminders.ts b/apps/api/src/services/reminders.ts new file mode 100644 index 0000000..60bda20 --- /dev/null +++ b/apps/api/src/services/reminders.ts @@ -0,0 +1,146 @@ +import cron from "node-cron"; +import { + and, + eq, + getDb, + gte, + lt, + appointments, + clients, + pets, + services, + staff, + reminderLogs, +} from "@groombook/db"; +import { + buildReminderEmail, + sendEmail, +} from "./email.js"; + +// How many hours before the appointment to send each reminder. +// Override via env: REMINDER_HOURS_EARLY (default 24) and REMINDER_HOURS_LATE (default 2). +function getReminderWindows(): { label: string; hours: number }[] { + const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24); + const late = Number(process.env.REMINDER_HOURS_LATE ?? 2); + return [ + { label: `${early}h`, hours: early }, + { label: `${late}h`, hours: late }, + ]; +} + +// Checks for upcoming appointments that need reminders and sends them. +// Runs every minute — idempotent via reminder_logs unique constraint. +export async function runReminderCheck(): Promise { + const db = getDb(); + const now = new Date(); + + for (const window of getReminderWindows()) { + // Target window: appointments starting between (hours - 1) and hours from now. + // Running every minute means we check a 1-minute slice; the 1-hour window + // ensures we catch appointments that started between heartbeats. + const windowStart = new Date(now.getTime() + (window.hours - 1) * 3600_000); + const windowEnd = new Date(now.getTime() + window.hours * 3600_000); + + // Find upcoming appointments in this time window that haven't been cancelled/completed + const upcoming = await db + .select({ + id: appointments.id, + startTime: appointments.startTime, + clientId: appointments.clientId, + petId: appointments.petId, + serviceId: appointments.serviceId, + staffId: appointments.staffId, + status: appointments.status, + }) + .from(appointments) + .where( + and( + gte(appointments.startTime, windowStart), + lt(appointments.startTime, windowEnd), + eq(appointments.status, "scheduled") + ) + ); + + for (const appt of upcoming) { + // Check if reminder already sent (unique constraint prevents double-send) + const existing = await db + .select({ id: reminderLogs.id }) + .from(reminderLogs) + .where( + and( + eq(reminderLogs.appointmentId, appt.id), + eq(reminderLogs.reminderType, window.label) + ) + ) + .limit(1); + + if (existing.length > 0) continue; // already sent + + // Fetch related records for the email + const [client] = await db + .select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut }) + .from(clients) + .where(eq(clients.id, appt.clientId)) + .limit(1); + + if (!client || !client.email || client.emailOptOut) continue; + + const [pet] = await db + .select({ name: pets.name }) + .from(pets) + .where(eq(pets.id, appt.petId)) + .limit(1); + + const [service] = await db + .select({ name: services.name }) + .from(services) + .where(eq(services.id, appt.serviceId)) + .limit(1); + + let groomerName: string | null = null; + if (appt.staffId) { + const [groomer] = await db + .select({ name: staff.name }) + .from(staff) + .where(eq(staff.id, appt.staffId)) + .limit(1); + groomerName = groomer?.name ?? null; + } + + if (!pet || !service) continue; + + const sent = await sendEmail( + buildReminderEmail( + client.email, + { + clientName: client.name, + petName: pet.name, + serviceName: service.name, + groomerName, + startTime: appt.startTime, + }, + window.hours + ) + ); + + if (sent) { + // Record send — ignore conflicts (race condition between instances) + await db + .insert(reminderLogs) + .values({ appointmentId: appt.id, reminderType: window.label }) + .onConflictDoNothing(); + } + } + } +} + +// Starts the cron scheduler. Call once at server startup. +export function startReminderScheduler(): void { + // Run every minute + cron.schedule("* * * * *", () => { + runReminderCheck().catch((err) => { + console.error("[reminders] Error during reminder check:", err); + }); + }); + console.log("[reminders] Reminder scheduler started"); +} diff --git a/packages/db/migrations/0004_reminder_logs.sql b/packages/db/migrations/0004_reminder_logs.sql new file mode 100644 index 0000000..6ed65f7 --- /dev/null +++ b/packages/db/migrations/0004_reminder_logs.sql @@ -0,0 +1,11 @@ +-- Add email opt-out flag to clients +ALTER TABLE "clients" ADD COLUMN "email_opt_out" boolean NOT NULL DEFAULT false; + +-- Track sent reminders to prevent duplicate sends +CREATE TABLE "reminder_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "appointment_id" uuid NOT NULL REFERENCES "appointments"("id") ON DELETE CASCADE, + "reminder_type" text NOT NULL, + "sent_at" timestamp DEFAULT now() NOT NULL, + UNIQUE ("appointment_id", "reminder_type") +); diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 3028955..7c80dc4 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1742169600000, "tag": "0003_recurring_series", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1773779939000, + "tag": "0004_reminder_logs", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 7be2f61..4adf1b2 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -6,6 +6,7 @@ import { pgTable, text, timestamp, + unique, uuid, } from "drizzle-orm/pg-core"; @@ -49,6 +50,8 @@ export const clients = pgTable("clients", { phone: text("phone"), address: text("address"), notes: text("notes"), + // Set to true if the client has opted out of email reminders/notifications + emailOptOut: boolean("email_opt_out").notNull().default(false), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); @@ -159,3 +162,19 @@ export const invoiceLineItems = pgTable("invoice_line_items", { totalCents: integer("total_cents").notNull(), createdAt: timestamp("created_at").notNull().defaultNow(), }); + +// Tracks which reminder emails have been sent per appointment (prevents duplicates). +// reminder_type values: "confirmation", "24h", "2h" +export const reminderLogs = pgTable( + "reminder_logs", + { + id: uuid("id").primaryKey().defaultRandom(), + appointmentId: uuid("appointment_id") + .notNull() + .references(() => appointments.id, { onDelete: "cascade" }), + // "confirmation" | "24h" | "2h" + reminderType: text("reminder_type").notNull(), + sentAt: timestamp("sent_at").notNull().defaultNow(), + }, + (t) => [unique().on(t.appointmentId, t.reminderType)] +); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 9abe82b..feb5efd 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -15,6 +15,7 @@ export interface Client { phone: string | null; address: string | null; notes: string | null; + emailOptOut: boolean; createdAt: string; updatedAt: string; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4086c50..aaaa63d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,12 @@ importers: jose: specifier: ^5.9.6 version: 5.10.0 + node-cron: + specifier: ^3.0.3 + version: 3.0.3 + nodemailer: + specifier: ^6.9.16 + version: 6.10.1 openid-client: specifier: ^6.1.7 version: 6.8.2 @@ -38,6 +44,12 @@ importers: '@types/node': specifier: ^22.10.7 version: 22.19.15 + '@types/node-cron': + specifier: ^3.0.11 + version: 3.0.11 + '@types/nodemailer': + specifier: ^6.4.17 + version: 6.4.23 eslint: specifier: ^9.18.0 version: 9.39.4 @@ -1529,9 +1541,15 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node-cron@3.0.11': + resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} + '@types/node@22.19.15': resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + '@types/nodemailer@6.4.23': + resolution: {integrity: sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -2506,9 +2524,17 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-cron@3.0.3: + resolution: {integrity: sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==} + engines: {node: '>=6.0.0'} + node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + nodemailer@6.10.1: + resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==} + engines: {node: '>=6.0.0'} + oauth4webapi@3.8.5: resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} @@ -2978,6 +3004,10 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -4401,10 +4431,16 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node-cron@3.0.11': {} + '@types/node@22.19.15': dependencies: undici-types: 6.21.0 + '@types/nodemailer@6.4.23': + dependencies: + '@types/node': 22.19.15 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -5485,8 +5521,14 @@ snapshots: natural-compare@1.4.0: {} + node-cron@3.0.3: + dependencies: + uuid: 8.3.2 + node-releases@2.0.36: {} + nodemailer@6.10.1: {} + oauth4webapi@3.8.5: {} object-inspect@1.13.4: {} @@ -6006,6 +6048,8 @@ snapshots: dependencies: punycode: 2.3.1 + uuid@8.3.2: {} + vite-node@3.2.4(@types/node@22.19.15)(terser@5.46.1)(tsx@4.21.0): dependencies: cac: 6.7.14