Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,43 @@ 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` |
| Komponenter og hooks (Vitest + Testing Library) | 288 | `npm test` |
| API og database (Vitest mot ekte PostgreSQL) | 502 | `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
Expand Down
2 changes: 1 addition & 1 deletion backend/src/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
73 changes: 40 additions & 33 deletions backend/src/resolvers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -35,6 +36,33 @@ function mapConnection<R, N>(conn: Connection<R>, 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(),
};
Expand Down Expand Up @@ -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;
},

Expand Down Expand Up @@ -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.
Expand All @@ -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.');
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions docs/ki/ki-logg.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@
- 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.
- 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).
4 changes: 2 additions & 2 deletions docs/readme-fakta.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
| 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 |
| Komponenttester (Vitest + Testing Library) | 288 i 27 filer | Kjørt på nytt 2026-10-13: alle grønne |
| Backendtester (Vitest mot PostgreSQL) | 502 i 24 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 |
Expand Down
15 changes: 10 additions & 5 deletions frontend/src/lib/searchState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <K extends string>(map: Record<K, string>) =>
Object.fromEntries(Object.entries(map).map(([key, url]) => [url, key])) as Record<
string,
K | undefined
>;

const SORT_TO_URL: Record<SortField, string> = {
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<string, SortField>;
const URL_TO_SORT = invert(SORT_TO_URL);

const TYPE_TO_URL: Record<TitleType, string> = { MOVIE: 'film', SERIES: 'serie' };
const URL_TO_TYPE: Record<string, TitleType> = { film: 'MOVIE', serie: 'SERIES' };
const URL_TO_TYPE = invert(TYPE_TO_URL);

export const DEFAULT_DIRECTION: Record<SortField, SortDirection> = {
RELEVANCE: 'DESC',
Expand Down Expand Up @@ -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)
);
}
Expand Down