feat: automated appointment reminders via email (GRO-23) (#29)
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: Groom Book CTO <cto@groombook.app> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #29.
This commit is contained in:
committed by
GitHub
parent
e7cf185d8c
commit
addcefe70b
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>`,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user