booking flow, loader, email, admin cli

This commit is contained in:
2026-05-01 18:59:19 -04:00
parent b513accdf5
commit c768dda3a1
11 changed files with 1524 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
// DB → algorithm adapter. Keeps the pure availability module free of Prisma.
import type { PrismaClient } from "@/generated/prisma/client";
import type {
RoomState,
ServiceLite,
TherapistState,
} from "@/lib/availability";
export type AvailabilityState = {
service: ServiceLite;
therapists: TherapistState[];
rooms: RoomState[];
};
export type LoadAvailabilityArgs = {
from: Date;
to: Date;
serviceId: string;
};
/**
* Load the resource state needed to find slots for a given service in a window.
* Returns null if the service doesn't exist or is inactive.
*
* Filters bookings/overrides/blocks to only those that overlap the window —
* keeps payload small for long horizons (e.g. 30-day customer search).
*/
export async function loadAvailabilityState(
db: PrismaClient,
args: LoadAvailabilityArgs,
): Promise<AvailabilityState | null> {
const service = await db.service.findUnique({
where: { id: args.serviceId },
});
if (!service || !service.active) return null;
const therapistRows = await db.therapist.findMany({
where: {
active: true,
services: { some: { serviceId: args.serviceId } },
},
include: {
tags: { select: { tag: true } },
services: {
where: { serviceId: args.serviceId },
select: { serviceId: true },
},
workingHours: true,
overrides: {
where: {
endsAt: { gt: args.from },
startsAt: { lt: args.to },
},
select: { startsAt: true, endsAt: true, kind: true },
},
bookings: {
where: {
status: { in: ["HOLD", "CONFIRMED"] },
endsAt: { gt: args.from },
startsAt: { lt: args.to },
},
select: { startsAt: true, endsAt: true },
},
},
});
const therapists: TherapistState[] = therapistRows.map((t) => ({
id: t.userId,
active: t.active,
tags: new Set(t.tags.map((tt) => tt.tag)),
serviceIds: new Set(t.services.map((s) => s.serviceId)),
workingHours: t.workingHours.map((w) => ({
weekday: w.weekday,
startMin: w.startMin,
endMin: w.endMin,
effectiveFrom: w.effectiveFrom ?? null,
effectiveTo: w.effectiveTo ?? null,
})),
overrides: t.overrides.map((o) => ({
kind: o.kind,
startsAt: o.startsAt,
endsAt: o.endsAt,
})),
bookings: t.bookings.map((b) => ({
startsAt: b.startsAt,
endsAt: b.endsAt,
})),
}));
const roomRows = await db.room.findMany({
where: { active: true },
include: {
tags: { select: { tag: true } },
blocks: {
where: {
endsAt: { gt: args.from },
startsAt: { lt: args.to },
},
select: { startsAt: true, endsAt: true },
},
bookings: {
where: {
status: { in: ["HOLD", "CONFIRMED"] },
roomReleasedAt: { gt: args.from },
startsAt: { lt: args.to },
},
select: { startsAt: true, roomReleasedAt: true },
},
},
});
const rooms: RoomState[] = roomRows.map((r) => ({
id: r.id,
active: r.active,
tags: new Set(r.tags.map((rt) => rt.tag)),
blocks: r.blocks.map((b) => ({
startsAt: b.startsAt,
endsAt: b.endsAt,
})),
bookings: r.bookings.map((b) => ({
startsAt: b.startsAt,
// For room booking footprint, the "end" is when the room is released
// (= booking endsAt + service buffer).
endsAt: b.roomReleasedAt,
})),
}));
return {
service: {
id: service.id,
durationMin: service.durationMin,
bufferAfterMin: service.bufferAfterMin,
requiredTherapistTags: service.requiredTherapistTags,
requiredRoomTags: service.requiredRoomTags,
},
therapists,
rooms,
};
}

219
src/lib/booking.ts Normal file
View File

