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..a43033d --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,94 @@ +name: CI + +on: + pull_request: + branches: ['**'] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: steakhouse + POSTGRES_PASSWORD: steakhouse + POSTGRES_DB: steakhouse + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U steakhouse" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + DATABASE_URL: postgres://steakhouse:steakhouse@localhost:5432/steakhouse + BETTER_AUTH_SECRET: ci-only-secret-not-used-in-production + BETTER_AUTH_URL: http://localhost:4173 + STAFF_SEED_EMAIL: admin@example.com + STAFF_SEED_PASSWORD: ci-only-password-not-used-in-production + RESEND_API_KEY: re_ci_dummy_key + RESERVATIONS_FROM_EMAIL: 'Steakhaus bei Mirko ' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npx tsc --noEmit + + - name: Check that Drizzle migrations are up to date + run: | + npx drizzle-kit generate + if [ -n "$(git status --porcelain drizzle)" ]; then + echo "::error::src/db/schema.ts changed without a matching migration. Run 'npm run db:generate' and commit the result." + git status --porcelain drizzle + git diff drizzle + exit 1 + fi + + - name: Apply migrations + run: npm run db:migrate + + - name: Seed baseline data + run: npm run db:seed + + - name: Build + run: npm run build + + - name: Smoke test the production build + run: | + npx vite preview --port 4173 & + PREVIEW_PID=$! + + for i in $(seq 1 30); do + curl -sf http://localhost:4173/ >/dev/null 2>&1 && break + sleep 1 + done + + check() { + code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:4173$1") + if [ "$code" != "$2" ]; then + echo "::error::GET $1 returned $code, expected $2" + kill "$PREVIEW_PID" + exit 1 + fi + echo "GET $1 -> $code" + } + + check / 200 + check /staff/login 200 + check /impressum 200 + + kill "$PREVIEW_PID" 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/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 ( + + ) +} 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} +
+ )} +
+ + +
+
+
+ ) +} 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) => ( + + ))} +
+ ) +} + +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-datetime-field.tsx b/src/components/reservation-datetime-field.tsx new file mode 100644 index 0000000..7854cff --- /dev/null +++ b/src/components/reservation-datetime-field.tsx @@ -0,0 +1,358 @@ +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 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, + 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) + } + + 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 + /> + + + {isCalendarOpen && ( +
setIsCalendarOpen(false)}> +
event.stopPropagation()} + > +
+ + {monthLabel} + +
+
+ {WEEKDAY_HEADER_LABELS.map((label) => ( + {label} + ))} +
+
+ {getMonthGrid(view.year, view.month) + .flat() + .map((cell, index) => ( + + ))} +
+
+
+ )} +
+ + +
+ ) +} diff --git a/src/components/reservation-detail-dialog.tsx b/src/components/reservation-detail-dialog.tsx new file mode 100644 index 0000000..c3a6ad4 --- /dev/null +++ b/src/components/reservation-detail-dialog.tsx @@ -0,0 +1,136 @@ +import { useEffect } from 'react' + +import { ActionMenu } from '#/components/action-menu' +import { RESERVATION_STATUS_LABELS, type ReservationStatus, type getReservations } from '#/lib/reservations' + +type ReservationRow = Awaited>[number] + +type Props = { + reservation: ReservationRow | null + onClose: () => void + onStatusChange: (status: ReservationStatus) => void + isUpdatingStatus?: boolean + onEdit: () => void + onDelete: () => void + isDeleting?: boolean +} + +const STATUS_OPTIONS = Object.keys(RESERVATION_STATUS_LABELS) as ReservationStatus[] + +const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', { + dateStyle: 'full', + timeStyle: 'short', +}) + +export function ReservationDetailDialog({ + reservation, + onClose, + onStatusChange, + isUpdatingStatus, + onEdit, + onDelete, + isDeleting, +}: Props) { + useEffect(() => { + if (!reservation) return + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') onClose() + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [reservation, onClose]) + + if (!reservation) return null + + return ( +
+
event.stopPropagation()} + > +
+

+ Reservierung von {reservation.customerName} +

+ + + + +
+ +
+ {STATUS_OPTIONS.map((status) => ( + + ))} +
+ +
+ {reservation.tableNumber !== null && ( +
+
Tischnummer
+
{reservation.tableNumber}
+
+ )} +
+
Tisch
+
{reservation.tableName}
+
+
+
Datum & Uhrzeit
+
{dateTimeFormatter.format(new Date(reservation.reservationTime))}
+
+
+
Personen
+
{reservation.partySize}
+
+
+
E-Mail
+
+ {reservation.customerEmail} +
+
+ {reservation.customerPhone && ( +
+
Telefon
+
+ {reservation.customerPhone} +
+
+ )} +
+ + {reservation.message && ( +
+ Nachricht +

{reservation.message}

+
+ )} + +
+ +
+
+
+ ) +} 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 (
- - Steakhaus bei Mirko - - +
+ + Steakhaus bei Mirko + + + Mitarbeiter-Login + +
) } 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} +

