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
@@ -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>`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user