import { createServerFn } from '@tanstack/react-start' import { eq } from 'drizzle-orm' import { z } from 'zod' import { db } from '#/db' import { reservations, restaurantTables } from '#/db/schema' import { requireStaffSession } from '#/lib/auth' import { sendReservationCancellationEmail, sendReservationConfirmationEmail, sendReservationUpdateEmail, } from '#/lib/email' import { isOnReservationInterval, isWithinOpeningHours } from '#/lib/opening-hours' import { phoneSchema } from '#/lib/phone' import { zodValidator } from '#/lib/server-validator' import { loadReservationSettings } from '#/lib/settings' import { dataUrlImageSchema } from '#/lib/table-image' function formatLeadTime(minutes: number) { if (minutes % 60 === 0) { const hours = minutes / 60 return `${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}` } return `${minutes} Minuten` } function assertValidReservationSlot( reservationTime: Date, intervalMinutes: number, minLeadTimeMinutes: number, maxLeadTimeDays: number, durationMinutes: number, ) { const earliestAllowed = Date.now() + minLeadTimeMinutes * 60_000 if (reservationTime.getTime() < earliestAllowed) { throw new Error( minLeadTimeMinutes > 0 ? `Reservierungen müssen mindestens ${formatLeadTime(minLeadTimeMinutes)} im Voraus gebucht werden` : 'Die Reservierungszeit muss in der Zukunft liegen', ) } const latestAllowed = Date.now() + maxLeadTimeDays * 24 * 60 * 60_000 if (reservationTime.getTime() > latestAllowed) { throw new Error(`Reservierungen sind höchstens ${maxLeadTimeDays} Tage im Voraus möglich`) } if (!isOnReservationInterval(reservationTime, intervalMinutes)) { throw new Error(`Reservierungen sind nur im ${intervalMinutes}-Minuten-Raster möglich`) } if (!isWithinOpeningHours(reservationTime, durationMinutes)) { throw new Error('Zu dieser Zeit reicht die verbleibende Zeit bis zum Schließen nicht mehr aus') } } // A table is occupied for its own configured duration, not just the exact minute it // was booked for — so overlapping bookings (not just identical ones) are conflicts. // Each reservation's own stored duration is used (not the current setting), since an // older booking keeps whatever duration was in effect when it was made. `bufferMinutes` // (the current minLeadTimeMinutes setting, reused here as a table turnover gap) is // added on both sides of the existing reservation, so back-to-back bookings need at // least that much breathing room between one guest leaving and the next arriving. async function findOverlappingReservation( tableId: number, start: Date, durationMinutes: number, bufferMinutes: number, excludeId?: number, ) { const end = start.getTime() + durationMinutes * 60_000 const bufferMs = bufferMinutes * 60_000 const candidates = await db.select().from(reservations).where(eq(reservations.tableId, tableId)) return candidates.find((r) => { if (excludeId !== undefined && r.id === excludeId) return false const existingStart = r.reservationTime.getTime() - bufferMs const existingEnd = existingStart + r.durationMinutes * 60_000 + 2 * bufferMs return existingStart < end && start.getTime() < existingEnd }) } export const getTables = createServerFn({ method: 'GET' }).handler(async () => { return db.select().from(restaurantTables).orderBy(restaurantTables.name) }) const tableSchema = z.object({ name: z.string().trim().min(1, 'Name ist erforderlich').max(120), tableNumber: z.number().int().positive('Tischnummer muss positiv sein'), seats: z.number().int().min(1, 'Mindestens 1 Platz').max(50), imageUrl: dataUrlImageSchema, }) export const createTable = createServerFn({ method: 'POST' }) .validator(zodValidator(tableSchema)) .handler(async ({ data }) => { const session = await requireStaffSession() if (session.user.role !== 'admin') { throw new Error('Nur Admins dürfen Tische verwalten') } const [table] = await db.insert(restaurantTables).values(data).returning() return table }) const updateTableSchema = tableSchema.extend({ id: z.number().int().positive(), }) export const updateTable = createServerFn({ method: 'POST' }) .validator(zodValidator(updateTableSchema)) .handler(async ({ data }) => { const session = await requireStaffSession() if (session.user.role !== 'admin') { throw new Error('Nur Admins dürfen Tische verwalten') } const [updated] = await db .update(restaurantTables) .set({ name: data.name, tableNumber: data.tableNumber, seats: data.seats, imageUrl: data.imageUrl }) .where(eq(restaurantTables.id, data.id)) .returning() if (!updated) { throw new Error('Tisch wurde nicht gefunden') } return updated }) const deleteTableSchema = z.object({ id: z.number().int().positive(), }) export const deleteTable = createServerFn({ method: 'POST' }) .validator(zodValidator(deleteTableSchema)) .handler(async ({ data }) => { const session = await requireStaffSession() if (session.user.role !== 'admin') { throw new Error('Nur Admins dürfen Tische verwalten') } // The FK from reservations to this table cascades on delete, which would // silently wipe out (and never notify) any guest booked on it. Deleting // reservations individually first goes through deleteReservation, which // does send a cancellation email. const [existingReservation] = await db .select({ id: reservations.id }) .from(reservations) .where(eq(reservations.tableId, data.id)) .limit(1) if (existingReservation) { throw new Error( 'Tisch kann nicht gelöscht werden, solange Reservierungen dafür bestehen — bitte zuerst verschieben oder löschen', ) } const [deleted] = await db.delete(restaurantTables).where(eq(restaurantTables.id, data.id)).returning() if (!deleted) { throw new Error('Tisch wurde nicht gefunden') } return deleted }) const setTableMarkerSchema = z.object({ id: z.number().int().positive(), // Both coordinates set together (a marker) or both cleared (none) — there's no // meaningful state for only one of them being present. marker: z .object({ x: z.number().min(0).max(100), y: z.number().min(0).max(100), }) .nullable(), }) export const setTableMarker = createServerFn({ method: 'POST' }) .validator(zodValidator(setTableMarkerSchema)) .handler(async ({ data }) => { const session = await requireStaffSession() if (session.user.role !== 'admin') { throw new Error('Nur Admins dürfen Tische verwalten') } const [updated] = await db .update(restaurantTables) .set({ markerX: data.marker?.x ?? null, markerY: data.marker?.y ?? null }) .where(eq(restaurantTables.id, data.id)) .returning() if (!updated) { throw new Error('Tisch wurde nicht gefunden') } return updated }) export const getReservations = createServerFn({ method: 'GET' }).handler(async () => { await requireStaffSession() return db .select({ id: reservations.id, tableId: reservations.tableId, tableName: restaurantTables.name, tableNumber: restaurantTables.tableNumber, customerName: reservations.customerName, customerEmail: reservations.customerEmail, customerPhone: reservations.customerPhone, message: reservations.message, partySize: reservations.partySize, status: reservations.status, reservationTime: reservations.reservationTime, durationMinutes: reservations.durationMinutes, }) .from(reservations) .innerJoin(restaurantTables, eq(reservations.tableId, restaurantTables.id)) .orderBy(reservations.reservationTime) }) // Unauthenticated — powers the public booking form's time picker, so it only // exposes what's needed to block already-taken slots (table + time span), never // any customer data. export const getReservationAvailability = createServerFn({ method: 'GET' }).handler(async () => { return db .select({ tableId: reservations.tableId, reservationTime: reservations.reservationTime, durationMinutes: reservations.durationMinutes, }) .from(reservations) }) const createReservationSchema = z.object({ tableId: z.number().int().positive(), customerName: z.string().trim().min(1, 'Name is required').max(120), customerEmail: z.string().trim().email('Enter a valid email'), customerPhone: phoneSchema, message: z .string() .trim() .max(500, 'Message must be at most 500 characters') .optional() .transform((value) => value || null), partySize: z.number().int().min(1, 'At least 1 guest').max(20), reservationTime: z.string().refine((value) => !Number.isNaN(Date.parse(value)), { message: 'Enter a valid date and time', }), }) export const createReservation = createServerFn({ method: 'POST' }) .validator(zodValidator(createReservationSchema)) .handler(async ({ data }) => { const settings = await loadReservationSettings() const reservationTime = new Date(data.reservationTime) assertValidReservationSlot( reservationTime, settings.reservationIntervalMinutes, settings.minLeadTimeMinutes, settings.maxLeadTimeDays, settings.reservationDurationMinutes, ) const [table] = await db .select() .from(restaurantTables) .where(eq(restaurantTables.id, data.tableId)) if (!table) { throw new Error('Selected table does not exist') } if (data.partySize > table.seats) { throw new Error(`${table.name} only seats up to ${table.seats} guests`) } const conflict = await findOverlappingReservation( data.tableId, reservationTime, settings.reservationDurationMinutes, settings.minLeadTimeMinutes, ) if (conflict) { throw new Error('This table is already booked for that time') } const [reservation] = await db .insert(reservations) .values({ tableId: data.tableId, customerName: data.customerName, customerEmail: data.customerEmail, customerPhone: data.customerPhone, message: data.message, durationMinutes: settings.reservationDurationMinutes, partySize: data.partySize, reservationTime, }) .returning() await sendReservationConfirmationEmail({ customerName: reservation.customerName, customerEmail: reservation.customerEmail, message: reservation.message, partySize: reservation.partySize, reservationTime: reservation.reservationTime, cancelToken: reservation.cancelToken, table, }) return reservation }) const updateReservationSchema = createReservationSchema.extend({ id: z.number().int().positive(), }) export const updateReservation = createServerFn({ method: 'POST' }) .validator(zodValidator(updateReservationSchema)) .handler(async ({ data }) => { await requireStaffSession() const settings = await loadReservationSettings() const reservationTime = new Date(data.reservationTime) assertValidReservationSlot( reservationTime, settings.reservationIntervalMinutes, settings.minLeadTimeMinutes, settings.maxLeadTimeDays, settings.reservationDurationMinutes, ) const [table] = await db .select() .from(restaurantTables) .where(eq(restaurantTables.id, data.tableId)) if (!table) { throw new Error('Selected table does not exist') } if (data.partySize > table.seats) { throw new Error(`${table.name} only seats up to ${table.seats} guests`) } const conflict = await findOverlappingReservation( data.tableId, reservationTime, settings.reservationDurationMinutes, settings.minLeadTimeMinutes, data.id, ) if (conflict) { throw new Error('This table is already booked for that time') } const [updated] = await db .update(reservations) .set({ tableId: data.tableId, customerName: data.customerName, customerEmail: data.customerEmail, customerPhone: data.customerPhone, message: data.message, partySize: data.partySize, reservationTime, durationMinutes: settings.reservationDurationMinutes, }) .where(eq(reservations.id, data.id)) .returning() if (!updated) { throw new Error('Reservation not found') } await sendReservationUpdateEmail({ customerName: updated.customerName, customerEmail: updated.customerEmail, message: updated.message, partySize: updated.partySize, reservationTime: updated.reservationTime, cancelToken: updated.cancelToken, table, }) return updated }) export const reservationStatusSchema = z.enum(['confirmed', 'no_show', 'completed']) export type ReservationStatus = z.infer export const RESERVATION_STATUS_LABELS: Record = { confirmed: 'Bestätigt', no_show: 'Nicht erschienen', completed: 'Abgeschlossen', } const updateReservationStatusSchema = z.object({ id: z.number().int().positive(), status: reservationStatusSchema, }) export const updateReservationStatus = createServerFn({ method: 'POST' }) .validator(zodValidator(updateReservationStatusSchema)) .handler(async ({ data }) => { await requireStaffSession() const [updated] = await db .update(reservations) .set({ status: data.status }) .where(eq(reservations.id, data.id)) .returning() if (!updated) { throw new Error('Reservation not found') } return updated }) const deleteReservationSchema = z.object({ id: z.number().int().positive(), }) export const deleteReservation = createServerFn({ method: 'POST' }) .validator(zodValidator(deleteReservationSchema)) .handler(async ({ data }) => { await requireStaffSession() const [deleted] = await db.delete(reservations).where(eq(reservations.id, data.id)).returning() if (!deleted) { throw new Error('Reservation not found') } const [table] = await db .select() .from(restaurantTables) .where(eq(restaurantTables.id, deleted.tableId)) if (table) { await sendReservationCancellationEmail({ customerName: deleted.customerName, customerEmail: deleted.customerEmail, partySize: deleted.partySize, reservationTime: deleted.reservationTime, cancelToken: deleted.cancelToken, table, }) } return deleted }) const getReservationByCancelTokenSchema = z.object({ token: z.string().min(1), }) export const getReservationByCancelToken = createServerFn({ method: 'GET' }) .validator(zodValidator(getReservationByCancelTokenSchema)) .handler(async ({ data }) => { const [reservation] = await db .select({ customerName: reservations.customerName, partySize: reservations.partySize, reservationTime: reservations.reservationTime, tableName: restaurantTables.name, }) .from(reservations) .innerJoin(restaurantTables, eq(reservations.tableId, restaurantTables.id)) .where(eq(reservations.cancelToken, data.token)) return reservation ?? null }) const cancelReservationByTokenSchema = z.object({ token: z.string().min(1), }) export const cancelReservationByToken = createServerFn({ method: 'POST' }) .validator(zodValidator(cancelReservationByTokenSchema)) .handler(async ({ data }) => { const [deleted] = await db .delete(reservations) .where(eq(reservations.cancelToken, data.token)) .returning() if (!deleted) { throw new Error('Reservation not found') } const [table] = await db .select() .from(restaurantTables) .where(eq(restaurantTables.id, deleted.tableId)) if (table) { await sendReservationCancellationEmail({ customerName: deleted.customerName, customerEmail: deleted.customerEmail, partySize: deleted.partySize, reservationTime: deleted.reservationTime, cancelToken: deleted.cancelToken, table, }) } })