feat: appointment confirmation and cancellation (GH #98, GRO-153)
Add customer confirmation/cancellation flow for appointments: - DB migration (0013): add confirmation_status, confirmed_at, cancelled_at, confirmation_token to appointments table with index on token column - schema.ts + factories.ts + types: expose new columns and ConfirmationStatus type - GET /api/book/confirm/:token — tokenized confirm via email link (redirects) - GET /api/book/cancel/:token — tokenized cancel via email link, single-use token - POST /api/appointments/:id/confirm — portal/staff confirm endpoint - POST /api/appointments/:id/cancel — portal/staff cancel endpoint - Reminder emails now include Confirm/Cancel CTA buttons with tokenized links - Reminder service generates confirmation token if missing before sending - Staff calendar shows confirmation status indicator on appointment cards and in the detail modal (confirmed ✓ / customer cancelled ✗) - /booking/confirmed, /booking/cancelled, /booking/error redirect pages - 23 new unit tests covering all new endpoints and edge cases Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
// ─── Mock appointment data ────────────────────────────────────────────────────
|
||||
|
||||
const FUTURE_TIME = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 1 week from now
|
||||
const PAST_TIME = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||
|
||||
const BASE_APPT = {
|
||||
id: "appt-uuid-1",
|
||||
clientId: "client-uuid-1",
|
||||
petId: "pet-uuid-1",
|
||||
serviceId: "service-uuid-1",
|
||||
staffId: "staff-uuid-1",
|
||||
batherStaffId: null,
|
||||
status: "scheduled" as const,
|
||||
startTime: FUTURE_TIME,
|
||||
endTime: new Date(FUTURE_TIME.getTime() + 3600_000),
|
||||
notes: null,
|
||||
priceCents: null,
|
||||
seriesId: null,
|
||||
seriesIndex: null,
|
||||
groupId: null,
|
||||
confirmationStatus: "pending",
|
||||
confirmedAt: null,
|
||||
cancelledAt: null,
|
||||
confirmationToken: "valid-token-abc123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// ─── Shared mock DB state ─────────────────────────────────────────────────────
|
||||
|
||||
let mockAppt: typeof BASE_APPT | null = BASE_APPT;
|
||||
let lastUpdate: Record<string, unknown> = {};
|
||||
|
||||
function resetMock() {
|
||||
mockAppt = { ...BASE_APPT };
|
||||
lastUpdate = {};
|
||||
}
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
const appointments = new Proxy(
|
||||
{ _name: "appointments" },
|
||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => (mockAppt ? [mockAppt] : []),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
set: (vals: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
lastUpdate = { ...vals };
|
||||
if (mockAppt) {
|
||||
mockAppt = { ...mockAppt, ...vals } as typeof BASE_APPT;
|
||||
}
|
||||
return { returning: () => (mockAppt ? [mockAppt] : []) };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
appointments,
|
||||
eq: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Book router (tokenized endpoints) ───────────────────────────────────────
|
||||
|
||||
async function makeBookApp() {
|
||||
const { bookRouter } = await import("../routes/book.js");
|
||||
const app = new Hono();
|
||||
app.route("/api/book", bookRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Appointments router (portal endpoints) ────────────────────────────────
|
||||
|
||||
async function makeAppointmentsApp() {
|
||||
const { appointmentsRouter } = await import("../routes/appointments.js");
|
||||
const app = new Hono();
|
||||
app.route("/api/appointments", appointmentsRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Tests: tokenized confirm endpoint ────────────────────────────────────────
|
||||
|
||||
describe("GET /api/book/confirm/:token", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
resetMock();
|
||||
app = await makeBookApp();
|
||||
});
|
||||
|
||||
it("redirects to /booking/confirmed on valid token and future appointment", async () => {
|
||||
const res = await app.request("/api/book/confirm/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/confirmed");
|
||||
});
|
||||
|
||||
it("sets confirmationStatus to confirmed", async () => {
|
||||
await app.request("/api/book/confirm/valid-token-abc123");
|
||||
expect(lastUpdate.confirmationStatus).toBe("confirmed");
|
||||
expect(lastUpdate.confirmedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("redirects to /booking/error when token not found", async () => {
|
||||
mockAppt = null;
|
||||
const res = await app.request("/api/book/confirm/bad-token");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/error");
|
||||
});
|
||||
|
||||
it("redirects to /booking/error when appointment is in the past", async () => {
|
||||
mockAppt = { ...BASE_APPT, startTime: PAST_TIME };
|
||||
const res = await app.request("/api/book/confirm/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/error");
|
||||
});
|
||||
|
||||
it("redirects to /booking/confirmed idempotently when already confirmed", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" };
|
||||
const res = await app.request("/api/book/confirm/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/confirmed");
|
||||
});
|
||||
|
||||
it("redirects to /booking/error when appointment is already customer-cancelled", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
|
||||
const res = await app.request("/api/book/confirm/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/error");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests: tokenized cancel endpoint ────────────────────────────────────────
|
||||
|
||||
describe("GET /api/book/cancel/:token", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
resetMock();
|
||||
app = await makeBookApp();
|
||||
});
|
||||
|
||||
it("redirects to /booking/cancelled on valid token and future appointment", async () => {
|
||||
const res = await app.request("/api/book/cancel/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/cancelled");
|
||||
});
|
||||
|
||||
it("sets confirmationStatus to cancelled and nullifies token (single-use)", async () => {
|
||||
await app.request("/api/book/cancel/valid-token-abc123");
|
||||
expect(lastUpdate.confirmationStatus).toBe("cancelled");
|
||||
expect(lastUpdate.cancelledAt).toBeInstanceOf(Date);
|
||||
expect(lastUpdate.confirmationToken).toBeNull();
|
||||
});
|
||||
|
||||
it("redirects to /booking/error when token not found", async () => {
|
||||
mockAppt = null;
|
||||
const res = await app.request("/api/book/cancel/bad-token");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/error");
|
||||
});
|
||||
|
||||
it("redirects to /booking/error when appointment is in the past", async () => {
|
||||
mockAppt = { ...BASE_APPT, startTime: PAST_TIME };
|
||||
const res = await app.request("/api/book/cancel/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/error");
|
||||
});
|
||||
|
||||
it("redirects to /booking/error when already customer-cancelled", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
|
||||
const res = await app.request("/api/book/cancel/valid-token-abc123");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("/booking/error");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests: portal confirm endpoint ──────────────────────────────────────────
|
||||
|
||||
describe("POST /api/appointments/:id/confirm", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
resetMock();
|
||||
app = await makeAppointmentsApp();
|
||||
});
|
||||
|
||||
it("confirms a pending appointment", async () => {
|
||||
const res = await app.request("/api/appointments/appt-uuid-1/confirm", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(lastUpdate.confirmationStatus).toBe("confirmed");
|
||||
expect(lastUpdate.confirmedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("returns 404 when appointment not found", async () => {
|
||||
mockAppt = null;
|
||||
const res = await app.request("/api/appointments/nonexistent/confirm", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 409 when appointment is already customer-cancelled", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
|
||||
const res = await app.request("/api/appointments/appt-uuid-1/confirm", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("returns 200 idempotently when appointment is already confirmed", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" };
|
||||
const res = await app.request("/api/appointments/appt-uuid-1/confirm", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests: portal cancel endpoint ───────────────────────────────────────────
|
||||
|
||||
describe("POST /api/appointments/:id/cancel", () => {
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
resetMock();
|
||||
app = await makeAppointmentsApp();
|
||||
});
|
||||
|
||||
it("cancels a pending appointment and nullifies the token", async () => {
|
||||
const res = await app.request("/api/appointments/appt-uuid-1/cancel", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(lastUpdate.confirmationStatus).toBe("cancelled");
|
||||
expect(lastUpdate.cancelledAt).toBeInstanceOf(Date);
|
||||
expect(lastUpdate.confirmationToken).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 404 when appointment not found", async () => {
|
||||
mockAppt = null;
|
||||
const res = await app.request("/api/appointments/nonexistent/cancel", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 409 when appointment is already customer-cancelled", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" };
|
||||
const res = await app.request("/api/appointments/appt-uuid-1/cancel", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("can cancel a confirmed appointment", async () => {
|
||||
mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" };
|
||||
const res = await app.request("/api/appointments/appt-uuid-1/cancel", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(lastUpdate.confirmationStatus).toBe("cancelled");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests: token generation helper ──────────────────────────────────────────
|
||||
|
||||
describe("generateConfirmationToken", () => {
|
||||
it("generates a 64-character hex string", async () => {
|
||||
const { generateConfirmationToken } = await import("../routes/appointments.js");
|
||||
const token = generateConfirmationToken();
|
||||
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("generates unique tokens on each call", async () => {
|
||||
const { generateConfirmationToken } = await import("../routes/appointments.js");
|
||||
const t1 = generateConfirmationToken();
|
||||
const t2 = generateConfirmationToken();
|
||||
expect(t1).not.toBe(t2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests: reminder email with action links ──────────────────────────────────
|
||||
|
||||
describe("buildReminderEmail with confirmation token", () => {
|
||||
it("includes confirm and cancel links when token is provided", async () => {
|
||||
const { buildReminderEmail } = await import("../services/email.js");
|
||||
const mail = buildReminderEmail(
|
||||
"client@example.com",
|
||||
{
|
||||
clientName: "Jane",
|
||||
petName: "Biscuit",
|
||||
serviceName: "Full Groom",
|
||||
groomerName: null,
|
||||
startTime: new Date(),
|
||||
},
|
||||
24,
|
||||
"abc123token"
|
||||
);
|
||||
expect(mail.text).toContain("abc123token");
|
||||
expect(mail.html as string).toContain("abc123token");
|
||||
expect(mail.html as string).toContain("Confirm Appointment");
|
||||
expect(mail.html as string).toContain("Cancel Appointment");
|
||||
});
|
||||
|
||||
it("omits action links when no token is provided", async () => {
|
||||
const { buildReminderEmail } = await import("../services/email.js");
|
||||
const mail = buildReminderEmail(
|
||||
"client@example.com",
|
||||
{
|
||||
clientName: "Jane",
|
||||
petName: "Biscuit",
|
||||
serviceName: "Full Groom",
|
||||
groomerName: null,
|
||||
startTime: new Date(),
|
||||
},
|
||||
24,
|
||||
null
|
||||
);
|
||||
expect(mail.html as string).not.toContain("Confirm Appointment");
|
||||
expect(mail.html as string).not.toContain("Cancel Appointment");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
@@ -521,3 +522,74 @@ appointmentsRouter.delete("/:id", async (c) => {
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── POST /api/appointments/:id/confirm ───────────────────────────────────────
|
||||
// Staff/portal: confirm a specific appointment by ID. Idempotent.
|
||||
|
||||
appointmentsRouter.post("/:id/confirm", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.json({ error: "Cannot confirm a cancelled appointment" }, 409);
|
||||
}
|
||||
|
||||
if (appt.confirmationStatus === "confirmed") {
|
||||
return c.json(appt); // idempotent
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointments)
|
||||
.set({ confirmationStatus: "confirmed", confirmedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// ─── POST /api/appointments/:id/cancel ───────────────────────────────────────
|
||||
// Staff/portal: cancel confirmation for a specific appointment by ID. Single-use token nullified.
|
||||
|
||||
appointmentsRouter.post("/:id/cancel", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.json({ error: "Appointment is already cancelled" }, 409);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "cancelled",
|
||||
cancelledAt: new Date(),
|
||||
confirmationToken: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(appointments.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// ─── Token generation helper ──────────────────────────────────────────────────
|
||||
|
||||
export function generateConfirmationToken(): string {
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
@@ -245,3 +245,91 @@ bookRouter.post(
|
||||
return c.json({ appointment, client, pet }, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── GET /api/book/confirm/:token ──────────────────────────────────────────
|
||||
// Public: confirm appointment via tokenized email link. Redirects to success/error page.
|
||||
|
||||
const BASE_URL = () => process.env.APP_URL ?? "http://localhost:5173";
|
||||
|
||||
bookRouter.get("/confirm/:token", async (c) => {
|
||||
const token = c.req.param("token");
|
||||
const db = getDb();
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.confirmationToken, token))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Reject if appointment is in the past
|
||||
if (appt.startTime < new Date()) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Idempotent confirm: if already confirmed, redirect to success
|
||||
if (appt.confirmationStatus === "confirmed") {
|
||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||
}
|
||||
|
||||
// Reject if already cancelled
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "confirmed",
|
||||
confirmedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(appointments.id, appt.id));
|
||||
|
||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||
});
|
||||
|
||||
// ─── GET /api/book/cancel/:token ───────────────────────────────────────────
|
||||
// Public: cancel appointment via tokenized email link. Redirects to success/error page.
|
||||
|
||||
bookRouter.get("/cancel/:token", async (c) => {
|
||||
const token = c.req.param("token");
|
||||
const db = getDb();
|
||||
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.confirmationToken, token))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Reject if appointment is in the past
|
||||
if (appt.startTime < new Date()) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Reject if already cancelled (token was nullified — this path won't normally hit,
|
||||
// but guard against edge cases where token lookup still works)
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Single-use cancellation: nullify token after use
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "cancelled",
|
||||
cancelledAt: new Date(),
|
||||
confirmationToken: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(appointments.id, appt.id));
|
||||
|
||||
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
||||
});
|
||||
|
||||
@@ -93,11 +93,34 @@ export function buildConfirmationEmail(
|
||||
export function buildReminderEmail(
|
||||
to: string,
|
||||
data: AppointmentEmailData,
|
||||
hoursAhead: number
|
||||
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 baseUrl = process.env.APP_URL ?? "http://localhost:5173";
|
||||
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}`,
|
||||
@@ -109,7 +132,7 @@ export function buildReminderEmail(
|
||||
` Pet: ${data.petName}`,
|
||||
` Service: ${data.serviceName}`,
|
||||
` When: ${time}${groomer}`,
|
||||
``,
|
||||
actionText,
|
||||
`See you soon!`,
|
||||
``,
|
||||
`— Groom Book`,
|
||||
@@ -122,6 +145,7 @@ export function buildReminderEmail(
|
||||
<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>`,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import cron from "node-cron";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
@@ -51,6 +52,7 @@ export async function runReminderCheck(): Promise<void> {
|
||||
serviceId: appointments.serviceId,
|
||||
staffId: appointments.staffId,
|
||||
status: appointments.status,
|
||||
confirmationToken: appointments.confirmationToken,
|
||||
})
|
||||
.from(appointments)
|
||||
.where(
|
||||
@@ -109,6 +111,17 @@ export async function runReminderCheck(): Promise<void> {
|
||||
|
||||
if (!pet || !service) continue;
|
||||
|
||||
// Ensure the appointment has a confirmation token before sending the reminder.
|
||||
// Generate one if it doesn't have one yet (e.g. pre-existing appointments).
|
||||
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));
|
||||
}
|
||||
|
||||
const sent = await sendEmail(
|
||||
buildReminderEmail(
|
||||
client.email,
|
||||
@@ -119,7 +132,8 @@ export async function runReminderCheck(): Promise<void> {
|
||||
groomerName,
|
||||
startTime: appt.startTime,
|
||||
},
|
||||
window.hours
|
||||
window.hours,
|
||||
confirmationToken
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user