customer account/bookings + cancellation email

This commit is contained in:
2026-05-02 09:07:19 -04:00
parent 4c70fe2f39
commit dbca91e06b
9 changed files with 575 additions and 18 deletions

View File

@@ -3,6 +3,7 @@ import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
import {
BookingConflictError,
cancelBooking,
confirmHold,
createHold,
expireStaleHolds,
@@ -174,6 +175,108 @@ describe("confirmHold", () => {
});
});
describe("cancelBooking", () => {
async function makeBooking(status: "HOLD" | "CONFIRMED" | "CANCELLED" | "COMPLETED" | "NO_SHOW" = "CONFIRMED") {
const hold = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
if (status !== "HOLD") {
await db.booking.update({
where: { id: hold.id },
data: { status, holdExpiresAt: null },
});
}
return hold;
}
test("cancels a CONFIRMED booking and records audit fields", async () => {
const hold = await makeBooking("CONFIRMED");
const result = await cancelBooking(db, {
bookingId: hold.id,
cancelledByUserId: customerId,
reason: "test cancel",
});
expect(result).toEqual({ kind: "cancelled", alreadyCancelled: false });
const row = await db.booking.findUnique({ where: { id: hold.id } });
expect(row?.status).toBe("CANCELLED");
expect(row?.cancelledBy).toBe(customerId);
expect(row?.cancelReason).toBe("test cancel");
expect(row?.cancelledAt).toBeTruthy();
});
test("cancels a HOLD booking", async () => {
const hold = await makeBooking("HOLD");
const result = await cancelBooking(db, {
bookingId: hold.id,
cancelledByUserId: customerId,
});
expect(result.kind).toBe("cancelled");
const row = await db.booking.findUnique({ where: { id: hold.id } });
expect(row?.status).toBe("CANCELLED");
});
test("is idempotent — cancelling an already-CANCELLED booking returns alreadyCancelled", async () => {
const hold = await makeBooking("CANCELLED");
const result = await cancelBooking(db, {
bookingId: hold.id,
cancelledByUserId: customerId,
});
expect(result).toEqual({ kind: "cancelled", alreadyCancelled: true });
});
test("rejects cancelling a COMPLETED booking", async () => {
const hold = await makeBooking("COMPLETED");
const result = await cancelBooking(db, {
bookingId: hold.id,
cancelledByUserId: customerId,
});
expect(result).toEqual({ kind: "rejected", reason: "completed" });
const row = await db.booking.findUnique({ where: { id: hold.id } });
expect(row?.status).toBe("COMPLETED"); // unchanged
});
test("rejects cancelling a NO_SHOW booking", async () => {
const hold = await makeBooking("NO_SHOW");
const result = await cancelBooking(db, {
bookingId: hold.id,
cancelledByUserId: customerId,
});
expect(result).toEqual({ kind: "rejected", reason: "no_show" });
});
test("after cancelling, the slot becomes bookable again (DB exclusion no longer blocks)", async () => {
const hold = await makeBooking("CONFIRMED");
await cancelBooking(db, {
bookingId: hold.id,
cancelledByUserId: customerId,
});
// Same therapist + same time should now succeed
const newHold = await createHold(db, {
customerId,
serviceId,
therapistId: therapistA,
roomId: roomA,
startsAt: D("2026-05-05T14:00:00Z"),
});
expect(newHold.id).toBeTruthy();
expect(newHold.id).not.toBe(hold.id);
});
test("throws on unknown bookingId", async () => {
await expect(
cancelBooking(db, {
bookingId: "no-such-booking",
cancelledByUserId: customerId,
}),
).rejects.toThrow(/not found/);
});
});
describe("expireStaleHolds", () => {
test("expires only HOLDs whose holdExpiresAt is past", async () => {
// Create one fresh hold (10 min default) and manually backdate another.