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
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,18 @@ npm run test
frontend/
src/
components/ Reusable UI (Banner, SearchBar, SightingList, SightingMap, BackendStatus)
pages/ Route-level components (SearchPage, SightingDetailPage)
pages/ Route-level components (SearchPage, SightingDetailPage, SubmitSightingPage)
hooks/ Custom hooks (debounce, sighting search, single sighting)
utils/ Pure helpers (filtering, formatting)
types/ Shared TypeScript types
data/ Mock sightings (to be replaced by GraphQL)
graphql/ Apollo Client setup (client.ts) and GraphQL queries (queries.ts)
graphql/ Apollo Client setup (client.ts), queries.ts and mutations.ts
backend/ Node + TypeScript GraphQL server (Apollo Server 5)
src/
index.ts Starts the server on PORT (default 3001)
schema.ts GraphQL type definitions and resolvers
# db/ Planned: MySQL connection and queries
validate-sighting.ts Server-side validation for createSighting
db/ MySQL connection pool and sighting queries
dist/ Build output from `npm run build` (git-ignored)
# docs/
```
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,18 @@ browser.

## GraphQL API Design

The schema is in `backend/src/schema.ts`. It is a proposal: the resolvers and
database connection are not written yet, so only `health` returns data.
The schema is in `backend/src/schema.ts`.

- `sighting(id)` returns one sighting, or null if it does not exist.
- `sightings(search, filter, sort, first, after)` returns a page of sightings
with `totalCount` and `pageInfo`. Search matches city, shape and comments.
Filters are shape, country, state and a date range. Sort is date (newest or
oldest), city or shape. All of it runs in the database.
- `createSighting(input)` stores a user-submitted sighting and returns it with
`userSubmitted: true`. The server checks required fields, text length,
coordinate ranges and that the date is real and not in the future. Invalid
input fails with code `BAD_USER_INPUT` and a `fieldErrors` list, so the form
can show each message next to its field.

**Pagination: cursor based "load more".** With about 80,000 rows, `LIMIT/OFFSET`
gets slower the further you page, while a cursor continues from where the last
Expand Down
4 changes: 4 additions & 0 deletions backend/db-design/create-tables.sql
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ CREATE TABLE IF NOT EXISTS UFO_SIGHTINGS (
LATITUDE DECIMAL(9,6) NULL,
LONGITUDE DECIMAL(9,6) NULL,
CREATED_BY_USER_ID INT NULL,
-- TRUE for rows created through the createSighting mutation, FALSE for
-- rows imported from the NUFORC dataset. Kept separate from
-- CREATED_BY_USER_ID because sightings can be submitted without login.
IS_USER_SUBMITTED BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (UFO_SIGHTING_ID),
KEY IDX_SIGHTINGS_DATETIME (SIGHTING_DATETIME),
KEY IDX_SIGHTINGS_LOCATION (LOCATION_ID),
Expand Down
3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"dev": "tsx watch --env-file-if-exists=.env src/index.ts",
"build": "tsc -p .",
"start": "node --env-file-if-exists=.env dist/index.js",
"lint": "eslint ."
"lint": "eslint .",
"test": "node --test \"src/**/*.test.ts\""
},
"devDependencies": {
"@eslint/js": "^10.0.1",
Expand Down
73 changes: 71 additions & 2 deletions backend/src/db/sightings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { RowDataPacket } from 'mysql2/promise'
import type { ResultSetHeader, RowDataPacket } from 'mysql2/promise'
import type { NewSighting } from '../validate-sighting.ts'
import { pool } from './pool.ts'

export interface SightingsArgs {
Expand Down Expand Up @@ -32,7 +33,8 @@ const SELECT = `
s.COMMENTS AS comments,
s.DATE_POSTED AS datePosted,
s.LATITUDE AS latitude,
s.LONGITUDE AS longitude`
s.LONGITUDE AS longitude,
s.IS_USER_SUBMITTED AS userSubmitted`

// Looked up here, never built from user input, so it cannot inject SQL.
// The id breaks ties so pages stay stable when values repeat.
Expand All @@ -50,6 +52,8 @@ function toNode(row: RowDataPacket) {
id: String(row.id),
datetime: row.datetime?.replace(' ', 'T') ?? null,
datePosted: row.datePosted?.replace(' ', 'T') ?? null,
// BOOLEAN is TINYINT(1) in MySQL, so it arrives as 0 or 1.
userSubmitted: Boolean(row.userSubmitted),
}
}