@@ -0,0 +1,219 @@
// Booking commit helpers. All multi-row work goes through here so the
// transactional and conflict-detection logic lives in one place.
import type { PrismaClient } from "@/generated/prisma/client";
const PG_EXCLUSION_VIOLATION = "23P01";
const PG_FOREIGN_KEY_VIOLATION = "23503";
const PG_DEADLOCK_DETECTED = "40P01";
const PG_SERIALIZATION_FAILURE = "40001";
const DEFAULT_HOLD_MINUTES = 10;
export class BookingConflictError extends Error {
readonly code = "BOOKING_CONFLICT";
readonly constraint?: string;
constructor(constraint?: string) {
super(
constraint === "Booking_no_room_overlap"
? "Room is no longer available for this slot"
: constraint === "Booking_no_therapist_overlap"
? "Therapist is no longer available for this slot"
: "Slot is no longer available",
);
this.constraint = constraint;
this.name = "BookingConflictError";
}
}
export type CreateHoldInput = {
customerId: string;
serviceId: string;
therapistId: string;
roomId: string;
startsAt: Date;
holdMinutes?: number; // default 10
};
export type Hold = {
id: string;
customerId: string;
serviceId: string;
therapistId: string;
roomId: string;
startsAt: Date;
endsAt: Date;
roomReleasedAt: Date;
holdExpiresAt: Date;
priceCents: number;
depositCents: number;
};
/**
* Insert a booking in HOLD status. The DB exclusion constraints are the
* authoritative double-booking guard — if a concurrent caller booked the same
* therapist/room slot, this throws BookingConflictError. The application's
* findSlots search is best-effort; this insert is the truth.
*
* Caller is responsible for validating the slot was previously offered by
* findSlots — this helper does NOT recheck working hours, tag eligibility,
* or the ServiceTherapist allowlist. (Rationale: the DB has no context for
* working-hours rules, so we either re-run the algorithm or trust the caller.
* For a single trusted server boundary, trusting the caller is fine.)
*/
export async function createHold(
db: PrismaClient,
input: CreateHoldInput,
): Promise<Hold> {
const service = await db.service.findUnique({
where: { id: input.serviceId },
select: {
id: true,
durationMin: true,
bufferAfterMin: true,
priceCents: true,
depositCents: true,
active: true,
},
});
if (!service) throw new Error(`Service not found: ${input.serviceId}`);
if (!service.active) throw new Error(`Service is inactive: ${input.serviceId}`);
const startsAt = input.startsAt;
const endsAt = new Date(startsAt.getTime() + service.durationMin * 60_000);
const roomReleasedAt = new Date(
endsAt.getTime() + service.bufferAfterMin * 60_000,
);
const holdMinutes = input.holdMinutes ?? DEFAULT_HOLD_MINUTES;
const holdExpiresAt = new Date(Date.now() + holdMinutes * 60_000);
try {
const row = await db.booking.create({
data: {
customerId: input.customerId,
therapistId: input.therapistId,
roomId: input.roomId,
serviceId: input.serviceId,
startsAt,
endsAt,
roomReleasedAt,
status: "HOLD",
holdExpiresAt,
priceCents: service.priceCents,
depositCents: service.depositCents,
},
});
return {
id: row.id,
customerId: row.customerId,
serviceId: row.serviceId,
therapistId: row.therapistId,
roomId: row.roomId,
startsAt: row.startsAt,
endsAt: row.endsAt,
roomReleasedAt: row.roomReleasedAt,
holdExpiresAt: row.holdExpiresAt!,
priceCents: row.priceCents,
depositCents: row.depositCents,
};
} catch (e) {
const constraint = extractConstraint(e);
if (constraint?.includes("_overlap")) {
throw new BookingConflictError(constraint);
}
// Concurrent inserts on the same slot can produce a deadlock or
// serialization failure during GiST exclusion check instead of a
// straight 23P01. From the user's perspective both mean "someone else
// got the slot" — surface as a conflict.
const code = extractPgCode(e);
if (code === PG_DEADLOCK_DETECTED || code === PG_SERIALIZATION_FAILURE) {
throw new BookingConflictError();
}
throw e;
}
}
/**
* Confirm a hold. Idempotent — confirming an already-CONFIRMED row is a no-op.
*/
export async function confirmHold(
db: PrismaClient,
bookingId: string,
): Promise<void> {
await db.booking.update({
where: { id: bookingId },
data: { status: "CONFIRMED", holdExpiresAt: null },
});
}
/**
* Sweep expired holds. Run periodically (pg-boss job, eventually). Returns count.
*/
export async function expireStaleHolds(db: PrismaClient): Promise<number> {
const now = new Date();
const result = await db.booking.updateMany({
where: {
status: "HOLD",
holdExpiresAt: { lt: now },
},
data: {
status: "CANCELLED",
cancelledAt: now,
cancelReason: "hold expired",
},
});
return result.count;
}
/**
* Walk the error chain looking for a Postgres constraint name. Prisma 7 wraps
* driver errors several layers deep — meta may live on the outer error, on a
* `cause`, or on the underlying DriverAdapterError. We also recognize the
* exclusion-violation message text as a fallback.
*/
function extractConstraint(e: unknown): string | undefined {
const seen = new Set<unknown>();
let cur: unknown = e;
while (cur && typeof cur === "object" && !seen.has(cur)) {
seen.add(cur);
const obj = cur as Record<string, unknown>;
const meta = obj.meta;
if (meta && typeof meta === "object") {
const c = (meta as { constraint?: unknown }).constraint;
if (typeof c === "string") return c;
}
if (typeof obj.message === "string") {
// pg-driver text e.g. `conflicting key value violates exclusion constraint "Booking_no_room_overlap"`
const m = obj.message.match(/exclusion constraint\s+"([^"]+)"/);
if (m) return m[1];
}
cur = obj.cause ?? undefined;
}
return undefined;
}
function extractPgCode(e: unknown): string | undefined {
const seen = new Set<unknown>();
let cur: unknown = e;
while (cur && typeof cur === "object" && !seen.has(cur)) {
seen.add(cur);
const obj = cur as Record<string, unknown>;
const code = obj.code;
if (typeof code === "string" && /^[0-9A-Z]{5}$/.test(code)) return code;
if (typeof obj.message === "string") {
// pg adapter sometimes leaves the code only in the message
const m = obj.message.match(/\b([0-9A-Z]{5})\b/);
if (m) return m[1];
}
cur = obj.cause ?? undefined;
}
return undefined;
}
// Exports kept for tests and callers that want to classify errors themselves.
export const PG_CODES = {
EXCLUSION_VIOLATION: PG_EXCLUSION_VIOLATION,
FOREIGN_KEY_VIOLATION: PG_FOREIGN_KEY_VIOLATION,
DEADLOCK_DETECTED: PG_DEADLOCK_DETECTED,
SERIALIZATION_FAILURE: PG_SERIALIZATION_FAILURE,
} as const;

