✨ first version
This commit is contained in:
+378
-26
@@ -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,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user