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
69 changes: 49 additions & 20 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 Down Expand Up @@ -196,6 +203,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 +215,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 +322,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 +361,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 +412,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 +557,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 +619,7 @@ 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/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