feat: appointment confirmation and cancellation (GH #98) #104

Merged
groombook-engineer[bot] merged 2 commits from feat/appointment-confirmation-gh98 into main 2026-03-24 21:15:07 +00:00
14 changed files with 735 additions and 3 deletions
+339
View File
@@ -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");
});
});
+72
View File
@@ -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");
}
+88
View File
@@ -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`);
});
+25 -2
View File
@@ -93,11 +93,33 @@ 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 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 +131,7 @@ export function buildReminderEmail(
` Pet: ${data.petName}`,
` Service: ${data.serviceName}`,
` When: ${time}${groomer}`,
``,
actionText,
`See you soon!`,
``,
`— 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">When</td><td>${time}${groomer}</td></tr>
</table>
${actionHtml}
<p>See you soon!</p>
<p>— Groom Book</p>`,
};
+15 -1
View File
@@ -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
)
);
+14
View File
@@ -9,6 +9,9 @@ import { BookPage } from "./pages/Book.js";
import { ReportsPage } from "./pages/Reports.js";
import { GroupBookingPage } from "./pages/GroupBooking.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 { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
@@ -151,6 +154,17 @@ export function App() {
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 (
<BrandingProvider>
{location.pathname.startsWith("/admin") ? (
+11
View File
@@ -431,6 +431,12 @@ export function AppointmentsPage() {
{a.seriesId && (
<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>
);
})}
@@ -695,6 +701,11 @@ function AppointmentDetail({
["Start", new Date(appt.startTime).toLocaleString()],
["End", new Date(appt.endTime).toLocaleString()],
["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 ?? "—"],
...(appt.seriesId
? [["Series slot", `#${(appt.seriesIndex ?? 0) + 1}`] as [string, string]]
+49
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+49
View File
@@ -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;
+4
View File
@@ -136,6 +136,10 @@ export function buildAppointment(
endTime,
notes: null,
priceCents: null,
confirmationStatus: "pending",
confirmedAt: null,
cancelledAt: null,
confirmationToken: null,
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-01T00:00:00Z"),
};
+7
View File
@@ -162,6 +162,13 @@ export const appointments = pgTable("appointments", {
groupId: uuid("group_id").references(() => appointmentGroups.id, {
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(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
+6
View File
@@ -8,6 +8,8 @@ export type AppointmentStatus =
| "cancelled"
| "no_show";
export type ConfirmationStatus = "pending" | "confirmed" | "cancelled";
export type ClientStatus = "active" | "disabled";
export interface Client {
@@ -104,6 +106,10 @@ export interface Appointment {
seriesId: string | null;
seriesIndex: number | null;
groupId: string | null;
confirmationStatus: ConfirmationStatus;
confirmedAt: string | null;
cancelledAt: string | null;
confirmationToken: string | null;
createdAt: string;
updatedAt: string;
}