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/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 = [ 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/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(); 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); };