Expand Down Expand Up @@ -134,3 +138,68 @@ export async function findSightingById(id: string) {
)
return rows[0] ? toNode(rows[0]) : null
}

/**
* Stores a validated user-submitted sighting and returns it as the API
* shows it. Runs in one transaction so a failed insert does not leave a
* new location or shape behind without a sighting.
*/
export async function insertSighting(sighting: NewSighting) {
const connection = await pool.getConnection()
try {
await connection.beginTransaction()

// Matches the import script: STATE and COUNTRY can be NULL, and the
// unique key does not treat NULLs as equal, so look up with <=> first.
const [locations] = await connection.query<RowDataPacket[]>(
`SELECT LOCATION_ID FROM LOCATIONS
WHERE CITY = ? AND STATE <=> ? AND COUNTRY <=> ? LIMIT 1`,
[sighting.city, sighting.state, sighting.country],
)
let locationId: number = locations[0]?.LOCATION_ID
if (locationId === undefined) {
// LAST_INSERT_ID(col) makes insertId the existing id if another
// request created the same location in the meantime.
const [created] = await connection.query<ResultSetHeader>(
`INSERT INTO LOCATIONS (CITY, STATE, COUNTRY) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE LOCATION_ID = LAST_INSERT_ID(LOCATION_ID)`,
[sighting.city, sighting.state, sighting.country],
)
locationId = created.insertId
}

const [shape] = await connection.query<ResultSetHeader>(
`INSERT INTO SHAPES (NAME) VALUES (?)
ON DUPLICATE KEY UPDATE SHAPE_ID = LAST_INSERT_ID(SHAPE_ID)`,
[sighting.shape],
)

const [inserted] = await connection.query<ResultSetHeader>(
`INSERT INTO UFO_SIGHTINGS (SIGHTING_DATETIME, LOCATION_ID, SHAPE_ID,
DURATION_SECONDS, COMMENTS, DATE_POSTED, LATITUDE, LONGITUDE,
IS_USER_SUBMITTED)
VALUES (?, ?, ?, ?, ?, NOW(), ?, ?, TRUE)`,
[
sighting.datetime,
locationId,
shape.insertId,
sighting.durationSeconds,
sighting.comments,
sighting.latitude,
sighting.longitude,
],
)

const [rows] = await connection.query<RowDataPacket[]>(
`${SELECT} ${FROM} WHERE s.UFO_SIGHTING_ID = ?`,
[inserted.insertId],
)
await connection.commit()
return toNode(rows[0])
} catch (error) {
await connection.rollback()
throw error
} finally {
connection.release()
}
}
49 changes: 49 additions & 0 deletions backend/src/schema.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { GraphQLError } from 'graphql'
import {
findSightingById,
findSightings,
insertSighting,
type SightingsArgs,
} from './db/sightings.ts'
import {
validateCreateSighting,
type CreateSightingInput,
} from './validate-sighting.ts'

