diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index c7679af..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e72415 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +.DS_Store +backend/data/ diff --git a/README.md b/README.md index 1228699..757c264 100644 --- a/README.md +++ b/README.md @@ -456,3 +456,38 @@ Dette kan blant annet innebære: - optimalisere bilder - unngå unødvendig tunge biblioteker - gjenbruke komponenter + +# Lagt ttil av Sofie +## Database og backend på VM + +PostgreSQL 18 er installert direkte på VM-en (it2810-01.idi.ntnu.no) med apt, uten Docker. Databasen lytter kun på localhost og er ikke tilgjengelig utenfra. + +- Kode: `/srv/winenot` (klonet fra dette repoet, branch `dev`) +- Database: `winenot`, eid av brukeren `winenot_app` +- Tilkoblingsinfo og passord: `/srv/winenot/backend/.env` (kun lesbar med sudo). Se `backend/.env.example` for hvilke variabler som trengs. +- Tabeller opprettes med migrasjonene i `backend/db/migrations/`, kjørt i nummerert rekkefølge: + `psql -h localhost -U winenot_app -d winenot -v ON_ERROR_STOP=1 -f backend/db/migrations/.sql` + +### Starte backend + +```bash +cd /srv/winenot/backend +npm ci +npm start +``` + +Backend kjører på port 3001. + +### Importere data (X-Wines) + +1. Last ned `XWines_Full_100K_wines.csv` og `XWines_Full_21M_ratings.csv` fra Google Drive-lenken i Dataset-mappen på https://github.com/rogerioxavier/X-Wines +2. Kopier filene til `/srv/winenot/backend/data/` (mappen ligger i `.gitignore`) +3. Kjør migrasjonene i `backend/db/migrations/` som ikke er kjørt, og deretter: +```bash + cd /srv/winenot/backend + npm ci + npm run import -- data/XWines_Full_100K_wines.csv data/XWines_Full_21M_ratings.csv +``` +Skriptet kan kjøres flere ganger, og favoritter beholdes. Uten argumenter bruker det testfilene (100 viner), som er nok til lokal utvikling. + +Datasett: de Azambuja, R. X., Morais, A. J., & Filipe, V. (2023). X-Wines: A Wine Dataset for Recommender Systems and Machine Learning. *Big Data and Cognitive Computing*, 7(1), 20. https://doi.org/10.3390/bdcc7010020 \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d6d8dfd --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,6 @@ +# Copy to .env and fill in your own values. .env must never be committed. +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=winenot +DB_USER=your_postgres_user +DB_PASSWORD= diff --git a/backend/db/migrations/001_create_tables.sql b/backend/db/migrations/001_create_tables.sql new file mode 100644 index 0000000..eb8a0d0 --- /dev/null +++ b/backend/db/migrations/001_create_tables.sql @@ -0,0 +1,103 @@ +-- 001_create_tables.sql +-- Wine not? – tabeller og typer for PostgreSQL +-- Kjør med: psql -d winenot -v ON_ERROR_STOP=1 -f backend/db/migrations/001_create_tables.sql +-- En migrasjon som er merget, endres ikke. Nye endringer legges i en ny, nummerert fil. + +BEGIN; + +-- ---------- Typer ---------- +-- Verdiene er de samme som i X-Wines, og rekkefølgen her bestemmer sorteringen. + +CREATE TYPE body_level AS ENUM ( + 'Very light-bodied', + 'Light-bodied', + 'Medium-bodied', + 'Full-bodied', + 'Very full-bodied' +); + +CREATE TYPE acidity_level AS ENUM ( + 'Low', + 'Medium', + 'High' +); + +-- ---------- Oppslagstabeller ---------- + +CREATE TABLE countries ( + code text PRIMARY KEY, -- Code fra X-Wines, f.eks. 'FR' + name text NOT NULL UNIQUE +); + +CREATE TABLE regions ( + id integer PRIMARY KEY, -- RegionID fra X-Wines + name text NOT NULL, + country_code text NOT NULL REFERENCES countries(code) +); + +CREATE TABLE wineries ( + id integer PRIMARY KEY, -- WineryID fra X-Wines + name text NOT NULL, + website text +); + +CREATE TABLE grapes ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE +); + +CREATE TABLE foods ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE -- verdier fra Harmonize, f.eks. 'Beef', 'Rich Fish' +); + +-- ---------- Viner ---------- + +CREATE TABLE wines ( + id integer PRIMARY KEY, -- WineID fra X-Wines + name text NOT NULL, + type text NOT NULL, -- Red, White, Rosé, Sparkling, Dessert, Dessert/Port + elaborate text, -- f.eks. 'Varietal/100%', 'Assemblage/Blend' + abv numeric(4,1), -- alkoholprosent + body body_level, + acidity acidity_level, + winery_id integer NOT NULL REFERENCES wineries(id), + region_id integer NOT NULL REFERENCES regions(id), + country_code text NOT NULL REFERENCES countries(code), -- bevisst duplisert fra regions: sparer en join ved landfilter + vintages integer[] NOT NULL DEFAULT '{}', -- 'N.V.' hoppes over i importskriptet + avg_rating numeric(3,2), -- forhåndsberegnet i importskriptet + rating_count integer NOT NULL DEFAULT 0 +); + +-- ---------- Mange-til-mange ---------- + +CREATE TABLE wine_grapes ( + wine_id integer NOT NULL REFERENCES wines(id) ON DELETE CASCADE, + grape_id integer NOT NULL REFERENCES grapes(id) ON DELETE CASCADE, + PRIMARY KEY (wine_id, grape_id) +); + +CREATE TABLE wine_foods ( + wine_id integer NOT NULL REFERENCES wines(id) ON DELETE CASCADE, + food_id integer NOT NULL REFERENCES foods(id) ON DELETE CASCADE, + PRIMARY KEY (wine_id, food_id) +); + +-- ---------- Brukere og favoritter (brukergenerert data) ---------- + +CREATE TABLE users ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + username text NOT NULL UNIQUE, + email text NOT NULL UNIQUE, + password_hash text NOT NULL, -- aldri klartekst; hashes med bcrypt/argon2 i backend + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE favorites ( + user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, + wine_id integer NOT NULL REFERENCES wines(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, wine_id) -- hindrer duplikater og dekker "mine favoritter" +); + +COMMIT; diff --git a/backend/db/migrations/002_create_indexes.sql b/backend/db/migrations/002_create_indexes.sql new file mode 100644 index 0000000..e9bca08 --- /dev/null +++ b/backend/db/migrations/002_create_indexes.sql @@ -0,0 +1,31 @@ +-- 002_create_indexes.sql +-- Wine not? – indekser for søk, filtrering og sortering +-- Kjør med: psql -d winenot -v ON_ERROR_STOP=1 -f backend/db/migrations/002_create_indexes.sql + +BEGIN; + +CREATE EXTENSION IF NOT EXISTS pg_trgm; -- raskt delstreng-søk på vinnavn + +-- Søk: WHERE name ILIKE '%tekst%' +CREATE INDEX wines_name_trgm_idx ON wines USING gin (name gin_trgm_ops); + +-- Filtre +CREATE INDEX wines_type_idx ON wines (type); +CREATE INDEX wines_country_idx ON wines (country_code); +CREATE INDEX wines_region_idx ON wines (region_id); +CREATE INDEX wines_winery_idx ON wines (winery_id); +CREATE INDEX wines_taste_idx ON wines (body, acidity); -- Explore by taste +CREATE INDEX wines_vintages_idx ON wines USING gin (vintages); -- WHERE vintages @> ARRAY[2018] + +-- Sortering (id som siste ledd gir stabil rekkefølge ved paginering) +CREATE INDEX wines_name_sort_idx ON wines (name, id); +CREATE INDEX wines_rating_idx ON wines (avg_rating DESC NULLS LAST, id); +CREATE INDEX wines_abv_idx ON wines (abv, id); + +-- Primærnøklene dekker oppslag fra vin/bruker. Disse dekker motsatt vei. +CREATE INDEX wine_grapes_grape_idx ON wine_grapes (grape_id); +CREATE INDEX wine_foods_food_idx ON wine_foods (food_id); +CREATE INDEX favorites_wine_idx ON favorites (wine_id); +CREATE INDEX regions_country_idx ON regions (country_code); + +COMMIT; diff --git a/backend/db/migrations/003_add_food_category.sql b/backend/db/migrations/003_add_food_category.sql new file mode 100644 index 0000000..d9e9e1d --- /dev/null +++ b/backend/db/migrations/003_add_food_category.sql @@ -0,0 +1,14 @@ +-- 003_add_food_category.sql +-- Wine not? – grovere matkategorier til Find wine by food +-- Kjør med: psql -d winenot -v ON_ERROR_STOP=1 -f backend/db/migrations/003_add_food_category.sql +-- +-- Hver matverdi fra X-Wines (f.eks. 'Rich Fish', 'Shellfish') får én kategori (f.eks. 'Fish & seafood'). +-- Selve grupperingen ligger i db/seeds/food_categories.sql og kjøres av importskriptet. + +BEGIN; + +ALTER TABLE foods ADD COLUMN category text; + +CREATE INDEX foods_category_idx ON foods (category); + +COMMIT; diff --git a/backend/db/seeds/food_categories.sql b/backend/db/seeds/food_categories.sql new file mode 100644 index 0000000..96361c1 --- /dev/null +++ b/backend/db/seeds/food_categories.sql @@ -0,0 +1,76 @@ +-- food_categories.sql +-- Grupperer matverdiene fra X-Wines (kolonnen Harmonize) i kategorier for Find wine by food. +-- +-- Kjøres automatisk av importskriptet etter at maten er satt inn, og kan også kjøres alene: +-- psql -d winenot -1 -v ON_ERROR_STOP=1 -f backend/db/seeds/food_categories.sql +-- (-1 kjører hele filen som én transaksjon. Filen har ikke egen BEGIN/COMMIT, +-- fordi importskriptet kjører den inne i sin egen transaksjon.) +-- +-- Endre en kategori: bytt navnet i høyre kolonne og kjør filen på nytt. +-- Ny matverdi uten kategori: importskriptet lister dem opp. Legg dem til her. +-- Matverdier som ikke står her, får category = NULL og vises ikke i Find wine by food. + +UPDATE foods SET category = NULL; + +UPDATE foods AS f +SET category = m.category +FROM (VALUES + -- Kjøtt + ('Beef', 'Meat'), + ('Lamb', 'Meat'), + ('Veal', 'Meat'), + ('Pork', 'Meat'), + ('Game Meat', 'Meat'), + ('Barbecue', 'Meat'), + ('Grilled', 'Meat'), + + -- Fjærkre + ('Poultry', 'Poultry'), + ('Chicken', 'Poultry'), + + -- Fisk og sjømat + ('Fish', 'Fish & seafood'), + ('Rich Fish', 'Fish & seafood'), + ('Lean Fish', 'Fish & seafood'), + ('Seafood', 'Fish & seafood'), + ('Shellfish', 'Fish & seafood'), + + -- Ost + ('Maturated Cheese', 'Cheese'), + ('Soft Cheese', 'Cheese'), + ('Hard Cheese', 'Cheese'), + ('Blue Cheese', 'Cheese'), + ('Goat Cheese', 'Cheese'), + + -- Pasta, pizza og gryter + ('Pasta', 'Pasta, pizza & stews'), + ('Pizza', 'Pasta, pizza & stews'), + ('Risotto', 'Pasta, pizza & stews'), + ('Tomato Dishes', 'Pasta, pizza & stews'), + ('Light Stews', 'Pasta, pizza & stews'), + + -- Vegetar + ('Vegetarian', 'Vegetarian'), + ('Salad', 'Vegetarian'), + ('Mushrooms', 'Vegetarian'), + + -- Sterk mat + ('Spicy Food', 'Spicy food'), + + -- Småretter og aperitiff + ('Appetizer', 'Snacks & starters'), + ('Snack', 'Snacks & starters'), + ('Aperitif', 'Snacks & starters'), + ('Cured Meat', 'Snacks & starters'), + ('Cold Cuts', 'Snacks & starters'), + ('French Fries', 'Snacks & starters'), + + -- Dessert + ('Sweet Dessert', 'Dessert'), + ('Fruit Dessert', 'Dessert'), + ('Cake', 'Dessert'), + ('Chocolate', 'Dessert'), + ('Fruit', 'Dessert'), + ('Cream', 'Dessert') +) AS m(food, category) +WHERE f.name = m.food; diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..1e0229a --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,2464 @@ +{ + "name": "backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backend", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@apollo/server": "^5.5.1", + "csv-parse": "^7.0.3", + "dotenv": "^18.0.6", + "graphql": "^16.14.2", + "pg": "^8.23.1" + }, + "devDependencies": { + "@types/node": "^26.6.4", + "@types/pg": "^8.23.1", + "tsx": "^4.23.15", + "typescript": "^7.0.2" + } + }, + "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/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@graphql-tools/merge": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.2.6.tgz", + "integrity": "sha512-M7iI/qoCnks6iITkimtdZgI2SrWOInr+QRWuhEsInaumzQGPd0pnWbRAVTRVcFN2Gu9hM2aS5kYzASC93d9epw==", + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^12.0.3", + "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.3", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.1.3.tgz", + "integrity": "sha512-N1ZpzguxzqTy5fpurqWQ5BXyjirlrtvVLeSafsu+SCDNjDeRnVxf9n5PVbdgzwFPAmP/7NK9sq9UB/kyR8oFiw==", + "license": "MIT", + "dependencies": { + "@graphql-tools/merge": "^9.2.6", + "@graphql-tools/utils": "^12.0.3", + "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.3", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.3.tgz", + "integrity": "sha512-M+04bHvI1SKoug7W2EkwfKjig2mdtgIIlFY8p2jLHmxPNVs5bC/3R+58JrD+7ZXWgPILWTwuqJ9AqTjPlahroA==", + "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": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "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/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.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/csv-parse": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.3.tgz", + "integrity": "sha512-YFd3QM/yo17vH91L1IOZuvl09zM0zEEtdfTcOCKWcMdM++MNaQSyp09slXyFCLaPXvHstQFx/xC8myiFHSMUvw==", + "license": "MIT" + }, + "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/dotenv": { + "version": "18.0.6", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.6.tgz", + "integrity": "sha512-ZMVmohV6CvAJtj++pZNyggluUt6hbRdQAxc2+Z635RL6/AG/ruXv5lHnybUFoxMPL/yrTL/tsF4paZu2l7RA6Q==", + "license": "BSD-2-Clause", + "bin": { + "dotenv": "dist/index.cjs" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "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/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "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/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "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": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "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..1abc594 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,29 @@ +{ + "name": "backend", + "version": "1.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "import": "tsx scripts/import.ts" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@apollo/server": "^5.5.1", + "csv-parse": "^7.0.3", + "dotenv": "^18.0.6", + "graphql": "^16.14.2", + "pg": "^8.23.1" + }, + "devDependencies": { + "@types/node": "^26.6.4", + "@types/pg": "^8.23.1", + "tsx": "^4.23.15", + "typescript": "^7.0.2" + } +} diff --git a/backend/scripts/import.ts b/backend/scripts/import.ts new file mode 100644 index 0000000..bfacfe3 --- /dev/null +++ b/backend/scripts/import.ts @@ -0,0 +1,401 @@ +// Importerer X-Wines-datasettet til PostgreSQL. +// +// Bruk (fra backend/): +// npm run import testfilene i data/ +// npm run import -- f.eks. full versjon +// +// Skriptet kan kjøres flere ganger. Viner oppdateres, og favoritter beholdes. + +import { createReadStream, readFileSync } from "node:fs"; +import { parse } from "csv-parse"; +import type { PoolClient } from "pg"; +import { pool } from "../src/db.js"; + +const WINES_FILE = process.argv[2] ?? "data/XWines_Test_100_wines.csv"; +const RATINGS_FILE = process.argv[3] ?? "data/XWines_Test_1K_ratings.csv"; +const BATCH_SIZE = 1000; +const FOOD_CATEGORIES_FILE = "db/seeds/food_categories.sql"; +const CURRENT_YEAR = new Date().getFullYear(); + +const BODY_LEVELS = new Set([ + "Very light-bodied", + "Light-bodied", + "Medium-bodied", + "Full-bodied", + "Very full-bodied", +]); +const ACIDITY_LEVELS = new Set(["Low", "Medium", "High"]); + +type CsvRow = Record; + +interface Wine { + id: number; + name: string; + type: string; + elaborate: string | null; + abv: number | null; + body: string | null; + acidity: string | null; + wineryId: number; + wineryName: string; + website: string | null; + regionId: number; + regionName: string; + countryCode: string; + countryName: string; + grapes: string[]; + foods: string[]; + vintages: number[]; +} + +type Validation = { ok: true; wine: Wine; warnings: string[] } | { ok: false; reason: string }; + +// ---------- Hjelpefunksjoner for rensing ---------- + +function text(value: string | undefined): string { + return (value ?? "").trim(); +} + +function orNull(value: string): string | null { + return value === "" ? null : value; +} + +function toId(value: string | undefined): number | null { + const n = Number(text(value)); + return Number.isInteger(n) && n > 0 ? n : null; +} + +// "['Merlot', \"Nero d'Avola\"]" -> ["Merlot", "Nero d'Avola"] +function parseList(value: string): string[] { + const items: string[] = []; + for (const match of value.matchAll(/'([^']*)'|"([^"]*)"/g)) { + const item = (match[1] ?? match[2] ?? "").trim(); + if (item !== "") items.push(item); + } + return [...new Set(items)]; +} + +// "[2020, 2019, 'N.V.']" -> [2020, 2019] +function parseVintages(value: string): number[] { + const years = (value.match(/\d{4}/g) ?? []) + .map(Number) + .filter((year) => year >= 1800 && year <= CURRENT_YEAR); + return [...new Set(years)].sort((a, b) => b - a); +} + +// ---------- Validering av én vinrad ---------- + +function validateWine(row: CsvRow): Validation { + const fail = (reason: string): Validation => ({ ok: false, reason }); + const warnings: string[] = []; + + const id = toId(row.WineID); + if (id === null) return fail("ugyldig WineID"); + + const name = text(row.WineName); + if (name === "") return fail("mangler WineName"); + + const type = text(row.Type); + if (type === "") return fail("mangler Type"); + + const wineryId = toId(row.WineryID); + const wineryName = text(row.WineryName); + if (wineryId === null || wineryName === "") return fail("mangler produsent"); + + const regionId = toId(row.RegionID); + const regionName = text(row.RegionName); + if (regionId === null || regionName === "") return fail("mangler region"); + + const countryCode = text(row.Code).toUpperCase(); + const countryName = text(row.Country); + if (countryCode === "" || countryName === "") return fail("mangler land"); + + // Valgfrie felt: ugyldig verdi blir NULL, vinen beholdes. + const abvText = text(row.ABV); + const abvNumber = Number(abvText); + let abv: number | null = null; + if (abvText !== "") { + if (Number.isFinite(abvNumber) && abvNumber > 0 && abvNumber <= 50) { + abv = Math.round(abvNumber * 10) / 10; + } else { + warnings.push("ugyldig ABV"); + } + } + + const bodyText = text(row.Body); + const body = BODY_LEVELS.has(bodyText) ? bodyText : null; + if (bodyText !== "" && body === null) warnings.push("ugyldig Body"); + + const acidityText = text(row.Acidity); + const acidity = ACIDITY_LEVELS.has(acidityText) ? acidityText : null; + if (acidityText !== "" && acidity === null) warnings.push("ugyldig Acidity"); + + return { + ok: true, + warnings, + wine: { + id, + name, + type, + elaborate: orNull(text(row.Elaborate)), + abv, + body, + acidity, + wineryId, + wineryName, + website: orNull(text(row.Website)), + regionId, + regionName, + countryCode, + countryName, + grapes: parseList(text(row.Grapes)), + foods: parseList(text(row.Harmonize)), + vintages: parseVintages(text(row.Vintages)), + }, + }; +} + +// ---------- Lesing av filene ---------- + +function countReason(map: Map, reason: string) { + map.set(reason, (map.get(reason) ?? 0) + 1); +} + +async function readWines() { + const wines: Wine[] = []; + const seenIds = new Set(); + const skipped = new Map(); + const warnings = new Map(); + const examples: string[] = []; + let line = 1; // linje 1 er overskriften + + const parser = createReadStream(WINES_FILE).pipe(parse({ columns: true, relax_column_count: true })); + for await (const row of parser as AsyncIterable) { + line++; + let result = validateWine(row); + if (result.ok && seenIds.has(result.wine.id)) result = { ok: false, reason: "duplikat WineID" }; + + if (!result.ok) { + countReason(skipped, result.reason); + if (examples.length < 10) examples.push(`linje ${line}: ${result.reason} (WineID=${text(row.WineID) || "tom"})`); + continue; + } + + for (const warning of result.warnings) countReason(warnings, warning); + seenIds.add(result.wine.id); + wines.push(result.wine); + } + + return { wines, skipped, warnings, examples }; +} + +async function readRatings(wineIds: Set) { + const totals = new Map(); + const skipped = new Map(); + let read = 0; + + const parser = createReadStream(RATINGS_FILE).pipe(parse({ columns: true, relax_column_count: true })); + for await (const row of parser as AsyncIterable) { + read++; + if (read % 1_000_000 === 0) console.log(` ... ${read.toLocaleString("nb-NO")} vurderinger lest`); + + const wineId = toId(row.WineID); + const rating = Number(text(row.Rating)); + + if (wineId === null || !wineIds.has(wineId)) { + countReason(skipped, "ukjent vin"); + continue; + } + if (!Number.isFinite(rating) || rating < 1 || rating > 5) { + countReason(skipped, "ugyldig rating"); + continue; + } + + const total = totals.get(wineId) ?? { sum: 0, count: 0 }; + total.sum += rating; + total.count++; + totals.set(wineId, total); + } + + return { totals, skipped, read }; +} + +// ---------- Skriving til databasen ---------- + +// Sender radene som JSON i bolker på BATCH_SIZE, i stedet for én spørring per rad. +async function insertInBatches(client: PoolClient, sql: string, rows: object[]) { + for (let i = 0; i < rows.length; i += BATCH_SIZE) { + await client.query(sql, [JSON.stringify(rows.slice(i, i + BATCH_SIZE))]); + } +} + +function uniqueBy(items: T[], key: (item: T) => string | number): T[] { + const map = new Map(); + for (const item of items) if (!map.has(key(item))) map.set(key(item), item); + return [...map.values()]; +} + +async function writeToDatabase(client: PoolClient, wines: Wine[], totals: Map) { + // 1. Oppslagstabellene, i den rekkefølgen fremmednøklene krever + await insertInBatches( + client, + `INSERT INTO countries (code, name) + SELECT code, name FROM jsonb_to_recordset($1::jsonb) AS x(code text, name text) + ON CONFLICT DO NOTHING`, + uniqueBy(wines, (w) => w.countryCode).map((w) => ({ code: w.countryCode, name: w.countryName })) + ); + + await insertInBatches( + client, + `INSERT INTO regions (id, name, country_code) + SELECT id, name, country_code FROM jsonb_to_recordset($1::jsonb) AS x(id int, name text, country_code text) + ON CONFLICT DO NOTHING`, + uniqueBy(wines, (w) => w.regionId).map((w) => ({ id: w.regionId, name: w.regionName, country_code: w.countryCode })) + ); + + await insertInBatches( + client, + `INSERT INTO wineries (id, name, website) + SELECT id, name, website FROM jsonb_to_recordset($1::jsonb) AS x(id int, name text, website text) + ON CONFLICT DO NOTHING`, + uniqueBy(wines, (w) => w.wineryId).map((w) => ({ id: w.wineryId, name: w.wineryName, website: w.website })) + ); + + // 2. Vinene. ON CONFLICT DO UPDATE gjør at en ny kjøring oppdaterer vinene + // i stedet for å slette dem, slik at favoritter ikke forsvinner. + await insertInBatches( + client, + `INSERT INTO wines (id, name, type, elaborate, abv, body, acidity, winery_id, region_id, country_code, vintages) + SELECT id, name, type, elaborate, abv, body, acidity, winery_id, region_id, country_code, vintages + FROM jsonb_to_recordset($1::jsonb) AS x( + id int, name text, type text, elaborate text, abv numeric, body body_level, acidity acidity_level, + winery_id int, region_id int, country_code text, vintages int[]) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, type = EXCLUDED.type, elaborate = EXCLUDED.elaborate, abv = EXCLUDED.abv, + body = EXCLUDED.body, acidity = EXCLUDED.acidity, winery_id = EXCLUDED.winery_id, + region_id = EXCLUDED.region_id, country_code = EXCLUDED.country_code, vintages = EXCLUDED.vintages`, + wines.map((w) => ({ + id: w.id, + name: w.name, + type: w.type, + elaborate: w.elaborate, + abv: w.abv, + body: w.body, + acidity: w.acidity, + winery_id: w.wineryId, + region_id: w.regionId, + country_code: w.countryCode, + vintages: w.vintages, + })) + ); + + // 3. Druer og mat, og koblingene til vinene + const grapeLinks = wines.flatMap((w) => w.grapes.map((name) => ({ wine_id: w.id, name }))); + const foodLinks = wines.flatMap((w) => w.foods.map((name) => ({ wine_id: w.id, name }))); + + await insertInBatches( + client, + `INSERT INTO grapes (name) SELECT name FROM jsonb_to_recordset($1::jsonb) AS x(name text) ON CONFLICT DO NOTHING`, + uniqueBy(grapeLinks, (l) => l.name).map((l) => ({ name: l.name })) + ); + await insertInBatches( + client, + `INSERT INTO foods (name) SELECT name FROM jsonb_to_recordset($1::jsonb) AS x(name text) ON CONFLICT DO NOTHING`, + uniqueBy(foodLinks, (l) => l.name).map((l) => ({ name: l.name })) + ); + + await insertInBatches( + client, + `INSERT INTO wine_grapes (wine_id, grape_id) + SELECT x.wine_id, g.id FROM jsonb_to_recordset($1::jsonb) AS x(wine_id int, name text) + JOIN grapes g ON g.name = x.name + ON CONFLICT DO NOTHING`, + grapeLinks + ); + await insertInBatches( + client, + `INSERT INTO wine_foods (wine_id, food_id) + SELECT x.wine_id, f.id FROM jsonb_to_recordset($1::jsonb) AS x(wine_id int, name text) + JOIN foods f ON f.name = x.name + ON CONFLICT DO NOTHING`, + foodLinks + ); + + // 4. Rating: snitt og antall per vin + await insertInBatches( + client, + `UPDATE wines AS w + SET avg_rating = round(x.sum / x.count, 2), rating_count = x.count + FROM jsonb_to_recordset($1::jsonb) AS x(id int, sum numeric, count int) + WHERE w.id = x.id`, + [...totals].map(([id, t]) => ({ id, sum: t.sum, count: t.count })) + ); + + // 5. Matkategorier: grupperingen ligger i en egen SQL-fil, så den er lett å endre + await client.query(readFileSync(FOOD_CATEGORIES_FILE, "utf8")); + + return { grapeLinks: grapeLinks.length, foodLinks: foodLinks.length }; +} + +// ---------- Oppsummering ---------- + +function printCounts(title: string, map: Map) { + if (map.size === 0) return; + console.log(title); + for (const [reason, n] of map) console.log(` ${reason}: ${n}`); +} + +async function main() { + const started = Date.now(); + + console.log(`Leser viner fra ${WINES_FILE}`); + const { wines, skipped, warnings, examples } = await readWines(); + console.log(` ${wines.length} gyldige viner`); + + console.log(`Leser vurderinger fra ${RATINGS_FILE}`); + const ratings = await readRatings(new Set(wines.map((w) => w.id))); + console.log(` ${ratings.read} vurderinger lest, ${ratings.totals.size} viner har rating`); + + console.log("Skriver til databasen"); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const links = await writeToDatabase(client, wines, ratings.totals); + await client.query("COMMIT"); + console.log(` ${links.grapeLinks} drue-koblinger og ${links.foodLinks} mat-koblinger`); + } catch (error) { + await client.query("ROLLBACK"); + console.error("Importen feilet, ingenting ble lagret."); + throw error; + } finally { + client.release(); + } + + console.log(""); + printCounts("Viner som ble hoppet over:", skipped); + if (examples.length > 0) { + console.log("Eksempler:"); + for (const example of examples) console.log(` ${example}`); + } + printCounts("Felt satt til NULL på grunn av ugyldig verdi:", warnings); + printCounts("Vurderinger som ble hoppet over:", ratings.skipped); + + const uncategorized = await pool.query( + `SELECT f.name, count(*)::int AS wines + FROM foods f JOIN wine_foods wf ON wf.food_id = f.id + WHERE f.category IS NULL + GROUP BY f.name ORDER BY wines DESC` + ); + if (uncategorized.rows.length > 0) { + console.log(`Mat uten kategori (legg dem til i ${FOOD_CATEGORIES_FILE}):`); + for (const row of uncategorized.rows) console.log(` ${row.name}: ${row.wines} viner`); + } + console.log(`Ferdig på ${((Date.now() - started) / 1000).toFixed(1)} s`); +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => pool.end()); \ No newline at end of file diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..46ab7b4 --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,12 @@ +import pg from "pg"; +import "dotenv/config"; + +const { Pool } = pg; + +export const pool = new Pool({ + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT ?? 5432), + database: process.env.DB_NAME, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, +}); \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..8e06108 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,270 @@ +import { ApolloServer } from "@apollo/server"; +import { startStandaloneServer } from "@apollo/server/standalone"; +import {GraphQLError} from "graphql"; +import { pool } from "./db.js"; + +const MAX_LIMIT = 100; + +function readPaging(args: { limit?: number; offset?: number }) { + const limit = args.limit ?? 20; + const offset = args.offset ?? 0; + + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) { + throw new GraphQLError(`limit må være mellom 1 og ${MAX_LIMIT}`, { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + if (!Number.isInteger(offset) || offset < 0) { + throw new GraphQLError("offset kan ikke være negativ", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + return { limit, offset }; +} + +const typeDefs = `#graphql + + type Wine { + id: ID! + name: String! + type: String! + abv: Float + body: String + acidity: String + winery: String! + region: String! + country: String! + avgRating: Float + ratingCount: Int! + } + + type WineDetails { + id: ID! + name: String! + type: String! + elaborate: String + abv: Float + body: String + acidity: String + winery: String! + wineryWebsite: String + region: String! + country: String! + vintages: [Int!]! + grapes: [String!]! + foodPairings: [String!]! + avgRating: Float + ratingCount: Int! + } + + type WinePage { + items: [Wine!]! + totalCount: Int! + } + + type FoodCategory { + name: String! + wineCount: Int! + } + + type Query { + health: String! + + wines( + limit: Int = 20 + offset: Int = 0 + ): WinePage! + + wine(id: ID!): WineDetails + + foodCategories: [FoodCategory!]! + + winesByFoodCategory( + category: String! + limit: Int = 20 + offset: Int = 0 + ): WinePage! + } +`; + +const resolvers = { + Query: { + health: () => "OK", + + wines: async ( + _: unknown, + args: { limit?: number; offset?: number } + ) => { + const { limit, offset } = readPaging(args); + + const winesResult = await pool.query( + ` + SELECT + w.id, + w.name, + w.type, + w.abv, + w.body, + w.acidity, + wi.name AS winery, + r.name AS region, + c.name AS country, + w.avg_rating AS "avgRating", + w.rating_count AS "ratingCount" + FROM wines w + JOIN wineries wi ON w.winery_id = wi.id + JOIN regions r ON w.region_id = r.id + JOIN countries c ON w.country_code = c.code + ORDER BY w.name, w.id + LIMIT $1 + OFFSET $2 + `, + [limit, offset] + ); + + const countResult = await pool.query( + `SELECT COUNT(*) FROM wines` + ); + + return { + items: winesResult.rows, + totalCount: Number(countResult.rows[0].count), + }; + }, + + wine: async ( + _: unknown, + args: { id: string } +) => { + const wineId = Number(args.id); + + if (!Number.isInteger(wineId)) { + return null; + } + + const result = await pool.query( + ` + SELECT + w.id, + w.name, + w.type, + w.elaborate, + w.abv::float AS abv, + w.body, + w.acidity, + wi.name AS winery, + wi.website AS "wineryWebsite", + r.name AS region, + c.name AS country, + w.vintages, + w.avg_rating::float AS "avgRating", + w.rating_count AS "ratingCount", + + COALESCE( + ( + SELECT array_agg(g.name ORDER BY g.name) + FROM wine_grapes wg + JOIN grapes g ON g.id = wg.grape_id + WHERE wg.wine_id = w.id + ), + ARRAY[]::text[] + ) AS grapes, + + COALESCE( + ( + SELECT array_agg(f.name ORDER BY f.name) + FROM wine_foods wf + JOIN foods f ON f.id = wf.food_id + WHERE wf.wine_id = w.id + ), + ARRAY[]::text[] + ) AS "foodPairings" + + FROM wines w + JOIN wineries wi ON w.winery_id = wi.id + JOIN regions r ON w.region_id = r.id + JOIN countries c ON w.country_code = c.code + WHERE w.id = $1 + `, + [wineId] + ); + + return result.rows[0] ?? null; + }, + + foodCategories: async () => { + const result = await pool.query( + ` + SELECT f.category AS name, count(DISTINCT wf.wine_id)::int AS "wineCount" + FROM foods f + JOIN wine_foods wf ON wf.food_id = f.id + WHERE f.category IS NOT NULL + GROUP BY f.category + ORDER BY f.category + ` + ); + return result.rows; + }, + + winesByFoodCategory: async ( + _: unknown, + args: { category: string; limit?: number; offset?: number } + ) => { + const { limit, offset } = readPaging(args); + const inCategory = ` + EXISTS ( + SELECT 1 + FROM wine_foods wf + JOIN foods f ON f.id = wf.food_id + WHERE wf.wine_id = w.id AND f.category = $1 + )`; + + const winesResult = await pool.query( + ` + SELECT + w.id, + w.name, + w.type, + w.abv, + w.body, + w.acidity, + wi.name AS winery, + r.name AS region, + c.name AS country, + w.avg_rating AS "avgRating", + w.rating_count AS "ratingCount" + FROM wines w + JOIN wineries wi ON w.winery_id = wi.id + JOIN regions r ON w.region_id = r.id + JOIN countries c ON w.country_code = c.code + WHERE ${inCategory} + ORDER BY w.name, w.id + LIMIT $2 + OFFSET $3 + `, + [args.category, limit, offset] + ); + + const countResult = await pool.query( + `SELECT count(*)::int AS count FROM wines w WHERE ${inCategory}`, + [args.category] + ); + + return { + items: winesResult.rows, + totalCount: countResult.rows[0].count, + }; + }, + }, +}; + +const server = new ApolloServer({ + typeDefs, + resolvers, +}); + +const { url } = await startStandaloneServer(server, { + listen: { port: Number(process.env.PORT ?? 3001) }, +}); + +console.log(`GraphQL server running at ${url}`); \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..aacaa39 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmitOnError": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/client/.env.example b/client/.env.example new file mode 100644 index 0000000..ca554f2 --- /dev/null +++ b/client/.env.example @@ -0,0 +1,4 @@ +# URL to the GraphQL backend. +# Locally: http://localhost:3001/ +# On the VM: set this to the VM's backend address before building. +VITE_GRAPHQL_URL=http://localhost:3001/ diff --git a/client/.env.production b/client/.env.production new file mode 100644 index 0000000..8eb4c9e --- /dev/null +++ b/client/.env.production @@ -0,0 +1 @@ +VITE_GRAPHQL_URL=http://it2810-01.idi.ntnu.no:3001/ \ No newline at end of file diff --git a/client/.gitignore b/client/.gitignore index a547bf3..5fac54c 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +backend/data/ diff --git a/client/package-lock.json b/client/package-lock.json index 4580c12..cb9fa52 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,8 +8,12 @@ "name": "client", "version": "0.0.0", "dependencies": { + "@apollo/client": "^4.3.2", + "graphql": "^16.14.2", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router": "^8.4.0", + "rxjs": "^7.8.2" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -26,6 +30,43 @@ "vite": "^8.3.0" } }, + "node_modules/@apollo/client": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.3.2.tgz", + "integrity": "sha512-ra6f8NhP4b5Wznw6VpRGOGF6Q6a8rORToQeIx5BMC5Rc7mRVZ9wCN+01ynIZPjmlGPNif2Lqt0LRD+futfNgIw==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@wry/caches": "^1.0.0", + "@wry/equality": "^0.5.6", + "@wry/trie": "^0.5.0", + "graphql-tag": "^2.12.6", + "optimism": "^0.18.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "graphql": "^16.0.0 || ^17.0.0", + "graphql-ws": "^5.5.5 || ^6.0.3", + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "react-dom": "^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "rxjs": "^7.3.0", + "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" + }, + "peerDependenciesMeta": { + "graphql-ws": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "subscriptions-transport-ws": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -418,6 +459,15 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "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/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -568,6 +618,12 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@remix-run/route-pattern": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz", + "integrity": "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", @@ -678,9 +734,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -698,9 +751,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -718,9 +768,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -738,9 +785,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -758,9 +802,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -778,9 +819,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1172,6 +1210,54 @@ } } }, + "node_modules/@wry/caches": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", + "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/context": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz", + "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/equality": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz", + "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/trie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz", + "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -1324,6 +1410,12 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1732,6 +1824,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/graphql-tag": { + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.7.tgz", + "integrity": "sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^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/hashery": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", @@ -2033,9 +2149,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2057,9 +2170,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2081,9 +2191,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2105,9 +2212,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2248,6 +2352,18 @@ "node": ">=18" } }, + "node_modules/optimism": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz", + "integrity": "sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==", + "license": "MIT", + "dependencies": { + "@wry/caches": "^1.0.0", + "@wry/context": "^0.7.0", + "@wry/trie": "^0.5.0", + "tslib": "^2.3.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2428,6 +2544,28 @@ "react": "^19.3.0" } }, + "node_modules/react-router": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.4.0.tgz", + "integrity": "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g==", + "license": "MIT", + "dependencies": { + "@remix-run/route-pattern": "^0.22.1", + "cookie-es": "^3.1.1" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "react": ">=19.2.7", + "react-dom": ">=19.2.7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/rolldown": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", @@ -2462,6 +2600,15 @@ "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -2541,6 +2688,12 @@ "typescript": ">=4.8.4" } }, + "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-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/client/package.json b/client/package.json index 05280cc..a82364f 100644 --- a/client/package.json +++ b/client/package.json @@ -10,8 +10,12 @@ "preview": "vite preview" }, "dependencies": { + "@apollo/client": "^4.3.2", + "graphql": "^16.14.2", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router": "^8.4.0", + "rxjs": "^7.8.2" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/client/src/App.tsx b/client/src/App.tsx index 9ca8e8d..ec50821 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,122 +1,17 @@ -import { useState } from 'react' -import heroImg from './assets/hero.png' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import './App.css' - -function App() { - const [count, setCount] = useState(0) +import WineCatalog from './components/WineCatalog'; +/** + * Root component for Wine not? + * + *
marks the page's main content, so screen reader and keyboard users + * can jump straight to it. The page has one

