Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <user>@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
Expand Down
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
DB_HOST=localhost
DB_PORT=3306
DB_USER=project_2_app
DB_PASSWORD=
DB_NAME=project_2_db
7 changes: 4 additions & 3 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -21,6 +21,7 @@
},
"dependencies": {
"@apollo/server": "^5.5.1",
"graphql": "^16.14.2"
"graphql": "^16.14.2",
"mysql2": "^3.24.5"
}
}
12 changes: 12 additions & 0 deletions backend/src/db/pool.ts
Original file line number Diff line number Diff line change
@@ -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,
})
136 changes: 136 additions & 0 deletions backend/src/db/sightings.ts
Original file line number Diff line number Diff line change
@@ -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<RowDataPacket[]>(
`${SELECT} ${FROM} ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
[...params, first, offset],
),
pool.query<RowDataPacket[]>(
`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<RowDataPacket[]>(
`${SELECT} ${FROM} WHERE s.UFO_SIGHTING_ID = ?`,
[id],
)
return rows[0] ? toNode(rows[0]) : null
}
9 changes: 9 additions & 0 deletions backend/src/schema.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import {
findSightingById,
findSightings,
type SightingsArgs,
} from './db/sightings.ts'

export const typeDefs = `#graphql
"A single UFO sighting report."
type Sighting {
Expand Down Expand Up @@ -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),
},
}
98 changes: 95 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.