Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
99599b7
feat(backend): legg til tabellen reviews for vurderinger av annonser
Oct 29, 2026
3716fd0
feat(backend): valider nye vurderinger
Oct 29, 2026
bd8ec13
feat(backend): legg til vurderinger, snittscore og createReview i Gra…
Oct 29, 2026
457d0e9
test(backend): test vurderinger, snittscore og createReview
Oct 29, 2026
b6c8a5c
feat(backend): legg til eksempelvurderinger i seed
Oct 29, 2026
a1bde3b
feat(graphql): hent snittscore og vurderinger, og legg til createRevi…
Oct 29, 2026
11c767e
feat(components): vis snittscore med tekst og stjerner på annonsekortet
Oct 29, 2026
bf6b792
feat(components): lag skjema for å gi en vurdering
Oct 29, 2026
7cc6c4f
test(src): gjør testen for lasting av nytt søk mindre avhengig av tid
Oct 29, 2026
dbe1d8f
feat(pages): vis vurderinger og skjema for nye vurderinger i detaljvi…
Oct 29, 2026
d20da11
test(src): test vurderinger i katalogen og detaljvisningen
Oct 29, 2026
35bca3f
docs(docs): dokumenter vurderinger av annonser
Oct 29, 2026
8980a31
fix(pages): flytt fokus til treffene når brukeren blar i katalogen
Oct 29, 2026
91dcb62
fix(styles): gi plassholdertekst nok kontrast i alle nettlesere
Oct 29, 2026
174b5d8
feat(styles): slå av animasjoner når brukeren ber om mindre bevegelse
Oct 29, 2026
defd6aa
test(src): sjekk alle sidene automatisk med axe-core
Oct 29, 2026
868c83f
fix(components): behold fokus på knappen mens et skjema lagres
Oct 29, 2026
a63897f
fix(pages): flytt fokus når «Fjern alle filtre» eller «Vis alle annon…
Oct 29, 2026
e055c45
docs(docs): dokumenter gjennomgangen av tilgjengelighet
Oct 29, 2026
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
77 changes: 54 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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
Expand All @@ -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.

Expand All @@ -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,
Expand All @@ -114,9 +121,10 @@ 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.
- **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, buffring og
spørringer som bare henter feltene som vises. Vi måler datamengde og antall
forespørsler.
Expand Down Expand Up @@ -196,6 +204,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.
Expand All @@ -207,6 +216,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
Expand Down Expand Up @@ -308,7 +323,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:**
Expand Down Expand Up @@ -347,12 +362,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
Expand Down Expand Up @@ -398,9 +413,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`):
Expand Down Expand Up @@ -542,6 +558,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
Expand Down Expand Up @@ -591,6 +620,8 @@ 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/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 |
Expand Down
58 changes: 58 additions & 0 deletions backend/migrations/1793307661044_create-reviews-table.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
pgm.dropTable('reviews');
}
49 changes: 41 additions & 8 deletions backend/src/db/listingsRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ListingRow>(
`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 }>(
Expand Down Expand Up @@ -93,7 +120,10 @@ export async function findListingAreas(): Promise<string[]> {

export async function findListingById(id: string): Promise<Listing | null> {
const result = await pool.query<ListingRow>(
`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];
Expand All @@ -108,7 +138,9 @@ export async function insertListing(
): Promise<Listing> {
const id = `listing-${randomUUID()}`;

const result = await pool.query<ListingRow>(
const result = await pool.query<
Omit<ListingRow, 'review_count' | 'average_score'>
>(
`INSERT INTO listings (
id,
title,
Expand Down Expand Up @@ -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<boolean> {
Expand Down
70 changes: 70 additions & 0 deletions backend/src/db/reviewsRepository.ts
Original file line number Diff line number Diff line change
@@ -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<Review | null> {
const id = `review-${randomUUID()}`;

try {
const result = await pool.query<ReviewRow>(
`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<Review[]> {
const result = await pool.query<ReviewRow>(
`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);
}
Loading