From 741fb8ad5a4061e73944240df29651e37f977569 Mon Sep 17 00:00:00 2001 From: Brage-Molstad Date: Thu, 15 Oct 2026 15:17:36 +0200 Subject: [PATCH 1/2] docs(readme): describe component, library and API choices Co-Authored-By: Claude Opus 5.5 --- README.md | 33 ++++++++++++++++++++++++++++++++- docs/ki/ki-logg.md | 1 + docs/readme-fakta.md | 2 +- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 62afa8b..98251ea 100644 --- a/README.md +++ b/README.md @@ -82,11 +82,42 @@ sammen, steg for steg: [`docs/forklaring.md`](docs/forklaring.md). - **Ingen SSR:** en statisk SPA bak Apache er enklere å drifte. Prisen er mobil-Lighthouse på 95–97 ([begrunnelse](docs/beslutninger.md#hvorfor-ikke-ssr)). +## Komponenter, biblioteker og API + +**Biblioteker.** Vi gjenbruker veletablerte tredjepartsløsninger der de gjør en jobb vi ellers måtte +skrevet selv, og velger dem bort der de ville gitt mye JavaScript for lite nytte. + +| Bibliotek | Hvorfor | +| ---------------------------------------- | ------------------------------------------------------------------------------------- | +| React 19, React Router, Vite | Krav i oppgaven. Routeren gir lazy-lastede ruter og innebygd scroll-gjenoppretting | +| Apollo Client | State management: normalisert cache, paginering og reactive vars, så vi slipper Redux | +| GraphQL Yoga, `pg` | Lett GraphQL-server med gode utvidelsespunkter, og parametriserte spørringer uten ORM | +| Vitest, Testing Library, Playwright, axe | Komponenttester som tester oppførsel, E2E i ekte nettleser og automatisk WCAG-sjekk | +| ESLint (`jsx-a11y`), Prettier, Husky | Lik kodestil og tilgjengelighetslinting før hver commit | + +Valgt bort med vilje: komponentbibliotek (MUI o.l.), spillerbibliotek og ORM. De ville gitt mer +JavaScript og mindre kontroll over fokus, kontrast og SQL-en søket er avhengig av. Begrunnelse og +oversikt over egne komponenter og hooks: [`docs/beslutninger.md`](docs/beslutninger.md#datasett-komponenter-og-biblioteker). + +**API.** Ett GraphQL-endepunkt (`/project2/graphql`) med et lite skjema som speiler det appen trenger: + +| Operasjon | Brukes til | +| -------------------------------------------- | ---------------------------------------------------------------------------- | +| `search(query, filters, sort, first, after)` | Søk, filtrering og sortering i SQL på hele datasettet, cursor-paginert | +| `facets(query, filters)` | Antall treff per sjanger, tiår og type, gitt de andre aktive filtrene | +| `title(id)` | Detaljside med anmeldelser og eventuell gratisfilm | +| `myList(first, after)`, `genres` | Min liste og sjangerlista til filterpanelet | +| `addReview`, `deleteReview`, `toggleList` | Brukergenererte data, lagret i PostgreSQL og knyttet til en anonym bruker-ID | + +GraphQL passer fordi forsiderader, resultatliste og detaljside trenger ulike felt fra samme +objekt, og `totalCount` bare beregnes når klienten ber om det. Full kontrakt, grenser og +feilkoder: [`docs/api.md`](docs/api.md). + ## Testing | Type | Antall | Kommando | | ----------------------------------------------- | -----: | ------------------ | -| API og database (Vitest mot ekte PostgreSQL) | 490 | `npm test` | +| API og database (Vitest mot ekte PostgreSQL) | 502 | `npm test` | | Komponenter og hooks (Vitest + Testing Library) | 288 | `npm test` | | E2E med axe (Playwright, desktop og mobil) | 101 | `npm run test:e2e` | diff --git a/docs/ki/ki-logg.md b/docs/ki/ki-logg.md index e7db91b..a61bc5d 100644 --- a/docs/ki/ki-logg.md +++ b/docs/ki/ki-logg.md @@ -27,3 +27,4 @@ - 2026-10-13 – A1 status.md oppdatert til 2026-10-13 – subagent – prettier grønn. - 2026-10-13 – A5/A7/A8 GitLab-guide med MR- og issue-maler, mal for «Gruppens egen gjennomgang» uten svar, `docs/readme-fakta.md` – subagent – lint og prettier grønne. - 2026-10-13 – A9 konsistenssjekk av docs/: linjehenvisninger, testtall (502/288/101), PR-lister til #31, stier og lenker – subagent – 502 backend-, 288 komponenttester og E2E 101 bestått/15 hoppet over, lint grønn. +- 2026-10-15 – README: ny seksjon «Komponenter, biblioteker og API» (oppgaven krever at README diskuterer valg av komponenter og API) og rettet backend-testtall 490 → 502 – leder, på forespørsel fra Brage – prettier grønn. diff --git a/docs/readme-fakta.md b/docs/readme-fakta.md index 43e4070..0a3b3f6 100644 --- a/docs/readme-fakta.md +++ b/docs/readme-fakta.md @@ -28,7 +28,7 @@ | Tall | Verdi | Kilde og status | | ----------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------- | | Titler i produksjon | 190 607 (filmer og serier med minst 100 stemmer) | `docs/beslutninger.md#datasett`, `docs/prosess/status.md`. Talt av Nicolay på VM-en | -| Backendtester (Vitest mot PostgreSQL) | 502 i 24 filer | Kjørt på nytt 2026-10-13: alle grønne. `README.md` oppgir et eldre tall | +| Backendtester (Vitest mot PostgreSQL) | 502 i 24 filer | Kjørt på nytt 2026-10-13: alle grønne | | Komponenttester (Vitest + Testing Library) | 288 i 27 filer | Kjørt på nytt 2026-10-13: alle grønne | | E2E-tester (Playwright, desktop og mobil) | 101 bestått, 15 hoppet over, 0 ustabile | Kjørt på nytt 2026-10-13 i CI-modus. `README.md` oppgir 101 uten de hoppede | | Søk «dark», sortert på rating | 76,7 ms uten indekser, 3,1 ms med | `docs/ytelse.md`, syntetiske data (120 000 titler) | From 12881b5609a618fae171e6cfcc9978d45ad72b27 Mon Sep 17 00:00:00 2001 From: Brage-Molstad Date: Thu, 15 Oct 2026 15:24:30 +0200 Subject: [PATCH 2/2] refactor: share mutation guard, title lookup and image resolvers Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- backend/src/rateLimit.ts | 2 +- backend/src/resolvers/index.ts | 73 ++++++++++++++++++--------------- docs/ki/ki-logg.md | 1 + docs/readme-fakta.md | 2 +- frontend/src/lib/searchState.ts | 15 ++++--- 6 files changed, 54 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 98251ea..7e23251 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ feilkoder: [`docs/api.md`](docs/api.md). | Type | Antall | Kommando | | ----------------------------------------------- | -----: | ------------------ | | API og database (Vitest mot ekte PostgreSQL) | 502 | `npm test` | -| Komponenter og hooks (Vitest + Testing Library) | 288 | `npm test` | +| Komponenter og hooks (Vitest + Testing Library) | 293 | `npm test` | | E2E med axe (Playwright, desktop og mobil) | 101 | `npm run test:e2e` | Typisk bruk testes som hele flyter (søk, filtrer, scroll, anmeld, spill film med tastatur). Utypisk diff --git a/backend/src/rateLimit.ts b/backend/src/rateLimit.ts index 5c8dc38..71d51f7 100644 --- a/backend/src/rateLimit.ts +++ b/backend/src/rateLimit.ts @@ -17,7 +17,7 @@ export const DEFAULT_RATE_LIMITS: RateLimitOptions = { ipFactor: 3, }; -type MutationKind = 'review' | 'mutation'; +export type MutationKind = 'review' | 'mutation'; interface Bucket { tokens: number; diff --git a/backend/src/resolvers/index.ts b/backend/src/resolvers/index.ts index 7a91a83..932006e 100644 --- a/backend/src/resolvers/index.ts +++ b/backend/src/resolvers/index.ts @@ -2,6 +2,7 @@ import { BACKDROP_WIDTHS, POSTER_WIDTHS, buildImageUrl } from '../artwork.js'; import { badInput, notFound, unauthenticated } from '../errors.js'; import type { Context } from '../context.js'; import { facetCacheKey } from '../facetCache.js'; +import type { MutationKind } from '../rateLimit.js'; import { DEFAULT_DIRECTION, getFacets, @@ -35,6 +36,33 @@ function mapConnection(conn: Connection, map: (row: R) => N) { }; } +/** Ugyldige id-er slås aldri opp, så tull fra klienten koster ikke en databasespørring. */ +const findTitle = (ctx: Context, id: string) => + isPlausibleTitleId(id) ? getTitle(ctx.pool, id) : Promise.resolve(null); + +/** + * Felles inngang for alle mutations: krever anonym bruker-id og trekker fra rate limit-kvoten. + * Kvoten trekkes før input valideres, ellers kan en løkke prøve ugyldige forsøk gratis. + */ +function requireUser(ctx: Context, kind: MutationKind): string { + if (!ctx.userId) throw unauthenticated('Mangler gyldig x-user-id.'); + ctx.limiter.check(kind, ctx.userId, ctx.ip); + return ctx.userId; +} + +/** + * Resolver for et bildefelt. Bredden valideres før oppslaget, så en ugyldig bredde aldri koster et + * TMDB-kall. Artwork slås bare opp når feltet er med i queryen (resolvere kjøres kun for valgte felt). + */ +const imageResolver = + (defaultWidth: number, allowed: readonly number[], pick: 'posterPath' | 'backdropPath') => + async (t: TitleNode, args: { width?: number | null }, ctx: Context) => { + const width = args.width ?? defaultWidth; + buildImageUrl('', null, width, allowed); + const art = await ctx.loaders.artwork.load(t.id, t.type); + return buildImageUrl(ctx.artwork.imageUrl, art?.[pick] ?? null, width, allowed); + }; + const connectionResolvers = { totalCount: (page: Page) => page.totalCount(), }; @@ -67,8 +95,7 @@ export const resolvers = { }, title: async (_: unknown, args: { id: string }, ctx: Context) => { - if (!isPlausibleTitleId(args.id)) return null; - const row = await getTitle(ctx.pool, args.id); + const row = await findTitle(ctx, args.id); return row ? mapTitle(row) : null; }, @@ -103,28 +130,21 @@ export const resolvers = { Mutation: { addReview: async (_: unknown, args: { input: ReviewInput }, ctx: Context) => { - if (!ctx.userId) throw unauthenticated('Mangler gyldig x-user-id.'); - // Før validering: ugyldige forsøk skal også koste, ellers kan en løkke prøve fritt. - ctx.limiter.check('review', ctx.userId, ctx.ip); + const userId = requireUser(ctx, 'review'); const input = validateReview(args.input); - if (!isPlausibleTitleId(input.titleId) || !(await getTitle(ctx.pool, input.titleId))) { - throw notFound('Fant ikke tittelen.'); - } + if (!(await findTitle(ctx, input.titleId))) throw notFound('Fant ikke tittelen.'); const { rows } = await ctx.pool.query( `INSERT INTO reviews (title_id, user_id, author, rating, body) VALUES ($1, $2, $3, $4, $5) RETURNING id::text AS id, title_id, user_id, author, rating, body, created_at`, - [input.titleId, ctx.userId, input.author, input.rating, input.text], + [input.titleId, userId, input.author, input.rating, input.text], ); - return mapReview(rows[0], ctx.userId); + return mapReview(rows[0], userId); }, toggleList: async (_: unknown, args: { titleId: string }, ctx: Context) => { - if (!ctx.userId) throw unauthenticated('Mangler gyldig x-user-id.'); - ctx.limiter.check('mutation', ctx.userId, ctx.ip); - const title = isPlausibleTitleId(args.titleId) - ? await getTitle(ctx.pool, args.titleId) - : null; + const userId = requireUser(ctx, 'mutation'); + const title = await findTitle(ctx, args.titleId); if (!title) throw notFound('Fant ikke tittelen.'); // Én atomisk setning: slett hvis den finnes, ellers legg til. Unngår at to raske klikk // gir en unik-brudd-feil, og returnerer den nye tilstanden uten ekstra spørring. @@ -138,21 +158,20 @@ export const resolvers = { RETURNING 1 ) SELECT EXISTS (SELECT 1 FROM ins) AS in_list`, - [ctx.userId, title.id], + [userId, title.id], ); return { ...mapTitle(title), inMyList: rows[0]?.in_list ?? false }; }, deleteReview: async (_: unknown, args: { id: string }, ctx: Context) => { - if (!ctx.userId) throw unauthenticated('Mangler gyldig x-user-id.'); - ctx.limiter.check('mutation', ctx.userId, ctx.ip); + const userId = requireUser(ctx, 'mutation'); // bigint tar maks 19 siffer; 18 er nok og gir aldri overflow i ::bigint-castet. if (!/^[1-9]\d{0,17}$/.test(args.id)) throw badInput('Ugyldig id.'); // Eierskapet sjekkes i selve DELETE-en. «Finnes ikke» og «tilhører andre» gir samme svar, // så en fremmed ikke kan bruke mutationen til å lete etter anmeldelses-id-er. const { rows } = await ctx.pool.query<{ title_id: string }>( 'DELETE FROM reviews WHERE id = $1::bigint AND user_id = $2 RETURNING title_id', - [args.id, ctx.userId], + [args.id, userId], ); const titleId = rows[0]?.title_id; if (!titleId) throw notFound('Fant ikke anmeldelsen.'); @@ -170,22 +189,10 @@ export const resolvers = { (await ctx.loaders.reviewStats.load(t.id)).count, inMyList: (t: TitleNode, _: unknown, ctx: Context) => t.inMyList ?? ctx.loaders.inMyList.load(t.id), - // Feltene slår bare opp artwork når de er med i queryen (resolvere kjøres kun for valgte felt). overview: async (t: TitleNode, _: unknown, ctx: Context) => (await ctx.loaders.artwork.load(t.id, t.type))?.overview ?? null, - posterUrl: async (t: TitleNode, args: { width?: number | null }, ctx: Context) => { - const width = args.width ?? 342; - // Validerer før oppslaget, så en ugyldig bredde aldri koster et TMDB-kall. - buildImageUrl('', null, width, POSTER_WIDTHS); - const art = await ctx.loaders.artwork.load(t.id, t.type); - return buildImageUrl(ctx.artwork.imageUrl, art?.posterPath ?? null, width, POSTER_WIDTHS); - }, - backdropUrl: async (t: TitleNode, args: { width?: number | null }, ctx: Context) => { - const width = args.width ?? 1280; - buildImageUrl('', null, width, BACKDROP_WIDTHS); - const art = await ctx.loaders.artwork.load(t.id, t.type); - return buildImageUrl(ctx.artwork.imageUrl, art?.backdropPath ?? null, width, BACKDROP_WIDTHS); - }, + posterUrl: imageResolver(342, POSTER_WIDTHS, 'posterPath'), + backdropUrl: imageResolver(1280, BACKDROP_WIDTHS, 'backdropPath'), stream: (t: TitleNode, _: unknown, ctx: Context) => ctx.loaders.stream.load(t.id), reviews: async ( t: TitleNode, diff --git a/docs/ki/ki-logg.md b/docs/ki/ki-logg.md index a61bc5d..33c9f93 100644 --- a/docs/ki/ki-logg.md +++ b/docs/ki/ki-logg.md @@ -28,3 +28,4 @@ - 2026-10-13 – A5/A7/A8 GitLab-guide med MR- og issue-maler, mal for «Gruppens egen gjennomgang» uten svar, `docs/readme-fakta.md` – subagent – lint og prettier grønne. - 2026-10-13 – A9 konsistenssjekk av docs/: linjehenvisninger, testtall (502/288/101), PR-lister til #31, stier og lenker – subagent – 502 backend-, 288 komponenttester og E2E 101 bestått/15 hoppet over, lint grønn. - 2026-10-15 – README: ny seksjon «Komponenter, biblioteker og API» (oppgaven krever at README diskuterer valg av komponenter og API) og rettet backend-testtall 490 → 502 – leder, på forespørsel fra Brage – prettier grønn. +- 2026-10-15 – Forenkling: requireUser, findTitle og imageResolver i resolverne, invert() for URL-tabeller i searchState, frontend-testtall 288 → 293 – leder, på forespørsel fra Brage – lint, typecheck og 293 komponenttester grønne; backendtester ikke kjørt (PostgreSQL mangler lokalt). diff --git a/docs/readme-fakta.md b/docs/readme-fakta.md index 0a3b3f6..1b19719 100644 --- a/docs/readme-fakta.md +++ b/docs/readme-fakta.md @@ -29,7 +29,7 @@ | ----------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------- | | Titler i produksjon | 190 607 (filmer og serier med minst 100 stemmer) | `docs/beslutninger.md#datasett`, `docs/prosess/status.md`. Talt av Nicolay på VM-en | | Backendtester (Vitest mot PostgreSQL) | 502 i 24 filer | Kjørt på nytt 2026-10-13: alle grønne | -| Komponenttester (Vitest + Testing Library) | 288 i 27 filer | Kjørt på nytt 2026-10-13: alle grønne | +| Komponenttester (Vitest + Testing Library) | 293 i 27 filer | Kjørt på nytt 2026-10-15: alle grønne | | E2E-tester (Playwright, desktop og mobil) | 101 bestått, 15 hoppet over, 0 ustabile | Kjørt på nytt 2026-10-13 i CI-modus. `README.md` oppgir 101 uten de hoppede | | Søk «dark», sortert på rating | 76,7 ms uten indekser, 3,1 ms med | `docs/ytelse.md`, syntetiske data (120 000 titler) | | Søk «dark», relevans | 82,4 ms uten indekser, 8,9 ms med | `docs/ytelse.md`, syntetiske data | diff --git a/frontend/src/lib/searchState.ts b/frontend/src/lib/searchState.ts index cdf3a22..1fcc32c 100644 --- a/frontend/src/lib/searchState.ts +++ b/frontend/src/lib/searchState.ts @@ -34,18 +34,23 @@ export const EMPTY_STATE: SearchState = { dir: null, }; +/** Snur en oversettelsestabell, så hver URL-verdi bare står ett sted og ikke kan komme ut av synk. */ +const invert = (map: Record) => + Object.fromEntries(Object.entries(map).map(([key, url]) => [url, key])) as Record< + string, + K | undefined + >; + const SORT_TO_URL: Record = { RELEVANCE: 'relevans', RATING: 'rating', YEAR: 'ar', TITLE: 'tittel', }; -const URL_TO_SORT = Object.fromEntries( - Object.entries(SORT_TO_URL).map(([field, url]) => [url, field as SortField]), -) as Record; +const URL_TO_SORT = invert(SORT_TO_URL); const TYPE_TO_URL: Record = { MOVIE: 'film', SERIES: 'serie' }; -const URL_TO_TYPE: Record = { film: 'MOVIE', serie: 'SERIES' }; +const URL_TO_TYPE = invert(TYPE_TO_URL); export const DEFAULT_DIRECTION: Record = { RELEVANCE: 'DESC', @@ -117,7 +122,7 @@ export function activeFilterCount(state: SearchState): number { state.genres.length + state.decades.length + state.types.length + - (state.minRating ? 1 : 0) + + (state.minRating !== null ? 1 : 0) + (state.available ? 1 : 0) ); }