From cec5f0af86be71d7c2399fec452c9fe6c575323e Mon Sep 17 00:00:00 2001 From: Aleksander Krogsvold Johansen <226727752+AleksanderDenStore@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:23:23 +0200 Subject: [PATCH] feat: connect venue catalog to GraphQL --- .env.example | 2 +- client/README.md | 7 + client/src/App.css | 7 + client/src/api/places.test.ts | 129 +++++++++++++++++ client/src/api/places.ts | 159 ++++++++++++++++++--- client/src/components/FeaturedPlace.tsx | 32 +++-- client/src/components/PageFooter.tsx | 14 ++ client/src/components/map/PlaceDetails.tsx | 30 ++-- client/vite.config.ts | 9 ++ docs/venue-model.md | 54 +++---- server/README.md | 21 +-- server/src/graphql/schema.ts | 44 +++++- server/src/index.ts | 2 +- server/tests/database.test.ts | 67 ++++++++- 14 files changed, 490 insertions(+), 87 deletions(-) create mode 100644 client/src/api/places.test.ts diff --git a/.env.example b/.env.example index f222053..72e0a13 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ # For a backend running locally through SSH tunnel, use 127.0.0.1:5433 instead. # Never put real passwords or secrets in this file or commit server/.env. DATABASE_URL=postgresql://tapfinder_app:REPLACE_WITH_PASSWORD@localhost:5432/tapfinder_dev -PORT=4000 +PORT=3001 # Keep the API local until a reverse proxy (for example, Nginx) is configured. HOST=127.0.0.1 diff --git a/client/README.md b/client/README.md index 3456e4d..585bb88 100644 --- a/client/README.md +++ b/client/README.md @@ -52,3 +52,10 @@ Tests use fixed data and mocked hooks rather than external requests. The snapshot protects the header's rendered HTML structure; it is not a pixel-level visual regression test for CSS. Visual screenshot tests should use one fixed browser environment to avoid differences between operating systems. + +## GraphQL during local development + +The frontend requests venue data from `/project2/api/graphql`. During local +development, Vite proxies that path to the backend on `127.0.0.1:3001`. Start +the backend separately before running `npm run dev`; no database credentials +are exposed to the browser. diff --git a/client/src/App.css b/client/src/App.css index 1aab49e..080f6ba 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -819,3 +819,10 @@ main { margin-top: 0.3rem; font-size: 0.8rem; } + +.site-footer .source-attribution { + margin-top: 0.75rem; + font-family: system-ui, sans-serif; + font-size: 0.75rem; + font-style: normal; +} diff --git a/client/src/api/places.test.ts b/client/src/api/places.test.ts new file mode 100644 index 0000000..debc63c --- /dev/null +++ b/client/src/api/places.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fetchAllPlaces, fetchPlaces } from './places' + +const weekdays = [ + 'SUNDAY', + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', +] + +function venue(overrides: Record = {}) { + return { + id: '00000000-0000-0000-0000-000000000001', + name: 'Testpuben', + type: 'PUB', + area: 'Midtbyen', + address: 'Testgata 1', + latitude: 63.43, + longitude: 10.39, + rating: null, + reviewCount: 0, + description: null, + openingHours: weekdays.map((day) => ({ + day, + opens: '16:00', + closes: '23:00', + closed: false, + })), + tags: ['pub'], + websiteUrl: null, + imageUrl: null, + ...overrides, + } +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('GraphQL places API', () => { + it('requests one page and maps nullable backend fields for the UI', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + data: { + venues: { items: [venue()], totalCount: 51 }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const page = await fetchPlaces(10, 10) + + expect(page.totalCount).toBe(51) + expect(page.items[0]).toMatchObject({ + name: 'Testpuben', + type: 'Pub', + rating: 0, + description: 'Ingen beskrivelse tilgjengelig.', + openingHours: '16:00–23:00', + websiteUrl: '', + }) + expect(fetchMock).toHaveBeenCalledOnce() + const [url, options] = fetchMock.mock.calls[0] + expect(url).toBe('/project2/api/graphql') + expect(JSON.parse(options.body)).toMatchObject({ + variables: { offset: 10, limit: 10 }, + }) + }) + + it('reports GraphQL errors to the existing loading and error states', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ errors: [{ message: 'Database nede' }] }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ), + ) + + await expect(fetchPlaces(0, 10)).rejects.toThrow('Database nede') + }) + + it('loads additional pages for views that currently need the full catalog', async () => { + const firstItems = Array.from({ length: 100 }, (_, index) => + venue({ id: `venue-${index}`, name: `Sted ${index}` }), + ) + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { venues: { items: firstItems, totalCount: 101 } }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + venues: { + items: [venue({ id: 'venue-100', name: 'Siste sted' })], + totalCount: 101, + }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const places = await fetchAllPlaces() + + expect(places).toHaveLength(101) + expect(places.at(-1)?.name).toBe('Siste sted') + expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables).toEqual({ + offset: 100, + limit: 100, + }) + }) +}) diff --git a/client/src/api/places.ts b/client/src/api/places.ts index 35789cd..b11767a 100644 --- a/client/src/api/places.ts +++ b/client/src/api/places.ts @@ -1,33 +1,148 @@ -import { mockPlaces } from '../data/mockPlaces' -import type { Place, PlacesPage } from '../types/place' - -/** Simulerer nettverksforsinkelse så lastetilstander kan sees i grensesnittet. */ -const LATENCY_MS = 200 - -/** - * Henter en side med utesteder. - * - * Dette er det eneste stedet appen henter data fra. Når GraphQL-backenden er - * klar, byttes innholdet her ut med et kall mot serveren (med samme signatur), - * og resten av appen trenger ikke endres. - */ +import type { Place, PlacesPage, VenueType } from '../types/place' + +const GRAPHQL_ENDPOINT = '/project2/api/graphql' +const MAX_PAGE_SIZE = 100 + +const VENUES_QUERY = ` + query Venues($offset: Int!, $limit: Int!) { + venues(offset: $offset, limit: $limit) { + totalCount + items { + id + name + type + area + address + latitude + longitude + rating + reviewCount + description + openingHours { + day + opens + closes + closed + } + tags + websiteUrl + imageUrl + } + } + } +` + +type GraphqlOpeningHours = { + day: string + opens: string | null + closes: string | null + closed: boolean +} + +type GraphqlVenue = Omit< + Place, + 'type' | 'openingHours' | 'rating' | 'description' | 'websiteUrl' +> & { + type: string + openingHours: GraphqlOpeningHours[] + rating: number | null + description: string | null + websiteUrl: string | null +} + +type VenuesResponse = { + data?: { + venues: { + items: GraphqlVenue[] + totalCount: number + } + } + errors?: { message: string }[] +} + +const weekdayNames = [ + 'SUNDAY', + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', +] + +function formatOpeningHours(hours: GraphqlOpeningHours[], now = new Date()) { + const today = hours.filter(({ day }) => day === weekdayNames[now.getDay()]) + if (!today.length) return 'Åpningstider ikke oppgitt' + if (today.every(({ closed }) => closed)) return 'Stengt' + + const intervals = today.flatMap(({ opens, closes, closed }) => + !closed && opens && closes ? [`${opens}–${closes}`] : [], + ) + return intervals.length ? intervals.join(', ') : 'Åpningstider ikke oppgitt' +} + +function toVenueType(type: string): VenueType { + const venueTypes: Record = { + BAR: 'Bar', + PUB: 'Pub', + NIGHTCLUB: 'Nattklubb', + } + const venueType = venueTypes[type] + if (!venueType) throw new Error(`Ukjent stedstype fra GraphQL: ${type}`) + return venueType +} + +function toPlace(venue: GraphqlVenue): Place { + return { + ...venue, + type: toVenueType(venue.type), + openingHours: formatOpeningHours(venue.openingHours), + rating: venue.rating ?? 0, + description: venue.description ?? 'Ingen beskrivelse tilgjengelig.', + websiteUrl: venue.websiteUrl ?? '', + } +} + +/** Henter én avgrenset side med utesteder fra vår GraphQL-backend. */ export async function fetchPlaces( offset: number, limit: number, ): Promise { - await new Promise((resolve) => setTimeout(resolve, LATENCY_MS)) + const response = await fetch(GRAPHQL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: VENUES_QUERY, + variables: { offset, limit }, + }), + }) + + if (!response.ok) { + throw new Error(`GraphQL svarte med HTTP ${response.status}.`) + } + + const payload = (await response.json()) as VenuesResponse + if (payload.errors?.length) { + throw new Error(payload.errors[0].message) + } + if (!payload.data) throw new Error('GraphQL-responsen mangler data.') return { - items: mockPlaces.slice(offset, offset + limit), - totalCount: mockPlaces.length, + items: payload.data.venues.items.map(toPlace), + totalCount: payload.data.venues.totalCount, } } -/** Så mange steder hentes når en side trenger hele listen (kart og hjul). */ -const ALL_LIMIT = 500 - -/** Henter alle steder. Byttes ut med egne spørringer når backenden er klar. */ +/** Henter alle sider til visninger som foreløpig trenger hele datasettet. */ export async function fetchAllPlaces(): Promise { - const page = await fetchPlaces(0, ALL_LIMIT) - return page.items + const firstPage = await fetchPlaces(0, MAX_PAGE_SIZE) + const places = [...firstPage.items] + + while (places.length < firstPage.totalCount) { + const page = await fetchPlaces(places.length, MAX_PAGE_SIZE) + if (!page.items.length) break + places.push(...page.items) + } + + return places } diff --git a/client/src/components/FeaturedPlace.tsx b/client/src/components/FeaturedPlace.tsx index 02850cc..2401135 100644 --- a/client/src/components/FeaturedPlace.tsx +++ b/client/src/components/FeaturedPlace.tsx @@ -44,20 +44,28 @@ export function FeaturedPlace({
- - - + {place.websiteUrl && ( + + + + )}

