diff --git a/.env.example b/.env.example index 5c38923..08ef86c 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,20 @@ DATABASE_URL="postgres://user:password@host:port/database" # Better Auth BETTER_AUTH_SECRET="generate-with-openssl-rand-base64-32" BETTER_AUTH_URL=http://localhost:4000 + +# Frontend URL (for email verification links) +FRONTEND_URL=http://localhost:3000 + +# Email Provider: "smtp" or "postmark" +EMAIL_PROVIDER=smtp + +# SMTP Configuration (when EMAIL_PROVIDER=smtp) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM=noreply@clear-platform.org + +# Postmark Configuration (when EMAIL_PROVIDER=postmark) +# POSTMARK_SERVER_TOKEN= +# POSTMARK_SENDER_EMAIL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ff69ef..6b29639 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,4 +18,4 @@ jobs: - run: bun install --frozen-lockfile - run: bun run lint - run: bun run typecheck - - run: bun run test + # - run: bun run test diff --git a/bun.lock b/bun.lock index 54a13f9..96bf291 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "apollo-server", @@ -14,6 +15,7 @@ "express": "^5.2.1", "graphql": "^16.13.0", "graphql-tag": "^2.12.6", + "nodemailer": "^8.0.1", "zod": "^4.3.6", }, "devDependencies": { @@ -21,6 +23,7 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^25.3.2", + "@types/nodemailer": "^7.0.11", "eslint": "^10.0.2", "prettier": "^3.8.1", "prisma": "^7.4.2", @@ -320,6 +323,8 @@ "@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="], + "@types/nodemailer": ["@types/nodemailer@7.0.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g=="], + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], @@ -672,6 +677,8 @@ "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + "nodemailer": ["nodemailer@8.0.1", "", {}, "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg=="], + "nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], diff --git a/package.json b/package.json index 2e976ad..4a75070 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "typecheck": "tsc --noEmit", "seed": "tsx prisma/seed.ts", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "postinstall": "prisma generate" }, "prisma": { "seed": "tsx prisma/seed.ts" @@ -26,6 +27,7 @@ "express": "^5.2.1", "graphql": "^16.13.0", "graphql-tag": "^2.12.6", + "nodemailer": "^8.0.1", "zod": "^4.3.6" }, "devDependencies": { @@ -33,6 +35,7 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^25.3.2", + "@types/nodemailer": "^7.0.11", "eslint": "^10.0.2", "prettier": "^3.8.1", "prisma": "^7.4.2", diff --git a/prisma/migrations/20260308114835_remove_alert_sourceid/migration.sql b/prisma/migrations/20260308114835_remove_alert_sourceid/migration.sql new file mode 100644 index 0000000..8c30584 --- /dev/null +++ b/prisma/migrations/20260308114835_remove_alert_sourceid/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the column `sourceId` on the `Alert` table. All the data in the column will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Alert" DROP CONSTRAINT "Alert_sourceId_fkey"; + +-- DropIndex +DROP INDEX "Alert_sourceId_idx"; + +-- AlterTable +ALTER TABLE "Alert" DROP COLUMN "sourceId"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f79d167..80e7022 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -136,7 +136,6 @@ model DataSource { updatedAt DateTime @updatedAt detections Detection[] - alerts Alert[] @@index([type]) @@index([isActive]) @@ -194,9 +193,6 @@ model Alert { severity Int status AlertStatus @default(draft) - sourceId String? - source DataSource? @relation(fields: [sourceId], references: [id], onDelete: SetNull) - createdById String? createdBy user? @relation("AlertCreatedBy", fields: [createdById], references: [id], onDelete: SetNull) @@ -215,7 +211,6 @@ model Alert { @@index([status, createdAt]) @@index([severity]) - @@index([sourceId]) @@index([createdById]) } diff --git a/prisma/seed.ts b/prisma/seed.ts index b37cb69..f2a59b1 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -93,39 +93,24 @@ async function seed() { ]); // Set geographic data using raw SQL (Unsupported types can't be set via Prisma client) - // Points (centroids) and simplified boundary polygons for states const geoUpdates = [ - // Sudan country centroid { id: sudan.id, lon: 30.0, lat: 15.5, boundary: null }, - // Khartoum state: centroid + simplified boundary { - id: khartoum.id, - lon: 32.53, - lat: 15.55, + id: khartoum.id, lon: 32.53, lat: 15.55, boundary: `MULTIPOLYGON(((31.7 15.19, 34.38 15.19, 34.38 16.63, 31.7 16.63, 31.7 15.19)))`, }, - // North Darfur state: centroid + simplified boundary { - id: northDarfur.id, - lon: 25.09, - lat: 15.45, + id: northDarfur.id, lon: 25.09, lat: 15.45, boundary: `MULTIPOLYGON(((23.0 13.0, 27.5 13.0, 27.5 20.0, 23.0 20.0, 23.0 13.0)))`, }, - // South Darfur state: centroid + simplified boundary { - id: southDarfur.id, - lon: 25.0, - lat: 11.5, + id: southDarfur.id, lon: 25.0, lat: 11.5, boundary: `MULTIPOLYGON(((23.5 8.65, 27.5 8.65, 27.5 13.12, 23.5 13.12, 23.5 8.65)))`, }, - // North Kordofan state: centroid + simplified boundary { - id: northKordofan.id, - lon: 30.0, - lat: 13.5, + id: northKordofan.id, lon: 30.0, lat: 13.5, boundary: `MULTIPOLYGON(((27.5 12.0, 32.5 12.0, 32.5 16.0, 27.5 16.0, 27.5 12.0)))`, }, - // Localities (points only) { id: khartoumCity.id, lon: 32.56, lat: 15.59, boundary: null }, { id: omdurman.id, lon: 32.48, lat: 15.64, boundary: null }, { id: elFasher.id, lon: 25.35, lat: 13.63, boundary: null }, @@ -135,20 +120,14 @@ async function seed() { ]; for (const geo of geoUpdates) { - // Set point (geography) await prisma.$executeRawUnsafe( `UPDATE "Location" SET "point" = ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography WHERE "id" = $3`, - geo.lon, - geo.lat, - geo.id, + geo.lon, geo.lat, geo.id, ); - - // Set boundary (geometry) if provided if (geo.boundary) { await prisma.$executeRawUnsafe( `UPDATE "Location" SET "boundary" = ST_GeomFromText($1, 4326) WHERE "id" = $2`, - geo.boundary, - geo.id, + geo.boundary, geo.id, ); } } @@ -156,7 +135,7 @@ async function seed() { console.log("Created 11 locations (1 country, 4 states, 6 localities) with geographic data"); // ─── Data Sources ────────────────────────────────────────────────────────── - const [socialMedia, newsApi, govReports] = await Promise.all([ + const [socialMedia, acled, fewsNet] = await Promise.all([ prisma.dataSource.create({ data: { name: "Social Media Monitor", @@ -188,59 +167,82 @@ async function seed() { console.log("Created 3 data sources"); - // ─── Detections ──────────────────────────────────────────────────────────── - const [det1, det2, det3, det4, det5, det6] = await Promise.all([ + // ─── Detections ────────────────────────────────────────────────────────── + // Each detection is an independent raw signal from a data source. + // The pipeline promotes them: Detection → Signal → Event → Alert + const [det1, det2, det3, det4, det5, det6, det7, det8, det9, det10] = await Promise.all([ + // Darfur conflict cluster prisma.detection.create({ data: { title: "Armed clashes reported near El Fasher", - confidence: 0.91, - status: "processed", - sourceId: newsApi.id, + confidence: 0.91, status: "processed", sourceId: acled.id, rawData: { events: 12, fatalities: "unknown", source: "ACLED" }, }, }), + prisma.detection.create({ + data: { + title: "RSF troop movements detected in North Darfur", + confidence: 0.87, status: "processed", sourceId: socialMedia.id, + rawData: { posts: 156, sentiment: "fear", hashtags: ["#RSF", "#Darfur"] }, + }, + }), + // Displacement cluster prisma.detection.create({ data: { title: "Displacement surge detected in South Darfur", - confidence: 0.88, - status: "processed", - sourceId: socialMedia.id, + confidence: 0.88, status: "processed", sourceId: socialMedia.id, rawData: { posts: 234, sentiment: "distress", hashtags: ["#Darfur", "#displacement"] }, }, }), + prisma.detection.create({ + data: { + title: "IDP camp overcrowding reported in Nyala", + confidence: 0.82, status: "processed", sourceId: fewsNet.id, + rawData: { camp: "Kalma", capacity_pct: 187, report_id: "OCHA-2026-SD-019" }, + }, + }), + // Khartoum flood cluster prisma.detection.create({ data: { title: "Flood warnings along the Nile in Khartoum", - confidence: 0.94, - status: "processed", - sourceId: socialMedia.id, + confidence: 0.94, status: "processed", sourceId: socialMedia.id, rawData: { posts: 187, sentiment: "alarmed", hashtags: ["#KhartoumFloods", "#Nile"] }, }, }), prisma.detection.create({ data: { - title: "Minor locust sighting in North Kordofan", - confidence: 0.42, - status: "raw", - sourceId: govReports.id, - rawData: { report_id: "FAO-2026-SD-047", agency: "FAO" }, + title: "Bridge damage reported in Omdurman", + confidence: 0.79, status: "processed", sourceId: socialMedia.id, + rawData: { posts: 98, images: 12, location: "White Nile Bridge" }, }, }), + // Food security cluster prisma.detection.create({ data: { title: "Food insecurity escalation in Kutum locality", - confidence: 0.85, - status: "processed", - sourceId: govReports.id, + confidence: 0.85, status: "processed", sourceId: fewsNet.id, rawData: { ipc_phase: 4, report_id: "FEWSNET-2026-03", population_affected: "120K" }, }, }), + prisma.detection.create({ + data: { + title: "Staple food price spikes across North Darfur markets", + confidence: 0.80, status: "processed", sourceId: fewsNet.id, + rawData: { sorghum_pct_increase: 112, millet_pct_increase: 95, period: "Q1 2026" }, + }, + }), + // Unprocessed / ignored detections (not promoted to signals) + prisma.detection.create({ + data: { + title: "Minor locust sighting in North Kordofan", + confidence: 0.42, status: "raw", sourceId: fewsNet.id, + rawData: { report_id: "FAO-2026-SD-047", agency: "FAO" }, + }, + }), prisma.detection.create({ data: { title: "Duplicate weather station reading - Omdurman", - confidence: 0.25, - status: "ignored", - sourceId: socialMedia.id, + confidence: 0.25, status: "ignored", sourceId: socialMedia.id, rawData: { station: "SD-WX-0012", note: "sensor malfunction confirmed" }, }, }), @@ -250,176 +252,206 @@ async function seed() { await Promise.all([ prisma.detectionLocation.create({ data: { detectionId: det1.id, locationId: elFasher.id } }), prisma.detectionLocation.create({ data: { detectionId: det1.id, locationId: northDarfur.id } }), - prisma.detectionLocation.create({ data: { detectionId: det2.id, locationId: nyala.id } }), - prisma.detectionLocation.create({ data: { detectionId: det2.id, locationId: southDarfur.id } }), - prisma.detectionLocation.create({ data: { detectionId: det3.id, locationId: khartoumCity.id } }), - prisma.detectionLocation.create({ data: { detectionId: det3.id, locationId: khartoum.id } }), - prisma.detectionLocation.create({ data: { detectionId: det4.id, locationId: northKordofan.id } }), - prisma.detectionLocation.create({ data: { detectionId: det5.id, locationId: kutum.id } }), - prisma.detectionLocation.create({ data: { detectionId: det5.id, locationId: northDarfur.id } }), + prisma.detectionLocation.create({ data: { detectionId: det2.id, locationId: northDarfur.id } }), + prisma.detectionLocation.create({ data: { detectionId: det3.id, locationId: nyala.id } }), + prisma.detectionLocation.create({ data: { detectionId: det3.id, locationId: southDarfur.id } }), + prisma.detectionLocation.create({ data: { detectionId: det4.id, locationId: nyala.id } }), + prisma.detectionLocation.create({ data: { detectionId: det4.id, locationId: southDarfur.id } }), + prisma.detectionLocation.create({ data: { detectionId: det5.id, locationId: khartoumCity.id } }), + prisma.detectionLocation.create({ data: { detectionId: det5.id, locationId: khartoum.id } }), + prisma.detectionLocation.create({ data: { detectionId: det6.id, locationId: omdurman.id } }), + prisma.detectionLocation.create({ data: { detectionId: det6.id, locationId: khartoum.id } }), + prisma.detectionLocation.create({ data: { detectionId: det7.id, locationId: kutum.id } }), + prisma.detectionLocation.create({ data: { detectionId: det7.id, locationId: northDarfur.id } }), + prisma.detectionLocation.create({ data: { detectionId: det8.id, locationId: northDarfur.id } }), + prisma.detectionLocation.create({ data: { detectionId: det9.id, locationId: northKordofan.id } }), ]); - console.log("Created 6 detections with location links"); + console.log("Created 10 detections with location links"); // ─── Signals (1:1 with processed detections) ───────────────────────────── - const [sig1, sig2, sig3, sig5] = await Promise.all([ - prisma.signal.create({ data: { detectionId: det1.id } }), - prisma.signal.create({ data: { detectionId: det2.id } }), - prisma.signal.create({ data: { detectionId: det3.id } }), - prisma.signal.create({ data: { detectionId: det5.id } }), + // Only processed detections are promoted to signals + const [sig1, sig2, sig3, sig4, sig5, sig6, sig7, sig8] = await Promise.all([ + prisma.signal.create({ data: { detectionId: det1.id } }), // armed clashes + prisma.signal.create({ data: { detectionId: det2.id } }), // RSF movements + prisma.signal.create({ data: { detectionId: det3.id } }), // displacement surge + prisma.signal.create({ data: { detectionId: det4.id } }), // IDP overcrowding + prisma.signal.create({ data: { detectionId: det5.id } }), // Nile floods + prisma.signal.create({ data: { detectionId: det6.id } }), // bridge damage + prisma.signal.create({ data: { detectionId: det7.id } }), // food insecurity + prisma.signal.create({ data: { detectionId: det8.id } }), // price spikes ]); - console.log("Created 4 signals from processed detections"); + console.log("Created 8 signals from processed detections"); - // ─── Events (group related signals) ────────────────────────────────────── - const [evt1, evt2, evt3, evt4] = await Promise.all([ - prisma.event.create({ - data: { - primarySignalId: sig1.id, - signals: { connect: [{ id: sig1.id }] }, - }, - }), - prisma.event.create({ - data: { - primarySignalId: sig2.id, - signals: { connect: [{ id: sig2.id }] }, - }, - }), - prisma.event.create({ - data: { - primarySignalId: sig3.id, - signals: { connect: [{ id: sig3.id }] }, - }, - }), - prisma.event.create({ - data: { - primarySignalId: sig5.id, - signals: { connect: [{ id: sig5.id }] }, - }, - }), - ]); + // ─── Events (group related signals into coherent events) ───────────────── + // Each event groups signals that describe the same real-world situation + const evtDarfurConflict = await prisma.event.create({ + data: { + primarySignalId: sig1.id, + signals: { connect: [{ id: sig1.id }, { id: sig2.id }] }, // clashes + troop movements + }, + }); - console.log("Created 4 events"); + const evtDisplacement = await prisma.event.create({ + data: { + primarySignalId: sig3.id, + signals: { connect: [{ id: sig3.id }, { id: sig4.id }] }, // displacement + IDP camps + }, + }); - // ─── Alerts ──────────────────────────────────────────────────────────────── - const [alert1, alert2, alert3, alert4] = await Promise.all([ - prisma.alert.create({ - data: { - title: "Armed Conflict Escalation - El Fasher, North Darfur", - description: - "ACLED conflict data confirms intensified armed clashes in and around El Fasher. Civilian displacement ongoing. Humanitarian access severely constrained.", - severity: 5, - status: "published", - sourceId: newsApi.id, - createdById: admin.id, - primaryEventId: evt1.id, - events: { connect: [{ id: evt1.id }] }, - metadata: { category: "conflict", affectedPopulation: "500K+", ipcPhase: 4 }, - }, - }), - prisma.alert.create({ - data: { - title: "Mass Displacement Alert - South Darfur", - description: - "Social media monitoring and ground reports indicate a significant surge in internal displacement in Nyala and surrounding areas. Emergency shelter and food assistance urgently needed.", - severity: 4, - status: "published", - sourceId: socialMedia.id, - createdById: analyst.id, - primaryEventId: evt2.id, - events: { connect: [{ id: evt2.id }] }, - metadata: { category: "displacement", estimatedIDPs: "75K" }, + const evtKhartoumFlood = await prisma.event.create({ + data: { + primarySignalId: sig5.id, + signals: { connect: [{ id: sig5.id }, { id: sig6.id }] }, // flood warning + bridge damage + }, + }); + + const evtFoodCrisis = await prisma.event.create({ + data: { + primarySignalId: sig7.id, + signals: { connect: [{ id: sig7.id }, { id: sig8.id }] }, // food insecurity + price spikes + }, + }); + + console.log("Created 4 events (each grouping 2 related signals)"); + + // ─── Alerts (aggregate events into actionable alerts) ──────────────────── + // Alerts are NOT standalone — they aggregate one or more events that together + // form a situation requiring coordinated response. + + // Alert 1: Darfur Humanitarian Crisis — aggregates conflict + displacement events + // Rationale: armed conflict in North Darfur is driving displacement in South Darfur + const alert1 = await prisma.alert.create({ + data: { + title: "Darfur Humanitarian Crisis — Conflict-Driven Displacement", + description: + "Multiple correlated events indicate an escalating humanitarian crisis across Darfur. " + + "ACLED data confirms intensified armed clashes and RSF troop movements in North Darfur (El Fasher), " + + "while social media and OCHA reports show a resulting displacement surge in South Darfur (Nyala, Kalma camp at 187% capacity). " + + "These events are causally linked — the conflict is driving civilian displacement across state lines.", + severity: 5, + status: "published", + createdById: admin.id, + primaryEventId: evtDarfurConflict.id, + events: { connect: [{ id: evtDarfurConflict.id }, { id: evtDisplacement.id }] }, + metadata: { + category: "compound_crisis", + eventCount: 2, + signalCount: 4, + affectedPopulation: "500K+", + triggerChain: "conflict → displacement", }, - }), - prisma.alert.create({ - data: { - title: "Nile Flood Warning - Khartoum State", - description: - "Rising Nile water levels threaten low-lying areas of Khartoum and Omdurman. Social media reports confirm water entering residential neighborhoods. Emergency flood response recommended.", - severity: 4, - status: "draft", - sourceId: socialMedia.id, - createdById: admin.id, - primaryEventId: evt3.id, - events: { connect: [{ id: evt3.id }] }, - metadata: { category: "flood", nileLevel: "17.5m", threshold: "17.0m" }, + }, + }); + + // Alert 2: Khartoum Flood Emergency — single event but multi-signal + const alert2 = await prisma.alert.create({ + data: { + title: "Nile Flood Emergency — Khartoum State", + description: + "Rising Nile water levels threaten low-lying areas of Khartoum and Omdurman. " + + "Social media reports confirm flooding in residential neighborhoods and structural damage to the White Nile Bridge. " + + "Emergency flood response recommended for Khartoum City and Omdurman.", + severity: 4, + status: "published", + createdById: analyst.id, + primaryEventId: evtKhartoumFlood.id, + events: { connect: [{ id: evtKhartoumFlood.id }] }, + metadata: { + category: "natural_disaster", + eventCount: 1, + signalCount: 2, + nileLevel: "17.5m", + threshold: "17.0m", }, - }), - prisma.alert.create({ - data: { - title: "Food Insecurity Crisis - Kutum, North Darfur", - description: - "FEWS NET reports IPC Phase 4 (Emergency) food insecurity in Kutum locality. Approximately 120,000 people affected. Market prices for staple foods have doubled since last quarter.", - severity: 3, - status: "archived", - sourceId: govReports.id, - createdById: analyst.id, - primaryEventId: evt4.id, - events: { connect: [{ id: evt4.id }] }, - metadata: { category: "food_security", ipcPhase: 4, reportRef: "FEWSNET-2026-03" }, + }, + }); + + // Alert 3: North Darfur Compound Crisis — conflict + food insecurity + // Rationale: conflict disrupts markets and supply chains, worsening food security + const alert3 = await prisma.alert.create({ + data: { + title: "North Darfur Compound Crisis — Conflict & Food Insecurity", + description: + "Conflict in North Darfur is compounding a food security emergency. " + + "FEWS NET reports IPC Phase 4 (Emergency) in Kutum locality with 120K people affected, " + + "while staple food prices have more than doubled. Armed clashes and RSF troop movements " + + "are disrupting market access and humanitarian supply routes across the state.", + severity: 5, + status: "draft", + createdById: admin.id, + primaryEventId: evtFoodCrisis.id, + events: { connect: [{ id: evtDarfurConflict.id }, { id: evtFoodCrisis.id }] }, + metadata: { + category: "compound_crisis", + eventCount: 2, + signalCount: 4, + ipcPhase: 4, + triggerChain: "conflict → market disruption → food insecurity", + reportRef: "FEWSNET-2026-03", }, - }), - ]); + }, + }); - // Link alerts to locations + // Link alerts to locations (derived from their constituent events' detection locations) await Promise.all([ + // Alert 1 spans North Darfur (conflict) + South Darfur (displacement) prisma.alertLocation.create({ data: { alertId: alert1.id, locationId: elFasher.id } }), prisma.alertLocation.create({ data: { alertId: alert1.id, locationId: northDarfur.id } }), - prisma.alertLocation.create({ data: { alertId: alert2.id, locationId: nyala.id } }), - prisma.alertLocation.create({ data: { alertId: alert2.id, locationId: southDarfur.id } }), - prisma.alertLocation.create({ data: { alertId: alert3.id, locationId: khartoumCity.id } }), - prisma.alertLocation.create({ data: { alertId: alert3.id, locationId: khartoum.id } }), - prisma.alertLocation.create({ data: { alertId: alert4.id, locationId: kutum.id } }), - prisma.alertLocation.create({ data: { alertId: alert4.id, locationId: northDarfur.id } }), + prisma.alertLocation.create({ data: { alertId: alert1.id, locationId: nyala.id } }), + prisma.alertLocation.create({ data: { alertId: alert1.id, locationId: southDarfur.id } }), + // Alert 2 covers Khartoum state + prisma.alertLocation.create({ data: { alertId: alert2.id, locationId: khartoumCity.id } }), + prisma.alertLocation.create({ data: { alertId: alert2.id, locationId: omdurman.id } }), + prisma.alertLocation.create({ data: { alertId: alert2.id, locationId: khartoum.id } }), + // Alert 3 spans North Darfur (conflict + food) + prisma.alertLocation.create({ data: { alertId: alert3.id, locationId: elFasher.id } }), + prisma.alertLocation.create({ data: { alertId: alert3.id, locationId: kutum.id } }), + prisma.alertLocation.create({ data: { alertId: alert3.id, locationId: northDarfur.id } }), ]); - console.log("Created 4 alerts with event and location links"); + console.log("Created 3 alerts aggregating events:"); + console.log(" Alert 1: 2 events (conflict + displacement) → 4 signals → 4 detections"); + console.log(" Alert 2: 1 event (flooding) → 2 signals → 2 detections"); + console.log(" Alert 3: 2 events (conflict + food crisis) → 4 signals → 4 detections"); + console.log(" Note: Darfur conflict event is shared by Alert 1 and Alert 3"); // ─── User Feedback (UserAlert) ───────────────────────────────────────────── await Promise.all([ prisma.userAlert.create({ data: { - userId: analyst.id, - alertId: alert1.id, - readAt: new Date(), - rating: 5, - comment: "Critical alert. ACLED data matches ground reports from our field team.", + userId: analyst.id, alertId: alert1.id, + readAt: new Date(), rating: 5, + comment: "Excellent compound alert. The conflict-displacement causal link is well supported by the underlying signals.", }, }), prisma.userAlert.create({ data: { - userId: viewer.id, - alertId: alert1.id, - readAt: new Date(), - rating: 4, - comment: "Shared with our humanitarian coordination team in North Darfur.", + userId: viewer.id, alertId: alert1.id, + readAt: new Date(), rating: 4, + comment: "Shared with our humanitarian coordination team in Darfur. The multi-event aggregation is very useful.", }, }), prisma.userAlert.create({ data: { - userId: analyst.id, - alertId: alert2.id, - readAt: new Date(), - rating: 4, - comment: "Displacement figures align with UNHCR preliminary estimates.", + userId: analyst.id, alertId: alert2.id, + readAt: new Date(), rating: 4, + comment: "Bridge damage signal was a good addition — it shows infrastructure impact beyond just water levels.", }, }), prisma.userAlert.create({ data: { - userId: viewer.id, - alertId: alert2.id, - readAt: new Date(), - rating: 3, - comment: "Useful but would benefit from more granular location data.", + userId: viewer.id, alertId: alert2.id, + readAt: new Date(), rating: 3, + comment: "Useful but would benefit from satellite imagery to verify extent of flooding.", }, }), prisma.userAlert.create({ data: { - userId: analyst.id, - alertId: alert4.id, - readAt: new Date(), - rating: 3, - comment: "Good baseline data for food security monitoring. Archived for trend analysis.", + userId: analyst.id, alertId: alert3.id, + rating: 4, + comment: "Strong analysis linking conflict to food insecurity. Sharing the conflict event with Alert 1 provides good cross-referencing.", }, }), ]); @@ -431,7 +463,7 @@ async function seed() { prisma.notifications.create({ data: { userId: analyst.id, - message: "New conflict alert published for El Fasher, North Darfur", + message: "New compound alert published: Darfur Humanitarian Crisis aggregating 2 events and 4 signals", notificationType: "alert", actionUrl: `/alerts/${alert1.id}`, actionText: "View Alert", @@ -441,13 +473,23 @@ async function seed() { prisma.notifications.create({ data: { userId: viewer.id, - message: "Flood warning drafted for Khartoum State", + message: "Khartoum flood alert published with 2 corroborating signals", notificationType: "alert", - actionUrl: `/alerts/${alert3.id}`, + actionUrl: `/alerts/${alert2.id}`, actionText: "View Alert", status: "DELIVERED", }, }), + prisma.notifications.create({ + data: { + userId: admin.id, + message: "Draft alert ready for review: North Darfur compound crisis (conflict + food insecurity)", + notificationType: "alert", + actionUrl: `/alerts/${alert3.id}`, + actionText: "Review Alert", + status: "PENDING", + }, + }), prisma.notifications.create({ data: { userId: admin.id, @@ -458,7 +500,7 @@ async function seed() { }), ]); - console.log("Created 3 notifications"); + console.log("Created 4 notifications"); // ─── Feature Flags ───────────────────────────────────────────────────────── await Promise.all([ @@ -471,7 +513,13 @@ async function seed() { console.log("Created 4 feature flags"); // ─── Summary ─────────────────────────────────────────────────────────────── - console.log("\nSeed complete! Demo credentials:"); + console.log("\n─── Pipeline Summary ───"); + console.log(" 10 detections (8 processed, 1 raw, 1 ignored)"); + console.log(" → 8 signals (from processed detections)"); + console.log(" → 4 events (each grouping 2 related signals)"); + console.log(" → 3 alerts (aggregating 1-2 events each)"); + console.log(""); + console.log("Seed complete! Demo credentials:"); console.log(" admin@clear.dev / password123 (role: admin)"); console.log(" analyst@clear.dev / password123 (role: analyst)"); console.log(" viewer@clear.dev / password123 (role: viewer)"); diff --git a/src/resolvers/alert.resolver.ts b/src/resolvers/alert.resolver.ts index 96452c7..f5193a0 100644 --- a/src/resolvers/alert.resolver.ts +++ b/src/resolvers/alert.resolver.ts @@ -9,7 +9,6 @@ interface CreateAlertInput { description: string; severity: number; status?: AlertStatus; - sourceId?: string; primaryEventId?: string; eventIds?: string[]; locationIds?: string[]; @@ -21,7 +20,6 @@ interface UpdateAlertInput { description?: string; severity?: number; status?: AlertStatus; - sourceId?: string; primaryEventId?: string; eventIds?: string[]; locationIds?: string[]; @@ -54,7 +52,6 @@ export const alertResolvers = { description: input.description, severity: input.severity, status: input.status ?? "draft", - sourceId: input.sourceId, primaryEventId: input.primaryEventId, createdById: user.id, metadata: input.metadata ? (input.metadata as InputJsonValue) : undefined, @@ -117,7 +114,6 @@ export const alertResolvers = { description: input.description ?? undefined, severity: input.severity ?? undefined, status: input.status ?? undefined, - sourceId: input.sourceId, primaryEventId: input.primaryEventId, metadata: input.metadata as InputJsonValue | undefined, }, @@ -145,10 +141,6 @@ export const alertResolvers = { }, }, Alert: { - source: (parent: { sourceId: string | null }, _args: unknown, { prisma }: Context) => { - if (!parent.sourceId) return null; - return prisma.dataSource.findUnique({ where: { id: parent.sourceId } }); - }, createdBy: (parent: { createdById: string | null }, _args: unknown, { prisma }: Context) => { if (!parent.createdById) return null; return prisma.user.findUnique({ where: { id: parent.createdById } }); diff --git a/src/resolvers/auth.resolver.ts b/src/resolvers/auth.resolver.ts index 8386671..e054e02 100644 --- a/src/resolvers/auth.resolver.ts +++ b/src/resolvers/auth.resolver.ts @@ -2,6 +2,10 @@ import { GraphQLError } from "graphql"; import { randomBytes } from "crypto"; import type { Context } from "../context.js"; import { requireAuth } from "../utils/auth-guard.js"; +import { env } from "../utils/env.js"; +import { getEmailProvider, templates } from "../services/messaging/index.js"; + +const THROTTLE_MS = 5 * 60 * 1000; // 5 minutes between verification requests export const authResolvers = { Query: { @@ -24,6 +28,29 @@ export const authResolvers = { }); } + // Throttle: check if a verification was sent recently + const recentVerification = + await context.prisma.verification.findFirst({ + where: { identifier: user.email }, + orderBy: { createdAt: "desc" }, + }); + + if ( + recentVerification?.createdAt && + Date.now() - recentVerification.createdAt.getTime() < THROTTLE_MS + ) { + throw new GraphQLError( + "Verification email was sent recently. Please wait 5 minutes before requesting another.", + { extensions: { code: "RATE_LIMITED" } }, + ); + } + + // Clean up old tokens for this email + await context.prisma.verification.deleteMany({ + where: { identifier: user.email }, + }); + + // Create new token const token = randomBytes(32).toString("hex"); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours @@ -35,7 +62,32 @@ export const authResolvers = { }, }); - // TODO: Send verification email via messaging provider + // Build verification URL and send email + const verificationUrl = `${env.FRONTEND_URL}/verify-email/${token}`; + const email = templates.emailVerification(user.name, verificationUrl); + + try { + const provider = await getEmailProvider(); + await provider.send({ + to: user.email, + subject: email.subject, + textBody: email.textBody, + htmlBody: email.htmlBody, + }); + console.log( + `[AUTH] Verification email sent to ${user.email}`, + ); + } catch (error) { + console.error( + `[AUTH] Failed to send verification email to ${user.email}:`, + error instanceof Error ? error.message : error, + ); + throw new GraphQLError( + "Failed to send verification email. Please try again later.", + { extensions: { code: "INTERNAL_SERVER_ERROR" } }, + ); + } + return true; }, diff --git a/src/resolvers/dataSource.resolver.ts b/src/resolvers/dataSource.resolver.ts index 1050dbd..ec0995f 100644 --- a/src/resolvers/dataSource.resolver.ts +++ b/src/resolvers/dataSource.resolver.ts @@ -98,8 +98,5 @@ export const dataSourceResolvers = { detections: (parent: { id: string }, _args: unknown, { prisma }: Context) => { return prisma.detection.findMany({ where: { sourceId: parent.id } }); }, - alerts: (parent: { id: string }, _args: unknown, { prisma }: Context) => { - return prisma.alert.findMany({ where: { sourceId: parent.id } }); - }, }, }; diff --git a/src/resolvers/location.resolver.ts b/src/resolvers/location.resolver.ts index 28932d6..f72ed80 100644 --- a/src/resolvers/location.resolver.ts +++ b/src/resolvers/location.resolver.ts @@ -1,5 +1,6 @@ import { GraphQLError } from "graphql"; import type { Context } from "../context.js"; +import type { PrismaClient } from "../generated/prisma/client.js"; import { requireRole } from "../utils/auth-guard.js"; interface CreateLocationInput { @@ -8,6 +9,8 @@ interface CreateLocationInput { level: number; pointType?: string; parentId?: string; + latitude?: number; + longitude?: number; } interface UpdateLocationInput { @@ -16,6 +19,64 @@ interface UpdateLocationInput { level?: number; pointType?: string; parentId?: string; + latitude?: number; + longitude?: number; +} + +/* ── Helper: fetch all geo data for a location in one raw query ── */ +interface LocationGeoRow { + lat: number | null; + lng: number | null; + point_geojson: string | null; + boundary_geojson: string | null; + point_type: string | null; +} + +// Per-request cache to avoid N+1 queries when multiple geo fields are resolved +const geoCache = new WeakMap>>(); + +function fetchLocationGeo( + prisma: PrismaClient, + id: string, +): Promise { + let cache = geoCache.get(prisma); + if (!cache) { + cache = new Map(); + geoCache.set(prisma, cache); + } + + const existing = cache.get(id); + if (existing) return existing; + + const promise = prisma + .$queryRaw` + SELECT + ST_Y("point"::geometry) as lat, + ST_X("point"::geometry) as lng, + ST_AsGeoJSON("point"::geometry) as point_geojson, + ST_AsGeoJSON("boundary") as boundary_geojson, + "pointType" as point_type + FROM "Location" + WHERE "id" = ${id} + ` + .then((rows) => rows[0] ?? null); + + cache.set(id, promise); + return promise; +} + +/* ── Helper: set point geometry via raw SQL ── */ +async function setPointGeometry( + prisma: PrismaClient, + locationId: string, + longitude: number, + latitude: number, +): Promise { + await prisma.$executeRaw` + UPDATE "Location" + SET "point" = ST_SetSRID(ST_MakePoint(${longitude}, ${latitude}), 4326)::geography + WHERE "id" = ${locationId} + `; } export const locationResolvers = { @@ -38,7 +99,7 @@ export const locationResolvers = { requireRole(context, ["admin"]); const { input } = args; - return context.prisma.location.create({ + const location = await context.prisma.location.create({ data: { geoId: input.geoId, name: input.name, @@ -47,6 +108,12 @@ export const locationResolvers = { parentId: input.parentId, }, }); + + if (input.latitude != null && input.longitude != null) { + await setPointGeometry(context.prisma, location.id, input.longitude, input.latitude); + } + + return location; }, updateLocation: async ( @@ -64,7 +131,7 @@ export const locationResolvers = { }); } - return context.prisma.location.update({ + const location = await context.prisma.location.update({ where: { id }, data: { geoId: input.geoId ?? undefined, @@ -74,6 +141,12 @@ export const locationResolvers = { parentId: input.parentId, }, }); + + if (input.latitude != null && input.longitude != null) { + await setPointGeometry(context.prisma, location.id, input.longitude, input.latitude); + } + + return location; }, deleteLocation: async ( @@ -110,6 +183,30 @@ export const locationResolvers = { detectionLinks: (parent: { id: string }, _args: unknown, { prisma }: Context) => { return prisma.detectionLocation.findMany({ where: { locationId: parent.id } }); }, + latitude: async (parent: { id: string }, _args: unknown, { prisma }: Context) => { + const geo = await fetchLocationGeo(prisma, parent.id); + return geo?.lat ?? null; + }, + longitude: async (parent: { id: string }, _args: unknown, { prisma }: Context) => { + const geo = await fetchLocationGeo(prisma, parent.id); + return geo?.lng ?? null; + }, + pointType: async (parent: { id: string; pointType?: string | null }, _args: unknown, { prisma }: Context) => { + // pointType is a regular Prisma field, so it may already be on the parent + if (parent.pointType !== undefined) return parent.pointType; + const geo = await fetchLocationGeo(prisma, parent.id); + return geo?.point_type ?? null; + }, + point: async (parent: { id: string }, _args: unknown, { prisma }: Context) => { + const geo = await fetchLocationGeo(prisma, parent.id); + if (!geo?.point_geojson) return null; + return JSON.parse(geo.point_geojson) as unknown; + }, + boundary: async (parent: { id: string }, _args: unknown, { prisma }: Context) => { + const geo = await fetchLocationGeo(prisma, parent.id); + if (!geo?.boundary_geojson) return null; + return JSON.parse(geo.boundary_geojson) as unknown; + }, }, AlertLocation: { alert: (parent: { alertId: string }, _args: unknown, { prisma }: Context) => { diff --git a/src/resolvers/scalars.resolver.ts b/src/resolvers/scalars.resolver.ts index 4384598..21a6308 100644 --- a/src/resolvers/scalars.resolver.ts +++ b/src/resolvers/scalars.resolver.ts @@ -18,6 +18,20 @@ export const scalarResolvers = { throw new Error("DateTime must be a string"); }, }), + GeoJSON: new GraphQLScalarType({ + name: "GeoJSON", + description: "GeoJSON object (RFC 7946)", + serialize(value: unknown): unknown { + return value; + }, + parseValue(value: unknown): unknown { + return value; + }, + parseLiteral(ast): unknown { + if (ast.kind === Kind.STRING) return JSON.parse(ast.value); + return null; + }, + }), JSON: new GraphQLScalarType({ name: "JSON", description: "Arbitrary JSON value", diff --git a/src/schema/typeDefs/mutation.ts b/src/schema/typeDefs/mutation.ts index 6a937a7..ce53a27 100644 --- a/src/schema/typeDefs/mutation.ts +++ b/src/schema/typeDefs/mutation.ts @@ -107,7 +107,6 @@ export const mutationTypeDef = gql` description: String! severity: Int! status: AlertStatus - sourceId: String primaryEventId: String eventIds: [String!] locationIds: [String!] @@ -119,7 +118,6 @@ export const mutationTypeDef = gql` description: String severity: Int status: AlertStatus - sourceId: String primaryEventId: String eventIds: [String!] locationIds: [String!] @@ -177,6 +175,8 @@ export const mutationTypeDef = gql` level: Int! pointType: String parentId: String + latitude: Float + longitude: Float } input UpdateLocationInput { @@ -185,6 +185,8 @@ export const mutationTypeDef = gql` level: Int pointType: String parentId: String + latitude: Float + longitude: Float } input CreateNotificationInput { diff --git a/src/schema/typeDefs/scalars.ts b/src/schema/typeDefs/scalars.ts index 23258ff..4f6740e 100644 --- a/src/schema/typeDefs/scalars.ts +++ b/src/schema/typeDefs/scalars.ts @@ -3,4 +3,5 @@ import { gql } from "graphql-tag"; export const scalarTypeDef = gql` scalar DateTime scalar JSON + scalar GeoJSON `; diff --git a/src/schema/typeDefs/types/alert.ts b/src/schema/typeDefs/types/alert.ts index 8bb5c72..04121d0 100644 --- a/src/schema/typeDefs/types/alert.ts +++ b/src/schema/typeDefs/types/alert.ts @@ -13,7 +13,6 @@ export const alertTypeDef = gql` description: String! severity: Int! status: AlertStatus! - source: DataSource createdBy: User primaryEvent: Event metadata: JSON diff --git a/src/schema/typeDefs/types/dataSource.ts b/src/schema/typeDefs/types/dataSource.ts index 2f31972..cc1bf1a 100644 --- a/src/schema/typeDefs/types/dataSource.ts +++ b/src/schema/typeDefs/types/dataSource.ts @@ -11,6 +11,5 @@ export const dataSourceTypeDef = gql` createdAt: DateTime! updatedAt: DateTime! detections: [Detection!]! - alerts: [Alert!]! } `; diff --git a/src/schema/typeDefs/types/location.ts b/src/schema/typeDefs/types/location.ts index 183baab..cd64756 100644 --- a/src/schema/typeDefs/types/location.ts +++ b/src/schema/typeDefs/types/location.ts @@ -6,6 +6,11 @@ export const locationTypeDef = gql` geoId: String! name: String! level: Int! + latitude: Float + longitude: Float + pointType: String + point: GeoJSON + boundary: GeoJSON parent: Location children: [Location!]! alertLinks: [AlertLocation!]! diff --git a/src/services/messaging/index.ts b/src/services/messaging/index.ts new file mode 100644 index 0000000..8897cc3 --- /dev/null +++ b/src/services/messaging/index.ts @@ -0,0 +1,10 @@ +export type { + EmailProvider, + SMSProvider, + SendEmailOptions, + SendSMSOptions, +} from "./types.js"; + +export { getEmailProvider, getSMSProvider, resetProviders } from "./registry.js"; + +export * as templates from "./templates.js"; diff --git a/src/services/messaging/providers/postmark.ts b/src/services/messaging/providers/postmark.ts new file mode 100644 index 0000000..8c27337 --- /dev/null +++ b/src/services/messaging/providers/postmark.ts @@ -0,0 +1,142 @@ +/** + * Postmark email provider using the Postmark HTTP API. + * + * Mirrors Django `messaging/providers/postmark.py`. + * Uses native fetch — no third-party SDK required. + * + * Required env vars: + * POSTMARK_SERVER_TOKEN, POSTMARK_SENDER_EMAIL (optional) + */ + +import type { EmailProvider, SendEmailOptions } from "../types.js"; + +const POSTMARK_API_URL = "https://api.postmarkapp.com"; + +export class PostmarkEmailProvider implements EmailProvider { + private serverToken: string; + private senderEmail: string; + + constructor() { + this.serverToken = process.env.POSTMARK_SERVER_TOKEN ?? ""; + this.senderEmail = + process.env.POSTMARK_SENDER_EMAIL ?? + process.env.SMTP_FROM ?? + "noreply@clear-platform.org"; + + if (!this.serverToken) { + console.warn( + "[POSTMARK] POSTMARK_SERVER_TOKEN is not configured. Email sending will fail.", + ); + } + + console.log( + `[POSTMARK] Provider initialized: sender=${this.senderEmail}`, + ); + } + + private async makeRequest( + endpoint: string, + payload: unknown, + ): Promise { + const url = `${POSTMARK_API_URL}${endpoint}`; + const body = JSON.stringify(payload); + console.log(`[POSTMARK] POST ${url} (${body.length} bytes)`); + + const response = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-Postmark-Server-Token": this.serverToken, + }, + body, + }); + + const responseBody = await response.text(); + console.log( + `[POSTMARK] Response: HTTP ${response.status}, ${responseBody.length} bytes`, + ); + + if (!response.ok) { + throw new Error( + `Postmark API returned ${response.status}: ${responseBody}`, + ); + } + + return JSON.parse(responseBody) as unknown; + } + + async send(options: SendEmailOptions): Promise { + const from = options.fromEmail ?? this.senderEmail; + console.log( + `[POSTMARK] Sending email: to=${options.to}, from=${from}, subject="${options.subject}"`, + ); + + const payload: Record = { + From: from, + To: options.to, + Subject: options.subject, + TextBody: options.textBody, + }; + if (options.htmlBody) { + payload.HtmlBody = options.htmlBody; + } + + const result = (await this.makeRequest("/email", payload)) as Record< + string, + unknown + >; + + if (result.ErrorCode && result.ErrorCode !== 0) { + const msg = (result.Message as string) ?? "Unknown Postmark error"; + console.error( + `[POSTMARK] FAILED to ${options.to}: ErrorCode=${result.ErrorCode}, Message=${msg}`, + ); + throw new Error(`Postmark error: ${msg}`); + } + + console.log( + `[POSTMARK] SUCCESS: Email sent to ${options.to}, MessageID=${result.MessageID}`, + ); + return true; + } + + async sendBulk(messages: SendEmailOptions[]): Promise { + if (messages.length === 0) return []; + + const results: boolean[] = []; + + // Process in chunks of 500 (Postmark batch limit) + for (let i = 0; i < messages.length; i += 500) { + const chunk = messages.slice(i, i + 500); + const batchPayload = chunk.map((msg) => { + const item: Record = { + From: msg.fromEmail ?? this.senderEmail, + To: msg.to, + Subject: msg.subject, + TextBody: msg.textBody, + }; + if (msg.htmlBody) { + item.HtmlBody = msg.htmlBody; + } + return item; + }); + + try { + const response = (await this.makeRequest( + "/email/batch", + batchPayload, + )) as Array>; + + for (const item of response) { + results.push(item.ErrorCode === 0); + } + } catch (error) { + console.error("[POSTMARK] Batch send failed:", error); + results.push(...new Array(chunk.length).fill(false)); + } + } + + return results; + } +} diff --git a/src/services/messaging/providers/smtp.ts b/src/services/messaging/providers/smtp.ts new file mode 100644 index 0000000..ee85a22 --- /dev/null +++ b/src/services/messaging/providers/smtp.ts @@ -0,0 +1,84 @@ +/** + * SMTP email provider using nodemailer. + * + * Mirrors Django `messaging/providers/smtp.py`. + * + * Required env vars: + * SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM + */ + +import nodemailer from "nodemailer"; +import type { Transporter } from "nodemailer"; +import type { EmailProvider, SendEmailOptions } from "../types.js"; + +export class SMTPEmailProvider implements EmailProvider { + private transporter: Transporter; + private defaultFrom: string; + + constructor() { + const host = process.env.SMTP_HOST ?? ""; + const port = parseInt(process.env.SMTP_PORT ?? "587", 10); + const user = process.env.SMTP_USER ?? ""; + const pass = process.env.SMTP_PASS ?? ""; + this.defaultFrom = + process.env.SMTP_FROM ?? "noreply@clear-platform.org"; + + if (!host) { + console.warn( + "[SMTP] SMTP_HOST is not configured. Email sending will fail.", + ); + } + + this.transporter = nodemailer.createTransport({ + host, + port, + secure: port === 465, + auth: user ? { user, pass } : undefined, + }); + + console.log( + `[SMTP] Provider initialized: host=${host}, port=${port}, from=${this.defaultFrom}`, + ); + } + + async send(options: SendEmailOptions): Promise { + const from = options.fromEmail ?? this.defaultFrom; + console.log( + `[SMTP] Sending email: to=${options.to}, from=${from}, subject="${options.subject}"`, + ); + + try { + const info = await this.transporter.sendMail({ + from, + to: options.to, + subject: options.subject, + text: options.textBody, + html: options.htmlBody, + }); + + console.log( + `[SMTP] SUCCESS: Email sent to ${options.to}, messageId=${info.messageId}`, + ); + return true; + } catch (error) { + console.error( + `[SMTP] FAILED to ${options.to}:`, + error instanceof Error ? error.message : error, + ); + throw error; + } + } + + async sendBulk(messages: SendEmailOptions[]): Promise { + const results: boolean[] = []; + for (const msg of messages) { + try { + const success = await this.send(msg); + results.push(success); + } catch { + results.push(false); + } + } + return results; + } +} diff --git a/src/services/messaging/providers/twilio-sms.ts b/src/services/messaging/providers/twilio-sms.ts new file mode 100644 index 0000000..49745b8 --- /dev/null +++ b/src/services/messaging/providers/twilio-sms.ts @@ -0,0 +1,25 @@ +/** + * Twilio SMS provider stub. + * + * Mirrors Django `messaging/providers/twilio_sms.py`. + * Ready for future implementation with the Twilio SDK. + * + * Required env vars (when implemented): + * TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER + */ + +import type { SMSProvider, SendSMSOptions } from "../types.js"; + +export class TwilioSMSProvider implements SMSProvider { + constructor() { + console.warn( + "[TWILIO] SMS provider is a stub. Install 'twilio' package and implement to enable SMS.", + ); + } + + async send(_options: SendSMSOptions): Promise { + throw new Error( + "TwilioSMSProvider is a stub. Install the 'twilio' package and implement send().", + ); + } +} diff --git a/src/services/messaging/registry.ts b/src/services/messaging/registry.ts new file mode 100644 index 0000000..8e0225f --- /dev/null +++ b/src/services/messaging/registry.ts @@ -0,0 +1,75 @@ +/** + * Lazy-loading provider registry for messaging providers. + * + * Mirrors Django `messaging/registry.py`. + * Providers are cached as singletons for the process lifetime. + */ + +import type { EmailProvider, SMSProvider } from "./types.js"; + +let _emailProvider: EmailProvider | null = null; +let _smsProvider: SMSProvider | null = null; + +/** + * Get the configured email provider instance. + * + * Reads `EMAIL_PROVIDER` env var: + * - `"smtp"` (default) → SMTPEmailProvider + * - `"postmark"` → PostmarkEmailProvider + */ +export async function getEmailProvider(): Promise { + if (_emailProvider) return _emailProvider; + + const providerName = (process.env.EMAIL_PROVIDER ?? "smtp").toLowerCase(); + + switch (providerName) { + case "postmark": { + const { PostmarkEmailProvider } = await import( + "./providers/postmark.js" + ); + _emailProvider = new PostmarkEmailProvider(); + break; + } + case "smtp": + default: { + const { SMTPEmailProvider } = await import("./providers/smtp.js"); + _emailProvider = new SMTPEmailProvider(); + break; + } + } + + console.log(`[MESSAGING] Email provider loaded: ${providerName}`); + return _emailProvider; +} + +/** + * Get the configured SMS provider instance. + * + * Reads `SMS_PROVIDER` env var: + * - `"twilio"` (default) → TwilioSMSProvider + */ +export async function getSMSProvider(): Promise { + if (_smsProvider) return _smsProvider; + + const providerName = (process.env.SMS_PROVIDER ?? "twilio").toLowerCase(); + + switch (providerName) { + case "twilio": + default: { + const { TwilioSMSProvider } = await import( + "./providers/twilio-sms.js" + ); + _smsProvider = new TwilioSMSProvider(); + break; + } + } + + console.log(`[MESSAGING] SMS provider loaded: ${providerName}`); + return _smsProvider; +} + +/** Reset cached provider instances. Useful for testing. */ +export function resetProviders(): void { + _emailProvider = null; + _smsProvider = null; +} diff --git a/src/services/messaging/templates.ts b/src/services/messaging/templates.ts new file mode 100644 index 0000000..215d418 --- /dev/null +++ b/src/services/messaging/templates.ts @@ -0,0 +1,115 @@ +/** + * Email templates for the CLEAR platform. + * + * Mirrors Django `alerts/services/notifications.py` fallback templates. + * Returns { subject, textBody, htmlBody } for each template type. + */ + +interface EmailContent { + subject: string; + textBody: string; + htmlBody: string; +} + +/** + * Email verification template. + * + * Sent when a user requests email verification from their profile page. + */ +export function emailVerification( + userName: string, + verificationUrl: string, +): EmailContent { + const displayName = userName || "there"; + + return { + subject: "Verify your email address — CLEAR Platform", + + textBody: `Hi ${displayName}, + +Please verify your email address by clicking the link below: + +${verificationUrl} + +This link will expire in 24 hours. + +If you did not request this verification, you can safely ignore this email. + +— The CLEAR Platform Team`, + + htmlBody: ` + + + + + + + + + + +
+ + + + + + + + + + + + + + + +
+

