feat: automated appointment reminders via email #29
@@ -14,3 +14,17 @@ OIDC_AUDIENCE=groombook
|
|||||||
# ── API ───────────────────────────────────────────────────────────────────────
|
# ── API ───────────────────────────────────────────────────────────────────────
|
||||||
PORT=3000
|
PORT=3000
|
||||||
CORS_ORIGIN=http://localhost:8080
|
CORS_ORIGIN=http://localhost:8080
|
||||||
|
|
||||||
|
# ── Email Reminders (optional) ────────────────────────────────────────────────
|
||||||
|
# Leave SMTP_HOST unset to disable email notifications entirely.
|
||||||
|
# When configured, appointment confirmation and reminder emails are sent via SMTP.
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=user@example.com
|
||||||
|
SMTP_PASS=password
|
||||||
|
SMTP_FROM="Groom Book <noreply@example.com>"
|
||||||
|
|
||||||
|
# Hours before appointment to send reminder emails (defaults: 24 and 2)
|
||||||
|
REMINDER_HOURS_EARLY=24
|
||||||
|
REMINDER_HOURS_LATE=2
|
||||||
|
|||||||
@@ -18,11 +18,15 @@
|
|||||||
"@hono/zod-validator": "^0.4.3",
|
"@hono/zod-validator": "^0.4.3",
|
||||||
"hono": "^4.6.17",
|
"hono": "^4.6.17",
|
||||||
"jose": "^5.9.6",
|
"jose": "^5.9.6",
|
||||||
|
"node-cron": "^3.0.3",
|
||||||
|
"nodemailer": "^6.9.16",
|
||||||
"openid-client": "^6.1.7",
|
"openid-client": "^6.1.7",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { staffRouter } from "./routes/staff.js";
|
|||||||
import { invoicesRouter } from "./routes/invoices.js";
|
import { invoicesRouter } from "./routes/invoices.js";
|
||||||
import { bookRouter } from "./routes/book.js";
|
import { bookRouter } from "./routes/book.js";
|
||||||
import { authMiddleware } from "./middleware/auth.js";
|
import { authMiddleware } from "./middleware/auth.js";
|
||||||
|
import { startReminderScheduler } from "./services/reminders.js";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -44,4 +45,7 @@ const port = Number(process.env.PORT ?? 3000);
|
|||||||
console.log(`API server listening on port ${port}`);
|
console.log(`API server listening on port ${port}`);
|
||||||
serve({ fetch: app.fetch, port });
|
serve({ fetch: app.fetch, port });
|
||||||
|
|
||||||
|
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
||||||
|
startReminderScheduler();
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -10,8 +10,14 @@ import {
|
|||||||
lte,
|
lte,
|
||||||
ne,
|
ne,
|
||||||
appointments,
|
appointments,
|
||||||
|
clients,
|
||||||
|
pets,
|
||||||
recurringSeries,
|
recurringSeries,
|
||||||
|
reminderLogs,
|
||||||
|
services,
|
||||||
|
staff,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
|
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||||
|
|
||||||
export const appointmentsRouter = new Hono();
|
export const appointmentsRouter = new Hono();
|
||||||
|
|
||||||
@@ -189,10 +195,71 @@ appointmentsRouter.post(
|
|||||||
throw err;
|
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);
|
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(
|
appointmentsRouter.patch(
|
||||||
"/:id",
|
"/:id",
|
||||||
zValidator("json", updateAppointmentSchema),
|
zValidator("json", updateAppointmentSchema),
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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
|
||||||
|
): Mail.Options {
|
||||||
|
const time = formatDateTime(data.startTime);
|
||||||
|
const groomer = data.groomerName ? ` with ${data.groomerName}` : "";
|
||||||
|
const when = hoursAhead >= 24 ? `tomorrow` : `in ${hoursAhead} hours`;
|
||||||
|
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}`,
|
||||||
|
``,
|
||||||
|
`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>
|
||||||
|
<p>See you soon!</p>
|
||||||
|
<p>— Groom Book</p>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import cron from "node-cron";
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
eq,
|
||||||
|
getDb,
|
||||||
|
gte,
|
||||||
|
lt,
|
||||||
|
appointments,
|
||||||
|
clients,
|
||||||
|
pets,
|
||||||
|
services,
|
||||||
|
staff,
|
||||||
|
reminderLogs,
|
||||||
|
} from "@groombook/db";
|
||||||
|
import {
|
||||||
|
buildReminderEmail,
|
||||||
|
sendEmail,
|
||||||
|
} from "./email.js";
|
||||||
|
|
||||||
|
// How many hours before the appointment to send each reminder.
|
||||||
|
// Override via env: REMINDER_HOURS_EARLY (default 24) and REMINDER_HOURS_LATE (default 2).
|
||||||
|
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 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks for upcoming appointments that need reminders and sends them.
|
||||||
|
// Runs every minute — idempotent via reminder_logs unique constraint.
|
||||||
|
export async function runReminderCheck(): Promise<void> {
|
||||||
|
const db = getDb();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const window of getReminderWindows()) {
|
||||||
|
// Target window: appointments starting between (hours - 1) and hours from now.
|
||||||
|
// Running every minute means we check a 1-minute slice; the 1-hour window
|
||||||
|
// ensures we catch appointments that started between heartbeats.
|
||||||
|
const windowStart = new Date(now.getTime() + (window.hours - 1) * 3600_000);
|
||||||
|
const windowEnd = new Date(now.getTime() + window.hours * 3600_000);
|
||||||
|
|
||||||
|
// Find upcoming appointments in this time window that haven't been cancelled/completed
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
gte(appointments.startTime, windowStart),
|
||||||
|
lt(appointments.startTime, windowEnd),
|
||||||
|
eq(appointments.status, "scheduled")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const appt of upcoming) {
|
||||||
|
// Check if reminder already sent (unique constraint prevents double-send)
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: reminderLogs.id })
|
||||||
|
.from(reminderLogs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(reminderLogs.appointmentId, appt.id),
|
||||||
|
eq(reminderLogs.reminderType, window.label)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) continue; // already sent
|
||||||
|
|
||||||
|
// Fetch related records for the email
|
||||||
|
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) continue;
|
||||||
|
|
||||||
|
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) continue;
|
||||||
|
|
||||||
|
const sent = await sendEmail(
|
||||||
|
buildReminderEmail(
|
||||||
|
client.email,
|
||||||
|
{
|
||||||
|
clientName: client.name,
|
||||||
|
petName: pet.name,
|
||||||
|
serviceName: service.name,
|
||||||
|
groomerName,
|
||||||
|
startTime: appt.startTime,
|
||||||
|
},
|
||||||
|
window.hours
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
// Record send — ignore conflicts (race condition between instances)
|
||||||
|
await db
|
||||||
|
.insert(reminderLogs)
|
||||||
|
.values({ appointmentId: appt.id, reminderType: window.label })
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starts the cron scheduler. Call once at server startup.
|
||||||
|
export function startReminderScheduler(): void {
|
||||||
|
// Run every minute
|
||||||
|
cron.schedule("* * * * *", () => {
|
||||||
|
runReminderCheck().catch((err) => {
|
||||||
|
console.error("[reminders] Error during reminder check:", err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
console.log("[reminders] Reminder scheduler started");
|
||||||
|
}
|
||||||
@@ -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,
|
"when": 1742169600000,
|
||||||
"tag": "0003_recurring_series",
|
"tag": "0003_recurring_series",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773779939000,
|
||||||
|
"tag": "0004_reminder_logs",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
pgTable,
|
pgTable,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
unique,
|
||||||
uuid,
|
uuid,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
@@ -49,6 +50,8 @@ export const clients = pgTable("clients", {
|
|||||||
phone: text("phone"),
|
phone: text("phone"),
|
||||||
address: text("address"),
|
address: text("address"),
|
||||||
notes: text("notes"),
|
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(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_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(),
|
totalCents: integer("total_cents").notNull(),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
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)]
|
||||||
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface Client {
|
|||||||
phone: string | null;
|
phone: string | null;
|
||||||
address: string | null;
|
address: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
emailOptOut: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+44
@@ -28,6 +28,12 @@ importers:
|
|||||||
jose:
|
jose:
|
||||||
specifier: ^5.9.6
|
specifier: ^5.9.6
|
||||||
version: 5.10.0
|
version: 5.10.0
|
||||||
|
node-cron:
|
||||||
|
specifier: ^3.0.3
|
||||||
|
version: 3.0.3
|
||||||
|
nodemailer:
|
||||||
|
specifier: ^6.9.16
|
||||||
|
version: 6.10.1
|
||||||
openid-client:
|
openid-client:
|
||||||
specifier: ^6.1.7
|
specifier: ^6.1.7
|
||||||
version: 6.8.2
|
version: 6.8.2
|
||||||
@@ -38,6 +44,12 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.10.7
|
specifier: ^22.10.7
|
||||||
version: 22.19.15
|
version: 22.19.15
|
||||||
|
'@types/node-cron':
|
||||||
|
specifier: ^3.0.11
|
||||||
|
version: 3.0.11
|
||||||
|
'@types/nodemailer':
|
||||||
|
specifier: ^6.4.17
|
||||||
|
version: 6.4.23
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9.18.0
|
specifier: ^9.18.0
|
||||||
version: 9.39.4
|
version: 9.39.4
|
||||||
@@ -1529,9 +1541,15 @@ packages:
|
|||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/node-cron@3.0.11':
|
||||||
|
resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
|
||||||
|
|
||||||
'@types/node@22.19.15':
|
'@types/node@22.19.15':
|
||||||
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
|
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
|
||||||
|
|
||||||
|
'@types/nodemailer@6.4.23':
|
||||||
|
resolution: {integrity: sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==}
|
||||||
|
|
||||||
'@types/react-dom@19.2.3':
|
'@types/react-dom@19.2.3':
|
||||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2506,9 +2524,17 @@ packages:
|
|||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||||
|
|
||||||
|
node-cron@3.0.3:
|
||||||
|
resolution: {integrity: sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
node-releases@2.0.36:
|
node-releases@2.0.36:
|
||||||
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
|
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
|
||||||
|
|
||||||
|
nodemailer@6.10.1:
|
||||||
|
resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
oauth4webapi@3.8.5:
|
oauth4webapi@3.8.5:
|
||||||
resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==}
|
resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==}
|
||||||
|
|
||||||
@@ -2978,6 +3004,10 @@ packages:
|
|||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||||
|
|
||||||
|
uuid@8.3.2:
|
||||||
|
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
vite-node@3.2.4:
|
vite-node@3.2.4:
|
||||||
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
|
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
|
||||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||||
@@ -4401,10 +4431,16 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
|
'@types/node-cron@3.0.11': {}
|
||||||
|
|
||||||
'@types/node@22.19.15':
|
'@types/node@22.19.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
|
'@types/nodemailer@6.4.23':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.19.15
|
||||||
|
|
||||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
@@ -5485,8 +5521,14 @@ snapshots:
|
|||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
|
node-cron@3.0.3:
|
||||||
|
dependencies:
|
||||||
|
uuid: 8.3.2
|
||||||
|
|
||||||
node-releases@2.0.36: {}
|
node-releases@2.0.36: {}
|
||||||
|
|
||||||
|
nodemailer@6.10.1: {}
|
||||||
|
|
||||||
oauth4webapi@3.8.5: {}
|
oauth4webapi@3.8.5: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
@@ -6006,6 +6048,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
|
uuid@8.3.2: {}
|
||||||
|
|
||||||
vite-node@3.2.4(@types/node@22.19.15)(terser@5.46.1)(tsx@4.21.0):
|
vite-node@3.2.4(@types/node@22.19.15)(terser@5.46.1)(tsx@4.21.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
|
|||||||
Reference in New Issue
Block a user