509 lines
19 KiB
TypeScript
509 lines
19 KiB
TypeScript
import { z } from 'zod';
|
|
import type { Prisma } from '@prisma/client';
|
|
import { db } from './db';
|
|
import type { NitroEvent } from './h3-event';
|
|
import {
|
|
BOOKING_STATUSES,
|
|
canTransition,
|
|
generateSlots,
|
|
isBookingStatus,
|
|
isValidDate,
|
|
isWithinWorkingHours,
|
|
localDate,
|
|
weekdayOf,
|
|
zonedTimeToUtc,
|
|
type BookingStatus,
|
|
type Slot,
|
|
type WorkingHour,
|
|
} from '#shared/booking';
|
|
|
|
const MS_MIN = 60_000;
|
|
const DAY_MS = 24 * 60 * MS_MIN;
|
|
|
|
/** The business timezone — slots are generated and displayed here. */
|
|
export function siteTimezone(event: NitroEvent): string {
|
|
const config = useRuntimeConfig(event);
|
|
return String(config.public.siteTimezone || 'UTC');
|
|
}
|
|
|
|
function httpError(statusCode: number, statusMessage: string) {
|
|
return createError({ statusCode, statusMessage });
|
|
}
|
|
|
|
// ---------------------------------------------------------------- validation schemas
|
|
|
|
export const serviceSchema = z.object({
|
|
name: z.string().min(1).max(160),
|
|
slug: z.string().min(1).max(160).regex(/^[a-z0-9-]+$/, 'lowercase letters, digits and dashes only'),
|
|
description: z.string().max(2000).optional().or(z.literal('')),
|
|
durationMin: z.number().int().min(5).max(600),
|
|
priceCents: z.number().int().min(0).max(100_000_00),
|
|
active: z.boolean().optional(),
|
|
staffIds: z.array(z.string().min(1)).max(50).optional(),
|
|
});
|
|
export type ServiceInput = z.infer<typeof serviceSchema>;
|
|
|
|
export const workingHourSchema = z
|
|
.object({
|
|
weekday: z.number().int().min(0).max(6),
|
|
startMin: z.number().int().min(0).max(1439),
|
|
endMin: z.number().int().min(1).max(1440),
|
|
})
|
|
.refine((h) => h.endMin > h.startMin, { message: 'end must be after start' });
|
|
|
|
export const staffSchema = z.object({
|
|
name: z.string().min(1).max(120),
|
|
slug: z.string().min(1).max(120).regex(/^[a-z0-9-]+$/, 'lowercase letters, digits and dashes only'),
|
|
title: z.string().max(120).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().min(1)).max(50).optional(),
|
|
hours: z.array(workingHourSchema).max(50).optional(),
|
|
});
|
|
export type StaffInput = z.infer<typeof staffSchema>;
|
|
|
|
export const bookingCreateSchema = z.object({
|
|
serviceId: z.string().min(1),
|
|
staffId: z.string().min(1).nullable().optional(),
|
|
date: z.string().refine(isValidDate, 'invalid date'),
|
|
startMin: z.number().int().min(0).max(1439),
|
|
customerName: z.string().min(1).max(120),
|
|
customerEmail: z.string().email().max(200),
|
|
notes: z.string().max(1000).optional().or(z.literal('')),
|
|
});
|
|
export type BookingCreateInput = z.infer<typeof bookingCreateSchema>;
|
|
|
|
export const adminBookingSchema = bookingCreateSchema.extend({
|
|
status: z.enum(BOOKING_STATUSES).optional(),
|
|
});
|
|
export type AdminBookingInput = z.infer<typeof adminBookingSchema>;
|
|
|
|
export const statusPatchSchema = z.object({ status: z.enum(BOOKING_STATUSES) });
|
|
|
|
// ---------------------------------------------------------------- shared shapes
|
|
|
|
export type PublicStaff = { id: string; name: string; title: string; bio: string | null; avatarColor: string };
|
|
export type PublicService = {
|
|
id: string;
|
|
slug: string;
|
|
name: string;
|
|
description: string;
|
|
durationMin: number;
|
|
priceCents: number;
|
|
active: boolean;
|
|
staff: PublicStaff[];
|
|
};
|
|
|
|
function toPublicStaff(s: { id: string; name: string; title: string; bio: string | null; avatarColor: string }): PublicStaff {
|
|
return { id: s.id, name: s.name, title: s.title, bio: s.bio, avatarColor: s.avatarColor };
|
|
}
|
|
|
|
function toPublicService(s: {
|
|
id: string;
|
|
slug: string;
|
|
name: string;
|
|
description: string;
|
|
durationMin: number;
|
|
priceCents: number;
|
|
active: boolean;
|
|
staff: { staff: { id: string; name: string; title: string; bio: string | null; avatarColor: string } }[];
|
|
}): PublicService {
|
|
return {
|
|
id: s.id,
|
|
slug: s.slug,
|
|
name: s.name,
|
|
description: s.description,
|
|
durationMin: s.durationMin,
|
|
priceCents: s.priceCents,
|
|
active: s.active,
|
|
staff: s.staff.map((r) => toPublicStaff(r.staff)),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------- public catalog
|
|
|
|
export async function listPublicServices(): Promise<PublicService[]> {
|
|
const rows = await db.service.findMany({
|
|
where: { active: true },
|
|
include: { staff: { include: { staff: true } } },
|
|
orderBy: { name: 'asc' },
|
|
});
|
|
return rows.map(toPublicService);
|
|
}
|
|
|
|
export async function getPublicServiceBySlug(slug: string): Promise<PublicService | null> {
|
|
const row = await db.service.findUnique({
|
|
where: { slug },
|
|
include: { staff: { include: { staff: true } } },
|
|
});
|
|
if (!row || !row.active) return null;
|
|
return toPublicService(row);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- availability
|
|
|
|
export type Availability = {
|
|
date: string;
|
|
timezone: string;
|
|
serviceId: string;
|
|
staffId: string | null;
|
|
slots: Slot[];
|
|
};
|
|
|
|
/**
|
|
* Server-computed bookable slots. Unknown/missing services and malformed dates degrade to an empty
|
|
* slot list (HTTP 200) so the endpoint is safe to poll while the form is being filled in.
|
|
*/
|
|
export async function computeAvailability(
|
|
event: NitroEvent,
|
|
query: { serviceId?: string; staffId?: string | null; date?: string },
|
|
): Promise<Availability> {
|
|
const timezone = siteTimezone(event);
|
|
const now = Date.now();
|
|
const date = query.date && isValidDate(query.date) ? query.date : localDate(now, timezone);
|
|
const base: Availability = { date, timezone, serviceId: query.serviceId ?? '', staffId: query.staffId ?? null, slots: [] };
|
|
if (!query.serviceId) return base;
|
|
|
|
const service = await db.service.findFirst({
|
|
where: { id: query.serviceId, active: true },
|
|
include: { staff: { select: { staffId: true } } },
|
|
});
|
|
if (!service) return base;
|
|
base.serviceId = service.id;
|
|
|
|
const offered = service.staff.map((s) => s.staffId);
|
|
const scoped = query.staffId ? offered.filter((id) => id === query.staffId) : offered;
|
|
if (scoped.length === 0) return base;
|
|
|
|
const rangeStart = zonedTimeToUtc(date, 0, timezone);
|
|
const rangeEnd = rangeStart + DAY_MS;
|
|
|
|
const [hours, bookings] = await Promise.all([
|
|
db.workingHour.findMany({ where: { staffId: { in: scoped } } }),
|
|
db.booking.findMany({
|
|
where: {
|
|
staffId: { in: scoped },
|
|
status: { not: 'cancelled' },
|
|
startsAt: { lt: new Date(rangeEnd) },
|
|
endsAt: { gt: new Date(rangeStart) },
|
|
},
|
|
select: { staffId: true, startsAt: true, endsAt: true },
|
|
}),
|
|
]);
|
|
|
|
const merged = new Map<number, Slot>();
|
|
for (const staffId of scoped) {
|
|
const staffHours: WorkingHour[] = hours
|
|
.filter((h) => h.staffId === staffId)
|
|
.map((h) => ({ weekday: h.weekday, startMin: h.startMin, endMin: h.endMin }));
|
|
const taken = bookings
|
|
.filter((b) => b.staffId === staffId)
|
|
.map((b) => ({ start: b.startsAt.getTime(), end: b.endsAt.getTime() }));
|
|
for (const slot of generateSlots({ date, durationMin: service.durationMin, timezone, hours: staffHours, taken, now })) {
|
|
if (!merged.has(slot.startMin)) merged.set(slot.startMin, slot);
|
|
}
|
|
}
|
|
|
|
base.slots = [...merged.values()].sort((a, b) => a.startMin - b.startMin);
|
|
return base;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- booking creation
|
|
|
|
type BookingWithRelations = Prisma.BookingGetPayload<{ include: { service: true; staff: true } }>;
|
|
export type CreateResult = { ok: true; booking: BookingWithRelations } | { ok: false; error: string; status: number };
|
|
|
|
export async function createBooking(
|
|
input: BookingCreateInput,
|
|
userId: string | null,
|
|
opts: { timezone: string; now?: number },
|
|
): Promise<CreateResult> {
|
|
const now = opts.now ?? Date.now();
|
|
const timezone = opts.timezone;
|
|
|
|
const service = await db.service.findFirst({
|
|
where: { id: input.serviceId, active: true },
|
|
include: { staff: { select: { staffId: true } } },
|
|
});
|
|
if (!service) return { ok: false, error: 'Service not found.', status: 404 };
|
|
|
|
const start = zonedTimeToUtc(input.date, input.startMin, timezone);
|
|
if (Number.isNaN(start)) return { ok: false, error: 'Invalid date.', status: 400 };
|
|
const end = start + service.durationMin * MS_MIN;
|
|
if (start <= now) return { ok: false, error: 'That slot is in the past.', status: 409 };
|
|
|
|
const offered = service.staff.map((s) => s.staffId);
|
|
if (offered.length === 0) return { ok: false, error: 'This service has no staff assigned yet.', status: 409 };
|
|
if (input.staffId && !offered.includes(input.staffId)) {
|
|
return { ok: false, error: 'The chosen staff member does not offer this service.', status: 400 };
|
|
}
|
|
|
|
try {
|
|
const booking = await db.$transaction(async (tx) => {
|
|
const candidates = input.staffId ? [input.staffId] : offered;
|
|
const weekday = weekdayOf(input.date);
|
|
const hours = await tx.workingHour.findMany({ where: { staffId: { in: candidates } } });
|
|
const conflicts = await tx.booking.findMany({
|
|
where: {
|
|
staffId: { in: candidates },
|
|
status: { not: 'cancelled' },
|
|
startsAt: { lt: new Date(end) },
|
|
endsAt: { gt: new Date(start) },
|
|
},
|
|
select: { staffId: true },
|
|
});
|
|
const busy = new Set(conflicts.map((c) => c.staffId));
|
|
const free = candidates.find((id) => {
|
|
if (busy.has(id)) return false;
|
|
const staffHours: WorkingHour[] = hours
|
|
.filter((h) => h.staffId === id)
|
|
.map((h) => ({ weekday: h.weekday, startMin: h.startMin, endMin: h.endMin }));
|
|
return isWithinWorkingHours(staffHours, weekday, input.startMin, input.startMin + service.durationMin);
|
|
});
|
|
if (!free) {
|
|
throw httpError(
|
|
409,
|
|
input.staffId ? 'That slot is no longer available.' : 'No staff member is available for that slot.',
|
|
);
|
|
}
|
|
|
|
return tx.booking.create({
|
|
data: {
|
|
serviceId: service.id,
|
|
staffId: input.staffId ? input.staffId : free,
|
|
userId: userId ?? null,
|
|
startsAt: new Date(start),
|
|
endsAt: new Date(end),
|
|
status: 'pending',
|
|
customerName: input.customerName,
|
|
customerEmail: input.customerEmail.toLowerCase(),
|
|
notes: input.notes ? input.notes : null,
|
|
},
|
|
include: { service: true, staff: true },
|
|
});
|
|
});
|
|
return { ok: true, booking };
|
|
} catch (e) {
|
|
const err = e as { statusCode?: number; statusMessage?: string };
|
|
if (typeof err.statusCode === 'number') return { ok: false, error: err.statusMessage || 'Booking failed.', status: err.statusCode };
|
|
console.error('[booking] create failed', e);
|
|
return { ok: false, error: 'Booking failed.', status: 500 };
|
|
}
|
|
}
|
|
|
|
export async function markConfirmationSent(id: string): Promise<void> {
|
|
await db.booking.update({ where: { id }, data: { confirmationSentAt: new Date() } }).catch(() => undefined);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- customer account
|
|
|
|
const bookingInclude = { service: true, staff: true } as const;
|
|
|
|
export function listBookingsForUser(userId: string) {
|
|
return db.booking.findMany({ where: { userId }, include: bookingInclude, orderBy: { startsAt: 'desc' } });
|
|
}
|
|
|
|
export function getBooking(id: string) {
|
|
return db.booking.findUnique({ where: { id }, include: bookingInclude });
|
|
}
|
|
|
|
export async function cancelBookingByCustomer(id: string, userId: string): Promise<{ ok: true } | { ok: false; status: number; error: string }> {
|
|
const booking = await db.booking.findUnique({ where: { id } });
|
|
if (!booking) return { ok: false, status: 404, error: 'Booking not found.' };
|
|
if (booking.userId !== userId) return { ok: false, status: 403, error: 'You can only cancel your own bookings.' };
|
|
if (booking.status === 'cancelled') return { ok: true };
|
|
if (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 };
|
|
}
|
|
|
|
// ---------------------------------------------------------------- admin — bookings
|
|
|
|
export type BookingFilters = { from?: string; to?: string; status?: string; staffId?: string; serviceId?: string };
|
|
|
|
export async function adminListBookings(event: NitroEvent, filters: BookingFilters) {
|
|
const timezone = siteTimezone(event);
|
|
const where: Prisma.BookingWhereInput = {};
|
|
if (isBookingStatus(filters.status)) where.status = filters.status;
|
|
if (filters.staffId) where.staffId = filters.staffId;
|
|
if (filters.serviceId) where.serviceId = filters.serviceId;
|
|
if (filters.from && isValidDate(filters.from)) where.startsAt = { gte: new Date(zonedTimeToUtc(filters.from, 0, timezone)) };
|
|
if (filters.to && isValidDate(filters.to)) {
|
|
const end = zonedTimeToUtc(filters.to, 0, timezone) + DAY_MS;
|
|
where.startsAt = { ...(where.startsAt as object | undefined), lt: new Date(end) };
|
|
}
|
|
return db.booking.findMany({ where, include: bookingInclude, orderBy: { startsAt: 'asc' }, take: 500 });
|
|
}
|
|
|
|
export async function adminCreateBooking(
|
|
event: NitroEvent,
|
|
input: AdminBookingInput,
|
|
): Promise<CreateResult> {
|
|
const timezone = siteTimezone(event);
|
|
const service = await db.service.findUnique({ where: { id: input.serviceId }, include: { staff: { select: { staffId: true } } } });
|
|
if (!service) return { ok: false, error: 'Service not found.', status: 404 };
|
|
|
|
const start = zonedTimeToUtc(input.date, input.startMin, timezone);
|
|
if (Number.isNaN(start)) return { ok: false, error: 'Invalid date.', status: 400 };
|
|
const end = start + service.durationMin * MS_MIN;
|
|
|
|
const offered = service.staff.map((s) => s.staffId);
|
|
if (input.staffId && !offered.includes(input.staffId)) {
|
|
return { ok: false, error: 'The chosen staff member does not offer this service.', status: 400 };
|
|
}
|
|
|
|
if (input.staffId) {
|
|
const clash = await db.booking.findFirst({
|
|
where: {
|
|
staffId: input.staffId,
|
|
status: { not: 'cancelled' },
|
|
startsAt: { lt: new Date(end) },
|
|
endsAt: { gt: new Date(start) },
|
|
},
|
|
});
|
|
if (clash) return { ok: false, error: 'That staff member is already booked for this slot.', status: 409 };
|
|
}
|
|
|
|
const booking = await db.booking.create({
|
|
data: {
|
|
serviceId: service.id,
|
|
staffId: input.staffId ?? null,
|
|
startsAt: new Date(start),
|
|
endsAt: new Date(end),
|
|
status: input.status ?? 'confirmed',
|
|
customerName: input.customerName,
|
|
customerEmail: input.customerEmail.toLowerCase(),
|
|
notes: input.notes ? input.notes : null,
|
|
},
|
|
include: bookingInclude,
|
|
});
|
|
return { ok: true, booking };
|
|
}
|
|
|
|
export async function adminUpdateBookingStatus(
|
|
id: string,
|
|
status: BookingStatus,
|
|
): Promise<{ ok: true; booking: BookingWithRelations } | { ok: false; status: number; error: string }> {
|
|
const booking = await db.booking.findUnique({ where: { id } });
|
|
if (!booking) return { ok: false, status: 404, error: 'Booking not found.' };
|
|
if (booking.status === status) {
|
|
return { ok: true, booking: (await db.booking.findUnique({ where: { id }, include: bookingInclude }))! };
|
|
}
|
|
if (!isBookingStatus(booking.status) || !canTransition(booking.status, status)) {
|
|
return { ok: false, status: 409, error: `Cannot move a ${booking.status} booking to ${status}.` };
|
|
}
|
|
const updated = await db.booking.update({ where: { id }, data: { status }, include: bookingInclude });
|
|
return { ok: true, booking: updated };
|
|
}
|
|
|
|
// ---------------------------------------------------------------- admin — services
|
|
|
|
export async function adminListServices() {
|
|
return db.service.findMany({
|
|
include: { staff: { include: { staff: true } }, _count: { select: { bookings: true } } },
|
|
orderBy: { name: 'asc' },
|
|
});
|
|
}
|
|
|
|
function servicePayload(data: ServiceInput) {
|
|
return {
|
|
name: data.name,
|
|
slug: data.slug,
|
|
description: data.description || '',
|
|
durationMin: data.durationMin,
|
|
priceCents: data.priceCents,
|
|
active: data.active ?? true,
|
|
};
|
|
}
|
|
|
|
export async function createService(data: ServiceInput) {
|
|
const service = await db.service.create({ data: servicePayload(data) });
|
|
if (data.staffIds) await setServiceStaff(service.id, data.staffIds);
|
|
return db.service.findUniqueOrThrow({ where: { id: service.id }, include: { staff: { include: { staff: true } } } });
|
|
}
|
|
|
|
export async function updateService(id: string, data: ServiceInput) {
|
|
await db.service.update({ where: { id }, data: servicePayload(data) });
|
|
if (data.staffIds) await setServiceStaff(id, data.staffIds);
|
|
return db.service.findUniqueOrThrow({ where: { id }, include: { staff: { include: { staff: true } } } });
|
|
}
|
|
|
|
async function setServiceStaff(serviceId: string, staffIds: string[]) {
|
|
const unique = [...new Set(staffIds)];
|
|
await db.$transaction([
|
|
db.staffService.deleteMany({ where: { serviceId } }),
|
|
...(unique.length ? [db.staffService.createMany({ data: unique.map((staffId) => ({ serviceId, staffId })) })] : []),
|
|
]);
|
|
}
|
|
|
|
export async function deleteService(id: string): Promise<{ ok: true } | { ok: false; status: number; error: string }> {
|
|
const bookings = await db.booking.count({ where: { serviceId: id } });
|
|
if (bookings > 0) return { ok: false, status: 409, error: 'This service has bookings — deactivate it instead.' };
|
|
await db.service.delete({ where: { id } });
|
|
return { ok: true };
|
|
}
|
|
|
|
// ---------------------------------------------------------------- admin — staff
|
|
|
|
export async function adminListStaff() {
|
|
return db.staff.findMany({
|
|
include: { services: { include: { service: true } }, hours: { orderBy: [{ weekday: 'asc' }, { startMin: 'asc' }] } },
|
|
orderBy: { name: 'asc' },
|
|
});
|
|
}
|
|
|
|
export async function createStaff(data: StaffInput) {
|
|
const staff = await db.staff.create({
|
|
data: {
|
|
name: data.name,
|
|
slug: data.slug,
|
|
title: data.title || '',
|
|
bio: data.bio || null,
|
|
avatarColor: data.avatarColor || '#4f46e5',
|
|
},
|
|
});
|
|
await setStaffRelations(staff.id, data);
|
|
return db.staff.findUniqueOrThrow({ where: { id: staff.id }, include: { services: { include: { service: true } }, hours: true } });
|
|
}
|
|
|
|
export async function updateStaff(id: string, data: StaffInput) {
|
|
await db.staff.update({
|
|
where: { id },
|
|
data: {
|
|
name: data.name,
|
|
slug: data.slug,
|
|
title: data.title || '',
|
|
bio: data.bio || null,
|
|
...(data.avatarColor ? { avatarColor: data.avatarColor } : {}),
|
|
},
|
|
});
|
|
await setStaffRelations(id, data);
|
|
return db.staff.findUniqueOrThrow({ where: { id }, include: { services: { include: { service: true } }, hours: true } });
|
|
}
|
|
|
|
async function setStaffRelations(staffId: string, data: StaffInput) {
|
|
const ops: Prisma.PrismaPromise<unknown>[] = [];
|
|
if (data.serviceIds) {
|
|
const unique = [...new Set(data.serviceIds)];
|
|
ops.push(db.staffService.deleteMany({ where: { staffId } }));
|
|
if (unique.length) ops.push(db.staffService.createMany({ data: unique.map((serviceId) => ({ staffId, serviceId })) }));
|
|
}
|
|
if (data.hours) {
|
|
ops.push(db.workingHour.deleteMany({ where: { staffId } }));
|
|
if (data.hours.length) ops.push(db.workingHour.createMany({ data: data.hours.map((h) => ({ staffId, weekday: h.weekday, startMin: h.startMin, endMin: h.endMin })) }));
|
|
}
|
|
if (ops.length) await db.$transaction(ops);
|
|
}
|
|
|
|
export async function deleteStaff(id: string): Promise<{ ok: true } | { ok: false; status: number; error: string }> {
|
|
const bookings = await db.booking.count({ where: { staffId: id } });
|
|
if (bookings > 0) return { ok: false, status: 409, error: 'This staff member has bookings — reassign them first.' };
|
|
await db.staff.delete({ where: { id } });
|
|
return { ok: true };
|
|
}
|