feat: automated appointment reminders via email (GRO-23)
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: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
-- Add email opt-out flag to clients
|
||||
ALTER TABLE "clients" ADD COLUMN "email_opt_out" boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- Track sent reminders to prevent duplicate sends
|
||||
CREATE TABLE "reminder_logs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"appointment_id" uuid NOT NULL REFERENCES "appointments"("id") ON DELETE CASCADE,
|
||||
"reminder_type" text NOT NULL,
|
||||
"sent_at" timestamp DEFAULT now() NOT NULL,
|
||||
UNIQUE ("appointment_id", "reminder_type")
|
||||
);
|
||||
@@ -29,6 +29,13 @@
|
||||
"when": 1742169600000,
|
||||
"tag": "0003_recurring_series",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1773779939000,
|
||||
"tag": "0004_reminder_logs",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user