From 94c82ef3b30bfc0ef50003dcd7413524aaf770d4 Mon Sep 17 00:00:00 2001 From: positonic Date: Thu, 19 Mar 2026 19:48:12 +0000 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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 } `;
FieldTypeDescription
idString!
userUser!
eventEvent
signalSignal
ratingInt!Rating from 1 to 5.
textStringOptional textual feedback.
createdAt DateTime!