Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 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
741fb8d
feat(deploy): komprimer JSON og SVG og sett cache-headere i Apache-siten
Oct 29, 2026
625b2c7
feat(styles): legg til mørkt tema som følger systeminnstillingen
Oct 29, 2026
e0748d6
fix(components): hindre at bunnteksten hopper ned mens en side lastes
Oct 29, 2026
28595a7
docs(docs): dokumenter målingene og tiltakene for bærekraft
Oct 29, 2026
77a1645
build(config): la proxyen i Vite peke på en annen backend med BACKEND…
Oct 29, 2026
c94c0e4
test(e2e): sett opp Playwright med egen backend mot testdatabasen
Oct 29, 2026
d2e0f2e
test(e2e): test søk, filtre, sortering, blaing og veien tilbake i kat…
Oct 29, 2026
2dd45e4
test(e2e): test at nye annonser og vurderinger lagres og vises
Oct 29, 2026
041450e
docs(docs): dokumenter ende-til-ende-testene med Playwright
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ node_modules
dist
*.tsbuildinfo

# Resultater fra ende-til-ende-testene (Playwright)
test-results
playwright-report

# Logger
npm-debug.log*
*.log
Expand Down
116 changes: 76 additions & 40 deletions README.md

Large diffs are not rendered by default.

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);
}
34 changes: 34 additions & 0 deletions backend/src/graphql/resolvers.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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)),
},
};
Loading