export const typeDefs = `#graphql
"A single UFO sighting report."
Expand All @@ -19,6 +25,8 @@ export const typeDefs = `#graphql
datePosted: String
latitude: Float
longitude: Float
"True if a user submitted it through the app, false if it is from the NUFORC dataset."
userSubmitted: Boolean!
}
enum SightingSort {
Expand Down Expand Up @@ -57,6 +65,24 @@ export const typeDefs = `#graphql
totalCount: Int!
}
"A new sighting from a user. Validated on the server, see createSighting."
input CreateSightingInput {
"Local time where it was seen, no timezone, e.g. 2005-01-01T00:30. Not in the future."
datetime: String!
city: String!
state: String
country: String!
shape: String!
"1 to 31536000 (one year)."
durationSeconds: Int!
"1-2000 characters."
comments: String!
"-90 to 90."
latitude: Float!
"-180 to 180."
longitude: Float!
}
type Query {
"Returns \\"ok\\" when the server is up."
health: String!
Expand All @@ -73,6 +99,15 @@ export const typeDefs = `#graphql
after: String
): SightingConnection!
}
type Mutation {
"""
Stores a user-submitted sighting and returns it. Invalid input fails
with code BAD_USER_INPUT and a \`fieldErrors\` list of { field, message }
in the error extensions.
"""
createSighting(input: CreateSightingInput!): Sighting!
}
`

export const resolvers = {
Expand All @@ -82,4 +117,18 @@ export const resolvers = {
findSightingById(args.id),
sightings: (_parent: unknown, args: SightingsArgs) => findSightings(args),
},
Mutation: {
createSighting: (
_parent: unknown,
{ input }: { input: CreateSightingInput },
) => {
const result = validateCreateSighting(input)
if (!result.ok) {
throw new GraphQLError('The sighting has invalid fields.', {
extensions: { code: 'BAD_USER_INPUT', fieldErrors: result.errors },
})
}
return insertSighting(result.value)
},
},
}
132 changes: 132 additions & 0 deletions backend/src/validate-sighting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
validateCreateSighting,
type CreateSightingInput,
} from './validate-sighting.ts'

// Fixed clock so "not in the future" does not depend on when tests run.
const now = new Date('2026-06-15T12:00:00Z')

const valid: CreateSightingInput = {
datetime: '2024-03-10T21:15',
city: ' Trondheim ',
state: '',
country: 'NO',
shape: 'Light',
durationSeconds: 120,
comments: 'Bright light hovering over the fjord.',
latitude: 63.43,
longitude: 10.39,
}

function errorFields(input: CreateSightingInput): string[] {
const result = validateCreateSighting(input, now)
return result.ok ? [] : result.errors.map((error) => error.field)
}

describe('validateCreateSighting', () => {
it('accepts valid input and normalizes it', () => {
const result = validateCreateSighting(valid, now)
assert.deepEqual(result, {
ok: true,
value: {
datetime: '2024-03-10T21:15:00',
city: 'trondheim',
state: null,
country: 'no',
shape: 'light',
durationSeconds: 120,
comments: 'Bright light hovering over the fjord.',
latitude: 63.43,
longitude: 10.39,
},
})
})

it('rejects blank required text fields', () => {
assert.deepEqual(
errorFields({
...valid,
city: ' ',
country: '',
shape: '',
comments: '',
}),
['city', 'country', 'shape', 'comments'],
)
})

it('rejects text longer than the database columns', () => {
assert.deepEqual(errorFields({ ...valid, shape: 'x'.repeat(101) }), [
'shape',
])
assert.deepEqual(errorFields({ ...valid, comments: 'x'.repeat(2001) }), [
'comments',
])
})

it('rejects coordinates out of range', () => {
assert.deepEqual(errorFields({ ...valid, latitude: 90.1 }), ['latitude'])
assert.deepEqual(errorFields({ ...valid, longitude: -180.1 }), [
'longitude',
])
})

it('accepts coordinates on the boundary', () => {
assert.deepEqual(
errorFields({ ...valid, latitude: -90, longitude: 180 }),
[],
)
})

it('rejects durations outside 1 second to 1 year', () => {
assert.deepEqual(errorFields({ ...valid, durationSeconds: 0 }), [
'durationSeconds',
])
assert.deepEqual(errorFields({ ...valid, durationSeconds: 31_536_001 }), [
'durationSeconds',
])
})

it('rejects malformed and impossible datetimes', () => {
for (const datetime of [
'2024-03-10',
'2024-03-10 21:15',
'2024-03-10T21:15Z',
'2023-02-29T10:00',
'2024-13-01T10:00',
'2024-03-10T24:00',
]) {
assert.deepEqual(
errorFields({ ...valid, datetime }),
['datetime'],
datetime,
)
}
})

it('rejects datetimes in the future or before 1900', () => {
assert.deepEqual(errorFields({ ...valid, datetime: '2026-06-17T00:00' }), [
'datetime',
])
assert.deepEqual(errorFields({ ...valid, datetime: '1899-12-31T23:59' }), [
'datetime',
])
})

it('allows a local time slightly ahead of the server clock', () => {
// 12:00 UTC is 23:00 in UTC+11, so this is the past for that observer.
assert.deepEqual(
errorFields({ ...valid, datetime: '2026-06-15T23:00' }),
[],
)
})

it('reports every invalid field at once', () => {
assert.deepEqual(
errorFields({ ...valid, city: '', latitude: 200, durationSeconds: -5 }),
['city', 'durationSeconds', 'latitude'],
)
})
})
Loading