✨ first version

This commit is contained in:
2026-08-28 10:24:15 +02:00
parent 55a51a521d
commit ecbc42846b
72 changed files with 12788 additions and 483 deletions
+378 -26
View File
@@ -1,15 +1,205 @@
import { createServerFn } from '@tanstack/react-start'
import { and, eq, ne } from 'drizzle-orm'
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()
@@ -18,20 +208,45 @@ export const getReservations = createServerFn({ method: 'GET' }).handler(async (
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',
@@ -39,13 +254,18 @@ const createReservationSchema = z.object({
})
export const createReservation = createServerFn({ method: 'POST' })
.validator(createReservationSchema)
.validator(zodValidator(createReservationSchema))
.handler(async ({ data }) => {
const settings = await loadReservationSettings()
const reservationTime = new Date(data.reservationTime)
if (reservationTime.getTime() < Date.now()) {
throw new Error('Reservation time must be in the future')
}
assertValidReservationSlot(
reservationTime,
settings.reservationIntervalMinutes,
settings.minLeadTimeMinutes,
settings.maxLeadTimeDays,
settings.reservationDurationMinutes,
)
const [table] = await db
.select()
@@ -60,15 +280,12 @@ export const createReservation = createServerFn({ method: 'POST' })
throw new Error(`${table.name} only seats up to ${table.seats} guests`)
}
const [conflict] = await db
.select()
.from(reservations)
.where(
and(
eq(reservations.tableId, data.tableId),
eq(reservations.reservationTime, reservationTime),
),
)
const conflict = await findOverlappingReservation(
data.tableId,
reservationTime,
settings.reservationDurationMinutes,
settings.minLeadTimeMinutes,
)
if (conflict) {
throw new Error('This table is already booked for that time')
@@ -80,11 +297,24 @@ export const createReservation = createServerFn({ method: 'POST' })
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
})
@@ -93,12 +323,21 @@ const updateReservationSchema = createReservationSchema.extend({
})
export const updateReservation = createServerFn({ method: 'POST' })
.validator(updateReservationSchema)
.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)
@@ -112,16 +351,13 @@ export const updateReservation = createServerFn({ method: 'POST' })
throw new Error(`${table.name} only seats up to ${table.seats} guests`)
}
const [conflict] = await db
.select()
.from(reservations)
.where(
and(
eq(reservations.tableId, data.tableId),
eq(reservations.reservationTime, reservationTime),
ne(reservations.id, data.id),
),
)
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')
@@ -133,8 +369,11 @@ export const updateReservation = createServerFn({ method: 'POST' })
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()
@@ -143,6 +382,49 @@ export const updateReservation = createServerFn({ method: 'POST' })
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
})
@@ -151,7 +433,7 @@ const deleteReservationSchema = z.object({
})
export const deleteReservation = createServerFn({ method: 'POST' })
.validator(deleteReservationSchema)
.validator(zodValidator(deleteReservationSchema))
.handler(async ({ data }) => {
await requireStaffSession()
@@ -161,5 +443,75 @@ export const deleteReservation = createServerFn({ method: 'POST' })
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,
})
}
})