diff --git a/prisma/seed.ts b/prisma/seed.ts index 32d514c..b37cb69 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -48,67 +48,140 @@ async function seed() { console.log(`Created 3 users: admin (${admin.id}), analyst (${analyst.id}), viewer (${viewer.id})`); - // ─── Locations (3-level hierarchy) ───────────────────────────────────────── - const us = await prisma.location.create({ - data: { geoId: "US", name: "United States", level: 0 }, + // ─── Locations (Sudan hierarchy: Country → State → Locality) ───────────── + // Level 0: Country + const sudan = await prisma.location.create({ + data: { geoId: "SD", name: "Sudan", level: 0 }, }); - const [california, texas, newYork] = await Promise.all([ + // Level 1: States + const [khartoum, northDarfur, southDarfur, northKordofan] = await Promise.all([ prisma.location.create({ - data: { geoId: "US-CA", name: "California", level: 1, parentId: us.id }, + data: { geoId: "SD_001", name: "Khartoum", level: 1, parentId: sudan.id, pointType: "CENTROID" }, }), prisma.location.create({ - data: { geoId: "US-TX", name: "Texas", level: 1, parentId: us.id }, + data: { geoId: "SD_002", name: "North Darfur", level: 1, parentId: sudan.id, pointType: "CENTROID" }, }), prisma.location.create({ - data: { geoId: "US-NY", name: "New York", level: 1, parentId: us.id }, + data: { geoId: "SD_003", name: "South Darfur", level: 1, parentId: sudan.id, pointType: "CENTROID" }, + }), + prisma.location.create({ + data: { geoId: "SD_004", name: "North Kordofan", level: 1, parentId: sudan.id, pointType: "CENTROID" }, }), ]); - const [losAngeles, sanFrancisco, houston, nyc] = await Promise.all([ + // Level 2: Localities + const [khartoumCity, omdurman, elFasher, kutum, nyala, elDaein] = await Promise.all([ + prisma.location.create({ + data: { geoId: "SD_001_001", name: "Khartoum City", level: 2, parentId: khartoum.id, pointType: "CENTROID" }, + }), + prisma.location.create({ + data: { geoId: "SD_001_002", name: "Omdurman", level: 2, parentId: khartoum.id, pointType: "CENTROID" }, + }), prisma.location.create({ - data: { geoId: "US-CA-LA", name: "Los Angeles", level: 2, parentId: california.id }, + data: { geoId: "SD_002_001", name: "El Fasher", level: 2, parentId: northDarfur.id, pointType: "CENTROID" }, }), prisma.location.create({ - data: { geoId: "US-CA-SF", name: "San Francisco", level: 2, parentId: california.id }, + data: { geoId: "SD_002_002", name: "Kutum", level: 2, parentId: northDarfur.id, pointType: "CENTROID" }, }), prisma.location.create({ - data: { geoId: "US-TX-HOU", name: "Houston", level: 2, parentId: texas.id }, + data: { geoId: "SD_003_001", name: "Nyala", level: 2, parentId: southDarfur.id, pointType: "CENTROID" }, }), prisma.location.create({ - data: { geoId: "US-NY-NYC", name: "New York City", level: 2, parentId: newYork.id }, + data: { geoId: "SD_003_002", name: "Ed Daein", level: 2, parentId: southDarfur.id, pointType: "CENTROID" }, }), ]); - console.log("Created 8 locations (1 country, 3 states, 4 cities)"); + // 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, + 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, + 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, + 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, + 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 }, + { id: kutum.id, lon: 24.67, lat: 14.20, boundary: null }, + { id: nyala.id, lon: 24.88, lat: 12.05, boundary: null }, + { id: elDaein.id, lon: 26.13, lat: 11.46, boundary: null }, + ]; + + 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, + ); + + // 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, + ); + } + } + + console.log("Created 11 locations (1 country, 4 states, 6 localities) with geographic data"); // ─── Data Sources ────────────────────────────────────────────────────────── - const [twitter, newsApi, govRss] = await Promise.all([ + const [socialMedia, newsApi, govReports] = await Promise.all([ prisma.dataSource.create({ data: { - name: "Twitter/X", + name: "Social Media Monitor", type: "social_media", isActive: true, - baseUrl: "https://api.twitter.com/2", - infoUrl: "https://developer.twitter.com", + baseUrl: "https://api.social-monitor.org/v2", + infoUrl: "https://social-monitor.org", }, }), prisma.dataSource.create({ data: { - name: "NewsAPI", - type: "news_aggregator", + name: "ACLED Conflict Data", + type: "conflict_tracker", isActive: true, - baseUrl: "https://newsapi.org/v2", - infoUrl: "https://newsapi.org", + baseUrl: "https://acleddata.com/api/v3", + infoUrl: "https://acleddata.com", }, }), prisma.dataSource.create({ data: { - name: "Government RSS", - type: "rss_feed", - isActive: false, - baseUrl: "https://www.govinfo.gov/rss", - infoUrl: "https://www.govinfo.gov", + name: "FEWS NET", + type: "food_security", + isActive: true, + baseUrl: "https://fews.net/api", + infoUrl: "https://fews.net", }, }), ]); @@ -119,71 +192,71 @@ async function seed() { const [det1, det2, det3, det4, det5, det6] = await Promise.all([ prisma.detection.create({ data: { - title: "Unusual seismic activity reported near LA", - confidence: 0.87, + title: "Armed clashes reported near El Fasher", + confidence: 0.91, status: "processed", - sourceId: twitter.id, - rawData: { tweets: 142, sentiment: "negative", hashtags: ["#earthquake", "#LA"] }, + sourceId: newsApi.id, + rawData: { events: 12, fatalities: "unknown", source: "ACLED" }, }, }), prisma.detection.create({ data: { - title: "Wildfire smoke detected in satellite imagery", - confidence: 0.95, + title: "Displacement surge detected in South Darfur", + confidence: 0.88, status: "processed", - sourceId: twitter.id, - rawData: { tweets: 89, sentiment: "alarmed", hashtags: ["#wildfire", "#CalFire"] }, + sourceId: socialMedia.id, + rawData: { posts: 234, sentiment: "distress", hashtags: ["#Darfur", "#displacement"] }, }, }), prisma.detection.create({ data: { - title: "Flash flood warnings issued for Houston area", - confidence: 0.92, + title: "Flood warnings along the Nile in Khartoum", + confidence: 0.94, status: "processed", - sourceId: newsApi.id, - rawData: { articles: 23, sources: ["AP", "Reuters", "local news"] }, + sourceId: socialMedia.id, + rawData: { posts: 187, sentiment: "alarmed", hashtags: ["#KhartoumFloods", "#Nile"] }, }, }), prisma.detection.create({ data: { - title: "Minor tremor registered in upstate New York", - confidence: 0.45, + title: "Minor locust sighting in North Kordofan", + confidence: 0.42, status: "raw", - sourceId: newsApi.id, - rawData: { articles: 3, sources: ["local news"] }, + sourceId: govReports.id, + rawData: { report_id: "FAO-2026-SD-047", agency: "FAO" }, }, }), prisma.detection.create({ data: { - title: "Coastal erosion report from NOAA", - confidence: 0.78, + title: "Food insecurity escalation in Kutum locality", + confidence: 0.85, status: "processed", - sourceId: govRss.id, - rawData: { report_id: "NOAA-2026-0312", agency: "NOAA" }, + sourceId: govReports.id, + rawData: { ipc_phase: 4, report_id: "FEWSNET-2026-03", population_affected: "120K" }, }, }), prisma.detection.create({ data: { - title: "Duplicate weather station reading", - confidence: 0.3, + title: "Duplicate weather station reading - Omdurman", + confidence: 0.25, status: "ignored", - sourceId: govRss.id, - rawData: { station: "WX-4421", note: "sensor malfunction confirmed" }, + sourceId: socialMedia.id, + rawData: { station: "SD-WX-0012", note: "sensor malfunction confirmed" }, }, }), ]); // Link detections to locations await Promise.all([ - prisma.detectionLocation.create({ data: { detectionId: det1.id, locationId: losAngeles.id } }), - prisma.detectionLocation.create({ data: { detectionId: det1.id, locationId: california.id } }), - prisma.detectionLocation.create({ data: { detectionId: det2.id, locationId: sanFrancisco.id } }), - prisma.detectionLocation.create({ data: { detectionId: det2.id, locationId: california.id } }), - prisma.detectionLocation.create({ data: { detectionId: det3.id, locationId: houston.id } }), - prisma.detectionLocation.create({ data: { detectionId: det3.id, locationId: texas.id } }), - prisma.detectionLocation.create({ data: { detectionId: det4.id, locationId: newYork.id } }), - prisma.detectionLocation.create({ data: { detectionId: det5.id, locationId: nyc.id } }), - prisma.detectionLocation.create({ data: { detectionId: det5.id, locationId: newYork.id } }), + 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 } }), ]); console.log("Created 6 detections with location links"); @@ -232,72 +305,72 @@ async function seed() { const [alert1, alert2, alert3, alert4] = await Promise.all([ prisma.alert.create({ data: { - title: "Earthquake Risk Alert - Los Angeles", + title: "Armed Conflict Escalation - El Fasher, North Darfur", description: - "Multiple social media reports and seismic data indicate increased earthquake risk in the greater Los Angeles area. Residents should review emergency preparedness plans.", - severity: 4, + "ACLED conflict data confirms intensified armed clashes in and around El Fasher. Civilian displacement ongoing. Humanitarian access severely constrained.", + severity: 5, status: "published", - sourceId: twitter.id, + sourceId: newsApi.id, createdById: admin.id, primaryEventId: evt1.id, events: { connect: [{ id: evt1.id }] }, - metadata: { category: "seismic", affectedPopulation: "10M+" }, + metadata: { category: "conflict", affectedPopulation: "500K+", ipcPhase: 4 }, }, }), prisma.alert.create({ data: { - title: "Wildfire Smoke Advisory - Northern California", + title: "Mass Displacement Alert - South Darfur", description: - "Satellite imagery confirms active wildfire producing significant smoke. Air quality index may exceed safe levels in the Bay Area over the next 48 hours.", - severity: 3, + "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: twitter.id, + sourceId: socialMedia.id, createdById: analyst.id, primaryEventId: evt2.id, events: { connect: [{ id: evt2.id }] }, - metadata: { category: "wildfire", aqi: "unhealthy" }, + metadata: { category: "displacement", estimatedIDPs: "75K" }, }, }), prisma.alert.create({ data: { - title: "Flash Flood Warning - Houston Metro", + title: "Nile Flood Warning - Khartoum State", description: - "National Weather Service has issued flash flood warnings for the Houston metropolitan area. Multiple news sources confirm rising water levels.", - severity: 5, + "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: newsApi.id, + sourceId: socialMedia.id, createdById: admin.id, primaryEventId: evt3.id, events: { connect: [{ id: evt3.id }] }, - metadata: { category: "flood", nwsWarningId: "TX-2026-0845" }, + metadata: { category: "flood", nileLevel: "17.5m", threshold: "17.0m" }, }, }), prisma.alert.create({ data: { - title: "Coastal Erosion Update - NYC Waterfront", + title: "Food Insecurity Crisis - Kutum, North Darfur", description: - "NOAA report indicates accelerated coastal erosion along NYC waterfront areas. Infrastructure assessments recommended.", - severity: 2, + "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: govRss.id, + sourceId: govReports.id, createdById: analyst.id, primaryEventId: evt4.id, events: { connect: [{ id: evt4.id }] }, - metadata: { category: "erosion", reportRef: "NOAA-2026-0312" }, + metadata: { category: "food_security", ipcPhase: 4, reportRef: "FEWSNET-2026-03" }, }, }), ]); // Link alerts to locations await Promise.all([ - prisma.alertLocation.create({ data: { alertId: alert1.id, locationId: losAngeles.id } }), - prisma.alertLocation.create({ data: { alertId: alert1.id, locationId: california.id } }), - prisma.alertLocation.create({ data: { alertId: alert2.id, locationId: sanFrancisco.id } }), - prisma.alertLocation.create({ data: { alertId: alert2.id, locationId: california.id } }), - prisma.alertLocation.create({ data: { alertId: alert3.id, locationId: houston.id } }), - prisma.alertLocation.create({ data: { alertId: alert3.id, locationId: texas.id } }), - prisma.alertLocation.create({ data: { alertId: alert4.id, locationId: nyc.id } }), - prisma.alertLocation.create({ data: { alertId: alert4.id, locationId: newYork.id } }), + 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 } }), ]); console.log("Created 4 alerts with event and location links"); @@ -310,7 +383,7 @@ async function seed() { alertId: alert1.id, readAt: new Date(), rating: 5, - comment: "High confidence alert. Seismic data corroborates social media signals.", + comment: "Critical alert. ACLED data matches ground reports from our field team.", }, }), prisma.userAlert.create({ @@ -319,7 +392,7 @@ async function seed() { alertId: alert1.id, readAt: new Date(), rating: 4, - comment: "Useful alert, shared with our local emergency team.", + comment: "Shared with our humanitarian coordination team in North Darfur.", }, }), prisma.userAlert.create({ @@ -328,7 +401,7 @@ async function seed() { alertId: alert2.id, readAt: new Date(), rating: 4, - comment: "Good catch from satellite data. AQI prediction was accurate.", + comment: "Displacement figures align with UNHCR preliminary estimates.", }, }), prisma.userAlert.create({ @@ -337,7 +410,7 @@ async function seed() { alertId: alert2.id, readAt: new Date(), rating: 3, - comment: "Alert was helpful but arrived a bit late.", + comment: "Useful but would benefit from more granular location data.", }, }), prisma.userAlert.create({ @@ -346,7 +419,7 @@ async function seed() { alertId: alert4.id, readAt: new Date(), rating: 3, - comment: "Informational but low urgency. Good to have in the archive.", + comment: "Good baseline data for food security monitoring. Archived for trend analysis.", }, }), ]); @@ -358,7 +431,7 @@ async function seed() { prisma.notifications.create({ data: { userId: analyst.id, - message: "New earthquake alert published for Los Angeles area", + message: "New conflict alert published for El Fasher, North Darfur", notificationType: "alert", actionUrl: `/alerts/${alert1.id}`, actionText: "View Alert", @@ -368,7 +441,7 @@ async function seed() { prisma.notifications.create({ data: { userId: viewer.id, - message: "Flash flood warning drafted for Houston Metro", + message: "Flood warning drafted for Khartoum State", notificationType: "alert", actionUrl: `/alerts/${alert3.id}`, actionText: "View Alert", diff --git a/src/resolvers/alert.resolver.ts b/src/resolvers/alert.resolver.ts index abbd72f..96452c7 100644 --- a/src/resolvers/alert.resolver.ts +++ b/src/resolvers/alert.resolver.ts @@ -1,5 +1,32 @@ +import { GraphQLError } from "graphql"; import type { Context } from "../context.js"; import type { AlertStatus } from "../generated/prisma/client.js"; +import type { InputJsonValue } from "../generated/prisma/internal/prismaNamespace.js"; +import { requireRole } from "../utils/auth-guard.js"; + +interface CreateAlertInput { + title: string; + description: string; + severity: number; + status?: AlertStatus; + sourceId?: string; + primaryEventId?: string; + eventIds?: string[]; + locationIds?: string[]; + metadata?: Record; +} + +interface UpdateAlertInput { + title?: string; + description?: string; + severity?: number; + status?: AlertStatus; + sourceId?: string; + primaryEventId?: string; + eventIds?: string[]; + locationIds?: string[]; + metadata?: Record; +} export const alertResolvers = { Query: { @@ -8,12 +35,117 @@ export const alertResolvers = { where: args.status ? { status: args.status } : undefined, }); }, - alert: (_parent: unknown, args: { id: number }, { prisma }: Context) => { + alert: (_parent: unknown, args: { id: string }, { prisma }: Context) => { return prisma.alert.findUnique({ where: { id: args.id } }); }, }, + Mutation: { + createAlert: async ( + _parent: unknown, + args: { input: CreateAlertInput }, + context: Context, + ) => { + const user = requireRole(context, ["admin", "analyst"]); + const { input } = args; + + const alert = await context.prisma.alert.create({ + data: { + title: input.title, + 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, + events: input.eventIds?.length + ? { connect: input.eventIds.map((id) => ({ id })) } + : undefined, + }, + }); + + if (input.locationIds?.length) { + await context.prisma.alertLocation.createMany({ + data: input.locationIds.map((locationId) => ({ + alertId: alert.id, + locationId, + })), + }); + } + + return alert; + }, + + updateAlert: async ( + _parent: unknown, + args: { id: string; input: UpdateAlertInput }, + context: Context, + ) => { + requireRole(context, ["admin", "analyst"]); + const { id, input } = args; + + const existing = await context.prisma.alert.findUnique({ where: { id } }); + if (!existing) { + throw new GraphQLError("Alert not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + if (input.eventIds !== undefined) { + await context.prisma.alert.update({ + where: { id }, + data: { events: { set: input.eventIds.map((eid) => ({ id: eid })) } }, + }); + } + + if (input.locationIds !== undefined) { + await context.prisma.alertLocation.deleteMany({ where: { alertId: id } }); + if (input.locationIds.length) { + await context.prisma.alertLocation.createMany({ + data: input.locationIds.map((locationId) => ({ + alertId: id, + locationId, + })), + }); + } + } + + return context.prisma.alert.update({ + where: { id }, + data: { + title: input.title ?? undefined, + description: input.description ?? undefined, + severity: input.severity ?? undefined, + status: input.status ?? undefined, + sourceId: input.sourceId, + primaryEventId: input.primaryEventId, + metadata: input.metadata as InputJsonValue | undefined, + }, + }); + }, + + deleteAlert: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + requireRole(context, ["admin"]); + + const existing = await context.prisma.alert.findUnique({ + where: { id: args.id }, + }); + if (!existing) { + throw new GraphQLError("Alert not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + await context.prisma.alert.delete({ where: { id: args.id } }); + return true; + }, + }, Alert: { - source: (parent: { sourceId: number | null }, _args: unknown, { prisma }: Context) => { + source: (parent: { sourceId: string | null }, _args: unknown, { prisma }: Context) => { if (!parent.sourceId) return null; return prisma.dataSource.findUnique({ where: { id: parent.sourceId } }); }, @@ -21,21 +153,23 @@ export const alertResolvers = { if (!parent.createdById) return null; return prisma.user.findUnique({ where: { id: parent.createdById } }); }, - primaryDetection: ( - parent: { primaryDetectionId: number | null }, + primaryEvent: ( + parent: { primaryEventId: string | null }, _args: unknown, { prisma }: Context, ) => { - if (!parent.primaryDetectionId) return null; - return prisma.detection.findUnique({ where: { id: parent.primaryDetectionId } }); + if (!parent.primaryEventId) return null; + return prisma.event.findUnique({ where: { id: parent.primaryEventId } }); }, - detections: (parent: { id: number }, _args: unknown, { prisma }: Context) => { - return prisma.detection.findMany({ where: { alertId: parent.id } }); + events: (parent: { id: string }, _args: unknown, { prisma }: Context) => { + return prisma.alert + .findUnique({ where: { id: parent.id } }) + .events(); }, - locations: (parent: { id: number }, _args: unknown, { prisma }: Context) => { + locations: (parent: { id: string }, _args: unknown, { prisma }: Context) => { return prisma.alertLocation.findMany({ where: { alertId: parent.id } }); }, - feedback: (parent: { id: number }, _args: unknown, { prisma }: Context) => { + feedback: (parent: { id: string }, _args: unknown, { prisma }: Context) => { return prisma.userAlert.findMany({ where: { alertId: parent.id } }); }, }, @@ -43,7 +177,7 @@ export const alertResolvers = { user: (parent: { userId: string }, _args: unknown, { prisma }: Context) => { return prisma.user.findUnique({ where: { id: parent.userId } }); }, - alert: (parent: { alertId: number }, _args: unknown, { prisma }: Context) => { + alert: (parent: { alertId: string }, _args: unknown, { prisma }: Context) => { return prisma.alert.findUnique({ where: { id: parent.alertId } }); }, }, diff --git a/src/resolvers/apiKey.resolver.ts b/src/resolvers/apiKey.resolver.ts index fde8f35..31940be 100644 --- a/src/resolvers/apiKey.resolver.ts +++ b/src/resolvers/apiKey.resolver.ts @@ -51,7 +51,7 @@ export const apiKeyResolvers = { revokeApiKey: async ( _parent: unknown, - args: { id: number }, + args: { id: string }, context: Context, ) => { const user = requireAuth(context); diff --git a/src/resolvers/auth.resolver.ts b/src/resolvers/auth.resolver.ts index 32a3b1b..8386671 100644 --- a/src/resolvers/auth.resolver.ts +++ b/src/resolvers/auth.resolver.ts @@ -1,4 +1,7 @@ +import { GraphQLError } from "graphql"; +import { randomBytes } from "crypto"; import type { Context } from "../context.js"; +import { requireAuth } from "../utils/auth-guard.js"; export const authResolvers = { Query: { @@ -7,4 +10,63 @@ export const authResolvers = { return user; }, }, + Mutation: { + requestEmailVerification: async ( + _parent: unknown, + _args: unknown, + context: Context, + ) => { + const user = requireAuth(context); + + if (user.emailVerified) { + throw new GraphQLError("Email is already verified", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + const token = randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours + + await context.prisma.verification.create({ + data: { + identifier: user.email, + value: token, + expiresAt, + }, + }); + + // TODO: Send verification email via messaging provider + return true; + }, + + verifyEmail: async ( + _parent: unknown, + args: { token: string }, + context: Context, + ) => { + const verification = await context.prisma.verification.findFirst({ + where: { + value: args.token, + expiresAt: { gt: new Date() }, + }, + }); + + if (!verification) { + throw new GraphQLError("Invalid or expired verification token", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + await context.prisma.user.updateMany({ + where: { email: verification.identifier }, + data: { emailVerified: true }, + }); + + await context.prisma.verification.delete({ + where: { id: verification.id }, + }); + + return true; + }, + }, }; diff --git a/src/resolvers/dataSource.resolver.ts b/src/resolvers/dataSource.resolver.ts index 0655699..1050dbd 100644 --- a/src/resolvers/dataSource.resolver.ts +++ b/src/resolvers/dataSource.resolver.ts @@ -1,19 +1,104 @@ +import { GraphQLError } from "graphql"; import type { Context } from "../context.js"; +import { requireRole } from "../utils/auth-guard.js"; + +interface CreateDataSourceInput { + name: string; + type: string; + isActive?: boolean; + baseUrl?: string; + infoUrl?: string; +} + +interface UpdateDataSourceInput { + name?: string; + type?: string; + isActive?: boolean; + baseUrl?: string; + infoUrl?: string; +} export const dataSourceResolvers = { Query: { dataSources: (_parent: unknown, _args: unknown, { prisma }: Context) => { return prisma.dataSource.findMany(); }, - dataSource: (_parent: unknown, args: { id: number }, { prisma }: Context) => { + dataSource: (_parent: unknown, args: { id: string }, { prisma }: Context) => { return prisma.dataSource.findUnique({ where: { id: args.id } }); }, }, + Mutation: { + createDataSource: async ( + _parent: unknown, + args: { input: CreateDataSourceInput }, + context: Context, + ) => { + requireRole(context, ["admin"]); + const { input } = args; + + return context.prisma.dataSource.create({ + data: { + name: input.name, + type: input.type, + isActive: input.isActive ?? true, + baseUrl: input.baseUrl, + infoUrl: input.infoUrl, + }, + }); + }, + + updateDataSource: async ( + _parent: unknown, + args: { id: string; input: UpdateDataSourceInput }, + context: Context, + ) => { + requireRole(context, ["admin"]); + const { id, input } = args; + + const existing = await context.prisma.dataSource.findUnique({ where: { id } }); + if (!existing) { + throw new GraphQLError("DataSource not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + return context.prisma.dataSource.update({ + where: { id }, + data: { + name: input.name ?? undefined, + type: input.type ?? undefined, + isActive: input.isActive ?? undefined, + baseUrl: input.baseUrl, + infoUrl: input.infoUrl, + }, + }); + }, + + deleteDataSource: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + requireRole(context, ["admin"]); + + const existing = await context.prisma.dataSource.findUnique({ + where: { id: args.id }, + }); + if (!existing) { + throw new GraphQLError("DataSource not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + await context.prisma.dataSource.delete({ where: { id: args.id } }); + return true; + }, + }, DataSource: { - detections: (parent: { id: number }, _args: unknown, { prisma }: Context) => { + detections: (parent: { id: string }, _args: unknown, { prisma }: Context) => { return prisma.detection.findMany({ where: { sourceId: parent.id } }); }, - alerts: (parent: { id: number }, _args: unknown, { prisma }: Context) => { + alerts: (parent: { id: string }, _args: unknown, { prisma }: Context) => { return prisma.alert.findMany({ where: { sourceId: parent.id } }); }, }, diff --git a/src/resolvers/detection.resolver.ts b/src/resolvers/detection.resolver.ts index df90302..cbf2fba 100644 --- a/src/resolvers/detection.resolver.ts +++ b/src/resolvers/detection.resolver.ts @@ -1,5 +1,27 @@ +import { GraphQLError } from "graphql"; import type { Context } from "../context.js"; import type { DetectionStatus } from "../generated/prisma/client.js"; +import type { InputJsonValue } from "../generated/prisma/internal/prismaNamespace.js"; +import { requireRole } from "../utils/auth-guard.js"; + +interface CreateDetectionInput { + title: string; + confidence?: number; + status?: DetectionStatus; + detectedAt?: string; + rawData?: Record; + sourceId?: string; + locationIds?: string[]; +} + +interface UpdateDetectionInput { + title?: string; + confidence?: number; + status?: DetectionStatus; + rawData?: Record; + sourceId?: string; + locationIds?: string[]; +} export const detectionResolvers = { Query: { @@ -12,20 +34,110 @@ export const detectionResolvers = { where: args.status ? { status: args.status } : undefined, }); }, - detection: (_parent: unknown, args: { id: number }, { prisma }: Context) => { + detection: (_parent: unknown, args: { id: string }, { prisma }: Context) => { return prisma.detection.findUnique({ where: { id: args.id } }); }, }, + Mutation: { + createDetection: async ( + _parent: unknown, + args: { input: CreateDetectionInput }, + context: Context, + ) => { + requireRole(context, ["admin", "analyst"]); + const { input } = args; + + const detection = await context.prisma.detection.create({ + data: { + title: input.title, + confidence: input.confidence, + status: input.status ?? "raw", + detectedAt: input.detectedAt ? new Date(input.detectedAt) : undefined, + rawData: input.rawData ? (input.rawData as InputJsonValue) : undefined, + sourceId: input.sourceId, + }, + }); + + if (input.locationIds?.length) { + await context.prisma.detectionLocation.createMany({ + data: input.locationIds.map((locationId) => ({ + detectionId: detection.id, + locationId, + })), + }); + } + + return detection; + }, + + updateDetection: async ( + _parent: unknown, + args: { id: string; input: UpdateDetectionInput }, + context: Context, + ) => { + requireRole(context, ["admin", "analyst"]); + const { id, input } = args; + + const existing = await context.prisma.detection.findUnique({ where: { id } }); + if (!existing) { + throw new GraphQLError("Detection not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + if (input.locationIds !== undefined) { + await context.prisma.detectionLocation.deleteMany({ where: { detectionId: id } }); + if (input.locationIds.length) { + await context.prisma.detectionLocation.createMany({ + data: input.locationIds.map((locationId) => ({ + detectionId: id, + locationId, + })), + }); + } + } + + return context.prisma.detection.update({ + where: { id }, + data: { + title: input.title ?? undefined, + confidence: input.confidence ?? undefined, + status: input.status ?? undefined, + rawData: input.rawData as InputJsonValue | undefined, + sourceId: input.sourceId, + }, + }); + }, + + deleteDetection: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + requireRole(context, ["admin"]); + + const existing = await context.prisma.detection.findUnique({ + where: { id: args.id }, + }); + if (!existing) { + throw new GraphQLError("Detection not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + await context.prisma.detection.delete({ where: { id: args.id } }); + return true; + }, + }, Detection: { - source: (parent: { sourceId: number | null }, _args: unknown, { prisma }: Context) => { + source: (parent: { sourceId: string | null }, _args: unknown, { prisma }: Context) => { if (!parent.sourceId) return null; return prisma.dataSource.findUnique({ where: { id: parent.sourceId } }); }, - alert: (parent: { alertId: number | null }, _args: unknown, { prisma }: Context) => { - if (!parent.alertId) return null; - return prisma.alert.findUnique({ where: { id: parent.alertId } }); + signal: (parent: { id: string }, _args: unknown, { prisma }: Context) => { + return prisma.signal.findUnique({ where: { detectionId: parent.id } }); }, - locations: (parent: { id: number }, _args: unknown, { prisma }: Context) => { + locations: (parent: { id: string }, _args: unknown, { prisma }: Context) => { return prisma.detectionLocation.findMany({ where: { detectionId: parent.id } }); }, }, diff --git a/src/resolvers/event.resolver.ts b/src/resolvers/event.resolver.ts new file mode 100644 index 0000000..32c0c02 --- /dev/null +++ b/src/resolvers/event.resolver.ts @@ -0,0 +1,105 @@ +import { GraphQLError } from "graphql"; +import type { Context } from "../context.js"; +import { requireRole } from "../utils/auth-guard.js"; + +interface CreateEventInput { + signalIds: string[]; + primarySignalId?: string; +} + +interface UpdateEventInput { + signalIds?: string[]; + primarySignalId?: string; +} + +export const eventResolvers = { + Query: { + events: (_parent: unknown, _args: unknown, { prisma }: Context) => { + return prisma.event.findMany(); + }, + event: (_parent: unknown, args: { id: string }, { prisma }: Context) => { + return prisma.event.findUnique({ where: { id: args.id } }); + }, + }, + Mutation: { + createEvent: async ( + _parent: unknown, + args: { input: CreateEventInput }, + context: Context, + ) => { + requireRole(context, ["admin", "analyst"]); + const { input } = args; + + return context.prisma.event.create({ + data: { + primarySignalId: input.primarySignalId, + signals: { + connect: input.signalIds.map((id) => ({ id })), + }, + }, + }); + }, + + updateEvent: async ( + _parent: unknown, + args: { id: string; input: UpdateEventInput }, + context: Context, + ) => { + requireRole(context, ["admin", "analyst"]); + const { id, input } = args; + + const existing = await context.prisma.event.findUnique({ where: { id } }); + if (!existing) { + throw new GraphQLError("Event not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + return context.prisma.event.update({ + where: { id }, + data: { + primarySignalId: input.primarySignalId, + signals: input.signalIds + ? { set: input.signalIds.map((sid) => ({ id: sid })) } + : undefined, + }, + }); + }, + + deleteEvent: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + requireRole(context, ["admin"]); + + const existing = await context.prisma.event.findUnique({ + where: { id: args.id }, + }); + if (!existing) { + throw new GraphQLError("Event not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + await context.prisma.event.delete({ where: { id: args.id } }); + return true; + }, + }, + Event: { + signals: (parent: { id: string }, _args: unknown, { prisma }: Context) => { + return prisma.event + .findUnique({ where: { id: parent.id } }) + .signals(); + }, + primarySignal: (parent: { primarySignalId: string | null }, _args: unknown, { prisma }: Context) => { + if (!parent.primarySignalId) return null; + return prisma.signal.findUnique({ where: { id: parent.primarySignalId } }); + }, + alerts: (parent: { id: string }, _args: unknown, { prisma }: Context) => { + return prisma.event + .findUnique({ where: { id: parent.id } }) + .alerts(); + }, + }, +}; diff --git a/src/resolvers/index.ts b/src/resolvers/index.ts index c2197d2..f99be62 100644 --- a/src/resolvers/index.ts +++ b/src/resolvers/index.ts @@ -1,21 +1,28 @@ +import type { IResolvers } from "@graphql-tools/utils"; import { scalarResolvers } from "./scalars.resolver.js"; import { authResolvers } from "./auth.resolver.js"; import { userResolvers } from "./user.resolver.js"; import { alertResolvers } from "./alert.resolver.js"; import { detectionResolvers } from "./detection.resolver.js"; +import { signalResolvers } from "./signal.resolver.js"; +import { eventResolvers } from "./event.resolver.js"; import { dataSourceResolvers } from "./dataSource.resolver.js"; import { locationResolvers } from "./location.resolver.js"; +import { notificationResolvers } from "./notification.resolver.js"; import { featureFlagResolvers } from "./featureFlag.resolver.js"; import { apiKeyResolvers } from "./apiKey.resolver.js"; -export const resolvers = [ +export const resolvers: IResolvers[] = [ scalarResolvers, authResolvers, userResolvers, alertResolvers, detectionResolvers, + signalResolvers, + eventResolvers, dataSourceResolvers, locationResolvers, + notificationResolvers, featureFlagResolvers, apiKeyResolvers, ]; diff --git a/src/resolvers/location.resolver.ts b/src/resolvers/location.resolver.ts index 6f75fee..28932d6 100644 --- a/src/resolvers/location.resolver.ts +++ b/src/resolvers/location.resolver.ts @@ -1,4 +1,22 @@ +import { GraphQLError } from "graphql"; import type { Context } from "../context.js"; +import { requireRole } from "../utils/auth-guard.js"; + +interface CreateLocationInput { + geoId: string; + name: string; + level: number; + pointType?: string; + parentId?: string; +} + +interface UpdateLocationInput { + geoId?: string; + name?: string; + level?: number; + pointType?: string; + parentId?: string; +} export const locationResolvers = { Query: { @@ -7,38 +25,105 @@ export const locationResolvers = { where: args.level !== undefined ? { level: args.level } : undefined, }); }, - location: (_parent: unknown, args: { id: number }, { prisma }: Context) => { + location: (_parent: unknown, args: { id: string }, { prisma }: Context) => { return prisma.location.findUnique({ where: { id: args.id } }); }, }, + Mutation: { + createLocation: async ( + _parent: unknown, + args: { input: CreateLocationInput }, + context: Context, + ) => { + requireRole(context, ["admin"]); + const { input } = args; + + return context.prisma.location.create({ + data: { + geoId: input.geoId, + name: input.name, + level: input.level, + pointType: input.pointType as "CENTROID" | "GPS" | undefined, + parentId: input.parentId, + }, + }); + }, + + updateLocation: async ( + _parent: unknown, + args: { id: string; input: UpdateLocationInput }, + context: Context, + ) => { + requireRole(context, ["admin"]); + const { id, input } = args; + + const existing = await context.prisma.location.findUnique({ where: { id } }); + if (!existing) { + throw new GraphQLError("Location not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + return context.prisma.location.update({ + where: { id }, + data: { + geoId: input.geoId ?? undefined, + name: input.name ?? undefined, + level: input.level ?? undefined, + pointType: input.pointType as "CENTROID" | "GPS" | undefined, + parentId: input.parentId, + }, + }); + }, + + deleteLocation: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + requireRole(context, ["admin"]); + + const existing = await context.prisma.location.findUnique({ + where: { id: args.id }, + }); + if (!existing) { + throw new GraphQLError("Location not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + await context.prisma.location.delete({ where: { id: args.id } }); + return true; + }, + }, Location: { - parent: (parent: { parentId: number | null }, _args: unknown, { prisma }: Context) => { + parent: (parent: { parentId: string | null }, _args: unknown, { prisma }: Context) => { if (!parent.parentId) return null; return prisma.location.findUnique({ where: { id: parent.parentId } }); }, - children: (parent: { id: number }, _args: unknown, { prisma }: Context) => { + children: (parent: { id: string }, _args: unknown, { prisma }: Context) => { return prisma.location.findMany({ where: { parentId: parent.id } }); }, - alertLinks: (parent: { id: number }, _args: unknown, { prisma }: Context) => { + alertLinks: (parent: { id: string }, _args: unknown, { prisma }: Context) => { return prisma.alertLocation.findMany({ where: { locationId: parent.id } }); }, - detectionLinks: (parent: { id: number }, _args: unknown, { prisma }: Context) => { + detectionLinks: (parent: { id: string }, _args: unknown, { prisma }: Context) => { return prisma.detectionLocation.findMany({ where: { locationId: parent.id } }); }, }, AlertLocation: { - alert: (parent: { alertId: number }, _args: unknown, { prisma }: Context) => { + alert: (parent: { alertId: string }, _args: unknown, { prisma }: Context) => { return prisma.alert.findUnique({ where: { id: parent.alertId } }); }, - location: (parent: { locationId: number }, _args: unknown, { prisma }: Context) => { + location: (parent: { locationId: string }, _args: unknown, { prisma }: Context) => { return prisma.location.findUnique({ where: { id: parent.locationId } }); }, }, DetectionLocation: { - detection: (parent: { detectionId: number }, _args: unknown, { prisma }: Context) => { + detection: (parent: { detectionId: string }, _args: unknown, { prisma }: Context) => { return prisma.detection.findUnique({ where: { id: parent.detectionId } }); }, - location: (parent: { locationId: number }, _args: unknown, { prisma }: Context) => { + location: (parent: { locationId: string }, _args: unknown, { prisma }: Context) => { return prisma.location.findUnique({ where: { id: parent.locationId } }); }, }, diff --git a/src/resolvers/notification.resolver.ts b/src/resolvers/notification.resolver.ts new file mode 100644 index 0000000..d76ec62 --- /dev/null +++ b/src/resolvers/notification.resolver.ts @@ -0,0 +1,120 @@ +import { GraphQLError } from "graphql"; +import type { Context } from "../context.js"; +import type { NotificationStatus } from "../generated/prisma/client.js"; +import { requireAuth, requireRole } from "../utils/auth-guard.js"; + +interface CreateNotificationInput { + userId: string; + message: string; + notificationType: string; + actionUrl?: string; + actionText?: string; +} + +export const notificationResolvers = { + Query: { + notifications: ( + _parent: unknown, + args: { status?: NotificationStatus }, + context: Context, + ) => { + const user = requireAuth(context); + return context.prisma.notifications.findMany({ + where: { + userId: user.id, + ...(args.status ? { status: args.status } : {}), + }, + orderBy: { createdAt: "desc" }, + }); + }, + notification: (_parent: unknown, args: { id: string }, context: Context) => { + const user = requireAuth(context); + return context.prisma.notifications.findFirst({ + where: { id: args.id, userId: user.id }, + }); + }, + }, + Mutation: { + createNotification: async ( + _parent: unknown, + args: { input: CreateNotificationInput }, + context: Context, + ) => { + requireRole(context, ["admin"]); + const { input } = args; + + return context.prisma.notifications.create({ + data: { + userId: input.userId, + message: input.message, + notificationType: input.notificationType, + actionUrl: input.actionUrl, + actionText: input.actionText, + }, + }); + }, + + deleteNotification: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + const user = requireAuth(context); + + const notification = await context.prisma.notifications.findUnique({ + where: { id: args.id }, + }); + + if (!notification || notification.userId !== user.id) { + throw new GraphQLError("Notification not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + await context.prisma.notifications.delete({ where: { id: args.id } }); + return true; + }, + + markNotificationRead: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + const user = requireAuth(context); + + const notification = await context.prisma.notifications.findUnique({ + where: { id: args.id }, + }); + + if (!notification || notification.userId !== user.id) { + throw new GraphQLError("Notification not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + return context.prisma.notifications.update({ + where: { id: args.id }, + data: { status: "READ" }, + }); + }, + markAllNotificationsRead: async ( + _parent: unknown, + _args: unknown, + context: Context, + ) => { + const user = requireAuth(context); + + await context.prisma.notifications.updateMany({ + where: { userId: user.id, status: { not: "READ" } }, + data: { status: "READ" }, + }); + + return true; + }, + }, + Notification: { + user: (parent: { userId: string }, _args: unknown, { prisma }: Context) => { + return prisma.user.findUnique({ where: { id: parent.userId } }); + }, + }, +}; diff --git a/src/resolvers/signal.resolver.ts b/src/resolvers/signal.resolver.ts new file mode 100644 index 0000000..d96ff10 --- /dev/null +++ b/src/resolvers/signal.resolver.ts @@ -0,0 +1,78 @@ +import { GraphQLError } from "graphql"; +import type { Context } from "../context.js"; +import { requireRole } from "../utils/auth-guard.js"; + +export const signalResolvers = { + Query: { + signals: (_parent: unknown, _args: unknown, { prisma }: Context) => { + return prisma.signal.findMany(); + }, + signal: (_parent: unknown, args: { id: string }, { prisma }: Context) => { + return prisma.signal.findUnique({ where: { id: args.id } }); + }, + }, + Mutation: { + createSignal: async ( + _parent: unknown, + args: { detectionId: string }, + context: Context, + ) => { + requireRole(context, ["admin", "analyst"]); + + const detection = await context.prisma.detection.findUnique({ + where: { id: args.detectionId }, + }); + if (!detection) { + throw new GraphQLError("Detection not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + const existing = await context.prisma.signal.findUnique({ + where: { detectionId: args.detectionId }, + }); + if (existing) { + throw new GraphQLError("A signal already exists for this detection", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + return context.prisma.signal.create({ + data: { detectionId: args.detectionId }, + }); + }, + + deleteSignal: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + requireRole(context, ["admin"]); + + const existing = await context.prisma.signal.findUnique({ + where: { id: args.id }, + }); + if (!existing) { + throw new GraphQLError("Signal not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + await context.prisma.signal.delete({ where: { id: args.id } }); + return true; + }, + }, + Signal: { + detection: (parent: { detectionId: string }, _args: unknown, { prisma }: Context) => { + return prisma.detection.findUnique({ where: { id: parent.detectionId } }); + }, + events: (parent: { id: string }, _args: unknown, { prisma }: Context) => { + return prisma.signal + .findUnique({ where: { id: parent.id } }) + .events(); + }, + primaryOf: (parent: { id: string }, _args: unknown, { prisma }: Context) => { + return prisma.event.findMany({ where: { primarySignalId: parent.id } }); + }, + }, +}; diff --git a/src/resolvers/user.resolver.ts b/src/resolvers/user.resolver.ts index d1572b7..08b4059 100644 --- a/src/resolvers/user.resolver.ts +++ b/src/resolvers/user.resolver.ts @@ -1,4 +1,15 @@ +import { GraphQLError } from "graphql"; import type { Context } from "../context.js"; +import { requireAuth } from "../utils/auth-guard.js"; + +interface UpdateProfileInput { + name?: string; + phoneNumber?: string; + image?: string; + enableInAppNotification?: boolean; + enableEmailNotification?: boolean; + enableSMSNotification?: boolean; +} export const userResolvers = { Query: { @@ -9,12 +20,78 @@ export const userResolvers = { return prisma.user.findUnique({ where: { id: args.id } }); }, }, + Mutation: { + updateProfile: async ( + _parent: unknown, + args: { input: UpdateProfileInput }, + context: Context, + ) => { + const user = requireAuth(context); + const { input } = args; + + const data: Record = {}; + + if (input.name !== undefined) { + data.name = input.name; + } + + if (input.image !== undefined) { + data.image = input.image; + } + + if (input.phoneNumber !== undefined) { + const e164Regex = /^\+[1-9]\d{1,14}$/; + if (input.phoneNumber !== "" && !e164Regex.test(input.phoneNumber)) { + throw new GraphQLError( + "Phone number must be in E.164 format (e.g. +249912345678)", + { extensions: { code: "BAD_USER_INPUT" } }, + ); + } + data.phoneNumber = input.phoneNumber; + } + + if (input.enableInAppNotification !== undefined) { + data.enableInAppNotification = input.enableInAppNotification; + } + + if (input.enableEmailNotification !== undefined) { + data.enableEmailNotification = input.enableEmailNotification; + } + + if (input.enableSMSNotification !== undefined) { + if (input.enableSMSNotification) { + const phoneNumber = input.phoneNumber ?? ( + await context.prisma.user.findUnique({ + where: { id: user.id }, + select: { phoneNumber: true }, + }) + )?.phoneNumber; + + if (!phoneNumber) { + throw new GraphQLError( + "A phone number is required to enable SMS notifications", + { extensions: { code: "BAD_USER_INPUT" } }, + ); + } + } + data.enableSMSNotification = input.enableSMSNotification; + } + + return context.prisma.user.update({ + where: { id: user.id }, + data, + }); + }, + }, User: { createdAlerts: (parent: { id: string }, _args: unknown, { prisma }: Context) => { return prisma.alert.findMany({ where: { createdById: parent.id } }); }, - feedback: (parent: { id: string }, _args: unknown, { prisma }: Context) => { - return prisma.userAlert.findMany({ where: { userId: parent.id } }); + notifications: (parent: { id: string }, _args: unknown, { prisma }: Context) => { + return prisma.notifications.findMany({ + where: { userId: parent.id }, + orderBy: { createdAt: "desc" }, + }); }, }, }; diff --git a/src/schema/index.ts b/src/schema/index.ts index c90ea51..2ce1c4f 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -4,8 +4,11 @@ import { mutationTypeDef } from "./typeDefs/mutation.js"; import { userTypeDef } from "./typeDefs/types/user.js"; import { alertTypeDef } from "./typeDefs/types/alert.js"; import { detectionTypeDef } from "./typeDefs/types/detection.js"; +import { signalTypeDef } from "./typeDefs/types/signal.js"; +import { eventTypeDef } from "./typeDefs/types/event.js"; import { dataSourceTypeDef } from "./typeDefs/types/dataSource.js"; import { locationTypeDef } from "./typeDefs/types/location.js"; +import { notificationTypeDef } from "./typeDefs/types/notification.js"; import { featureFlagTypeDef } from "./typeDefs/types/featureFlag.js"; import { apiKeyTypeDef } from "./typeDefs/types/apiKey.js"; @@ -16,8 +19,11 @@ export const typeDefs = [ userTypeDef, alertTypeDef, detectionTypeDef, + signalTypeDef, + eventTypeDef, dataSourceTypeDef, locationTypeDef, + notificationTypeDef, featureFlagTypeDef, apiKeyTypeDef, ]; diff --git a/src/schema/typeDefs/mutation.ts b/src/schema/typeDefs/mutation.ts index 5cbd871..6a937a7 100644 --- a/src/schema/typeDefs/mutation.ts +++ b/src/schema/typeDefs/mutation.ts @@ -2,10 +2,196 @@ import { gql } from "graphql-tag"; export const mutationTypeDef = gql` type Mutation { + # ─── API Keys ────────────────────────────────────────────────────────────── """Create a new API key for the authenticated user.""" createApiKey(input: CreateApiKeyInput!): CreateApiKeyPayload! """Revoke an API key by ID. Only the key owner or an admin can revoke.""" - revokeApiKey(id: Int!): ApiKey! + revokeApiKey(id: String!): ApiKey! + + # ─── Auth ────────────────────────────────────────────────────────────────── + """Request an email verification link for the authenticated user.""" + requestEmailVerification: Boolean! + + """Verify email using a token from the verification link.""" + verifyEmail(token: String!): Boolean! + + # ─── User ────────────────────────────────────────────────────────────────── + """Update the authenticated user's profile and notification preferences.""" + updateProfile(input: UpdateProfileInput!): User! + + # ─── Alerts ──────────────────────────────────────────────────────────────── + """Create a new alert.""" + createAlert(input: CreateAlertInput!): Alert! + + """Update an existing alert.""" + updateAlert(id: String!, input: UpdateAlertInput!): Alert! + + """Delete an alert.""" + deleteAlert(id: String!): Boolean! + + # ─── Detections ──────────────────────────────────────────────────────────── + """Create a new detection.""" + createDetection(input: CreateDetectionInput!): Detection! + + """Update an existing detection.""" + updateDetection(id: String!, input: UpdateDetectionInput!): Detection! + + """Delete a detection.""" + deleteDetection(id: String!): Boolean! + + # ─── Signals ─────────────────────────────────────────────────────────────── + """Create a signal from a detection.""" + createSignal(detectionId: String!): Signal! + + """Delete a signal.""" + deleteSignal(id: String!): Boolean! + + # ─── Events ──────────────────────────────────────────────────────────────── + """Create a new event from signals.""" + createEvent(input: CreateEventInput!): Event! + + """Update an existing event.""" + updateEvent(id: String!, input: UpdateEventInput!): Event! + + """Delete an event.""" + deleteEvent(id: String!): Boolean! + + # ─── Data Sources ────────────────────────────────────────────────────────── + """Create a new data source.""" + createDataSource(input: CreateDataSourceInput!): DataSource! + + """Update an existing data source.""" + updateDataSource(id: String!, input: UpdateDataSourceInput!): DataSource! + + """Delete a data source.""" + deleteDataSource(id: String!): Boolean! + + # ─── Locations ───────────────────────────────────────────────────────────── + """Create a new location.""" + createLocation(input: CreateLocationInput!): Location! + + """Update an existing location.""" + updateLocation(id: String!, input: UpdateLocationInput!): Location! + + """Delete a location.""" + deleteLocation(id: String!): Boolean! + + # ─── Notifications ───────────────────────────────────────────────────────── + """Create a notification for a user.""" + createNotification(input: CreateNotificationInput!): Notification! + + """Delete a notification.""" + deleteNotification(id: String!): Boolean! + + """Mark a notification as read.""" + markNotificationRead(id: String!): Notification! + + """Mark all notifications as read for the authenticated user.""" + markAllNotificationsRead: Boolean! + } + + # ─── Input Types ─────────────────────────────────────────────────────────── + + input UpdateProfileInput { + name: String + phoneNumber: String + image: String + enableInAppNotification: Boolean + enableEmailNotification: Boolean + enableSMSNotification: Boolean + } + + input CreateAlertInput { + title: String! + description: String! + severity: Int! + status: AlertStatus + sourceId: String + primaryEventId: String + eventIds: [String!] + locationIds: [String!] + metadata: JSON + } + + input UpdateAlertInput { + title: String + description: String + severity: Int + status: AlertStatus + sourceId: String + primaryEventId: String + eventIds: [String!] + locationIds: [String!] + metadata: JSON + } + + input CreateDetectionInput { + title: String! + confidence: Float + status: DetectionStatus + detectedAt: DateTime + rawData: JSON + sourceId: String + locationIds: [String!] + } + + input UpdateDetectionInput { + title: String + confidence: Float + status: DetectionStatus + rawData: JSON + sourceId: String + locationIds: [String!] + } + + input CreateEventInput { + signalIds: [String!]! + primarySignalId: String + } + + input UpdateEventInput { + signalIds: [String!] + primarySignalId: String + } + + input CreateDataSourceInput { + name: String! + type: String! + isActive: Boolean + baseUrl: String + infoUrl: String + } + + input UpdateDataSourceInput { + name: String + type: String + isActive: Boolean + baseUrl: String + infoUrl: String + } + + input CreateLocationInput { + geoId: String! + name: String! + level: Int! + pointType: String + parentId: String + } + + input UpdateLocationInput { + geoId: String + name: String + level: Int + pointType: String + parentId: String + } + + input CreateNotificationInput { + userId: String! + message: String! + notificationType: String! + actionUrl: String + actionText: String } `; diff --git a/src/schema/typeDefs/query.ts b/src/schema/typeDefs/query.ts index 557c9fc..44af5f5 100644 --- a/src/schema/typeDefs/query.ts +++ b/src/schema/typeDefs/query.ts @@ -11,19 +11,31 @@ export const queryTypeDef = gql` # Alerts alerts(status: AlertStatus): [Alert!]! - alert(id: Int!): Alert + alert(id: String!): Alert # Detections detections(status: DetectionStatus): [Detection!]! - detection(id: Int!): Detection + detection(id: String!): Detection + + # Signals + signals: [Signal!]! + signal(id: String!): Signal + + # Events + events: [Event!]! + event(id: String!): Event # Data Sources dataSources: [DataSource!]! - dataSource(id: Int!): DataSource + dataSource(id: String!): DataSource # Locations locations(level: Int): [Location!]! - location(id: Int!): Location + location(id: String!): Location + + # Notifications + notifications(status: NotificationStatus): [Notification!]! + notification(id: String!): Notification # Feature Flags featureFlags: [FeatureFlag!]! diff --git a/src/schema/typeDefs/types/alert.ts b/src/schema/typeDefs/types/alert.ts index dee8f66..8bb5c72 100644 --- a/src/schema/typeDefs/types/alert.ts +++ b/src/schema/typeDefs/types/alert.ts @@ -8,16 +8,16 @@ export const alertTypeDef = gql` } type Alert { - id: Int! + id: String! title: String! description: String! severity: Int! status: AlertStatus! source: DataSource createdBy: User - primaryDetection: Detection + primaryEvent: Event metadata: JSON - detections: [Detection!]! + events: [Event!]! locations: [AlertLocation!]! feedback: [UserAlert!]! createdAt: DateTime! @@ -25,7 +25,7 @@ export const alertTypeDef = gql` } type UserAlert { - id: Int! + id: String! user: User! alert: Alert! readAt: DateTime diff --git a/src/schema/typeDefs/types/apiKey.ts b/src/schema/typeDefs/types/apiKey.ts index e46c213..e111afa 100644 --- a/src/schema/typeDefs/types/apiKey.ts +++ b/src/schema/typeDefs/types/apiKey.ts @@ -2,7 +2,7 @@ import { gql } from "graphql-tag"; export const apiKeyTypeDef = gql` type ApiKey { - id: Int! + id: String! name: String! prefix: String! expiresAt: DateTime diff --git a/src/schema/typeDefs/types/dataSource.ts b/src/schema/typeDefs/types/dataSource.ts index 52f75ff..2f31972 100644 --- a/src/schema/typeDefs/types/dataSource.ts +++ b/src/schema/typeDefs/types/dataSource.ts @@ -2,7 +2,7 @@ import { gql } from "graphql-tag"; export const dataSourceTypeDef = gql` type DataSource { - id: Int! + id: String! name: String! type: String! isActive: Boolean! diff --git a/src/schema/typeDefs/types/detection.ts b/src/schema/typeDefs/types/detection.ts index 68fdc37..d080427 100644 --- a/src/schema/typeDefs/types/detection.ts +++ b/src/schema/typeDefs/types/detection.ts @@ -8,14 +8,14 @@ export const detectionTypeDef = gql` } type Detection { - id: Int! + id: String! title: String! confidence: Float status: DetectionStatus! detectedAt: DateTime! rawData: JSON source: DataSource - alert: Alert + signal: Signal locations: [DetectionLocation!]! createdAt: DateTime! updatedAt: DateTime! diff --git a/src/schema/typeDefs/types/event.ts b/src/schema/typeDefs/types/event.ts new file mode 100644 index 0000000..16cd939 --- /dev/null +++ b/src/schema/typeDefs/types/event.ts @@ -0,0 +1,10 @@ +import { gql } from "graphql-tag"; + +export const eventTypeDef = gql` + type Event { + id: String! + signals: [Signal!]! + primarySignal: Signal + alerts: [Alert!]! + } +`; diff --git a/src/schema/typeDefs/types/location.ts b/src/schema/typeDefs/types/location.ts index 082ec4a..183baab 100644 --- a/src/schema/typeDefs/types/location.ts +++ b/src/schema/typeDefs/types/location.ts @@ -2,7 +2,7 @@ import { gql } from "graphql-tag"; export const locationTypeDef = gql` type Location { - id: Int! + id: String! geoId: String! name: String! level: Int! @@ -13,14 +13,14 @@ export const locationTypeDef = gql` } type AlertLocation { - id: Int! + id: String! alert: Alert! location: Location! createdAt: DateTime! } type DetectionLocation { - id: Int! + id: String! detection: Detection! location: Location! createdAt: DateTime! diff --git a/src/schema/typeDefs/types/notification.ts b/src/schema/typeDefs/types/notification.ts new file mode 100644 index 0000000..9e6d881 --- /dev/null +++ b/src/schema/typeDefs/types/notification.ts @@ -0,0 +1,24 @@ +import { gql } from "graphql-tag"; + +export const notificationTypeDef = gql` + enum NotificationStatus { + PENDING + DELIVERED + FAILED + READ + } + + type Notification { + id: String! + user: User! + message: String! + notificationType: String! + actionUrl: String + actionText: String + status: NotificationStatus! + emailNotificationStatus: NotificationStatus + smsNotificationStatus: NotificationStatus + createdAt: DateTime! + updatedAt: DateTime! + } +`; diff --git a/src/schema/typeDefs/types/signal.ts b/src/schema/typeDefs/types/signal.ts new file mode 100644 index 0000000..f3bd470 --- /dev/null +++ b/src/schema/typeDefs/types/signal.ts @@ -0,0 +1,10 @@ +import { gql } from "graphql-tag"; + +export const signalTypeDef = gql` + type Signal { + id: String! + detection: Detection! + events: [Event!]! + primaryOf: [Event!]! + } +`; diff --git a/src/schema/typeDefs/types/user.ts b/src/schema/typeDefs/types/user.ts index d449834..baf8481 100644 --- a/src/schema/typeDefs/types/user.ts +++ b/src/schema/typeDefs/types/user.ts @@ -6,12 +6,17 @@ export const userTypeDef = gql` email: String! name: String! emailVerified: Boolean! + phoneNumber: String image: String role: String! isActive: Boolean! + enableInAppNotification: Boolean! + enableEmailNotification: Boolean! + enableSMSNotification: Boolean! createdAt: DateTime! updatedAt: DateTime! createdAlerts: [Alert!]! feedback: [UserAlert!]! + notifications: [Notification!]! } `;