From 81abef003191895786f97ab9eb9b19201c4a3526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bryd=C3=B8y=20Savland?= Date: Tue, 13 Oct 2026 18:05:31 +0200 Subject: [PATCH] feat: add venue schema and Geoapify seed import --- .env.example | 2 +- server/README.md | 105 ++++++-- server/data/venues.json | 1 + .../migrations/1791904375317_create-venues.js | 77 ++++++ server/package.json | 3 +- server/scripts/seed-venues.ts | 228 ++++++++++++++++++ server/src/graphql/schema.ts | 55 +++++ 7 files changed, 455 insertions(+), 16 deletions(-) create mode 100644 server/data/venues.json create mode 100644 server/migrations/1791904375317_create-venues.js create mode 100644 server/scripts/seed-venues.ts diff --git a/.env.example b/.env.example index ca2e148..8b5de34 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # VM defaults: copy to server/.env on the VM and replace the password placeholder. # 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 +DATABASE_URL=postgresql://tapfinder_app:REPLACE_WITH_PASSWORD@localhost:5432/tapfinder_dev PORT=4000 # Keep the API local until a reverse proxy (for example, Nginx) is configured. HOST=127.0.0.1 diff --git a/server/README.md b/server/README.md index 5b9ba5f..577e991 100644 --- a/server/README.md +++ b/server/README.md @@ -1,9 +1,9 @@ # Backend og database Backenden er et TypeScript-prosjekt med Apollo Server (GraphQL) og PostgreSQL. -Målet er at både backend og PostgreSQL kjører direkte på prosjektets VM, uten -Docker. Backendoppsettet er foreløpig et skjelett med helsesjekker; Tapfinders -domene-API og tabeller kommer når gruppa har bestemt datamodellen. +Både backend og PostgreSQL kjøres direkte på prosjektets VM, uten Docker. +Dette oppsettet inneholder første venue-skjema, GraphQL-lesespørringer og et +idempotent importscript for et kuratert Geoapify-datasett. ## Krav og npm-oppsett @@ -25,10 +25,12 @@ lockfila med `npm --prefix server install`, og `package.json` og etter at ny kode er hentet inn. Dette skjer ikke automatisk ved Git-push før automatisk utrulling er satt opp. -## Database på VM-en +## Utviklingsdatabase på VM-en Kjør dette på VM-en der PostgreSQL er installert. Opprett applikasjonsrollen -uten å skrive passordet inn i SQL-kommandoen: +uten å skrive passordet inn i SQL-kommandoen. Hvis `tapfinder_app` allerede +finnes fra det tidligere databaseoppsettet, behold rollen og hopp over +`CREATE ROLE` og `\password`: ```bash sudo -u postgres psql @@ -39,7 +41,7 @@ I `psql`: ```sql CREATE ROLE tapfinder_app LOGIN; \password tapfinder_app -CREATE DATABASE tapfinder OWNER tapfinder_app; +CREATE DATABASE tapfinder_dev OWNER tapfinder_app; \q ``` @@ -48,16 +50,17 @@ localhost på VM-en. Ikke åpne port 5432 mot internett. ## Miljøvariabler -Backend leser tilkoblingen fra `DATABASE_URL`. `.env.example` i repo-roten er en -mal uten hemmeligheter. På VM-en kopierer du den til `server/.env` og legger inn -det faktiske databasepassordet der. `server/.env` er ignorert av Git og må ikke -committes eller deles. +Backend og migreringsverktøy leser tilkoblingen fra `DATABASE_URL`. `.env.example` +i repo-roten er en mal uten hemmeligheter. På VM-en kopierer du den til +`server/.env` og legger inn det faktiske databasepassordet der. `server/.env` er +ignorert av Git og må ikke committes. Del passordet med gruppa gjennom en avtalt +privat kanal, ikke Git. På VM-en, der backend og PostgreSQL kjører på samme maskin, skal adressen bruke `localhost` og port `5432`: ```env -DATABASE_URL=postgresql://tapfinder_app:PASSORD@127.0.0.1:5432/tapfinder +DATABASE_URL=postgresql://tapfinder_app:PASSORD@127.0.0.1:5432/tapfinder_dev PORT=4000 HOST=127.0.0.1 ``` @@ -129,11 +132,85 @@ npm --prefix server run migrate:create -- add-your-change-name ``` Review migrasjonen sammen med backendendringen i pull request. Før migrasjoner -kjøres mot VM-databasen, ta backup og sørg for at riktig `DATABASE_URL` er satt -i VM-ens `server/.env`. Kjør deretter: +kjøres på VM-en, kontroller at VM-ens `server/.env` peker til `tapfinder_dev`. +Før migrering av en database med viktige data: ta backup. Kjør deretter: ```bash npm --prefix server run migrate:up ``` -Ikke kjør migrasjoner mot VM-databasen før endringen er godkjent. +Migreringen `create_venues` oppretter `venues` og `venue_sources`. Interne venue- +ID-er lages av databasen. Geoapify-ID-en lagres i `venue_sources`, med unik +kombinasjon av leverandør og ekstern ID, slik at importen kan kjøres flere ganger +uten duplikater. + +## Venue-felter og Geoapify-import + +Første versjon inneholder `id`, `name`, `type`, `city`, `area`, `address`, +`latitude`, `longitude`, `openingHours`, `tags` og `websiteUrl`. Image, beskrivelse, +prisnivå, vurdering og antall anmeldelser er utelatt inntil dere har datakilder +for dem. `websiteUrl` kan være `null`. `openingHours` og `tags` er alltid lister; +ukjente verdier representeres som `[]`. + +Importer bare data dere har tillatelse til å lagre og distribuere. Behold +nødvendig Geoapify/OpenStreetMap-attribusjon i produktet og kontroller gjeldende +vilkår før dere committer et datasett til Git. + +Seed-scriptet leser `server/data/venues.json`. Filen er opprettet som en tom liste +og skal fylles med normaliserte poster. Format: + +```json +[ + { + "sourceId": "Geoapify- eller OSM-ID", + "name": "Eksempelbar", + "type": "BAR", + "city": "Trondheim", + "area": "Sentrum", + "address": "Eksempelveien 1", + "latitude": 63.4305, + "longitude": 10.3951, + "openingHours": [ + { "day": "FRIDAY", "opens": "18:00", "closes": "02:00", "closed": false } + ], + "tags": ["bar"], + "websiteUrl": null + } +] +``` + +Dette er kun et format-eksempel, ikke et virkelig sted. `sourceId` må være stabil +og unik hos Geoapify/OSM. `openingHours` må konverteres fra Geoapifys +åpningstidstekst til ukedag/intervallformat før import; bruk `[]` hvis data +mangler eller ikke er korrekt tolket. `tags` skal være deres normaliserte tagger. +Fyll ut `server/data/venues.json` med de verifiserte postene før seed-kjøring. + +Kjør importen fra repoets rot på VM-en etter at migreringen er på plass: + +```bash +npm --prefix server run seed:venues +``` + +Scriptet avviser andre databaser enn `tapfinder_dev`, validerer feltene og gjør +alle endringene i én transaksjon. Kjør det gjerne igjen etter at datasettet er +oppdatert; samme Geoapify-ID oppdaterer samme venue. + +GraphQL Sandbox kan kontrollere dataene med: + +```graphql +query { + venues { + id + name + type + city + area + address + latitude + longitude + openingHours { day opens closes closed } + tags + websiteUrl + } +} +``` diff --git a/server/data/venues.json b/server/data/venues.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/server/data/venues.json @@ -0,0 +1 @@ +[] diff --git a/server/migrations/1791904375317_create-venues.js b/server/migrations/1791904375317_create-venues.js new file mode 100644 index 0000000..1e1cfa2 --- /dev/null +++ b/server/migrations/1791904375317_create-venues.js @@ -0,0 +1,77 @@ +/** + * @type {import('node-pg-migrate').ColumnDefinitions | undefined} + */ +export const shorthands = undefined; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +export const up = (pgm) => { + pgm.createTable('venues', { + id: { + type: 'uuid', + primaryKey: true, + default: pgm.func('gen_random_uuid()'), + }, + name: { type: 'text', notNull: true }, + type: { type: 'text', notNull: true }, + city: { type: 'text', notNull: true }, + area: { type: 'text', notNull: true }, + address: { type: 'text', notNull: true }, + latitude: { type: 'double precision', notNull: true }, + longitude: { type: 'double precision', notNull: true }, + opening_hours: { + type: 'jsonb', + notNull: true, + default: pgm.func("'[]'::jsonb"), + }, + tags: { type: 'text[]', notNull: true, default: pgm.func("'{}'::text[]") }, + website_url: { type: 'text' }, + created_at: { + type: 'timestamptz', + notNull: true, + default: pgm.func('current_timestamp'), + }, + updated_at: { + type: 'timestamptz', + notNull: true, + default: pgm.func('current_timestamp'), + }, + }); + + pgm.createTable('venue_sources', { + id: { type: 'bigserial', primaryKey: true }, + venue_id: { + type: 'uuid', + notNull: true, + references: 'venues', + onDelete: 'CASCADE', + }, + provider: { type: 'text', notNull: true }, + external_id: { type: 'text', notNull: true }, + fetched_at: { + type: 'timestamptz', + notNull: true, + default: pgm.func('current_timestamp'), + }, + raw_data: { type: 'jsonb' }, + }); + + pgm.addConstraint('venue_sources', 'venue_sources_provider_external_id_unique', { + unique: ['provider', 'external_id'], + }); + pgm.createIndex('venues', 'city', { name: 'venues_city_idx' }); + pgm.createIndex('venues', 'type', { name: 'venues_type_idx' }); +}; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +export const down = (pgm) => { + pgm.dropTable('venue_sources'); + pgm.dropTable('venues'); +}; \ No newline at end of file diff --git a/server/package.json b/server/package.json index 04a6f92..8bcdf9a 100644 --- a/server/package.json +++ b/server/package.json @@ -8,7 +8,8 @@ "start": "node dist/index.js", "migrate:create": "node-pg-migrate create", "migrate:up": "node-pg-migrate up", - "migrate:down": "node-pg-migrate down" + "migrate:down": "node-pg-migrate down", + "seed:venues": "tsx scripts/seed-venues.ts" }, "dependencies": { "@apollo/server": "^5.0.0", diff --git a/server/scripts/seed-venues.ts b/server/scripts/seed-venues.ts new file mode 100644 index 0000000..201ca18 --- /dev/null +++ b/server/scripts/seed-venues.ts @@ -0,0 +1,228 @@ +import 'dotenv/config'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pool } from '../src/db/pool.js'; + +type Weekday = + | 'MONDAY' + | 'TUESDAY' + | 'WEDNESDAY' + | 'THURSDAY' + | 'FRIDAY' + | 'SATURDAY' + | 'SUNDAY'; + +type VenueSeed = { + sourceId: string; + name: string; + type: 'BAR' | 'PUB' | 'NIGHTCLUB'; + city: string; + area: string; + address: string; + latitude: number; + longitude: number; + openingHours: { + day: Weekday; + opens: string | null; + closes: string | null; + closed: boolean; + }[]; + tags: string[]; + websiteUrl: string | null; +}; + +const weekdays = new Set([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', +]); + +function validateVenue(value: unknown, index: number): VenueSeed { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`venues[${index}] must be an object`); + } + + const venue = value as Record; + const requiredStrings = [ + 'sourceId', + 'name', + 'type', + 'city', + 'area', + 'address', + ] as const; + + for (const key of requiredStrings) { + if (typeof venue[key] !== 'string' || venue[key].trim() === '') { + throw new Error(`venues[${index}].${key} must be a non-empty string`); + } + } + + if (!['BAR', 'PUB', 'NIGHTCLUB'].includes(venue.type as string)) { + throw new Error(`venues[${index}].type must be BAR, PUB, or NIGHTCLUB`); + } + if (typeof venue.latitude !== 'number' || !Number.isFinite(venue.latitude)) { + throw new Error(`venues[${index}].latitude must be a finite number`); + } + if (typeof venue.longitude !== 'number' || !Number.isFinite(venue.longitude)) { + throw new Error(`venues[${index}].longitude must be a finite number`); + } + if (venue.websiteUrl !== null && typeof venue.websiteUrl !== 'string') { + throw new Error(`venues[${index}].websiteUrl must be a URL string or null`); + } + if (!Array.isArray(venue.tags) || !venue.tags.every((tag) => typeof tag === 'string')) { + throw new Error(`venues[${index}].tags must be an array of strings`); + } + if (!Array.isArray(venue.openingHours)) { + throw new Error(`venues[${index}].openingHours must be an array (use [] when unknown)`); + } + + const openingHours = venue.openingHours.map((hours, hoursIndex) => { + if (!hours || typeof hours !== 'object' || Array.isArray(hours)) { + throw new Error(`venues[${index}].openingHours[${hoursIndex}] must be an object`); + } + const item = hours as Record; + if (!weekdays.has(item.day as Weekday)) { + throw new Error(`venues[${index}].openingHours[${hoursIndex}].day is invalid`); + } + if (item.opens !== null && typeof item.opens !== 'string') { + throw new Error(`venues[${index}].openingHours[${hoursIndex}].opens must be a string or null`); + } + if (item.closes !== null && typeof item.closes !== 'string') { + throw new Error(`venues[${index}].openingHours[${hoursIndex}].closes must be a string or null`); + } + if (typeof item.closed !== 'boolean') { + throw new Error(`venues[${index}].openingHours[${hoursIndex}].closed must be boolean`); + } + return { + day: item.day as Weekday, + opens: item.opens as string | null, + closes: item.closes as string | null, + closed: item.closed, + }; + }); + + return { + sourceId: venue.sourceId as string, + name: venue.name as string, + type: venue.type as VenueSeed['type'], + city: venue.city as string, + area: venue.area as string, + address: venue.address as string, + latitude: venue.latitude, + longitude: venue.longitude, + openingHours, + tags: venue.tags as string[], + websiteUrl: venue.websiteUrl as string | null, + }; +} + +async function seed() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl || new URL(databaseUrl).pathname !== '/tapfinder_dev') { + throw new Error( + 'Refusing to seed: DATABASE_URL must point to the tapfinder_dev database.', + ); + } + + const inputPath = resolve(process.cwd(), 'data/venues.json'); + const parsed: unknown = JSON.parse(await readFile(inputPath, 'utf8')); + if (!Array.isArray(parsed)) { + throw new Error(`${inputPath} must contain a JSON array`); + } + + const venues = parsed.map(validateVenue); + if (venues.length === 0) { + console.log('No venues in data/venues.json; nothing to import.'); + return; + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + for (const venue of venues) { + const source = await client.query<{ venue_id: string }>( + `SELECT venue_id + FROM venue_sources + WHERE provider = 'geoapify' AND external_id = $1 + FOR UPDATE`, + [venue.sourceId], + ); + + let venueId = source.rows[0]?.venue_id; + if (venueId) { + await client.query( + `UPDATE venues + SET name = $2, type = $3, city = $4, area = $5, address = $6, + latitude = $7, longitude = $8, opening_hours = $9::jsonb, + tags = $10, website_url = $11, updated_at = NOW() + WHERE id = $1`, + [ + venueId, + venue.name, + venue.type, + venue.city, + venue.area, + venue.address, + venue.latitude, + venue.longitude, + JSON.stringify(venue.openingHours), + venue.tags, + venue.websiteUrl, + ], + ); + await client.query( + `UPDATE venue_sources SET fetched_at = NOW() + WHERE provider = 'geoapify' AND external_id = $1`, + [venue.sourceId], + ); + } else { + const inserted = await client.query<{ id: string }>( + `INSERT INTO venues + (name, type, city, area, address, latitude, longitude, + opening_hours, tags, website_url) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10) + RETURNING id`, + [ + venue.name, + venue.type, + venue.city, + venue.area, + venue.address, + venue.latitude, + venue.longitude, + JSON.stringify(venue.openingHours), + venue.tags, + venue.websiteUrl, + ], + ); + venueId = inserted.rows[0].id; + await client.query( + `INSERT INTO venue_sources (venue_id, provider, external_id) + VALUES ($1, 'geoapify', $2)`, + [venueId, venue.sourceId], + ); + } + } + await client.query('COMMIT'); + console.log(`Imported or updated ${venues.length} venue(s).`); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +} + +try { + await seed(); +} catch (error) { + console.error('Venue import failed:', error); + process.exitCode = 1; +} finally { + await pool.end(); +} diff --git a/server/src/graphql/schema.ts b/server/src/graphql/schema.ts index c68fa36..f2aec66 100644 --- a/server/src/graphql/schema.ts +++ b/server/src/graphql/schema.ts @@ -1,12 +1,46 @@ import { pool } from '../db/pool.js'; export const typeDefs = ` + enum Weekday { + MONDAY + TUESDAY + WEDNESDAY + THURSDAY + FRIDAY + SATURDAY + SUNDAY + } + + type OpeningHours { + day: Weekday! + opens: String + closes: String + closed: Boolean! + } + + type Venue { + id: ID! + name: String! + type: String! + city: String! + area: String! + address: String! + latitude: Float! + longitude: Float! + openingHours: [OpeningHours!]! + tags: [String!]! + websiteUrl: String + } + type Query { """Simple API check that does not require a database query.""" health: String! """Checks whether the backend can reach its PostgreSQL database.""" databaseStatus: String! + + venues: [Venue!]! + venue(id: ID!): Venue } `; @@ -17,5 +51,26 @@ export const resolvers = { await pool.query('SELECT 1'); return 'connected'; }, + venues: async () => { + const { rows } = await pool.query( + `SELECT id, name, type, city, area, address, latitude, longitude, + opening_hours AS "openingHours", tags, + website_url AS "websiteUrl" + FROM venues + ORDER BY name, id`, + ); + return rows; + }, + venue: async (_parent: unknown, args: { id: string }) => { + const { rows } = await pool.query( + `SELECT id, name, type, city, area, address, latitude, longitude, + opening_hours AS "openingHours", tags, + website_url AS "websiteUrl" + FROM venues + WHERE id = $1`, + [args.id], + ); + return rows[0] ?? null; + }, }, };