From 8de9c4a464e9a8c4fc7303c47c270b2119bec7e4 Mon Sep 17 00:00:00 2001 From: Thor Tveito Date: Thu, 8 Oct 2026 13:03:54 +0200 Subject: [PATCH] Feat: Add createSighting mutation and validation Add createSighting(input: CreateSightingInput!): Sighting! for #37. Input is validated on the server: required fields, text lengths that match the VARCHAR columns, latitude -90..90, longitude -180..180, duration 1 s to 1 year, and a real local datetime from 1900 that is not in the future. All invalid fields are reported at once as a BAD_USER_INPUT error with fieldErrors in the extensions. Add IS_USER_SUBMITTED to UFO_SIGHTINGS, exposed as Sighting.userSubmitted, so user rows can be told apart from NUFORC rows without a login. Add a backend test script (node --test) with unit tests for the validation. TODO: - Store the sighting. insertSighting in src/db/sightings.ts is a stub that throws NOT_IMPLEMENTED until the MySQL layer exists (#31). It should find or create the LOCATIONS and SHAPES rows and insert with DATE_POSTED = NOW() and IS_USER_SUBMITTED = TRUE in one transaction. - Migrate the existing VM database. CREATE TABLE IF NOT EXISTS does not add the new column to a table that already exists, so run ALTER TABLE UFO_SIGHTINGS ADD COLUMN IS_USER_SUBMITTED BOOLEAN NOT NULL DEFAULT FALSE once. - Map IS_USER_SUBMITTED to userSubmitted in the sighting and sightings resolvers when they are written (the field is non-null). - Get group agreement on the schema change: the mutation, the input fields and limits, userSubmitted and the error format. - Connect SubmitSightingPage to the mutation instead of the local mock store. Co-Authored-By: Claude Opus 5.5 --- backend/db-design/create-tables.sql | 4 + backend/package.json | 3 +- backend/src/db/sightings.ts | 33 +++++ backend/src/schema.ts | 50 ++++++++ backend/src/validate-sighting.test.ts | 132 +++++++++++++++++++ backend/src/validate-sighting.ts | 175 ++++++++++++++++++++++++++ backend/tsconfig.json | 4 +- 7 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 backend/src/db/sightings.ts create mode 100644 backend/src/validate-sighting.test.ts create mode 100644 backend/src/validate-sighting.ts diff --git a/backend/db-design/create-tables.sql b/backend/db-design/create-tables.sql index 7904cdc..3653143 100644 --- a/backend/db-design/create-tables.sql +++ b/backend/db-design/create-tables.sql @@ -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), diff --git a/backend/package.json b/backend/package.json index 481c446..9411a9f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,8 @@ "dev": "tsx watch src/index.ts", "build": "tsc -p .", "start": "node dist/index.js", - "lint": "eslint ." + "lint": "eslint .", + "test": "node --test \"src/**/*.test.ts\"" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/backend/src/db/sightings.ts b/backend/src/db/sightings.ts new file mode 100644 index 0000000..9cc511f --- /dev/null +++ b/backend/src/db/sightings.ts @@ -0,0 +1,33 @@ +import { GraphQLError } from 'graphql' +import type { NewSighting } from '../validate-sighting.ts' + +/** A row as returned to GraphQL. Mirrors the `Sighting` type in schema.ts. */ +export interface Sighting { + id: string + datetime: string | null + city: string | null + state: string | null + country: string | null + shape: string | null + durationSeconds: number | null + comments: string | null + datePosted: string | null + latitude: number | null + longitude: number | null + userSubmitted: boolean +} + +/** + * Stores a validated sighting with IS_USER_SUBMITTED = TRUE and returns it. + * + * TODO(#31): the backend has no MySQL connection yet. Once the DB layer + * exists this should, in one transaction, find or create the LOCATIONS and + * SHAPES rows, insert into UFO_SIGHTINGS with DATE_POSTED = NOW() and + * IS_USER_SUBMITTED = TRUE, and return the new row. + */ +export async function insertSighting(sighting: NewSighting): Promise { + void sighting + throw new GraphQLError('Saving sightings is not available yet.', { + extensions: { code: 'NOT_IMPLEMENTED' }, + }) +} diff --git a/backend/src/schema.ts b/backend/src/schema.ts index 1ac4e56..a94d84e 100644 --- a/backend/src/schema.ts +++ b/backend/src/schema.ts @@ -1,3 +1,10 @@ +import { GraphQLError } from 'graphql' +import { insertSighting } from './db/sightings.ts' +import { + validateCreateSighting, + type CreateSightingInput, +} from './validate-sighting.ts' + export const typeDefs = `#graphql "A single UFO sighting report." type Sighting { @@ -13,6 +20,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 { @@ -51,6 +60,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! @@ -67,10 +94,33 @@ 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 = { Query: { health: () => 'ok', }, + 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) + }, + }, } diff --git a/backend/src/validate-sighting.test.ts b/backend/src/validate-sighting.test.ts new file mode 100644 index 0000000..528c58c --- /dev/null +++ b/backend/src/validate-sighting.test.ts @@ -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'], + ) + }) +}) diff --git a/backend/src/validate-sighting.ts b/backend/src/validate-sighting.ts new file mode 100644 index 0000000..908ac75 --- /dev/null +++ b/backend/src/validate-sighting.ts @@ -0,0 +1,175 @@ +/** Arguments of the `createSighting` mutation, as GraphQL delivers them. */ +export interface CreateSightingInput { + datetime: string + city: string + state?: string | null + country: string + shape: string + durationSeconds: number + comments: string + latitude: number + longitude: number +} + +/** Input that passed validation, trimmed and ready to store. */ +export interface NewSighting { + datetime: string + city: string + state: string | null + country: string + shape: string + durationSeconds: number + comments: string + latitude: number + longitude: number +} + +export interface FieldError { + field: keyof CreateSightingInput + message: string +} + +export type ValidationResult = + { ok: true; value: NewSighting } | { ok: false; errors: FieldError[] } + +// Text limits match the VARCHAR sizes in db-design/create-tables.sql, so +// valid input never fails on insert. +const MAX_PLACE_LENGTH = 255 +const MAX_SHAPE_LENGTH = 100 +const MAX_COMMENTS_LENGTH = 2000 +const MAX_DURATION_SECONDS = 365 * 24 * 60 * 60 +const EARLIEST_YEAR = 1900 +// The datetime is local to the observer and has no timezone, so "now" +// can be up to UTC+14 ahead of the server clock. +const MAX_TIMEZONE_AHEAD_MS = 14 * 60 * 60 * 1000 + +const LOCAL_DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/ + +/** + * Checks a user-submitted sighting and collects every problem at once, so + * the form can show all field errors together instead of one per submit. + */ +export function validateCreateSighting( + input: CreateSightingInput, + now: Date = new Date(), +): ValidationResult { + const errors: FieldError[] = [] + + const requireText = ( + field: 'city' | 'country' | 'shape' | 'comments', + maxLength: number, + ): string => { + const value = input[field].trim() + if (value === '') { + errors.push({ field, message: `${field} is required.` }) + } else if (value.length > maxLength) { + errors.push({ + field, + message: `${field} must be at most ${maxLength} characters.`, + }) + } + return value + } + + const datetime = normalizeDatetime(input.datetime, now) + if (typeof datetime !== 'string') { + errors.push({ field: 'datetime', message: datetime.error }) + } + + const city = requireText('city', MAX_PLACE_LENGTH) + const country = requireText('country', MAX_PLACE_LENGTH) + // Shapes in the dataset are lowercase. Matching that keeps one row per + // shape in the SHAPES table. + const shape = requireText('shape', MAX_SHAPE_LENGTH).toLowerCase() + const comments = requireText('comments', MAX_COMMENTS_LENGTH) + + // An empty state is treated as "no state", the same as in the imported data. + const state = input.state?.trim() || null + if (state !== null && state.length > MAX_PLACE_LENGTH) { + errors.push({ + field: 'state', + message: `state must be at most ${MAX_PLACE_LENGTH} characters.`, + }) + } + + if ( + input.durationSeconds < 1 || + input.durationSeconds > MAX_DURATION_SECONDS + ) { + errors.push({ + field: 'durationSeconds', + message: `durationSeconds must be between 1 and ${MAX_DURATION_SECONDS}.`, + }) + } + + if (!(input.latitude >= -90 && input.latitude <= 90)) { + errors.push({ + field: 'latitude', + message: 'latitude must be between -90 and 90.', + }) + } + if (!(input.longitude >= -180 && input.longitude <= 180)) { + errors.push({ + field: 'longitude', + message: 'longitude must be between -180 and 180.', + }) + } + + if (errors.length > 0 || typeof datetime !== 'string') { + return { ok: false, errors } + } + + return { + ok: true, + value: { + datetime, + city, + state, + country, + shape, + durationSeconds: input.durationSeconds, + comments, + latitude: input.latitude, + longitude: input.longitude, + }, + } +} + +/** + * Returns the datetime with seconds filled in, so all stored values share + * the `Sighting.datetime` format, or an error message. + */ +function normalizeDatetime(raw: string, now: Date): string | { error: string } { + const match = LOCAL_DATETIME.exec(raw.trim()) + if (!match) { + return { error: 'datetime must look like 2005-01-01T00:30 (local time).' } + } + + const [, year, month, day, hour, minute, second = '00'] = match + const parts = [year, month, day, hour, minute, second].map(Number) + const [y, mo, d, h, mi, s] = parts + + // Date.UTC rolls invalid values over (Feb 30 becomes Mar 2), so a + // round trip that changes any part means the input was not a real time. + const asUtc = new Date(Date.UTC(y, mo - 1, d, h, mi, s)) + const roundTrip = [ + asUtc.getUTCFullYear(), + asUtc.getUTCMonth() + 1, + asUtc.getUTCDate(), + asUtc.getUTCHours(), + asUtc.getUTCMinutes(), + asUtc.getUTCSeconds(), + ] + if (roundTrip.some((value, i) => value !== parts[i])) { + return { error: 'datetime is not a valid date and time.' } + } + + if (y < EARLIEST_YEAR) { + return { error: `datetime must be in ${EARLIEST_YEAR} or later.` } + } + if (asUtc.getTime() > now.getTime() + MAX_TIMEZONE_AHEAD_MS) { + return { error: 'datetime cannot be in the future.' } + } + + return `${year}-${month}-${day}T${hour}:${minute}:${second}` +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 910da94..209d204 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -24,5 +24,7 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + /* Tests run straight from source with `node --test`, not from dist/ */ + "exclude": ["src/**/*.test.ts"] }