diff --git a/.gitignore b/.gitignore index fcef886..9f66edf 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ proto/disaster_pb.d.ts # Prisma generated client generated/ + +# Cursor AI +.cursor/ diff --git a/app.ts b/app.ts index a052010..41882de 100644 --- a/app.ts +++ b/app.ts @@ -21,7 +21,13 @@ import { typeDefs } from './graphql/schema.js'; import { resolvers } from './graphql/resolvers.js'; import { errorHandler } from './middleware/error.js'; import type { GraphQLFormattedError } from 'graphql'; -import { CREATE_DISASTERS_TABLE_SQL, CREATE_LOCATION_INDEX_SQL } from './disaster.model.js'; +import { + CREATE_DISASTERS_TABLE_SQL, + CREATE_LOCATION_INDEX_SQL, + CREATE_SOURCE_INDEX_SQL, + CREATE_SOURCE_EXTERNAL_ID_UNIQUE_INDEX_SQL, + ADD_SOURCE_COLUMNS_SQL, +} from './disaster.model.js'; dotenv.config(); @@ -160,10 +166,13 @@ async function createApp(pgPool?: Pool): Promise { throw new Error('PostgreSQL connection failed: ' + (err as Error).message); } - // Ensure disasters table and index exist + // Ensure disasters table and indexes exist try { await pool.query(CREATE_DISASTERS_TABLE_SQL); + await pool.query(ADD_SOURCE_COLUMNS_SQL); await pool.query(CREATE_LOCATION_INDEX_SQL); + await pool.query(CREATE_SOURCE_INDEX_SQL); + await pool.query(CREATE_SOURCE_EXTERNAL_ID_UNIQUE_INDEX_SQL); } catch (err) { logger.error('Failed to ensure disasters table/index', { error: err }); throw new Error('Failed to ensure disasters table/index: ' + (err as Error).message); diff --git a/disaster.model.ts b/disaster.model.ts index c849e6c..7c325d6 100644 --- a/disaster.model.ts +++ b/disaster.model.ts @@ -11,6 +11,10 @@ export interface Disaster { date: string | Date; description: string; status: 'active' | 'contained' | 'resolved'; + source: string; + externalId?: string | null; + sourceUrl?: string | null; + distanceKm?: number; createdAt?: string | Date; updatedAt?: string | Date; } @@ -24,12 +28,43 @@ CREATE TABLE IF NOT EXISTS disasters ( date TIMESTAMP NOT NULL, description TEXT, status VARCHAR(32) NOT NULL DEFAULT 'active', + source VARCHAR(255) NOT NULL DEFAULT 'official', + external_id VARCHAR(255), + source_url TEXT, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); `; +// Helper: SQL to add source tracking columns to existing tables (safe to run repeatedly) +export const ADD_SOURCE_COLUMNS_SQL = ` +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'disasters' AND column_name = 'source') THEN + ALTER TABLE disasters ADD COLUMN source VARCHAR(255) NOT NULL DEFAULT 'official'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'disasters' AND column_name = 'external_id') THEN + ALTER TABLE disasters ADD COLUMN external_id VARCHAR(255); + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'disasters' AND column_name = 'source_url') THEN + ALTER TABLE disasters ADD COLUMN source_url TEXT; + END IF; +END +$$; +`; + // Helper: SQL for creating the geospatial index export const CREATE_LOCATION_INDEX_SQL = ` CREATE INDEX IF NOT EXISTS idx_disasters_location ON disasters USING GIST(location); `; + +// Helper: SQL for creating source-related indexes +export const CREATE_SOURCE_INDEX_SQL = ` +CREATE INDEX IF NOT EXISTS idx_disasters_source ON disasters (source); +`; + +export const CREATE_SOURCE_EXTERNAL_ID_UNIQUE_INDEX_SQL = ` +CREATE UNIQUE INDEX IF NOT EXISTS idx_disasters_source_external_id + ON disasters (source, external_id) + WHERE external_id IS NOT NULL; +`; diff --git a/dto/disaster.dto.test.ts b/dto/disaster.dto.test.ts index c0f2fe3..7e47c18 100644 --- a/dto/disaster.dto.test.ts +++ b/dto/disaster.dto.test.ts @@ -31,8 +31,83 @@ describe('DisasterResponseDTO', () => { expect(dto.status).toBe('contained'); }); - // This file is missing an export or test. Add a dummy test to satisfy Jest. - it('dummy test to satisfy Jest', () => { - expect(true).toBe(true); + it('defaults source to official if missing', () => { + const input = { + id: 'test-id', + type: 'fire', + location: { type: 'Point', coordinates: [1, 2] }, + date: '2025-01-01', + description: 'desc', + status: 'active', + }; + const dto = new DisasterResponseDTO(input as any); + expect(dto.source).toBe('official'); + expect(dto.externalId).toBeNull(); + expect(dto.sourceUrl).toBeNull(); + }); + + it('maps source fields when present', () => { + const input = { + id: 'test-id', + type: 'wildfire', + location: { type: 'Point', coordinates: [-9.1393, 38.7223] }, + date: '2025-08-01', + description: 'Wildfire near Lisbon', + status: 'active', + source: 'fogos_pt', + external_id: 'FOGOS-2025-001', + source_url: 'https://fogos.pt/fogo/2025-001', + }; + const dto = new DisasterResponseDTO(input as any); + expect(dto.source).toBe('fogos_pt'); + expect(dto.externalId).toBe('FOGOS-2025-001'); + expect(dto.sourceUrl).toBe('https://fogos.pt/fogo/2025-001'); + }); + + it('maps camelCase source fields when present', () => { + const input = { + id: 'test-id', + type: 'wildfire', + location: { type: 'Point', coordinates: [-9.1393, 38.7223] }, + date: '2025-08-01', + description: 'Wildfire near Lisbon', + status: 'active', + source: 'prociv', + externalId: 'PROCIV-2025-042', + sourceUrl: 'https://prociv.gov.pt/event/042', + }; + const dto = new DisasterResponseDTO(input as any); + expect(dto.source).toBe('prociv'); + expect(dto.externalId).toBe('PROCIV-2025-042'); + expect(dto.sourceUrl).toBe('https://prociv.gov.pt/event/042'); + }); + + it('maps distanceKm from snake_case distance_km', () => { + const input = { + id: 'test-id', + type: 'wildfire', + location: { type: 'Point', coordinates: [1, 2] }, + date: '2025-01-01', + description: 'desc', + status: 'active', + source: 'official', + distance_km: 12.345, + }; + const dto = new DisasterResponseDTO(input as any); + expect(dto.distanceKm).toBeCloseTo(12.345); + }); + + it('omits distanceKm when not present', () => { + const input = { + id: 'test-id', + type: 'wildfire', + location: { type: 'Point', coordinates: [1, 2] }, + date: '2025-01-01', + description: 'desc', + status: 'active', + source: 'official', + }; + const dto = new DisasterResponseDTO(input as any); + expect(dto.distanceKm).toBeUndefined(); }); }); diff --git a/dto/disaster.dto.ts b/dto/disaster.dto.ts index d5d7e7c..46dd880 100644 --- a/dto/disaster.dto.ts +++ b/dto/disaster.dto.ts @@ -9,7 +9,10 @@ export interface DisasterInput { }; date: string | Date; description: string; - status: string; // <-- add status + status: string; + source?: string; + external_id?: string | null; + source_url?: string | null; } export class DisasterInputDTO implements DisasterInput { @@ -17,14 +20,29 @@ export class DisasterInputDTO implements DisasterInput { location: { type: 'Point'; coordinates: [number, number] }; date: string | Date; description: string; - status: string; // <-- add status + status: string; + source?: string; + external_id?: string | null; + source_url?: string | null; - constructor({ type, location, date, description, status }: DisasterInput) { + constructor({ + type, + location, + date, + description, + status, + source, + external_id, + source_url, + }: DisasterInput) { this.type = type; this.location = location; this.date = date; this.description = description; - this.status = status; // <-- assign status + this.status = status; + this.source = source; + this.external_id = external_id; + this.source_url = source_url; } } @@ -38,6 +56,31 @@ export interface DisasterResponse { createdAt?: string | Date; updatedAt?: string | Date; status: string; + source: string; + externalId?: string | null; + sourceUrl?: string | null; + distanceKm?: number; +} + +// Allow snake_case fields from raw DB results +interface RawDisasterRow { + id?: string; + type: string; + location: { type: 'Point'; coordinates: [number, number] }; + date: string | Date; + description: string; + createdAt?: string | Date; + created_at?: string | Date; + updatedAt?: string | Date; + updated_at?: string | Date; + status: string; + source?: string; + externalId?: string | null; + external_id?: string | null; + sourceUrl?: string | null; + source_url?: string | null; + distanceKm?: number; + distance_km?: number; } export class DisasterResponseDTO implements DisasterResponse { @@ -49,15 +92,27 @@ export class DisasterResponseDTO implements DisasterResponse { createdAt?: string | Date; updatedAt?: string | Date; status: string; + source: string; + externalId?: string | null; + sourceUrl?: string | null; + distanceKm?: number; - constructor(disaster: DisasterResponse | import('../disaster.model').Disaster) { + constructor(disaster: RawDisasterRow | DisasterResponse | import('../disaster.model').Disaster) { + const raw = disaster as RawDisasterRow; this.id = disaster.id as string; this.type = disaster.type; this.location = disaster.location; this.date = disaster.date; this.description = disaster.description; - this.createdAt = disaster.createdAt; - this.updatedAt = disaster.updatedAt; + this.createdAt = raw.createdAt ?? raw.created_at; + this.updatedAt = raw.updatedAt ?? raw.updated_at; this.status = disaster.status || 'active'; + this.source = raw.source || 'official'; + this.externalId = raw.externalId ?? raw.external_id ?? null; + this.sourceUrl = raw.sourceUrl ?? raw.source_url ?? null; + const distKm = raw.distanceKm ?? raw.distance_km; + if (distKm !== undefined && distKm !== null) { + this.distanceKm = Number(distKm); + } } } diff --git a/graphql/resolvers.ts b/graphql/resolvers.ts index e209564..2937920 100644 --- a/graphql/resolvers.ts +++ b/graphql/resolvers.ts @@ -31,16 +31,18 @@ const resolvers: IResolvers = { limit?: number; type?: string; status?: string; + source?: string; dateFrom?: string; dateTo?: string; }, ) => { try { - const { page = 1, limit = 20, type, dateFrom, dateTo, status } = args; - // Create filter object for PostgreSQL service - simplified for now + const { page = 1, limit = 20, type, dateFrom, dateTo, status, source } = args; + // Create filter object for PostgreSQL service const filter: Record = {}; if (type) filter.type = type; if (status) filter.status = status; + if (source) filter.source = source; if (dateFrom) filter.dateFrom = dateFrom; if (dateTo) filter.dateTo = dateTo; @@ -82,15 +84,21 @@ const resolvers: IResolvers = { }, disastersNear: async ( _: unknown, - { lat, lng, distance }: { lat: number; lng: number; distance: number }, + { + lat, + lng, + distance, + status, + source, + }: { lat: number; lng: number; distance: number; status?: string; source?: string }, ) => { try { - const { error } = nearQuerySchema.validate({ lat, lng, distance }); + const { error } = nearQuerySchema.validate({ lat, lng, distance, status, source }); if (error) throw new GraphQLError(mapJoiErrorMessage(error.message), { extensions: { code: 'BAD_USER_INPUT' }, }); - return (await findDisastersNear({ lat, lng, distance })).map( + return (await findDisastersNear({ lat, lng, distance, status, source })).map( (doc: Disaster) => new DisasterResponseDTO(doc), ); } catch (err) { diff --git a/graphql/schema.ts b/graphql/schema.ts index 78e9e48..c8360dc 100644 --- a/graphql/schema.ts +++ b/graphql/schema.ts @@ -20,6 +20,10 @@ const typeDefs: DocumentNode = gql` date: String! description: String status: DisasterStatus! + source: String + externalId: String + sourceUrl: String + distanceKm: Float } input LocationInput { @@ -33,6 +37,9 @@ const typeDefs: DocumentNode = gql` date: String! description: String status: DisasterStatus! + source: String + externalId: String + sourceUrl: String } input DisasterUpdateInput { @@ -42,6 +49,9 @@ const typeDefs: DocumentNode = gql` date: String description: String status: DisasterStatus + source: String + externalId: String + sourceUrl: String } type DisasterPage { @@ -60,9 +70,16 @@ const typeDefs: DocumentNode = gql` dateFrom: String dateTo: String status: DisasterStatus + source: String ): DisasterPage! disaster(id: ID!): Disaster - disastersNear(lat: Float!, lng: Float!, distance: Float!): [Disaster!]! + disastersNear( + lat: Float! + lng: Float! + distance: Float! + status: DisasterStatus + source: String + ): [Disaster!]! } type Mutation { diff --git a/openapi.json b/openapi.json index 0d58d7e..3f79d8f 100644 --- a/openapi.json +++ b/openapi.json @@ -55,6 +55,13 @@ "enum": ["active", "contained", "resolved"] }, "description": "Filter disasters by status (active, contained, resolved)" + }, + { + "name": "source", + "in": "query", + "required": false, + "schema": { "type": "string" }, + "description": "Filter by data source (e.g., official, fogos_pt, prociv, user, nasa_firms)" } ], "responses": { @@ -111,24 +118,44 @@ "name": "lat", "in": "query", "required": true, - "schema": { "type": "number", "minimum": -90, "maximum": 90 } + "schema": { "type": "number", "minimum": -90, "maximum": 90 }, + "description": "Latitude of the center point" }, { "name": "lng", "in": "query", "required": true, - "schema": { "type": "number", "minimum": -180, "maximum": 180 } + "schema": { "type": "number", "minimum": -180, "maximum": 180 }, + "description": "Longitude of the center point" }, { "name": "distance", "in": "query", "required": true, - "schema": { "type": "number", "minimum": 0 } + "schema": { "type": "number", "minimum": 0 }, + "description": "Search radius in kilometers" + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["active", "contained", "resolved"] + }, + "description": "Filter by disaster status" + }, + { + "name": "source", + "in": "query", + "required": false, + "schema": { "type": "string" }, + "description": "Filter by data source (e.g., official, fogos_pt, prociv, user)" } ], "responses": { "200": { - "description": "List of disasters within distance", + "description": "List of disasters within distance, ordered by proximity. Each result includes distance_km.", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Disaster" } } @@ -275,7 +302,7 @@ "Disaster": { "type": "object", "properties": { - "_id": { "type": "string" }, + "id": { "type": "string", "format": "uuid" }, "type": { "type": "string" }, "location": { "type": "object", @@ -302,9 +329,29 @@ "type": "string", "description": "Status of the disaster", "enum": ["active", "contained", "resolved"] + }, + "source": { + "type": "string", + "description": "Data origin identifier (e.g., official, fogos_pt, prociv, user, nasa_firms)", + "default": "official" + }, + "externalId": { + "type": "string", + "nullable": true, + "description": "Original ID from the government feed or external source" + }, + "sourceUrl": { + "type": "string", + "nullable": true, + "description": "URL linking back to the official source" + }, + "distanceKm": { + "type": "number", + "readOnly": true, + "description": "Distance from the query point in kilometers (only present in /near responses)" } }, - "required": ["_id", "type", "location", "date", "status"] + "required": ["id", "type", "location", "date", "status", "source"] }, "DisasterInput": { "type": "object", @@ -335,6 +382,21 @@ "type": "string", "description": "Status of the disaster", "enum": ["active", "contained", "resolved"] + }, + "source": { + "type": "string", + "description": "Data origin identifier (defaults to 'official')", + "default": "official" + }, + "external_id": { + "type": "string", + "nullable": true, + "description": "Original ID from the external source" + }, + "source_url": { + "type": "string", + "nullable": true, + "description": "URL linking back to the official source" } }, "required": ["type", "location", "date", "status"] diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9741781..078dab8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,6 +16,9 @@ model Disaster { date DateTime description String? status String @default("active") + source String @default("official") + externalId String? @map("external_id") + sourceUrl String? @map("source_url") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/proto/disaster.proto b/proto/disaster.proto index be52050..c541f84 100644 --- a/proto/disaster.proto +++ b/proto/disaster.proto @@ -11,6 +11,10 @@ message Disaster { string status = 6; string created_at = 7; string updated_at = 8; + string source = 9; + string external_id = 10; + string source_url = 11; + double distance_km = 12; } message DisasterList { diff --git a/routes/disasters.protobuf.test.ts b/routes/disasters.protobuf.test.ts index dfadccb..a9f00ae 100644 --- a/routes/disasters.protobuf.test.ts +++ b/routes/disasters.protobuf.test.ts @@ -51,6 +51,7 @@ describe('Protobuf Content Negotiation', () => { date: '2025-01-01', status: 'active', description: 'Test fire', + source: 'official', }; const res = await request(server) .post('/api/v1/disasters') diff --git a/routes/disasters.ts b/routes/disasters.ts index 45ff161..9a2f6d0 100644 --- a/routes/disasters.ts +++ b/routes/disasters.ts @@ -64,6 +64,10 @@ function toProtoDisaster(disaster: Disaster) { date: disaster.date instanceof Date ? disaster.date.toISOString() : disaster.date, description: disaster.description, status: disaster.status, + source: disaster.source || 'official', + external_id: disaster.externalId || '', + source_url: disaster.sourceUrl || '', + distance_km: disaster.distanceKm ?? 0, createdAt: disaster.createdAt instanceof Date ? disaster.createdAt.toISOString() : disaster.createdAt, updatedAt: @@ -100,12 +104,14 @@ router.get( dateFrom, dateTo, status, + source, } = req.query as Record; const pageNum = Number(page) || 1; const limitNum = Math.min(Number(limit) || 20, 100); const filter: Record = {}; if (type) filter.type = type; if (status) filter.status = status; + if (source) filter.source = source; if (dateFrom) filter.dateFrom = dateFrom; if (dateTo) filter.dateTo = dateTo; const disasters = await getAllDisasters({ @@ -140,8 +146,8 @@ router.get( status: 400, }); } - const { lat, lng, distance } = value; - const disasters = await findDisastersNear({ lng, lat, distance }); + const { lat, lng, distance, status, source } = value; + const disasters = await findDisastersNear({ lng, lat, distance, status, source }); if (wantsProtobuf(req)) { const pbDisasters = disasters.map(toProtoDisaster); const message = disastersPb.disasters.DisasterList.create({ disasters: pbDisasters }); diff --git a/scripts/seed.ts b/scripts/seed.ts index f6b0940..31f37e1 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -2,7 +2,7 @@ * seed.ts — Populate the Disasters API with realistic sample data. * * 17 disasters across 6 continents, 12 types, Jan 2025 – Feb 2026, - * and all three statuses (active, contained, resolved). + * all three statuses (active, contained, resolved), and multiple data sources. * * Usage: * npm run seed # defaults to http://localhost:3000 @@ -23,6 +23,9 @@ const disasters = [ description: 'Prolonged drought across the Horn of Africa centered on Nairobi, Kenya. Fifth consecutive failed rainy season affecting 8 million people.', status: 'resolved', + source: 'official', + external_id: 'GDACS-DR-2025-000012', + source_url: 'https://www.gdacs.org/report.aspx?eventid=2025000012', }, { type: 'earthquake', @@ -31,6 +34,9 @@ const disasters = [ description: '7.2 magnitude earthquake off the coast of Miyagi Prefecture, Japan. Triggered tsunami warnings along the Pacific coast.', status: 'resolved', + source: 'official', + external_id: 'USGS-EQ-2025-0311-JP', + source_url: 'https://earthquake.usgs.gov/earthquakes/eventpage/us2025abcd', }, { type: 'volcanic_eruption', @@ -39,6 +45,9 @@ const disasters = [ description: "Hunga Tonga-Hunga Ha'apai resumed activity with a VEI-3 eruption. Ash cloud reached 18 km altitude, disrupting Pacific air routes.", status: 'resolved', + source: 'official', + external_id: 'GVP-2025-TONGA-001', + source_url: 'https://volcano.si.edu/volcano.cfm?vn=243040', }, { type: 'flood', @@ -47,6 +56,9 @@ const disasters = [ description: 'Severe monsoon flooding along the Padma River in Dhaka Division, Bangladesh. 1.2 million people displaced.', status: 'resolved', + source: 'official', + external_id: 'GDACS-FL-2025-000089', + source_url: 'https://www.gdacs.org/report.aspx?eventid=2025000089', }, { type: 'wildfire', @@ -55,6 +67,9 @@ const disasters = [ description: 'Creek Fire — 85,000 acres burned in the Sierra Nevada foothills near Fresno, California. Over 2,000 structures threatened.', status: 'resolved', + source: 'nasa_firms', + external_id: 'FIRMS-2025-CA-CREEK', + source_url: 'https://firms.modaps.eosdis.nasa.gov/map/#t:adv;d:2025-08-15', }, { type: 'hurricane', @@ -63,6 +78,9 @@ const disasters = [ description: 'Hurricane Mara — Category 4 landfall on the Yucatán Peninsula, Mexico. Sustained winds of 240 km/h.', status: 'resolved', + source: 'official', + external_id: 'NHC-2025-AL09', + source_url: 'https://www.nhc.noaa.gov/archive/2025/al09/', }, { type: 'earthquake', @@ -71,6 +89,9 @@ const disasters = [ description: '6.4 magnitude earthquake in the Sea of Marmara near Istanbul, Turkey. Significant structural damage in the Fatih and Beyoğlu districts.', status: 'resolved', + source: 'official', + external_id: 'USGS-EQ-2025-1105-TR', + source_url: 'https://earthquake.usgs.gov/earthquakes/eventpage/us2025efgh', }, { type: 'cyclone', @@ -79,6 +100,9 @@ const disasters = [ description: 'Cyclone Biparjoy made landfall near Mumbai, India with sustained winds of 185 km/h. Major flooding in low-lying coastal areas.', status: 'resolved', + source: 'official', + external_id: 'IMD-2025-CYC-BIPARJOY', + source_url: 'https://mausam.imd.gov.in/imd_latest/contents/cyclone.php', }, { type: 'landslide', @@ -87,6 +111,9 @@ const disasters = [ description: 'Massive rainfall-triggered landslide in Ayacucho region, Peru. Buried a section of the Pan-American Highway and isolated three villages.', status: 'contained', + source: 'official', + external_id: 'GDACS-LS-2026-000003', + source_url: 'https://www.gdacs.org/report.aspx?eventid=2026000003', }, { type: 'wildfire', @@ -95,6 +122,9 @@ const disasters = [ description: 'Bushfire in the Brindabella Ranges west of Canberra, Australia. 40,000 hectares burned with ember attacks reaching suburban Weston Creek.', status: 'contained', + source: 'nasa_firms', + external_id: 'FIRMS-2026-AU-BRINDA', + source_url: 'https://firms.modaps.eosdis.nasa.gov/map/#t:adv;d:2026-01-20', }, { type: 'flood', @@ -103,6 +133,9 @@ const disasters = [ description: 'Seine River overflows in central Paris, France. Louvre museum lower levels evacuated. Metro lines 1 and 4 suspended.', status: 'contained', + source: 'official', + external_id: 'EFAS-2026-FR-SEINE', + source_url: 'https://www.efas.eu/en/efas-flood-alerts', }, { type: 'earthquake', @@ -111,6 +144,9 @@ const disasters = [ description: '6.8 magnitude earthquake centered 40 km south of Santiago, Chile. Widespread power outages across the Metropolitan Region.', status: 'active', + source: 'official', + external_id: 'USGS-EQ-2026-0201-CL', + source_url: 'https://earthquake.usgs.gov/earthquakes/eventpage/us2026ijkl', }, { type: 'tsunami', @@ -119,6 +155,9 @@ const disasters = [ description: 'Tsunami warning issued for southern Bali, Indonesia after a 7.1 undersea earthquake in the Indian Ocean. Waves of 1.5m observed at Kuta Beach.', status: 'active', + source: 'official', + external_id: 'PTWC-2026-IO-001', + source_url: 'https://ptwc.weather.gov/', }, { type: 'blizzard', @@ -127,6 +166,9 @@ const disasters = [ description: "Nor'easter dumps 75 cm of snow on Boston, Massachusetts. Logan Airport closed for 36 hours. State of emergency declared.", status: 'active', + source: 'official', + external_id: 'NWS-2026-NE-BLIZZARD', + source_url: 'https://www.weather.gov/box/', }, { type: 'volcanic_eruption', @@ -135,6 +177,9 @@ const disasters = [ description: 'Mount Vesuvius enters eruptive phase with lava fountaining and pyroclastic flows. Mandatory evacuation of Ercolano and Torre del Greco near Naples, Italy.', status: 'active', + source: 'official', + external_id: 'INGV-2026-VES-001', + source_url: 'https://www.ov.ingv.it/ov/en/vesuvio.html', }, { type: 'industrial_accident', @@ -143,6 +188,9 @@ const disasters = [ description: 'Chemical plant explosion in Pudong New Area, Shanghai, China. Toxic plume prompted shelter-in-place orders for 500,000 residents.', status: 'active', + source: 'user', + external_id: null, + source_url: null, }, { type: 'tornado', @@ -151,6 +199,9 @@ const disasters = [ description: 'EF-4 tornado with 280 km/h winds cuts a 25 km path through Moore, Oklahoma. Dozens of homes destroyed.', status: 'active', + source: 'user', + external_id: null, + source_url: null, }, ]; diff --git a/services/disaster.service.ts b/services/disaster.service.ts index 8cace1d..c5399dc 100644 --- a/services/disaster.service.ts +++ b/services/disaster.service.ts @@ -2,31 +2,41 @@ import { prisma } from './prisma'; import { Disaster } from '../disaster.model.js'; import { DisasterInput } from '../dto/disaster.dto.js'; +// Common SELECT columns used across queries +const BASE_SELECT_COLUMNS = `id, type, ST_AsGeoJSON(location)::json as location, date, description, status, source, external_id, source_url, created_at, updated_at`; + +// Helper to format date as YYYY-MM-DD +function formatDisasterDate(d: Disaster): Disaster { + return { + ...d, + date: + d.date instanceof Date + ? d.date.toISOString().slice(0, 10) + : typeof d.date === 'string' + ? d.date.slice(0, 10) + : d.date, + }; +} + /** * Create a new disaster record */ export const createDisaster = async (data: DisasterInput): Promise => { - const { type, location, date, description, status } = data; - // Prisma does not natively support PostGIS geography(Point,4326), so use raw SQL + const { type, location, date, description, status, source, external_id, source_url } = data; const result = (await prisma.$queryRawUnsafe( - `INSERT INTO disasters (type, location, date, description, status) - VALUES ($1, ST_GeomFromGeoJSON($2)::geography, $3::timestamp, $4, $5) - RETURNING id, type, ST_AsGeoJSON(location)::json as location, date, description, status, created_at, updated_at`, + `INSERT INTO disasters (type, location, date, description, status, source, external_id, source_url) + VALUES ($1, ST_GeomFromGeoJSON($2)::geography, $3::timestamp, $4, $5, $6, $7, $8) + RETURNING ${BASE_SELECT_COLUMNS}`, type, JSON.stringify(location), date, description, status || 'active', + source || 'official', + external_id || null, + source_url || null, )) as Disaster[]; - return { - ...result[0], - date: - result[0].date instanceof Date - ? result[0].date.toISOString().slice(0, 10) - : typeof result[0].date === 'string' - ? result[0].date.slice(0, 10) - : result[0].date, - }; + return formatDisasterDate(result[0]); }; export type DisasterFilter = Partial & { @@ -55,6 +65,10 @@ export const getAllDisasters = async ( conditions.push(`status = $${paramIndex++}`); values.push(filterConst.status); } + if (filterConst.source) { + conditions.push(`source = $${paramIndex++}`); + values.push(filterConst.source); + } if (filterConst.dateFrom) { conditions.push(`date >= $${paramIndex++}::timestamp`); values.push(filterConst.dateFrom); @@ -69,23 +83,14 @@ export const getAllDisasters = async ( } values.push(sanitizedSkip, sanitizedLimit); const result = (await prisma.$queryRawUnsafe( - `SELECT id, type, ST_AsGeoJSON(location)::json as location, date, description, status, created_at, updated_at + `SELECT ${BASE_SELECT_COLUMNS} FROM disasters ${whereClause} ORDER BY created_at DESC OFFSET $${paramIndex++} LIMIT $${paramIndex++}`, ...values, )) as Disaster[]; - // Format date as YYYY-MM-DD in each result - return result.map((d) => ({ - ...d, - date: - d.date instanceof Date - ? d.date.toISOString().slice(0, 10) - : typeof d.date === 'string' - ? d.date.slice(0, 10) - : d.date, - })); + return result.map(formatDisasterDate); }; export const countDisasters = async (filter: DisasterFilter = {}): Promise => { @@ -100,6 +105,10 @@ export const countDisasters = async (filter: DisasterFilter = {}): Promise= $${paramIndex++}::timestamp`); values.push(filter.dateFrom); @@ -118,21 +127,12 @@ export const countDisasters = async (filter: DisasterFilter = {}): Promise => { const result = (await prisma.$queryRawUnsafe( - `SELECT id, type, ST_AsGeoJSON(location)::json as location, date, description, status, created_at, updated_at + `SELECT ${BASE_SELECT_COLUMNS} FROM disasters WHERE id = $1::uuid`, id, )) as Disaster[]; if (!result[0]) return null; - // Format date as YYYY-MM-DD - return { - ...result[0], - date: - result[0].date instanceof Date - ? result[0].date.toISOString().slice(0, 10) - : typeof result[0].date === 'string' - ? result[0].date.slice(0, 10) - : result[0].date, - }; + return formatDisasterDate(result[0]); }; export const updateDisaster = async ( @@ -164,19 +164,11 @@ export const updateDisaster = async ( `UPDATE disasters SET ${fields.join(', ')} WHERE id = $1::uuid - RETURNING id, type, ST_AsGeoJSON(location)::json as location, date, description, status, created_at, updated_at`, + RETURNING ${BASE_SELECT_COLUMNS}`, ...values, )) as Disaster[]; if (!result[0]) return null; - return { - ...result[0], - date: - result[0].date instanceof Date - ? result[0].date.toISOString().slice(0, 10) - : typeof result[0].date === 'string' - ? result[0].date.slice(0, 10) - : result[0].date, - }; + return formatDisasterDate(result[0]); }; export const deleteDisaster = async (id: string): Promise => { @@ -191,7 +183,7 @@ export const bulkInsertDisasters = async (disasters: DisasterInput[]): Promise ({ - ...d, - date: - d.date instanceof Date - ? d.date.toISOString().slice(0, 10) - : typeof d.date === 'string' - ? d.date.slice(0, 10) - : d.date, - })); + return result.map(formatDisasterDate); }; export const bulkUpdateDisasters = async ( @@ -233,21 +220,40 @@ export const bulkUpdateDisasters = async ( return { matchedCount, modifiedCount }; }; -export async function findDisastersNear(arg1: { +export async function findDisastersNear(params: { lat: number; lng: number; distance: number; + status?: string; + source?: string; }): Promise { - const { lat, lng, distance } = arg1; + const { lat, lng, distance, status, source } = params; + + // Build optional WHERE conditions beyond the spatial filter + const conditions: string[] = [ + `ST_DWithin(location, ST_GeomFromText('POINT(' || $1 || ' ' || $2 || ')')::geography, $3 * 1000)`, + ]; + const values: unknown[] = [lng, lat, distance]; + let paramIndex = 4; + + if (status) { + conditions.push(`status = $${paramIndex++}`); + values.push(status); + } + if (source) { + conditions.push(`source = $${paramIndex++}`); + values.push(source); + } + + const whereClause = `WHERE ${conditions.join(' AND ')}`; + const result = (await prisma.$queryRawUnsafe( - `SELECT id, type, ST_AsGeoJSON(location)::json as location, date, description, status, created_at, updated_at, + `SELECT ${BASE_SELECT_COLUMNS}, ST_Distance(location, ST_GeomFromText('POINT(' || $1 || ' ' || $2 || ')')::geography) / 1000 as distance_km FROM disasters - WHERE ST_DWithin(location, ST_GeomFromText('POINT(' || $1 || ' ' || $2 || ')')::geography, $3 * 1000) + ${whereClause} ORDER BY distance_km`, - lng, - lat, - distance, + ...values, )) as Disaster[]; return result; } diff --git a/validation/disaster.ts b/validation/disaster.ts index 494fa37..b14f103 100644 --- a/validation/disaster.ts +++ b/validation/disaster.ts @@ -18,13 +18,18 @@ const disasterSchema = Joi.object({ date: Joi.string().isoDate().required(), description: Joi.string().allow('').optional(), status: Joi.string().valid('active', 'contained', 'resolved').default('active').required(), + source: Joi.string().max(255).default('official').optional(), + external_id: Joi.string().max(255).allow(null).optional(), + source_url: Joi.string().uri().allow(null, '').optional(), }); const nearQuerySchema = Joi.object({ lat: Joi.number().min(-90).max(90).required(), lng: Joi.number().min(-180).max(180).required(), distance: Joi.number().min(0).required(), -}); + status: Joi.string().valid('active', 'contained', 'resolved').optional(), + source: Joi.string().max(255).optional(), +}).options({ stripUnknown: true }); const bulkInsertSchema = Joi.array().items(disasterSchema).min(1).required(); @@ -42,6 +47,9 @@ const bulkUpdateSchema = Joi.array() date: Joi.string().isoDate(), description: Joi.string().allow(''), status: Joi.string().valid('active', 'contained', 'resolved'), + source: Joi.string().max(255), + external_id: Joi.string().max(255).allow(null), + source_url: Joi.string().uri().allow(null, ''), }).min(2), // must have id and at least one field to update ) .min(1)