+ +
+
+
E-Mail
+
+ {member.email} +
+
+
+
Telefon
+
{member.phone ? {member.phone} : '—'}
+
+
+
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))}
+
+
+ +
+ +
+
+
+ ) +} 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 ( + + ) +} 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/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'}
+
+
+ +
+ +
+
+
+ ) +} 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 + +
+ + + + + + + + + + + + + + + + + ${ + reservation.message + ? ` + + + ` + : '' + } + ${ + showActions + ? ` + + + + + + ` + : '' + } +
+ +
+ ${heading} +
+ Hallo ${name}, +
+ ${intro} +
+ + ${row('Tisch', tableName)} + ${row('Datum & Uhrzeit', formattedTime)} + ${row('Personen', String(reservation.partySize))} +
+
+
+ Nachricht +
+
${escapeHtml(reservation.message)}
+
+ + 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..4f0d0c0 --- /dev/null +++ b/src/lib/opening-hours.ts @@ -0,0 +1,132 @@ +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 +} + +export function isWithinOpeningHours(date: Date) { + const { weekday, minutesSinceMidnight } = getBerlinTimeParts(date) + const ranges = OPENING_HOURS_BY_WEEKDAY[weekday] ?? [] + + return ranges.some( + (range) => minutesSinceMidnight >= toMinutes(range.start) && minutesSinceMidnight < 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] ?? [] +} + +export function generateTimeOptions( + ranges: OpeningRange[], + intervalMinutes: number = RESERVATION_INTERVAL_MINUTES, +): string[] { + const options: string[] = [] + + for (const range of ranges) { + const startMinutes = toMinutes(range.start) + const endMinutes = toMinutes(range.end) + + for (let minutes = startMinutes; minutes < 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, +): { 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) + + 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..6b4232d 100644 --- a/src/lib/reservations.ts +++ b/src/lib/reservations.ts @@ -1,15 +1,199 @@ 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, +) { + 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)) { + throw new Error('Zu dieser Zeit haben wir leider geschlossen') + } +} + +// 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. +async function findOverlappingReservation( + tableId: number, + start: Date, + durationMinutes: number, + excludeId?: number, +) { + const end = start.getTime() + durationMinutes * 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() + const existingEnd = existingStart + r.durationMinutes * 60_000 + 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,10 +202,15 @@ 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)) @@ -32,6 +221,13 @@ 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 +235,17 @@ 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, + ) const [table] = await db .select() @@ -60,15 +260,11 @@ 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, + ) if (conflict) { throw new Error('This table is already booked for that time') @@ -80,11 +276,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 +302,20 @@ 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, + ) + const [table] = await db .select() .from(restaurantTables) @@ -112,16 +329,12 @@ 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, + data.id, + ) if (conflict) { throw new Error('This table is already booked for that time') @@ -133,8 +346,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 +359,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 +410,7 @@ const deleteReservationSchema = z.object({ }) export const deleteReservation = createServerFn({ method: 'POST' }) - .validator(deleteReservationSchema) + .validator(zodValidator(deleteReservationSchema)) .handler(async ({ data }) => { await requireStaffSession() @@ -161,5 +420,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/index.tsx b/src/routes/index.tsx index fe38fe8..abbe101 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,28 +1,22 @@ import { useState } from 'react' import { createFileRoute, useRouter } from '@tanstack/react-router' +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 { getNextAvailableSlot, getOpeningHoursForDisplay, getTodayLabelBerlin } from '#/lib/opening-hours' import { createReservation, 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] = await Promise.all([getTables(), getReservationSettings()]) + return { tables, settings } }, }) -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() function FlameIcon() { return ( @@ -39,20 +33,27 @@ function FlameIcon() { function Home() { const router = useRouter() - const { tables } = Route.useLoaderData() + const { tables, settings } = 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 [reservationTime, setReservationTime] = useState(() => { + const slot = getNextAvailableSlot( + new Date(Date.now() + settings.minLeadTimeMinutes * 60_000), + settings.reservationIntervalMinutes, + ) + return slot ? `${slot.date}T${slot.time}` : '' + }) const [error, setError] = useState(null) const [success, setSuccess] = 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)) async function handleSubmit(event: React.FormEvent) { event.preventDefault() @@ -64,6 +65,11 @@ function Home() { return } + if (!reservationTime.split('T')[1]) { + setError('Bitte wählen Sie eine gültige Uhrzeit aus') + return + } + setIsSubmitting(true) try { await createReservation({ @@ -71,6 +77,8 @@ function Home() { tableId: Number(tableId), customerName, customerEmail, + customerPhone, + message, partySize: Number(partySize), reservationTime: new Date(reservationTime).toISOString(), }, @@ -79,6 +87,8 @@ function Home() { setSuccess('Vielen Dank! Ihre Reservierung ist bestätigt.') setCustomerName('') setCustomerEmail('') + setCustomerPhone('') + setMessage('') setPartySize(2) await router.invalidate() } catch (err) { @@ -95,11 +105,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.

@@ -155,6 +165,36 @@ function Home() { + {settings.tableDisplayMode === 'overview' ? ( + settings.tableOverviewImageUrl && ( +
+ Übersicht unserer Tische + {tables + .filter((table) => table.markerX !== null && table.markerY !== null) + .map((table) => ( + + ))} +
+ ) + ) : ( + selectedTable?.imageUrl && ( + {`Ansicht + ) + )} +
+ + - -
+ + +