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:
groombook-paperclip[bot]
2026-03-17 20:46:49 +00:00
committed by GitHub
parent e7cf185d8c
commit addcefe70b
11 changed files with 445 additions and 0 deletions
+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),