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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,23 @@ and configures per-worker test databases automatically:
docker compose exec api npm test
```

### Seeding Sample Data

The repository includes a seed script that populates the database with 17
realistic disaster records spanning 6 continents, 12 disaster types, multiple
time frames (Jan 2025 – Feb 2026), and all three statuses (`active`,
`contained`, `resolved`).

```sh
docker compose exec api npm run seed # inside Docker (recommended)
npm run seed # from the host
npm run seed -- http://my-host:4000 # custom base URL
```

> **Note:** The script is additive — running it multiple times creates
> duplicate records. To start fresh, recreate the database with
> `docker compose down -v && docker compose up --build -d`.

### Local Development (without Docker)

If you prefer running Node.js directly on your machine:
Expand Down
120 changes: 120 additions & 0 deletions graphql/graphql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,126 @@ describe('GraphQL API', () => {
}
});

it('should return total and totalPages that match filtered results by status', async () => {
// Seed: 3 active, 2 contained, 1 resolved
const seeds = [
{ status: 'active', desc: 'A1' },
{ status: 'active', desc: 'A2' },
{ status: 'active', desc: 'A3' },
{ status: 'contained', desc: 'C1' },
{ status: 'contained', desc: 'C2' },
{ status: 'resolved', desc: 'R1' },
];
for (const s of seeds) {
const mutation = `mutation { createDisaster(input: { type: "flood", location: { type: "Point", coordinates: [0, 0] }, date: "2025-06-01T00:00:00Z", description: "${s.desc}", status: ${s.status} }) { id } }`;
const res = await request(appInstance).post('/graphql').send({ query: mutation });
failOnGraphQLErrors(res);
expect(res.body.data.createDisaster).toHaveProperty('id');
}

// Query active — expect total=3, data.length=3
let query = `query { disasters(status: active) { total totalPages data { id status } } }`;
let res = await request(appInstance).post('/graphql').send({ query });
failOnGraphQLErrors(res);
expect(res.body.data.disasters.total).toBe(3);
expect(res.body.data.disasters.data).toHaveLength(3);
expect(res.body.data.disasters.totalPages).toBe(1);
for (const d of res.body.data.disasters.data) {
expect(d.status).toBe('active');
}

// Query contained — expect total=2, data.length=2
query = `query { disasters(status: contained) { total totalPages data { id status } } }`;
res = await request(appInstance).post('/graphql').send({ query });
failOnGraphQLErrors(res);
expect(res.body.data.disasters.total).toBe(2);
expect(res.body.data.disasters.data).toHaveLength(2);
for (const d of res.body.data.disasters.data) {
expect(d.status).toBe('contained');
}

// Query resolved — expect total=1, data.length=1
query = `query { disasters(status: resolved) { total totalPages data { id status } } }`;
res = await request(appInstance).post('/graphql').send({ query });
failOnGraphQLErrors(res);
expect(res.body.data.disasters.total).toBe(1);
expect(res.body.data.disasters.data).toHaveLength(1);
expect(res.body.data.disasters.data[0].status).toBe('resolved');

// No filter — expect total=6
query = `query { disasters { total data { id } } }`;
res = await request(appInstance).post('/graphql').send({ query });
failOnGraphQLErrors(res);
expect(res.body.data.disasters.total).toBe(6);
expect(res.body.data.disasters.data).toHaveLength(6);
});

it('should return total and totalPages that match filtered results by type', async () => {
// Seed: 2 earthquakes, 1 flood, 1 wildfire
const seeds = [
{ type: 'earthquake', desc: 'EQ1' },
{ type: 'earthquake', desc: 'EQ2' },
{ type: 'flood', desc: 'FL1' },
{ type: 'wildfire', desc: 'WF1' },
];
for (const s of seeds) {
const mutation = `mutation { createDisaster(input: { type: "${s.type}", location: { type: "Point", coordinates: [10, 20] }, date: "2025-07-01T00:00:00Z", description: "${s.desc}", status: active }) { id } }`;
const res = await request(appInstance).post('/graphql').send({ query: mutation });
failOnGraphQLErrors(res);
}

// Filter by earthquake — total=2
let query = `query { disasters(type: "earthquake") { total data { id type } } }`;
let res = await request(appInstance).post('/graphql').send({ query });
failOnGraphQLErrors(res);
expect(res.body.data.disasters.total).toBe(2);
expect(res.body.data.disasters.data).toHaveLength(2);
for (const d of res.body.data.disasters.data) {
expect(d.type).toBe('earthquake');
}

// Filter by flood — total=1
query = `query { disasters(type: "flood") { total data { id type } } }`;
res = await request(appInstance).post('/graphql').send({ query });
failOnGraphQLErrors(res);
expect(res.body.data.disasters.total).toBe(1);
expect(res.body.data.disasters.data[0].type).toBe('flood');

// No filter — total=4
query = `query { disasters { total } }`;
res = await request(appInstance).post('/graphql').send({ query });
failOnGraphQLErrors(res);
expect(res.body.data.disasters.total).toBe(4);
});

it('should return correct total with pagination and filters combined', async () => {
// Seed 5 active disasters
for (let i = 0; i < 5; i++) {
const mutation = `mutation { createDisaster(input: { type: "tornado", location: { type: "Point", coordinates: [${i}, ${i}] }, date: "2025-08-0${i + 1}T00:00:00Z", description: "Tornado ${i + 1}", status: active }) { id } }`;
const res = await request(appInstance).post('/graphql').send({ query: mutation });
failOnGraphQLErrors(res);
}
// Add 2 resolved to make sure they don't leak into counts
for (let i = 0; i < 2; i++) {
const mutation = `mutation { createDisaster(input: { type: "tornado", location: { type: "Point", coordinates: [${i}, ${i}] }, date: "2025-08-0${i + 1}T00:00:00Z", description: "Resolved ${i + 1}", status: resolved }) { id } }`;
const res = await request(appInstance).post('/graphql').send({ query: mutation });
failOnGraphQLErrors(res);
}

// Page 1, limit 2, status=active — total should still be 5, totalPages=3
const query = `query { disasters(status: active, page: 1, limit: 2) { total totalPages page limit data { id status } } }`;
const res = await request(appInstance).post('/graphql').send({ query });
failOnGraphQLErrors(res);
expect(res.body.data.disasters.total).toBe(5);
expect(res.body.data.disasters.totalPages).toBe(3);
expect(res.body.data.disasters.page).toBe(1);
expect(res.body.data.disasters.limit).toBe(2);
expect(res.body.data.disasters.data).toHaveLength(2);
for (const d of res.body.data.disasters.data) {
expect(d.status).toBe('active');
}
});

it('should filter disasters by dateFrom, dateTo, and both', async () => {
// Create disasters with different dates
const disasters = [
Expand Down
2 changes: 1 addition & 1 deletion graphql/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const resolvers: IResolvers = {
filter,
})
).map((doc: Disaster) => new DisasterResponseDTO(doc));
const total = await countDisasters();
const total = await countDisasters(filter);
return {
data,
page,
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"pretest": "npx tsx create-test-dbs.ts",
"proto:js": "pbjs -t static-module -w commonjs -o proto/disaster_pb.js proto/disaster.proto",
"proto:ts": "pbts -o proto/disaster_pb.d.ts proto/disaster_pb.js",
"proto:all": "npm run proto:js && npm run proto:ts"
"proto:all": "npm run proto:js && npm run proto:ts",
"seed": "npx tsx scripts/seed.ts"
},
"keywords": [],
"author": "",
Expand Down
182 changes: 182 additions & 0 deletions scripts/seed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* 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).
*
* Usage:
* npm run seed # defaults to http://localhost:3000
* npx tsx scripts/seed.ts http://my-host:4000 # custom base URL
*
* Works inside the Docker container and on the host alike.
* The script is additive — running it multiple times creates duplicate records.
*/

const BASE_URL = process.argv[2] || 'http://localhost:3000';
const ENDPOINT = `${BASE_URL}/api/v1/disasters/bulk`;

const disasters = [
{
type: 'drought',
location: { type: 'Point', coordinates: [36.8219, -1.2921] },
date: '2025-01-10T00:00:00.000Z',
description:
'Prolonged drought across the Horn of Africa centered on Nairobi, Kenya. Fifth consecutive failed rainy season affecting 8 million people.',
status: 'resolved',
},
{
type: 'earthquake',
location: { type: 'Point', coordinates: [142.3728, 38.3224] },
date: '2025-03-11T14:46:00.000Z',
description:
'7.2 magnitude earthquake off the coast of Miyagi Prefecture, Japan. Triggered tsunami warnings along the Pacific coast.',
status: 'resolved',
},
{
type: 'volcanic_eruption',
location: { type: 'Point', coordinates: [-175.38, -20.57] },
date: '2025-06-03T22:15:00.000Z',
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',
},
{
type: 'flood',
location: { type: 'Point', coordinates: [90.3563, 23.685] },
date: '2025-07-22T03:00:00.000Z',
description:
'Severe monsoon flooding along the Padma River in Dhaka Division, Bangladesh. 1.2 million people displaced.',
status: 'resolved',
},
{
type: 'wildfire',
location: { type: 'Point', coordinates: [-119.4179, 36.7783] },
date: '2025-08-15T08:30:00.000Z',
description:
'Creek Fire — 85,000 acres burned in the Sierra Nevada foothills near Fresno, California. Over 2,000 structures threatened.',
status: 'resolved',
},
{
type: 'hurricane',
location: { type: 'Point', coordinates: [-89.6165, 20.9674] },
date: '2025-09-18T18:00:00.000Z',
description:
'Hurricane Mara — Category 4 landfall on the Yucatán Peninsula, Mexico. Sustained winds of 240 km/h.',
status: 'resolved',
},
{
type: 'earthquake',
location: { type: 'Point', coordinates: [28.9784, 41.0082] },
date: '2025-11-05T04:17:00.000Z',
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',
},
{
type: 'cyclone',
location: { type: 'Point', coordinates: [72.8777, 19.076] },
date: '2025-12-01T12:00:00.000Z',
description:
'Cyclone Biparjoy made landfall near Mumbai, India with sustained winds of 185 km/h. Major flooding in low-lying coastal areas.',
status: 'resolved',
},
{
type: 'landslide',
location: { type: 'Point', coordinates: [-72.3388, -13.532] },
date: '2026-01-08T07:45:00.000Z',
description:
'Massive rainfall-triggered landslide in Ayacucho region, Peru. Buried a section of the Pan-American Highway and isolated three villages.',
status: 'contained',
},
{
type: 'wildfire',
location: { type: 'Point', coordinates: [149.13, -35.2809] },
date: '2026-01-20T11:00:00.000Z',
description:
'Bushfire in the Brindabella Ranges west of Canberra, Australia. 40,000 hectares burned with ember attacks reaching suburban Weston Creek.',
status: 'contained',
},
{
type: 'flood',
location: { type: 'Point', coordinates: [2.3522, 48.8566] },
date: '2026-01-28T16:30:00.000Z',
description:
'Seine River overflows in central Paris, France. Louvre museum lower levels evacuated. Metro lines 1 and 4 suspended.',
status: 'contained',
},
{
type: 'earthquake',
location: { type: 'Point', coordinates: [-70.6693, -33.4489] },
date: '2026-02-01T02:33:00.000Z',
description:
'6.8 magnitude earthquake centered 40 km south of Santiago, Chile. Widespread power outages across the Metropolitan Region.',
status: 'active',
},
{
type: 'tsunami',
location: { type: 'Point', coordinates: [115.1889, -8.4095] },
date: '2026-02-03T09:12:00.000Z',
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',
},
{
type: 'blizzard',
location: { type: 'Point', coordinates: [-71.0589, 42.3601] },
date: '2026-02-05T20:00:00.000Z',
description:
"Nor'easter dumps 75 cm of snow on Boston, Massachusetts. Logan Airport closed for 36 hours. State of emergency declared.",
status: 'active',
},
{
type: 'volcanic_eruption',
location: { type: 'Point', coordinates: [14.426, 40.821] },
date: '2026-02-06T15:45:00.000Z',
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',
},
{
type: 'industrial_accident',
location: { type: 'Point', coordinates: [121.4737, 31.2304] },
date: '2026-02-07T03:20:00.000Z',
description:
'Chemical plant explosion in Pudong New Area, Shanghai, China. Toxic plume prompted shelter-in-place orders for 500,000 residents.',
status: 'active',
},
{
type: 'tornado',
location: { type: 'Point', coordinates: [-97.5164, 35.4676] },
date: '2026-02-07T17:30:00.000Z',
description:
'EF-4 tornado with 280 km/h winds cuts a 25 km path through Moore, Oklahoma. Dozens of homes destroyed.',
status: 'active',
},
];

async function main() {
console.log(`Seeding ${disasters.length} disasters at ${ENDPOINT} ...`);

const response = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(disasters),
});

const body = await response.json();

if (response.ok) {
const count = Array.isArray(body.data)
? body.data.length
: Array.isArray(body)
? body.length
: '?';
console.log(`Success (HTTP ${response.status}) — ${count} disasters created.`);
} else {
console.error(`Failed (HTTP ${response.status}):`);
console.error(JSON.stringify(body, null, 2));
process.exit(1);
}
}

main();
29 changes: 25 additions & 4 deletions services/disaster.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,31 @@ export const getAllDisasters = async (
}));
};

export const countDisasters = async (): Promise<number> => {
const result = (await prisma.$queryRawUnsafe('SELECT COUNT(*) FROM disasters')) as {
count: string;
}[];
export const countDisasters = async (filter: DisasterFilter = {}): Promise<number> => {
const conditions: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (filter.type) {
conditions.push(`type = $${paramIndex++}`);
values.push(filter.type);
}
if (filter.status) {
conditions.push(`status = $${paramIndex++}`);
values.push(filter.status);
}
if (filter.dateFrom) {
conditions.push(`date >= $${paramIndex++}::timestamp`);
values.push(filter.dateFrom);
}
if (filter.dateTo) {
conditions.push(`date <= $${paramIndex++}::timestamp`);
values.push(filter.dateTo);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = (await prisma.$queryRawUnsafe(
`SELECT COUNT(*) FROM disasters ${whereClause}`,
...values,
)) as { count: string }[];
return parseInt(result[0].count, 10);
};

Expand Down