, and WineList uses

, + * so the heading hierarchy is correct. + */ +export default function App() { return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- - ) +
+

Wine not?

+ +
+ ); } - -export default App diff --git a/client/src/apollo/client.ts b/client/src/apollo/client.ts new file mode 100644 index 0000000..e0a5bff --- /dev/null +++ b/client/src/apollo/client.ts @@ -0,0 +1,17 @@ +import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'; + +/** + * Apollo Client handles communication with our GraphQL backend and is also + * our state management: InMemoryCache stores query results, so the same + * query isn't sent to the server again (fewer requests, more sustainable). + * + * The backend URL is read from the environment variable VITE_GRAPHQL_URL, + * so the same code works locally and on the VM. Without it, we use the + * local backend on port 3001. + */ +export const client = new ApolloClient({ + link: new HttpLink({ + uri: import.meta.env.VITE_GRAPHQL_URL ?? 'http://localhost:3001/', + }), + cache: new InMemoryCache(), +}); \ No newline at end of file diff --git a/client/src/components/Pagination.css b/client/src/components/Pagination.css new file mode 100644 index 0000000..577abe2 --- /dev/null +++ b/client/src/components/Pagination.css @@ -0,0 +1,43 @@ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + margin: 2rem 0 1rem; +} + +.pagination-status { + margin: 0; + color: var(--color-muted); +} + +.pagination button { + /* At least 44x44 px, so the buttons are easy to hit on touch screens (WCAG 2.5.5) */ + min-height: 2.75rem; + min-width: 2.75rem; + padding: 0.5rem 1rem; + font: inherit; + color: var(--color-surface); + background: var(--color-accent); + border: 2px solid var(--color-accent); + border-radius: 0.5rem; + cursor: pointer; +} + +.pagination button:hover:not(:disabled) { + background: var(--color-text); + border-color: var(--color-text); +} + +/* Clear focus ring for keyboard users, not shown on mouse click */ +.pagination button:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 3px; +} + +.pagination button:disabled { + color: var(--color-muted); + background: transparent; + border-color: var(--color-border); + cursor: not-allowed; +} diff --git a/client/src/components/Pagination.tsx b/client/src/components/Pagination.tsx new file mode 100644 index 0000000..cfe367e --- /dev/null +++ b/client/src/components/Pagination.tsx @@ -0,0 +1,57 @@ +import './Pagination.css'; + +type Props = { + /** Current page, starting at 1 */ + page: number; + totalPages: number; + onPageChange: (page: number) => void; + /** True while the next page is loading, so the user can't click twice */ + disabled?: boolean; +}; + +/** + * Previous/next navigation between pages of results. + * + * Display only, like WineList: it knows nothing about GraphQL, it just reports + * which page the user wants. That makes it reusable (Find wine by food, + * Favourites ...) and easy to test with Vitest. + * + * Accessibility: + * - + ); +} diff --git a/client/src/components/WineCatalog.tsx b/client/src/components/WineCatalog.tsx new file mode 100644 index 0000000..fbb4d69 --- /dev/null +++ b/client/src/components/WineCatalog.tsx @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from 'react'; +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; +import WineList from './WineList'; +import Pagination from './Pagination'; +import type { WineListItem } from '../types/wine'; + +export const PAGE_SIZE = 20; + +/** + * Fetches wines from the GraphQL backend and passes them to WineList. + * + * Data fetching is kept here and display in WineList, so WineList can be + * reused and tested without Apollo. + * + * We fetch only the fields the list shows, and always with a limit, so we + * never load the whole dataset (scalability and sustainability). + * Pagination comes in issue 10. + */ +export const WINES_QUERY = gql` + query Wines($offset: Int, $limit: Int) { + wines(offset: $offset, limit: $limit) { + totalCount + items { + id + name + type + winery + region + country + avgRating + } + } + } +`; + +type WinesData = { + wines: { totalCount: number; items: WineListItem[] }; +}; + +export default function WineCatalog() { + const [page, setPage] = useState(1); + const offset = (page - 1) * PAGE_SIZE; + + const { data, previousData, loading, error } = useQuery( + WINES_QUERY, + { variables: { offset, limit: PAGE_SIZE } }, + ); + + const shown = data ?? previousData; + + const pageChanged = useRef(false); + useEffect(() => { + if (!pageChanged.current || !data) return; + pageChanged.current = false; + const heading = document.getElementById('catalog-heading'); + heading?.focus(); + heading?.scrollIntoView({ block: 'start' }); + }, [data]); + + function changePage(newPage: number) { + pageChanged.current = true; + setPage(newPage); + } + + // role="status" and role="alert" make screen readers announce the messages + if (error) { + return

Kunne ikke hente viner. Prøv igjen senere.

; + } + if (!shown) return

