diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..6763bb9
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,20 @@
+node_modules
+dist
+dist-ssr
+.git
+.gitea
+.github
+.idea
+.vscode
+.env
+.env.*
+!.env.example
+*.local
+.nitro
+.tanstack
+.wrangler
+.output
+.vinxi
+__unconfig*
+todos.json
+README.md
diff --git a/.env.example b/.env.example
index 82574c6..1efbd5c 100644
--- a/.env.example
+++ b/.env.example
@@ -3,3 +3,5 @@ BETTER_AUTH_SECRET=replace-with-a-long-random-string
BETTER_AUTH_URL=http://localhost:3000
STAFF_SEED_EMAIL=admin@steakhaus-meppen.de
STAFF_SEED_PASSWORD=replace-with-a-strong-password
+RESEND_API_KEY=replace-with-your-resend-api-key
+RESERVATIONS_FROM_EMAIL=Steakhaus bei Mirko
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..2d17c2e
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,153 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ - proof
+ workflow_dispatch:
+
+env:
+ REGISTRY: git.lukas-harat.com
+ IMAGE_NAME: ${{ github.repository }}
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Typecheck
+ run: npx tsc --noEmit
+
+ build:
+ needs: test
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ secrets.USERNAME }}
+ password: ${{ secrets.TOKEN }}
+
+ - name: Build and push image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: true
+ tags: |
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
+ cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
+ cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Deploy to TrueNAS
+ env:
+ DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
+ DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
+ DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
+ DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
+ DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
+
+ POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
+ BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
+ BETTER_AUTH_URL: ${{ secrets.BETTER_AUTH_URL }}
+ RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
+ RESERVATIONS_FROM_EMAIL: ${{ secrets.RESERVATIONS_FROM_EMAIL }}
+
+ IMAGE_TAG: ${{ github.sha }}
+
+ run: |
+ set -euo pipefail
+
+ mkdir -p ~/.ssh
+
+ printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
+ chmod 600 ~/.ssh/deploy_key
+
+ ssh-keyscan \
+ -p "${DEPLOY_PORT:-22}" \
+ -H "$DEPLOY_HOST" \
+ >> ~/.ssh/known_hosts
+
+ echo "Creating deployment directory if needed..."
+
+ ssh \
+ -i ~/.ssh/deploy_key \
+ -p "${DEPLOY_PORT:-22}" \
+ "$DEPLOY_USER@$DEPLOY_HOST" \
+ "mkdir -p '$DEPLOY_PATH'"
+
+ echo "Uploading compose file..."
+
+ scp \
+ -i ~/.ssh/deploy_key \
+ -P "${DEPLOY_PORT:-22}" \
+ docker-compose.prod.yml \
+ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml"
+
+ echo "Deploying..."
+
+ ssh \
+ -i ~/.ssh/deploy_key \
+ -p "${DEPLOY_PORT:-22}" \
+ "$DEPLOY_USER@$DEPLOY_HOST" \
+ "POSTGRES_PASSWORD=$(printf '%q' "$POSTGRES_PASSWORD") \
+ BETTER_AUTH_SECRET=$(printf '%q' "$BETTER_AUTH_SECRET") \
+ BETTER_AUTH_URL=$(printf '%q' "$BETTER_AUTH_URL") \
+ RESEND_API_KEY=$(printf '%q' "$RESEND_API_KEY") \
+ RESERVATIONS_FROM_EMAIL=$(printf '%q' "$RESERVATIONS_FROM_EMAIL") \
+ IMAGE_TAG=$(printf '%q' "$IMAGE_TAG") \
+ bash -c '
+ set -euo pipefail
+
+ cd \"$DEPLOY_PATH\"
+
+ echo \"Pulling images...\"
+ docker compose pull
+
+ echo \"Starting PostgreSQL...\"
+ docker compose up -d postgres
+
+ echo \"Running database migrations...\"
+ docker compose run --rm app npx drizzle-kit migrate
+
+ echo \"Starting application...\"
+ docker compose up -d
+
+ echo \"Cleaning unused images...\"
+ docker image prune -f
+
+ echo \"Deployment complete.\"
+ '"
diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
new file mode 100644
index 0000000..7cd46bd
--- /dev/null
+++ b/.idea/dataSources.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ postgresql
+ true
+ org.postgresql.Driver
+ jdbc:postgresql://localhost:5432/postgres
+ $ProjectFileDir$
+
+
+
\ No newline at end of file
diff --git a/.idea/jsonCatalog.xml b/.idea/jsonCatalog.xml
new file mode 100644
index 0000000..0b181a9
--- /dev/null
+++ b/.idea/jsonCatalog.xml
@@ -0,0 +1,6 @@
+
+
+
+ false
+
+
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..b93b09b
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,31 @@
+# syntax=docker/dockerfile:1
+
+FROM node:22-alpine AS builder
+WORKDIR /app
+
+COPY package.json package-lock.json ./
+RUN npm ci
+
+COPY . .
+
+# Dummy values so the build-time env checks in src/db/index.ts and
+# src/lib/auth-instance.ts don't throw while bundling. The real values are
+# supplied at container runtime.
+ENV DATABASE_URL=postgres://build:build@localhost:5432/build \
+ BETTER_AUTH_SECRET=build-time-placeholder \
+ BETTER_AUTH_URL=http://localhost:3000
+
+RUN npm run build
+
+FROM node:22-alpine AS runner
+WORKDIR /app
+ENV NODE_ENV=production
+
+# This build has no standalone Node server entrypoint — dist/server/server.js
+# only exports a fetch handler. `vite preview` (via the tanstackStart plugin)
+# is the supported way to serve it, and it re-resolves the app's router entry
+# from src/ at startup, so the full source tree (not just dist/) has to ship.
+COPY --from=builder /app ./
+
+EXPOSE 3000
+CMD ["npm", "run", "preview", "--", "--host", "0.0.0.0", "--port", "3000"]
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..69770c5
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,35 @@
+services:
+ app:
+ image: git.lukas-harat.com/lukas/promo_steakhouse:${IMAGE_TAG:-latest}
+ restart: unless-stopped
+ ports:
+ - '3003:3000'
+ environment:
+ DATABASE_URL: postgres://steakhouse:${POSTGRES_PASSWORD}@postgres:5432/steakhouse
+ BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
+ BETTER_AUTH_URL: ${BETTER_AUTH_URL}
+ STAFF_SEED_EMAIL: ${STAFF_SEED_EMAIL}
+ STAFF_SEED_PASSWORD: ${STAFF_SEED_PASSWORD}
+ RESEND_API_KEY: ${RESEND_API_KEY}
+ RESERVATIONS_FROM_EMAIL: ${RESERVATIONS_FROM_EMAIL}
+ depends_on:
+ postgres:
+ condition: service_healthy
+
+ postgres:
+ image: postgres:16-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: steakhouse
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ POSTGRES_DB: steakhouse
+ volumes:
+ - steakhouse-postgres-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U steakhouse -d steakhouse"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+volumes:
+ steakhouse-postgres-data:
diff --git a/drizzle/0000_milky_squadron_sinister.sql b/drizzle/0000_milky_squadron_sinister.sql
new file mode 100644
index 0000000..cbcdd82
--- /dev/null
+++ b/drizzle/0000_milky_squadron_sinister.sql
@@ -0,0 +1,73 @@
+CREATE TABLE "account" (
+ "id" text PRIMARY KEY NOT NULL,
+ "account_id" text NOT NULL,
+ "provider_id" text NOT NULL,
+ "user_id" text NOT NULL,
+ "access_token" text,
+ "refresh_token" text,
+ "id_token" text,
+ "access_token_expires_at" timestamp,
+ "refresh_token_expires_at" timestamp,
+ "scope" text,
+ "password" text,
+ "created_at" timestamp NOT NULL,
+ "updated_at" timestamp NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "reservations" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "table_id" integer NOT NULL,
+ "customer_name" text NOT NULL,
+ "customer_email" text NOT NULL,
+ "party_size" integer NOT NULL,
+ "reservation_time" timestamp with time zone NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "reservations_table_id_reservation_time_unique" UNIQUE("table_id","reservation_time")
+);
+--> statement-breakpoint
+CREATE TABLE "restaurant_tables" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "name" text NOT NULL,
+ "seats" integer NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "session" (
+ "id" text PRIMARY KEY NOT NULL,
+ "expires_at" timestamp NOT NULL,
+ "token" text NOT NULL,
+ "created_at" timestamp NOT NULL,
+ "updated_at" timestamp NOT NULL,
+ "ip_address" text,
+ "user_agent" text,
+ "user_id" text NOT NULL,
+ "impersonated_by" text,
+ CONSTRAINT "session_token_unique" UNIQUE("token")
+);
+--> statement-breakpoint
+CREATE TABLE "user" (
+ "id" text PRIMARY KEY NOT NULL,
+ "name" text NOT NULL,
+ "email" text NOT NULL,
+ "email_verified" boolean NOT NULL,
+ "image" text,
+ "created_at" timestamp NOT NULL,
+ "updated_at" timestamp NOT NULL,
+ "role" text,
+ "banned" boolean,
+ "ban_reason" text,
+ "ban_expires" timestamp,
+ CONSTRAINT "user_email_unique" UNIQUE("email")
+);
+--> statement-breakpoint
+CREATE TABLE "verification" (
+ "id" text PRIMARY KEY NOT NULL,
+ "identifier" text NOT NULL,
+ "value" text NOT NULL,
+ "expires_at" timestamp NOT NULL,
+ "created_at" timestamp,
+ "updated_at" timestamp
+);
+--> statement-breakpoint
+ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "reservations" ADD CONSTRAINT "reservations_table_id_restaurant_tables_id_fk" FOREIGN KEY ("table_id") REFERENCES "public"."restaurant_tables"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
\ No newline at end of file
diff --git a/drizzle/0001_melted_supernaut.sql b/drizzle/0001_melted_supernaut.sql
new file mode 100644
index 0000000..7771034
--- /dev/null
+++ b/drizzle/0001_melted_supernaut.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "reservations" ADD COLUMN "cancel_token" text DEFAULT gen_random_uuid() NOT NULL;--> statement-breakpoint
+ALTER TABLE "reservations" ADD CONSTRAINT "reservations_cancel_token_unique" UNIQUE("cancel_token");
\ No newline at end of file
diff --git a/drizzle/0002_deep_dorian_gray.sql b/drizzle/0002_deep_dorian_gray.sql
new file mode 100644
index 0000000..d680ecf
--- /dev/null
+++ b/drizzle/0002_deep_dorian_gray.sql
@@ -0,0 +1,8 @@
+CREATE TABLE "restaurant_settings" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "reservation_interval_minutes" integer DEFAULT 15 NOT NULL,
+ "reservation_duration_minutes" integer DEFAULT 90 NOT NULL,
+ "min_lead_time_minutes" integer DEFAULT 60 NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "reservations" ADD COLUMN "duration_minutes" integer DEFAULT 90 NOT NULL;
\ No newline at end of file
diff --git a/drizzle/0003_big_jack_flag.sql b/drizzle/0003_big_jack_flag.sql
new file mode 100644
index 0000000..a51ec26
--- /dev/null
+++ b/drizzle/0003_big_jack_flag.sql
@@ -0,0 +1 @@
+ALTER TABLE "restaurant_settings" ADD COLUMN "max_lead_time_days" integer DEFAULT 60 NOT NULL;
\ No newline at end of file
diff --git a/drizzle/0004_wide_thunderbolt.sql b/drizzle/0004_wide_thunderbolt.sql
new file mode 100644
index 0000000..2b1f490
--- /dev/null
+++ b/drizzle/0004_wide_thunderbolt.sql
@@ -0,0 +1 @@
+ALTER TABLE "restaurant_tables" ADD COLUMN "image_url" text;
\ No newline at end of file
diff --git a/drizzle/0005_nice_stark_industries.sql b/drizzle/0005_nice_stark_industries.sql
new file mode 100644
index 0000000..7f53a2e
--- /dev/null
+++ b/drizzle/0005_nice_stark_industries.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "restaurant_settings" ADD COLUMN "table_display_mode" text DEFAULT 'individual' NOT NULL;--> statement-breakpoint
+ALTER TABLE "restaurant_settings" ADD COLUMN "table_overview_image_url" text;
\ No newline at end of file
diff --git a/drizzle/0006_uneven_captain_cross.sql b/drizzle/0006_uneven_captain_cross.sql
new file mode 100644
index 0000000..042cbfc
--- /dev/null
+++ b/drizzle/0006_uneven_captain_cross.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "restaurant_tables" ADD COLUMN "marker_x" double precision;--> statement-breakpoint
+ALTER TABLE "restaurant_tables" ADD COLUMN "marker_y" double precision;
\ No newline at end of file
diff --git a/drizzle/0007_dusty_payback.sql b/drizzle/0007_dusty_payback.sql
new file mode 100644
index 0000000..0114f2b
--- /dev/null
+++ b/drizzle/0007_dusty_payback.sql
@@ -0,0 +1 @@
+ALTER TABLE "restaurant_tables" ADD COLUMN "table_number" integer;
\ No newline at end of file
diff --git a/drizzle/0008_even_toxin.sql b/drizzle/0008_even_toxin.sql
new file mode 100644
index 0000000..51ca032
--- /dev/null
+++ b/drizzle/0008_even_toxin.sql
@@ -0,0 +1 @@
+ALTER TABLE "reservations" ADD COLUMN "message" text;
\ No newline at end of file
diff --git a/drizzle/0009_nosy_alice.sql b/drizzle/0009_nosy_alice.sql
new file mode 100644
index 0000000..e0ee462
--- /dev/null
+++ b/drizzle/0009_nosy_alice.sql
@@ -0,0 +1 @@
+ALTER TABLE "reservations" ADD COLUMN "customer_phone" text;
\ No newline at end of file
diff --git a/drizzle/0010_common_masque.sql b/drizzle/0010_common_masque.sql
new file mode 100644
index 0000000..17ed09e
--- /dev/null
+++ b/drizzle/0010_common_masque.sql
@@ -0,0 +1 @@
+ALTER TABLE "user" ADD COLUMN "phone" text;
\ No newline at end of file
diff --git a/drizzle/0011_cloudy_gwen_stacy.sql b/drizzle/0011_cloudy_gwen_stacy.sql
new file mode 100644
index 0000000..50370f2
--- /dev/null
+++ b/drizzle/0011_cloudy_gwen_stacy.sql
@@ -0,0 +1 @@
+ALTER TABLE "reservations" ADD COLUMN "status" text DEFAULT 'confirmed' NOT NULL;
\ No newline at end of file
diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json
new file mode 100644
index 0000000..5d4d16f
--- /dev/null
+++ b/drizzle/meta/0000_snapshot.json
@@ -0,0 +1,459 @@
+{
+ "id": "fc9539d6-3015-4ae3-bbf2-7a1a9f4de482",
+ "prevId": "00000000-0000-0000-0000-000000000000",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reservations": {
+ "name": "reservations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "table_id": {
+ "name": "table_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_name": {
+ "name": "customer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_email": {
+ "name": "customer_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "party_size": {
+ "name": "party_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reservation_time": {
+ "name": "reservation_time",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reservations_table_id_restaurant_tables_id_fk": {
+ "name": "reservations_table_id_restaurant_tables_id_fk",
+ "tableFrom": "reservations",
+ "tableTo": "restaurant_tables",
+ "columnsFrom": [
+ "table_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "reservations_table_id_reservation_time_unique": {
+ "name": "reservations_table_id_reservation_time_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "table_id",
+ "reservation_time"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_tables": {
+ "name": "restaurant_tables",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seats": {
+ "name": "seats",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json
new file mode 100644
index 0000000..155d02e
--- /dev/null
+++ b/drizzle/meta/0001_snapshot.json
@@ -0,0 +1,473 @@
+{
+ "id": "65d581ab-b3c7-4ffb-8a79-afcf26be5af5",
+ "prevId": "fc9539d6-3015-4ae3-bbf2-7a1a9f4de482",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reservations": {
+ "name": "reservations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "table_id": {
+ "name": "table_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_name": {
+ "name": "customer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_email": {
+ "name": "customer_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "party_size": {
+ "name": "party_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reservation_time": {
+ "name": "reservation_time",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cancel_token": {
+ "name": "cancel_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reservations_table_id_restaurant_tables_id_fk": {
+ "name": "reservations_table_id_restaurant_tables_id_fk",
+ "tableFrom": "reservations",
+ "tableTo": "restaurant_tables",
+ "columnsFrom": [
+ "table_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "reservations_cancel_token_unique": {
+ "name": "reservations_cancel_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "cancel_token"
+ ]
+ },
+ "reservations_table_id_reservation_time_unique": {
+ "name": "reservations_table_id_reservation_time_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "table_id",
+ "reservation_time"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_tables": {
+ "name": "restaurant_tables",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seats": {
+ "name": "seats",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json
new file mode 100644
index 0000000..98624a7
--- /dev/null
+++ b/drizzle/meta/0002_snapshot.json
@@ -0,0 +1,520 @@
+{
+ "id": "5e900bf2-030d-4eb3-a8ab-3b4c7d85e203",
+ "prevId": "65d581ab-b3c7-4ffb-8a79-afcf26be5af5",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reservations": {
+ "name": "reservations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "table_id": {
+ "name": "table_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_name": {
+ "name": "customer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_email": {
+ "name": "customer_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "party_size": {
+ "name": "party_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reservation_time": {
+ "name": "reservation_time",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "cancel_token": {
+ "name": "cancel_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reservations_table_id_restaurant_tables_id_fk": {
+ "name": "reservations_table_id_restaurant_tables_id_fk",
+ "tableFrom": "reservations",
+ "tableTo": "restaurant_tables",
+ "columnsFrom": [
+ "table_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "reservations_cancel_token_unique": {
+ "name": "reservations_cancel_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "cancel_token"
+ ]
+ },
+ "reservations_table_id_reservation_time_unique": {
+ "name": "reservations_table_id_reservation_time_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "table_id",
+ "reservation_time"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_settings": {
+ "name": "restaurant_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "reservation_interval_minutes": {
+ "name": "reservation_interval_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 15
+ },
+ "reservation_duration_minutes": {
+ "name": "reservation_duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "min_lead_time_minutes": {
+ "name": "min_lead_time_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_tables": {
+ "name": "restaurant_tables",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seats": {
+ "name": "seats",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json
new file mode 100644
index 0000000..7cd9cca
--- /dev/null
+++ b/drizzle/meta/0003_snapshot.json
@@ -0,0 +1,527 @@
+{
+ "id": "d03b9103-cb6a-4968-a016-c04270ec176a",
+ "prevId": "5e900bf2-030d-4eb3-a8ab-3b4c7d85e203",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reservations": {
+ "name": "reservations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "table_id": {
+ "name": "table_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_name": {
+ "name": "customer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_email": {
+ "name": "customer_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "party_size": {
+ "name": "party_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reservation_time": {
+ "name": "reservation_time",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "cancel_token": {
+ "name": "cancel_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reservations_table_id_restaurant_tables_id_fk": {
+ "name": "reservations_table_id_restaurant_tables_id_fk",
+ "tableFrom": "reservations",
+ "tableTo": "restaurant_tables",
+ "columnsFrom": [
+ "table_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "reservations_cancel_token_unique": {
+ "name": "reservations_cancel_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "cancel_token"
+ ]
+ },
+ "reservations_table_id_reservation_time_unique": {
+ "name": "reservations_table_id_reservation_time_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "table_id",
+ "reservation_time"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_settings": {
+ "name": "restaurant_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "reservation_interval_minutes": {
+ "name": "reservation_interval_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 15
+ },
+ "reservation_duration_minutes": {
+ "name": "reservation_duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "min_lead_time_minutes": {
+ "name": "min_lead_time_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "max_lead_time_days": {
+ "name": "max_lead_time_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_tables": {
+ "name": "restaurant_tables",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seats": {
+ "name": "seats",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json
new file mode 100644
index 0000000..e353d1f
--- /dev/null
+++ b/drizzle/meta/0004_snapshot.json
@@ -0,0 +1,533 @@
+{
+ "id": "05eac8a1-b172-4e4d-90d2-a409ecde610a",
+ "prevId": "d03b9103-cb6a-4968-a016-c04270ec176a",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reservations": {
+ "name": "reservations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "table_id": {
+ "name": "table_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_name": {
+ "name": "customer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_email": {
+ "name": "customer_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "party_size": {
+ "name": "party_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reservation_time": {
+ "name": "reservation_time",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "cancel_token": {
+ "name": "cancel_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reservations_table_id_restaurant_tables_id_fk": {
+ "name": "reservations_table_id_restaurant_tables_id_fk",
+ "tableFrom": "reservations",
+ "tableTo": "restaurant_tables",
+ "columnsFrom": [
+ "table_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "reservations_cancel_token_unique": {
+ "name": "reservations_cancel_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "cancel_token"
+ ]
+ },
+ "reservations_table_id_reservation_time_unique": {
+ "name": "reservations_table_id_reservation_time_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "table_id",
+ "reservation_time"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_settings": {
+ "name": "restaurant_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "reservation_interval_minutes": {
+ "name": "reservation_interval_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 15
+ },
+ "reservation_duration_minutes": {
+ "name": "reservation_duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "min_lead_time_minutes": {
+ "name": "min_lead_time_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "max_lead_time_days": {
+ "name": "max_lead_time_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_tables": {
+ "name": "restaurant_tables",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seats": {
+ "name": "seats",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json
new file mode 100644
index 0000000..01e8121
--- /dev/null
+++ b/drizzle/meta/0005_snapshot.json
@@ -0,0 +1,546 @@
+{
+ "id": "71bc6bc2-516c-4af1-8c61-3df7814e9976",
+ "prevId": "05eac8a1-b172-4e4d-90d2-a409ecde610a",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reservations": {
+ "name": "reservations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "table_id": {
+ "name": "table_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_name": {
+ "name": "customer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_email": {
+ "name": "customer_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "party_size": {
+ "name": "party_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reservation_time": {
+ "name": "reservation_time",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "cancel_token": {
+ "name": "cancel_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reservations_table_id_restaurant_tables_id_fk": {
+ "name": "reservations_table_id_restaurant_tables_id_fk",
+ "tableFrom": "reservations",
+ "tableTo": "restaurant_tables",
+ "columnsFrom": [
+ "table_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "reservations_cancel_token_unique": {
+ "name": "reservations_cancel_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "cancel_token"
+ ]
+ },
+ "reservations_table_id_reservation_time_unique": {
+ "name": "reservations_table_id_reservation_time_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "table_id",
+ "reservation_time"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_settings": {
+ "name": "restaurant_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "reservation_interval_minutes": {
+ "name": "reservation_interval_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 15
+ },
+ "reservation_duration_minutes": {
+ "name": "reservation_duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "min_lead_time_minutes": {
+ "name": "min_lead_time_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "max_lead_time_days": {
+ "name": "max_lead_time_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "table_display_mode": {
+ "name": "table_display_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'individual'"
+ },
+ "table_overview_image_url": {
+ "name": "table_overview_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_tables": {
+ "name": "restaurant_tables",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seats": {
+ "name": "seats",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json
new file mode 100644
index 0000000..c261f07
--- /dev/null
+++ b/drizzle/meta/0006_snapshot.json
@@ -0,0 +1,558 @@
+{
+ "id": "cc3da65b-b162-4866-ac44-b7d177588316",
+ "prevId": "71bc6bc2-516c-4af1-8c61-3df7814e9976",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reservations": {
+ "name": "reservations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "table_id": {
+ "name": "table_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_name": {
+ "name": "customer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_email": {
+ "name": "customer_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "party_size": {
+ "name": "party_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reservation_time": {
+ "name": "reservation_time",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "cancel_token": {
+ "name": "cancel_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reservations_table_id_restaurant_tables_id_fk": {
+ "name": "reservations_table_id_restaurant_tables_id_fk",
+ "tableFrom": "reservations",
+ "tableTo": "restaurant_tables",
+ "columnsFrom": [
+ "table_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "reservations_cancel_token_unique": {
+ "name": "reservations_cancel_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "cancel_token"
+ ]
+ },
+ "reservations_table_id_reservation_time_unique": {
+ "name": "reservations_table_id_reservation_time_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "table_id",
+ "reservation_time"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_settings": {
+ "name": "restaurant_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "reservation_interval_minutes": {
+ "name": "reservation_interval_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 15
+ },
+ "reservation_duration_minutes": {
+ "name": "reservation_duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "min_lead_time_minutes": {
+ "name": "min_lead_time_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "max_lead_time_days": {
+ "name": "max_lead_time_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "table_display_mode": {
+ "name": "table_display_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'individual'"
+ },
+ "table_overview_image_url": {
+ "name": "table_overview_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_tables": {
+ "name": "restaurant_tables",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seats": {
+ "name": "seats",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "marker_x": {
+ "name": "marker_x",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "marker_y": {
+ "name": "marker_y",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json
new file mode 100644
index 0000000..5e727df
--- /dev/null
+++ b/drizzle/meta/0007_snapshot.json
@@ -0,0 +1,564 @@
+{
+ "id": "0914058c-53fc-4e93-b703-a907e0ae72a8",
+ "prevId": "cc3da65b-b162-4866-ac44-b7d177588316",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reservations": {
+ "name": "reservations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "table_id": {
+ "name": "table_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_name": {
+ "name": "customer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_email": {
+ "name": "customer_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "party_size": {
+ "name": "party_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reservation_time": {
+ "name": "reservation_time",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "cancel_token": {
+ "name": "cancel_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reservations_table_id_restaurant_tables_id_fk": {
+ "name": "reservations_table_id_restaurant_tables_id_fk",
+ "tableFrom": "reservations",
+ "tableTo": "restaurant_tables",
+ "columnsFrom": [
+ "table_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "reservations_cancel_token_unique": {
+ "name": "reservations_cancel_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "cancel_token"
+ ]
+ },
+ "reservations_table_id_reservation_time_unique": {
+ "name": "reservations_table_id_reservation_time_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "table_id",
+ "reservation_time"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_settings": {
+ "name": "restaurant_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "reservation_interval_minutes": {
+ "name": "reservation_interval_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 15
+ },
+ "reservation_duration_minutes": {
+ "name": "reservation_duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "min_lead_time_minutes": {
+ "name": "min_lead_time_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "max_lead_time_days": {
+ "name": "max_lead_time_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "table_display_mode": {
+ "name": "table_display_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'individual'"
+ },
+ "table_overview_image_url": {
+ "name": "table_overview_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_tables": {
+ "name": "restaurant_tables",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seats": {
+ "name": "seats",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "table_number": {
+ "name": "table_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "marker_x": {
+ "name": "marker_x",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "marker_y": {
+ "name": "marker_y",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json
new file mode 100644
index 0000000..0237bba
--- /dev/null
+++ b/drizzle/meta/0008_snapshot.json
@@ -0,0 +1,570 @@
+{
+ "id": "c95ecb29-7866-4794-9aad-8a6fba8e7bc4",
+ "prevId": "0914058c-53fc-4e93-b703-a907e0ae72a8",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reservations": {
+ "name": "reservations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "table_id": {
+ "name": "table_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_name": {
+ "name": "customer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_email": {
+ "name": "customer_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "party_size": {
+ "name": "party_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reservation_time": {
+ "name": "reservation_time",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "cancel_token": {
+ "name": "cancel_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reservations_table_id_restaurant_tables_id_fk": {
+ "name": "reservations_table_id_restaurant_tables_id_fk",
+ "tableFrom": "reservations",
+ "tableTo": "restaurant_tables",
+ "columnsFrom": [
+ "table_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "reservations_cancel_token_unique": {
+ "name": "reservations_cancel_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "cancel_token"
+ ]
+ },
+ "reservations_table_id_reservation_time_unique": {
+ "name": "reservations_table_id_reservation_time_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "table_id",
+ "reservation_time"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_settings": {
+ "name": "restaurant_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "reservation_interval_minutes": {
+ "name": "reservation_interval_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 15
+ },
+ "reservation_duration_minutes": {
+ "name": "reservation_duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "min_lead_time_minutes": {
+ "name": "min_lead_time_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "max_lead_time_days": {
+ "name": "max_lead_time_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "table_display_mode": {
+ "name": "table_display_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'individual'"
+ },
+ "table_overview_image_url": {
+ "name": "table_overview_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_tables": {
+ "name": "restaurant_tables",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seats": {
+ "name": "seats",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "table_number": {
+ "name": "table_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "marker_x": {
+ "name": "marker_x",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "marker_y": {
+ "name": "marker_y",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json
new file mode 100644
index 0000000..b73355d
--- /dev/null
+++ b/drizzle/meta/0009_snapshot.json
@@ -0,0 +1,576 @@
+{
+ "id": "69162a80-7a1b-480b-85c2-7f11e2f1725c",
+ "prevId": "c95ecb29-7866-4794-9aad-8a6fba8e7bc4",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reservations": {
+ "name": "reservations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "table_id": {
+ "name": "table_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_name": {
+ "name": "customer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_email": {
+ "name": "customer_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_phone": {
+ "name": "customer_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "party_size": {
+ "name": "party_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reservation_time": {
+ "name": "reservation_time",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "cancel_token": {
+ "name": "cancel_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reservations_table_id_restaurant_tables_id_fk": {
+ "name": "reservations_table_id_restaurant_tables_id_fk",
+ "tableFrom": "reservations",
+ "tableTo": "restaurant_tables",
+ "columnsFrom": [
+ "table_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "reservations_cancel_token_unique": {
+ "name": "reservations_cancel_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "cancel_token"
+ ]
+ },
+ "reservations_table_id_reservation_time_unique": {
+ "name": "reservations_table_id_reservation_time_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "table_id",
+ "reservation_time"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_settings": {
+ "name": "restaurant_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "reservation_interval_minutes": {
+ "name": "reservation_interval_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 15
+ },
+ "reservation_duration_minutes": {
+ "name": "reservation_duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "min_lead_time_minutes": {
+ "name": "min_lead_time_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "max_lead_time_days": {
+ "name": "max_lead_time_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "table_display_mode": {
+ "name": "table_display_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'individual'"
+ },
+ "table_overview_image_url": {
+ "name": "table_overview_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_tables": {
+ "name": "restaurant_tables",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seats": {
+ "name": "seats",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "table_number": {
+ "name": "table_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "marker_x": {
+ "name": "marker_x",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "marker_y": {
+ "name": "marker_y",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json
new file mode 100644
index 0000000..6f2e8ce
--- /dev/null
+++ b/drizzle/meta/0010_snapshot.json
@@ -0,0 +1,582 @@
+{
+ "id": "b5488545-3b1f-4145-a565-f4fcdc5b3748",
+ "prevId": "69162a80-7a1b-480b-85c2-7f11e2f1725c",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reservations": {
+ "name": "reservations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "table_id": {
+ "name": "table_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_name": {
+ "name": "customer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_email": {
+ "name": "customer_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_phone": {
+ "name": "customer_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "party_size": {
+ "name": "party_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reservation_time": {
+ "name": "reservation_time",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "cancel_token": {
+ "name": "cancel_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reservations_table_id_restaurant_tables_id_fk": {
+ "name": "reservations_table_id_restaurant_tables_id_fk",
+ "tableFrom": "reservations",
+ "tableTo": "restaurant_tables",
+ "columnsFrom": [
+ "table_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "reservations_cancel_token_unique": {
+ "name": "reservations_cancel_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "cancel_token"
+ ]
+ },
+ "reservations_table_id_reservation_time_unique": {
+ "name": "reservations_table_id_reservation_time_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "table_id",
+ "reservation_time"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_settings": {
+ "name": "restaurant_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "reservation_interval_minutes": {
+ "name": "reservation_interval_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 15
+ },
+ "reservation_duration_minutes": {
+ "name": "reservation_duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "min_lead_time_minutes": {
+ "name": "min_lead_time_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "max_lead_time_days": {
+ "name": "max_lead_time_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "table_display_mode": {
+ "name": "table_display_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'individual'"
+ },
+ "table_overview_image_url": {
+ "name": "table_overview_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_tables": {
+ "name": "restaurant_tables",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seats": {
+ "name": "seats",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "table_number": {
+ "name": "table_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "marker_x": {
+ "name": "marker_x",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "marker_y": {
+ "name": "marker_y",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json
new file mode 100644
index 0000000..8a413f6
--- /dev/null
+++ b/drizzle/meta/0011_snapshot.json
@@ -0,0 +1,589 @@
+{
+ "id": "b91348a4-3a6f-4c6b-a7a0-c60c06eeb9f9",
+ "prevId": "b5488545-3b1f-4145-a565-f4fcdc5b3748",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reservations": {
+ "name": "reservations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "table_id": {
+ "name": "table_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_name": {
+ "name": "customer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_email": {
+ "name": "customer_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "customer_phone": {
+ "name": "customer_phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "party_size": {
+ "name": "party_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'confirmed'"
+ },
+ "reservation_time": {
+ "name": "reservation_time",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_minutes": {
+ "name": "duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "cancel_token": {
+ "name": "cancel_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reservations_table_id_restaurant_tables_id_fk": {
+ "name": "reservations_table_id_restaurant_tables_id_fk",
+ "tableFrom": "reservations",
+ "tableTo": "restaurant_tables",
+ "columnsFrom": [
+ "table_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "reservations_cancel_token_unique": {
+ "name": "reservations_cancel_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "cancel_token"
+ ]
+ },
+ "reservations_table_id_reservation_time_unique": {
+ "name": "reservations_table_id_reservation_time_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "table_id",
+ "reservation_time"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_settings": {
+ "name": "restaurant_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "reservation_interval_minutes": {
+ "name": "reservation_interval_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 15
+ },
+ "reservation_duration_minutes": {
+ "name": "reservation_duration_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 90
+ },
+ "min_lead_time_minutes": {
+ "name": "min_lead_time_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "max_lead_time_days": {
+ "name": "max_lead_time_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "table_display_mode": {
+ "name": "table_display_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'individual'"
+ },
+ "table_overview_image_url": {
+ "name": "table_overview_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.restaurant_tables": {
+ "name": "restaurant_tables",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seats": {
+ "name": "seats",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "table_number": {
+ "name": "table_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "marker_x": {
+ "name": "marker_x",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "marker_y": {
+ "name": "marker_y",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
new file mode 100644
index 0000000..2060162
--- /dev/null
+++ b/drizzle/meta/_journal.json
@@ -0,0 +1,90 @@
+{
+ "version": "7",
+ "dialect": "postgresql",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "7",
+ "when": 1787335013744,
+ "tag": "0000_milky_squadron_sinister",
+ "breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "7",
+ "when": 1787338587505,
+ "tag": "0001_melted_supernaut",
+ "breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "7",
+ "when": 1787604850455,
+ "tag": "0002_deep_dorian_gray",
+ "breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "7",
+ "when": 1787646264161,
+ "tag": "0003_big_jack_flag",
+ "breakpoints": true
+ },
+ {
+ "idx": 4,
+ "version": "7",
+ "when": 1787683584543,
+ "tag": "0004_wide_thunderbolt",
+ "breakpoints": true
+ },
+ {
+ "idx": 5,
+ "version": "7",
+ "when": 1787684215918,
+ "tag": "0005_nice_stark_industries",
+ "breakpoints": true
+ },
+ {
+ "idx": 6,
+ "version": "7",
+ "when": 1787684576667,
+ "tag": "0006_uneven_captain_cross",
+ "breakpoints": true
+ },
+ {
+ "idx": 7,
+ "version": "7",
+ "when": 1787685540508,
+ "tag": "0007_dusty_payback",
+ "breakpoints": true
+ },
+ {
+ "idx": 8,
+ "version": "7",
+ "when": 1787686033197,
+ "tag": "0008_even_toxin",
+ "breakpoints": true
+ },
+ {
+ "idx": 9,
+ "version": "7",
+ "when": 1787686357931,
+ "tag": "0009_nosy_alice",
+ "breakpoints": true
+ },
+ {
+ "idx": 10,
+ "version": "7",
+ "when": 1787688761615,
+ "tag": "0010_common_masque",
+ "breakpoints": true
+ },
+ {
+ "idx": 11,
+ "version": "7",
+ "when": 1787690760361,
+ "tag": "0011_cloudy_gwen_stacy",
+ "breakpoints": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 081cbc5..57f8066 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"postgres": "^3.4.9",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+ "resend": "^6.21.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -453,7 +454,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -470,7 +470,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -487,7 +486,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -504,7 +502,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -521,7 +518,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -538,7 +534,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -555,7 +550,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -572,7 +566,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -589,7 +582,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -606,7 +598,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -623,7 +614,6 @@
"cpu": [
"loong64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -640,7 +630,6 @@
"cpu": [
"mips64el"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -657,7 +646,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -674,7 +662,6 @@
"cpu": [
"riscv64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -691,7 +678,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -708,7 +694,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -725,7 +710,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -742,7 +726,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -759,7 +742,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -776,7 +758,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -793,7 +774,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -810,7 +790,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -877,7 +856,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -894,7 +872,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -911,7 +888,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -928,7 +904,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -945,7 +920,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -962,7 +936,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -979,7 +952,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -996,7 +968,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1013,7 +984,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1030,7 +1000,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1047,7 +1016,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1064,7 +1032,6 @@
"cpu": [
"loong64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1081,7 +1048,6 @@
"cpu": [
"mips64el"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1098,7 +1064,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1115,7 +1080,6 @@
"cpu": [
"riscv64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1132,7 +1096,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1149,7 +1112,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1166,7 +1128,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1183,7 +1144,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1200,7 +1160,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1217,7 +1176,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1234,7 +1192,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1251,7 +1208,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1268,7 +1224,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1285,7 +1240,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1302,7 +1256,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1455,7 +1408,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1472,7 +1424,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1489,7 +1440,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1506,7 +1456,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1523,7 +1472,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1540,7 +1488,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1557,7 +1504,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1574,7 +1520,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1591,7 +1536,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1608,7 +1552,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1625,7 +1568,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1642,7 +1584,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1659,7 +1600,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1676,7 +1616,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1693,6 +1632,12 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/@stablelib/base64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
+ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
+ "license": "MIT"
+ },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -2158,7 +2103,7 @@
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -2621,7 +2566,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2638,7 +2582,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2655,7 +2598,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2672,7 +2614,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2689,7 +2630,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2706,7 +2646,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2723,7 +2662,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2740,7 +2678,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2757,7 +2694,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2774,7 +2710,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2791,7 +2726,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2808,7 +2742,6 @@
"cpu": [
"loong64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2825,7 +2758,6 @@
"cpu": [
"mips64el"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2842,7 +2774,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2859,7 +2790,6 @@
"cpu": [
"riscv64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2876,7 +2806,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2893,7 +2822,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2910,7 +2838,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2927,7 +2854,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2944,7 +2870,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2961,7 +2886,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2978,7 +2902,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2995,7 +2918,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3012,7 +2934,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3029,7 +2950,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3046,7 +2966,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3293,6 +3212,12 @@
"integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
"license": "MIT"
},
+ "node_modules/fast-sha256": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
+ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
+ "license": "Unlicense"
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -3320,7 +3245,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -3826,6 +3750,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/postal-mime": {
+ "version": "2.7.5",
+ "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.5.tgz",
+ "integrity": "sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==",
+ "license": "MIT-0"
+ },
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
@@ -3927,6 +3857,27 @@
"node": ">=0.10.0"
}
},
+ "node_modules/resend": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/resend/-/resend-6.21.0.tgz",
+ "integrity": "sha512-XT1eP2iA8rZ51NSO2CJlL1ZE2baBoPpOGMgz2p0o2uluz/YU3XfItpGymXDlL0Sp2Mc+y9+Xxsfdccfi4f2FzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "postal-mime": "2.7.5",
+ "standardwebhooks": "1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@react-email/render": "*"
+ },
+ "peerDependenciesMeta": {
+ "@react-email/render": {
+ "optional": true
+ }
+ }
+ },
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -4070,6 +4021,16 @@
"node": ">=20.16.0"
}
},
+ "node_modules/standardwebhooks": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
+ "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
+ "license": "MIT",
+ "dependencies": {
+ "@stablelib/base64": "^1.0.0",
+ "fast-sha256": "^1.3.0"
+ }
+ },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -4157,7 +4118,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/unplugin": {
diff --git a/package.json b/package.json
index 77e8eea..6ab506b 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
"#/*": "./src/*"
},
"scripts": {
- "dev": "vite dev --port 3000",
+ "dev": "vite dev --port 3000 --host",
"generate-routes": "tsr generate",
"build": "vite build",
"preview": "vite preview",
@@ -24,6 +24,7 @@
"postgres": "^3.4.9",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+ "resend": "^6.21.0",
"zod": "^4.4.3"
},
"devDependencies": {
diff --git a/src/components/action-menu.tsx b/src/components/action-menu.tsx
new file mode 100644
index 0000000..604182a
--- /dev/null
+++ b/src/components/action-menu.tsx
@@ -0,0 +1,108 @@
+import { useEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+
+type Props = {
+ label?: string
+ children: React.ReactNode
+}
+
+function DotsIcon() {
+ return (
+
+
+
+
+
+ )
+}
+
+type Position = { top: number; right: number }
+
+// A small "⋮" menu that reveals its children (typically a stack of `.btn`s) in a
+// dropdown — used to tuck row actions away instead of listing every button inline.
+// The panel is portaled to and positioned with `position: fixed` from a
+// measured rect rather than anchored with `position: absolute` in place, since
+// rows live inside `.table-scroll` (`overflow-x: auto`, which per the CSS spec
+// forces `overflow-y` to clip too) — an in-place dropdown on the last row or two
+// would otherwise get cut off at the table's bottom edge.
+export function ActionMenu({ label = 'Aktionen', children }: Props) {
+ const [position, setPosition] = useState(null)
+ const triggerRef = useRef(null)
+ const panelRef = useRef(null)
+ const open = position !== null
+
+ function close() {
+ setPosition(null)
+ }
+
+ function toggle() {
+ if (open) {
+ close()
+ return
+ }
+
+ const rect = triggerRef.current?.getBoundingClientRect()
+ if (!rect) return
+ setPosition({ top: rect.bottom + 4, right: window.innerWidth - rect.right })
+ }
+
+ useEffect(() => {
+ if (!open) return
+
+ function handleOutsideClick(event: MouseEvent) {
+ const target = event.target as Node
+ if (!triggerRef.current?.contains(target) && !panelRef.current?.contains(target)) {
+ close()
+ }
+ }
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') close()
+ }
+ // The menu's position is measured once, on open — rather than track scroll to
+ // keep it glued to the trigger, just close it, matching how most native and
+ // web dropdowns behave when the page moves under them.
+ function handleScroll() {
+ close()
+ }
+
+ document.addEventListener('mousedown', handleOutsideClick)
+ document.addEventListener('keydown', handleKeyDown)
+ window.addEventListener('scroll', handleScroll, true)
+ return () => {
+ document.removeEventListener('mousedown', handleOutsideClick)
+ document.removeEventListener('keydown', handleKeyDown)
+ window.removeEventListener('scroll', handleScroll, true)
+ }
+ }, [open])
+
+ return (
+ <>
+
+
+
+
+ {open &&
+ position &&
+ createPortal(
+
+ {children}
+
,
+ document.body,
+ )}
+ >
+ )
+}
diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx
new file mode 100644
index 0000000..0c2c285
--- /dev/null
+++ b/src/components/app-sidebar.tsx
@@ -0,0 +1,128 @@
+import { useEffect, useRef, useState } from 'react'
+import { Link, useRouter } from '@tanstack/react-router'
+
+import { authClient } from '#/lib/auth-client'
+
+type StaffUser = { name: string; role?: string | null }
+type SectionLink = { label: string; hash: string }
+
+type Props = {
+ staffUser?: StaffUser | null
+ sectionLinks?: SectionLink[]
+}
+
+function MenuIcon() {
+ return (
+
+
+
+ )
+}
+
+export function AppSidebar({ staffUser, sectionLinks }: Props) {
+ const router = useRouter()
+ const [isOpen, setIsOpen] = useState(false)
+ const navRef = useRef(null)
+
+ useEffect(() => {
+ if (!isOpen) return
+
+ function handleOutsideClick(event: MouseEvent) {
+ if (navRef.current && !navRef.current.contains(event.target as Node)) {
+ setIsOpen(false)
+ }
+ }
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') setIsOpen(false)
+ }
+
+ document.addEventListener('mousedown', handleOutsideClick)
+ document.addEventListener('keydown', handleKeyDown)
+ return () => {
+ document.removeEventListener('mousedown', handleOutsideClick)
+ document.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [isOpen])
+
+ async function handleLogout() {
+ await authClient.signOut()
+ await router.navigate({ to: '/staff/login' })
+ await router.invalidate()
+ }
+
+ return (
+
+
+ setIsOpen(false)}>
+ Steakhaus bei Mirko
+
+ setIsOpen((open) => !open)}
+ >
+
+
+
+
+ setIsOpen(false)}>
+
+ {sectionLinks && sectionLinks.length > 0 && (
+ <>
+ {sectionLinks.map((section) => (
+
+
+ {section.label}
+
+
+ ))}
+
+ >
+ )}
+ {staffUser && (
+
+
+ Reservierungen
+
+
+ )}
+ {staffUser?.role === 'admin' && (
+
+
+ Mitarbeiter
+
+
+ )}
+ {staffUser && }
+
+
+ Startseite
+
+
+
+
+ Impressum
+
+
+
+
+
+ {staffUser ? (
+ <>
+ Angemeldet als {staffUser.name}
+
+ Abmelden
+
+ >
+ ) : (
+
+ Mitarbeiter-Login
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/confirm-dialog.tsx b/src/components/confirm-dialog.tsx
new file mode 100644
index 0000000..f560b7d
--- /dev/null
+++ b/src/components/confirm-dialog.tsx
@@ -0,0 +1,99 @@
+import { useEffect, useRef } from 'react'
+
+type Props = {
+ open: boolean
+ title: string
+ message: React.ReactNode
+ // A final, visually distinct callout for what irreversible actions this
+ // confirms — kept separate from `message` so callers don't have to hand-roll
+ // the emphasis every time (e.g. "This cannot be undone.").
+ warning?: React.ReactNode
+ confirmLabel?: string
+ cancelLabel?: string
+ danger?: boolean
+ onConfirm: () => void
+ onCancel: () => void
+}
+
+function WarningIcon() {
+ return (
+
+
+
+
+
+ )
+}
+
+export function ConfirmDialog({
+ open,
+ title,
+ message,
+ warning,
+ confirmLabel = 'Bestätigen',
+ cancelLabel = 'Abbrechen',
+ danger,
+ onConfirm,
+ onCancel,
+}: Props) {
+ const confirmRef = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+
+ confirmRef.current?.focus()
+
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') onCancel()
+ }
+
+ document.addEventListener('keydown', handleKeyDown)
+ return () => document.removeEventListener('keydown', handleKeyDown)
+ }, [open, onCancel])
+
+ if (!open) return null
+
+ return (
+
+
event.stopPropagation()}
+ >
+
+ {title}
+
+
+ {message}
+
+ {warning && (
+
+
+ {warning}
+
+ )}
+
+
+ {cancelLabel}
+
+
+ {confirmLabel}
+
+
+
+
+ )
+}
diff --git a/src/components/reservation-charts.tsx b/src/components/reservation-charts.tsx
new file mode 100644
index 0000000..98d6e1c
--- /dev/null
+++ b/src/components/reservation-charts.tsx
@@ -0,0 +1,321 @@
+import { useState } from 'react'
+
+import { getOpeningRangesForWeekday, type OpeningRange } from '#/lib/opening-hours'
+
+type ReservationLike = {
+ tableId: number
+ reservationTime: Date
+}
+
+type TableLike = {
+ id: number
+ name: string
+}
+
+type Props = {
+ reservations: ReservationLike[]
+ tables: TableLike[]
+}
+
+type BarDatum = {
+ label: string
+ value: number
+ tooltip: string
+}
+
+type Granularity = 'today' | 'week' | 'month'
+type DateBucketGranularity = 'week' | 'month'
+
+const GRANULARITY_LABELS: Record = {
+ today: 'Heute',
+ week: 'Woche',
+ month: 'Monat',
+}
+
+// How many buckets — and how far out — each granularity covers: enough weeks/months
+// to spot a trend without the axis becoming unreadable.
+const BUCKET_COUNTS: Record = {
+ week: 8,
+ month: 6,
+}
+
+const WEEK_LABEL_FORMATTER = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit' })
+const MONTH_AXIS_FORMATTER = new Intl.DateTimeFormat('de-DE', { month: 'short' })
+const MONTH_TOOLTIP_FORMATTER = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
+
+function reservationsLabel(count: number) {
+ return count === 1 ? 'Reservierung' : 'Reservierungen'
+}
+
+function addBucketUnit(date: Date, granularity: DateBucketGranularity, amount: number) {
+ const d = new Date(date)
+ if (granularity === 'week') d.setDate(d.getDate() + amount * 7)
+ else d.setMonth(d.getMonth() + amount)
+ return d
+}
+
+// The start of the first bucket: the Monday of this week, or the 1st of this month —
+// so the current, still-in-progress bucket covers the whole period, not just what's
+// left of it (the caller is expected to pass in ALL reservations, not just upcoming
+// ones, so this week's/month's earlier days are still counted).
+function bucketRangeStart(granularity: DateBucketGranularity) {
+ const now = new Date()
+ now.setHours(0, 0, 0, 0)
+
+ if (granularity === 'month') {
+ now.setDate(1)
+ return now
+ }
+ const mondayOffset = (now.getDay() + 6) % 7
+ now.setDate(now.getDate() - mondayOffset)
+ return now
+}
+
+function formatBucketLabel(start: Date, granularity: DateBucketGranularity) {
+ if (granularity === 'month') return MONTH_AXIS_FORMATTER.format(start)
+ return WEEK_LABEL_FORMATTER.format(start)
+}
+
+function formatBucketTooltip(start: Date, end: Date, granularity: DateBucketGranularity, count: number) {
+ const countLabel = `${count} ${reservationsLabel(count)}`
+
+ if (granularity === 'month') return `${MONTH_TOOLTIP_FORMATTER.format(start)}: ${countLabel}`
+
+ const lastDayOfWeek = new Date(end.getTime() - 24 * 60 * 60 * 1000)
+ return `${WEEK_LABEL_FORMATTER.format(start)}–${WEEK_LABEL_FORMATTER.format(lastDayOfWeek)}: ${countLabel}`
+}
+
+function buildBucketedCounts(reservations: ReservationLike[], granularity: DateBucketGranularity): BarDatum[] {
+ const rangeStart = bucketRangeStart(granularity)
+ const bucketCount = BUCKET_COUNTS[granularity]
+
+ return Array.from({ length: bucketCount }, (_, i) => {
+ const start = addBucketUnit(rangeStart, granularity, i)
+ const end = addBucketUnit(rangeStart, granularity, i + 1)
+ const count = reservations.filter((r) => {
+ const time = new Date(r.reservationTime).getTime()
+ return time >= start.getTime() && time < end.getTime()
+ }).length
+
+ return {
+ label: formatBucketLabel(start, granularity),
+ value: count,
+ tooltip: formatBucketTooltip(start, end, granularity, count),
+ }
+ })
+}
+
+// The restaurant can have more than one service window in a day (e.g. lunch +
+// dinner) — span the whole gap between them rather than faceting the chart, so a
+// quiet hour between services reads as "no bookings then", not a missing bar.
+function hourSpanForRanges(ranges: OpeningRange[]) {
+ const startHour = Math.min(...ranges.map((r) => Number(r.start.split(':')[0])))
+ const endHour = Math.max(
+ ...ranges.map((r) => {
+ const [hour, minute] = r.end.split(':').map(Number)
+ return minute > 0 ? hour + 1 : hour
+ }),
+ )
+ return { startHour, endHour }
+}
+
+function buildHourlyCountsForToday(reservations: ReservationLike[], ranges: OpeningRange[]): BarDatum[] {
+ const { startHour, endHour } = hourSpanForRanges(ranges)
+ const dayStart = new Date()
+ dayStart.setHours(0, 0, 0, 0)
+
+ return Array.from({ length: endHour - startHour }, (_, i) => {
+ const hour = startHour + i
+ const start = new Date(dayStart)
+ start.setHours(hour)
+ const end = new Date(dayStart)
+ end.setHours(hour + 1)
+
+ const count = reservations.filter((r) => {
+ const time = new Date(r.reservationTime).getTime()
+ return time >= start.getTime() && time < end.getTime()
+ }).length
+
+ return {
+ label: `${String(hour).padStart(2, '0')}:00`,
+ value: count,
+ tooltip: `${String(hour).padStart(2, '0')}:00–${String(hour + 1).padStart(2, '0')}:00: ${count} ${reservationsLabel(count)}`,
+ }
+ })
+}
+
+function buildTimelineData(
+ reservations: ReservationLike[],
+ granularity: Granularity,
+): { data: BarDatum[]; emptyMessage: string } {
+ if (granularity === 'today') {
+ const ranges = getOpeningRangesForWeekday(new Date().getDay())
+ if (ranges.length === 0) {
+ return { data: [], emptyMessage: 'Heute ist Ruhetag.' }
+ }
+ return {
+ data: buildHourlyCountsForToday(reservations, ranges),
+ emptyMessage: 'Heute sind noch keine Reservierungen eingetragen.',
+ }
+ }
+
+ return {
+ data: buildBucketedCounts(reservations, granularity),
+ emptyMessage: 'In diesem Zeitraum sind noch keine Reservierungen eingetragen.',
+ }
+}
+
+// The full date range the timeline chart currently covers, so the per-table chart
+// can be filtered to the same window and the two charts always tell one consistent
+// story instead of the table breakdown silently covering a different span.
+function getTimelineRange(granularity: Granularity): { start: Date; end: Date } {
+ if (granularity === 'today') {
+ const start = new Date()
+ start.setHours(0, 0, 0, 0)
+ const end = new Date(start)
+ end.setDate(end.getDate() + 1)
+ return { start, end }
+ }
+
+ const start = bucketRangeStart(granularity)
+ const end = addBucketUnit(start, granularity, BUCKET_COUNTS[granularity])
+ return { start, end }
+}
+
+function buildTableCounts(reservations: ReservationLike[], tables: TableLike[]): BarDatum[] {
+ return tables.map((table) => {
+ const count = reservations.filter((r) => r.tableId === table.id).length
+ return {
+ label: table.name,
+ value: count,
+ tooltip: `${table.name}: ${count} ${reservationsLabel(count)}`,
+ }
+ })
+}
+
+// Produces clean, evenly-stepped tick values (0, 2, 4, 6, …) so the gridlines
+// never land on an awkward number like 7 or 13.
+function niceTicks(maxValue: number, tickCount = 4): number[] {
+ if (maxValue <= 0) return [0, 1]
+
+ const rawStep = maxValue / (tickCount - 1)
+ const magnitude = 10 ** Math.floor(Math.log10(rawStep))
+ const residual = rawStep / magnitude
+ const niceResidual = residual <= 1 ? 1 : residual <= 2 ? 2 : residual <= 5 ? 5 : 10
+ // Counts are always whole numbers, so a sub-1 step (e.g. 0.5) would just
+ // round two distinct ticks down to the same displayed integer.
+ const step = Math.max(1, niceResidual * magnitude)
+ const niceMax = Math.ceil(maxValue / step) * step
+
+ const ticks: number[] = []
+ for (let v = 0; v <= niceMax + 1e-9; v += step) ticks.push(Math.round(v))
+ return ticks
+}
+
+function BarChartBody({ data, showValueLabels }: { data: BarDatum[]; showValueLabels?: boolean }) {
+ const ticks = niceTicks(Math.max(...data.map((d) => d.value), 0))
+ const chartMax = ticks[ticks.length - 1] || 1
+
+ return (
+
+
+ {ticks.map((tick) => (
+
+ {tick}
+
+ ))}
+
+ {data.map((d, i) => (
+
+
+ {showValueLabels && d.value > 0 && {d.value} }
+
+
{d.tooltip}
+
+ ))}
+
+
+
+ {data.map((d, i) => (
+
+ {d.label}
+
+ ))}
+
+
+ )
+}
+
+function ChartCard({
+ title,
+ headerExtra,
+ data,
+ showValueLabels,
+ emptyMessage,
+}: {
+ title: string
+ headerExtra?: React.ReactNode
+ data: BarDatum[]
+ showValueLabels?: boolean
+ emptyMessage: string
+}) {
+ const hasData = data.some((d) => d.value > 0)
+
+ return (
+
+
+
{title}
+ {headerExtra}
+
+ {hasData ?
:
{emptyMessage}
}
+
+ )
+}
+
+function GranularityToggle({ value, onChange }: { value: Granularity; onChange: (g: Granularity) => void }) {
+ return (
+
+ {(Object.keys(GRANULARITY_LABELS) as Granularity[]).map((g) => (
+ onChange(g)}
+ >
+ {GRANULARITY_LABELS[g]}
+
+ ))}
+
+ )
+}
+
+export function ReservationCharts({ reservations, tables }: Props) {
+ const [granularity, setGranularity] = useState('today')
+
+ const timeline = buildTimelineData(reservations, granularity)
+
+ const { start, end } = getTimelineRange(granularity)
+ const reservationsInRange = reservations.filter((r) => {
+ const time = new Date(r.reservationTime).getTime()
+ return time >= start.getTime() && time < end.getTime()
+ })
+ const tableCounts = buildTableCounts(reservationsInRange, tables)
+
+ return (
+
+ }
+ data={timeline.data}
+ emptyMessage={timeline.emptyMessage}
+ />
+
+
+ )
+}
diff --git a/src/components/reservation-confirmed-modal.tsx b/src/components/reservation-confirmed-modal.tsx
new file mode 100644
index 0000000..4b2c71c
--- /dev/null
+++ b/src/components/reservation-confirmed-modal.tsx
@@ -0,0 +1,108 @@
+import { useEffect, useRef } from 'react'
+
+type Props = {
+ open: boolean
+ customerName: string
+ customerEmail: string
+ tableName: string
+ date: string
+ startTime: string
+ endTime: string
+ partySize: number
+ onClose: () => void
+}
+
+function FlameBadgeIcon() {
+ return (
+
+
+
+ )
+}
+
+export function ReservationConfirmedModal({
+ open,
+ customerName,
+ customerEmail,
+ tableName,
+ date,
+ startTime,
+ endTime,
+ partySize,
+ onClose,
+}: Props) {
+ const closeRef = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+
+ closeRef.current?.focus()
+
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') onClose()
+ }
+
+ document.addEventListener('keydown', handleKeyDown)
+ return () => document.removeEventListener('keydown', handleKeyDown)
+ }, [open, onClose])
+
+ if (!open) return null
+
+ return (
+
+
event.stopPropagation()}
+ >
+
+
+
+ Tisch reserviert!
+
+
+ Bis bald, {customerName}! Wir halten Ihnen den Platz warm und legen schon mal Kohle nach.
+
+
+
+
+
Tisch
+ {tableName}
+
+
+
Datum
+ {date}
+
+
+
Uhrzeit
+
+ {startTime} – {endTime}
+
+
+
+
Personen
+ {partySize}
+
+
+
+
+ Eine Bestätigung ist unterwegs an {customerEmail} . Dort finden Sie auch einen
+ Link, falls sich doch noch etwas ändert.
+
+
+
+
+ Alles klar
+
+
+
+
+ )
+}
diff --git a/src/components/reservation-datetime-field.tsx b/src/components/reservation-datetime-field.tsx
new file mode 100644
index 0000000..835a818
--- /dev/null
+++ b/src/components/reservation-datetime-field.tsx
@@ -0,0 +1,362 @@
+import { useEffect, useRef, useState } from 'react'
+
+import { formatDateOnly } from '#/lib/datetime'
+import { generateTimeOptions, getOpeningRangesForWeekday, RESERVATION_INTERVAL_MINUTES } from '#/lib/opening-hours'
+
+type Props = {
+ value: string
+ onChange: (value: string) => void
+ // "HH:MM" times already taken (e.g. by another reservation on the same table)
+ // for the currently selected date — shown disabled rather than removed, so the
+ // slot doesn't look like it doesn't exist, just that it isn't free.
+ bookedTimes?: string[]
+ // How far apart selectable times are, and how far in advance a booking must be
+ // made — both configurable restaurant settings, falling back to sane defaults.
+ intervalMinutes?: number
+ minLeadTimeMinutes?: number
+ // How long a reservation booked at a slot occupies the table — a slot too close
+ // to closing time to fit the whole reservation is left out of the list entirely.
+ durationMinutes?: number
+ // How far into the future a booking may be made. Undefined leaves the horizon open,
+ // since not every caller of this field needs the restriction.
+ maxLeadTimeDays?: number
+}
+
+type DayCell = { day: number; dateStr: string } | null
+
+function splitValue(value: string) {
+ const [date = '', time = ''] = value.split('T')
+ return { date, time }
+}
+
+function getWeekday(dateStr: string) {
+ return new Date(`${dateStr}T00:00`).getDay()
+}
+
+function pad2(n: number) {
+ return String(n).padStart(2, '0')
+}
+
+function isoToDisplay(iso: string) {
+ if (!iso) return ''
+ const [year, month, day] = iso.split('-')
+ return `${day}.${month}.${year}`
+}
+
+// Progressively inserts dots as digits are typed, e.g. "22081" -> "22.08.1".
+function maskDateInput(raw: string) {
+ const digits = raw.replace(/\D/g, '').slice(0, 8)
+ let out = digits.slice(0, 2)
+ if (digits.length > 2) out += '.' + digits.slice(2, 4)
+ if (digits.length > 4) out += '.' + digits.slice(4, 8)
+ return out
+}
+
+// Returns an ISO "YYYY-MM-DD" string, or null if the text isn't a complete, real calendar date.
+function parseDisplayDate(raw: string) {
+ const digits = raw.replace(/\D/g, '')
+ if (digits.length !== 8) return null
+
+ const day = Number(digits.slice(0, 2))
+ const month = Number(digits.slice(2, 4))
+ const year = Number(digits.slice(4, 8))
+ const parsed = new Date(year, month - 1, day)
+
+ if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || parsed.getDate() !== day) {
+ return null
+ }
+
+ return `${digits.slice(4, 8)}-${digits.slice(2, 4)}-${digits.slice(0, 2)}`
+}
+
+// Monday-first weeks covering the given month, with null filler cells for days
+// that belong to the adjacent month.
+function getMonthGrid(year: number, month: number): DayCell[][] {
+ const firstOfMonth = new Date(year, month, 1)
+ const leadingBlanks = (firstOfMonth.getDay() + 6) % 7
+ const daysInMonth = new Date(year, month + 1, 0).getDate()
+
+ const cells: DayCell[] = Array(leadingBlanks).fill(null)
+ for (let day = 1; day <= daysInMonth; day++) {
+ cells.push({ day, dateStr: `${year}-${pad2(month + 1)}-${pad2(day)}` })
+ }
+ while (cells.length % 7 !== 0) cells.push(null)
+
+ const weeks: DayCell[][] = []
+ for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7))
+ return weeks
+}
+
+const WEEKDAY_HEADER_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
+
+function CalendarIcon() {
+ return (
+
+
+
+
+ )
+}
+
+export function ReservationDateTimeField({
+ value,
+ onChange,
+ bookedTimes = [],
+ intervalMinutes = RESERVATION_INTERVAL_MINUTES,
+ minLeadTimeMinutes = 0,
+ durationMinutes = RESERVATION_INTERVAL_MINUTES,
+ maxLeadTimeDays,
+}: Props) {
+ const { date, time } = splitValue(value)
+ const [dateText, setDateText] = useState(() => isoToDisplay(date))
+ const [isCalendarOpen, setIsCalendarOpen] = useState(false)
+ const [view, setView] = useState(() => {
+ const [year, month] = (date || formatDateOnly(new Date())).split('-')
+ return { year: Number(year), month: Number(month) - 1 }
+ })
+ const containerRef = useRef(null)
+
+ useEffect(() => {
+ setDateText(isoToDisplay(date))
+ }, [date])
+
+ useEffect(() => {
+ if (!isCalendarOpen) return
+
+ const [year, month] = (date || formatDateOnly(new Date())).split('-')
+ setView({ year: Number(year), month: Number(month) - 1 })
+
+ function handleOutsideClick(event: MouseEvent) {
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+ setIsCalendarOpen(false)
+ }
+ }
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') setIsCalendarOpen(false)
+ }
+
+ document.addEventListener('mousedown', handleOutsideClick)
+ document.addEventListener('keydown', handleKeyDown)
+
+ // Only locks the background on mobile, where the popover becomes a fixed,
+ // full-screen overlay that would otherwise let the page behind it scroll —
+ // on desktop the popover is just a small anchored dropdown, and pinning the
+ // body there only removes the scrollbar and shifts the whole page sideways.
+ // Matches the `@media (max-width: 30rem)` breakpoint the popover-to-modal
+ // CSS switches on.
+ const isMobile = window.matchMedia('(max-width: 30rem)').matches
+
+ if (!isMobile) {
+ return () => {
+ document.removeEventListener('mousedown', handleOutsideClick)
+ document.removeEventListener('keydown', handleKeyDown)
+ }
+ }
+
+ // `overflow: hidden` alone doesn't stop touch-driven rubber-band scrolling on
+ // iOS Safari, so the body is pinned in place instead and the scroll position
+ // restored after.
+ const scrollY = window.scrollY
+ const body = document.body.style
+ const previousStyle = { position: body.position, top: body.top, left: body.left, right: body.right }
+ body.position = 'fixed'
+ body.top = `-${scrollY}px`
+ body.left = '0'
+ body.right = '0'
+
+ return () => {
+ document.removeEventListener('mousedown', handleOutsideClick)
+ document.removeEventListener('keydown', handleKeyDown)
+ body.position = previousStyle.position
+ body.top = previousStyle.top
+ body.left = previousStyle.left
+ body.right = previousStyle.right
+ // `behavior: 'instant'` is required, not just the default — the page sets
+ // `scroll-behavior: smooth` globally, which would otherwise animate this
+ // restore from the top of the page instead of snapping back in place.
+ window.scrollTo({ top: scrollY, left: 0, behavior: 'instant' })
+ }
+ // Only re-run when the popover opens/closes — reopening should reset to the
+ // currently selected month, not follow every keystroke in the text field.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isCalendarOpen])
+
+ const today = formatDateOnly(new Date())
+ const isPast = date !== '' && date < today
+ const earliestAllowedTime = new Date(Date.now() + minLeadTimeMinutes * 60_000)
+ const latestDate =
+ maxLeadTimeDays === undefined ? null : formatDateOnly(new Date(Date.now() + maxLeadTimeDays * 86_400_000))
+ const isBeyondHorizon = date !== '' && latestDate !== null && date > latestDate
+
+ // Past dates and dates beyond the booking horizon never have slots — shared by both
+ // the render below and commitDate, so a date committed from anywhere (typed, or
+ // otherwise) can't slip through with a leftover, still-"valid"-looking time.
+ function optionsForDate(dateStr: string) {
+ if (!dateStr || dateStr < today || (latestDate !== null && dateStr > latestDate)) return []
+ return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes, durationMinutes)
+ }
+
+ function isSelectableDate(dateStr: string) {
+ return optionsForDate(dateStr).length > 0
+ }
+
+ const allOptions = optionsForDate(date)
+ // Compares full timestamps (not just "is it today") so a lead time longer than
+ // what's left of today correctly spills over into tomorrow's early slots too.
+ const timeOptions = allOptions.filter(
+ (option) => new Date(`${date}T${option}`).getTime() >= earliestAllowedTime.getTime(),
+ )
+
+ const bookedTimesSet = new Set(bookedTimes)
+ const availableTimeOptions = timeOptions.filter((option) => !bookedTimesSet.has(option))
+
+ const isClosed = date !== '' && !isPast && !isBeyondHorizon && allOptions.length === 0
+ const noSlotsLeft = date !== '' && !isPast && allOptions.length > 0 && availableTimeOptions.length === 0
+ const dateIncomplete = parseDisplayDate(dateText) === null
+
+ function commitDate(newDate: string) {
+ const newOptions = optionsForDate(newDate)
+ const stillValid = newOptions.includes(time)
+ onChange(`${newDate}T${stillValid ? time : newOptions[0] ?? ''}`)
+ }
+
+ function handleTextChange(raw: string) {
+ const masked = maskDateInput(raw)
+ setDateText(masked)
+
+ const parsed = parseDisplayDate(masked)
+ if (parsed) {
+ commitDate(parsed)
+ }
+ }
+
+ function handleDayPick(dateStr: string) {
+ commitDate(dateStr)
+ setIsCalendarOpen(false)
+ }
+
+ function handleTimeChange(newTime: string) {
+ onChange(`${date}T${newTime}`)
+ }
+
+ let timePlaceholder = 'Uhrzeit wählen'
+ if (dateIncomplete) timePlaceholder = 'Datum eingeben'
+ else if (isPast) timePlaceholder = 'Datum liegt in der Vergangenheit'
+ else if (isBeyondHorizon) timePlaceholder = 'Datum liegt zu weit in der Zukunft'
+ else if (isClosed) timePlaceholder = 'An diesem Tag geschlossen'
+ else if (noSlotsLeft) timePlaceholder = 'Keine Zeiten mehr verfügbar'
+
+ const monthLabel = new Date(view.year, view.month, 1).toLocaleDateString('de-DE', {
+ month: 'long',
+ year: 'numeric',
+ })
+ const [todayYear, todayMonthStr] = today.split('-')
+ const isAtEarliestMonth = view.year === Number(todayYear) && view.month === Number(todayMonthStr) - 1
+ const isAtLatestMonth = (() => {
+ if (latestDate === null) return false
+ const [latestYear, latestMonthStr] = latestDate.split('-')
+ return view.year === Number(latestYear) && view.month === Number(latestMonthStr) - 1
+ })()
+
+ function goToMonth(offset: number) {
+ setView((v) => {
+ const total = v.year * 12 + v.month + offset
+ return { year: Math.floor(total / 12), month: ((total % 12) + 12) % 12 }
+ })
+ }
+
+ return (
+
+
+
handleTextChange(e.target.value)}
+ required
+ />
+
setIsCalendarOpen((open) => !open)}
+ >
+
+
+
+ {isCalendarOpen && (
+
setIsCalendarOpen(false)}>
+
event.stopPropagation()}
+ >
+
+ goToMonth(-1)}
+ >
+ ‹
+
+ {monthLabel}
+ goToMonth(1)}
+ >
+ ›
+
+
+
+ {WEEKDAY_HEADER_LABELS.map((label) => (
+ {label}
+ ))}
+
+
+ {getMonthGrid(view.year, view.month)
+ .flat()
+ .map((cell, index) => (
+ cell && handleDayPick(cell.dateStr)}
+ >
+ {cell?.day ?? ''}
+
+ ))}
+
+
+
+ )}
+
+
+
handleTimeChange(e.target.value)}
+ disabled={availableTimeOptions.length === 0}
+ required
+ >
+
+ {timePlaceholder}
+
+ {timeOptions.map((option) => (
+
+ {option}
+ {bookedTimesSet.has(option) ? ' (belegt)' : ''}
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/reservation-dialog.tsx b/src/components/reservation-dialog.tsx
new file mode 100644
index 0000000..02e150d
--- /dev/null
+++ b/src/components/reservation-dialog.tsx
@@ -0,0 +1,456 @@
+import { useEffect, useState } from 'react'
+
+import { ActionMenu } from '#/components/action-menu'
+import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
+import { formatDateOnly, formatDateTimeLocal } from '#/lib/datetime'
+import { generateTimeOptions, getNextAvailableSlot, getOpeningRangesForWeekday } from '#/lib/opening-hours'
+import {
+ RESERVATION_STATUS_LABELS,
+ type ReservationStatus,
+ type getReservations,
+ type getTables,
+} from '#/lib/reservations'
+import { type getReservationSettings } from '#/lib/settings'
+
+type ReservationRow = Awaited>[number]
+type TableRow = Awaited>[number]
+type SettingsRow = Awaited>
+
+export type ReservationPayload = {
+ tableId: number
+ customerName: string
+ customerEmail: string
+ customerPhone: string
+ message: string
+ partySize: number
+ reservationTime: string
+}
+
+type FormState = {
+ tableId: number | string
+ customerName: string
+ customerEmail: string
+ customerPhone: string
+ message: string
+ partySize: number
+ reservationTime: string
+}
+
+type Props = {
+ // `null` reservation with `open: true` is "create" mode.
+ open: boolean
+ reservation: ReservationRow | null
+ tables: TableRow[]
+ settings: SettingsRow
+ reservations: ReservationRow[]
+ onClose: () => void
+ onCreate: (payload: ReservationPayload) => Promise
+ onUpdate: (id: number, payload: ReservationPayload) => Promise
+ onStatusChange: (status: ReservationStatus) => void
+ isUpdatingStatus?: boolean
+ onDelete: () => void
+ statusError?: string | null
+}
+
+const STATUS_OPTIONS = Object.keys(RESERVATION_STATUS_LABELS) as ReservationStatus[]
+
+const dateFormatter = new Intl.DateTimeFormat('de-DE', {
+ dateStyle: 'full',
+})
+
+const timeFormatter = new Intl.DateTimeFormat('de-DE', {
+ timeStyle: 'short',
+})
+
+const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+// Also used as the phone input's `pattern` attribute below, so the two stay in sync.
+const PHONE_PATTERN_SOURCE = String.raw`[+]?[0-9()\-.\s]{6,30}`
+const PHONE_PATTERN = new RegExp(`^${PHONE_PATTERN_SOURCE}$`)
+
+function emptyForm(defaultTableId: number | string, settings: SettingsRow): FormState {
+ const slot = getNextAvailableSlot(
+ new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
+ settings.reservationIntervalMinutes,
+ settings.reservationDurationMinutes,
+ )
+
+ return {
+ tableId: defaultTableId,
+ customerName: '',
+ customerEmail: '',
+ customerPhone: '',
+ message: '',
+ partySize: 2,
+ reservationTime: slot ? `${slot.date}T${slot.time}` : '',
+ }
+}
+
+function formFromReservation(reservation: ReservationRow): FormState {
+ return {
+ tableId: reservation.tableId,
+ customerName: reservation.customerName,
+ customerEmail: reservation.customerEmail,
+ customerPhone: reservation.customerPhone ?? '',
+ message: reservation.message ?? '',
+ partySize: reservation.partySize,
+ reservationTime: formatDateTimeLocal(new Date(reservation.reservationTime)),
+ }
+}
+
+// Rendered with a `key` (see staff.index.tsx) so a fresh instance mounts every time
+// the dialog opens — that's what lets this component safely derive its initial
+// state from props once, instead of syncing it via effects.
+export function ReservationDialog({
+ open,
+ reservation,
+ tables,
+ settings,
+ reservations,
+ onClose,
+ onCreate,
+ onUpdate,
+ onStatusChange,
+ isUpdatingStatus,
+ onDelete,
+ statusError,
+}: Props) {
+ const [isEditing, setIsEditing] = useState(reservation === null)
+ const [form, setForm] = useState(() =>
+ reservation ? formFromReservation(reservation) : emptyForm(tables[0]?.id ?? '', settings),
+ )
+ const [error, setError] = useState(null)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ useEffect(() => {
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') onClose()
+ }
+
+ document.addEventListener('keydown', handleKeyDown)
+ return () => document.removeEventListener('keydown', handleKeyDown)
+ }, [onClose])
+
+ if (!open) return null
+
+ // Other reservations already sitting on the currently selected table + date, so the
+ // time picker can block them instead of letting staff pick a slot the server will
+ // just reject as double-booked. A reservation occupies its whole configured
+ // duration, not just its exact start time, so every interval-grid slot that falls
+ // inside that window counts as taken too. `minLeadTimeMinutes` is reused here as a
+ // table turnover buffer, extending the taken window on both sides so back-to-back
+ // bookings on the same table always leave that much of a gap.
+ const [selectedDate] = form.reservationTime.split('T')
+ const otherReservationsOnSelection = reservations.filter(
+ (r) =>
+ r.id !== reservation?.id &&
+ String(r.tableId) === String(form.tableId) &&
+ formatDateOnly(new Date(r.reservationTime)) === selectedDate,
+ )
+ const turnoverBufferMs = settings.minLeadTimeMinutes * 60_000
+ const bookedTimesForForm = selectedDate
+ ? generateTimeOptions(
+ getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()),
+ settings.reservationIntervalMinutes,
+ settings.reservationDurationMinutes,
+ ).filter((option) => {
+ const slotStart = new Date(`${selectedDate}T${option}`).getTime()
+ return otherReservationsOnSelection.some((r) => {
+ const otherStart = new Date(r.reservationTime).getTime() - turnoverBufferMs
+ const otherEnd = otherStart + r.durationMinutes * 60_000 + 2 * turnoverBufferMs
+ return slotStart >= otherStart && slotStart < otherEnd
+ })
+ })
+ : []
+
+ // Mirrors the form fields' own `required`/`type`/`pattern`/`min`/`max` constraints,
+ // so the submit button is disabled until they'd all actually pass — instead of only
+ // catching an invalid table/time on submit and leaving everything else to the
+ // browser's native validation popup.
+ const isFormValid =
+ Boolean(form.tableId) &&
+ form.customerName.trim().length > 0 &&
+ EMAIL_PATTERN.test(form.customerEmail) &&
+ PHONE_PATTERN.test(form.customerPhone) &&
+ form.partySize >= 1 &&
+ form.partySize <= 20 &&
+ Boolean(form.reservationTime.split('T')[1])
+
+ function startEditing() {
+ if (reservation) setForm(formFromReservation(reservation))
+ setError(null)
+ setIsEditing(true)
+ }
+
+ function cancelEditing() {
+ if (!reservation) {
+ onClose()
+ return
+ }
+ setForm(formFromReservation(reservation))
+ setError(null)
+ setIsEditing(false)
+ }
+
+ async function handleSubmit(event: React.FormEvent) {
+ event.preventDefault()
+ setError(null)
+
+ if (!form.tableId) {
+ setError('Bitte wählen Sie einen Tisch aus')
+ return
+ }
+
+ if (!form.reservationTime.split('T')[1]) {
+ setError('Bitte wählen Sie eine gültige Uhrzeit aus')
+ return
+ }
+
+ setIsSubmitting(true)
+ try {
+ const payload: ReservationPayload = {
+ tableId: Number(form.tableId),
+ customerName: form.customerName,
+ customerEmail: form.customerEmail,
+ customerPhone: form.customerPhone,
+ message: form.message,
+ partySize: Number(form.partySize),
+ reservationTime: new Date(form.reservationTime).toISOString(),
+ }
+
+ if (reservation) {
+ await onUpdate(reservation.id, payload)
+ } else {
+ await onCreate(payload)
+ }
+ onClose()
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const title = reservation
+ ? isEditing
+ ? `Reservierung von ${reservation.customerName} bearbeiten`
+ : `Reservierung von ${reservation.customerName}`
+ : 'Neue Reservierung'
+
+ return (
+
+
event.stopPropagation()}
+ >
+
+
+ {title}
+
+ {reservation && !isEditing && (
+
+ )}
+
+
+ {isEditing ? (
+
+ ) : (
+ reservation && (
+ <>
+
+ {STATUS_OPTIONS.map((status) => (
+ onStatusChange(status)}
+ >
+ {RESERVATION_STATUS_LABELS[status]}
+
+ ))}
+
+
+
+ {reservation.tableNumber !== null && (
+
+
Tischnummer
+ {reservation.tableNumber}
+
+ )}
+
+
Tisch
+ {reservation.tableName}
+
+
+
Datum
+ {dateFormatter.format(new Date(reservation.reservationTime))}
+
+
+
Start
+ {timeFormatter.format(new Date(reservation.reservationTime))}
+
+
+
Ende
+
+ {timeFormatter.format(
+ new Date(new Date(reservation.reservationTime).getTime() + reservation.durationMinutes * 60_000),
+ )}
+
+
+
+
Personen
+ {reservation.partySize}
+
+
+ {reservation.customerPhone && (
+
+ )}
+
+
+ {reservation.message && (
+
+
Nachricht
+
{reservation.message}
+
+ )}
+
+ {statusError &&
{statusError}
}
+
+
+
+ Bearbeiten
+
+
+ Schließen
+
+
+ >
+ )
+ )}
+
+
+ )
+}
diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx
index a149257..341ef8d 100644
--- a/src/components/site-header.tsx
+++ b/src/components/site-header.tsx
@@ -3,28 +3,14 @@ import { Link } from '@tanstack/react-router'
export function SiteHeader() {
return (
)
}
diff --git a/src/components/staff-member-detail-dialog.tsx b/src/components/staff-member-detail-dialog.tsx
new file mode 100644
index 0000000..e895fd5
--- /dev/null
+++ b/src/components/staff-member-detail-dialog.tsx
@@ -0,0 +1,83 @@
+import { useEffect } from 'react'
+
+import type { listStaffMembers } from '#/lib/staff'
+
+type StaffMember = Awaited>[number]
+
+type Props = {
+ member: StaffMember | null
+ onClose: () => void
+}
+
+const dateFormatter = new Intl.DateTimeFormat('de-DE', {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+})
+
+export function StaffMemberDetailDialog({ member, onClose }: Props) {
+ useEffect(() => {
+ if (!member) return
+
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') onClose()
+ }
+
+ document.addEventListener('keydown', handleKeyDown)
+ return () => document.removeEventListener('keydown', handleKeyDown)
+ }, [member, onClose])
+
+ if (!member) return null
+
+ return (
+
+
event.stopPropagation()}
+ >
+
+ {member.name}
+
+
+
+
+
+
+
Rolle
+ {member.role === 'admin' ? 'Admin' : 'Mitarbeiter'}
+
+
+
Status
+ {member.banned ? 'Gesperrt' : 'Aktiv'}
+
+ {member.banned && member.banReason && (
+
+
Sperrgrund
+ {member.banReason}
+
+ )}
+
+
Angelegt am
+ {dateFormatter.format(new Date(member.createdAt))}
+
+
+
+
+
+ Schließen
+
+
+
+
+ )
+}
diff --git a/src/components/staff-tabs.tsx b/src/components/staff-tabs.tsx
new file mode 100644
index 0000000..9a04664
--- /dev/null
+++ b/src/components/staff-tabs.tsx
@@ -0,0 +1,51 @@
+import { Link } from '@tanstack/react-router'
+
+type Props = {
+ role?: string | null
+}
+
+export function StaffTabs({ role }: Props) {
+ return (
+
+
+ Reservierungen
+
+ {role === 'admin' && (
+
+ Tische
+
+ )}
+ {role === 'admin' && (
+
+ Mitarbeiter
+
+ )}
+ {role === 'admin' && (
+
+ Einstellungen
+
+ )}
+
+ )
+}
diff --git a/src/components/status-badge.tsx b/src/components/status-badge.tsx
new file mode 100644
index 0000000..908b23c
--- /dev/null
+++ b/src/components/status-badge.tsx
@@ -0,0 +1,5 @@
+import { RESERVATION_STATUS_LABELS, type ReservationStatus } from '#/lib/reservations'
+
+export function StatusBadge({ status }: { status: ReservationStatus }) {
+ return {RESERVATION_STATUS_LABELS[status]}
+}
diff --git a/src/components/success-modal.tsx b/src/components/success-modal.tsx
new file mode 100644
index 0000000..6e96bc5
--- /dev/null
+++ b/src/components/success-modal.tsx
@@ -0,0 +1,67 @@
+import { useEffect, useRef } from 'react'
+
+type Props = {
+ open: boolean
+ title: string
+ message: React.ReactNode
+ onClose: () => void
+}
+
+function CheckIcon() {
+ return (
+
+
+
+
+ )
+}
+
+export function SuccessModal({ open, title, message, onClose }: Props) {
+ const closeRef = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+
+ closeRef.current?.focus()
+
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') onClose()
+ }
+
+ document.addEventListener('keydown', handleKeyDown)
+ return () => document.removeEventListener('keydown', handleKeyDown)
+ }, [open, onClose])
+
+ if (!open) return null
+
+ return (
+
+
event.stopPropagation()}
+ >
+
+
+
+
+ {title}
+
+
{message}
+
+
+ OK
+
+
+
+
+ )
+}
diff --git a/src/components/table-detail-dialog.tsx b/src/components/table-detail-dialog.tsx
new file mode 100644
index 0000000..e97bac1
--- /dev/null
+++ b/src/components/table-detail-dialog.tsx
@@ -0,0 +1,68 @@
+import { useEffect } from 'react'
+
+import type { getTables } from '#/lib/reservations'
+
+type TableRow = Awaited>[number]
+
+type Props = {
+ table: TableRow | null
+ onClose: () => void
+}
+
+export function TableDetailDialog({ table, onClose }: Props) {
+ useEffect(() => {
+ if (!table) return
+
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') onClose()
+ }
+
+ document.addEventListener('keydown', handleKeyDown)
+ return () => document.removeEventListener('keydown', handleKeyDown)
+ }, [table, onClose])
+
+ if (!table) return null
+
+ return (
+
+
event.stopPropagation()}
+ >
+
+ {table.name}
+
+
+ {table.imageUrl &&
}
+
+
+
+
Tischnummer
+ {table.tableNumber ?? '—'}
+
+
+
Name
+ {table.name}
+
+
+
Plätze
+ {table.seats}
+
+
+
Marker
+ {table.markerX !== null && table.markerY !== null ? 'Gesetzt' : 'Nicht gesetzt'}
+
+
+
+
+
+ Schließen
+
+
+
+
+ )
+}
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 1253e74..3bb4c89 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -1,9 +1,24 @@
-import { boolean, integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core'
+import { sql } from 'drizzle-orm'
+import { boolean, doublePrecision, integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core'
export const restaurantTables = pgTable('restaurant_tables', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
seats: integer('seats').notNull(),
+ // The number shown to guests (on the table's marker, etc.) — distinct from
+ // `name`, which can be a description like "Eck Tisch". Nullable so existing
+ // rows created before this field don't need a value until staff set one.
+ tableNumber: integer('table_number'),
+ // A data: URL of the uploaded photo, stored inline rather than as a file on disk —
+ // there's no separate object storage in this app, and this keeps the image tied
+ // to the row with no extra infra. Null until staff upload one.
+ imageUrl: text('image_url'),
+ // Where this table's pin sits on the settings overview photo, as a percentage
+ // (0-100) of that image's width/height — resolution-independent, so the same
+ // marker lines up whether it's rendered in the admin editor or on the public
+ // booking page. Both null until staff place one; always set together.
+ markerX: doublePrecision('marker_x'),
+ markerY: doublePrecision('marker_y'),
})
export const reservations = pgTable(
@@ -15,13 +30,48 @@ export const reservations = pgTable(
.references(() => restaurantTables.id, { onDelete: 'cascade' }),
customerName: text('customer_name').notNull(),
customerEmail: text('customer_email').notNull(),
+ // Nullable so existing rows created before this field don't need a value —
+ // required going forward by the create/update zod schema instead.
+ customerPhone: text('customer_phone'),
+ // An optional free-text note from whoever booked — allergies, a birthday, a
+ // seating preference. Null rather than '' when nothing was entered.
+ message: text('message'),
partySize: integer('party_size').notNull(),
+ // 'confirmed' | 'no_show' | 'completed' — an app-level enum (validated by the
+ // zod schema in lib/reservations.ts, not a pg enum), matching the pattern
+ // already used for restaurant_settings.table_display_mode. Every reservation
+ // is confirmed at booking time, so that's the default; staff mark the other
+ // two afterward. Cancelling still deletes the row (and emails the guest) —
+ // this doesn't introduce a "cancelled" status.
+ status: text('status').notNull().default('confirmed'),
reservationTime: timestamp('reservation_time', { withTimezone: true }).notNull(),
+ // Captured from the reservation-duration setting at booking time, so a later
+ // change to that setting doesn't retroactively change how long past bookings
+ // are considered to occupy their table.
+ durationMinutes: integer('duration_minutes').notNull().default(90),
+ cancelToken: text('cancel_token')
+ .notNull()
+ .unique()
+ .default(sql`gen_random_uuid()`),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [unique().on(table.tableId, table.reservationTime)],
)
+// Single-row table of restaurant-wide booking rules, editable by admins.
+export const restaurantSettings = pgTable('restaurant_settings', {
+ id: serial('id').primaryKey(),
+ reservationIntervalMinutes: integer('reservation_interval_minutes').notNull().default(15),
+ reservationDurationMinutes: integer('reservation_duration_minutes').notNull().default(90),
+ minLeadTimeMinutes: integer('min_lead_time_minutes').notNull().default(60),
+ maxLeadTimeDays: integer('max_lead_time_days').notNull().default(60),
+ // Whether the booking form shows a photo of the selected table ('individual') or
+ // a single always-visible photo of the whole room ('overview') — an app-level
+ // enum, validated by the zod schema in lib/settings.ts rather than a pg enum.
+ tableDisplayMode: text('table_display_mode').notNull().default('individual'),
+ tableOverviewImageUrl: text('table_overview_image_url'),
+})
+
// Better Auth's standard schema (staff accounts) — see https://www.better-auth.com/docs/concepts/database
// `role`/`banned`/`banReason`/`banExpires` and `session.impersonatedBy` are added by the `admin` plugin.
export const user = pgTable('user', {
@@ -36,6 +86,9 @@ export const user = pgTable('user', {
banned: boolean('banned'),
banReason: text('ban_reason'),
banExpires: timestamp('ban_expires'),
+ // Registered with Better Auth as a `user.additionalFields` entry in
+ // auth-instance.ts — not part of its base schema, same as `role`/`banned` above.
+ phone: text('phone'),
})
export const session = pgTable('session', {
@@ -82,3 +135,4 @@ export const verification = pgTable('verification', {
export type RestaurantTable = typeof restaurantTables.$inferSelect
export type Reservation = typeof reservations.$inferSelect
export type NewReservation = typeof reservations.$inferInsert
+export type RestaurantSettings = typeof restaurantSettings.$inferSelect
diff --git a/src/lib/auth-instance.ts b/src/lib/auth-instance.ts
index fb251d6..d21b4a9 100644
--- a/src/lib/auth-instance.ts
+++ b/src/lib/auth-instance.ts
@@ -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,
},
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index cb3e248..718bfe3 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -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()
diff --git a/src/lib/datetime.ts b/src/lib/datetime.ts
index ed649c5..855e864 100644
--- a/src/lib/datetime.ts
+++ b/src/lib/datetime.ts
@@ -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())}`
}
diff --git a/src/lib/email.ts b/src/lib/email.ts
new file mode 100644
index 0000000..cfc639e
--- /dev/null
+++ b/src/lib/email.ts
@@ -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('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+}
+
+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
+}
+
+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) => `
+
+
+ ${label}
+
+
+ ${value}
+
+ `
+
+ return `
+
+
+
+
+
+ ${heading}
+
+
+
+
+
+
+
+
+
+ Steakhaus bei Mirko
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${heading}
+
+
+
+
+ Hallo ${name},
+
+
+
+
+ ${intro}
+
+
+
+
+
+ ${row('Tisch', tableName)}
+ ${row('Datum & Uhrzeit', formattedTime)}
+ ${row('Personen', String(reservation.partySize))}
+
+
+
+ ${
+ reservation.message
+ ? `
+
+
+
+ Nachricht
+
+ ${escapeHtml(reservation.message)}
+
+ `
+ : ''
+ }
+ ${
+ showActions
+ ? `
+
+
+
+ Reservierung stornieren
+
+
+
+
+
+ Möchten Sie etwas ändern? Schreiben Sie uns einfach eine E-Mail an
+ ${CONTACT_EMAIL} .
+
+ `
+ : ''
+ }
+
+
+
+
+
+ Wir freuen uns auf Sie!
+
+
+
+
+ Meppener Straße 8 · 49716 Meppen · 05931 - 12750
+
+
+
+
+
+
+
+`
+}
+
+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 '
+
+ 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,
+ )
+}
diff --git a/src/lib/opening-hours.ts b/src/lib/opening-hours.ts
new file mode 100644
index 0000000..4528963
--- /dev/null
+++ b/src/lib/opening-hours.ts
@@ -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 = {
+ 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
+}
diff --git a/src/lib/phone.ts b/src/lib/phone.ts
new file mode 100644
index 0000000..30f97a0
--- /dev/null
+++ b/src/lib/phone.ts
@@ -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')
diff --git a/src/lib/reservations.ts b/src/lib/reservations.ts
index bfcf1a0..638e874 100644
--- a/src/lib/reservations.ts
+++ b/src/lib/reservations.ts
@@ -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
+
+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
})
@@ -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,
+ })
+ }
+ })
diff --git a/src/lib/server-validator.ts b/src/lib/server-validator.ts
new file mode 100644
index 0000000..1f43c68
--- /dev/null
+++ b/src/lib/server-validator.ts
@@ -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: Schema) {
+ return (input: unknown): z.infer => {
+ const result = schema.safeParse(input)
+ if (!result.success) {
+ throw new Error(result.error.issues[0]?.message ?? 'Invalid input')
+ }
+ return result.data
+ }
+}
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
new file mode 100644
index 0000000..ff4d87e
--- /dev/null
+++ b/src/lib/settings.ts
@@ -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
+ })
diff --git a/src/lib/staff.ts b/src/lib/staff.ts
index d94cb91..650a569 100644
--- a/src/lib/staff.ts
+++ b/src/lib/staff.ts
@@ -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 }>
})
diff --git a/src/lib/table-image.ts b/src/lib/table-image.ts
new file mode 100644
index 0000000..f2bc62c
--- /dev/null
+++ b/src/lib/table-image.ts
@@ -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 {
+ 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)
+ })
+}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 379ed06..5a04eab 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -13,7 +13,10 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as ImpressumRouteImport } from './routes/impressum'
import { Route as StaffIndexRouteImport } from './routes/staff.index'
import { Route as StaffLoginRouteImport } from './routes/staff.login'
+import { Route as StaffSettingsRouteImport } from './routes/staff.settings'
+import { Route as StaffTablesRouteImport } from './routes/staff.tables'
import { Route as StaffTeamRouteImport } from './routes/staff.team'
+import { Route as StornierenTokenRouteImport } from './routes/stornieren.$token'
const IndexRoute = IndexRouteImport.update({
id: '/',
@@ -35,24 +38,45 @@ const StaffLoginRoute = StaffLoginRouteImport.update({
path: '/staff/login',
getParentRoute: () => rootRouteImport,
} as any)
+const StaffSettingsRoute = StaffSettingsRouteImport.update({
+ id: '/staff/settings',
+ path: '/staff/settings',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const StaffTablesRoute = StaffTablesRouteImport.update({
+ id: '/staff/tables',
+ path: '/staff/tables',
+ getParentRoute: () => rootRouteImport,
+} as any)
const StaffTeamRoute = StaffTeamRouteImport.update({
id: '/staff/team',
path: '/staff/team',
getParentRoute: () => rootRouteImport,
} as any)
+const StornierenTokenRoute = StornierenTokenRouteImport.update({
+ id: '/stornieren/$token',
+ path: '/stornieren/$token',
+ getParentRoute: () => rootRouteImport,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/impressum': typeof ImpressumRoute
'/staff/login': typeof StaffLoginRoute
+ '/staff/settings': typeof StaffSettingsRoute
+ '/staff/tables': typeof StaffTablesRoute
'/staff/team': typeof StaffTeamRoute
+ '/stornieren/$token': typeof StornierenTokenRoute
'/staff/': typeof StaffIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/impressum': typeof ImpressumRoute
'/staff/login': typeof StaffLoginRoute
+ '/staff/settings': typeof StaffSettingsRoute
+ '/staff/tables': typeof StaffTablesRoute
'/staff/team': typeof StaffTeamRoute
+ '/stornieren/$token': typeof StornierenTokenRoute
'/staff': typeof StaffIndexRoute
}
export interface FileRoutesById {
@@ -60,23 +84,53 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/impressum': typeof ImpressumRoute
'/staff/login': typeof StaffLoginRoute
+ '/staff/settings': typeof StaffSettingsRoute
+ '/staff/tables': typeof StaffTablesRoute
'/staff/team': typeof StaffTeamRoute
+ '/stornieren/$token': typeof StornierenTokenRoute
'/staff/': typeof StaffIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/' | '/impressum' | '/staff/login' | '/staff/team' | '/staff/'
+ fullPaths:
+ | '/'
+ | '/impressum'
+ | '/staff/login'
+ | '/staff/settings'
+ | '/staff/tables'
+ | '/staff/team'
+ | '/stornieren/$token'
+ | '/staff/'
fileRoutesByTo: FileRoutesByTo
- to: '/' | '/impressum' | '/staff/login' | '/staff/team' | '/staff'
+ to:
+ | '/'
+ | '/impressum'
+ | '/staff/login'
+ | '/staff/settings'
+ | '/staff/tables'
+ | '/staff/team'
+ | '/stornieren/$token'
+ | '/staff'
id:
- '__root__' | '/' | '/impressum' | '/staff/login' | '/staff/team' | '/staff/'
+ | '__root__'
+ | '/'
+ | '/impressum'
+ | '/staff/login'
+ | '/staff/settings'
+ | '/staff/tables'
+ | '/staff/team'
+ | '/stornieren/$token'
+ | '/staff/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ImpressumRoute: typeof ImpressumRoute
StaffLoginRoute: typeof StaffLoginRoute
+ StaffSettingsRoute: typeof StaffSettingsRoute
+ StaffTablesRoute: typeof StaffTablesRoute
StaffTeamRoute: typeof StaffTeamRoute
+ StornierenTokenRoute: typeof StornierenTokenRoute
StaffIndexRoute: typeof StaffIndexRoute
}
@@ -110,6 +164,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StaffLoginRouteImport
parentRoute: typeof rootRouteImport
}
+ '/staff/settings': {
+ id: '/staff/settings'
+ path: '/staff/settings'
+ fullPath: '/staff/settings'
+ preLoaderRoute: typeof StaffSettingsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/staff/tables': {
+ id: '/staff/tables'
+ path: '/staff/tables'
+ fullPath: '/staff/tables'
+ preLoaderRoute: typeof StaffTablesRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/staff/team': {
id: '/staff/team'
path: '/staff/team'
@@ -117,6 +185,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StaffTeamRouteImport
parentRoute: typeof rootRouteImport
}
+ '/stornieren/$token': {
+ id: '/stornieren/$token'
+ path: '/stornieren/$token'
+ fullPath: '/stornieren/$token'
+ preLoaderRoute: typeof StornierenTokenRouteImport
+ parentRoute: typeof rootRouteImport
+ }
}
}
@@ -124,7 +199,10 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ImpressumRoute: ImpressumRoute,
StaffLoginRoute: StaffLoginRoute,
+ StaffSettingsRoute: StaffSettingsRoute,
+ StaffTablesRoute: StaffTablesRoute,
StaffTeamRoute: StaffTeamRoute,
+ StornierenTokenRoute: StornierenTokenRoute,
StaffIndexRoute: StaffIndexRoute,
}
export const routeTree = rootRouteImport
diff --git a/src/routes/impressum.tsx b/src/routes/impressum.tsx
index 6fa6fb7..40d498d 100644
--- a/src/routes/impressum.tsx
+++ b/src/routes/impressum.tsx
@@ -57,7 +57,7 @@ function Impressum() {
bedürfen der schriftlichen Zustimmung des jeweiligen Erstellers.
- Datenschutz
+ Datenschutz
Verantwortlich für die Verarbeitung personenbezogener Daten auf dieser Website ist die
oben genannte Drenica & Mousa GbR.
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index fe38fe8..b846b8b 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -1,28 +1,52 @@
import { useState } from 'react'
import { createFileRoute, useRouter } from '@tanstack/react-router'
+import { ReservationConfirmedModal } from '#/components/reservation-confirmed-modal'
+import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
import { SiteFooter } from '#/components/site-footer'
import { SiteHeader } from '#/components/site-header'
-import { formatDateTimeLocal } from '#/lib/datetime'
-import { createReservation, getTables } from '#/lib/reservations'
+import { formatDateOnly } from '#/lib/datetime'
+import {
+ generateTimeOptions,
+ getNextAvailableSlot,
+ getOpeningHoursForDisplay,
+ getOpeningRangesForWeekday,
+ getTodayLabelBerlin,
+} from '#/lib/opening-hours'
+import { createReservation, getReservationAvailability, getTables } from '#/lib/reservations'
+import { getReservationSettings } from '#/lib/settings'
export const Route = createFileRoute('/')({
component: Home,
loader: async () => {
- const tables = await getTables()
- return { tables }
+ const [tables, settings, availability] = await Promise.all([
+ getTables(),
+ getReservationSettings(),
+ getReservationAvailability(),
+ ])
+ return { tables, settings, availability }
},
})
-const OPENING_HOURS = [
- { day: 'Montag', hours: null },
- { day: 'Dienstag', hours: ['17:00 – 21:30'] },
- { day: 'Mittwoch', hours: ['17:00 – 21:30'] },
- { day: 'Donnerstag', hours: ['17:00 – 21:30'] },
- { day: 'Freitag', hours: ['17:00 – 22:00'] },
- { day: 'Samstag', hours: ['17:00 – 22:00'] },
- { day: 'Sonntag', hours: ['11:30 – 13:30', '17:00 – 22:00'] },
-]
+const OPENING_HOURS = getOpeningHoursForDisplay()
+
+const confirmedDateFormatter = new Intl.DateTimeFormat('de-DE', { dateStyle: 'full' })
+const confirmedTimeFormatter = new Intl.DateTimeFormat('de-DE', { timeStyle: 'short' })
+
+const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+// Also used as the phone input's `pattern` attribute below, so the two stay in sync.
+const PHONE_PATTERN_SOURCE = String.raw`[+]?[0-9()\-.\s]{6,30}`
+const PHONE_PATTERN = new RegExp(`^${PHONE_PATTERN_SOURCE}$`)
+
+type ConfirmedReservation = {
+ customerName: string
+ customerEmail: string
+ tableName: string
+ date: string
+ startTime: string
+ endTime: string
+ partySize: number
+}
function FlameIcon() {
return (
@@ -39,47 +63,117 @@ function FlameIcon() {
function Home() {
const router = useRouter()
- const { tables } = Route.useLoaderData()
+ const { tables, settings, availability } = Route.useLoaderData()
const [tableId, setTableId] = useState(tables[0]?.id ?? '')
const [customerName, setCustomerName] = useState('')
const [customerEmail, setCustomerEmail] = useState('')
+ const [customerPhone, setCustomerPhone] = useState('')
+ const [message, setMessage] = useState('')
const [partySize, setPartySize] = useState(2)
- const [reservationTime, setReservationTime] = useState(() =>
- formatDateTimeLocal(new Date(Date.now() + 60 * 60 * 1000)),
- )
+ const [acceptsTerms, setAcceptsTerms] = useState(false)
+ const [reservationTime, setReservationTime] = useState(() => {
+ const slot = getNextAvailableSlot(
+ new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
+ settings.reservationIntervalMinutes,
+ settings.reservationDurationMinutes,
+ )
+ return slot ? `${slot.date}T${slot.time}` : ''
+ })
const [error, setError] = useState(null)
- const [success, setSuccess] = useState(null)
+ const [confirmedReservation, setConfirmedReservation] = useState(null)
const [isSubmitting, setIsSubmitting] = useState(false)
- const todayName = new Date().toLocaleDateString('de-DE', { weekday: 'long' })
+ const todayName = getTodayLabelBerlin()
+ const selectedTable = tables.find((table) => String(table.id) === String(tableId))
+
+ const [selectedDate] = reservationTime.split('T')
+ const otherReservationsOnSelection = availability.filter(
+ (r) => String(r.tableId) === String(tableId) && formatDateOnly(new Date(r.reservationTime)) === selectedDate,
+ )
+ // `minLeadTimeMinutes` is reused here as a table turnover buffer, extending each
+ // existing reservation's taken window on both sides so back-to-back bookings on the
+ // same table always leave that much of a gap.
+ const turnoverBufferMs = settings.minLeadTimeMinutes * 60_000
+ const bookedTimesForForm = selectedDate
+ ? generateTimeOptions(
+ getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()),
+ settings.reservationIntervalMinutes,
+ settings.reservationDurationMinutes,
+ ).filter((option) => {
+ const slotStart = new Date(`${selectedDate}T${option}`).getTime()
+ return otherReservationsOnSelection.some((r) => {
+ const otherStart = new Date(r.reservationTime).getTime() - turnoverBufferMs
+ const otherEnd = otherStart + r.durationMinutes * 60_000 + 2 * turnoverBufferMs
+ return slotStart >= otherStart && slotStart < otherEnd
+ })
+ })
+ : []
+
+ // Mirrors the fields' own `required`/`type`/`pattern`/`min`/`max` constraints, so
+ // the submit button is disabled until they'd all actually pass — instead of only
+ // catching a missing table/time/consent on submit and leaving everything else to
+ // the browser's native validation popup.
+ const isFormValid =
+ Boolean(tableId) &&
+ customerName.trim().length > 0 &&
+ EMAIL_PATTERN.test(customerEmail) &&
+ PHONE_PATTERN.test(customerPhone) &&
+ partySize >= 1 &&
+ partySize <= 20 &&
+ Boolean(reservationTime.split('T')[1]) &&
+ acceptsTerms
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setError(null)
- setSuccess(null)
if (!tableId) {
setError('Bitte wählen Sie einen Tisch aus')
return
}
+ if (!reservationTime.split('T')[1]) {
+ setError('Bitte wählen Sie eine gültige Uhrzeit aus')
+ return
+ }
+
+ if (!acceptsTerms) {
+ setError('Bitte stimmen Sie der Verarbeitung Ihrer Daten zu')
+ return
+ }
+
setIsSubmitting(true)
try {
- await createReservation({
+ const created = await createReservation({
data: {
tableId: Number(tableId),
customerName,
customerEmail,
+ customerPhone,
+ message,
partySize: Number(partySize),
reservationTime: new Date(reservationTime).toISOString(),
},
})
- setSuccess('Vielen Dank! Ihre Reservierung ist bestätigt.')
+ const start = new Date(created.reservationTime)
+ const end = new Date(start.getTime() + created.durationMinutes * 60_000)
+ setConfirmedReservation({
+ customerName: created.customerName,
+ customerEmail: created.customerEmail,
+ tableName: selectedTable?.name ?? '',
+ date: confirmedDateFormatter.format(start),
+ startTime: confirmedTimeFormatter.format(start),
+ endTime: confirmedTimeFormatter.format(end),
+ partySize: created.partySize,
+ })
setCustomerName('')
setCustomerEmail('')
+ setCustomerPhone('')
+ setMessage('')
setPartySize(2)
+ setAcceptsTerms(false)
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
@@ -95,11 +189,11 @@ function Home() {
- Steakhaus bei Mirko
+ Vom Feuer verwöhnt, mit Liebe serviert
- Steaks vom Grill, gute Gesellschaft und ein gemütlicher Abend mitten in Meppen. Reservieren
- Sie Ihren Tisch – wir freuen uns auf Sie.
+ Dicke Steaks, knisterndes Holzkohlefeuer und Gelächter am Nachbartisch – so schmeckt ein
+ guter Abend mitten in Meppen. Reservieren Sie Ihren Tisch, wir legen schon auf.
@@ -240,6 +412,20 @@ function Home() {
+
+ {confirmedReservation && (
+ setConfirmedReservation(null)}
+ />
+ )}
>
)
}
diff --git a/src/routes/staff.index.tsx b/src/routes/staff.index.tsx
index 74ce5ce..e0f54f4 100644
--- a/src/routes/staff.index.tsx
+++ b/src/routes/staff.index.tsx
@@ -1,16 +1,25 @@
import { useState } from 'react'
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
+import { ConfirmDialog } from '#/components/confirm-dialog'
+import { ReservationCharts } from '#/components/reservation-charts'
+import { ReservationDialog, type ReservationPayload } from '#/components/reservation-dialog'
+import { StaffTabs } from '#/components/staff-tabs'
+import { StatusBadge } from '#/components/status-badge'
+import { SuccessModal } from '#/components/success-modal'
import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client'
-import { formatDateTimeLocal } from '#/lib/datetime'
import {
+ RESERVATION_STATUS_LABELS,
createReservation,
deleteReservation,
getReservations,
getTables,
updateReservation,
+ updateReservationStatus,
+ type ReservationStatus,
} from '#/lib/reservations'
+import { getReservationSettings } from '#/lib/settings'
export const Route = createFileRoute('/staff/')({
component: StaffDashboard,
@@ -22,13 +31,20 @@ export const Route = createFileRoute('/staff/')({
return { staffUser }
},
loader: async ({ context }) => {
- const [reservations, tables] = await Promise.all([getReservations(), getTables()])
- return { staffUser: context.staffUser, reservations, tables }
+ const [reservations, tables, settings] = await Promise.all([
+ getReservations(),
+ getTables(),
+ getReservationSettings(),
+ ])
+ return { staffUser: context.staffUser, reservations, tables, settings }
},
})
-const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
+const dateFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium',
+})
+
+const timeFormatter = new Intl.DateTimeFormat('de-DE', {
timeStyle: 'short',
})
@@ -40,117 +56,100 @@ function isSameDay(a: Date, b: Date) {
type ReservationRow = Awaited>[number]
-type FormState = {
- tableId: number | string
- customerName: string
- customerEmail: string
- partySize: number
- reservationTime: string
-}
-
-function emptyForm(defaultTableId: number | string): FormState {
- return {
- tableId: defaultTableId,
- customerName: '',
- customerEmail: '',
- partySize: 2,
- reservationTime: formatDateTimeLocal(new Date(Date.now() + 60 * 60 * 1000)),
- }
-}
-
function StaffDashboard() {
const router = useRouter()
- const { staffUser, reservations, tables } = Route.useLoaderData()
+ const { staffUser, reservations, tables, settings } = Route.useLoaderData()
- const [editingId, setEditingId] = useState(null)
- const [form, setForm] = useState(() => emptyForm(tables[0]?.id ?? ''))
const [error, setError] = useState(null)
- const [success, setSuccess] = useState(null)
- const [isSubmitting, setIsSubmitting] = useState(false)
- const [deletingId, setDeletingId] = useState(null)
+ const [successModal, setSuccessModal] = useState<{ title: string; message: string } | null>(null)
+ const [pendingDelete, setPendingDelete] = useState(null)
+ const [viewingReservation, setViewingReservation] = useState(null)
+ const [isCreating, setIsCreating] = useState(false)
+ const [isUpdatingStatus, setIsUpdatingStatus] = useState(false)
+ const [searchQuery, setSearchQuery] = useState('')
+ const [tableFilter, setTableFilter] = useState('all')
+ const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('today')
+ const [statusFilter, setStatusFilter] = useState<'all' | ReservationStatus>('all')
+
+ const dialogOpen = viewingReservation !== null || isCreating
const now = new Date()
const todaysReservations = reservations.filter((r) => isSameDay(new Date(r.reservationTime), now))
const guestsToday = todaysReservations.reduce((sum, r) => sum + r.partySize, 0)
const upcoming = reservations.filter((r) => new Date(r.reservationTime).getTime() >= now.getTime())
- function startEdit(reservation: ReservationRow) {
- setEditingId(reservation.id)
- setForm({
- tableId: reservation.tableId,
- customerName: reservation.customerName,
- customerEmail: reservation.customerEmail,
- partySize: reservation.partySize,
- reservationTime: formatDateTimeLocal(new Date(reservation.reservationTime)),
+ const filteredReservations = reservations.filter((r) => {
+ if (tableFilter !== 'all' && String(r.tableId) !== tableFilter) return false
+ if (statusFilter !== 'all' && r.status !== statusFilter) return false
+
+ const reservationTime = new Date(r.reservationTime)
+ if (timeFilter === 'today' && !isSameDay(reservationTime, now)) return false
+ if (timeFilter === 'upcoming' && reservationTime.getTime() < now.getTime()) return false
+ if (timeFilter === 'past' && reservationTime.getTime() >= now.getTime()) return false
+
+ const query = searchQuery.trim().toLowerCase()
+ if (query) {
+ const haystack = `${r.customerName} ${r.customerEmail} ${r.customerPhone ?? ''} ${r.tableName} ${r.tableNumber ?? ''}`.toLowerCase()
+ if (!haystack.includes(query)) return false
+ }
+
+ return true
+ })
+
+ function closeDialog() {
+ setViewingReservation(null)
+ setIsCreating(false)
+ }
+
+ async function handleCreate(payload: ReservationPayload) {
+ await createReservation({ data: payload })
+ setSuccessModal({
+ title: 'Reservierung angelegt',
+ message: `Die Reservierung für ${payload.customerName} wurde erfolgreich angelegt.`,
})
- setError(null)
- setSuccess(null)
+ await router.invalidate()
}
- function cancelEdit() {
- setEditingId(null)
- setForm(emptyForm(tables[0]?.id ?? ''))
- setError(null)
+ async function handleUpdate(id: number, payload: ReservationPayload) {
+ await updateReservation({ data: { id, ...payload } })
+ setSuccessModal({
+ title: 'Reservierung aktualisiert',
+ message: `Die Reservierung für ${payload.customerName} wurde erfolgreich aktualisiert.`,
+ })
+ await router.invalidate()
}
- async function handleSubmit(event: React.FormEvent) {
- event.preventDefault()
+ async function confirmDelete() {
+ const reservation = pendingDelete
+ if (!reservation) return
+
+ setPendingDelete(null)
setError(null)
- setSuccess(null)
-
- if (!form.tableId) {
- setError('Bitte wählen Sie einen Tisch aus')
- return
- }
-
- setIsSubmitting(true)
- try {
- const payload = {
- tableId: Number(form.tableId),
- customerName: form.customerName,
- customerEmail: form.customerEmail,
- partySize: Number(form.partySize),
- reservationTime: new Date(form.reservationTime).toISOString(),
- }
-
- if (editingId) {
- await updateReservation({ data: { id: editingId, ...payload } })
- setSuccess('Reservierung wurde aktualisiert.')
- } else {
- await createReservation({ data: payload })
- setSuccess('Reservierung wurde angelegt.')
- }
-
- setEditingId(null)
- setForm(emptyForm(tables[0]?.id ?? ''))
- await router.invalidate()
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
- } finally {
- setIsSubmitting(false)
- }
- }
-
- async function handleDelete(reservation: ReservationRow) {
- if (!window.confirm(`Reservierung von ${reservation.customerName} wirklich löschen?`)) {
- return
- }
-
- setDeletingId(reservation.id)
- setError(null)
- setSuccess(null)
try {
await deleteReservation({ data: { id: reservation.id } })
- if (editingId === reservation.id) {
- cancelEdit()
- }
- setSuccess('Reservierung wurde gelöscht.')
+ setSuccessModal({
+ title: 'Reservierung gelöscht',
+ message: `Die Reservierung von ${reservation.customerName} wurde gelöscht.`,
+ })
+ await router.invalidate()
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
+ }
+ }
+
+ async function handleStatusChange(reservation: ReservationRow, status: ReservationStatus) {
+ setIsUpdatingStatus(true)
+ setError(null)
+
+ try {
+ await updateReservationStatus({ data: { id: reservation.id, status } })
+ setViewingReservation((current) => (current ? { ...current, status } : current))
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
- setDeletingId(null)
+ setIsUpdatingStatus(false)
}
}
@@ -162,26 +161,33 @@ function StaffDashboard() {
return (