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,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),
|
||||
|
||||
Reference in New Issue
Block a user