From a4c50a7a23a3cb242155d2f4b18cc86f362e7906 Mon Sep 17 00:00:00 2001 From: Asgeir Jacobsen Date: Thu, 8 Oct 2026 13:23:03 +0200 Subject: [PATCH 1/2] Feat: add sighting queries backed by MySQL Implements the sightings and sighting(id) resolvers so search, sorting, filtering and pagination run in the database instead of the client. Search matches city, shape and comments case-insensitively. Results are paginated with an opaque cursor, and totalCount covers all matches. Adds mysql2 as the database driver. Refs #28 --- backend/package.json | 7 +- backend/src/db/pool.ts | 12 ++++ backend/src/db/sightings.ts | 136 ++++++++++++++++++++++++++++++++++++ backend/src/schema.ts | 9 +++ package-lock.json | 98 +++++++++++++++++++++++++- 5 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 backend/src/db/pool.ts create mode 100644 backend/src/db/sightings.ts diff --git a/backend/package.json b/backend/package.json index 481c446..3a0d6b6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,9 +4,9 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "tsx watch src/index.ts", + "dev": "tsx watch --env-file-if-exists=.env src/index.ts", "build": "tsc -p .", - "start": "node dist/index.js", + "start": "node --env-file-if-exists=.env dist/index.js", "lint": "eslint ." }, "devDependencies": { @@ -21,6 +21,7 @@ }, "dependencies": { "@apollo/server": "^5.5.1", - "graphql": "^16.14.2" + "graphql": "^16.14.2", + "mysql2": "^3.24.5" } } diff --git a/backend/src/db/pool.ts b/backend/src/db/pool.ts new file mode 100644 index 0000000..7f496eb --- /dev/null +++ b/backend/src/db/pool.ts @@ -0,0 +1,12 @@ +import mysql from 'mysql2/promise' + +export const pool = mysql.createPool({ + host: process.env.DB_HOST ?? 'localhost', + port: Number(process.env.DB_PORT) || 3306, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + connectionLimit: 10, + dateStrings: true, + decimalNumbers: true, +}) diff --git a/backend/src/db/sightings.ts b/backend/src/db/sightings.ts new file mode 100644 index 0000000..4904699 --- /dev/null +++ b/backend/src/db/sightings.ts @@ -0,0 +1,136 @@ +import type { RowDataPacket } from 'mysql2/promise' +import { pool } from './pool.ts' + +export interface SightingsArgs { + search?: string | null + filter?: { + shape?: string | null + country?: string | null + state?: string | null + dateFrom?: string | null + dateTo?: string | null + } | null + sort?: keyof typeof ORDER_BY | null + first?: number | null + after?: string | null +} + +const FROM = ` + FROM UFO_SIGHTINGS s + LEFT JOIN LOCATIONS l ON l.LOCATION_ID = s.LOCATION_ID + LEFT JOIN SHAPES sh ON sh.SHAPE_ID = s.SHAPE_ID` + +const SELECT = ` + SELECT + s.UFO_SIGHTING_ID AS id, + s.SIGHTING_DATETIME AS datetime, + NULLIF(l.CITY, '') AS city, + l.STATE AS state, + l.COUNTRY AS country, + sh.NAME AS shape, + s.DURATION_SECONDS AS durationSeconds, + s.COMMENTS AS comments, + s.DATE_POSTED AS datePosted, + s.LATITUDE AS latitude, + s.LONGITUDE AS longitude` + +// Looked up here, never built from user input, so it cannot inject SQL. +// The id breaks ties so pages stay stable when values repeat. +const ORDER_BY = { + DATE_DESC: 's.SIGHTING_DATETIME DESC, s.UFO_SIGHTING_ID DESC', + DATE_ASC: 's.SIGHTING_DATETIME ASC, s.UFO_SIGHTING_ID ASC', + CITY_ASC: 'l.CITY ASC, s.UFO_SIGHTING_ID ASC', + SHAPE_ASC: 'sh.NAME ASC, s.UFO_SIGHTING_ID ASC', +} + +// MySQL gives "2005-01-01 00:30:00"; the API uses ISO 8601 without timezone. +function toNode(row: RowDataPacket) { + return { + ...row, + id: String(row.id), + datetime: row.datetime?.replace(' ', 'T') ?? null, + datePosted: row.datePosted?.replace(' ', 'T') ?? null, + } +} + +function buildWhere({ search, filter }: SightingsArgs) { + const conditions: string[] = [] + const params: string[] = [] + + // The columns use case-insensitive collations, so LIKE ignores case. + if (search?.trim()) { + const pattern = `%${search.trim()}%` + conditions.push('(l.CITY LIKE ? OR sh.NAME LIKE ? OR s.COMMENTS LIKE ?)') + params.push(pattern, pattern, pattern) + } + if (filter?.shape) { + conditions.push('sh.NAME = ?') + params.push(filter.shape) + } + if (filter?.country) { + conditions.push('l.COUNTRY = ?') + params.push(filter.country) + } + if (filter?.state) { + conditions.push('l.STATE = ?') + params.push(filter.state) + } + if (filter?.dateFrom) { + conditions.push('s.SIGHTING_DATETIME >= ?') + params.push(filter.dateFrom) + } + if (filter?.dateTo) { + // dateTo is inclusive, so compare with the start of the next day. + conditions.push('s.SIGHTING_DATETIME < DATE_ADD(?, INTERVAL 1 DAY)') + params.push(filter.dateTo) + } + + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '' + return { where, params } +} + +// The cursor is the offset of the next row, base64 encoded. +const encodeCursor = (offset: number) => + Buffer.from(String(offset)).toString('base64') +const decodeCursor = (cursor: string) => + Number(Buffer.from(cursor, 'base64').toString()) || 0 + +export async function findSightings(args: SightingsArgs) { + const first = Math.min(Math.max(args.first ?? 20, 1), 100) + const offset = args.after ? decodeCursor(args.after) : 0 + const { where, params } = buildWhere(args) + const orderBy = ORDER_BY[args.sort ?? 'DATE_DESC'] + + const [[rows], [[count]]] = await Promise.all([ + pool.query( + `${SELECT} ${FROM} ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`, + [...params, first, offset], + ), + pool.query( + `SELECT COUNT(*) AS total ${FROM} ${where}`, + params, + ), + ]) + + const edges = rows.map((row, i) => ({ + cursor: encodeCursor(offset + i + 1), + node: toNode(row), + })) + + return { + edges, + pageInfo: { + hasNextPage: offset + rows.length < count.total, + endCursor: edges.at(-1)?.cursor ?? null, + }, + totalCount: count.total, + } +} + +export async function findSightingById(id: string) { + const [rows] = await pool.query( + `${SELECT} ${FROM} WHERE s.UFO_SIGHTING_ID = ?`, + [id], + ) + return rows[0] ? toNode(rows[0]) : null +} diff --git a/backend/src/schema.ts b/backend/src/schema.ts index 1ac4e56..bd65513 100644 --- a/backend/src/schema.ts +++ b/backend/src/schema.ts @@ -1,3 +1,9 @@ +import { + findSightingById, + findSightings, + type SightingsArgs, +} from './db/sightings.ts' + export const typeDefs = `#graphql "A single UFO sighting report." type Sighting { @@ -72,5 +78,8 @@ export const typeDefs = `#graphql export const resolvers = { Query: { health: () => 'ok', + sighting: (_parent: unknown, args: { id: string }) => + findSightingById(args.id), + sightings: (_parent: unknown, args: SightingsArgs) => findSightings(args), }, } diff --git a/package-lock.json b/package-lock.json index 9c6db43..3149d6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "version": "0.0.0", "dependencies": { "@apollo/server": "^5.5.1", - "graphql": "^16.14.2" + "graphql": "^16.14.2", + "mysql2": "^3.24.5" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -2143,7 +2144,6 @@ "version": "24.19.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -2601,6 +2601,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/backend": { "resolved": "backend", "link": true @@ -3558,6 +3567,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3867,6 +3885,12 @@ "dev": true, "license": "MIT" }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", @@ -4346,6 +4370,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lru.min": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -4453,6 +4492,45 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mysql2": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.5.tgz", + "integrity": "sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", @@ -5153,6 +5231,21 @@ "node": ">=0.10.0" } }, + "node_modules/sql-escaper": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz", + "integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -5436,7 +5529,6 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { From 2e26ad7d64b1c5deac584a5b0dc97337eff36260 Mon Sep 17 00:00:00 2001 From: Asgeir Jacobsen Date: Thu, 8 Oct 2026 13:24:00 +0200 Subject: [PATCH 2/2] Docs: document backend database setup Adds .env.example and lets dev and start read backend/.env, so everyone can run the backend against the VM database through an SSH tunnel. Describes the setup in the README. --- README.md | 14 ++++++++++++++ backend/.env.example | 5 +++++ 2 files changed, 19 insertions(+) create mode 100644 backend/.env.example diff --git a/README.md b/README.md index f082b1b..b370247 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,20 @@ end tests and API tests as the application grows. ESLint and Prettier keep the code consistent, and every pull request is reviewed by another group member before it is merged into main. +## Running the backend against the VM database + +The backend reads its database settings from `backend/.env`, which is not +committed. To run it locally against the database on the course VM: + +1. Open an SSH tunnel and keep the terminal open. `ssh -L 3306:localhost:3306 @it2810-25.idi.ntnu.no` + (needs NTNU network or VPN). +2. Copy `backend/.env.example` to `backend/.env` and fill in `DB_PASSWORD` and + `DB_NAME` +3. Run `npm run dev:backend`. + +On the VM the same `.env` file is used with `DB_HOST=localhost`, and the +backend is started with `npm run start -w backend`. + ## AI Usage We use AI coding assistants, mainly Claude Code, to generate code, fix bugs and diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..6d006d3 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,5 @@ +DB_HOST=localhost +DB_PORT=3306 +DB_USER=project_2_app +DB_PASSWORD= +DB_NAME=project_2_db