diff --git a/AGENTS.md b/AGENTS.md
index 5a1a893..6e2887f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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/
```
diff --git a/README.md b/README.md
index b370247..855c55d 100644
--- a/README.md
+++ b/README.md
@@ -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
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 3a0d6b6..4501772 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -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",
diff --git a/backend/src/db/sightings.ts b/backend/src/db/sightings.ts
index 4904699..ca259a7 100644
--- a/backend/src/db/sightings.ts
+++ b/backend/src/db/sightings.ts
@@ -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 {
@@ -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.
@@ -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),
}
}
@@ -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(
+ `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(
+ `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(
+ `INSERT INTO SHAPES (NAME) VALUES (?)
+ ON DUPLICATE KEY UPDATE SHAPE_ID = LAST_INSERT_ID(SHAPE_ID)`,
+ [sighting.shape],
+ )
+
+ const [inserted] = await connection.query(
+ `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(
+ `${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()
+ }
+}
diff --git a/backend/src/schema.ts b/backend/src/schema.ts
index bd65513..85de207 100644
--- a/backend/src/schema.ts
+++ b/backend/src/schema.ts
@@ -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."
@@ -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 {
@@ -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!
@@ -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 = {
@@ -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)
+ },
+ },
}
diff --git a/backend/src/validate-sighting.test.ts b/backend/src/validate-sighting.test.ts
new file mode 100644
index 0000000..db8be62
--- /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..583b9bf
--- /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 })
+ }
+
+ // The import script stores places and shapes in lowercase. Doing the
+ // same keeps submitted rows consistent with the dataset in the API.
+ const city = requireText('city', MAX_PLACE_LENGTH).toLowerCase()
+ const country = requireText('country', MAX_PLACE_LENGTH).toLowerCase()
+ 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().toLowerCase() || 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"]
}
diff --git a/frontend/src/data/sighting-store.ts b/frontend/src/data/sighting-store.ts
index af5128c..7b8c35b 100644
--- a/frontend/src/data/sighting-store.ts
+++ b/frontend/src/data/sighting-store.ts
@@ -13,13 +13,12 @@ export function subscribeToSightings(listener: () => void): () => void {
return () => listeners.delete(listener)
}
-export function addSighting(sighting: Omit) {
- const newSighting: UfoSighting = {
- ...sighting,
- id: crypto.randomUUID(),
- datePosted: new Date().toISOString().slice(0, 10),
- }
- sightings = [newSighting, ...sightings]
+/**
+ * Adds a sighting the backend has already stored. The list and detail
+ * pages still read from this store, so without it a new sighting would
+ * not show up until those pages fetch from GraphQL.
+ */
+export function addSighting(sighting: UfoSighting) {
+ sightings = [sighting, ...sightings]
listeners.forEach((listener) => listener())
- return newSighting
}
diff --git a/frontend/src/graphql/mutations.ts b/frontend/src/graphql/mutations.ts
new file mode 100644
index 0000000..ee4676f
--- /dev/null
+++ b/frontend/src/graphql/mutations.ts
@@ -0,0 +1,68 @@
+import { gql, type TypedDocumentNode } from '@apollo/client'
+
+/** Matches `CreateSightingInput` in backend/src/schema.ts. */
+export interface CreateSightingInput {
+ /** Local time without timezone, as a datetime-local input gives it. */
+ datetime: string
+ city: string
+ state: string | null
+ country: string
+ shape: string
+ durationSeconds: number
+ comments: string
+ latitude: number
+ longitude: number
+}
+
+export interface CreatedSighting {
+ 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
+}
+
+interface CreateSightingData {
+ createSighting: CreatedSighting
+}
+
+interface CreateSightingVariables {
+ input: CreateSightingInput
+}
+
+/** One entry of `extensions.fieldErrors` on a BAD_USER_INPUT error. */
+export interface SightingFieldError {
+ field: keyof CreateSightingInput
+ message: string
+}
+
+/**
+ * Stores a user-submitted sighting. The server validates the input again
+ * and rejects it with BAD_USER_INPUT and `fieldErrors` if anything is off.
+ */
+export const CREATE_SIGHTING_MUTATION: TypedDocumentNode<
+ CreateSightingData,
+ CreateSightingVariables
+> = gql`
+ mutation CreateSighting($input: CreateSightingInput!) {
+ createSighting(input: $input) {
+ id
+ datetime
+ city
+ state
+ country
+ shape
+ durationSeconds
+ comments
+ datePosted
+ latitude
+ longitude
+ }
+ }
+`
diff --git a/frontend/src/pages/SubmitSightingPage.tsx b/frontend/src/pages/SubmitSightingPage.tsx
index 09bb19b..920dfe8 100644
--- a/frontend/src/pages/SubmitSightingPage.tsx
+++ b/frontend/src/pages/SubmitSightingPage.tsx
@@ -1,6 +1,13 @@
+import { CombinedGraphQLErrors } from '@apollo/client'
+import { useMutation } from '@apollo/client/react'
import { useState, type FormEvent } from 'react'
import { Link } from 'react-router'
import { addSighting } from '../data/sighting-store'
+import {
+ CREATE_SIGHTING_MUTATION,
+ type CreatedSighting,
+ type SightingFieldError,
+} from '../graphql/mutations'
import LocationPicker from '../components/LocationPicker'
import './SubmitSightingPage.css'
@@ -40,8 +47,8 @@ function validate(values: FormValues): FormErrors {
if (!values.city.trim()) errors.city = 'Enter the city.'
if (!values.country.trim()) errors.country = 'Enter the country code.'
if (!values.shape) errors.shape = 'Choose the closest shape.'
- if (!values.durationSeconds || !Number.isFinite(duration) || duration < 0) {
- errors.durationSeconds = 'Enter a duration of zero seconds or more.'
+ if (!values.durationSeconds || !Number.isInteger(duration) || duration < 1) {
+ errors.durationSeconds = 'Enter a whole number of seconds, at least 1.'
}
if (!values.comments.trim()) errors.comments = 'Describe what you saw.'
if (
@@ -63,10 +70,51 @@ function validate(values: FormValues): FormErrors {
return errors
}
+/**
+ * Turns the server's BAD_USER_INPUT `fieldErrors` into form errors, so the
+ * rules only the server checks (dates in the future, text length) show up
+ * next to the right field. Returns null for any other kind of error.
+ */
+function serverFieldErrors(error: unknown): FormErrors | null {
+ if (!CombinedGraphQLErrors.is(error)) return null
+ const invalid = error.errors.find(
+ (graphQLError) => graphQLError.extensions?.code === 'BAD_USER_INPUT',
+ )
+ const fieldErrors = invalid?.extensions?.fieldErrors as
+ SightingFieldError[] | undefined
+ if (!fieldErrors) return null
+
+ const errors: FormErrors = {}
+ for (const { field, message } of fieldErrors) {
+ errors[field] = message
+ }
+ return errors
+}
+
+// The API allows nulls for imported rows, but a submitted sighting always
+// has every field except state, so the fallbacks are only for type safety.
+function toUfoSighting(created: CreatedSighting) {
+ return {
+ id: created.id,
+ datetime: created.datetime ?? '',
+ city: created.city ?? '',
+ state: created.state ?? '',
+ country: created.country ?? '',
+ shape: created.shape ?? '',
+ durationSeconds: created.durationSeconds ?? 0,
+ comments: created.comments ?? '',
+ datePosted: created.datePosted ?? '',
+ latitude: created.latitude ?? 0,
+ longitude: created.longitude ?? 0,
+ }
+}
+
function SubmitSightingPage() {
const [values, setValues] = useState(initialValues)
const [errors, setErrors] = useState({})
const [submittedId, setSubmittedId] = useState()
+ const [saveFailed, setSaveFailed] = useState(false)
+ const [createSighting, { loading }] = useMutation(CREATE_SIGHTING_MUTATION)
function updateValue(field: keyof FormValues, value: string) {
setValues((previous) => ({ ...previous, [field]: value }))
@@ -78,24 +126,42 @@ function SubmitSightingPage() {
updateValue('longitude', longitude.toFixed(6))
}
- function handleSubmit(event: FormEvent) {
+ async function handleSubmit(event: FormEvent) {
event.preventDefault()
+ setSaveFailed(false)
const nextErrors = validate(values)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
- const sighting = addSighting({
- datetime: new Date(values.datetime).toISOString(),
- city: values.city.trim(),
- state: values.state.trim(),
- country: values.country.trim().toLowerCase(),
- shape: values.shape,
- durationSeconds: Number(values.durationSeconds),
- comments: values.comments.trim(),
- latitude: Number(values.latitude),
- longitude: Number(values.longitude),
- })
- setSubmittedId(sighting.id)
+ try {
+ const { data } = await createSighting({
+ variables: {
+ input: {
+ // datetime-local gives local time without timezone, which is
+ // the format the API stores.
+ datetime: values.datetime,
+ city: values.city.trim(),
+ state: values.state.trim() || null,
+ country: values.country.trim(),
+ shape: values.shape,
+ durationSeconds: Number(values.durationSeconds),
+ comments: values.comments.trim(),
+ latitude: Number(values.latitude),
+ longitude: Number(values.longitude),
+ },
+ },
+ })
+ if (!data) throw new Error('createSighting returned no data')
+ addSighting(toUfoSighting(data.createSighting))
+ setSubmittedId(data.createSighting.id)
+ } catch (error) {
+ const fieldErrors = serverFieldErrors(error)
+ if (fieldErrors) {
+ setErrors(fieldErrors)
+ } else {
+ setSaveFailed(true)
+ }
+ }
}
if (submittedId) {
@@ -169,6 +235,7 @@ function SubmitSightingPage() {
updateValue('city', event.target.value)}
aria-invalid={Boolean(errors.city)}
@@ -187,6 +254,7 @@ function SubmitSightingPage() {
updateValue('state', event.target.value)}
/>
@@ -260,8 +328,8 @@ function SubmitSightingPage() {
updateValue('durationSeconds', event.target.value)
@@ -361,6 +429,7 @@ function SubmitSightingPage() {
)}
-