templates2.Booking.Next/lib/server/booking.ts
w4c-templates 6a0c2e88d2
Some checks are pending
CI / quality (push) Waiting to run
chore(template): publish Booking.Next
2026-10-11 23:27:02 +03:00

366 lines
13 KiB
TypeScript

import 'server-only';
import { z } from 'zod';
import { db } from './db';
import { env } from '@/lib/env';
import { sendEmail } from './email';
import {
addMinutes,
dayBoundsUtc,
formatTimeInTimeZone,
generateSlots,
isoDateInTimeZone,
parseDate,
todayInTimeZone,
type Interval,
type Slot,
} from '@/lib/slots';
export const BOOKING_STATUSES = ['pending', 'confirmed', 'cancelled', 'completed', 'no_show'] as const;
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
/** Statuses that occupy a staff member's time (everything except a cancelled booking). */
const OCCUPYING = { not: 'cancelled' } as const;
export const siteTimeZone = () => env.SITE_TIMEZONE || 'UTC';
// ------------------------------------------------------------------ schemas
export const bookingSchema = z.object({
serviceId: z.string().min(1),
staffId: z.string().min(1).nullish(),
startsAt: z.string().min(1),
customerName: z.string().min(1).max(120),
customerEmail: z.string().email(),
notes: z.string().max(1000).optional().or(z.literal('')),
});
export const serviceSchema = z.object({
name: z.string().min(1).max(160),
slug: z.string().min(1).max(160).regex(/^[a-z0-9-]+$/),
description: z.string().max(2000).optional().or(z.literal('')),
durationMin: z.number().int().min(5).max(600),
priceCents: z.number().int().min(0),
active: z.boolean().optional(),
staffIds: z.array(z.string()).optional(),
});
export const workingHourSchema = z.object({
weekday: z.number().int().min(0).max(6),
startMin: z.number().int().min(0).max(24 * 60),
endMin: z.number().int().min(0).max(24 * 60),
});
export const staffSchema = z.object({
name: z.string().min(1).max(160),
slug: z.string().min(1).max(160).regex(/^[a-z0-9-]+$/),
title: z.string().max(160).optional().or(z.literal('')),
bio: z.string().max(2000).optional().or(z.literal('')),
avatarColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
serviceIds: z.array(z.string()).optional(),
hours: z.array(workingHourSchema).optional(),
});
export const adminBookingSchema = bookingSchema.extend({
status: z.enum(BOOKING_STATUSES).optional(),
});
export const statusSchema = z.object({ status: z.enum(BOOKING_STATUSES) });
// ------------------------------------------------------------------ reads
export function listActiveServices() {
return db.service.findMany({
where: { active: true },
orderBy: { createdAt: 'asc' },
include: { staff: { include: { staff: true } } },
});
}
export function listServicesWithStaff() {
return db.service.findMany({
orderBy: { createdAt: 'asc' },
include: { staff: { include: { staff: true } } },
});
}
export function getServiceBySlug(slug: string) {
return db.service.findUnique({
where: { slug },
include: { staff: { include: { staff: true } } },
});
}
export function getServiceById(id: string) {
return db.service.findUnique({ where: { id }, include: { staff: { include: { staff: true } } } });
}
export function getBookingById(id: string) {
return db.booking.findUnique({ where: { id }, include: { service: true, staff: true, user: true } });
}
export function listBookingsForUser(userId: string) {
return db.booking.findMany({
where: { userId },
include: { service: true, staff: true },
orderBy: { startsAt: 'desc' },
});
}
export type AvailableSlot = Slot & { staffId: string; staffName: string };
export type AvailabilityResult = { date: string; timeZone: string; slots: AvailableSlot[] };
async function busyForStaff(staffId: string, bounds: Interval): Promise<Interval[]> {
const rows = await db.booking.findMany({
where: { staffId, status: OCCUPYING, startsAt: { lt: bounds.end }, endsAt: { gt: bounds.start } },
select: { startsAt: true, endsAt: true },
});
return rows.map((r) => ({ start: r.startsAt, end: r.endsAt }));
}
/** Bookable slots for a service/date, optionally narrowed to one staff member. */
export async function getAvailableSlots(input: {
serviceId: string;
staffId?: string | null;
date?: string;
now?: Date;
}): Promise<AvailabilityResult | null> {
const service = await db.service.findUnique({
where: { id: input.serviceId },
include: { staff: { include: { staff: { include: { hours: true } } } } },
});
if (!service || !service.active) return null;
const tz = siteTimeZone();
const now = input.now ?? new Date();
const date = input.date && parseDate(input.date) ? input.date : todayInTimeZone(tz, now);
const bounds = dayBoundsUtc(date, tz)!;
const candidates = service.staff
.map((s) => s.staff)
.filter((s) => (input.staffId ? s.id === input.staffId : true));
const merged = new Map<string, AvailableSlot>();
for (const staff of candidates) {
const busy = await busyForStaff(staff.id, bounds);
const slots = generateSlots({
date,
durationMin: service.durationMin,
hours: staff.hours.map((h) => ({ weekday: h.weekday, startMin: h.startMin, endMin: h.endMin })),
busy,
timeZone: tz,
now,
});
for (const slot of slots) {
if (!merged.has(slot.start)) merged.set(slot.start, { ...slot, staffId: staff.id, staffName: staff.name });
}
}
return {
date,
timeZone: tz,
slots: [...merged.values()].sort((a, b) => a.start.localeCompare(b.start)),
};
}
// ------------------------------------------------------------------ create
export type CreateBookingResult =
| { ok: true; booking: { id: string } }
| { ok: false; status: number; error: string };
class BookingConflictError extends Error {}
/**
* Create a booking after fully re-validating it server-side: the service exists, the staff (if
* chosen) delivers it, the requested slot is a real bookable slot (not past, within working hours)
* and — inside a transaction — no overlapping booking exists for that staff member.
*/
export async function createBooking(
input: z.infer<typeof bookingSchema>,
opts: { userId?: string | null; status?: BookingStatus } = {},
): Promise<CreateBookingResult> {
const service = await db.service.findUnique({ where: { id: input.serviceId }, include: { staff: true } });
if (!service || !service.active) return { ok: false, status: 404, error: 'That service is not available.' };
const startDate = new Date(input.startsAt);
if (Number.isNaN(startDate.getTime())) return { ok: false, status: 400, error: 'Invalid start time.' };
const tz = siteTimeZone();
const date = isoDateInTimeZone(startDate, tz);
if (input.staffId && !service.staff.some((s) => s.staffId === input.staffId)) {
return { ok: false, status: 400, error: 'That team member does not offer this service.' };
}
const availability = await getAvailableSlots({ serviceId: service.id, staffId: input.staffId ?? null, date });
if (!availability) return { ok: false, status: 404, error: 'That service is not available.' };
const match = availability.slots.find((s) => s.start === startDate.toISOString());
if (!match) return { ok: false, status: 409, error: 'That slot is no longer available. Please pick another time.' };
const staffId = match.staffId;
const endsAt = addMinutes(startDate, service.durationMin);
try {
const booking = await db.$transaction(async (tx) => {
const clash = await tx.booking.findFirst({
where: { staffId, status: OCCUPYING, startsAt: { lt: endsAt }, endsAt: { gt: startDate } },
select: { id: true },
});
if (clash) throw new BookingConflictError();
return tx.booking.create({
data: {
serviceId: service.id,
staffId,
userId: opts.userId ?? null,
startsAt: startDate,
endsAt,
status: opts.status ?? 'pending',
customerName: input.customerName,
customerEmail: input.customerEmail.toLowerCase(),
notes: input.notes || null,
},
});
});
const when = new Intl.DateTimeFormat('en-US', { timeZone: tz, dateStyle: 'full', timeStyle: 'short' }).format(startDate);
try {
await sendEmail({
to: booking.customerEmail,
subject: `Your ${service.name} appointment is booked`,
html: `<p>Hi ${input.customerName},</p><p>Your <strong>${service.name}</strong> with ${match.staffName} is booked for <strong>${when}</strong> (${tz}).</p><p>Reference: ${booking.id}</p>`,
});
await db.booking.update({ where: { id: booking.id }, data: { confirmationSentAt: new Date() } });
} catch {
// A failed confirmation email must not lose the booking.
}
return { ok: true, booking: { id: booking.id } };
} catch (error) {
if (error instanceof BookingConflictError) {
return { ok: false, status: 409, error: 'That slot was just taken. Please pick another time.' };
}
throw error;
}
}
export async function cancelBooking(id: string, userId: string): Promise<{ ok: boolean; status: number; error?: string }> {
const booking = await db.booking.findUnique({ where: { id } });
if (!booking || booking.userId !== userId) return { ok: false, status: 404, error: 'Booking not found.' };
if (booking.status === 'cancelled' || booking.status === 'completed' || booking.status === 'no_show') {
return { ok: false, status: 409, error: 'This booking can no longer be cancelled.' };
}
if (booking.startsAt.getTime() < Date.now()) return { ok: false, status: 409, error: 'Past bookings cannot be cancelled.' };
await db.booking.update({ where: { id }, data: { status: 'cancelled' } });
return { ok: true, status: 200 };
}
// ------------------------------------------------------------------ admin
export type AdminBookingFilter = { date?: string; from?: string; to?: string; status?: string; q?: string };
export async function adminListBookings(filter: AdminBookingFilter = {}) {
const tz = siteTimeZone();
const now = new Date();
let start: Date | undefined;
let end: Date | undefined;
if (filter.date && parseDate(filter.date)) {
const b = dayBoundsUtc(filter.date, tz);
start = b?.start;
end = b?.end;
} else if (filter.from || filter.to) {
const from = filter.from && parseDate(filter.from) ? filter.from : todayInTimeZone(tz, now);
const to = filter.to && parseDate(filter.to) ? filter.to : from;
start = dayBoundsUtc(from, tz)?.start;
end = dayBoundsUtc(to, tz)?.end;
}
const where = {
...(start && end ? { startsAt: { lt: end }, endsAt: { gt: start } } : {}),
...(filter.status && (BOOKING_STATUSES as readonly string[]).includes(filter.status) ? { status: filter.status } : {}),
...(filter.q
? { OR: [{ customerName: { contains: filter.q } }, { customerEmail: { contains: filter.q } }] }
: {}),
};
return db.booking.findMany({
where,
include: { service: true, staff: true },
orderBy: { startsAt: 'asc' },
take: 500,
});
}
export async function adminUpdateStatus(id: string, status: BookingStatus) {
return db.booking.update({ where: { id }, data: { status } });
}
export function adminListServices() {
return db.service.findMany({ include: { staff: { include: { staff: true } } }, orderBy: { createdAt: 'asc' } });
}
export function adminListStaff() {
return db.staff.findMany({ include: { services: true, hours: true }, orderBy: { createdAt: 'asc' } });
}
export async function upsertService(data: z.infer<typeof serviceSchema>, id?: string) {
const base = {
name: data.name,
slug: data.slug,
description: data.description || '',
durationMin: data.durationMin,
priceCents: data.priceCents,
active: data.active ?? true,
};
const service = id
? await db.service.update({ where: { id }, data: base })
: await db.service.create({ data: base });
if (data.staffIds) {
await db.staffService.deleteMany({ where: { serviceId: service.id } });
if (data.staffIds.length) {
await db.staffService.createMany({ data: data.staffIds.map((staffId) => ({ staffId, serviceId: service.id })) });
}
}
return service;
}
export async function deleteService(id: string) {
return db.service.delete({ where: { id } });
}
export async function upsertStaff(data: z.infer<typeof staffSchema>, id?: string) {
const base = {
name: data.name,
slug: data.slug,
title: data.title || '',
bio: data.bio || null,
avatarColor: data.avatarColor || '#4f46e5',
};
const staff = id ? await db.staff.update({ where: { id }, data: base }) : await db.staff.create({ data: base });
if (data.serviceIds) {
await db.staffService.deleteMany({ where: { staffId: staff.id } });
if (data.serviceIds.length) {
await db.staffService.createMany({ data: data.serviceIds.map((serviceId) => ({ staffId: staff.id, serviceId })) });
}
}
if (data.hours) {
await db.workingHour.deleteMany({ where: { staffId: staff.id } });
if (data.hours.length) {
await db.workingHour.createMany({ data: data.hours.map((h) => ({ staffId: staff.id, weekday: h.weekday, startMin: h.startMin, endMin: h.endMin })) });
}
}
return staff;
}
export async function deleteStaff(id: string) {
return db.staff.delete({ where: { id } });
}
/** Human-friendly label used by the admin calendar. */
export function bookingLabel(booking: { startsAt: Date; service: { name: string }; customerName: string }) {
return `${formatTimeInTimeZone(booking.startsAt, siteTimeZone())} · ${booking.service.name} · ${booking.customerName}`;
}