From 94c82ef3b30bfc0ef50003dcd7413524aaf770d4 Mon Sep 17 00:00:00 2001 From: positonic Date: Thu, 19 Mar 2026 19:48:12 +0000 Subject: [PATCH 01/15] Refactor docs to pre-built HTML with build script Move docs generation from runtime schema introspection to a pre-built HTML file. Adds scripts/build-docs.ts, updates Dockerfile to copy the built HTML, and moves @graphql-tools/schema to devDependencies. Co-Authored-By: Claude Opus 4.6 --- .dockerignore | 4 + Dockerfile | 1 + package.json | 5 +- scripts/build-docs.ts | 17 + src/docs/docs.html | 1229 +++++++++++++++++++++++++++++++++++++++++ src/docs/index.ts | 38 +- src/index.ts | 10 +- tsconfig.build.json | 8 + 8 files changed, 1297 insertions(+), 15 deletions(-) create mode 100644 scripts/build-docs.ts create mode 100644 src/docs/docs.html create mode 100644 tsconfig.build.json diff --git a/.dockerignore b/.dockerignore index cba8fdd..e1e3b3c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,11 @@ node_modules dist .git .github +.beads +.claude infra +scripts *.md +*.test.ts .env .env.* diff --git a/Dockerfile b/Dockerfile index c2c4761..621c718 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,7 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist +COPY --from=build /app/src/docs/docs.html ./dist/docs/docs.html COPY --from=build /app/src/generated ./src/generated COPY package.json prisma.config.ts ./ COPY prisma ./prisma/ diff --git a/package.json b/package.json index ce54c5a..b6861ad 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "type": "module", "scripts": { "dev": "tsx watch src/index.ts", - "build": "tsc", + "build": "bun run build:docs && tsc -p tsconfig.build.json", + "build:docs": "bun run scripts/build-docs.ts", "start": "node dist/index.js", "lint": "eslint src/", "typecheck": "tsc --noEmit", @@ -18,7 +19,6 @@ "dependencies": { "@apollo/server": "^5.4.0", "@as-integrations/express5": "^1.1.2", - "@graphql-tools/schema": "^10.0.31", "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.2", "better-auth": "^1.5.1", @@ -42,6 +42,7 @@ "tsx": "^4.21.0", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", + "@graphql-tools/schema": "^10.0.31", "vitest": "^4.0.18" } } diff --git a/scripts/build-docs.ts b/scripts/build-docs.ts new file mode 100644 index 0000000..d3f4250 --- /dev/null +++ b/scripts/build-docs.ts @@ -0,0 +1,17 @@ +import { makeExecutableSchema } from "@graphql-tools/schema"; +import { typeDefs } from "../src/schema/index.js"; +import { introspectSchema } from "../src/docs/schema-introspect.js"; +import { renderDocsPage } from "../src/docs/template.js"; +import { writeFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const schema = makeExecutableSchema({ typeDefs }); +const schemaData = introspectSchema(schema); +const html = renderDocsPage(schemaData); + +const outPath = join(__dirname, "../src/docs/docs.html"); +writeFileSync(outPath, html, "utf-8"); +console.log(`docs: wrote ${outPath}`); diff --git a/src/docs/docs.html b/src/docs/docs.html new file mode 100644 index 0000000..20b08b5 --- /dev/null +++ b/src/docs/docs.html @@ -0,0 +1,1229 @@ + + + + + + CLEAR API — Documentation + + + + + +
+ + + + +
+
+

Docs › GET STARTED › Introduction

+

Introduction

+

Welcome to the CLEAR API — your gateway to environmental intelligence.

+ +

The CLEAR API gives you programmatic access to environmental alerts, detection events, data sources, and geographic location data through a single GraphQL endpoint. Whether you’re building a monitoring dashboard, integrating alerts into your workflow, or analysing detection patterns, this API has you covered.

+ +

Everything here is accessible via GraphQL at /graphql. You send a query describing exactly the data you want, and you get back precisely that — nothing more, nothing less.

+ +

What You Can Do

+ + + + + + + + + + +
FeatureDescription
AlertsBrowse, filter, and inspect environmental alerts with severity levels and geographic scope.
DetectionsAccess raw and processed detection events from multiple data sources with confidence scores.
Data SourcesDiscover the external data sources that feed into the platform (satellites, sensors, manual reports).
LocationsQuery a hierarchical geographic tree — countries, states, cities — and see what’s happening where.
Feature FlagsCheck runtime feature toggles to adapt your application’s behaviour.
API KeysCreate and manage personal API keys for server-to-server authentication.
+
+ +
+

Quick Start

+

Go from zero to your first API response in three steps.

+ +
+
+
1
+
+

Create an account

+

Head to the Developer Portal and sign up. It takes about ten seconds.

+
+
+
+
2
+
+

Generate an API key

+

In the portal, go to API Keys and create one. Copy it immediately — you won’t see it again.

+
+
+
+
3
+
+

Make your first query

+

Send a request with your key in the Authorization header:

+
curl -X POST https://api.clearinitiative.io/graphql \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{"query":"{ me { id email } }"}'
+
+
+
+ +

Want to explore interactively? Open the GraphQL Sandbox to browse the full schema, autocomplete queries, and test requests in your browser.

+
+ +
+

Authentication

+

Two ways to authenticate, depending on your use case.

+ +

API Keys (server-to-server)

+

Pass your key as a Bearer token in the Authorization header:

+
Authorization: Bearer sk_live_your_key_here
+ +
+ Never expose API keys in client-side code or version control. Store them in environment variables or a secrets manager. +
+ +

Session Cookies (browser apps)

+

Sign in via the REST auth API. The session cookie is set automatically and sent with subsequent requests:

+
// Sign in
+const res = await fetch('/api/auth/sign-in/email', {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/json' },
+  credentials: 'include',
+  body: JSON.stringify({ email: 'you@example.com', password: '...' }),
+});
+
+// Then query (cookie sent automatically)
+const data = await fetch('/graphql', {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/json' },
+  credentials: 'include',
+  body: JSON.stringify({ query: '{ me { id email } }' }),
+});
+
+ +
+ +

Queries

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameReturnsDescription
meUserReturns the currently authenticated user, or null if not signed in.
users[User!]!List all users.
userUserLook up a user by ID.
id: String!
alerts[Alert!]!List alerts, optionally filtered by status.
status: AlertStatus
alertAlertLook up an alert by ID.
id: String!
detections[Detection!]!List detections, optionally filtered by status.
status: DetectionStatus
detectionDetectionLook up a detection by ID.
id: String!
signals[Signal!]!List all signals.
signalSignalLook up a signal by ID.
id: String!
events[Event!]!List all events.
eventEventLook up an event by ID.
id: String!
dataSources[DataSource!]!List all data sources.
dataSourceDataSourceLook up a data source by ID.
id: String!
locations[Location!]!List locations, optionally filtered by hierarchy level (0 = country, 1 = state, etc.).
level: Int
locationLocationLook up a location by ID.
id: String!
notifications[Notification!]!List notifications, optionally filtered by status.
status: NotificationStatus
notificationNotificationLook up a notification by ID.
id: String!
featureFlags[FeatureFlag!]!List all feature flags.
featureFlagFeatureFlagLook up a feature flag by its unique key.
key: String!
myApiKeys[ApiKey!]!List all API keys belonging to the authenticated user. Requires authentication.
+ +

Mutations

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameReturnsDescription
createApiKeyCreateApiKeyPayload!Create a new API key for the authenticated user.
input: CreateApiKeyInput!
revokeApiKeyApiKey!Revoke an API key by ID. Only the key owner or an admin can revoke.
id: String!
requestEmailVerificationBoolean!Request an email verification link for the authenticated user.
verifyEmailBoolean!Verify email using a token from the verification link.
token: String!
updateProfileUser!Update the authenticated user's profile and notification preferences.
input: UpdateProfileInput!
createAlertAlert!Create a new alert.
input: CreateAlertInput!
updateAlertAlert!Update an existing alert.
id: String!input: UpdateAlertInput!
deleteAlertBoolean!Delete an alert.
id: String!
createDetectionDetection!Create a new detection.
input: CreateDetectionInput!
updateDetectionDetection!Update an existing detection.
id: String!input: UpdateDetectionInput!
deleteDetectionBoolean!Delete a detection.
id: String!
createSignalSignal!Create a signal from a detection.
detectionId: String!
deleteSignalBoolean!Delete a signal.
id: String!
createEventEvent!Create a new event from signals.
input: CreateEventInput!
updateEventEvent!Update an existing event.
id: String!input: UpdateEventInput!
deleteEventBoolean!Delete an event.
id: String!
createDataSourceDataSource!Create a new data source.
input: CreateDataSourceInput!
updateDataSourceDataSource!Update an existing data source.
id: String!input: UpdateDataSourceInput!
deleteDataSourceBoolean!Delete a data source.
id: String!
createLocationLocation!Create a new location.
input: CreateLocationInput!
updateLocationLocation!Update an existing location.
id: String!input: UpdateLocationInput!
deleteLocationBoolean!Delete a location.
id: String!
createNotificationNotification!Create a notification for a user.
input: CreateNotificationInput!
deleteNotificationBoolean!Delete a notification.
id: String!
markNotificationReadNotification!Mark a notification as read.
id: String!
markAllNotificationsReadBoolean!Mark all notifications as read for the authenticated user.
+ +

Types

+

All types in the schema, auto-generated from the running server.

+

DateTime scalar

ISO 8601 date-time string (e.g. 2024-01-15T09:30:00.000Z).

GeoJSON scalar

JSON scalar

Arbitrary JSON value — objects, arrays, strings, numbers, booleans, or null.

AlertStatus enum

Publication status of an alert.

ValueDescription
draft
published
archived

DetectionStatus enum

Processing status of a detection.

ValueDescription
raw
processed
ignored

NotificationStatus enum

ValueDescription
PENDING
DELIVERED
FAILED
READ

CreateAlertInput input

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
titleString!
descriptionString!
severityInt!
statusAlertStatus
primaryEventIdString
eventIds[String!]
locationIds[String!]
metadataJSON

CreateApiKeyInput input

Input for creating a new API key.

+ + + + + + + +
FieldTypeDescription
nameString!A descriptive name for this key (e.g. my-app-prod).
expiresAtDateTimeOptional expiration date. Omit for a key that never expires.

CreateDataSourceInput input

+ + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
nameString!
typeString!
isActiveBoolean
baseUrlString
infoUrlString

CreateDetectionInput input

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
titleString!
confidenceFloat
statusDetectionStatus
detectedAtDateTime
rawDataJSON
sourceIdString
locationIds[String!]

CreateEventInput input

+ + + + + + + +
FieldTypeDescription
signalIds[String!]!
primarySignalIdString

CreateLocationInput input

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
geoIdString!
nameString!
levelInt!
pointTypeString
parentIdString
latitudeFloat
longitudeFloat

CreateNotificationInput input

+ + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
userIdString!
messageString!
notificationTypeString!
actionUrlString
actionTextString

UpdateAlertInput input

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
titleString
descriptionString
severityInt
statusAlertStatus
primaryEventIdString
eventIds[String!]
locationIds[String!]
metadataJSON

UpdateDataSourceInput input

+ + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
nameString
typeString
isActiveBoolean
baseUrlString
infoUrlString

UpdateDetectionInput input

+ + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
titleString
confidenceFloat
statusDetectionStatus
rawDataJSON
sourceIdString
locationIds[String!]

UpdateEventInput input

+ + + + + + + +
FieldTypeDescription
signalIds[String!]
primarySignalIdString

UpdateLocationInput input

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
geoIdString
nameString
levelInt
pointTypeString
parentIdString
latitudeFloat
longitudeFloat

UpdateProfileInput input

+ + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
nameString
phoneNumberString
imageString
enableInAppNotificationBoolean
enableEmailNotificationBoolean
enableSMSNotificationBoolean

Alert object

An environmental alert with severity, geographic scope, and linked detections.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
titleString!
descriptionString!
severityInt!Severity from 1 (low) to 5 (critical).
statusAlertStatus!
createdByUserThe user who created this alert.
primaryEventEventThe primary event that triggered this alert.
metadataJSONArbitrary metadata as JSON.
events[Event!]!All events linked to this alert.
locations[AlertLocation!]!Geographic locations affected by this alert.
feedback[UserAlert!]!User feedback and ratings for this alert.
createdAtDateTime!
updatedAtDateTime!

AlertLocation object

Links an alert to a geographic location.

+ + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
alertAlert!
locationLocation!
createdAtDateTime!

ApiKey object

A personal API key for programmatic access. The full key is only shown once at creation.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
nameString!Descriptive name you chose when creating the key.
prefixString!Short prefix for identification (e.g. sk_live_abc1).
expiresAtDateTimeOptional expiration date. Expired keys are rejected automatically.
lastUsedAtDateTimeWhen this key was last used to authenticate a request.
revokedAtDateTimeWhen this key was revoked, if applicable. Revocation is permanent.
createdAtDateTime!
updatedAtDateTime!

CreateApiKeyPayload object

Returned only from createApiKey. Contains the full plaintext +key that will never be retrievable again.

+ + + + + + + +
FieldTypeDescription
apiKeyApiKey!
keyString!The full API key. Copy this immediately — it cannot be retrieved later.

DataSource object

An external data source that feeds detections and alerts into the system.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
nameString!
typeString!Source type identifier (e.g. satellite, sensor, manual).
isActiveBoolean!
baseUrlStringBase URL of the data source API.
infoUrlStringURL with more information about this source.
createdAtDateTime!
updatedAtDateTime!
detections[Detection!]!Detections produced by this source.

Detection object

A detection event from a data source, with confidence score and geographic scope.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
titleString!
confidenceFloatConfidence score from 0.0 to 1.0.
statusDetectionStatus!
detectedAtDateTime!When this event was originally detected.
rawDataJSONOriginal detection payload as JSON.
sourceDataSourceThe data source that produced this detection.
signalSignalThe signal derived from this detection, if any.
locations[DetectionLocation!]!Geographic locations where this detection occurred.
createdAtDateTime!
updatedAtDateTime!

DetectionLocation object

Links a detection to a geographic location.

+ + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
detectionDetection!
locationLocation!
createdAtDateTime!

Event object

+ + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
signals[Signal!]!
primarySignalSignal
alerts[Alert!]!

FeatureFlag object

A feature toggle that controls runtime behavior.

+ + + + + + + + + + + + + + + +
FieldTypeDescription
idInt!
keyString!Unique key used to look up this flag (e.g. dark_mode).
enabledBoolean!
updatedAtDateTime!

Location object

A geographic location in a hierarchy (country > state > city, etc.).

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
geoIdString!Unique geographic identifier.
nameString!
levelInt!Hierarchy level: 0 = country, 1 = state/province, 2 = city, etc.
latitudeFloat
longitudeFloat
pointTypeString
pointGeoJSON
boundaryGeoJSON
parentLocationParent location in the hierarchy.
children[Location!]!Child locations one level below.
alertLinks[AlertLocation!]!Alerts affecting this location.
detectionLinks[DetectionLocation!]!Detections at this location.

Notification object

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
userUser!
messageString!
notificationTypeString!
actionUrlString
actionTextString
statusNotificationStatus!
emailNotificationStatusNotificationStatus
smsNotificationStatusNotificationStatus
createdAtDateTime!
updatedAtDateTime!

Signal object

+ + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
detectionDetection!
events[Event!]!
primaryOf[Event!]!

User object

A registered user with role-based access.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
emailString!
nameString!
emailVerifiedBoolean!
phoneNumberString
imageString
roleString!User role: viewer, editor, or admin.
isActiveBoolean!
enableInAppNotificationBoolean!
enableEmailNotificationBoolean!
enableSMSNotificationBoolean!
createdAtDateTime!
updatedAtDateTime!
createdAlerts[Alert!]!Alerts created by this user.
feedback[UserAlert!]!This user's feedback on alerts.
notifications[Notification!]!

UserAlert object

A user's feedback on an alert — read status, rating, and comments.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
userUser!
alertAlert!
readAtDateTimeWhen the user marked this alert as read.
ratingIntUser rating (1-5).
commentString
createdAtDateTime!
updatedAtDateTime!
+
+
+ + + +
+ + + + \ No newline at end of file diff --git a/src/docs/index.ts b/src/docs/index.ts index 27deeff..be55caf 100644 --- a/src/docs/index.ts +++ b/src/docs/index.ts @@ -1,14 +1,38 @@ import { Router } from "express"; -import type { GraphQLSchema } from "graphql"; -import { introspectSchema } from "./schema-introspect.js"; -import { renderDocsPage } from "./template.js"; +import { readFileSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; -export function createDocsRouter(schema: GraphQLSchema): Router { - const router = Router(); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const htmlPath = join(__dirname, "docs.html"); + +let cachedHtml: string | null = null; + +async function getHtml(): Promise { + if (cachedHtml) return cachedHtml; + + if (existsSync(htmlPath)) { + cachedHtml = readFileSync(htmlPath, "utf-8"); + return cachedHtml; + } + + // Fallback: generate dynamically (dev mode, no pre-built file) + const { makeExecutableSchema } = await import("@graphql-tools/schema"); + const { typeDefs } = await import("../schema/index.js"); + const { introspectSchema } = await import("./schema-introspect.js"); + const { renderDocsPage } = await import("./template.js"); + + const schema = makeExecutableSchema({ typeDefs }); const schemaData = introspectSchema(schema); - const html = renderDocsPage(schemaData); + cachedHtml = renderDocsPage(schemaData); + return cachedHtml; +} + +export function createDocsRouter(): Router { + const router = Router(); - router.get("/", (_req, res) => { + router.get("/", async (_req, res) => { + const html = await getHtml(); res.setHeader("Content-Type", "text/html; charset=utf-8"); res.send(html); }); diff --git a/src/index.ts b/src/index.ts index 9b4aadb..8381260 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,6 @@ import express from "express"; import http from "node:http"; import cors from "cors"; import { toNodeHandler } from "better-auth/node"; -import { makeExecutableSchema } from "@graphql-tools/schema"; import { typeDefs } from "./schema/index.js"; import { resolvers } from "./resolvers/index.js"; import { createContext, type Context } from "./context.js"; @@ -20,10 +19,9 @@ import { createDocsRouter } from "./docs/index.js"; const app = express(); const httpServer = http.createServer(app); -const schema = makeExecutableSchema({ typeDefs, resolvers }); - const server = new ApolloServer({ - schema, + typeDefs, + resolvers, plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], introspection: env.NODE_ENV !== "production", }); @@ -39,8 +37,8 @@ app.all("/api/auth/*splat", toNodeHandler(auth)); // Developer portal app.use("/portal", portalRouter); -// Auto-generated docs (introspects the running schema) -app.use("/docs", createDocsRouter(schema)); +// Auto-generated docs (pre-built HTML) +app.use("/docs", createDocsRouter()); // Public home page app.use("/", homeRouter); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..2d45198 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "sourceMap": false + } +} From 8a228b2ee56a02e46937a7ec50119410fe2abfd9 Mon Sep 17 00:00:00 2001 From: positonic Date: Thu, 19 Mar 2026 19:48:51 +0000 Subject: [PATCH 02/15] Add multi-tenancy schema: organisations, teams, location scoping Evolve organisations table (add slug, isActive, timestamps). Add teams, teamMembers, and teamLocations tables for flexible geographic scoping. Add defaultTeamId on user for frontend convenience. Co-Authored-By: Claude Opus 4.6 --- .claude/settings.json | 7 +- .../migration.sql | 85 +++++++++++++++++++ .../migration.sql | 2 + prisma/schema.prisma | 77 +++++++++++++++-- 4 files changed, 162 insertions(+), 9 deletions(-) create mode 100644 prisma/migrations/20260319000000_add_teams_and_evolve_orgs/migration.sql create mode 100644 prisma/migrations/20260319010000_rename_active_team_to_default_team/migration.sql diff --git a/.claude/settings.json b/.claude/settings.json index 7165672..15266f2 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -51,7 +51,12 @@ "Bash(bun add @graphql-tools/schema)", "Bash(git add src/schema/typeDefs/query.ts src/schema/typeDefs/types/alert.ts src/schema/typeDefs/types/apiKey.ts src/schema/typeDefs/types/dataSource.ts src/schema/typeDefs/types/detection.ts src/schema/typeDefs/types/location.ts)", "Bash(git rebase --continue)", - "Bash(git add .claude/settings.json)" + "Bash(git add .claude/settings.json)", + "Bash(npx tsc --noEmit)", + "Bash(bun run tsc -p tsconfig.build.json --noEmit)", + "Bash(ls node_modules/nodemailer/lib/*.d.ts)", + "Bash(bun test:*)", + "Bash(git add:*)" ] } } diff --git a/prisma/migrations/20260319000000_add_teams_and_evolve_orgs/migration.sql b/prisma/migrations/20260319000000_add_teams_and_evolve_orgs/migration.sql new file mode 100644 index 0000000..e2a7520 --- /dev/null +++ b/prisma/migrations/20260319000000_add_teams_and_evolve_orgs/migration.sql @@ -0,0 +1,85 @@ +-- AlterTable: organisations - add new columns with defaults for existing rows +ALTER TABLE "organisations" ADD COLUMN "slug" TEXT; +ALTER TABLE "organisations" ADD COLUMN "is_active" BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE "organisations" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; +ALTER TABLE "organisations" ADD COLUMN "updated_at" TIMESTAMP(3); + +-- Backfill existing rows +UPDATE "organisations" SET "slug" = LOWER(REPLACE("name", ' ', '-')) WHERE "slug" IS NULL; +UPDATE "organisations" SET "updated_at" = CURRENT_TIMESTAMP WHERE "updated_at" IS NULL; + +-- Now make slug required and unique +ALTER TABLE "organisations" ALTER COLUMN "slug" SET NOT NULL; +ALTER TABLE "organisations" ALTER COLUMN "updated_at" SET NOT NULL; +CREATE UNIQUE INDEX "organisations_slug_key" ON "organisations"("slug"); + +-- AlterTable: user_to_organisation - update default role, add created_at, add cascading deletes +ALTER TABLE "user_to_organisation" ALTER COLUMN "role" SET DEFAULT 'member'; +ALTER TABLE "user_to_organisation" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- Update cascading deletes on user_to_organisation +ALTER TABLE "user_to_organisation" DROP CONSTRAINT IF EXISTS "user_to_organisation_user_id_fkey"; +ALTER TABLE "user_to_organisation" ADD CONSTRAINT "user_to_organisation_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "user_to_organisation" DROP CONSTRAINT IF EXISTS "user_to_organisation_organisation_id_fkey"; +ALTER TABLE "user_to_organisation" ADD CONSTRAINT "user_to_organisation_organisation_id_fkey" FOREIGN KEY ("organisation_id") REFERENCES "organisations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AlterTable: user - add active_team_id +ALTER TABLE "user" ADD COLUMN "active_team_id" TEXT; + +-- CreateTable: teams +CREATE TABLE "teams" ( + "id" TEXT NOT NULL, + "organisation_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "description" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "teams_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "teams_organisation_id_idx" ON "teams"("organisation_id"); +CREATE UNIQUE INDEX "teams_organisation_id_slug_key" ON "teams"("organisation_id", "slug"); + +-- CreateTable: team_members +CREATE TABLE "team_members" ( + "id" TEXT NOT NULL, + "team_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'viewer', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "team_members_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "team_members_user_id_idx" ON "team_members"("user_id"); +CREATE UNIQUE INDEX "team_members_team_id_user_id_key" ON "team_members"("team_id", "user_id"); + +-- CreateTable: team_locations +CREATE TABLE "team_locations" ( + "id" TEXT NOT NULL, + "team_id" TEXT NOT NULL, + "location_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "team_locations_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "team_locations_location_id_idx" ON "team_locations"("location_id"); +CREATE UNIQUE INDEX "team_locations_team_id_location_id_key" ON "team_locations"("team_id", "location_id"); + +-- AddForeignKey: user.active_team_id -> teams +ALTER TABLE "user" ADD CONSTRAINT "user_active_team_id_fkey" FOREIGN KEY ("active_team_id") REFERENCES "teams"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey: teams -> organisations +ALTER TABLE "teams" ADD CONSTRAINT "teams_organisation_id_fkey" FOREIGN KEY ("organisation_id") REFERENCES "organisations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey: team_members -> teams, user +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "teams"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey: team_locations -> teams, locations +ALTER TABLE "team_locations" ADD CONSTRAINT "team_locations_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "teams"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "team_locations" ADD CONSTRAINT "team_locations_location_id_fkey" FOREIGN KEY ("location_id") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260319010000_rename_active_team_to_default_team/migration.sql b/prisma/migrations/20260319010000_rename_active_team_to_default_team/migration.sql new file mode 100644 index 0000000..92bee4b --- /dev/null +++ b/prisma/migrations/20260319010000_rename_active_team_to_default_team/migration.sql @@ -0,0 +1,2 @@ +-- Rename column active_team_id → default_team_id on user table +ALTER TABLE "user" RENAME COLUMN "active_team_id" TO "default_team_id"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index be3e7aa..0bf6f47 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,10 +68,14 @@ model user { sessions session[] accounts account[] + defaultTeamId String? @map("default_team_id") + defaultTeam teams? @relation("DefaultTeam", fields: [defaultTeamId], references: [id], onDelete: SetNull) + alerts userAlerts[] apiKeys apiKeys[] notifications notifications[] organisations organisationUsers[] + teamMemberships teamMembers[] eventEscaladedByUsers eventEscaladedByUsers[] userFeedbacks userFeedbacks[] userComments userComments[] @@ -121,24 +125,80 @@ model verification { // ––– Organisation –––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– model organisations { - id String @id @default(cuid()) - name String + id String @id @default(cuid()) + name String + slug String @unique + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + users organisationUsers[] + teams teams[] } model organisationUsers { - id String @id @default(cuid()) - userId String @map("user_id") - organisationId String @map("organisation_id") - role String @default("viewer") + id String @id @default(cuid()) + userId String @map("user_id") + organisationId String @map("organisation_id") + role String @default("member") // owner | admin | member + createdAt DateTime @default(now()) @map("created_at") - user user @relation(fields: [userId], references: [id]) - organisation organisations @relation(fields: [organisationId], references: [id]) + user user @relation(fields: [userId], references: [id], onDelete: Cascade) + organisation organisations @relation(fields: [organisationId], references: [id], onDelete: Cascade) @@unique([userId, organisationId]) @@map("user_to_organisation") } +// ––– Teams ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– + +model teams { + id String @id @default(cuid()) + organisationId String @map("organisation_id") + name String + slug String + description String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + organisation organisations @relation(fields: [organisationId], references: [id], onDelete: Cascade) + members teamMembers[] + locations teamLocations[] + defaultForUsers user[] @relation("DefaultTeam") + + @@unique([organisationId, slug]) + @@index([organisationId]) +} + +model teamMembers { + id String @id @default(cuid()) + teamId String @map("team_id") + userId String @map("user_id") + role String @default("viewer") // lead | analyst | viewer + createdAt DateTime @default(now()) @map("created_at") + + team teams @relation(fields: [teamId], references: [id], onDelete: Cascade) + user user @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([teamId, userId]) + @@index([userId]) + @@map("team_members") +} + +model teamLocations { + id String @id @default(cuid()) + teamId String @map("team_id") + locationId String @map("location_id") + createdAt DateTime @default(now()) @map("created_at") + + team teams @relation(fields: [teamId], references: [id], onDelete: Cascade) + location locations @relation(fields: [locationId], references: [id], onDelete: Cascade) + + @@unique([teamId, locationId]) + @@index([locationId]) + @@map("team_locations") +} + // ─── Geography ─────────────────────────────────────────────────────────────── model locations { @@ -167,6 +227,7 @@ model locations { eventDestinations events[] @relation("EventDestination") eventLocations events[] @relation("EventLocation") userAlertSubscriptions userAlertSubscriptions[] + teamScopes teamLocations[] @@index([level]) @@index([parentId]) From 7d023e95df0ecb7005115495fb51e2d0d43d68a4 Mon Sep 17 00:00:00 2001 From: positonic Date: Thu, 19 Mar 2026 19:49:05 +0000 Subject: [PATCH 03/15] Add auth guards and location scope utilities for team-based filtering Add resolveTeamMembership guard, buildLocationFilterForTeam utility using recursive CTE for hierarchy expansion, and defaultTeamId to Better Auth config. Simplify context to remove server-side team state. Co-Authored-By: Claude Opus 4.6 --- src/lib/auth.ts | 5 +++ src/utils/auth-guard.ts | 26 +++++++++++++++ src/utils/location-scope.ts | 66 +++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 src/utils/location-scope.ts diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 2ede2e9..970a95f 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -35,6 +35,11 @@ export const auth = betterAuth({ defaultValue: true, input: false, }, + defaultTeamId: { + type: "string", + required: false, + input: false, + }, }, }, trustedOrigins: env.CORS_ORIGINS, diff --git a/src/utils/auth-guard.ts b/src/utils/auth-guard.ts index aaa1476..60fa085 100644 --- a/src/utils/auth-guard.ts +++ b/src/utils/auth-guard.ts @@ -1,5 +1,6 @@ import { GraphQLError } from "graphql"; import type { Context } from "../context.js"; +import type { PrismaClient } from "../generated/prisma/client.js"; export function requireAuth(context: Context) { if (!context.user) { @@ -10,6 +11,7 @@ export function requireAuth(context: Context) { return context.user; } +/** Check global user.role (admin, viewer). Use for platform-wide operations. */ export function requireRole(context: Context, roles: string[]) { const user = requireAuth(context); if (!user.role || !roles.includes(user.role)) { @@ -19,3 +21,27 @@ export function requireRole(context: Context, roles: string[]) { } return user; } + +/** + * Look up the user's role in a team. Returns the teamMembers record. + * Throws FORBIDDEN if the user is not a member (unless they're a global admin). + */ +export async function resolveTeamMembership( + prisma: PrismaClient, + userId: string, + teamId: string, + userRole?: string | null, +) { + // Global admins can access any team's data + if (userRole === "admin") return null; + + const membership = await prisma.teamMembers.findUnique({ + where: { teamId_userId: { teamId, userId } }, + }); + if (!membership) { + throw new GraphQLError("Not a member of this team", { + extensions: { code: "FORBIDDEN" }, + }); + } + return membership; +} diff --git a/src/utils/location-scope.ts b/src/utils/location-scope.ts new file mode 100644 index 0000000..53628ee --- /dev/null +++ b/src/utils/location-scope.ts @@ -0,0 +1,66 @@ +import type { PrismaClient } from "../generated/prisma/client.js"; +import type { Prisma } from "../generated/prisma/client.js"; + +/** + * Expand a set of location IDs to include all descendant locations + * using the location hierarchy (parent → children). + * Returns the original IDs plus all children, grandchildren, etc. + */ +export async function getExpandedLocationIds( + prisma: PrismaClient, + scopeLocationIds: string[], +): Promise { + if (scopeLocationIds.length === 0) return []; + + const rows = await prisma.$queryRaw<{ id: string }[]>` + WITH RECURSIVE tree AS ( + SELECT id FROM locations WHERE id = ANY(${scopeLocationIds}::text[]) + UNION ALL + SELECT c.id FROM locations c JOIN tree t ON c.parent_id = t.id + ) + SELECT id FROM tree + `; + return rows.map((r) => r.id); +} + +/** + * Build a Prisma where clause that filters signals by a team's location scope. + * Looks up the team's locations, expands the hierarchy, and returns the filter. + * Returns undefined if the team has no locations (global monitoring). + */ +export async function buildLocationFilterForTeam( + prisma: PrismaClient, + teamId: string, +): Promise { + const teamLocations = await prisma.teamLocations.findMany({ + where: { teamId }, + select: { locationId: true }, + }); + + const locationIds = teamLocations.map((tl) => tl.locationId); + + // Team with no locations = global monitoring (no filter) + if (locationIds.length === 0) return undefined; + + const expandedIds = await getExpandedLocationIds(prisma, locationIds); + + return { + OR: [ + { originId: { in: expandedIds } }, + { destinationId: { in: expandedIds } }, + { locationId: { in: expandedIds } }, + ], + }; +} + +/** + * Same filter shape, typed for the events model. + */ +export async function buildEventLocationFilterForTeam( + prisma: PrismaClient, + teamId: string, +): Promise { + return buildLocationFilterForTeam(prisma, teamId) as Promise< + Prisma.eventsWhereInput | undefined + >; +} From 2e58f564cccaf7990c210e870440873c23f6ea93 Mon Sep 17 00:00:00 2001 From: positonic Date: Thu, 19 Mar 2026 19:49:16 +0000 Subject: [PATCH 04/15] Add org/team GraphQL API and team-scoped query filtering Add Organisation and Team types, queries (myOrganisations, myTeams), and mutations (CRUD for orgs/teams, member management, setTeamLocations, setDefaultTeam). Update signals/events/alerts queries to accept explicit teamId argument for location-based filtering. Co-Authored-By: Claude Opus 4.6 --- src/resolvers/alert.resolver.ts | 26 +- src/resolvers/event.resolver.ts | 19 +- src/resolvers/index.ts | 4 + src/resolvers/organisation.resolver.ts | 186 +++++++++++ src/resolvers/signal.resolver.ts | 19 +- src/resolvers/team.resolver.ts | 384 ++++++++++++++++++++++ src/resolvers/user.resolver.ts | 7 + src/schema/index.ts | 4 + src/schema/typeDefs/mutation.ts | 38 +++ src/schema/typeDefs/query.ts | 25 +- src/schema/typeDefs/types/organisation.ts | 38 +++ src/schema/typeDefs/types/team.ts | 42 +++ src/schema/typeDefs/types/user.ts | 4 + 13 files changed, 780 insertions(+), 16 deletions(-) create mode 100644 src/resolvers/organisation.resolver.ts create mode 100644 src/resolvers/team.resolver.ts create mode 100644 src/schema/typeDefs/types/organisation.ts create mode 100644 src/schema/typeDefs/types/team.ts diff --git a/src/resolvers/alert.resolver.ts b/src/resolvers/alert.resolver.ts index e462011..ac64afa 100644 --- a/src/resolvers/alert.resolver.ts +++ b/src/resolvers/alert.resolver.ts @@ -1,7 +1,9 @@ import { GraphQLError } from "graphql"; import type { Context } from "../context.js"; import type { AlertStatus } from "../generated/prisma/client.js"; -import { requireRole } from "../utils/auth-guard.js"; +import { requireAuth, requireRole } from "../utils/auth-guard.js"; +import { resolveTeamMembership } from "../utils/auth-guard.js"; +import { buildEventLocationFilterForTeam } from "../utils/location-scope.js"; interface CreateAlertInput { eventId: string; @@ -14,9 +16,25 @@ interface UpdateAlertInput { export const alertResolvers = { Query: { - alerts: (_parent: unknown, args: { status?: AlertStatus }, { prisma }: Context) => { - return prisma.alerts.findMany({ - where: args.status ? { status: args.status } : undefined, + alerts: async (_parent: unknown, args: { status?: AlertStatus; teamId?: string }, context: Context) => { + const user = requireAuth(context); + if (!args.teamId) { + if (user.role !== "admin") { + throw new GraphQLError("teamId is required", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + return context.prisma.alerts.findMany({ + where: args.status ? { status: args.status } : undefined, + }); + } + await resolveTeamMembership(context.prisma, user.id, args.teamId, user.role); + const eventLocationFilter = await buildEventLocationFilterForTeam(context.prisma, args.teamId); + return context.prisma.alerts.findMany({ + where: { + ...(args.status ? { status: args.status } : {}), + ...(eventLocationFilter ? { event: eventLocationFilter } : {}), + }, }); }, alert: (_parent: unknown, args: { id: string }, { prisma }: Context) => { diff --git a/src/resolvers/event.resolver.ts b/src/resolvers/event.resolver.ts index bb4850d..683c0ba 100644 --- a/src/resolvers/event.resolver.ts +++ b/src/resolvers/event.resolver.ts @@ -1,7 +1,9 @@ import { GraphQLError } from "graphql"; import type { Context } from "../context.js"; import type { InputJsonValue } from "../generated/prisma/internal/prismaNamespace.js"; -import { requireRole } from "../utils/auth-guard.js"; +import { requireAuth, requireRole } from "../utils/auth-guard.js"; +import { resolveTeamMembership } from "../utils/auth-guard.js"; +import { buildEventLocationFilterForTeam } from "../utils/location-scope.js"; interface CreateEventInput { title?: string; @@ -39,8 +41,19 @@ interface UpdateEventInput { export const eventResolvers = { Query: { - events: (_parent: unknown, _args: unknown, { prisma }: Context) => { - return prisma.events.findMany(); + events: async (_parent: unknown, args: { teamId?: string }, context: Context) => { + const user = requireAuth(context); + if (!args.teamId) { + if (user.role !== "admin") { + throw new GraphQLError("teamId is required", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + return context.prisma.events.findMany(); + } + await resolveTeamMembership(context.prisma, user.id, args.teamId, user.role); + const filter = await buildEventLocationFilterForTeam(context.prisma, args.teamId); + return context.prisma.events.findMany({ where: filter }); }, event: (_parent: unknown, args: { id: string }, { prisma }: Context) => { return prisma.events.findUnique({ where: { id: args.id } }); diff --git a/src/resolvers/index.ts b/src/resolvers/index.ts index bed57c5..19254ab 100644 --- a/src/resolvers/index.ts +++ b/src/resolvers/index.ts @@ -11,6 +11,8 @@ import { notificationResolvers } from "./notification.resolver.js"; import { featureFlagResolvers } from "./featureFlag.resolver.js"; import { apiKeyResolvers } from "./apiKey.resolver.js"; import { disasterTypeResolvers } from "./disasterType.resolver.js"; +import { organisationResolvers } from "./organisation.resolver.js"; +import { teamResolvers } from "./team.resolver.js"; export const resolvers: IResolvers[] = [ scalarResolvers, @@ -25,4 +27,6 @@ export const resolvers: IResolvers[] = [ featureFlagResolvers, apiKeyResolvers, disasterTypeResolvers, + organisationResolvers, + teamResolvers, ]; diff --git a/src/resolvers/organisation.resolver.ts b/src/resolvers/organisation.resolver.ts new file mode 100644 index 0000000..8f26b95 --- /dev/null +++ b/src/resolvers/organisation.resolver.ts @@ -0,0 +1,186 @@ +import { GraphQLError } from "graphql"; +import type { Context } from "../context.js"; +import { requireAuth, requireRole } from "../utils/auth-guard.js"; + +interface CreateOrganisationInput { + name: string; + slug: string; +} + +interface UpdateOrganisationInput { + name?: string; + slug?: string; + isActive?: boolean; +} + +export const organisationResolvers = { + Query: { + myOrganisations: async ( + _parent: unknown, + _args: unknown, + context: Context, + ) => { + const user = requireAuth(context); + const memberships = await context.prisma.organisationUsers.findMany({ + where: { userId: user.id }, + select: { organisationId: true }, + }); + return context.prisma.organisations.findMany({ + where: { id: { in: memberships.map((m) => m.organisationId) } }, + }); + }, + + organisation: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + const user = requireAuth(context); + const org = await context.prisma.organisations.findUnique({ + where: { id: args.id }, + }); + if (!org) return null; + + // Global admins can see any org + if (user.role === "admin") return org; + + // Otherwise must be a member + const membership = await context.prisma.organisationUsers.findUnique({ + where: { + userId_organisationId: { + userId: user.id, + organisationId: args.id, + }, + }, + }); + if (!membership) { + throw new GraphQLError("Not a member of this organisation", { + extensions: { code: "FORBIDDEN" }, + }); + } + return org; + }, + }, + + Mutation: { + createOrganisation: async ( + _parent: unknown, + args: { input: CreateOrganisationInput }, + context: Context, + ) => { + const user = requireAuth(context); + const { name, slug } = args.input; + + const existing = await context.prisma.organisations.findUnique({ + where: { slug }, + }); + if (existing) { + throw new GraphQLError("An organisation with this slug already exists", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + return context.prisma.organisations.create({ + data: { + name, + slug, + users: { + create: { + userId: user.id, + role: "owner", + }, + }, + }, + }); + }, + + updateOrganisation: async ( + _parent: unknown, + args: { id: string; input: UpdateOrganisationInput }, + context: Context, + ) => { + const user = requireAuth(context); + await requireOrgAdmin(context.prisma, user, args.id); + + return context.prisma.organisations.update({ + where: { id: args.id }, + data: args.input, + }); + }, + + addOrgMember: async ( + _parent: unknown, + args: { orgId: string; userId: string; role?: string }, + context: Context, + ) => { + const user = requireAuth(context); + await requireOrgAdmin(context.prisma, user, args.orgId); + + return context.prisma.organisationUsers.create({ + data: { + userId: args.userId, + organisationId: args.orgId, + role: args.role ?? "member", + }, + }); + }, + + removeOrgMember: async ( + _parent: unknown, + args: { orgId: string; userId: string }, + context: Context, + ) => { + const user = requireAuth(context); + await requireOrgAdmin(context.prisma, user, args.orgId); + + await context.prisma.organisationUsers.delete({ + where: { + userId_organisationId: { + userId: args.userId, + organisationId: args.orgId, + }, + }, + }); + return true; + }, + }, + + Organisation: { + teams: (parent: { id: string }, _args: unknown, { prisma }: Context) => { + return prisma.teams.findMany({ + where: { organisationId: parent.id }, + }); + }, + members: (parent: { id: string }, _args: unknown, { prisma }: Context) => { + return prisma.organisationUsers.findMany({ + where: { organisationId: parent.id }, + }); + }, + }, + + OrgMember: { + user: (parent: { userId: string }, _args: unknown, { prisma }: Context) => { + return prisma.user.findUnique({ where: { id: parent.userId } }); + }, + }, +}; + +/** Helper: check that the user is an org owner/admin, or a global admin. */ +async function requireOrgAdmin( + prisma: Context["prisma"], + user: { id: string; role?: string | null }, + orgId: string, +) { + if (user.role === "admin") return; // global admin bypass + + const membership = await prisma.organisationUsers.findUnique({ + where: { + userId_organisationId: { userId: user.id, organisationId: orgId }, + }, + }); + if (!membership || !["owner", "admin"].includes(membership.role)) { + throw new GraphQLError("Requires org owner or admin role", { + extensions: { code: "FORBIDDEN" }, + }); + } +} diff --git a/src/resolvers/signal.resolver.ts b/src/resolvers/signal.resolver.ts index 5e7ffe8..2e8acbf 100644 --- a/src/resolvers/signal.resolver.ts +++ b/src/resolvers/signal.resolver.ts @@ -1,7 +1,9 @@ import { GraphQLError } from "graphql"; import type { Context } from "../context.js"; import type { InputJsonValue } from "../generated/prisma/internal/prismaNamespace.js"; -import { requireRole } from "../utils/auth-guard.js"; +import { requireAuth, requireRole } from "../utils/auth-guard.js"; +import { resolveTeamMembership } from "../utils/auth-guard.js"; +import { buildLocationFilterForTeam } from "../utils/location-scope.js"; interface CreateSignalInput { sourceId: string; @@ -18,8 +20,19 @@ interface CreateSignalInput { export const signalResolvers = { Query: { - signals: (_parent: unknown, _args: unknown, { prisma }: Context) => { - return prisma.signals.findMany(); + signals: async (_parent: unknown, args: { teamId?: string }, context: Context) => { + const user = requireAuth(context); + if (!args.teamId) { + if (user.role !== "admin") { + throw new GraphQLError("teamId is required", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + return context.prisma.signals.findMany(); + } + await resolveTeamMembership(context.prisma, user.id, args.teamId, user.role); + const filter = await buildLocationFilterForTeam(context.prisma, args.teamId); + return context.prisma.signals.findMany({ where: filter }); }, signal: (_parent: unknown, args: { id: string }, { prisma }: Context) => { return prisma.signals.findUnique({ where: { id: args.id } }); diff --git a/src/resolvers/team.resolver.ts b/src/resolvers/team.resolver.ts new file mode 100644 index 0000000..7cea816 --- /dev/null +++ b/src/resolvers/team.resolver.ts @@ -0,0 +1,384 @@ +import { GraphQLError } from "graphql"; +import type { Context } from "../context.js"; +import { requireAuth } from "../utils/auth-guard.js"; + +interface CreateTeamInput { + organisationId: string; + name: string; + slug: string; + description?: string; +} + +interface UpdateTeamInput { + name?: string; + slug?: string; + description?: string; +} + +export const teamResolvers = { + Query: { + myTeams: async (_parent: unknown, _args: unknown, context: Context) => { + const user = requireAuth(context); + const memberships = await context.prisma.teamMembers.findMany({ + where: { userId: user.id }, + select: { teamId: true }, + }); + return context.prisma.teams.findMany({ + where: { id: { in: memberships.map((m) => m.teamId) } }, + }); + }, + + team: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + const user = requireAuth(context); + const team = await context.prisma.teams.findUnique({ + where: { id: args.id }, + }); + if (!team) return null; + + if (user.role === "admin") return team; + + const membership = await context.prisma.teamMembers.findUnique({ + where: { + teamId_userId: { teamId: args.id, userId: user.id }, + }, + }); + if (!membership) { + throw new GraphQLError("Not a member of this team", { + extensions: { code: "FORBIDDEN" }, + }); + } + return team; + }, + }, + + Mutation: { + createTeam: async ( + _parent: unknown, + args: { input: CreateTeamInput }, + context: Context, + ) => { + const user = requireAuth(context); + await requireOrgAdminForTeam( + context.prisma, + user, + args.input.organisationId, + ); + + return context.prisma.teams.create({ + data: { + organisationId: args.input.organisationId, + name: args.input.name, + slug: args.input.slug, + description: args.input.description, + }, + }); + }, + + updateTeam: async ( + _parent: unknown, + args: { id: string; input: UpdateTeamInput }, + context: Context, + ) => { + const user = requireAuth(context); + const team = await context.prisma.teams.findUnique({ + where: { id: args.id }, + }); + if (!team) { + throw new GraphQLError("Team not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + await requireTeamLeadOrOrgAdmin( + context.prisma, + user, + args.id, + team.organisationId, + ); + + return context.prisma.teams.update({ + where: { id: args.id }, + data: args.input, + }); + }, + + deleteTeam: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + const user = requireAuth(context); + const team = await context.prisma.teams.findUnique({ + where: { id: args.id }, + }); + if (!team) { + throw new GraphQLError("Team not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + await requireOrgAdminForTeam(context.prisma, user, team.organisationId); + + await context.prisma.teams.delete({ where: { id: args.id } }); + return true; + }, + + addTeamMember: async ( + _parent: unknown, + args: { teamId: string; userId: string; role?: string }, + context: Context, + ) => { + const user = requireAuth(context); + const team = await context.prisma.teams.findUnique({ + where: { id: args.teamId }, + }); + if (!team) { + throw new GraphQLError("Team not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + await requireTeamLeadOrOrgAdmin( + context.prisma, + user, + args.teamId, + team.organisationId, + ); + + // Ensure target user is an org member + const orgMembership = await context.prisma.organisationUsers.findUnique({ + where: { + userId_organisationId: { + userId: args.userId, + organisationId: team.organisationId, + }, + }, + }); + if (!orgMembership) { + throw new GraphQLError( + "User must be a member of the organisation first", + { extensions: { code: "BAD_USER_INPUT" } }, + ); + } + + return context.prisma.teamMembers.create({ + data: { + teamId: args.teamId, + userId: args.userId, + role: args.role ?? "viewer", + }, + }); + }, + + removeTeamMember: async ( + _parent: unknown, + args: { teamId: string; userId: string }, + context: Context, + ) => { + const user = requireAuth(context); + const team = await context.prisma.teams.findUnique({ + where: { id: args.teamId }, + }); + if (!team) { + throw new GraphQLError("Team not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + await requireTeamLeadOrOrgAdmin( + context.prisma, + user, + args.teamId, + team.organisationId, + ); + + await context.prisma.teamMembers.delete({ + where: { + teamId_userId: { teamId: args.teamId, userId: args.userId }, + }, + }); + return true; + }, + + updateTeamMemberRole: async ( + _parent: unknown, + args: { teamId: string; userId: string; role: string }, + context: Context, + ) => { + const user = requireAuth(context); + const team = await context.prisma.teams.findUnique({ + where: { id: args.teamId }, + }); + if (!team) { + throw new GraphQLError("Team not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + await requireTeamLeadOrOrgAdmin( + context.prisma, + user, + args.teamId, + team.organisationId, + ); + + return context.prisma.teamMembers.update({ + where: { + teamId_userId: { teamId: args.teamId, userId: args.userId }, + }, + data: { role: args.role }, + }); + }, + + setTeamLocations: async ( + _parent: unknown, + args: { teamId: string; locationIds: string[] }, + context: Context, + ) => { + const user = requireAuth(context); + const team = await context.prisma.teams.findUnique({ + where: { id: args.teamId }, + }); + if (!team) { + throw new GraphQLError("Team not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + await requireTeamLeadOrOrgAdmin( + context.prisma, + user, + args.teamId, + team.organisationId, + ); + + // Replace all team locations in a transaction + await context.prisma.$transaction([ + context.prisma.teamLocations.deleteMany({ + where: { teamId: args.teamId }, + }), + ...args.locationIds.map((locationId) => + context.prisma.teamLocations.create({ + data: { teamId: args.teamId, locationId }, + }), + ), + ]); + + return context.prisma.teams.findUnique({ where: { id: args.teamId } }); + }, + + setDefaultTeam: async ( + _parent: unknown, + args: { teamId: string }, + context: Context, + ) => { + const user = requireAuth(context); + + // Verify membership + const membership = await context.prisma.teamMembers.findUnique({ + where: { + teamId_userId: { teamId: args.teamId, userId: user.id }, + }, + }); + + if (!membership && user.role !== "admin") { + throw new GraphQLError("Not a member of this team", { + extensions: { code: "FORBIDDEN" }, + }); + } + + await context.prisma.user.update({ + where: { id: user.id }, + data: { defaultTeamId: args.teamId }, + }); + + return context.prisma.teams.findUnique({ where: { id: args.teamId } }); + }, + }, + + Team: { + organisation: ( + parent: { organisationId: string }, + _args: unknown, + { prisma }: Context, + ) => { + return prisma.organisations.findUnique({ + where: { id: parent.organisationId }, + }); + }, + members: (parent: { id: string }, _args: unknown, { prisma }: Context) => { + return prisma.teamMembers.findMany({ where: { teamId: parent.id } }); + }, + locations: async ( + parent: { id: string }, + _args: unknown, + { prisma }: Context, + ) => { + const teamLocs = await prisma.teamLocations.findMany({ + where: { teamId: parent.id }, + select: { locationId: true }, + }); + return prisma.locations.findMany({ + where: { id: { in: teamLocs.map((tl) => tl.locationId) } }, + }); + }, + }, + + TeamMember: { + user: (parent: { userId: string }, _args: unknown, { prisma }: Context) => { + return prisma.user.findUnique({ where: { id: parent.userId } }); + }, + }, +}; + +/** Check that user is an org owner/admin, or global admin. */ +async function requireOrgAdminForTeam( + prisma: Context["prisma"], + user: { id: string; role?: string | null }, + orgId: string, +) { + if (user.role === "admin") return; + + const membership = await prisma.organisationUsers.findUnique({ + where: { + userId_organisationId: { userId: user.id, organisationId: orgId }, + }, + }); + if (!membership || !["owner", "admin"].includes(membership.role)) { + throw new GraphQLError("Requires org owner or admin role", { + extensions: { code: "FORBIDDEN" }, + }); + } +} + +/** Check that user is a team lead, org admin, or global admin. */ +async function requireTeamLeadOrOrgAdmin( + prisma: Context["prisma"], + user: { id: string; role?: string | null }, + teamId: string, + orgId: string, +) { + if (user.role === "admin") return; + + // Check team lead + const teamMembership = await prisma.teamMembers.findUnique({ + where: { teamId_userId: { teamId, userId: user.id } }, + }); + if (teamMembership?.role === "lead") return; + + // Check org admin + const orgMembership = await prisma.organisationUsers.findUnique({ + where: { + userId_organisationId: { userId: user.id, organisationId: orgId }, + }, + }); + if (orgMembership && ["owner", "admin"].includes(orgMembership.role)) return; + + throw new GraphQLError("Requires team lead or org admin role", { + extensions: { code: "FORBIDDEN" }, + }); +} diff --git a/src/resolvers/user.resolver.ts b/src/resolvers/user.resolver.ts index 798b971..94871de 100644 --- a/src/resolvers/user.resolver.ts +++ b/src/resolvers/user.resolver.ts @@ -93,9 +93,16 @@ export const userResolvers = { orderBy: { createdAt: "desc" }, }); }, + defaultTeam: (parent: { defaultTeamId: string | null }, _args: unknown, { prisma }: Context) => { + if (!parent.defaultTeamId) return null; + return prisma.teams.findUnique({ where: { id: parent.defaultTeamId } }); + }, organisations: (parent: { id: string }, _args: unknown, { prisma }: Context) => { return prisma.organisationUsers.findMany({ where: { userId: parent.id } }); }, + teamMemberships: (parent: { id: string }, _args: unknown, { prisma }: Context) => { + return prisma.teamMembers.findMany({ where: { userId: parent.id } }); + }, feedbacks: (parent: { id: string }, _args: unknown, { prisma }: Context) => { return prisma.userFeedbacks.findMany({ where: { userId: parent.id } }); }, diff --git a/src/schema/index.ts b/src/schema/index.ts index 7dc4605..e911bcd 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -13,6 +13,8 @@ import { featureFlagTypeDef } from "./typeDefs/types/featureFlag.js"; import { apiKeyTypeDef } from "./typeDefs/types/apiKey.js"; import { feedbackTypeDef } from "./typeDefs/types/feedback.js"; import { disasterTypeTypeDef } from "./typeDefs/types/disasterType.js"; +import { organisationTypeDef } from "./typeDefs/types/organisation.js"; +import { teamTypeDef } from "./typeDefs/types/team.js"; export const typeDefs = [ scalarTypeDef, @@ -30,4 +32,6 @@ export const typeDefs = [ apiKeyTypeDef, feedbackTypeDef, disasterTypeTypeDef, + organisationTypeDef, + teamTypeDef, ]; diff --git a/src/schema/typeDefs/mutation.ts b/src/schema/typeDefs/mutation.ts index b704c1f..fb23c0d 100644 --- a/src/schema/typeDefs/mutation.ts +++ b/src/schema/typeDefs/mutation.ts @@ -79,6 +79,44 @@ export const mutationTypeDef = gql` """Mark all notifications as read for the authenticated user.""" markAllNotificationsRead: Boolean! + + # ─── Organisations ───────────────────────────────────────────────────────── + """Create a new organisation. The creator becomes the owner.""" + createOrganisation(input: CreateOrganisationInput!): Organisation! + + """Update an existing organisation. Requires org owner or admin.""" + updateOrganisation(id: String!, input: UpdateOrganisationInput!): Organisation! + + """Add a member to an organisation.""" + addOrgMember(orgId: String!, userId: String!, role: String): OrgMember! + + """Remove a member from an organisation.""" + removeOrgMember(orgId: String!, userId: String!): Boolean! + + # ─── Teams ───────────────────────────────────────────────────────────────── + """Create a new team within an organisation. Requires org admin or owner.""" + createTeam(input: CreateTeamInput!): Team! + + """Update an existing team.""" + updateTeam(id: String!, input: UpdateTeamInput!): Team! + + """Delete a team.""" + deleteTeam(id: String!): Boolean! + + """Add a member to a team.""" + addTeamMember(teamId: String!, userId: String!, role: String): TeamMember! + + """Remove a member from a team.""" + removeTeamMember(teamId: String!, userId: String!): Boolean! + + """Update a team member's role.""" + updateTeamMemberRole(teamId: String!, userId: String!, role: String!): TeamMember! + + """Set the locations a team is scoped to. Replaces all existing locations.""" + setTeamLocations(teamId: String!, locationIds: [String!]!): Team! + + """Set the authenticated user's default team (for frontend convenience).""" + setDefaultTeam(teamId: String!): Team! } # ─── Input Types ─────────────────────────────────────────────────────────── diff --git a/src/schema/typeDefs/query.ts b/src/schema/typeDefs/query.ts index 2c822c4..e4a80b0 100644 --- a/src/schema/typeDefs/query.ts +++ b/src/schema/typeDefs/query.ts @@ -11,20 +11,20 @@ export const queryTypeDef = gql` """Look up a user by ID.""" user(id: String!): User - """List alerts, optionally filtered by status.""" - alerts(status: AlertStatus): [Alert!]! + """List alerts, optionally filtered by status. Pass teamId to scope by team locations.""" + alerts(status: AlertStatus, teamId: String): [Alert!]! """Look up an alert by ID.""" alert(id: String!): Alert - """List all signals.""" - signals: [Signal!]! + """List signals. Pass teamId to scope by team locations.""" + signals(teamId: String): [Signal!]! """Look up a signal by ID.""" signal(id: String!): Signal - """List all events.""" - events: [Event!]! + """List events. Pass teamId to scope by team locations.""" + events(teamId: String): [Event!]! """Look up an event by ID.""" event(id: String!): Event @@ -61,5 +61,18 @@ export const queryTypeDef = gql` """List all API keys belonging to the authenticated user. Requires authentication.""" myApiKeys: [ApiKey!]! + + # ─── Organisations & Teams ───────────────────────────────────────────────── + """List organisations the authenticated user belongs to.""" + myOrganisations: [Organisation!]! + + """Look up an organisation by ID. Requires membership or global admin.""" + organisation(id: String!): Organisation + + """List teams the authenticated user belongs to.""" + myTeams: [Team!]! + + """Look up a team by ID. Requires membership or global admin.""" + team(id: String!): Team } `; diff --git a/src/schema/typeDefs/types/organisation.ts b/src/schema/typeDefs/types/organisation.ts new file mode 100644 index 0000000..ad195ed --- /dev/null +++ b/src/schema/typeDefs/types/organisation.ts @@ -0,0 +1,38 @@ +import { gql } from "graphql-tag"; + +export const organisationTypeDef = gql` + """An organisation that owns teams and has members.""" + type Organisation { + id: String! + name: String! + """URL-friendly identifier.""" + slug: String! + isActive: Boolean! + createdAt: DateTime! + updatedAt: DateTime! + """Teams belonging to this organisation.""" + teams: [Team!]! + """Members of this organisation.""" + members: [OrgMember!]! + } + + """Links a user to an organisation with an org-level role.""" + type OrgMember { + id: String! + user: User! + """Organisation-level role: owner, admin, or member.""" + role: String! + createdAt: DateTime! + } + + input CreateOrganisationInput { + name: String! + slug: String! + } + + input UpdateOrganisationInput { + name: String + slug: String + isActive: Boolean + } +`; diff --git a/src/schema/typeDefs/types/team.ts b/src/schema/typeDefs/types/team.ts new file mode 100644 index 0000000..42bc791 --- /dev/null +++ b/src/schema/typeDefs/types/team.ts @@ -0,0 +1,42 @@ +import { gql } from "graphql-tag"; + +export const teamTypeDef = gql` + """A team within an organisation, scoped to specific locations.""" + type Team { + id: String! + name: String! + """URL-friendly identifier, unique within the organisation.""" + slug: String! + description: String + """The organisation this team belongs to.""" + organisation: Organisation! + """Members of this team.""" + members: [TeamMember!]! + """Locations this team is scoped to. Empty means global monitoring.""" + locations: [Location!]! + createdAt: DateTime! + updatedAt: DateTime! + } + + """Links a user to a team with a team-level role.""" + type TeamMember { + id: String! + user: User! + """Team-level role: lead, analyst, or viewer.""" + role: String! + createdAt: DateTime! + } + + input CreateTeamInput { + organisationId: String! + name: String! + slug: String! + description: String + } + + input UpdateTeamInput { + name: String + slug: String + description: String + } +`; diff --git a/src/schema/typeDefs/types/user.ts b/src/schema/typeDefs/types/user.ts index 70730c6..8e81e08 100644 --- a/src/schema/typeDefs/types/user.ts +++ b/src/schema/typeDefs/types/user.ts @@ -20,8 +20,12 @@ export const userTypeDef = gql` """Alerts received by this user.""" alerts: [UserAlert!]! notifications: [Notification!]! + """The user's default team (last selected).""" + defaultTeam: Team """Organisations this user belongs to.""" organisations: [OrganisationUser!]! + """Teams this user belongs to.""" + teamMemberships: [TeamMember!]! """Feedback given by this user.""" feedbacks: [UserFeedback!]! """Comments made by this user.""" From 531dfc1efc0bf8f92112ab63cfb9b1b290245bb4 Mon Sep 17 00:00:00 2001 From: positonic Date: Thu, 19 Mar 2026 20:20:36 +0000 Subject: [PATCH 05/15] feat: enhance access control for alerts, events, signals, and organisations - Implemented role-based access control for fetching alerts, events, and signals. - Non-admin users must now provide a teamId to access alerts, events, and signals within their team scope. - Added error handling for missing team memberships and improved error messages for forbidden access. - Updated organisation resolvers to include checks for organisation existence and improved error handling for member removal. - Introduced new enums for organisation and team member roles in the GraphQL schema. - Added fields for organisation creation and updates, including active status and timestamps. - Created migrations to deduplicate organisation slugs and rename foreign key constraints. --- .claude/settings.json | 7 +- .../migration.sql | 9 + .../migration.sql | 2 + scripts/build-docs.ts | 17 +- src/docs/docs.html | 880 ++++++++++++------ src/resolvers/alert.resolver.ts | 37 +- src/resolvers/event.resolver.ts | 35 +- src/resolvers/organisation.resolver.ts | 39 +- src/resolvers/signal.resolver.ts | 32 +- src/resolvers/team.resolver.ts | 79 +- src/schema/typeDefs/mutation.ts | 6 +- src/schema/typeDefs/query.ts | 12 +- src/schema/typeDefs/types/organisation.ts | 25 + 13 files changed, 857 insertions(+), 323 deletions(-) create mode 100644 prisma/migrations/20260319020000_fix_duplicate_org_slugs/migration.sql create mode 100644 prisma/migrations/20260319202006_fix_duplicate_org/migration.sql diff --git a/.claude/settings.json b/.claude/settings.json index 15266f2..6c74543 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -56,7 +56,12 @@ "Bash(bun run tsc -p tsconfig.build.json --noEmit)", "Bash(ls node_modules/nodemailer/lib/*.d.ts)", "Bash(bun test:*)", - "Bash(git add:*)" + "Bash(git add:*)", + "Bash(git checkout:*)", + "Bash(git push:*)", + "Bash(gh pr:*)", + "Bash(git stash:*)", + "Bash(bun run:*)" ] } } diff --git a/prisma/migrations/20260319020000_fix_duplicate_org_slugs/migration.sql b/prisma/migrations/20260319020000_fix_duplicate_org_slugs/migration.sql new file mode 100644 index 0000000..eb89bb5 --- /dev/null +++ b/prisma/migrations/20260319020000_fix_duplicate_org_slugs/migration.sql @@ -0,0 +1,9 @@ +-- Deduplicate organisation slugs by appending the row number for collisions. +-- Only the first org (by id) keeps the base slug; subsequent duplicates get "-2", "-3", etc. +UPDATE "organisations" o +SET "slug" = o."slug" || '-' || sub.rn::text +FROM ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY "slug" ORDER BY id) AS rn + FROM "organisations" +) sub +WHERE o.id = sub.id AND sub.rn > 1; diff --git a/prisma/migrations/20260319202006_fix_duplicate_org/migration.sql b/prisma/migrations/20260319202006_fix_duplicate_org/migration.sql new file mode 100644 index 0000000..d2cc7db --- /dev/null +++ b/prisma/migrations/20260319202006_fix_duplicate_org/migration.sql @@ -0,0 +1,2 @@ +-- RenameForeignKey +ALTER TABLE "user" RENAME CONSTRAINT "user_active_team_id_fkey" TO "user_default_team_id_fkey"; diff --git a/scripts/build-docs.ts b/scripts/build-docs.ts index d3f4250..d44121f 100644 --- a/scripts/build-docs.ts +++ b/scripts/build-docs.ts @@ -8,10 +8,15 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const schema = makeExecutableSchema({ typeDefs }); -const schemaData = introspectSchema(schema); -const html = renderDocsPage(schemaData); +try { + const schema = makeExecutableSchema({ typeDefs }); + const schemaData = introspectSchema(schema); + const html = renderDocsPage(schemaData); -const outPath = join(__dirname, "../src/docs/docs.html"); -writeFileSync(outPath, html, "utf-8"); -console.log(`docs: wrote ${outPath}`); + const outPath = join(__dirname, "../src/docs/docs.html"); + writeFileSync(outPath, html, "utf-8"); + console.log(`docs: wrote ${outPath}`); +} catch (error) { + console.error("docs build failed:", error); + process.exit(1); +} diff --git a/src/docs/docs.html b/src/docs/docs.html index 20b08b5..7258e53 100644 --- a/src/docs/docs.html +++ b/src/docs/docs.html @@ -140,7 +140,7 @@ Queries Mutations Types - DateTimeGeoJSONJSONAlertStatusDetectionStatusNotificationStatusCreateAlertInputCreateApiKeyInputCreateDataSourceInputCreateDetectionInputCreateEventInputCreateLocationInputCreateNotificationInputUpdateAlertInputUpdateDataSourceInputUpdateDetectionInputUpdateEventInputUpdateLocationInputUpdateProfileInputAlertAlertLocationApiKeyCreateApiKeyPayloadDataSourceDetectionDetectionLocationEventFeatureFlagLocationNotificationSignalUserUserAlert + DateTimeGeoJSONJSONAlertStatusDetectionStatusNotificationStatusOrgMemberRoleTeamMemberRoleCreateAlertInputCreateApiKeyInputCreateDataSourceInputCreateEventInputCreateLocationInputCreateNotificationInputCreateOrganisationInputCreateSignalInputCreateTeamInputUpdateAlertInputUpdateDataSourceInputUpdateEventInputUpdateLocationInputUpdateOrganisationInputUpdateProfileInputUpdateTeamInputAlertApiKeyCommentTagCreateApiKeyPayloadDataSourceDisasterTypeEventEventEscalationFeatureFlagLocationNotificationOrganisationOrganisationUserOrgMemberSignalTeamTeamMemberUserUserAlertUserCommentUserFeedback @@ -149,9 +149,9 @@

Docs › GET STARTED › Introduction

Introduction

-

Welcome to the CLEAR API — your gateway to environmental intelligence.

+

Welcome to the CLEAR API - your gateway to humanitarian intelligence.

-

The CLEAR API gives you programmatic access to environmental alerts, detection events, data sources, and geographic location data through a single GraphQL endpoint. Whether you’re building a monitoring dashboard, integrating alerts into your workflow, or analysing detection patterns, this API has you covered.

+

The CLEAR API gives you programmatic access to signals, events, alerts, data sources, and geographic location data through a single GraphQL endpoint. Whether you’re building a monitoring dashboard, integrating alerts into your workflow, or analysing humanitarian patterns, this API has you covered.

Everything here is accessible via GraphQL at /graphql. You send a query describing exactly the data you want, and you get back precisely that — nothing more, nothing less.

@@ -159,10 +159,12 @@

What You Can Do

- - - - + + + + + + @@ -255,35 +257,27 @@

Queries

- + - - - - - - - - - + - + - + - + - + @@ -316,10 +310,34 @@

Queries

+ + + + + + + + + + + + + + + + + + + + + + + +
FeatureDescription
AlertsBrowse, filter, and inspect environmental alerts with severity levels and geographic scope.
DetectionsAccess raw and processed detection events from multiple data sources with confidence scores.
Data SourcesDiscover the external data sources that feed into the platform (satellites, sensors, manual reports).
LocationsQuery a hierarchical geographic tree — countries, states, cities — and see what’s happening where.
SignalsAccess raw data items collected from data sources, with location links and metadata.
EventsBrowse grouped signals forming coherent situations, with location, population, and type data.
AlertsView events escalated for notification, delivered to subscribed users.
Data SourcesDiscover the external data feeds (ACLED, FEWS NET, social media monitors) that supply signals.
LocationsQuery a hierarchical geographic tree — countries, states, cities — with PostGIS geometry.
Disaster TypesLook up disaster classifications with GLIDE numbers.
Feature FlagsCheck runtime feature toggles to adapt your application’s behaviour.
API KeysCreate and manage personal API keys for server-to-server authentication.
alerts [Alert!]!List alerts, optionally filtered by status.
status: AlertStatus
List alerts. Requires authentication. Admins may omit teamId to list all; non-admins must provide a teamId for a team they belong to.
status: AlertStatusteamId: String
alert AlertLook up an alert by ID.
id: String!
detections[Detection!]!List detections, optionally filtered by status.
status: DetectionStatus
detectionDetectionLook up a detection by ID.
id: String!
Look up an alert by ID. Requires authentication. Non-admins can only access alerts within their team scope.
id: String!
signals [Signal!]!List all signals.List signals. Requires authentication. Admins may omit teamId to list all; non-admins must provide a teamId for a team they belong to.
teamId: String
signal SignalLook up a signal by ID.
id: String!
Look up a signal by ID. Requires authentication. Non-admins can only access signals within their team scope.
id: String!
events [Event!]!List all events.List events. Requires authentication. Admins may omit teamId to list all; non-admins must provide a teamId for a team they belong to.
teamId: String
event EventLook up an event by ID.
id: String!
Look up an event by ID. Requires authentication. Non-admins can only access events within their team scope.
id: String!
dataSources [DataSource!]! featureFlag FeatureFlag Look up a feature flag by its unique key.
key: String!
disasterTypes[DisasterType!]!List all disaster type classifications.
disasterTypeDisasterTypeLook up a disaster type by ID.
id: String!
myApiKeys [ApiKey!]! List all API keys belonging to the authenticated user. Requires authentication.
myOrganisations[Organisation!]!List organisations the authenticated user belongs to.
organisationOrganisationLook up an organisation by ID. Requires membership or global admin.
id: String!
myTeams[Team!]!List teams the authenticated user belongs to.
teamTeamLook up a team by ID. Requires membership or global admin.
id: String!
@@ -349,7 +367,7 @@

Mutations

createAlert Alert! - Create a new alert.
input: CreateAlertInput!
+ Create an alert from an event, notifying subscribers.
input: CreateAlertInput!
updateAlert Alert! @@ -358,22 +376,10 @@

Mutations

deleteAlert Boolean! Delete an alert.
id: String!
- - createDetection - Detection! - Create a new detection.
input: CreateDetectionInput!
- - updateDetection - Detection! - Update an existing detection.
id: String!input: UpdateDetectionInput!
- - deleteDetection - Boolean! - Delete a detection.
id: String!
createSignal Signal! - Create a signal from a detection.
detectionId: String!
+ Create a signal from a data source.
input: CreateSignalInput!
deleteSignal Boolean! @@ -430,43 +436,67 @@

Mutations

markAllNotificationsRead Boolean! Mark all notifications as read for the authenticated user. + + createOrganisation + Organisation! + Create a new organisation. The creator becomes the owner.
input: CreateOrganisationInput!
+ + updateOrganisation + Organisation! + Update an existing organisation. Requires org owner or admin.
id: String!input: UpdateOrganisationInput!
+ + addOrgMember + OrgMember! + Add a member to an organisation.
orgId: String!userId: String!role: OrgMemberRole
+ + removeOrgMember + Boolean! + Remove a member from an organisation.
orgId: String!userId: String!
+ + createTeam + Team! + Create a new team within an organisation. Requires org admin or owner.
input: CreateTeamInput!
+ + updateTeam + Team! + Update an existing team.
id: String!input: UpdateTeamInput!
+ + deleteTeam + Boolean! + Delete a team.
id: String!
+ + addTeamMember + TeamMember! + Add a member to a team.
teamId: String!userId: String!role: TeamMemberRole
+ + removeTeamMember + Boolean! + Remove a member from a team.
teamId: String!userId: String!
+ + updateTeamMemberRole + TeamMember! + Update a team member's role.
teamId: String!userId: String!role: TeamMemberRole!
+ + setTeamLocations + Team! + Set the locations a team is scoped to. Replaces all existing locations.
teamId: String!locationIds: [String!]!
+ + setDefaultTeam + Team! + Set the authenticated user's default team (for frontend convenience).
teamId: String!

Types

All types in the schema, auto-generated from the running server.

-

DateTime scalar

ISO 8601 date-time string (e.g. 2024-01-15T09:30:00.000Z).

GeoJSON scalar

JSON scalar

Arbitrary JSON value — objects, arrays, strings, numbers, booleans, or null.

AlertStatus enum

Publication status of an alert.

ValueDescription
draft
published
archived

DetectionStatus enum

Processing status of a detection.

ValueDescription
raw
processed
ignored

NotificationStatus enum

ValueDescription
PENDING
DELIVERED
FAILED
READ

CreateAlertInput input

- - - - - +

DateTime scalar

ISO 8601 date-time string (e.g. 2024-01-15T09:30:00.000Z).

GeoJSON scalar

JSON scalar

Arbitrary JSON value — objects, arrays, strings, numbers, booleans, or null.

AlertStatus enum

Publication status of an alert.

FieldTypeDescription
titleString!
description
ValueDescription
draft
published
archived

DetectionStatus enum

Processing status of a detection (retained for potential future use).

ValueDescription
raw
processed
ignored

NotificationStatus enum

ValueDescription
PENDING
DELIVERED
FAILED
READ

OrgMemberRole enum

Role within an organisation.

ValueDescription
owner
admin
member

TeamMemberRole enum

Role within a team.

ValueDescription
lead
analyst
viewer

CreateAlertInput input

+ - - - - - + - - - - - - - - - - - - - - - -
FieldTypeDescription
eventId String!
severityInt!The event ID to create an alert from.
status AlertStatus
primaryEventIdString
eventIds[String!]
locationIds[String!]
metadataJSON

CreateApiKeyInput input

Input for creating a new API key.

@@ -495,45 +525,73 @@

DateTime scalarinfoUrl

-
FieldTypeDescription
name String!String

CreateDetectionInput input

+
FieldTypeDescription

CreateEventInput input

+ + + + + + + + + + + + + + + + - - + + - - + + - - + + - - + + - + - - + + -
FieldTypeDescription
signalIds[String!]!
titleString
descriptionString
descriptionSignalsJSON
validFrom String!
confidenceFloatvalidToString!
statusDetectionStatusfirstSignalCreatedAtString!
detectedAtDateTimelastSignalCreatedAtString!
rawDataJSONoriginIdString
sourceIddestinationId String
locationIds[String!]locationIdString

CreateEventInput input

- + + - + + + + +
FieldTypeDescription
signalIds
types [String!]!
primarySignalIdpopulationAffected String
rankFloat!

CreateLocationInput input

- + + + + + + + + + @@ -543,22 +601,10 @@

DateTime scalarlevel

- - - - - - - - - - - -
FieldTypeDescription
geoIdString!Int
osmIdString
pCodeString
nameInt!
pointTypeString
parentId String
latitudeFloat
longitudeFloat

CreateNotificationInput input

@@ -579,7 +625,35 @@

DateTime scalaractionText

-
FieldTypeDescription
userId String!String

UpdateAlertInput input

+
FieldTypeDescription

CreateOrganisationInput input

Fields for creating a new organisation.

+ + + + + + + +
FieldTypeDescription
nameString!Display name for the organisation.
slugString!URL-friendly identifier. Must be unique.

CreateSignalInput input

+ + + + + + + + + + + + + + + + + + + + @@ -588,28 +662,36 @@

DateTime scalarString

- - + + - - + + - + +
FieldTypeDescription
sourceIdString!
rawDataJSON!
publishedAtString!
collectedAtString
urlString
title String
severityIntoriginIdString
statusAlertStatusdestinationIdString
primaryEventIdlocationId String

CreateTeamInput input

+ + + - - + + - - + + - - + + + +
FieldTypeDescription
organisationIdString!
eventIds[String!]nameString!
locationIds[String!]slugString!
metadataJSONdescriptionString

UpdateAlertInput input

+ +
FieldTypeDescription
statusAlertStatus

UpdateDataSourceInput input

@@ -631,40 +713,72 @@

DateTime scalarinfoUrl

-
FieldTypeDescription
nameString

UpdateDetectionInput input

+
FieldTypeDescription

UpdateEventInput input

+ + + + - - + + - - + + - - + + - + - - + + -
FieldTypeDescription
signalIds[String!]
title String
confidenceFloatdescriptionString
statusDetectionStatusdescriptionSignalsJSON
rawDataJSONvalidFromString
sourceIdvalidTo String
locationIds[String!]firstSignalCreatedAtString

UpdateEventInput input

- + + + + + + + + + + + + + + + + + + - + + + + +
FieldTypeDescription
signalIds
lastSignalCreatedAtString
originIdString
destinationIdString
locationIdString
types [String!]
primarySignalIdpopulationAffected String
rankFloat

UpdateLocationInput input

+ + + + + + + + @@ -675,22 +789,22 @@

DateTime scalarlevel

- - - - +
FieldTypeDescription
geoIdInt
osmIdString
pCode String
Int
pointTypeString
parentId String

UpdateOrganisationInput input

Fields for updating an existing organisation.

+ + + - - - + + + - - - + + +
FieldTypeDescription
nameStringNew display name.
latitudeFloatslugStringNew URL-friendly identifier.
longitudeFloatisActiveBooleanSet active/inactive status.

UpdateProfileInput input

@@ -715,74 +829,34 @@

DateTime scalarenableSMSNotification

-
FieldTypeDescription
name StringBoolean

Alert object

An environmental alert with severity, geographic scope, and linked detections.

- - +
FieldTypeDescription
idString!

UpdateTeamInput input

+ + - - + + + + +
FieldTypeDescription
nameString
titleString!slugString
descriptionString

Alert object

An alert created from an event, distributed to subscribed users.

+ - - - + + + - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - -
FieldTypeDescription
id String!
severityInt!Severity from 1 (low) to 5 (critical).eventEvent!The event this alert was created from.
status AlertStatus!
createdByUserThe user who created this alert.
primaryEventEventThe primary event that triggered this alert.
metadataJSONArbitrary metadata as JSON.
events[Event!]!All events linked to this alert.
locations[AlertLocation!]!Geographic locations affected by this alert.
feedbackuserAlerts [UserAlert!]!User feedback and ratings for this alert.
createdAtDateTime!
updatedAtDateTime!

AlertLocation object

Links an alert to a geographic location.

- - - - - - - - - - - - - - - +
FieldTypeDescription
idString!
alertAlert!
locationLocation!
createdAtDateTime!Users who received this alert.

ApiKey object

A personal API key for programmatic access. The full key is only shown once at creation.

@@ -815,6 +889,14 @@

DateTime scalarupdatedAt

+
FieldTypeDescription
id String!DateTime!

CommentTag object

A tag linking a user to a comment.

+ + + + + + +
FieldTypeDescription
userUser!
commentUserComment!

CreateApiKeyPayload object

Returned only from createApiKey. Contains the full plaintext key that will never be retrievable again.

@@ -824,7 +906,7 @@

DateTime scalarkey

-
FieldTypeDescription
apiKeyString! The full API key. Copy this immediately — it cannot be retrieved later.

DataSource object

An external data source that feeds detections and alerts into the system.

+
FieldTypeDescription

DataSource object

An external data source that feeds signals into the system.

@@ -857,84 +939,124 @@

DateTime scalarDateTime!

- - - -
FieldTypeDescription
id String!
detections[Detection!]!Detections produced by this source.

Detection object

A detection event from a data source, with confidence score and geographic scope.

+ + + +
FieldTypeDescription
signals[Signal!]!Signals collected from this data source.

DisasterType object

A disaster classification with GLIDE number.

- + - - - - - - + + - - - - - - - + + + +
FieldTypeDescription
id String!
titledisasterType String!
confidenceFloatConfidence score from 0.0 to 1.0.
statusDetectionStatus!disasterClassString!
detectedAtDateTime!When this event was originally detected.
rawDataJSONOriginal detection payload as JSON.glideNumberString!

Event object

An event grouping related signals into a coherent situation.

+ + + - - - + + + - - - + + + - - - + + + - + - + -
FieldTypeDescription
idString!
sourceDataSourceThe data source that produced this detection.titleString
signalSignalThe signal derived from this detection, if any.descriptionString
locations[DetectionLocation!]!Geographic locations where this detection occurred.descriptionSignalsJSONLLM-generated signal descriptions as JSON.
createdAtvalidFrom DateTime!
updatedAtvalidTo DateTime!

DetectionLocation object

Links a detection to a geographic location.

- - - - - + + - - + + - - + + + + + + + + + + + + + + + + + + + + + + -
FieldTypeDescription
idString!
detectionDetection!firstSignalCreatedAtDateTime!
locationLocation!lastSignalCreatedAtDateTime!
createdAtDateTime!originLocationLocationOrigin location of the event.
destinationLocationLocationDestination location of the event.
generalLocationLocationGeneral location (when no origin/destination).
types[String!]!Event type tags.
populationAffectedStringEstimated population affected.
rankFloat!

Event object

+ + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
signals[Signal!]!Signals linked to this event.
alerts[Alert!]!Alerts created from this event.
feedbacks[UserFeedback!]!User feedback on this event.
comments[UserComment!]!User comments on this event.
escalations[EventEscalation!]!Escalations by users.

EventEscalation object

Tracks a user escalating an event, optionally to a situation.

- - + + - - + + - - + + + + + + + + + +
FieldTypeDescription
id String!
signals[Signal!]!userUser!
primarySignalSignaleventEvent!
alerts[Alert!]!isSituationBoolean!Whether this has been escalated to a situation.
validFromDateTime!
validToDateTime!

FeatureFlag object

A feature toggle that controls runtime behavior.

@@ -958,8 +1080,16 @@

DateTime scalar—

- - + + + + + + + + + + @@ -969,25 +1099,9 @@

DateTime scalarInt!

- - - - - - - - - - - - - + - - - - - + @@ -996,14 +1110,6 @@

DateTime scalarchildren

- - - - - - - -
FieldTypeDescription
id
geoIdString!Unique geographic identifier.IntGeoNames identifier.
osmIdStringOpenStreetMap identifier.
pCodeStringP-Code identifier.
name String!Hierarchy level: 0 = country, 1 = state/province, 2 = city, etc.
latitudeFloat
longitudeFloat
pointTypeString
pointgeometry GeoJSON
boundaryGeoJSONGeometry as GeoJSON (Point or MultiPolygon).
parent Location[Location!]! Child locations one level below.
alertLinks[AlertLocation!]!Alerts affecting this location.
detectionLinks[DetectionLocation!]!Detections at this location.

Notification object

@@ -1048,21 +1154,177 @@

DateTime scalarupdatedAt

-
FieldTypeDescription
id String!DateTime!

Signal object

+
FieldTypeDescription

Organisation object

An organisation that owns teams and has members.

- - + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
id String!
detectionDetection!nameString!
slugString!URL-friendly identifier.
isActiveBoolean!Whether this organisation is active. Inactive organisations are hidden from non-admin users.
createdAtDateTime!When this organisation was created.
updatedAtDateTime!When this organisation was last updated.
teams[Team!]!Teams belonging to this organisation.
members[OrgMember!]!Members of this organisation.

OrganisationUser object

Links a user to an organisation with a role.

+ + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
userIdString!
organisationIdString!
roleString!

OrgMember object

Links a user to an organisation with an org-level role.

+ + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
userUser!
roleString!Organisation-level role: owner, admin, or member.
createdAtDateTime!When this membership was created.

Signal object

A signal derived from a data source.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
sourceDataSource!The data source this signal was collected from.
rawDataJSON!Original signal payload as JSON.
publishedAtDateTime!
collectedAtDateTime!
urlString
titleString
descriptionString
originLocationLocationOrigin location of the signal.
destinationLocationLocationDestination location of the signal.
generalLocationLocationGeneral location (when no origin/destination).
events [Event!]!Events this signal is linked to.
feedbacks[UserFeedback!]!User feedback on this signal.
comments[UserComment!]!User comments on this signal.

Team object

A team within an organisation, scoped to specific locations.

+ + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
primaryOf[Event!]!nameString!
slugString!URL-friendly identifier, unique within the organisation.
descriptionString
organisationOrganisation!The organisation this team belongs to.
members[TeamMember!]!Members of this team.
locations[Location!]!Locations this team is scoped to. Empty means global monitoring.
createdAtDateTime!
updatedAtDateTime!

TeamMember object

Links a user to a team with a team-level role.

+ + + + + + + + + + + + + +
FieldTypeDescription
idString!
userUser!
roleString!Team-level role: lead, analyst, or viewer.
createdAtDateTime!

User object

A registered user with role-based access.

@@ -1117,18 +1379,38 @@

DateTime scalarDateTime!

- - - - - + - + -
FieldTypeDescription
id
createdAlerts[Alert!]!Alerts created by this user.
feedbackalerts [UserAlert!]!This user's feedback on alerts.Alerts received by this user.
notifications [Notification!]!

UserAlert object

A user's feedback on an alert — read status, rating, and comments.

+ + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
defaultTeamTeamThe user's default team (last selected).
organisations[OrganisationUser!]!Organisations this user belongs to.
teamMemberships[TeamMember!]!Teams this user belongs to.
feedbacks[UserFeedback!]!Feedback given by this user.
comments[UserComment!]!Comments made by this user.
escalations[EventEscalation!]!Events/alerts escalated by this user.

UserAlert object

Tracks an alert delivered to a user — view status.

@@ -1141,17 +1423,73 @@

DateTime scalarAlert!

- + - + +
FieldTypeDescription
id String!
readAtviewedAt DateTimeWhen the user marked this alert as read.When the user viewed this alert.

UserComment object

A user comment on a signal or event, with reply support.

+ + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
ratingIntUser rating (1-5).userUser!
eventEvent
signalSignal
commentString!
isCommentReplyBoolean!Whether this comment is a reply to another comment.
repliedToCommentId StringID of the comment being replied to, if any.
tags[CommentTag!]!Users tagged in this comment.
createdAtDateTime!
updatedAtDateTime!

UserFeedback object

User feedback on a signal or event — rating and optional text.

+ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/resolvers/alert.resolver.ts b/src/resolvers/alert.resolver.ts index ac64afa..6254680 100644 --- a/src/resolvers/alert.resolver.ts +++ b/src/resolvers/alert.resolver.ts @@ -37,8 +37,41 @@ export const alertResolvers = { }, }); }, - alert: (_parent: unknown, args: { id: string }, { prisma }: Context) => { - return prisma.alerts.findUnique({ where: { id: args.id } }); + alert: async (_parent: unknown, args: { id: string }, context: Context) => { + const user = requireAuth(context); + const alert = await context.prisma.alerts.findUnique({ + where: { id: args.id }, + include: { event: true }, + }); + if (!alert) return null; + if (user.role !== "admin") { + const teamMemberships = await context.prisma.teamMembers.findMany({ + where: { userId: user.id }, + select: { teamId: true }, + }); + if (teamMemberships.length === 0) { + throw new GraphQLError("No team membership found", { + extensions: { code: "FORBIDDEN" }, + }); + } + let accessible = false; + for (const { teamId } of teamMemberships) { + const eventFilter = await buildEventLocationFilterForTeam(context.prisma, teamId); + if (!eventFilter) { accessible = true; break; } + const found = await context.prisma.alerts.findFirst({ + where: { id: args.id, event: eventFilter }, + }); + if (found) { accessible = true; break; } + } + if (!accessible) { + throw new GraphQLError("Alert not accessible from your teams", { + extensions: { code: "FORBIDDEN" }, + }); + } + } + // Return without the included event to match the type + const { event: _event, ...alertData } = alert; + return alertData; }, }, Mutation: { diff --git a/src/resolvers/event.resolver.ts b/src/resolvers/event.resolver.ts index 683c0ba..4eea8ed 100644 --- a/src/resolvers/event.resolver.ts +++ b/src/resolvers/event.resolver.ts @@ -55,8 +55,39 @@ export const eventResolvers = { const filter = await buildEventLocationFilterForTeam(context.prisma, args.teamId); return context.prisma.events.findMany({ where: filter }); }, - event: (_parent: unknown, args: { id: string }, { prisma }: Context) => { - return prisma.events.findUnique({ where: { id: args.id } }); + event: async (_parent: unknown, args: { id: string }, context: Context) => { + const user = requireAuth(context); + const event = await context.prisma.events.findUnique({ where: { id: args.id } }); + if (!event) return null; + if (user.role !== "admin") { + // Events don't have a direct teamId; check via location-based team scope + // For now, require admin access for single event lookups without team context + const teamMemberships = await context.prisma.teamMembers.findMany({ + where: { userId: user.id }, + select: { teamId: true }, + }); + if (teamMemberships.length === 0) { + throw new GraphQLError("No team membership found", { + extensions: { code: "FORBIDDEN" }, + }); + } + // Check if the event falls within any of the user's team scopes + let accessible = false; + for (const { teamId } of teamMemberships) { + const filter = await buildEventLocationFilterForTeam(context.prisma, teamId); + if (!filter) { accessible = true; break; } // global monitoring team + const found = await context.prisma.events.findFirst({ + where: { id: args.id, ...filter }, + }); + if (found) { accessible = true; break; } + } + if (!accessible) { + throw new GraphQLError("Event not accessible from your teams", { + extensions: { code: "FORBIDDEN" }, + }); + } + } + return event; }, }, Mutation: { diff --git a/src/resolvers/organisation.resolver.ts b/src/resolvers/organisation.resolver.ts index 8f26b95..29c5a9f 100644 --- a/src/resolvers/organisation.resolver.ts +++ b/src/resolvers/organisation.resolver.ts @@ -1,6 +1,6 @@ import { GraphQLError } from "graphql"; import type { Context } from "../context.js"; -import { requireAuth, requireRole } from "../utils/auth-guard.js"; +import { requireAuth } from "../utils/auth-guard.js"; interface CreateOrganisationInput { name: string; @@ -100,6 +100,16 @@ export const organisationResolvers = { context: Context, ) => { const user = requireAuth(context); + + const org = await context.prisma.organisations.findUnique({ + where: { id: args.id }, + }); + if (!org) { + throw new GraphQLError("Organisation not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + await requireOrgAdmin(context.prisma, user, args.id); return context.prisma.organisations.update({ @@ -133,15 +143,26 @@ export const organisationResolvers = { const user = requireAuth(context); await requireOrgAdmin(context.prisma, user, args.orgId); - await context.prisma.organisationUsers.delete({ - where: { - userId_organisationId: { - userId: args.userId, - organisationId: args.orgId, + try { + await context.prisma.organisationUsers.delete({ + where: { + userId_organisationId: { + userId: args.userId, + organisationId: args.orgId, + }, }, - }, - }); - return true; + }); + return true; + } catch (error: unknown) { + if ( + error instanceof Error && + "code" in error && + (error as { code: string }).code === "P2025" + ) { + return false; + } + throw error; + } }, }, diff --git a/src/resolvers/signal.resolver.ts b/src/resolvers/signal.resolver.ts index 2e8acbf..6b94cb4 100644 --- a/src/resolvers/signal.resolver.ts +++ b/src/resolvers/signal.resolver.ts @@ -34,8 +34,36 @@ export const signalResolvers = { const filter = await buildLocationFilterForTeam(context.prisma, args.teamId); return context.prisma.signals.findMany({ where: filter }); }, - signal: (_parent: unknown, args: { id: string }, { prisma }: Context) => { - return prisma.signals.findUnique({ where: { id: args.id } }); + signal: async (_parent: unknown, args: { id: string }, context: Context) => { + const user = requireAuth(context); + const signal = await context.prisma.signals.findUnique({ where: { id: args.id } }); + if (!signal) return null; + if (user.role !== "admin") { + const teamMemberships = await context.prisma.teamMembers.findMany({ + where: { userId: user.id }, + select: { teamId: true }, + }); + if (teamMemberships.length === 0) { + throw new GraphQLError("No team membership found", { + extensions: { code: "FORBIDDEN" }, + }); + } + let accessible = false; + for (const { teamId } of teamMemberships) { + const filter = await buildLocationFilterForTeam(context.prisma, teamId); + if (!filter) { accessible = true; break; } + const found = await context.prisma.signals.findFirst({ + where: { id: args.id, ...filter }, + }); + if (found) { accessible = true; break; } + } + if (!accessible) { + throw new GraphQLError("Signal not accessible from your teams", { + extensions: { code: "FORBIDDEN" }, + }); + } + } + return signal; }, }, Mutation: { diff --git a/src/resolvers/team.resolver.ts b/src/resolvers/team.resolver.ts index 7cea816..a49ed8c 100644 --- a/src/resolvers/team.resolver.ts +++ b/src/resolvers/team.resolver.ts @@ -196,12 +196,25 @@ export const teamResolvers = { team.organisationId, ); - await context.prisma.teamMembers.delete({ - where: { - teamId_userId: { teamId: args.teamId, userId: args.userId }, - }, - }); - return true; + try { + await context.prisma.teamMembers.delete({ + where: { + teamId_userId: { teamId: args.teamId, userId: args.userId }, + }, + }); + return true; + } catch (error: unknown) { + if ( + error instanceof Error && + "code" in error && + (error as { code: string }).code === "P2025" + ) { + throw new GraphQLError("Team member not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + throw error; + } }, updateTeamMemberRole: async ( @@ -226,12 +239,25 @@ export const teamResolvers = { team.organisationId, ); - return context.prisma.teamMembers.update({ - where: { - teamId_userId: { teamId: args.teamId, userId: args.userId }, - }, - data: { role: args.role }, - }); + try { + return await context.prisma.teamMembers.update({ + where: { + teamId_userId: { teamId: args.teamId, userId: args.userId }, + }, + data: { role: args.role }, + }); + } catch (error: unknown) { + if ( + error instanceof Error && + "code" in error && + (error as { code: string }).code === "P2025" + ) { + throw new GraphQLError("Team member not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + throw error; + } }, setTeamLocations: async ( @@ -278,17 +304,28 @@ export const teamResolvers = { ) => { const user = requireAuth(context); - // Verify membership - const membership = await context.prisma.teamMembers.findUnique({ - where: { - teamId_userId: { teamId: args.teamId, userId: user.id }, - }, + // Verify team exists + const team = await context.prisma.teams.findUnique({ + where: { id: args.teamId }, }); + if (!team) { + throw new GraphQLError("Team not found", { + extensions: { code: "NOT_FOUND" }, + }); + } - if (!membership && user.role !== "admin") { - throw new GraphQLError("Not a member of this team", { - extensions: { code: "FORBIDDEN" }, + // Verify membership (admins bypass) + if (user.role !== "admin") { + const membership = await context.prisma.teamMembers.findUnique({ + where: { + teamId_userId: { teamId: args.teamId, userId: user.id }, + }, }); + if (!membership) { + throw new GraphQLError("Not a member of this team", { + extensions: { code: "FORBIDDEN" }, + }); + } } await context.prisma.user.update({ @@ -296,7 +333,7 @@ export const teamResolvers = { data: { defaultTeamId: args.teamId }, }); - return context.prisma.teams.findUnique({ where: { id: args.teamId } }); + return team; }, }, diff --git a/src/schema/typeDefs/mutation.ts b/src/schema/typeDefs/mutation.ts index fb23c0d..d2f42ab 100644 --- a/src/schema/typeDefs/mutation.ts +++ b/src/schema/typeDefs/mutation.ts @@ -88,7 +88,7 @@ export const mutationTypeDef = gql` updateOrganisation(id: String!, input: UpdateOrganisationInput!): Organisation! """Add a member to an organisation.""" - addOrgMember(orgId: String!, userId: String!, role: String): OrgMember! + addOrgMember(orgId: String!, userId: String!, role: OrgMemberRole): OrgMember! """Remove a member from an organisation.""" removeOrgMember(orgId: String!, userId: String!): Boolean! @@ -104,13 +104,13 @@ export const mutationTypeDef = gql` deleteTeam(id: String!): Boolean! """Add a member to a team.""" - addTeamMember(teamId: String!, userId: String!, role: String): TeamMember! + addTeamMember(teamId: String!, userId: String!, role: TeamMemberRole): TeamMember! """Remove a member from a team.""" removeTeamMember(teamId: String!, userId: String!): Boolean! """Update a team member's role.""" - updateTeamMemberRole(teamId: String!, userId: String!, role: String!): TeamMember! + updateTeamMemberRole(teamId: String!, userId: String!, role: TeamMemberRole!): TeamMember! """Set the locations a team is scoped to. Replaces all existing locations.""" setTeamLocations(teamId: String!, locationIds: [String!]!): Team! diff --git a/src/schema/typeDefs/query.ts b/src/schema/typeDefs/query.ts index e4a80b0..f8ed292 100644 --- a/src/schema/typeDefs/query.ts +++ b/src/schema/typeDefs/query.ts @@ -11,22 +11,22 @@ export const queryTypeDef = gql` """Look up a user by ID.""" user(id: String!): User - """List alerts, optionally filtered by status. Pass teamId to scope by team locations.""" + """List alerts. Requires authentication. Admins may omit teamId to list all; non-admins must provide a teamId for a team they belong to.""" alerts(status: AlertStatus, teamId: String): [Alert!]! - """Look up an alert by ID.""" + """Look up an alert by ID. Requires authentication. Non-admins can only access alerts within their team scope.""" alert(id: String!): Alert - """List signals. Pass teamId to scope by team locations.""" + """List signals. Requires authentication. Admins may omit teamId to list all; non-admins must provide a teamId for a team they belong to.""" signals(teamId: String): [Signal!]! - """Look up a signal by ID.""" + """Look up a signal by ID. Requires authentication. Non-admins can only access signals within their team scope.""" signal(id: String!): Signal - """List events. Pass teamId to scope by team locations.""" + """List events. Requires authentication. Admins may omit teamId to list all; non-admins must provide a teamId for a team they belong to.""" events(teamId: String): [Event!]! - """Look up an event by ID.""" + """Look up an event by ID. Requires authentication. Non-admins can only access events within their team scope.""" event(id: String!): Event """List all data sources.""" diff --git a/src/schema/typeDefs/types/organisation.ts b/src/schema/typeDefs/types/organisation.ts index ad195ed..f46f165 100644 --- a/src/schema/typeDefs/types/organisation.ts +++ b/src/schema/typeDefs/types/organisation.ts @@ -7,8 +7,11 @@ export const organisationTypeDef = gql` name: String! """URL-friendly identifier.""" slug: String! + """Whether this organisation is active. Inactive organisations are hidden from non-admin users.""" isActive: Boolean! + """When this organisation was created.""" createdAt: DateTime! + """When this organisation was last updated.""" updatedAt: DateTime! """Teams belonging to this organisation.""" teams: [Team!]! @@ -22,17 +25,39 @@ export const organisationTypeDef = gql` user: User! """Organisation-level role: owner, admin, or member.""" role: String! + """When this membership was created.""" createdAt: DateTime! } + """Role within an organisation.""" + enum OrgMemberRole { + owner + admin + member + } + + """Role within a team.""" + enum TeamMemberRole { + lead + analyst + viewer + } + + """Fields for creating a new organisation.""" input CreateOrganisationInput { + """Display name for the organisation.""" name: String! + """URL-friendly identifier. Must be unique.""" slug: String! } + """Fields for updating an existing organisation.""" input UpdateOrganisationInput { + """New display name.""" name: String + """New URL-friendly identifier.""" slug: String + """Set active/inactive status.""" isActive: Boolean } `; From 9f14112d57f1315c38a41817a0df24dc31a08339 Mon Sep 17 00:00:00 2001 From: Prajjawalk Date: Fri, 20 Mar 2026 12:53:47 +0530 Subject: [PATCH 06/15] remove scripts from dockerignaore --- .dockerignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index e1e3b3c..4c62e0a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,7 +5,7 @@ dist .beads .claude infra -scripts + *.md *.test.ts .env From ab2b491a7bd7a062cb598555c1d34549c99605b6 Mon Sep 17 00:00:00 2001 From: positonic Date: Sat, 21 Mar 2026 06:15:02 +0000 Subject: [PATCH 07/15] feat: auto-add creator as team lead on team creation and update settings Co-Authored-By: Claude Opus 4.6 --- .claude/settings.json | 6 +++++- src/resolvers/team.resolver.ts | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index 6c74543..07b42c9 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -61,7 +61,11 @@ "Bash(git push:*)", "Bash(gh pr:*)", "Bash(git stash:*)", - "Bash(bun run:*)" + "Bash(bun run:*)", + "Bash(git fetch:*)" + ], + "additionalDirectories": [ + "/Users/james/code/clear-mvp" ] } } diff --git a/src/resolvers/team.resolver.ts b/src/resolvers/team.resolver.ts index a49ed8c..904efc5 100644 --- a/src/resolvers/team.resolver.ts +++ b/src/resolvers/team.resolver.ts @@ -74,6 +74,12 @@ export const teamResolvers = { name: args.input.name, slug: args.input.slug, description: args.input.description, + members: { + create: { + userId: user.id, + role: "lead", + }, + }, }, }); }, From 3516e9ecbf080911a530e8c91472cbe57194a736 Mon Sep 17 00:00:00 2001 From: positonic Date: Sat, 21 Mar 2026 06:15:16 +0000 Subject: [PATCH 08/15] feat: add command to kill specific process in settings --- .claude/settings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index 07b42c9..811bcc7 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -62,7 +62,8 @@ "Bash(gh pr:*)", "Bash(git stash:*)", "Bash(bun run:*)", - "Bash(git fetch:*)" + "Bash(git fetch:*)", + "Bash(kill 59234)" ], "additionalDirectories": [ "/Users/james/code/clear-mvp" From 57bfa8c6e8ec19cebbb2c8419482c5c2866d9cc1 Mon Sep 17 00:00:00 2001 From: positonic Date: Sat, 21 Mar 2026 07:37:40 +0000 Subject: [PATCH 09/15] feat: enhance addOrgMember mutation to support user lookup by email --- src/resolvers/organisation.resolver.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/resolvers/organisation.resolver.ts b/src/resolvers/organisation.resolver.ts index 29c5a9f..b73deaa 100644 --- a/src/resolvers/organisation.resolver.ts +++ b/src/resolvers/organisation.resolver.ts @@ -126,9 +126,22 @@ export const organisationResolvers = { const user = requireAuth(context); await requireOrgAdmin(context.prisma, user, args.orgId); + let targetUserId = args.userId; + if (args.userId.includes("@")) { + const found = await context.prisma.user.findFirst({ + where: { email: args.userId }, + }); + if (!found) { + throw new GraphQLError("No user found with that email address", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + targetUserId = found.id; + } + return context.prisma.organisationUsers.create({ data: { - userId: args.userId, + userId: targetUserId, organisationId: args.orgId, role: args.role ?? "member", }, From fbcf668643bff6119e50f2a28dda2b8a9856bfa8 Mon Sep 17 00:00:00 2001 From: Prajjawalk Date: Mon, 23 Mar 2026 22:43:51 +0530 Subject: [PATCH 10/15] Add: ancestorIds to filter datapoints by regions --- .../migration.sql | 5 + prisma/schema.prisma | 26 +- prisma/seed.ts | 417 ++++++++++++++---- src/resolvers/alert.resolver.ts | 21 + src/resolvers/event.resolver.ts | 27 +- src/resolvers/location.resolver.ts | 18 +- src/resolvers/signal.resolver.ts | 27 +- src/schema/typeDefs/mutation.ts | 8 + src/schema/typeDefs/query.ts | 9 + src/schema/typeDefs/types/location.ts | 4 + src/utils/geo-resolve.ts | 70 +++ src/utils/location-scope.ts | 35 +- 12 files changed, 546 insertions(+), 121 deletions(-) create mode 100644 prisma/migrations/20260323165357_add_ancestor_ids/migration.sql create mode 100644 src/utils/geo-resolve.ts diff --git a/prisma/migrations/20260323165357_add_ancestor_ids/migration.sql b/prisma/migrations/20260323165357_add_ancestor_ids/migration.sql new file mode 100644 index 0000000..fb42dfd --- /dev/null +++ b/prisma/migrations/20260323165357_add_ancestor_ids/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "locations" ADD COLUMN "ancestor_ids" TEXT[]; + +-- CreateIndex +CREATE INDEX "locations_ancestor_ids_idx" ON "locations" USING GIN ("ancestor_ids" array_ops); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0bf6f47..2a79dac 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -161,10 +161,10 @@ model teams { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - organisation organisations @relation(fields: [organisationId], references: [id], onDelete: Cascade) - members teamMembers[] - locations teamLocations[] - defaultForUsers user[] @relation("DefaultTeam") + organisation organisations @relation(fields: [organisationId], references: [id], onDelete: Cascade) + members teamMembers[] + locations teamLocations[] + defaultForUsers user[] @relation("DefaultTeam") @@unique([organisationId, slug]) @@index([organisationId]) @@ -202,14 +202,15 @@ model teamLocations { // ─── Geography ─────────────────────────────────────────────────────────────── model locations { - id String @id @default(cuid()) - geoId Int? @map("geonames_id") - osmId BigInt? @map("osm_id") - pCode String? @map("p_code") @db.VarChar(50) - geometry Unsupported("geometry(Geometry, 4326)") // use CHECK (GeometryType(geometry) IN ('POINT', 'MULTIPOLYGON')) - name String - level Int - parentId String? @map("parent_id") + id String @id @default(cuid()) + geoId Int? @map("geonames_id") + osmId BigInt? @map("osm_id") + pCode String? @map("p_code") @db.VarChar(50) + geometry Unsupported("geometry(Geometry, 4326)") // use CHECK (GeometryType(geometry) IN ('POINT', 'MULTIPOLYGON')) + name String + level Int + parentId String? @map("parent_id") + ancestorIds String[] @map("ancestor_ids") parent locations? @relation("LocationHierarchy", fields: [parentId], references: [id]) children locations[] @relation("LocationHierarchy") @@ -231,6 +232,7 @@ model locations { @@index([level]) @@index([parentId]) + @@index([ancestorIds(ops: ArrayOps)], type: Gin) } // ─── Data Mining ───────────────────────────────────────────────────────────── diff --git a/prisma/seed.ts b/prisma/seed.ts index 9f05497..a3580af 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,9 +1,311 @@ import { randomUUID } from "node:crypto"; import "dotenv/config"; -import { Prisma } from "../src/generated/prisma/client.js"; import { prisma } from "../src/lib/prisma.js"; import { auth } from "../src/lib/auth.js"; +// ─── Location Seeding (can be run independently) ──────────────────────────── + +// ─── Sudan Location Data ───────────────────────────────────────────────────── + +interface StateData { + key: string; + name: string; + bbox: string; // MULTIPOLYGON WKT + districts: { key: string; name: string; lng: number; lat: number }[]; +} + +const SUDAN_STATES: StateData[] = [ + { + key: "khartoum", name: "Khartoum", + bbox: "MULTIPOLYGON(((31.7 15.19, 34.38 15.19, 34.38 16.63, 31.7 16.63, 31.7 15.19)))", + districts: [ + { key: "khartoumCity", name: "Khartoum City", lng: 32.56, lat: 15.59 }, + { key: "omdurman", name: "Omdurman", lng: 32.48, lat: 15.64 }, + { key: "bahri", name: "Bahri (Khartoum North)", lng: 32.55, lat: 15.65 }, + { key: "jabelAwlia", name: "Jabel Awlia", lng: 32.47, lat: 15.22 }, + { key: "sharqAlNeel", name: "Sharq Al Neel", lng: 32.67, lat: 15.68 }, + { key: "umbadda", name: "Umbadda", lng: 32.38, lat: 15.70 }, + { key: "karrari", name: "Karrari", lng: 32.41, lat: 15.72 }, + ], + }, + { + key: "northDarfur", name: "North Darfur", + bbox: "MULTIPOLYGON(((23.0 13.0, 27.5 13.0, 27.5 20.0, 23.0 20.0, 23.0 13.0)))", + districts: [ + { key: "elFasher", name: "El Fasher", lng: 25.35, lat: 13.63 }, + { key: "kutum", name: "Kutum", lng: 24.67, lat: 14.20 }, + { key: "kebkabiya", name: "Kebkabiya", lng: 24.32, lat: 13.99 }, + { key: "mellit", name: "Mellit", lng: 25.48, lat: 14.78 }, + { key: "umKeddada", name: "Um Keddada", lng: 26.29, lat: 13.60 }, + { key: "tawila", name: "Tawila", lng: 24.85, lat: 13.36 }, + { key: "elLait", name: "El Lait", lng: 25.85, lat: 18.00 }, + { key: "sarafOmra", name: "Saraf Omra", lng: 24.14, lat: 13.11 }, + ], + }, + { + key: "southDarfur", name: "South Darfur", + bbox: "MULTIPOLYGON(((23.5 8.65, 27.5 8.65, 27.5 13.12, 23.5 13.12, 23.5 8.65)))", + districts: [ + { key: "nyala", name: "Nyala", lng: 24.88, lat: 12.05 }, + { key: "edDaein", name: "Ed Daein", lng: 26.13, lat: 11.46 }, + { key: "kass", name: "Kass", lng: 24.26, lat: 12.50 }, + { key: "buram", name: "Buram", lng: 25.72, lat: 9.96 }, + { key: "tullus", name: "Tullus", lng: 26.65, lat: 10.58 }, + { key: "reheidAlBirdi", name: "Reheid Al Birdi", lng: 25.90, lat: 12.30 }, + { key: "marshing", name: "Marshing", lng: 24.42, lat: 12.98 }, + { key: "adila", name: "Adila", lng: 27.18, lat: 11.52 }, + ], + }, + { + key: "westDarfur", name: "West Darfur", + bbox: "MULTIPOLYGON(((21.8 11.0, 23.5 11.0, 23.5 14.0, 21.8 14.0, 21.8 11.0)))", + districts: [ + { key: "elGeneina", name: "El Geneina", lng: 22.45, lat: 13.45 }, + { key: "kulbus", name: "Kulbus", lng: 22.22, lat: 13.88 }, + { key: "habila", name: "Habila (West Darfur)", lng: 22.90, lat: 12.90 }, + { key: "beida", name: "Beida", lng: 22.35, lat: 13.00 }, + { key: "sirba", name: "Sirba", lng: 22.62, lat: 13.22 }, + { key: "jabelMoon", name: "Jabel Moon", lng: 22.15, lat: 13.60 }, + ], + }, + { + key: "centralDarfur", name: "Central Darfur", + bbox: "MULTIPOLYGON(((23.0 11.5, 25.5 11.5, 25.5 14.0, 23.0 14.0, 23.0 11.5)))", + districts: [ + { key: "zalingei", name: "Zalingei", lng: 23.47, lat: 12.91 }, + { key: "nertiti", name: "Nertiti", lng: 24.26, lat: 12.93 }, + { key: "azum", name: "Azum", lng: 23.62, lat: 13.22 }, + { key: "wadiSalih", name: "Wadi Salih", lng: 23.75, lat: 12.15 }, + { key: "mukjar", name: "Mukjar", lng: 23.89, lat: 12.32 }, + { key: "umDukhun", name: "Um Dukhun", lng: 23.40, lat: 11.76 }, + ], + }, + { + key: "eastDarfur", name: "East Darfur", + bbox: "MULTIPOLYGON(((25.5 9.5, 28.0 9.5, 28.0 13.5, 25.5 13.5, 25.5 9.5)))", + districts: [ + { key: "edDaeinEast", name: "Ed Daein (East Darfur)", lng: 26.13, lat: 11.46 }, + { key: "abuKarinka", name: "Abu Karinka", lng: 26.27, lat: 11.90 }, + { key: "assalaya", name: "Assalaya", lng: 25.69, lat: 11.35 }, + { key: "elFirdous", name: "El Firdous", lng: 26.52, lat: 10.35 }, + { key: "sheiria", name: "Sheiria", lng: 27.15, lat: 11.43 }, + { key: "yassin", name: "Yassin", lng: 25.88, lat: 12.10 }, + ], + }, + { + key: "northKordofan", name: "North Kordofan", + bbox: "MULTIPOLYGON(((27.5 12.0, 32.5 12.0, 32.5 16.0, 27.5 16.0, 27.5 12.0)))", + districts: [ + { key: "elObeid", name: "El Obeid", lng: 30.22, lat: 13.18 }, + { key: "umRawaba", name: "Um Rawaba", lng: 31.22, lat: 12.90 }, + { key: "enNahud", name: "En Nahud", lng: 28.43, lat: 12.69 }, + { key: "sheikan", name: "Sheikan", lng: 30.30, lat: 13.30 }, + { key: "bara", name: "Bara", lng: 30.37, lat: 13.70 }, + { key: "sodari", name: "Sodari", lng: 30.55, lat: 14.45 }, + { key: "umDam", name: "Um Dam", lng: 31.45, lat: 13.51 }, + { key: "jabrat", name: "Jabrat El Sheikh", lng: 29.33, lat: 12.95 }, + ], + }, + { + key: "southKordofan", name: "South Kordofan", + bbox: "MULTIPOLYGON(((28.5 9.5, 32.0 9.5, 32.0 12.5, 28.5 12.5, 28.5 9.5)))", + districts: [ + { key: "kadugli", name: "Kadugli", lng: 29.72, lat: 11.01 }, + { key: "dilling", name: "Dilling", lng: 29.66, lat: 12.06 }, + { key: "abuJubaiyha", name: "Abu Jubaiyha", lng: 31.22, lat: 11.60 }, + { key: "rashad", name: "Rashad", lng: 31.05, lat: 11.85 }, + { key: "talodi", name: "Talodi", lng: 30.38, lat: 10.63 }, + { key: "kaduqli", name: "Heiban", lng: 30.12, lat: 11.40 }, + { key: "lagawa", name: "Lagawa", lng: 28.92, lat: 11.35 }, + ], + }, + { + key: "westKordofan", name: "West Kordofan", + bbox: "MULTIPOLYGON(((27.0 10.0, 30.0 10.0, 30.0 13.0, 27.0 13.0, 27.0 10.0)))", + districts: [ + { key: "elFula", name: "El Fula", lng: 28.35, lat: 11.73 }, + { key: "muglad", name: "Muglad", lng: 27.73, lat: 11.04 }, + { key: "abyei", name: "Abyei", lng: 28.44, lat: 9.59 }, + { key: "babanusa", name: "Babanusa", lng: 27.80, lat: 11.33 }, + { key: "ghubaysh", name: "Ghubaysh", lng: 28.60, lat: 12.30 }, + ], + }, + { + key: "blueNile", name: "Blue Nile", + bbox: "MULTIPOLYGON(((33.0 9.5, 35.5 9.5, 35.5 12.5, 33.0 12.5, 33.0 9.5)))", + districts: [ + { key: "edDamazin", name: "Ed Damazin", lng: 34.36, lat: 11.79 }, + { key: "roseires", name: "Roseires", lng: 34.38, lat: 11.85 }, + { key: "kurmuk", name: "Kurmuk", lng: 34.28, lat: 10.55 }, + { key: "bau", name: "Bau", lng: 34.07, lat: 10.96 }, + { key: "geissan", name: "Geissan", lng: 34.42, lat: 10.99 }, + { key: "tadamon", name: "Tadamon", lng: 33.85, lat: 11.28 }, + ], + }, + { + key: "whiteNile", name: "White Nile", + bbox: "MULTIPOLYGON(((31.5 12.0, 33.5 12.0, 33.5 14.5, 31.5 14.5, 31.5 12.0)))", + districts: [ + { key: "rabak", name: "Rabak", lng: 32.74, lat: 13.18 }, + { key: "kosti", name: "Kosti", lng: 32.66, lat: 13.16 }, + { key: "dueim", name: "Ed Dueim", lng: 32.30, lat: 14.00 }, + { key: "tendalti", name: "Tendalti", lng: 32.60, lat: 13.46 }, + { key: "jabalein", name: "Jabalein", lng: 32.95, lat: 12.59 }, + { key: "guli", name: "Guli", lng: 32.25, lat: 12.65 }, + ], + }, + { + key: "sennar", name: "Sennar", + bbox: "MULTIPOLYGON(((32.5 12.0, 35.5 12.0, 35.5 14.0, 32.5 14.0, 32.5 12.0)))", + districts: [ + { key: "sennarCity", name: "Sennar City", lng: 33.60, lat: 13.55 }, + { key: "singa", name: "Singa", lng: 33.93, lat: 13.15 }, + { key: "dinder", name: "Dinder", lng: 35.00, lat: 12.50 }, + { key: "abuHugar", name: "Abu Hugar", lng: 33.33, lat: 13.09 }, + { key: "easternSennar", name: "Eastern Sennar", lng: 34.30, lat: 13.25 }, + { key: "suruj", name: "Suruj", lng: 33.66, lat: 12.70 }, + ], + }, + { + key: "gezira", name: "Gezira", + bbox: "MULTIPOLYGON(((32.5 13.5, 34.5 13.5, 34.5 15.5, 32.5 15.5, 32.5 13.5)))", + districts: [ + { key: "wadMedani", name: "Wad Medani", lng: 33.52, lat: 14.40 }, + { key: "managil", name: "Managil", lng: 32.99, lat: 14.25 }, + { key: "hasaheisa", name: "Hasaheisa", lng: 33.30, lat: 14.75 }, + { key: "kamlin", name: "Kamlin", lng: 32.70, lat: 15.30 }, + { key: "elMesallamiya", name: "El Mesallamiya", lng: 33.60, lat: 14.63 }, + { key: "southGezira", name: "South Gezira", lng: 33.18, lat: 13.90 }, + { key: "umAlQura", name: "Um Al Qura", lng: 33.02, lat: 14.55 }, + ], + }, + { + key: "kassala", name: "Kassala", + bbox: "MULTIPOLYGON(((35.0 14.5, 37.0 14.5, 37.0 17.0, 35.0 17.0, 35.0 14.5)))", + districts: [ + { key: "kassalaCity", name: "Kassala City", lng: 36.40, lat: 15.45 }, + { key: "halfa", name: "New Halfa", lng: 35.60, lat: 15.32 }, + { key: "aroma", name: "Aroma", lng: 36.14, lat: 15.82 }, + { key: "khashm", name: "Khashm El Girba", lng: 35.88, lat: 14.90 }, + { key: "wagerHamid", name: "Wagar", lng: 36.25, lat: 15.56 }, + { key: "telkok", name: "Telkok", lng: 36.50, lat: 15.00 }, + ], + }, + { + key: "redSea", name: "Red Sea", + bbox: "MULTIPOLYGON(((35.5 17.5, 38.6 17.5, 38.6 22.0, 35.5 22.0, 35.5 17.5)))", + districts: [ + { key: "portSudan", name: "Port Sudan", lng: 37.22, lat: 19.62 }, + { key: "suakin", name: "Suakin", lng: 37.33, lat: 19.11 }, + { key: "tokar", name: "Tokar", lng: 37.73, lat: 18.43 }, + { key: "halayib", name: "Halayib", lng: 36.65, lat: 22.19 }, + { key: "sinkat", name: "Sinkat", lng: 36.72, lat: 19.78 }, + { key: "haya", name: "Haya", lng: 36.38, lat: 18.33 }, + ], + }, + { + key: "riverNile", name: "River Nile", + bbox: "MULTIPOLYGON(((31.5 16.5, 35.0 16.5, 35.0 20.0, 31.5 20.0, 31.5 16.5)))", + districts: [ + { key: "atbara", name: "Atbara", lng: 33.98, lat: 17.70 }, + { key: "edDamer", name: "Ed Damer", lng: 33.95, lat: 17.59 }, + { key: "shendi", name: "Shendi", lng: 33.43, lat: 16.68 }, + { key: "berber", name: "Berber", lng: 33.98, lat: 18.02 }, + { key: "abuHamed", name: "Abu Hamed", lng: 33.32, lat: 19.53 }, + { key: "meroe", name: "Meroe", lng: 33.75, lat: 16.94 }, + ], + }, + { + key: "northern", name: "Northern", + bbox: "MULTIPOLYGON(((24.0 18.0, 33.0 18.0, 33.0 22.0, 24.0 22.0, 24.0 18.0)))", + districts: [ + { key: "dongola", name: "Dongola", lng: 30.48, lat: 19.17 }, + { key: "merowe", name: "Merowe", lng: 31.82, lat: 18.49 }, + { key: "wadi", name: "Wadi Halfa", lng: 31.35, lat: 21.80 }, + { key: "delgo", name: "Delgo", lng: 30.45, lat: 20.46 }, + { key: "elGolid", name: "El Golid", lng: 30.12, lat: 18.88 }, + { key: "elDebba", name: "El Debba", lng: 30.95, lat: 18.06 }, + ], + }, + { + key: "gedaref", name: "Gedaref", + bbox: "MULTIPOLYGON(((34.0 12.5, 37.0 12.5, 37.0 15.5, 34.0 15.5, 34.0 12.5)))", + districts: [ + { key: "gedarefCity", name: "Gedaref City", lng: 35.40, lat: 14.03 }, + { key: "elFashaga", name: "El Fashaga", lng: 36.19, lat: 13.28 }, + { key: "elFao", name: "El Fao", lng: 34.47, lat: 13.97 }, + { key: "galabat", name: "Galabat", lng: 36.14, lat: 12.92 }, + { key: "rahad", name: "Eastern Rahad", lng: 35.10, lat: 13.55 }, + { key: "butana", name: "Butana", lng: 34.60, lat: 14.80 }, + { key: "gureisha", name: "Gureisha", lng: 35.90, lat: 13.85 }, + ], + }, +]; + +// ─── Seed Locations ────────────────────────────────────────────────────────── + +/** Seeded location IDs keyed by their data key (e.g. "khartoum", "elFasher") */ +export type LocationMap = Record; + +export async function seedLocations(): Promise { + console.log("Seeding locations..."); + + // Clear existing locations + await prisma.$executeRaw`DELETE FROM "locations"`; + + // Track ancestor chains for each location + const locationAncestors = new Map(); + const result: LocationMap = {}; + + async function insertLocation( + key: string, + name: string, + level: number, + wkt: string, + parentId: string | null = null, + ) { + const id = randomUUID(); + const ancestorIds = parentId + ? [parentId, ...(locationAncestors.get(parentId) ?? [])] + : []; + locationAncestors.set(id, ancestorIds); + + await prisma.$executeRaw` + INSERT INTO "locations" ("id", "name", "level", "parent_id", "ancestor_ids", "geometry") + VALUES (${id}, ${name}, ${level}, ${parentId}, ${ancestorIds}, ST_GeomFromText(${wkt}, 4326)) + `; + result[key] = { id }; + return { id }; + } + + // Level 0: Country + const sudan = await insertLocation("sudan", "Sudan", 0, "POINT(30.0 15.5)"); + + // Level 1: States (sequential to ensure parent exists before children reference it) + for (const state of SUDAN_STATES) { + const stateResult = await insertLocation(state.key, state.name, 1, state.bbox, sudan.id); + + // Level 2: Districts within each state + for (const district of state.districts) { + await insertLocation( + district.key, + district.name, + 2, + `POINT(${district.lng} ${district.lat})`, + stateResult.id, + ); + } + } + + const stateCount = SUDAN_STATES.length; + const districtCount = SUDAN_STATES.reduce((sum, s) => sum + s.districts.length, 0); + console.log(`Created ${1 + stateCount + districtCount} locations (1 country, ${stateCount} states, ${districtCount} districts) with geographic data`); + + return result; +} + +// ─── Full Seed ─────────────────────────────────────────────────────────────── + async function seed() { console.log("Seeding database...\n"); @@ -23,7 +325,6 @@ async function seed() { await prisma.featureFlags.deleteMany(); await prisma.disasterTypes.deleteMany(); await prisma.dataSources.deleteMany(); - await prisma.$executeRaw`DELETE FROM "locations"`; await prisma.organisationUsers.deleteMany(); await prisma.organisations.deleteMany(); console.log("Cleared existing data (users, sessions, accounts, and API keys preserved)."); @@ -61,80 +362,17 @@ async function seed() { `Created 3 users: admin (${admin.id}), analyst (${analyst.id}), viewer (${viewer.id})`, ); - // ─── Locations (Sudan hierarchy: Country → State → Locality) ───────────── - async function insertLocation( - id: string, - name: string, - level: number, - wkt: string, - parentId: string | null = null, - ) { - await prisma.$executeRaw` - INSERT INTO "locations" ("id", "name", "level", "parent_id", "geometry") - VALUES (${id}, ${name}, ${level}, ${parentId}, ST_GeomFromText(${wkt}, 4326)) - `; - return { id }; - } - - // Level 0: Country - const sudanId = randomUUID(); - const sudan = await insertLocation(sudanId, "Sudan", 0, "POINT(30.0 15.5)"); - - // Level 1: States - const khartoumId = randomUUID(); - const northDarfurId = randomUUID(); - const southDarfurId = randomUUID(); - const northKordofanId = randomUUID(); - - const [khartoum, northDarfur, southDarfur, _northKordofan] = await Promise.all([ - insertLocation( - khartoumId, - "Khartoum", - 1, - "MULTIPOLYGON(((31.7 15.19, 34.38 15.19, 34.38 16.63, 31.7 16.63, 31.7 15.19)))", - sudan.id, - ), - insertLocation( - northDarfurId, - "North Darfur", - 1, - "MULTIPOLYGON(((23.0 13.0, 27.5 13.0, 27.5 20.0, 23.0 20.0, 23.0 13.0)))", - sudan.id, - ), - insertLocation( - southDarfurId, - "South Darfur", - 1, - "MULTIPOLYGON(((23.5 8.65, 27.5 8.65, 27.5 13.12, 23.5 13.12, 23.5 8.65)))", - sudan.id, - ), - insertLocation( - northKordofanId, - "North Kordofan", - 1, - "MULTIPOLYGON(((27.5 12.0, 32.5 12.0, 32.5 16.0, 27.5 16.0, 27.5 12.0)))", - sudan.id, - ), - ]); - - // Level 2: Localities - const khartoumCityId = randomUUID(); - const omdurmanId = randomUUID(); - const elFasherId = randomUUID(); - const kutumId = randomUUID(); - const nyalaId = randomUUID(); - const elDaeinId = randomUUID(); - - const [khartoumCity, omdurman, elFasher, kutum, nyala, elDaein] = await Promise.all([ - insertLocation(khartoumCityId, "Khartoum City", 2, "POINT(32.56 15.59)", khartoum.id), - insertLocation(omdurmanId, "Omdurman", 2, "POINT(32.48 15.64)", khartoum.id), - insertLocation(elFasherId, "El Fasher", 2, "POINT(25.35 13.63)", northDarfur.id), - insertLocation(kutumId, "Kutum", 2, "POINT(24.67 14.20)", northDarfur.id), - insertLocation(nyalaId, "Nyala", 2, "POINT(24.88 12.05)", southDarfur.id), - insertLocation(elDaeinId, "Ed Daein", 2, "POINT(26.13 11.46)", southDarfur.id), - ]); + const loc = await seedLocations(); - console.log("Created 11 locations (1 country, 4 states, 6 localities) with geographic data"); + // Convenience aliases for signal/event seed data + const khartoum = loc.khartoum!; + const northDarfur = loc.northDarfur!; + const southDarfur = loc.southDarfur!; + const khartoumCity = loc.khartoumCity!; + const omdurman = loc.omdurman!; + const elFasher = loc.elFasher!; + const kutum = loc.kutum!; + const nyala = loc.nyala!; // ─── Data Sources ────────────────────────────────────────────────────────── const [dataminr, acled, gdacs, dtm] = await Promise.all([ @@ -598,9 +836,26 @@ async function seed() { console.log(" viewer@clear.dev / password123 (role: viewer)"); } -seed() - .catch((e) => { - console.error("Seed failed:", e); - process.exit(1); - }) - .finally(() => prisma.$disconnect()); +// ─── CLI Entry Point ───────────────────────────────────────────────────────── +// Usage: +// bun run prisma/seed.ts # Full seed (all tables) +// bun run prisma/seed.ts --locations # Seed only locations + +const args = process.argv.slice(2); + +if (args.includes("--locations")) { + seedLocations() + .then(() => console.log("Location seed complete.")) + .catch((e) => { + console.error("Location seed failed:", e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); +} else { + seed() + .catch((e) => { + console.error("Seed failed:", e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); +} diff --git a/src/resolvers/alert.resolver.ts b/src/resolvers/alert.resolver.ts index 6254680..d14f974 100644 --- a/src/resolvers/alert.resolver.ts +++ b/src/resolvers/alert.resolver.ts @@ -3,6 +3,7 @@ import type { Context } from "../context.js"; import type { AlertStatus } from "../generated/prisma/client.js"; import { requireAuth, requireRole } from "../utils/auth-guard.js"; import { resolveTeamMembership } from "../utils/auth-guard.js"; +import { getLocationIdsWithDescendants } from "../utils/geo-resolve.js"; import { buildEventLocationFilterForTeam } from "../utils/location-scope.js"; interface CreateAlertInput { @@ -37,6 +38,26 @@ export const alertResolvers = { }, }); }, + alertsByLocation: async ( + _parent: unknown, + args: { locationId: string; status?: AlertStatus }, + context: Context, + ) => { + requireAuth(context); + const locationIds = await getLocationIdsWithDescendants(context.prisma, args.locationId); + return context.prisma.alerts.findMany({ + where: { + ...(args.status ? { status: args.status } : {}), + event: { + OR: [ + { originId: { in: locationIds } }, + { destinationId: { in: locationIds } }, + { locationId: { in: locationIds } }, + ], + }, + }, + }); + }, alert: async (_parent: unknown, args: { id: string }, context: Context) => { const user = requireAuth(context); const alert = await context.prisma.alerts.findUnique({ diff --git a/src/resolvers/event.resolver.ts b/src/resolvers/event.resolver.ts index 4eea8ed..5bd28ee 100644 --- a/src/resolvers/event.resolver.ts +++ b/src/resolvers/event.resolver.ts @@ -3,6 +3,7 @@ import type { Context } from "../context.js"; import type { InputJsonValue } from "../generated/prisma/internal/prismaNamespace.js"; import { requireAuth, requireRole } from "../utils/auth-guard.js"; import { resolveTeamMembership } from "../utils/auth-guard.js"; +import { resolveLatLngToLocation, getLocationIdsWithDescendants } from "../utils/geo-resolve.js"; import { buildEventLocationFilterForTeam } from "../utils/location-scope.js"; interface CreateEventInput { @@ -20,6 +21,8 @@ interface CreateEventInput { populationAffected?: string; rank: number; signalIds: string[]; + lat?: number; + lng?: number; } interface UpdateEventInput { @@ -55,6 +58,19 @@ export const eventResolvers = { const filter = await buildEventLocationFilterForTeam(context.prisma, args.teamId); return context.prisma.events.findMany({ where: filter }); }, + eventsByLocation: async (_parent: unknown, args: { locationId: string }, context: Context) => { + requireAuth(context); + const locationIds = await getLocationIdsWithDescendants(context.prisma, args.locationId); + return context.prisma.events.findMany({ + where: { + OR: [ + { originId: { in: locationIds } }, + { destinationId: { in: locationIds } }, + { locationId: { in: locationIds } }, + ], + }, + }); + }, event: async (_parent: unknown, args: { id: string }, context: Context) => { const user = requireAuth(context); const event = await context.prisma.events.findUnique({ where: { id: args.id } }); @@ -99,6 +115,15 @@ export const eventResolvers = { requireRole(context, ["admin", "analyst"]); const { input } = args; + // Resolve lat/lng to a location if no explicit locationId is provided + let locationId = input.locationId; + if (!locationId && input.lat != null && input.lng != null) { + const resolved = await resolveLatLngToLocation(context.prisma, input.lat, input.lng); + if (resolved) { + locationId = resolved.id; + } + } + const event = await context.prisma.events.create({ data: { title: input.title, @@ -112,7 +137,7 @@ export const eventResolvers = { lastSignalCreatedAt: new Date(input.lastSignalCreatedAt), originId: input.originId, destinationId: input.destinationId, - locationId: input.locationId, + locationId, types: input.types, populationAffected: input.populationAffected ? BigInt(input.populationAffected) diff --git a/src/resolvers/location.resolver.ts b/src/resolvers/location.resolver.ts index edb07a6..39a0522 100644 --- a/src/resolvers/location.resolver.ts +++ b/src/resolvers/location.resolver.ts @@ -3,6 +3,7 @@ import { GraphQLError } from "graphql"; import type { Context } from "../context.js"; import { Prisma, type PrismaClient } from "../generated/prisma/client.js"; import { requireRole } from "../utils/auth-guard.js"; +import { computeAncestorIds } from "../utils/geo-resolve.js"; interface CreateLocationInput { geoId?: number; @@ -80,9 +81,12 @@ export const locationResolvers = { const pCode = input.pCode ?? null; const parentId = input.parentId ?? null; + // Compute ancestor IDs from the parent chain + const ancestorIds = await computeAncestorIds(context.prisma, parentId); + await context.prisma.$executeRaw` - INSERT INTO "locations" ("id", "geonames_id", "osm_id", "p_code", "name", "level", "parent_id", "geometry") - VALUES (${id}, ${geoId}, ${osmId}, ${pCode}, ${input.name}, ${input.level}, ${parentId}, ST_GeomFromText('POINT(0 0)', 4326)) + INSERT INTO "locations" ("id", "geonames_id", "osm_id", "p_code", "name", "level", "parent_id", "ancestor_ids", "geometry") + VALUES (${id}, ${geoId}, ${osmId}, ${pCode}, ${input.name}, ${input.level}, ${parentId}, ${ancestorIds}, ST_GeomFromText('POINT(0 0)', 4326)) `; return context.prisma.locations.findUniqueOrThrow({ where: { id } }); @@ -171,5 +175,15 @@ export const locationResolvers = { if (!geo?.geometry_geojson) return null; return JSON.parse(geo.geometry_geojson) as unknown; }, + ancestorIds: (parent: { ancestorIds: string[] }) => { + return parent.ancestorIds ?? []; + }, + ancestors: (parent: { ancestorIds: string[] }, _args: unknown, { prisma }: Context) => { + if (!parent.ancestorIds?.length) return []; + return prisma.locations.findMany({ + where: { id: { in: parent.ancestorIds } }, + orderBy: { level: "asc" }, + }); + }, }, }; diff --git a/src/resolvers/signal.resolver.ts b/src/resolvers/signal.resolver.ts index 6b94cb4..71a55bb 100644 --- a/src/resolvers/signal.resolver.ts +++ b/src/resolvers/signal.resolver.ts @@ -3,6 +3,7 @@ import type { Context } from "../context.js"; import type { InputJsonValue } from "../generated/prisma/internal/prismaNamespace.js"; import { requireAuth, requireRole } from "../utils/auth-guard.js"; import { resolveTeamMembership } from "../utils/auth-guard.js"; +import { resolveLatLngToLocation, getLocationIdsWithDescendants } from "../utils/geo-resolve.js"; import { buildLocationFilterForTeam } from "../utils/location-scope.js"; interface CreateSignalInput { @@ -16,6 +17,8 @@ interface CreateSignalInput { originId?: string; destinationId?: string; locationId?: string; + lat?: number; + lng?: number; } export const signalResolvers = { @@ -34,6 +37,19 @@ export const signalResolvers = { const filter = await buildLocationFilterForTeam(context.prisma, args.teamId); return context.prisma.signals.findMany({ where: filter }); }, + signalsByLocation: async (_parent: unknown, args: { locationId: string }, context: Context) => { + requireAuth(context); + const locationIds = await getLocationIdsWithDescendants(context.prisma, args.locationId); + return context.prisma.signals.findMany({ + where: { + OR: [ + { originId: { in: locationIds } }, + { destinationId: { in: locationIds } }, + { locationId: { in: locationIds } }, + ], + }, + }); + }, signal: async (_parent: unknown, args: { id: string }, context: Context) => { const user = requireAuth(context); const signal = await context.prisma.signals.findUnique({ where: { id: args.id } }); @@ -84,6 +100,15 @@ export const signalResolvers = { }); } + // Resolve lat/lng to a location if no explicit locationId is provided + let locationId = input.locationId; + if (!locationId && input.lat != null && input.lng != null) { + const resolved = await resolveLatLngToLocation(context.prisma, input.lat, input.lng); + if (resolved) { + locationId = resolved.id; + } + } + return context.prisma.signals.create({ data: { sourceId: input.sourceId, @@ -95,7 +120,7 @@ export const signalResolvers = { description: input.description, originId: input.originId, destinationId: input.destinationId, - locationId: input.locationId, + locationId, }, }); }, diff --git a/src/schema/typeDefs/mutation.ts b/src/schema/typeDefs/mutation.ts index d2f42ab..8cace82 100644 --- a/src/schema/typeDefs/mutation.ts +++ b/src/schema/typeDefs/mutation.ts @@ -151,6 +151,10 @@ export const mutationTypeDef = gql` originId: String destinationId: String locationId: String + """Latitude for automatic geo-resolution (resolves to nearest location in hierarchy).""" + lat: Float + """Longitude for automatic geo-resolution.""" + lng: Float } input CreateEventInput { @@ -168,6 +172,10 @@ export const mutationTypeDef = gql` types: [String!]! populationAffected: String rank: Float! + """Latitude for automatic geo-resolution (resolves to nearest location in hierarchy).""" + lat: Float + """Longitude for automatic geo-resolution.""" + lng: Float } input UpdateEventInput { diff --git a/src/schema/typeDefs/query.ts b/src/schema/typeDefs/query.ts index f8ed292..a6e6097 100644 --- a/src/schema/typeDefs/query.ts +++ b/src/schema/typeDefs/query.ts @@ -23,12 +23,21 @@ export const queryTypeDef = gql` """Look up a signal by ID. Requires authentication. Non-admins can only access signals within their team scope.""" signal(id: String!): Signal + """List signals by location. Returns all signals whose origin, destination, or general location is within the given location (including descendants).""" + signalsByLocation(locationId: String!): [Signal!]! + """List events. Requires authentication. Admins may omit teamId to list all; non-admins must provide a teamId for a team they belong to.""" events(teamId: String): [Event!]! """Look up an event by ID. Requires authentication. Non-admins can only access events within their team scope.""" event(id: String!): Event + """List events by location. Returns all events whose origin, destination, or general location is within the given location (including descendants).""" + eventsByLocation(locationId: String!): [Event!]! + + """List alerts by location. Returns all alerts whose event's location is within the given location (including descendants).""" + alertsByLocation(locationId: String!, status: AlertStatus): [Alert!]! + """List all data sources.""" dataSources: [DataSource!]! diff --git a/src/schema/typeDefs/types/location.ts b/src/schema/typeDefs/types/location.ts index c48fa73..4535f1f 100644 --- a/src/schema/typeDefs/types/location.ts +++ b/src/schema/typeDefs/types/location.ts @@ -19,5 +19,9 @@ export const locationTypeDef = gql` parent: Location """Child locations one level below.""" children: [Location!]! + """IDs of all ancestor locations (parent, grandparent, etc.).""" + ancestorIds: [String!]! + """All ancestor locations (parent, grandparent, etc.).""" + ancestors: [Location!]! } `; diff --git a/src/utils/geo-resolve.ts b/src/utils/geo-resolve.ts new file mode 100644 index 0000000..7e3d910 --- /dev/null +++ b/src/utils/geo-resolve.ts @@ -0,0 +1,70 @@ +import type { PrismaClient } from "../generated/prisma/client.js"; + +interface ResolvedLocation { + id: string; + name: string; + level: number; +} + +/** + * Resolve a lat/lng point to the most granular location in the hierarchy + * using PostGIS ST_Contains. Returns the location with the highest level + * (most specific — district > state > country). + */ +export async function resolveLatLngToLocation( + prisma: PrismaClient, + lat: number, + lng: number, +): Promise { + const rows = await prisma.$queryRaw` + SELECT id, name, level + FROM "locations" + WHERE ST_Contains("geometry"::geometry, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)) + ORDER BY level DESC + LIMIT 1 + `; + return rows[0] ?? null; +} + +/** + * Get all location IDs that are within the given location + * (the location itself + all descendants), using the ancestorIds array. + * Much faster than the recursive CTE approach. + */ +export async function getLocationIdsWithDescendants( + prisma: PrismaClient, + locationId: string, +): Promise { + // Find all locations where ancestorIds contains the target, plus the target itself + const rows = await prisma.$queryRaw<{ id: string }[]>` + SELECT id FROM "locations" + WHERE id = ${locationId} + OR ${locationId} = ANY("ancestor_ids") + `; + return rows.map((r) => r.id); +} + +/** + * Compute the ancestor IDs for a location by walking up the parent chain. + * Returns an array ordered from direct parent to root. + */ +export async function computeAncestorIds( + prisma: PrismaClient, + parentId: string | null, +): Promise { + if (!parentId) return []; + + const ancestors: string[] = []; + let currentId: string | null = parentId; + + while (currentId) { + ancestors.push(currentId); + const parent: { parentId: string | null } | null = await prisma.locations.findUnique({ + where: { id: currentId }, + select: { parentId: true }, + }); + currentId = parent?.parentId ?? null; + } + + return ancestors; +} diff --git a/src/utils/location-scope.ts b/src/utils/location-scope.ts index 53628ee..df20365 100644 --- a/src/utils/location-scope.ts +++ b/src/utils/location-scope.ts @@ -1,31 +1,11 @@ import type { PrismaClient } from "../generated/prisma/client.js"; import type { Prisma } from "../generated/prisma/client.js"; - -/** - * Expand a set of location IDs to include all descendant locations - * using the location hierarchy (parent → children). - * Returns the original IDs plus all children, grandchildren, etc. - */ -export async function getExpandedLocationIds( - prisma: PrismaClient, - scopeLocationIds: string[], -): Promise { - if (scopeLocationIds.length === 0) return []; - - const rows = await prisma.$queryRaw<{ id: string }[]>` - WITH RECURSIVE tree AS ( - SELECT id FROM locations WHERE id = ANY(${scopeLocationIds}::text[]) - UNION ALL - SELECT c.id FROM locations c JOIN tree t ON c.parent_id = t.id - ) - SELECT id FROM tree - `; - return rows.map((r) => r.id); -} +import { getLocationIdsWithDescendants } from "./geo-resolve.js"; /** * Build a Prisma where clause that filters signals by a team's location scope. - * Looks up the team's locations, expands the hierarchy, and returns the filter. + * Looks up the team's locations, expands the hierarchy using ancestorIds, + * and returns the filter. * Returns undefined if the team has no locations (global monitoring). */ export async function buildLocationFilterForTeam( @@ -42,7 +22,14 @@ export async function buildLocationFilterForTeam( // Team with no locations = global monitoring (no filter) if (locationIds.length === 0) return undefined; - const expandedIds = await getExpandedLocationIds(prisma, locationIds); + // Expand each scope location to include all descendants + const allIds = new Set(); + for (const locId of locationIds) { + const expanded = await getLocationIdsWithDescendants(prisma, locId); + for (const id of expanded) allIds.add(id); + } + + const expandedIds = [...allIds]; return { OR: [ From 213fa291f78e05791d05f935dc8d18517393b964 Mon Sep 17 00:00:00 2001 From: Prajjawalk Date: Tue, 24 Mar 2026 19:50:09 +0530 Subject: [PATCH 11/15] Add: org & team invitation handlers --- .../migration.sql | 34 ++ prisma/schema.prisma | 31 +- prisma/seed.ts | 36 +- src/resolvers/auth.resolver.ts | 106 ++++ src/resolvers/index.ts | 2 + src/resolvers/invitation.resolver.ts | 463 ++++++++++++++++++ src/resolvers/organisation.resolver.ts | 48 +- src/schema/index.ts | 2 + src/schema/typeDefs/mutation.ts | 39 ++ src/schema/typeDefs/query.ts | 7 + src/schema/typeDefs/types/invitation.ts | 40 ++ src/services/messaging/templates.ts | 182 ++++++- src/utils/env.ts | 4 + 13 files changed, 965 insertions(+), 29 deletions(-) create mode 100644 prisma/migrations/20260324131311_add_invitations/migration.sql create mode 100644 src/resolvers/invitation.resolver.ts create mode 100644 src/schema/typeDefs/types/invitation.ts diff --git a/prisma/migrations/20260324131311_add_invitations/migration.sql b/prisma/migrations/20260324131311_add_invitations/migration.sql new file mode 100644 index 0000000..0deb4ee --- /dev/null +++ b/prisma/migrations/20260324131311_add_invitations/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "invitations" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "organisation_id" TEXT NOT NULL, + "team_id" TEXT, + "role" TEXT NOT NULL DEFAULT 'member', + "team_role" TEXT, + "token" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "accepted_at" TIMESTAMP(3), + "invited_by_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "invitations_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "invitations_token_key" ON "invitations"("token"); + +-- CreateIndex +CREATE INDEX "invitations_email_idx" ON "invitations"("email"); + +-- CreateIndex +CREATE INDEX "invitations_organisation_id_idx" ON "invitations"("organisation_id"); + +-- AddForeignKey +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_organisation_id_fkey" FOREIGN KEY ("organisation_id") REFERENCES "organisations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "teams"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_invited_by_id_fkey" FOREIGN KEY ("invited_by_id") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2a79dac..40e11c6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -80,6 +80,7 @@ model user { userFeedbacks userFeedbacks[] userComments userComments[] commentTags commentTags[] + invitationsSent invitations[] @relation("InvitedBy") } model session { @@ -132,8 +133,9 @@ model organisations { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - users organisationUsers[] - teams teams[] + users organisationUsers[] + teams teams[] + invitations invitations[] } model organisationUsers { @@ -165,6 +167,7 @@ model teams { members teamMembers[] locations teamLocations[] defaultForUsers user[] @relation("DefaultTeam") + invitations invitations[] @@unique([organisationId, slug]) @@index([organisationId]) @@ -199,6 +202,30 @@ model teamLocations { @@map("team_locations") } +// ─── Invitations ───────────────────────────────────────────────────────────── + +model invitations { + id String @id @default(cuid()) + email String + organisationId String @map("organisation_id") + teamId String? @map("team_id") + role String @default("member") // org role: owner | admin | member + teamRole String? @map("team_role") // team role: lead | analyst | viewer + token String @unique + expiresAt DateTime @map("expires_at") + acceptedAt DateTime? @map("accepted_at") + invitedById String @map("invited_by_id") + createdAt DateTime @default(now()) @map("created_at") + + organisation organisations @relation(fields: [organisationId], references: [id], onDelete: Cascade) + team teams? @relation(fields: [teamId], references: [id], onDelete: SetNull) + invitedBy user @relation("InvitedBy", fields: [invitedById], references: [id]) + + @@index([email]) + @@index([organisationId]) + @@map("invitations") +} + // ─── Geography ─────────────────────────────────────────────────────────────── model locations { diff --git a/prisma/seed.ts b/prisma/seed.ts index a3580af..a109e7f 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import "dotenv/config"; import { prisma } from "../src/lib/prisma.js"; import { auth } from "../src/lib/auth.js"; +import { env } from "../src/utils/env.js"; // ─── Location Seeding (can be run independently) ──────────────────────────── @@ -325,6 +326,7 @@ async function seed() { await prisma.featureFlags.deleteMany(); await prisma.disasterTypes.deleteMany(); await prisma.dataSources.deleteMany(); + await prisma.invitations.deleteMany(); await prisma.organisationUsers.deleteMany(); await prisma.organisations.deleteMany(); console.log("Cleared existing data (users, sessions, accounts, and API keys preserved)."); @@ -337,30 +339,36 @@ async function seed() { return signup.user; } - const admin = await getOrCreateUser("Admin User", "admin@clear.dev", "password123"); + const admin = await getOrCreateUser("Admin User", env.ADMIN_EMAIL, env.ADMIN_PASSWORD); + // Ensure admin has global admin role and verified email + await prisma.user.update({ + where: { id: admin.id }, + data: { role: "admin", emailVerified: true }, + }); + const analyst = await getOrCreateUser("Analyst User", "analyst@clear.dev", "password123"); + await prisma.user.update({ where: { id: analyst.id }, data: { emailVerified: true } }); + const viewer = await getOrCreateUser("Viewer User", "viewer@clear.dev", "password123"); + await prisma.user.update({ where: { id: viewer.id }, data: { emailVerified: true } }); - // ─── Organisation & Roles ───────────────────────────────────────────────── + // ─── Organisation ──────────────────────────────────────────────────────── + // Global admin (admin@clear.dev) does NOT need org membership — global role is sufficient. + // Analyst is added as the org owner for demo purposes. const org = await prisma.organisations.create({ - data: { name: "CLEAR Platform" }, + data: { name: "CLEAR Platform", slug: "clear-platform" }, }); - await Promise.all([ - prisma.organisationUsers.create({ - data: { userId: admin.id, organisationId: org.id, role: "admin" }, - }), - prisma.organisationUsers.create({ - data: { userId: analyst.id, organisationId: org.id, role: "analyst" }, - }), - prisma.organisationUsers.create({ - data: { userId: viewer.id, organisationId: org.id, role: "viewer" }, - }), - ]); + await prisma.organisationUsers.create({ + data: { userId: analyst.id, organisationId: org.id, role: "owner" }, + }); console.log( `Created 3 users: admin (${admin.id}), analyst (${analyst.id}), viewer (${viewer.id})`, ); + console.log(" admin@clear.dev is global admin (no org membership needed)"); + console.log(" analyst@clear.dev is org owner of 'CLEAR Platform'"); + console.log(" viewer@clear.dev has no org membership (invite-only)"); const loc = await seedLocations(); diff --git a/src/resolvers/auth.resolver.ts b/src/resolvers/auth.resolver.ts index e054e02..cf5f215 100644 --- a/src/resolvers/auth.resolver.ts +++ b/src/resolvers/auth.resolver.ts @@ -120,5 +120,111 @@ export const authResolvers = { return true; }, + + requestPasswordReset: async ( + _parent: unknown, + args: { email: string }, + context: Context, + ) => { + // Always return true to prevent email enumeration + const user = await context.prisma.user.findUnique({ + where: { email: args.email }, + }); + + if (!user) return true; + + // Throttle check + const identifier = `password-reset:${args.email}`; + const recent = await context.prisma.verification.findFirst({ + where: { identifier }, + orderBy: { createdAt: "desc" }, + }); + + if (recent?.createdAt && Date.now() - recent.createdAt.getTime() < THROTTLE_MS) { + // Silently succeed — don't reveal throttle to client + return true; + } + + // Clean up old tokens + await context.prisma.verification.deleteMany({ where: { identifier } }); + + // Create reset token (1 hour expiry) + const token = randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + + await context.prisma.verification.create({ + data: { identifier, value: token, expiresAt }, + }); + + // Send password reset email + const resetUrl = `${env.FRONTEND_URL}/auth/reset-password?token=${token}`; + const emailContent = templates.passwordReset(user.name, resetUrl); + + try { + const provider = await getEmailProvider(); + await provider.send({ + to: args.email, + subject: emailContent.subject, + textBody: emailContent.textBody, + htmlBody: emailContent.htmlBody, + }); + } catch (error) { + console.error("[AUTH] Failed to send password reset email:", error instanceof Error ? error.message : error); + // Don't throw — silently fail to prevent info leakage + } + + return true; + }, + + resetPassword: async ( + _parent: unknown, + args: { token: string; newPassword: string }, + context: Context, + ) => { + if (args.newPassword.length < 8) { + throw new GraphQLError("Password must be at least 8 characters", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + const verification = await context.prisma.verification.findFirst({ + where: { + value: args.token, + identifier: { startsWith: "password-reset:" }, + expiresAt: { gt: new Date() }, + }, + }); + + if (!verification) { + throw new GraphQLError("Invalid or expired reset token", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + const email = verification.identifier.replace("password-reset:", ""); + const user = await context.prisma.user.findUnique({ where: { email } }); + + if (!user) { + throw new GraphQLError("User not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + // Hash new password using Better Auth's built-in hasher + const { hashPassword } = await import("better-auth/crypto"); + const hashedPassword = await hashPassword(args.newPassword); + + await context.prisma.account.updateMany({ + where: { userId: user.id, providerId: "credential" }, + data: { password: hashedPassword }, + }); + + // Clean up verification token + await context.prisma.verification.delete({ + where: { id: verification.id }, + }); + + return true; + }, }, }; diff --git a/src/resolvers/index.ts b/src/resolvers/index.ts index 19254ab..1a47bad 100644 --- a/src/resolvers/index.ts +++ b/src/resolvers/index.ts @@ -13,6 +13,7 @@ import { apiKeyResolvers } from "./apiKey.resolver.js"; import { disasterTypeResolvers } from "./disasterType.resolver.js"; import { organisationResolvers } from "./organisation.resolver.js"; import { teamResolvers } from "./team.resolver.js"; +import { invitationResolvers } from "./invitation.resolver.js"; export const resolvers: IResolvers[] = [ scalarResolvers, @@ -29,4 +30,5 @@ export const resolvers: IResolvers[] = [ disasterTypeResolvers, organisationResolvers, teamResolvers, + invitationResolvers, ]; diff --git a/src/resolvers/invitation.resolver.ts b/src/resolvers/invitation.resolver.ts new file mode 100644 index 0000000..2407e8d --- /dev/null +++ b/src/resolvers/invitation.resolver.ts @@ -0,0 +1,463 @@ +import { randomBytes } from "node:crypto"; +import { GraphQLError } from "graphql"; +import type { Context } from "../context.js"; +import { requireAuth } from "../utils/auth-guard.js"; +import { env } from "../utils/env.js"; +import { getEmailProvider } from "../services/messaging/registry.js"; +import { + organisationInvite, + teamInviteNotification, +} from "../services/messaging/templates.js"; +import { auth } from "../lib/auth.js"; + +// ─── Interfaces ────────────────────────────────────────────────────────────── + +interface InviteUserInput { + email: string; + organisationId: string; + teamId?: string; + role?: string; + teamRole?: string; +} + +interface AcceptInviteInput { + token: string; + name: string; + password: string; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function generateToken(): string { + return randomBytes(32).toString("hex"); +} + +function invitationStatus(invite: { + acceptedAt: Date | null; + expiresAt: Date; +}): "pending" | "accepted" | "expired" { + if (invite.acceptedAt) return "accepted"; + if (invite.expiresAt < new Date()) return "expired"; + return "pending"; +} + +async function requireOrgAdmin(context: Context, organisationId: string) { + const user = requireAuth(context); + + // Global admins bypass org-level checks + if (user.role === "admin") return user; + + const membership = await context.prisma.organisationUsers.findUnique({ + where: { + userId_organisationId: { userId: user.id, organisationId }, + }, + }); + + if (!membership || !["owner", "admin"].includes(membership.role)) { + throw new GraphQLError("Requires organisation admin or owner role", { + extensions: { code: "FORBIDDEN" }, + }); + } + + return user; +} + +// ─── Resolver ──────────────────────────────────────────────────────────────── + +export const invitationResolvers = { + Query: { + pendingInvites: async ( + _parent: unknown, + args: { organisationId: string }, + context: Context, + ) => { + await requireOrgAdmin(context, args.organisationId); + return context.prisma.invitations.findMany({ + where: { + organisationId: args.organisationId, + acceptedAt: null, + expiresAt: { gt: new Date() }, + }, + orderBy: { createdAt: "desc" }, + }); + }, + + invitationByToken: async ( + _parent: unknown, + args: { token: string }, + context: Context, + ) => { + const invite = await context.prisma.invitations.findUnique({ + where: { token: args.token }, + include: { + organisation: { select: { name: true } }, + team: { select: { name: true } }, + }, + }); + + if (!invite) return null; + + return { + id: invite.id, + email: invite.email, + organisationName: invite.organisation.name, + teamName: invite.team?.name ?? null, + role: invite.role, + teamRole: invite.teamRole, + expiresAt: invite.expiresAt, + status: invitationStatus(invite), + }; + }, + }, + + Mutation: { + inviteUser: async ( + _parent: unknown, + args: { input: InviteUserInput }, + context: Context, + ) => { + const inviter = await requireOrgAdmin(context, args.input.organisationId); + const { email, organisationId, teamId, role = "member", teamRole = "viewer" } = args.input; + + // Validate org exists + const org = await context.prisma.organisations.findUnique({ + where: { id: organisationId }, + }); + if (!org) { + throw new GraphQLError("Organisation not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + // Validate team if provided + let teamName: string | undefined; + if (teamId) { + const team = await context.prisma.teams.findUnique({ + where: { id: teamId }, + }); + if (!team || team.organisationId !== organisationId) { + throw new GraphQLError("Team not found in this organisation", { + extensions: { code: "NOT_FOUND" }, + }); + } + teamName = team.name; + } + + // Check if the user is already an org member + const existingUser = await context.prisma.user.findUnique({ + where: { email }, + }); + + if (existingUser) { + const existingMembership = await context.prisma.organisationUsers.findUnique({ + where: { + userId_organisationId: { userId: existingUser.id, organisationId }, + }, + }); + + if (existingMembership) { + // Already an org member — if teamId provided, add directly to team + if (teamId) { + const existingTeamMember = await context.prisma.teamMembers.findUnique({ + where: { teamId_userId: { teamId, userId: existingUser.id } }, + }); + + if (existingTeamMember) { + throw new GraphQLError("User is already a member of this team", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + // Add directly to team (no invite needed) + await context.prisma.teamMembers.create({ + data: { teamId, userId: existingUser.id, role: teamRole }, + }); + + // Send notification email + const emailProvider = await getEmailProvider(); + const content = teamInviteNotification( + inviter.name, + org.name, + teamName!, + teamRole, + `${env.FRONTEND_URL}/dashboard`, + ); + await emailProvider.send({ + to: email, + subject: content.subject, + textBody: content.textBody, + htmlBody: content.htmlBody, + }); + + // Return a synthetic invitation record for consistency + return context.prisma.invitations.create({ + data: { + email, + organisationId, + teamId, + role, + teamRole, + token: generateToken(), + expiresAt: new Date(), + acceptedAt: new Date(), + invitedById: inviter.id, + }, + }); + } + + throw new GraphQLError("User is already a member of this organisation", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + } + + // Check for existing pending invite + const existingInvite = await context.prisma.invitations.findFirst({ + where: { + email, + organisationId, + acceptedAt: null, + expiresAt: { gt: new Date() }, + }, + }); + + if (existingInvite) { + throw new GraphQLError("A pending invitation already exists for this email. Use resendInvite to resend.", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + // Create invitation + const token = generateToken(); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + + const invitation = await context.prisma.invitations.create({ + data: { + email, + organisationId, + teamId: teamId ?? null, + role, + teamRole: teamId ? teamRole : null, + token, + expiresAt, + invitedById: inviter.id, + }, + }); + + // Send invite email + const inviteUrl = `${env.FRONTEND_URL}/accept-invite?token=${token}`; + const emailProvider = await getEmailProvider(); + const content = organisationInvite( + inviter.name, + org.name, + role, + inviteUrl, + teamName, + ); + await emailProvider.send({ + to: email, + subject: content.subject, + textBody: content.textBody, + htmlBody: content.htmlBody, + }); + + return invitation; + }, + + acceptInvite: async ( + _parent: unknown, + args: { input: AcceptInviteInput }, + context: Context, + ) => { + const { token, name, password } = args.input; + + const invitation = await context.prisma.invitations.findUnique({ + where: { token }, + }); + + if (!invitation) { + throw new GraphQLError("Invalid invitation token", { + extensions: { code: "NOT_FOUND" }, + }); + } + + if (invitation.acceptedAt) { + throw new GraphQLError("Invitation has already been accepted", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + if (invitation.expiresAt < new Date()) { + throw new GraphQLError("Invitation has expired", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + // Check if user with this email already exists + let existingUser = await context.prisma.user.findUnique({ + where: { email: invitation.email }, + }); + + if (!existingUser) { + // Create new user via Better Auth + const signup = await auth.api.signUpEmail({ + body: { name, email: invitation.email, password }, + }); + // Mark email as verified (admin vouched for it) + await context.prisma.user.update({ + where: { id: signup.user.id }, + data: { emailVerified: true }, + }); + existingUser = await context.prisma.user.findUniqueOrThrow({ + where: { id: signup.user.id }, + }); + } + + // Add to organisation + const existingOrgMembership = await context.prisma.organisationUsers.findUnique({ + where: { + userId_organisationId: { + userId: existingUser.id, + organisationId: invitation.organisationId, + }, + }, + }); + + if (!existingOrgMembership) { + await context.prisma.organisationUsers.create({ + data: { + userId: existingUser.id, + organisationId: invitation.organisationId, + role: invitation.role, + }, + }); + } + + // Add to team if specified + if (invitation.teamId) { + const existingTeamMember = await context.prisma.teamMembers.findUnique({ + where: { + teamId_userId: { + teamId: invitation.teamId, + userId: existingUser.id, + }, + }, + }); + + if (!existingTeamMember) { + await context.prisma.teamMembers.create({ + data: { + teamId: invitation.teamId, + userId: existingUser.id, + role: invitation.teamRole ?? "viewer", + }, + }); + } + } + + // Mark invitation as accepted + await context.prisma.invitations.update({ + where: { id: invitation.id }, + data: { acceptedAt: new Date() }, + }); + + return true; + }, + + cancelInvite: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + const invitation = await context.prisma.invitations.findUnique({ + where: { id: args.id }, + }); + + if (!invitation) { + throw new GraphQLError("Invitation not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + await requireOrgAdmin(context, invitation.organisationId); + + await context.prisma.invitations.delete({ + where: { id: args.id }, + }); + + return true; + }, + + resendInvite: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + const invitation = await context.prisma.invitations.findUnique({ + where: { id: args.id }, + include: { + organisation: { select: { name: true } }, + team: { select: { name: true } }, + }, + }); + + if (!invitation) { + throw new GraphQLError("Invitation not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + const inviter = await requireOrgAdmin(context, invitation.organisationId); + + if (invitation.acceptedAt) { + throw new GraphQLError("Cannot resend an accepted invitation", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + // Reset token and expiry + const newToken = generateToken(); + const newExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + const updated = await context.prisma.invitations.update({ + where: { id: args.id }, + data: { token: newToken, expiresAt: newExpiry }, + }); + + // Resend email + const inviteUrl = `${env.FRONTEND_URL}/accept-invite?token=${newToken}`; + const emailProvider = await getEmailProvider(); + const content = organisationInvite( + inviter.name, + invitation.organisation.name, + invitation.role, + inviteUrl, + invitation.team?.name, + ); + await emailProvider.send({ + to: invitation.email, + subject: content.subject, + textBody: content.textBody, + htmlBody: content.htmlBody, + }); + + return updated; + }, + }, + + Invitation: { + organisation: (parent: { organisationId: string }, _args: unknown, { prisma }: Context) => { + return prisma.organisations.findUnique({ where: { id: parent.organisationId } }); + }, + team: (parent: { teamId: string | null }, _args: unknown, { prisma }: Context) => { + if (!parent.teamId) return null; + return prisma.teams.findUnique({ where: { id: parent.teamId } }); + }, + invitedBy: (parent: { invitedById: string }, _args: unknown, { prisma }: Context) => { + return prisma.user.findUnique({ where: { id: parent.invitedById } }); + }, + status: (parent: { acceptedAt: Date | null; expiresAt: Date }) => { + return invitationStatus(parent); + }, + }, +}; diff --git a/src/resolvers/organisation.resolver.ts b/src/resolvers/organisation.resolver.ts index b73deaa..45a120f 100644 --- a/src/resolvers/organisation.resolver.ts +++ b/src/resolvers/organisation.resolver.ts @@ -1,6 +1,6 @@ import { GraphQLError } from "graphql"; import type { Context } from "../context.js"; -import { requireAuth } from "../utils/auth-guard.js"; +import { requireAuth, requireRole } from "../utils/auth-guard.js"; interface CreateOrganisationInput { name: string; @@ -21,6 +21,14 @@ export const organisationResolvers = { context: Context, ) => { const user = requireAuth(context); + + // Global admins see all organisations + if (user.role === "admin") { + return context.prisma.organisations.findMany({ + orderBy: { createdAt: "desc" }, + }); + } + const memberships = await context.prisma.organisationUsers.findMany({ where: { userId: user.id }, select: { organisationId: true }, @@ -68,7 +76,8 @@ export const organisationResolvers = { args: { input: CreateOrganisationInput }, context: Context, ) => { - const user = requireAuth(context); + // Only global admins can create organisations + requireRole(context, ["admin"]); const { name, slug } = args.input; const existing = await context.prisma.organisations.findUnique({ @@ -80,20 +89,35 @@ export const organisationResolvers = { }); } + // Global admin creates the org but is NOT added as a member — + // they control everything via their global role. return context.prisma.organisations.create({ - data: { - name, - slug, - users: { - create: { - userId: user.id, - role: "owner", - }, - }, - }, + data: { name, slug }, }); }, + deleteOrganisation: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + // Only global admins can delete organisations + requireRole(context, ["admin"]); + + const org = await context.prisma.organisations.findUnique({ + where: { id: args.id }, + }); + if (!org) { + throw new GraphQLError("Organisation not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + // Cascade is handled by Prisma schema (onDelete: Cascade on teams, members, invitations) + await context.prisma.organisations.delete({ where: { id: args.id } }); + return true; + }, + updateOrganisation: async ( _parent: unknown, args: { id: string; input: UpdateOrganisationInput }, diff --git a/src/schema/index.ts b/src/schema/index.ts index e911bcd..c9427fb 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -15,6 +15,7 @@ import { feedbackTypeDef } from "./typeDefs/types/feedback.js"; import { disasterTypeTypeDef } from "./typeDefs/types/disasterType.js"; import { organisationTypeDef } from "./typeDefs/types/organisation.js"; import { teamTypeDef } from "./typeDefs/types/team.js"; +import { invitationTypeDef } from "./typeDefs/types/invitation.js"; export const typeDefs = [ scalarTypeDef, @@ -34,4 +35,5 @@ export const typeDefs = [ disasterTypeTypeDef, organisationTypeDef, teamTypeDef, + invitationTypeDef, ]; diff --git a/src/schema/typeDefs/mutation.ts b/src/schema/typeDefs/mutation.ts index 8cace82..95abd3e 100644 --- a/src/schema/typeDefs/mutation.ts +++ b/src/schema/typeDefs/mutation.ts @@ -93,6 +93,9 @@ export const mutationTypeDef = gql` """Remove a member from an organisation.""" removeOrgMember(orgId: String!, userId: String!): Boolean! + """Delete an organisation and all its teams, members, and invitations. Requires global admin.""" + deleteOrganisation(id: String!): Boolean! + # ─── Teams ───────────────────────────────────────────────────────────────── """Create a new team within an organisation. Requires org admin or owner.""" createTeam(input: CreateTeamInput!): Team! @@ -117,10 +120,46 @@ export const mutationTypeDef = gql` """Set the authenticated user's default team (for frontend convenience).""" setDefaultTeam(teamId: String!): Team! + + # ─── Invitations ────────────────────────────────────────────────────────── + """Invite a user to an organisation (and optionally a team). Sends invite email.""" + inviteUser(input: InviteUserInput!): Invitation! + + """Accept an invitation. Creates user account if new, adds to org and team.""" + acceptInvite(input: AcceptInviteInput!): Boolean! + + """Cancel a pending invitation.""" + cancelInvite(id: String!): Boolean! + + """Resend an invitation email (resets expiry to 7 days).""" + resendInvite(id: String!): Invitation! + + # ─── Password Reset ────────────────────────────────────────────────────── + """Request a password reset email (public, always returns true).""" + requestPasswordReset(email: String!): Boolean! + + """Reset password using a token from the reset email.""" + resetPassword(token: String!, newPassword: String!): Boolean! } # ─── Input Types ─────────────────────────────────────────────────────────── + input InviteUserInput { + email: String! + organisationId: String! + teamId: String + """Organisation role: owner, admin, member (default: member).""" + role: String + """Team role: lead, analyst, viewer (default: viewer). Only used if teamId is provided.""" + teamRole: String + } + + input AcceptInviteInput { + token: String! + name: String! + password: String! + } + input UpdateProfileInput { name: String phoneNumber: String diff --git a/src/schema/typeDefs/query.ts b/src/schema/typeDefs/query.ts index a6e6097..eb6d34a 100644 --- a/src/schema/typeDefs/query.ts +++ b/src/schema/typeDefs/query.ts @@ -83,5 +83,12 @@ export const queryTypeDef = gql` """Look up a team by ID. Requires membership or global admin.""" team(id: String!): Team + + # ─── Invitations ────────────────────────────────────────────────────────── + """List pending invitations for an organisation. Requires org admin.""" + pendingInvites(organisationId: String!): [Invitation!]! + + """Look up an invitation by token (public — used on accept-invite page).""" + invitationByToken(token: String!): InvitationInfo } `; diff --git a/src/schema/typeDefs/types/invitation.ts b/src/schema/typeDefs/types/invitation.ts new file mode 100644 index 0000000..36beee1 --- /dev/null +++ b/src/schema/typeDefs/types/invitation.ts @@ -0,0 +1,40 @@ +import { gql } from "graphql-tag"; + +export const invitationTypeDef = gql` + """Status of an invitation.""" + enum InvitationStatus { + pending + accepted + expired + } + + """An invitation to join an organisation (and optionally a team).""" + type Invitation { + id: String! + email: String! + organisation: Organisation! + team: Team + """Organisation role assigned on acceptance.""" + role: String! + """Team role assigned on acceptance (if team specified).""" + teamRole: String + expiresAt: DateTime! + acceptedAt: DateTime + invitedBy: User! + createdAt: DateTime! + """Computed from acceptedAt and expiresAt.""" + status: InvitationStatus! + } + + """Public invitation info returned by token lookup (limited fields).""" + type InvitationInfo { + id: String! + email: String! + organisationName: String! + teamName: String + role: String! + teamRole: String + expiresAt: DateTime! + status: InvitationStatus! + } +`; diff --git a/src/services/messaging/templates.ts b/src/services/messaging/templates.ts index 215d418..94d436d 100644 --- a/src/services/messaging/templates.ts +++ b/src/services/messaging/templates.ts @@ -5,12 +5,53 @@ * Returns { subject, textBody, htmlBody } for each template type. */ -interface EmailContent { +export interface EmailContent { subject: string; textBody: string; htmlBody: string; } +/** Reusable HTML email wrapper */ +function wrapHtml(heading: string, body: string): string { + return ` + + + + + + +
FieldTypeDescription
idString!
userUser!
eventEvent
signalSignal
ratingInt!Rating from 1 to 5.
textStringOptional textual feedback.
createdAt DateTime!
+ + + +
+ + + + + + + + + + +
+

CLEAR Platform

+

${heading}

+
${body}
+

© CLEAR — Crisis Landscape Early Assessment and Response

+
+
+ +`; +} + +function ctaButton(label: string, url: string): string { + return `
+ ${label} +
`; +} + /** * Email verification template. * @@ -113,3 +154,142 @@ If you did not request this verification, you can safely ignore this email. `, }; } + +/** + * Organisation invite template. + * Sent when an admin invites a new or existing user to join an organisation. + */ +export function organisationInvite( + inviterName: string, + orgName: string, + role: string, + inviteUrl: string, + teamName?: string, +): EmailContent { + const teamLine = teamName ? ` and the "${teamName}" team` : ""; + const subject = `You've been invited to join ${orgName} on CLEAR Platform`; + + return { + subject, + textBody: `${inviterName} has invited you to join "${orgName}"${teamLine} as ${role} on the CLEAR Platform. + +Click the link below to accept your invitation and set up your account: + +${inviteUrl} + +This invitation expires in 7 days. + +— The CLEAR Platform Team`, + + htmlBody: wrapHtml( + "Organisation Invitation", + `

+ You've been invited! +

+

+ ${inviterName} has invited you to join + ${orgName}${teamLine} as ${role} + on the CLEAR Platform. +

+ ${ctaButton("Accept Invitation", inviteUrl)} +

+ This invitation expires in 7 days. If the button doesn't work, copy and paste this URL: +

+

+ ${inviteUrl} +

+
+

+ If you were not expecting this invitation, you can safely ignore this email. +

`, + ), + }; +} + +/** + * Team invite notification for existing org members. + * Sent when an admin adds an existing org member to a new team. + */ +export function teamInviteNotification( + inviterName: string, + orgName: string, + teamName: string, + teamRole: string, + dashboardUrl: string, +): EmailContent { + return { + subject: `You've been added to ${teamName} on CLEAR Platform`, + + textBody: `${inviterName} has added you to the "${teamName}" team in "${orgName}" as ${teamRole}. + +Visit your dashboard to start working with your new team: + +${dashboardUrl} + +— The CLEAR Platform Team`, + + htmlBody: wrapHtml( + "Team Assignment", + `

+ You've been added to a new team! +

+

+ ${inviterName} has added you to the + ${teamName} team in ${orgName} + as ${teamRole}. +

+ ${ctaButton("Go to Dashboard", dashboardUrl)} +
+

+ You received this because you are a member of ${orgName}. +

`, + ), + }; +} + +/** + * Password reset template. + */ +export function passwordReset( + userName: string, + resetUrl: string, +): EmailContent { + const displayName = userName || "there"; + + return { + subject: "Reset your password — CLEAR Platform", + + textBody: `Hi ${displayName}, + +We received a request to reset your password. Click the link below to set a new password: + +${resetUrl} + +This link will expire in 1 hour. + +If you did not request this, you can safely ignore this email. Your password will not be changed. + +— The CLEAR Platform Team`, + + htmlBody: wrapHtml( + "Password Reset", + `

+ Hi ${displayName}, +

+

+ We received a request to reset your password. Click the button below to set a new password. +

+ ${ctaButton("Reset Password", resetUrl)} +

+ This link will expire in 1 hour. If the button doesn't work, copy and paste this URL: +

+

+ ${resetUrl} +

+
+

+ If you did not request this reset, you can safely ignore this email. Your password will not be changed. +

`, + ), + }; +} diff --git a/src/utils/env.ts b/src/utils/env.ts index 7941b3a..eb5f59f 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -20,6 +20,10 @@ const envSchema = z.object({ SMTP_FROM: z.string().default("noreply@clear-platform.org"), POSTMARK_SERVER_TOKEN: z.string().optional(), POSTMARK_SENDER_EMAIL: z.string().optional(), + + // Global admin seed (env overrides seed defaults) + ADMIN_EMAIL: z.string().email().default("admin@clear.dev"), + ADMIN_PASSWORD: z.string().min(8).default("password123"), }); const parsed = envSchema.parse(process.env); From fb79c75860b00a5c826bc70847240be4ab181085 Mon Sep 17 00:00:00 2001 From: Prajjawalk Date: Wed, 25 Mar 2026 15:02:17 +0530 Subject: [PATCH 12/15] Add: feedback and comment resolvers --- src/resolvers/feedback.resolver.ts | 266 +++++++++++++++++++++++++++++ src/resolvers/index.ts | 2 + src/schema/typeDefs/mutation.ts | 47 +++++ 3 files changed, 315 insertions(+) create mode 100644 src/resolvers/feedback.resolver.ts diff --git a/src/resolvers/feedback.resolver.ts b/src/resolvers/feedback.resolver.ts new file mode 100644 index 0000000..5fd0cc6 --- /dev/null +++ b/src/resolvers/feedback.resolver.ts @@ -0,0 +1,266 @@ +import { GraphQLError } from "graphql"; +import type { Context } from "../context.js"; +import { requireAuth } from "../utils/auth-guard.js"; + +// ─── Interfaces ────────────────────────────────────────────────────────────── + +interface AddFeedbackInput { + eventId?: string; + signalId?: string; + rating: number; + text?: string; +} + +interface AddCommentInput { + eventId?: string; + signalId?: string; + comment: string; + tagUserIds?: string[]; +} + +interface ReplyToCommentInput { + repliedToCommentId: string; + comment: string; + tagUserIds?: string[]; +} + +// ─── Resolver ──────────────────────────────────────────────────────────────── + +export const feedbackResolvers = { + Mutation: { + addFeedback: async ( + _parent: unknown, + args: { input: AddFeedbackInput }, + context: Context, + ) => { + const user = requireAuth(context); + const { eventId, signalId, rating, text } = args.input; + + if (!eventId && !signalId) { + throw new GraphQLError("Provide either eventId or signalId", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + if (eventId && signalId) { + throw new GraphQLError("Provide only one of eventId or signalId, not both", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + if (rating < 1 || rating > 5) { + throw new GraphQLError("Rating must be between 1 and 5", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + return context.prisma.userFeedbacks.create({ + data: { + userId: user.id, + eventId: eventId ?? null, + signalId: signalId ?? null, + rating, + text: text ?? null, + }, + }); + }, + + deleteFeedback: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + const user = requireAuth(context); + + const feedback = await context.prisma.userFeedbacks.findUnique({ + where: { id: args.id }, + }); + if (!feedback) { + throw new GraphQLError("Feedback not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + if (feedback.userId !== user.id && user.role !== "admin") { + throw new GraphQLError("You can only delete your own feedback", { + extensions: { code: "FORBIDDEN" }, + }); + } + + await context.prisma.userFeedbacks.delete({ where: { id: args.id } }); + return true; + }, + + addComment: async ( + _parent: unknown, + args: { input: AddCommentInput }, + context: Context, + ) => { + const user = requireAuth(context); + const { eventId, signalId, comment, tagUserIds } = args.input; + + if (!eventId && !signalId) { + throw new GraphQLError("Provide either eventId or signalId", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + if (eventId && signalId) { + throw new GraphQLError("Provide only one of eventId or signalId, not both", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + const created = await context.prisma.userComments.create({ + data: { + userId: user.id, + eventId: eventId ?? null, + signalId: signalId ?? null, + comment, + isCommentReply: false, + }, + }); + + if (tagUserIds?.length) { + await context.prisma.commentTags.createMany({ + data: tagUserIds.map((userId) => ({ + userId, + commentId: created.id, + })), + skipDuplicates: true, + }); + } + + return created; + }, + + replyToComment: async ( + _parent: unknown, + args: { input: ReplyToCommentInput }, + context: Context, + ) => { + const user = requireAuth(context); + const { repliedToCommentId, comment, tagUserIds } = args.input; + + const parentComment = await context.prisma.userComments.findUnique({ + where: { id: repliedToCommentId }, + }); + if (!parentComment) { + throw new GraphQLError("Comment not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + const created = await context.prisma.userComments.create({ + data: { + userId: user.id, + eventId: parentComment.eventId, + signalId: parentComment.signalId, + comment, + isCommentReply: true, + repliedToCommentId, + }, + }); + + if (tagUserIds?.length) { + await context.prisma.commentTags.createMany({ + data: tagUserIds.map((userId) => ({ + userId, + commentId: created.id, + })), + skipDuplicates: true, + }); + } + + return created; + }, + + deleteComment: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + const user = requireAuth(context); + + const comment = await context.prisma.userComments.findUnique({ + where: { id: args.id }, + }); + if (!comment) { + throw new GraphQLError("Comment not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + if (comment.userId !== user.id && user.role !== "admin") { + throw new GraphQLError("You can only delete your own comments", { + extensions: { code: "FORBIDDEN" }, + }); + } + + await context.prisma.userComments.delete({ where: { id: args.id } }); + return true; + }, + + tagUsersInComment: async ( + _parent: unknown, + args: { commentId: string; userIds: string[] }, + context: Context, + ) => { + requireAuth(context); + + const comment = await context.prisma.userComments.findUnique({ + where: { id: args.commentId }, + }); + if (!comment) { + throw new GraphQLError("Comment not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + await context.prisma.commentTags.createMany({ + data: args.userIds.map((userId) => ({ + userId, + commentId: args.commentId, + })), + skipDuplicates: true, + }); + + return comment; + }, + }, + + UserFeedback: { + user: (parent: { userId: string }, _args: unknown, { prisma }: Context) => { + return prisma.user.findUnique({ where: { id: parent.userId } }); + }, + event: (parent: { eventId: string | null }, _args: unknown, { prisma }: Context) => { + if (!parent.eventId) return null; + return prisma.events.findUnique({ where: { id: parent.eventId } }); + }, + signal: (parent: { signalId: string | null }, _args: unknown, { prisma }: Context) => { + if (!parent.signalId) return null; + return prisma.signals.findUnique({ where: { id: parent.signalId } }); + }, + }, + + UserComment: { + user: (parent: { userId: string }, _args: unknown, { prisma }: Context) => { + return prisma.user.findUnique({ where: { id: parent.userId } }); + }, + event: (parent: { eventId: string | null }, _args: unknown, { prisma }: Context) => { + if (!parent.eventId) return null; + return prisma.events.findUnique({ where: { id: parent.eventId } }); + }, + signal: (parent: { signalId: string | null }, _args: unknown, { prisma }: Context) => { + if (!parent.signalId) return null; + return prisma.signals.findUnique({ where: { id: parent.signalId } }); + }, + tags: (parent: { id: string }, _args: unknown, { prisma }: Context) => { + return prisma.commentTags.findMany({ where: { commentId: parent.id } }); + }, + }, + + CommentTag: { + user: (parent: { userId: string }, _args: unknown, { prisma }: Context) => { + return prisma.user.findUnique({ where: { id: parent.userId } }); + }, + comment: (parent: { commentId: string }, _args: unknown, { prisma }: Context) => { + return prisma.userComments.findUnique({ where: { id: parent.commentId } }); + }, + }, +}; diff --git a/src/resolvers/index.ts b/src/resolvers/index.ts index 1a47bad..d15bc70 100644 --- a/src/resolvers/index.ts +++ b/src/resolvers/index.ts @@ -13,6 +13,7 @@ import { apiKeyResolvers } from "./apiKey.resolver.js"; import { disasterTypeResolvers } from "./disasterType.resolver.js"; import { organisationResolvers } from "./organisation.resolver.js"; import { teamResolvers } from "./team.resolver.js"; +import { feedbackResolvers } from "./feedback.resolver.js"; import { invitationResolvers } from "./invitation.resolver.js"; export const resolvers: IResolvers[] = [ @@ -30,5 +31,6 @@ export const resolvers: IResolvers[] = [ disasterTypeResolvers, organisationResolvers, teamResolvers, + feedbackResolvers, invitationResolvers, ]; diff --git a/src/schema/typeDefs/mutation.ts b/src/schema/typeDefs/mutation.ts index 95abd3e..657c0e6 100644 --- a/src/schema/typeDefs/mutation.ts +++ b/src/schema/typeDefs/mutation.ts @@ -80,6 +80,26 @@ export const mutationTypeDef = gql` """Mark all notifications as read for the authenticated user.""" markAllNotificationsRead: Boolean! + # ─── Feedback ────────────────────────────────────────────────────────────── + """Add feedback (rating + optional text) to a signal or event.""" + addFeedback(input: AddFeedbackInput!): UserFeedback! + + """Delete your own feedback.""" + deleteFeedback(id: String!): Boolean! + + # ─── Comments ───────────────────────────────────────────────────────────── + """Add a comment to a signal or event.""" + addComment(input: AddCommentInput!): UserComment! + + """Reply to an existing comment.""" + replyToComment(input: ReplyToCommentInput!): UserComment! + + """Delete your own comment.""" + deleteComment(id: String!): Boolean! + + """Tag users in a comment.""" + tagUsersInComment(commentId: String!, userIds: [String!]!): UserComment! + # ─── Organisations ───────────────────────────────────────────────────────── """Create a new organisation. The creator becomes the owner.""" createOrganisation(input: CreateOrganisationInput!): Organisation! @@ -275,4 +295,31 @@ export const mutationTypeDef = gql` actionUrl: String actionText: String } + + input AddFeedbackInput { + """Provide exactly one of eventId or signalId.""" + eventId: String + signalId: String + """Rating from 1 to 5.""" + rating: Int! + """Optional textual feedback.""" + text: String + } + + input AddCommentInput { + """Provide exactly one of eventId or signalId.""" + eventId: String + signalId: String + comment: String! + """User IDs to tag in the comment.""" + tagUserIds: [String!] + } + + input ReplyToCommentInput { + """ID of the comment to reply to.""" + repliedToCommentId: String! + comment: String! + """User IDs to tag in the reply.""" + tagUserIds: [String!] + } `; From a94218ec23b73af50bbf4edcab568a06011c9604 Mon Sep 17 00:00:00 2001 From: Prajjawalk Date: Wed, 25 Mar 2026 20:02:39 +0530 Subject: [PATCH 13/15] Add: notification mutations --- src/resolvers/alert.resolver.ts | 64 ++++- src/resolvers/notification.resolver.ts | 338 ++++++++++++++++++++++++- src/schema/typeDefs/mutation.ts | 30 +++ src/services/messaging/templates.ts | 106 ++++++++ 4 files changed, 534 insertions(+), 4 deletions(-) diff --git a/src/resolvers/alert.resolver.ts b/src/resolvers/alert.resolver.ts index d14f974..d0b0546 100644 --- a/src/resolvers/alert.resolver.ts +++ b/src/resolvers/alert.resolver.ts @@ -5,6 +5,9 @@ import { requireAuth, requireRole } from "../utils/auth-guard.js"; import { resolveTeamMembership } from "../utils/auth-guard.js"; import { getLocationIdsWithDescendants } from "../utils/geo-resolve.js"; import { buildEventLocationFilterForTeam } from "../utils/location-scope.js"; +import { env } from "../utils/env.js"; +import { getEmailProvider } from "../services/messaging/registry.js"; +import { alertNotification } from "../services/messaging/templates.js"; interface CreateAlertInput { eventId: string; @@ -122,7 +125,7 @@ export const alertResolvers = { }, }); - // Find subscribers matching the event's types and locations + // Fan out notifications to immediate subscribers const eventLocationIds = [ event.originId, event.destinationId, @@ -130,17 +133,34 @@ export const alertResolvers = { ].filter((id): id is string => id !== null); if (eventLocationIds.length > 0 && event.types.length > 0) { + // Expand locations to include ancestors so country-level subscriptions + // match district-level alerts + const allLocationIds = new Set(eventLocationIds); + const locations = await context.prisma.locations.findMany({ + where: { id: { in: eventLocationIds } }, + select: { ancestorIds: true }, + }); + for (const loc of locations) { + for (const aid of loc.ancestorIds) allLocationIds.add(aid); + } + const subscriptions = await context.prisma.userAlertSubscriptions.findMany({ where: { active: true, + frequency: "immediately", alertType: { in: event.types }, - locationId: { in: eventLocationIds }, + locationId: { in: [...allLocationIds] }, }, + select: { userId: true }, }); - // Create userAlerts entries for each unique subscriber const uniqueUserIds = [...new Set(subscriptions.map((s) => s.userId))]; + if (uniqueUserIds.length > 0) { + const title = event.title ?? event.types[0] ?? "Alert"; + const alertUrl = `${env.FRONTEND_URL}/alerts/${alert.id}`; + + // 1. Populate userAlerts join table await context.prisma.userAlerts.createMany({ data: uniqueUserIds.map((userId) => ({ userId, @@ -148,6 +168,44 @@ export const alertResolvers = { })), skipDuplicates: true, }); + + // 2. Create in-app notifications + await context.prisma.notifications.createMany({ + data: uniqueUserIds.map((userId) => ({ + userId, + message: `New alert: ${title}`, + notificationType: "alert", + actionUrl: `/alerts/${alert.id}`, + actionText: "View Alert", + })), + }); + + // 3. Send email notifications (fire-and-forget) + const emailUsers = await context.prisma.user.findMany({ + where: { id: { in: uniqueUserIds }, emailNotification: true }, + select: { name: true, email: true }, + }); + + if (emailUsers.length > 0) { + void (async () => { + try { + const emailProvider = await getEmailProvider(); + await emailProvider.sendBulk( + emailUsers.map((u) => { + const content = alertNotification(u.name, title, event.description, alertUrl); + return { + to: u.email, + subject: content.subject, + textBody: content.textBody, + htmlBody: content.htmlBody, + }; + }), + ); + } catch (err) { + console.error("[createAlert] Failed to send alert emails:", err); + } + })(); + } } } diff --git a/src/resolvers/notification.resolver.ts b/src/resolvers/notification.resolver.ts index d76ec62..45d9425 100644 --- a/src/resolvers/notification.resolver.ts +++ b/src/resolvers/notification.resolver.ts @@ -1,7 +1,13 @@ import { GraphQLError } from "graphql"; import type { Context } from "../context.js"; -import type { NotificationStatus } from "../generated/prisma/client.js"; +import type { NotificationStatus, PrismaClient } from "../generated/prisma/client.js"; import { requireAuth, requireRole } from "../utils/auth-guard.js"; +import { env } from "../utils/env.js"; +import { getEmailProvider } from "../services/messaging/registry.js"; +import { + alertNotification, + alertDigest, +} from "../services/messaging/templates.js"; interface CreateNotificationInput { userId: string; @@ -11,6 +17,61 @@ interface CreateNotificationInput { actionText?: string; } +interface CreateBulkNotificationsInput { + userIds: string[]; + message: string; + notificationType: string; + actionUrl?: string; + actionText?: string; +} + +interface AlertNotifyInput { + alertId: string; +} + +interface AlertDigestInput { + alertIds: string[]; + frequency: "daily" | "weekly" | "monthly"; +} + +/** + * Find all subscriber user IDs for a given alert based on its event's + * types and locations, filtered by frequency. + */ +async function findSubscribers( + prisma: PrismaClient, + eventTypes: string[], + locationIds: string[], + frequency: "immediately" | "daily" | "weekly" | "monthly", +): Promise { + if (eventTypes.length === 0 || locationIds.length === 0) return []; + + // Expand locations to include ancestors (subscriptions at country level + // should match alerts at district level) + const allLocationIds = new Set(locationIds); + const locations = await prisma.locations.findMany({ + where: { id: { in: locationIds } }, + select: { ancestorIds: true }, + }); + for (const loc of locations) { + for (const ancestorId of loc.ancestorIds) { + allLocationIds.add(ancestorId); + } + } + + const subscriptions = await prisma.userAlertSubscriptions.findMany({ + where: { + active: true, + frequency, + alertType: { in: eventTypes }, + locationId: { in: [...allLocationIds] }, + }, + select: { userId: true }, + }); + + return [...new Set(subscriptions.map((s) => s.userId))]; +} + export const notificationResolvers = { Query: { notifications: ( @@ -54,6 +115,281 @@ export const notificationResolvers = { }); }, + createBulkNotifications: async ( + _parent: unknown, + args: { input: CreateBulkNotificationsInput }, + context: Context, + ) => { + requireRole(context, ["admin", "analyst"]); + const { input } = args; + + const result = await context.prisma.notifications.createMany({ + data: input.userIds.map((userId) => ({ + userId, + message: input.message, + notificationType: input.notificationType, + actionUrl: input.actionUrl, + actionText: input.actionText, + })), + }); + + return result.count; + }, + + notifyAlertSubscribers: async ( + _parent: unknown, + args: { input: AlertNotifyInput }, + context: Context, + ) => { + requireRole(context, ["admin", "analyst"]); + + const alert = await context.prisma.alerts.findUnique({ + where: { id: args.input.alertId }, + include: { event: true }, + }); + if (!alert) { + throw new GraphQLError("Alert not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + const event = alert.event; + const eventLocationIds = [ + event.originId, + event.destinationId, + event.locationId, + ].filter((id): id is string => id !== null); + + const userIds = await findSubscribers( + context.prisma, + event.types, + eventLocationIds, + "immediately", + ); + + if (userIds.length === 0) return 0; + + const title = event.title ?? event.types[0] ?? "Alert"; + const alertUrl = `${env.FRONTEND_URL}/alerts/${alert.id}`; + + // 1. Populate userAlerts join table + await context.prisma.userAlerts.createMany({ + data: userIds.map((userId) => ({ + userId, + alertId: alert.id, + })), + skipDuplicates: true, + }); + + // 2. Create in-app notifications + const result = await context.prisma.notifications.createMany({ + data: userIds.map((userId) => ({ + userId, + message: `New alert: ${title}`, + notificationType: "alert", + actionUrl: `/alerts/${alert.id}`, + actionText: "View Alert", + })), + }); + + // 3. Send email notifications to users who have email notifications enabled + const emailUsers = await context.prisma.user.findMany({ + where: { id: { in: userIds }, emailNotification: true }, + select: { id: true, name: true, email: true }, + }); + + if (emailUsers.length > 0) { + const emailProvider = await getEmailProvider(); + const emails = emailUsers.map((u) => { + const content = alertNotification( + u.name, + title, + event.description, + alertUrl, + ); + return { + to: u.email, + subject: content.subject, + textBody: content.textBody, + htmlBody: content.htmlBody, + }; + }); + + // Fire-and-forget — don't block the response on email delivery + void emailProvider.sendBulk(emails).catch((err) => { + console.error("[NOTIFY] Failed to send alert emails:", err); + }); + } + + return result.count; + }, + + notifyAlertDigest: async ( + _parent: unknown, + args: { input: AlertDigestInput }, + context: Context, + ) => { + requireRole(context, ["admin", "analyst"]); + const { alertIds, frequency } = args.input; + + if (!["daily", "weekly", "monthly"].includes(frequency)) { + throw new GraphQLError("Frequency must be daily, weekly, or monthly", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + const alerts = await context.prisma.alerts.findMany({ + where: { id: { in: alertIds } }, + include: { event: true }, + }); + + if (alerts.length === 0) return 0; + + // Pre-compute the expanded location set (including ancestors) for each alert + const alertLocationSets: Map> = new Map(); + const allTypes = new Set(); + const allExpandedLocationIds = new Set(); + + for (const alert of alerts) { + for (const t of alert.event.types) allTypes.add(t); + + const directIds = [ + alert.event.originId, + alert.event.destinationId, + alert.event.locationId, + ].filter((id): id is string => id !== null); + + const expanded = new Set(directIds); + const locations = await context.prisma.locations.findMany({ + where: { id: { in: directIds } }, + select: { ancestorIds: true }, + }); + for (const loc of locations) { + for (const aid of loc.ancestorIds) expanded.add(aid); + } + alertLocationSets.set(alert.id, expanded); + for (const lid of expanded) allExpandedLocationIds.add(lid); + } + + if (allTypes.size === 0 || allExpandedLocationIds.size === 0) return 0; + + // Fetch only subscriptions that match ANY of the alert types AND locations + const subscriptions = await context.prisma.userAlertSubscriptions.findMany({ + where: { + active: true, + frequency, + alertType: { in: [...allTypes] }, + locationId: { in: [...allExpandedLocationIds] }, + }, + select: { userId: true, alertType: true, locationId: true }, + }); + + if (subscriptions.length === 0) return 0; + + // For each user, find which alerts match their subscriptions + // userAlertMap: userId → Set + const userAlertMap = new Map>(); + + for (const sub of subscriptions) { + for (const alert of alerts) { + const typesMatch = alert.event.types.includes(sub.alertType); + const locationSet = alertLocationSets.get(alert.id); + const locationMatch = locationSet?.has(sub.locationId) ?? false; + + if (typesMatch && locationMatch) { + let set = userAlertMap.get(sub.userId); + if (!set) { + set = new Set(); + userAlertMap.set(sub.userId, set); + } + set.add(alert.id); + } + } + } + + if (userAlertMap.size === 0) return 0; + + const frequencyLabel = frequency.charAt(0).toUpperCase() + frequency.slice(1); + const dashboardUrl = `${env.FRONTEND_URL}/detection`; + + // 1. Populate userAlerts join table for each user's matched alerts + const userAlertData: Array<{ userId: string; alertId: string }> = []; + for (const [userId, matchedAlertIds] of userAlertMap) { + for (const alertId of matchedAlertIds) { + userAlertData.push({ userId, alertId }); + } + } + await context.prisma.userAlerts.createMany({ + data: userAlertData, + skipDuplicates: true, + }); + + // 2. Create in-app notifications per user + const notificationData: Array<{ + userId: string; + message: string; + notificationType: string; + actionUrl: string; + actionText: string; + }> = []; + + for (const [userId, matchedAlertIds] of userAlertMap) { + const count = matchedAlertIds.size; + const titles = alerts + .filter((a) => matchedAlertIds.has(a.id)) + .map((a) => a.event.title ?? a.event.types[0] ?? "Alert") + .slice(0, 3); + const preview = titles.join(", ") + (count > 3 ? ` +${count - 3} more` : ""); + + notificationData.push({ + userId, + message: `${frequencyLabel} digest (${count}): ${preview}`, + notificationType: "alert_digest", + actionUrl: "/detection", + actionText: "View Alerts", + }); + } + + const result = await context.prisma.notifications.createMany({ + data: notificationData, + }); + + // 3. Send digest emails to users who have email notifications enabled + const allUserIds = [...userAlertMap.keys()]; + const emailUsers = await context.prisma.user.findMany({ + where: { id: { in: allUserIds }, emailNotification: true }, + select: { id: true, name: true, email: true }, + }); + + if (emailUsers.length > 0) { + const emailProvider = await getEmailProvider(); + const emails = emailUsers.map((u) => { + const matchedIds = userAlertMap.get(u.id)!; + const userAlerts = alerts + .filter((a) => matchedIds.has(a.id)) + .map((a) => ({ + title: a.event.title ?? a.event.types[0] ?? "Alert", + description: a.event.description, + url: `${env.FRONTEND_URL}/alerts/${a.id}`, + })); + + const content = alertDigest(u.name, frequency, userAlerts, dashboardUrl); + return { + to: u.email, + subject: content.subject, + textBody: content.textBody, + htmlBody: content.htmlBody, + }; + }); + + void emailProvider.sendBulk(emails).catch((err) => { + console.error("[NOTIFY] Failed to send digest emails:", err); + }); + } + + return result.count; + }, + deleteNotification: async ( _parent: unknown, args: { id: string }, diff --git a/src/schema/typeDefs/mutation.ts b/src/schema/typeDefs/mutation.ts index 657c0e6..f646fd9 100644 --- a/src/schema/typeDefs/mutation.ts +++ b/src/schema/typeDefs/mutation.ts @@ -71,6 +71,15 @@ export const mutationTypeDef = gql` """Create a notification for a user.""" createNotification(input: CreateNotificationInput!): Notification! + """Create notifications for multiple users at once. Returns the count of notifications created.""" + createBulkNotifications(input: CreateBulkNotificationsInput!): Int! + + """Notify all subscribers of a single alert (immediate frequency). Matches on event types and locations.""" + notifyAlertSubscribers(input: AlertNotifyInput!): Int! + + """Send a digest notification for multiple alerts to subscribers of the given frequency (daily/weekly/monthly).""" + notifyAlertDigest(input: AlertDigestInput!): Int! + """Delete a notification.""" deleteNotification(id: String!): Boolean! @@ -296,6 +305,27 @@ export const mutationTypeDef = gql` actionText: String } + input CreateBulkNotificationsInput { + """List of user IDs to notify.""" + userIds: [String!]! + message: String! + notificationType: String! + actionUrl: String + actionText: String + } + + input AlertNotifyInput { + """Alert ID to notify subscribers about (uses immediate frequency).""" + alertId: String! + } + + input AlertDigestInput { + """List of alert IDs to include in the digest.""" + alertIds: [String!]! + """Frequency: daily, weekly, or monthly.""" + frequency: String! + } + input AddFeedbackInput { """Provide exactly one of eventId or signalId.""" eventId: String diff --git a/src/services/messaging/templates.ts b/src/services/messaging/templates.ts index 94d436d..cdbb004 100644 --- a/src/services/messaging/templates.ts +++ b/src/services/messaging/templates.ts @@ -293,3 +293,109 @@ If you did not request this, you can safely ignore this email. Your password wil ), }; } + +/** + * Immediate alert notification — single alert sent to a subscriber. + */ +export function alertNotification( + userName: string, + alertTitle: string, + alertDescription: string | null, + alertUrl: string, +): EmailContent { + const displayName = userName || "there"; + + return { + subject: `Alert: ${alertTitle} — CLEAR Platform`, + + textBody: `Hi ${displayName}, + +A new alert has been published that matches your subscriptions: + +${alertTitle} +${alertDescription ?? ""} + +View the alert: ${alertUrl} + +— The CLEAR Platform Team`, + + htmlBody: wrapHtml( + "New Alert", + `

+ Hi ${displayName}, +

+

+ A new alert has been published that matches your subscriptions: +

+
+

${alertTitle}

+ ${alertDescription ? `

${alertDescription}

` : ""} +
+ ${ctaButton("View Alert", alertUrl)} +
+

+ You received this because of your alert subscriptions. Manage your subscriptions in Settings. +

`, + ), + }; +} + +/** + * Alert digest — personalized list of alerts for a subscriber. + */ +export function alertDigest( + userName: string, + frequency: string, + alertItems: Array<{ title: string; description: string | null; url: string }>, + dashboardUrl: string, +): EmailContent { + const displayName = userName || "there"; + const freqLabel = frequency.charAt(0).toUpperCase() + frequency.slice(1); + const count = alertItems.length; + + const alertListText = alertItems + .map((a, i) => `${i + 1}. ${a.title}${a.description ? ` — ${a.description}` : ""}`) + .join("\n"); + + const alertListHtml = alertItems + .map( + (a) => + ``, + ) + .join(""); + + return { + subject: `${freqLabel} Alert Digest (${count}) — CLEAR Platform`, + + textBody: `Hi ${displayName}, + +Here is your ${frequency} alert digest with ${count} alert${count > 1 ? "s" : ""}: + +${alertListText} + +View all alerts: ${dashboardUrl} + +— The CLEAR Platform Team`, + + htmlBody: wrapHtml( + `${freqLabel} Alert Digest`, + `

+ Hi ${displayName}, +

+

+ Here is your ${frequency} alert digest with ${count} alert${count > 1 ? "s" : ""} matching your subscriptions: +

+ ${alertListHtml} + ${ctaButton("View All Alerts", dashboardUrl)} +
+

+ You received this because of your alert subscriptions. Manage your subscriptions in Settings. +

`, + ), + }; +} From 9f9d5ca5dfbee0d324f0a891cc945434ba6739e5 Mon Sep 17 00:00:00 2001 From: Prajjawalk Date: Thu, 26 Mar 2026 18:01:44 +0530 Subject: [PATCH 14/15] update location resolvers --- prisma/seed.ts | 4 +- scripts/backfill-locations.ts | 235 +++++++++++++++++++++++++++++++ src/resolvers/event.resolver.ts | 60 ++++++-- src/resolvers/signal.resolver.ts | 15 +- src/utils/geo-resolve.ts | 154 +++++++++++++++++++- 5 files changed, 446 insertions(+), 22 deletions(-) create mode 100644 scripts/backfill-locations.ts diff --git a/prisma/seed.ts b/prisma/seed.ts index a109e7f..6ffd77a 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -279,8 +279,8 @@ export async function seedLocations(): Promise { return { id }; } - // Level 0: Country - const sudan = await insertLocation("sudan", "Sudan", 0, "POINT(30.0 15.5)"); + // Level 0: Country (bounding box covering all of Sudan ~8.5°N to 22°N, 21.8°E to 38.6°E) + const sudan = await insertLocation("sudan", "Sudan", 0, "MULTIPOLYGON(((21.8 8.5, 38.6 8.5, 38.6 22.0, 21.8 22.0, 21.8 8.5)))"); // Level 1: States (sequential to ensure parent exists before children reference it) for (const state of SUDAN_STATES) { diff --git a/scripts/backfill-locations.ts b/scripts/backfill-locations.ts new file mode 100644 index 0000000..7b769a1 --- /dev/null +++ b/scripts/backfill-locations.ts @@ -0,0 +1,235 @@ +/** + * Backfill location_id on existing signals and events. + * + * Signals: + * - With lat/lng in raw_data → creates level-4 point location (child of resolved district) + * - Without lat/lng → text-matches title/description against known location names + * + * Events: + * - Single signal point → reuses that signal's location + * - Multiple signal points → creates level-4 convex hull region + * - No signal points → text-matches event title/description + * + * Usage: + * bunx tsx scripts/backfill-locations.ts + * bunx tsx scripts/backfill-locations.ts --dry-run # Preview without writing + */ + +import "dotenv/config"; +import { prisma } from "../src/lib/prisma.js"; +import { createPointLocation, createRegionFromPoints } from "../src/utils/geo-resolve.js"; + +const dryRun = process.argv.includes("--dry-run"); + +interface RawDataWithLocation { + estimatedEventLocation?: { + coordinates?: number[]; + name?: string; + }; +} + +/** Try to find the most granular location whose name appears in the text. */ +async function findLocationByTextMatch(text: string): Promise<{ id: string; name: string; level: number } | null> { + const locations = await prisma.locations.findMany({ + select: { id: true, name: true, level: true }, + orderBy: { level: "desc" }, // most granular first (district > state > country) + }); + for (const loc of locations) { + if (text.includes(loc.name.toLowerCase())) { + return loc; + } + } + return null; +} + +async function backfillSignals() { + console.log("=== Backfilling signal locations ==="); + + const signals = await prisma.signals.findMany({ + where: { locationId: null, originId: null, destinationId: null }, + select: { id: true, rawData: true, title: true, description: true }, + }); + + console.log(`Found ${signals.length} signals with no location`); + + let resolved = 0; + let failed = 0; + + for (const signal of signals) { + const raw = signal.rawData as RawDataWithLocation | null; + const coords = raw?.estimatedEventLocation?.coordinates; + + if (coords && coords.length >= 2) { + // Has coordinates — create a level-4 point location + const lat = coords[0]!; + const lng = coords[1]!; + const locName = raw?.estimatedEventLocation?.name ?? signal.title ?? undefined; + + if (!dryRun) { + const pointLoc = await createPointLocation(prisma, lat, lng, locName); + await prisma.signals.update({ + where: { id: signal.id }, + data: { locationId: pointLoc.id }, + }); + resolved++; + console.log(` ${signal.title ?? signal.id} → ${pointLoc.name} (level 4, point) [${lat}, ${lng}]`); + } else { + resolved++; + console.log(` [DRY] ${signal.title ?? signal.id} → would create point at (${lat}, ${lng})`); + } + } else { + // No coordinates — text match fallback + const textToSearch = `${signal.title ?? ""} ${signal.description ?? ""}`.toLowerCase(); + const matchedLoc = await findLocationByTextMatch(textToSearch); + if (matchedLoc) { + if (!dryRun) { + await prisma.signals.update({ + where: { id: signal.id }, + data: { locationId: matchedLoc.id }, + }); + } + resolved++; + console.log(` ${dryRun ? "[DRY] " : ""}${signal.title ?? signal.id} → ${matchedLoc.name} (level ${matchedLoc.level}) [text match]`); + } else { + const locName = raw?.estimatedEventLocation?.name ?? "no location data"; + failed++; + console.log(` SKIP: ${signal.title ?? signal.id} — ${locName} (no coordinates, no text match)`); + } + } + } + + console.log(`Signals: ${resolved} resolved, ${failed} unresolved out of ${signals.length}`); +} + +async function backfillEvents() { + console.log("\n=== Backfilling event locations ==="); + + const events = await prisma.events.findMany({ + where: { locationId: null, originId: null, destinationId: null }, + select: { + id: true, + title: true, + description: true, + signalEvents: { + select: { + signal: { + select: { locationId: true, originId: true, destinationId: true }, + }, + }, + }, + }, + }); + + console.log(`Found ${events.length} events with no location`); + + let resolved = 0; + let failed = 0; + + for (const event of events) { + // Collect all unique location IDs from linked signals + const locIds = new Set(); + for (const se of event.signalEvents) { + if (se.signal.locationId) locIds.add(se.signal.locationId); + if (se.signal.originId) locIds.add(se.signal.originId); + if (se.signal.destinationId) locIds.add(se.signal.destinationId); + } + + if (locIds.size === 0) { + // No signal locations — try text match on event title/description + const textToSearch = `${event.title ?? ""} ${event.description ?? ""}`.toLowerCase(); + const matchedLoc = await findLocationByTextMatch(textToSearch); + if (matchedLoc) { + if (!dryRun) { + await prisma.events.update({ + where: { id: event.id }, + data: { locationId: matchedLoc.id }, + }); + } + resolved++; + console.log(` ${dryRun ? "[DRY] " : ""}${event.title ?? event.id} → ${matchedLoc.name} (level ${matchedLoc.level}) [text match]`); + } else { + failed++; + console.log(` SKIP: ${event.title ?? event.id} — no signal locations, no text match`); + } + continue; + } + + if (locIds.size === 1) { + // Single location — reuse directly + const locId = [...locIds][0]!; + if (!dryRun) { + await prisma.events.update({ + where: { id: event.id }, + data: { locationId: locId }, + }); + } + resolved++; + console.log(` ${dryRun ? "[DRY] " : ""}${event.title ?? event.id} → reusing signal location ${locId}`); + continue; + } + + // Multiple locations — fetch point geometries and create a convex hull region + const locPoints = await prisma.$queryRaw>` + SELECT ST_Y("geometry"::geometry) as lat, ST_X("geometry"::geometry) as lng + FROM "locations" + WHERE id = ANY(${[...locIds]}::text[]) + AND "geometry" IS NOT NULL + AND ST_GeometryType("geometry"::geometry) = 'ST_Point' + `; + + if (locPoints.length === 0) { + // Signal locations exist but have no point geometry — use first location ID + const locId = [...locIds][0]!; + if (!dryRun) { + await prisma.events.update({ + where: { id: event.id }, + data: { locationId: locId }, + }); + } + resolved++; + console.log(` ${dryRun ? "[DRY] " : ""}${event.title ?? event.id} → fallback to first signal location ${locId}`); + } else if (locPoints.length === 1) { + const locId = [...locIds][0]!; + if (!dryRun) { + await prisma.events.update({ + where: { id: event.id }, + data: { locationId: locId }, + }); + } + resolved++; + console.log(` ${dryRun ? "[DRY] " : ""}${event.title ?? event.id} → single point location`); + } else { + // Multiple points — create convex hull region + if (!dryRun) { + const region = await createRegionFromPoints(prisma, locPoints, event.title ?? undefined); + await prisma.events.update({ + where: { id: event.id }, + data: { locationId: region.id }, + }); + resolved++; + console.log(` ${event.title ?? event.id} → region "${region.name}" (${locPoints.length} points)`); + } else { + resolved++; + console.log(` [DRY] ${event.title ?? event.id} → would create region from ${locPoints.length} points`); + } + } + } + + console.log(`Events: ${resolved} resolved, ${failed} unresolved out of ${events.length}`); +} + +async function main() { + if (dryRun) console.log("*** DRY RUN — no changes will be written ***\n"); + + await backfillSignals(); + await backfillEvents(); + + console.log("\nDone."); +} + +main() + .catch((e) => { + console.error("Backfill failed:", e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/src/resolvers/event.resolver.ts b/src/resolvers/event.resolver.ts index 5bd28ee..2f9c92f 100644 --- a/src/resolvers/event.resolver.ts +++ b/src/resolvers/event.resolver.ts @@ -3,7 +3,7 @@ import type { Context } from "../context.js"; import type { InputJsonValue } from "../generated/prisma/internal/prismaNamespace.js"; import { requireAuth, requireRole } from "../utils/auth-guard.js"; import { resolveTeamMembership } from "../utils/auth-guard.js"; -import { resolveLatLngToLocation, getLocationIdsWithDescendants } from "../utils/geo-resolve.js"; +import { createPointLocation, createRegionFromPoints, getLocationIdsWithDescendants } from "../utils/geo-resolve.js"; import { buildEventLocationFilterForTeam } from "../utils/location-scope.js"; interface CreateEventInput { @@ -115,12 +115,56 @@ export const eventResolvers = { requireRole(context, ["admin", "analyst"]); const { input } = args; - // Resolve lat/lng to a location if no explicit locationId is provided + // Resolve location for the event let locationId = input.locationId; - if (!locationId && input.lat != null && input.lng != null) { - const resolved = await resolveLatLngToLocation(context.prisma, input.lat, input.lng); - if (resolved) { - locationId = resolved.id; + let originId = input.originId; + let destinationId = input.destinationId; + + if (!locationId && !originId && !destinationId) { + if (input.lat != null && input.lng != null) { + // Single lat/lng provided — create a point location + const pointLoc = await createPointLocation( + context.prisma, input.lat, input.lng, input.title ?? undefined, + ); + locationId = pointLoc.id; + } else if (input.signalIds.length > 0) { + // No explicit location — gather point geometries from linked signals + const signalLocations = await context.prisma.signals.findMany({ + where: { id: { in: input.signalIds } }, + select: { locationId: true, originId: true, destinationId: true }, + }); + + // Collect unique location IDs from signals + const locIds = new Set(); + for (const sl of signalLocations) { + if (sl.locationId) locIds.add(sl.locationId); + if (sl.originId) locIds.add(sl.originId); + if (sl.destinationId) locIds.add(sl.destinationId); + } + + if (locIds.size > 0) { + // Fetch point geometries for these locations + const locPoints = await context.prisma.$queryRaw< + Array<{ lat: number; lng: number }> + >` + SELECT ST_Y("geometry"::geometry) as lat, ST_X("geometry"::geometry) as lng + FROM "locations" + WHERE id = ANY(${[...locIds]}::text[]) + AND "geometry" IS NOT NULL + AND ST_GeometryType("geometry"::geometry) = 'ST_Point' + `; + + if (locPoints.length === 1) { + // Single point — reuse the signal's location directly + locationId = [...locIds][0]!; + } else if (locPoints.length > 1) { + // Multiple points — create a convex hull region + const region = await createRegionFromPoints( + context.prisma, locPoints, input.title ?? undefined, + ); + locationId = region.id; + } + } } } @@ -135,8 +179,8 @@ export const eventResolvers = { validTo: new Date(input.validTo), firstSignalCreatedAt: new Date(input.firstSignalCreatedAt), lastSignalCreatedAt: new Date(input.lastSignalCreatedAt), - originId: input.originId, - destinationId: input.destinationId, + originId, + destinationId, locationId, types: input.types, populationAffected: input.populationAffected diff --git a/src/resolvers/signal.resolver.ts b/src/resolvers/signal.resolver.ts index 71a55bb..d21cd7a 100644 --- a/src/resolvers/signal.resolver.ts +++ b/src/resolvers/signal.resolver.ts @@ -3,7 +3,7 @@ import type { Context } from "../context.js"; import type { InputJsonValue } from "../generated/prisma/internal/prismaNamespace.js"; import { requireAuth, requireRole } from "../utils/auth-guard.js"; import { resolveTeamMembership } from "../utils/auth-guard.js"; -import { resolveLatLngToLocation, getLocationIdsWithDescendants } from "../utils/geo-resolve.js"; +import { createPointLocation, getLocationIdsWithDescendants } from "../utils/geo-resolve.js"; import { buildLocationFilterForTeam } from "../utils/location-scope.js"; interface CreateSignalInput { @@ -100,13 +100,16 @@ export const signalResolvers = { }); } - // Resolve lat/lng to a location if no explicit locationId is provided + // Resolve lat/lng to a level-4 point location if no explicit locationId is provided let locationId = input.locationId; if (!locationId && input.lat != null && input.lng != null) { - const resolved = await resolveLatLngToLocation(context.prisma, input.lat, input.lng); - if (resolved) { - locationId = resolved.id; - } + const pointLoc = await createPointLocation( + context.prisma, + input.lat, + input.lng, + input.title ?? undefined, + ); + locationId = pointLoc.id; } return context.prisma.signals.create({ diff --git a/src/utils/geo-resolve.ts b/src/utils/geo-resolve.ts index 7e3d910..78a602e 100644 --- a/src/utils/geo-resolve.ts +++ b/src/utils/geo-resolve.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import type { PrismaClient } from "../generated/prisma/client.js"; interface ResolvedLocation { @@ -7,23 +8,164 @@ interface ResolvedLocation { } /** - * Resolve a lat/lng point to the most granular location in the hierarchy - * using PostGIS ST_Contains. Returns the location with the highest level - * (most specific — district > state > country). + * Resolve a lat/lng point to the most granular existing location in the hierarchy. + * Returns the best match (district > state > country) without creating new entries. */ export async function resolveLatLngToLocation( prisma: PrismaClient, lat: number, lng: number, ): Promise { - const rows = await prisma.$queryRaw` + // Phase 1: Find polygon that contains the point (state/country level) + const containRows = await prisma.$queryRaw` SELECT id, name, level FROM "locations" - WHERE ST_Contains("geometry"::geometry, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)) + WHERE "geometry" IS NOT NULL + AND ST_Contains("geometry"::geometry, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)) ORDER BY level DESC LIMIT 1 `; - return rows[0] ?? null; + if (containRows.length > 0) return containRows[0]!; + + // Phase 2: Find nearest point location within 50km (district level) + const nearbyRows = await prisma.$queryRaw` + SELECT id, name, level + FROM "locations" + WHERE "geometry" IS NOT NULL + AND ST_DWithin("geometry", ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, 50000) + ORDER BY level DESC, ST_Distance("geometry", ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography) ASC + LIMIT 1 + `; + return nearbyRows[0] ?? null; +} + +/** + * Create a level-4 point location for an exact lat/lng, parented to the + * nearest resolved district/state. If an existing level-4 point is within + * 500m, reuse it instead of creating a duplicate. + * + * @param name Human-readable name (e.g., Dataminr location name or generated) + * @returns The created or reused location row + */ +export async function createPointLocation( + prisma: PrismaClient, + lat: number, + lng: number, + name?: string, +): Promise { + // Check for an existing level-4 point within 500m to avoid duplicates + const existing = await prisma.$queryRaw` + SELECT id, name, level + FROM "locations" + WHERE level = 4 + AND "geometry" IS NOT NULL + AND ST_DWithin("geometry", ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, 500) + ORDER BY ST_Distance("geometry", ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography) ASC + LIMIT 1 + `; + if (existing.length > 0) return existing[0]!; + + // Resolve parent location (most granular existing: district > state > country) + const parent = await resolveLatLngToLocation(prisma, lat, lng); + const parentId = parent?.id ?? null; + + // Compute ancestor IDs + const ancestorIds = parentId ? await computeAncestorIds(prisma, parentId) : []; + + const id = randomUUID(); + const locationName = name ?? `Point ${lat.toFixed(4)}, ${lng.toFixed(4)}`; + + await prisma.$executeRaw` + INSERT INTO "locations" ("id", "name", "level", "parent_id", "ancestor_ids", "geometry") + VALUES (${id}, ${locationName}, 4, ${parentId}, ${ancestorIds}, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)) + `; + + console.log(`[createPointLocation] Created "${locationName}" (level 4) → parent: ${parent?.name ?? "none"}`); + + return { id, name: locationName, level: 4 }; +} + +/** + * Create a level-4 region location from multiple signal points. + * Uses ST_ConvexHull to build a polygon around the points, or a single point + * if there's only one. Parented to the most common parent among the points. + */ +export async function createRegionFromPoints( + prisma: PrismaClient, + points: Array<{ lat: number; lng: number }>, + name?: string, +): Promise { + if (points.length === 0) { + throw new Error("Cannot create region from zero points"); + } + + // Single point — delegate to createPointLocation + if (points.length === 1) { + return createPointLocation(prisma, points[0]!.lat, points[0]!.lng, name); + } + + // Build a convex hull from the points + const pointsWkt = points.map((p) => `${p.lng} ${p.lat}`).join(","); + const multipointWkt = "MULTIPOINT(" + pointsWkt + ")"; + + // Find the best parent by resolving the centroid + const avgLat = points.reduce((s, p) => s + p.lat, 0) / points.length; + const avgLng = points.reduce((s, p) => s + p.lng, 0) / points.length; + const parent = await resolveLatLngToLocation(prisma, avgLat, avgLng); + const parentId = parent?.id ?? null; + + const ancestorIds = parentId ? await computeAncestorIds(prisma, parentId) : []; + + const id = randomUUID(); + const regionName = name ?? `Region ${avgLat.toFixed(2)}, ${avgLng.toFixed(2)}`; + + // Find the nearest state-level (level 1) polygon to clip the region against + // Walk up ancestors to find a state, or use the parent directly if it's a state + let clipLocationId: string | null = null; + if (parent && parent.level <= 1) { + clipLocationId = parent.id; + } else if (parentId) { + // Parent is a district (level 2+), find the state ancestor + for (const aid of ancestorIds) { + const ancestor = await prisma.locations.findUnique({ + where: { id: aid }, + select: { id: true, level: true }, + }); + if (ancestor && ancestor.level === 1) { + clipLocationId = ancestor.id; + break; + } + } + } + + if (clipLocationId) { + // Clip the convex hull to the state boundary using ST_Intersection + await prisma.$executeRaw` + INSERT INTO "locations" ("id", "name", "level", "parent_id", "ancestor_ids", "geometry") + SELECT + ${id}, ${regionName}, 4, ${parentId}, ${ancestorIds}::text[], + ST_Intersection( + ST_ConvexHull(ST_GeomFromText(${multipointWkt}, 4326)), + "geometry"::geometry + ) + FROM "locations" + WHERE id = ${clipLocationId} + AND "geometry" IS NOT NULL + `; + } else { + // No state boundary to clip against — use raw convex hull + await prisma.$executeRaw` + INSERT INTO "locations" ("id", "name", "level", "parent_id", "ancestor_ids", "geometry") + VALUES ( + ${id}, ${regionName}, 4, ${parentId}, ${ancestorIds}, + ST_ConvexHull(ST_GeomFromText(${multipointWkt}, 4326)) + ) + `; + } + + console.log(`[createRegionFromPoints] Created "${regionName}" (level 4, ${points.length} points, clipped=${!!clipLocationId}) → parent: ${parent?.name ?? "none"}`); + + return { id, name: regionName, level: 4 }; } /** From b7368f36e629b72563d02f5ce349515e46f74e8e Mon Sep 17 00:00:00 2001 From: Prajjawalk Date: Sat, 28 Mar 2026 21:51:49 +0530 Subject: [PATCH 15/15] Add: alert subscription --- src/resolvers/index.ts | 2 + src/resolvers/notification.resolver.ts | 49 ++------ src/resolvers/subscription.resolver.ts | 153 +++++++++++++++++++++++++ src/schema/typeDefs/mutation.ts | 24 ++++ src/schema/typeDefs/query.ts | 7 ++ src/schema/typeDefs/types/alert.ts | 29 +++++ 6 files changed, 224 insertions(+), 40 deletions(-) create mode 100644 src/resolvers/subscription.resolver.ts diff --git a/src/resolvers/index.ts b/src/resolvers/index.ts index d15bc70..181aa0c 100644 --- a/src/resolvers/index.ts +++ b/src/resolvers/index.ts @@ -15,6 +15,7 @@ import { organisationResolvers } from "./organisation.resolver.js"; import { teamResolvers } from "./team.resolver.js"; import { feedbackResolvers } from "./feedback.resolver.js"; import { invitationResolvers } from "./invitation.resolver.js"; +import { subscriptionResolvers } from "./subscription.resolver.js"; export const resolvers: IResolvers[] = [ scalarResolvers, @@ -33,4 +34,5 @@ export const resolvers: IResolvers[] = [ teamResolvers, feedbackResolvers, invitationResolvers, + subscriptionResolvers, ]; diff --git a/src/resolvers/notification.resolver.ts b/src/resolvers/notification.resolver.ts index 45d9425..0db883c 100644 --- a/src/resolvers/notification.resolver.ts +++ b/src/resolvers/notification.resolver.ts @@ -4,10 +4,7 @@ import type { NotificationStatus, PrismaClient } from "../generated/prisma/clien import { requireAuth, requireRole } from "../utils/auth-guard.js"; import { env } from "../utils/env.js"; import { getEmailProvider } from "../services/messaging/registry.js"; -import { - alertNotification, - alertDigest, -} from "../services/messaging/templates.js"; +import { alertNotification, alertDigest } from "../services/messaging/templates.js"; interface CreateNotificationInput { userId: string; @@ -74,11 +71,7 @@ async function findSubscribers( export const notificationResolvers = { Query: { - notifications: ( - _parent: unknown, - args: { status?: NotificationStatus }, - context: Context, - ) => { + notifications: (_parent: unknown, args: { status?: NotificationStatus }, context: Context) => { const user = requireAuth(context); return context.prisma.notifications.findMany({ where: { @@ -114,7 +107,6 @@ export const notificationResolvers = { }, }); }, - createBulkNotifications: async ( _parent: unknown, args: { input: CreateBulkNotificationsInput }, @@ -135,7 +127,6 @@ export const notificationResolvers = { return result.count; }, - notifyAlertSubscribers: async ( _parent: unknown, args: { input: AlertNotifyInput }, @@ -154,11 +145,9 @@ export const notificationResolvers = { } const event = alert.event; - const eventLocationIds = [ - event.originId, - event.destinationId, - event.locationId, - ].filter((id): id is string => id !== null); + const eventLocationIds = [event.originId, event.destinationId, event.locationId].filter( + (id): id is string => id !== null, + ); const userIds = await findSubscribers( context.prisma, @@ -201,12 +190,7 @@ export const notificationResolvers = { if (emailUsers.length > 0) { const emailProvider = await getEmailProvider(); const emails = emailUsers.map((u) => { - const content = alertNotification( - u.name, - title, - event.description, - alertUrl, - ); + const content = alertNotification(u.name, title, event.description, alertUrl); return { to: u.email, subject: content.subject, @@ -223,7 +207,6 @@ export const notificationResolvers = { return result.count; }, - notifyAlertDigest: async ( _parent: unknown, args: { input: AlertDigestInput }, @@ -389,12 +372,7 @@ export const notificationResolvers = { return result.count; }, - - deleteNotification: async ( - _parent: unknown, - args: { id: string }, - context: Context, - ) => { + deleteNotification: async (_parent: unknown, args: { id: string }, context: Context) => { const user = requireAuth(context); const notification = await context.prisma.notifications.findUnique({ @@ -410,12 +388,7 @@ export const notificationResolvers = { await context.prisma.notifications.delete({ where: { id: args.id } }); return true; }, - - markNotificationRead: async ( - _parent: unknown, - args: { id: string }, - context: Context, - ) => { + markNotificationRead: async (_parent: unknown, args: { id: string }, context: Context) => { const user = requireAuth(context); const notification = await context.prisma.notifications.findUnique({ @@ -433,11 +406,7 @@ export const notificationResolvers = { data: { status: "READ" }, }); }, - markAllNotificationsRead: async ( - _parent: unknown, - _args: unknown, - context: Context, - ) => { + markAllNotificationsRead: async (_parent: unknown, _args: unknown, context: Context) => { const user = requireAuth(context); await context.prisma.notifications.updateMany({ diff --git a/src/resolvers/subscription.resolver.ts b/src/resolvers/subscription.resolver.ts new file mode 100644 index 0000000..2001a8a --- /dev/null +++ b/src/resolvers/subscription.resolver.ts @@ -0,0 +1,153 @@ +import { GraphQLError } from "graphql"; +import type { Context } from "../context.js"; +import type { Channel, Frequency } from "../generated/prisma/client.js"; +import { requireAuth, requireRole } from "../utils/auth-guard.js"; + +interface SubscribeToAlertsInput { + locationId: string; + alertType: string; + channel: Channel; + frequency: Frequency; +} + +interface UpdateAlertSubscriptionInput { + channel?: Channel; + frequency?: Frequency; + active?: boolean; +} + +export const subscriptionResolvers = { + Query: { + myAlertSubscriptions: async (_parent: unknown, _args: unknown, context: Context) => { + const user = requireAuth(context); + return context.prisma.userAlertSubscriptions.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: "desc" }, + }); + }, + + alertSubscriptionsByLocation: async ( + _parent: unknown, + args: { locationId: string }, + context: Context, + ) => { + requireRole(context, ["admin"]); + return context.prisma.userAlertSubscriptions.findMany({ + where: { locationId: args.locationId }, + orderBy: { createdAt: "desc" }, + }); + }, + }, + + Mutation: { + subscribeToAlerts: async ( + _parent: unknown, + args: { input: SubscribeToAlertsInput }, + context: Context, + ) => { + const user = requireAuth(context); + const { input } = args; + + // Verify location exists + const location = await context.prisma.locations.findUnique({ + where: { id: input.locationId }, + }); + if (!location) { + throw new GraphQLError("Location not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + // Check for duplicate subscription + const existing = await context.prisma.userAlertSubscriptions.findFirst({ + where: { + userId: user.id, + locationId: input.locationId, + alertType: input.alertType, + channel: input.channel, + }, + }); + if (existing) { + throw new GraphQLError("You already have a subscription for this type, location, and channel", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + return context.prisma.userAlertSubscriptions.create({ + data: { + userId: user.id, + locationId: input.locationId, + alertType: input.alertType, + channel: input.channel, + frequency: input.frequency, + }, + }); + }, + + updateAlertSubscription: async ( + _parent: unknown, + args: { id: string; input: UpdateAlertSubscriptionInput }, + context: Context, + ) => { + const user = requireAuth(context); + const { id, input } = args; + + const subscription = await context.prisma.userAlertSubscriptions.findUnique({ + where: { id }, + }); + if (!subscription) { + throw new GraphQLError("Subscription not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + if (subscription.userId !== user.id && user.role !== "admin") { + throw new GraphQLError("Not authorized to update this subscription", { + extensions: { code: "FORBIDDEN" }, + }); + } + + return context.prisma.userAlertSubscriptions.update({ + where: { id }, + data: { + channel: input.channel ?? undefined, + frequency: input.frequency ?? undefined, + active: input.active ?? undefined, + }, + }); + }, + + unsubscribeFromAlerts: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + const user = requireAuth(context); + + const subscription = await context.prisma.userAlertSubscriptions.findUnique({ + where: { id: args.id }, + }); + if (!subscription) { + throw new GraphQLError("Subscription not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + if (subscription.userId !== user.id && user.role !== "admin") { + throw new GraphQLError("Not authorized to delete this subscription", { + extensions: { code: "FORBIDDEN" }, + }); + } + + await context.prisma.userAlertSubscriptions.delete({ where: { id: args.id } }); + return true; + }, + }, + + AlertSubscription: { + user: (parent: { userId: string }, _args: unknown, { prisma }: Context) => { + return prisma.user.findUnique({ where: { id: parent.userId } }); + }, + location: (parent: { locationId: string }, _args: unknown, { prisma }: Context) => { + return prisma.locations.findUnique({ where: { id: parent.locationId } }); + }, + }, +}; diff --git a/src/schema/typeDefs/mutation.ts b/src/schema/typeDefs/mutation.ts index f646fd9..757a662 100644 --- a/src/schema/typeDefs/mutation.ts +++ b/src/schema/typeDefs/mutation.ts @@ -169,10 +169,34 @@ export const mutationTypeDef = gql` """Reset password using a token from the reset email.""" resetPassword(token: String!, newPassword: String!): Boolean! + + # ─── Alert Subscriptions ────────────────────────────────────────────────── + """Subscribe to alerts for a specific type and location.""" + subscribeToAlerts(input: SubscribeToAlertsInput!): AlertSubscription! + + """Update an existing alert subscription (channel, frequency, active).""" + updateAlertSubscription(id: String!, input: UpdateAlertSubscriptionInput!): AlertSubscription! + + """Unsubscribe — deletes the subscription.""" + unsubscribeFromAlerts(id: String!): Boolean! } # ─── Input Types ─────────────────────────────────────────────────────────── + input SubscribeToAlertsInput { + locationId: String! + """Disaster/event type (glideNumber from disaster_types, e.g. 'fl', 'eq').""" + alertType: String! + channel: Channel! + frequency: Frequency! + } + + input UpdateAlertSubscriptionInput { + channel: Channel + frequency: Frequency + active: Boolean + } + input InviteUserInput { email: String! organisationId: String! diff --git a/src/schema/typeDefs/query.ts b/src/schema/typeDefs/query.ts index eb6d34a..83c0652 100644 --- a/src/schema/typeDefs/query.ts +++ b/src/schema/typeDefs/query.ts @@ -90,5 +90,12 @@ export const queryTypeDef = gql` """Look up an invitation by token (public — used on accept-invite page).""" invitationByToken(token: String!): InvitationInfo + + # ─── Alert Subscriptions ──────────────────────────────────────────────── + """List the authenticated user's alert subscriptions.""" + myAlertSubscriptions: [AlertSubscription!]! + + """List all alert subscriptions for a location (admin only).""" + alertSubscriptionsByLocation(locationId: String!): [AlertSubscription!]! } `; diff --git a/src/schema/typeDefs/types/alert.ts b/src/schema/typeDefs/types/alert.ts index 4e52972..379d33b 100644 --- a/src/schema/typeDefs/types/alert.ts +++ b/src/schema/typeDefs/types/alert.ts @@ -37,4 +37,33 @@ export const alertTypeDef = gql` validFrom: DateTime! validTo: DateTime! } + + """Notification channel for alert subscriptions.""" + enum Channel { + email + sms + } + + """How often a user receives alert notifications.""" + enum Frequency { + immediately + daily + weekly + monthly + } + + """A user's subscription to alerts of a specific type at a specific location.""" + type AlertSubscription { + id: String! + userId: String! + user: User! + location: Location! + """Disaster/event type to subscribe to (e.g. 'fl' for flood, 'eq' for earthquake).""" + alertType: String! + active: Boolean! + channel: Channel! + frequency: Frequency! + createdAt: DateTime! + updatedAt: DateTime! + } `;