templates2.Booking.Nuxt/server/utils/booking.ts
w4c-templates 3491e2d39c
Some checks failed
CI / quality (push) Has been cancelled
chore(template): publish Booking.Nuxt
2026-10-11 23:27:22 +03:00

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 };
}