diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index fa4b48c..a581eae 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -39,6 +39,7 @@ COPY packages/db/package.json ./packages/db/ COPY --from=builder /app/apps/api/dist ./apps/api/dist COPY --from=builder /app/packages/shared/dist ./packages/shared/dist COPY --from=builder /app/packages/db/dist ./packages/db/dist +COPY --from=builder /app/packages/db/drizzle ./packages/db/drizzle RUN pnpm install --frozen-lockfile --prod EXPOSE 3000 -CMD ["node", "apps/api/dist/src/server.js"] +CMD ["sh", "-c", "node packages/db/dist/src/migrate.js && node apps/api/dist/src/server.js"] diff --git a/docker-compose.yml b/docker-compose.yml index b661975..cae960a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,7 @@ services: api: container_name: lila-api + user: "${UID}:${GID}" build: context: . dockerfile: ./apps/api/Dockerfile @@ -59,6 +60,7 @@ services: web: container_name: lila-web + user: "${UID}:${GID}" build: context: . dockerfile: ./apps/web/Dockerfile diff --git a/documentation/backlog.md b/documentation/backlog.md new file mode 100644 index 0000000..8515238 --- /dev/null +++ b/documentation/backlog.md @@ -0,0 +1,122 @@ +# lila — backlog + +Labels: `[feature]` `[infra]` `[security]` `[ux]` `[debt]` + +--- + +## now + +Things that are actively in progress or should be picked up immediately. Mostly operational risk and the remaining phase 7 hardening work. + +- **Migrations in the deploy pipeline** `[infra]` `[debt]` + Run `drizzle migrate` as a step in the CI/CD pipeline before the API container is restarted. Deploying code before schema is applied causes crashes. See `deployment.md` — deploy order is currently documented but not enforced. + +- **Rate limiting on API endpoints** `[security]` + At minimum: auth endpoints (brute force prevention) and game endpoints (spam prevention). Consider `express-rate-limit`. + +- **404 and redirect handling** `[ux]` + Unknown routes return raw errors. Add a catch-all route on the frontend for client-side 404s. Consider a Caddy fallback for unrecognized subdomains. + +- **React error boundaries** `[ux]` + Catch and display runtime errors gracefully instead of crashing the entire app. + +- **Pin dependencies in package.json** `[debt]` `[infra]` + Unpinned deps in a CI/CD pipeline are a real risk. Pin all versions to exact values to prevent unexpected breakage on build. + +- **Docker credential helper** `[debt]` `[infra]` + Credentials are stored unencrypted in `~/.docker/config.json`. Set up a credential helper. See https://docs.docker.com/go/credential-store/ + +- **Google OAuth publishing** `[infra]` + Only test users can currently log in via Google. Publish the OAuth consent screen so any Google user can sign in — requires branding verification in Google Cloud Console. + +- **Hetzner domain migration check** `[infra]` + Verify whether the lilastudy.com domain needs to be migrated following a Hetzner DNS change. Check Hetzner dashboard for any pending migration notice. + +- **Security headers with helmet** `[security]` + Add helmet middleware to set secure HTTP response headers. One-liner: app.use(helmet()). Covers headers like X-Content-Type-Options, X-Frame-Options, and Content-Security-Policy. + +--- + +## next + +Clearly planned work, not yet started. No hard ordering — sequence based on what unblocks real users first. + +- **Guest / try-now flow** `[feature]` + Allow users to play a quiz without signing in so they can see what the app offers before creating an account. Make auth middleware optional on game routes, add a "Try without account" button on the landing/login page. + +- **Favicon, page titles, Open Graph meta** `[ux]` + Add favicon, set proper per-route page titles, add Open Graph meta tags for link previews. + +- **Accessibility pass** `[ux]` + Keyboard navigation for quiz buttons, ARIA labels on interactive elements, focus management during quiz flow. + +- **Monitoring and logging** `[infra]` + Uptime monitoring and centralized logging on the VPS. Options: `chkrootkit`/`rkhunter` for security, `logwatch`/`monit` for daily summaries. + +- **Offsite backup storage** `[infra]` + Database backups currently live on the same VPS. Add offsite copies to Hetzner Object Storage or an S3-compatible service to protect against VPS failure. + +- **Valkey for game session store** `[infra]` + Add Valkey to the production Docker stack. Implement `ValkeyGameSessionStore` against the existing `GameSessionStore` interface. Required before multiplayer scales. + +- **User stats endpoint + profile page** `[feature]` + `GET /users/me/stats` returning games played, score history, etc. Frontend profile page displaying the stats. + +- **Admin dashboard** `[feature]` + User management, overview of words and languages, and per-term stats. Not urgent but has real operational value once real users are present. + +- **Email + password login** `[feature]` + Traditional email/password auth as an alternative to social login. Configure via Better Auth. + +- **Apple login** `[feature]` + Add Apple as a social login option via Better Auth. Requires Apple Developer account and Sign in with Apple configuration. + +- **Graceful WS reconnect** `[infra]` + Handle WebSocket disconnections gracefully. Reconnect with exponential back-off. Restore game state on reconnection if a game is still in progress. + +- **Configurable game settings in multiplayer lobby** `[feature]` + Game settings (mode, round count, timer duration, target score) are currently hardcoded. The host should be able to configure these when creating a lobby. Settings should be stored in the settings jsonb column on the lobbies table and passed through to the game service at start. + +--- + +## later + +Directionally right, timing is unclear. Revisit when the next/now work is done. + +- **Game modes** `[feature]` + Five modes are designed in `game_modes.md` — TV Quiz Show, Race to the Top, Chain Link, Elimination Round, Cooperative Challenge. The lobby infrastructure is mode-agnostic; each mode adds game logic only. First mode to implement is TBD. This is effectively a new phase. + +- **Single Player Extended** `[feature]` + Expanded singleplayer flow. Possible directions: longer sessions with increasing difficulty, streak bonuses, mixed POS/language rounds, progress tracking across sessions, timed challenge mode. + +- **Users in a separate database** `[infra]` + Architectural separation of auth/user data from vocabulary and game data. No immediate benefit — revisit after hardening is complete and user growth justifies the complexity. + +- **Modern env management** `[debt]` + Replace `.env` files with a more robust approach (e.g. `dotenvx`, `infisical`). Current setup works but is error-prone and not versioned. + +- **Reorganize datafiles and wordlists** `[debt]` + The current layout of `data-sources/`, `scripts/datafiles/`, `scripts/data-sources/`, and `packages/db/src/data/` is confusing with overlapping content. Consolidate into a clear structure. + +- **Resolve eslint peer dependency warning** `[debt]` + `eslint-plugin-react-hooks 7.0.1` expects `eslint ^3.0.0–^9.0.0` but found `10.0.3`. Low impact but worth cleaning up when nearby. + +- **husky + lint-staged** `[debt]` + Set up husky and lint-staged to run linting and formatting checks before every commit. Prevents CI failures from formatting or lint issues that slipped through locally. + +- **OpenAPI documentation for REST endpoints** `[feature]` + Document the API surface using OpenAPI/Swagger. Covers all REST endpoints with request/response shapes. Useful groundwork for the admin dashboard and any future contributors. + +--- + +## changelog + +Shipped milestones, newest first. + +- **04 - 2026 — Phase 6: Production deployment** — Hetzner VPS, Caddy HTTPS, Forgejo CI/CD, daily DB backups, cross-subdomain auth +- **04 - 2026 — Phase 5: Multiplayer game** — real-time simultaneous play, 15s server timer, live scoring, winner screen +- **04 - 2026 — Phase 4: Multiplayer lobby** — WebSocket server, lobby create/join, real-time player list +- **04 - 2026 — Phase 3: Auth** — Better Auth, Google + GitHub social login, session middleware, auth guard +- **04 - 2026 — Phase 2: Singleplayer UI** — full quiz loop in browser, game setup, question card, score screen +- **04 - 2026 — Phase 1: Vocabulary data + API** — WordNet/OMW data pipeline, CEFR enrichment, game session endpoints +- **04 - 2026 — Phase 0: Foundation** — pnpm monorepo, TypeScript, ESLint, Vitest, Drizzle, Docker Compose diff --git a/documentation/PIPELINE.md b/documentation/data-pipeline.md similarity index 100% rename from documentation/PIPELINE.md rename to documentation/data-pipeline.md diff --git a/documentation/notes.md b/documentation/notes.md index 50c13c1..2da500d 100644 --- a/documentation/notes.md +++ b/documentation/notes.md @@ -2,6 +2,7 @@ ## tasks +- **IMPORTANT** db migrations have to be part of the deployment pipeline!!!!!!!!!!!!!!!!!! - put users in separate db - pinning dependencies in package.json files - rethink organisation of datafiles and wordlists diff --git a/packages/db/drizzle/0009_rapid_cobalt_man.sql b/packages/db/drizzle/0009_rapid_cobalt_man.sql new file mode 100644 index 0000000..e3dcc04 --- /dev/null +++ b/packages/db/drizzle/0009_rapid_cobalt_man.sql @@ -0,0 +1,3 @@ +CREATE TABLE "dummy" ( + "id" serial PRIMARY KEY NOT NULL +); diff --git a/packages/db/drizzle/meta/0009_snapshot.json b/packages/db/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..8274112 --- /dev/null +++ b/packages/db/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1203 @@ +{ + "id": "24f8a0f9-40eb-4ad7-b08a-00ab7c98ecd4", + "prevId": "3824b3fb-5334-4efe-aa71-b6dbfb682c15", + "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', 'de', 'fr', 'es')" + }, + "validated_languages_check": { + "name": "validated_languages_check", + "value": "validated_languages <@ ARRAY['en', 'it', 'de', 'fr', 'es']::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.dummy": { + "name": "dummy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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.term_examples": { + "name": "term_examples", + "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": { + "idx_term_examples_term_id": { + "name": "idx_term_examples_term_id", + "columns": [ + { + "expression": "term_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "term_examples_term_id_terms_id_fk": { + "name": "term_examples_term_id_terms_id_fk", + "tableFrom": "term_examples", + "tableTo": "terms", + "columnsFrom": [ + "term_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_term_example": { + "name": "unique_term_example", + "nullsNotDistinct": false, + "columns": [ + "term_id", + "language_code", + "text" + ] + } + }, + "policies": {}, + "checkConstraints": { + "language_code_check": { + "name": "language_code_check", + "value": "\"term_examples\".\"language_code\" IN ('en', 'it', 'de', 'fr', 'es')" + } + }, + "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 + }, + "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": { + "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', 'de', 'fr', 'es')" + } + }, + "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', 'adjective', 'adverb')" + } + }, + "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', 'de', 'fr', 'es')" + }, + "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 f86ac07..394f7e7 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1776695279870, "tag": "0008_far_energizer", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1776928720684, + "tag": "0009_rapid_cobalt_man", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/package.json b/packages/db/package.json index a717652..914e989 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "tsc", + "build": "rm -rf dist && tsc", "generate": "drizzle-kit generate", "migrate": "drizzle-kit migrate" }, diff --git a/packages/db/src/db/schema.ts b/packages/db/src/db/schema.ts index b2184b3..a79e64d 100644 --- a/packages/db/src/db/schema.ts +++ b/packages/db/src/db/schema.ts @@ -10,6 +10,7 @@ import { index, boolean, integer, + serial, } from "drizzle-orm/pg-core"; import { sql, relations } from "drizzle-orm"; @@ -330,3 +331,5 @@ export const lobbyPlayersRelations = relations(lobby_players, ({ one }) => ({ }), user: one(user, { fields: [lobby_players.userId], references: [user.id] }), })); + +export const dummy = pgTable("dummy", { id: serial("id").primaryKey() }); diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts new file mode 100644 index 0000000..075cf2f --- /dev/null +++ b/packages/db/src/migrate.ts @@ -0,0 +1,25 @@ +import { config } from "dotenv"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { Pool } from "pg"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +config({ + path: resolve(dirname(fileURLToPath(import.meta.url)), "../../../.env"), +}); + +const pool = new Pool({ connectionString: process.env["DATABASE_URL"]! }); +const db = drizzle(pool); + +console.log("starting database migrations..."); + +await migrate(db, { + migrationsFolder: resolve( + dirname(fileURLToPath(import.meta.url)), + "../drizzle", + ), +}); + +await pool.end(); +console.log("database migrations complete.");