diff --git a/drizzle/0000_even_thor_girl.sql b/drizzle/0000_even_thor_girl.sql
new file mode 100644
index 0000000..6c5cd2b
--- /dev/null
+++ b/drizzle/0000_even_thor_girl.sql
@@ -0,0 +1,130 @@
+CREATE TYPE "public"."step_type" AS ENUM('question', 'text', 'puzzle', 'location');--> statement-breakpoint
+CREATE TABLE "escape_game" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "title" text NOT NULL,
+ "description" text,
+ "is_deleted" boolean DEFAULT false NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "game_session" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "escape_game_id" integer NOT NULL,
+ "code" varchar(10) NOT NULL,
+ "expires_at" timestamp NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "started_at" timestamp,
+ "completed_at" timestamp,
+ "is_active" integer DEFAULT 1 NOT NULL,
+ CONSTRAINT "game_session_code_unique" UNIQUE("code")
+);
+--> statement-breakpoint
+CREATE TABLE "item" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "escape_game_id" integer NOT NULL,
+ "step_id" integer,
+ "name" text NOT NULL,
+ "description" text,
+ "image_url" text,
+ "created_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "player" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "game_session_id" integer NOT NULL,
+ "cgu_accepted" boolean DEFAULT false NOT NULL,
+ "joined_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "session_item" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "game_session_id" integer NOT NULL,
+ "item_id" integer NOT NULL,
+ "collected_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "session_progress" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "game_session_id" integer NOT NULL,
+ "step_id" integer NOT NULL,
+ "completed_at" timestamp,
+ "attempts" integer DEFAULT 0 NOT NULL,
+ "last_attempt_at" timestamp
+);
+--> statement-breakpoint
+CREATE TABLE "step" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "escape_game_id" integer NOT NULL,
+ "title" text NOT NULL,
+ "description" text,
+ "type" "step_type" NOT NULL,
+ "order" integer NOT NULL,
+ "content" text,
+ "answer" text,
+ "hint" text,
+ "created_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+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 DEFAULT now() NOT NULL,
+ "updated_at" timestamp 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 DEFAULT now() NOT NULL,
+ "updated_at" timestamp NOT NULL,
+ "ip_address" text,
+ "user_agent" text,
+ "user_id" text NOT NULL,
+ 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 DEFAULT false NOT NULL,
+ "image" text,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL,
+ 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 DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "game_session" ADD CONSTRAINT "game_session_escape_game_id_escape_game_id_fk" FOREIGN KEY ("escape_game_id") REFERENCES "public"."escape_game"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "item" ADD CONSTRAINT "item_escape_game_id_escape_game_id_fk" FOREIGN KEY ("escape_game_id") REFERENCES "public"."escape_game"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "item" ADD CONSTRAINT "item_step_id_step_id_fk" FOREIGN KEY ("step_id") REFERENCES "public"."step"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "player" ADD CONSTRAINT "player_game_session_id_game_session_id_fk" FOREIGN KEY ("game_session_id") REFERENCES "public"."game_session"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "session_item" ADD CONSTRAINT "session_item_game_session_id_game_session_id_fk" FOREIGN KEY ("game_session_id") REFERENCES "public"."game_session"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "session_item" ADD CONSTRAINT "session_item_item_id_item_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."item"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "session_progress" ADD CONSTRAINT "session_progress_game_session_id_game_session_id_fk" FOREIGN KEY ("game_session_id") REFERENCES "public"."game_session"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "session_progress" ADD CONSTRAINT "session_progress_step_id_step_id_fk" FOREIGN KEY ("step_id") REFERENCES "public"."step"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "step" ADD CONSTRAINT "step_escape_game_id_escape_game_id_fk" FOREIGN KEY ("escape_game_id") REFERENCES "public"."escape_game"("id") ON DELETE cascade ON UPDATE no action;--> 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 "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
+CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
+CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");
\ No newline at end of file
diff --git a/drizzle/0001_new_silver_samurai.sql b/drizzle/0001_new_silver_samurai.sql
new file mode 100644
index 0000000..d4f7386
--- /dev/null
+++ b/drizzle/0001_new_silver_samurai.sql
@@ -0,0 +1,3 @@
+ALTER TABLE "step" ADD COLUMN "latitude" double precision;--> statement-breakpoint
+ALTER TABLE "step" ADD COLUMN "longitude" double precision;--> statement-breakpoint
+ALTER TABLE "step" ADD COLUMN "proximity_radius" integer DEFAULT 50;
\ 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..ac0a030
--- /dev/null
+++ b/drizzle/meta/0000_snapshot.json
@@ -0,0 +1,889 @@
+{
+ "id": "6bb611c5-8d24-4601-a6ea-fe00ce6d0f5c",
+ "prevId": "00000000-0000-0000-0000-000000000000",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.escape_game": {
+ "name": "escape_game",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_deleted": {
+ "name": "is_deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.game_session": {
+ "name": "game_session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "escape_game_id": {
+ "name": "escape_game_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "varchar(10)",
+ "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": true,
+ "default": "now()"
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "game_session_escape_game_id_escape_game_id_fk": {
+ "name": "game_session_escape_game_id_escape_game_id_fk",
+ "tableFrom": "game_session",
+ "tableTo": "escape_game",
+ "columnsFrom": [
+ "escape_game_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "game_session_code_unique": {
+ "name": "game_session_code_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "code"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.item": {
+ "name": "item",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "escape_game_id": {
+ "name": "escape_game_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "step_id": {
+ "name": "step_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_escape_game_id_escape_game_id_fk": {
+ "name": "item_escape_game_id_escape_game_id_fk",
+ "tableFrom": "item",
+ "tableTo": "escape_game",
+ "columnsFrom": [
+ "escape_game_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_step_id_step_id_fk": {
+ "name": "item_step_id_step_id_fk",
+ "tableFrom": "item",
+ "tableTo": "step",
+ "columnsFrom": [
+ "step_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.player": {
+ "name": "player",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "game_session_id": {
+ "name": "game_session_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cgu_accepted": {
+ "name": "cgu_accepted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "joined_at": {
+ "name": "joined_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "player_game_session_id_game_session_id_fk": {
+ "name": "player_game_session_id_game_session_id_fk",
+ "tableFrom": "player",
+ "tableTo": "game_session",
+ "columnsFrom": [
+ "game_session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session_item": {
+ "name": "session_item",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "game_session_id": {
+ "name": "game_session_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "collected_at": {
+ "name": "collected_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_item_game_session_id_game_session_id_fk": {
+ "name": "session_item_game_session_id_game_session_id_fk",
+ "tableFrom": "session_item",
+ "tableTo": "game_session",
+ "columnsFrom": [
+ "game_session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "session_item_item_id_item_id_fk": {
+ "name": "session_item_item_id_item_id_fk",
+ "tableFrom": "session_item",
+ "tableTo": "item",
+ "columnsFrom": [
+ "item_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session_progress": {
+ "name": "session_progress",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "game_session_id": {
+ "name": "game_session_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "step_id": {
+ "name": "step_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_attempt_at": {
+ "name": "last_attempt_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_progress_game_session_id_game_session_id_fk": {
+ "name": "session_progress_game_session_id_game_session_id_fk",
+ "tableFrom": "session_progress",
+ "tableTo": "game_session",
+ "columnsFrom": [
+ "game_session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "session_progress_step_id_step_id_fk": {
+ "name": "session_progress_step_id_step_id_fk",
+ "tableFrom": "session_progress",
+ "tableTo": "step",
+ "columnsFrom": [
+ "step_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.step": {
+ "name": "step",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "escape_game_id": {
+ "name": "escape_game_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "step_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "answer": {
+ "name": "answer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hint": {
+ "name": "hint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "step_escape_game_id_escape_game_id_fk": {
+ "name": "step_escape_game_id_escape_game_id_fk",
+ "tableFrom": "step",
+ "tableTo": "escape_game",
+ "columnsFrom": [
+ "escape_game_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "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,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "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.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,
+ "default": "now()"
+ },
+ "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
+ }
+ },
+ "indexes": {
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "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,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "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": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.step_type": {
+ "name": "step_type",
+ "schema": "public",
+ "values": [
+ "question",
+ "text",
+ "puzzle",
+ "location"
+ ]
+ }
+ },
+ "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..0db8873
--- /dev/null
+++ b/drizzle/meta/0001_snapshot.json
@@ -0,0 +1,908 @@
+{
+ "id": "fe46bb64-72d0-4fc1-849b-4867f5f2209c",
+ "prevId": "6bb611c5-8d24-4601-a6ea-fe00ce6d0f5c",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.escape_game": {
+ "name": "escape_game",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_deleted": {
+ "name": "is_deleted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.game_session": {
+ "name": "game_session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "escape_game_id": {
+ "name": "escape_game_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "varchar(10)",
+ "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": true,
+ "default": "now()"
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "game_session_escape_game_id_escape_game_id_fk": {
+ "name": "game_session_escape_game_id_escape_game_id_fk",
+ "tableFrom": "game_session",
+ "tableTo": "escape_game",
+ "columnsFrom": [
+ "escape_game_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "game_session_code_unique": {
+ "name": "game_session_code_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "code"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.item": {
+ "name": "item",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "escape_game_id": {
+ "name": "escape_game_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "step_id": {
+ "name": "step_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_escape_game_id_escape_game_id_fk": {
+ "name": "item_escape_game_id_escape_game_id_fk",
+ "tableFrom": "item",
+ "tableTo": "escape_game",
+ "columnsFrom": [
+ "escape_game_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_step_id_step_id_fk": {
+ "name": "item_step_id_step_id_fk",
+ "tableFrom": "item",
+ "tableTo": "step",
+ "columnsFrom": [
+ "step_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.player": {
+ "name": "player",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "game_session_id": {
+ "name": "game_session_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cgu_accepted": {
+ "name": "cgu_accepted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "joined_at": {
+ "name": "joined_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "player_game_session_id_game_session_id_fk": {
+ "name": "player_game_session_id_game_session_id_fk",
+ "tableFrom": "player",
+ "tableTo": "game_session",
+ "columnsFrom": [
+ "game_session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session_item": {
+ "name": "session_item",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "game_session_id": {
+ "name": "game_session_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "collected_at": {
+ "name": "collected_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_item_game_session_id_game_session_id_fk": {
+ "name": "session_item_game_session_id_game_session_id_fk",
+ "tableFrom": "session_item",
+ "tableTo": "game_session",
+ "columnsFrom": [
+ "game_session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "session_item_item_id_item_id_fk": {
+ "name": "session_item_item_id_item_id_fk",
+ "tableFrom": "session_item",
+ "tableTo": "item",
+ "columnsFrom": [
+ "item_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session_progress": {
+ "name": "session_progress",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "game_session_id": {
+ "name": "game_session_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "step_id": {
+ "name": "step_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_attempt_at": {
+ "name": "last_attempt_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_progress_game_session_id_game_session_id_fk": {
+ "name": "session_progress_game_session_id_game_session_id_fk",
+ "tableFrom": "session_progress",
+ "tableTo": "game_session",
+ "columnsFrom": [
+ "game_session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "session_progress_step_id_step_id_fk": {
+ "name": "session_progress_step_id_step_id_fk",
+ "tableFrom": "session_progress",
+ "tableTo": "step",
+ "columnsFrom": [
+ "step_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.step": {
+ "name": "step",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "escape_game_id": {
+ "name": "escape_game_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "step_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "answer": {
+ "name": "answer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hint": {
+ "name": "hint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "latitude": {
+ "name": "latitude",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "longitude": {
+ "name": "longitude",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proximity_radius": {
+ "name": "proximity_radius",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 50
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "step_escape_game_id_escape_game_id_fk": {
+ "name": "step_escape_game_id_escape_game_id_fk",
+ "tableFrom": "step",
+ "tableTo": "escape_game",
+ "columnsFrom": [
+ "escape_game_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "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,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "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.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,
+ "default": "now()"
+ },
+ "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
+ }
+ },
+ "indexes": {
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "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,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "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": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.step_type": {
+ "name": "step_type",
+ "schema": "public",
+ "values": [
+ "question",
+ "text",
+ "puzzle",
+ "location"
+ ]
+ }
+ },
+ "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
index eaa8fcf..f6999ad 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -1,5 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
- "entries": []
+ "entries": [
+ {
+ "idx": 0,
+ "version": "7",
+ "when": 1772972870708,
+ "tag": "0000_even_thor_girl",
+ "breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "7",
+ "when": 1772977993829,
+ "tag": "0001_new_silver_samurai",
+ "breakpoints": true
+ }
+ ]
}
\ No newline at end of file
diff --git a/package.json b/package.json
index 173c6c0..0b57988 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,6 @@
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
- "db:reset": "drizzle-kit drop && drizzle-kit push",
"db:studio": "drizzle-kit studio",
"auth:schema": "better-auth generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes"
},
diff --git a/src/lib/auth/client.ts b/src/lib/auth/client.ts
new file mode 100644
index 0000000..c687ca6
--- /dev/null
+++ b/src/lib/auth/client.ts
@@ -0,0 +1,17 @@
+import { createAuthClient } from 'better-auth/svelte';
+import { browser } from '$app/environment';
+
+export const authClient = browser ? createAuthClient({
+ baseURL: typeof window !== 'undefined' ? window.location.origin : ''
+}) : null;
+
+export function useAuth() {
+ if (!authClient) return null;
+
+ return {
+ signUp: authClient.signUp,
+ signIn: authClient.signIn,
+ signOut: authClient.signOut,
+ getSession: authClient.getSession
+ };
+}
diff --git a/src/lib/components/LanguagePicker.svelte b/src/lib/components/LanguagePicker.svelte
new file mode 100644
index 0000000..7a57b5c
--- /dev/null
+++ b/src/lib/components/LanguagePicker.svelte
@@ -0,0 +1,27 @@
+
+
+
+ {#each availableLanguages as lang (lang)}
+
+ {/each}
+
diff --git a/src/lib/components/StepForm.svelte b/src/lib/components/StepForm.svelte
new file mode 100644
index 0000000..ce5d64b
--- /dev/null
+++ b/src/lib/components/StepForm.svelte
@@ -0,0 +1,279 @@
+
+
+
diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json
new file mode 100644
index 0000000..8741a46
--- /dev/null
+++ b/src/lib/i18n/en.json
@@ -0,0 +1,138 @@
+{
+ "common": {
+ "language": "Language",
+ "selectLanguage": "Select Language",
+ "english": "English",
+ "french": "Français",
+ "german": "Deutsch",
+ "spanish": "Español"
+ },
+ "home": {
+ "title": "Outdoor Escape Game",
+ "subtitle": "Adventure awaits! Enter your session code to start playing.",
+ "playGame": "Play Game",
+ "joinWithCode": "Join with a session code"
+ },
+ "game": {
+ "sessionCode": "Session Code",
+ "enterSessionCode": "Enter session code",
+ "yourName": "Your Name",
+ "enterYourName": "Enter your name",
+ "acceptTerms": "I accept the",
+ "termsAndConditions": "terms and conditions",
+ "joinGame": "Join Game",
+ "joining": "Joining...",
+ "pleaseEnterCode": "Please enter a session code",
+ "pleaseEnterName": "Please enter your name",
+ "mustAcceptTerms": "You must accept the terms and conditions",
+ "failedToJoin": "Failed to join session",
+ "errorOccurred": "An error occurred. Please try again."
+ },
+ "gameplay": {
+ "progress": "Progress",
+ "step": "Step",
+ "of": "of",
+ "currentStep": "Current Step",
+ "yourAnswer": "Your Answer",
+ "enterYourAnswer": "Enter your answer",
+ "submitAnswer": "Submit Answer",
+ "checking": "Checking...",
+ "incorrectAnswer": "Incorrect answer",
+ "needAHint": "Need a hint?",
+ "continue": "Continue",
+ "loadingStep": "Loading step...",
+ "collectedItems": "Collected Items",
+ "inventory": "Inventory",
+ "previous": "Previous",
+ "next": "Next",
+ "emptyInventory": "No collected items yet.",
+ "viewingUnlockedStep": "You are viewing an already unlocked step. Go back to the active step to continue progression.",
+ "completedLabel": "Escape completed",
+ "completedTitle": "Great job, you completed the escape game!",
+ "completedIn": "Total time",
+ "playAgain": "Play again",
+ "sessionCode": "Session code",
+ "tutorial": "Tutorial",
+ "tutorialTitle": "Navigation bar guide",
+ "tutorialIntro": "This bar helps you move quickly during the game:",
+ "tutorialPrevious": "Previous: go back to the previous unlocked step.",
+ "tutorialInventory": "Inventory: open the collected items section.",
+ "tutorialNext": "Next: move to the next step only if it is already unlocked.",
+ "backToGame": "Back to game",
+ "locationError": "Location error",
+ "locatingYou": "Locating your position...",
+ "distance": "Distance",
+ "arrived": "You've arrived!",
+ "getWithin": "Get within",
+ "toValidate": "to validate",
+ "validateLocation": "Validate Location",
+ "locationDenied": "Location Access Denied",
+ "locationDeniedMessage": "Please enable location access in your browser settings to continue with this step.",
+ "locationRequired": "Location Access Required",
+ "locationRequiredMessage": "This step requires your location to show you the way to the destination.",
+ "enableLocation": "Enable Location",
+ "tryAgain": "Try Again"
+ },
+ "admin": {
+ "adminDashboard": "Admin Dashboard",
+ "createNewGame": "Create New Game",
+ "createSession": "Create Session",
+ "createSessionDescription": "Create a game session and generate an access code for players.",
+ "selectGame": "Select a game",
+ "expiresDate": "Expiration date",
+ "expiresTime": "Expiration time",
+ "expiresAtDateTime": "Expiration date and time",
+ "expiresAtDateTimeHelp": "Choose when this session should expire.",
+ "cancel": "Cancel",
+ "createGameBeforeSession": "You need at least one game before creating a session.",
+ "totalGames": "Total Games",
+ "activeSessions": "Active Sessions",
+ "totalPlayers": "Total Players",
+ "escapeGames": "Escape Games",
+ "gameTitle": "Game Title",
+ "steps": "Steps",
+ "sessions": "Sessions",
+ "created": "Created",
+ "actions": "Actions",
+ "edit": "Edit",
+ "delete": "Delete",
+ "confirmDeleteSessionTitle": "Delete session",
+ "confirmDeleteSession": "Are you sure you want to delete session",
+ "confirmDeleteTitle": "Delete game",
+ "confirmDeleteGame": "Are you sure you want to delete",
+ "confirmDelete": "Delete permanently",
+ "manage": "Manage",
+ "editSession": "Edit Session",
+ "editSessionDescription": "Update game, expiration date, and active status.",
+ "saveChanges": "Save changes",
+ "noGamesYet": "No escape games yet",
+ "createFirstGame": "Create Your First Game",
+ "recentSessions": "Recent Sessions",
+ "currentAndIncomingSessions": "Current and Incoming Sessions",
+ "meanResolutionTime": "Mean Resolution Time by Game",
+ "noResolutionData": "No completed sessions yet.",
+ "current": "Current",
+ "incoming": "Incoming",
+ "noCurrentOrIncomingSessions": "No current or incoming sessions",
+ "code": "Code",
+ "game": "Game",
+ "status": "Status",
+ "players": "Players",
+ "expires": "Expires",
+ "active": "Active",
+ "inactive": "Inactive",
+ "noSessions": "No sessions yet",
+ "logout": "Logout"
+ },
+ "login": {
+ "login": "Login",
+ "signup": "Sign Up",
+ "accessAdmin": "Access the admin dashboard",
+ "createAccount": "Create an admin account",
+ "emptyFields": "Please fill all fields",
+ "authFailed": "Authentication failed",
+ "hasAccount": "Already have an account?",
+ "noAccount": "Don't have an account?",
+ "loading": "Loading..."
+ }
+}
diff --git a/src/lib/i18n/fr.json b/src/lib/i18n/fr.json
new file mode 100644
index 0000000..d0a77fc
--- /dev/null
+++ b/src/lib/i18n/fr.json
@@ -0,0 +1,138 @@
+{
+ "common": {
+ "language": "Langue",
+ "selectLanguage": "Sélectionnez la Langue",
+ "english": "English",
+ "french": "Français",
+ "german": "Deutsch",
+ "spanish": "Español"
+ },
+ "home": {
+ "title": "Jeu d'Évasion en Plein Air",
+ "subtitle": "L'aventure vous attend ! Entrez votre code de session pour commencer à jouer.",
+ "playGame": "Jouez",
+ "joinWithCode": "Rejoignez avec un code de session"
+ },
+ "game": {
+ "sessionCode": "Code de Session",
+ "enterSessionCode": "Entrez le code de session",
+ "yourName": "Votre Nom",
+ "enterYourName": "Entrez votre nom",
+ "acceptTerms": "J'accepte les",
+ "termsAndConditions": "conditions d'utilisation",
+ "joinGame": "Rejoindre le Jeu",
+ "joining": "Connexion...",
+ "pleaseEnterCode": "Veuillez entrer un code de session",
+ "pleaseEnterName": "Veuillez entrer votre nom",
+ "mustAcceptTerms": "Vous devez accepter les conditions d'utilisation",
+ "failedToJoin": "Échec de la connexion à la session",
+ "errorOccurred": "Une erreur s'est produite. Veuillez réessayer."
+ },
+ "gameplay": {
+ "progress": "Progression",
+ "step": "Étape",
+ "of": "sur",
+ "currentStep": "Étape Actuelle",
+ "yourAnswer": "Votre Réponse",
+ "enterYourAnswer": "Entrez votre réponse",
+ "submitAnswer": "Soumettre la Réponse",
+ "checking": "Vérification...",
+ "incorrectAnswer": "Réponse incorrecte",
+ "needAHint": "Besoin d'un indice ?",
+ "continue": "Continuer",
+ "loadingStep": "Chargement de l'étape...",
+ "collectedItems": "Articles Collectés",
+ "inventory": "Inventaire",
+ "previous": "Précédent",
+ "next": "Suivant",
+ "emptyInventory": "Aucun objet collecté pour le moment.",
+ "viewingUnlockedStep": "Vous consultez une étape déjà débloquée. Revenez à l'étape active pour continuer la progression.",
+ "completedLabel": "Escape termine",
+ "completedTitle": "Bravo, vous avez termine l'escape game !",
+ "completedIn": "Temps total",
+ "playAgain": "Rejouer",
+ "sessionCode": "Code session",
+ "tutorial": "Tutoriel",
+ "tutorialTitle": "Guide de la barre de navigation",
+ "tutorialIntro": "Cette barre vous aide a naviguer rapidement pendant le jeu :",
+ "tutorialPrevious": "Precedent : revenir a l'etape debloquee precedente.",
+ "tutorialInventory": "Inventaire : ouvrir la zone des objets recoltes.",
+ "tutorialNext": "Suivant : aller a l'etape suivante uniquement si elle est deja debloquee.",
+ "backToGame": "Retour au jeu",
+ "locationError": "Erreur de localisation",
+ "locatingYou": "Localisation en cours...",
+ "distance": "Distance",
+ "arrived": "Vous êtes arrivé !",
+ "getWithin": "Approchez-vous à",
+ "toValidate": "pour valider",
+ "validateLocation": "Valider la position",
+ "locationDenied": "Accès à la localisation refusé",
+ "locationDeniedMessage": "Veuillez activer l'accès à la localisation dans les paramètres de votre navigateur pour continuer cette étape.",
+ "locationRequired": "Accès à la localisation requis",
+ "locationRequiredMessage": "Cette étape nécessite votre position pour vous montrer le chemin vers la destination.",
+ "enableLocation": "Activer la localisation",
+ "tryAgain": "Réessayer"
+ },
+ "admin": {
+ "adminDashboard": "Tableau de Bord Admin",
+ "createNewGame": "Créer un Nouveau Jeu",
+ "createSession": "Créer une Session",
+ "createSessionDescription": "Créez une session de jeu et générez un code d'accès pour les joueurs.",
+ "selectGame": "Sélectionnez un jeu",
+ "expiresDate": "Date d'expiration",
+ "expiresTime": "Heure d'expiration",
+ "expiresAtDateTime": "Date et heure d'expiration",
+ "expiresAtDateTimeHelp": "Choisissez quand cette session doit expirer.",
+ "cancel": "Annuler",
+ "createGameBeforeSession": "Vous avez besoin d'au moins un jeu avant de créer une session.",
+ "totalGames": "Jeux Totaux",
+ "activeSessions": "Sessions Actives",
+ "totalPlayers": "Joueurs Totaux",
+ "escapeGames": "Jeux d'Évasion",
+ "gameTitle": "Titre du Jeu",
+ "steps": "Étapes",
+ "sessions": "Sessions",
+ "created": "Créé",
+ "actions": "Actions",
+ "edit": "Modifier",
+ "delete": "Supprimer",
+ "confirmDeleteSessionTitle": "Supprimer la session",
+ "confirmDeleteSession": "Voulez-vous vraiment supprimer la session",
+ "confirmDeleteTitle": "Supprimer le jeu",
+ "confirmDeleteGame": "Voulez-vous vraiment supprimer",
+ "confirmDelete": "Supprimer definitivement",
+ "manage": "Gérer",
+ "editSession": "Modifier la session",
+ "editSessionDescription": "Mettez a jour le jeu, la date d'expiration et le statut actif.",
+ "saveChanges": "Enregistrer les modifications",
+ "noGamesYet": "Aucun jeu d'évasion pour le moment",
+ "createFirstGame": "Créez Votre Premier Jeu",
+ "recentSessions": "Sessions Récentes",
+ "currentAndIncomingSessions": "Sessions en cours et a venir",
+ "meanResolutionTime": "Temps moyen de resolution par jeu",
+ "noResolutionData": "Aucune session terminee pour le moment.",
+ "current": "En cours",
+ "incoming": "A venir",
+ "noCurrentOrIncomingSessions": "Aucune session en cours ou a venir",
+ "code": "Code",
+ "game": "Jeu",
+ "status": "Statut",
+ "players": "Joueurs",
+ "expires": "Expire",
+ "active": "Actif",
+ "inactive": "Inactif",
+ "noSessions": "Aucune session",
+ "logout": "Déconnexion"
+ },
+ "login": {
+ "login": "Connexion",
+ "signup": "Inscription",
+ "accessAdmin": "Accédez au tableau de bord d'administration",
+ "createAccount": "Créer un compte administrateur",
+ "emptyFields": "Veuillez remplir tous les champs",
+ "authFailed": "L'authentification a échoué",
+ "hasAccount": "Vous avez déjà un compte ?",
+ "noAccount": "Vous n'avez pas de compte ?",
+ "loading": "Chargement..."
+ }
+}
diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts
new file mode 100644
index 0000000..2e8b4db
--- /dev/null
+++ b/src/lib/i18n/index.ts
@@ -0,0 +1,51 @@
+import { writable, derived } from 'svelte/store';
+import type { Writable, Readable } from 'svelte/store';
+
+import en from './en.json';
+import fr from './fr.json';
+
+type Messages = typeof en;
+
+const translations: Record = { en, fr };
+
+// Get initial language
+function getInitialLanguage(): string {
+ if (typeof window !== 'undefined') {
+ const stored = localStorage.getItem('language');
+ if (stored && stored in translations) {
+ return stored;
+ }
+ const browserLang = navigator.language.split('-')[0];
+ if (browserLang in translations) {
+ return browserLang;
+ }
+ }
+ return 'en';
+}
+
+// Create writable store for the current language
+export const language: Writable = writable(getInitialLanguage());
+
+// Create derived store for the current messages
+export const t: Readable = derived(language, ($language) => {
+ return translations[$language] || translations['en'];
+});
+
+export function setLanguage(lang: string) {
+ if (lang in translations) {
+ if (typeof window !== 'undefined') {
+ localStorage.setItem('language', lang);
+ }
+ language.set(lang);
+ }
+}
+
+export function getLanguage(): string {
+ let currentLang = 'en';
+ language.subscribe((lang) => {
+ currentLang = lang;
+ })();
+ return currentLang;
+}
+
+export const availableLanguages = Object.keys(translations);
diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
index 2f2fb5b..8f7f518 100644
--- a/src/lib/server/db/schema.ts
+++ b/src/lib/server/db/schema.ts
@@ -1,14 +1,15 @@
-import { pgTable, serial, integer, text, timestamp, varchar, pgEnum, boolean } from 'drizzle-orm/pg-core';
+import { pgTable, serial, integer, text, timestamp, varchar, pgEnum, boolean, doublePrecision } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
// Enum for step types
-export const stepTypeEnum = pgEnum('step_type', ['question', 'text', 'puzzle', 'challenge', 'photo', 'location']);
+export const stepTypeEnum = pgEnum('step_type', ['question', 'text', 'puzzle', 'location']);
-// Escape Game table
+// Escape Game table (soft delete)
export const escapeGame = pgTable('escape_game', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
description: text('description'),
+ isDeleted: boolean('is_deleted').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
@@ -23,9 +24,12 @@ export const step = pgTable('step', {
description: text('description'),
type: stepTypeEnum('type').notNull(),
order: integer('order').notNull(), // Order of the step in the game
- content: text('content'), // Question text, puzzle instructions, etc.
- answer: text('answer'), // Expected answer for question/puzzle types
+ content: text('content'), // Question text, puzzle, etc.
+ answer: text('answer'), // Expected answer for question types
hint: text('hint'), // Optional hint for the step
+ latitude: doublePrecision('latitude'), // Target latitude for location steps
+ longitude: doublePrecision('longitude'), // Target longitude for location steps
+ proximityRadius: integer('proximity_radius').default(50), // Proximity radius in meters (default: 50m)
createdAt: timestamp('created_at').defaultNow().notNull(),
});
diff --git a/src/routes/(admin)/admin/+layout.server.ts b/src/routes/(admin)/admin/+layout.server.ts
new file mode 100644
index 0000000..72f9139
--- /dev/null
+++ b/src/routes/(admin)/admin/+layout.server.ts
@@ -0,0 +1,15 @@
+import { redirect } from '@sveltejs/kit';
+import type { LayoutServerLoad } from './$types';
+import { auth } from '$lib/server/auth';
+
+export const load: LayoutServerLoad = async (event) => {
+ const session = await auth.api.getSession({ headers: event.request.headers });
+
+ if (!session?.user) {
+ redirect(303, '/login');
+ }
+
+ return {
+ user: session.user
+ };
+};
diff --git a/src/routes/(admin)/admin/+layout.svelte b/src/routes/(admin)/admin/+layout.svelte
new file mode 100644
index 0000000..90576a2
--- /dev/null
+++ b/src/routes/(admin)/admin/+layout.svelte
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
{$t.admin?.adminDashboard || 'Admin'}
+
+ {@render children()}
+
+
diff --git a/src/routes/(admin)/admin/+page.server.ts b/src/routes/(admin)/admin/+page.server.ts
new file mode 100644
index 0000000..b75ff84
--- /dev/null
+++ b/src/routes/(admin)/admin/+page.server.ts
@@ -0,0 +1,161 @@
+import { redirect } from '@sveltejs/kit';
+import type { PageServerLoad, Actions } from './$types';
+import { auth } from '$lib/server/auth';
+import { db } from '$lib/server/db';
+import { gameSession, player } from '$lib/server/db/schema';
+import { and, eq, gt } from 'drizzle-orm';
+
+type Game = {
+ id: number;
+ title: string;
+ description?: string;
+ stepCount?: number;
+ sessionCount?: number;
+ createdAt: string;
+};
+
+type Session = {
+ id: number;
+ code: string;
+ gameName: string;
+ isActive: boolean;
+ startedAt?: string;
+ completedAt?: string;
+ playerCount?: number;
+ expiresAt: string;
+};
+
+type ResolutionMetric = {
+ gameId: number;
+ gameTitle: string;
+ meanResolutionMinutes: number;
+ completedSessions: number;
+};
+
+export const load: PageServerLoad = async () => {
+ const gamesRaw = await db.query.escapeGame.findMany({
+ orderBy: (escapeGame, { desc }) => [desc(escapeGame.createdAt)],
+ with: {
+ steps: true,
+ sessions: true
+ }
+ });
+
+ const games: Game[] = gamesRaw.map((game) => ({
+ id: game.id,
+ title: game.title,
+ description: game.description ?? undefined,
+ stepCount: game.steps.length,
+ sessionCount: game.sessions.length,
+ createdAt: game.createdAt.toISOString()
+ }));
+
+ const resolutionMetrics: ResolutionMetric[] = gamesRaw
+ .map((game) => {
+ const completedDurations = game.sessions
+ .filter((session) => session.startedAt && session.completedAt)
+ .map((session) => {
+ const startedAt = new Date(session.startedAt as Date).getTime();
+ const completedAt = new Date(session.completedAt as Date).getTime();
+ return Math.max(0, completedAt - startedAt);
+ });
+
+ if (completedDurations.length === 0) {
+ return null;
+ }
+
+ const totalMs = completedDurations.reduce((sum, duration) => sum + duration, 0);
+ const meanResolutionMinutes = totalMs / completedDurations.length / 60000;
+
+ return {
+ gameId: game.id,
+ gameTitle: game.title,
+ meanResolutionMinutes,
+ completedSessions: completedDurations.length
+ };
+ })
+ .filter((metric): metric is ResolutionMetric => metric !== null)
+ .sort((a, b) => b.meanResolutionMinutes - a.meanResolutionMinutes);
+
+ const maxMeanResolutionMinutes =
+ resolutionMetrics.length > 0
+ ? Math.max(...resolutionMetrics.map((metric) => metric.meanResolutionMinutes))
+ : 0;
+
+ const totalGames = games.length;
+ const activeSessions = await db.$count(gameSession, eq(gameSession.isActive, 1));
+ const totalPlayers = await db.$count(player);
+
+ const recentSessionsRaw = await db.query.gameSession.findMany({
+ orderBy: (session, { desc }) => [desc(session.createdAt)],
+ limit: 10,
+ with: {
+ escapeGame: true,
+ players: true
+ }
+ });
+
+ const currentAndIncomingRaw = await db.query.gameSession.findMany({
+ where: and(eq(gameSession.isActive, 1), gt(gameSession.expiresAt, new Date())),
+ with: {
+ escapeGame: true,
+ players: true
+ }
+ });
+
+ const currentAndIncomingSessions: Session[] = currentAndIncomingRaw
+ .map((session) => ({
+ id: session.id,
+ code: session.code,
+ gameName: session.escapeGame.title,
+ isActive: session.isActive === 1,
+ startedAt: session.startedAt ? new Date(session.startedAt).toISOString() : undefined,
+ completedAt: session.completedAt ? new Date(session.completedAt).toISOString() : undefined,
+ playerCount: session.players.length,
+ expiresAt: session.expiresAt.toISOString()
+ }))
+ .sort((a, b) => {
+ const aIsCurrent = Boolean(a.startedAt) && !a.completedAt;
+ const bIsCurrent = Boolean(b.startedAt) && !b.completedAt;
+
+ if (aIsCurrent !== bIsCurrent) {
+ return aIsCurrent ? -1 : 1;
+ }
+
+ return new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime();
+ })
+ .slice(0, 10);
+
+ const recentSessions: Session[] = recentSessionsRaw.map((session) => ({
+ id: session.id,
+ code: session.code,
+ gameName: session.escapeGame.title,
+ isActive: session.isActive === 1,
+ startedAt: session.startedAt ? new Date(session.startedAt).toISOString() : undefined,
+ completedAt: session.completedAt ? new Date(session.completedAt).toISOString() : undefined,
+ playerCount: session.players.length,
+ expiresAt: session.expiresAt.toISOString()
+ }));
+
+ return {
+ stats: {
+ totalGames,
+ activeSessions,
+ totalPlayers
+ },
+ resolutionMetrics,
+ maxMeanResolutionMinutes,
+ games,
+ currentAndIncomingSessions,
+ recentSessions
+ };
+};
+
+export const actions: Actions = {
+ logout: async (event) => {
+ await auth.api.signOut({
+ headers: event.request.headers
+ });
+ redirect(302, '/admin/login');
+ }
+};
diff --git a/src/routes/(admin)/admin/+page.svelte b/src/routes/(admin)/admin/+page.svelte
new file mode 100644
index 0000000..40cf939
--- /dev/null
+++ b/src/routes/(admin)/admin/+page.svelte
@@ -0,0 +1,176 @@
+
+
+
+
+
+
+
+
+
+
+
{$t.admin.totalGames}
+
{data.stats?.totalGames || 0}
+
+
+
+
+
+
+
+
+
{$t.admin.activeSessions}
+
{data.stats?.activeSessions || 0}
+
+
+
+
+
+
+
+
+
{$t.admin.totalPlayers}
+
{data.stats?.totalPlayers || 0}
+
+
+
+
+
+
+
+
+
+
{$t.admin.currentAndIncomingSessions}
+
+
+ {#if data.currentAndIncomingSessions && data.currentAndIncomingSessions.length > 0}
+
+
+
+
+ |
+ {$t.admin.code}
+ |
+
+ {$t.admin.game}
+ |
+
+ {$t.admin.status}
+ |
+
+ {$t.admin.players}
+ |
+
+ {$t.admin.expires}
+ |
+
+
+
+ {#each data.currentAndIncomingSessions as session (session.id)}
+
+ |
+ {session.code}
+ |
+
+ {session.gameName}
+ |
+
+
+ {session.startedAt && !session.completedAt ? $t.admin.current : $t.admin.incoming}
+
+ |
+
+ {session.playerCount || 0}
+ |
+
+ {new Date(session.expiresAt).toLocaleString()}
+ |
+
+ {/each}
+
+
+
+ {:else}
+
+ {$t.admin.noCurrentOrIncomingSessions}
+
+ {/if}
+
+
+
+
+
+
{$t.admin.meanResolutionTime}
+
+
+ {#if data.resolutionMetrics && data.resolutionMetrics.length > 0}
+
+ {#each data.resolutionMetrics as metric (metric.gameId)}
+
+
+
{metric.gameTitle}
+
+ {formatDuration(metric.meanResolutionMinutes)}
+ ({metric.completedSessions})
+
+
+
+
+ {/each}
+
+ {:else}
+
+
{$t.admin.noResolutionData}
+
+ {/if}
+
+
+
diff --git a/src/routes/(admin)/admin/games/+page.server.ts b/src/routes/(admin)/admin/games/+page.server.ts
new file mode 100644
index 0000000..deca9e6
--- /dev/null
+++ b/src/routes/(admin)/admin/games/+page.server.ts
@@ -0,0 +1,62 @@
+import { error, fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { escapeGame } from '$lib/server/db/schema';
+import { eq } from 'drizzle-orm';
+
+export const load: PageServerLoad = async () => {
+ const gamesRaw = await db.query.escapeGame.findMany({
+ orderBy: (game, { desc }) => [desc(game.createdAt)],
+ where: (game, { eq }) => eq(game.isDeleted, false),
+ with: {
+ steps: true,
+ sessions: true
+ }
+ });
+
+ const games = gamesRaw.map((game) => ({
+ id: game.id,
+ title: game.title,
+ description: game.description ?? undefined,
+ stepCount: game.steps.length,
+ sessionCount: game.sessions.length,
+ createdAt: game.createdAt.toISOString()
+ }));
+
+ if (!Array.isArray(games)) {
+ error(500, 'Failed to load games');
+ }
+
+ return {
+ games
+ };
+};
+
+export const actions: Actions = {
+ deleteGame: async ({ request }) => {
+ const formData = await request.formData();
+ const rawGameId = formData.get('gameId')?.toString() ?? '';
+ const gameId = Number.parseInt(rawGameId, 10);
+
+ if (!Number.isInteger(gameId) || gameId <= 0) {
+ return fail(400, {
+ error: 'Invalid game ID'
+ });
+ }
+
+ const existingGame = await db.query.escapeGame.findFirst({
+ where: eq(escapeGame.id, gameId),
+ columns: { id: true }
+ });
+
+ if (!existingGame) {
+ return fail(404, {
+ error: 'Game not found'
+ });
+ }
+
+ await db.update(escapeGame).set({ isDeleted: true }).where(eq(escapeGame.id, gameId));
+
+ return { success: true };
+ }
+};
diff --git a/src/routes/(admin)/admin/games/+page.svelte b/src/routes/(admin)/admin/games/+page.svelte
new file mode 100644
index 0000000..c5a39a2
--- /dev/null
+++ b/src/routes/(admin)/admin/games/+page.svelte
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+
+
+
+
+ |
+ {$t.admin.gameTitle}
+ |
+
+ {$t.admin.steps}
+ |
+
+ {$t.admin.sessions}
+ |
+
+ {$t.admin.created}
+ |
+
+ {$t.admin.actions}
+ |
+
+
+
+ {#if data.games && data.games.length > 0}
+ {#each data.games as game (game.id)}
+
+ |
+ {game.title}
+ {#if game.description}
+ {game.description}
+ {/if}
+ |
+ {game.stepCount} |
+ {game.sessionCount} |
+
+ {new Date(game.createdAt).toLocaleDateString()}
+ |
+
+
+
+
+ {$t.admin.edit}
+
+
+
+ |
+
+ {/each}
+ {:else}
+
+ |
+ {$t.admin.noGamesYet}
+ |
+
+ {/if}
+
+
+
+
+
+
+
+{#if gameToDelete}
+
+
+
{$t.admin.confirmDeleteTitle}
+
+ {$t.admin.confirmDeleteGame} {gameToDelete.title}?
+
+
+
+
+
+
+
+
+{/if}
diff --git a/src/routes/(admin)/admin/games/[id]/+page.server.ts b/src/routes/(admin)/admin/games/[id]/+page.server.ts
new file mode 100644
index 0000000..99be553
--- /dev/null
+++ b/src/routes/(admin)/admin/games/[id]/+page.server.ts
@@ -0,0 +1,127 @@
+import { error, fail } from '@sveltejs/kit';
+import type { PageServerLoad, Actions } from './$types';
+import { db } from '$lib/server/db';
+import { escapeGame, step } from '$lib/server/db/schema';
+import { and, eq } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ params }) => {
+ const gameId = parseInt(params.id, 10);
+
+ if (isNaN(gameId)) {
+ error(400, 'Invalid game ID');
+ }
+
+ const game = await db.query.escapeGame.findFirst({
+ where: eq(escapeGame.id, gameId),
+ with: {
+ steps: {
+ orderBy: (steps) => steps.order
+ }
+ }
+ });
+
+ if (!game) {
+ error(404, 'Game not found');
+ }
+
+ return {
+ game
+ };
+};
+
+export const actions: Actions = {
+ reorderSteps: async ({ params, request }) => {
+ const gameId = Number.parseInt(params.id, 10);
+ if (Number.isNaN(gameId)) {
+ return fail(400, { error: 'Invalid game ID' });
+ }
+
+ const formData = await request.formData();
+ const orderRaw = formData.get('order')?.toString() ?? '';
+
+ if (!orderRaw) {
+ return fail(400, { error: 'Missing order payload' });
+ }
+
+ let orderedIds: number[] = [];
+ try {
+ const parsed = JSON.parse(orderRaw);
+ if (!Array.isArray(parsed)) {
+ return fail(400, { error: 'Invalid order payload' });
+ }
+ orderedIds = parsed
+ .map((value) => Number.parseInt(String(value), 10))
+ .filter((value) => Number.isInteger(value) && value > 0);
+ } catch {
+ return fail(400, { error: 'Invalid JSON payload' });
+ }
+
+ if (orderedIds.length === 0) {
+ return fail(400, { error: 'No steps to reorder' });
+ }
+
+ const uniqueIds = new Set(orderedIds);
+ if (uniqueIds.size !== orderedIds.length) {
+ return fail(400, { error: 'Duplicate step IDs in payload' });
+ }
+
+ const gameSteps = await db
+ .select({ id: step.id })
+ .from(step)
+ .where(eq(step.escapeGameId, gameId));
+
+ if (gameSteps.length !== orderedIds.length) {
+ return fail(400, { error: 'Order payload does not match game steps' });
+ }
+
+ const gameStepIds = new Set(gameSteps.map((gameStep) => gameStep.id));
+ for (const id of orderedIds) {
+ if (!gameStepIds.has(id)) {
+ return fail(400, { error: 'Order payload contains invalid step IDs' });
+ }
+ }
+
+ await db.transaction(async (tx) => {
+ for (let index = 0; index < orderedIds.length; index += 1) {
+ const stepId = orderedIds[index];
+ await tx
+ .update(step)
+ .set({ order: index + 1 })
+ .where(and(eq(step.id, stepId), eq(step.escapeGameId, gameId)));
+ }
+ });
+
+ return { success: true };
+ },
+
+ deleteStep: async ({ params, request }) => {
+ const gameId = Number.parseInt(params.id, 10);
+ if (Number.isNaN(gameId)) {
+ return fail(400, { error: 'Invalid game ID' });
+ }
+
+ const formData = await request.formData();
+ const stepId = Number.parseInt(formData.get('stepId')?.toString() ?? '', 10);
+
+ if (Number.isNaN(stepId)) {
+ return fail(400, { error: 'Invalid step ID' });
+ }
+
+ // Verify the step exists and belongs to this game
+ const existingStep = await db.query.step.findFirst({
+ where: and(eq(step.id, stepId), eq(step.escapeGameId, gameId))
+ });
+
+ if (!existingStep) {
+ return fail(404, { error: 'Step not found' });
+ }
+
+ try {
+ await db.delete(step).where(and(eq(step.id, stepId), eq(step.escapeGameId, gameId)));
+ return { success: true };
+ } catch (err) {
+ console.error('Delete step error:', err);
+ return fail(500, { error: 'Failed to delete step' });
+ }
+ }
+};
diff --git a/src/routes/(admin)/admin/games/[id]/+page.svelte b/src/routes/(admin)/admin/games/[id]/+page.svelte
new file mode 100644
index 0000000..ee4856f
--- /dev/null
+++ b/src/routes/(admin)/admin/games/[id]/+page.svelte
@@ -0,0 +1,238 @@
+
+
+
+
+
+
+
+ ← Back to Dashboard
+
+
+
{game.title}
+ {#if game.description}
+
{game.description}
+ {/if}
+
+ Created: {new Date(game.createdAt).toLocaleDateString()}
+ Steps: {game.steps?.length || 0}
+
+
+
+
+
+
+
+
+
+
+ {#if steps.length > 0}
+
+ {#each steps as step (step.id)}
+
onDragStart(step.id)}
+ ondragover={onDragOver}
+ ondrop={() => onDrop(step.id)}
+ >
+
+
+
+ ::
+
+ Step {step.order}
+
+ {step.type}
+
+
{step.title}
+ {#if step.description}
+
{step.description}
+ {/if}
+
+
+
+
+ Edit
+
+
+
+
+
+ {/each}
+
+ {:else}
+
+
+
No steps yet. Add your first step to get started.
+
+ Add First Step
+
+
+ {/if}
+
+
+
+
+{#if stepToDelete}
+
+
+
Confirm Deletion
+
+ Are you sure you want to delete Step {stepToDelete.order}: {stepToDelete.title}?
+ This action cannot be undone.
+
+
+
+
+
+
+
+
+{/if}
+
+{#if stepToDelete}
+
+
+
Confirm Deletion
+
+ Are you sure you want to delete Step {stepToDelete.order}: {stepToDelete.title}?
+ This action cannot be undone.
+
+
+
+
+
+
+
+
+{/if}
diff --git a/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.server.ts b/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.server.ts
new file mode 100644
index 0000000..2ee9768
--- /dev/null
+++ b/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.server.ts
@@ -0,0 +1,226 @@
+import { fail, redirect, error } from '@sveltejs/kit';
+import type { PageServerLoad, Actions } from './$types';
+import { db } from '$lib/server/db';
+import { escapeGame, step } from '$lib/server/db/schema';
+import { and, eq, max } from 'drizzle-orm';
+
+const stepTypes = ['question', 'text', 'puzzle', 'location'] as const;
+type StepType = (typeof stepTypes)[number];
+
+const isStepType = (value: string): value is StepType =>
+ stepTypes.includes(value as StepType);
+
+export const load: PageServerLoad = async ({ params }) => {
+ const gameId = Number.parseInt(params.id, 10);
+ const stepId = Number.parseInt(params.stepId, 10);
+
+ if (Number.isNaN(gameId) || Number.isNaN(stepId)) {
+ error(400, 'Invalid ID');
+ }
+
+ const game = await db.query.escapeGame.findFirst({
+ where: eq(escapeGame.id, gameId)
+ });
+
+ if (!game) {
+ error(404, 'Game not found');
+ }
+
+ const gameStep = await db.query.step.findFirst({
+ where: and(eq(step.id, stepId), eq(step.escapeGameId, gameId))
+ });
+
+ if (!gameStep) {
+ error(404, 'Step not found');
+ }
+
+ // Get the total number of steps
+ const lastStep = await db
+ .select({ order: max(step.order) })
+ .from(step)
+ .where(eq(step.escapeGameId, gameId))
+ .then((result) => result[0]?.order ?? 0);
+
+ return {
+ game,
+ step: gameStep,
+ totalSteps: lastStep
+ };
+};
+
+export const actions: Actions = {
+ default: async ({ params, request }) => {
+ const gameId = Number.parseInt(params.id, 10);
+ const stepId = Number.parseInt(params.stepId, 10);
+
+ if (Number.isNaN(gameId) || Number.isNaN(stepId)) {
+ return fail(400, { error: 'Invalid ID' });
+ }
+
+ const formData = await request.formData();
+ const title = formData.get('title')?.toString().trim() ?? '';
+ const description = formData.get('description')?.toString().trim() ?? '';
+ const type = formData.get('type')?.toString() ?? '';
+ const content = formData.get('content')?.toString().trim() ?? '';
+ const answer = formData.get('answer')?.toString().trim() ?? '';
+ const hint = formData.get('hint')?.toString().trim() ?? '';
+ const order = Number.parseInt(formData.get('order')?.toString() ?? '1', 10);
+ const latitude = formData.get('latitude')?.toString() ? parseFloat(formData.get('latitude')!.toString()) : null;
+ const longitude = formData.get('longitude')?.toString() ? parseFloat(formData.get('longitude')!.toString()) : null;
+ const proximityRadius = formData.get('proximityRadius')?.toString() ? parseInt(formData.get('proximityRadius')!.toString(), 10) : 50;
+
+ if (!title) {
+ return fail(400, {
+ title,
+ description,
+ type,
+ content,
+ answer,
+ hint,
+ order,
+ error: 'Le titre est requis'
+ });
+ }
+
+ if (!type) {
+ return fail(400, {
+ title,
+ description,
+ type,
+ content,
+ answer,
+ hint,
+ order,
+ error: 'Le type est requis'
+ });
+ }
+
+ if (!isStepType(type)) {
+ return fail(400, {
+ title,
+ description,
+ type,
+ content,
+ answer,
+ hint,
+ order,
+ error: 'Type invalide'
+ });
+ }
+
+ // Validate location-specific fields
+ if (type === 'location') {
+ if (latitude === null || longitude === null || isNaN(latitude) || isNaN(longitude)) {
+ return fail(400, {
+ title,
+ description,
+ type,
+ content,
+ answer,
+ hint,
+ order,
+ latitude: latitude?.toString() ?? '',
+ longitude: longitude?.toString() ?? '',
+ proximityRadius,
+ error: 'Latitude and longitude are required for location steps'
+ });
+ }
+ if (latitude < -90 || latitude > 90) {
+ return fail(400, {
+ title,
+ description,
+ type,
+ content,
+ answer,
+ hint,
+ order,
+ latitude: latitude.toString(),
+ longitude: longitude?.toString() ?? '',
+ proximityRadius,
+ error: 'Latitude must be between -90 and 90'
+ });
+ }
+ if (longitude < -180 || longitude > 180) {
+ return fail(400, {
+ title,
+ description,
+ type,
+ content,
+ answer,
+ hint,
+ order,
+ latitude: latitude?.toString() ?? '',
+ longitude: longitude.toString(),
+ proximityRadius,
+ error: 'Longitude must be between -180 and 180'
+ });
+ }
+ }
+
+ // Get the current total steps to validate order
+ const lastStep = await db
+ .select({ order: max(step.order) })
+ .from(step)
+ .where(eq(step.escapeGameId, gameId))
+ .then((result) => result[0]?.order ?? 0);
+
+ if (!Number.isInteger(order) || order < 1 || order > lastStep) {
+ return fail(400, {
+ title,
+ description,
+ type,
+ content,
+ answer,
+ hint,
+ order,
+ error: `L'ordre doit etre entre 1 et ${lastStep}`
+ });
+ }
+
+ try {
+ const updated = await db
+ .update(step)
+ .set({
+ title,
+ description: description || null,
+ type,
+ order,
+ content: content || null,
+ answer: answer || null,
+ hint: hint || null,
+ latitude,
+ longitude,
+ proximityRadius
+ })
+ .where(and(eq(step.id, stepId), eq(step.escapeGameId, gameId)))
+ .returning({ id: step.id });
+
+ if (!updated[0]) {
+ return fail(404, {
+ title,
+ description,
+ type,
+ content,
+ answer,
+ hint,
+ order,
+ error: 'Etape introuvable'
+ });
+ }
+ } catch (err) {
+ console.error('Update step error:', err);
+ return fail(500, {
+ title,
+ description,
+ type,
+ content,
+ answer,
+ hint,
+ order,
+ error: 'Une erreur est survenue'
+ });
+ }
+
+ redirect(302, `/admin/games/${gameId}`);
+ }
+};
diff --git a/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.svelte b/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.svelte
new file mode 100644
index 0000000..7a45eb6
--- /dev/null
+++ b/src/routes/(admin)/admin/games/[id]/steps/[stepId]/+page.svelte
@@ -0,0 +1,43 @@
+
+
+
diff --git a/src/routes/(admin)/admin/games/[id]/steps/new/+page.server.ts b/src/routes/(admin)/admin/games/[id]/steps/new/+page.server.ts
new file mode 100644
index 0000000..230c848
--- /dev/null
+++ b/src/routes/(admin)/admin/games/[id]/steps/new/+page.server.ts
@@ -0,0 +1,197 @@
+import { fail, redirect, error } from '@sveltejs/kit';
+import type { PageServerLoad, Actions } from './$types';
+import { db } from '$lib/server/db';
+import { escapeGame, step } from '$lib/server/db/schema';
+import { eq, max } from 'drizzle-orm';
+
+const stepTypes = ['question', 'text', 'puzzle', 'location'] as const;
+type StepType = (typeof stepTypes)[number];
+
+const isStepType = (value: string): value is StepType =>
+ stepTypes.includes(value as StepType);
+
+export const load: PageServerLoad = async ({ params }) => {
+ const gameId = parseInt(params.id, 10);
+
+ if (isNaN(gameId)) {
+ error(400, 'Invalid game ID');
+ }
+
+ const game = await db.query.escapeGame.findFirst({
+ where: eq(escapeGame.id, gameId)
+ });
+
+ if (!game) {
+ error(404, 'Game not found');
+ }
+
+ // Get the highest step order
+ const lastStep = await db
+ .select({ order: max(step.order) })
+ .from(step)
+ .where(eq(step.escapeGameId, gameId))
+ .then((result) => result[0]?.order ?? 0);
+
+ const nextStepOrder = lastStep + 1;
+
+ return {
+ game,
+ nextStepOrder,
+ totalSteps: lastStep
+ };
+};
+
+export const actions: Actions = {
+ default: async ({ params, request }) => {
+ const gameId = parseInt(params.id, 10);
+
+ if (isNaN(gameId)) {
+ return fail(400, { error: 'Invalid game ID' });
+ }
+
+ const formData = await request.formData();
+ const title = formData.get('title')?.toString().trim() ?? '';
+ const description = formData.get('description')?.toString().trim() ?? '';
+ const type = formData.get('type')?.toString() ?? '';
+ const content = formData.get('content')?.toString().trim() ?? '';
+ const answer = formData.get('answer')?.toString().trim() ?? '';
+ const hint = formData.get('hint')?.toString().trim() ?? '';
+ const order = parseInt(formData.get('order')?.toString() ?? '1', 10);
+ const latitude = formData.get('latitude')?.toString() ? parseFloat(formData.get('latitude')!.toString()) : null;
+ const longitude = formData.get('longitude')?.toString() ? parseFloat(formData.get('longitude')!.toString()) : null;
+ const proximityRadius = formData.get('proximityRadius')?.toString() ? parseInt(formData.get('proximityRadius')!.toString(), 10) : 50;
+
+ if (!title) {
+ return fail(400, {
+ title,
+ description,
+ type,
+ content,
+ answer,
+ hint,
+ error: 'Le titre est requis'
+ });
+ }
+
+ if (!type) {
+ return fail(400, {
+ title,
+ description,
+ type,
+ content,
+ answer,
+ hint,
+ error: 'Le type est requis'
+ });
+ }
+
+ if (!isStepType(type)) {
+ return fail(400, {
+ title,
+ description,
+ type,
+ content,
+ answer,
+ hint,
+ error: 'Type invalide'
+ });
+ }
+
+ // Validate location-specific fields
+ if (type === 'location') {
+ if (latitude === null || longitude === null || isNaN(latitude) || isNaN(longitude)) {
+ return fail(400, {
+ title,
+ description,
+ type,
+ content,
+ answer,
+ hint,
+ latitude: latitude?.toString() ?? '',
+ longitude: longitude?.toString() ?? '',
+ proximityRadius,
+ error: 'Latitude and longitude are required for location steps'
+ });
+ }
+ if (latitude < -90 || latitude > 90) {
+ return fail(400, {
+ title,
+ description,
+ type,
+ content,
+ answer,
+ hint,
+ latitude: latitude.toString(),
+ longitude: longitude?.toString() ?? '',
+ proximityRadius,
+ error: 'Latitude must be between -90 and 90'
+ });
+ }
+ if (longitude < -180 || longitude > 180) {
+ return fail(400, {
+ title,
+ description,
+ type,
+ content,
+ answer,
+ hint,
+ latitude: latitude?.toString() ?? '',
+ longitude: longitude.toString(),
+ proximityRadius,
+ error: 'Longitude must be between -180 and 180'
+ });
+ }
+ }
+
+ // Get the current total steps to validate order
+ const lastStep = await db
+ .select({ order: max(step.order) })
+ .from(step)
+ .where(eq(step.escapeGameId, gameId))
+ .then((result) => result[0]?.order ?? 0);
+
+ const maxOrder = lastStep + 1;
+
+ if (!Number.isInteger(order) || order < 1 || order > maxOrder) {
+ return fail(400, {
+ title,
+ description,
+ type,
+ content,
+ answer,
+ hint,
+ order,
+ error: `L'ordre doit etre entre 1 et ${maxOrder}`
+ });
+ }
+
+ try {
+ await db.insert(step).values({
+ escapeGameId: gameId,
+ title,
+ description: description || null,
+ type,
+ order,
+ content: content || null,
+ answer: answer || null,
+ hint: hint || null,
+ latitude,
+ longitude,
+ proximityRadius
+ });
+ } catch (error) {
+ console.error('Create step error:', error);
+ return fail(500, {
+ title,
+ description,
+ type,
+ content,
+ answer,
+ hint,
+ error: 'Une erreur est survenue'
+ });
+ }
+
+ redirect(302, `/admin/games/${gameId}`);
+ }
+};
diff --git a/src/routes/(admin)/admin/games/[id]/steps/new/+page.svelte b/src/routes/(admin)/admin/games/[id]/steps/new/+page.svelte
new file mode 100644
index 0000000..e7ca16e
--- /dev/null
+++ b/src/routes/(admin)/admin/games/[id]/steps/new/+page.svelte
@@ -0,0 +1,40 @@
+
+
+
diff --git a/src/routes/(admin)/admin/games/new/+page.server.ts b/src/routes/(admin)/admin/games/new/+page.server.ts
new file mode 100644
index 0000000..d3e546c
--- /dev/null
+++ b/src/routes/(admin)/admin/games/new/+page.server.ts
@@ -0,0 +1,66 @@
+import { fail, redirect } from '@sveltejs/kit';
+import type { Actions } from './$types';
+import { db } from '$lib/server/db';
+import { escapeGame } from '$lib/server/db/schema';
+
+export const actions: Actions = {
+ default: async (event) => {
+ const formData = await event.request.formData();
+ const title = formData.get('title')?.toString().trim() ?? '';
+ const description = formData.get('description')?.toString().trim() ?? '';
+ let createdGameId: number | null = null;
+
+ if (!title) {
+ return fail(400, {
+ title,
+ description,
+ error: 'Le titre est requis'
+ });
+ }
+
+ if (title.length < 3) {
+ return fail(400, {
+ title,
+ description,
+ error: 'Le titre doit contenir au moins 3 caractères'
+ });
+ }
+
+ try {
+ const result = await db
+ .insert(escapeGame)
+ .values({
+ title,
+ description: description || null
+ })
+ .returning({ id: escapeGame.id });
+
+ if (!result[0]) {
+ return fail(500, {
+ title,
+ description,
+ error: 'Erreur lors de la création du jeu'
+ });
+ }
+
+ createdGameId = result[0].id;
+ } catch (error) {
+ console.error('Create game error:', error);
+ return fail(500, {
+ title,
+ description,
+ error: 'Une erreur est survenue'
+ });
+ }
+
+ if (createdGameId === null) {
+ return fail(500, {
+ title,
+ description,
+ error: 'Erreur lors de la création du jeu'
+ });
+ }
+
+ redirect(302, `/admin/games/${createdGameId}`);
+ }
+};
diff --git a/src/routes/(admin)/admin/games/new/+page.svelte b/src/routes/(admin)/admin/games/new/+page.svelte
new file mode 100644
index 0000000..d345667
--- /dev/null
+++ b/src/routes/(admin)/admin/games/new/+page.svelte
@@ -0,0 +1,74 @@
+
+
+
+
+
+
Create New Game
+
Create a new escape game and add steps to it.
+
+
+
+
+
+
+
diff --git a/src/routes/(admin)/admin/sessions/+page.server.ts b/src/routes/(admin)/admin/sessions/+page.server.ts
new file mode 100644
index 0000000..af8c528
--- /dev/null
+++ b/src/routes/(admin)/admin/sessions/+page.server.ts
@@ -0,0 +1,81 @@
+import { fail, redirect } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { gameSession } from '$lib/server/db/schema';
+import { eq } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ url }) => {
+ const requestedGameId = Number.parseInt(url.searchParams.get('gameId') ?? '', 10);
+ const selectedGameId = Number.isInteger(requestedGameId) && requestedGameId > 0 ? requestedGameId : null;
+
+ const gamesRaw = await db.query.escapeGame.findMany({
+ orderBy: (game, { asc }) => [asc(game.title)]
+ });
+
+ const sessionsRaw = await db.query.gameSession.findMany({
+ where: selectedGameId ? eq(gameSession.escapeGameId, selectedGameId) : undefined,
+ orderBy: (session, { desc }) => [desc(session.createdAt)],
+ with: {
+ escapeGame: true,
+ players: true
+ }
+ });
+
+ return {
+ selectedGameId,
+ games: gamesRaw.map((game) => ({
+ id: game.id,
+ title: game.title
+ })),
+ sessions: sessionsRaw.map((session) => ({
+ id: session.id,
+ code: session.code,
+ gameId: session.escapeGameId,
+ gameTitle: session.escapeGame.title,
+ isActive: session.isActive === 1,
+ players: session.players.length,
+ createdAt: session.createdAt.toISOString(),
+ expiresAt: session.expiresAt.toISOString(),
+ startedAt: session.startedAt ? new Date(session.startedAt).toISOString() : null,
+ completedAt: session.completedAt ? new Date(session.completedAt).toISOString() : null
+ }))
+ };
+};
+
+export const actions: Actions = {
+ toggleStatus: async ({ request, url }) => {
+ const formData = await request.formData();
+ const sessionId = Number.parseInt(formData.get('sessionId')?.toString() ?? '', 10);
+ const currentStatus = Number.parseInt(formData.get('currentStatus')?.toString() ?? '', 10);
+
+ if (!Number.isInteger(sessionId) || sessionId <= 0) {
+ return fail(400, { error: 'Invalid session ID' });
+ }
+
+ if (![0, 1].includes(currentStatus)) {
+ return fail(400, { error: 'Invalid status value' });
+ }
+
+ await db
+ .update(gameSession)
+ .set({ isActive: currentStatus === 1 ? 0 : 1 })
+ .where(eq(gameSession.id, sessionId));
+
+ redirect(303, `${url.pathname}${url.search}`);
+ },
+ deleteSession: async ({ request, url }) => {
+ const formData = await request.formData();
+ const sessionId = Number.parseInt(formData.get('sessionId')?.toString() ?? '', 10);
+
+ if (!Number.isInteger(sessionId) || sessionId <= 0) {
+ return fail(400, { error: 'Invalid session ID' });
+ }
+
+ await db.delete(gameSession).where(eq(gameSession.id, sessionId));
+
+ return {
+ success: true,
+ redirectTo: `${url.pathname}${url.search}`
+ };
+ }
+};
diff --git a/src/routes/(admin)/admin/sessions/+page.svelte b/src/routes/(admin)/admin/sessions/+page.svelte
new file mode 100644
index 0000000..b81ae93
--- /dev/null
+++ b/src/routes/(admin)/admin/sessions/+page.svelte
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+
{$t.admin.sessions}
+
{$t.admin.recentSessions}
+
+
+
+
+ {$t.admin.createSession}
+
+
+
+
+
+ {#if form?.error}
+
{form.error}
+ {/if}
+
+
+
+
+
+
+ | {$t.admin.code} |
+ {$t.admin.game} |
+ {$t.admin.status} |
+ {$t.admin.players} |
+ {$t.admin.created} |
+ {$t.admin.expires} |
+ {$t.admin.actions} |
+
+
+
+ {#if data.sessions.length > 0}
+ {#each data.sessions as session (session.id)}
+
+ | {session.code} |
+ {session.gameTitle} |
+
+
+ {session.isActive ? $t.admin.active : $t.admin.inactive}
+
+ |
+ {session.players} |
+ {new Date(session.createdAt).toLocaleString()} |
+ {new Date(session.expiresAt).toLocaleString()} |
+
+
+
+
+ {$t.admin.edit}
+
+
+
+ |
+
+ {/each}
+ {:else}
+
+ | {$t.admin.noSessions} |
+
+ {/if}
+
+
+
+
+
+
+
+{#if sessionToDelete}
+
+
+
{$t.admin.confirmDeleteSessionTitle}
+
+ {$t.admin.confirmDeleteSession}
+ {sessionToDelete.code}?
+
+
+
+
+
+
+
+
+{/if}
diff --git a/src/routes/(admin)/admin/sessions/[id]/+page.server.ts b/src/routes/(admin)/admin/sessions/[id]/+page.server.ts
new file mode 100644
index 0000000..aa37592
--- /dev/null
+++ b/src/routes/(admin)/admin/sessions/[id]/+page.server.ts
@@ -0,0 +1,131 @@
+import { error, fail, redirect } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { escapeGame, gameSession } from '$lib/server/db/schema';
+import { eq } from 'drizzle-orm';
+
+function toDateTimeLocalInputValue(value: Date | string): string {
+ const date = value instanceof Date ? value : new Date(value);
+ const year = date.getFullYear();
+ const month = `${date.getMonth() + 1}`.padStart(2, '0');
+ const day = `${date.getDate()}`.padStart(2, '0');
+ const hours = `${date.getHours()}`.padStart(2, '0');
+ const minutes = `${date.getMinutes()}`.padStart(2, '0');
+ return `${year}-${month}-${day}T${hours}:${minutes}`;
+}
+
+export const load: PageServerLoad = async ({ params }) => {
+ const sessionId = Number.parseInt(params.id, 10);
+ if (!Number.isInteger(sessionId) || sessionId <= 0) {
+ error(400, 'Invalid session ID');
+ }
+
+ const session = await db.query.gameSession.findFirst({
+ where: eq(gameSession.id, sessionId),
+ with: {
+ escapeGame: true
+ }
+ });
+
+ if (!session) {
+ error(404, 'Session not found');
+ }
+
+ const gamesRaw = await db.query.escapeGame.findMany({
+ orderBy: (game, { asc }) => [asc(game.title)],
+ columns: {
+ id: true,
+ title: true
+ }
+ });
+
+ return {
+ session: {
+ id: session.id,
+ code: session.code,
+ gameId: session.escapeGameId,
+ expiresAt: toDateTimeLocalInputValue(session.expiresAt),
+ isActive: session.isActive === 1
+ },
+ games: gamesRaw
+ };
+};
+
+export const actions: Actions = {
+ default: async ({ params, request }) => {
+ const formData = await request.formData();
+ const rawGameId = formData.get('gameId')?.toString() ?? '';
+ const rawExpiresAt = formData.get('expiresAt')?.toString() ?? '';
+ const isActive = formData.get('isActive') === 'on';
+
+ const sessionId = Number.parseInt(params.id, 10);
+ if (!Number.isInteger(sessionId) || sessionId <= 0) {
+ return fail(400, {
+ error: 'Invalid session ID',
+ gameId: rawGameId,
+ expiresAt: rawExpiresAt,
+ isActive
+ });
+ }
+
+ const gameId = Number.parseInt(rawGameId, 10);
+ const expiresAt = new Date(rawExpiresAt);
+
+ if (!Number.isInteger(gameId) || gameId <= 0) {
+ return fail(400, {
+ error: 'Please select a valid game',
+ gameId: rawGameId,
+ expiresAt: rawExpiresAt,
+ isActive
+ });
+ }
+
+ if (!rawExpiresAt || Number.isNaN(expiresAt.getTime())) {
+ return fail(400, {
+ error: 'Please provide a valid expiration date and time',
+ gameId: rawGameId,
+ expiresAt: rawExpiresAt,
+ isActive
+ });
+ }
+
+ const existingGame = await db.query.escapeGame.findFirst({
+ where: eq(escapeGame.id, gameId),
+ columns: { id: true }
+ });
+
+ if (!existingGame) {
+ return fail(404, {
+ error: 'Selected game does not exist',
+ gameId: rawGameId,
+ expiresAt: rawExpiresAt,
+ isActive
+ });
+ }
+
+ const existingSession = await db.query.gameSession.findFirst({
+ where: eq(gameSession.id, sessionId),
+ columns: { id: true }
+ });
+
+ if (!existingSession) {
+ return fail(404, {
+ error: 'Session not found',
+ gameId: rawGameId,
+ expiresAt: rawExpiresAt,
+ isActive
+ });
+ }
+
+ await db
+ .update(gameSession)
+ .set({
+ escapeGameId: gameId,
+ expiresAt,
+ isActive: isActive ? 1 : 0
+ })
+ .where(eq(gameSession.id, sessionId));
+
+ redirect(303, `/admin/sessions?gameId=${gameId}`);
+ }
+};
diff --git a/src/routes/(admin)/admin/sessions/[id]/+page.svelte b/src/routes/(admin)/admin/sessions/[id]/+page.svelte
new file mode 100644
index 0000000..689071f
--- /dev/null
+++ b/src/routes/(admin)/admin/sessions/[id]/+page.svelte
@@ -0,0 +1,102 @@
+
+
+
+
+
+
{$t.admin.editSession}
+
{$t.admin.editSessionDescription}
+
+
+
+
+
+
+
diff --git a/src/routes/(admin)/admin/sessions/new/+page.server.ts b/src/routes/(admin)/admin/sessions/new/+page.server.ts
new file mode 100644
index 0000000..0201ed7
--- /dev/null
+++ b/src/routes/(admin)/admin/sessions/new/+page.server.ts
@@ -0,0 +1,116 @@
+import { fail, redirect } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { escapeGame, gameSession } from '$lib/server/db/schema';
+import { eq } from 'drizzle-orm';
+
+const SESSION_CODE_LENGTH = 6;
+const SESSION_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
+const MAX_SESSION_CODE_ATTEMPTS = 10;
+
+function generateSessionCode(): string {
+ return Array.from({ length: SESSION_CODE_LENGTH }, () => {
+ const randomIndex = Math.floor(Math.random() * SESSION_CODE_ALPHABET.length);
+ return SESSION_CODE_ALPHABET[randomIndex];
+ }).join('');
+}
+
+async function createUniqueSessionCode(): Promise {
+ for (let attempt = 0; attempt < MAX_SESSION_CODE_ATTEMPTS; attempt += 1) {
+ const code = generateSessionCode();
+ const existing = await db.query.gameSession.findFirst({
+ where: eq(gameSession.code, code),
+ columns: { id: true }
+ });
+
+ if (!existing) {
+ return code;
+ }
+ }
+
+ return null;
+}
+
+export const load: PageServerLoad = async ({ url }) => {
+ const requestedGameId = Number.parseInt(url.searchParams.get('gameId') ?? '', 10);
+ const selectedGameId = Number.isInteger(requestedGameId) && requestedGameId > 0 ? requestedGameId : null;
+
+ const gamesRaw = await db.query.escapeGame.findMany({
+ orderBy: (game, { asc }) => [asc(game.title)],
+ columns: {
+ id: true,
+ title: true
+ }
+ });
+
+ return {
+ selectedGameId,
+ games: gamesRaw
+ };
+};
+
+export const actions: Actions = {
+ default: async ({ request }) => {
+ const formData = await request.formData();
+ const rawGameId = formData.get('gameId')?.toString() ?? '';
+ const rawExpiresAt = formData.get('expiresAt')?.toString() ?? '';
+
+ const gameId = Number.parseInt(rawGameId, 10);
+ const expiresAt = new Date(rawExpiresAt);
+
+ if (!Number.isInteger(gameId) || gameId <= 0) {
+ return fail(400, {
+ error: 'Please select a valid game',
+ gameId: rawGameId,
+ expiresAt: rawExpiresAt
+ });
+ }
+
+ if (!rawExpiresAt || Number.isNaN(expiresAt.getTime())) {
+ return fail(400, {
+ error: 'Please provide a valid expiration date and time',
+ gameId: rawGameId,
+ expiresAt: rawExpiresAt
+ });
+ }
+
+ if (expiresAt.getTime() <= Date.now()) {
+ return fail(400, {
+ error: 'Expiration date must be in the future',
+ gameId: rawGameId,
+ expiresAt: rawExpiresAt
+ });
+ }
+
+ const existingGame = await db.query.escapeGame.findFirst({
+ where: eq(escapeGame.id, gameId),
+ columns: { id: true }
+ });
+
+ if (!existingGame) {
+ return fail(404, {
+ error: 'Selected game does not exist',
+ gameId: rawGameId,
+ expiresAt: rawExpiresAt
+ });
+ }
+
+ const code = await createUniqueSessionCode();
+ if (!code) {
+ return fail(500, {
+ error: 'Unable to generate session code. Please retry.',
+ gameId: rawGameId,
+ expiresAt: rawExpiresAt
+ });
+ }
+
+ await db.insert(gameSession).values({
+ escapeGameId: gameId,
+ code,
+ expiresAt,
+ isActive: 1
+ });
+
+ redirect(303, `/admin/sessions?gameId=${gameId}`);
+ }
+};
diff --git a/src/routes/(admin)/admin/sessions/new/+page.svelte b/src/routes/(admin)/admin/sessions/new/+page.svelte
new file mode 100644
index 0000000..6afb753
--- /dev/null
+++ b/src/routes/(admin)/admin/sessions/new/+page.svelte
@@ -0,0 +1,87 @@
+
+
+
+
+
+
{$t.admin.createSession}
+
{$t.admin.createSessionDescription}
+
+
+
+
+
+
+ {#if data.games.length === 0}
+
+ {$t.admin.createGameBeforeSession}
+ {$t.admin.createNewGame}
+
+ {/if}
+
+
diff --git a/src/routes/(game)/+layout.svelte b/src/routes/(game)/+layout.svelte
new file mode 100644
index 0000000..f901bcd
--- /dev/null
+++ b/src/routes/(game)/+layout.svelte
@@ -0,0 +1,9 @@
+
+
+
+
+ {@render children()}
+
+
diff --git a/src/routes/(game)/game/+page.server.ts b/src/routes/(game)/game/+page.server.ts
new file mode 100644
index 0000000..444077e
--- /dev/null
+++ b/src/routes/(game)/game/+page.server.ts
@@ -0,0 +1,86 @@
+import { fail, redirect } from '@sveltejs/kit';
+import type { Actions } from './$types';
+import { db } from '$lib/server/db';
+import { gameSession, player } from '$lib/server/db/schema';
+import { eq } from 'drizzle-orm';
+
+export const actions: Actions = {
+ joinSession: async (event) => {
+ const formData = await event.request.formData();
+ const code = (formData.get('code') as string)?.toUpperCase();
+ const cguAccepted = formData.get('cguAccepted') === 'on';
+ let sessionCode: string | null = null;
+
+ if (!code?.trim()) {
+ return fail(400, {
+ error: 'Please enter a session code'
+ });
+ }
+
+ if (!cguAccepted) {
+ return fail(400, {
+ error: 'You must accept the terms and conditions'
+ });
+ }
+
+ try {
+ // Find the session by code
+ const sessions = await db
+ .select()
+ .from(gameSession)
+ .where(eq(gameSession.code, code));
+
+ if (!sessions || sessions.length === 0) {
+ return fail(404, {
+ error: 'Session not found. Please check the code.'
+ });
+ }
+
+ const session = sessions[0];
+
+ // Check if session is active
+ if (!session.isActive) {
+ return fail(400, {
+ error: 'This session is no longer active.'
+ });
+ }
+
+ // Check if session has expired
+ if (session.expiresAt && new Date(session.expiresAt) < new Date()) {
+ return fail(400, {
+ error: 'This session has expired.'
+ });
+ }
+
+ // Create player
+ await db.insert(player).values({
+ gameSessionId: session.id,
+ cguAccepted
+ });
+
+ // Start timer when the first player joins the session
+ if (!session.startedAt) {
+ await db
+ .update(gameSession)
+ .set({ startedAt: new Date() })
+ .where(eq(gameSession.id, session.id));
+ }
+
+ sessionCode = session.code;
+
+ } catch (err) {
+ console.error('Join session error:', err);
+ return fail(500, {
+ error: 'An error occurred. Please try again.'
+ });
+ }
+
+ if (sessionCode === null) {
+ return fail(500, {
+ error: 'An error occurred. Please try again.'
+ });
+ }
+
+ redirect(302, `/game/play/${sessionCode}`);
+ }
+};
diff --git a/src/routes/(game)/game/+page.svelte b/src/routes/(game)/game/+page.svelte
new file mode 100644
index 0000000..fee7107
--- /dev/null
+++ b/src/routes/(game)/game/+page.svelte
@@ -0,0 +1,62 @@
+
+
+
+
+
{$t.home.title}
+
{$t.home.subtitle}
+
+
+
+
diff --git a/src/routes/(game)/game/play/[sessionCode]/+layout.server.ts b/src/routes/(game)/game/play/[sessionCode]/+layout.server.ts
new file mode 100644
index 0000000..96d5f47
--- /dev/null
+++ b/src/routes/(game)/game/play/[sessionCode]/+layout.server.ts
@@ -0,0 +1,120 @@
+import { error, redirect } from '@sveltejs/kit';
+import type { LayoutServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { gameSession } from '$lib/server/db/schema';
+import { eq } from 'drizzle-orm';
+
+export const load: LayoutServerLoad = async ({ params, url }) => {
+ const sessionCode = params.sessionCode.toUpperCase().trim();
+
+ if (!sessionCode) {
+ error(400, 'Invalid session code');
+ }
+
+ const session = await db.query.gameSession.findFirst({
+ where: eq(gameSession.code, sessionCode),
+ with: {
+ escapeGame: {
+ with: {
+ steps: {
+ orderBy: (steps, { asc }) => [asc(steps.order)]
+ }
+ }
+ },
+ progress: {
+ with: {
+ step: true
+ }
+ },
+ collectedItems: {
+ with: {
+ item: true
+ }
+ }
+ }
+ });
+
+ if (!session) {
+ error(404, 'Session not found');
+ }
+
+ if (session.isActive !== 1) {
+ error(400, 'Session is inactive');
+ }
+
+ if (session.expiresAt && new Date(session.expiresAt) < new Date()) {
+ error(400, 'Session has expired');
+ }
+
+ const steps = session.escapeGame.steps;
+ const totalSteps = steps.length;
+
+ const completedStepIds = new Set(
+ session.progress.filter((entry) => entry.completedAt !== null).map((entry) => entry.stepId)
+ );
+
+ const currentStep = steps.find((entry) => !completedStepIds.has(entry.id)) ?? null;
+ const currentStepOrder = currentStep?.order ?? totalSteps;
+
+ if (!currentStep && !url.pathname.endsWith('/complete')) {
+ if (!session.completedAt) {
+ await db
+ .update(gameSession)
+ .set({ completedAt: new Date() })
+ .where(eq(gameSession.id, session.id));
+ }
+
+ redirect(303, `/game/play/${session.code}/complete`);
+ }
+
+ const unlockedSteps = currentStep
+ ? steps.filter((entry) => entry.order <= currentStep.order)
+ : steps;
+
+ const requestedStepId = Number.parseInt(url.searchParams.get('step') ?? '', 10);
+ const isRequestedStepUnlocked = unlockedSteps.some((entry) => entry.id === requestedStepId);
+
+ const displayedStepRecord = isRequestedStepUnlocked
+ ? (unlockedSteps.find((entry) => entry.id === requestedStepId) ?? null)
+ : (currentStep ?? unlockedSteps.at(-1) ?? null);
+
+ const displayedStepIndex = displayedStepRecord
+ ? unlockedSteps.findIndex((entry) => entry.id === displayedStepRecord.id)
+ : -1;
+
+ const previousStepId = displayedStepIndex > 0 ? unlockedSteps[displayedStepIndex - 1].id : null;
+ const nextStepId =
+ displayedStepIndex >= 0 && displayedStepIndex < unlockedSteps.length - 1
+ ? unlockedSteps[displayedStepIndex + 1].id
+ : null;
+
+ return {
+ sessionCode: session.code,
+ currentStepOrder,
+ totalSteps,
+ activeStepId: currentStep?.id ?? null,
+ displayedStep: displayedStepRecord
+ ? {
+ id: displayedStepRecord.id,
+ title: displayedStepRecord.title,
+ description: displayedStepRecord.description ?? undefined,
+ content: displayedStepRecord.content ?? undefined,
+ type: displayedStepRecord.type,
+ hint: displayedStepRecord.hint ?? undefined
+ }
+ : null,
+ unlockedSteps: unlockedSteps.map((entry) => ({
+ id: entry.id,
+ order: entry.order,
+ title: entry.title,
+ isCompleted: completedStepIds.has(entry.id)
+ })),
+ previousStepId,
+ nextStepId,
+ collectedItems: session.collectedItems.map((entry) => ({
+ id: entry.item.id,
+ name: entry.item.name,
+ imageUrl: entry.item.imageUrl ?? undefined
+ }))
+ };
+};
diff --git a/src/routes/(game)/game/play/[sessionCode]/+layout.svelte b/src/routes/(game)/game/play/[sessionCode]/+layout.svelte
new file mode 100644
index 0000000..aa3e79b
--- /dev/null
+++ b/src/routes/(game)/game/play/[sessionCode]/+layout.svelte
@@ -0,0 +1,189 @@
+
+
+
+ {@render children()}
+
+
+{#if showMenu}
+
+
+ {#if inventoryOpen}
+ {
+ if (e.key === 'Escape') closeInventory();
+ }}
+ role="button"
+ tabindex={0}
+ >
+
+ {/if}
+{/if}
diff --git a/src/routes/(game)/game/play/[sessionCode]/+page.server.ts b/src/routes/(game)/game/play/[sessionCode]/+page.server.ts
new file mode 100644
index 0000000..75ffddf
--- /dev/null
+++ b/src/routes/(game)/game/play/[sessionCode]/+page.server.ts
@@ -0,0 +1,214 @@
+import { fail, redirect } from '@sveltejs/kit';
+import type { Actions } from './$types';
+import { db } from '$lib/server/db';
+import { gameSession, sessionProgress, step } from '$lib/server/db/schema';
+import { and, eq } from 'drizzle-orm';
+
+export const actions: Actions = {
+ continueStep: async ({ params, request }) => {
+ const sessionCode = params.sessionCode.toUpperCase().trim();
+ if (!sessionCode) {
+ return fail(400, { error: 'Invalid session code' });
+ }
+
+ const formData = await request.formData();
+ const stepId = Number.parseInt(formData.get('stepId')?.toString() ?? '', 10);
+
+ if (!Number.isInteger(stepId) || stepId <= 0) {
+ return fail(400, { error: 'Invalid step ID' });
+ }
+
+ const session = await db.query.gameSession.findFirst({
+ where: eq(gameSession.code, sessionCode)
+ });
+ if (!session) {
+ return fail(404, { error: 'Session not found' });
+ }
+
+ const stepRecord = await db.query.step.findFirst({
+ where: and(eq(step.id, stepId), eq(step.escapeGameId, session.escapeGameId))
+ });
+
+ if (!stepRecord) {
+ return fail(404, { error: 'Step not found' });
+ }
+
+ if (stepRecord.type !== 'text') {
+ return fail(400, { error: 'Only text steps can be continued without an answer' });
+ }
+
+ const existingProgress = await db.query.sessionProgress.findFirst({
+ where: and(eq(sessionProgress.gameSessionId, session.id), eq(sessionProgress.stepId, stepId))
+ });
+
+ if (existingProgress) {
+ await db
+ .update(sessionProgress)
+ .set({
+ completedAt: existingProgress.completedAt ?? new Date()
+ })
+ .where(eq(sessionProgress.id, existingProgress.id));
+ } else {
+ await db.insert(sessionProgress).values({
+ gameSessionId: session.id,
+ stepId,
+ attempts: 0,
+ completedAt: new Date()
+ });
+ }
+
+ redirect(303, `/game/play/${session.code}`);
+ },
+
+ submitAnswer: async ({ params, request }) => {
+ const sessionCode = params.sessionCode.toUpperCase().trim();
+ if (!sessionCode) {
+ return fail(400, { error: 'Invalid session code' });
+ }
+
+ const formData = await request.formData();
+ const stepId = Number.parseInt(formData.get('stepId')?.toString() ?? '', 10);
+ const answer = formData.get('answer')?.toString().trim() ?? '';
+
+ if (!Number.isInteger(stepId) || stepId <= 0) {
+ return fail(400, { error: 'Invalid step ID' });
+ }
+
+ if (!answer) {
+ return fail(400, { error: 'Please enter your answer', answer });
+ }
+
+ const session = await db.query.gameSession.findFirst({
+ where: eq(gameSession.code, sessionCode)
+ });
+ if (!session) {
+ return fail(404, { error: 'Session not found', answer });
+ }
+
+ const stepRecord = await db.query.step.findFirst({
+ where: and(eq(step.id, stepId), eq(step.escapeGameId, session.escapeGameId))
+ });
+ if (!stepRecord) {
+ return fail(404, { error: 'Step not found', answer });
+ }
+
+ const expectedAnswer = (stepRecord.answer ?? '').trim().toLowerCase();
+ const submittedAnswer = answer.trim().toLowerCase();
+ const isCorrect = expectedAnswer.length > 0 && expectedAnswer === submittedAnswer;
+
+ const existingProgress = await db.query.sessionProgress.findFirst({
+ where: and(eq(sessionProgress.gameSessionId, session.id), eq(sessionProgress.stepId, stepId))
+ });
+
+ if (existingProgress) {
+ await db
+ .update(sessionProgress)
+ .set({
+ attempts: existingProgress.attempts + 1,
+ lastAttemptAt: new Date(),
+ completedAt: isCorrect ? new Date() : existingProgress.completedAt
+ })
+ .where(eq(sessionProgress.id, existingProgress.id));
+ } else {
+ await db.insert(sessionProgress).values({
+ gameSessionId: session.id,
+ stepId,
+ attempts: 1,
+ lastAttemptAt: new Date(),
+ completedAt: isCorrect ? new Date() : null
+ });
+ }
+
+ if (!isCorrect) {
+ return fail(400, { error: 'Incorrect answer', answer });
+ }
+
+ redirect(303, `/game/play/${session.code}`);
+ },
+
+ validateLocation: async ({ params, request }) => {
+ const sessionCode = params.sessionCode.toUpperCase().trim();
+ if (!sessionCode) {
+ return fail(400, { error: 'Invalid session code' });
+ }
+
+ const formData = await request.formData();
+ const stepId = Number.parseInt(formData.get('stepId')?.toString() ?? '', 10);
+ const userLat = parseFloat(formData.get('userLat')?.toString() ?? '');
+ const userLon = parseFloat(formData.get('userLon')?.toString() ?? '');
+
+ if (!Number.isInteger(stepId) || stepId <= 0) {
+ return fail(400, { error: 'Invalid step ID' });
+ }
+
+ if (isNaN(userLat) || isNaN(userLon)) {
+ return fail(400, { error: 'Unable to get your location' });
+ }
+
+ const session = await db.query.gameSession.findFirst({
+ where: eq(gameSession.code, sessionCode)
+ });
+ if (!session) {
+ return fail(404, { error: 'Session not found' });
+ }
+
+ const stepRecord = await db.query.step.findFirst({
+ where: and(eq(step.id, stepId), eq(step.escapeGameId, session.escapeGameId))
+ });
+ if (!stepRecord) {
+ return fail(404, { error: 'Step not found' });
+ }
+
+ if (stepRecord.type !== 'location') {
+ return fail(400, { error: 'This step is not a location step' });
+ }
+
+ if (stepRecord.latitude === null || stepRecord.longitude === null) {
+ return fail(400, { error: 'Location coordinates not set for this step' });
+ }
+
+ // Calculate distance using Haversine formula
+ const R = 6371e3; // Earth radius in meters
+ const φ1 = (userLat * Math.PI) / 180;
+ const φ2 = (stepRecord.latitude * Math.PI) / 180;
+ const Δφ = ((stepRecord.latitude - userLat) * Math.PI) / 180;
+ const Δλ = ((stepRecord.longitude - userLon) * Math.PI) / 180;
+
+ const a =
+ Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
+ Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ const distance = R * c;
+
+ const proximityRadius = stepRecord.proximityRadius ?? 50;
+
+ if (distance > proximityRadius) {
+ return fail(400, {
+ error: `You are ${Math.round(distance)}m away. Get within ${proximityRadius}m to validate.`
+ });
+ }
+
+ // User is within proximity - mark step as completed
+ const existingProgress = await db.query.sessionProgress.findFirst({
+ where: and(eq(sessionProgress.gameSessionId, session.id), eq(sessionProgress.stepId, stepId))
+ });
+
+ if (existingProgress) {
+ await db
+ .update(sessionProgress)
+ .set({
+ completedAt: existingProgress.completedAt ?? new Date()
+ })
+ .where(eq(sessionProgress.id, existingProgress.id));
+ } else {
+ await db.insert(sessionProgress).values({
+ gameSessionId: session.id,
+ stepId,
+ attempts: 0,
+ completedAt: new Date()
+ });
+ }
+
+ redirect(303, `/game/play/${session.code}`);
+ }
+};
diff --git a/src/routes/(game)/game/play/[sessionCode]/+page.svelte b/src/routes/(game)/game/play/[sessionCode]/+page.svelte
new file mode 100644
index 0000000..d0a8c46
--- /dev/null
+++ b/src/routes/(game)/game/play/[sessionCode]/+page.svelte
@@ -0,0 +1,513 @@
+
+
+
+
+
+
+
+
+ {$t.gameplay.progress}
+ {$t.gameplay.step} {data.currentStepOrder || 0} {$t.gameplay.of} {data.totalSteps || 0}
+
+
+
+
+
+ {#if currentStep}
+
{currentStep.title}
+
+ {#if currentStep.description}
+
{currentStep.description}
+ {/if}
+
+ {#if currentStep.content}
+
+
{currentStep.content}
+
+ {/if}
+
+ {#if (currentStep.type === 'question' || currentStep.type === 'puzzle') && isCurrentActiveStep}
+
+
+ {#if currentStep.hint}
+
+
+ {$t.gameplay.needAHint}
+
+ {currentStep.hint}
+
+ {/if}
+ {:else if currentStep.type === 'text' && isCurrentActiveStep}
+
+ {:else if currentStep.type === 'location' && isCurrentActiveStep}
+
+
+ {#if currentStep.hint}
+
+
+ {$t.gameplay.needAHint}
+
+ {currentStep.hint}
+
+ {/if}
+ {:else if !isCurrentActiveStep}
+
+ {$t.gameplay.viewingUnlockedStep}
+
+ {:else}
+
{$t.gameplay.loadingStep}
+ {/if}
+ {:else}
+
+
{$t.gameplay.loadingStep}
+
+ {/if}
+
+
+
+
diff --git a/src/routes/(game)/game/play/[sessionCode]/complete/+page.server.ts b/src/routes/(game)/game/play/[sessionCode]/complete/+page.server.ts
new file mode 100644
index 0000000..776b72a
--- /dev/null
+++ b/src/routes/(game)/game/play/[sessionCode]/complete/+page.server.ts
@@ -0,0 +1,46 @@
+import { error } from '@sveltejs/kit';
+import type { PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { gameSession } from '$lib/server/db/schema';
+import { eq } from 'drizzle-orm';
+
+const toSafeDate = (value: Date | string | null | undefined): Date | null => {
+ if (!value) return null;
+ const date = value instanceof Date ? value : new Date(value);
+ return Number.isNaN(date.getTime()) ? null : date;
+};
+
+export const load: PageServerLoad = async ({ params }) => {
+ const sessionCode = params.sessionCode.toUpperCase().trim();
+
+ if (!sessionCode) {
+ error(400, 'Invalid session code');
+ }
+
+ const session = await db.query.gameSession.findFirst({
+ where: eq(gameSession.code, sessionCode),
+ with: {
+ escapeGame: true
+ }
+ });
+
+ if (!session) {
+ error(404, 'Session not found');
+ }
+
+ const startedAt = toSafeDate(session.startedAt) ?? toSafeDate(session.createdAt) ?? new Date();
+ const completedAt = toSafeDate(session.completedAt) ?? new Date();
+ const elapsedMs = Math.max(0, completedAt.getTime() - startedAt.getTime());
+ const totalSeconds = Math.floor(elapsedMs / 1000);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+
+ return {
+ sessionCode: session.code,
+ gameTitle: session.escapeGame.title,
+ startedAt,
+ completedAt,
+ totalSeconds,
+ formattedDuration: `${minutes}m ${seconds.toString().padStart(2, '0')}s`
+ };
+};
diff --git a/src/routes/(game)/game/play/[sessionCode]/complete/+page.svelte b/src/routes/(game)/game/play/[sessionCode]/complete/+page.svelte
new file mode 100644
index 0000000..aa9b94e
--- /dev/null
+++ b/src/routes/(game)/game/play/[sessionCode]/complete/+page.svelte
@@ -0,0 +1,27 @@
+
+
+
+
+
{$t.gameplay.completedLabel}
+
{$t.gameplay.completedTitle}
+
{data.gameTitle} - {$t.gameplay.sessionCode}: {data.sessionCode}
+
+
+
{$t.gameplay.completedIn}
+
{data.formattedDuration}
+
+
+
+ {$t.gameplay.playAgain}
+
+
+
diff --git a/src/routes/(game)/game/play/[sessionCode]/tutorial/+page.svelte b/src/routes/(game)/game/play/[sessionCode]/tutorial/+page.svelte
new file mode 100644
index 0000000..7d2b0ee
--- /dev/null
+++ b/src/routes/(game)/game/play/[sessionCode]/tutorial/+page.svelte
@@ -0,0 +1,29 @@
+
+
+
+
+
{$t.gameplay.tutorialTitle}
+
{$t.gameplay.tutorialIntro}
+
+
+
+
1
+
{$t.gameplay.tutorialPrevious}
+
+
+
2
+
{$t.gameplay.tutorialInventory}
+
+
+
3
+
{$t.gameplay.tutorialNext}
+
+
+
+
+ {$t.gameplay.tutorial}: {$t.gameplay.inventory}, {$t.gameplay.previous}, {$t.gameplay.next}
+
+
+
diff --git a/src/routes/(game)/terms/+page.svelte b/src/routes/(game)/terms/+page.svelte
new file mode 100644
index 0000000..ded84ff
--- /dev/null
+++ b/src/routes/(game)/terms/+page.svelte
@@ -0,0 +1,25 @@
+
+
+
+
+
+ ← Back
+
+
+
{$t.game.termsAndConditions}
+
+ By joining a session, you agree to follow the game instructions, respect other players,
+ and use the platform appropriately.
+
+
+ The organizer may collect minimal session data needed to operate the game
+ (code, progress, and participation timestamps).
+
+
+ If you do not agree with these terms, please do not join the game session.
+
+
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 0d8eb03..ae2313c 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -6,4 +6,5 @@
-{@render children()}
+
+{@render children()}
\ No newline at end of file
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index cc88df0..01b044e 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1,2 +1,34 @@
-Welcome to SvelteKit
-Visit svelte.dev/docs/kit to read the documentation
+
+
+
+
+
+ {$t.home.title}
+
+
+ {$t.home.subtitle}
+
+
+
+
+
+
+
{$t.common?.selectLanguage || 'Select Language'}
+
+
+
diff --git a/src/routes/layout.css b/src/routes/layout.css
index d4b5078..f95fbc1 100644
--- a/src/routes/layout.css
+++ b/src/routes/layout.css
@@ -1 +1,5 @@
@import 'tailwindcss';
+
+input:where([type='datetime-local']) {
+ min-height: 2.75rem;
+}
diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts
new file mode 100644
index 0000000..385c9f6
--- /dev/null
+++ b/src/routes/login/+page.server.ts
@@ -0,0 +1,106 @@
+import { fail, redirect } from '@sveltejs/kit';
+import type { PageServerLoad, Actions } from './$types';
+import { auth } from '$lib/server/auth';
+import { user } from '$lib/server/db/schema';
+import { db } from '$lib/server/db';
+import { sql } from 'drizzle-orm';
+import { APIError } from 'better-auth/api';
+
+export const load: PageServerLoad = async (event) => {
+ const session = await auth.api.getSession({ headers: event.request.headers });
+
+ if (session?.user) {
+ redirect(303, '/admin');
+ }
+
+ // Check if any users exist
+ const users = await db.select().from(user).limit(1);
+ const noUsersExist = users.length === 0;
+
+ return {
+ noUsersExist
+ };
+};
+
+export const actions: Actions = {
+ signIn: async (event) => {
+ const formData = await event.request.formData();
+ const identifier = formData.get('identifier')?.toString().trim() ?? formData.get('email')?.toString().trim() ?? '';
+ const password = formData.get('password')?.toString() ?? '';
+
+ if (!identifier) {
+ return fail(400, { message: 'Email ou nom d\'utilisateur requis' });
+ }
+
+ let email = identifier;
+ if (!identifier.includes('@')) {
+ const [foundUser] = await db
+ .select({ email: user.email })
+ .from(user)
+ .where(sql`lower(${user.email}) = ${identifier.toLowerCase()}`)
+ .limit(1);
+
+ if (!foundUser) {
+ return fail(400, { message: 'Identifiants invalides' });
+ }
+
+ email = foundUser.email;
+ }
+
+ try {
+ await auth.api.signInEmail({
+ body: {
+ email,
+ password,
+ callbackURL: '/auth/verification-success'
+ }
+ });
+ } catch (error) {
+ if (error instanceof APIError) {
+ return fail(400, { message: error.message || 'Signin failed' });
+ }
+ return fail(500, { message: 'Unexpected error' });
+ }
+
+ return redirect(302, '/');
+ },
+
+ createFirstUser: async (event) => {
+ // Check if any users already exist
+ const users = await db.select().from(user).limit(1);
+ if (users.length > 0) {
+ return fail(400, { message: 'Un utilisateur existe déjà' });
+ }
+
+ const formData = await event.request.formData();
+ const email = formData.get('email')?.toString().trim() ?? '';
+ const password = formData.get('password')?.toString() ?? '';
+ const name = formData.get('name')?.toString().trim() ?? email;
+
+ if (!email || !password) {
+ return fail(400, { message: 'Email et mot de passe requis' });
+ }
+
+ if (password.length < 6) {
+ return fail(400, { message: 'Le mot de passe doit contenir au moins 6 caractères' });
+ }
+
+ try {
+ await auth.api.signUpEmail({
+ body: {
+ email,
+ password,
+ name,
+ callbackURL: '/admin'
+ }
+ });
+ } catch (error) {
+ if (error instanceof APIError) {
+ return fail(400, { message: error.message || 'Creation failed' });
+ }
+ return fail(500, { message: 'Unexpected error' });
+ }
+
+ return redirect(302, '/admin');
+ }
+};
diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte
new file mode 100644
index 0000000..372d9b3
--- /dev/null
+++ b/src/routes/login/+page.svelte
@@ -0,0 +1,143 @@
+
+
+
+
+ {#if noUsersExist}
+
+ Créer le premier utilisateur
+
+
+ Aucun utilisateur n'existe. Créez le premier compte administrateur.
+
+
+
+ {:else}
+
+ {$t.login?.login || 'Login'}
+
+
+ {$t.login?.accessAdmin || 'Access the admin dashboard'}
+
+
+
+ {/if}
+
+
+
+