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 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": {