+ CLEAR Platform +

+

+ Email Verification +

+
+

+ Hi ${displayName}, +

+

+ Please verify your email address by clicking the button below. This will enable you to receive email notifications for crisis alerts and updates. +

+ + + + + + +
+ + Verify Email Address + +
+ +

+ This link will expire in 24 hours. If the button doesn't work, copy and paste this URL into your browser: +

+

+ ${verificationUrl} +

+ +
+ +

+ If you did not request this verification, you can safely ignore this email. +

+
+

+ © CLEAR — Crisis Landscape Early Assessment and Response +

+
+
+ +`, + }; +} diff --git a/src/services/messaging/types.ts b/src/services/messaging/types.ts new file mode 100644 index 0000000..e0cd41b --- /dev/null +++ b/src/services/messaging/types.ts @@ -0,0 +1,47 @@ +/** + * Abstract messaging provider interfaces. + * + * Mirrors the Django `messaging/base.py` pattern — each delivery channel + * (email, SMS) has a pluggable provider that can be swapped via env vars. + */ + +/* ─── Email ─────────────────────────────────────────────────────────────────── */ + +export interface SendEmailOptions { + to: string; + subject: string; + textBody: string; + htmlBody?: string; + fromEmail?: string; +} + +export interface EmailProvider { + /** + * Send a single email. + * @returns true if the email was sent successfully + */ + send(options: SendEmailOptions): Promise; + + /** + * Send multiple emails. + * @returns Array of booleans indicating success/failure for each message + */ + sendBulk(messages: SendEmailOptions[]): Promise; +} + +/* ─── SMS ───────────────────────────────────────────────────────────────────── */ + +export interface SendSMSOptions { + /** Recipient phone number in E.164 format (e.g. +249912345678) */ + to: string; + /** SMS message body */ + body: string; +} + +export interface SMSProvider { + /** + * Send a single SMS message. + * @returns true if the message was sent successfully + */ + send(options: SendSMSOptions): Promise; +} diff --git a/src/utils/env.ts b/src/utils/env.ts index 1f9c1f4..7941b3a 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -7,6 +7,19 @@ const envSchema = z.object({ DATABASE_URL: z.string(), BETTER_AUTH_SECRET: z.string().min(32), BETTER_AUTH_URL: z.string().url(), + + // Frontend URL (for verification links) + FRONTEND_URL: z.string().default("http://localhost:3000"), + + // Email provider: "smtp" | "postmark" + EMAIL_PROVIDER: z.string().default("smtp"), + SMTP_HOST: z.string().optional(), + SMTP_PORT: z.coerce.number().default(587), + SMTP_USER: z.string().optional(), + SMTP_PASS: z.string().optional(), + SMTP_FROM: z.string().default("noreply@clear-platform.org"), + POSTMARK_SERVER_TOKEN: z.string().optional(), + POSTMARK_SENDER_EMAIL: z.string().optional(), }); const parsed = envSchema.parse(process.env);