feat: automated appointment reminders via email #29

Merged
ghost merged 1 commits from feat/automated-reminders into main 2026-03-17 20:46:49 +00:00
11 changed files with 445 additions and 0 deletions
+14
View File
@@ -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 <noreply@example.com>"
# Hours before appointment to send reminder emails (defaults: 24 and 2)
REMINDER_HOURS_EARLY=24
REMINDER_HOURS_LATE=2
+4
View File
@@ -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",
+4
View File
@@ -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;
+67
View File
@@ -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<typeof getDb>,
appt: typeof appointments.$inferSelect
): Promise<void> {
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),
+128
View File
@@ -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 <noreply@groombook.local>";
export async function sendEmail(opts: Mail.Options): Promise<boolean> {
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: `
<p>Hi ${data.clientName},</p>
<p>Your appointment has been confirmed!</p>
<table style="border-collapse:collapse;margin:1em 0">
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Pet</td><td>${data.petName}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Service</td><td>${data.serviceName}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">When</td><td>${time}${groomer}</td></tr>
</table>
<p>We look forward to seeing you. If you need to reschedule, please contact us.</p>
<p>— Groom Book</p>`,
};
}
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: `
<p>Hi ${data.clientName},</p>
<p>Just a reminder that <strong>${data.petName}</strong>'s grooming appointment is <strong>${when}</strong>.</p>
<table style="border-collapse:collapse;margin:1em 0">
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Pet</td><td>${data.petName}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Service</td><td>${data.serviceName}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">When</td><td>${time}${groomer}</td></tr>
</table>
<p>See you soon!</p>
<p>— Groom Book</p>`,
};
}
+146
View File
@@ -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<void> {
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");
}
@@ -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")
);
@@ -29,6 +29,13 @@
"when": 1742169600000,
"tag": "0003_recurring_series",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1773779939000,
"tag": "0004_reminder_logs",
"breakpoints": true
}
]
}
+19
View File
@@ -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)]
);
+1
View File
@@ -15,6 +15,7 @@ export interface Client {
phone: string | null;
address: string | null;
notes: string | null;
emailOptOut: boolean;
createdAt: string;
updatedAt: string;
}
+44
View File
@@ -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