booking flow, loader, email, admin cli
This commit is contained in:
140
src/lib/availability-loader.ts
Normal file
140
src/lib/availability-loader.ts
Normal 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
219
src/lib/booking.ts
Normal 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
161
src/lib/email.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user