diff --git a/.gitignore b/.gitignore index efd2889..f6e7570 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ npm-debug.log* # Lokale miljøvariabler .env +.env.test +backend/.env.test .env.*.local .env.local diff --git a/.prettierignore b/.prettierignore index 47d7191..632fc01 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,7 @@ node_modules dist coverage package-lock.json + +# Skrevet av npm run import:tools. Formatet bestemmes av skriptet, slik at en +# ny import ikke gir formateringsfeil. +backend/src/seed/data diff --git a/README.md b/README.md index 59790e2..41be805 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,40 @@ Repository for team IT2810-H26-T36 in IT2810-H26. Webapplikasjon for leie og gratis utlån av verktøy mellom studenter og -privatpersoner i Trondheim. Bygget med React, TypeScript og Vite. +privatpersoner i Trondheim. Bygget med React, TypeScript og Vite, med +GraphQL-backend i Node og PostgreSQL. + +## Prøv appen + +Appen kjører på gruppens VM: + +VM-en er bare tilgjengelig fra NTNU-nettet. Utenfor campus må du koble til +[NTNU VPN](https://i.ntnu.no/vpn) først. Hvis VM-en ikke svarer, kan appen +kjøres lokalt med egen database og backend, se +[Frontend og backend sammen](#frontend-og-backend-sammen). ## Om prototypen -Dette er første innlevering: en klikkbar prototype som viser idéen og hvordan -løsningen er tenkt brukt. Den har ingen backend, database eller innlogging. +Første innlevering var en klikkbar **frontend-prototype** som viste idéen og +hvordan løsningen er tenkt brukt. + +**Innlevering 2** kobler prototypen til en ekte database og backend: + +- **Backend:** PostgreSQL med migrasjoner og seed, og et GraphQL-API i Node + og TypeScript i `backend/`. Backenden kjører som en systemd-tjeneste på VM-en, + og Apache sender `/project2/api/` videre til den. +- **Katalogen** henter annonsene fra backenden, 24 om gangen med de nyeste + først. Databasen på VM-en har 10 010 annonser. +- **Detaljvisningen** henter én annonse fra backenden, også når lenken åpnes + direkte. +- **Utleieskjemaet** lagrer nye annonser i databasen. Serveren validerer dem + på nytt, og den nye annonsen vises øverst i katalogen. + +Leieforespørselen er fortsatt demo. Søk, filtre og sortering er midlertidig +tatt ut av katalogen til serveren støtter dem, se +[Søk, filtre og sortering](#søk-filtre-og-sortering). Se også +[Frontend mot GraphQL](#frontend-mot-graphql) og +[Backend og database](#backend-og-database). ### Idé @@ -22,7 +50,7 @@ låne verktøy i nærheten i stedet for å kjøpe nytt. | Visning | Slik kommer du dit | Hva du kan gjøre | | -------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------- | -| Katalog | Startsiden, «Katalog» i menyen eller navnet øverst | Se alle annonsene, søke, filtrere på kategori og gratis utlån og sortere på pris | +| Katalog | Startsiden, «Katalog» i menyen eller navnet øverst | Bla gjennom alle annonsene i databasen, 24 om gangen, med de nyeste først | | Detaljvisning | Klikk på et annonsekort i katalogen | Se bilde, beskrivelse, tilstand, tilbehør og pris, velge datoer og se samlet pris | | Lei ut verktøy | «Lei ut verktøy» i menyen | Fylle ut skjemaet for en ny annonse med tittel, kategori, beskrivelse, område og pris | @@ -36,17 +64,18 @@ annonser viser for eksempel «150 kr/døgn», mens gratis annonser viser ### Hva som er mockup -- **Annonsene er oppdiktede eksempler.** De 10 annonsene i - `src/data/mockListings.ts` er skrevet for prototypen. De er ikke ekte tilbud - og inneholder ingen personopplysninger. Illustrasjonene er laget for - prosjektet. Ekstern datakilde, for eksempel for produktdata, er ikke valgt. +- **Annonsene er oppdiktede eksempler.** Appen henter annonsene fra + databasen, men de er ikke ekte tilbud og inneholder ingen + personopplysninger. 10 håndskrevne annonser seedes fra + `backend/src/seed/listings.ts`, og 10 000 til genereres med ekte + verktøytyper og merker fra Wikidata, se [docs/datasett.md](docs/datasett.md). + Illustrasjonene er laget for prosjektet. - **Leieforespørselen er en demo.** Knappen heter «Send forespørsel (demo)». Ingen forespørsel blir sendt, og verktøyet blir ikke reservert. -- **Utleieskjemaet er en demo.** Knappen heter «Legg ut verktøy (demo)». - Annonsen blir ikke lagret og vises ikke i katalogen. -- **Ingenting lagres.** Det finnes ingen backend, database eller innlogging. - Søk og filtre virker bare på eksempelannonsene i nettleseren, og det som - er fylt ut i skjemaene, forsvinner når siden lastes inn på nytt. +- **Ingen innlogging.** Alle kan legge ut annonser, og annonsene har ingen + eier ennå. + +`src/data/mockListings.ts` brukes nå bare i tester og på designsiden. ### Data @@ -65,21 +94,20 @@ I den ferdige løsningen ligger dataene i en database: | Vurdering | Leietakerens vurdering etter et gjennomført leieforhold | Annonser, bookinger og vurderinger er de brukergenererte dataene som lagres. -For å teste søk og blaing i store resultatsett fyller vi databasen med -genererte eksempelannonser via et seed-skript. Om vi henter produktdata fra en -ekstern kilde, er ikke avklart. +Eksempelannonsene seedes i PostgreSQL via `backend/` (samme innhold som mock). +For å teste skalering fyller `npm run seed:large` databasen med 10 000 +genererte annonser. Verktøytypene og merkene er ekte og hentet fra Wikidata, +mens selve annonsene genereres. Se [docs/datasett.md](docs/datasett.md). ### Planlagt videre -- **Backend og database:** En egen GraphQL-backend i Node og TypeScript med - PostgreSQL. Begge kjører direkte på gruppens VM, uten Docker. Appen blir - tilgjengelig via Apache under `/project2`, og backenden på port 3001. - **Søk i hele databasen:** Søk, filtre og sortering skjer på serveren, før resultatet deles opp i sider. Katalogen får blaing med «Forrige» og «Neste», og endrede valg starter på første side. Søket venter til brukeren har sluttet å skrive (debouncing), og svar på utdaterte søk forkastes. -- **State:** Apollo Client henter og bufrer data fra backenden. Søk, filtre og - sortering ligger fortsatt i URL-en. +- **State:** Apollo Client henter og bufrer allerede data fra backenden. Søk, + filtre og sortering skal fortsatt ligge i URL-en, slik at de kan deles som + lenke. - **Innlogging og egne annonser:** Brukere kan opprette, redigere og deaktivere egne annonser. Serveren sjekker at bare eieren kan endre dem. - **Booking:** Eieren godkjenner eller avslår leieforespørsler, og begge @@ -92,8 +120,8 @@ ekstern kilde, er ikke avklart. - **Bærekraft:** Små bilder som lastes når de trengs, systemfont, buffring og spørringer som bare henter feltene som vises. Vi måler datamengde og antall forespørsler. -- **Testing:** Komponenttester med Vitest finnes allerede. Senere kommer - API-tester mot en testdatabase og en ende-til-ende-test med Playwright av en +- **Testing:** Komponenttester med Vitest og API-tester mot en testdatabase + finnes allerede. Senere kommer en ende-til-ende-test med Playwright av en hel leieflyt. Betaling, depositum, frakt og chat er ikke en del av planen. Hvis kjernen blir @@ -102,9 +130,25 @@ bokhylle», med verktøyene som trengs. ### Dette ønsker vi tilbakemelding på +**Oppsett og samspill mellom klient og backend:** + +- Var det lett å finne ut hvordan appen kjøres, både på VM-en og lokalt? Hvor + stoppet du opp? +- Er det forståelig hvordan Apache, backenden og databasen henger sammen på + VM-en, ut fra [docs/vm-backend.md](docs/vm-backend.md) og + [deploy/apache/README.md](deploy/apache/README.md)? +- Er GraphQL-skjemaet i `backend/src/graphql/schema.ts` lett å forstå? Ville + du delt opp spørringene eller feltene annerledes? +- Føles katalogen rask nok når du blar, og er det tydelig hva som skjer mens + data lastes eller hvis backenden ikke svarer? +- Dukket annonsen du la ut via «Lei ut verktøy», opp i katalogen slik du + forventet? + +**Idé og brukeropplevelse:** + - Er idéen og forskjellen på leie og gratis utlån lett å forstå? -- Mangler katalogen filtre eller sorteringer du ville brukt, for eksempel - område eller ledige datoer? +- Hvilke filtre og sorteringer ville du brukt mest når søket kommer tilbake, + for eksempel område eller ledige datoer? - Er det tydelig hvordan en leieforespørsel fungerer? - Er det noe i datamodellen over som virker unødvendig, eller som mangler? @@ -126,9 +170,239 @@ TypeScript 7. ```bash npm ci # installer avhengigheter nøyaktig etter package-lock.json -npm run dev # start utviklingsserver på http://localhost:5173 +npm run dev # start utviklingsserver på http://localhost:5173/project2/ +``` + +Katalogen henter annonsene fra backenden. Uten backend viser appen en +feilmelding med «Prøv igjen». Se [Frontend og backend sammen](#frontend-og-backend-sammen). + +## Frontend mot GraphQL + +Frontend bruker [Apollo Client](https://www.apollographql.com/docs/react/) for +å hente data fra GraphQL-backenden. Vi valgte Apollo fordi: + +- **Cache:** Svar lagres i en normalisert cache. En side eller annonse som er + hentet før, vises med en gang og uten ny forespørsel når brukeren går + tilbake. +- **Tilstand:** `useQuery` gir laste-, feil- og datatilstand og `refetch` for + «Prøv igjen», så vi slipper å skrive dette selv. +- **Testing:** `MockedProvider` lar komponenttestene bruke mockede + GraphQL-svar uten backend. +- **Videre arbeid:** Apollo er laget for GraphQL og håndterer mutasjoner og + oppdatering av cachen når brukere lagrer data. + +| Fil | Innhold | +| --------------------------------- | -------------------------------------------- | +| `src/graphql/client.ts` | Apollo-klienten og adressen til API-et | +| `src/graphql/listingQueries.ts` | Spørringene for katalogen og detaljvisningen | +| `src/graphql/listingMutations.ts` | Mutasjonen som lagrer nye annonser | +| `src/test/listingMocks.ts` | Mockede GraphQL-svar til testene | + +Katalogen henter bare feltene annonsekortet viser, og 24 annonser per side. +Én side er omtrent 4 kB. Resten av opplysningene hentes når brukeren åpner en +annonse. Gjeldende side ligger i URL-en som `?side=2`. + +**Nye annonser:** Utleieskjemaet sender annonsen til `createListing` når +klientvalideringen er godkjent. Serveren validerer den på nytt. Etter lagring +fjernes de hentede katalogsidene fra cachen, så katalogen hentes på nytt neste +gang den vises, med den nye annonsen øverst. + +**Samme adresser lokalt og på VM-en:** Appen har `base: '/project2/'` i +`vite.config.ts`, og klienten sender spørringer til `/project2/api/graphql`. +På VM-en sender Apache dette videre til backenden. Lokalt gjør Vite det samme +med en proxy til `http://localhost:3001`. Annonsebildene ligger i +`public/assets/listings/` og har samme adresse som `image_url` i databasen. + +### Frontend og backend sammen + +Første gang: sett opp PostgreSQL, `backend/.env`, migrasjon og seed som +beskrevet i [Lokal backend-utvikling](#lokal-backend-utvikling). Deretter, i +to terminaler: + +```bash +# Terminal 1: backend på port 3001 +cd backend +npm run dev + +# Terminal 2: frontend på port 5173 +npm run dev +``` + +Åpne . Kjør `npm run seed:large` i `backend/` +for å se katalogen med 10 010 annonser. + +## Backend og database + +Backend ligger i `backend/` og bruker PostgreSQL. Apollo Server 5 eksponerer +GraphQL på port 3001. Migrasjon og seed fyller `listings` med de samme +eksempelannonsene som mockdataene som brukes i testene. + +| Miljø | Dokumentasjon | +| ---------------- | -------------------------------------------------------------------------------------------------------- | +| **Lokal maskin** | [Lokal backend-utvikling](#lokal-backend-utvikling) (under) | +| **NTNU-VM** | [docs/vm-backend.md](docs/vm-backend.md) — delt database, SSH, **ikke** samme som lokalt oppsett | +| systemd / Apache | [backend/deploy/README.md](backend/deploy/README.md), [deploy/apache/README.md](deploy/apache/README.md) | + +### Lokal backend-utvikling + +Følg dette for å kjøre migrasjon og seed på **egen PC**. VM-oppsettet er +separat og beskrives ikke her. + +**1. Node.js** — samme krav som frontend (se [Krav](#krav)): `nvm use` i +rotmappen (`.nvmrc` → Node 24). Sjekk med `node -v` og `npm -v`. + +**2. PostgreSQL lokalt** — installer PostgreSQL slik at du kan koble til +`localhost:5432` (Homebrew, apt, Postgres.app e.l.). Start tjenesten om den +ikke kjører. + +**3. Database og bruker** — opprett en database som matcher det du setter i +`.env` (eksempelnavn fra `backend/.env.example`: databasen `verktoyutlan`). +Passord settes interaktivt, ikke i shell-historikk: + +```bash +psql -h localhost -U postgres -d postgres +``` + +```sql +CREATE USER verktoyutlan_dev WITH LOGIN; +\password verktoyutlan_dev +CREATE DATABASE verktoyutlan OWNER verktoyutlan_dev; +\q +``` + +Tilpass bruker- og databasenavn etter eget valg; de må samsvare med `DATABASE_URL`. + +**4. Miljøfil** — fra repo-roten: + +```bash +cd backend +cp .env.example .env +``` + +Rediger `backend/.env` og sett for eksempel: + +```env +DATABASE_URL=postgresql://verktoyutlan_dev:PASSORD@localhost:5432/verktoyutlan ``` +(Erstatt `PASSORD` i filen — ikke skriv det i terminalkommandoer.) Filen skal +ikke committes. + +**5. Avhengigheter, migrasjon og seed:** + +```bash +npm ci +npm run typecheck +npm run migrate +npm run seed +``` + +`npm run migrate` bruker `node-pg-migrate` og `.env`. `npm run seed` kjører +`src/scripts/seed.ts` og er idempotent (`ON CONFLICT DO NOTHING`). + +**6. Verifisering:** + +```bash +npm run typecheck +psql -h localhost -U verktoyutlan_dev -d verktoyutlan -W -c "SELECT COUNT(*) FROM listings;" +npm run seed # forvent 0 nye annonser andre gang +``` + +Forvent **10** rader i `listings`. Seed-data tilsvarer +`src/data/mockListings.ts` (id `listing-001`–`listing-010`). + +**7. Stort datasett:** + +```bash +npm run seed:large # 10 000 genererte annonser +npm run seed:large -- --count 50000 # annet antall +npm run seed:large -- --replace # erstatt tidligere genererte +``` + +Forvent **10 010** rader i `listings` etter første kjøring, og «0 nye» om du +kjører den på nytt. Tar under ett sekund for 10 000 annonser. Datakilde, +fordelinger og målinger står i [docs/datasett.md](docs/datasett.md). + +**Bilder:** Seed lagrer `image_url` under `/project2/assets/listings/`. Filene +ligger i `public/assets/listings/`, så Vite serverer dem på samme sti både ved +lokal utvikling og i bygget. + +### GraphQL lokalt + +Etter migrasjon og seed (steg 5–6 over): + +```bash +cd backend +npm run dev # tsx watch, leser backend/.env +# eller: npm run start +``` + +Serveren logger URL til GraphQL-endepunktet (standard +`http://127.0.0.1:3001/graphql`). `HOST` og `PORT` settes i `backend/.env` +(samme som i `backend/.env.example`). + +**Queries** + +| Felt | Beskrivelse | +| ----------------------------------- | -------------------------------------------------------- | +| `health` | Returnerer `"ok"`. | +| `listings(limit: Int, offset: Int)` | Én side med annonser, nyeste først. Se paginering under. | +| `listing(id: ID!)` | Én annonse, eller `null` hvis id ikke finnes. | + +**Paginering:** `listings` returnerer en `ListingPage` med `items`, +`totalCount` (antall annonser totalt) og `hasNextPage`. `limit` er +sidestørrelsen, standard 24 og maks 100. `offset` er hvor mange annonser som +hoppes over, standard 0. Ugyldige verdier gir `BAD_USER_INPUT`. En side utenfor +treffmengden gir tom `items`, men riktig `totalCount`. + +Annonsene sorteres på `createdAt` med nyeste først, og deretter på `id`. Den +andre nøkkelen gjør rekkefølgen stabil, slik at ingen annonser hoppes over +eller vises to ganger når man blar. Sorteringen har egen indeks, se +[docs/datasett.md](docs/datasett.md#paginering-og-indeks). + +**Mutations** + +| Felt | Beskrivelse | +| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `createListing(input: CreateListingInput!)` | Oppretter annonse i `listings`. Validerer input som utleieskjemaet i frontend. Setter standardverdier for `condition`, `accessories` og `imageUrl`. | + +Feltene returneres i camelCase (`dailyPrice`, `imageUrl`, osv.). Enkelt eksempel +med curl (juster URL om du har endret `PORT`): + +```bash +curl -s -X POST http://127.0.0.1:3001/graphql \ + -H 'Content-Type: application/json' \ + -d '{"query":"{ health listings(limit: 5) { totalCount hasNextPage items { id title dailyPrice } } }"}' +``` + +### API-tester (backend) + +GraphQL-testene ligger i `backend/src/graphql/*.api.test.ts` og kjører +mot en **egen testdatabase**, ikke utviklings- eller VM-databasen. + +1. Kopier `backend/.env.test.example` til `backend/.env.test` og sett + `TEST_DATABASE_URL` til en database som **slutter på `_test`** (for eksempel + `verktoyutlan_test`). +2. Opprett databasen én gang, migrer og seed mot den: + +```bash +cd backend +createdb verktoyutlan_test +set -a && source .env.test && set +a && export DATABASE_URL="$TEST_DATABASE_URL" +npm run migrate && npm run seed +``` + +3. Kjør tester: + +```bash +npm test # én kjøring +npm run test:watch # ved endringer +``` + +Vitest laster bare `backend/.env.test` (ikke `.env`). Hvis +`TEST_DATABASE_URL` peker på en database uten `_test`-suffiks, feiler +`npm test` med en gang. Uten `.env.test` hoppes database-testene over. + ## Kommandoer | Kommando | Hva den gjør | @@ -143,6 +417,10 @@ npm run dev # start utviklingsserver på http://localhost:5173 | `npm run format` | Formaterer alle filer med Prettier | | `npm run format:check` | Sjekker formatering uten å endre filer | +Backend (`cd backend`): `npm run dev`, `npm run start`, `npm run migrate`, +`npm run seed`, `npm run seed:large`, `npm run import:tools`, +`npm run typecheck`, `npm test`. + ESLint finner feil i koden, mens Prettier bestemmer formateringen. `eslint-config-prettier` ligger sist i `eslint.config.js` og slår av ESLint-regler som ellers ville kollidert med Prettier. Byggfiler, @@ -154,17 +432,29 @@ kontroller som er gjort i nettleser, står i [docs/testing.md](docs/testing.md). ## Mappestruktur ``` +deploy/ + apache/ Apache-vhost for /project2/ og /project2/api/ (mal) +backend/ + deploy/ systemd-mal og VM-deploy (port 3001) + migrations/ Versjonerte PostgreSQL-migrasjoner + src/db/ Tilkoblingspool og repositories + src/graphql/ GraphQL-schema, resolvers og API-tester + src/scripts/ Seed og andre engangsskript + src/seed/ Seed-data, verktøymodeller fra Wikidata og generator for stort datasett + src/server.ts Apollo Server (GraphQL) src/ types/ Felles datatyper, for eksempel Listing - data/ Mockdata. Byttes med GraphQL-backend senere + graphql/ Apollo-klient og GraphQL-spørringer + data/ Eksempelannonser til designreferansen og testene utils/ Formatering av pris og etiketter, filtrering og sortering - assets/ Illustrasjoner til eksempelannonsene styles/ Designvariabler og grunnstiler components/ Gjenbrukbare komponenter, én mappe per komponent pages/ Én mappe per side hooks/ Små felles hooks, for eksempel usePageTitle design/ Designreferanse, bare under utvikling - test/ Felles oppsett for testene + test/ Felles oppsett, mockede GraphQL-svar og renderApp for testene +public/ + assets/listings/ Illustrasjoner til annonsene, samme sti som image_url ``` Komponenter i `components/` får data gjennom props og importerer ikke @@ -177,9 +467,9 @@ Rutene er definert i `src/App.tsx` med React Router. | Rute | Side | Innhold nå | | ---------------------- | ------------------- | ------------------------------------------- | -| `/` | `CatalogPage` | Annonsekort med søk, filtre og sortering | +| `/` | `CatalogPage` | Annonser fra GraphQL, én side om gangen | | `/annonser/:listingId` | `ListingDetailPage` | Alle opplysninger og leieforespørsel (demo) | -| `/lei-ut` | `RentOutPage` | Skjema for å legge ut verktøy (demo) | +| `/lei-ut` | `RentOutPage` | Skjema som lagrer nye annonser | | Alle andre | `NotFoundPage` | «Fant ikke siden» | Ny side: lag en mappe i `src/pages/`, legg til en `Route` i `App.tsx` og kall @@ -188,6 +478,11 @@ hopp-lenke automatisk. ## Søk, filtre og sortering +> **Midlertidig skjult.** Katalogen henter nå én side om gangen fra serveren. +> Søk, filtre og sortering i nettleseren ville bare virket på de 24 annonsene +> som er lastet, ikke på hele katalogen. De er derfor tatt ut av katalogen til +> serveren støtter dem. Logikken og testene under ligger fortsatt i `src/`. + Katalogen kan snevres inn med fritekstsøk, kategori og gratis utlån. Søket leter i tittel og beskrivelse og skiller ikke mellom store og små bokstaver. Filtrene kan kombineres, og et tomt søk viser alle annonser. @@ -249,11 +544,12 @@ leieforespørsel. Henting og retur samme dag regnes som ett døgn. `src/utils/listingForm.ts` validerer skjemaet for å legge ut verktøy. Alle felt er påkrevd, og døgnprisen må være et helt tall fra 0 til 10 000 kroner. -Serveren skal senere gjøre den samme kontrollen. +Serveren gjør den samme kontrollen i `backend/src/validation/createListingInput.ts`. `src/data/mockListings.ts` har 10 eksempelannonser som dekker gratis og betalt -utlån, korte og lange titler, tomt tilbehør og annonser uten bilde. Se -[Hva som er mockup](#hva-som-er-mockup) for hvor dataene kommer fra. +utlån, korte og lange titler, tomt tilbehør og annonser uten bilde. De brukes i +testene og på designsiden. Se [Hva som er mockup](#hva-som-er-mockup) for hvor +dataene i appen kommer fra. ## Dokumentasjon @@ -262,6 +558,9 @@ utlån, korte og lange titler, tomt tilbehør og annonser uten bilde. Se | [docs/prosjektstandard.md](docs/prosjektstandard.md) | Branches, commit-format, pull requests og krav før merge | | [docs/design.md](docs/design.md) | Farger, typografi, kontrast og felles komponenter | | [docs/testing.md](docs/testing.md) | Tester, kontroller, resultater og det som gjenstår | +| [docs/datasett.md](docs/datasett.md) | Datakilde, generert datasett og målinger | +| [docs/vm-backend.md](docs/vm-backend.md) | PostgreSQL og backend på NTNU-VM | +| [deploy/apache/README.md](deploy/apache/README.md) | Apache-mal for /project2/ og API-proxy | | [docs/ki-bruk.md](docs/ki-bruk.md) | Hvordan vi bruker og kontrollerer KI | `main` er beskyttet, så alt arbeid går via pull request mot `dev`. Maler for diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..5bbeec8 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,9 @@ +# PostgreSQL-tilkobling. Kopier til .env og tilpass verdien. +DATABASE_URL=postgresql://bruker:passord@localhost:5432/verktoyutlan + +# GraphQL-server (npm run dev / npm run start) +HOST=127.0.0.1 +PORT=3001 + +# Valgfritt: rotsti når frontend er deployet (standard /project2). +# PUBLIC_APP_BASE=/project2 diff --git a/backend/.env.test.example b/backend/.env.test.example new file mode 100644 index 0000000..b0495e7 --- /dev/null +++ b/backend/.env.test.example @@ -0,0 +1,10 @@ +# Kopier til .env.test (committes ikke). Kun testdatabase — ikke dev eller prod. +# Databasenavnet må slutte på _test (sjekkes av vitest.config.ts). +# +# Én gang per maskin: +# createdb verktoyutlan_test +# set -a && source .env.test && set +a && export DATABASE_URL="$TEST_DATABASE_URL" +# npm run migrate && npm run seed +# +# Kjøre tester: cd backend && npm test +TEST_DATABASE_URL=postgresql://bruker@localhost:5432/verktoyutlan_test diff --git a/backend/.node-pg-migraterc.json b/backend/.node-pg-migraterc.json new file mode 100644 index 0000000..0486989 --- /dev/null +++ b/backend/.node-pg-migraterc.json @@ -0,0 +1,4 @@ +{ + "migrations-dir": "migrations", + "migration-file-language": "ts" +} diff --git a/backend/deploy/README.md b/backend/deploy/README.md new file mode 100644 index 0000000..55701c1 --- /dev/null +++ b/backend/deploy/README.md @@ -0,0 +1,161 @@ +# Deploy: systemd og port 3001 (prosjekt 2) + +Mal for å kjøre GraphQL-backenden som egen systemd-tjeneste på +`it2810-36.idi.ntnu.no`. Steg-for-steg på VM: [docs/vm-backend.md §7](../../docs/vm-backend.md#7-graphql-med-systemd-port-3001). + +**Verifisert på VM (oktober 2026):** tjenesten kjører som `User=birkjr`, +`Group=eall_s`, med Node +`/home/birkjr/.nvm/versions/node/v24.21.0/bin/node`, +`WorkingDirectory=/home/birkjr/T36-Project-2/backend`, miljø fra +`/etc/t36-project2/backend.env`. GraphQL svarer på `127.0.0.1:3001/graphql` +(`health`, `listings`, `createListing`); `systemctl restart` og `enabled` er +testet. VM-reboot er ikke testet. + +## Avhengighet før enable + +Før `systemctl enable --now` må repoet ha minst: + +- `backend/src/server.ts` som **leser `HOST` og `PORT` fra miljø** og binder til + **`127.0.0.1:3001`** (verdier fra `/etc/t36-project2/backend.env` — ikke + hardkode `0.0.0.0`). +- `npm ci` kjørt i `backend/`, slik at `node_modules/tsx/dist/cli.mjs` finnes. +- `npm run start` (eller `dev`) verifisert mot VM-database etter deploy av kode. +- Valgfritt senere: `npm run build` + `ExecStart` mot `dist/server.js` i stedet + for tsx. + +## Filer + +| Fil | Formål | +| ------------------------------ | -------------------------------------------------------------------- | +| `t36-project2-backend.service` | systemd-enhet (`__DEPLOY_USER__`, `__BACKEND_DIR__`, `__NODE_BIN__`) | +| `backend.env.example` | Mal for `/etc/t36-project2/backend.env` | + +Migrasjon/seed kan fortsatt bruke `~/T36-Project-2/backend/.env`. Tjenesten leser +**kun** `/etc/t36-project2/backend.env` (utenfor git). + +## Plassholdere + +| Plassholder | Betydning | +| ----------------- | ---------------------------------------------------------------------------------- | +| `__DEPLOY_USER__` | NTNU-bruker på VM (ikke root) | +| `__BACKEND_DIR__` | Absolutt sti, f.eks. `/home//T36-Project-2/backend` | +| `__NODE_BIN__` | Full sti til Node 24, f.eks. `/home//.nvm/versions/node/v24.21.0/bin/node` | + +Etter `sed` fra malen: på IT2810-VM kan **`Group=`** settes til primærgruppen +(f.eks. `eall_s`) i stedet for brukernavn, hvis `systemd` krever det. Rediger +`/etc/systemd/system/t36-project2-backend.service` og kjør `daemon-reload`. + +## Forberedelse på VM (etter GraphQL er merget) + +Erstatt `` med faktisk bruker (samme som eier repoet). + +```bash +export DEPLOY_USER="" +export BACKEND_DIR="/home/${DEPLOY_USER}/T36-Project-2/backend" + +cd "${BACKEND_DIR}/.." +git pull +cd "${BACKEND_DIR}" +nvm use 24 +npm ci +npm run typecheck +test -f node_modules/tsx/dist/cli.mjs +# npm run build — hvis dere bytter ExecStart til dist/server.js +``` + +Noter Node-sti: + +```bash +export NODE_BIN="$(which node)" +echo "${NODE_BIN}" +``` + +## Miljøfil utenfor git + +Tjenesten kjører som **`DEPLOY_USER`** ( ikke root ). Filen skal være lesbar for +prosessen, men ikke for «alle» på VM: + +```bash +export DEPLOY_USER="" + +sudo mkdir -p /etc/t36-project2 +sudo cp "/home/${DEPLOY_USER}/T36-Project-2/backend/deploy/backend.env.example" \ + /etc/t36-project2/backend.env +sudo nano /etc/t36-project2/backend.env # DATABASE_URL, HOST, PORT +sudo chmod 640 /etc/t36-project2/backend.env +sudo chown "root:${DEPLOY_USER}" /etc/t36-project2/backend.env +``` + +**Krav i filen:** `HOST=127.0.0.1`, `PORT=3001`. GraphQL-implementasjonen må respektere +disse. + +## Installere tjenesten (manuelt, én gang) + +Kjør på VM etter `nvm use 24` og `npm ci` i backend: + +```bash +export DEPLOY_USER="" +export BACKEND_DIR="/home/${DEPLOY_USER}/T36-Project-2/backend" +export NODE_BIN="$(which node)" + +sed -e "s|__DEPLOY_USER__|${DEPLOY_USER}|g" \ + -e "s|__BACKEND_DIR__|${BACKEND_DIR}|g" \ + -e "s|__NODE_BIN__|${NODE_BIN}|g" \ + "${BACKEND_DIR}/deploy/t36-project2-backend.service" \ + | sudo tee /etc/systemd/system/t36-project2-backend.service > /dev/null + +sudo systemctl daemon-reload +``` + +Test manuelt før `enable` (miljø fra `/etc/t36-project2/backend.env`): + +```bash +set -a && source /etc/t36-project2/backend.env && set +a +"${NODE_BIN}" "${BACKEND_DIR}/node_modules/tsx/dist/cli.mjs" "${BACKEND_DIR}/src/server.ts" +``` + +Deretter: + +```bash +sudo systemctl enable t36-project2-backend +sudo systemctl start t36-project2-backend +``` + +## Kontroll + +```bash +sudo systemctl status t36-project2-backend --no-pager +sudo systemctl is-enabled t36-project2-backend +journalctl -u t36-project2-backend -n 50 --no-pager +ss -tlnp | grep 3001 || true + +curl -sS -X POST http://127.0.0.1:3001/graphql \ + -H 'Content-Type: application/json' \ + -d '{"query":"{ health listings(limit: 1) { totalCount } }"}' +``` + +Stopp / restart: + +```bash +sudo systemctl stop t36-project2-backend +sudo systemctl restart t36-project2-backend +``` + +Ved OS-omstart: autostart når enheten er **enabled** (`Restart=on-failure` ved krasj). + +## Prosjekt 1 + +- Enheten heter `t36-project2-backend` og bruker port **3001** — ikke endre + prosjekt 1s tjenester eller Apache-vhosts. + +## Oppdatere etter pull + +```bash +export BACKEND_DIR="/home//T36-Project-2/backend" +cd "${BACKEND_DIR}" && npm ci +sudo systemctl restart t36-project2-backend +``` + +Ved bytt til produksjonsbuild, oppdater `ExecStart` i +`/etc/systemd/system/t36-project2-backend.service` (f.eks. `node dist/server.js`), +deretter `daemon-reload` og `restart`. diff --git a/backend/deploy/backend.env.example b/backend/deploy/backend.env.example new file mode 100644 index 0000000..f8a49e2 --- /dev/null +++ b/backend/deploy/backend.env.example @@ -0,0 +1,21 @@ +# Eksempel for systemd-tjenesten på VM. +# Kopier til /etc/t36-project2/backend.env (aldri commit med passord). +# +# sudo mkdir -p /etc/t36-project2 +# sudo cp backend/deploy/backend.env.example /etc/t36-project2/backend.env +# sudo chmod 640 /etc/t36-project2/backend.env +# sudo chown root:__DEPLOY_USER__ /etc/t36-project2/backend.env +# +# Tjenesten kjører som __DEPLOY_USER__ (ikke root). Med 640 og gruppe = +# deploy-brukeren kan prosessen lese filen; andre brukere på VM kan ikke. + +DATABASE_URL=postgresql://t36_project2:PASSORD@localhost:5432/t36_project2 + +# GraphQL-server (src/server.ts): les HOST og PORT, lytt kun på 127.0.0.1 — ikke +# 0.0.0.0. Apache proxyer mot http://127.0.0.1:3001/graphql (se deploy/apache/). +HOST=127.0.0.1 +PORT=3001 +NODE_ENV=production + +# Valgfritt: offentlig rot for seed-bilder +# PUBLIC_APP_BASE=/project2 diff --git a/backend/deploy/t36-project2-backend.service b/backend/deploy/t36-project2-backend.service new file mode 100644 index 0000000..56d1927 --- /dev/null +++ b/backend/deploy/t36-project2-backend.service @@ -0,0 +1,33 @@ +# Mal for gruppens Ubuntu-VM. Ikke enable før npm ci, backend.env og GraphQL er +# verifisert lokalt (src/server.ts leser HOST/PORT, default 127.0.0.1:3001). +# +# Installasjon: se backend/deploy/README.md (erstatt __DEPLOY_USER__, __BACKEND_DIR__, +# __NODE_BIN__). + +[Unit] +Description=T36 Project 2 backend (GraphQL, port 3001) +Documentation=file://__BACKEND_DIR__/deploy/README.md +After=network-online.target postgresql.service +Wants=network-online.target +# Prosjekt 2 egen enhet — ikke delt med prosjekt 1 + +[Service] +Type=simple +User=__DEPLOY_USER__ +Group=__DEPLOY_USER__ +WorkingDirectory=__BACKEND_DIR__ + +# Hemmeligheter utenfor git (DATABASE_URL, HOST, PORT, osv.) +EnvironmentFile=/etc/t36-project2/backend.env + +# Node 24 (nvm) + tsx fra backend/node_modules etter «npm ci» — ikke global tsx +ExecStart=__NODE_BIN__ __BACKEND_DIR__/node_modules/tsx/dist/cli.mjs __BACKEND_DIR__/src/server.ts + +Restart=on-failure +RestartSec=5 +TimeoutStopSec=30 + +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target diff --git a/backend/migrations/1791895214550_create-listings-table.ts b/backend/migrations/1791895214550_create-listings-table.ts new file mode 100644 index 0000000..8b11642 --- /dev/null +++ b/backend/migrations/1791895214550_create-listings-table.ts @@ -0,0 +1,48 @@ +import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +const LISTING_CATEGORIES = [ + 'powerTools', + 'handTools', + 'garden', + 'painting', + 'measuring', + 'cleaning', +] as const; + +const LISTING_CONDITIONS = ['likeNew', 'good', 'worn'] as const; + +function sqlInList(values: readonly string[]): string { + return values.map((value) => `'${value}'`).join(', '); +} + +export async function up(pgm: MigrationBuilder): Promise { + pgm.createTable('listings', { + id: { type: 'text', primaryKey: true }, + title: { type: 'text', notNull: true }, + category: { type: 'text', notNull: true }, + description: { type: 'text', notNull: true }, + area: { type: 'text', notNull: true }, + daily_price: { type: 'integer', notNull: true }, + condition: { type: 'text', notNull: true }, + accessories: { type: 'text[]', notNull: true, default: '{}' }, + image_url: { type: 'text', notNull: false }, + }); + + pgm.addConstraint('listings', 'listings_category_check', { + check: `category IN (${sqlInList(LISTING_CATEGORIES)})`, + }); + + pgm.addConstraint('listings', 'listings_condition_check', { + check: `condition IN (${sqlInList(LISTING_CONDITIONS)})`, + }); + + pgm.addConstraint('listings', 'listings_daily_price_check', { + check: 'daily_price >= 0', + }); +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropTable('listings'); +} diff --git a/backend/migrations/1791978982612_add-listings-created-at.ts b/backend/migrations/1791978982612_add-listings-created-at.ts new file mode 100644 index 0000000..7fa15a5 --- /dev/null +++ b/backend/migrations/1791978982612_add-listings-created-at.ts @@ -0,0 +1,24 @@ +import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +/** + * Legger til når annonsen ble opprettet. Trengs for å sortere på «nyeste» og + * for å spre genererte annonser utover tid. + * + * Eksisterende rader får tidspunktet migrasjonen kjøres. Nye rader får + * tidspunktet de settes inn, med mindre seed-skriptet oppgir et eget. + */ +export async function up(pgm: MigrationBuilder): Promise { + pgm.addColumn('listings', { + created_at: { + type: 'timestamptz', + notNull: true, + default: pgm.func('current_timestamp'), + }, + }); +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropColumn('listings', 'created_at'); +} diff --git a/backend/migrations/1792006352081_add-listings-created-at-index.ts b/backend/migrations/1792006352081_add-listings-created-at-index.ts new file mode 100644 index 0000000..ae856bf --- /dev/null +++ b/backend/migrations/1792006352081_add-listings-created-at-index.ts @@ -0,0 +1,24 @@ +import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +/** + * Indeks for standardsorteringen i listings: nyeste først, med id som andre + * nøkkel. Indeksen har samme rekkefølge som ORDER BY created_at DESC, id DESC, + * så PostgreSQL kan lese den første siden rett fra indeksen i stedet for å + * sortere hele tabellen for hver forespørsel. + */ +export async function up(pgm: MigrationBuilder): Promise { + pgm.createIndex( + 'listings', + [ + { name: 'created_at', sort: 'DESC' }, + { name: 'id', sort: 'DESC' }, + ], + { name: 'listings_created_at_id_idx' }, + ); +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropIndex('listings', [], { name: 'listings_created_at_id_idx' }); +} diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..2c678ae --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,3509 @@ +{ + "name": "t36-verktoyutlan-backend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "t36-verktoyutlan-backend", + "version": "0.0.0", + "dependencies": { + "@apollo/server": "^5.5.1", + "dotenv": "^17.2.3", + "graphql": "^16.14.2", + "pg": "^8.16.3" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@types/pg": "^8.15.6", + "node-pg-migrate": "^9.0.0", + "tsx": "^4.20.6", + "typescript": "~6.0.0", + "vitest": "^5.0.3" + }, + "engines": { + "node": "^22.22.2 || >=24.15.0", + "npm": ">=10" + } + }, + "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.7", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.2.7.tgz", + "integrity": "sha512-Zo69OGURoOupi9v+3PCNOuQSgwz0/KNOsfTNMudSLzHTKMu40Bz3KqHaIxWuUiFr/ZDRXClI8ou48vdJWtwh4w==", + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^12.0.4", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/schema": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.1.4.tgz", + "integrity": "sha512-talZuSrjeklX7RVYLgF7fxHgywZXSYWzFZf3NG5CNFgkeK3xTocYMvlC3FLAApMWUW59qkyuHN1/HIgaIGfjRA==", + "license": "MIT", + "dependencies": { + "@graphql-tools/merge": "^9.2.7", + "@graphql-tools/utils": "^12.0.4", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/utils": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.4.tgz", + "integrity": "sha512-hJmeBRrK1OUABqRxLXB1YwrBB+3zTXeRJYb5PaF7CweV80zNGnN640Jz+0BrCaUE8HBk6KtoVXM/tUScUsKdeg==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^2.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "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/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.4", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "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/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "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/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "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/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-inspect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", + "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "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/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "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-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "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/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "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/node-pg-migrate": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/node-pg-migrate/-/node-pg-migrate-9.0.0.tgz", + "integrity": "sha512-lp5+UZx1KKgOz/y0h6BYGPuJ5wVlZTgiBPGXCGoX6Bs6X9LtiIhrI4V2yW4mPwnJBfptVq0KtLQtXHcoEKOyig==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "~13.0.0", + "jiti": "~2.7.0", + "yargs": "~18.0.0" + }, + "bin": { + "node-pg-migrate": "bin/node-pg-migrate.js" + }, + "engines": { + "node": ">=20.11.0" + }, + "peerDependencies": { + "@types/pg": ">=6.0.0 <9.0.0", + "pg": ">=4.3.0 <9.0.0" + }, + "peerDependenciesMeta": { + "@types/pg": { + "optional": true + } + } + }, + "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/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "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/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "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/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "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/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "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": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.4", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "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/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "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" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..ebd24a7 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,35 @@ +{ + "name": "t36-verktoyutlan-backend", + "private": true, + "version": "0.0.0", + "type": "module", + "engines": { + "node": "^22.22.2 || >=24.15.0", + "npm": ">=10" + }, + "scripts": { + "dev": "tsx watch --env-file=.env src/server.ts", + "start": "tsx --env-file=.env src/server.ts", + "migrate": "node-pg-migrate up --envPath .env", + "seed": "tsx --env-file=.env src/scripts/seed.ts", + "seed:large": "tsx --env-file=.env src/scripts/seedLarge.ts", + "import:tools": "tsx src/scripts/importToolModels.ts", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@apollo/server": "^5.5.1", + "dotenv": "^17.2.3", + "graphql": "^16.14.2", + "pg": "^8.16.3" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@types/pg": "^8.15.6", + "node-pg-migrate": "^9.0.0", + "tsx": "^4.20.6", + "typescript": "~6.0.0", + "vitest": "^5.0.3" + } +} diff --git a/backend/src/db/listingsRepository.ts b/backend/src/db/listingsRepository.ts new file mode 100644 index 0000000..d9a1b76 --- /dev/null +++ b/backend/src/db/listingsRepository.ts @@ -0,0 +1,114 @@ +import { randomUUID } from 'node:crypto'; + +import type { Listing, ListingRow } from '../types/listing.js'; +import { listingFromRow } from '../types/listing.js'; +import type { ValidatedCreateListingInput } from '../validation/createListingInput.js'; +import { pool } from './pool.js'; + +const LISTING_COLUMNS = ` + id, + title, + category, + description, + area, + daily_price, + condition, + accessories, + image_url, + created_at +`; + +export interface ListingPage { + items: Listing[]; + totalCount: number; + hasNextPage: boolean; +} + +/** + * Henter én side med annonser, nyeste først. id er andre sorteringsnøkkel, + * fordi mange annonser kan ha samme created_at. Uten den kunne PostgreSQL + * returnere like tidspunkt i ulik rekkefølge fra gang til gang, og en annonse + * kunne blitt vist på to sider eller ikke i det hele tatt. + */ +export async function findListingsPage( + limit: number, + offset: number, +): Promise { + const [itemsResult, countResult] = await Promise.all([ + pool.query( + `SELECT ${LISTING_COLUMNS} + FROM listings + ORDER BY created_at DESC, id DESC + LIMIT $1 OFFSET $2`, + [limit, offset], + ), + pool.query<{ total: string }>( + 'SELECT COUNT(*)::text AS total FROM listings', + ), + ]); + + const totalCount = Number(countResult.rows[0]?.total ?? 0); + + return { + items: itemsResult.rows.map(listingFromRow), + totalCount, + hasNextPage: offset + limit < totalCount, + }; +} + +export async function findListingById(id: string): Promise { + const result = await pool.query( + `SELECT ${LISTING_COLUMNS} FROM listings WHERE id = $1`, + [id], + ); + const row = result.rows[0]; + return row ? listingFromRow(row) : null; +} + +const DEFAULT_CONDITION = 'good'; +const DEFAULT_ACCESSORIES: string[] = []; + +export async function insertListing( + input: ValidatedCreateListingInput, +): Promise { + const id = `listing-${randomUUID()}`; + + const result = await pool.query( + `INSERT INTO listings ( + id, + title, + category, + description, + area, + daily_price, + condition, + accessories, + image_url + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING ${LISTING_COLUMNS}`, + [ + id, + input.title, + input.category, + input.description, + input.area, + input.dailyPrice, + DEFAULT_CONDITION, + DEFAULT_ACCESSORIES, + null, + ], + ); + + const row = result.rows[0]; + if (!row) { + throw new Error('INSERT RETURNING ga ingen rad.'); + } + + return listingFromRow(row); +} + +export async function deleteListingById(id: string): Promise { + const result = await pool.query('DELETE FROM listings WHERE id = $1', [id]); + return (result.rowCount ?? 0) > 0; +} diff --git a/backend/src/db/pool.ts b/backend/src/db/pool.ts new file mode 100644 index 0000000..207a120 --- /dev/null +++ b/backend/src/db/pool.ts @@ -0,0 +1,20 @@ +import 'dotenv/config'; + +import pg from 'pg'; + +const { Pool } = pg; + +function requireDatabaseUrl(): string { + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error( + 'DATABASE_URL er ikke satt. Kopier backend/.env.example til backend/.env.', + ); + } + return url; +} + +/** Delt pool for PostgreSQL. Opprettes ved første import. */ +export const pool = new Pool({ + connectionString: requireDatabaseUrl(), +}); diff --git a/backend/src/graphql/graphql.api.test.ts b/backend/src/graphql/graphql.api.test.ts new file mode 100644 index 0000000..6b3a361 --- /dev/null +++ b/backend/src/graphql/graphql.api.test.ts @@ -0,0 +1,280 @@ +import type { ApolloServer } from '@apollo/server'; +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; + +import { + closeTestPool, + createTestApolloServer, +} from '../test/apolloTestServer.js'; +import { hasTestDatabase } from '../test/env.js'; +import { expectListingShape } from '../test/listingExpectations.js'; + +const SEED_LISTING_ID = 'listing-001'; +const UNKNOWN_LISTING_ID = 'listing-00000000-0000-0000-0000-000000000099'; + +const validCreateInput = { + title: 'Vitest hammer', + category: 'handTools', + description: 'Kort testannonse fra GraphQL API-test.', + area: 'Gløshaugen', + dailyPrice: 25, +}; + +describe('GraphQL API', () => { + it('hopper over database-tester uten TEST_DATABASE_URL', () => { + if (hasTestDatabase) { + expect(process.env.DATABASE_URL).toBe(process.env.TEST_DATABASE_URL); + } else { + expect(hasTestDatabase).toBe(false); + } + }); + + describe.skipIf(!hasTestDatabase)('mot testdatabase', () => { + let server: ApolloServer; + const createdListingIds: string[] = []; + + beforeAll(async () => { + server = await createTestApolloServer(); + }); + + afterEach(async () => { + if (createdListingIds.length === 0) { + return; + } + const { deleteListingById } = await import('../db/listingsRepository.js'); + while (createdListingIds.length > 0) { + const id = createdListingIds.pop(); + if (id) { + await deleteListingById(id); + } + } + }); + + afterAll(async () => { + await server.stop(); + await closeTestPool(); + }); + + it('health returnerer «ok»', async () => { + const response = await server.executeOperation({ + query: `query { health }`, + }); + + expect(response.body.kind).toBe('single'); + if (response.body.kind !== 'single') { + return; + } + expect(response.body.singleResult.errors).toBeUndefined(); + expect(response.body.singleResult.data?.health).toBe('ok'); + }); + + it('listings returnerer annonser med riktig feltstruktur', async () => { + const response = await server.executeOperation({ + query: `query { + listings { + items { + id + title + category + description + area + dailyPrice + condition + accessories + imageUrl + createdAt + } + } + }`, + }); + + expect(response.body.kind).toBe('single'); + if (response.body.kind !== 'single') { + return; + } + expect(response.body.singleResult.errors).toBeUndefined(); + + const page = response.body.singleResult.data?.listings as + { items: Record[] } | undefined; + const listings = page?.items; + expect(Array.isArray(listings)).toBe(true); + expect(listings!.length).toBeGreaterThan(0); + + for (const listing of listings!) { + expectListingShape(listing); + } + }); + + it('listing(id) returnerer riktig annonse', async () => { + const response = await server.executeOperation({ + query: `query ($id: ID!) { + listing(id: $id) { + id + title + category + dailyPrice + } + }`, + variables: { id: SEED_LISTING_ID }, + }); + + expect(response.body.kind).toBe('single'); + if (response.body.kind !== 'single') { + return; + } + expect(response.body.singleResult.errors).toBeUndefined(); + + const listing = response.body.singleResult.data?.listing as + Record | null | undefined; + expect(listing).not.toBeNull(); + expect(listing!.id).toBe(SEED_LISTING_ID); + expect(typeof listing!.title).toBe('string'); + }); + + it('listing med ukjent id returnerer null', async () => { + const response = await server.executeOperation({ + query: `query ($id: ID!) { + listing(id: $id) { id } + }`, + variables: { id: UNKNOWN_LISTING_ID }, + }); + + expect(response.body.kind).toBe('single'); + if (response.body.kind !== 'single') { + return; + } + expect(response.body.singleResult.errors).toBeUndefined(); + expect(response.body.singleResult.data?.listing).toBeNull(); + }); + + it('createListing oppretter annonse med standardverdier', async () => { + const response = await server.executeOperation({ + query: `mutation ($input: CreateListingInput!) { + createListing(input: $input) { + id + title + category + description + area + dailyPrice + condition + accessories + imageUrl + } + }`, + variables: { input: validCreateInput }, + }); + + expect(response.body.kind).toBe('single'); + if (response.body.kind !== 'single') { + return; + } + expect(response.body.singleResult.errors).toBeUndefined(); + + const created = response.body.singleResult.data?.createListing as + Record | undefined; + expect(created).toBeDefined(); + expectListingShape(created!); + expect(created!.title).toBe(validCreateInput.title); + expect(created!.category).toBe(validCreateInput.category); + expect(created!.dailyPrice).toBe(validCreateInput.dailyPrice); + expect(created!.condition).toBe('good'); + expect(created!.accessories).toEqual([]); + expect(created!.imageUrl).toBeNull(); + expect(String(created!.id)).toMatch(/^listing-/); + + createdListingIds.push(String(created!.id)); + }); + + it('avviser tom tittel', async () => { + const response = await server.executeOperation({ + query: `mutation ($input: CreateListingInput!) { + createListing(input: $input) { id } + }`, + variables: { + input: { ...validCreateInput, title: ' ' }, + }, + }); + + expect(response.body.kind).toBe('single'); + if (response.body.kind !== 'single') { + return; + } + expect(response.body.singleResult.data?.createListing).toBeUndefined(); + const errors = response.body.singleResult.errors; + expect(errors?.length).toBeGreaterThan(0); + expect(errors![0]?.message).toBe('Skriv en tittel.'); + expect(errors![0]?.extensions?.code).toBe('BAD_USER_INPUT'); + }); + + it('avviser negativ pris', async () => { + const response = await server.executeOperation({ + query: `mutation ($input: CreateListingInput!) { + createListing(input: $input) { id } + }`, + variables: { + input: { ...validCreateInput, dailyPrice: -1 }, + }, + }); + + expect(response.body.kind).toBe('single'); + if (response.body.kind !== 'single') { + return; + } + expect(response.body.singleResult.data?.createListing).toBeUndefined(); + const errors = response.body.singleResult.errors; + expect(errors?.length).toBeGreaterThan(0); + expect(errors![0]?.message).toBe( + 'Skriv prisen som et helt tall i kroner, for eksempel 100.', + ); + }); + + it('opprettet annonse kan hentes med listing(id)', async () => { + const createResponse = await server.executeOperation({ + query: `mutation ($input: CreateListingInput!) { + createListing(input: $input) { id title } + }`, + variables: { input: validCreateInput }, + }); + + expect(createResponse.body.kind).toBe('single'); + if (createResponse.body.kind !== 'single') { + return; + } + expect(createResponse.body.singleResult.errors).toBeUndefined(); + + const created = createResponse.body.singleResult.data?.createListing as + { id: string; title: string } | undefined; + expect(created?.id).toBeDefined(); + createdListingIds.push(created!.id); + + const fetchResponse = await server.executeOperation({ + query: `query ($id: ID!) { + listing(id: $id) { + id + title + dailyPrice + condition + accessories + imageUrl + } + }`, + variables: { id: created!.id }, + }); + + expect(fetchResponse.body.kind).toBe('single'); + if (fetchResponse.body.kind !== 'single') { + return; + } + expect(fetchResponse.body.singleResult.errors).toBeUndefined(); + + const fetched = fetchResponse.body.singleResult.data?.listing as + Record | null | undefined; + expect(fetched).not.toBeNull(); + expect(fetched!.id).toBe(created!.id); + expect(fetched!.title).toBe(validCreateInput.title); + expect(fetched!.condition).toBe('good'); + expect(fetched!.accessories).toEqual([]); + expect(fetched!.imageUrl).toBeNull(); + }); + }); +}); diff --git a/backend/src/graphql/listingsPagination.api.test.ts b/backend/src/graphql/listingsPagination.api.test.ts new file mode 100644 index 0000000..205763c --- /dev/null +++ b/backend/src/graphql/listingsPagination.api.test.ts @@ -0,0 +1,156 @@ +import type { ApolloServer } from '@apollo/server'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + closeTestPool, + createTestApolloServer, +} from '../test/apolloTestServer.js'; +import { hasTestDatabase } from '../test/env.js'; +import { MAX_PAGE_SIZE } from '../validation/paginationArgs.js'; + +interface PageItem { + id: string; + createdAt: string; +} + +interface Page { + items: PageItem[]; + totalCount: number; + hasNextPage: boolean; +} + +/** + * Tre annonser med nøyaktig samme tidspunkt, lenger fram i tid enn resten, så + * de havner først. Seed-annonsene har ulike tidspunkt ned på millisekundet, så + * uten disse ville rekkefølgen ved like tidspunkt aldri blitt testet. + */ +const TIED_IDS = ['test-page-a', 'test-page-b', 'test-page-c']; +const TIED_CREATED_AT = '2099-01-01T00:00:00.000Z'; + +const LISTINGS_QUERY = `query ($limit: Int, $offset: Int) { + listings(limit: $limit, offset: $offset) { + totalCount + hasNextPage + items { id createdAt } + } +}`; + +describe.skipIf(!hasTestDatabase)('listings med paginering', () => { + let server: ApolloServer; + + beforeAll(async () => { + server = await createTestApolloServer(); + const { pool } = await import('../db/pool.js'); + for (const id of TIED_IDS) { + await pool.query( + `INSERT INTO listings + (id, title, category, description, area, daily_price, condition, created_at) + VALUES ($1, 'Testannonse', 'handTools', 'Test av paginering.', 'Moholt', 0, 'good', $2) + ON CONFLICT (id) DO NOTHING`, + [id, TIED_CREATED_AT], + ); + } + }); + + afterAll(async () => { + const { pool } = await import('../db/pool.js'); + await pool.query('DELETE FROM listings WHERE id = ANY($1)', [TIED_IDS]); + await server.stop(); + await closeTestPool(); + }); + + async function runListings(variables: { limit?: number; offset?: number }) { + const response = await server.executeOperation({ + query: LISTINGS_QUERY, + variables, + }); + if (response.body.kind !== 'single') { + throw new Error('Forventet ett svar fra GraphQL.'); + } + return response.body.singleResult; + } + + async function fetchPage(limit: number, offset: number): Promise { + const result = await runListings({ limit, offset }); + expect(result.errors).toBeUndefined(); + return result.data?.listings as Page; + } + + it('gir første side med riktig størrelse og totalt antall', async () => { + const page = await fetchPage(3, 0); + expect(page.totalCount).toBeGreaterThan(3); + expect(page.items).toHaveLength(3); + expect(page.hasNextPage).toBe(true); + }); + + it('bruker 24 som standard sidestørrelse', async () => { + const result = await runListings({}); + expect(result.errors).toBeUndefined(); + const page = result.data?.listings as Page; + expect(page.items).toHaveLength(Math.min(24, page.totalCount)); + }); + + it('gir resten av annonsene på siste side', async () => { + const limit = 4; + const { totalCount } = await fetchPage(1, 0); + const lastOffset = Math.floor((totalCount - 1) / limit) * limit; + + const page = await fetchPage(limit, lastOffset); + expect(page.items).toHaveLength(totalCount - lastOffset); + expect(page.hasNextPage).toBe(false); + }); + + it('gir tom side, men riktig totalt antall, utenfor treffmengden', async () => { + const { totalCount } = await fetchPage(1, 0); + const page = await fetchPage(5, totalCount + 10); + expect(page.items).toEqual([]); + expect(page.totalCount).toBe(totalCount); + expect(page.hasNextPage).toBe(false); + }); + + it('viser hver annonse nøyaktig én gang, nyeste først, på tvers av sider', async () => { + const limit = 3; + const { totalCount } = await fetchPage(1, 0); + const all: PageItem[] = []; + for (let offset = 0; offset < totalCount; offset += limit) { + all.push(...(await fetchPage(limit, offset)).items); + } + + expect(all).toHaveLength(totalCount); + expect(new Set(all.map((item) => item.id)).size).toBe(totalCount); + + // Like tidspunkt sorteres på id, også synkende. + const expectedOrder = [...all].sort( + (a, b) => + b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id), + ); + expect(all.map((item) => item.id)).toEqual( + expectedOrder.map((item) => item.id), + ); + }); + + it('sorterer annonser med samme tidspunkt på id, også over sidegrenser', async () => { + const first = await fetchPage(2, 0); + const second = await fetchPage(2, 2); + expect( + [...first.items, ...second.items].slice(0, 3).map((i) => i.id), + ).toEqual(['test-page-c', 'test-page-b', 'test-page-a']); + }); + + it('returnerer createdAt som ISO 8601-tekst', async () => { + const [item] = (await fetchPage(1, 0)).items; + expect(item).toBeDefined(); + expect(new Date(item!.createdAt).toISOString()).toBe(item!.createdAt); + }); + + it.each([ + { limit: 0 }, + { limit: -1 }, + { limit: MAX_PAGE_SIZE + 1 }, + { offset: -1 }, + ])('avviser ugyldig sidestørrelse eller posisjon %o', async (variables) => { + const result = await runListings(variables); + expect(result.data?.listings).toBeUndefined(); + expect(result.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + }); +}); diff --git a/backend/src/graphql/resolvers.ts b/backend/src/graphql/resolvers.ts new file mode 100644 index 0000000..47adb47 --- /dev/null +++ b/backend/src/graphql/resolvers.ts @@ -0,0 +1,27 @@ +import { + findListingById, + findListingsPage, + insertListing, +} from '../db/listingsRepository.js'; +import type { CreateListingInput } from '../validation/createListingInput.js'; +import { validateCreateListingInput } from '../validation/createListingInput.js'; +import type { PaginationArgs } from '../validation/paginationArgs.js'; +import { validatePaginationArgs } from '../validation/paginationArgs.js'; + +export const resolvers = { + Query: { + health: () => 'ok' as const, + listings: (_parent: unknown, args: PaginationArgs) => { + const { limit, offset } = validatePaginationArgs(args); + return findListingsPage(limit, offset); + }, + listing: (_parent: unknown, args: { id: string }) => + findListingById(args.id), + }, + Mutation: { + createListing: (_parent: unknown, args: { input: CreateListingInput }) => { + const validated = validateCreateListingInput(args.input); + return insertListing(validated); + }, + }, +}; diff --git a/backend/src/graphql/schema.ts b/backend/src/graphql/schema.ts new file mode 100644 index 0000000..559ff94 --- /dev/null +++ b/backend/src/graphql/schema.ts @@ -0,0 +1,60 @@ +export const typeDefs = `#graphql + enum ToolCategory { + powerTools + handTools + garden + painting + measuring + cleaning + } + + enum ToolCondition { + likeNew + good + worn + } + + type Listing { + id: ID! + title: String! + category: ToolCategory! + description: String! + area: String! + dailyPrice: Int! + condition: ToolCondition! + accessories: [String!]! + imageUrl: String + "Når annonsen ble opprettet, som ISO 8601-tekst i UTC." + createdAt: String! + } + + "Én side med annonser og totalt antall treff." + type ListingPage { + items: [Listing!]! + "Antall annonser totalt, ikke bare på denne siden." + totalCount: Int! + hasNextPage: Boolean! + } + + input CreateListingInput { + title: String! + category: ToolCategory! + description: String! + area: String! + dailyPrice: Int! + } + + type Query { + health: String! + """ + Annonser sortert med nyeste først. limit er sidestørrelsen (1–100) og + offset er hvor mange annonser som hoppes over. + """ + listings(limit: Int = 24, offset: Int = 0): ListingPage! + listing(id: ID!): Listing + } + + type Mutation { + createListing(input: CreateListingInput!): Listing! + } +`; diff --git a/backend/src/scripts/importToolModels.ts b/backend/src/scripts/importToolModels.ts new file mode 100644 index 0000000..f7b3e32 --- /dev/null +++ b/backend/src/scripts/importToolModels.ts @@ -0,0 +1,180 @@ +/** + * Henter verktøytyper og merker fra Wikidata og lagrer dem i + * src/seed/data/toolModels.json. + * + * Resultatet committes, slik at seeding ikke er avhengig av at Wikidata er + * tilgjengelig. Kjør skriptet på nytt bare når listene i toolSources.ts endres: + * + * npm run import:tools + * + * Wikidata-data er lisensiert CC0, så vi kan bruke og lagre dem fritt. + */ + +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { + toolModelsPath as outputPath, + type Brand, + type ToolModelsFile, + type ToolType, +} from '../seed/toolModels.js'; +import { brandSources, toolTypeSources } from '../seed/toolSources.js'; + +const SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql'; +// Wikimedia krever en User-Agent som sier hvem som sender forespørselen. +const USER_AGENT = + 'T36-Project2-verktoyutlan/1.0 (NTNU IT2810 studentprosjekt; https://git.ntnu.no/IT2810-H26/T36-Project-2)'; + +interface WikidataEntity { + nbLabel: string | null; + enLabel: string | null; +} + +/** Henter norsk og engelsk navn for alle ID-ene i én spørring. */ +async function fetchEntities( + ids: string[], +): Promise> { + const query = ` + SELECT ?item ?nbLabel ?enLabel WHERE { + VALUES ?item { ${ids.map((id) => `wd:${id}`).join(' ')} } + OPTIONAL { ?item rdfs:label ?nbLabel FILTER(LANG(?nbLabel) = "nb") } + OPTIONAL { ?item rdfs:label ?enLabel FILTER(LANG(?enLabel) = "en") } + }`; + + const response = await fetch( + `${SPARQL_ENDPOINT}?query=${encodeURIComponent(query)}`, + { + headers: { + Accept: 'application/sparql-results+json', + 'User-Agent': USER_AGENT, + }, + }, + ); + + if (!response.ok) { + throw new Error( + `Wikidata svarte ${response.status} ${response.statusText}. Prøv igjen om litt.`, + ); + } + + const json = (await response.json()) as { + results: { bindings: Record[] }; + }; + + const entities = new Map(); + for (const row of json.results.bindings) { + const id = row.item?.value.split('/').pop(); + if (!id) continue; + // En ID kan gi flere rader når den har flere navn på samme språk. + // Første treff beholdes, så resultatet blir det samme hver gang. + const existing = entities.get(id); + entities.set(id, { + nbLabel: existing?.nbLabel ?? row.nbLabel?.value ?? null, + enLabel: existing?.enLabel ?? row.enLabel?.value ?? null, + }); + } + return entities; +} + +/** + * Wikidata skriver noen verktøy med stor forbokstav («Båndsag»). Navnene skal + * brukes midt i titler, så første bokstav gjøres liten. Merkenavn berøres ikke. + */ +function toLowerFirst(text: string): string { + return text.charAt(0).toLocaleLowerCase('nb-NO') + text.slice(1); +} + +function buildToolTypes(entities: Map): ToolType[] { + const missing: string[] = []; + + const toolTypes = toolTypeSources.map((source): ToolType => { + const entity = entities.get(source.wikidataId); + const name = entity?.nbLabel ?? source.fallbackName; + if (!entity || !name) { + missing.push(source.wikidataId); + } + return { + wikidataId: source.wikidataId, + name: toLowerFirst(name ?? ''), + category: source.category, + nameSource: entity?.nbLabel ? 'wikidata' : 'manual', + }; + }); + + if (missing.length > 0) { + throw new Error( + `Fant ikke navn for ${missing.join(', ')}. Sjekk ID-ene eller legg til fallbackName i toolSources.ts.`, + ); + } + + const duplicates = toolTypes + .map((type) => type.name) + .filter((name, index, names) => names.indexOf(name) !== index); + if (duplicates.length > 0) { + throw new Error( + `Samme verktøynavn finnes flere ganger: ${duplicates.join(', ')}.`, + ); + } + + return toolTypes.sort( + (a, b) => + a.category.localeCompare(b.category) || + a.name.localeCompare(b.name, 'nb'), + ); +} + +function buildBrands(entities: Map): Brand[] { + return brandSources + .map((source): Brand => { + const entity = source.wikidataId + ? entities.get(source.wikidataId) + : undefined; + if (source.wikidataId && !entity) { + throw new Error( + `Fant ikke ${source.wikidataId} (${source.name}) i Wikidata.`, + ); + } + return { + name: source.name, + wikidataId: source.wikidataId, + wikidataLabel: entity?.enLabel ?? entity?.nbLabel ?? null, + categories: source.categories, + }; + }) + .sort((a, b) => a.name.localeCompare(b.name, 'nb')); +} + +async function importToolModels(): Promise { + const ids = [ + ...toolTypeSources.map((source) => source.wikidataId), + ...brandSources.flatMap((source) => + source.wikidataId ? [source.wikidataId] : [], + ), + ]; + const entities = await fetchEntities([...new Set(ids)]); + + const file: ToolModelsFile = { + source: { + name: 'Wikidata', + url: 'https://www.wikidata.org', + license: 'CC0 1.0', + retrieved: new Date().toISOString().slice(0, 10), + }, + toolTypes: buildToolTypes(entities), + brands: buildBrands(entities), + }; + + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(file, null, 2)}\n`, 'utf8'); + + const fromWikidata = file.toolTypes.filter( + (type) => type.nameSource === 'wikidata', + ).length; + const brandsWithId = file.brands.filter((brand) => brand.wikidataId).length; + console.info( + `Lagret ${file.toolTypes.length} verktøytyper (${fromWikidata} med norsk navn fra Wikidata) og ${file.brands.length} merker (${brandsWithId} med Wikidata-ID) i ${outputPath}.`, + ); +} + +await importToolModels(); diff --git a/backend/src/scripts/seed.ts b/backend/src/scripts/seed.ts new file mode 100644 index 0000000..604f21e --- /dev/null +++ b/backend/src/scripts/seed.ts @@ -0,0 +1,62 @@ +import { pool } from '../db/pool.js'; +import { seedListings } from '../seed/listings.js'; + +const INSERT_SQL = ` + INSERT INTO listings ( + id, + title, + category, + description, + area, + daily_price, + condition, + accessories, + image_url + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO NOTHING +`; + +async function seed(): Promise { + if (seedListings.length !== 10) { + throw new Error(`Forventet 10 seed-annonser, fant ${seedListings.length}.`); + } + + let inserted = 0; + let skipped = 0; + + for (const listing of seedListings) { + const result = await pool.query(INSERT_SQL, [ + listing.id, + listing.title, + listing.category, + listing.description, + listing.area, + listing.dailyPrice, + listing.condition, + listing.accessories, + listing.imageUrl, + ]); + + if (result.rowCount === 1) { + inserted += 1; + } else { + skipped += 1; + } + } + + const countResult = await pool.query<{ count: string }>( + 'SELECT COUNT(*)::text AS count FROM listings', + ); + const total = Number(countResult.rows[0]?.count ?? 0); + + console.info( + `Seed fullført: ${inserted} nye, ${skipped} hoppet over (finnes fra før). Totalt ${total} rader i listings.`, + ); +} + +try { + await seed(); +} finally { + await pool.end(); +} diff --git a/backend/src/scripts/seedLarge.ts b/backend/src/scripts/seedLarge.ts new file mode 100644 index 0000000..02fd703 --- /dev/null +++ b/backend/src/scripts/seedLarge.ts @@ -0,0 +1,174 @@ +/** + * Fyller databasen med et stort, generert datasett for å teste skalering. + * + * npm run seed:large # 10 000 annonser + * npm run seed:large -- --count 50000 # annet antall + * npm run seed:large -- --replace # slett tidligere genererte først + * + * Skriptet er idempotent: annonser som finnes fra før (samme ID), hoppes over. + * De vanlige seed-annonsene (listing-001 til listing-010) og annonser brukere + * har opprettet, røres ikke. Kjør `npm run seed` først hvis de mangler. + */ + +import { parseArgs } from 'node:util'; + +import type { PoolClient } from 'pg'; + +import { pool } from '../db/pool.js'; +import { + DEFAULT_SEED, + generateListings, + type GeneratedListing, +} from '../seed/generateListings.js'; +import { loadToolModels } from '../seed/toolModels.js'; + +const DEFAULT_COUNT = 10_000; +/** + * Rader per INSERT. PostgreSQL tillater maks 65 535 parametre per spørring, + * og hver rad bruker 10, så 1 000 rader holder god avstand til grensen. + */ +const BATCH_SIZE = 1_000; +/** Alle genererte annonser har ID-er på formen gen-000001. */ +const GENERATED_ID_PATTERN = 'gen-%'; + +const COLUMNS = [ + 'id', + 'title', + 'category', + 'description', + 'area', + 'daily_price', + 'condition', + 'accessories', + 'image_url', + 'created_at', +] as const; + +function parseOptions(): { count: number; seed: number; replace: boolean } { + const { values } = parseArgs({ + options: { + count: { type: 'string' }, + seed: { type: 'string' }, + replace: { type: 'boolean', default: false }, + }, + }); + + const count = + values.count === undefined ? DEFAULT_COUNT : Number(values.count); + const seed = values.seed === undefined ? DEFAULT_SEED : Number(values.seed); + + if (!Number.isInteger(count) || count < 0) { + throw new Error( + `--count må være et heltall som er 0 eller større, fikk «${values.count}».`, + ); + } + if (!Number.isInteger(seed)) { + throw new Error(`--seed må være et heltall, fikk «${values.seed}».`); + } + return { count, seed, replace: values.replace }; +} + +/** Setter inn én bolk med en INSERT som har én VALUES-rad per annonse. */ +async function insertBatch( + client: PoolClient, + batch: readonly GeneratedListing[], +): Promise { + const params: unknown[] = []; + const rows = batch.map((listing) => { + const values = [ + listing.id, + listing.title, + listing.category, + listing.description, + listing.area, + listing.dailyPrice, + listing.condition, + listing.accessories, + listing.imageUrl, + listing.createdAt, + ]; + const placeholders = values.map((value) => { + params.push(value); + return `$${params.length}`; + }); + return `(${placeholders.join(', ')})`; + }); + + const result = await client.query( + `INSERT INTO listings (${COLUMNS.join(', ')}) + VALUES ${rows.join(',\n')} + ON CONFLICT (id) DO NOTHING`, + params, + ); + return result.rowCount ?? 0; +} + +async function seedLarge(): Promise { + const { count, seed, replace } = parseOptions(); + const started = performance.now(); + + const listings = generateListings({ + count, + seed, + toolModels: loadToolModels(), + }); + const generatedMs = performance.now() - started; + + const client = await pool.connect(); + let inserted = 0; + let deleted = 0; + + try { + // Én transaksjon: enten kommer hele datasettet inn, eller ingenting endres. + await client.query('BEGIN'); + + if (replace) { + const result = await client.query( + 'DELETE FROM listings WHERE id LIKE $1', + [GENERATED_ID_PATTERN], + ); + deleted = result.rowCount ?? 0; + } + + for (let start = 0; start < listings.length; start += BATCH_SIZE) { + inserted += await insertBatch( + client, + listings.slice(start, start + BATCH_SIZE), + ); + } + + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + + const totals = await pool.query<{ total: string; generated: string }>( + `SELECT COUNT(*)::text AS total, + COUNT(*) FILTER (WHERE id LIKE $1)::text AS generated + FROM listings`, + [GENERATED_ID_PATTERN], + ); + const totalMs = performance.now() - started; + const { total, generated } = totals.rows[0] ?? { total: '0', generated: '0' }; + + console.info( + [ + `Seed av stort datasett fullført (seed-verdi ${seed}).`, + replace ? `Slettet ${deleted} tidligere genererte annonser.` : null, + `${inserted} nye, ${count - inserted} hoppet over (finnes fra før).`, + `Totalt ${total} rader i listings, hvorav ${generated} genererte.`, + `Tid: ${(totalMs / 1000).toFixed(1)} s (generering ${Math.round(generatedMs)} ms).`, + ] + .filter(Boolean) + .join('\n'), + ); +} + +try { + await seedLarge(); +} finally { + await pool.end(); +} diff --git a/backend/src/seed/data/toolModels.json b/backend/src/seed/data/toolModels.json new file mode 100644 index 0000000..d20ad1d --- /dev/null +++ b/backend/src/seed/data/toolModels.json @@ -0,0 +1,792 @@ +{ + "source": { + "name": "Wikidata", + "url": "https://www.wikidata.org", + "license": "CC0 1.0", + "retrieved": "2026-10-14" + }, + "toolTypes": [ + { + "wikidataId": "Q1158863", + "name": "damprenser", + "category": "cleaning", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1128099", + "name": "feiebrett", + "category": "cleaning", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1545773", + "name": "høytrykkspyler", + "category": "cleaning", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q828361", + "name": "mopp", + "category": "cleaning", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1143580", + "name": "nal", + "category": "cleaning", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q101674", + "name": "støvsuger", + "category": "cleaning", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q117207770", + "name": "tepperenser", + "category": "cleaning", + "nameSource": "manual" + }, + { + "wikidataId": "Q1540750", + "name": "greip", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q260521", + "name": "gressklipper", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1340233", + "name": "gresstrimmer", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q2480927", + "name": "hageslange", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1593024", + "name": "hekksaks", + "category": "garden", + "nameSource": "manual" + }, + { + "wikidataId": "Q1150400", + "name": "jordfreser", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q130912", + "name": "ljå", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1450186", + "name": "løvblåser", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q208040", + "name": "motorsag", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q6503658", + "name": "plenlufter", + "category": "garden", + "nameSource": "manual" + }, + { + "wikidataId": "Q200822", + "name": "rive", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q144764", + "name": "rosesaks", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q4249364", + "name": "ryddesag", + "category": "garden", + "nameSource": "manual" + }, + { + "wikidataId": "Q1351693", + "name": "snøfreser", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q216642", + "name": "spade", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1514872", + "name": "stolpehullsbor", + "category": "garden", + "nameSource": "manual" + }, + { + "wikidataId": "Q175334", + "name": "trillebår", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q2245442", + "name": "vannpumpe", + "category": "garden", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1195804", + "name": "baufil", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q13681", + "name": "brekkjern", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q193142", + "name": "fil", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q27103691", + "name": "flisekutter", + "category": "handTools", + "nameSource": "manual" + }, + { + "wikidataId": "Q25294", + "name": "hammer", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q204260", + "name": "høvel", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q2750929", + "name": "håndsag", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q269359", + "name": "kombinasjonstang", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q474188", + "name": "meisel", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1571051", + "name": "momentnøkkel", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1585781", + "name": "nebbtang", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q947463", + "name": "pipenøkkel", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q277885", + "name": "rasp", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q2645890", + "name": "rørtang", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1421844", + "name": "sagkrakk", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q16953", + "name": "sekskantnøkkel", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q449109", + "name": "skiftenøkkel", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q475638", + "name": "skrustikke", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q161071", + "name": "skrutrekker", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q846600", + "name": "skrutvinge", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1935441", + "name": "slegge", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q13221843", + "name": "stemjern", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q2801795", + "name": "stiftepistol", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q168639", + "name": "stige", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q39397", + "name": "øks", + "category": "handTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1643652", + "name": "avstandsmåler", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q135263727", + "name": "laservater", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1826705", + "name": "linjelaser", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1925365", + "name": "meterstokk", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q189996", + "name": "multimeter", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q214649", + "name": "målebånd", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q12408", + "name": "skyvelære", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q805083", + "name": "stenderfinner", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q646", + "name": "termometer", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q207988", + "name": "vaterpass", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1778014", + "name": "vinkelhake", + "category": "measuring", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q3057632", + "name": "gardintrapp", + "category": "painting", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1396432", + "name": "malerrulle", + "category": "painting", + "nameSource": "manual" + }, + { + "wikidataId": "Q3389292", + "name": "malingssprøyte", + "category": "painting", + "nameSource": "manual" + }, + { + "wikidataId": "Q193897", + "name": "pensel", + "category": "painting", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q921044", + "name": "sparkelspade", + "category": "painting", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q7963205", + "name": "tapetdamper", + "category": "painting", + "nameSource": "manual" + }, + { + "wikidataId": "Q456101", + "name": "batteridrill", + "category": "powerTools", + "nameSource": "manual" + }, + { + "wikidataId": "Q831584", + "name": "betongblander", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q58964", + "name": "boremaskin", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1932875", + "name": "borhammer", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q806378", + "name": "båndsag", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q806370", + "name": "båndsliper", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1183768", + "name": "dekupørsag", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q2455671", + "name": "eksentersliper", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q642560", + "name": "håndoverfres", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q17530767", + "name": "håndsirkelsag", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q373255", + "name": "kapp- og gjærsag", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1079988", + "name": "kompressor", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1304613", + "name": "limpistol", + "category": "powerTools", + "nameSource": "manual" + }, + { + "wikidataId": "Q623402", + "name": "loddebolt", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q13575304", + "name": "muttertrekker", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q6934510", + "name": "oscillerende multiverktøy", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1253445", + "name": "planhøvel", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q279497", + "name": "platevibrator", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1141837", + "name": "sirkelsag", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q19599600", + "name": "slagboremaskin", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q368341", + "name": "slagskrutrekker", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q77257", + "name": "spikerpistol", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1140903", + "name": "stikksag", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1936800", + "name": "sveiseapparat", + "category": "powerTools", + "nameSource": "manual" + }, + { + "wikidataId": "Q19766", + "name": "trykkluftbor", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1209958", + "name": "tykkelsehøvel", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q1057985", + "name": "varmepistol", + "category": "powerTools", + "nameSource": "wikidata" + }, + { + "wikidataId": "Q827274", + "name": "vinkelsliper", + "category": "powerTools", + "nameSource": "wikidata" + } + ], + "brands": [ + { + "name": "Bahco", + "wikidataId": "Q2656321", + "wikidataLabel": "Bahco", + "categories": [ + "handTools", + "garden" + ] + }, + { + "name": "Biltema", + "wikidataId": "Q3355552", + "wikidataLabel": "Biltema", + "categories": [ + "powerTools", + "handTools", + "garden", + "painting", + "measuring", + "cleaning" + ] + }, + { + "name": "Black+Decker", + "wikidataId": "Q10860313", + "wikidataLabel": "Black+Decker", + "categories": [ + "powerTools", + "garden", + "cleaning" + ] + }, + { + "name": "Bosch", + "wikidataId": "Q234021", + "wikidataLabel": "Robert Bosch", + "categories": [ + "powerTools", + "measuring", + "garden", + "cleaning" + ] + }, + { + "name": "Cocraft", + "wikidataId": "Q3356220", + "wikidataLabel": "Clas Ohlson", + "categories": [ + "powerTools", + "handTools", + "measuring", + "painting" + ] + }, + { + "name": "DeWalt", + "wikidataId": null, + "wikidataLabel": null, + "categories": [ + "powerTools", + "measuring" + ] + }, + { + "name": "Einhell", + "wikidataId": "Q1307902", + "wikidataLabel": "Einhell", + "categories": [ + "powerTools", + "garden", + "cleaning" + ] + }, + { + "name": "Festool", + "wikidataId": "Q320091", + "wikidataLabel": "Festool", + "categories": [ + "powerTools", + "cleaning" + ] + }, + { + "name": "Fiskars", + "wikidataId": "Q1241369", + "wikidataLabel": "Fiskars", + "categories": [ + "handTools", + "garden" + ] + }, + { + "name": "Gardena", + "wikidataId": null, + "wikidataLabel": null, + "categories": [ + "garden" + ] + }, + { + "name": "HiKOKI", + "wikidataId": null, + "wikidataLabel": null, + "categories": [ + "powerTools" + ] + }, + { + "name": "Husqvarna", + "wikidataId": "Q899356", + "wikidataLabel": "Husqvarna Group", + "categories": [ + "garden" + ] + }, + { + "name": "Kärcher", + "wikidataId": "Q696798", + "wikidataLabel": "Kärcher", + "categories": [ + "cleaning" + ] + }, + { + "name": "Limit", + "wikidataId": null, + "wikidataLabel": null, + "categories": [ + "measuring", + "handTools" + ] + }, + { + "name": "Makita", + "wikidataId": "Q691508", + "wikidataLabel": "Makita", + "categories": [ + "powerTools", + "garden" + ] + }, + { + "name": "Meec Tools", + "wikidataId": "Q10541151", + "wikidataLabel": "Jula", + "categories": [ + "powerTools", + "handTools", + "garden", + "painting" + ] + }, + { + "name": "Metabo", + "wikidataId": "Q457140", + "wikidataLabel": "Metabo", + "categories": [ + "powerTools" + ] + }, + { + "name": "Milwaukee", + "wikidataId": null, + "wikidataLabel": null, + "categories": [ + "powerTools" + ] + }, + { + "name": "Nilfisk", + "wikidataId": "Q969760", + "wikidataLabel": "Nilfisk", + "categories": [ + "cleaning" + ] + }, + { + "name": "Ryobi", + "wikidataId": "Q1104912", + "wikidataLabel": "Ryobi", + "categories": [ + "powerTools", + "garden" + ] + }, + { + "name": "Stanley", + "wikidataId": "Q706094", + "wikidataLabel": "Stanley Black & Decker", + "categories": [ + "handTools", + "measuring" + ] + }, + { + "name": "Stihl", + "wikidataId": "Q239372", + "wikidataLabel": "Stihl", + "categories": [ + "garden" + ] + }, + { + "name": "Würth", + "wikidataId": "Q679750", + "wikidataLabel": "Würth", + "categories": [ + "handTools" + ] + } + ] +} diff --git a/backend/src/seed/generateListings.test.ts b/backend/src/seed/generateListings.test.ts new file mode 100644 index 0000000..48b6173 --- /dev/null +++ b/backend/src/seed/generateListings.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_REFERENCE_DATE, + generateListings, + type GeneratedListing, +} from './generateListings.js'; +import { textsForTool } from './listingTexts.js'; +import { loadToolModels } from './toolModels.js'; + +const toolModels = loadToolModels(); +const DAY_MS = 24 * 60 * 60 * 1000; + +// Genereres én gang og deles av testene, siden det er det samme datasettet. +const listings = generateListings({ count: 10_000, toolModels }); + +function countBy(items: readonly T[], key: (item: T) => string | number) { + const counts = new Map(); + for (const item of items) { + counts.set(key(item), (counts.get(key(item)) ?? 0) + 1); + } + return counts; +} + +describe('generateListings', () => { + it('lager nøyaktig så mange annonser som bedt om, med unike ID-er', () => { + expect(listings).toHaveLength(10_000); + expect(new Set(listings.map((listing) => listing.id)).size).toBe(10_000); + expect(listings[0]?.id).toBe('gen-000001'); + expect(listings.at(-1)?.id).toBe('gen-010000'); + }); + + it('gir samme datasett med samme seed-verdi og et annet med en annen seed-verdi', () => { + const first = generateListings({ count: 200, toolModels, seed: 1 }); + const again = generateListings({ count: 200, toolModels, seed: 1 }); + const other = generateListings({ count: 200, toolModels, seed: 2 }); + + expect(again).toEqual(first); + expect(other).not.toEqual(first); + }); + + it('håndterer 0 annonser og avviser ugyldig antall', () => { + expect(generateListings({ count: 0, toolModels })).toEqual([]); + expect(() => generateListings({ count: -1, toolModels })).toThrow(); + expect(() => generateListings({ count: 1.5, toolModels })).toThrow(); + }); + + it('lager bare gyldige verdier som passer databasen', () => { + const categories = new Set( + toolModels.toolTypes.map((type) => type.category), + ); + const conditions = new Set(['likeNew', 'good', 'worn']); + + for (const listing of listings) { + expect(categories.has(listing.category)).toBe(true); + expect(conditions.has(listing.condition)).toBe(true); + expect(Number.isInteger(listing.dailyPrice)).toBe(true); + expect(listing.dailyPrice).toBeGreaterThanOrEqual(0); + expect(listing.title.trim()).not.toBe(''); + expect(listing.description.trim()).not.toBe(''); + expect(listing.area.trim()).not.toBe(''); + } + }); + + it('har 15–25 % gratis utlån', () => { + const freeShare = + listings.filter((listing) => listing.dailyPrice === 0).length / + listings.length; + + expect(freeShare).toBeGreaterThanOrEqual(0.15); + expect(freeShare).toBeLessThanOrEqual(0.25); + }); + + it('har skjev fordeling mellom kategorier og områder', () => { + const categoryCounts = [ + ...countBy(listings, (listing) => listing.category).values(), + ]; + const areaCounts = [ + ...countBy(listings, (listing) => listing.area).values(), + ]; + + expect(Math.max(...categoryCounts)).toBeGreaterThan( + 2 * Math.min(...categoryCounts), + ); + expect(Math.max(...areaCounts)).toBeGreaterThan( + 5 * Math.min(...areaCounts), + ); + }); + + it('har mange annonser med samme pris', () => { + const priceCounts = countBy(listings, (listing) => listing.dailyPrice); + + // Få, runde priser gjør at sortering på pris må håndtere like verdier. + expect(priceCounts.size).toBeLessThan(30); + expect(Math.max(...priceCounts.values())).toBeGreaterThan(500); + }); + + it('varierer lengden på titler og beskrivelser', () => { + const titleLengths = listings.map((listing) => listing.title.length); + const descriptionLengths = listings.map( + (listing) => listing.description.length, + ); + + expect(Math.min(...titleLengths)).toBeLessThan(15); + expect(Math.max(...titleLengths)).toBeGreaterThan(50); + expect(Math.min(...descriptionLengths)).toBeLessThan(60); + expect(Math.max(...descriptionLengths)).toBeGreaterThan(300); + }); + + it('har både annonser med og uten bilde og tilbehør', () => { + const withImage = listings.filter((listing) => listing.imageUrl !== null); + const withoutAccessories = listings.filter( + (listing) => listing.accessories.length === 0, + ); + + expect(withImage.length).toBeGreaterThan(0); + expect(withImage.length).toBeLessThan(listings.length); + expect(withoutAccessories.length).toBeGreaterThan(0); + expect(withoutAccessories.length).toBeLessThan(listings.length); + }); + + it('sprer opprettelsesdatoene over det siste året', () => { + const times = listings.map((listing: GeneratedListing) => + listing.createdAt.getTime(), + ); + const newest = DEFAULT_REFERENCE_DATE.getTime(); + const oldest = newest - 366 * DAY_MS; + + expect(Math.max(...times)).toBeLessThanOrEqual(newest); + expect(Math.min(...times)).toBeGreaterThanOrEqual(oldest); + // Datasettet skal dekke hele året, ikke bare de siste ukene. + expect(Math.min(...times)).toBeLessThan(newest - 300 * DAY_MS); + }); +}); + +describe('textsForTool', () => { + it('velger tekster som passer verktøyet, ikke bare kategorien', () => { + const sander = textsForTool('powerTools', 'båndsliper'); + const rake = textsForTool('garden', 'rive'); + + expect(sander.uses.join(' ')).not.toContain('hyller'); + expect(rake.accessories).not.toContain('Ekstra kjede'); + expect(rake.powered).toBe(false); + expect(textsForTool('garden', 'motorsag').powered).toBe(true); + }); +}); diff --git a/backend/src/seed/generateListings.ts b/backend/src/seed/generateListings.ts new file mode 100644 index 0000000..5384df2 --- /dev/null +++ b/backend/src/seed/generateListings.ts @@ -0,0 +1,286 @@ +/** + * Genererer et stort og variert sett med eksempelannonser. + * + * Verktøytyper og merker er ekte (hentet fra Wikidata). Selve annonsene, altså + * eier, pris, tilstand, område og tekst, genereres, fordi det ikke finnes en + * åpen datakilde for utlån av verktøy i Trondheim. + * + * Funksjonen er ren: den bruker ingen database, og samme seed-verdi gir alltid samme + * annonser. Det gjør den enkel å teste og gjør datasettet reproduserbart. + */ + +import type { ToolCategory } from '../types/listing.js'; +import { listingImageUrl } from './listingImageUrl.js'; +import { + areaWeights, + categoryWeights, + conditionPhrases, + conditionWeights, + extraPhrases, + FREE_SHARE, + historyPhrases, + imageByToolName, + manualOnlyBrands, + pickupPhrases, + poweredOnlyBrands, + priceWeights, + textsForTool, + type ToolTexts, +} from './listingTexts.js'; +import type { SeedListing } from './listings.js'; +import { createRandom, type Random } from './random.js'; +import type { Brand, ToolModelsFile, ToolType } from './toolModels.js'; + +export interface GeneratedListing extends SeedListing { + createdAt: Date; +} + +export interface GenerateOptions { + count: number; + toolModels: ToolModelsFile; + /** Seed-verdi for tilfeldighetene. Samme seed-verdi gir samme datasett. */ + seed?: number; + /** + * Seneste mulige opprettelsesdato. Fast som standard, slik at datasettet + * ikke endrer seg fra dag til dag. + */ + referenceDate?: Date; +} + +export const DEFAULT_SEED = 2810; +export const DEFAULT_REFERENCE_DATE = new Date('2026-10-01T12:00:00Z'); + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Andel annonser som har et merke i tittelen. Resten er umerkede. */ +const BRAND_SHARE = 0.7; +/** Andel annonser uten tilbehør. */ +const NO_ACCESSORIES_SHARE = 0.3; +/** Andel annonser med passende bilde som faktisk har bilde. */ +const IMAGE_SHARE = 0.8; + +/** Antall setninger i beskrivelsen. De fleste er korte, noen er lange. */ +const sentenceCountWeights = [ + [1, 12], + [2, 22], + [3, 26], + [4, 20], + [5, 12], + [7, 8], +] as const; + +function capitalize(text: string): string { + return text.charAt(0).toLocaleUpperCase('nb-NO') + text.slice(1); +} + +/** «a», «a og b», «a, b og c». */ +function joinNorwegian(items: readonly string[]): string { + if (items.length <= 1) { + return items.join(''); + } + return `${items.slice(0, -1).join(', ')} og ${items.at(-1)}`; +} + +/** + * Gir verktøytypene i hver kategori ulik popularitet. Rekkefølgen stokkes med + * seed-verdien, og vekten faller med plasseringen, slik at noen verktøy er vanlige og + * mange er sjeldne, som i virkeligheten. + */ +function buildToolWeights( + toolTypes: readonly ToolType[], + random: Random, +): Map { + const byCategory = new Map(); + for (const type of toolTypes) { + byCategory.set(type.category, [ + ...(byCategory.get(type.category) ?? []), + type, + ]); + } + + const weights = new Map(); + for (const [category, types] of byCategory) { + const shuffled = random.sample(types, types.length); + weights.set( + category, + shuffled.map((type, rank) => [type, 1 / (rank + 1) ** 0.8] as const), + ); + } + return weights; +} + +function buildTitle( + random: Random, + texts: ToolTexts, + typeName: string, + brand: Brand | null, + isFree: boolean, +): string { + const type = typeName; + const Type = capitalize(typeName); + + const templates: [() => string, number][] = [ + [() => Type, 20], + [() => `${Type} ${random.pick(texts.specs)}`, 10], + [() => `${Type} – ${random.pick(texts.uses)}`, 8], + [() => `Lite brukt ${type}`, 5], + [() => `${Type} til utlån`, 5], + ]; + if (brand) { + templates.push( + [() => `${brand.name} ${type}`, 30], + [() => `${Type} fra ${brand.name}`, 10], + [() => `${brand.name} ${type} ${random.pick(texts.specs)}`, 15], + ); + } + if (isFree) { + templates.push([() => `Gratis utlån av ${type}`, 8]); + } + + return random.weighted(templates)(); +} + +function buildDescription( + random: Random, + listing: { + texts: ToolTexts; + typeName: string; + brand: Brand | null; + condition: GeneratedListing['condition']; + area: string; + accessories: string[]; + isFree: boolean; + }, +): string { + const { texts, typeName, brand, area, accessories, isFree } = listing; + const condition = random.pick(conditionPhrases[listing.condition]); + const brandAndType = brand ? `${brand.name} ${typeName}` : typeName; + + const intro = random.weighted<() => string>([ + [() => `${capitalize(brandAndType)} ${condition}.`, 3], + // «Leier ut» står uten tilstand, fordi frasene som «fungerer som den + // skal» ikke passer etter den. + [() => `Leier ut ${brandAndType}.`, 2], + [ + () => + brand + ? `${capitalize(typeName)} fra ${brand.name}, ${condition}.` + : `${capitalize(typeName)}, ${condition}.`, + 1, + ], + ])(); + + const candidates = [ + `${capitalize(random.pick(texts.uses))}.`, + random.pick(historyPhrases), + ...(accessories.length > 0 + ? [`Følger med: ${joinNorwegian(accessories)}.`] + : []), + random.pick(pickupPhrases)(area), + ...(isFree ? ['Gratis å låne, jeg vil bare at den kommer til nytte.'] : []), + ...random.sample(extraPhrases, 3), + ]; + + const sentenceCount = random.weighted(sentenceCountWeights); + const sentences = [intro, ...candidates.slice(0, sentenceCount - 1)]; + + // Lange beskrivelser deles i to avsnitt, slik folk ofte skriver. + if (sentences.length >= 6) { + const middle = Math.ceil(sentences.length / 2); + return `${sentences.slice(0, middle).join(' ')}\n\n${sentences.slice(middle).join(' ')}`; + } + return sentences.join(' '); +} + +/** + * Opprettelsesdato innen det siste året før referansedatoen. Nyere annonser er + * vanligere enn gamle, slik det er på en aktiv markedsplass. + */ +function buildCreatedAt(random: Random, referenceDate: Date): Date { + const daysAgo = Math.floor(365 * random.next() ** 2); + const msIntoDay = Math.floor(random.next() * DAY_MS); + return new Date(referenceDate.getTime() - daysAgo * DAY_MS - msIntoDay); +} + +export function generateListings(options: GenerateOptions): GeneratedListing[] { + const { + count, + toolModels, + seed = DEFAULT_SEED, + referenceDate = DEFAULT_REFERENCE_DATE, + } = options; + + if (!Number.isInteger(count) || count < 0) { + throw new Error( + `Antall annonser må være et heltall som er 0 eller større, fikk ${count}.`, + ); + } + + const random = createRandom(seed); + const toolWeights = buildToolWeights(toolModels.toolTypes, random); + const listings: GeneratedListing[] = []; + + for (let index = 0; index < count; index += 1) { + const category = random.weighted(categoryWeights); + const tools = toolWeights.get(category); + if (!tools) { + throw new Error(`Ingen verktøytyper i kategorien ${category}.`); + } + const tool = random.weighted(tools); + const texts = textsForTool(category, tool.name); + + // Merket må selges i kategorien og passe til verktøyet: rene motormerker + // brukes bare på motorisert verktøy, og rene håndverktøymerker bare på + // manuelt verktøy. + const brandsForCategory = toolModels.brands.filter( + (brand) => + brand.categories.includes(category) && + (texts.powered + ? !manualOnlyBrands.has(brand.name) + : !poweredOnlyBrands.has(brand.name)), + ); + const brand = + brandsForCategory.length > 0 && random.chance(BRAND_SHARE) + ? random.pick(brandsForCategory) + : null; + + const isFree = random.chance(FREE_SHARE); + const dailyPrice = isFree ? 0 : random.weighted(priceWeights[category]); + const condition = random.weighted(conditionWeights); + const area = random.weighted(areaWeights); + const accessories = random.chance(NO_ACCESSORIES_SHARE) + ? [] + : random.sample(texts.accessories, random.int(1, 4)); + + const imageFile = imageByToolName[tool.name]; + const imageUrl = + imageFile && random.chance(IMAGE_SHARE) + ? listingImageUrl(imageFile) + : null; + + listings.push({ + // Egen ID-serie, så de genererte annonsene aldri kolliderer med + // listing-001 til listing-010 eller annonser brukere oppretter. + id: `gen-${String(index + 1).padStart(6, '0')}`, + title: buildTitle(random, texts, tool.name, brand, isFree), + category, + description: buildDescription(random, { + texts, + typeName: tool.name, + brand, + condition, + area, + accessories, + isFree, + }), + area, + dailyPrice, + condition, + accessories, + imageUrl, + createdAt: buildCreatedAt(random, referenceDate), + }); + } + + return listings; +} diff --git a/backend/src/seed/listingImageUrl.ts b/backend/src/seed/listingImageUrl.ts new file mode 100644 index 0000000..120cb7d --- /dev/null +++ b/backend/src/seed/listingImageUrl.ts @@ -0,0 +1,12 @@ +/** + * Offentlig bildesti for annonser etter deploy under /project2. + * Filnavn tilsvarer SVG-ene i frontend public/assets/listings/. + */ + +const DEFAULT_APP_BASE = '/project2'; + +export function listingImageUrl(filename: string): string { + const base = process.env.PUBLIC_APP_BASE ?? DEFAULT_APP_BASE; + const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base; + return `${normalizedBase}/assets/listings/${filename}`; +} diff --git a/backend/src/seed/listingTexts.ts b/backend/src/seed/listingTexts.ts new file mode 100644 index 0000000..29a3c5a --- /dev/null +++ b/backend/src/seed/listingTexts.ts @@ -0,0 +1,540 @@ +/** + * Tekstbiter og fordelinger for genererte annonser. + * + * Vektene er bevisst skjeve, slik at datasettet ligner ekte annonser: noen + * områder og kategorier har mange flere annonser enn andre, og mange annonser + * har samme pris. Da blir søk, filtrering og sortering testet på realistiske + * data og ikke på en jevn, kunstig fordeling. + */ + +import type { ToolCategory, ToolCondition } from '../types/listing.js'; + +type Weighted = readonly (readonly [T, number])[]; + +/** Bydeler og områder i Trondheim. Studentområdene har flest annonser. */ +export const areaWeights: Weighted = [ + ['Moholt', 14], + ['Gløshaugen', 10], + ['Lade', 9], + ['Singsaker', 8], + ['Sentrum', 8], + ['Møllenberg', 7], + ['Byåsen', 6], + ['Heimdal', 6], + ['Nardo', 5], + ['Lerkendal', 5], + ['Tiller', 5], + ['Øya', 4], + ['Rosenborg', 4], + ['Ila', 4], + ['Bakklandet', 3], + ['Tyholt', 3], + ['Ranheim', 3], + ['Kolstad', 2], + ['Saupstad', 2], + ['Charlottenlund', 2], + ['Strinda', 2], + ['Valentinlyst', 2], + ['Jakobsli', 1], + ['Vikåsen', 1], + ['Flatåsen', 1], + ['Sverresborg', 1], + ['Persaunet', 1], + ['Ugla', 1], + ['Stavset', 1], + ['Klæbu', 1], +]; + +/** Hvor stor andel av annonsene som havner i hver kategori. */ +export const categoryWeights: Weighted = [ + ['powerTools', 32], + ['handTools', 22], + ['garden', 20], + ['cleaning', 10], + ['painting', 8], + ['measuring', 8], +]; + +export const conditionWeights: Weighted = [ + ['likeNew', 25], + ['good', 55], + ['worn', 20], +]; + +/** Andel annonser med gratis utlån (pris 0). */ +export const FREE_SHARE = 0.2; + +/** + * Typiske døgnpriser per kategori. De lave prisene er vanligst, og alle er + * runde tall, slik at mange annonser får samme pris. + */ +export const priceWeights: Record> = { + powerTools: [ + [50, 10], + [75, 8], + [100, 12], + [120, 5], + [150, 8], + [200, 5], + [250, 3], + [300, 2], + [400, 1], + [500, 1], + ], + handTools: [ + [20, 10], + [25, 8], + [30, 8], + [40, 6], + [50, 6], + [75, 3], + [100, 2], + ], + garden: [ + [30, 6], + [40, 6], + [50, 8], + [75, 6], + [100, 7], + [150, 5], + [200, 4], + [300, 2], + [450, 1], + ], + painting: [ + [20, 8], + [30, 6], + [50, 6], + [75, 3], + [100, 3], + [150, 2], + ], + measuring: [ + [25, 6], + [30, 6], + [50, 6], + [75, 4], + [100, 4], + [150, 2], + [200, 1], + ], + cleaning: [ + [50, 6], + [75, 5], + [100, 7], + [150, 6], + [200, 4], + [250, 3], + [350, 1], + ], +}; + +/** Tekster som passer til et verktøy: bruksområder, tilbehør og tittelspesifikasjoner. */ +export interface ToolTexts { + uses: readonly string[]; + accessories: readonly string[]; + specs: readonly string[]; + /** Motorisert verktøy (strøm, batteri eller bensin). Styrer hvilke merker som passer. */ + powered: boolean; +} + +interface ToolTextRule extends Partial { + /** Treffer verktøynavnet, for eksempel /sag/ for stikksag og sirkelsag. */ + pattern: RegExp; +} + +interface CategoryTexts { + /** Reserve for verktøy som ikke treffer noen regel. */ + general: ToolTexts; + /** Første regel som treffer verktøynavnet, gjelder. */ + rules: readonly ToolTextRule[]; +} + +/** + * Tekstene velges per verktøygruppe, ikke bare per kategori. Ellers får en + * båndsliper teksten «passer til å skru opp hyller», og en rive får trimmertråd + * og ekstra kjede som tilbehør. + */ +export const categoryTexts: Record = { + powerTools: { + general: { + uses: [ + 'gjør jobben mye raskere enn for hånd', + 'perfekt til små og store prosjekter i leiligheten', + 'fin til oppussing av bad og kjøkken', + ], + accessories: [ + 'Koffert', + 'Skjøteledning', + 'Vernebriller', + 'Hørselvern', + 'Bruksanvisning', + ], + specs: ['lite brukt', 'profesjonell modell', 'nesten ny'], + powered: true, + }, + rules: [ + { + pattern: /bor|drill|skrutrekker|muttertrekker/, + uses: [ + 'passer til å skru opp hyller og montere møbler', + 'kraftig nok til betong og mur', + 'fin til å henge opp bilder, lamper og TV-braketter', + ], + accessories: [ + 'Lader', + '2 batterier', + 'Ekstra batteri', + 'Bitssett', + 'Borsett', + ], + specs: ['18 V', '12 V', 'med 2 batterier', 'børsteløs', 'med koffert'], + }, + { + pattern: /sag/, + uses: [ + 'kapper rett og presist', + 'fin til å sage kurver i treplater og laminat', + 'passer til terrassebord og listverk', + ], + accessories: ['Sagblader', 'Ekstra sagblad', 'Parallellanlegg'], + specs: ['1200 W', 'med ekstra blader', '18 V'], + }, + { + pattern: /sliper|høvel|fres/, + uses: [ + 'gir glatt overflate før maling og lakkering', + 'fin til å pusse opp gamle møbler', + 'passer til gulv, dører og listverk', + ], + accessories: ['Slipepapir', 'Støvpose', 'Ekstra høvelstål'], + specs: ['med støvpose', '1200 W'], + }, + { + pattern: /blander|vibrator|pigghammer/, + uses: [ + 'passer til støping og grunnarbeid', + 'tung, men gjør jobben på kort tid', + 'egnet til større byggeprosjekter', + ], + specs: ['tung modell', 'profesjonell modell'], + }, + ], + }, + handTools: { + general: { + uses: [ + 'passer til små reparasjoner hjemme', + 'god å ha i hytta eller på hybelen', + 'solid og ligger godt i hånda', + ], + accessories: ['Oppbevaringsboks', 'Verktøyveske', 'Arbeidshansker'], + specs: ['lite brukt', 'solid', 'profesjonell'], + powered: false, + }, + rules: [ + { + pattern: /nøkkel|tang|skrutrekker/, + uses: [ + 'grei å ha når noe skal skrus fra hverandre', + 'fin til sykkelreparasjoner og møbelmontering', + ], + accessories: ['Pipesett', 'Bitssett'], + specs: ['i sett', 'med etui'], + }, + { + pattern: /øks/, + uses: ['fin til vedhogging på hytta', 'skarp og godt balansert'], + }, + { + pattern: /slegge|brekkjern|meisel/, + uses: ['nyttig når noe skal rives', 'tåler hardt arbeid'], + }, + { + pattern: /sag|baufil|høvel|stemjern|fil|rasp/, + uses: [ + 'fin til trearbeid og små snekkerprosjekter', + 'skarp og klar til bruk', + ], + accessories: ['Ekstra blad'], + }, + ], + }, + garden: { + general: { + uses: [ + 'perfekt til vårrengjøring i hagen', + 'passer til både små og store hager', + 'gjør hagearbeidet mye lettere', + ], + accessories: ['Hansker'], + specs: ['til store hager', 'lite brukt'], + powered: false, + }, + rules: [ + { + pattern: /motorsag|ryddesag/, + uses: [ + 'brukt til å rydde kratt og busker', + 'fin til å kappe ved til vinteren', + ], + accessories: [ + 'Ekstra kjede', + 'Kjedeolje', + 'Bensinkanne', + 'Visir', + 'Hørselvern', + ], + specs: ['bensin', 'batteridrevet'], + powered: true, + }, + { + pattern: /trimmer|gressklipper|hekksaks|plenlufter|løvblåser/, + uses: [ + 'fin til å holde hekken og plenen pen gjennom sommeren', + 'gjør høstarbeidet i hagen mye lettere', + ], + accessories: [ + 'Trimmertråd', + 'Oppsamler', + 'Batteri og lader', + 'Hørselvern', + ], + specs: ['batteridrevet', 'elektrisk', 'bensin'], + powered: true, + }, + { + pattern: /snøfreser/, + uses: ['rydder oppkjørselen på minutter etter snøfall'], + accessories: ['Bensinkanne'], + specs: ['bensin', 'elektrisk'], + powered: true, + }, + { + pattern: /jordfreser|stolpehullsbor|vannpumpe/, + uses: [ + 'passer til større hageprosjekter', + 'sparer deg for mye graving', + ], + accessories: ['Bensinkanne', 'Slange'], + specs: ['bensin'], + powered: true, + }, + ], + }, + painting: { + general: { + uses: [ + 'passer til maling av vegger og tak', + 'fin til oppussing før innflytting', + 'brukt til å male hele leiligheten i fjor', + ], + accessories: ['Dekkplast', 'Maskeringstape'], + specs: ['med tilbehør', 'nesten ny'], + powered: false, + }, + rules: [ + { + pattern: /rulle/, + uses: ['gir jevnt resultat uten striper'], + accessories: ['Malerbrett', 'Teleskopskaft', 'Ekstra ruller'], + specs: ['med teleskopskaft'], + }, + { + pattern: /sprøyte/, + uses: [ + 'maler en hel vegg på noen minutter', + 'fin til gjerder og fasader', + ], + accessories: ['Ekstra dyse'], + powered: true, + }, + { + pattern: /tapetdamper/, + uses: ['gjør det enkelt å fjerne gammel tapet'], + accessories: ['Slange', 'Sparkel'], + powered: true, + }, + ], + }, + measuring: { + general: { + uses: [ + 'presis nok til snekkerarbeid', + 'fin til å planlegge oppussing og møblering', + 'gjør det enkelt å måle opp rom og vegger', + ], + accessories: ['Etui', 'Bruksanvisning'], + specs: ['presis', 'med etui'], + powered: false, + }, + rules: [ + { + pattern: /laser|vater|avstand/, + uses: ['nyttig når hyller og bilder skal henge rett'], + accessories: ['Batterier', 'Stativ', 'Mottaker'], + specs: ['digital', 'med stativ'], + powered: true, + }, + { + pattern: /multimeter|termometer|stender/, + uses: ['fin til å finne feil før du borer eller skrur'], + accessories: ['Batterier', 'Måleledninger'], + specs: ['digital'], + powered: true, + }, + ], + }, + cleaning: { + general: { + uses: [ + 'perfekt til utvask når du skal flytte', + 'gjør storrengjøringen enklere', + ], + accessories: ['Bøtte', 'Ekstra mopp'], + specs: ['lite brukt'], + powered: false, + }, + rules: [ + { + pattern: /høytrykkspyler/, + uses: [ + 'fin til terrasse, bil og fasade', + 'fjerner mose og skitt fra helleplater', + ], + accessories: ['Slange', 'Munnstykker', 'Terrassevasker'], + specs: ['150 bar', 'med tilbehør'], + powered: true, + }, + { + pattern: /tepperenser|damprenser|støvsuger/, + uses: [ + 'fjerner flekker fra tepper og sofaer', + 'gjør storrengjøringen mye raskere', + ], + accessories: [ + 'Munnstykker', + 'Rengjøringsmiddel', + 'Filter', + 'Skjøteledning', + ], + specs: ['til tepper og møbler', 'kraftig'], + powered: true, + }, + ], + }, +}; + +/** + * Finner tekstene for et verktøy. Bruksområder og spesifikasjoner kommer fra + * regelen som treffer, mens tilbehøret er regelens pluss det generelle for + * kategorien. + */ +export function textsForTool( + category: ToolCategory, + toolName: string, +): ToolTexts { + const { general, rules } = categoryTexts[category]; + const rule = rules.find((candidate) => candidate.pattern.test(toolName)); + return { + uses: rule?.uses ?? general.uses, + accessories: [...(rule?.accessories ?? []), ...general.accessories], + specs: rule?.specs ?? general.specs, + powered: rule?.powered ?? general.powered, + }; +} + +export const conditionPhrases: Record = { + likeNew: [ + 'som ny', + 'nesten ubrukt', + 'i strøken stand', + 'kjøpt i år og lite brukt', + ], + good: [ + 'i god stand', + 'godt vedlikeholdt', + 'fungerer som den skal', + 'i fin stand', + ], + worn: [ + 'litt slitt, men fungerer fint', + 'godt brukt, men gjør jobben', + 'med noen riper, men i full drift', + 'har sett noen prosjekter, men virker som den skal', + ], +}; + +export const historyPhrases: readonly string[] = [ + 'Brukt et par ganger i året.', + 'Kjøpt til et prosjekt som nå er ferdig.', + 'Har fulgt meg gjennom tre flyttinger.', + 'Arvet fra bestefar, men fortsatt i god form.', + 'Står mest i boden, så den kan like gjerne komme til nytte.', + 'Kjøpt i fjor og bare brukt én gang.', + 'Vi har to, så denne kan lånes ut.', +]; + +export const pickupPhrases: readonly ((area: string) => string)[] = [ + (area) => `Hentes på ${area}.`, + (area) => `Henting på ${area}, gjerne etter klokka 16.`, + (area) => `Kan hentes på ${area} etter avtale.`, + (area) => `Bor på ${area} og er hjemme de fleste kvelder.`, + (area) => `Henting på ${area}. Kan levere på Gløshaugen om det passer bedre.`, +]; + +export const extraPhrases: readonly string[] = [ + 'Husk vernebriller.', + 'Leveres rengjort, og jeg forventer det samme tilbake.', + 'Send gjerne melding om du lurer på noe.', + 'Ikke egnet for proffbruk, men perfekt til hjemmebruk.', + 'Depositum er ikke nødvendig, men ta godt vare på den.', + 'Kan lånes over helgen om du vil ha et lengre prosjekt.', + 'Bruksanvisning ligger i esken.', + 'Si fra hvis noe går i stykker, så finner vi en løsning.', +]; + +/** + * Merker som bare lager motorisert utstyr. De brukes ikke på manuelle verktøy, + * så vi unngår annonser som «Stihl spade» eller «Kärcher mopp». + */ +export const poweredOnlyBrands: ReadonlySet = new Set([ + 'Makita', + 'Bosch', + 'DeWalt', + 'Milwaukee', + 'Ryobi', + 'Einhell', + 'Metabo', + 'HiKOKI', + 'Black+Decker', + 'Festool', + 'Husqvarna', + 'Stihl', + 'Kärcher', + 'Nilfisk', +]); + +/** Merker som bare lager manuelt verktøy. De brukes ikke på motorisert utstyr. */ +export const manualOnlyBrands: ReadonlySet = new Set([ + 'Fiskars', + 'Bahco', + 'Würth', + 'Limit', +]); + +/** + * Bilder som finnes i frontend (public/assets/listings). De brukes bare på + * verktøytyper bildet faktisk viser, slik at en vinkelsliper ikke får bilde av + * en drill. Alle andre annonser vises med plassholder. + */ +export const imageByToolName: Record = { + boremaskin: 'drill.svg', + batteridrill: 'drill.svg', + slagboremaskin: 'drill.svg', + slagskrutrekker: 'drill.svg', + håndsag: 'handsaw.svg', + baufil: 'handsaw.svg', + malerrulle: 'paint-roller.svg', + spade: 'spade.svg', +}; diff --git a/backend/src/seed/listings.ts b/backend/src/seed/listings.ts new file mode 100644 index 0000000..c5db1ed --- /dev/null +++ b/backend/src/seed/listings.ts @@ -0,0 +1,146 @@ +/** + * Seed-data for eksempelannonsene. Innholdet tilsvarer src/data/mockListings.ts + * uten å importere frontend eller SVG-filer. + */ + +import type { ToolCategory, ToolCondition } from '../types/listing.js'; +import { listingImageUrl } from './listingImageUrl.js'; + +export interface SeedListing { + id: string; + title: string; + category: ToolCategory; + description: string; + area: string; + dailyPrice: number; + condition: ToolCondition; + accessories: string[]; + imageUrl: string | null; +} + +export const seedListings: SeedListing[] = [ + { + id: 'listing-001', + title: 'Drill', + category: 'powerTools', + description: + 'Batteridrill som passer til å skru opp hyller og montere møbler.', + area: 'Moholt', + dailyPrice: 100, + condition: 'good', + accessories: ['2 batterier', 'Lader', 'Bitssett'], + imageUrl: listingImageUrl('drill.svg'), + }, + { + id: 'listing-002', + title: 'Slagdrill med borsett for betong og murvegger', + category: 'powerTools', + description: + 'Kraftig slagdrill for boring i betong, mur og tegl. Egner seg til å henge opp tunge hyller, TV-braketter og kjøkkenskap. Borsett fra 6 til 12 mm følger med. Bruk hørselvern ved lengre arbeid.', + area: 'Lade', + dailyPrice: 150, + condition: 'likeNew', + accessories: ['Borsett for betong', 'Koffert', 'Dybdestopper'], + imageUrl: listingImageUrl('drill.svg'), + }, + { + id: 'listing-003', + title: 'Fogsvans', + category: 'handTools', + description: 'Vanlig håndsag for tre. Bladet er skarpt.', + area: 'Gløshaugen', + dailyPrice: 0, + condition: 'good', + accessories: [], + imageUrl: listingImageUrl('handsaw.svg'), + }, + { + id: 'listing-004', + title: 'Verktøykasse med hammer, skrutrekkere og tenger', + category: 'handTools', + description: + 'Grunnleggende verktøy for småreparasjoner i hybelen. Kassen har hammer, flere skrutrekkere, nebbtang, kombinasjonstang og målebånd.', + area: 'Singsaker', + dailyPrice: 50, + condition: 'worn', + accessories: ['Målebånd', 'Unbrakonøkler'], + imageUrl: null, + }, + { + id: 'listing-005', + title: 'Spade', + category: 'garden', + description: 'Solid spade til hagearbeid og planting.', + area: 'Byåsen', + dailyPrice: 0, + condition: 'worn', + accessories: [], + imageUrl: listingImageUrl('spade.svg'), + }, + { + id: 'listing-006', + title: 'Hagespade og rive', + category: 'garden', + description: + 'Spade og rive som lånes ut sammen. Fint til å gjøre klar et lite bed eller rake løv om høsten.', + area: 'Tiller', + dailyPrice: 40, + condition: 'good', + accessories: ['Rive', 'Arbeidshansker'], + imageUrl: listingImageUrl('spade.svg'), + }, + { + id: 'listing-007', + title: 'Malerrulle og brett', + category: 'painting', + description: + 'Rulle og malebrett for vegger. Rullene er vasket og klare til bruk.', + area: 'Møllenberg', + dailyPrice: 0, + condition: 'likeNew', + accessories: ['2 ruller', 'Malebrett', 'Teleskopskaft'], + imageUrl: listingImageUrl('paint-roller.svg'), + }, + { + id: 'listing-008', + title: + 'Komplett malersett til en liten leilighet: ruller, pensler, dekkplast og tape', + category: 'painting', + description: + 'Alt du trenger for å male om en hybel eller en liten leilighet, bortsett fra maling. Settet har ruller i to størrelser, pensler til kanter, dekkplast til gulv og møbler og maskeringstape. Rengjør ruller og pensler før levering.', + area: 'Midtbyen', + dailyPrice: 120, + condition: 'good', + accessories: [ + 'Ruller i to størrelser', + '3 pensler', + 'Dekkplast', + 'Maskeringstape', + ], + imageUrl: listingImageUrl('paint-roller.svg'), + }, + { + id: 'listing-009', + title: 'Laservater', + category: 'measuring', + description: + 'Krysslaser for å henge bilder og hyller rett. Rekker omtrent 15 meter innendørs.', + area: 'Heimdal', + dailyPrice: 80, + condition: 'likeNew', + accessories: ['Stativ', 'Batterier'], + imageUrl: null, + }, + { + id: 'listing-010', + title: 'Tepperenser', + category: 'cleaning', + description: + 'Rensemaskin for tepper og møbeltrekk. Nyttig ved utflytting. Rensemiddel følger ikke med.', + area: 'Nardo', + dailyPrice: 250, + condition: 'good', + accessories: ['Slange', '2 munnstykker'], + imageUrl: null, + }, +]; diff --git a/backend/src/seed/random.ts b/backend/src/seed/random.ts new file mode 100644 index 0000000..72664eb --- /dev/null +++ b/backend/src/seed/random.ts @@ -0,0 +1,87 @@ +/** + * Tilfeldighetsgenerator med fast seed-verdi. Samme seed-verdi gir alltid samme tallrekke, + * slik at alle i gruppen får nøyaktig samme genererte datasett. + * + * Math.random kan ikke brukes fordi den ikke kan få en seed-verdi. + */ + +export interface Random { + /** Desimaltall i intervallet [0, 1). */ + next(): number; + /** Heltall fra og med min til og med max. */ + int(min: number, max: number): number; + /** Sann med gitt sannsynlighet, for eksempel 0.2 for 20 %. */ + chance(probability: number): boolean; + /** Tilfeldig element, der alle er like sannsynlige. */ + pick(items: readonly T[]): T; + /** Tilfeldig element der vekten bestemmer hvor ofte det velges. */ + weighted(items: readonly (readonly [T, number])[]): T; + /** Velger opptil count ulike elementer i tilfeldig rekkefølge. */ + sample(items: readonly T[], count: number): T[]; +} + +/** + * Mulberry32: en liten og rask generator som er god nok til testdata. + * Den er ikke egnet til kryptografi. + */ +function mulberry32(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function createRandom(seed: number): Random { + const next = mulberry32(seed); + + const random: Random = { + next, + int(min, max) { + return min + Math.floor(next() * (max - min + 1)); + }, + chance(probability) { + return next() < probability; + }, + pick(items) { + if (items.length === 0) { + throw new Error('Kan ikke velge fra en tom liste.'); + } + return items[Math.floor(next() * items.length)] as (typeof items)[number]; + }, + weighted(items) { + const total = items.reduce((sum, [, weight]) => sum + weight, 0); + let target = next() * total; + for (const [item, weight] of items) { + target -= weight; + if (target < 0) { + return item; + } + } + // Avrunding kan gi en rest helt på slutten; da velges siste element. + const last = items.at(-1); + if (!last) { + throw new Error('Kan ikke velge fra en tom liste.'); + } + return last[0]; + }, + sample(items, count) { + const copy = [...items]; + // Fisher–Yates-stokking av de første count elementene. + const size = Math.min(count, copy.length); + for (let i = 0; i < size; i += 1) { + const j = random.int(i, copy.length - 1); + [copy[i], copy[j]] = [ + copy[j] as (typeof copy)[number], + copy[i] as (typeof copy)[number], + ]; + } + return copy.slice(0, size); + }, + }; + + return random; +} diff --git a/backend/src/seed/toolModels.ts b/backend/src/seed/toolModels.ts new file mode 100644 index 0000000..fdecd31 --- /dev/null +++ b/backend/src/seed/toolModels.ts @@ -0,0 +1,51 @@ +/** + * Typer og innlesing for data/toolModels.json, som lages av + * `npm run import:tools` (src/scripts/importToolModels.ts). + */ + +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { ToolCategory } from '../types/listing.js'; + +export const toolModelsPath = resolve( + dirname(fileURLToPath(import.meta.url)), + 'data/toolModels.json', +); + +/** + * Verktøytype som annonsene bygges på. Beskrivelser fra Wikidata tas ikke med: + * nesten alle er på engelsk og beskriver typen generelt. Annonsene får egne + * norske beskrivelser når datasettet genereres. + */ +export interface ToolType { + wikidataId: string; + name: string; + category: ToolCategory; + /** Hvor navnet kommer fra: Wikidata eller vår egen oversettelse. */ + nameSource: 'wikidata' | 'manual'; +} + +export interface Brand { + name: string; + wikidataId: string | null; + /** Navnet på elementet i Wikidata, slik at koblingen kan kontrolleres. */ + wikidataLabel: string | null; + categories: ToolCategory[]; +} + +export interface ToolModelsFile { + source: { + name: string; + url: string; + license: string; + retrieved: string; + }; + toolTypes: ToolType[]; + brands: Brand[]; +} + +export function loadToolModels(): ToolModelsFile { + return JSON.parse(readFileSync(toolModelsPath, 'utf8')) as ToolModelsFile; +} diff --git a/backend/src/seed/toolSources.ts b/backend/src/seed/toolSources.ts new file mode 100644 index 0000000..af45f07 --- /dev/null +++ b/backend/src/seed/toolSources.ts @@ -0,0 +1,267 @@ +/** + * Hvilke verktøytyper og merker som hentes fra Wikidata. + * + * Verktøytypene er valgt for hånd fordi kategoritrærne i Wikidata er svært + * støyete: under «håndverktøy» ligger blant annet bestikk og lysekroner, og + * under «måleinstrument» ligger hundrevis av militære radarer. En liste med + * faste Wikidata-ID-er gir oss ekte verktøytyper uten støy. + * + * Kjør `npm run import:tools` etter endringer her. Skriptet henter navn og + * beskrivelse fra Wikidata og skriver resultatet til data/toolModels.json. + */ + +import type { ToolCategory } from '../types/listing.js'; + +export interface ToolTypeSource { + /** Wikidata-ID, for eksempel «Q58964» for boremaskin. */ + wikidataId: string; + category: ToolCategory; + /** + * Norsk navn når Wikidata mangler norsk (bokmål) navn. Brukes bare som + * reserve; finnes et norsk navn i Wikidata, vinner det. + */ + fallbackName?: string; +} + +export interface BrandSource { + name: string; + /** Wikidata-ID for merket eller selskapet bak, eller null når det mangler. */ + wikidataId: string | null; + /** Kategoriene merket typisk selges innenfor i norske butikker. */ + categories: ToolCategory[]; +} + +export const toolTypeSources: ToolTypeSource[] = [ + // Elektroverktøy + { wikidataId: 'Q58964', category: 'powerTools' }, // boremaskin + { wikidataId: 'Q19599600', category: 'powerTools' }, // slagboremaskin + { + wikidataId: 'Q456101', + category: 'powerTools', + fallbackName: 'batteridrill', + }, + { wikidataId: 'Q1932875', category: 'powerTools', fallbackName: 'borhammer' }, + { wikidataId: 'Q368341', category: 'powerTools' }, // slagskrutrekker + { wikidataId: 'Q13575304', category: 'powerTools' }, // muttertrekker + { wikidataId: 'Q1140903', category: 'powerTools' }, // stikksag + { wikidataId: 'Q1141837', category: 'powerTools' }, // sirkelsag + { wikidataId: 'Q17530767', category: 'powerTools' }, // håndsirkelsag + { wikidataId: 'Q373255', category: 'powerTools' }, // kapp- og gjærsag + { wikidataId: 'Q1183768', category: 'powerTools' }, // dekupørsag + { wikidataId: 'Q806378', category: 'powerTools' }, // båndsag + { wikidataId: 'Q827274', category: 'powerTools' }, // vinkelsliper + { wikidataId: 'Q2455671', category: 'powerTools' }, // eksentersliper + { wikidataId: 'Q806370', category: 'powerTools' }, // båndsliper + { wikidataId: 'Q1253445', category: 'powerTools' }, // planhøvel + { wikidataId: 'Q1209958', category: 'powerTools' }, // tykkelsehøvel + { wikidataId: 'Q642560', category: 'powerTools' }, // håndoverfres + { wikidataId: 'Q77257', category: 'powerTools' }, // spikerpistol + { wikidataId: 'Q6934510', category: 'powerTools' }, // oscillerende multiverktøy + { wikidataId: 'Q1057985', category: 'powerTools' }, // varmepistol + { wikidataId: 'Q623402', category: 'powerTools' }, // loddebolt + { wikidataId: 'Q1079988', category: 'powerTools' }, // kompressor + { + wikidataId: 'Q1936800', + category: 'powerTools', + fallbackName: 'sveiseapparat', + }, + { wikidataId: 'Q1304613', category: 'powerTools', fallbackName: 'limpistol' }, + { wikidataId: 'Q19766', category: 'powerTools', fallbackName: 'pigghammer' }, + { + wikidataId: 'Q831584', + category: 'powerTools', + fallbackName: 'betongblander', + }, + { + wikidataId: 'Q279497', + category: 'powerTools', + fallbackName: 'markvibrator', + }, + + // Håndverktøy + { wikidataId: 'Q25294', category: 'handTools' }, // hammer + { wikidataId: 'Q1935441', category: 'handTools' }, // slegge + { wikidataId: 'Q161071', category: 'handTools' }, // skrutrekker + { wikidataId: 'Q2750929', category: 'handTools' }, // håndsag + { wikidataId: 'Q1195804', category: 'handTools' }, // baufil + { wikidataId: 'Q39397', category: 'handTools' }, // øks + { wikidataId: 'Q13681', category: 'handTools' }, // brekkjern + { wikidataId: 'Q269359', category: 'handTools' }, // kombinasjonstang + { wikidataId: 'Q1585781', category: 'handTools' }, // nebbtang + { wikidataId: 'Q2645890', category: 'handTools' }, // rørtang + { wikidataId: 'Q449109', category: 'handTools' }, // skiftenøkkel + { wikidataId: 'Q1571051', category: 'handTools' }, // momentnøkkel + { wikidataId: 'Q947463', category: 'handTools' }, // pipenøkkel + { wikidataId: 'Q16953', category: 'handTools' }, // sekskantnøkkel + { wikidataId: 'Q13221843', category: 'handTools' }, // stemjern + { wikidataId: 'Q204260', category: 'handTools' }, // høvel + { wikidataId: 'Q193142', category: 'handTools' }, // fil + { wikidataId: 'Q277885', category: 'handTools' }, // rasp + { wikidataId: 'Q474188', category: 'handTools' }, // meisel + { wikidataId: 'Q2801795', category: 'handTools' }, // stiftepistol + { + wikidataId: 'Q27103691', + category: 'handTools', + fallbackName: 'flisekutter', + }, + { wikidataId: 'Q846600', category: 'handTools', fallbackName: 'skrutvinge' }, + { wikidataId: 'Q475638', category: 'handTools', fallbackName: 'skrustikke' }, + { wikidataId: 'Q1421844', category: 'handTools', fallbackName: 'sagbukk' }, + { wikidataId: 'Q168639', category: 'handTools' }, // stige + + // Hage + { wikidataId: 'Q208040', category: 'garden' }, // motorsag + { wikidataId: 'Q1593024', category: 'garden', fallbackName: 'hekksaks' }, + { wikidataId: 'Q144764', category: 'garden' }, // rosesaks + { wikidataId: 'Q1150400', category: 'garden' }, // jordfreser + { wikidataId: 'Q175334', category: 'garden' }, // trillebår + { wikidataId: 'Q200822', category: 'garden' }, // rive + { wikidataId: 'Q1340233', category: 'garden' }, // gresstrimmer + { wikidataId: 'Q4249364', category: 'garden', fallbackName: 'ryddesag' }, + { wikidataId: 'Q260521', category: 'garden' }, // gressklipper + { wikidataId: 'Q1450186', category: 'garden' }, // løvblåser + { wikidataId: 'Q216642', category: 'garden' }, // spade + { wikidataId: 'Q1540750', category: 'garden' }, // greip + { wikidataId: 'Q130912', category: 'garden' }, // ljå + { wikidataId: 'Q1351693', category: 'garden' }, // snøfreser + { wikidataId: 'Q2480927', category: 'garden', fallbackName: 'hageslange' }, + { wikidataId: 'Q6503658', category: 'garden', fallbackName: 'plenlufter' }, + { + wikidataId: 'Q1514872', + category: 'garden', + fallbackName: 'stolpehullsbor', + }, + { wikidataId: 'Q2245442', category: 'garden' }, // vannpumpe + + // Maling og overflate + { wikidataId: 'Q193897', category: 'painting' }, // pensel + { wikidataId: 'Q1396432', category: 'painting', fallbackName: 'malerrulle' }, + { + wikidataId: 'Q3389292', + category: 'painting', + fallbackName: 'malingssprøyte', + }, + { wikidataId: 'Q921044', category: 'painting' }, // sparkelspade + { wikidataId: 'Q7963205', category: 'painting', fallbackName: 'tapetdamper' }, + { wikidataId: 'Q3057632', category: 'painting' }, // gardintrapp + + // Måleverktøy + { wikidataId: 'Q207988', category: 'measuring' }, // vaterpass + { wikidataId: 'Q1925365', category: 'measuring' }, // meterstokk + { wikidataId: 'Q214649', category: 'measuring' }, // målebånd + { wikidataId: 'Q12408', category: 'measuring' }, // skyvelære + { wikidataId: 'Q189996', category: 'measuring' }, // multimeter + { wikidataId: 'Q1643652', category: 'measuring' }, // avstandsmåler + { wikidataId: 'Q1826705', category: 'measuring', fallbackName: 'krysslaser' }, + { + wikidataId: 'Q135263727', + category: 'measuring', + fallbackName: 'laservater', + }, + { wikidataId: 'Q1778014', category: 'measuring' }, // vinkelhake + { wikidataId: 'Q646', category: 'measuring' }, // termometer + { + wikidataId: 'Q805083', + category: 'measuring', + fallbackName: 'stendersøker', + }, + + // Rengjøring + { wikidataId: 'Q1545773', category: 'cleaning' }, // høytrykkspyler + { wikidataId: 'Q1158863', category: 'cleaning' }, // damprenser + { wikidataId: 'Q101674', category: 'cleaning' }, // støvsuger + { wikidataId: 'Q828361', category: 'cleaning' }, // mopp + { wikidataId: 'Q1128099', category: 'cleaning' }, // feiebrett + { wikidataId: 'Q1143580', category: 'cleaning' }, // nal + { + wikidataId: 'Q117207770', + category: 'cleaning', + fallbackName: 'tepperenser', + }, +]; + +/** + * Merker som selges i norske butikker (kontrollert mot nettbutikkene til + * blant andre Biltema, Clas Ohlson og Maxbo). Butikkenes egne merker peker på + * butikkjeden i Wikidata. Merker som ikke finnes i Wikidata, har null. + */ +export const brandSources: BrandSource[] = [ + { + name: 'Makita', + wikidataId: 'Q691508', + categories: ['powerTools', 'garden'], + }, + { + name: 'Bosch', + wikidataId: 'Q234021', + categories: ['powerTools', 'measuring', 'garden', 'cleaning'], + }, + { name: 'DeWalt', wikidataId: null, categories: ['powerTools', 'measuring'] }, + { name: 'Milwaukee', wikidataId: null, categories: ['powerTools'] }, + { + name: 'Ryobi', + wikidataId: 'Q1104912', + categories: ['powerTools', 'garden'], + }, + { + name: 'Einhell', + wikidataId: 'Q1307902', + categories: ['powerTools', 'garden', 'cleaning'], + }, + { name: 'Metabo', wikidataId: 'Q457140', categories: ['powerTools'] }, + { name: 'HiKOKI', wikidataId: null, categories: ['powerTools'] }, + { + name: 'Black+Decker', + wikidataId: 'Q10860313', + categories: ['powerTools', 'garden', 'cleaning'], + }, + { + name: 'Festool', + wikidataId: 'Q320091', + categories: ['powerTools', 'cleaning'], + }, + { + name: 'Bahco', + wikidataId: 'Q2656321', + categories: ['handTools', 'garden'], + }, + { + name: 'Stanley', + wikidataId: 'Q706094', + categories: ['handTools', 'measuring'], + }, + { + name: 'Fiskars', + wikidataId: 'Q1241369', + categories: ['handTools', 'garden'], + }, + { name: 'Würth', wikidataId: 'Q679750', categories: ['handTools'] }, + { name: 'Husqvarna', wikidataId: 'Q899356', categories: ['garden'] }, + { name: 'Stihl', wikidataId: 'Q239372', categories: ['garden'] }, + { name: 'Gardena', wikidataId: null, categories: ['garden'] }, + { name: 'Limit', wikidataId: null, categories: ['measuring', 'handTools'] }, + { name: 'Kärcher', wikidataId: 'Q696798', categories: ['cleaning'] }, + { name: 'Nilfisk', wikidataId: 'Q969760', categories: ['cleaning'] }, + { + name: 'Biltema', + wikidataId: 'Q3355552', + categories: [ + 'powerTools', + 'handTools', + 'garden', + 'painting', + 'measuring', + 'cleaning', + ], + }, + { + name: 'Cocraft', + wikidataId: 'Q3356220', + categories: ['powerTools', 'handTools', 'measuring', 'painting'], + }, + { + name: 'Meec Tools', + wikidataId: 'Q10541151', + categories: ['powerTools', 'handTools', 'garden', 'painting'], + }, +]; diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..1326def --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,23 @@ +import 'dotenv/config'; + +import { ApolloServer } from '@apollo/server'; +import { startStandaloneServer } from '@apollo/server/standalone'; +import { resolvers } from './graphql/resolvers.js'; +import { typeDefs } from './graphql/schema.js'; + +const host = process.env.HOST ?? '127.0.0.1'; +const port = Number(process.env.PORT ?? '3001'); + +if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error( + `PORT må være et gyldig portnummer, fikk «${process.env.PORT}».`, + ); +} + +const server = new ApolloServer({ typeDefs, resolvers }); + +const { url } = await startStandaloneServer(server, { + listen: { host, port }, +}); + +console.info(`GraphQL tilgjengelig på ${url}graphql`); diff --git a/backend/src/test/apolloTestServer.ts b/backend/src/test/apolloTestServer.ts new file mode 100644 index 0000000..a2ee9e4 --- /dev/null +++ b/backend/src/test/apolloTestServer.ts @@ -0,0 +1,18 @@ +import type { ApolloServer } from '@apollo/server'; + +export type TestApolloServer = ApolloServer; + +export async function createTestApolloServer(): Promise { + const { ApolloServer } = await import('@apollo/server'); + const { typeDefs } = await import('../graphql/schema.js'); + const { resolvers } = await import('../graphql/resolvers.js'); + + const server = new ApolloServer({ typeDefs, resolvers }); + await server.start(); + return server; +} + +export async function closeTestPool(): Promise { + const { pool } = await import('../db/pool.js'); + await pool.end(); +} diff --git a/backend/src/test/env.ts b/backend/src/test/env.ts new file mode 100644 index 0000000..c0411c9 --- /dev/null +++ b/backend/src/test/env.ts @@ -0,0 +1,32 @@ +import { + assertAllowedTestDatabaseUrl, + isAllowedTestDatabaseName, + databaseNameFromPostgresUrl, +} from './testDatabaseUrl.js'; + +/** Satt i backend/.env.test som TEST_DATABASE_URL (lastes i vitest.config.ts). */ +export function getTestDatabaseUrl(): string | undefined { + return process.env.TEST_DATABASE_URL?.trim() || undefined; +} + +export const hasTestDatabase = (() => { + const url = getTestDatabaseUrl(); + if (!url) { + return false; + } + try { + const name = databaseNameFromPostgresUrl(url); + return isAllowedTestDatabaseName(name); + } catch { + return false; + } +})(); + +/** Kalles i vitest.config.ts før DATABASE_URL settes for integrasjonstester. */ +export function validateTestDatabaseConfig(): void { + const url = getTestDatabaseUrl(); + if (!url) { + return; + } + assertAllowedTestDatabaseUrl(url); +} diff --git a/backend/src/test/listingExpectations.ts b/backend/src/test/listingExpectations.ts new file mode 100644 index 0000000..541020d --- /dev/null +++ b/backend/src/test/listingExpectations.ts @@ -0,0 +1,31 @@ +import { expect } from 'vitest'; + +const TOOL_CATEGORIES = [ + 'powerTools', + 'handTools', + 'garden', + 'painting', + 'measuring', + 'cleaning', +] as const; + +const TOOL_CONDITIONS = ['likeNew', 'good', 'worn'] as const; + +/** Sjekker at et GraphQL-listing-objekt har forventede felt og typer. */ +export function expectListingShape(listing: Record): void { + expect(typeof listing.id).toBe('string'); + expect(typeof listing.title).toBe('string'); + expect(TOOL_CATEGORIES).toContain(listing.category); + expect(typeof listing.description).toBe('string'); + expect(typeof listing.area).toBe('string'); + expect(typeof listing.dailyPrice).toBe('number'); + expect(Number.isInteger(listing.dailyPrice)).toBe(true); + expect(TOOL_CONDITIONS).toContain(listing.condition); + expect(Array.isArray(listing.accessories)).toBe(true); + for (const item of listing.accessories as unknown[]) { + expect(typeof item).toBe('string'); + } + expect( + listing.imageUrl === null || typeof listing.imageUrl === 'string', + ).toBe(true); +} diff --git a/backend/src/test/testDatabaseUrl.test.ts b/backend/src/test/testDatabaseUrl.test.ts new file mode 100644 index 0000000..fbe26fe --- /dev/null +++ b/backend/src/test/testDatabaseUrl.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; + +import { + assertAllowedTestDatabaseUrl, + databaseNameFromPostgresUrl, + isAllowedTestDatabaseName, +} from './testDatabaseUrl.js'; + +describe('testDatabaseUrl', () => { + it('godtar databasenavn som slutter på _test', () => { + expect(isAllowedTestDatabaseName('verktoyutlan_test')).toBe(true); + expect(isAllowedTestDatabaseName('t36_project2_test')).toBe(true); + expect(isAllowedTestDatabaseName('verktoyutlan')).toBe(false); + expect(isAllowedTestDatabaseName('t36_project2')).toBe(false); + }); + + it('parser databasenavn fra TEST_DATABASE_URL', () => { + expect( + databaseNameFromPostgresUrl( + 'postgresql://bruker@localhost:5432/verktoyutlan_test', + ), + ).toBe('verktoyutlan_test'); + }); + + it('kaster ved utviklingsdatabase i TEST_DATABASE_URL', () => { + expect(() => + assertAllowedTestDatabaseUrl( + 'postgresql://bruker@localhost:5432/t36_project2', + ), + ).toThrow(/_test/); + }); +}); diff --git a/backend/src/test/testDatabaseUrl.ts b/backend/src/test/testDatabaseUrl.ts new file mode 100644 index 0000000..3c8c7ae --- /dev/null +++ b/backend/src/test/testDatabaseUrl.ts @@ -0,0 +1,26 @@ +/** Database-navn fra PostgreSQL connection string (pathname uten ledende /). */ +export function databaseNameFromPostgresUrl(url: string): string { + const withScheme = url.startsWith('postgresql://') + ? url.replace(/^postgresql:\/\//, 'postgres://') + : url; + const pathname = new URL(withScheme).pathname; + const name = pathname.replace(/^\//, '').split('/')[0] ?? ''; + if (!name) { + throw new Error('DATABASE_URL mangler databasenavn i pathname.'); + } + return decodeURIComponent(name); +} + +/** Tester skal bare kjøre mot en egen DB som tydelig er merket som test. */ +export function isAllowedTestDatabaseName(databaseName: string): boolean { + return databaseName.endsWith('_test'); +} + +export function assertAllowedTestDatabaseUrl(url: string): void { + const name = databaseNameFromPostgresUrl(url); + if (!isAllowedTestDatabaseName(name)) { + throw new Error( + `TEST_DATABASE_URL må peke på en database som slutter på «_test» (fikk «${name}»).`, + ); + } +} diff --git a/backend/src/types/listing.ts b/backend/src/types/listing.ts new file mode 100644 index 0000000..8cc2ad2 --- /dev/null +++ b/backend/src/types/listing.ts @@ -0,0 +1,50 @@ +/** Speiler src/types/listing.ts for backend uten avhengighet til frontend. */ + +export type ToolCategory = + 'powerTools' | 'handTools' | 'garden' | 'painting' | 'measuring' | 'cleaning'; + +export type ToolCondition = 'likeNew' | 'good' | 'worn'; + +export interface Listing { + id: string; + title: string; + category: ToolCategory; + description: string; + area: string; + dailyPrice: number; + condition: ToolCondition; + accessories: string[]; + imageUrl: string | null; + /** Når annonsen ble opprettet, som ISO 8601-tekst i UTC. */ + createdAt: string; +} + +/** Rad fra PostgreSQL-tabellen listings. */ +export interface ListingRow { + id: string; + title: string; + category: string; + description: string; + area: string; + daily_price: number; + condition: string; + accessories: string[]; + image_url: string | null; + /** pg gjør timestamptz om til Date. */ + created_at: Date; +} + +export function listingFromRow(row: ListingRow): Listing { + return { + id: row.id, + title: row.title, + category: row.category as ToolCategory, + description: row.description, + area: row.area, + dailyPrice: row.daily_price, + condition: row.condition as ToolCondition, + accessories: row.accessories, + imageUrl: row.image_url, + createdAt: row.created_at.toISOString(), + }; +} diff --git a/backend/src/validation/createListingInput.ts b/backend/src/validation/createListingInput.ts new file mode 100644 index 0000000..2ff408c --- /dev/null +++ b/backend/src/validation/createListingInput.ts @@ -0,0 +1,88 @@ +import { GraphQLError } from 'graphql'; + +import type { ToolCategory } from '../types/listing.js'; + +/** Samme grenser som src/utils/listingForm.ts i frontend. */ +export const TITLE_MAX_LENGTH = 80; +export const DESCRIPTION_MAX_LENGTH = 1000; +export const DAILY_PRICE_MAX = 10000; + +const TOOL_CATEGORIES: readonly ToolCategory[] = [ + 'powerTools', + 'handTools', + 'garden', + 'painting', + 'measuring', + 'cleaning', +]; + +export interface CreateListingInput { + title: string; + category: string; + description: string; + area: string; + dailyPrice: number; +} + +export interface ValidatedCreateListingInput { + title: string; + category: ToolCategory; + description: string; + area: string; + dailyPrice: number; +} + +function invalidInput(message: string): never { + throw new GraphQLError(message, { + extensions: { code: 'BAD_USER_INPUT' }, + }); +} + +/** Validerer createListing-input i tråd med utleieskjemaet i frontend. */ +export function validateCreateListingInput( + input: CreateListingInput, +): ValidatedCreateListingInput { + const title = input.title.trim(); + if (!title) { + invalidInput('Skriv en tittel.'); + } + if (title.length > TITLE_MAX_LENGTH) { + invalidInput(`Tittelen kan ha maks ${TITLE_MAX_LENGTH} tegn.`); + } + + if (!TOOL_CATEGORIES.includes(input.category as ToolCategory)) { + invalidInput('Velg en kategori.'); + } + + const description = input.description.trim(); + if (!description) { + invalidInput('Skriv en beskrivelse av verktøyet.'); + } + if (description.length > DESCRIPTION_MAX_LENGTH) { + invalidInput(`Beskrivelsen kan ha maks ${DESCRIPTION_MAX_LENGTH} tegn.`); + } + + const area = input.area.trim(); + if (!area) { + invalidInput('Skriv hvor i Trondheim verktøyet kan hentes.'); + } + + const { dailyPrice } = input; + if (!Number.isInteger(dailyPrice)) { + invalidInput('Skriv prisen som et helt tall i kroner, for eksempel 100.'); + } + if (dailyPrice < 0) { + invalidInput('Skriv prisen som et helt tall i kroner, for eksempel 100.'); + } + if (dailyPrice > DAILY_PRICE_MAX) { + invalidInput(`Døgnprisen kan være maks ${DAILY_PRICE_MAX} kr.`); + } + + return { + title, + category: input.category as ToolCategory, + description, + area, + dailyPrice, + }; +} diff --git a/backend/src/validation/paginationArgs.test.ts b/backend/src/validation/paginationArgs.test.ts new file mode 100644 index 0000000..6d00dfb --- /dev/null +++ b/backend/src/validation/paginationArgs.test.ts @@ -0,0 +1,54 @@ +import { GraphQLError } from 'graphql'; +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_PAGE_SIZE, + MAX_PAGE_SIZE, + validatePaginationArgs, +} from './paginationArgs.js'; + +function expectBadUserInput(run: () => unknown) { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(GraphQLError); + expect((error as GraphQLError).extensions.code).toBe('BAD_USER_INPUT'); + return; + } + throw new Error('Forventet BAD_USER_INPUT, men ingen feil ble kastet.'); +} + +describe('validatePaginationArgs', () => { + it('bruker standardverdier når ingenting er oppgitt', () => { + expect(validatePaginationArgs({})).toEqual({ + limit: DEFAULT_PAGE_SIZE, + offset: 0, + }); + }); + + it('behandler null som ikke oppgitt', () => { + expect(validatePaginationArgs({ limit: null, offset: null })).toEqual({ + limit: DEFAULT_PAGE_SIZE, + offset: 0, + }); + }); + + it('godtar grenseverdiene', () => { + expect(validatePaginationArgs({ limit: 1, offset: 0 })).toEqual({ + limit: 1, + offset: 0, + }); + expect(validatePaginationArgs({ limit: MAX_PAGE_SIZE })).toEqual({ + limit: MAX_PAGE_SIZE, + offset: 0, + }); + }); + + it.each([0, -1, MAX_PAGE_SIZE + 1])('avviser limit %i', (limit) => { + expectBadUserInput(() => validatePaginationArgs({ limit })); + }); + + it('avviser negativ offset', () => { + expectBadUserInput(() => validatePaginationArgs({ offset: -1 })); + }); +}); diff --git a/backend/src/validation/paginationArgs.ts b/backend/src/validation/paginationArgs.ts new file mode 100644 index 0000000..0726e97 --- /dev/null +++ b/backend/src/validation/paginationArgs.ts @@ -0,0 +1,50 @@ +import { GraphQLError } from 'graphql'; + +/** Antall annonser per side når klienten ikke ber om noe annet. */ +export const DEFAULT_PAGE_SIZE = 24; +/** + * Øvre grense for én side. Hindrer at en klient henter hele tabellen i én + * spørring, noe som blir tregere og tyngre jo flere annonser databasen har. + */ +export const MAX_PAGE_SIZE = 100; + +export interface PaginationArgs { + limit?: number | null; + offset?: number | null; +} + +export interface ValidatedPaginationArgs { + limit: number; + offset: number; +} + +function invalidInput(message: string): never { + throw new GraphQLError(message, { + extensions: { code: 'BAD_USER_INPUT' }, + }); +} + +/** + * Validerer sidestørrelse og posisjon for listings. GraphQL sikrer allerede at + * verdiene er heltall, så her sjekkes bare grensene. null behandles som at + * verdien ikke er oppgitt. + */ +export function validatePaginationArgs( + args: PaginationArgs, +): ValidatedPaginationArgs { + const limit = args.limit ?? DEFAULT_PAGE_SIZE; + const offset = args.offset ?? 0; + + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_PAGE_SIZE) { + invalidInput( + `Sidestørrelsen (limit) må være et heltall fra 1 til ${MAX_PAGE_SIZE}.`, + ); + } + if (!Number.isInteger(offset) || offset < 0) { + invalidInput( + 'Posisjonen (offset) må være et heltall som er 0 eller større.', + ); + } + + return { limit, offset }; +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..a4d84cf --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "esModuleInterop": true + }, + "include": ["src/**/*.ts", "migrations/**/*.ts", "vitest.config.ts"] +} diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 0000000..dfe4ac5 --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,30 @@ +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { config as loadEnv } from 'dotenv'; +import { defineConfig } from 'vitest/config'; + +import { validateTestDatabaseConfig } from './src/test/env.js'; + +const backendRoot = dirname(fileURLToPath(import.meta.url)); +const envTestPath = resolve(backendRoot, '.env.test'); + +if (existsSync(envTestPath)) { + loadEnv({ path: envTestPath, override: true }); +} + +validateTestDatabaseConfig(); + +const testDatabaseUrl = process.env.TEST_DATABASE_URL?.trim(); +if (testDatabaseUrl) { + process.env.DATABASE_URL = testDatabaseUrl; +} + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + fileParallelism: false, + }, +}); diff --git a/deploy/apache/README.md b/deploy/apache/README.md new file mode 100644 index 0000000..1951a68 --- /dev/null +++ b/deploy/apache/README.md @@ -0,0 +1,146 @@ +# Apache: frontend `/project2/` og API-proxy + +Mal for å serve prosjekt 2 på gruppens VM **uten** å endre prosjekt 1s +Apache-oppsett. **Ikke aktiver på VM ennå** — krever bygget frontend med riktig +`base`, og GraphQL på port 3001. + +## Vite: base path og statiske filer + +| Tema | Status i repo nå | Krav ved VM-deploy | +| ---------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `base` | `/project2/` i `vite.config.ts` | Ingen endring | +| React Router | `basename={import.meta.env.BASE_URL}` i `main.tsx` | Følger automatisk Vite `base` | +| JS/CSS fra build | Hashede filer under `dist/assets/` | URL-er blir `/project2/assets/…` når `base` er satt | +| Annonsebilder | `public/assets/listings/*.svg` | Blir med i bygget under `/project2/assets/listings/`, samme sti som `image_url` i databasen | + +Bygg (etter `base` er satt): + +```bash +cd __REPO_ROOT__ +nvm use 24 +npm ci +npm run build +# deploy __DIST_DIR__ (typisk __REPO_ROOT__/dist) +``` + +Test lokalt med base uten Apache: + +```bash +npm run preview -- --base /project2/ +``` + +## Apache-moduler (Ubuntu `apache2`) + +Konfigurasjonen forutsetter at modulene er **enabled**. Sjekk: + +```bash +apache2ctl -M | grep -E 'alias_module|rewrite_module|proxy_module|proxy_http_module' +``` + +Aktiver manglende moduler (krever sudo; **ikke** deaktiver prosjekt 1): + +```bash +sudo a2enmod alias rewrite proxy proxy_http +``` + +Valgfritt senere: `headers` for cache på statiske filer. + +## Filer + +| Fil | Formål | +| ------------------- | --------------------------------------- | +| `t36-project2.conf` | Egen site: statisk SPA og reverse proxy | + +Plassholdere: + +| Plassholder | Eksempel | +| --------------- | -------------------------------------------- | +| `__REPO_ROOT__` | `/home//T36-Project-2` | +| `__DIST_DIR__` | `/home//T36-Project-2/dist` | + +## Reverse proxy og GraphQL + +| Klient (nettleser) | Backend (Node på VM) | +| -------------------------------- | ------------------------------- | +| `https://…/project2/api/graphql` | `http://127.0.0.1:3001/graphql` | + +`ProxyPass /project2/api/` fjerner ikke `/project2/api` i stien mot backend — +mappingen er: + +- Forespørsel: `/project2/api/graphql` +- Backend mottar: `/graphql` (prefiks `/project2/api/` → rot på 3001) + +GraphQL-implementasjonen bør eksponere f.eks. `POST /graphql` på port 3001. +Apollo Client konfigureres senere med URL under `/project2/api/`. + +Backend må fortsatt lytte på **`127.0.0.1:3001`** (systemd), ikke på `0.0.0.0`. + +## SPA-ruter + +Uten `mod_rewrite` vil direkte navigering til f.eks. `/project2/annonser/listing-001` +gi 404 fra Apache. `RewriteRule` i `t36-project2.conf` sender ukjente stier til +`/project2/index.html` når fil/mappe ikke finnes. + +## Annonsebilder + +Seed lagrer `image_url` som `/project2/assets/listings/drill.svg` osv. Filene +ligger i `public/assets/listings/`, så Vite kopierer dem til +`dist/assets/listings/`, og de serveres av samme `Alias /project2` som resten +av appen. Det trengs ingen egen `Alias` for bildene. + +## Installasjon på VM (når klart — ikke kjør enable før dere er klare) + +Forutsetter: prosjekt 1 fungerer; dere har testet `npm run build` med +`base: '/project2/'`; GraphQL kjører på 3001 (senere). + +```bash +export DEPLOY_USER="" +export REPO_ROOT="/home/${DEPLOY_USER}/T36-Project-2" +export DIST_DIR="${REPO_ROOT}/dist" + +sudo a2enmod alias rewrite proxy proxy_http + +sed -e "s|__REPO_ROOT__|${REPO_ROOT}|g" \ + -e "s|__DIST_DIR__|${DIST_DIR}|g" \ + "${REPO_ROOT}/deploy/apache/t36-project2.conf" \ + | sudo tee /etc/apache2/sites-available/t36-project2.conf > /dev/null + +# Valider FØR reload — skal vise «Syntax OK» +sudo apache2ctl configtest + +# Først når configtest er OK og dere har avtalt med gruppen: +# sudo a2ensite t36-project2.conf +# sudo systemctl reload apache2 +``` + +**Ikke** `a2dissite` på prosjekt 1 eller rediger deres vhost-filer. + +## Kontroll etter aktivering + +```bash +sudo apache2ctl -S +curl -I "https://it2810-36.idi.ntnu.no/project2/" +curl -I "https://it2810-36.idi.ntnu.no/project2/assets/listings/drill.svg" +curl -sS -o /dev/null -w "%{http_code}\n" -X POST \ + "https://it2810-36.idi.ntnu.no/project2/api/graphql" \ + -H "Content-Type: application/json" -d '{"query":"{ __typename }"}' +``` + +SPA: åpne `/project2/annonser/listing-001` i nettleser og trykk refresh — skal +ikke gi Apache 404. + +## Avhengigheter + +1. Frontend-issue: `base: '/project2/'` i `vite.config.ts` + produksjonsbuild. +2. GraphQL-issue: server på `127.0.0.1:3001`, endepunkt `/graphql` (eller dokumentert sti). +3. systemd: `t36-project2-backend` enabled (se `backend/deploy/README.md`). + +## Feilsøking + +| Symptom | Mulig årsak | +| ---------------------------------- | ------------------------------------------------- | +| `configtest` feiler på `ProxyPass` | `proxy` / `proxy_http` ikke enabled | +| SPA 404 ved refresh | `rewrite` ikke enabled, eller feil `RewriteBase` | +| SVG 404 | Frontend er bygget uten `public/assets/listings` | +| API 502/503 | Backend ikke startet på 3001 | +| `/project2` ødelegger P1 | Feil site aktivert — bruk kun `t36-project2.conf` | diff --git a/deploy/apache/t36-project2.conf b/deploy/apache/t36-project2.conf new file mode 100644 index 0000000..e5153bd --- /dev/null +++ b/deploy/apache/t36-project2.conf @@ -0,0 +1,39 @@ +# Prosjekt 2 (T36) — egen Apache-site. Publiseres som +# /etc/apache2/sites-available/t36-project2.conf +# +# Erstatt plassholdere ved installasjon (se deploy/apache/README.md): +# __REPO_ROOT__ — f.eks. /home//T36-Project-2 +# __DIST_DIR__ — f.eks. __REPO_ROOT__/dist (etter npm run build med base /project2/) +# +# Ikke rediger prosjekt 1s vhost. Aktiver med a2ensite t36-project2.conf. + +# Frontend (Vite build med base: '/project2/'). Annonsebildene ligger i +# public/assets/listings og blir med i bygget under /project2/assets/listings. + + Alias /project2 __DIST_DIR__ + + + + Require all granted + Options -Indexes +FollowSymLinks + AllowOverride None + + # SPA: direkte URL og refresh (React Router) + + RewriteEngine On + RewriteBase /project2/ + RewriteRule ^index\.html$ - [L] + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule . /project2/index.html [L] + + + +# GraphQL-backend (127.0.0.1:3001) — /project2/api/graphql → backend /graphql + + + ProxyPreserveHost On + ProxyPass /project2/api/ http://127.0.0.1:3001/ + ProxyPassReverse /project2/api/ http://127.0.0.1:3001/ + + diff --git a/docs/datasett.md b/docs/datasett.md new file mode 100644 index 0000000..4f7013a --- /dev/null +++ b/docs/datasett.md @@ -0,0 +1,144 @@ +# Datasett + +For å vise at søk, filtrering, sortering og visning av treff side for side +skalerer, fyller vi databasen med et stort og variert datasett. Verktøytypene og merkene er ekte og hentes +fra en åpen kilde. Selve annonsene genereres, fordi det ikke finnes en åpen +datakilde for utlån av verktøy mellom privatpersoner i Trondheim. + +## Kort fortalt + +| Del | Kilde | Antall | +| ------------------ | ------------------------------------------ | ----------------------- | +| Verktøytyper | [Wikidata](https://www.wikidata.org), CC0 | 95 | +| Merker | Håndplukket, koblet til Wikidata der mulig | 23 (18 med Wikidata-ID) | +| Eksempelannonser | Skrevet for hånd (`npm run seed`) | 10 | +| Genererte annonser | `npm run seed:large` | 10 000 (kan endres) | + +## Verktøytyper og merker fra Wikidata + +`npm run import:tools` henter navn på verktøytyper og merker fra Wikidata og +lagrer dem i `backend/src/seed/data/toolModels.json`. Filen committes, slik at +seeding ikke er avhengig av at Wikidata er tilgjengelig. Skriptet trenger bare +å kjøres på nytt når listene i `backend/src/seed/toolSources.ts` endres. + +**Hvorfor en fast liste med Wikidata-ID-er?** Vi prøvde først å hente alle +undertyper av kategorier som «håndverktøy» og «måleinstrument». Det ga mye +støy: under håndverktøy lå blant annet bestikk, lysekroner og stridsøkser, og +under måleinstrument lå hundrevis av militære radarer. Derfor velger vi +verktøytypene med faste ID-er, og navnene hentes fra Wikidata. + +- **Norske navn:** 83 av 95 verktøytyper har norsk (bokmål) navn i Wikidata. + De 12 som mangler, for eksempel malerrulle og hekksaks, har et norsk + reservenavn i `toolSources.ts`. Feltet `nameSource` viser hvor navnet kommer + fra. +- **Beskrivelser:** Beskrivelsene i Wikidata brukes ikke. Bare én av 95 var på + norsk, og de beskriver typen generelt, ikke et konkret eksemplar. +- **Merker:** Det finnes ingen åpen kilde for hvilke merker norske butikker + selger. Listen er derfor satt sammen for hånd og kontrollert mot nettbutikker + som Biltema, Clas Ohlson og Maxbo. Merker som finnes i Wikidata, er koblet til + sin ID. DeWalt, Milwaukee, Gardena, HiKOKI og Limit fant vi ikke. + Butikkenes egne merker (Cocraft og Meec Tools) peker på butikkjeden. +- **Kategorier:** Lagres som de samme engelske nøklene som resten av appen + (`powerTools`, `garden` osv.). Frontend viser de norske navnene. + +## Genererte annonser + +`backend/src/seed/generateListings.ts` lager annonser fra verktøytypene og +merkene. Generatoren bruker en fast seed-verdi (standard `2810`) og en fast +referansedato, slik at alle får nøyaktig samme datasett. ID-ene er +`gen-000001` og oppover, så de kolliderer ikke med `listing-001`–`listing-010` +eller med annonser brukere oppretter. + +Datasettet er bevisst ujevnt, slik ekte annonser er. En jevn, kunstig fordeling +ville fått søk og sortering til å se bedre ut enn de er. + +| Egenskap | Slik er det generert | +| ---------------- | ------------------------------------------------------------------------------------------------------------ | +| Kategori | Skjev: elektroverktøy 32 %, håndverktøy 22 %, hage 20 %, rengjøring 10 %, maling 8 %, måleverktøy 8 % | +| Verktøytype | Noen typer er vanlige og mange er sjeldne innen hver kategori | +| Område | 30 bydeler i Trondheim. Studentområdene har flest (Moholt ≈ 1 150, Flatåsen ≈ 70 per 10 000) | +| Pris | 20 % gratis. Resten får runde priser per kategori, så mange annonser har samme pris (17 ulike priser totalt) | +| Tilstand | Som ny 25 %, god stand 55 %, godt brukt 20 % | +| Merke | 70 % har merke. Motormerker brukes bare på motorisert verktøy, og håndverktøymerker bare på manuelt verktøy | +| Tittel | 3–77 tegn, fra «Øks» til «Sekskantnøkkel – grei å ha når noe skal skrus fra hverandre» | +| Beskrivelse | 1–7 setninger på norsk, 11–396 tegn. Lange beskrivelser deles i to avsnitt | +| Tilbehør | 0–4 ting som passer til verktøyet. 30 % har ingen | +| Bilde | Bare verktøy som har en passende illustrasjon (drill, håndsag, malerrulle, spade). Resten får plassholder | +| Opprettelsesdato | Spredt over året før 1. oktober 2026, med flest nylige annonser | + +Tekster, tilbehør og spesifikasjoner i titler velges per verktøygruppe i +`backend/src/seed/listingTexts.ts`. Uten det ville en båndsliper fått teksten +«passer til å skru opp hyller», og en rive ville fått trimmertråd som tilbehør. + +Generatoren testes i `backend/src/seed/generateListings.test.ts`, blant annet +for at samme seed-verdi gir samme datasett, andelen gratis annonser, skjev fordeling, +like priser, variasjon i tekstlengde og datoer. + +## Kjøre det + +Fra `backend/`, etter `npm run migrate` og `npm run seed`: + +```bash +npm run seed:large # 10 000 annonser +npm run seed:large -- --count 50000 # annet antall +npm run seed:large -- --replace # slett tidligere genererte først +npm run seed:large -- --seed 42 # et annet, men like reproduserbart datasett +``` + +Skriptet setter inn 1 000 rader per spørring i én transaksjon. Feiler noe, +rulles alt tilbake. Det er idempotent: rader som finnes fra før, hoppes over. +Med `--replace` slettes bare genererte annonser (`gen-…`), aldri +eksempelannonsene eller annonser brukere har opprettet. + +## Målinger + +Målt lokalt med PostgreSQL 18 på en bærbar PC, uten indekser utover +primærnøkkelen. Spørretidene er median av sju kjøringer med `EXPLAIN ANALYZE`. + +| Måling | 10 000 annonser | 50 000 annonser | +| ---------------------------------------------------------------- | --------------- | --------------- | +| `npm run seed:large` | 0,3 s | 1,2 s | +| Søk på «drill» i tittel og beskrivelse, 24 treff sortert på pris | 14 ms | 68 ms | +| Kategori og gratis, 24 nyeste | 0,9 ms | 4,0 ms | +| Alle annonser (`listings` før paginering) | 21 ms | 150 ms | + +Tidene vokser omtrent i takt med datamengden, fordi PostgreSQL må lese hele +tabellen. Det viser hvorfor neste steg er serverbasert paginering og indekser, +for eksempel på `category`, `daily_price` og `created_at`, og en trigram-indeks +(`pg_trgm`) for søk i tekst. Med indekser bør tidene holde seg omtrent like +uansett datamengde. + +## Paginering og indeks + +`listings` henter én side om gangen, sortert på `created_at DESC, id DESC`. +Indeksen `listings_created_at_id_idx` har samme rekkefølge, så PostgreSQL kan +lese siden rett fra indeksen i stedet for å sortere hele tabellen. + +Målt 14. oktober 2026 lokalt med PostgreSQL 18, sider på 24 annonser. Tidene +er median av sju kjøringer med `EXPLAIN ANALYZE` etter én oppvarmingskjøring. + +| Spørring | 10 000 uten indeks | 10 000 med indeks | 50 000 med indeks | +| --------------------------- | ------------------ | ----------------- | ----------------- | +| Første side | 2,5 ms | 0,01 ms | 0,01 ms | +| Side midt i (offset 5 000) | 6,8 ms | 1,1 ms | 1,0 ms | +| Siste side | 11,4 ms | 3,3 ms | 15,2 ms | +| `COUNT(*)` for `totalCount` | 0,7 ms | 0,6 ms | 3,1 ms | + +Uten indeks leser PostgreSQL hele tabellen og sorterer den for hver side +(`Seq Scan` og `Sort`). Med indeks brukes `Index Scan`, og de første sidene +tar like kort tid uansett datamengde. + +**Begrensning:** Med `OFFSET` må PostgreSQL fortsatt gå gjennom alle radene +foran siden, så sider langt bak og `COUNT(*)` blir tregere når datamengden +vokser. Det merkes lite med 50 000 annonser. Blir det et problem, kan vi bytte +til cursor-paginering, der klienten sender med posisjonen til siste annonse på +forrige side. + +## Begrensninger + +- Annonsene er generert, så eiere, bookinger og vurderinger finnes ikke ennå. +- Tekstene bygges av et begrenset sett med setninger. Mange titler går igjen + (rundt 2 400 unike titler per 10 000 annonser), slik de også gjør på ekte + markedsplasser. +- Kategoriene maling og rengjøring har få verktøytyper (6 og 7), fordi + Wikidata har få relevante typer der. diff --git a/docs/design.md b/docs/design.md index 5667c62..9cd5e54 100644 --- a/docs/design.md +++ b/docs/design.md @@ -147,6 +147,27 @@ bekreftelsen sier at ingenting er sendt eller lagret. Bekreftelsen ligger i et `role="status"`-område, så skjermlesere leser den opp. Bruk `key`, slik at skjemaet nullstilles når brukeren går til en annen annonse. +### Pagination + +«Forrige» og «Neste» mellom sidene, med «Side 2 av 418» mellom. Sidene er +lenker, så siden ligger i URL-en og «Tilbake» i nettleseren virker. På første +og siste side vises teksten uten lenke og med `aria-disabled`, så den ikke får +fokus. Komponenten vises ikke når alt får plass på én side. + +```tsx + +``` + +### QueryError + +Feilmelding når data ikke kan hentes fra backenden, med knappen «Prøv igjen». +Den har `role="alert"`, så skjermlesere leser den med en gang, og rød ramme i +tillegg til teksten. + +```tsx + void refetch()} /> +``` + ### ListingGrid Responsivt rutenett med ett `ListingCard` per annonse. Annonsene vises i den @@ -254,9 +275,14 @@ nettlesere viser ugyldig input som en tom verdi. Med tekstfelt kan valideringen gi en presis feilmelding. Feil vises ved feltet først etter et forsøk på innsending. Da flyttes også -fokus til første ugyldige felt. Innsendingen er en demo. Knappen heter «Legg -ut verktøy (demo)», og bekreftelsen i `role="status"` sier at annonsen ikke er -lagret og at katalogen ikke er endret. +fokus til første ugyldige felt. + +Et gyldig skjema lagres med `createListing`. Mens annonsen lagres, er knappen +deaktivert og viser «Legger ut …», slik at samme annonse ikke sendes to +ganger. Etter lagring tømmes skjemaet, og en bekreftelse i `role="status"` +lenker til den nye annonsen. Avviser serveren annonsen, eller svarer den ikke, +vises en feil med rød kant i `role="alert"`. Det brukeren har fylt ut, beholdes +da, så hen kan rette opp eller prøve igjen. ### Layout diff --git a/docs/ki-bruk.md b/docs/ki-bruk.md index 5650b33..a9f6bf2 100644 --- a/docs/ki-bruk.md +++ b/docs/ki-bruk.md @@ -30,12 +30,19 @@ ser hva vi bør være oppmerksomme på. ## Logg -| Issue | Hva KI gjorde | Kontroll og funn | -| ----- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| – | Gikk gjennom repoet mot arbeidsplanen og foreslo de resterende issuene til innlevering 1 | Issuene ble lest og opprettet av gruppen | -| #18 | Laget detaljvisning, `ListingImage`, `RentalRequestForm` og hjelpefunksjoner for leieperiode | Kontroller og nettleser på desktop og 360 px. Første forsøk på datolesing feilet typekontrollen og ble skrevet om | -| #19 | Laget utleieskjema, `SelectField`, `TextAreaField` og validering | Kontroller og nettleser. Fant at prismerket for betalt leie ble usynlig i forhåndsvisningen, og rettet det før commit | -| #20 | Skrev avsnittene om idé, visninger, mockup og planer i README | Teksten ble sjekket mot appen. Sortering ble utelatt til #16 var merget | -| #7 | Satte opp Vitest og skrev 79 tester, og kontrollerte ren installasjon, bredder og tastatur | En test sjekket påkrevde felt med `toBeInvalid()` og ville bestått uansett. Byttet til `aria-invalid`. Nettleserkontrollene var automatiserte, se [testing.md](testing.md) | -| #3 | Skrev prosjektstandard, maler for issues og pull requests og dette dokumentet | Standarden ble sjekket mot commit-historikken og reglene i repoet | -| #21 | Kjørte sluttkontrollen før innlevering 1 og rettet det den fant | Fant at `engines` tillot Node-versjoner som `jsdom` ikke støtter, og at favicon manglet. Begge er rettet. PR-er, review og merge til `main` gjøres av gruppen | +| Issue | Hva KI gjorde | Kontroll og funn | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| – | Gikk gjennom repoet mot arbeidsplanen og foreslo de resterende issuene til innlevering 1 | Issuene ble lest og opprettet av gruppen | +| #18 | Laget detaljvisning, `ListingImage`, `RentalRequestForm` og hjelpefunksjoner for leieperiode | Kontroller og nettleser på desktop og 360 px. Første forsøk på datolesing feilet typekontrollen og ble skrevet om | +| #19 | Laget utleieskjema, `SelectField`, `TextAreaField` og validering | Kontroller og nettleser. Fant at prismerket for betalt leie ble usynlig i forhåndsvisningen, og rettet det før commit | +| #20 | Skrev avsnittene om idé, visninger, mockup og planer i README | Teksten ble sjekket mot appen. Sortering ble utelatt til #16 var merget | +| #7 | Satte opp Vitest og skrev 79 tester, og kontrollerte ren installasjon, bredder og tastatur | En test sjekket påkrevde felt med `toBeInvalid()` og ville bestått uansett. Byttet til `aria-invalid`. Nettleserkontrollene var automatiserte, se [testing.md](testing.md) | +| #3 | Skrev prosjektstandard, maler for issues og pull requests og dette dokumentet | Standarden ble sjekket mot commit-historikken og reglene i repoet | +| #21 | Kjørte sluttkontrollen før innlevering 1 og rettet det den fant | Fant at `engines` tillot Node-versjoner som `jsdom` ikke støtter, og at favicon manglet. Begge er rettet. PR-er, review og merge til `main` gjøres av gruppen | +| #31 | Kjørte en kontroll på backend oppsettet | Sluttkontroll fant Prettier-feil og at seed-bilder ikke følger av Vite-build; dokumentasjon oppdatert. Migrate/seed og typecheck verifisert. PR og merge gjøres av gruppen | +| #33 | Kontroll på oppsettet av backend i VM | Maler er ikke aktivert på VM. GraphQL og Vite `base` gjenstår. SSH til VM ble kjørt manuelt. PR og merge gjøres av gruppen | +| #35 | Undersøkte eksisterende backend, utleieskjema og `listings`-tabell; foreslo og kontrollerte Apollo Server 5, GraphQL-schema, resolvere, PostgreSQL-repository med parameterisert SQL, servervalidering som frontend, og Vitest-integrasjonstester mot egen testdatabase | Typecheck og `npm test` i `backend/` mot lokal database med `_test`-suffiks. README, testing.md og deploy-maler oppdatert. Frontend uendret. PR og merge gjøres av gruppen | +| #39 | Søkte etter åpne datasett om verktøy på nettet (Wikidata, Open Products Facts, Geonorge) og foreslo Wikidata for verktøytyper og merker. Veiledet oppsett av lokal PostgreSQL-database. Skrev skript som henter verktøymodeller fra Wikidata (`import:tools`), migrasjon for `created_at`, generator for varierte annonser med tester og `seed:large` som setter inn datasettet | Wikidata-kategoriene ga mye støy, så verktøytypene velges med faste ID-er. Stikkprøver av genererte annonser viste tekster, tilbehør og merker som ikke passet verktøyet (for eksempel «båndsliper» som «passer til å skru opp hyller»), og dette ble rettet før commit. Migrasjon testet med up/down mot lokal database. | +| #42 | Laget paginering i `listings` med validering, `createdAt` i skjemaet, indeks for sorteringen og API-tester, og målte spørringene med `EXPLAIN ANALYZE` | Kjørt mot ekte PostgreSQL 18 lokalt med 10 010 og 50 010 annonser. En test for rekkefølge ved like tidspunkt bestod også med feil sortering, fordi seed-annonsene har ulike tidspunkt. Testen ble skrevet om med egne testannonser og kontrollert ved å innføre feilen med vilje. Målingene viste at sider langt bak blir tregere med `OFFSET`, og det er dokumentert | +| #43 | Hjalp med oppsett av Apollo Client, katalog med sider, detaljvisning mot GraphQL og mockede GraphQL-svar i testene | Gruppen testet hele flyten lokalt mot PostgreSQL med 10 010 annonser, også feiltilstanden med backenden stoppet, og koden gjennomgås i PR før merge | +| #44 | Koblet utleieskjemaet til `createListing` med Apollo: mutasjon, omgjøring av skjemaverdier, oppdatering av katalogcachen, bekreftelse med lenke og feilmeldinger. Skrev komponenttester med mockede GraphQL-svar | Gruppemedlemmet testet selv lokalt mot PostgreSQL: annonsen ble lagret, vist først i katalogen uten ny sidelasting og åpnet i detaljvisningen. Med backenden stoppet ble feilen vist og feltene beholdt. Typekontrollen fant en feil i en av testene, som ble rettet før commit | diff --git a/docs/prosjektstandard.md b/docs/prosjektstandard.md index eae5bd6..c03bbcf 100644 --- a/docs/prosjektstandard.md +++ b/docs/prosjektstandard.md @@ -57,15 +57,16 @@ Beskrivelsen har liten forbokstav og ikke punktum, for eksempel | `build` | Avhengigheter, byggoppsett og verktøy | | `refactor` | Omskriving som ikke endrer oppførsel | -| Scope | Gjelder | -| -------- | ------------------------------------------- | -| `src` | Komponenter, hooks, utils og data i `src/` | -| `pages` | Sidene i `src/pages/` | -| `app` | Ruting og oppsett i `App.tsx` og `main.tsx` | -| `css` | Felles stiler i `src/styles/` | -| `config` | Konfigurasjonsfiler og `package.json` | -| `docs` | README og filene i `docs/` | -| `github` | Maler og innstillinger i `.github/` | +| Scope | Gjelder | +| --------- | ------------------------------------------- | +| `src` | Komponenter, hooks, utils og data i `src/` | +| `pages` | Sidene i `src/pages/` | +| `backend` | Backend, database og seed i `backend/` | +| `app` | Ruting og oppsett i `App.tsx` og `main.tsx` | +| `css` | Felles stiler i `src/styles/` | +| `config` | Konfigurasjonsfiler og `package.json` | +| `docs` | README og filene i `docs/` | +| `github` | Maler og innstillinger i `.github/` | Én commit skal inneholde én logisk endring. Når flere har jobbet sammen, legges de andre til med `Co-authored-by: navn ` nederst i meldingen. diff --git a/docs/testing.md b/docs/testing.md index 9c35a94..bc4aaef 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -6,8 +6,8 @@ innlevering. Dokumentet oppdateres når nye tester eller kontroller kommer til. ## Kjøre testene ```bash -npm test # kjører alle testene én gang og avslutter -npm run test:watch # kjører testene på nytt ved hver endring +npm test # frontend-testene én gang (rotmappen) +npm run test:watch # frontend ved hver endring ``` Testene bruker [Vitest](https://vitest.dev/) med @@ -19,6 +19,27 @@ Oppsettet ligger i `vite.config.ts` og `src/test/setup.ts`. Testfilene ligger ved siden av koden de tester og heter `*.test.ts` eller `*.test.tsx`. +### GraphQL (backend) + +API-testene ligger i `backend/` og kjøres **separat** fra frontend-testene: + +```bash +cd backend +npm test # én kjøring +npm run test:watch # ved endringer +``` + +De bruker Vitest i Node (ikke jsdom) og Apollo Server sin +`executeOperation`, uten å starte HTTP-server. Integrasjonstestene trenger en +egen PostgreSQL-database: sett `TEST_DATABASE_URL` i `backend/.env.test` (mal: +`backend/.env.test.example`). Databasenavnet **må slutte på `_test`**, slik at +testene ikke kan peke på utviklings- eller produksjonsdatabase ved en feil. +Migrer og seed mot testdatabasen én gang før første kjøring — se +[README.md](../README.md#api-tester-backend). + +Uten `.env.test` hoppes integrasjonstestene over; enhetstester for +testdatabase-URL kjører fortsatt. + ## Prinsipper - **Test oppførsel, ikke implementasjon.** Testene finner elementer slik en @@ -38,21 +59,37 @@ Testfilene ligger ved siden av koden de tester og heter `*.test.ts` eller ## Hva som testes -| Fil | Hva som testes | -| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `src/App.test.tsx` | Navigasjon via meny, annonsekort og tilbakelenke, aktiv side, fanetittel, fokus ved sidebytte, hopp-lenke, 404-sider, katalogsøk | -| `utils/listingFormat.test.ts` | Døgnpris, gratis utlån og norsk tusenskille | -| `utils/listingFilters.test.ts` | Søk uten å skille store og små bokstaver, tomt søk, kombinerte filtre, filtre i URL-en | -| `utils/listingSort.test.ts` | Sortering på pris, stabil rekkefølge ved lik pris, sortering i URL-en | -| `utils/rentalPeriod.test.ts` | Antall døgn, samme dag, overgang fra sommertid, datovalidering | -| `utils/listingForm.test.ts` | Validering av alle feltene, pris 0, ugyldige priser som «12,5» og «1e3» | -| `components/PriceTag/` | Gratismerking og døgnpris vises som tekst | -| `components/TextField/` | Tilgjengelig navn, beskrivelse og feilmelding for `TextField`, `SelectField` og `TextAreaField` | -| `components/Button/` | Sender ikke inn skjema ved et uhell, tastatur og deaktivert knapp | -| `components/RentalRequestForm/` | Feil ved tomme og ugyldige datoer, samlet pris, gratis utlån, demomelding | -| `components/ListingForm/` | Etiketter, feil og fokus på første ugyldige felt, prisforhåndsvisning, demomelding | - -Status 1. oktober 2026: 11 testfiler og 79 tester, alle grønne. +| Fil | Hva som testes | +| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/App.test.tsx` | Navigasjon, aktiv side, fanetittel, fokus ved sidebytte, hopp-lenke og 404. Katalog og detaljvisning mot mockede GraphQL-svar: lasting, antall fra serveren, sider i URL-en, blaing, feil med «Prøv igjen», tom katalog, side som ikke finnes og ukjent annonse | +| `components/Pagination/` | Lenker til forrige og neste side, ingen lenke på første og siste side, skjult når alt får plass på én side | +| `utils/catalogPage.test.ts` | Lesing av `?side=` fra URL-en, lenker til sider og antall sider | +| `utils/listingFormat.test.ts` | Døgnpris, gratis utlån og norsk tusenskille | +| `utils/listingFilters.test.ts` | Søk uten å skille store og små bokstaver, tomt søk, kombinerte filtre, filtre i URL-en | +| `utils/listingSort.test.ts` | Sortering på pris, stabil rekkefølge ved lik pris, sortering i URL-en | +| `utils/rentalPeriod.test.ts` | Antall døgn, samme dag, overgang fra sommertid, datovalidering | +| `utils/listingForm.test.ts` | Validering av alle feltene, pris 0, ugyldige priser som «12,5» og «1e3», skjemaverdier gjort om til input for `createListing` | +| `components/PriceTag/` | Gratismerking og døgnpris vises som tekst | +| `components/TextField/` | Tilgjengelig navn, beskrivelse og feilmelding for `TextField`, `SelectField` og `TextAreaField` | +| `components/Button/` | Sender ikke inn skjema ved et uhell, tastatur og deaktivert knapp | +| `components/RentalRequestForm/` | Feil ved tomme og ugyldige datoer, samlet pris, gratis utlån, demomelding | +| `components/ListingForm/` | Etiketter, feil og fokus på første ugyldige felt, prisforhåndsvisning. Innsending med mockede GraphQL-svar: bekreftelse med lenke, tømt skjema, deaktivert knapp under lagring, feil fra serveren og nettverksfeil der feltene beholdes | +| `graphql/listingMutations.test.ts` | Feilmelding ved `BAD_USER_INPUT`, andre feil gir ingen melding, katalogsider fjernes fra cachen etter lagring | +| `backend/src/graphql/graphql.api.test.ts` | `health`, `listings`, `listing`, `createListing`, validering; testdata ryddes etter hver test | +| `backend/src/graphql/listingsPagination.api.test.ts` | Paginering i `listings`: første og siste side, side utenfor treffmengden, `totalCount`, stabil rekkefølge ved like tidspunkt, ugyldig `limit` og `offset` | +| `backend/src/validation/paginationArgs.test.ts` | Standardverdier og grenser for `limit` og `offset`, uten database | +| `backend/src/test/testDatabaseUrl.test.ts` | Krav om databasenavn som slutter på `_test` for integrasjonstester | + +Status 14. oktober 2026, frontend: 13 testfiler og 97 tester, alle grønne. + +Komponenttestene bruker `MockedProvider` fra Apollo med svarene i +`src/test/listingMocks.ts`, og `renderApp` i `src/test/renderApp.tsx` rendrer +hele appen med mockene. Hver mock brukes én gang, så en test som henter samme +side to ganger, for eksempel «Prøv igjen», trenger to mocks. +Status 14. oktober 2026, backend: 5 testfiler og 41 tester, alle grønne mot en +lokal testdatabase med PostgreSQL 18. + +Før merge av GraphQL-endringer: `cd backend && npm run typecheck && npm test`. ## Kontroller før innlevering @@ -133,7 +170,7 @@ gjort, og flytt resultatet opp i dokumentet. - [ ] Appen prøvd på en ekte telefon - [ ] Hovedflyten prøvd med skjermleser (VoiceOver eller NVDA) - [ ] Manuell brukertest med en person utenfor gruppen -- [ ] API-tester mot testdatabase, når backenden finnes +- [x] API-tester mot testdatabase (GraphQL i `backend/`, oktober 2026) - [ ] Ende-til-ende-test med Playwright for en hel leieflyt, når booking finnes ## Begrensninger @@ -144,6 +181,7 @@ gjort, og flytt resultatet opp i dokumentet. senere innleveringer. - **Datofelt i Chromium** har ett tabulatorstopp for dag, måned og år. Det er nettleserens egen oppførsel. -- **Ingen ende-til-ende-tester eller API-tester ennå.** De kommer når - backenden finnes. Testene for appen rendrer hele `App` i en `MemoryRouter`, - men kjører i jsdom og sjekker ikke layout. +- **GraphQL-testene dekker ikke HTTP eller Apache-proxy**, bare resolver-laget + mot PostgreSQL. Ende-til-ende-tester mot hele leieflyten finnes ikke ennå. + Frontend-testene rendrer hele `App` i en `MemoryRouter`, men kjører i jsdom og + sjekker ikke layout. diff --git a/docs/vm-backend.md b/docs/vm-backend.md new file mode 100644 index 0000000..059c859 --- /dev/null +++ b/docs/vm-backend.md @@ -0,0 +1,506 @@ +# VM: PostgreSQL og backend (prosjekt 2) + +Kort beskrivelse av databaselaget og GraphQL-backenden på gruppens NTNU-VM. +**Apache-proxy og frontend mot API** er fortsatt ikke aktivert — GraphQL kjører +kun på **`127.0.0.1:3001`** inne på VM (systemd). + +**Lokal utvikling på egen maskin** (egen PostgreSQL, egen `.env`) står i +README under [Lokal backend-utvikling](../README.md#lokal-backend-utvikling). +Dette dokumentet gjelder **kun** den delte VM-en. + +**VM:** `it2810-36.idi.ntnu.no` (Ubuntu 26.04.1 LTS) + +## Status på VM (verifisert) + +Følgende er **på plass** og testet: + +| Komponent | Detalj | +| -------------- | -------------------------------------------------------------------- | +| PostgreSQL | 18.6, cluster `18/main`, port 5432, autostart | +| Database | `t36_project2` | +| Databasebruker | `t36_project2` | +| Node.js | 24.21.0 via nvm (npm 11.x) | +| Schema/data | To migrasjoner, 10 seed-annonser og 10 000 genererte (totalt 10 010) | +| GraphQL | systemd `t36-project2-backend`, `127.0.0.1:3001/graphql` | + +Repo for prosjekt 2 ligger i `~/T36-Project-2` på VM. + +### Stort datasett (verifisert 15. oktober 2026) + +Lastet inn etter fremgangsmåten i [Stort datasett](#stort-datasett), fra +`/home/andretg/T36-Project-2/backend` med koden fra grenen til #41 (samme kode som `dev` etter #39, pluss +dokumentasjon). + +| Punkt | Resultat | +| ------------ | ------------------------------------------------------------------------------ | +| Backup | `~/t36_project2-for-datasett-20261015.dump` hos `andretg`, tatt før endringene | +| Migrasjon | `1791978982612_add-listings-created-at` kjørt. `pgmigrations` har to rader | +| `seed:large` | 10 000 nye, 0 hoppet over, 0,4 s | +| Antall rader | 10 010 i `listings`, hvorav 10 000 genererte (`gen-…`) | +| GraphQL | `health` gir `ok`. `listing(id: "gen-000001")` gir «Båndsliper med støvpose» | +| Restart | Ikke nødvendig. Tjenesten så de nye radene uten restart | + +### GraphQL/systemd (verifisert oktober 2026) + +Deploy er kjørt og kontrollert på `it2810-36.idi.ntnu.no`: + +| Punkt | Verdi | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| Tjeneste | `t36-project2-backend` — `active`, `enabled` | +| Endepunkt | `http://127.0.0.1:3001/graphql` (kun på VM / via SSH) | +| `WorkingDirectory` | `/home/birkjr/T36-Project-2/backend` | +| `ExecStart` (Node) | `/home/birkjr/.nvm/versions/node/v24.21.0/bin/node` + lokal `tsx` | +| `User` / `Group` | `birkjr` / `eall_s` (på VM; mal i repo bruker `Group=__DEPLOY_USER__` — sett `Group` til primærgruppen din om den avviker) | +| Miljøfil | `/etc/t36-project2/backend.env` (`DATABASE_URL`, `HOST=127.0.0.1`, `PORT=3001`) | +| Data | 10 seed-annonser og 10 000 genererte (se [Stort datasett](#stort-datasett-verifisert-15-oktober-2026)) | +| Mutasjon | `createListing` testet; testannonse slettet i PostgreSQL | +| Drift | `systemctl restart t36-project2-backend` verifisert | +| Gjenstår | Full **VM-reboot** for å bekrefte autostart etter boot (ikke kjørt ennå) | + +Andre gruppemedlemmer med SSH til samme VM trenger ikke ny systemd-installasjon +med mindre enheten endres; de kan bruke GraphQL med `curl` mot `127.0.0.1:3001` +inne på VM. + +**Andre gruppemedlemmer** trenger normalt bare NTNU VPN, SSH-tilgang til VM-en og +`DATABASE_URL` (databasepassord). De skal **ikke** kjøre +førstegangsoppsettet under med mindre noe må bygges på nytt. + +## 1. SSH via NTNU VPN + +1. Koble til [NTNU VPN](https://i.ntnu.no/vpn). +2. Logg inn (bytt ut brukernavn med ditt NTNU-brukernavn): + +```bash +ssh @it2810-36.idi.ntnu.no +``` + +Passord eller SSH-nøkkel avhenger av VM-oppsettet fra faget. + +## 2. Repo og backend (vanlig flyt) + +### Første gang på VM + +```bash +cd ~ +git clone https://git.ntnu.no/IT2810-H26/T36-Project-2.git +cd T36-Project-2 +git checkout dev +git pull +``` + +### Senere besøk + +```bash +cd ~/T36-Project-2 +git pull +``` + +### Avhengigheter, migrasjon og seed + +Alle kommandoer under kjøres fra **`~/T36-Project-2/backend`**: + +```bash +cd ~/T36-Project-2/backend +nvm use 24 # eller tilsvarende, slik at node -v viser 24.x +npm ci +npm run typecheck +npm run migrate +npm run seed +``` + +- Første seed på tom database: forvent **10 nye** annonser. +- Senere kjøringer: **0 nye**, 10 hoppet over (idempotent). + +### Stort datasett + +Fyller den delte databasen med 10 000 genererte annonser. Se +[docs/datasett.md](datasett.md) for kilde, fordelinger og målinger. + +**Før du starter:** Avtal med gruppen hvem som kjører, slik at ikke to gjør det +samtidig. Skriptene kan kjøres fra hvilken som helst kopi av repoet på VM-en, +for eksempel din egen i `~/T36-Project-2`, fordi alle peker på samme database. +Du trenger Node 24 for din egen bruker (se +[Node.js 24 via nvm](#nodejs-24-via-nvm)) og `backend/.env` med +`DATABASE_URL` (se §3). + +**1. Hent nyeste kode fra `dev`:** + +```bash +cd ~/T36-Project-2 +git checkout dev +git pull +cd backend +npm ci +``` + +**2. Ta backup** (passord via `-W`). Filen havner i hjemmemappen din: + +```bash +pg_dump -h localhost -U t36_project2 -d t36_project2 -Fc -W -f ~/t36_project2-for-datasett-$(date +%Y%m%d).dump +``` + +**3. Migrer og last inn datasettet:** + +```bash +npm run migrate +npm run seed:large +``` + +- `migrate` legger til `created_at` i `listings`. Det må gjøres før + `seed:large`, som bruker kolonnen. +- `seed:large` skal skrive «10000 nye» og «Totalt 10010 rader». +- Kjøres `seed:large` på nytt, skal den skrive «0 nye» (idempotent). + +**4. Kontroller databasen:** + +```bash +psql -h localhost -U t36_project2 -d t36_project2 -W +``` + +```sql +SELECT COUNT(*) FROM listings; -- 10010 +SELECT COUNT(*) FROM listings WHERE id LIKE 'gen-%'; -- 10000 +\d listings -- har created_at +SELECT name FROM pgmigrations ORDER BY run_on; -- to migrasjoner +``` + +**5. Kontroller GraphQL.** Den kjørende tjenesten ser de nye radene med en +gang, uten restart: + +```bash +curl -sS -X POST http://127.0.0.1:3001/graphql \ + -H 'Content-Type: application/json' \ + -d '{"query":"{ health listing(id: \"gen-000001\") { id title dailyPrice } }"}' +``` + +Forvent `"health":"ok"` og annonsen `gen-000001`. Bruk ikke `listings` til +kontrollen: den returnerer alle 10 010 annonsene i ett svar. + +Tjenesten trenger bare restart når koden i mappen den kjører fra, er oppdatert +med `git pull` (se §7.3). Det gjøres av den som eier tjenesten. + +**Angre:** Slett de genererte annonsene og fjern kolonnen igjen: + +```bash +psql -h localhost -U t36_project2 -d t36_project2 -W -c "DELETE FROM listings WHERE id LIKE 'gen-%';" +npx node-pg-migrate down --envPath .env +``` + +Backupen fra steg 2 kan også gjenopprettes med `pg_restore --clean`. + +## 3. Sikker håndtering av `DATABASE_URL` + +- **`DATABASE_URL` skal aldri committes.** Kun `backend/.env.example` med + plassholdere ligger i git. +- På VM: opprett `backend/.env` (fra `.env.example`), fyll inn verdi gruppen har + avtalt, og begrens filrettigheter — **fra katalogen `backend/`**: + +```bash +cd ~/T36-Project-2/backend +cp .env.example .env +# Rediger .env i editor (ikke lim passord inn i terminalkommandoer) +chmod 600 .env +``` + +- Eksempelformat i `.env` (erstatt passord med gruppen sitt, ikke skriv det i + dokumentasjon eller issues): + +```env +DATABASE_URL=postgresql://t36_project2:PASSORD@localhost:5432/t36_project2 +``` + +Erstatt `PASSORD` i editoren med gruppen sitt databasepassord (ikke i terminal +eller git). Alternativt kan gruppen senere vurdere +[pgpass](https://www.postgresql.org/docs/current/libpq-pgpass.html) om gruppen +velger det senere. + +- **Ikke** eksporter `DATABASE_URL` i shell med passord i klartekst (historikk). + `npm run migrate` og `npm run seed` leser `.env` via `--envPath` / `--env-file`. + +## 4. Kontroll at oppsettet fungerer + +Fra **`~/T36-Project-2/backend`**: + +```bash +npm run typecheck +npm run migrate # forvent «No migrations to run!» når schema er oppdatert +npm run seed # forvent 0 nye når data allerede finnes +``` + +SQL (passord **interaktivt**, ikke i kommandolinjen): + +```bash +psql -h localhost -U t36_project2 -d t36_project2 -W -c "SELECT COUNT(*) FROM listings;" +psql -h localhost -U t36_project2 -d t36_project2 -W -c "SELECT id, title, daily_price FROM listings ORDER BY id;" +``` + +Forvent **10** rader i `listings`. + +Tjenester og Node: + +```bash +sudo systemctl is-active postgresql +node -v +``` + +## 5. Prosjekt 1 på samme VM + +- **Ikke** stopp, avinstaller eller rekonfigurer Apache, databaser eller tjenester + som prosjekt 1 bruker uten å sjekke med gruppen. +- Prosjekt 2 bruker egen database (`t36_project2`) — ikke del eller overskriv + prosjekt 1s data. +- Apache `/project2` og proxy mot port **3001** er **ikke** aktivert for prosjekt 2 + ennå. GraphQL er bare nåbar **på VM-en** via `127.0.0.1:3001/graphql` (SSH / + tunnel), ikke fra internett uten videre oppsett. + +Etter endringer: verifiser at prosjekt 1 fortsatt fungerer. + +## 6. Backup + +VM-en har **ikke** automatisk backup. Ved behov, fra **`~/T36-Project-2/backend`** +(passord via `-W`): + +```bash +pg_dump -h localhost -U t36_project2 -d t36_project2 -Fc -W -f "t36_project2-$(date +%Y%m%d).dump" +``` + +Lagre dump utenfor VM. Gjenoppretting er gruppens ansvar. + +--- + +## Referanse: førstegangsoppsett på VM + +> **Kun ved ny VM eller gjenoppbygging.** Gruppemedlemmer som kobler til +> eksisterende VM skal **ikke** kjøre dette på nytt uten avtale. + +### PostgreSQL: installasjon og tjeneste + +```bash +sudo apt update +sudo apt install -y postgresql postgresql-contrib +sudo systemctl enable postgresql +sudo systemctl start postgresql +pg_lsclusters +``` + +### Database og bruker (passord interaktivt) + +Ikke endre databaser som tilhører prosjekt 1. + +```bash +sudo -u postgres psql +``` + +I `psql`: + +```sql +CREATE USER t36_project2; +CREATE DATABASE t36_project2 OWNER t36_project2; +GRANT ALL PRIVILEGES ON DATABASE t36_project2 TO t36_project2; +\password t36_project2 +\q +``` + +Test ( `-W` ber om passord): + +```bash +psql -h localhost -U t36_project2 -d t36_project2 -W -c 'SELECT 1 AS ok;' +``` + +### Node.js 24 via nvm + +Prosjektet krever Node 22.22.2+ eller 24.15+ (se `.nvmrc`). + +Installer nvm etter [nvm-dokumentasjonen](https://github.com/nvm-sh/nvm), logg ut +og inn, deretter: + +```bash +nvm install 24 +nvm use 24 +node -v +``` + +## 7. GraphQL med systemd (port 3001) + +Detaljer og feilsøking: [backend/deploy/README.md](../backend/deploy/README.md). + +| Fil | Formål | +| --------------------------------------------- | ----------------------------------------------------------------- | +| `backend/deploy/t36-project2-backend.service` | systemd-mal (plassholdere erstattes på VM) | +| `/etc/t36-project2/backend.env` | Hemmeligheter for **tjenesten** (utenfor git) | +| `~/T36-Project-2/backend/.env` | Valgfritt for **migrate/seed** (endres ikke av systemd-oppsettet) | + +Tjenesten **`t36-project2-backend`** kjører som din NTNU-bruker, lytter på +`127.0.0.1:3001`, GraphQL på **`/graphql`**. `ExecStart` bruker absolutt sti til +Node 24 (nvm) og `node_modules/tsx` etter `npm ci`. + +### 7.1 Første gangs aktivering på VM + +Erstatt `` med ditt brukernavn. Kjør steg for steg; ikke lim +databasepassord inn i terminalen. + +**1. Hent kode (grenen `dev` skal inneholde GraphQL etter merge #35/#36):** + +```bash +ssh @it2810-36.idi.ntnu.no +cd ~/T36-Project-2 +git fetch origin +git checkout dev +git pull origin dev +``` + +**2. Backend-avhengigheter:** + +```bash +cd ~/T36-Project-2/backend +source ~/.nvm/nvm.sh +nvm use 24 +node -v +npm ci +npm run typecheck +test -f node_modules/tsx/dist/cli.mjs +``` + +**3. Noter absolutt Node-sti (systemd laster ikke nvm-shell):** + +```bash +export DEPLOY_USER="$(whoami)" +export NODE_BIN="$(which node)" +export BACKEND_DIR="/home/${DEPLOY_USER}/T36-Project-2/backend" +echo "DEPLOY_USER=${DEPLOY_USER}" +echo "NODE_BIN=${NODE_BIN}" +echo "BACKEND_DIR=${BACKEND_DIR}" +``` + +**4. Miljøfil for tjenesten** (overskriver **ikke** `backend/.env`): + +```bash +sudo mkdir -p /etc/t36-project2 +sudo cp "${BACKEND_DIR}/deploy/backend.env.example" /etc/t36-project2/backend.env +sudo chmod 640 /etc/t36-project2/backend.env +sudo chown "root:${DEPLOY_USER}" /etc/t36-project2/backend.env +sudo nano /etc/t36-project2/backend.env +``` + +I editoren: sett `DATABASE_URL=postgresql://t36_project2:…@localhost:5432/t36_project2` +(samme passord som gruppen bruker i `backend/.env`), `HOST=127.0.0.1`, `PORT=3001`. +Lagre og lukk. + +**5. Manuell røyktest** (Ctrl+C for å stoppe): + +```bash +set -a && source /etc/t36-project2/backend.env && set +a +"${NODE_BIN}" "${BACKEND_DIR}/node_modules/tsx/dist/cli.mjs" "${BACKEND_DIR}/src/server.ts" +``` + +I annen SSH-sesjon (eller etter Ctrl+C og systemd): + +```bash +curl -sS -X POST http://127.0.0.1:3001/graphql \ + -H 'Content-Type: application/json' \ + -d '{"query":"{ health }"}' +``` + +**6. Installer og start systemd** (kun prosjekt 2, port 3001): + +```bash +sed -e "s|__DEPLOY_USER__|${DEPLOY_USER}|g" \ + -e "s|__BACKEND_DIR__|${BACKEND_DIR}|g" \ + -e "s|__NODE_BIN__|${NODE_BIN}|g" \ + "${BACKEND_DIR}/deploy/t36-project2-backend.service" \ + | sudo tee /etc/systemd/system/t36-project2-backend.service > /dev/null + +sudo systemctl daemon-reload +sudo systemctl enable t36-project2-backend +sudo systemctl start t36-project2-backend +``` + +**7. Status og logger:** + +```bash +sudo systemctl status t36-project2-backend --no-pager +sudo systemctl is-enabled t36-project2-backend +journalctl -u t36-project2-backend -n 50 --no-pager +ss -tlnp | grep 3001 || true +``` + +### 7.2 Verifisering av GraphQL + +```bash +curl -sS -X POST http://127.0.0.1:3001/graphql \ + -H 'Content-Type: application/json' \ + -d '{"query":"{ health }"}' + +curl -sS -X POST http://127.0.0.1:3001/graphql \ + -H 'Content-Type: application/json' \ + -d '{"query":"{ listings(limit: 5) { totalCount items { id title dailyPrice } } }"}' +``` + +Test `createListing` og rydd opp (ingen delete-mutation — slett i SQL): + +```bash +curl -sS -X POST http://127.0.0.1:3001/graphql \ + -H 'Content-Type: application/json' \ + -d '{"query":"mutation { createListing(input: { title: \"VM systemd test\", category: handTools, description: \"Kort test\", area: \"Gløshaugen\", dailyPrice: 1 }) { id title } }"}' +``` + +Noter `id` fra svaret, deretter (passord med `-W`): + +```bash +psql -h localhost -U t36_project2 -d t36_project2 -W \ + -c "DELETE FROM listings WHERE id = 'listing-…';" +``` + +Autostart: `sudo systemctl is-enabled t36-project2-backend` skal vise `enabled`. +Full VM-reboot for å bekrefte autostart etter boot er **ikke** kjørt ennå (planlegg +i avtalt vindu). + +### 7.3 Drift: start, stopp, restart, oppdatering + +```bash +sudo systemctl stop t36-project2-backend +sudo systemctl start t36-project2-backend +sudo systemctl restart t36-project2-backend +``` + +Etter `git pull` i backend: + +```bash +cd ~/T36-Project-2/backend && source ~/.nvm/nvm.sh && nvm use 24 && npm ci +sudo systemctl restart t36-project2-backend +``` + +### 7.4 Feilsøking + +| Symptom | Sjekk | +| --------------------------- | ------------------------------------------------------------------- | +| `failed` / exit code | `journalctl -u t36-project2-backend -n 100 --no-pager` | +| `DATABASE_URL` / tilkobling | Innhold i `/etc/t36-project2/backend.env`, `psql` med samme URL | +| `tsx` mangler | `npm ci` i `backend/`, `test -f node_modules/tsx/dist/cli.mjs` | +| Node ikke funnet | `NODE_BIN` i unit-filen må være **absolutt** sti etter `nvm use 24` | +| Port opptatt | `ss -tlnp \| grep 3001` — skal ikke kollidere med prosjekt 1 | +| Endret Node-versjon | Oppdater `ExecStart`/`NODE_BIN`, `daemon-reload`, `restart` | + +`src/server.ts` laster også `backend/.env` via dotenv hvis filen finnes; variabler +fra systemd (`EnvironmentFile`) overskrives ikke. Migrate/seed bruker fortsatt +`backend/.env` som før. + +## 8. Apache `/project2/` (forberedt i repo) + +Egen site-mal (prosjekt 2 only) ligger i +[deploy/apache/README.md](../deploy/apache/README.md): + +- Statisk frontend fra `dist/` med SPA-fallback +- Reverse proxy `/project2/api/` → `http://127.0.0.1:3001/` + +Krever `a2enmod alias rewrite proxy proxy_http` og **`apache2ctl configtest`** +før `reload`. **Ikke `a2ensite` på VM ennå** — krever Vite `base: '/project2/'` +og GraphQL. + +## Gjenstår (ikke implementert) + +- Aktivering av Apache-site `t36-project2.conf` og offentlig `/project2/api/` +- Frontend koblet til GraphQL +- Evt. full VM-reboot-test av systemd autostart (anbefales i avtalt vindu) + +Se [README](../README.md) for planlagt videre arbeid. diff --git a/package-lock.json b/package-lock.json index 5ba7022..a80c6cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,12 @@ "name": "t36-verktoyutlan", "version": "0.0.0", "dependencies": { + "@apollo/client": "^4.3.3", + "graphql": "^16.14.2", "react": "^19.3.0", "react-dom": "^19.3.0", - "react-router": "^8.4.0" + "react-router": "^8.4.0", + "rxjs": "^7.8.2" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -45,6 +48,43 @@ "dev": true, "license": "MIT" }, + "node_modules/@apollo/client": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.3.3.tgz", + "integrity": "sha512-wMfOUaFKLgNsu0OMCg50gfhEHTV9/GyO7DxQvut7iSBUoePy0kvll3oWeY8KVPRfnVhYEzcwPJ/Jwz4uSe3fQA==", + "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/@asamuzakjp/css-color": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.2.tgz", @@ -671,6 +711,15 @@ } } }, + "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", @@ -1564,6 +1613,54 @@ "url": "https://opencollective.com/vitest" } }, + "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", @@ -2323,6 +2420,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", @@ -2958,6 +3079,18 @@ "node": ">=12.20.0" } }, + "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", @@ -3269,6 +3402,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/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -3447,6 +3589,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/package.json b/package.json index 83e9c8c..bfa98ad 100644 --- a/package.json +++ b/package.json @@ -15,13 +15,16 @@ "test": "vitest run", "test:watch": "vitest", "lint": "eslint .", - "format": "prettier . --write", - "format:check": "prettier . --check" + "format": "prettier --write .", + "format:check": "node scripts/format-check.mjs" }, "dependencies": { + "@apollo/client": "^4.3.3", + "graphql": "^16.14.2", "react": "^19.3.0", "react-dom": "^19.3.0", - "react-router": "^8.4.0" + "react-router": "^8.4.0", + "rxjs": "^7.8.2" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/assets/listings/drill.svg b/public/assets/listings/drill.svg similarity index 100% rename from src/assets/listings/drill.svg rename to public/assets/listings/drill.svg diff --git a/src/assets/listings/handsaw.svg b/public/assets/listings/handsaw.svg similarity index 100% rename from src/assets/listings/handsaw.svg rename to public/assets/listings/handsaw.svg diff --git a/src/assets/listings/paint-roller.svg b/public/assets/listings/paint-roller.svg similarity index 100% rename from src/assets/listings/paint-roller.svg rename to public/assets/listings/paint-roller.svg diff --git a/src/assets/listings/spade.svg b/public/assets/listings/spade.svg similarity index 100% rename from src/assets/listings/spade.svg rename to public/assets/listings/spade.svg diff --git a/scripts/format-check.mjs b/scripts/format-check.mjs new file mode 100644 index 0000000..91e41ea --- /dev/null +++ b/scripts/format-check.mjs @@ -0,0 +1,12 @@ +import { spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); + +const result = spawnSync('prettier', ['--check', '.'], { + cwd: root, + stdio: 'inherit', +}); + +process.exit(result.status === 0 ? 0 : 1); diff --git a/src/App.test.tsx b/src/App.test.tsx index 5cc2e05..4f315c6 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,23 +1,25 @@ -import { render, screen, within } from '@testing-library/react'; +import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router'; import { describe, expect, it } from 'vitest'; -import App from './App.tsx'; - -function renderApp(route = '/') { - return render( - - - , - ); -} +import { mockListings } from './data/mockListings.ts'; +import { + listingMock, + listingsPageMock, + networkErrorMock, +} from './test/listingMocks.ts'; +import { renderApp } from './test/renderApp.tsx'; function heading() { return screen.getByRole('heading', { level: 1 }); } +/** Venter til katalogen har fått svar fra (den mockede) backenden. */ +function findCards() { + return screen.findAllByRole('article'); +} + describe('navigasjon', () => { - it('viser katalogen på startsiden og markerer aktiv side', () => { + it('viser katalogen på startsiden og markerer aktiv side', async () => { renderApp(); expect(heading()).toHaveTextContent('Finn verktøy'); expect(document.title).toBe('Katalog – Verktøyutlån Trondheim'); @@ -26,6 +28,7 @@ describe('navigasjon', () => { 'aria-current', 'page', ); + await findCards(); }); it('går til utleieskjemaet fra menyen', async () => { @@ -36,10 +39,11 @@ describe('navigasjon', () => { }); it('åpner detaljvisningen fra et annonsekort og går tilbake', async () => { - renderApp(); - await userEvent.click(screen.getByRole('link', { name: 'Drill' })); - expect(heading()).toHaveTextContent('Drill'); - expect(screen.getByText('Moholt')).toBeInTheDocument(); + renderApp('/', [listingsPageMock(), listingMock('listing-001')]); + await userEvent.click(await screen.findByRole('link', { name: 'Drill' })); + expect( + await screen.findByRole('heading', { level: 1, name: 'Drill' }), + ).toBeInTheDocument(); await userEvent.click( screen.getByRole('link', { name: 'Tilbake til katalogen' }), @@ -68,47 +72,127 @@ describe('navigasjon', () => { screen.getByRole('link', { name: 'Gå til katalogen' }), ).toBeInTheDocument(); }); - - it('viser «Fant ikke annonsen» for ukjent annonse-ID', () => { - renderApp('/annonser/finnes-ikke'); - expect(heading()).toHaveTextContent('Fant ikke annonsen'); - }); }); describe('katalogen', () => { - it('leser filtre fra URL-en', () => { - renderApp('/?gratis=1'); + it('viser lastetilstand og deretter annonsene fra serveren', async () => { + renderApp('/', [listingsPageMock({ totalCount: 10010 })]); + expect(screen.getByRole('status')).toHaveTextContent('Laster annonser'); + + expect(await findCards()).toHaveLength(mockListings.length); + // Antallet kommer fra totalCount, ikke fra antall kort på siden. + expect(screen.getByRole('status')).toHaveTextContent( + /10\s010 annonser · side 1 av 418/, + ); + }); + + it('leser siden fra URL-en og lenker til forrige og neste side', async () => { + renderApp('/?side=2', [listingsPageMock({ page: 2, totalCount: 10010 })]); + await findCards(); + expect(screen.getByRole('status')).toHaveTextContent('side 2 av 418'); + + const pages = screen.getByRole('navigation', { name: 'Sider' }); expect( - screen.getByRole('checkbox', { name: 'Kun gratis utlån' }), - ).toBeChecked(); - const cards = screen - .getAllByRole('listitem') - .filter((item) => within(item).queryByRole('heading')); - expect(cards.length).toBeGreaterThan(0); - for (const card of cards) { - expect(card).toHaveTextContent('Gratis utlån'); - } - }); - - it('oppdaterer antall treff mens brukeren søker', async () => { - renderApp(); - await userEvent.type( - screen.getByRole('searchbox', { name: 'Søk' }), - 'spade', + within(pages).getByRole('link', { name: 'Forrige' }), + ).toHaveAttribute('href', '/'); + expect(within(pages).getByRole('link', { name: 'Neste' })).toHaveAttribute( + 'href', + '/?side=3', ); - expect(screen.getByRole('status')).toHaveTextContent(/^Viser 2 av \d+/); }); - it('viser tomtilstand og kan fjerne filtrene', async () => { - renderApp('/?q=motorsag'); + it('henter neste side når brukeren blar', async () => { + const firstPage = mockListings.slice(0, 5); + const secondPage = mockListings.slice(5); + renderApp('/', [ + listingsPageMock({ page: 1, items: firstPage, totalCount: 30 }), + listingsPageMock({ page: 2, items: secondPage, totalCount: 30 }), + ]); + await findCards(); + + await userEvent.click(screen.getByRole('link', { name: 'Neste' })); expect( - screen.getByRole('heading', { name: 'Ingen annonser passer søket' }), + await screen.findByRole('link', { name: secondPage[0]!.title }), ).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('side 2 av 2'); + }); + + it('viser feil når backenden ikke svarer, og kan prøve igjen', async () => { + renderApp('/', [ + networkErrorMock(listingsPageMock().request), + listingsPageMock(), + ]); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('Backenden svarer ikke'); await userEvent.click( - screen.getByRole('button', { name: 'Fjern alle filtre' }), + within(alert).getByRole('button', { name: 'Prøv igjen' }), ); - expect(screen.getByRole('searchbox', { name: 'Søk' })).toHaveValue(''); - expect(screen.getByRole('status')).not.toHaveTextContent(/Viser/); + expect(await findCards()).toHaveLength(mockListings.length); + }); + + it('viser tomtilstand når det ikke finnes annonser', async () => { + renderApp('/', [listingsPageMock({ items: [], totalCount: 0 })]); + expect( + await screen.findByRole('heading', { name: 'Ingen annonser ennå' }), + ).toBeInTheDocument(); + }); + + it('forteller når siden ikke finnes', async () => { + renderApp('/?side=99', [ + listingsPageMock({ page: 99, items: [], totalCount: 10 }), + ]); + expect( + await screen.findByRole('heading', { name: 'Siden finnes ikke' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Gå til første side' }), + ).toHaveAttribute('href', '/'); + }); + + it('viser ikke søk og filtre som bare ville virket på én side', async () => { + renderApp(); + await findCards(); + expect(screen.queryByRole('searchbox')).not.toBeInTheDocument(); + expect(screen.queryByRole('search')).not.toBeInTheDocument(); + }); +}); + +describe('detaljvisningen', () => { + it('henter annonsen fra serveren og viser alle opplysningene', async () => { + renderApp('/annonser/listing-001', [listingMock('listing-001')]); + expect(screen.getByRole('status')).toHaveTextContent('Laster annonsen'); + + expect( + await screen.findByRole('heading', { level: 1, name: 'Drill' }), + ).toBeInTheDocument(); + expect(screen.getByText('Moholt')).toBeInTheDocument(); + expect(screen.getByText('100 kr/døgn')).toBeInTheDocument(); + expect(screen.getByText('2 batterier')).toBeInTheDocument(); + expect(document.title).toBe('Drill – Verktøyutlån Trondheim'); + }); + + it('viser «Fant ikke annonsen» når serveren svarer null', async () => { + renderApp('/annonser/finnes-ikke', [listingMock('finnes-ikke', null)]); + expect( + await screen.findByRole('heading', { + level: 1, + name: 'Fant ikke annonsen', + }), + ).toBeInTheDocument(); + }); + + it('viser feil når backenden ikke svarer, og kan prøve igjen', async () => { + renderApp('/annonser/listing-001', [ + networkErrorMock(listingMock('listing-001').request), + listingMock('listing-001'), + ]); + const alert = await screen.findByRole('alert'); + await userEvent.click( + within(alert).getByRole('button', { name: 'Prøv igjen' }), + ); + expect( + await screen.findByRole('heading', { level: 1, name: 'Drill' }), + ).toBeInTheDocument(); }); }); diff --git a/src/components/ListingCard/ListingCard.tsx b/src/components/ListingCard/ListingCard.tsx index 6b65829..209f32a 100644 --- a/src/components/ListingCard/ListingCard.tsx +++ b/src/components/ListingCard/ListingCard.tsx @@ -1,12 +1,12 @@ import { Link } from 'react-router'; -import type { Listing } from '../../types/listing.ts'; +import type { ListingSummary } from '../../types/listing.ts'; import { categoryLabels } from '../../utils/listingFormat.ts'; import ListingImage from '../ListingImage/ListingImage.tsx'; import PriceTag from '../PriceTag/PriceTag.tsx'; import styles from './ListingCard.module.css'; interface ListingCardProps { - listing: Listing; + listing: ListingSummary; } /** diff --git a/src/components/ListingForm/ListingForm.module.css b/src/components/ListingForm/ListingForm.module.css index 37dd3b4..0102f51 100644 --- a/src/components/ListingForm/ListingForm.module.css +++ b/src/components/ListingForm/ListingForm.module.css @@ -19,8 +19,8 @@ .previewLabel, .previewEmpty, -.demoNote, -.status { +.status p, +.error p { margin: 0; } @@ -28,8 +28,7 @@ font-weight: var(--font-weight-bold); } -.previewEmpty, -.demoNote { +.previewEmpty { color: var(--color-text-muted); font-size: var(--font-size-sm); } @@ -41,3 +40,11 @@ border-radius: var(--radius-md); background: var(--color-surface); } + +/* Feilen får rød kant i tillegg til teksten, som skjemafeltene. */ +.error { + padding: var(--space-3) var(--space-4); + border: 2px solid var(--color-error); + border-radius: var(--radius-md); + background: var(--color-surface); +} diff --git a/src/components/ListingForm/ListingForm.test.tsx b/src/components/ListingForm/ListingForm.test.tsx index 1c42a23..bb9f2ee 100644 --- a/src/components/ListingForm/ListingForm.test.tsx +++ b/src/components/ListingForm/ListingForm.test.tsx @@ -1,15 +1,29 @@ -import { render, screen } from '@testing-library/react'; +import type { MockLink } from '@apollo/client/testing'; +import { MockedProvider } from '@apollo/client/testing/react'; +import { render as renderElement, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; +import { MemoryRouter } from 'react-router'; import { describe, expect, it } from 'vitest'; +import { + createListingMock, + createListingRejectedMock, + createListingRequest, + networkErrorMock, +} from '../../test/listingMocks.ts'; import ListingForm from './ListingForm.tsx'; -function submit() { - return userEvent.click( - screen.getByRole('button', { name: 'Legg ut verktøy (demo)' }), +/** Skjemaet bruker Apollo for å lagre og en router for lenken til annonsen. */ +function render(element: ReactElement, mocks: MockLink.MockedResponse[] = []) { + return renderElement( + + {element} + , ); } -async function fillValidForm(dailyPrice: string) { +/** Fyller ut feltene slik at de gir validCreateListingInput i listingMocks. */ +async function fillValidForm() { await userEvent.type( screen.getByRole('textbox', { name: 'Tittel' }), 'Stikksag', @@ -25,7 +39,13 @@ async function fillValidForm(dailyPrice: string) { await userEvent.type(screen.getByRole('textbox', { name: 'Område' }), 'Lade'); await userEvent.type( screen.getByRole('textbox', { name: 'Døgnpris i kroner' }), - dailyPrice, + '80', + ); +} + +function submit() { + return userEvent.click( + screen.getByRole('button', { name: 'Legg ut verktøy' }), ); } @@ -74,21 +94,85 @@ describe('ListingForm', () => { ); expect(screen.getByText('150 kr/døgn')).toBeInTheDocument(); }); +}); - it('sier tydelig at annonsen ikke er lagret', async () => { - render(); - await fillValidForm('80'); +describe('ListingForm innsending', () => { + it('lagrer annonsen, viser bekreftelse med lenke og tømmer skjemaet', async () => { + render(, [createListingMock('listing-ny')]); + await fillValidForm(); await submit(); - expect(screen.getByRole('status')).toHaveTextContent( - /Annonsen er ikke lagret/, + + const status = await screen.findByText(/er lagt ut/); + expect(status).toHaveTextContent('Annonsen «Stikksag» er lagt ut'); + expect(screen.getByRole('status')).toContainElement(status); + expect(screen.getByRole('link', { name: 'Se annonsen' })).toHaveAttribute( + 'href', + '/annonser/listing-ny', ); + expect(screen.getByRole('textbox', { name: 'Tittel' })).toHaveValue(''); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('deaktiverer knappen mens annonsen lagres', async () => { + render(, [createListingMock()]); + await fillValidForm(); + await submit(); + + expect(screen.getByRole('button', { name: 'Legger ut …' })).toBeDisabled(); + await screen.findByText(/er lagt ut/); + expect( + screen.getByRole('button', { name: 'Legg ut verktøy' }), + ).toBeEnabled(); }); - it('fjerner demomeldingen når brukeren endrer et felt', async () => { + it('sender ikke annonsen når klientvalideringen feiler', async () => { + // Uten mocks ville en innsending gitt feil, så ingen feil betyr at + // ingenting ble sendt. render(); - await fillValidForm('80'); await submit(); - await userEvent.type(screen.getByRole('textbox', { name: 'Område' }), 'n'); + + expect(screen.getByRole('textbox', { name: 'Tittel' })).toHaveAttribute( + 'aria-invalid', + 'true', + ); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('viser feilen når serveren avviser annonsen og beholder feltene', async () => { + render(, [ + createListingRejectedMock('Tittelen kan ha maks 80 tegn.'), + ]); + await fillValidForm(); + await submit(); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('Annonsen ble ikke lagret.'); + expect(alert).toHaveTextContent('Tittelen kan ha maks 80 tegn.'); + expect(screen.getByRole('textbox', { name: 'Tittel' })).toHaveValue( + 'Stikksag', + ); + }); + + it('viser nettverksfeil og beholder det brukeren har fylt ut', async () => { + render(, [networkErrorMock(createListingRequest())]); + await fillValidForm(); + await submit(); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('Vi fikk ikke kontakt med serveren'); + expect(screen.getByRole('textbox', { name: 'Område' })).toHaveValue('Lade'); + expect( + screen.getByRole('textbox', { name: 'Døgnpris i kroner' }), + ).toHaveValue('80'); + }); + + it('fjerner bekreftelsen når brukeren begynner på en ny annonse', async () => { + render(, [createListingMock()]); + await fillValidForm(); + await submit(); + await screen.findByText(/er lagt ut/); + + await userEvent.type(screen.getByRole('textbox', { name: 'Tittel' }), 'H'); expect(screen.getByRole('status')).toBeEmptyDOMElement(); }); }); diff --git a/src/components/ListingForm/ListingForm.tsx b/src/components/ListingForm/ListingForm.tsx index 9772fb2..5d4c27d 100644 --- a/src/components/ListingForm/ListingForm.tsx +++ b/src/components/ListingForm/ListingForm.tsx @@ -1,10 +1,18 @@ +import { useMutation } from '@apollo/client/react'; import { useEffect, useRef, useState, type SubmitEvent } from 'react'; +import { Link } from 'react-router'; +import { + CREATE_LISTING_MUTATION, + removeCachedCatalogPages, + serverValidationMessage, +} from '../../graphql/listingMutations.ts'; import { categoryLabels } from '../../utils/listingFormat.ts'; import { DESCRIPTION_MAX_LENGTH, TITLE_MAX_LENGTH, emptyListingForm, parseDailyPrice, + toCreateListingInput, validateListingForm, type ListingFormValues, } from '../../utils/listingForm.ts'; @@ -19,19 +27,30 @@ const categoryOptions = Object.entries(categoryLabels).map( ([value, label]) => ({ value, label }), ); +/** Resultatet av siste innsending, som vises under knappen. */ +type SubmitResult = + | { kind: 'created'; id: string; title: string } + | { kind: 'rejected'; message: string } + | { kind: 'networkError' }; + /** - * Skjema for å legge ut et verktøy for utleie eller gratis utlån. Før backend - * finnes er innsendingen en demo: annonsen lagres ikke og vises ikke i - * katalogen, og det sier både knappen og bekreftelsen tydelig. + * Skjema for å legge ut et verktøy for utleie eller gratis utlån. Annonsen + * lagres med createListing, og katalogen hentes på nytt neste gang den vises, + * slik at den nye annonsen ligger øverst. */ function ListingForm() { const [values, setValues] = useState(emptyListingForm); // Feil vises først etter et forsøk på innsending, så brukeren ikke får // feilmeldinger mens hen fyller ut feltene for første gang. const [submitCount, setSubmitCount] = useState(0); - const [isDemoSaved, setIsDemoSaved] = useState(false); + const [result, setResult] = useState(null); const formRef = useRef(null); + const [createListing, { loading: isSaving }] = useMutation( + CREATE_LISTING_MUTATION, + { update: (cache) => removeCachedCatalogPages(cache) }, + ); + const errors = validateListingForm(values); const isValid = Object.keys(errors).length === 0; const visibleErrors = submitCount > 0 ? errors : {}; @@ -50,13 +69,40 @@ function ListingForm() { function updateField(field: keyof ListingFormValues, value: string) { setValues((current) => ({ ...current, [field]: value })); - setIsDemoSaved(false); + // En gammel bekreftelse eller feil gjelder ikke lenger når skjemaet endres. + setResult(null); } - function handleSubmit(event: SubmitEvent) { + async function handleSubmit(event: SubmitEvent) { event.preventDefault(); + // Knappen er deaktivert mens annonsen lagres, men Enter i et felt kan + // fortsatt sende skjemaet. Da skal ikke samme annonse sendes to ganger. + if (isSaving) { + return; + } setSubmitCount((count) => count + 1); - setIsDemoSaved(isValid); + setResult(null); + if (!isValid) { + return; + } + + try { + const { data } = await createListing({ + variables: { input: toCreateListingInput(values) }, + }); + if (!data) { + throw new Error('Serveren svarte uten data.'); + } + setResult({ kind: 'created', ...data.createListing }); + setValues(emptyListingForm); + setSubmitCount(0); + } catch (error) { + // Feltene beholdes, så brukeren kan rette opp eller prøve igjen. + const message = serverValidationMessage(error); + setResult( + message ? { kind: 'rejected', message } : { kind: 'networkError' }, + ); + } } return ( @@ -120,18 +166,33 @@ function ListingForm() { )} -

