Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,6 @@ proto/disaster_pb.d.ts

# Prisma generated client
generated/

# Cursor AI
.cursor/
13 changes: 11 additions & 2 deletions app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -160,10 +166,13 @@ async function createApp(pgPool?: Pool): Promise<express.Application> {
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);
Expand Down
35 changes: 35 additions & 0 deletions disaster.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
`;
81 changes: 78 additions & 3 deletions dto/disaster.dto.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
69 changes: 62 additions & 7 deletions dto/disaster.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,40 @@ 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 {
type: string;
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;
}
}

Expand All @@ -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 {
Expand All @@ -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);
}
}
}
18 changes: 13 additions & 5 deletions graphql/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {};
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;

Expand Down Expand Up @@ -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) {
Expand Down
19 changes: 18 additions & 1 deletion graphql/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const typeDefs: DocumentNode = gql`
date: String!
description: String
status: DisasterStatus!
source: String
externalId: String
sourceUrl: String
distanceKm: Float
}

input LocationInput {
Expand All @@ -33,6 +37,9 @@ const typeDefs: DocumentNode = gql`
date: String!
description: String
status: DisasterStatus!
source: String
externalId: String
sourceUrl: String
Comment on lines +40 to +42
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Find the resolver files and examine them
find . -type f -name "*.ts" | grep -E "(resolver|graphql)" | head -20

Repository: goodthingsdev/disasters-api

Length of output: 208


🏁 Script executed:

# Also look at the DTO file structure
find . -type f -path "*/dto/*" -name "*.ts" | head -20

Repository: goodthingsdev/disasters-api

Length of output: 119


🏁 Script executed:

# Search for DisasterInput type definition and its usage in resolvers
rg "DisasterInput" --type ts -A 5 -B 2

Repository: goodthingsdev/disasters-api

Length of output: 9736


🏁 Script executed:

cat -n dto/disaster.dto.ts

Repository: goodthingsdev/disasters-api

Length of output: 4013


🏁 Script executed:

cat -n graphql/resolvers.ts | head -100

Repository: goodthingsdev/disasters-api

Length of output: 3848


🏁 Script executed:

cat -n graphql/resolvers.ts | sed -n '100,150p'

Repository: goodthingsdev/disasters-api

Length of output: 2455


🏁 Script executed:

# Look for the createDisaster, updateDisaster, bulkInsertDisasters mutations
rg "createDisaster|updateDisaster|bulkInsertDisasters|bulkUpdateDisasters" graphql/resolvers.ts -A 15

Repository: goodthingsdev/disasters-api

Length of output: 4213


🏁 Script executed:

# Check how the service layer destructures the input
rg "external_id|source_url" services/disaster.service.ts -B 2 -A 2 | head -50

Repository: goodthingsdev/disasters-api

Length of output: 1335


🏁 Script executed:

# Verify the DisasterInput type is being used directly (without DTO wrapper) in resolvers
# and confirm there's no transformation happening
rg "DisasterInput\|DisasterInputDTO" graphql/resolvers.ts

Repository: goodthingsdev/disasters-api

Length of output: 53


🏁 Script executed:

# Double-check: does DisasterInputDTO do any field remapping?
cat -n dto/disaster.dto.ts | sed -n '28,46p'

Repository: goodthingsdev/disasters-api

Length of output: 585


GraphQL input fields will always be null in the database due to camelCase/snake_case mismatch.

The DisasterInput GraphQL type uses camelCase (externalId, sourceUrl), but the DisasterInput interface in dto/disaster.dto.ts expects snake_case (external_id, source_url). The GraphQL resolvers pass input directly to the service layer without wrapping it in DisasterInputDTO, causing destructuring to fail:

const { external_id, source_url } = { externalId, sourceUrl }  // undefined, undefined

This affects all GraphQL mutations (createDisaster, updateDisaster, bulkInsertDisasters, bulkUpdateDisasters). REST endpoints correctly wrap input in DisasterInputDTO, but GraphQL does not, creating a critical discrepancy that silently loses data.

🤖 Prompt for AI Agents
In `@graphql/schema.ts` around lines 40 - 42, GraphQL inputs use camelCase
(externalId, sourceUrl) but your DTO expects snake_case (external_id,
source_url), so update the GraphQL resolvers (createDisaster, updateDisaster,
bulkInsertDisasters, bulkUpdateDisasters) to map/convert the incoming
DisasterInput to the DTO shape before passing to the service — e.g. construct or
call a mapper to produce a DisasterInputDTO with keys external_id and source_url
(and all other fields translated) or add a small helper that transforms
camelCase input to snake_case and use it when building DisasterInputDTO; ensure
the resolver no longer passes the raw GraphQL input directly to services.

}

input DisasterUpdateInput {
Expand All @@ -42,6 +49,9 @@ const typeDefs: DocumentNode = gql`
date: String
description: String
status: DisasterStatus
source: String
externalId: String
sourceUrl: String
}

type DisasterPage {
Expand All @@ -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 {
Expand Down
Loading