This commit is contained in:
2026-08-27 11:41:41 +02:00
parent 55a51a521d
commit 734ab92bca
66 changed files with 12545 additions and 483 deletions
+13
View File
@@ -24,6 +24,19 @@ function createAuth() {
// Staff accounts are provisioned via the seed script, not public self sign-up.
disableSignUp: true,
},
user: {
additionalFields: {
// `required` here only governs Better Auth's own self-service sign-up/
// update-user flows, which this app doesn't use for staff — accounts go
// through admin.createUser, whose `data` field isn't schema-validated by
// Better Auth, so the actual "required" enforcement lives in the staff
// team UI (staff.team.tsx) instead.
phone: {
type: 'string',
required: true,
},
},
},
session: {
expiresIn: 60 * 60 * 12,
},
+8 -3
View File
@@ -1,10 +1,15 @@
import { createServerFn } from '@tanstack/react-start'
import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
export async function getStaffSession() {
// `staff.index.tsx` and friends import `getCurrentStaffUser` from this module for
// their `beforeLoad` checks, which pulls the whole file into the client bundle too.
// `createServerOnlyFn` tells the compiler this closure (and the server-only
// `getRequest` import it uses) can be stripped from that client bundle instead of
// dragging the Postgres driver into the browser.
export const getStaffSession = createServerOnlyFn(async function getStaffSession() {
const { getAuth } = await import('#/lib/auth-instance')
return getAuth().api.getSession({ headers: getRequest().headers })
}
})
export async function requireStaffSession() {
const session = await getStaffSession()
+6 -1
View File
@@ -1,4 +1,9 @@
export function formatDateOnly(date: Date) {
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
export function formatDateTimeLocal(date: Date) {
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
return `${formatDateOnly(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
+275
View File
@@ -0,0 +1,275 @@
import { Resend } from 'resend'
import type { RestaurantTable } from '#/db/schema'
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'full',
timeStyle: 'short',
})
const COLORS = {
bg: '#17120f',
bgRaised: '#1e1712',
line: '#3a2f26',
text: '#f3ead9',
textMuted: '#c9baa3',
gold: '#c9a15a',
goldBright: '#e0bb78',
}
function escapeHtml(value: string) {
return value
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
let resendClient: Resend | undefined
function getResendClient() {
const apiKey = process.env.RESEND_API_KEY
if (!apiKey) {
return null
}
resendClient ??= new Resend(apiKey)
return resendClient
}
type ReservationEmailInfo = {
customerName: string
customerEmail: string
message?: string | null
partySize: number
reservationTime: Date
cancelToken: string
table: Pick<RestaurantTable, 'name'>
}
const CONTACT_EMAIL = 'info@steakhaus-meppen.de'
function getSiteUrl() {
return process.env.BETTER_AUTH_URL ?? 'http://localhost:3000'
}
function buildCancelUrl(cancelToken: string) {
return `${getSiteUrl()}/stornieren/${cancelToken}`
}
function renderReservationEmailHtml(
heading: string,
intro: string,
reservation: ReservationEmailInfo,
showActions: boolean,
) {
const formattedTime = dateTimeFormatter.format(reservation.reservationTime)
const name = escapeHtml(reservation.customerName)
const tableName = escapeHtml(reservation.table.name)
const cancelUrl = buildCancelUrl(reservation.cancelToken)
const row = (label: string, value: string) => `
<tr>
<td style="padding:10px 0;border-top:1px solid ${COLORS.line};color:${COLORS.textMuted};font:400 14px/1.4 'Helvetica Neue',Arial,sans-serif;letter-spacing:0.03em;text-transform:uppercase;white-space:nowrap;">
${label}
</td>
<td style="padding:10px 0;border-top:1px solid ${COLORS.line};color:${COLORS.text};font:600 15px/1.4 Georgia,'Times New Roman',serif;text-align:right;">
${value}
</td>
</tr>`
return `
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${heading}</title>
</head>
<body style="margin:0;padding:32px 16px;background:${COLORS.bg};font:400 16px/1.5 'Helvetica Neue',Arial,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;">
<tr>
<td align="center" style="padding-bottom:28px;">
<span style="font:600 20px/1.3 Georgia,'Times New Roman',serif;letter-spacing:0.03em;color:${COLORS.text};">
Steakhaus <span style="color:${COLORS.goldBright};">bei Mirko</span>
</span>
</td>
</tr>
<tr>
<td style="background:${COLORS.bgRaised};border:1px solid ${COLORS.line};border-radius:10px;padding:32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center" style="padding-bottom:20px;">
<span style="display:inline-block;width:36px;height:2px;background:${COLORS.gold};"></span>
</td>
</tr>
<tr>
<td style="font:600 22px/1.3 Georgia,'Times New Roman',serif;color:${COLORS.goldBright};padding-bottom:12px;">
${heading}
</td>
</tr>
<tr>
<td style="color:${COLORS.textMuted};font-size:15px;padding-bottom:8px;">
Hallo ${name},
</td>
</tr>
<tr>
<td style="color:${COLORS.textMuted};font-size:15px;padding-bottom:20px;">
${intro}
</td>
</tr>
<tr>
<td>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
${row('Tisch', tableName)}
${row('Datum &amp; Uhrzeit', formattedTime)}
${row('Personen', String(reservation.partySize))}
</table>
</td>
</tr>
${
reservation.message
? `
<tr>
<td style="padding-top:16px;">
<div style="color:${COLORS.textMuted};font:400 12px/1.4 'Helvetica Neue',Arial,sans-serif;letter-spacing:0.03em;text-transform:uppercase;padding-bottom:4px;">
Nachricht
</div>
<div style="color:${COLORS.text};font-size:14px;">${escapeHtml(reservation.message)}</div>
</td>
</tr>`
: ''
}
${
showActions
? `
<tr>
<td align="center" style="padding-top:28px;">
<a href="${cancelUrl}" style="display:inline-block;padding:0.7rem 1.4rem;border:1px solid ${COLORS.line};border-radius:4px;color:${COLORS.text};font:600 13px/1 'Helvetica Neue',Arial,sans-serif;text-decoration:none;">
Reservierung stornieren
</a>
</td>
</tr>
<tr>
<td align="center" style="padding-top:16px;color:${COLORS.textMuted};font-size:13px;">
Möchten Sie etwas ändern? Schreiben Sie uns einfach eine E-Mail an
<a href="mailto:${CONTACT_EMAIL}" style="color:${COLORS.goldBright};">${CONTACT_EMAIL}</a>.
</td>
</tr>`
: ''
}
</table>
</td>
</tr>
<tr>
<td align="center" style="padding-top:28px;color:${COLORS.textMuted};font-size:13px;">
Wir freuen uns auf Sie!
</td>
</tr>
<tr>
<td align="center" style="padding-top:16px;color:${COLORS.textMuted};font-size:12px;">
Meppener Straße 8 · 49716 Meppen · 05931 - 12750
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
}
function renderReservationEmailText(
heading: string,
intro: string,
reservation: ReservationEmailInfo,
showActions: boolean,
) {
const formattedTime = dateTimeFormatter.format(reservation.reservationTime)
return [
'Steakhaus bei Mirko',
'',
heading,
'',
`Hallo ${reservation.customerName},`,
'',
intro,
'',
`Tisch: ${reservation.table.name}`,
`Datum & Uhrzeit: ${formattedTime}`,
`Personen: ${reservation.partySize}`,
...(reservation.message ? [`Nachricht: ${reservation.message}`] : []),
'',
...(showActions
? [
`Reservierung stornieren: ${buildCancelUrl(reservation.cancelToken)}`,
`Möchten Sie etwas ändern? Schreiben Sie uns eine E-Mail an ${CONTACT_EMAIL}.`,
'',
]
: []),
'Wir freuen uns auf Sie!',
'Steakhaus bei Mirko',
'Meppener Straße 8 · 49716 Meppen · 05931 - 12750',
].join('\n')
}
async function sendReservationEmail(
subject: string,
intro: string,
reservation: ReservationEmailInfo,
showActions: boolean,
) {
const client = getResendClient()
if (!client) {
console.warn('RESEND_API_KEY is not set — skipping reservation email')
return
}
const fromAddress = process.env.RESERVATIONS_FROM_EMAIL ?? 'Steakhaus bei Mirko <reservierungen@steakhaus-meppen.de>'
try {
await client.emails.send({
from: fromAddress,
to: reservation.customerEmail,
subject,
html: renderReservationEmailHtml(subject, intro, reservation, showActions),
text: renderReservationEmailText(subject, intro, reservation, showActions),
})
} catch (err) {
console.error('Failed to send reservation email', err)
}
}
export function sendReservationConfirmationEmail(reservation: ReservationEmailInfo) {
return sendReservationEmail(
'Ihre Reservierung ist bestätigt',
'vielen Dank für Ihre Reservierung. Hier sind die Details:',
reservation,
true,
)
}
export function sendReservationUpdateEmail(reservation: ReservationEmailInfo) {
return sendReservationEmail(
'Ihre Reservierung wurde geändert',
'Ihre Reservierung wurde aktualisiert. Hier sind die neuen Details:',
reservation,
true,
)
}
export function sendReservationCancellationEmail(reservation: ReservationEmailInfo) {
return sendReservationEmail(
'Ihre Reservierung wurde storniert',
'Ihre folgende Reservierung wurde storniert:',
reservation,
false,
)
}
+143
View File
@@ -0,0 +1,143 @@
import { formatDateOnly } from '#/lib/datetime'
export const RESTAURANT_TIME_ZONE = 'Europe/Berlin'
export const RESERVATION_INTERVAL_MINUTES = 15
export type OpeningRange = { start: string; end: string }
// Keyed by the JS `Date#getDay()` convention: 0 = Sunday … 6 = Saturday.
const OPENING_HOURS_BY_WEEKDAY: Record<number, OpeningRange[]> = {
0: [
{ start: '11:30', end: '13:30' },
{ start: '17:00', end: '22:00' },
],
1: [],
2: [{ start: '17:00', end: '21:30' }],
3: [{ start: '17:00', end: '21:30' }],
4: [{ start: '17:00', end: '21:30' }],
5: [{ start: '17:00', end: '22:00' }],
6: [{ start: '17:00', end: '22:00' }],
}
const WEEKDAY_LABELS_MONDAY_FIRST: Array<{ day: string; weekday: number }> = [
{ day: 'Montag', weekday: 1 },
{ day: 'Dienstag', weekday: 2 },
{ day: 'Mittwoch', weekday: 3 },
{ day: 'Donnerstag', weekday: 4 },
{ day: 'Freitag', weekday: 5 },
{ day: 'Samstag', weekday: 6 },
{ day: 'Sonntag', weekday: 0 },
]
const WEEKDAY_SHORT_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const berlinPartsFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: RESTAURANT_TIME_ZONE,
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
})
function toMinutes(time: string) {
const [hours, minutes] = time.split(':').map(Number)
return hours * 60 + minutes
}
function getBerlinTimeParts(date: Date) {
const parts = berlinPartsFormatter.formatToParts(date)
const byType = Object.fromEntries(parts.map((part) => [part.type, part.value]))
return {
weekday: WEEKDAY_SHORT_NAMES.indexOf(byType.weekday),
minutesSinceMidnight: Number(byType.hour) * 60 + Number(byType.minute),
}
}
export function isOnReservationInterval(date: Date, intervalMinutes: number = RESERVATION_INTERVAL_MINUTES) {
return date.getUTCMinutes() % intervalMinutes === 0 && date.getUTCSeconds() === 0
}
// `durationMinutes` requires the whole reservation — not just its start — to fit
// before closing, so a slot too close to close time is rejected outright.
export function isWithinOpeningHours(date: Date, durationMinutes = 0) {
const { weekday, minutesSinceMidnight } = getBerlinTimeParts(date)
const ranges = OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
return ranges.some(
(range) =>
minutesSinceMidnight >= toMinutes(range.start) &&
minutesSinceMidnight + durationMinutes <= toMinutes(range.end),
)
}
export function getOpeningHoursForDisplay() {
return WEEKDAY_LABELS_MONDAY_FIRST.map(({ day, weekday }) => {
const ranges = OPENING_HOURS_BY_WEEKDAY[weekday]
return {
day,
hours: ranges.length ? ranges.map((range) => `${range.start} – ${range.end}`) : null,
}
})
}
export function getTodayLabelBerlin() {
return new Intl.DateTimeFormat('de-DE', { timeZone: RESTAURANT_TIME_ZONE, weekday: 'long' }).format(new Date())
}
// The functions below drive the customer-facing date/time picker. They work with the
// browser's local time (not RESTAURANT_TIME_ZONE) since visitors are assumed to be
// browsing from the restaurant's own timezone — same assumption the rest of the
// booking form already makes when it builds a Date from an unqualified local string.
export function getOpeningRangesForWeekday(weekday: number): OpeningRange[] {
return OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
}
// `durationMinutes` (how long a reservation booked at a slot would occupy the
// table) defaults to `intervalMinutes` so a bare call still behaves sensibly —
// but every real caller should pass the restaurant's actual reservation
// duration, so a slot too close to closing time to fit the whole reservation
// is excluded rather than offered and then silently running past close.
export function generateTimeOptions(
ranges: OpeningRange[],
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
durationMinutes: number = intervalMinutes,
): string[] {
const options: string[] = []
for (const range of ranges) {
const startMinutes = toMinutes(range.start)
const endMinutes = toMinutes(range.end)
for (let minutes = startMinutes; minutes + durationMinutes <= endMinutes; minutes += intervalMinutes) {
const hours = Math.floor(minutes / 60)
const remainder = minutes % 60
options.push(`${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`)
}
}
return options
}
export function getNextAvailableSlot(
from: Date,
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
durationMinutes: number = intervalMinutes,
): { date: string; time: string } | null {
for (let dayOffset = 0; dayOffset < 14; dayOffset++) {
const day = new Date(from)
day.setDate(from.getDate() + dayOffset)
const dateStr = formatDateOnly(day)
const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes, durationMinutes)
for (const time of options) {
const slot = new Date(`${dateStr}T${time}`)
if (slot.getTime() >= from.getTime()) {
return { date: dateStr, time }
}
}
}
return null
}
+15
View File
@@ -0,0 +1,15 @@
import { z } from 'zod'
// Shared between the customer reservation form (lib/reservations.ts) and the
// staff phone field (routes/staff.team.tsx), so "a valid phone number" means
// the same thing everywhere in the app.
export const phoneSchema = z
.string()
.trim()
.max(30, 'Enter a valid phone number')
// Optional leading `+` plus digits and common separators (spaces, dashes,
// dots, parentheses) — permissive about international formats, but the
// character class alone would still accept e.g. "()---" or "+", so the
// refine below requires it to actually contain a real number of digits.
.regex(/^\+?[0-9()\-.\s]+$/, 'Enter a valid phone number')
.refine((value) => (value.match(/\d/g)?.length ?? 0) >= 6, 'Enter a valid phone number')
+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,
})
}
})
+17
View File
@@ -0,0 +1,17 @@
import type { z, ZodType } from 'zod'
// `createServerFn().validator(schema)` passes a zod schema straight through to
// TanStack Start, which on failure throws `new Error(JSON.stringify(issues))` —
// the raw issue array, not a message meant for a user to read. Wrapping the
// schema in a plain function (`.validator(zodValidator(schema))`) routes
// validation through this instead, so a rejected field surfaces its own zod
// `.message` (e.g. "Enter a valid phone number") rather than a JSON blob.
export function zodValidator<Schema extends ZodType>(schema: Schema) {
return (input: unknown): z.infer<Schema> => {
const result = schema.safeParse(input)
if (!result.success) {
throw new Error(result.error.issues[0]?.message ?? 'Invalid input')
}
return result.data
}
}
+64
View File
@@ -0,0 +1,64 @@
import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { db } from '#/db'
import { restaurantSettings } from '#/db/schema'
import { requireStaffSession } from '#/lib/auth'
import { zodValidator } from '#/lib/server-validator'
import { dataUrlImageSchema } from '#/lib/table-image'
// Reads never require a session — the public booking form needs the interval and
// lead time to generate its own time-slot grid, just like getTables(). Exported
// (rather than only reachable through the server fn below) so reservations.ts can
// read the current settings directly while validating a booking. This module is
// imported by client-reachable route files for the createServerFns below, so —
// same issue as `#/lib/auth` — a plain, unwrapped export using `db` directly would
// drag the Postgres driver into the browser bundle; `createServerOnlyFn` keeps it
// server-side only.
export const loadReservationSettings = createServerOnlyFn(async function loadReservationSettings() {
const [existing] = await db.select().from(restaurantSettings).limit(1)
if (existing) return existing
const [created] = await db.insert(restaurantSettings).values({}).returning()
return created
})
export const getReservationSettings = createServerFn({ method: 'GET' }).handler(async () => {
return loadReservationSettings()
})
const updateReservationSettingsSchema = z.object({
reservationIntervalMinutes: z.number().int().min(5).max(120),
reservationDurationMinutes: z.number().int().min(15).max(480),
minLeadTimeMinutes: z.number().int().min(0).max(10080),
maxLeadTimeDays: z.number().int().min(1).max(365),
tableDisplayMode: z.enum(['individual', 'overview']),
tableOverviewImageUrl: dataUrlImageSchema,
})
export const updateReservationSettings = createServerFn({ method: 'POST' })
.validator(zodValidator(updateReservationSettingsSchema))
.handler(async ({ data }) => {
console.log('[settings] updateReservationSettings called with', data)
const session = await requireStaffSession()
console.log('[settings] session role:', session.user.role)
if (session.user.role !== 'admin') {
throw new Error('Nur Admins dürfen die Einstellungen ändern')
}
const existing = await loadReservationSettings()
console.log('[settings] existing row before update:', existing)
const [updated] = await db
.update(restaurantSettings)
.set(data)
.where(eq(restaurantSettings.id, existing.id))
.returning()
console.log('[settings] row after update:', updated)
return updated
})
+5 -1
View File
@@ -11,5 +11,9 @@ export const listStaffMembers = createServerFn({ method: 'GET' }).handler(async
query: { sortBy: 'createdAt', sortDirection: 'asc', limit: 100 },
})
return users
// `listUsers`'s return type doesn't pick up `user.additionalFields` from the
// auth config the way the actual response body does — `phone` is present at
// runtime (Better Auth includes every configured additional field on every
// user-returning endpoint) but missing from admin.d.ts's `UserWithRole` type.
return users as Array<(typeof users)[number] & { phone: string | null }>
})
+24
View File
@@ -0,0 +1,24 @@
import { z } from 'zod'
// Generous enough for a photo, small enough to keep rows (and the customer-facing
// page that loads them up front) from bloating — these are data: URLs stored
// inline, not uploads to disk-backed storage, which this app doesn't have.
export const MAX_TABLE_IMAGE_BYTES = 2_000_000
export const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp']
export const dataUrlImageSchema = z
.string()
.trim()
.max(Math.ceil((MAX_TABLE_IMAGE_BYTES * 4) / 3) + 100)
.regex(/^data:image\/(png|jpeg|jpg|webp);base64,/, 'Bild muss als PNG, JPEG oder WebP hochgeladen werden')
.nullable()
export function readImageAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error ?? new Error('Datei konnte nicht gelesen werden'))
reader.readAsDataURL(file)
})
}