- Dette er en demo. Annonsen blir ikke lagret og vises ikke i katalogen. -

- +
{/* Alltid i DOM-en, slik at skjermlesere leser opp ny tekst i den. */} -

- {isDemoSaved && - 'Demo: Annonsen er ikke lagret, og katalogen er ikke endret. Ekte annonser kan legges ut når backend er på plass.'} -

+
+ {result?.kind === 'created' && ( +

+ Annonsen «{result.title}» er lagt ut og vises først i katalogen.{' '} + Se annonsen +

+ )} +
+ + {result && result.kind !== 'created' && ( + // role="alert" leses opp med en gang, fordi annonsen ikke ble lagret. +
+

+ Annonsen ble ikke lagret.{' '} + {result.kind === 'rejected' + ? result.message + : 'Vi fikk ikke kontakt med serveren. Det du har fylt ut, er tatt vare på, så du kan prøve igjen om litt.'} +

+
+ )} ); } diff --git a/src/components/ListingGrid/ListingGrid.tsx b/src/components/ListingGrid/ListingGrid.tsx index 96ab233..4f50385 100644 --- a/src/components/ListingGrid/ListingGrid.tsx +++ b/src/components/ListingGrid/ListingGrid.tsx @@ -1,10 +1,10 @@ -import type { Listing } from '../../types/listing.ts'; +import type { ListingSummary } from '../../types/listing.ts'; import ListingCard from '../ListingCard/ListingCard.tsx'; import styles from './ListingGrid.module.css'; interface ListingGridProps { /** Annonsene som skal vises, i den rekkefølgen de skal stå. */ - listings: Listing[]; + listings: ListingSummary[]; } /** diff --git a/src/components/Pagination/Pagination.module.css b/src/components/Pagination/Pagination.module.css new file mode 100644 index 0000000..ba3eea0 --- /dev/null +++ b/src/components/Pagination/Pagination.module.css @@ -0,0 +1,42 @@ +.pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: var(--space-2) var(--space-4); + margin-top: var(--space-8); +} + +/* Samme utseende som Button med variant="secondary". */ +.link, +.disabled { + display: inline-flex; + align-items: center; + min-height: var(--tap-target); + padding: var(--space-2) var(--space-6); + border: 2px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-weight: var(--font-weight-bold); + text-decoration: none; +} + +.link { + background: var(--color-surface); + color: var(--color-text); +} + +.link:hover { + background: var(--color-surface-muted); + color: var(--color-text); +} + +.disabled { + border-color: var(--color-border); + background: var(--color-surface-muted); + color: var(--color-text-muted); +} + +.status { + margin: 0; + color: var(--color-text-muted); +} diff --git a/src/components/Pagination/Pagination.test.tsx b/src/components/Pagination/Pagination.test.tsx new file mode 100644 index 0000000..36c4040 --- /dev/null +++ b/src/components/Pagination/Pagination.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { describe, expect, it } from 'vitest'; +import Pagination from './Pagination.tsx'; + +function renderPagination(page: number, totalPages: number) { + return render( + + `?side=${target}`} + /> + , + ); +} + +describe('Pagination', () => { + it('lenker til forrige og neste side', () => { + renderPagination(3, 5); + expect(screen.getByRole('link', { name: 'Forrige' })).toHaveAttribute( + 'href', + '/?side=2', + ); + expect(screen.getByRole('link', { name: 'Neste' })).toHaveAttribute( + 'href', + '/?side=4', + ); + expect(screen.getByText('Side 3 av 5')).toBeInTheDocument(); + }); + + it('har ingen lenke bakover på første side', () => { + renderPagination(1, 5); + expect( + screen.queryByRole('link', { name: 'Forrige' }), + ).not.toBeInTheDocument(); + expect(screen.getByText('Forrige')).toHaveAttribute( + 'aria-disabled', + 'true', + ); + expect(screen.getByRole('link', { name: 'Neste' })).toBeInTheDocument(); + }); + + it('har ingen lenke framover på siste side', () => { + renderPagination(5, 5); + expect( + screen.queryByRole('link', { name: 'Neste' }), + ).not.toBeInTheDocument(); + }); + + it('vises ikke når alt får plass på én side', () => { + renderPagination(1, 1); + expect(screen.queryByRole('navigation')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx new file mode 100644 index 0000000..faf06f8 --- /dev/null +++ b/src/components/Pagination/Pagination.tsx @@ -0,0 +1,54 @@ +import { Link } from 'react-router'; +import styles from './Pagination.module.css'; + +interface PaginationProps { + /** Gjeldende side, fra 1. */ + page: number; + totalPages: number; + /** Lager lenken til en side, for eksempel «?side=3». */ + pageLink: (page: number) => string; +} + +/** + * «Forrige» og «Neste» mellom sidene i et resultat. Sidene er lenker, ikke + * knapper, slik at siden ligger i URL-en og «Tilbake» i nettleseren virker. + * På første og siste side vises teksten uten lenke, så den ikke får fokus. + */ +function Pagination({ page, totalPages, pageLink }: PaginationProps) { + if (totalPages <= 1) { + return null; + } + + const hasPrevious = page > 1; + const hasNext = page < totalPages; + + return ( + + ); +} + +export default Pagination; diff --git a/src/components/QueryError/QueryError.module.css b/src/components/QueryError/QueryError.module.css new file mode 100644 index 0000000..e384cb0 --- /dev/null +++ b/src/components/QueryError/QueryError.module.css @@ -0,0 +1,19 @@ +.error { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-2); + padding: var(--space-6); + background: var(--color-surface); + border: 2px solid var(--color-error); + border-radius: var(--radius-md); +} + +.error h2, +.error p { + margin: 0; +} + +.error p { + margin-bottom: var(--space-2); +} diff --git a/src/components/QueryError/QueryError.tsx b/src/components/QueryError/QueryError.tsx new file mode 100644 index 0000000..68f9d98 --- /dev/null +++ b/src/components/QueryError/QueryError.tsx @@ -0,0 +1,27 @@ +import Button from '../Button/Button.tsx'; +import styles from './QueryError.module.css'; + +interface QueryErrorProps { + /** Hva som ikke kunne hentes, for eksempel «annonsene». */ + what: string; + onRetry: () => void; +} + +/** + * Feilmelding når data ikke kan hentes fra backenden, med knapp for å prøve + * igjen. role="alert" gjør at skjermlesere leser meldingen med en gang. + */ +function QueryError({ what, onRetry }: QueryErrorProps) { + return ( +
+

Fikk ikke kontakt med serveren

+

+ Vi kunne ikke hente {what}. Backenden svarer ikke akkurat nå. Prøv igjen + om litt. +

+ +
+ ); +} + +export default QueryError; diff --git a/src/data/mockListings.ts b/src/data/mockListings.ts index be58e37..19abe72 100644 --- a/src/data/mockListings.ts +++ b/src/data/mockListings.ts @@ -10,12 +10,20 @@ * annonser gjennom props i stedet for å importere denne filen direkte. */ -import drillImage from '../assets/listings/drill.svg'; -import handsawImage from '../assets/listings/handsaw.svg'; -import paintRollerImage from '../assets/listings/paint-roller.svg'; -import spadeImage from '../assets/listings/spade.svg'; import type { Listing } from '../types/listing.ts'; +/** + * Bildene ligger i public/assets/listings, slik at de har samme adresse som + * image_url i databasen, for eksempel /project2/assets/listings/drill.svg. + */ +const imageUrl = (filename: string) => + `${import.meta.env.BASE_URL}assets/listings/${filename}`; + +const drillImage = imageUrl('drill.svg'); +const handsawImage = imageUrl('handsaw.svg'); +const paintRollerImage = imageUrl('paint-roller.svg'); +const spadeImage = imageUrl('spade.svg'); + export const mockListings: Listing[] = [ { id: 'listing-001', diff --git a/src/graphql/client.ts b/src/graphql/client.ts new file mode 100644 index 0000000..8050109 --- /dev/null +++ b/src/graphql/client.ts @@ -0,0 +1,17 @@ +import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'; + +/** + * GraphQL-endepunktet ligger under appens egen sti. På VM-en sender Apache + * /project2/api/ videre til backenden, og lokalt gjør Vite det samme (se + * vite.config.ts). Derfor trenger klienten ikke vite om portnummeret. + */ +export const GRAPHQL_URL = `${import.meta.env.BASE_URL}api/graphql`; + +/** + * Felles Apollo-klient. Cachen gjør at en side eller annonse som allerede er + * hentet, vises med en gang og uten ny forespørsel når brukeren går tilbake. + */ +export const apolloClient = new ApolloClient({ + link: new HttpLink({ uri: GRAPHQL_URL }), + cache: new InMemoryCache(), +}); diff --git a/src/graphql/listingMutations.test.ts b/src/graphql/listingMutations.test.ts new file mode 100644 index 0000000..a723fe3 --- /dev/null +++ b/src/graphql/listingMutations.test.ts @@ -0,0 +1,66 @@ +import { CombinedGraphQLErrors, InMemoryCache } from '@apollo/client'; +import { describe, expect, it } from 'vitest'; +import { LISTINGS_PAGE_QUERY } from './listingQueries.ts'; +import { + removeCachedCatalogPages, + serverValidationMessage, +} from './listingMutations.ts'; + +describe('serverValidationMessage', () => { + it('gir meldingen når serveren avviser annonsen', () => { + const error = new CombinedGraphQLErrors({ + data: null, + errors: [ + { + message: 'Tittelen kan ha maks 80 tegn.', + extensions: { code: 'BAD_USER_INPUT' }, + }, + ], + }); + + expect(serverValidationMessage(error)).toBe( + 'Tittelen kan ha maks 80 tegn.', + ); + }); + + it('gir null for andre GraphQL-feil og for nettverksfeil', () => { + const serverError = new CombinedGraphQLErrors({ + data: null, + errors: [ + { + message: 'Noe gikk galt.', + extensions: { code: 'INTERNAL_SERVER_ERROR' }, + }, + ], + }); + + expect(serverValidationMessage(serverError)).toBeNull(); + expect( + serverValidationMessage(new TypeError('Failed to fetch')), + ).toBeNull(); + }); +}); + +describe('removeCachedCatalogPages', () => { + it('fjerner lagrede katalogsider, slik at de hentes på nytt', () => { + const cache = new InMemoryCache(); + const variables = { limit: 24, offset: 0 }; + cache.writeQuery({ + query: LISTINGS_PAGE_QUERY, + variables, + data: { + listings: { + items: [], + totalCount: 0, + hasNextPage: false, + }, + }, + }); + + removeCachedCatalogPages(cache); + + expect( + cache.readQuery({ query: LISTINGS_PAGE_QUERY, variables }), + ).toBeNull(); + }); +}); diff --git a/src/graphql/listingMutations.ts b/src/graphql/listingMutations.ts new file mode 100644 index 0000000..ee38d4d --- /dev/null +++ b/src/graphql/listingMutations.ts @@ -0,0 +1,66 @@ +import { + CombinedGraphQLErrors, + gql, + type ApolloCache, + type TypedDocumentNode, +} from '@apollo/client'; +import type { ToolCategory } from '../types/listing.ts'; + +/** Feltene backenden tar imot når en annonse opprettes. */ +export interface CreateListingInput { + title: string; + category: ToolCategory; + description: string; + area: string; + /** Hele kroner per døgn. 0 betyr gratis utlån. */ + dailyPrice: number; +} + +export interface CreateListingData { + createListing: { id: string; title: string }; +} + +export interface CreateListingVariables { + input: CreateListingInput; +} + +/** + * Oppretter en annonse. Svaret trenger bare ID og tittel til bekreftelsen og + * lenken til den nye annonsen. Resten hentes når annonsen åpnes. + */ +export const CREATE_LISTING_MUTATION: TypedDocumentNode< + CreateListingData, + CreateListingVariables +> = gql` + mutation CreateListing($input: CreateListingInput!) { + createListing(input: $input) { + id + title + } + } +`; + +/** + * Fjerner alle hentede katalogsider fra cachen. En ny annonse havner først i + * katalogen og forskyver alle sidene, så de lagrede sidene er utdaterte. Neste + * gang katalogen vises, hentes siden på nytt fra serveren, uten at brukeren + * må laste siden på nytt. + */ +export function removeCachedCatalogPages(cache: ApolloCache) { + cache.evict({ id: 'ROOT_QUERY', fieldName: 'listings' }); + cache.gc(); +} + +/** + * Melding fra serveren når den avviser annonsen (BAD_USER_INPUT), for + * eksempel for lang tittel. Returnerer null for andre feil, som nettverksfeil. + */ +export function serverValidationMessage(error: unknown): string | null { + if (!CombinedGraphQLErrors.is(error)) { + return null; + } + const validationError = error.errors.find( + (graphQLError) => graphQLError.extensions?.code === 'BAD_USER_INPUT', + ); + return validationError?.message ?? null; +} diff --git a/src/graphql/listingQueries.ts b/src/graphql/listingQueries.ts new file mode 100644 index 0000000..b2668ca --- /dev/null +++ b/src/graphql/listingQueries.ts @@ -0,0 +1,68 @@ +import { gql, type TypedDocumentNode } from '@apollo/client'; +import type { Listing, ListingSummary } from '../types/listing.ts'; + +/** Antall annonser per side i katalogen. Serveren tillater maks 100. */ +export const CATALOG_PAGE_SIZE = 24; + +export interface ListingsPageData { + listings: { + items: ListingSummary[]; + totalCount: number; + hasNextPage: boolean; + }; +} + +export interface ListingsPageVariables { + limit: number; + offset: number; +} + +/** + * Én side i katalogen. Henter bare feltene annonsekortet viser, så hver side + * blir liten. Resten hentes først når brukeren åpner en annonse. + */ +export const LISTINGS_PAGE_QUERY: TypedDocumentNode< + ListingsPageData, + ListingsPageVariables +> = gql` + query ListingsPage($limit: Int!, $offset: Int!) { + listings(limit: $limit, offset: $offset) { + items { + id + title + category + area + dailyPrice + imageUrl + } + totalCount + hasNextPage + } + } +`; + +export interface ListingData { + listing: Listing | null; +} + +export interface ListingVariables { + id: string; +} + +/** Alle opplysninger om én annonse, til detaljvisningen. */ +export const LISTING_QUERY: TypedDocumentNode = + gql` + query Listing($id: ID!) { + listing(id: $id) { + id + title + category + description + area + dailyPrice + condition + accessories + imageUrl + } + } + `; diff --git a/src/main.tsx b/src/main.tsx index 0fdc649..6b17787 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,9 @@ +import { ApolloProvider } from '@apollo/client/react'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router'; import App from './App.tsx'; +import { apolloClient } from './graphql/client.ts'; import './styles/tokens.css'; import './styles/base.css'; @@ -12,12 +14,14 @@ if (!rootElement) { throw new Error('Fant ikke #root i index.html'); } -// basename følger Vites base-sti, slik at rutene også virker når appen senere -// publiseres under en undermappe som /project2. +// basename følger Vites base-sti (/project2/), slik at rutene virker likt +// lokalt og på VM-en. createRoot(rootElement).render( - - - + + + + + , ); diff --git a/src/pages/CatalogPage/CatalogPage.module.css b/src/pages/CatalogPage/CatalogPage.module.css index 5ec772b..796e8db 100644 --- a/src/pages/CatalogPage/CatalogPage.module.css +++ b/src/pages/CatalogPage/CatalogPage.module.css @@ -1,22 +1,12 @@ -/* Antall treff til venstre, sortering og «Fjern alle filtre» til høyre. */ -.resultBar { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - justify-content: space-between; - gap: var(--space-2) var(--space-4); - margin-block: var(--space-4); -} - -.resultActions { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--space-2) var(--space-4); +.notice { + padding: var(--space-3) var(--space-4); + background: var(--color-surface-muted); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); } .count { - margin: 0; + margin-block: var(--space-4); color: var(--color-text-muted); } diff --git a/src/pages/CatalogPage/CatalogPage.tsx b/src/pages/CatalogPage/CatalogPage.tsx index 00aae6d..f5d5aab 100644 --- a/src/pages/CatalogPage/CatalogPage.tsx +++ b/src/pages/CatalogPage/CatalogPage.tsx @@ -1,43 +1,52 @@ -import Button from '../../components/Button/Button.tsx'; -import CatalogFilters from '../../components/CatalogFilters/CatalogFilters.tsx'; +import { useQuery } from '@apollo/client/react'; +import { useEffect, useRef } from 'react'; +import { Link, useSearchParams } from 'react-router'; import ListingGrid from '../../components/ListingGrid/ListingGrid.tsx'; -import SelectField from '../../components/SelectField/SelectField.tsx'; -import { mockListings } from '../../data/mockListings.ts'; -import { useListingFilters } from '../../hooks/useListingFilters.ts'; -import { useListingSort } from '../../hooks/useListingSort.ts'; -import { usePageTitle } from '../../hooks/usePageTitle.ts'; +import Pagination from '../../components/Pagination/Pagination.tsx'; +import QueryError from '../../components/QueryError/QueryError.tsx'; import { - filterListings, - hasActiveFilters, -} from '../../utils/listingFilters.ts'; + CATALOG_PAGE_SIZE, + LISTINGS_PAGE_QUERY, + type ListingsPageData, +} from '../../graphql/listingQueries.ts'; +import { usePageTitle } from '../../hooks/usePageTitle.ts'; import { - sortLabels, - sortListings, - type ListingSort, -} from '../../utils/listingSort.ts'; + catalogPageLink, + countPages, + readCatalogPage, +} from '../../utils/catalogPage.ts'; import styles from './CatalogPage.module.css'; -const sortOptions = Object.entries(sortLabels).map(([value, label]) => ({ - value, - label, -})); +const countFormatter = new Intl.NumberFormat('nb-NO'); /** - * Katalog med søk, filtre og sortering. Siden henter dataene og sender dem - * videre som props, så bare denne filen må endres når mockdataene byttes ut - * med GraphQL. Søk, filtre og sortering ligger i URL-en. + * Katalog med én side annonser om gangen fra GraphQL-backenden. Gjeldende side + * ligger i URL-en (?side=2), så den overlever en sideoppdatering og kan deles. + * + * Søk, filtre og sortering fra prototypen er tatt ut her til serveren støtter + * dem. Lokalt ville de bare virket på de 24 annonsene som er lastet, ikke på + * hele katalogen. Komponentene og logikken ligger fortsatt i src/. */ function CatalogPage() { usePageTitle('Katalog'); - const [filters, updateFilters] = useListingFilters(); - const [sort, updateSort] = useListingSort(); - // Filtrerer først og sorterer treffene etterpå. - const listings = sortListings(filterListings(mockListings, filters), sort); - const isFiltered = hasActiveFilters(filters); + const [searchParams] = useSearchParams(); + const page = readCatalogPage(searchParams); - function clearFilters() { - updateFilters({ query: '', category: null, freeOnly: false }); - } + const { data, loading, error, refetch } = useQuery(LISTINGS_PAGE_QUERY, { + variables: { + limit: CATALOG_PAGE_SIZE, + offset: (page - 1) * CATALOG_PAGE_SIZE, + }, + }); + + // Ved bytte av side starter brukeren øverst, slik at de nye annonsene vises. + const previousPage = useRef(page); + useEffect(() => { + if (previousPage.current !== page) { + previousPage.current = page; + window.scrollTo({ top: 0 }); + } + }, [page]); return ( <> @@ -45,40 +54,87 @@ function CatalogPage() {

Lei eller lån verktøy av andre studenter og privatpersoner i Trondheim.

+

+ Søk, filtre og sortering kobles til serveren i neste versjon. Nå vises + alle annonsene, med de nyeste først. +

+ + void refetch()} + /> + + ); +} - +interface CatalogResultsProps { + page: number; + data: ListingsPageData | undefined; + loading: boolean; + hasError: boolean; + onRetry: () => void; +} -
- {/* role="status" gjør at skjermlesere leser opp nytt antall treff. */} -

- {isFiltered - ? `Viser ${listings.length} av ${mockListings.length} annonser` - : `${mockListings.length} annonser`} +function CatalogResults({ + page, + data, + loading, + hasError, + onRetry, +}: CatalogResultsProps) { + if (loading) { + return ( +

+ Laster annonser … +

+ ); + } + + if (hasError || !data) { + return ; + } + + const { items, totalCount } = data.listings; + const totalPages = countPages(totalCount, CATALOG_PAGE_SIZE); + + if (totalCount === 0) { + return ( +
+

Ingen annonser ennå

+

Det er ikke lagt ut noen verktøy. Bli den første!

+ Lei ut et verktøy +
+ ); + } + + if (items.length === 0) { + return ( +
+

Siden finnes ikke

+

+ Katalogen har {totalPages} sider, men du prøvde å åpne side {page}.

-
- {isFiltered && listings.length > 0 && ( - - )} - updateSort(event.target.value as ListingSort)} - /> -
+ Gå til første side
+ ); + } - {listings.length > 0 ? ( - - ) : ( -
-

Ingen annonser passer søket

-

Prøv et annet søkeord, eller velg en annen kategori.

- -
- )} + return ( + <> + {/* role="status" gjør at skjermlesere leser opp ny side og nytt antall. */} +

+ {countFormatter.format(totalCount)} annonser · side {page} av{' '} + {totalPages} +

+ + ); } diff --git a/src/pages/ListingDetailPage/ListingDetailPage.tsx b/src/pages/ListingDetailPage/ListingDetailPage.tsx index e93c23c..9c0aa19 100644 --- a/src/pages/ListingDetailPage/ListingDetailPage.tsx +++ b/src/pages/ListingDetailPage/ListingDetailPage.tsx @@ -1,21 +1,51 @@ +import { useQuery } from '@apollo/client/react'; import { Link, useParams } from 'react-router'; import ListingImage from '../../components/ListingImage/ListingImage.tsx'; import PriceTag from '../../components/PriceTag/PriceTag.tsx'; +import QueryError from '../../components/QueryError/QueryError.tsx'; import RentalRequestForm from '../../components/RentalRequestForm/RentalRequestForm.tsx'; -import { mockListings } from '../../data/mockListings.ts'; +import { LISTING_QUERY } from '../../graphql/listingQueries.ts'; import { usePageTitle } from '../../hooks/usePageTitle.ts'; import { categoryLabels, conditionLabels } from '../../utils/listingFormat.ts'; +import type { Listing } from '../../types/listing.ts'; import styles from './ListingDetailPage.module.css'; /** - * Detaljvisning for én annonse, med alle opplysninger og skjema for - * leieforespørsel. Siden henter annonsen selv, så bare denne filen må endres - * når mockdataene byttes ut med GraphQL. + * Detaljvisning for én annonse, hentet fra GraphQL-backenden med listing(id). + * Viser laste- og feiltilstand mens annonsen hentes, og «Fant ikke annonsen» + * når serveren svarer at den ikke finnes. */ function ListingDetailPage() { - const { listingId } = useParams(); - const listing = mockListings.find((item) => item.id === listingId); - usePageTitle(listing ? listing.title : 'Fant ikke annonsen'); + const { listingId = '' } = useParams(); + const { data, loading, error, refetch } = useQuery(LISTING_QUERY, { + variables: { id: listingId }, + }); + const listing = data?.listing; + + usePageTitle( + listing?.title ?? + (loading ? 'Laster annonsen' : error ? 'Feil' : 'Fant ikke annonsen'), + ); + + if (loading) { + return ( + <> + +

Annonse

+

Laster annonsen …

+ + ); + } + + if (error) { + return ( + <> + +

Annonse

+ void refetch()} /> + + ); + } if (!listing) { return ( @@ -29,11 +59,21 @@ function ListingDetailPage() { ); } + return ; +} + +function BackLink() { + return ( +

+ Tilbake til katalogen +

+ ); +} + +function ListingDetails({ listing }: { listing: Listing }) { return ( <> -

- Tilbake til katalogen -

+
diff --git a/src/test/listingMocks.ts b/src/test/listingMocks.ts new file mode 100644 index 0000000..a2a8e98 --- /dev/null +++ b/src/test/listingMocks.ts @@ -0,0 +1,131 @@ +import type { MockLink } from '@apollo/client/testing'; +import { mockListings } from '../data/mockListings.ts'; +import { + CREATE_LISTING_MUTATION, + type CreateListingInput, +} from '../graphql/listingMutations.ts'; +import { + CATALOG_PAGE_SIZE, + LISTINGS_PAGE_QUERY, + LISTING_QUERY, +} from '../graphql/listingQueries.ts'; +import type { Listing } from '../types/listing.ts'; + +/** + * Mockede GraphQL-svar for testene. Annonsene er de samme eksemplene som i + * prototypen, men komponentene får dem gjennom Apollo, slik de gjør mot + * backenden. Apollo legger til __typename i spørringene, så svarene må ha det. + */ + +function toSummary(listing: Listing) { + const { id, title, category, area, dailyPrice, imageUrl } = listing; + return { + __typename: 'Listing' as const, + id, + title, + category, + area, + dailyPrice, + imageUrl, + }; +} + +interface PageMockOptions { + page?: number; + items?: Listing[]; + totalCount?: number; +} + +/** Svar på katalogspørringen for én side. */ +export function listingsPageMock({ + page = 1, + items = mockListings, + totalCount = items.length, +}: PageMockOptions = {}): MockLink.MockedResponse { + const offset = (page - 1) * CATALOG_PAGE_SIZE; + return { + request: { + query: LISTINGS_PAGE_QUERY, + variables: { limit: CATALOG_PAGE_SIZE, offset }, + }, + result: { + data: { + listings: { + __typename: 'ListingPage', + items: items.map(toSummary), + totalCount, + hasNextPage: offset + items.length < totalCount, + }, + }, + }, + }; +} + +/** Svar på detaljspørringen. null betyr at annonsen ikke finnes. */ +export function listingMock( + id: string, + listing: Listing | null = mockListings.find((item) => item.id === id) ?? null, +): MockLink.MockedResponse { + return { + request: { query: LISTING_QUERY, variables: { id } }, + result: { + data: { + listing: listing && { __typename: 'Listing', ...listing }, + }, + }, + }; +} + +/** Nettverksfeil, som når backenden ikke svarer. */ +export function networkErrorMock( + request: MockLink.MockedResponse['request'], +): MockLink.MockedResponse { + return { request, error: new Error('Failed to fetch') }; +} + +/** Gyldig annonse fra utleieskjemaet, slik den sendes til createListing. */ +export const validCreateListingInput: CreateListingInput = { + title: 'Stikksag', + category: 'powerTools', + description: 'Stikksag for tre.', + area: 'Lade', + dailyPrice: 80, +}; + +export function createListingRequest( + input: CreateListingInput = validCreateListingInput, +): MockLink.MockedResponse['request'] { + return { query: CREATE_LISTING_MUTATION, variables: { input } }; +} + +/** + * Vellykket createListing. delay holder svaret igjen litt, slik at testene + * kan se at knappen er deaktivert mens annonsen lagres. + */ +export function createListingMock( + id = 'listing-ny', + input: CreateListingInput = validCreateListingInput, +): MockLink.MockedResponse { + return { + request: createListingRequest(input), + delay: 20, + result: { + data: { + createListing: { __typename: 'Listing', id, title: input.title }, + }, + }, + }; +} + +/** Serveren avviser annonsen, som når valideringen i backenden feiler. */ +export function createListingRejectedMock( + message: string, + input: CreateListingInput = validCreateListingInput, +): MockLink.MockedResponse { + return { + request: createListingRequest(input), + result: { + errors: [{ message, extensions: { code: 'BAD_USER_INPUT' } }], + }, + }; +} diff --git a/src/test/renderApp.tsx b/src/test/renderApp.tsx new file mode 100644 index 0000000..ed441cf --- /dev/null +++ b/src/test/renderApp.tsx @@ -0,0 +1,23 @@ +import type { MockLink } from '@apollo/client/testing'; +import { MockedProvider } from '@apollo/client/testing/react'; +import { render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import App from '../App.tsx'; +import { listingsPageMock } from './listingMocks.ts'; + +/** + * Rendrer hele appen på en gitt adresse med mockede GraphQL-svar. Uten egne + * mocks får katalogen første side med eksempelannonsene. + */ +export function renderApp( + route = '/', + mocks: MockLink.MockedResponse[] = [listingsPageMock()], +) { + return render( + + + + + , + ); +} diff --git a/src/test/setup.ts b/src/test/setup.ts index 36d26bd..9b8baff 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -7,3 +7,6 @@ import { afterEach } from 'vitest'; afterEach(() => { cleanup(); }); + +// jsdom har ikke scrolling. Katalogen scroller til toppen ved bytte av side. +window.scrollTo = () => {}; diff --git a/src/types/listing.ts b/src/types/listing.ts index b8c5abb..b2ec889 100644 --- a/src/types/listing.ts +++ b/src/types/listing.ts @@ -26,3 +26,9 @@ export interface Listing { /** Adresse til bilde av verktøyet, eller null når annonsen mangler bilde. */ imageUrl: string | null; } + +/** Feltene annonsekortet i katalogen trenger. */ +export type ListingSummary = Pick< + Listing, + 'id' | 'title' | 'category' | 'area' | 'dailyPrice' | 'imageUrl' +>; diff --git a/src/utils/catalogPage.test.ts b/src/utils/catalogPage.test.ts new file mode 100644 index 0000000..8f60b9c --- /dev/null +++ b/src/utils/catalogPage.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { catalogPageLink, countPages, readCatalogPage } from './catalogPage.ts'; + +describe('readCatalogPage', () => { + it('leser siden fra URL-en', () => { + expect(readCatalogPage(new URLSearchParams('side=3'))).toBe(3); + }); + + it.each(['', 'side=0', 'side=-2', 'side=abc', 'side=1.5'])( + 'gir første side for «%s»', + (search) => { + expect(readCatalogPage(new URLSearchParams(search))).toBe(1); + }, + ); +}); + +describe('catalogPageLink', () => { + it('lar første side være uten parameter', () => { + expect(catalogPageLink(1)).toBe('/'); + expect(catalogPageLink(2)).toBe('/?side=2'); + }); +}); + +describe('countPages', () => { + it('runder opp, og gir minst én side', () => { + expect(countPages(10010, 24)).toBe(418); + expect(countPages(24, 24)).toBe(1); + expect(countPages(0, 24)).toBe(1); + }); +}); diff --git a/src/utils/catalogPage.ts b/src/utils/catalogPage.ts new file mode 100644 index 0000000..83d9fbd --- /dev/null +++ b/src/utils/catalogPage.ts @@ -0,0 +1,25 @@ +/** Navnet på sideparameteren i URL-en, for eksempel «?side=3». */ +const PARAM_PAGE = 'side'; + +/** + * Leser gjeldende side fra URL-en. Mangler eller ugyldige verdier, som «0», + * «-2» eller «abc», gir første side. + */ +export function readCatalogPage(params: URLSearchParams): number { + const value = params.get(PARAM_PAGE); + if (value === null || !/^\d+$/.test(value)) { + return 1; + } + const page = Number(value); + return page >= 1 ? page : 1; +} + +/** Lenke til en side i katalogen. Første side får ingen parameter. */ +export function catalogPageLink(page: number): string { + return page <= 1 ? '/' : `/?${PARAM_PAGE}=${page}`; +} + +/** Antall sider, minst én, slik at en tom katalog også har «side 1 av 1». */ +export function countPages(totalCount: number, pageSize: number): number { + return Math.max(1, Math.ceil(totalCount / pageSize)); +} diff --git a/src/utils/listingForm.test.ts b/src/utils/listingForm.test.ts index 6590a15..6001268 100644 --- a/src/utils/listingForm.test.ts +++ b/src/utils/listingForm.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { emptyListingForm, parseDailyPrice, + toCreateListingInput, validateListingForm, type ListingFormValues, } from './listingForm.ts'; @@ -67,3 +68,26 @@ describe('validateListingForm', () => { expect(errors.dailyPrice).toBeDefined(); }); }); + +describe('toCreateListingInput', () => { + it('fjerner mellomrom og gjør prisen om til et tall', () => { + expect( + toCreateListingInput({ + ...validForm, + title: ' Stikksag ', + area: ' Lade ', + dailyPrice: ' 0 ', + }), + ).toEqual({ + title: 'Stikksag', + category: 'powerTools', + description: 'Stikksag for tre og laminat.', + area: 'Lade', + dailyPrice: 0, + }); + }); + + it('nekter å lage input fra et ugyldig skjema', () => { + expect(() => toCreateListingInput(emptyListingForm)).toThrow(); + }); +}); diff --git a/src/utils/listingForm.ts b/src/utils/listingForm.ts index e4b675f..d56a06d 100644 --- a/src/utils/listingForm.ts +++ b/src/utils/listingForm.ts @@ -1,8 +1,12 @@ /** * Validering av skjemaet for å legge ut verktøy. Verdiene er strenger slik de - * kommer fra skjemafeltene. Senere skal serveren gjøre den samme kontrollen. + * kommer fra skjemafeltene. Serveren gjør den samme kontrollen når annonsen + * lagres (backend/src/validation/createListingInput.ts). */ +import type { CreateListingInput } from '../graphql/listingMutations.ts'; +import type { ToolCategory } from '../types/listing.ts'; + export interface ListingFormValues { title: string; /** Tom streng til brukeren har valgt en kategori. */ @@ -80,3 +84,23 @@ export function validateListingForm( return errors; } + +/** + * Gjør skjemaverdiene om til input for createListing. Skal bare kalles når + * validateListingForm ikke har funnet feil, slik at kategori og pris er gyldige. + */ +export function toCreateListingInput( + values: ListingFormValues, +): CreateListingInput { + const dailyPrice = parseDailyPrice(values.dailyPrice); + if (dailyPrice === null || !values.category) { + throw new Error('Skjemaet må være gyldig før det sendes til serveren.'); + } + return { + title: values.title.trim(), + category: values.category as ToolCategory, + description: values.description.trim(), + area: values.area.trim(), + dailyPrice, + }; +} diff --git a/vite.config.ts b/vite.config.ts index 18f31aa..6f17882 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,8 +2,24 @@ import react from '@vitejs/plugin-react'; // defineConfig fra vitest/config godtar også test-innstillingene under. import { defineConfig } from 'vitest/config'; +/** Adressen til GraphQL-backenden ved lokal utvikling (backend/.env). */ +const BACKEND_URL = 'http://localhost:3001'; + export default defineConfig({ + // Appen publiseres under /project2 på VM-en. Samme base lokalt gjør at + // lenker, bilder og API-kall bruker de samme stiene i utvikling og drift. + base: '/project2/', plugins: [react()], + server: { + proxy: { + // På VM-en sender Apache /project2/api/ videre til backenden. Lokalt + // gjør Vite det samme, så frontend slipper å kjenne til portnummeret. + '/project2/api': { + target: BACKEND_URL, + rewrite: (path) => path.replace(/^\/project2\/api/, ''), + }, + }, + }, test: { // jsdom gir et DOM i Node, slik at komponentene kan rendres uten nettleser. environment: 'jsdom',