161
src/lib/email.ts Normal file
View File

@@ -0,0 +1,161 @@
// SMTP transport + transactional email sender. Mailpit in dev, Resend in prod.
// Both speak SMTP, so swap is a config change.
import { createHash } from "node:crypto";
import nodemailer, { type Transporter } from "nodemailer";
import type { PrismaClient } from "@/generated/prisma/client";
let transporter: Transporter | null = null;
function getTransporter(): Transporter {
if (transporter) return transporter;
const port = Number(process.env.SMTP_PORT ?? 1025);
transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST ?? "localhost",
port,
secure: port === 465,
auth: process.env.SMTP_USER
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
: undefined,
});
return transporter;
}
/** Test seam — reset cached transport between tests if env changes. */
export function resetEmailTransport(): void {
transporter = null;
}
export type SendResult = {
notificationId: string;
providerId: string | null;
status: "sent" | "failed";
};
type SendEmailArgs = {
db: PrismaClient;
to: string;
subject: string;
text: string;
html?: string;
template: string;
userId?: string;
bookingId?: string;
};
export async function sendEmail(args: SendEmailArgs): Promise<SendResult> {
const from = process.env.SMTP_FROM ?? "TouchBase <noreply@touchbase.local>";
const bodyHash = createHash("sha256")
.update(args.subject)
.update("\n")
.update(args.text)
.digest("hex");
const noti = await args.db.notification.create({
data: {
userId: args.userId,
bookingId: args.bookingId,
channel: "email",
template: args.template,
to: args.to,
subject: args.subject,
bodyHash,
status: "queued",
},
});
try {
const info = await getTransporter().sendMail({
from,
to: args.to,
subject: args.subject,
text: args.text,
html: args.html,
});
await args.db.notification.update({
where: { id: noti.id },
data: {
status: "sent",
providerId: info.messageId ?? null,
sentAt: new Date(),
},
});
return {
notificationId: noti.id,
providerId: info.messageId ?? null,
status: "sent",
};
} catch (e) {
await args.db.notification.update({
where: { id: noti.id },
data: { status: "failed" },
});
throw e;
}
}
// ============================================================
// Booking confirmation
// ============================================================
export type BookingConfirmationArgs = {
db: PrismaClient;
bookingId: string;
};
export async function sendBookingConfirmation(
args: BookingConfirmationArgs,
): Promise<SendResult> {
const booking = await args.db.booking.findUnique({
where: { id: args.bookingId },
include: {
customer: true,
service: true,
therapist: { include: { user: true } },
room: true,
},
});
if (!booking) throw new Error(`Booking not found: ${args.bookingId}`);
const tz = process.env.APP_TZ ?? "America/Detroit";
const localStart = formatLocal(booking.startsAt, tz);
const localEnd = formatLocal(booking.endsAt, tz);
const subject = `Your ${booking.service.name} is confirmed`;
const text = [
`Hi ${booking.customer.name},`,
"",
`Your appointment is confirmed.`,
"",
`Service: ${booking.service.name}`,
`When: ${localStart} ${localEnd}`,
`Therapist: ${booking.therapist.user.name}`,
`Room: ${booking.room.name}`,
"",
`If you need to reschedule or cancel, reply to this email.`,
"",
`— TouchBase`,
].join("\n");
return sendEmail({
db: args.db,
to: booking.customer.email,
subject,
text,
template: "booking_confirmation",
userId: booking.customerId,
bookingId: booking.id,
});
}
function formatLocal(d: Date, tz: string): string {
return new Intl.DateTimeFormat("en-US", {
timeZone: tz,
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
}).format(d);
}