diff --git a/apps/api/src/controllers/gameController.test.ts b/apps/api/src/controllers/gameController.test.ts index 168d2d1..d45298a 100644 --- a/apps/api/src/controllers/gameController.test.ts +++ b/apps/api/src/controllers/gameController.test.ts @@ -64,9 +64,14 @@ const validBody = { }; const fakeTerms = [ - { termId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null }, - { termId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null }, - { termId: "t3", sourceText: "house", targetText: "casa", sourceGloss: null }, + { entryId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null }, + { entryId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null }, + { + entryId: "t3", + sourceText: "house", + targetText: "casa", + sourceGloss: "a building for living in", + }, ]; beforeEach(() => { @@ -197,7 +202,7 @@ describe("POST /api/v1/game/answer", () => { expect(body.success).toBe(false); expect(body.error).toContain("Question already answered"); }); - + it("returns 400 when a field has an invalid value", async () => { const res = await request(app) .post("/api/v1/game/start") diff --git a/apps/api/src/services/gameService.test.ts b/apps/api/src/services/gameService.test.ts index 76fa3a2..160d816 100644 --- a/apps/api/src/services/gameService.test.ts +++ b/apps/api/src/services/gameService.test.ts @@ -19,10 +19,10 @@ const validRequest: GameRequest = { }; const fakeTerms = [ - { termId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null }, - { termId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null }, + { entryId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null }, + { entryId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null }, { - termId: "t3", + entryId: "t3", sourceText: "house", targetText: "casa", sourceGloss: "a building for living in", diff --git a/apps/api/src/services/gameService.ts b/apps/api/src/services/gameService.ts index ad34c72..a31014a 100644 --- a/apps/api/src/services/gameService.ts +++ b/apps/api/src/services/gameService.ts @@ -38,8 +38,9 @@ export const createGameSession = async ( const questions: GameQuestion[] = await Promise.all( terms.map(async (term) => { const distractorTexts = await getDistractors( - term.termId, + term.entryId, term.targetText, + request.source_language, request.target_language, request.pos, request.difficulty, diff --git a/apps/api/src/services/multiplayerGameService.test.ts b/apps/api/src/services/multiplayerGameService.test.ts index 2261960..7817a0c 100644 --- a/apps/api/src/services/multiplayerGameService.test.ts +++ b/apps/api/src/services/multiplayerGameService.test.ts @@ -9,10 +9,10 @@ const mockGetGameTerms = vi.mocked(getGameTerms); const mockGetDistractors = vi.mocked(getDistractors); const fakeTerms = [ - { termId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null }, - { termId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null }, + { entryId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null }, + { entryId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null }, { - termId: "t3", + entryId: "t3", sourceText: "house", targetText: "casa", sourceGloss: "a building for living in", diff --git a/apps/api/src/services/multiplayerGameService.ts b/apps/api/src/services/multiplayerGameService.ts index 32727b1..64f55fb 100644 --- a/apps/api/src/services/multiplayerGameService.ts +++ b/apps/api/src/services/multiplayerGameService.ts @@ -44,8 +44,9 @@ export const generateMultiplayerQuestions = async (): Promise< const questions: MultiplayerQuestion[] = await Promise.all( correctAnswers.map(async (correctAnswer) => { const distractorTexts = await getDistractors( - correctAnswer.termId, + correctAnswer.entryId, correctAnswer.targetText, + MULTIPLAYER_DEFAULTS.sourceLanguage, MULTIPLAYER_DEFAULTS.targetLanguage, MULTIPLAYER_DEFAULTS.pos, MULTIPLAYER_DEFAULTS.difficulty, diff --git a/packages/db/drizzle/0011_nice_spyke.sql b/packages/db/drizzle/0011_nice_spyke.sql new file mode 100644 index 0000000..74c2715 --- /dev/null +++ b/packages/db/drizzle/0011_nice_spyke.sql @@ -0,0 +1,46 @@ +CREATE TABLE "entry_translations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "entry_id" uuid NOT NULL, + "target_language_code" varchar(10) NOT NULL, + "translation" text NOT NULL, + "sense_hint" text, + "cefr_level" varchar(2), + "difficulty" varchar(20), + "source" varchar(50) DEFAULT 'kaikki' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "unique_translation" UNIQUE("entry_id","target_language_code","translation"), + CONSTRAINT "target_language_code_check" CHECK ("entry_translations"."target_language_code" IN ('en', 'it', 'de', 'fr', 'es')), + CONSTRAINT "cefr_check" CHECK ("entry_translations"."cefr_level" IS NULL OR "entry_translations"."cefr_level" IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')), + CONSTRAINT "difficulty_check" CHECK ("entry_translations"."difficulty" IS NULL OR "entry_translations"."difficulty" IN ('easy', 'intermediate', 'hard')) +); +--> statement-breakpoint +CREATE TABLE "vocabulary_entries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "headword" text NOT NULL, + "language_code" varchar(10) NOT NULL, + "pos" varchar(20) NOT NULL, + "sense_index" smallint DEFAULT 0 NOT NULL, + "gloss" text, + "examples" text[] DEFAULT '{}' NOT NULL, + "cefr_level" varchar(2), + "difficulty" varchar(20), + "source" varchar(50) DEFAULT 'kaikki' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "unique_entry" UNIQUE("headword","language_code","pos","sense_index"), + CONSTRAINT "language_code_check" CHECK ("vocabulary_entries"."language_code" IN ('en', 'it', 'de', 'fr', 'es')), + CONSTRAINT "pos_check" CHECK ("vocabulary_entries"."pos" IN ('noun', 'verb', 'adjective', 'adverb')), + CONSTRAINT "cefr_check" CHECK ("vocabulary_entries"."cefr_level" IS NULL OR "vocabulary_entries"."cefr_level" IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')), + CONSTRAINT "difficulty_check" CHECK ("vocabulary_entries"."difficulty" IS NULL OR "vocabulary_entries"."difficulty" IN ('easy', 'intermediate', 'hard')) +); +--> statement-breakpoint +DROP TABLE "deck_terms" CASCADE;--> statement-breakpoint +DROP TABLE "decks" CASCADE;--> statement-breakpoint +DROP TABLE "term_examples" CASCADE;--> statement-breakpoint +DROP TABLE "term_glosses" CASCADE;--> statement-breakpoint +DROP TABLE "term_topics" CASCADE;--> statement-breakpoint +DROP TABLE "terms" CASCADE;--> statement-breakpoint +DROP TABLE "topics" CASCADE;--> statement-breakpoint +DROP TABLE "translations" CASCADE;--> statement-breakpoint +ALTER TABLE "entry_translations" ADD CONSTRAINT "entry_translations_entry_id_vocabulary_entries_id_fk" FOREIGN KEY ("entry_id") REFERENCES "public"."vocabulary_entries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_translations_target_lang" ON "entry_translations" USING btree ("target_language_code","difficulty","entry_id");--> statement-breakpoint +CREATE INDEX "idx_entries_lang_pos" ON "vocabulary_entries" USING btree ("language_code","pos","difficulty"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0011_snapshot.json b/packages/db/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..c07ede2 --- /dev/null +++ b/packages/db/drizzle/meta/0011_snapshot.json @@ -0,0 +1,750 @@ +{ + "id": "6f1811a6-8573-4d43-912a-ceb5191341cc", + "prevId": "6c1cb049-807d-43d0-b83e-d3575b80de33", + "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.entry_translations": { + "name": "entry_translations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "entry_id": { + "name": "entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_language_code": { + "name": "target_language_code", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "translation": { + "name": "translation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sense_hint": { + "name": "sense_hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cefr_level": { + "name": "cefr_level", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'kaikki'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_translations_target_lang": { + "name": "idx_translations_target_lang", + "columns": [ + { + "expression": "target_language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "difficulty", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entry_translations_entry_id_vocabulary_entries_id_fk": { + "name": "entry_translations_entry_id_vocabulary_entries_id_fk", + "tableFrom": "entry_translations", + "tableTo": "vocabulary_entries", + "columnsFrom": ["entry_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_translation": { + "name": "unique_translation", + "nullsNotDistinct": false, + "columns": ["entry_id", "target_language_code", "translation"] + } + }, + "policies": {}, + "checkConstraints": { + "target_language_code_check": { + "name": "target_language_code_check", + "value": "\"entry_translations\".\"target_language_code\" IN ('en', 'it', 'de', 'fr', 'es')" + }, + "cefr_check": { + "name": "cefr_check", + "value": "\"entry_translations\".\"cefr_level\" IS NULL OR \"entry_translations\".\"cefr_level\" IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')" + }, + "difficulty_check": { + "name": "difficulty_check", + "value": "\"entry_translations\".\"difficulty\" IS NULL OR \"entry_translations\".\"difficulty\" IN ('easy', 'intermediate', 'hard')" + } + }, + "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, + "default": "'waiting'" + }, + "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.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 + }, + "public.vocabulary_entries": { + "name": "vocabulary_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "headword": { + "name": "headword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language_code": { + "name": "language_code", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "pos": { + "name": "pos", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "sense_index": { + "name": "sense_index", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "gloss": { + "name": "gloss", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "examples": { + "name": "examples", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cefr_level": { + "name": "cefr_level", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'kaikki'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_entries_lang_pos": { + "name": "idx_entries_lang_pos", + "columns": [ + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pos", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "difficulty", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_entry": { + "name": "unique_entry", + "nullsNotDistinct": false, + "columns": ["headword", "language_code", "pos", "sense_index"] + } + }, + "policies": {}, + "checkConstraints": { + "language_code_check": { + "name": "language_code_check", + "value": "\"vocabulary_entries\".\"language_code\" IN ('en', 'it', 'de', 'fr', 'es')" + }, + "pos_check": { + "name": "pos_check", + "value": "\"vocabulary_entries\".\"pos\" IN ('noun', 'verb', 'adjective', 'adverb')" + }, + "cefr_check": { + "name": "cefr_check", + "value": "\"vocabulary_entries\".\"cefr_level\" IS NULL OR \"vocabulary_entries\".\"cefr_level\" IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')" + }, + "difficulty_check": { + "name": "difficulty_check", + "value": "\"vocabulary_entries\".\"difficulty\" IS NULL OR \"vocabulary_entries\".\"difficulty\" IN ('easy', 'intermediate', 'hard')" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { "columns": {}, "schemas": {}, "tables": {} } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 65dc2f0..cc6836e 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1776929932845, "tag": "0010_thankful_reaper", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1777994750330, + "tag": "0011_nice_spyke", + "breakpoints": true } ] } diff --git a/packages/db/src/db/schema.ts b/packages/db/src/db/schema.ts index b2184b3..4eb8473 100644 --- a/packages/db/src/db/schema.ts +++ b/packages/db/src/db/schema.ts @@ -10,6 +10,7 @@ import { index, boolean, integer, + smallint, } from "drizzle-orm/pg-core"; import { sql, relations } from "drizzle-orm"; @@ -18,182 +19,100 @@ import { SUPPORTED_POS, SUPPORTED_LANGUAGE_CODES, CEFR_LEVELS, - SUPPORTED_DECK_TYPES, DIFFICULTY_LEVELS, LOBBY_STATUSES, } from "@lila/shared"; -export const terms = pgTable( - "terms", +// ── Vocabulary ──────────────────────────────────────────────────────────────── + +export const vocabulary_entries = pgTable( + "vocabulary_entries", { id: uuid().primaryKey().defaultRandom(), - source: varchar({ length: 50 }), // 'omw', 'wiktionary', null for manual - source_id: text(), // synset_id value for omw, wiktionary QID, etc. + headword: text().notNull(), + language_code: varchar({ length: 10 }).notNull(), pos: varchar({ length: 20 }).notNull(), + sense_index: smallint().notNull().default(0), + gloss: text(), + examples: text().array().notNull().default([]), + cefr_level: varchar({ length: 2 }), + difficulty: varchar({ length: 20 }), + source: varchar({ length: 50 }).notNull().default("kaikki"), created_at: timestamp({ withTimezone: true }).defaultNow().notNull(), }, (table) => [ + unique("unique_entry").on( + table.headword, + table.language_code, + table.pos, + table.sense_index, + ), + check( + "language_code_check", + sql`${table.language_code} IN (${sql.raw(SUPPORTED_LANGUAGE_CODES.map((l) => `'${l}'`).join(", "))})`, + ), check( "pos_check", sql`${table.pos} IN (${sql.raw(SUPPORTED_POS.map((p) => `'${p}'`).join(", "))})`, ), - unique("unique_source_id").on(table.source, table.source_id), - index("idx_terms_source_pos").on(table.source, table.pos), - ], -); - -export const term_glosses = pgTable( - "term_glosses", - { - id: uuid().primaryKey().defaultRandom(), - term_id: uuid() - .notNull() - .references(() => terms.id, { onDelete: "cascade" }), - language_code: varchar({ length: 10 }).notNull(), - text: text().notNull(), - description: text(), - created_at: timestamp({ withTimezone: true }).defaultNow().notNull(), - }, - (table) => [ - unique("unique_term_gloss").on(table.term_id, table.language_code), - check( - "language_code_check", - sql`${table.language_code} IN (${sql.raw(SUPPORTED_LANGUAGE_CODES.map((l) => `'${l}'`).join(", "))})`, - ), - ], -); - -export const term_examples = pgTable( - "term_examples", - { - id: uuid().primaryKey().defaultRandom(), - term_id: uuid() - .notNull() - .references(() => terms.id, { onDelete: "cascade" }), - language_code: varchar({ length: 10 }).notNull(), - text: text().notNull(), - created_at: timestamp({ withTimezone: true }).defaultNow().notNull(), - }, - (table) => [ - unique("unique_term_example").on( - table.term_id, - table.language_code, - table.text, - ), - check( - "language_code_check", - sql`${table.language_code} IN (${sql.raw(SUPPORTED_LANGUAGE_CODES.map((l) => `'${l}'`).join(", "))})`, - ), - index("idx_term_examples_term_id").on(table.term_id, table.language_code), - ], -); - -export const translations = pgTable( - "translations", - { - id: uuid().primaryKey().defaultRandom(), - term_id: uuid() - .notNull() - .references(() => terms.id, { onDelete: "cascade" }), - language_code: varchar({ length: 10 }).notNull(), - text: text().notNull(), - cefr_level: varchar({ length: 2 }), - difficulty: varchar({ length: 20 }), - created_at: timestamp({ withTimezone: true }).defaultNow().notNull(), - }, - (table) => [ - unique("unique_translations").on( - table.term_id, - table.language_code, - table.text, - ), - check( - "language_code_check", - sql`${table.language_code} IN (${sql.raw(SUPPORTED_LANGUAGE_CODES.map((l) => `'${l}'`).join(", "))})`, - ), check( "cefr_check", - sql`${table.cefr_level} IN (${sql.raw(CEFR_LEVELS.map((l) => `'${l}'`).join(", "))})`, + sql`${table.cefr_level} IS NULL OR ${table.cefr_level} IN (${sql.raw(CEFR_LEVELS.map((l) => `'${l}'`).join(", "))})`, ), check( "difficulty_check", - sql`${table.difficulty} IN (${sql.raw(DIFFICULTY_LEVELS.map((d) => `'${d}'`).join(", "))})`, + sql`${table.difficulty} IS NULL OR ${table.difficulty} IN (${sql.raw(DIFFICULTY_LEVELS.map((d) => `'${d}'`).join(", "))})`, ), - index("idx_translations_lang").on( + index("idx_entries_lang_pos").on( table.language_code, + table.pos, table.difficulty, - table.cefr_level, - table.term_id, ), ], ); -export const decks = pgTable( - "decks", +export const entry_translations = pgTable( + "entry_translations", { id: uuid().primaryKey().defaultRandom(), - name: text().notNull(), - description: text(), - source_language: varchar({ length: 10 }).notNull(), - validated_languages: varchar({ length: 10 }).array().notNull().default([]), - type: varchar({ length: 20 }).notNull(), + entry_id: uuid() + .notNull() + .references(() => vocabulary_entries.id, { onDelete: "cascade" }), + target_language_code: varchar({ length: 10 }).notNull(), + translation: text().notNull(), + sense_hint: text(), + cefr_level: varchar({ length: 2 }), + difficulty: varchar({ length: 20 }), + source: varchar({ length: 50 }).notNull().default("kaikki"), created_at: timestamp({ withTimezone: true }).defaultNow().notNull(), }, (table) => [ - check( - "source_language_check", - sql`${table.source_language} IN (${sql.raw(SUPPORTED_LANGUAGE_CODES.map((l) => `'${l}'`).join(", "))})`, + unique("unique_translation").on( + table.entry_id, + table.target_language_code, + table.translation, ), check( - "validated_languages_check", - sql`validated_languages <@ ARRAY[${sql.raw(SUPPORTED_LANGUAGE_CODES.map((l) => `'${l}'`).join(", "))}]::varchar[]`, + "target_language_code_check", + sql`${table.target_language_code} IN (${sql.raw(SUPPORTED_LANGUAGE_CODES.map((l) => `'${l}'`).join(", "))})`, ), check( - "validated_languages_excludes_source", - sql`NOT (${table.source_language} = ANY(${table.validated_languages}))`, + "cefr_check", + sql`${table.cefr_level} IS NULL OR ${table.cefr_level} IN (${sql.raw(CEFR_LEVELS.map((l) => `'${l}'`).join(", "))})`, ), check( - "deck_type_check", - sql`${table.type} IN (${sql.raw(SUPPORTED_DECK_TYPES.map((t) => `'${t}'`).join(", "))})`, + "difficulty_check", + sql`${table.difficulty} IS NULL OR ${table.difficulty} IN (${sql.raw(DIFFICULTY_LEVELS.map((d) => `'${d}'`).join(", "))})`, + ), + index("idx_translations_target_lang").on( + table.target_language_code, + table.difficulty, + table.entry_id, ), - unique("unique_deck_name").on(table.name, table.source_language), - index("idx_decks_type").on(table.type, table.source_language), ], ); -export const deck_terms = pgTable( - "deck_terms", - { - deck_id: uuid() - .notNull() - .references(() => decks.id, { onDelete: "cascade" }), - term_id: uuid() - .notNull() - .references(() => terms.id, { onDelete: "cascade" }), - }, - (table) => [primaryKey({ columns: [table.deck_id, table.term_id] })], -); - -export const topics = pgTable("topics", { - id: uuid().primaryKey().defaultRandom(), - slug: varchar({ length: 50 }).notNull().unique(), - label: text().notNull(), - description: text(), - created_at: timestamp({ withTimezone: true }).defaultNow().notNull(), -}); - -export const term_topics = pgTable( - "term_topics", - { - term_id: uuid() - .notNull() - .references(() => terms.id, { onDelete: "cascade" }), - topic_id: uuid() - .notNull() - .references(() => topics.id, { onDelete: "cascade" }), - }, - (table) => [primaryKey({ columns: [table.term_id, table.topic_id] })], -); +// ── Auth (managed by Better Auth) ───────────────────────────────────────────── export const user = pgTable("user", { id: text("id").primaryKey(), @@ -204,7 +123,7 @@ export const user = pgTable("user", { createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }); @@ -216,7 +135,7 @@ export const session = pgTable( token: text("token").notNull().unique(), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), ipAddress: text("ip_address"), userAgent: text("user_agent"), @@ -245,7 +164,7 @@ export const account = pgTable( password: text("password"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [index("account_userId_idx").on(table.userId)], @@ -261,24 +180,13 @@ export const verification = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [index("verification_identifier_idx").on(table.identifier)], ); -export const userRelations = relations(user, ({ many }) => ({ - sessions: many(session), - accounts: many(account), -})); - -export const sessionRelations = relations(session, ({ one }) => ({ - user: one(user, { fields: [session.userId], references: [user.id] }), -})); - -export const accountRelations = relations(account, ({ one }) => ({ - user: one(user, { fields: [account.userId], references: [user.id] }), -})); +// ── Lobbies ─────────────────────────────────────────────────────────────────── export const lobbies = pgTable( "lobbies", @@ -318,6 +226,36 @@ export const lobby_players = pgTable( (table) => [primaryKey({ columns: [table.lobbyId, table.userId] })], ); +// ── Relations ───────────────────────────────────────────────────────────────── + +export const vocabularyEntryRelations = relations( + vocabulary_entries, + ({ many }) => ({ translations: many(entry_translations) }), +); + +export const entryTranslationRelations = relations( + entry_translations, + ({ one }) => ({ + entry: one(vocabulary_entries, { + fields: [entry_translations.entry_id], + references: [vocabulary_entries.id], + }), + }), +); + +export const userRelations = relations(user, ({ many }) => ({ + sessions: many(session), + accounts: many(account), +})); + +export const sessionRelations = relations(session, ({ one }) => ({ + user: one(user, { fields: [session.userId], references: [user.id] }), +})); + +export const accountRelations = relations(account, ({ one }) => ({ + user: one(user, { fields: [account.userId], references: [user.id] }), +})); + export const lobbyRelations = relations(lobbies, ({ one, many }) => ({ host: one(user, { fields: [lobbies.hostUserId], references: [user.id] }), players: many(lobby_players), diff --git a/packages/db/src/models/termModel.ts b/packages/db/src/models/termModel.ts index b446a72..54c8482 100644 --- a/packages/db/src/models/termModel.ts +++ b/packages/db/src/models/termModel.ts @@ -1,25 +1,27 @@ import { db } from "@lila/db"; -import { eq, and, isNotNull, sql, ne } from "drizzle-orm"; -import { terms, translations, term_glosses } from "@lila/db/schema"; +import { eq, and, ne, sql, isNotNull } from "drizzle-orm"; +import { vocabulary_entries, entry_translations } from "@lila/db/schema"; import { alias } from "drizzle-orm/pg-core"; - import type { SupportedLanguageCode, SupportedPos, DifficultyLevel, } from "@lila/shared"; +// ── Types ───────────────────────────────────────────────────────────────────── + export type TranslationPairRow = { - termId: string; + entryId: string; sourceText: string; targetText: string; sourceGloss: string | null; }; -// Note: difficulty filter is intentionally asymmetric. We filter on the target -// (answer) side only — a word can be A2 in Italian but B1 in English, and what -// matters for the learner is the difficulty of the word they're being taught. +// ── Queries ─────────────────────────────────────────────────────────────────── +// Note: difficulty filter is intentionally on the target (translation) side. +// A word can be A2 in one language but B1 in another — what matters for the +// learner is the difficulty of the word they are being tested on. export const getGameTerms = async ( sourceLanguage: SupportedLanguageCode, targetLanguage: SupportedLanguageCode, @@ -27,53 +29,36 @@ export const getGameTerms = async ( difficulty: DifficultyLevel, rounds: number, ): Promise => { - const sourceTranslations = alias(translations, "source_translations"); - const targetTranslations = alias(translations, "target_translations"); + const sourceEntries = alias(vocabulary_entries, "source_entries"); + const targetTranslations = alias(entry_translations, "target_translations"); const rows = await db .select({ - termId: terms.id, - sourceText: sourceTranslations.text, - targetText: targetTranslations.text, - sourceGloss: term_glosses.text, + entryId: sourceEntries.id, + sourceText: sourceEntries.headword, + targetText: targetTranslations.translation, + sourceGloss: sourceEntries.gloss, }) - .from(terms) - .innerJoin( - sourceTranslations, - and( - eq(sourceTranslations.term_id, terms.id), - eq(sourceTranslations.language_code, sourceLanguage), // Filter here! - ), - ) + .from(sourceEntries) .innerJoin( targetTranslations, and( - eq(targetTranslations.term_id, terms.id), - eq(targetTranslations.language_code, targetLanguage), // Filter here! - ), - ) - .leftJoin( - term_glosses, - and( - eq(term_glosses.term_id, terms.id), - eq(term_glosses.language_code, sourceLanguage), + eq(targetTranslations.entry_id, sourceEntries.id), + eq(targetTranslations.target_language_code, targetLanguage), + eq(targetTranslations.difficulty, difficulty), + isNotNull(targetTranslations.translation), ), ) .where( and( - eq(terms.pos, pos), - eq(targetTranslations.difficulty, difficulty), - isNotNull(sourceTranslations.difficulty), // Good data quality check! + eq(sourceEntries.language_code, sourceLanguage), + eq(sourceEntries.pos, pos), + isNotNull(sourceEntries.difficulty), ), ) - // TODO(post-mvp): ORDER BY RANDOM() sorts the entire filtered result set before - // applying LIMIT, which is fine at current data volumes (low thousands of rows - // after POS + difficulty filters) but degrades as the terms table grows. Once - // the database is fully populated and tagged, replace with one of: - // - TABLESAMPLE BERNOULLI(n) for approximate sampling on large tables - // - Random offset: SELECT ... OFFSET floor(random() * (SELECT count(*) ...)) - // - Pre-computed random column with a btree index, reshuffled periodically - // Benchmark first — don't optimise until it actually hurts. + // TODO(post-mvp): ORDER BY RANDOM() sorts the entire filtered result set + // before applying LIMIT, which is fine at current data volumes but degrades + // as the table grows. See original termModel.ts for optimisation options. .orderBy(sql`RANDOM()`) .limit(rounds); @@ -81,32 +66,33 @@ export const getGameTerms = async ( }; export const getDistractors = async ( - excludeTermId: string, + excludeEntryId: string, excludeText: string, + sourceLanguage: SupportedLanguageCode, targetLanguage: SupportedLanguageCode, pos: SupportedPos, difficulty: DifficultyLevel, count: number, ): Promise => { const rows = await db - .select({ text: translations.text }) - .from(terms) + .select({ text: entry_translations.translation }) + .from(vocabulary_entries) .innerJoin( - translations, + entry_translations, and( - eq(translations.term_id, terms.id), - eq(translations.language_code, targetLanguage), + eq(entry_translations.entry_id, vocabulary_entries.id), + eq(entry_translations.target_language_code, targetLanguage), + eq(entry_translations.difficulty, difficulty), ), ) .where( and( - eq(terms.pos, pos), - eq(translations.difficulty, difficulty), - ne(terms.id, excludeTermId), - ne(translations.text, excludeText), + eq(vocabulary_entries.language_code, sourceLanguage), + eq(vocabulary_entries.pos, pos), + ne(vocabulary_entries.id, excludeEntryId), + ne(entry_translations.translation, excludeText), ), ) - // TODO(post-mvp): same ORDER BY RANDOM() concern as getGameTerms — see comment there. .orderBy(sql`RANDOM()`) .limit(count);