518 lines
16 KiB
TypeScript
518 lines
16 KiB
TypeScript
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<typeof reservationStatusSchema>
|
|
|
|
export const RESERVATION_STATUS_LABELS: Record<ReservationStatus, string> = {
|
|
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,
|
|
})
|
|
}
|
|
})
|