Laster viner …

; + + const totalCount = shown.wines.totalCount; + const totalPages = Math.ceil(totalCount / PAGE_SIZE); + + return ( + // aria-busy tells screen readers the content is being updated +
+ + +
+ ); +} diff --git a/client/src/components/WineList.css b/client/src/components/WineList.css new file mode 100644 index 0000000..7d35e49 --- /dev/null +++ b/client/src/components/WineList.css @@ -0,0 +1,42 @@ +/* Grid of wine cards: adapts the number of columns to the screen width. */ +.wine-list { + list-style: none; + padding: 0; + margin: 1.5rem 0 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); + gap: 1rem; +} + +.wine-card { + height: 100%; + padding: 1.25rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 0.75rem; +} + +.wine-card h3 { + margin: 0 0 0.75rem; + font-size: 1.125rem; +} + +/* Label and value side by side: label on the left, value on the right */ +.wine-card dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.25rem 1rem; + margin: 0; +} + +.wine-card dt { + color: var(--color-muted); +} + +.wine-card dd { + margin: 0; +} + +.wine-count { + color: var(--color-muted); +} diff --git a/client/src/components/WineList.tsx b/client/src/components/WineList.tsx new file mode 100644 index 0000000..076bb52 --- /dev/null +++ b/client/src/components/WineList.tsx @@ -0,0 +1,73 @@ +import type { WineListItem } from '../types/wine'; +import './WineList.css'; + +type Props = { + wines: WineListItem[]; + /** Total number of wines in the database, not just the ones shown */ + totalCount: number; + /** How many wines come before this page (0 on the first page) */ + offset?: number; +}; + +/** + * Shows a list of wines as cards. + * + * Display only: the component receives the wines as props and knows nothing + * about where they come from (GraphQL, example data, tests). That makes it + * easy to test and to reuse in e.g. Find wine by food and Favourites. + * + * Accessibility: we use semantic elements instead of divs, so screen readers + * understand the structure without extra ARIA: + * -
labelled by the heading via aria-labelledby + * -
    /
  • so the list is announced with its number of items + * -
    per wine, labelled by the wine's name + * -
    /
    /
    so each property is read with its label + */ +export default function WineList({ wines, totalCount, offset = 0 }: Props) { + if (wines.length === 0) { + return

    Ingen viner funnet.

    ; + } + + return ( +
    +

    Vinkatalog

    +

    + Viser {offset + 1}-{offset + wines.length} av {totalCount} viner +

    + + {/* + role="list" is needed because list-style: none in CSS makes + Safari/VoiceOver stop announcing the element as a list. + */} +
      + {wines.map((wine) => ( +
    • +
      +

      {wine.name}

      +
      +
      Produsent
      +
      {wine.winery}
      + +
      Type
      +
      {wine.type}
      + +
      Region
      +
      + {wine.region}, {wine.country} +
      + +
      Rating
      +
      + {/* avgRating can be null, so check before .toFixed */} + {wine.avgRating != null + ? wine.avgRating.toFixed(1) + : 'Ingen vurderinger'} +
      +
      +
      +
    • + ))} +
    +
    + ); +} diff --git a/client/src/data/exampleWines.ts b/client/src/data/exampleWines.ts new file mode 100644 index 0000000..c454650 --- /dev/null +++ b/client/src/data/exampleWines.ts @@ -0,0 +1,45 @@ +import type { WineListItem } from '../types/wine'; + +/** + * Temporary example data with the same fields as the backend. + * Used until Apollo Client (issue 8) is in place, and in tests. + * One wine has avgRating null to show that case. + */ +export const exampleWines: WineListItem[] = [ + { + id: '1', + name: 'Barolo Riserva', + type: 'Red', + winery: 'Giacomo Conterno', + region: 'Piemonte', + country: 'Italy', + avgRating: 4.5, + }, + { + id: '2', + name: 'Chablis Premier Cru', + type: 'White', + winery: 'William Fèvre', + region: 'Bourgogne', + country: 'France', + avgRating: 4.1, + }, + { + id: '3', + name: 'Rioja Gran Reserva', + type: 'Red', + winery: 'La Rioja Alta', + region: 'Rioja', + country: 'Spain', + avgRating: 3.9, + }, + { + id: '4', + name: 'Riesling Kabinett', + type: 'White', + winery: 'Dr. Loosen', + region: 'Mosel', + country: 'Germany', + avgRating: null, + }, +]; diff --git a/client/src/index.css b/client/src/index.css index 5fb3313..148f63a 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1,64 +1,23 @@ +/* + * Global base styling for Wine not? + * Replaces the Vite template. Colours are chosen for sufficient contrast + * (WCAG AA, at least 4.5:1 for normal text). + */ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + --color-bg: #faf7f5; + --color-surface: #ffffff; + --color-text: #2b1d1f; + --color-muted: #5f4b4e; + --color-accent: #7b1e2b; /* wine red */ + --color-border: #e4dcd9; - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + line-height: 1.5; + color: var(--color-text); + background: var(--color-bg); } -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; +* { box-sizing: border-box; } @@ -66,46 +25,19 @@ body { margin: 0; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); +main { + max-width: 72rem; + margin: 0 auto; + padding: 2rem 1rem; } h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); + color: var(--color-accent); + margin-top: 0; } -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); +/* Clear focus outline for keyboard navigation */ +:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 2px; } diff --git a/client/src/main.tsx b/client/src/main.tsx index bef5202..e1bbd9e 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,10 +1,17 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router'; +import { ApolloProvider } from '@apollo/client/react'; +import { client } from './apollo/client'; +import App from './App'; +import './index.css'; createRoot(document.getElementById('root')!).render( - + + + + + , -) +); diff --git a/client/src/types/wine.ts b/client/src/types/wine.ts new file mode 100644 index 0000000..d6804b0 --- /dev/null +++ b/client/src/types/wine.ts @@ -0,0 +1,15 @@ +/** + * A wine as shown in the catalog list. + * The fields match the Wine type in the GraphQL schema (backend/src), + * but only the ones the list needs. Details are fetched on the detail page. + */ +export type WineListItem = { + id: string; + name: string; + type: string; + winery: string; + region: string; + country: string; + /** null when the wine has no ratings */ + avgRating: number | null; +}; diff --git a/client/vite.config.ts b/client/vite.config.ts index 9982072..2a1e3b1 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -3,5 +3,7 @@ import { defineConfig } from 'vite' // https://vite.dev/config/ export default defineConfig({ + // The app is served from http://it2810-xx.idi.ntnu.no/project2/ + base: '/project2/', plugins: [react()], }) diff --git a/deploy/apache-project2.conf b/deploy/apache-project2.conf new file mode 100644 index 0000000..d7632c8 --- /dev/null +++ b/deploy/apache-project2.conf @@ -0,0 +1,20 @@ +# Apache config for the Wine not? client, served at /project2. +# Installed to /etc/apache2/conf-available/project2.conf on the VM by deploy/deploy.sh on every deploy. + + + # Unknown paths (e.g. /project2/wine/42) get index.html, + # so React Router can handle them and a page refresh doesn't give 404. + FallbackResource /project2/index.html + + # index.html and version.txt are always re-checked, + # so a new deploy shows up immediately. + + Header set Cache-Control "no-cache" + + + +# Vite gives built files hashed names, so browsers can cache them for a year. +# Returning visitors don't download them again (less data transferred). + + Header set Cache-Control "public, max-age=31536000, immutable" + diff --git a/deploy/backend.env.example b/deploy/backend.env.example new file mode 100644 index 0000000..1f02da8 --- /dev/null +++ b/deploy/backend.env.example @@ -0,0 +1,9 @@ +# Template for /etc/wine-not/backend.env on the VM. The real file, with the +# password, only exists on the VM and can be read with sudo. +# Same variable names as backend/.env on a laptop. +PORT=3001 +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=winenot +DB_USER=winenot_app +DB_PASSWORD= diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..12538e1 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Builds the backend and the client on the VM and puts them live. Runs as root. +# +# Normally started by deploy/push-to-vm.sh on a laptop, which sends a new +# copy of main: +# bash deploy.sh --install +# +# To rebuild the code that's already on the VM (e.g. after a migration), +# on the VM: +# sudo bash /opt/wine-not/deploy/deploy.sh +# +# Both parts are built before anything goes live. If a build fails, the +# script stops and the version that's already live keeps running. +set -euo pipefail + +main() { + local app_dir=/opt/wine-not + local web_dir=/var/www/html/project2 + + # Only one deploy at a time; a second one waits for the first to finish. + exec 9>/run/wine-not-deploy.lock + flock 9 + + if [ "${1:-}" = "--install" ]; then + local src="$2" commit="$3" + echo "==> Copying commit $commit to $app_dir" + mkdir -p "$app_dir" + chmod 755 "$src" # the backend runs as user winenot and must read the code + # Replace the code, but keep installed packages, builds and the CSV data. + rsync -a --delete \ + --exclude node_modules --exclude dist --exclude /backend/data \ + "$src/" "$app_dir/" + echo "$commit" > "$app_dir/COMMIT" + fi + + echo "==> Backend: install and build" + cd "$app_dir/backend" + npm ci + npm run build + + echo "==> Client: install and build" + cd "$app_dir/client" + npm ci + npm run build + + echo "==> Server setup: backend service and Apache config" + install -m 644 "$app_dir/deploy/wine-not-api.service" /etc/systemd/system/wine-not-api.service + systemctl daemon-reload + systemctl enable --quiet wine-not-api + install -m 644 "$app_dir/deploy/apache-project2.conf" /etc/apache2/conf-available/project2.conf + a2enmod --quiet headers + a2enconf --quiet project2 + apache2ctl configtest + + echo "==> Going live" + systemctl restart wine-not-api + mkdir -p "$web_dir" + rsync -a --delete "$app_dir/client/dist/" "$web_dir/" + systemctl restart apache2 + + # Shows which commit is live at /project2/version.txt + local commit_live + commit_live="$(cat "$app_dir/COMMIT" 2>/dev/null || echo unknown)" + echo "$commit_live deployed $(date '+%Y-%m-%d %H:%M')" > "$web_dir/version.txt" + + sleep 2 + echo "==> Done. Commit $commit_live. Backend status: $(systemctl is-active wine-not-api)" +} + +# Everything is inside a function so bash reads the whole script before +# running it, even if the file is replaced while it runs. +main "$@" diff --git a/deploy/push-to-vm.sh b/deploy/push-to-vm.sh new file mode 100755 index 0000000..cfe210f --- /dev/null +++ b/deploy/push-to-vm.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Deploys the newest version of main to the VM. +# +# Run it on your LAPTOP, from the repo, on the NTNU network or VPN: +# bash deploy/push-to-vm.sh +# +# The VM has no access to the Git server, so your laptop packs up what's on +# main, copies it to the VM and runs deploy/deploy.sh there. +# You may be asked for your NTNU password up to three times: +# once to copy, once to log in and once for sudo. +set -euo pipefail + +VM_HOST="it2810-01.idi.ntnu.no" + +if [ "$#" -ne 1 ]; then + echo "Usage: bash deploy/push-to-vm.sh " >&2 + exit 1 +fi +vm_user="$1" + +cd "$(git rev-parse --show-toplevel)" + +echo "==> Getting the newest main" +git fetch --quiet origin main +commit="$(git rev-parse --short origin/main)" +echo " Commit $commit: $(git log -1 --format=%s origin/main)" + +# Only committed files on main are packed: no node_modules, .env or CSV data. +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT +git archive --format=tar.gz -o "$tmp_dir/wine-not.tar.gz" origin/main + +echo "==> Copying to $VM_HOST" +remote_archive="/tmp/wine-not-$commit.tar.gz" +scp -q "$tmp_dir/wine-not.tar.gz" "$vm_user@$VM_HOST:$remote_archive" + +echo "==> Building and starting on the VM" +# Unpacks into a temporary folder and runs the deploy.sh from this commit, +# which copies the code to /opt/wine-not, builds it and puts it live. +ssh -t "$vm_user@$VM_HOST" \ + "sudo bash -c 'set -e; d=\$(mktemp -d); tar -xzf $remote_archive -C \"\$d\"; rm -f $remote_archive; bash \"\$d/deploy/deploy.sh\" --install \"\$d\" $commit; rm -rf \"\$d\"'" + +echo "==> Live at http://$VM_HOST/project2/" diff --git a/deploy/wine-not-api.service b/deploy/wine-not-api.service new file mode 100644 index 0000000..c469722 --- /dev/null +++ b/deploy/wine-not-api.service @@ -0,0 +1,19 @@ +# systemd service that runs the Wine not? GraphQL backend on port 3001. +# Installed to /etc/systemd/system/ on the VM by deploy/deploy.sh on every deploy. +[Unit] +Description=Wine not? GraphQL backend +After=network.target postgresql.service + +[Service] +User=winenot +Group=winenot +WorkingDirectory=/opt/wine-not/backend +# Database settings and port. This file exists only on the VM, never in Git. +EnvironmentFile=/etc/wine-not/backend.env +Environment=NODE_ENV=production +ExecStart=/usr/bin/node dist/index.js +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/with-env.sh b/deploy/with-env.sh new file mode 100755 index 0000000..d4d8dd8 --- /dev/null +++ b/deploy/with-env.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Runs one command with the backend's database settings loaded from +# /etc/wine-not/backend.env (the secret file that only exists on the VM). +# The DB_ settings are also given to psql (as PGHOST, PGUSER, ...), +# so psql connects to the same database as the backend. +# +# Usage on the VM, from /opt/wine-not/backend: +# sudo bash ../deploy/with-env.sh npm run import +# sudo bash ../deploy/with-env.sh psql -f db/migrations/.sql +set -euo pipefail + +if [ "$#" -eq 0 ]; then + echo "Usage: sudo bash ../deploy/with-env.sh [args...]" >&2 + exit 1 +fi + +set -a # export every variable defined in the file +# shellcheck source=/dev/null +. /etc/wine-not/backend.env +set +a + +export PGHOST="${DB_HOST:-localhost}" +export PGPORT="${DB_PORT:-5432}" +export PGDATABASE="${DB_NAME:-}" +export PGUSER="${DB_USER:-}" +export PGPASSWORD="${DB_PASSWORD:-}" + +exec "$@"