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
+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)]
);