From 879b72005acd4c68378eeedd43fb0cd2b980c3d0 Mon Sep 17 00:00:00 2001 From: Akhil Negi Date: Thu, 15 Oct 2026 10:40:23 +0200 Subject: [PATCH 1/3] feat(backend): Satt opp GraphQL-backend med PostgreSQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Databaseskjema: movies, genres, movie_genres, users, reviews og saved_movies (med list_type for vil se/sett/favoritt) - Apollo Server med spørringene searchMovies (søk, filter, sortering, paginering) og movie - Skript for å opprette tabeller (migrate) og engangsimport fra TMDB Co-Authored-By: Claude Opus 5.5 --- backend/.env.example | 17 + backend/.gitignore | 3 + backend/README.md | 69 ++ backend/db/schema.sql | 82 ++ backend/package-lock.json | 1563 ++++++++++++++++++++++++++++++ backend/package.json | 26 + backend/scripts/import-movies.ts | 177 ++++ backend/scripts/migrate.ts | 11 + backend/src/db.ts | 11 + backend/src/index.ts | 12 + backend/src/resolvers.ts | 151 +++ backend/src/schema.ts | 60 ++ backend/tsconfig.json | 17 + 13 files changed, 2199 insertions(+) create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/README.md create mode 100644 backend/db/schema.sql create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/scripts/import-movies.ts create mode 100644 backend/scripts/migrate.ts create mode 100644 backend/src/db.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/resolvers.ts create mode 100644 backend/src/schema.ts create mode 100644 backend/tsconfig.json diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..9269676 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,17 @@ +# Kopier denne fila til .env og fyll inn verdiene. .env skal ALDRI committes. + +# Adressen til databasen: postgres://bruker:passord@vert:port/databasenavn +DATABASE_URL=postgres://localhost:5432/framerate + +# Porten GraphQL-serveren lytter på +PORT=3001 + +# TMDB "API Read Access Token" (lang tekst), fra https://www.themoviedb.org/settings/api +# Brukes bare av importskriptet, ikke av serveren. +TMDB_READ_TOKEN= + +# Hvor mange filmer importskriptet henter (maks 10000) +IMPORT_MOVIE_COUNT=5000 + +# Sett til "production" på VM-en, så skjuler Apollo interne feildetaljer +NODE_ENV=development diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..e768dd6 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,3 @@ +node_modules +.env +*.log diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..3722090 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,69 @@ +# Frame Rate – backend + +GraphQL-server (Apollo Server) med egen PostgreSQL-database. Filmene importeres **én gang** fra TMDB med et skript; serveren spør bare vår egen database. + +## Kom i gang + +Krever Node 24.6+ og PostgreSQL. + +```bash +cd backend +npm install +cp .env.example .env # fyll inn DATABASE_URL og TMDB_READ_TOKEN +createdb framerate # lager en tom database (første gang) +npm run db:migrate # lager tabellene +npm run import:movies # henter ~5000 filmer fra TMDB (tar noen minutter) +npm run dev # starter serveren på http://localhost:3001 +``` + +Åpne http://localhost:3001 i nettleseren for å teste spørringer i Apollo Sandbox. + +## Skript + +| Kommando | Hva den gjør | +| ----------------------- | -------------------------------------------------------------------------- | +| `npm run dev` | Starter serveren og restarter den når du lagrer en fil | +| `npm start` | Starter serveren (bruk denne på VM-en) | +| `npm run typecheck` | Sjekker TypeScript-typer | +| `npm run db:migrate` | Lager/oppdaterer tabellene fra `db/schema.sql`. Trygt å kjøre flere ganger | +| `npm run import:movies` | Henter filmer fra TMDB. Kan kjøres på nytt uten duplikater | + +## Eksempel + +```graphql +query { + searchMovies(search: "star", genreIds: [878], yearFrom: 1970, sort: RATING_DESC, limit: 20, offset: 0) { + totalCount + movies { + id + title + releaseYear + posterPath + averageRating + genres { name } + } + } + movie(id: 11) { + title + director + runtime + ageRating + } +} +``` + +Plakater: `https://image.tmdb.org/t/p/w185{posterPath}` (liste) eller `w500` (detaljside). + +## Mappestruktur + +``` +backend/ +├── db/schema.sql Tabeller: movies, genres, movie_genres, users, reviews, saved_movies +├── scripts/migrate.ts Kjører schema.sql +├── scripts/import-movies.ts Engangsimport fra TMDB +└── src/ + ├── index.ts Starter serveren + ├── schema.ts GraphQL-typene (hva API-et tilbyr) + ├── resolvers.ts Henter data fra databasen + └── db.ts Tilkobling til PostgreSQL +``` diff --git a/backend/db/schema.sql b/backend/db/schema.sql new file mode 100644 index 0000000..114e185 --- /dev/null +++ b/backend/db/schema.sql @@ -0,0 +1,82 @@ +-- Databaseskjema for Frame Rate (PostgreSQL). +-- Kjøres med `npm run db:migrate`. Trygt å kjøre flere ganger (IF NOT EXISTS). + +-- pg_trgm gjør at søk med ILIKE '%tekst%' kan bruke en indeks. +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE TABLE IF NOT EXISTS genres ( + id INTEGER PRIMARY KEY, -- TMDB sin sjanger-ID + name TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS movies ( + id INTEGER PRIMARY KEY, -- TMDB sin film-ID, hindrer duplikater ved ny import + title TEXT NOT NULL, + original_title TEXT, + overview TEXT, + release_date DATE, + release_year INTEGER GENERATED ALWAYS AS (EXTRACT(YEAR FROM release_date)::INTEGER) STORED, + runtime INTEGER, -- minutter + director TEXT, + age_rating TEXT, -- f.eks. "15" (NO) eller "PG-13" (US) + poster_path TEXT, + backdrop_path TEXT, + popularity REAL NOT NULL DEFAULT 0, + -- Regnes ut fra våre egne anmeldelser, ikke fra TMDB + average_rating NUMERIC(3, 2), + review_count INTEGER NOT NULL DEFAULT 0, + imported_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS movies_title_trgm_idx ON movies USING gin (title gin_trgm_ops); +CREATE INDEX IF NOT EXISTS movies_release_year_idx ON movies (release_year); +CREATE INDEX IF NOT EXISTS movies_popularity_idx ON movies (popularity DESC); +CREATE INDEX IF NOT EXISTS movies_average_rating_idx ON movies (average_rating DESC NULLS LAST); + +CREATE TABLE IF NOT EXISTS movie_genres ( + movie_id INTEGER NOT NULL REFERENCES movies (id) ON DELETE CASCADE, + genre_id INTEGER NOT NULL REFERENCES genres (id) ON DELETE CASCADE, + PRIMARY KEY (movie_id, genre_id) +); + +CREATE INDEX IF NOT EXISTS movie_genres_genre_idx ON movie_genres (genre_id); + +-- Brukere: innlogging bygges senere, men tabellen finnes så anmeldelser og lister kan peke hit. +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS reviews ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + movie_id INTEGER NOT NULL REFERENCES movies (id) ON DELETE CASCADE, + rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 6), -- terningkast + text TEXT CHECK (char_length(text) <= 2000), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, movie_id) -- én anmeldelse per bruker per film +); + +CREATE INDEX IF NOT EXISTS reviews_movie_idx ON reviews (movie_id, created_at DESC); + +DO $$ +BEGIN + CREATE TYPE list_type AS ENUM ('WATCHLIST', 'SEEN', 'FAVORITE'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END +$$; + +-- Én tabell for alle tre listene; list_type sier hvilken liste filmen ligger i. +CREATE TABLE IF NOT EXISTS saved_movies ( + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + movie_id INTEGER NOT NULL REFERENCES movies (id) ON DELETE CASCADE, + list_type list_type NOT NULL, + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, movie_id, list_type) +); + +CREATE INDEX IF NOT EXISTS saved_movies_user_list_idx ON saved_movies (user_id, list_type, added_at DESC); diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..e82d72f --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1563 @@ +{ + "name": "framerate-backend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "framerate-backend", + "version": "0.0.0", + "dependencies": { + "@apollo/server": "^5.5.1", + "graphql": "^16.14.2", + "pg": "^8.23.1" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/pg": "^8.23.1", + "typescript": "~6.0.2" + }, + "engines": { + "node": ">=24.6" + } + }, + "node_modules/@apollo/cache-control-types": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@apollo/cache-control-types/-/cache-control-types-1.0.3.tgz", + "integrity": "sha512-F17/vCp7QVwom9eG7ToauIKdAxpSoadsJnqIfyryLFSkLSOEqu+eC5Z3N8OXcUVStuOMcNHlyraRsA6rRICu4g==", + "license": "MIT", + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/protobufjs": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.8.tgz", + "integrity": "sha512-r7xNeUqZX+eBBEmyvaPw0/cSz6zgf5jdH8mjUz8ynKpNs/GU7vi2T7sNcZINk2ZID7wwjG91FCgdpCrQuJ8rzA==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.0", + "long": "^4.0.0" + }, + "bin": { + "apollo-pbjs": "bin/pbjs", + "apollo-pbts": "bin/pbts" + } + }, + "node_modules/@apollo/server": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@apollo/server/-/server-5.5.1.tgz", + "integrity": "sha512-Rn3g5TJQsMSUY23CWZTghWdBWyjX7dP1eaEBPkvmM2RHi82cDcpgTIkSCbGvtTUEGjwopLv1AAooU/n7iIZ20A==", + "license": "MIT", + "dependencies": { + "@apollo/cache-control-types": "^1.0.3", + "@apollo/server-gateway-interface": "^2.0.0", + "@apollo/usage-reporting-protobuf": "^4.1.1", + "@apollo/utils.createhash": "^3.0.0", + "@apollo/utils.fetcher": "^3.0.0", + "@apollo/utils.isnodelike": "^3.0.0", + "@apollo/utils.keyvaluecache": "^4.0.0", + "@apollo/utils.logger": "^3.0.0", + "@apollo/utils.usagereporting": "^2.1.0", + "@apollo/utils.withrequired": "^3.0.0", + "@graphql-tools/schema": "^10.0.0", + "async-retry": "^1.2.1", + "body-parser": "^2.2.2", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "finalhandler": "^2.1.0", + "loglevel": "^1.6.8", + "lru-cache": "^11.1.0", + "negotiator": "^1.0.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "graphql": "^16.11.0" + } + }, + "node_modules/@apollo/server-gateway-interface": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@apollo/server-gateway-interface/-/server-gateway-interface-2.0.0.tgz", + "integrity": "sha512-3HEMD6fSantG2My3jWkb9dvfkF9vJ4BDLRjMgsnD790VINtuPaEp+h3Hg9HOHiWkML6QsOhnaRqZ+gvhp3y8Nw==", + "license": "MIT", + "dependencies": { + "@apollo/usage-reporting-protobuf": "^4.1.1", + "@apollo/utils.fetcher": "^3.0.0", + "@apollo/utils.keyvaluecache": "^4.0.0", + "@apollo/utils.logger": "^3.0.0" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/usage-reporting-protobuf": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@apollo/usage-reporting-protobuf/-/usage-reporting-protobuf-4.1.2.tgz", + "integrity": "sha512-aTnAD41RYz0d5dawlyR5Iclkgzx0Xb0njUJmEfvZ6pS4f4HU8wCYyctPpWat/HWp2PmRwDfX5R1k4uVcDKZ4xA==", + "license": "MIT", + "dependencies": { + "@apollo/protobufjs": "1.2.8" + } + }, + "node_modules/@apollo/utils.createhash": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.createhash/-/utils.createhash-3.0.1.tgz", + "integrity": "sha512-CKrlySj4eQYftBE5MJ8IzKwIibQnftDT7yGfsJy5KSEEnLlPASX0UTpbKqkjlVEwPPd4mEwI7WOM7XNxEuO05A==", + "license": "MIT", + "dependencies": { + "@apollo/utils.isnodelike": "^3.0.0", + "sha.js": "^2.4.11" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@apollo/utils.dropunuseddefinitions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.dropunuseddefinitions/-/utils.dropunuseddefinitions-2.0.1.tgz", + "integrity": "sha512-EsPIBqsSt2BwDsv8Wu76LK5R1KtsVkNoO4b0M5aK0hx+dGg9xJXuqlr7Fo34Dl+y83jmzn+UvEW+t1/GP2melA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.fetcher": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.fetcher/-/utils.fetcher-3.1.0.tgz", + "integrity": "sha512-Z3QAyrsQkvrdTuHAFwWDNd+0l50guwoQUoaDQssLOjkmnmVuvXlJykqlEJolio+4rFwBnWdoY1ByFdKaQEcm7A==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@apollo/utils.isnodelike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.isnodelike/-/utils.isnodelike-3.0.0.tgz", + "integrity": "sha512-xrjyjfkzunZ0DeF6xkHaK5IKR8F1FBq6qV+uZ+h9worIF/2YSzA0uoBxGv6tbTeo9QoIQnRW4PVFzGix5E7n/g==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@apollo/utils.keyvaluecache": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-4.0.0.tgz", + "integrity": "sha512-mKw1myRUkQsGPNB+9bglAuhviodJ2L2MRYLTafCMw5BIo7nbvCPNCkLnIHjZ1NOzH7SnMAr5c9LmXiqsgYqLZw==", + "license": "MIT", + "dependencies": { + "@apollo/utils.logger": "^3.0.0", + "lru-cache": "^11.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@apollo/utils.logger": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-3.0.0.tgz", + "integrity": "sha512-M8V8JOTH0F2qEi+ktPfw4RL7MvUycDfKp7aEap2eWXfL5SqWHN6jTLbj5f5fj1cceHpyaUSOZlvlaaryaxZAmg==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@apollo/utils.printwithreducedwhitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.printwithreducedwhitespace/-/utils.printwithreducedwhitespace-2.0.1.tgz", + "integrity": "sha512-9M4LUXV/fQBh8vZWlLvb/HyyhjJ77/I5ZKu+NBWV/BmYGyRmoEP9EVAy7LCVoY3t8BDcyCAGfxJaLFCSuQkPUg==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.removealiases": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.removealiases/-/utils.removealiases-2.0.1.tgz", + "integrity": "sha512-0joRc2HBO4u594Op1nev+mUF6yRnxoUH64xw8x3bX7n8QBDYdeYgY4tF0vJReTy+zdn2xv6fMsquATSgC722FA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.sortast": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.sortast/-/utils.sortast-2.0.1.tgz", + "integrity": "sha512-eciIavsWpJ09za1pn37wpsCGrQNXUhM0TktnZmHwO+Zy9O4fu/WdB4+5BvVhFiZYOXvfjzJUcc+hsIV8RUOtMw==", + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.stripsensitiveliterals": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.stripsensitiveliterals/-/utils.stripsensitiveliterals-2.0.1.tgz", + "integrity": "sha512-QJs7HtzXS/JIPMKWimFnUMK7VjkGQTzqD9bKD1h3iuPAqLsxd0mUNVbkYOPTsDhUKgcvUOfOqOJWYohAKMvcSA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.usagereporting": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.usagereporting/-/utils.usagereporting-2.1.0.tgz", + "integrity": "sha512-LPSlBrn+S17oBy5eWkrRSGb98sWmnEzo3DPTZgp8IQc8sJe0prDgDuppGq4NeQlpoqEHz0hQeYHAOA0Z3aQsxQ==", + "license": "MIT", + "dependencies": { + "@apollo/usage-reporting-protobuf": "^4.1.0", + "@apollo/utils.dropunuseddefinitions": "^2.0.1", + "@apollo/utils.printwithreducedwhitespace": "^2.0.1", + "@apollo/utils.removealiases": "2.0.1", + "@apollo/utils.sortast": "^2.0.1", + "@apollo/utils.stripsensitiveliterals": "^2.0.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.withrequired": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.withrequired/-/utils.withrequired-3.0.0.tgz", + "integrity": "sha512-aaxeavfJ+RHboh7c2ofO5HHtQobGX4AgUujXP4CXpREHp9fQ9jPi6K9T1jrAKe7HIipoP0OJ1gd6JamSkFIpvA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@graphql-tools/merge": { + "version": "9.2.7", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.2.7.tgz", + "integrity": "sha512-Zo69OGURoOupi9v+3PCNOuQSgwz0/KNOsfTNMudSLzHTKMu40Bz3KqHaIxWuUiFr/ZDRXClI8ou48vdJWtwh4w==", + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^12.0.4", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/schema": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.1.4.tgz", + "integrity": "sha512-talZuSrjeklX7RVYLgF7fxHgywZXSYWzFZf3NG5CNFgkeK3xTocYMvlC3FLAApMWUW59qkyuHN1/HIgaIGfjRA==", + "license": "MIT", + "dependencies": { + "@graphql-tools/merge": "^9.2.7", + "@graphql-tools/utils": "^12.0.4", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/utils": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.4.tgz", + "integrity": "sha512-hJmeBRrK1OUABqRxLXB1YwrBB+3zTXeRJYb5PaF7CweV80zNGnN640Jz+0BrCaUE8HBk6KtoVXM/tUScUsKdeg==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^2.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-inspect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", + "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "license": "MIT" + }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..2dbe922 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,26 @@ +{ + "name": "framerate-backend", + "private": true, + "version": "0.0.0", + "type": "module", + "engines": { + "node": ">=24.6" + }, + "scripts": { + "dev": "node --watch --env-file-if-exists=.env src/index.ts", + "start": "node --env-file-if-exists=.env src/index.ts", + "typecheck": "tsc --noEmit", + "db:migrate": "node --env-file-if-exists=.env scripts/migrate.ts", + "import:movies": "node --env-file-if-exists=.env scripts/import-movies.ts" + }, + "dependencies": { + "@apollo/server": "^5.5.1", + "graphql": "^16.14.2", + "pg": "^8.23.1" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/pg": "^8.23.1", + "typescript": "~6.0.2" + } +} diff --git a/backend/scripts/import-movies.ts b/backend/scripts/import-movies.ts new file mode 100644 index 0000000..f20b89c --- /dev/null +++ b/backend/scripts/import-movies.ts @@ -0,0 +1,177 @@ +// Engangsimport av filmer fra TMDB til vår egen database. +// Kjør: npm run import:movies +// Kan kjøres på nytt: eksisterende filmer oppdateres, anmeldelser og snitt beholdes. + +import { pool } from '../src/db.ts'; + +const TMDB_TOKEN = process.env.TMDB_READ_TOKEN; +const TARGET = Number(process.env.IMPORT_MOVIE_COUNT ?? 5000); +const CONCURRENCY = 8; // antall detalj-forespørsler samtidig, godt under TMDB sin grense + +if (!TMDB_TOKEN) { + throw new Error('TMDB_READ_TOKEN mangler i .env'); +} +if (!Number.isInteger(TARGET) || TARGET < 1 || TARGET > 10000) { + throw new Error('IMPORT_MOVIE_COUNT må være mellom 1 og 10000'); +} + +interface TmdbGenre { + id: number; + name: string; +} + +interface TmdbDetails { + id: number; + title: string; + original_title: string; + overview: string; + release_date: string; + runtime: number | null; + poster_path: string | null; + backdrop_path: string | null; + popularity: number; + genres: TmdbGenre[]; + credits: { crew: { job: string; name: string }[] }; + release_dates: { results: { iso_3166_1: string; release_dates: { certification: string }[] }[] }; +} + +async function tmdb(path: string, params: Record = {}): Promise { + const url = new URL(`https://api.themoviedb.org/3${path}`); + url.search = new URLSearchParams({ language: 'en-US', ...params }).toString(); + + for (let attempt = 1; ; attempt++) { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${TMDB_TOKEN}`, Accept: 'application/json' }, + }); + if (response.ok) return (await response.json()) as T; + + // 429 = for mange forespørsler; vent og prøv igjen. + if ((response.status === 429 || response.status >= 500) && attempt < 5) { + const waitSeconds = Number(response.headers.get('retry-after')) || attempt * 2; + await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000)); + continue; + } + throw new Error(`TMDB ${response.status} på ${path}: ${await response.text()}`); + } +} + +// Aldersgrense: norsk hvis den finnes, ellers amerikansk. +function findAgeRating(details: TmdbDetails): string | null { + for (const country of ['NO', 'US']) { + const entry = details.release_dates.results.find((r) => r.iso_3166_1 === country); + const certification = entry?.release_dates.find((d) => d.certification)?.certification; + if (certification) return certification; + } + return null; +} + +async function importGenres() { + const { genres } = await tmdb<{ genres: TmdbGenre[] }>('/genre/movie/list'); + for (const genre of genres) { + await pool.query( + 'INSERT INTO genres (id, name) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name', + [genre.id, genre.name], + ); + } + console.log(`Sjangre: ${genres.length}`); +} + +async function collectMovieIds(): Promise { + const ids = new Set(); + for (let page = 1; ids.size < TARGET && page <= 500; page++) { + const data = await tmdb<{ results: { id: number }[]; total_pages: number }>('/discover/movie', { + sort_by: 'popularity.desc', + include_adult: 'false', + include_video: 'false', + 'vote_count.gte': '50', // hopper over obskure filmer med nesten ingen data + page: String(page), + }); + for (const movie of data.results) ids.add(movie.id); + if (page >= data.total_pages) break; + } + return [...ids].slice(0, TARGET); +} + +async function saveMovie(details: TmdbDetails) { + const director = details.credits.crew.find((person) => person.job === 'Director')?.name ?? null; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query( + `INSERT INTO movies (id, title, original_title, overview, release_date, runtime, director, + age_rating, poster_path, backdrop_path, popularity) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (id) DO UPDATE SET + title = EXCLUDED.title, original_title = EXCLUDED.original_title, overview = EXCLUDED.overview, + release_date = EXCLUDED.release_date, runtime = EXCLUDED.runtime, director = EXCLUDED.director, + age_rating = EXCLUDED.age_rating, poster_path = EXCLUDED.poster_path, + backdrop_path = EXCLUDED.backdrop_path, popularity = EXCLUDED.popularity, imported_at = now()`, + [ + details.id, + details.title, + details.original_title || null, + details.overview || null, + details.release_date || null, + details.runtime || null, + director, + findAgeRating(details), + details.poster_path, + details.backdrop_path, + details.popularity, + ], + ); + await client.query('DELETE FROM movie_genres WHERE movie_id = $1', [details.id]); + if (details.genres.length > 0) { + await client.query( + `INSERT INTO movie_genres (movie_id, genre_id) + SELECT $1, g.id FROM genres g WHERE g.id = ANY($2)`, + [details.id, details.genres.map((genre) => genre.id)], + ); + } + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +} + +async function importMovies(ids: number[]) { + let done = 0; + let failed = 0; + let next = 0; + + // Noen få "arbeidere" som henter hver sin film parallelt. + async function worker() { + while (next < ids.length) { + const id = ids[next++]; + try { + const details = await tmdb(`/movie/${id}`, { + append_to_response: 'credits,release_dates', + }); + await saveMovie(details); + } catch (error) { + failed++; + console.warn(`Hoppet over film ${id}: ${(error as Error).message}`); + } + done++; + if (done % 250 === 0 || done === ids.length) { + console.log(`Filmer: ${done}/${ids.length}`); + } + } + } + + await Promise.all(Array.from({ length: CONCURRENCY }, worker)); + return failed; +} + +try { + await importGenres(); + const ids = await collectMovieIds(); + console.log(`Fant ${ids.length} filmer, henter detaljer ...`); + const failed = await importMovies(ids); + console.log(`Ferdig. ${ids.length - failed} filmer importert, ${failed} feilet.`); +} finally { + await pool.end(); +} diff --git a/backend/scripts/migrate.ts b/backend/scripts/migrate.ts new file mode 100644 index 0000000..5dc64d2 --- /dev/null +++ b/backend/scripts/migrate.ts @@ -0,0 +1,11 @@ +import { readFile } from 'node:fs/promises'; +import { pool } from '../src/db.ts'; + +const sql = await readFile(new URL('../db/schema.sql', import.meta.url), 'utf8'); + +try { + await pool.query(sql); + console.log('Databaseskjemaet er oppdatert.'); +} finally { + await pool.end(); +} diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..a1469bb --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,11 @@ +import pg from 'pg'; + +if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL mangler. Kopier .env.example til .env og fyll den inn.'); +} + +// En "pool" holder noen få databasetilkoblinger åpne og gjenbruker dem. +export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); + +// NUMERIC kommer som tekst fra pg som standard; vi vil ha vanlige tall. +pg.types.setTypeParser(pg.types.builtins.NUMERIC, (value) => Number(value)); diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..b3df9a9 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,12 @@ +import { ApolloServer } from '@apollo/server'; +import { startStandaloneServer } from '@apollo/server/standalone'; +import { typeDefs } from './schema.ts'; +import { resolvers } from './resolvers.ts'; + +const server = new ApolloServer({ typeDefs, resolvers }); + +const { url } = await startStandaloneServer(server, { + listen: { port: Number(process.env.PORT ?? 3001) }, +}); + +console.log(`GraphQL-serveren kjører på ${url}`); diff --git a/backend/src/resolvers.ts b/backend/src/resolvers.ts new file mode 100644 index 0000000..84b8e89 --- /dev/null +++ b/backend/src/resolvers.ts @@ -0,0 +1,151 @@ +import { GraphQLError } from 'graphql'; +import { pool } from './db.ts'; + +type MovieSort = 'POPULARITY_DESC' | 'RATING_DESC' | 'RELEASE_DESC' | 'RELEASE_ASC' | 'TITLE_ASC'; + +interface SearchMoviesArgs { + search?: string | null; + genreIds?: number[] | null; + yearFrom?: number | null; + yearTo?: number | null; + minRating?: number | null; + sort: MovieSort; + limit: number; + offset: number; +} + +interface MovieRow { + id: number; + title: string; + original_title: string | null; + overview: string | null; + release_date: string | null; + release_year: number | null; + runtime: number | null; + director: string | null; + age_rating: string | null; + poster_path: string | null; + backdrop_path: string | null; + popularity: number; + average_rating: number | null; + review_count: number; + genres: { id: number; name: string }[]; +} + +const MAX_LIMIT = 50; + +// Sorteringen er en fast liste, så brukerinput havner aldri direkte i SQL-en. +// m.id til slutt gjør rekkefølgen stabil, så "Vis flere" ikke gir duplikater. +const ORDER_BY: Record = { + POPULARITY_DESC: 'm.popularity DESC, m.id', + RATING_DESC: 'm.average_rating DESC NULLS LAST, m.review_count DESC, m.id', + RELEASE_DESC: 'm.release_date DESC NULLS LAST, m.id', + RELEASE_ASC: 'm.release_date ASC NULLS LAST, m.id', + TITLE_ASC: 'm.title ASC, m.id', +}; + +// Henter filmens felter + sjangrene som en JSON-liste i samme spørring. +const MOVIE_COLUMNS = ` + m.id, m.title, m.original_title, m.overview, + to_char(m.release_date, 'YYYY-MM-DD') AS release_date, m.release_year, + m.runtime, m.director, m.age_rating, m.poster_path, m.backdrop_path, + m.popularity, m.average_rating, m.review_count, + COALESCE(( + SELECT json_agg(json_build_object('id', g.id, 'name', g.name) ORDER BY g.name) + FROM movie_genres mg JOIN genres g ON g.id = mg.genre_id + WHERE mg.movie_id = m.id + ), '[]') AS genres +`; + +function toMovie(row: MovieRow) { + return { + id: row.id, + title: row.title, + originalTitle: row.original_title, + overview: row.overview, + releaseDate: row.release_date, + releaseYear: row.release_year, + runtime: row.runtime, + director: row.director, + ageRating: row.age_rating, + posterPath: row.poster_path, + backdropPath: row.backdrop_path, + popularity: row.popularity, + averageRating: row.average_rating, + reviewCount: row.review_count, + genres: row.genres, + }; +} + +function badInput(message: string): GraphQLError { + return new GraphQLError(message, { extensions: { code: 'BAD_USER_INPUT' } }); +} + +// % og _ betyr "hva som helst" i ILIKE; de må escapes for å søkes etter bokstavelig. +function escapeLike(text: string): string { + return text.replace(/[\\%_]/g, (char) => `\\${char}`); +} + +async function searchMovies(args: SearchMoviesArgs) { + const { search, genreIds, yearFrom, yearTo, minRating, sort, limit, offset } = args; + + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) { + throw badInput(`limit må være mellom 1 og ${MAX_LIMIT}`); + } + if (!Number.isInteger(offset) || offset < 0) { + throw badInput('offset kan ikke være negativ'); + } + if (minRating != null && (minRating < 1 || minRating > 6)) { + throw badInput('minRating må være mellom 1 og 6'); + } + + const where: string[] = []; + const params: unknown[] = []; + const addParam = (value: unknown) => { + params.push(value); + return `$${params.length}`; + }; + + const trimmed = search?.trim(); + if (trimmed) { + where.push(`m.title ILIKE ${addParam(`%${escapeLike(trimmed)}%`)}`); + } + if (genreIds && genreIds.length > 0) { + where.push( + `EXISTS (SELECT 1 FROM movie_genres mg WHERE mg.movie_id = m.id AND mg.genre_id = ANY(${addParam(genreIds)}))`, + ); + } + if (yearFrom != null) where.push(`m.release_year >= ${addParam(yearFrom)}`); + if (yearTo != null) where.push(`m.release_year <= ${addParam(yearTo)}`); + if (minRating != null) where.push(`m.average_rating >= ${addParam(minRating)}`); + + const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; + const filterParams = [...params]; + + const [moviesResult, countResult] = await Promise.all([ + pool.query( + `SELECT ${MOVIE_COLUMNS} FROM movies m ${whereSql} + ORDER BY ${ORDER_BY[sort]} + LIMIT ${addParam(limit)} OFFSET ${addParam(offset)}`, + params, + ), + pool.query<{ count: string }>(`SELECT count(*) FROM movies m ${whereSql}`, filterParams), + ]); + + return { + movies: moviesResult.rows.map(toMovie), + totalCount: Number(countResult.rows[0].count), + }; +} + +async function getMovie(id: number) { + const { rows } = await pool.query(`SELECT ${MOVIE_COLUMNS} FROM movies m WHERE m.id = $1`, [id]); + return rows[0] ? toMovie(rows[0]) : null; +} + +export const resolvers = { + Query: { + searchMovies: (_parent: unknown, args: SearchMoviesArgs) => searchMovies(args), + movie: (_parent: unknown, args: { id: number }) => getMovie(args.id), + }, +}; diff --git a/backend/src/schema.ts b/backend/src/schema.ts new file mode 100644 index 0000000..2fed61d --- /dev/null +++ b/backend/src/schema.ts @@ -0,0 +1,60 @@ +export const typeDefs = `#graphql + enum MovieSort { + POPULARITY_DESC + RATING_DESC + RELEASE_DESC + RELEASE_ASC + TITLE_ASC + } + + type Genre { + id: Int! + name: String! + } + + type Movie { + id: Int! + title: String! + originalTitle: String + overview: String + "Format: YYYY-MM-DD" + releaseDate: String + releaseYear: Int + "Spilletid i minutter" + runtime: Int + director: String + ageRating: String + "Sti til bildet hos TMDB, f.eks. /abc.jpg. Full URL: https://image.tmdb.org/t/p/w500{posterPath}" + posterPath: String + backdropPath: String + popularity: Float! + "Snitt-terningkast fra våre egne anmeldelser. null hvis ingen har anmeldt filmen." + averageRating: Float + reviewCount: Int! + genres: [Genre!]! + } + + type MovieSearchResult { + movies: [Movie!]! + "Antall treff totalt, uavhengig av limit/offset" + totalCount: Int! + } + + type Query { + searchMovies( + "Søk i tittel, uavhengig av store/små bokstaver" + search: String + "Filmen må ha minst én av disse sjangrene" + genreIds: [Int!] + yearFrom: Int + yearTo: Int + minRating: Float + sort: MovieSort = POPULARITY_DESC + "1–50" + limit: Int = 20 + offset: Int = 0 + ): MovieSearchResult! + + movie(id: Int!): Movie + } +`; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..5ce9218 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es2024", + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["node"], + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src", "scripts"] +} From 76296c12b64a125137ba5dfcf48846fa3f82c098 Mon Sep 17 00:00:00 2001 From: Akhil Negi Date: Thu, 15 Oct 2026 11:20:31 +0200 Subject: [PATCH 2/3] =?UTF-8?q?refactor(backend):=20Lagrer=20bare=20s?= =?UTF-8?q?=C3=B8kefelt=20i=20DB,=20henter=20detaljer=20fra=20TMDB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Svar på review i #4: movies-tabellen har nå bare feltene som trengs for søk, filtrering og sortering. Beskrivelse, regissør, spilletid, aldersgrense, originaltittel og bakgrunnsbilde hentes fra TMDB med film-ID-en via det nye feltet Movie.details (caches i 24 timer). - Importen bruker bare /discover/movie, ikke ett detaljkall per film - Nytt: users.avatar_url (profilbilde) og tabellen review_likes - Felles TMDB-hjelper i src/tmdb.ts Co-Authored-By: Claude Opus 5.5 --- backend/.env.example | 2 +- backend/README.md | 21 +++-- backend/db/schema.sql | 26 ++++-- backend/scripts/import-movies.ts | 150 ++++++++----------------------- backend/src/resolvers.ts | 21 ++--- backend/src/schema.ts | 24 +++-- backend/src/tmdb.ts | 82 +++++++++++++++++ 7 files changed, 175 insertions(+), 151 deletions(-) create mode 100644 backend/src/tmdb.ts diff --git a/backend/.env.example b/backend/.env.example index 9269676..e61a7a5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,7 +7,7 @@ DATABASE_URL=postgres://localhost:5432/framerate PORT=3001 # TMDB "API Read Access Token" (lang tekst), fra https://www.themoviedb.org/settings/api -# Brukes bare av importskriptet, ikke av serveren. +# Brukes av importskriptet og av serveren (filmdetaljer). Sendes aldri til nettleseren. TMDB_READ_TOKEN= # Hvor mange filmer importskriptet henter (maks 10000) diff --git a/backend/README.md b/backend/README.md index 3722090..96d9359 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,10 @@ # Frame Rate – backend -GraphQL-server (Apollo Server) med egen PostgreSQL-database. Filmene importeres **én gang** fra TMDB med et skript; serveren spør bare vår egen database. +GraphQL-server (Apollo Server) med egen PostgreSQL-database. + +- **Søk, filtrering og sortering** går mot vår egen database. Filmene importeres én gang fra TMDB med et skript, og vi lagrer bare feltene vi trenger til dette (tittel, dato, plakat, popularitet, sjangre). +- **Detaljer** (beskrivelse, regissør, spilletid, aldersgrense) lagres ikke. De hentes fra TMDB med film-ID-en når detaljsiden ber om `details`, slik [TMDB anbefaler](https://developer.themoviedb.org/docs/search-and-query-for-details). Serveren husker svaret i 24 timer. +- **Anmeldelser, likes og lister** er våre egne data og lagres bare i databasen. ## Kom i gang @@ -12,7 +16,7 @@ npm install cp .env.example .env # fyll inn DATABASE_URL og TMDB_READ_TOKEN createdb framerate # lager en tom database (første gang) npm run db:migrate # lager tabellene -npm run import:movies # henter ~5000 filmer fra TMDB (tar noen minutter) +npm run import:movies # henter ~5000 filmer fra TMDB (under ett minutt) npm run dev # starter serveren på http://localhost:3001 ``` @@ -45,9 +49,13 @@ query { } movie(id: 11) { title - director - runtime - ageRating + averageRating + details { # hentes fra TMDB, bruk bare på detaljsiden + overview + director + runtime + ageRating + } } } ``` @@ -58,12 +66,13 @@ Plakater: `https://image.tmdb.org/t/p/w185{posterPath}` (liste) eller `w500` (de ``` backend/ -├── db/schema.sql Tabeller: movies, genres, movie_genres, users, reviews, saved_movies +├── db/schema.sql Tabeller: movies, genres, movie_genres, users, reviews, review_likes, saved_movies ├── scripts/migrate.ts Kjører schema.sql ├── scripts/import-movies.ts Engangsimport fra TMDB └── src/ ├── index.ts Starter serveren ├── schema.ts GraphQL-typene (hva API-et tilbyr) ├── resolvers.ts Henter data fra databasen + ├── tmdb.ts Kall mot TMDB (filmdetaljer og import) └── db.ts Tilkobling til PostgreSQL ``` diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 114e185..69122d8 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -9,23 +9,19 @@ CREATE TABLE IF NOT EXISTS genres ( name TEXT NOT NULL ); +-- Bare feltene vi trenger for å søke, filtrere, sortere og vise filmkort. +-- Detaljer (beskrivelse, regissør, spilletid, aldersgrense ...) hentes fra TMDB +-- med film-ID-en når detaljsiden åpnes, se src/tmdb.ts. CREATE TABLE IF NOT EXISTS movies ( - id INTEGER PRIMARY KEY, -- TMDB sin film-ID, hindrer duplikater ved ny import + id INTEGER PRIMARY KEY, -- TMDB sin film-ID, brukes til å hente detaljer title TEXT NOT NULL, - original_title TEXT, - overview TEXT, release_date DATE, release_year INTEGER GENERATED ALWAYS AS (EXTRACT(YEAR FROM release_date)::INTEGER) STORED, - runtime INTEGER, -- minutter - director TEXT, - age_rating TEXT, -- f.eks. "15" (NO) eller "PG-13" (US) poster_path TEXT, - backdrop_path TEXT, popularity REAL NOT NULL DEFAULT 0, -- Regnes ut fra våre egne anmeldelser, ikke fra TMDB average_rating NUMERIC(3, 2), - review_count INTEGER NOT NULL DEFAULT 0, - imported_at TIMESTAMPTZ NOT NULL DEFAULT now() + review_count INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS movies_title_trgm_idx ON movies USING gin (title gin_trgm_ops); @@ -46,6 +42,7 @@ CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, + avatar_url TEXT, -- profilbilde, null = standardbilde created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); @@ -61,6 +58,17 @@ CREATE TABLE IF NOT EXISTS reviews ( ); CREATE INDEX IF NOT EXISTS reviews_movie_idx ON reviews (movie_id, created_at DESC); +CREATE INDEX IF NOT EXISTS reviews_user_idx ON reviews (user_id, created_at DESC); + +-- "Liker" på anmeldelser. Én like per bruker per anmeldelse. +CREATE TABLE IF NOT EXISTS review_likes ( + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + review_id INTEGER NOT NULL REFERENCES reviews (id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, review_id) +); + +CREATE INDEX IF NOT EXISTS review_likes_review_idx ON review_likes (review_id); DO $$ BEGIN diff --git a/backend/scripts/import-movies.ts b/backend/scripts/import-movies.ts index f20b89c..d9c2dc2 100644 --- a/backend/scripts/import-movies.ts +++ b/backend/scripts/import-movies.ts @@ -1,16 +1,15 @@ // Engangsimport av filmer fra TMDB til vår egen database. // Kjør: npm run import:movies // Kan kjøres på nytt: eksisterende filmer oppdateres, anmeldelser og snitt beholdes. +// +// Vi lagrer bare det vi trenger for søk, filtrering og sortering. Alt dette finnes +// i TMDB sin filmliste (/discover/movie), så vi slipper ett ekstra kall per film. import { pool } from '../src/db.ts'; +import { tmdb } from '../src/tmdb.ts'; -const TMDB_TOKEN = process.env.TMDB_READ_TOKEN; const TARGET = Number(process.env.IMPORT_MOVIE_COUNT ?? 5000); -const CONCURRENCY = 8; // antall detalj-forespørsler samtidig, godt under TMDB sin grense -if (!TMDB_TOKEN) { - throw new Error('TMDB_READ_TOKEN mangler i .env'); -} if (!Number.isInteger(TARGET) || TARGET < 1 || TARGET > 10000) { throw new Error('IMPORT_MOVIE_COUNT må være mellom 1 og 10000'); } @@ -20,49 +19,13 @@ interface TmdbGenre { name: string; } -interface TmdbDetails { +interface TmdbListMovie { id: number; title: string; - original_title: string; - overview: string; release_date: string; - runtime: number | null; poster_path: string | null; - backdrop_path: string | null; popularity: number; - genres: TmdbGenre[]; - credits: { crew: { job: string; name: string }[] }; - release_dates: { results: { iso_3166_1: string; release_dates: { certification: string }[] }[] }; -} - -async function tmdb(path: string, params: Record = {}): Promise { - const url = new URL(`https://api.themoviedb.org/3${path}`); - url.search = new URLSearchParams({ language: 'en-US', ...params }).toString(); - - for (let attempt = 1; ; attempt++) { - const response = await fetch(url, { - headers: { Authorization: `Bearer ${TMDB_TOKEN}`, Accept: 'application/json' }, - }); - if (response.ok) return (await response.json()) as T; - - // 429 = for mange forespørsler; vent og prøv igjen. - if ((response.status === 429 || response.status >= 500) && attempt < 5) { - const waitSeconds = Number(response.headers.get('retry-after')) || attempt * 2; - await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000)); - continue; - } - throw new Error(`TMDB ${response.status} på ${path}: ${await response.text()}`); - } -} - -// Aldersgrense: norsk hvis den finnes, ellers amerikansk. -function findAgeRating(details: TmdbDetails): string | null { - for (const country of ['NO', 'US']) { - const entry = details.release_dates.results.find((r) => r.iso_3166_1 === country); - const certification = entry?.release_dates.find((d) => d.certification)?.certification; - if (certification) return certification; - } - return null; + genre_ids: number[]; } async function importGenres() { @@ -76,56 +39,24 @@ async function importGenres() { console.log(`Sjangre: ${genres.length}`); } -async function collectMovieIds(): Promise { - const ids = new Set(); - for (let page = 1; ids.size < TARGET && page <= 500; page++) { - const data = await tmdb<{ results: { id: number }[]; total_pages: number }>('/discover/movie', { - sort_by: 'popularity.desc', - include_adult: 'false', - include_video: 'false', - 'vote_count.gte': '50', // hopper over obskure filmer med nesten ingen data - page: String(page), - }); - for (const movie of data.results) ids.add(movie.id); - if (page >= data.total_pages) break; - } - return [...ids].slice(0, TARGET); -} - -async function saveMovie(details: TmdbDetails) { - const director = details.credits.crew.find((person) => person.job === 'Director')?.name ?? null; +async function saveMovie(movie: TmdbListMovie) { const client = await pool.connect(); try { await client.query('BEGIN'); await client.query( - `INSERT INTO movies (id, title, original_title, overview, release_date, runtime, director, - age_rating, poster_path, backdrop_path, popularity) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + `INSERT INTO movies (id, title, release_date, poster_path, popularity) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET - title = EXCLUDED.title, original_title = EXCLUDED.original_title, overview = EXCLUDED.overview, - release_date = EXCLUDED.release_date, runtime = EXCLUDED.runtime, director = EXCLUDED.director, - age_rating = EXCLUDED.age_rating, poster_path = EXCLUDED.poster_path, - backdrop_path = EXCLUDED.backdrop_path, popularity = EXCLUDED.popularity, imported_at = now()`, - [ - details.id, - details.title, - details.original_title || null, - details.overview || null, - details.release_date || null, - details.runtime || null, - director, - findAgeRating(details), - details.poster_path, - details.backdrop_path, - details.popularity, - ], + title = EXCLUDED.title, release_date = EXCLUDED.release_date, + poster_path = EXCLUDED.poster_path, popularity = EXCLUDED.popularity`, + [movie.id, movie.title, movie.release_date || null, movie.poster_path, movie.popularity], ); - await client.query('DELETE FROM movie_genres WHERE movie_id = $1', [details.id]); - if (details.genres.length > 0) { + await client.query('DELETE FROM movie_genres WHERE movie_id = $1', [movie.id]); + if (movie.genre_ids.length > 0) { await client.query( `INSERT INTO movie_genres (movie_id, genre_id) SELECT $1, g.id FROM genres g WHERE g.id = ANY($2)`, - [details.id, details.genres.map((genre) => genre.id)], + [movie.id, movie.genre_ids], ); } await client.query('COMMIT'); @@ -137,41 +68,34 @@ async function saveMovie(details: TmdbDetails) { } } -async function importMovies(ids: number[]) { - let done = 0; - let failed = 0; - let next = 0; - - // Noen få "arbeidere" som henter hver sin film parallelt. - async function worker() { - while (next < ids.length) { - const id = ids[next++]; - try { - const details = await tmdb(`/movie/${id}`, { - append_to_response: 'credits,release_dates', - }); - await saveMovie(details); - } catch (error) { - failed++; - console.warn(`Hoppet over film ${id}: ${(error as Error).message}`); - } - done++; - if (done % 250 === 0 || done === ids.length) { - console.log(`Filmer: ${done}/${ids.length}`); - } +async function importMovies(): Promise { + const seen = new Set(); + for (let page = 1; seen.size < TARGET && page <= 500; page++) { + const data = await tmdb<{ results: TmdbListMovie[]; total_pages: number }>('/discover/movie', { + sort_by: 'popularity.desc', + include_adult: 'false', + include_video: 'false', + 'vote_count.gte': '50', // hopper over obskure filmer med nesten ingen data + page: String(page), + }); + for (const movie of data.results) { + // Samme film kan dukke opp på to sider hvis populariteten endrer seg underveis. + if (seen.has(movie.id) || seen.size >= TARGET) continue; + seen.add(movie.id); + await saveMovie(movie); } + if (seen.size % 1000 === 0 || page >= data.total_pages) { + console.log(`Filmer: ${seen.size}/${TARGET}`); + } + if (page >= data.total_pages) break; } - - await Promise.all(Array.from({ length: CONCURRENCY }, worker)); - return failed; + return seen.size; } try { await importGenres(); - const ids = await collectMovieIds(); - console.log(`Fant ${ids.length} filmer, henter detaljer ...`); - const failed = await importMovies(ids); - console.log(`Ferdig. ${ids.length - failed} filmer importert, ${failed} feilet.`); + const count = await importMovies(); + console.log(`Ferdig. ${count} filmer importert.`); } finally { await pool.end(); } diff --git a/backend/src/resolvers.ts b/backend/src/resolvers.ts index 84b8e89..184058f 100644 --- a/backend/src/resolvers.ts +++ b/backend/src/resolvers.ts @@ -1,5 +1,6 @@ import { GraphQLError } from 'graphql'; import { pool } from './db.ts'; +import { getMovieDetails } from './tmdb.ts'; type MovieSort = 'POPULARITY_DESC' | 'RATING_DESC' | 'RELEASE_DESC' | 'RELEASE_ASC' | 'TITLE_ASC'; @@ -17,15 +18,9 @@ interface SearchMoviesArgs { interface MovieRow { id: number; title: string; - original_title: string | null; - overview: string | null; release_date: string | null; release_year: number | null; - runtime: number | null; - director: string | null; - age_rating: string | null; poster_path: string | null; - backdrop_path: string | null; popularity: number; average_rating: number | null; review_count: number; @@ -46,10 +41,9 @@ const ORDER_BY: Record = { // Henter filmens felter + sjangrene som en JSON-liste i samme spørring. const MOVIE_COLUMNS = ` - m.id, m.title, m.original_title, m.overview, + m.id, m.title, to_char(m.release_date, 'YYYY-MM-DD') AS release_date, m.release_year, - m.runtime, m.director, m.age_rating, m.poster_path, m.backdrop_path, - m.popularity, m.average_rating, m.review_count, + m.poster_path, m.popularity, m.average_rating, m.review_count, COALESCE(( SELECT json_agg(json_build_object('id', g.id, 'name', g.name) ORDER BY g.name) FROM movie_genres mg JOIN genres g ON g.id = mg.genre_id @@ -61,15 +55,9 @@ function toMovie(row: MovieRow) { return { id: row.id, title: row.title, - originalTitle: row.original_title, - overview: row.overview, releaseDate: row.release_date, releaseYear: row.release_year, - runtime: row.runtime, - director: row.director, - ageRating: row.age_rating, posterPath: row.poster_path, - backdropPath: row.backdrop_path, popularity: row.popularity, averageRating: row.average_rating, reviewCount: row.review_count, @@ -148,4 +136,7 @@ export const resolvers = { searchMovies: (_parent: unknown, args: SearchMoviesArgs) => searchMovies(args), movie: (_parent: unknown, args: { id: number }) => getMovie(args.id), }, + Movie: { + details: (movie: { id: number }) => getMovieDetails(movie.id), + }, }; diff --git a/backend/src/schema.ts b/backend/src/schema.ts index 2fed61d..f49c9fe 100644 --- a/backend/src/schema.ts +++ b/backend/src/schema.ts @@ -12,26 +12,36 @@ export const typeDefs = `#graphql name: String! } + "Lagret i vår database: det som trengs for søk, filtrering, sortering og filmkort." type Movie { id: Int! title: String! - originalTitle: String - overview: String "Format: YYYY-MM-DD" releaseDate: String releaseYear: Int - "Spilletid i minutter" - runtime: Int - director: String - ageRating: String "Sti til bildet hos TMDB, f.eks. /abc.jpg. Full URL: https://image.tmdb.org/t/p/w500{posterPath}" posterPath: String - backdropPath: String popularity: Float! "Snitt-terningkast fra våre egne anmeldelser. null hvis ingen har anmeldt filmen." averageRating: Float reviewCount: Int! genres: [Genre!]! + """ + Hentes fra TMDB med film-ID-en, bare når feltet etterspørres (detaljsiden). + Ikke be om dette i søkelister, da blir det ett TMDB-kall per film. + """ + details: MovieDetails + } + + type MovieDetails { + originalTitle: String + overview: String + "Spilletid i minutter" + runtime: Int + director: String + "Norsk aldersgrense hvis den finnes, ellers amerikansk" + ageRating: String + backdropPath: String } type MovieSearchResult { diff --git a/backend/src/tmdb.ts b/backend/src/tmdb.ts new file mode 100644 index 0000000..2c51aa7 --- /dev/null +++ b/backend/src/tmdb.ts @@ -0,0 +1,82 @@ +// Kall mot TMDB sitt API. Tokenet brukes bare her på serveren og sendes aldri til nettleseren. + +const TMDB_TOKEN = process.env.TMDB_READ_TOKEN; + +export async function tmdb(path: string, params: Record = {}): Promise { + if (!TMDB_TOKEN) { + throw new Error('TMDB_READ_TOKEN mangler i .env'); + } + + const url = new URL(`https://api.themoviedb.org/3${path}`); + url.search = new URLSearchParams({ language: 'en-US', ...params }).toString(); + + for (let attempt = 1; ; attempt++) { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${TMDB_TOKEN}`, Accept: 'application/json' }, + }); + if (response.ok) return (await response.json()) as T; + + // 429 = for mange forespørsler; vent og prøv igjen. + if ((response.status === 429 || response.status >= 500) && attempt < 5) { + const waitSeconds = Number(response.headers.get('retry-after')) || attempt * 2; + await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000)); + continue; + } + throw new Error(`TMDB ${response.status} på ${path}: ${await response.text()}`); + } +} + +interface TmdbMovieDetails { + original_title: string; + overview: string; + runtime: number | null; + backdrop_path: string | null; + credits: { crew: { job: string; name: string }[] }; + release_dates: { results: { iso_3166_1: string; release_dates: { certification: string }[] }[] }; +} + +export interface MovieDetails { + originalTitle: string | null; + overview: string | null; + runtime: number | null; + director: string | null; + ageRating: string | null; + backdropPath: string | null; +} + +// Aldersgrense: norsk hvis den finnes, ellers amerikansk. +function findAgeRating(details: TmdbMovieDetails): string | null { + for (const country of ['NO', 'US']) { + const entry = details.release_dates.results.find((r) => r.iso_3166_1 === country); + const certification = entry?.release_dates.find((d) => d.certification)?.certification; + if (certification) return certification; + } + return null; +} + +// Husker detaljene en stund, så samme film ikke hentes fra TMDB hver gang noen åpner den. +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const CACHE_MAX_ENTRIES = 1000; +const cache = new Map(); + +export async function getMovieDetails(id: number): Promise { + const cached = cache.get(id); + if (cached && cached.expires > Date.now()) return cached.details; + + // append_to_response henter regissør og aldersgrense i samme kall. + const data = await tmdb(`/movie/${id}`, { append_to_response: 'credits,release_dates' }); + const details: MovieDetails = { + originalTitle: data.original_title || null, + overview: data.overview || null, + runtime: data.runtime || null, + director: data.credits.crew.find((person) => person.job === 'Director')?.name ?? null, + ageRating: findAgeRating(data), + backdropPath: data.backdrop_path, + }; + + if (cache.size >= CACHE_MAX_ENTRIES) { + cache.delete(cache.keys().next().value!); // fjerner den eldste + } + cache.set(id, { details, expires: Date.now() + CACHE_TTL_MS }); + return details; +} From c9e32fa303d27ca1dd312aaa5c27a02d49b00a59 Mon Sep 17 00:00:00 2001 From: Akhil Negi Date: Thu, 15 Oct 2026 11:38:32 +0200 Subject: [PATCH 3/3] docs: AI-deklarasjon for PR #4 Co-Authored-By: Claude Opus 5.5 --- docs/AI_Deklarasjon/AI_Deklarasjon.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/AI_Deklarasjon/AI_Deklarasjon.md b/docs/AI_Deklarasjon/AI_Deklarasjon.md index e69de29..cbb34a1 100644 --- a/docs/AI_Deklarasjon/AI_Deklarasjon.md +++ b/docs/AI_Deklarasjon/AI_Deklarasjon.md @@ -0,0 +1,25 @@ +# AI-deklarasjon + +Her dokumenterer vi bruk av KI-verktøy i prosjektet, med én del per pull request. + +## PR #4 – Backend-oppsett (`feat/backend-setup`) + +**Verktøy:** Claude (Anthropic) via Claude Code +**Brukt av:** Akhil + +### Hva KI ble brukt til + +- Forslag til og skriving av databaseskjemaet (`backend/db/schema.sql`) +- Oppsett av GraphQL-serveren med Apollo Server: typer (`src/schema.ts`) og resolvere for søk, filtrering, sortering og paginering (`src/resolvers.ts`) +- Skript for å opprette tabeller (`scripts/migrate.ts`) og importere filmer fra TMDB (`scripts/import-movies.ts`) +- Henting av filmdetaljer fra TMDB med caching (`src/tmdb.ts`) +- `backend/README.md` og `.env.example` +- Testing av koden mot en lokal testdatabase (PGlite) med falske TMDB-svar +- Endringer etter review: fjerne detaljfelter fra databasen, og legge til profilbilde og likes på anmeldelser +- Forklaringer av databasen og koden underveis + +### Hva vi har gjort selv + +- Bestemt arkitektur og datamodell i planleggingsdokumentet: hvilke funksjoner appen skal ha, hvilke data som trengs, og at filmene importeres fra TMDB én gang til vår egen database +- Sammenlignet databasen med planleggingsdokumentet +- Kodegjennomgang (review) av et annet gruppemedlem, som førte til endringer i hvilke felter som lagres