Vurdering: - {place.rating.toFixed(1)}{' '} - ({place.reviewCount}) + {place.reviewCount > 0 ? ( + <> + {place.rating.toFixed(1)}{' '} + ({place.reviewCount}) + + ) : ( + 'Ingen vurderinger' + )}

diff --git a/client/src/components/PageFooter.tsx b/client/src/components/PageFooter.tsx index a02681c..413e982 100644 --- a/client/src/components/PageFooter.tsx +++ b/client/src/components/PageFooter.tsx @@ -36,6 +36,20 @@ export function PageFooter() {
– {author}
+

+ Powered by{' '} + + Geoapify + {' '} + · ©{' '} + + OpenStreetMap contributors + +

) } diff --git a/client/src/components/map/PlaceDetails.tsx b/client/src/components/map/PlaceDetails.tsx index be4c6e7..8ee5c49 100644 --- a/client/src/components/map/PlaceDetails.tsx +++ b/client/src/components/map/PlaceDetails.tsx @@ -53,8 +53,14 @@ export function PlaceDetails({

Vurdering: - {place.rating.toFixed(1)}{' '} - ({place.reviewCount}) + {place.reviewCount > 0 ? ( + <> + {place.rating.toFixed(1)}{' '} + ({place.reviewCount}) + + ) : ( + 'Ingen vurderinger' + )}

@@ -82,15 +88,17 @@ export function PlaceDetails({ ))} - - Besøk nettside - (åpnes i ny fane) - + {place.websiteUrl && ( + + Besøk nettside + (åpnes i ny fane) + + )} ) } diff --git a/client/vite.config.ts b/client/vite.config.ts index 67bdfd7..1c094a4 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -5,6 +5,15 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ base: '/project2/', plugins: [react()], + server: { + proxy: { + '/project2/api/graphql': { + target: 'http://127.0.0.1:3001', + changeOrigin: false, + rewrite: () => '/', + }, + }, + }, test: { environment: 'jsdom', setupFiles: './src/setupTests.ts', diff --git a/docs/venue-model.md b/docs/venue-model.md index 7a6dde4..4a1ec0c 100644 --- a/docs/venue-model.md +++ b/docs/venue-model.md @@ -89,34 +89,38 @@ record excluded; non-null fields must not be filled with invented values. ## API The initial queries are `health`, `databaseStatus`, `venues` and `venue(id: ID!)`. -List results are ordered by name and ID. Unknown or malformed IDs return null. -Search, filtering, sorting arguments and pagination are still separate work; -the current list query should not be presented as a completed scalable API. +List results are ordered by name and ID. `venues` uses offset pagination and +returns the total database count. Limits from 1 to 100 are accepted. Unknown or +malformed IDs return null. Search, filtering and sorting arguments are still +separate work. ```graphql -query { - venues { - id - name - type - city - area - address - latitude - longitude - priceLevel - rating - reviewCount - description - openingHours { - day - opens - closes - closed +query Venues($offset: Int!, $limit: Int!) { + venues(offset: $offset, limit: $limit) { + totalCount + items { + id + name + type + city + area + address + latitude + longitude + priceLevel + rating + reviewCount + description + openingHours { + day + opens + closes + closed + } + tags + imageUrl + websiteUrl } - tags - imageUrl - websiteUrl } } ``` diff --git a/server/README.md b/server/README.md index d2b1e1d..233076a 100644 --- a/server/README.md +++ b/server/README.md @@ -38,7 +38,7 @@ The local `server/.env` then uses: ```env DATABASE_URL=postgresql://tapfinder_app:REPLACE_WITH_PASSWORD@127.0.0.1:5433/tapfinder_dev -PORT=4000 +PORT=3001 HOST=127.0.0.1 GEOAPIFY_API_KEY=REPLACE_WITH_GEOAPIFY_KEY ``` @@ -97,24 +97,27 @@ Pre-existing rows with invalid coordinates must be corrected before this new migration can succeed. `migrate:down` reverses a migration and may remove data; it is not needed for normal setup. -The API listens on `127.0.0.1:4000`. If running on the VM, open a separate API +The API listens on `127.0.0.1:3001`. If running on the VM, open a separate API tunnel from your PC to use the GraphQL Sandbox: ```powershell -ssh -N -L 4000:127.0.0.1:4000 YOUR_VM_USER@it2810-07.idi.ntnu.no +ssh -N -L 3001:127.0.0.1:3001 YOUR_VM_USER@it2810-07.idi.ntnu.no ``` -Open `http://127.0.0.1:4000/` and run: +Open `http://127.0.0.1:3001/` and run: ```graphql query { health databaseStatus - venues { - id - name - type - reviewCount + venues(offset: 0, limit: 10) { + totalCount + items { + id + name + type + reviewCount + } } } ``` diff --git a/server/src/graphql/schema.ts b/server/src/graphql/schema.ts index 380e1ef..025fcf8 100644 --- a/server/src/graphql/schema.ts +++ b/server/src/graphql/schema.ts @@ -1,4 +1,5 @@ import type { SqlExecutor } from '../data/importVenues.js' +import { GraphQLError } from 'graphql' export const typeDefs = ` enum Weekday { @@ -37,6 +38,11 @@ export const typeDefs = ` websiteUrl: String } + type VenuePage { + items: [Venue!]! + totalCount: Int! + } + type Query { """Simple API check that does not require a database query.""" health: String! @@ -44,7 +50,7 @@ export const typeDefs = ` """Checks whether the backend can reach its PostgreSQL database.""" databaseStatus: String! - venues: [Venue!]! + venues(offset: Int = 0, limit: Int = 10): VenuePage! venue(id: ID!): Venue } ` @@ -53,6 +59,27 @@ const venueColumns = `id, name, type, city, area, address, latitude, longitude, price_level AS "priceLevel", rating, review_count AS "reviewCount", description, opening_hours AS "openingHours", tags, image_url AS "imageUrl", website_url AS "websiteUrl"` +type VenuePageArguments = { + offset?: number + limit?: number +} + +function validatePagination({ offset = 0, limit = 10 }: VenuePageArguments) { + if (offset < 0) { + throw new GraphQLError('offset must be zero or greater.', { + extensions: { code: 'BAD_USER_INPUT' }, + }) + } + + if (limit < 1 || limit > 100) { + throw new GraphQLError('limit must be between 1 and 100.', { + extensions: { code: 'BAD_USER_INPUT' }, + }) + } + + return { offset, limit } +} + export const createResolvers = (database: SqlExecutor) => ({ Query: { health: () => 'ok', @@ -60,13 +87,22 @@ export const createResolvers = (database: SqlExecutor) => ({ await database.query('SELECT 1') return 'connected' }, - venues: async () => { + venues: async (_parent: unknown, args: VenuePageArguments) => { + const { offset, limit } = validatePagination(args) + const countResult = await database.query( + 'SELECT COUNT(*)::int AS "totalCount" FROM venues', + ) const { rows } = await database.query( `SELECT ${venueColumns} FROM venues - ORDER BY name, id`, + ORDER BY name, id + LIMIT $1 OFFSET $2`, + [limit, offset], ) - return rows + return { + items: rows, + totalCount: Number(countResult.rows[0]?.totalCount ?? 0), + } }, venue: async (_parent: unknown, args: { id: string }) => { if ( diff --git a/server/src/index.ts b/server/src/index.ts index 9951465..5776b4a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,7 +4,7 @@ import { startStandaloneServer } from '@apollo/server/standalone' import { pool } from './db/pool.js' import { createResolvers, typeDefs } from './graphql/schema.js' -const port = Number(process.env.PORT ?? 4000) +const port = Number(process.env.PORT ?? 3001) const host = process.env.HOST ?? '127.0.0.1' if (!Number.isInteger(port) || port < 1 || port > 65535) { diff --git a/server/tests/database.test.ts b/server/tests/database.test.ts index 42b9577..04ae11d 100644 --- a/server/tests/database.test.ts +++ b/server/tests/database.test.ts @@ -235,7 +235,7 @@ test('GraphQL exposes all 16 fields, multiple daily intervals and null for unkno }), ]) const query = - '{ health databaseStatus venues { id name type city area address latitude longitude priceLevel rating reviewCount description openingHours { day opens closes closed } tags imageUrl websiteUrl } }' + '{ health databaseStatus venues { totalCount items { id name type city area address latitude longitude priceLevel rating reviewCount description openingHours { day opens closes closed } tags imageUrl websiteUrl } } }' const result = await server.executeOperation({ query }) assert.equal(result.body.kind, 'single') if (result.body.kind !== 'single') @@ -244,7 +244,12 @@ test('GraphQL exposes all 16 fields, multiple daily intervals and null for unkno const data = result.body.singleResult.data as Record assert.equal(data.health, 'ok') assert.equal(data.databaseStatus, 'connected') - const venues = data.venues as Record[] + const venuePage = data.venues as { + items: Record[] + totalCount: number + } + assert.equal(venuePage.totalCount, 1) + const venues = venuePage.items assert.equal(Object.keys(venues[0]).length, 16) assert.equal(venues[0].rating, null) assert.equal(venues[0].reviewCount, 0) @@ -273,3 +278,61 @@ test('GraphQL exposes all 16 fields, multiple daily intervals and null for unkno await db.close() } }) + +test('GraphQL paginates venues and rejects unsafe pagination arguments', async () => { + const db = await database() + const server = new ApolloServer({ + typeDefs, + resolvers: createResolvers({ + query: (sql, values) => db.query>(sql, values), + }), + }) + try { + await importVenues(adapter(db), [ + venueFixture({ name: 'Alpha', sourceId: 'openstreetmap:n:1' }), + venueFixture({ name: 'Bravo', sourceId: 'openstreetmap:n:2' }), + venueFixture({ name: 'Charlie', sourceId: 'openstreetmap:n:3' }), + ]) + + const result = await server.executeOperation({ + query: + 'query($offset: Int!, $limit: Int!) { venues(offset: $offset, limit: $limit) { totalCount items { name } } }', + variables: { offset: 1, limit: 1 }, + }) + assert.equal(result.body.kind, 'single') + if (result.body.kind !== 'single') + throw new Error('Expected single response') + assert.equal(result.body.singleResult.errors, undefined) + const page = result.body.singleResult.data?.venues as { + totalCount: number + items: { name: string }[] + } + assert.equal(page.totalCount, 3) + assert.deepEqual( + page.items.map(({ name }) => name), + ['Bravo'], + ) + + for (const variables of [ + { offset: -1, limit: 10 }, + { offset: 0, limit: 0 }, + { offset: 0, limit: 101 }, + ]) { + const invalid = await server.executeOperation({ + query: + 'query($offset: Int!, $limit: Int!) { venues(offset: $offset, limit: $limit) { totalCount } }', + variables, + }) + assert.equal(invalid.body.kind, 'single') + if (invalid.body.kind !== 'single') + throw new Error('Expected single response') + assert.equal( + invalid.body.singleResult.errors?.[0].extensions?.code, + 'BAD_USER_INPUT', + ) + } + } finally { + await server.stop() + await db.close() + } +})