feat: appointment confirmation and cancellation (GH #98)
feat: appointment confirmation and cancellation (GH #98)
This commit was merged in pull request #104.
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 { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
eq,
|
eq,
|
||||||
@@ -521,3 +522,74 @@ appointmentsRouter.delete("/:id", async (c) => {
|
|||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
return c.json({ ok: true });
|
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);
|
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,33 @@ export function buildConfirmationEmail(
|
|||||||
export function buildReminderEmail(
|
export function buildReminderEmail(
|
||||||
to: string,
|
to: string,
|
||||||
data: AppointmentEmailData,
|
data: AppointmentEmailData,
|
||||||
hoursAhead: number
|
hoursAhead: number,
|
||||||
|
confirmationToken?: string | null
|
||||||
): Mail.Options {
|
): Mail.Options {
|
||||||
const time = formatDateTime(data.startTime);
|
const time = formatDateTime(data.startTime);
|
||||||
const groomer = data.groomerName ? ` with ${data.groomerName}` : "";
|
const groomer = data.groomerName ? ` with ${data.groomerName}` : "";
|
||||||
const when = hoursAhead >= 24 ? `tomorrow` : `in ${hoursAhead} hours`;
|
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 {
|
return {
|
||||||
to,
|
to,
|
||||||
subject: `Reminder: ${data.petName}'s appointment is ${when}`,
|
subject: `Reminder: ${data.petName}'s appointment is ${when}`,
|
||||||
@@ -109,7 +131,7 @@ export function buildReminderEmail(
|
|||||||
` Pet: ${data.petName}`,
|
` Pet: ${data.petName}`,
|
||||||
` Service: ${data.serviceName}`,
|
` Service: ${data.serviceName}`,
|
||||||
` When: ${time}${groomer}`,
|
` When: ${time}${groomer}`,
|
||||||
``,
|
actionText,
|
||||||
`See you soon!`,
|
`See you soon!`,
|
||||||
``,
|
``,
|
||||||
`— Groom Book`,
|
`— Groom Book`,
|
||||||
@@ -122,6 +144,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">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>
|
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">When</td><td>${time}${groomer}</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
${actionHtml}
|
||||||
<p>See you soon!</p>
|
<p>See you soon!</p>
|
||||||
<p>— Groom Book</p>`,
|
<p>— Groom Book</p>`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import cron from "node-cron";
|
import cron from "node-cron";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
eq,
|
eq,
|
||||||
@@ -51,6 +52,7 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
serviceId: appointments.serviceId,
|
serviceId: appointments.serviceId,
|
||||||
staffId: appointments.staffId,
|
staffId: appointments.staffId,
|
||||||
status: appointments.status,
|
status: appointments.status,
|
||||||
|
confirmationToken: appointments.confirmationToken,
|
||||||
})
|
})
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
.where(
|
.where(
|
||||||
@@ -109,6 +111,17 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
|
|
||||||
if (!pet || !service) continue;
|
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(
|
const sent = await sendEmail(
|
||||||
buildReminderEmail(
|
buildReminderEmail(
|
||||||
client.email,
|
client.email,
|
||||||
@@ -119,7 +132,8 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
groomerName,
|
groomerName,
|
||||||
startTime: appt.startTime,
|
startTime: appt.startTime,
|
||||||
},
|
},
|
||||||
window.hours
|
window.hours,
|
||||||
|
confirmationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { BookPage } from "./pages/Book.js";
|
|||||||
import { ReportsPage } from "./pages/Reports.js";
|
import { ReportsPage } from "./pages/Reports.js";
|
||||||
import { GroupBookingPage } from "./pages/GroupBooking.js";
|
import { GroupBookingPage } from "./pages/GroupBooking.js";
|
||||||
import { SettingsPage } from "./pages/Settings.js";
|
import { SettingsPage } from "./pages/Settings.js";
|
||||||
|
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
||||||
|
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
||||||
|
import { BookingErrorPage } from "./pages/BookingError.js";
|
||||||
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||||
@@ -151,6 +154,17 @@ export function App() {
|
|||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public booking redirect pages — no auth or portal chrome needed
|
||||||
|
if (location.pathname === "/booking/confirmed") {
|
||||||
|
return <BookingConfirmedPage />;
|
||||||
|
}
|
||||||
|
if (location.pathname === "/booking/cancelled") {
|
||||||
|
return <BookingCancelledPage />;
|
||||||
|
}
|
||||||
|
if (location.pathname === "/booking/error") {
|
||||||
|
return <BookingErrorPage />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrandingProvider>
|
<BrandingProvider>
|
||||||
{location.pathname.startsWith("/admin") ? (
|
{location.pathname.startsWith("/admin") ? (
|
||||||
|
|||||||
@@ -431,6 +431,12 @@ export function AppointmentsPage() {
|
|||||||
{a.seriesId && (
|
{a.seriesId && (
|
||||||
<div style={{ opacity: 0.85, fontSize: 10 }}>↻ recurring</div>
|
<div style={{ opacity: 0.85, fontSize: 10 }}>↻ recurring</div>
|
||||||
)}
|
)}
|
||||||
|
{a.confirmationStatus === "confirmed" && (
|
||||||
|
<div style={{ opacity: 0.95, fontSize: 10 }}>✓ confirmed</div>
|
||||||
|
)}
|
||||||
|
{a.confirmationStatus === "cancelled" && (
|
||||||
|
<div style={{ opacity: 0.95, fontSize: 10, textDecoration: "line-through" }}>✗ cust. cancelled</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -695,6 +701,11 @@ function AppointmentDetail({
|
|||||||
["Start", new Date(appt.startTime).toLocaleString()],
|
["Start", new Date(appt.startTime).toLocaleString()],
|
||||||
["End", new Date(appt.endTime).toLocaleString()],
|
["End", new Date(appt.endTime).toLocaleString()],
|
||||||
["Status", appt.status.replace("_", " ")],
|
["Status", appt.status.replace("_", " ")],
|
||||||
|
["Confirmation", appt.confirmationStatus === "confirmed"
|
||||||
|
? `✓ Confirmed${appt.confirmedAt ? ` (${new Date(appt.confirmedAt).toLocaleString()})` : ""}`
|
||||||
|
: appt.confirmationStatus === "cancelled"
|
||||||
|
? `✗ Customer cancelled${appt.cancelledAt ? ` (${new Date(appt.cancelledAt).toLocaleString()})` : ""}`
|
||||||
|
: "Pending"],
|
||||||
["Notes", appt.notes ?? "—"],
|
["Notes", appt.notes ?? "—"],
|
||||||
...(appt.seriesId
|
...(appt.seriesId
|
||||||
? [["Series slot", `#${(appt.seriesIndex ?? 0) + 1}`] as [string, string]]
|
? [["Series slot", `#${(appt.seriesIndex ?? 0) + 1}`] as [string, string]]
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export function BookingCancelledPage() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
background: "#fff7ed",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "2.5rem 3rem",
|
||||||
|
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: 420,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}>✗</div>
|
||||||
|
<h1 style={{ color: "#c2410c", fontSize: 24, margin: "0 0 0.5rem" }}>
|
||||||
|
Appointment Cancelled
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}>
|
||||||
|
Your appointment has been cancelled. If this was a mistake or you'd
|
||||||
|
like to rebook, please contact us.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "0.6rem 1.5rem",
|
||||||
|
background: "#ea580c",
|
||||||
|
color: "#fff",
|
||||||
|
borderRadius: 6,
|
||||||
|
textDecoration: "none",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to Portal
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export function BookingConfirmedPage() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
background: "#f0fdf4",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "2.5rem 3rem",
|
||||||
|
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: 420,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}>✓</div>
|
||||||
|
<h1 style={{ color: "#15803d", fontSize: 24, margin: "0 0 0.5rem" }}>
|
||||||
|
Appointment Confirmed
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}>
|
||||||
|
Thank you! Your appointment is confirmed. We look forward to seeing you
|
||||||
|
and your furry friend.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "0.6rem 1.5rem",
|
||||||
|
background: "#16a34a",
|
||||||
|
color: "#fff",
|
||||||
|
borderRadius: 6,
|
||||||
|
textDecoration: "none",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to Portal
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export function BookingErrorPage() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
background: "#fef2f2",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "2.5rem 3rem",
|
||||||
|
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: 420,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}>⚠️</div>
|
||||||
|
<h1 style={{ color: "#b91c1c", fontSize: 24, margin: "0 0 0.5rem" }}>
|
||||||
|
Link Invalid or Expired
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}>
|
||||||
|
This confirmation link is invalid, has already been used, or your
|
||||||
|
appointment has already passed. Please contact us if you need help.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "0.6rem 1.5rem",
|
||||||
|
background: "#dc2626",
|
||||||
|
color: "#fff",
|
||||||
|
borderRadius: 6,
|
||||||
|
textDecoration: "none",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to Portal
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE appointments
|
||||||
|
ADD COLUMN confirmation_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
ADD COLUMN confirmed_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN cancelled_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN confirmation_token TEXT UNIQUE;
|
||||||
|
|
||||||
|
CREATE INDEX idx_appointments_confirmation_token ON appointments (confirmation_token) WHERE confirmation_token IS NOT NULL;
|
||||||
@@ -136,6 +136,10 @@ export function buildAppointment(
|
|||||||
endTime,
|
endTime,
|
||||||
notes: null,
|
notes: null,
|
||||||
priceCents: null,
|
priceCents: null,
|
||||||
|
confirmationStatus: "pending",
|
||||||
|
confirmedAt: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
confirmationToken: null,
|
||||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -162,6 +162,13 @@ export const appointments = pgTable("appointments", {
|
|||||||
groupId: uuid("group_id").references(() => appointmentGroups.id, {
|
groupId: uuid("group_id").references(() => appointmentGroups.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
|
// Customer confirmation/cancellation tracking
|
||||||
|
// Values: "pending" | "confirmed" | "cancelled"
|
||||||
|
confirmationStatus: text("confirmation_status").notNull().default("pending"),
|
||||||
|
confirmedAt: timestamp("confirmed_at"),
|
||||||
|
cancelledAt: timestamp("cancelled_at"),
|
||||||
|
// Token for tokenized email confirm/cancel links (no auth required)
|
||||||
|
confirmationToken: text("confirmation_token").unique(),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export type AppointmentStatus =
|
|||||||
| "cancelled"
|
| "cancelled"
|
||||||
| "no_show";
|
| "no_show";
|
||||||
|
|
||||||
|
export type ConfirmationStatus = "pending" | "confirmed" | "cancelled";
|
||||||
|
|
||||||
export type ClientStatus = "active" | "disabled";
|
export type ClientStatus = "active" | "disabled";
|
||||||
|
|
||||||
export interface Client {
|
export interface Client {
|
||||||
@@ -104,6 +106,10 @@ export interface Appointment {
|
|||||||
seriesId: string | null;
|
seriesId: string | null;
|
||||||
seriesIndex: number | null;
|
seriesIndex: number | null;
|
||||||
groupId: string | null;
|
groupId: string | null;
|
||||||
|
confirmationStatus: ConfirmationStatus;
|
||||||
|
confirmedAt: string | null;
|
||||||
|
cancelledAt: string | null;
|
||||||
|
confirmationToken: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user