This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
app/apps/api/src/services/email.ts
T
Chris Farhood 90af76f222 feat(appointments): cascading delay prevention for appointment overruns
When a PATCH /appointments/:id extends endTime beyond the original, detect
and automatically shift downstream same-groomer appointments by the overrun
delta plus buffer. Only affects scheduled/confirmed appointments; appointments
that would shift outside business hours are flagged for manual review.

Clients receive email notification of rescheduled times.

GRO-1175: GRO-1162-G

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-16 16:08:05 +00:00

253 lines
9.1 KiB
TypeScript

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,
confirmationToken?: string | null
): Mail.Options {
const time = formatDateTime(data.startTime);
const groomer = data.groomerName ? ` with ${data.groomerName}` : "";
const when = hoursAhead >= 24 ? `tomorrow` : `in ${hoursAhead} hours`;
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
const confirmUrl = confirmationToken ? `${apiUrl}/api/book/confirm/${confirmationToken}` : null;
const cancelUrl = confirmationToken ? `${apiUrl}/api/book/cancel/${confirmationToken}` : null;
const actionText = confirmationToken
? [
``,
`Confirm your appointment: ${confirmUrl}`,
`Cancel your appointment: ${cancelUrl}`,
].join("\n")
: "";
const actionHtml = confirmationToken
? `
<div style="margin:1.5em 0">
<a href="${confirmUrl}" style="display:inline-block;padding:10px 20px;background:#10b981;color:#fff;text-decoration:none;border-radius:4px;font-weight:600;margin-right:12px">Confirm Appointment</a>
<a href="${cancelUrl}" style="display:inline-block;padding:10px 20px;background:#fff;color:#ef4444;text-decoration:none;border-radius:4px;font-weight:600;border:1px solid #ef4444">Cancel Appointment</a>
</div>`
: "";
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}`,
actionText,
`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>
${actionHtml}
<p>See you soon!</p>
<p>— Groom Book</p>`,
};
}
interface WaitlistNotificationData {
clientName: string;
petName: string;
serviceName: string;
preferredDate: string;
preferredTime: string;
}
export function buildWaitlistNotificationEmail(
to: string,
data: WaitlistNotificationData
): Mail.Options {
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
const bookUrl = `${apiUrl}/book`;
return {
to,
subject: `Appointment Cancelled — A slot has opened up for ${data.petName}`,
text: [
`Hi ${data.clientName},`,
``,
`Great news! An appointment slot has become available.`,
``,
`We had a cancellation for:`,
` Pet: ${data.petName}`,
` Service: ${data.serviceName}`,
` Date: ${data.preferredDate}`,
` Time: ${data.preferredTime}`,
``,
`If you're still interested, book now before this slot is taken!`,
``,
`Book your appointment: ${bookUrl}`,
``,
`— Groom Book`,
].join("\n"),
html: `
<p>Hi ${data.clientName},</p>
<p>Great news! <strong>An appointment slot has become available</strong>.</p>
<p>We had a cancellation for:</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">Date</td><td>${data.preferredDate}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Time</td><td>${data.preferredTime}</td></tr>
</table>
<div style="margin:1.5em 0">
<a href="${bookUrl}" style="display:inline-block;padding:12px 24px;background:#10b981;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;font-size:16px">Book This Slot</a>
</div>
<p>If you're no longer interested, you can ignore this email or remove yourself from the waitlist in your portal.</p>
<p>— Groom Book</p>`,
};
}
// ─── Reschedule notification email ────────────────────────────────────────────
interface RescheduleEmailData {
clientName: string;
petName: string;
serviceName: string;
groomerName: string | null;
oldStartTime: Date;
newStartTime: Date;
}
export function buildRescheduleNotificationEmail(
to: string,
data: RescheduleEmailData
): Mail.Options {
const oldTime = formatDateTime(data.oldStartTime);
const newTime = formatDateTime(data.newStartTime);
const groomer = data.groomerName ? ` with ${data.groomerName}` : "";
return {
to,
subject: `Appointment Rescheduled — ${data.petName}'s appointment has been moved`,
text: [
`Hi ${data.clientName},`,
``,
`Your appointment has been rescheduled.`,
``,
` Pet: ${data.petName}`,
` Service: ${data.serviceName}`,
` Was: ${oldTime}${groomer}`,
` Now: ${newTime}${groomer}`,
``,
`If you have any questions or need to make changes, please contact us.`,
``,
`— Groom Book`,
].join("\n"),
html: `
<p>Hi ${data.clientName},</p>
<p>Your appointment has been <strong>rescheduled</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:#ef4444">Was</td><td style="text-decoration:line-through;color:#ef4444">${oldTime}${groomer}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#10b981">Now</td><td style="color:#10b981">${newTime}${groomer}</td></tr>
</table>
<p>If you have any questions or need to make changes, please contact us.</p>
<p>— Groom Book</p>`,
};
}