Extract groombook/api from monorepo with CI workflow
- Add source code from apps/api - Add packages/db and packages/types workspace dependencies - Add GitHub Actions CI workflow (lint, typecheck, test, docker) - Generate pnpm-lock.yaml - Add .gitignore Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
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>`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import Stripe from "stripe";
|
||||
import { getDb, clients, eq, inArray, invoices } from "@groombook/db";
|
||||
|
||||
let _stripe: Stripe | null | undefined;
|
||||
|
||||
export function getStripeClient(): Stripe | null {
|
||||
if (_stripe === undefined) {
|
||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!secretKey) return null;
|
||||
_stripe = new Stripe(secretKey);
|
||||
}
|
||||
return _stripe;
|
||||
}
|
||||
|
||||
export async function getOrCreateStripeCustomer(clientId: string): Promise<string | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const db = getDb();
|
||||
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||
if (!client) return null;
|
||||
|
||||
if (client.stripeCustomerId) return client.stripeCustomerId;
|
||||
|
||||
const customer = await stripe.customers.create({
|
||||
metadata: { groombook_client_id: clientId },
|
||||
});
|
||||
|
||||
await db
|
||||
.update(clients)
|
||||
.set({ stripeCustomerId: customer.id, updatedAt: new Date() })
|
||||
.where(eq(clients.id, clientId));
|
||||
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
export async function createPaymentIntent(
|
||||
invoiceIdOrIds: string | string[],
|
||||
clientId: string
|
||||
): Promise<{ clientSecret: string; paymentIntentId: string } | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const db = getDb();
|
||||
const invoiceIds = Array.isArray(invoiceIdOrIds) ? invoiceIdOrIds : [invoiceIdOrIds];
|
||||
const firstInvoiceId = invoiceIds[0];
|
||||
if (!firstInvoiceId) return null;
|
||||
|
||||
const invoiceRows = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, firstInvoiceId));
|
||||
|
||||
const [invoice] = invoiceRows;
|
||||
if (!invoice) return null;
|
||||
|
||||
let totalCents = invoice.totalCents;
|
||||
if (invoiceIds.length > 1) {
|
||||
const allInvoices = await db
|
||||
.select({ totalCents: invoices.totalCents })
|
||||
.from(invoices)
|
||||
.where(inArray(invoices.id, invoiceIds));
|
||||
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
|
||||
}
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!stripeCustomerId) return null;
|
||||
|
||||
const paymentIntent = await stripe.paymentIntents.create({
|
||||
amount: totalCents,
|
||||
currency: "usd",
|
||||
customer: stripeCustomerId,
|
||||
metadata: {
|
||||
groombook_invoice_ids: invoiceIds.join(","),
|
||||
groombook_client_id: clientId,
|
||||
},
|
||||
automatic_payment_methods: { enabled: true },
|
||||
});
|
||||
|
||||
for (const invId of invoiceIds) {
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({ stripePaymentIntentId: paymentIntent.id, updatedAt: new Date() })
|
||||
.where(eq(invoices.id, invId));
|
||||
}
|
||||
|
||||
const clientSecret = paymentIntent.client_secret;
|
||||
if (!clientSecret) return null;
|
||||
|
||||
return { clientSecret, paymentIntentId: paymentIntent.id };
|
||||
}
|
||||
|
||||
export async function processRefund(
|
||||
invoiceId: string,
|
||||
amountCents?: number
|
||||
): Promise<{ refundId: string } | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const db = getDb();
|
||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1);
|
||||
if (!invoice?.stripePaymentIntentId) return null;
|
||||
|
||||
const refund = await stripe.refunds.create({
|
||||
payment_intent: invoice.stripePaymentIntentId,
|
||||
amount: amountCents,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({ stripeRefundId: refund.id, updatedAt: new Date() })
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
|
||||
return { refundId: refund.id };
|
||||
}
|
||||
|
||||
export async function listPaymentMethods(clientId: string): Promise<Stripe.PaymentMethod[] | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!stripeCustomerId) return null;
|
||||
|
||||
const methods = await stripe.paymentMethods.list({
|
||||
customer: stripeCustomerId,
|
||||
type: "card",
|
||||
});
|
||||
|
||||
return methods.data;
|
||||
}
|
||||
|
||||
export async function attachPaymentMethod(
|
||||
clientId: string,
|
||||
paymentMethodId: string
|
||||
): Promise<boolean> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return false;
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!stripeCustomerId) return false;
|
||||
|
||||
await stripe.paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId });
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function detachPaymentMethod(paymentMethodId: string): Promise<boolean> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return false;
|
||||
|
||||
await stripe.paymentMethods.detach(paymentMethodId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function createSetupIntent(customerId: string): Promise<{ clientSecret: string } | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const setupIntent = await stripe.setupIntents.create({
|
||||
customer: customerId,
|
||||
payment_method_types: ["card"],
|
||||
});
|
||||
|
||||
return { clientSecret: setupIntent.client_secret! };
|
||||
}
|
||||
|
||||
export async function getPaymentIntentDetails(
|
||||
paymentIntentId: string
|
||||
): Promise<{ cardLast4: string | null; paymentStatus: string | null } | null> {
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return null;
|
||||
|
||||
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, { expand: ["payment_method"] });
|
||||
const cardLast4 = pi.payment_method
|
||||
? (pi.payment_method as Stripe.PaymentMethod).card?.last4 ?? null
|
||||
: null;
|
||||
return {
|
||||
cardLast4,
|
||||
paymentStatus: pi.status ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import cron from "node-cron";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
getDb,
|
||||
gte,
|
||||
inArray,
|
||||
lt,
|
||||
appointments,
|
||||
clients,
|
||||
pets,
|
||||
services,
|
||||
staff,
|
||||
reminderLogs,
|
||||
session,
|
||||
} from "@groombook/db";
|
||||
import {
|
||||
buildReminderEmail,
|
||||
sendEmail,
|
||||
} from "./email.js";
|
||||
import { smsSend } from "./sms.js";
|
||||
|
||||
const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply.";
|
||||
|
||||
function getReminderWindows(): { label: string; hours: number }[] {
|
||||
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
|
||||
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
|
||||
return [
|
||||
{ label: `${early}h`, hours: early },
|
||||
{ label: `${late}h`, hours: late },
|
||||
];
|
||||
}
|
||||
|
||||
export async function runReminderCheck(): Promise<void> {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
|
||||
for (const window of getReminderWindows()) {
|
||||
const windowStart = new Date(now.getTime() + (window.hours - 1) * 3600_000);
|
||||
const windowEnd = new Date(now.getTime() + window.hours * 3600_000);
|
||||
|
||||
const upcoming = await db
|
||||
.select({
|
||||
id: appointments.id,
|
||||
startTime: appointments.startTime,
|
||||
clientId: appointments.clientId,
|
||||
petId: appointments.petId,
|
||||
serviceId: appointments.serviceId,
|
||||
staffId: appointments.staffId,
|
||||
status: appointments.status,
|
||||
confirmationToken: appointments.confirmationToken,
|
||||
})
|
||||
.from(appointments)
|
||||
.where(
|
||||
and(
|
||||
gte(appointments.startTime, windowStart),
|
||||
lt(appointments.startTime, windowEnd),
|
||||
eq(appointments.status, "scheduled")
|
||||
)
|
||||
);
|
||||
|
||||
const appointmentIds: string[] = upcoming.map((a) => a.id as string);
|
||||
if (appointmentIds.length === 0) continue;
|
||||
|
||||
// Bulk check: which appointments already have email and SMS reminders sent?
|
||||
const sentRows = await db
|
||||
.select({ appointmentId: reminderLogs.appointmentId, channel: reminderLogs.channel })
|
||||
.from(reminderLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(reminderLogs.reminderType, window.label),
|
||||
appointmentIds.length === 1
|
||||
? eq(reminderLogs.appointmentId, appointmentIds[0]!)
|
||||
: inArray(reminderLogs.appointmentId, appointmentIds)
|
||||
)
|
||||
);
|
||||
|
||||
const sentEmail = new Set(
|
||||
sentRows.filter((r) => r.channel === "email").map((r) => r.appointmentId)
|
||||
);
|
||||
const sentSms = new Set(
|
||||
sentRows.filter((r) => r.channel === "sms").map((r) => r.appointmentId)
|
||||
);
|
||||
|
||||
// Bulk JOIN: fetch all client/pet/service/staff data in one query
|
||||
const joinedRows = await db
|
||||
.select({
|
||||
appointmentId: appointments.id,
|
||||
startTime: appointments.startTime,
|
||||
clientId: appointments.clientId,
|
||||
petId: appointments.petId,
|
||||
serviceId: appointments.serviceId,
|
||||
staffId: appointments.staffId,
|
||||
confirmationToken: appointments.confirmationToken,
|
||||
clientName: clients.name,
|
||||
clientEmail: clients.email,
|
||||
clientEmailOptOut: clients.emailOptOut,
|
||||
clientSmsOptIn: clients.smsOptIn,
|
||||
clientPhone: clients.phone,
|
||||
petName: pets.name,
|
||||
serviceName: services.name,
|
||||
staffName: staff.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(clients, eq(appointments.clientId, clients.id))
|
||||
.innerJoin(pets, eq(appointments.petId, pets.id))
|
||||
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
||||
.where(
|
||||
and(
|
||||
gte(appointments.startTime, windowStart),
|
||||
lt(appointments.startTime, windowEnd),
|
||||
eq(appointments.status, "scheduled")
|
||||
)
|
||||
);
|
||||
|
||||
const appointmentMap = new Map<string, typeof joinedRows[number]>();
|
||||
for (const row of joinedRows) {
|
||||
appointmentMap.set(row.appointmentId, row);
|
||||
}
|
||||
|
||||
for (const appt of upcoming) {
|
||||
const joined = appointmentMap.get(appt.id as string);
|
||||
if (!joined) continue;
|
||||
|
||||
const { clientName, clientEmail, clientEmailOptOut, clientSmsOptIn, clientPhone, petName, serviceName, staffName } = joined;
|
||||
|
||||
if (!clientEmail || clientEmailOptOut) continue;
|
||||
if (!petName || !serviceName) continue;
|
||||
|
||||
const emailSent = sentEmail.has(appt.id as string);
|
||||
const smsSent = sentSms.has(appt.id as string);
|
||||
|
||||
let confirmationToken = appt.confirmationToken;
|
||||
if (!confirmationToken) {
|
||||
confirmationToken = randomBytes(32).toString("hex");
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({ confirmationToken, updatedAt: new Date() })
|
||||
.where(eq(appointments.id, appt.id));
|
||||
}
|
||||
|
||||
if (!emailSent) {
|
||||
const sent = await sendEmail(
|
||||
buildReminderEmail(
|
||||
clientEmail,
|
||||
{
|
||||
clientName,
|
||||
petName,
|
||||
serviceName,
|
||||
groomerName: staffName,
|
||||
startTime: appt.startTime,
|
||||
},
|
||||
window.hours,
|
||||
confirmationToken
|
||||
)
|
||||
);
|
||||
|
||||
if (sent) {
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: window.label, channel: "email" })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
if (!smsSent && clientSmsOptIn && clientPhone) {
|
||||
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
|
||||
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
|
||||
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
|
||||
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
|
||||
const smsBody = [
|
||||
`Hi ${clientName}, just a reminder: ${petName}'s grooming appointment is ${when}.`,
|
||||
`Service: ${serviceName}${staffName ? ` with ${staffName}` : ""}`,
|
||||
`Confirm: ${confirmUrl}`,
|
||||
`Cancel: ${cancelUrl}`,
|
||||
TCPA_OPT_OUT,
|
||||
].join(". ");
|
||||
try {
|
||||
const smsOk = await smsSend(clientPhone, smsBody);
|
||||
if (smsOk) {
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: window.label, channel: "sms" })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[reminders] SMS send failed:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function startReminderScheduler(): void {
|
||||
cron.schedule("* * * * *", () => {
|
||||
runReminderCheck().catch((err) => {
|
||||
console.error("[reminders] Error during reminder check:", err);
|
||||
});
|
||||
runSessionCleanup().catch((err) => {
|
||||
console.error("[reminders] Error during session cleanup:", err);
|
||||
});
|
||||
});
|
||||
console.log("[reminders] Reminder scheduler started");
|
||||
}
|
||||
|
||||
export async function runSessionCleanup(): Promise<void> {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
await db
|
||||
.delete(session)
|
||||
.where(lt(session.expiresAt, now));
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Telnyx } from "telnyx";
|
||||
import { createHmac } from "crypto";
|
||||
|
||||
export interface SmsProvider {
|
||||
sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>;
|
||||
validateWebhookSignature(req: Request): boolean;
|
||||
}
|
||||
|
||||
interface TelnyxSmsResult {
|
||||
message_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function createTelnyxClient(): Telnyx | null {
|
||||
const apiKey = process.env.TELNYX_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
return new Telnyx(apiKey);
|
||||
}
|
||||
|
||||
let _client: Telnyx | null | undefined;
|
||||
|
||||
function getClient(): Telnyx | null {
|
||||
if (_client === undefined) _client = createTelnyxClient();
|
||||
return _client;
|
||||
}
|
||||
|
||||
function getFromNumber(): string | null {
|
||||
return process.env.TELNYX_FROM_NUMBER ?? null;
|
||||
}
|
||||
|
||||
function isE164(phone: string): boolean {
|
||||
return /^\+[1-9]\d{7,14}$/.test(phone);
|
||||
}
|
||||
|
||||
export async function sendSms(
|
||||
to: string,
|
||||
body: string,
|
||||
mediaUrls?: string[]
|
||||
): Promise<{ messageId: string; status: string }> {
|
||||
const client = getClient();
|
||||
if (!client) throw new Error("Telnyx client not initialized. Set TELNYX_API_KEY.");
|
||||
|
||||
const from = getFromNumber();
|
||||
if (!from) throw new Error("TELNYX_FROM_NUMBER is not set");
|
||||
|
||||
if (!isE164(to)) throw new Error(`Invalid recipient phone format: ${to}. Expected E.164.`);
|
||||
if (!isE164(from)) throw new Error(`Invalid sender phone format: ${from}. Expected E.164.`);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
from,
|
||||
to,
|
||||
body,
|
||||
};
|
||||
|
||||
if (mediaUrls && mediaUrls.length > 0) {
|
||||
payload.media_urls = mediaUrls;
|
||||
}
|
||||
|
||||
const result = await client.messages.create(payload as Record<string, string | string[]>);
|
||||
const smsResult = result.data as unknown as TelnyxSmsResult;
|
||||
return {
|
||||
messageId: smsResult.message_id,
|
||||
status: smsResult.status,
|
||||
};
|
||||
}
|
||||
|
||||
export class TelnyxProvider implements SmsProvider {
|
||||
async sendSms(
|
||||
to: string,
|
||||
body: string,
|
||||
mediaUrls?: string[]
|
||||
): Promise<{ messageId: string; status: string }> {
|
||||
return sendSms(to, body, mediaUrls);
|
||||
}
|
||||
|
||||
validateWebhookSignature(req: Request): boolean {
|
||||
const secret = process.env.TELNYX_WEBHOOK_SECRET;
|
||||
if (!secret) return false;
|
||||
|
||||
const signature = req.headers.get("telnyx-signature");
|
||||
if (!signature) return false;
|
||||
|
||||
const payload = JSON.stringify(req.body);
|
||||
|
||||
try {
|
||||
const hmac = createHmac("sha256", secret);
|
||||
const expected = `sha256=${hmac.update(payload).digest("hex")}`;
|
||||
|
||||
const sigBuf = Buffer.from(signature);
|
||||
const expBuf = Buffer.from(expected);
|
||||
|
||||
if (sigBuf.length !== expBuf.length) return false;
|
||||
|
||||
let diff = 0;
|
||||
for (let i = 0; i < sigBuf.length; i++) {
|
||||
const sigByte = sigBuf[i] ?? 0;
|
||||
const expByte = expBuf[i] ?? 0;
|
||||
diff |= sigByte ^ expByte;
|
||||
}
|
||||
return diff === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _provider: SmsProvider | null | undefined;
|
||||
|
||||
export function createSmsProvider(): SmsProvider | null {
|
||||
if (_provider === undefined) {
|
||||
if (process.env.SMS_ENABLED !== "true") {
|
||||
_provider = null;
|
||||
return null;
|
||||
}
|
||||
switch (process.env.SMS_PROVIDER) {
|
||||
case "telnyx": {
|
||||
const client = getClient();
|
||||
if (!client) {
|
||||
_provider = null;
|
||||
return null;
|
||||
}
|
||||
_provider = new TelnyxProvider();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
_provider = null;
|
||||
}
|
||||
}
|
||||
return _provider;
|
||||
}
|
||||
|
||||
export async function smsSend(
|
||||
to: string,
|
||||
body: string,
|
||||
mediaUrls?: string[]
|
||||
): Promise<boolean> {
|
||||
const provider = createSmsProvider();
|
||||
if (!provider) return false;
|
||||
|
||||
await provider.sendSms(to, body, mediaUrls);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { and, eq, getDb, waitlistEntries, clients, pets, services } from "@groombook/db";
|
||||
import { buildWaitlistNotificationEmail, sendEmail } from "./email.js";
|
||||
|
||||
export async function notifyWaitlistForAppointment(
|
||||
appointmentId: string,
|
||||
appointmentDate: string,
|
||||
appointmentTime: string,
|
||||
serviceId: string
|
||||
): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
const matchingEntries = await db
|
||||
.select()
|
||||
.from(waitlistEntries)
|
||||
.where(
|
||||
and(
|
||||
eq(waitlistEntries.preferredDate, appointmentDate),
|
||||
eq(waitlistEntries.preferredTime, appointmentTime),
|
||||
eq(waitlistEntries.serviceId, serviceId),
|
||||
eq(waitlistEntries.status, "active")
|
||||
)
|
||||
);
|
||||
|
||||
for (const entry of matchingEntries) {
|
||||
const [client] = await db
|
||||
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, entry.clientId))
|
||||
.limit(1);
|
||||
|
||||
if (!client?.email || client.emailOptOut) continue;
|
||||
|
||||
const [pet] = await db
|
||||
.select({ name: pets.name })
|
||||
.from(pets)
|
||||
.where(eq(pets.id, entry.petId))
|
||||
.limit(1);
|
||||
|
||||
const [service] = await db
|
||||
.select({ name: services.name })
|
||||
.from(services)
|
||||
.where(eq(services.id, entry.serviceId))
|
||||
.limit(1);
|
||||
|
||||
if (!pet || !service) continue;
|
||||
|
||||
const email = buildWaitlistNotificationEmail(client.email, {
|
||||
clientName: client.name,
|
||||
petName: pet.name,
|
||||
serviceName: service.name,
|
||||
preferredDate: appointmentDate,
|
||||
preferredTime: appointmentTime,
|
||||
});
|
||||
|
||||
const sent = await sendEmail(email);
|
||||
if (sent) {
|
||||
await db
|
||||
.update(waitlistEntries)
|
||||
.set({ status: "notified", notifiedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(waitlistEntries.id, entry.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user