From 8d92d106be1b629af7e1fb28441d63490ed76cde Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 7 Feb 2026 17:25:20 +0000 Subject: [PATCH 1/3] Fix countDisasters to respect query filters countDisasters() previously ignored all filters (type, status, dateFrom, dateTo) and always returned the total row count. The GraphQL disasters query now returns accurate total and totalPages for filtered results. Co-authored-by: Cursor --- graphql/resolvers.ts | 2 +- services/disaster.service.ts | 29 +++++++++++++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/graphql/resolvers.ts b/graphql/resolvers.ts index d08cdf4..e209564 100644 --- a/graphql/resolvers.ts +++ b/graphql/resolvers.ts @@ -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, diff --git a/services/disaster.service.ts b/services/disaster.service.ts index 01f6948..8cace1d 100644 --- a/services/disaster.service.ts +++ b/services/disaster.service.ts @@ -88,10 +88,31 @@ export const getAllDisasters = async ( })); }; -export const countDisasters = async (): Promise => { - const result = (await prisma.$queryRawUnsafe('SELECT COUNT(*) FROM disasters')) as { - count: string; - }[]; +export const countDisasters = async (filter: DisasterFilter = {}): Promise => { + 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); }; From bc4d572d808b7cda96fc50657f9cb09b820ba5a9 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 7 Feb 2026 17:25:34 +0000 Subject: [PATCH 2/3] Add regression tests for filtered total and totalPages in GraphQL Three new tests verify that total and totalPages reflect the applied filters (status, type, and combined pagination + filter) rather than the unfiltered row count. Co-authored-by: Cursor --- graphql/graphql.test.ts | 120 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/graphql/graphql.test.ts b/graphql/graphql.test.ts index deb867a..ecf7dcc 100644 --- a/graphql/graphql.test.ts +++ b/graphql/graphql.test.ts @@ -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 = [ From a97ace0725b3e8a04357cb082dd58db26bcab6b6 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 7 Feb 2026 17:25:50 +0000 Subject: [PATCH 3/3] Add seed script with 17 realistic disaster records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single TypeScript script (npm run seed) that populates the API via the bulk insert endpoint. Covers 6 continents, 12 disaster types, dates spanning Jan 2025 – Feb 2026, and all three statuses. Co-authored-by: Cursor --- README.md | 17 +++++ package.json | 3 +- scripts/seed.ts | 182 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 scripts/seed.ts diff --git a/README.md b/README.md index 91649b4..05c538f 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/package.json b/package.json index 8016649..3753266 100644 --- a/package.json +++ b/package.json @@ -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": "", diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 0000000..f6b0940 --- /dev/null +++ b/scripts/seed.ts @@ -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();