366 lines
13 KiB
TypeScript
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}`;
|
|
}
|