From 47a68c03153f4cbe9fa91ee5821d52c83e9057ec Mon Sep 17 00:00:00 2001 From: lila Date: Thu, 16 Apr 2026 14:45:45 +0200 Subject: [PATCH] feat(db): add lobbies and lobby_players tables + model --- .../db/drizzle/0006_certain_adam_destine.sql | 21 + packages/db/drizzle/meta/0006_snapshot.json | 1081 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/db/schema.ts | 61 +- packages/db/src/index.ts | 3 +- packages/db/src/models/lobbyModel.ts | 122 ++ packages/shared/src/constants.ts | 3 + packages/shared/src/schemas/lobby.ts | 22 + 8 files changed, 1310 insertions(+), 10 deletions(-) create mode 100644 packages/db/drizzle/0006_certain_adam_destine.sql create mode 100644 packages/db/drizzle/meta/0006_snapshot.json create mode 100644 packages/db/src/models/lobbyModel.ts create mode 100644 packages/shared/src/schemas/lobby.ts diff --git a/packages/db/drizzle/0006_certain_adam_destine.sql b/packages/db/drizzle/0006_certain_adam_destine.sql new file mode 100644 index 0000000..04d62fd --- /dev/null +++ b/packages/db/drizzle/0006_certain_adam_destine.sql @@ -0,0 +1,21 @@ +CREATE TABLE "lobbies" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "code" varchar(10) NOT NULL, + "host_user_id" text NOT NULL, + "status" varchar(20) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "lobbies_code_unique" UNIQUE("code"), + CONSTRAINT "lobby_status_check" CHECK ("lobbies"."status" IN ('waiting', 'in_progress', 'finished')) +); +--> statement-breakpoint +CREATE TABLE "lobby_players" ( + "lobby_id" uuid NOT NULL, + "user_id" text NOT NULL, + "score" integer DEFAULT 0 NOT NULL, + "joined_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "lobby_players_lobby_id_user_id_pk" PRIMARY KEY("lobby_id","user_id") +); +--> statement-breakpoint +ALTER TABLE "lobbies" ADD CONSTRAINT "lobbies_host_user_id_user_id_fk" FOREIGN KEY ("host_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "lobby_players" ADD CONSTRAINT "lobby_players_lobby_id_lobbies_id_fk" FOREIGN KEY ("lobby_id") REFERENCES "public"."lobbies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "lobby_players" ADD CONSTRAINT "lobby_players_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0006_snapshot.json b/packages/db/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..b583776 --- /dev/null +++ b/packages/db/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1081 @@ +{ + "id": "66d16ffb-4cdd-4437-b82f-a9fb7ee7b243", + "prevId": "8f34bafa-cffc-4933-952f-64b46afa9c5c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "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.deck_terms": { + "name": "deck_terms", + "schema": "", + "columns": { + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "term_id": { + "name": "term_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "deck_terms_deck_id_decks_id_fk": { + "name": "deck_terms_deck_id_decks_id_fk", + "tableFrom": "deck_terms", + "tableTo": "decks", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_terms_term_id_terms_id_fk": { + "name": "deck_terms_term_id_terms_id_fk", + "tableFrom": "deck_terms", + "tableTo": "terms", + "columnsFrom": [ + "term_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deck_terms_deck_id_term_id_pk": { + "name": "deck_terms_deck_id_term_id_pk", + "columns": [ + "deck_id", + "term_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.decks": { + "name": "decks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_language": { + "name": "source_language", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "validated_languages": { + "name": "validated_languages", + "type": "varchar(10)[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_decks_type": { + "name": "idx_decks_type", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_deck_name": { + "name": "unique_deck_name", + "nullsNotDistinct": false, + "columns": [ + "name", + "source_language" + ] + } + }, + "policies": {}, + "checkConstraints": { + "source_language_check": { + "name": "source_language_check", + "value": "\"decks\".\"source_language\" IN ('en', 'it')" + }, + "validated_languages_check": { + "name": "validated_languages_check", + "value": "validated_languages <@ ARRAY['en', 'it']::varchar[]" + }, + "validated_languages_excludes_source": { + "name": "validated_languages_excludes_source", + "value": "NOT (\"decks\".\"source_language\" = ANY(\"decks\".\"validated_languages\"))" + }, + "deck_type_check": { + "name": "deck_type_check", + "value": "\"decks\".\"type\" IN ('grammar', 'media')" + } + }, + "isRLSEnabled": false + }, + "public.lobbies": { + "name": "lobbies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "host_user_id": { + "name": "host_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "lobbies_host_user_id_user_id_fk": { + "name": "lobbies_host_user_id_user_id_fk", + "tableFrom": "lobbies", + "tableTo": "user", + "columnsFrom": [ + "host_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "lobbies_code_unique": { + "name": "lobbies_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": { + "lobby_status_check": { + "name": "lobby_status_check", + "value": "\"lobbies\".\"status\" IN ('waiting', 'in_progress', 'finished')" + } + }, + "isRLSEnabled": false + }, + "public.lobby_players": { + "name": "lobby_players", + "schema": "", + "columns": { + "lobby_id": { + "name": "lobby_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "lobby_players_lobby_id_lobbies_id_fk": { + "name": "lobby_players_lobby_id_lobbies_id_fk", + "tableFrom": "lobby_players", + "tableTo": "lobbies", + "columnsFrom": [ + "lobby_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lobby_players_user_id_user_id_fk": { + "name": "lobby_players_user_id_user_id_fk", + "tableFrom": "lobby_players", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "lobby_players_lobby_id_user_id_pk": { + "name": "lobby_players_lobby_id_user_id_pk", + "columns": [ + "lobby_id", + "user_id" + ] + } + }, + "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.term_glosses": { + "name": "term_glosses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "term_id": { + "name": "term_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language_code": { + "name": "language_code", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "term_glosses_term_id_terms_id_fk": { + "name": "term_glosses_term_id_terms_id_fk", + "tableFrom": "term_glosses", + "tableTo": "terms", + "columnsFrom": [ + "term_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_term_gloss": { + "name": "unique_term_gloss", + "nullsNotDistinct": false, + "columns": [ + "term_id", + "language_code" + ] + } + }, + "policies": {}, + "checkConstraints": { + "language_code_check": { + "name": "language_code_check", + "value": "\"term_glosses\".\"language_code\" IN ('en', 'it')" + } + }, + "isRLSEnabled": false + }, + "public.term_topics": { + "name": "term_topics", + "schema": "", + "columns": { + "term_id": { + "name": "term_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "topic_id": { + "name": "topic_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "term_topics_term_id_terms_id_fk": { + "name": "term_topics_term_id_terms_id_fk", + "tableFrom": "term_topics", + "tableTo": "terms", + "columnsFrom": [ + "term_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "term_topics_topic_id_topics_id_fk": { + "name": "term_topics_topic_id_topics_id_fk", + "tableFrom": "term_topics", + "tableTo": "topics", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "term_topics_term_id_topic_id_pk": { + "name": "term_topics_term_id_topic_id_pk", + "columns": [ + "term_id", + "topic_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.terms": { + "name": "terms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source": { + "name": "source", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pos": { + "name": "pos", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_terms_source_pos": { + "name": "idx_terms_source_pos", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pos", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_source_id": { + "name": "unique_source_id", + "nullsNotDistinct": false, + "columns": [ + "source", + "source_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "pos_check": { + "name": "pos_check", + "value": "\"terms\".\"pos\" IN ('noun', 'verb')" + } + }, + "isRLSEnabled": false + }, + "public.topics": { + "name": "topics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "topics_slug_unique": { + "name": "topics_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.translations": { + "name": "translations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "term_id": { + "name": "term_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language_code": { + "name": "language_code", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cefr_level": { + "name": "cefr_level", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_translations_lang": { + "name": "idx_translations_lang", + "columns": [ + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "difficulty", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cefr_level", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "term_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "translations_term_id_terms_id_fk": { + "name": "translations_term_id_terms_id_fk", + "tableFrom": "translations", + "tableTo": "terms", + "columnsFrom": [ + "term_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_translations": { + "name": "unique_translations", + "nullsNotDistinct": false, + "columns": [ + "term_id", + "language_code", + "text" + ] + } + }, + "policies": {}, + "checkConstraints": { + "language_code_check": { + "name": "language_code_check", + "value": "\"translations\".\"language_code\" IN ('en', 'it')" + }, + "cefr_check": { + "name": "cefr_check", + "value": "\"translations\".\"cefr_level\" IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')" + }, + "difficulty_check": { + "name": "difficulty_check", + "value": "\"translations\".\"difficulty\" IN ('easy', 'intermediate', 'hard')" + } + }, + "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": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 3613600..e0701fa 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1776154563168, "tag": "0005_broad_mariko_yashida", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1776270391189, + "tag": "0006_certain_adam_destine", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/db/schema.ts b/packages/db/src/db/schema.ts index 7fb5cd3..11ea51b 100644 --- a/packages/db/src/db/schema.ts +++ b/packages/db/src/db/schema.ts @@ -9,6 +9,7 @@ import { primaryKey, index, boolean, + integer, } from "drizzle-orm/pg-core"; import { sql, relations } from "drizzle-orm"; @@ -19,6 +20,7 @@ import { CEFR_LEVELS, SUPPORTED_DECK_TYPES, DIFFICULTY_LEVELS, + LOBBY_STATUSES, } from "@lila/shared"; export const terms = pgTable( @@ -252,12 +254,53 @@ export const accountRelations = relations(account, ({ one }) => ({ user: one(user, { fields: [account.userId], references: [user.id] }), })); -/* - * INTENTIONAL DESIGN DECISIONS — see decisions.md for full reasoning - * - * source + source_id (terms): idempotency key per import pipeline - * display_name UNIQUE (users): multiplayer requires distinguishable names - * UNIQUE(term_id, language_code, text): allows synonyms, prevents exact duplicates - * updated_at omitted: misleading without a trigger to maintain it - * FK indexes: all FK columns covered, no sequential scans on joins - */ +export const lobbies = pgTable( + "lobbies", + { + id: uuid().primaryKey().defaultRandom(), + code: varchar({ length: 10 }).notNull().unique(), + hostUserId: text("host_user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + status: varchar({ length: 20 }).notNull().default("waiting"), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + check( + "lobby_status_check", + sql`${table.status} IN (${sql.raw(LOBBY_STATUSES.map((s) => `'${s}'`).join(", "))})`, + ), + ], +); + +export const lobby_players = pgTable( + "lobby_players", + { + lobbyId: uuid("lobby_id") + .notNull() + .references(() => lobbies.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + score: integer().notNull().default(0), + joinedAt: timestamp("joined_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [primaryKey({ columns: [table.lobbyId, table.userId] })], +); + +export const lobbyRelations = relations(lobbies, ({ one, many }) => ({ + host: one(user, { fields: [lobbies.hostUserId], references: [user.id] }), + players: many(lobby_players), +})); + +export const lobbyPlayersRelations = relations(lobby_players, ({ one }) => ({ + lobby: one(lobbies, { + fields: [lobby_players.lobbyId], + references: [lobbies.id], + }), + user: one(user, { fields: [lobby_players.userId], references: [user.id] }), +})); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index cd261de..02eb21c 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -3,11 +3,12 @@ import { drizzle } from "drizzle-orm/node-postgres"; import { resolve } from "path"; import { fileURLToPath } from "url"; import { dirname } from "path"; +import * as schema from "./db/schema.js"; config({ path: resolve(dirname(fileURLToPath(import.meta.url)), "../../../.env"), }); -export const db = drizzle(process.env["DATABASE_URL"]!); +export const db = drizzle(process.env["DATABASE_URL"]!, { schema }); export * from "./models/termModel.js"; diff --git a/packages/db/src/models/lobbyModel.ts b/packages/db/src/models/lobbyModel.ts new file mode 100644 index 0000000..7aa02d2 --- /dev/null +++ b/packages/db/src/models/lobbyModel.ts @@ -0,0 +1,122 @@ +import { db } from "@lila/db"; +import { lobbies, lobby_players } from "@lila/db/schema"; +import { eq, and, sql } from "drizzle-orm"; + +import type { LobbyStatus } from "@lila/shared"; + +export type Lobby = typeof lobbies.$inferSelect; +export type LobbyPlayer = typeof lobby_players.$inferSelect; +export type LobbyWithPlayers = Lobby & { + players: (LobbyPlayer & { user: { id: string; name: string } })[]; +}; + +export const createLobby = async ( + code: string, + hostUserId: string, +): Promise => { + const [newLobby] = await db + .insert(lobbies) + .values({ code, hostUserId, status: "waiting" }) + .returning(); + + if (!newLobby) { + throw new Error("Failed to create lobby"); + } + + return newLobby; +}; + +export const getLobbyByCodeWithPlayers = async ( + code: string, +): Promise => { + return db.query.lobbies.findFirst({ + where: eq(lobbies.code, code), + with: { + players: { with: { user: { columns: { id: true, name: true } } } }, + }, + }); +}; + +export const updateLobbyStatus = async ( + lobbyId: string, + status: LobbyStatus, +): Promise => { + await db.update(lobbies).set({ status }).where(eq(lobbies.id, lobbyId)); +}; + +export const deleteLobby = async (lobbyId: string): Promise => { + await db.delete(lobbies).where(eq(lobbies.id, lobbyId)); +}; + +/** + * Atomically inserts a player into a lobby. Returns the new player row, + * or undefined if the insert was skipped because: + * - the lobby is at capacity, or + * - the lobby is not in 'waiting' status, or + * - the user is already in the lobby (PK conflict). + * + * Callers are expected to pre-check these conditions against a hydrated + * lobby state to produce specific error messages; the undefined return + * is a safety net for concurrent races. + */ +export const addPlayer = async ( + lobbyId: string, + userId: string, + maxPlayers: number, +): Promise => { + const result = await db.execute(sql` + INSERT INTO lobby_players (lobby_id, user_id) + SELECT ${lobbyId}::uuid, ${userId} + WHERE ( + SELECT COUNT(*) FROM lobby_players WHERE lobby_id = ${lobbyId}::uuid + ) < ${maxPlayers} + AND EXISTS ( + SELECT 1 FROM lobbies WHERE id = ${lobbyId}::uuid AND status = 'waiting' + ) + ON CONFLICT (lobby_id, user_id) DO NOTHING + `); + + if (!result.rowCount) return undefined; + const [player] = await db + .select() + .from(lobby_players) + .where( + and(eq(lobby_players.lobbyId, lobbyId), eq(lobby_players.userId, userId)), + ); + + return player; +}; + +export const removePlayer = async ( + lobbyId: string, + userId: string, +): Promise => { + await db + .delete(lobby_players) + .where( + and(eq(lobby_players.lobbyId, lobbyId), eq(lobby_players.userId, userId)), + ); +}; + +export const finishGame = async ( + lobbyId: string, + scoresByUser: Map, +): Promise => { + await db.transaction(async (tx) => { + for (const [userId, score] of scoresByUser) { + await tx + .update(lobby_players) + .set({ score }) + .where( + and( + eq(lobby_players.lobbyId, lobbyId), + eq(lobby_players.userId, userId), + ), + ); + } + await tx + .update(lobbies) + .set({ status: "finished" }) + .where(eq(lobbies.id, lobbyId)); + }); +}; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index b0ae2f3..fa69eb0 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -13,3 +13,6 @@ export const SUPPORTED_DECK_TYPES = ["grammar", "media"] as const; export const DIFFICULTY_LEVELS = ["easy", "intermediate", "hard"] as const; export type DifficultyLevel = (typeof DIFFICULTY_LEVELS)[number]; + +export const LOBBY_STATUSES = ["waiting", "in_progress", "finished"] as const; +export type LobbyStatus = (typeof LOBBY_STATUSES)[number]; diff --git a/packages/shared/src/schemas/lobby.ts b/packages/shared/src/schemas/lobby.ts new file mode 100644 index 0000000..93d5563 --- /dev/null +++ b/packages/shared/src/schemas/lobby.ts @@ -0,0 +1,22 @@ +import * as z from "zod"; + +import { LOBBY_STATUSES } from "../constants.js"; + +export const LobbyPlayerSchema = z.object({ + lobbyId: z.uuid(), + userId: z.string(), + score: z.number().int().min(0), + user: z.object({ id: z.string(), name: z.string() }), +}); + +export type LobbyPlayer = z.infer; + +export const LobbySchema = z.object({ + id: z.uuid(), + code: z.string().min(1).max(10), + hostUserId: z.string(), + status: z.enum(LOBBY_STATUSES), + createdAt: z.iso.datetime(), + players: z.array(LobbyPlayerSchema), +}); +export type Lobby = z.infer;