diff --git a/README.md b/README.md index 2ca61db..f9ca908 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ hvordan løsningen er tenkt brukt. Leieforespørselen er fortsatt demo. Søk, filtre og sortering i katalogen gjøres på serveren, i hele databasen, se -[Søk, filtre og sortering](#søk-filtre-og-sortering). Se også +[Søk, filtre og sortering](#søk-filtre-og-sortering). Brukere kan også gi +annonser en vurdering fra 1 til 5, se [Vurderinger](#vurderinger). Se også [Frontend mot GraphQL](#frontend-mot-graphql) og [Backend og database](#backend-og-database). @@ -48,11 +49,11 @@ låne verktøy i nærheten i stedet for å kjøpe nytt. ### Visninger og navigasjon -| Visning | Slik kommer du dit | Hva du kan gjøre | -| -------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------- | -| Katalog | Startsiden, «Katalog» i menyen eller navnet øverst | Søke, filtrere på kategori og gratis utlån, sortere og bla, 24 om gangen | -| 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 | +| Visning | Slik kommer du dit | Hva du kan gjøre | +| -------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Katalog | Startsiden, «Katalog» i menyen eller navnet øverst | Søke, filtrere på kategori og gratis utlån, sortere og bla, 24 om gangen | +| Detaljvisning | Klikk på et annonsekort i katalogen | Se bilde, beskrivelse, tilstand, tilbehør, pris og vurderinger, gi en vurdering, 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 | Menyen ligger øverst på alle sider. Detaljvisningen har en lenke tilbake til katalogen, og en ukjent adresse gir en side med lenke til katalogen. Alt kan @@ -72,8 +73,11 @@ annonser viser for eksempel «150 kr/døgn», mens gratis annonser viser 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. -- **Ingen innlogging.** Alle kan legge ut annonser, og annonsene har ingen - eier ennå. +- **Eksempelvurderingene er oppdiktede.** `npm run seed` legger inn 9 + vurderinger av noen av de håndskrevne annonsene, fra + `backend/src/seed/reviews.ts`. +- **Ingen innlogging.** Alle kan legge ut annonser og gi vurderinger, og + annonsene og vurderingene har ingen eier ennå. `src/data/mockListings.ts` brukes nå bare i tester og på designsiden. @@ -91,9 +95,12 @@ I den ferdige løsningen ligger dataene i en database: | Annonse | Ett fysisk eksemplar med eier, tilstand, pris, område og bilde | | Bruker | Den som legger ut annonser eller sender leieforespørsler | | Booking | Leieperiode og status: sendt, godkjent, avslått, utlevert eller levert | -| Vurdering | Leietakerens vurdering etter et gjennomført leieforhold | +| Vurdering | Score fra 1 til 5 og en valgfri kommentar til en annonse | -Annonser, bookinger og vurderinger er de brukergenererte dataene som lagres. +Annonser og vurderinger er de brukergenererte dataene som lagres i dag. +Bookinger kommer senere. Da bør en vurdering høre til et gjennomført +leieforhold, men nå kan hvem som helst gi en vurdering, se +[docs/vurderinger.md](docs/vurderinger.md). 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, @@ -114,12 +121,14 @@ mens selve annonsene genereres. Se [docs/datasett.md](docs/datasett.md). parter ser sine leieforhold. Godkjente bookinger kan ikke overlappe for samme verktøy. Utlevering og retur registreres, og leietakeren kan gi en vurdering etterpå. Gratis utlån bruker samme flyt. -- **Tilgjengelighet:** Semantisk HTML, tastaturnavigasjon, synlig fokus og - kontrast etter WCAG 2.1 er på plass allerede. Senere kommer test med - skjermleser og en manuell brukertest. -- **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. +- **Tilgjengelighet:** Appen er gått gjennom mot WCAG 2.1 AA med axe-core, + tastatur, 320 px bredde og `prefers-reduced-motion`, og funnene er rettet, + se [docs/tilgjengelighet.md](docs/tilgjengelighet.md). Test med skjermleser + gjenstår. +- **Bærekraft:** Små bilder som lastes når de trengs, systemfont, mørkt tema, + komprimering og cache-headere i Apache, og spørringer som bare henter feltene + som vises. Målingene før og etter står i + [docs/baerekraft.md](docs/baerekraft.md). - **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. @@ -196,6 +205,7 @@ Frontend bruker [Apollo Client](https://www.apollographql.com/docs/react/) for | `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/graphql/reviewMutations.ts` | Mutasjonen som lagrer nye vurderinger | | `src/test/listingMocks.ts` | Mockede GraphQL-svar til testene | Katalogen henter bare feltene annonsekortet viser, og 24 annonser per side. @@ -207,6 +217,12 @@ 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. +**Nye vurderinger:** `createReview` svarer med annonsen med nytt snitt, antall +og liste over vurderinger, i samme form som detaljspørringen. Apollo oppdaterer +annonsen i cachen, så detaljvisningen og annonsekortet viser den nye +vurderingen uten en ny spørring, se +[docs/vurderinger.md](docs/vurderinger.md#ny-vurdering-uten-ny-henting). + **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 @@ -308,7 +324,7 @@ psql -h localhost -U verktoyutlan_dev -d verktoyutlan -W -c "SELECT COUNT(*) FRO npm run seed # forvent 0 nye annonser andre gang ``` -Forvent **10** rader i `listings`. Seed-data tilsvarer +Forvent **10** rader i `listings` og **9** i `reviews`. Seed-data tilsvarer `src/data/mockListings.ts` (id `listing-001`–`listing-010`). **7. Stort datasett:** @@ -347,12 +363,12 @@ Serveren logger URL til GraphQL-endepunktet (standard **Queries** -| Felt | Beskrivelse | -| ------------------ | --------------------------------------------------------------- | -| `health` | Returnerer `"ok"`. | -| `listings(…)` | Én side med annonser. Se paginering, søk og filtre under. | -| `listing(id: ID!)` | Én annonse, eller `null` hvis id ikke finnes. | -| `listingAreas` | Områdene som har minst én annonse, sortert alfabetisk på norsk. | +| Felt | Beskrivelse | +| ------------------ | ------------------------------------------------------------------------------------------ | +| `health` | Returnerer `"ok"`. | +| `listings(…)` | Én side med annonser. Se paginering, søk og filtre under. | +| `listing(id: ID!)` | Én annonse, eller `null` hvis id ikke finnes. Har snitt, antall og de nyeste vurderingene. | +| `listingAreas` | Områdene som har minst én annonse, sortert alfabetisk på norsk. | **Paginering:** `listings` returnerer en `ListingPage` med `items`, `totalCount` (antall annonser totalt) og `hasNextPage`. `limit` er @@ -398,9 +414,10 @@ trigram-indeks (`pg_trgm`) for søk i tekst. Med indeksene tar et søk under 2 m **Mutations** -| Felt | Beskrivelse | -| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| `createListing(input: CreateListingInput!)` | Oppretter annonse i `listings`. Validerer input som utleieskjemaet i frontend. Setter standardverdier for `condition`, `accessories` og `imageUrl`. | +| Felt | Beskrivelse | +| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `createListing(input: CreateListingInput!)` | Oppretter annonse i `listings`. Validerer input som utleieskjemaet i frontend. Setter standardverdier for `condition`, `accessories` og `imageUrl`. | +| `createReview(input: CreateReviewInput!)` | Lagrer en vurdering fra 1 til 5 med valgfri kommentar, og gir annonsen med oppdatert snitt og liste. Se [docs/vurderinger.md](docs/vurderinger.md#graphql). | Feltene returneres i camelCase (`dailyPrice`, `imageUrl`, osv.). Enkelt eksempel med curl (juster URL om du har endret `PORT`): @@ -542,6 +559,19 @@ og valget er begrunnet i dokumentasjonen. Hvordan det virker, valideringen, tilgjengeligheten og filene står i [docs/sok-og-filtre.md](docs/sok-og-filtre.md). +## Vurderinger + +Brukere kan gi en annonse en vurdering fra 1 til 5 med en valgfri kommentar. +Annonsekortet viser snittet som stjerner og tekst, for eksempel «4,7 av 5 (3 +vurderinger)». Detaljvisningen viser snittet, de 10 nyeste vurderingene og et +skjema for å gi en ny. Den nye vurderingen og det nye snittet vises med en +gang, uten at siden lastes på nytt. + +Vurderingene lagres i tabellen `reviews`, og snitt og antall regnes ut i +samme spørring som annonsene, uten én ekstra spørring per kort. Datamodellen, +API-et, valideringen, tilgjengeligheten og testene står i +[docs/vurderinger.md](docs/vurderinger.md). + ## Design Felles farger, typografi, avstander og komponenter er beskrevet i @@ -591,6 +621,9 @@ dataene i appen kommer fra. | [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/sok-og-filtre.md](docs/sok-og-filtre.md) | Søk, filtre og sortering: oppførsel, validering og filer | +| [docs/vurderinger.md](docs/vurderinger.md) | Vurderinger: datamodell, API, validering og cache | +| [docs/tilgjengelighet.md](docs/tilgjengelighet.md) | Gjennomgang mot WCAG 2.1 AA: metode, funn og rettelser | +| [docs/baerekraft.md](docs/baerekraft.md) | Bærekraft: målinger før og etter, tiltak og vurderinger | | [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 | diff --git a/backend/migrations/1793307661044_create-reviews-table.ts b/backend/migrations/1793307661044_create-reviews-table.ts new file mode 100644 index 0000000..f44082f --- /dev/null +++ b/backend/migrations/1793307661044_create-reviews-table.ts @@ -0,0 +1,58 @@ +import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +/** Samme grense som REVIEW_COMMENT_MAX_LENGTH i backend og frontend. */ +const COMMENT_MAX_LENGTH = 500; + +/** + * Vurderinger av annonser: en score fra 1 til 5 og en valgfri kommentar. + * Grensene sjekkes også i databasen, slik at ugyldige vurderinger ikke kan + * lagres selv om valideringen i backenden skulle bli omgått. + * + * Slettes en annonse, slettes vurderingene av den også (ON DELETE CASCADE). + * + * Indeksen har samme rekkefølge som listen over de nyeste vurderingene, så den + * kan leses rett fra indeksen. Den brukes også når snittscore og antall regnes + * ut for én annonse, fordi listing_id står først. + */ +export async function up(pgm: MigrationBuilder): Promise { + pgm.createTable('reviews', { + id: { type: 'text', primaryKey: true }, + listing_id: { + type: 'text', + notNull: true, + references: 'listings', + onDelete: 'CASCADE', + }, + score: { type: 'integer', notNull: true }, + comment: { type: 'text', notNull: true, default: '' }, + created_at: { + type: 'timestamptz', + notNull: true, + default: pgm.func('current_timestamp'), + }, + }); + + pgm.addConstraint('reviews', 'reviews_score_check', { + check: 'score BETWEEN 1 AND 5', + }); + + pgm.addConstraint('reviews', 'reviews_comment_length_check', { + check: `char_length(comment) <= ${COMMENT_MAX_LENGTH}`, + }); + + pgm.createIndex( + 'reviews', + [ + 'listing_id', + { name: 'created_at', sort: 'DESC' }, + { name: 'id', sort: 'DESC' }, + ], + { name: 'reviews_listing_id_created_at_id_idx' }, + ); +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropTable('reviews'); +} diff --git a/backend/src/db/listingsRepository.ts b/backend/src/db/listingsRepository.ts index 83c2bf4..6b1d8bb 100644 --- a/backend/src/db/listingsRepository.ts +++ b/backend/src/db/listingsRepository.ts @@ -27,6 +27,26 @@ const LISTING_COLUMNS = ` created_at `; +/** + * Antall vurderinger og snittscore for annonsen l. Brukes med CROSS JOIN + * LATERAL, så det regnes ut bare for annonsene som returneres, og hver + * utregning slår opp i indeksen på reviews.listing_id. En side med 24 + * annonser gir da én spørring, ikke én ekstra spørring per annonse. + * + * En aggregering uten GROUP BY gir alltid én rad, så annonser uten + * vurderinger får 0 og null. AVG gir numeric, som pg leverer som tekst, så + * den gjøres om til float8 for å bli et tall i JavaScript. + */ +const REVIEW_STATS_JOIN = ` + CROSS JOIN LATERAL ( + SELECT + COUNT(*)::int AS review_count, + AVG(score)::float8 AS average_score + FROM reviews + WHERE reviews.listing_id = l.id + ) AS review_stats +`; + export interface ListingPage { items: Listing[]; totalCount: number; @@ -55,13 +75,20 @@ export async function findListingsPage({ const where = buildListingsWhere(filter); const limitParam = where.values.length + 1; + // Siden velges først i den indre spørringen. Vurderingene hentes etterpå, + // bare for annonsene på siden, og rekkefølgen settes på nytt etter joinen. const [itemsResult, countResult] = await Promise.all([ pool.query( - `SELECT ${LISTING_COLUMNS} - FROM listings - ${where.sql} - ORDER BY ${LISTING_ORDER_BY[sort]} - LIMIT $${limitParam} OFFSET $${limitParam + 1}`, + `SELECT l.*, review_stats.* + FROM ( + SELECT ${LISTING_COLUMNS} + FROM listings + ${where.sql} + ORDER BY ${LISTING_ORDER_BY[sort]} + LIMIT $${limitParam} OFFSET $${limitParam + 1} + ) AS l + ${REVIEW_STATS_JOIN} + ORDER BY ${LISTING_ORDER_BY[sort]}`, [...where.values, limit, offset], ), pool.query<{ total: string }>( @@ -93,7 +120,10 @@ export async function findListingAreas(): Promise { export async function findListingById(id: string): Promise { const result = await pool.query( - `SELECT ${LISTING_COLUMNS} FROM listings WHERE id = $1`, + `SELECT l.*, review_stats.* + FROM listings AS l + ${REVIEW_STATS_JOIN} + WHERE l.id = $1`, [id], ); const row = result.rows[0]; @@ -108,7 +138,9 @@ export async function insertListing( ): Promise { const id = `listing-${randomUUID()}`; - const result = await pool.query( + const result = await pool.query< + Omit + >( `INSERT INTO listings ( id, title, @@ -140,7 +172,8 @@ export async function insertListing( throw new Error('INSERT RETURNING ga ingen rad.'); } - return listingFromRow(row); + // En ny annonse har ingen vurderinger ennå. + return listingFromRow({ ...row, review_count: 0, average_score: null }); } export async function deleteListingById(id: string): Promise { diff --git a/backend/src/db/reviewsRepository.ts b/backend/src/db/reviewsRepository.ts new file mode 100644 index 0000000..60c43f6 --- /dev/null +++ b/backend/src/db/reviewsRepository.ts @@ -0,0 +1,70 @@ +import { randomUUID } from 'node:crypto'; + +import type { Review, ReviewRow } from '../types/review.js'; +import { reviewFromRow } from '../types/review.js'; +import type { ValidatedCreateReviewInput } from '../validation/createReviewInput.js'; +import { pool } from './pool.js'; + +const REVIEW_COLUMNS = 'id, listing_id, score, comment, created_at'; + +/** PostgreSQL-koden for brudd på en fremmednøkkel. */ +const FOREIGN_KEY_VIOLATION = '23503'; + +function isForeignKeyViolation(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + (error as { code?: unknown }).code === FOREIGN_KEY_VIOLATION + ); +} + +/** + * Lagrer en vurdering. Returnerer null når annonsen ikke finnes. Det sjekkes + * av fremmednøkkelen i databasen, så en annonse som slettes samtidig, ikke + * kan få en vurdering som peker på ingenting. + */ +export async function insertReview( + input: ValidatedCreateReviewInput, +): Promise { + const id = `review-${randomUUID()}`; + + try { + const result = await pool.query( + `INSERT INTO reviews (id, listing_id, score, comment) + VALUES ($1, $2, $3, $4) + RETURNING ${REVIEW_COLUMNS}`, + [id, input.listingId, input.score, input.comment], + ); + const row = result.rows[0]; + if (!row) { + throw new Error('INSERT RETURNING ga ingen rad.'); + } + return reviewFromRow(row); + } catch (error) { + if (isForeignKeyViolation(error)) { + return null; + } + throw error; + } +} + +/** + * De nyeste vurderingene av én annonse, nyeste først. id er siste + * sorteringsnøkkel, slik at vurderinger med samme tidspunkt alltid kommer i + * samme rekkefølge. Rekkefølgen er den samme som i indeksen + * reviews_listing_id_created_at_id_idx. + */ +export async function findReviewsForListing( + listingId: string, + limit: number, +): Promise { + const result = await pool.query( + `SELECT ${REVIEW_COLUMNS} + FROM reviews + WHERE listing_id = $1 + ORDER BY created_at DESC, id DESC + LIMIT $2`, + [listingId, limit], + ); + return result.rows.map(reviewFromRow); +} diff --git a/backend/src/graphql/resolvers.ts b/backend/src/graphql/resolvers.ts index 7f31b65..884c3b5 100644 --- a/backend/src/graphql/resolvers.ts +++ b/backend/src/graphql/resolvers.ts @@ -1,11 +1,23 @@ +import { GraphQLError } from 'graphql'; + import { findListingAreas, findListingById, findListingsPage, insertListing, } from '../db/listingsRepository.js'; +import { + findReviewsForListing, + insertReview, +} from '../db/reviewsRepository.js'; +import type { Listing } from '../types/listing.js'; import type { CreateListingInput } from '../validation/createListingInput.js'; import { validateCreateListingInput } from '../validation/createListingInput.js'; +import type { CreateReviewInput } from '../validation/createReviewInput.js'; +import { + validateCreateReviewInput, + validateReviewsLimit, +} from '../validation/createReviewInput.js'; import type { ListingFilterArgs } from '../validation/listingFilterArgs.js'; import { validateListingFilterArgs } from '../validation/listingFilterArgs.js'; import type { PaginationArgs } from '../validation/paginationArgs.js'; @@ -28,5 +40,27 @@ export const resolvers = { const validated = validateCreateListingInput(args.input); return insertListing(validated); }, + createReview: async ( + _parent: unknown, + args: { input: CreateReviewInput }, + ) => { + const validated = validateCreateReviewInput(args.input); + const review = await insertReview(validated); + // Annonsen hentes etter at vurderingen er lagret, så snitt og antall + // tar med den nye vurderingen. + const listing = review && (await findListingById(validated.listingId)); + if (!review || !listing) { + throw new GraphQLError('Fant ikke annonsen.', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + return { review, listing }; + }, + }, + Listing: { + // Hentes bare når klienten ber om feltet, som i detaljvisningen. Katalogen + // spør ikke etter det, så den gir ingen ekstra spørringer. + reviews: (parent: Listing, args: { limit?: number | null }) => + findReviewsForListing(parent.id, validateReviewsLimit(args.limit)), }, }; diff --git a/backend/src/graphql/reviews.api.test.ts b/backend/src/graphql/reviews.api.test.ts new file mode 100644 index 0000000..5e4b2bb --- /dev/null +++ b/backend/src/graphql/reviews.api.test.ts @@ -0,0 +1,306 @@ +import type { ApolloServer } from '@apollo/server'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { + closeTestPool, + createTestApolloServer, +} from '../test/apolloTestServer.js'; +import { hasTestDatabase } from '../test/env.js'; +import { + MAX_REVIEWS_LIMIT, + REVIEW_COMMENT_MAX_LENGTH, +} from '../validation/createReviewInput.js'; + +/** Annonsene testene vurderer. Ordet MARKER gjør dem lette å søke fram. */ +const MARKER = 'vurderingstest'; +const LISTING_ID = 'test-review-listing'; +const OTHER_LISTING_ID = 'test-review-other'; +const FIXTURE_IDS = [LISTING_ID, OTHER_LISTING_ID]; +const UNKNOWN_LISTING_ID = 'listing-finnes-ikke'; + +const CREATE_REVIEW = `mutation ($input: CreateReviewInput!) { + createReview(input: $input) { + review { id score comment createdAt } + listing { + id + averageScore + reviewCount + reviews(limit: 10) { id score comment } + } + } +}`; + +const LISTING_WITH_REVIEWS = `query ($id: ID!, $limit: Int) { + listing(id: $id) { + averageScore + reviewCount + reviews(limit: $limit) { id score comment createdAt } + } +}`; + +interface ReviewResult { + id: string; + score: number; + comment: string; + createdAt: string; +} + +interface ListingResult { + averageScore: number | null; + reviewCount: number; + reviews: ReviewResult[]; +} + +describe.skipIf(!hasTestDatabase)('vurderinger', () => { + let server: ApolloServer; + + async function run(query: string, variables: Record) { + const response = await server.executeOperation({ query, variables }); + if (response.body.kind !== 'single') { + throw new Error('Forventet ett svar fra GraphQL.'); + } + return response.body.singleResult; + } + + async function createReview(input: Record) { + return run(CREATE_REVIEW, { + input: { listingId: LISTING_ID, ...input }, + }); + } + + async function fetchListing( + id = LISTING_ID, + limit?: number, + ): Promise { + const result = await run(LISTING_WITH_REVIEWS, { id, limit }); + expect(result.errors).toBeUndefined(); + return result.data?.listing as ListingResult; + } + + async function pool() { + return (await import('../db/pool.js')).pool; + } + + beforeAll(async () => { + server = await createTestApolloServer(); + const db = await pool(); + for (const id of FIXTURE_IDS) { + await db.query( + `INSERT INTO listings + (id, title, category, description, area, daily_price, condition) + VALUES ($1, 'Testannonse', 'handTools', $2, 'Moholt', 50, 'good') + ON CONFLICT (id) DO NOTHING`, + [id, `Test av vurderinger. ${MARKER}`], + ); + } + }); + + // Hver test starter uten vurderinger, så antall og snitt er kjent. + beforeEach(async () => { + const db = await pool(); + await db.query('DELETE FROM reviews WHERE listing_id = ANY($1)', [ + FIXTURE_IDS, + ]); + }); + + afterAll(async () => { + const db = await pool(); + // Vurderingene slettes sammen med annonsene (ON DELETE CASCADE). + await db.query('DELETE FROM listings WHERE id = ANY($1)', [FIXTURE_IDS]); + await server.stop(); + await closeTestPool(); + }); + + it('gir 0 vurderinger og snitt null for en annonse uten vurderinger', async () => { + expect(await fetchListing()).toEqual({ + averageScore: null, + reviewCount: 0, + reviews: [], + }); + }); + + it('lagrer en vurdering og gir annonsen med oppdatert snitt', async () => { + const result = await createReview({ + score: 4, + comment: ' Fin drill, ladet fort. ', + }); + expect(result.errors).toBeUndefined(); + + const payload = result.data?.createReview as { + review: ReviewResult; + listing: ListingResult & { id: string }; + }; + expect(payload.review).toMatchObject({ + score: 4, + comment: 'Fin drill, ladet fort.', + }); + expect(payload.review.id).toMatch(/^review-/); + expect(new Date(payload.review.createdAt).toISOString()).toBe( + payload.review.createdAt, + ); + expect(payload.listing).toMatchObject({ + id: LISTING_ID, + averageScore: 4, + reviewCount: 1, + }); + expect(payload.listing.reviews.map((r) => r.id)).toEqual([ + payload.review.id, + ]); + }); + + it('lagrer en vurdering uten kommentar som tom tekst', async () => { + const result = await createReview({ score: 5 }); + expect(result.errors).toBeUndefined(); + const payload = result.data?.createReview as { review: ReviewResult }; + expect(payload.review.comment).toBe(''); + }); + + it('regner ut snittet av alle vurderingene', async () => { + for (const score of [4, 5, 2]) { + expect((await createReview({ score })).errors).toBeUndefined(); + } + const listing = await fetchListing(); + expect(listing.reviewCount).toBe(3); + expect(listing.averageScore).toBeCloseTo(11 / 3); + }); + + it('holder vurderingene for hver annonse adskilt', async () => { + await createReview({ score: 1 }); + expect(await fetchListing(OTHER_LISTING_ID)).toMatchObject({ + averageScore: null, + reviewCount: 0, + }); + }); + + it('viser de nyeste vurderingene først, og like tidspunkt etter id', async () => { + const db = await pool(); + await db.query( + `INSERT INTO reviews (id, listing_id, score, comment, created_at) VALUES + ('test-review-a', $1, 3, 'Eldst', '2098-01-01T00:00:00Z'), + ('test-review-b', $1, 4, 'Lik tid', '2098-01-02T00:00:00Z'), + ('test-review-c', $1, 5, 'Lik tid', '2098-01-02T00:00:00Z'), + ('test-review-d', $1, 2, 'Nyest', '2098-01-03T00:00:00Z')`, + [LISTING_ID], + ); + const listing = await fetchListing(); + expect(listing.reviews.map((r) => r.id)).toEqual([ + 'test-review-d', + 'test-review-c', + 'test-review-b', + 'test-review-a', + ]); + }); + + it('gir bare så mange vurderinger som limit sier', async () => { + for (const score of [1, 2, 3]) { + await createReview({ score }); + } + const listing = await fetchListing(LISTING_ID, 2); + expect(listing.reviews).toHaveLength(2); + expect(listing.reviewCount).toBe(3); + }); + + it('viser snitt og antall på annonsene i katalogen', async () => { + await createReview({ score: 5 }); + await createReview({ score: 3 }); + const result = await run( + `query ($search: String) { + listings(search: $search, limit: 10) { + items { id averageScore reviewCount } + } + }`, + { search: MARKER }, + ); + expect(result.errors).toBeUndefined(); + const items = ( + result.data?.listings as { + items: { + id: string; + averageScore: number | null; + reviewCount: number; + }[]; + } + ).items; + expect(items).toContainEqual({ + id: LISTING_ID, + averageScore: 4, + reviewCount: 2, + }); + expect(items).toContainEqual({ + id: OTHER_LISTING_ID, + averageScore: null, + reviewCount: 0, + }); + }); + + describe('ugyldige vurderinger', () => { + it.each([0, 6, -1])( + 'avviser score %i og lagrer ingenting', + async (score) => { + const result = await createReview({ score }); + expect(result.data).toBeNull(); + expect(result.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + expect((await fetchListing()).reviewCount).toBe(0); + }, + ); + + it('avviser en for lang kommentar', async () => { + const result = await createReview({ + score: 3, + comment: 'a'.repeat(REVIEW_COMMENT_MAX_LENGTH + 1), + }); + expect(result.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + expect(result.errors?.[0]?.message).toContain( + `${REVIEW_COMMENT_MAX_LENGTH}`, + ); + }); + + it('avviser en vurdering av en annonse som ikke finnes', async () => { + const result = await run(CREATE_REVIEW, { + input: { listingId: UNKNOWN_LISTING_ID, score: 4 }, + }); + expect(result.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + expect(result.errors?.[0]?.message).toBe('Fant ikke annonsen.'); + }); + + it.each([0, MAX_REVIEWS_LIMIT + 1])( + 'avviser reviews(limit: %i)', + async (limit) => { + const result = await run(LISTING_WITH_REVIEWS, { + id: LISTING_ID, + limit, + }); + expect(result.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT'); + }, + ); + }); + + describe('databasen', () => { + it('avviser score utenfor 1–5 også uten om backenden', async () => { + const db = await pool(); + await expect( + db.query( + `INSERT INTO reviews (id, listing_id, score) VALUES ('test-review-x', $1, 6)`, + [LISTING_ID], + ), + ).rejects.toThrow(/reviews_score_check/); + }); + + it('sletter vurderingene når annonsen slettes', async () => { + const db = await pool(); + await db.query( + `INSERT INTO listings + (id, title, category, description, area, daily_price, condition) + VALUES ('test-review-temp', 'Midlertidig', 'garden', 'Slettes.', 'Lade', 0, 'good')`, + ); + await db.query( + `INSERT INTO reviews (id, listing_id, score) VALUES ('test-review-temp-1', 'test-review-temp', 4)`, + ); + await db.query(`DELETE FROM listings WHERE id = 'test-review-temp'`); + const remaining = await db.query( + `SELECT 1 FROM reviews WHERE id = 'test-review-temp-1'`, + ); + expect(remaining.rowCount).toBe(0); + }); + }); +}); diff --git a/backend/src/graphql/schema.ts b/backend/src/graphql/schema.ts index 9e69bde..61d22b9 100644 --- a/backend/src/graphql/schema.ts +++ b/backend/src/graphql/schema.ts @@ -26,6 +26,23 @@ export const typeDefs = `#graphql imageUrl: String "Når annonsen ble opprettet, som ISO 8601-tekst i UTC." createdAt: String! + "Snittet av alle vurderingene, fra 1 til 5. null når annonsen ikke har vurderinger." + averageScore: Float + "Antall vurderinger av annonsen." + reviewCount: Int! + "De nyeste vurderingene, nyeste først. limit er fra 1 til 50." + reviews(limit: Int = 10): [Review!]! + } + + "En vurdering av en annonse. Hvem som helst kan gi en vurdering." + type Review { + id: ID! + "Fra 1 til 5, der 5 er best." + score: Int! + "Tom tekst når vurderingen ikke har kommentar." + comment: String! + "Når vurderingen ble lagt inn, som ISO 8601-tekst i UTC." + createdAt: String! } "Rekkefølgen annonsene sorteres i." @@ -95,7 +112,29 @@ export const typeDefs = `#graphql listingAreas: [String!]! } + input CreateReviewInput { + listingId: ID! + "Fra 1 til 5, der 5 er best." + score: Int! + "Valgfri, høyst 500 tegn. Mellomrom i endene fjernes." + comment: String + } + + """ + Svaret på createReview. listing har oppdatert snitt, antall og liste over + vurderinger, slik at klienten kan oppdatere visningen uten ny spørring. + """ + type CreateReviewPayload { + review: Review! + listing: Listing! + } + type Mutation { createListing(input: CreateListingInput!): Listing! + """ + Lagrer en vurdering av en annonse. Gir BAD_USER_INPUT når score ikke er + fra 1 til 5, kommentaren er for lang eller annonsen ikke finnes. + """ + createReview(input: CreateReviewInput!): CreateReviewPayload! } `; diff --git a/backend/src/scripts/seed.ts b/backend/src/scripts/seed.ts index 604f21e..97c7f5b 100644 --- a/backend/src/scripts/seed.ts +++ b/backend/src/scripts/seed.ts @@ -1,5 +1,6 @@ import { pool } from '../db/pool.js'; import { seedListings } from '../seed/listings.js'; +import { seedReviews } from '../seed/reviews.js'; const INSERT_SQL = ` INSERT INTO listings ( @@ -17,6 +18,12 @@ const INSERT_SQL = ` ON CONFLICT (id) DO NOTHING `; +const INSERT_REVIEW_SQL = ` + INSERT INTO reviews (id, listing_id, score, comment, created_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO NOTHING +`; + async function seed(): Promise { if (seedListings.length !== 10) { throw new Error(`Forventet 10 seed-annonser, fant ${seedListings.length}.`); @@ -45,6 +52,19 @@ async function seed(): Promise { } } + // Vurderingene legges inn etter annonsene, fordi de peker på dem. + let insertedReviews = 0; + for (const review of seedReviews) { + const result = await pool.query(INSERT_REVIEW_SQL, [ + review.id, + review.listingId, + review.score, + review.comment, + review.createdAt, + ]); + insertedReviews += result.rowCount ?? 0; + } + const countResult = await pool.query<{ count: string }>( 'SELECT COUNT(*)::text AS count FROM listings', ); @@ -53,6 +73,9 @@ async function seed(): Promise { console.info( `Seed fullført: ${inserted} nye, ${skipped} hoppet over (finnes fra før). Totalt ${total} rader i listings.`, ); + console.info( + `Vurderinger: ${insertedReviews} nye, ${seedReviews.length - insertedReviews} hoppet over.`, + ); } try { diff --git a/backend/src/seed/reviews.ts b/backend/src/seed/reviews.ts new file mode 100644 index 0000000..ea579ae --- /dev/null +++ b/backend/src/seed/reviews.ts @@ -0,0 +1,83 @@ +/** + * Eksempelvurderinger av noen av seed-annonsene, slik at snittscore og + * vurderinger kan prøves uten å legge dem inn for hånd. Vurderingene er + * oppdiktede og har ingen personopplysninger. + * + * Faste ID-er gjør at seed-skriptet kan kjøres flere ganger uten duplikater. + * Tidspunktene er faste, så rekkefølgen i detaljvisningen er den samme hver + * gang. + */ + +export interface SeedReview { + id: string; + listingId: string; + score: number; + comment: string; + createdAt: string; +} + +export const seedReviews: SeedReview[] = [ + { + id: 'review-seed-001', + listingId: 'listing-001', + score: 5, + comment: 'Batteriet holdt hele dagen. Enkel henting på Moholt.', + createdAt: '2026-09-02T16:20:00.000Z', + }, + { + id: 'review-seed-002', + listingId: 'listing-001', + score: 4, + comment: 'Gjorde jobben med hyllene. Kunne hatt flere bits.', + createdAt: '2026-09-14T09:05:00.000Z', + }, + { + id: 'review-seed-003', + listingId: 'listing-001', + score: 5, + comment: '', + createdAt: '2026-10-01T18:40:00.000Z', + }, + { + id: 'review-seed-004', + listingId: 'listing-002', + score: 4, + comment: 'Kraftig nok til betongvegg. Litt tung å holde over hodet.', + createdAt: '2026-09-21T12:00:00.000Z', + }, + { + id: 'review-seed-005', + listingId: 'listing-003', + score: 3, + comment: 'Saget greit, men bladet var ikke så skarpt som beskrevet.', + createdAt: '2026-09-08T15:30:00.000Z', + }, + { + id: 'review-seed-006', + listingId: 'listing-005', + score: 5, + comment: 'Gratis og i fin stand. Tusen takk!', + createdAt: '2026-09-28T10:15:00.000Z', + }, + { + id: 'review-seed-007', + listingId: 'listing-005', + score: 4, + comment: 'Fin spade til kjøkkenhagen.', + createdAt: '2026-10-05T08:45:00.000Z', + }, + { + id: 'review-seed-008', + listingId: 'listing-008', + score: 5, + comment: 'Alt vi trengte for å male hybelen. Rullene var rene.', + createdAt: '2026-09-17T19:00:00.000Z', + }, + { + id: 'review-seed-009', + listingId: 'listing-010', + score: 2, + comment: 'Tepperenseren virket, men slangen lakk litt.', + createdAt: '2026-10-08T13:25:00.000Z', + }, +]; diff --git a/backend/src/types/listing.ts b/backend/src/types/listing.ts index 8cc2ad2..8658245 100644 --- a/backend/src/types/listing.ts +++ b/backend/src/types/listing.ts @@ -17,9 +17,15 @@ export interface Listing { imageUrl: string | null; /** Når annonsen ble opprettet, som ISO 8601-tekst i UTC. */ createdAt: string; + /** Snittet av vurderingene, fra 1 til 5, eller null uten vurderinger. */ + averageScore: number | null; + reviewCount: number; } -/** Rad fra PostgreSQL-tabellen listings. */ +/** + * Rad fra PostgreSQL-tabellen listings, med antall vurderinger og snittscore + * regnet ut fra tabellen reviews. + */ export interface ListingRow { id: string; title: string; @@ -32,6 +38,8 @@ export interface ListingRow { image_url: string | null; /** pg gjør timestamptz om til Date. */ created_at: Date; + review_count: number; + average_score: number | null; } export function listingFromRow(row: ListingRow): Listing { @@ -46,5 +54,7 @@ export function listingFromRow(row: ListingRow): Listing { accessories: row.accessories, imageUrl: row.image_url, createdAt: row.created_at.toISOString(), + averageScore: row.average_score, + reviewCount: row.review_count, }; } diff --git a/backend/src/types/review.ts b/backend/src/types/review.ts new file mode 100644 index 0000000..fd50994 --- /dev/null +++ b/backend/src/types/review.ts @@ -0,0 +1,31 @@ +/** Speiler typen Review i GraphQL-skjemaet. */ +export interface Review { + id: string; + listingId: string; + /** Fra 1 til 5, der 5 er best. */ + score: number; + /** Tom tekst når vurderingen ikke har kommentar. */ + comment: string; + /** Når vurderingen ble lagt inn, som ISO 8601-tekst i UTC. */ + createdAt: string; +} + +/** Rad fra PostgreSQL-tabellen reviews. */ +export interface ReviewRow { + id: string; + listing_id: string; + score: number; + comment: string; + /** pg gjør timestamptz om til Date. */ + created_at: Date; +} + +export function reviewFromRow(row: ReviewRow): Review { + return { + id: row.id, + listingId: row.listing_id, + score: row.score, + comment: row.comment, + createdAt: row.created_at.toISOString(), + }; +} diff --git a/backend/src/validation/createReviewInput.test.ts b/backend/src/validation/createReviewInput.test.ts new file mode 100644 index 0000000..9e21b6e --- /dev/null +++ b/backend/src/validation/createReviewInput.test.ts @@ -0,0 +1,79 @@ +import { GraphQLError } from 'graphql'; +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_REVIEWS_LIMIT, + MAX_REVIEWS_LIMIT, + REVIEW_COMMENT_MAX_LENGTH, + validateCreateReviewInput, + validateReviewsLimit, +} from './createReviewInput.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.'); +} + +const valid = { listingId: 'listing-001', score: 4, comment: 'Fin drill.' }; + +describe('validateCreateReviewInput', () => { + it('godtar en gyldig vurdering', () => { + expect(validateCreateReviewInput(valid)).toEqual(valid); + }); + + it.each([1, 5])('godtar grenseverdien %i', (score) => { + expect(validateCreateReviewInput({ ...valid, score }).score).toBe(score); + }); + + it.each([0, 6, -1, 4.5])('avviser score %s', (score) => { + expectBadUserInput(() => validateCreateReviewInput({ ...valid, score })); + }); + + it('fjerner mellomrom i endene av kommentaren', () => { + expect( + validateCreateReviewInput({ ...valid, comment: ' Fin drill. ' }) + .comment, + ).toBe('Fin drill.'); + }); + + it.each([undefined, null, '', ' '])( + 'gjør kommentaren %o om til tom tekst', + (comment) => { + expect(validateCreateReviewInput({ ...valid, comment }).comment).toBe(''); + }, + ); + + it('godtar en kommentar på nøyaktig maksimal lengde', () => { + const comment = 'a'.repeat(REVIEW_COMMENT_MAX_LENGTH); + expect(validateCreateReviewInput({ ...valid, comment }).comment).toBe( + comment, + ); + }); + + it('avviser en for lang kommentar', () => { + const comment = 'a'.repeat(REVIEW_COMMENT_MAX_LENGTH + 1); + expectBadUserInput(() => validateCreateReviewInput({ ...valid, comment })); + }); +}); + +describe('validateReviewsLimit', () => { + it('bruker standardverdien når ingenting er oppgitt', () => { + expect(validateReviewsLimit(undefined)).toBe(DEFAULT_REVIEWS_LIMIT); + expect(validateReviewsLimit(null)).toBe(DEFAULT_REVIEWS_LIMIT); + }); + + it('godtar grenseverdiene', () => { + expect(validateReviewsLimit(1)).toBe(1); + expect(validateReviewsLimit(MAX_REVIEWS_LIMIT)).toBe(MAX_REVIEWS_LIMIT); + }); + + it.each([0, -1, MAX_REVIEWS_LIMIT + 1])('avviser %i', (limit) => { + expectBadUserInput(() => validateReviewsLimit(limit)); + }); +}); diff --git a/backend/src/validation/createReviewInput.ts b/backend/src/validation/createReviewInput.ts new file mode 100644 index 0000000..2e8d5a0 --- /dev/null +++ b/backend/src/validation/createReviewInput.ts @@ -0,0 +1,70 @@ +import { GraphQLError } from 'graphql'; + +/** Laveste og høyeste score. Samme grenser som i databasen. */ +export const REVIEW_SCORE_MIN = 1; +export const REVIEW_SCORE_MAX = 5; + +/** Lengste kommentar, etter at mellomrom i endene er fjernet. */ +export const REVIEW_COMMENT_MAX_LENGTH = 500; + +/** Antall vurderinger Listing.reviews gir når klienten ikke ber om noe annet. */ +export const DEFAULT_REVIEWS_LIMIT = 10; +/** Flest vurderinger én spørring kan hente. */ +export const MAX_REVIEWS_LIMIT = 50; + +export interface CreateReviewInput { + listingId: string; + score: number; + comment?: string | null; +} + +export interface ValidatedCreateReviewInput { + listingId: string; + score: number; + /** Tom tekst når vurderingen ikke har kommentar. */ + comment: string; +} + +function invalidInput(message: string): never { + throw new GraphQLError(message, { + extensions: { code: 'BAD_USER_INPUT' }, + }); +} + +/** + * Validerer createReview-input. GraphQL sikrer at score er et heltall, så her + * sjekkes grensene. Kommentaren er valgfri, og null blir tom tekst. Om + * annonsen finnes, sjekkes når vurderingen lagres. + */ +export function validateCreateReviewInput( + input: CreateReviewInput, +): ValidatedCreateReviewInput { + const { score } = input; + if ( + !Number.isInteger(score) || + score < REVIEW_SCORE_MIN || + score > REVIEW_SCORE_MAX + ) { + invalidInput( + `Velg en vurdering fra ${REVIEW_SCORE_MIN} til ${REVIEW_SCORE_MAX}.`, + ); + } + + const comment = input.comment?.trim() ?? ''; + if (comment.length > REVIEW_COMMENT_MAX_LENGTH) { + invalidInput(`Kommentaren kan ha høyst ${REVIEW_COMMENT_MAX_LENGTH} tegn.`); + } + + return { listingId: input.listingId, score, comment }; +} + +/** Validerer hvor mange vurderinger Listing.reviews skal gi. */ +export function validateReviewsLimit(limit: number | null | undefined): number { + const value = limit ?? DEFAULT_REVIEWS_LIMIT; + if (!Number.isInteger(value) || value < 1 || value > MAX_REVIEWS_LIMIT) { + invalidInput( + `Antall vurderinger (limit) må være et heltall fra 1 til ${MAX_REVIEWS_LIMIT}.`, + ); + } + return value; +} diff --git a/deploy/apache/README.md b/deploy/apache/README.md index a55a8c4..9424d9b 100644 --- a/deploy/apache/README.md +++ b/deploy/apache/README.md @@ -26,16 +26,18 @@ npm run preview -- --base /project2/ Konfigurasjonen forutsetter at modulene er **enabled**. Sjekk: ```bash -apache2ctl -M | grep -E 'alias_module|rewrite_module|proxy_module|proxy_http_module' +apache2ctl -M | grep -E 'alias_module|rewrite_module|proxy_module|proxy_http_module|headers_module|deflate_module|filter_module' ``` Aktiver manglende moduler (krever sudo; **ikke** deaktiver prosjekt 1): ```bash -sudo a2enmod alias rewrite proxy proxy_http +sudo a2enmod alias rewrite proxy proxy_http headers deflate filter ``` -Valgfritt senere: `headers` for cache på statiske filer. +`headers` gir cache-headerne, og `deflate` og `filter` komprimerer tekst. +`deflate` er slått på som standard på Ubuntu. Uten `headers` virker appen, +men uten cache-headerne. Se [docs/baerekraft.md](../../docs/baerekraft.md). ## Filer @@ -111,7 +113,7 @@ Forutsetter: prosjekt 1 fungerer, og GraphQL kjører på 3001. ```bash export REPO_ROOT="$HOME/T36-Project-2" -sudo a2enmod alias rewrite proxy proxy_http +sudo a2enmod alias rewrite proxy proxy_http headers deflate filter sudo mkdir -p /var/www/project2 # Bygg og kopier til /var/www/project2, se «Oppdatere frontenden» @@ -141,6 +143,25 @@ curl -sS -o /dev/null -w "%{http_code}\n" -X POST \ SPA: åpne `/project2/annonser/listing-001` i nettleser og trykk refresh — skal ikke gi Apache 404. +Komprimering og cache. Bytt inn navnet på JS-filen fra `index.html`: + +```bash +B=http://it2810-36.idi.ntnu.no/project2 +# index.html: Content-Encoding: gzip og Cache-Control: no-cache +curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' "$B/" | grep -i -E 'content-encoding|cache-control' +# JS: Cache-Control: public, max-age=31536000, immutable +curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' "$B/assets/index-.js" | grep -i cache-control +# SVG: Content-Encoding: gzip og Cache-Control: public, max-age=604800 +curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' "$B/assets/listings/drill.svg" | grep -i -E 'content-encoding|cache-control' +# GraphQL: Content-Encoding: gzip +curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' -H 'Content-Type: application/json' \ + -H 'Accept: application/graphql-response+json' -X POST "$B/api/graphql" \ + -d '{"query":"{ listings { totalCount } }"}' | grep -i content-encoding +# 304 når filen er uendret +etag=$(curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' "$B/" | grep -i etag | cut -d' ' -f2 | tr -d '\r') +curl -s -o /dev/null -w '%{http_code}\n' -H 'Accept-Encoding: gzip' -H "If-None-Match: $etag" "$B/" +``` + ## Avhengigheter 1. Frontend-issue: `base: '/project2/'` i `vite.config.ts` + produksjonsbuild. @@ -157,3 +178,6 @@ ikke gi Apache 404. | 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` | +| Ingen `Cache-Control` på filene | `headers` ikke enabled (`sudo a2enmod headers` og reload) | +| GraphQL-svar er ikke komprimert | `filter` eller `deflate` ikke enabled | +| Alltid 200, aldri 304 | `DeflateAlterETag NoChange` mangler, så ETag-en har `-gzip` og matcher ikke | diff --git a/deploy/apache/t36-project2.conf b/deploy/apache/t36-project2.conf index 9693155..642db26 100644 --- a/deploy/apache/t36-project2.conf +++ b/deploy/apache/t36-project2.conf @@ -26,8 +26,54 @@ RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /project2/index.html [L] + + # Cache. Målingene og begrunnelsen står i docs/baerekraft.md. + + # index.html sjekkes mot serveren hver gang (ETag gir 304 når den er + # uendret). Den peker på JS- og CSS-filene, så etter en deploy må + # nettleseren få den nye, ellers spør den etter filer som er slettet. + + Header set Cache-Control "no-cache" + + # Vite legger en hash av innholdet i navnet, for eksempel + # index-OnZCJrkh.js. Nytt innhold gir nytt navn, så filene kan caches i + # et år uten å bli spurt etter på nytt. + + Header set Cache-Control "public, max-age=31536000, immutable" + + # Bildene og favicon har ikke hash i navnet. En uke, og etter det sjekker + # nettleseren med ETag om de er endret. + + Header set Cache-Control "public, max-age=604800" + + +# Komprimering av tekst, bare for prosjekt 2. Ubuntus deflate.conf komprimerer +# allerede HTML, CSS og JS, men ikke JSON fra GraphQL eller SVG-bildene. Alle +# typene står her, så siten ikke avhenger av standardoppsettet. mod_deflate +# komprimerer ikke et svar som allerede er komprimert. +# +# Apollo Client ber om application/graphql-response+json, og Apollo Server +# svarer da med den typen, ikke application/json. Begge må stå i listen. + + + + AddOutputFilterByType DEFLATE text/html text/css text/javascript application/javascript image/svg+xml + AddOutputFilterByType DEFLATE application/json application/graphql-response+json + + + + +# mod_deflate legger «-gzip» på ETag-en til komprimerte svar. Nettleseren +# sender den tilbake i If-None-Match, men Apache kjenner den ikke igjen og +# svarer 200 med hele filen i stedet for 304. NoChange beholder ETag-en, så +# uendrede filer gir 304. Direktivet kan bare stå for hele serveren og gjelder +# derfor også prosjekt 1, der virkningen er den samme. + + DeflateAlterETag NoChange + + # GraphQL-backend (127.0.0.1:3001) — /project2/api/graphql → backend /graphql diff --git a/docs/baerekraft.md b/docs/baerekraft.md new file mode 100644 index 0000000..37ca6b8 --- /dev/null +++ b/docs/baerekraft.md @@ -0,0 +1,194 @@ +# Bærekraft + +Hvordan appen holder datamengden, antall forespørsler og arbeidet på serveren +nede, hva vi har målt, og hva tiltakene i innlevering 3 ga. Mindre data over +nettet betyr mindre energi i nettverk, server og enhet, og raskere sider på +treg mobilforbindelse. + +## Innhold + +- [Sammendrag](#sammendrag) +- [Slik målte vi](#slik-målte-vi) +- [Før og etter](#før-og-etter) +- [Tiltak i innlevering 3](#tiltak-i-innlevering-3) +- [Det som var på plass fra før](#det-som-var-på-plass-fra-før) +- [JS-bunten](#js-bunten) +- [Vurdert, men ikke gjort](#vurdert-men-ikke-gjort) +- [På VM-en](#på-vm-en) + +## Sammendrag + +| Måling, katalogen | Før | Etter | +| ----------------------------------- | -------- | -------- | +| Første besøk, data over nettet | 164,3 kB | 160,3 kB | +| Første besøk, forespørsler | 10 | 10 | +| Nytt besøk, data over nettet | 5,7 kB | 2,2 kB | +| Én side med 24 annonser fra GraphQL | 5,0 kB | 1,4 kB | +| Layout-forskyvning (CLS) | 0,069 | 0 | +| JS-bunten (gzip) | 151,7 kB | 151,7 kB | + +- **GraphQL-svarene komprimeres.** Hver side i katalogen er 73 % mindre over + nettet. Det merkes hver gang brukeren søker, filtrerer eller blar. +- **Riktige cache-headere.** Filene med hash i navnet hentes én gang i året, + og `index.html` sjekkes alltid mot serveren. Før gjettet nettleseren hvor + lenge filene var gyldige, og kunne bruke en gammel `index.html` etter en + deploy. +- **Mørkt tema** følger systeminnstillingen. Det koster 0,15 kB CSS. +- **Ingen layout-forskyvning.** Bunnteksten hoppet ned når siden var lastet. +- **JS-bunten er uendret.** 93 % av den er React, Apollo, React Router og + `graphql`, se [JS-bunten](#js-bunten). + +## Slik målte vi + +Målt 29. oktober 2026 med produksjonsbygget (`npm run build`), servert av +Apache med vår site-konfigurasjon fra `deploy/apache/t36-project2.conf`, og +backenden mot databasen med 50 010 annonser. Begge målingene er gjort på +samme maskin med samme data, bare Apache-konfigurasjonen og CSS-en er +endret. + +- **Apache lokalt:** Apache 2.4 på macOS med `mod_deflate`, `mod_headers` og + `mod_proxy`. «Før» er dagens site-konfigurasjon pluss Ubuntus standard + `deflate.conf`. Den er slått på som standard på Ubuntu. +- **Sjekket mot VM-en:** Med VPN svarte VM-en det samme som «før»: gzip på + HTML, CSS og JS, ingen komprimering av SVG og GraphQL (3,4 kB per side), ingen + `Cache-Control`, og ETag med `-gzip`. +- **Nettleser:** Chromium via Playwright, 1280 × 900. Bytes over nettet er + `encodedDataLength` fra Chrome DevTools Protocol, med HTTP-headere. + «Første besøk» er med tom cache, «nytt besøk» er å åpne katalogen på nytt + senere. +- **CLS:** `PerformanceObserver` for `layout-shift` på katalogen, + detaljvisningen og utleieskjemaet, på 1280 og 375 px. +- **Bunten:** `vite build`, og en fordeling av den minifiserte bunten per + pakke ut fra sourcemapen. + +## Før og etter + +**Første besøk i katalogen**, tom cache: + +| Fil | Før | Etter | Endring | +| ------------------- | -------------- | -------------- | -------------------- | +| `index.html` | 692 B, gzip | 713 B, gzip | Lenke til ny CSS-fil | +| CSS | 3,9 kB, gzip | 4,1 kB, gzip | Mørkt tema | +| JS | 150,4 kB, gzip | 150,5 kB, gzip | Uendret | +| GraphQL, annonser | 5 038 B | 1 374 B, gzip | Komprimert | +| GraphQL, områder | 643 B | 591 B, gzip | Komprimert | +| 4 bilder og favicon | 3 624 B | 3 097 B, gzip | Komprimert | +| **Sum** | **164,3 kB** | **160,3 kB** | **−4,0 kB** | + +**Nytt besøk** i katalogen: + +| | Før | Etter | +| ----------------- | ------------------------------------ | ---------------------------------------------- | +| Over nettet | 5,7 kB | 2,2 kB | +| `index.html` | Fra cache, uten å spørre serveren | Sjekket mot serveren, 304 uten innhold (271 B) | +| JS, CSS og bilder | Fra cache, fordi nettleseren gjettet | Fra cache, fordi `Cache-Control` sier det | +| GraphQL | 5,7 kB | 2,0 kB | + +Før var det tilfeldig at det nye besøket var lite. Uten `Cache-Control` +gjetter nettleseren hvor lenge en fil er gyldig ut fra `Last-Modified`. Da kan +den bruke en gammel `index.html` etter en deploy. Den peker på JS-filer som +`rsync --delete` har fjernet, og appen blir hvit til brukeren laster siden på +nytt. + +**Søk:** Å skrive «batteridrill», 12 tegn, gir én forespørsel til GraphQL, +ikke tolv, fordi søket venter til brukeren har sluttet å skrive. + +## Tiltak i innlevering 3 + +**Komprimering av JSON og SVG.** Ubuntus standardoppsett komprimerer HTML, +CSS og JS, men ikke svarene fra GraphQL eller SVG-bildene. Site-konfigurasjonen +legger dem til for `/project2/`. Apollo Client ber om +`application/graphql-response+json`, og Apollo Server svarer da med den typen, +ikke `application/json`. Begge står i listen. GraphQL-svarene blir 73 % mindre, +og SVG-ene omtrent halvparten. + +**Cache-headere.** `mod_headers` setter: + +| Filer | `Cache-Control` | Hvorfor | +| ------------------------------------- | ------------------------------------- | --------------------------------------------------------- | +| `index.html`, også for SPA-ruter | `no-cache` | Sjekkes hver gang, så en ny deploy tas i bruk med en gang | +| JS og CSS med hash, `index-.js` | `public, max-age=31536000, immutable` | Nytt innhold gir nytt navn, så de kan caches i et år | +| SVG-bilder og favicon | `public, max-age=604800` | Ingen hash i navnet, så en uke og deretter sjekk med ETag | + +**ETag som virker med komprimering.** `mod_deflate` legger `-gzip` på +ETag-en. Nettleseren sender den tilbake, men Apache kjenner den ikke igjen og +svarer 200 med hele filen i stedet for 304. `DeflateAlterETag NoChange` retter +det. Direktivet gjelder hele serveren, også prosjekt 1, der virkningen er den +samme. + +**Mørkt tema.** Fargene i `src/styles/tokens.css` får mørke verdier under +`prefers-color-scheme: dark`, og `color-scheme: light dark` gjør at +nettleserens egne kontroller følger med. På OLED-skjermer, som de fleste nyere +telefoner har, bruker mørke piksler mindre strøm. Alle fargeparene oppfyller +WCAG AA, se [design.md](design.md#mørkt-tema), og axe fant ingen brudd i mørkt +tema. + +**Ingen layout-forskyvning.** Bunnteksten stod nederst i vinduet mens en side +lastet, og hoppet ned når innholdet kom (CLS 0,069 i katalogen og 0,086 i +detaljvisningen på mobil). `main` er nå minst like høy som vinduet, og CLS er 0 +på alle sidene. + +## Det som var på plass fra før + +- **Bare feltene som vises.** Katalogen henter `id`, `title`, `category`, + `area`, `dailyPrice`, `imageUrl`, `averageScore` og `reviewCount`. Beskrivelse, + tilbehør og vurderinger hentes først i detaljvisningen. +- **24 annonser per side**, og søk, filtre og sortering gjøres i databasen. + Klienten får aldri hele katalogen. +- **Indekser** gjør at databasen ikke leser hele tabellen for hvert søk, se + [datasett.md](datasett.md#indekser-for-søk-og-filtre). +- **Debouncing** på søket, se over. +- **Apollo-cachen** viser en side eller annonse som er hentet før, uten ny + forespørsel, for eksempel med «Tilbake». +- **Små bilder.** Illustrasjonene er SVG på 0,6–0,9 kB. Mange annonser deler + samme bilde, så en side med 24 kort laster bare 4 bildefiler. +- **Bilder ved behov.** Bildene i katalogen har `loading="lazy"`, og + `width`, `height` og `aspect-ratio`, så plassen er satt av før de lastes. +- **Systemfont.** Ingen nettfonter å laste ned. +- **Ingen sporing**, analyseverktøy eller tredjepartsskript. + +## JS-bunten + +Bunten er 493 kB, eller 152 kB med gzip. Fordelt på pakker etter minifisering: + +| Pakke | Størrelse | Andel | +| ---------------- | --------- | ----- | +| `react-dom` | 202 kB | 42 % | +| `@apollo/client` | 112 kB | 23 % | +| `graphql` | 40 kB | 8 % | +| `react-router` | 37 kB | 8 % | +| Appens kode | 34 kB | 7 % | +| `rxjs` | 24 kB | 5 % | +| Resten | 28 kB | 6 % | + +Appens egen kode er bare 7 %. Resten er bibliotekene vi har valgt, og de er +allerede bygget for produksjon med tree-shaking. Etter første besøk ligger +bunten i cachen i et år, så den lastes bare på nytt når appen endres. + +## Vurdert, men ikke gjort + +- **Kodesplitting per side.** Vi prøvde å laste detaljvisningen og + utleieskjemaet først når de trengs. Første besøk i katalogen ble 3,5 kB JS og + 0,7 kB CSS mindre med gzip, men detaljvisningen fikk tre ekstra + forespørsler første gang den åpnes, og mange tester måtte vente på at siden + lastes. Gevinsten var for liten til å forsvare det. +- **Brotli** komprimerer tekst noe bedre enn gzip. Ubuntu har `mod_brotli`, + men vi kunne ikke teste det lokalt, og gzip gir allerede det meste av + gevinsten. +- **Bytte ut React eller Apollo** med noe mindre, som Preact. Det ville + krympet bunten mest, men er en for stor endring nå. +- **Service worker og CDN** er utenfor rammen av issuet. + +## På VM-en + +Endringene i Apache virker først når konfigurasjonen er kopiert til VM-en og +modulene er slått på: + +```bash +sudo a2enmod headers deflate filter +sudo cp ~/T36-Project-2/deploy/apache/t36-project2.conf /etc/apache2/sites-available/ +sudo apache2ctl configtest && sudo systemctl reload apache2 +``` + +Kommandoene for å kontrollere komprimering, cache-headere og 304 etterpå står +i [deploy/apache/README.md](../deploy/apache/README.md#kontroll-etter-aktivering). diff --git a/docs/design.md b/docs/design.md index a30b316..3c9028f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -41,22 +41,74 @@ fokusmarkering. Kontrollerte fargepar: -| Forgrunn | Bakgrunn | Kontrast | Krav | -| ----------------------- | ----------------------- | -------- | ----- | -| `--color-text` | `--color-bg` | 14,9:1 | 4,5:1 | -| `--color-text-muted` | `--color-bg` | 5,9:1 | 4,5:1 | -| `--color-text-muted` | `--color-surface-muted` | 5,4:1 | 4,5:1 | -| `--color-on-accent` | `--color-accent` | 6,3:1 | 4,5:1 | -| `--color-on-accent` | `--color-accent-hover` | 8,7:1 | 4,5:1 | -| `--color-accent` | `--color-bg` | 5,9:1 | 4,5:1 | -| `--color-free-text` | `--color-free-bg` | 7,7:1 | 4,5:1 | -| `--color-text` | `--color-surface-muted` | 13,5:1 | 4,5:1 | -| `--color-error` | `--color-surface` | 6,5:1 | 4,5:1 | -| `--color-border-strong` | `--color-surface` | 4,3:1 | 3:1 | +| Forgrunn | Bakgrunn | Kontrast | Krav | +| ---------------------------- | ----------------------- | -------- | ----- | +| `--color-text` | `--color-bg` | 14,9:1 | 4,5:1 | +| `--color-text-muted` | `--color-bg` | 5,9:1 | 4,5:1 | +| `--color-text-muted` | `--color-surface-muted` | 5,4:1 | 4,5:1 | +| `--color-on-accent` | `--color-accent` | 6,3:1 | 4,5:1 | +| `--color-on-accent` | `--color-accent-hover` | 8,7:1 | 4,5:1 | +| `--color-accent` | `--color-bg` | 5,9:1 | 4,5:1 | +| `--color-free-text` | `--color-free-bg` | 7,7:1 | 4,5:1 | +| `--color-text` | `--color-surface-muted` | 13,5:1 | 4,5:1 | +| `--color-error` | `--color-surface` | 6,5:1 | 4,5:1 | +| `--color-border-strong` | `--color-surface` | 4,3:1 | 3:1 | +| `--color-text-muted` | `--color-surface` | 6,3:1 | 4,5:1 | +| Fokusring (`--color-accent`) | `--color-bg` | 5,9:1 | 3:1 | `--color-border` har lav kontrast (1,3:1) og brukes bare til dekorative rammer, der innholdet er forståelig uten rammen. +Plassholdertekst i felt bruker `--color-text-muted` (6,3:1 mot hvitt). Uten +den regelen gir Firefox bare 3,5:1, se +[tilgjengelighet.md](tilgjengelighet.md#funn-og-rettelser). + +### Mørkt tema + +Når systemet er satt til mørkt tema (`prefers-color-scheme: dark`), får de +samme variablene mørke verdier. Komponentene bruker bare variablene, så de +trenger ingen egne regler. `color-scheme: light dark` gjør at nedtrekkslister, +avkrysningsbokser og datofelt også blir mørke. Aksentfargen er lys grønn i +stedet for mørk, og knapper med aksentfarge har mørk tekst. Illustrasjonene +beholder sine lyse bakgrunner, som bilder. + +| Variabel | Mørk verdi | +| ----------------------- | ---------- | +| `--color-bg` | `#151917` | +| `--color-surface` | `#1e2320` | +| `--color-surface-muted` | `#2a302c` | +| `--color-text` | `#edf0ec` | +| `--color-text-muted` | `#b3bab5` | +| `--color-border` | `#3a413c` | +| `--color-border-strong` | `#8b938d` | +| `--color-accent` | `#7fc6a1` | +| `--color-accent-hover` | `#9bd4b6` | +| `--color-on-accent` | `#0f2419` | +| `--color-free-bg` | `#1c3a2a` | +| `--color-free-text` | `#bfe8d0` | +| `--color-error` | `#ff9187` | + +Kontrollerte fargepar i mørkt tema: + +| Forgrunn | Bakgrunn | Kontrast | Krav | +| ---------------------------- | ----------------------- | -------- | ----- | +| `--color-text` | `--color-bg` | 15,4:1 | 4,5:1 | +| `--color-text` | `--color-surface-muted` | 11,7:1 | 4,5:1 | +| `--color-text-muted` | `--color-bg` | 9,0:1 | 4,5:1 | +| `--color-text-muted` | `--color-surface` | 8,1:1 | 4,5:1 | +| `--color-text-muted` | `--color-surface-muted` | 6,8:1 | 4,5:1 | +| `--color-on-accent` | `--color-accent` | 8,2:1 | 4,5:1 | +| `--color-on-accent` | `--color-accent-hover` | 9,7:1 | 4,5:1 | +| `--color-accent` | `--color-bg` | 8,9:1 | 4,5:1 | +| `--color-accent` | `--color-surface` | 8,0:1 | 4,5:1 | +| `--color-free-text` | `--color-free-bg` | 9,3:1 | 4,5:1 | +| `--color-error` | `--color-surface` | 7,3:1 | 4,5:1 | +| `--color-border-strong` | `--color-surface` | 5,1:1 | 3:1 | +| Fokusring (`--color-accent`) | `--color-bg` | 8,9:1 | 3:1 | + +axe-core fant ingen brudd i mørkt tema på noen side, se +[tilgjengelighet.md](tilgjengelighet.md#resultat-med-axe). + ## Typografi | Variabel | Størrelse | Bruk | @@ -103,8 +155,8 @@ bakgrunn, men teksten alene er nok til å skille dem. ### ListingCard -Annonsekort med bilde, tittel, kategori, område og pris. Bildet vises med -`ListingImage`. +Annonsekort med bilde, tittel, kategori, område, snittscore og pris. Bildet +vises med `ListingImage`, og snittscoren med `ReviewScore`. ```tsx @@ -151,6 +203,44 @@ 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. +### ReviewScore + +Snittscore som stjerner og tekst, på annonsekortet og i detaljvisningen. + +```tsx + +// ★★★★★ 4,7 av 5 (3 vurderinger) +``` + +De fylte stjernene ligger over fem tomme og klippes til snittet, så 4,7 gir +nesten fem fylte. Stjernene har `aria-hidden="true"`, og teksten står alltid +ved siden av, så scoren ikke bare vises med symboler eller farge. Uten +vurderinger vises bare «Ingen vurderinger ennå» i dempet tekst. Fylte +stjerner har aksentfargen, tomme har `--color-border-strong`. + +### ReviewList + +De nyeste vurderingene av en annonse, som en liste med kort. Hvert kort viser +stjerner, scoren som tekst («4 av 5»), datoen i en `