diff --git a/.claude/settings.json b/.claude/settings.json
index 7165672..6c74543 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -51,7 +51,17 @@
"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:*)",
+ "Bash(git checkout:*)",
+ "Bash(git push:*)",
+ "Bash(gh pr:*)",
+ "Bash(git stash:*)",
+ "Bash(bun run:*)"
]
}
}
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/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/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/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])
diff --git a/scripts/build-docs.ts b/scripts/build-docs.ts
new file mode 100644
index 0000000..d44121f
--- /dev/null
+++ b/scripts/build-docs.ts
@@ -0,0 +1,22 @@
+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));
+
+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}`);
+} catch (error) {
+ console.error("docs build failed:", error);
+ process.exit(1);
+}
diff --git a/src/docs/docs.html b/src/docs/docs.html
new file mode 100644
index 0000000..7258e53
--- /dev/null
+++ b/src/docs/docs.html
@@ -0,0 +1,1567 @@
+
+
+
+
+
+ CLEAR API — Documentation
+
+
+
+
+ ◆ CLEAR API
+
+
+
+
+
+
+
+
+
+
+
Docs › GET STARTED › Introduction
+
Introduction
+
Welcome to the CLEAR API - your gateway to humanitarian intelligence.
+
+
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.
+
+
What You Can Do
+
+ Feature Description
+
+ Signals Access raw data items collected from data sources, with location links and metadata.
+ Events Browse grouped signals forming coherent situations, with location, population, and type data.
+ Alerts View events escalated for notification, delivered to subscribed users.
+ Data Sources Discover the external data feeds (ACLED, FEWS NET, social media monitors) that supply signals.
+ Locations Query a hierarchical geographic tree — countries, states, cities — with PostGIS geometry.
+ Disaster Types Look up disaster classifications with GLIDE numbers.
+ Feature Flags Check runtime feature toggles to adapt your application’s behaviour.
+ API Keys Create 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 } }"}'Copy
+
+
+
+
+
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_hereCopy
+
+
+ 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 } }' }),
+});Copy
+
+
+
+
+
Queries
+
+ Name Returns Description
+
+ me
+ User
+ Returns the currently authenticated user, or null if not signed in.
+
+ users
+ [User!]!
+ List all users.
+
+ user
+ User
+ Look up a user by ID.id: String!
+
+ alerts
+ [Alert!]!
+ 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
+ Alert
+ Look up an alert by ID. Requires authentication. Non-admins can only access alerts within their team scope.id: String!
+
+ signals
+ [Signal!]!
+ 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
+ Signal
+ Look up a signal by ID. Requires authentication. Non-admins can only access signals within their team scope.id: String!
+
+ events
+ [Event!]!
+ 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
+ Event
+ Look up an event by ID. Requires authentication. Non-admins can only access events within their team scope.id: String!
+
+ dataSources
+ [DataSource!]!
+ List all data sources.
+
+ dataSource
+ DataSource
+ Look up a data source by ID.id: String!
+
+ locations
+ [Location!]!
+ List locations, optionally filtered by hierarchy level (0 = country, 1 = state, etc.).level: Int
+
+ location
+ Location
+ Look up a location by ID.id: String!
+
+ notifications
+ [Notification!]!
+ List notifications, optionally filtered by status.status: NotificationStatus
+
+ notification
+ Notification
+ Look up a notification by ID.id: String!
+
+ featureFlags
+ [FeatureFlag!]!
+ List all feature flags.
+
+ featureFlag
+ FeatureFlag
+ Look up a feature flag by its unique key.key: String!
+
+ disasterTypes
+ [DisasterType!]!
+ List all disaster type classifications.
+
+ disasterType
+ DisasterType
+ Look 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.
+
+ organisation
+ Organisation
+ Look up an organisation by ID. Requires membership or global admin.id: String!
+
+ myTeams
+ [Team!]!
+ List teams the authenticated user belongs to.
+
+ team
+ Team
+ Look up a team by ID. Requires membership or global admin.id: String!
+
+
+
+
Mutations
+
+ Name Returns Description
+
+ createApiKey
+ CreateApiKeyPayload!
+ Create a new API key for the authenticated user.input: CreateApiKeyInput!
+
+ revokeApiKey
+ ApiKey!
+ Revoke an API key by ID. Only the key owner or an admin can revoke.id: String!
+
+ requestEmailVerification
+ Boolean!
+ Request an email verification link for the authenticated user.
+
+ verifyEmail
+ Boolean!
+ Verify email using a token from the verification link.token: String!
+
+ updateProfile
+ User!
+ Update the authenticated user's profile and notification preferences.input: UpdateProfileInput!
+
+ createAlert
+ Alert!
+ Create an alert from an event, notifying subscribers.input: CreateAlertInput!
+
+ updateAlert
+ Alert!
+ Update an existing alert.id: String!input: UpdateAlertInput!
+
+ deleteAlert
+ Boolean!
+ Delete an alert.id: String!
+
+ createSignal
+ Signal!
+ Create a signal from a data source.input: CreateSignalInput!
+
+ deleteSignal
+ Boolean!
+ Delete a signal.id: String!
+
+ createEvent
+ Event!
+ Create a new event from signals.input: CreateEventInput!
+
+ updateEvent
+ Event!
+ Update an existing event.id: String!input: UpdateEventInput!
+
+ deleteEvent
+ Boolean!
+ Delete an event.id: String!
+
+ createDataSource
+ DataSource!
+ Create a new data source.input: CreateDataSourceInput!
+
+ updateDataSource
+ DataSource!
+ Update an existing data source.id: String!input: UpdateDataSourceInput!
+
+ deleteDataSource
+ Boolean!
+ Delete a data source.id: String!
+
+ createLocation
+ Location!
+ Create a new location.input: CreateLocationInput!
+
+ updateLocation
+ Location!
+ Update an existing location.id: String!input: UpdateLocationInput!
+
+ deleteLocation
+ Boolean!
+ Delete a location.id: String!
+
+ createNotification
+ Notification!
+ Create a notification for a user.input: CreateNotificationInput!
+
+ deleteNotification
+ Boolean!
+ Delete a notification.id: String!
+
+ markNotificationRead
+ Notification!
+ Mark a notification as read.id: String!
+
+ 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.
Value Description draft— published— archived—
DetectionStatus enum Processing status of a detection (retained for potential future use).
Value Description raw— processed— ignored—
NotificationStatus enum Value Description PENDING— DELIVERED— FAILED— READ—
OrgMemberRole enum Role within an organisation.
Value Description owner— admin— member—
TeamMemberRole enum Role within a team.
Value Description lead— analyst— viewer—
Field Type Description
+ eventId
+ String!
+ The event ID to create an alert from.
+
+ status
+ AlertStatus
+ —
+
Input for creating a new API key.
Field Type Description
+ name
+ String!
+ A descriptive name for this key (e.g. my-app-prod).
+
+ expiresAt
+ DateTime
+ Optional expiration date. Omit for a key that never expires.
+
Field Type Description
+ name
+ String!
+ —
+
+ type
+ String!
+ —
+
+ isActive
+ Boolean
+ —
+
+ baseUrl
+ String
+ —
+
+ infoUrl
+ String
+ —
+
Field Type Description
+ signalIds
+ [String!]!
+ —
+
+ title
+ String
+ —
+
+ description
+ String
+ —
+
+ descriptionSignals
+ JSON
+ —
+
+ validFrom
+ String!
+ —
+
+ validTo
+ String!
+ —
+
+ firstSignalCreatedAt
+ String!
+ —
+
+ lastSignalCreatedAt
+ String!
+ —
+
+ originId
+ String
+ —
+
+ destinationId
+ String
+ —
+
+ locationId
+ String
+ —
+
+ types
+ [String!]!
+ —
+
+ populationAffected
+ String
+ —
+
+ rank
+ Float!
+ —
+
Field Type Description
+ geoId
+ Int
+ —
+
+ osmId
+ String
+ —
+
+ pCode
+ String
+ —
+
+ name
+ String!
+ —
+
+ level
+ Int!
+ —
+
+ parentId
+ String
+ —
+
Field Type Description
+ userId
+ String!
+ —
+
+ message
+ String!
+ —
+
+ notificationType
+ String!
+ —
+
+ actionUrl
+ String
+ —
+
+ actionText
+ String
+ —
+
Fields for creating a new organisation.
Field Type Description
+ name
+ String!
+ Display name for the organisation.
+
+ slug
+ String!
+ URL-friendly identifier. Must be unique.
+
Field Type Description
+ sourceId
+ String!
+ —
+
+ rawData
+ JSON!
+ —
+
+ publishedAt
+ String!
+ —
+
+ collectedAt
+ String
+ —
+
+ url
+ String
+ —
+
+ title
+ String
+ —
+
+ description
+ String
+ —
+
+ originId
+ String
+ —
+
+ destinationId
+ String
+ —
+
+ locationId
+ String
+ —
+
Field Type Description
+ organisationId
+ String!
+ —
+
+ name
+ String!
+ —
+
+ slug
+ String!
+ —
+
+ description
+ String
+ —
+
Field Type Description
+ status
+ AlertStatus
+ —
+
Field Type Description
+ name
+ String
+ —
+
+ type
+ String
+ —
+
+ isActive
+ Boolean
+ —
+
+ baseUrl
+ String
+ —
+
+ infoUrl
+ String
+ —
+
Field Type Description
+ signalIds
+ [String!]
+ —
+
+ title
+ String
+ —
+
+ description
+ String
+ —
+
+ descriptionSignals
+ JSON
+ —
+
+ validFrom
+ String
+ —
+
+ validTo
+ String
+ —
+
+ firstSignalCreatedAt
+ String
+ —
+
+ lastSignalCreatedAt
+ String
+ —
+
+ originId
+ String
+ —
+
+ destinationId
+ String
+ —
+
+ locationId
+ String
+ —
+
+ types
+ [String!]
+ —
+
+ populationAffected
+ String
+ —
+
+ rank
+ Float
+ —
+
Field Type Description
+ geoId
+ Int
+ —
+
+ osmId
+ String
+ —
+
+ pCode
+ String
+ —
+
+ name
+ String
+ —
+
+ level
+ Int
+ —
+
+ parentId
+ String
+ —
+
Fields for updating an existing organisation.
Field Type Description
+ name
+ String
+ New display name.
+
+ slug
+ String
+ New URL-friendly identifier.
+
+ isActive
+ Boolean
+ Set active/inactive status.
+
Field Type Description
+ name
+ String
+ —
+
+ phoneNumber
+ String
+ —
+
+ image
+ String
+ —
+
+ enableInAppNotification
+ Boolean
+ —
+
+ enableEmailNotification
+ Boolean
+ —
+
+ enableSMSNotification
+ Boolean
+ —
+
Field Type Description
+ name
+ String
+ —
+
+ slug
+ String
+ —
+
+ description
+ String
+ —
+
Alert object An alert created from an event, distributed to subscribed users.
Field Type Description
+ id
+ String!
+ —
+
+ event
+ Event!
+ The event this alert was created from.
+
+ status
+ AlertStatus!
+ —
+
+ userAlerts
+ [UserAlert!]!
+ Users who received this alert.
+
ApiKey object A personal API key for programmatic access. The full key is only shown once at creation.
Field Type Description
+ id
+ String!
+ —
+
+ name
+ String!
+ Descriptive name you chose when creating the key.
+
+ prefix
+ String!
+ Short prefix for identification (e.g. sk_live_abc1).
+
+ expiresAt
+ DateTime
+ Optional expiration date. Expired keys are rejected automatically.
+
+ lastUsedAt
+ DateTime
+ When this key was last used to authenticate a request.
+
+ revokedAt
+ DateTime
+ When this key was revoked, if applicable. Revocation is permanent.
+
+ createdAt
+ DateTime!
+ —
+
+ updatedAt
+ DateTime!
+ —
+
A tag linking a user to a comment.
Field Type Description
+ user
+ User!
+ —
+
+ comment
+ UserComment!
+ —
+
CreateApiKeyPayload object Returned only from createApiKey. Contains the full plaintext
+key that will never be retrievable again.
Field Type Description
+ apiKey
+ ApiKey!
+ —
+
+ key
+ String!
+ The full API key. Copy this immediately — it cannot be retrieved later.
+
DataSource object An external data source that feeds signals into the system.
Field Type Description
+ id
+ String!
+ —
+
+ name
+ String!
+ —
+
+ type
+ String!
+ Source type identifier (e.g. satellite, sensor, manual).
+
+ isActive
+ Boolean!
+ —
+
+ baseUrl
+ String
+ Base URL of the data source API.
+
+ infoUrl
+ String
+ URL with more information about this source.
+
+ createdAt
+ DateTime!
+ —
+
+ updatedAt
+ DateTime!
+ —
+
+ signals
+ [Signal!]!
+ Signals collected from this data source.
+
DisasterType object A disaster classification with GLIDE number.
Field Type Description
+ id
+ String!
+ —
+
+ disasterType
+ String!
+ —
+
+ disasterClass
+ String!
+ —
+
+ glideNumber
+ String!
+ —
+
Event object An event grouping related signals into a coherent situation.
Field Type Description
+ id
+ String!
+ —
+
+ title
+ String
+ —
+
+ description
+ String
+ —
+
+ descriptionSignals
+ JSON
+ LLM-generated signal descriptions as JSON.
+
+ validFrom
+ DateTime!
+ —
+
+ validTo
+ DateTime!
+ —
+
+ firstSignalCreatedAt
+ DateTime!
+ —
+
+ lastSignalCreatedAt
+ DateTime!
+ —
+
+ originLocation
+ Location
+ Origin location of the event.
+
+ destinationLocation
+ Location
+ Destination location of the event.
+
+ generalLocation
+ Location
+ General location (when no origin/destination).
+
+ types
+ [String!]!
+ Event type tags.
+
+ populationAffected
+ String
+ Estimated population affected.
+
+ rank
+ Float!
+ —
+
+ 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.
Field Type Description
+ id
+ String!
+ —
+
+ user
+ User!
+ —
+
+ event
+ Event!
+ —
+
+ isSituation
+ Boolean!
+ Whether this has been escalated to a situation.
+
+ validFrom
+ DateTime!
+ —
+
+ validTo
+ DateTime!
+ —
+
FeatureFlag object A feature toggle that controls runtime behavior.
Field Type Description
+ id
+ Int!
+ —
+
+ key
+ String!
+ Unique key used to look up this flag (e.g. dark_mode).
+
+ enabled
+ Boolean!
+ —
+
+ updatedAt
+ DateTime!
+ —
+
Location object A geographic location in a hierarchy (country > state > city, etc.).
Field Type Description
+ id
+ String!
+ —
+
+ geoId
+ Int
+ GeoNames identifier.
+
+ osmId
+ String
+ OpenStreetMap identifier.
+
+ pCode
+ String
+ P-Code identifier.
+
+ name
+ String!
+ —
+
+ level
+ Int!
+ Hierarchy level: 0 = country, 1 = state/province, 2 = city, etc.
+
+ geometry
+ GeoJSON
+ Geometry as GeoJSON (Point or MultiPolygon).
+
+ parent
+ Location
+ Parent location in the hierarchy.
+
+ children
+ [Location!]!
+ Child locations one level below.
+
Notification object Field Type Description
+ id
+ String!
+ —
+
+ user
+ User!
+ —
+
+ message
+ String!
+ —
+
+ notificationType
+ String!
+ —
+
+ actionUrl
+ String
+ —
+
+ actionText
+ String
+ —
+
+ status
+ NotificationStatus!
+ —
+
+ emailNotificationStatus
+ NotificationStatus
+ —
+
+ smsNotificationStatus
+ NotificationStatus
+ —
+
+ createdAt
+ DateTime!
+ —
+
+ updatedAt
+ DateTime!
+ —
+
Organisation object An organisation that owns teams and has members.
Field Type Description
+ id
+ String!
+ —
+
+ name
+ String!
+ —
+
+ slug
+ String!
+ URL-friendly identifier.
+
+ isActive
+ Boolean!
+ Whether this organisation is active. Inactive organisations are hidden from non-admin users.
+
+ createdAt
+ DateTime!
+ When this organisation was created.
+
+ updatedAt
+ DateTime!
+ 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.
Field Type Description
+ id
+ String!
+ —
+
+ userId
+ String!
+ —
+
+ organisationId
+ String!
+ —
+
+ role
+ String!
+ —
+
OrgMember object Links a user to an organisation with an org-level role.
Field Type Description
+ id
+ String!
+ —
+
+ user
+ User!
+ —
+
+ role
+ String!
+ Organisation-level role: owner, admin, or member.
+
+ createdAt
+ DateTime!
+ When this membership was created.
+
Signal object A signal derived from a data source.
Field Type Description
+ id
+ String!
+ —
+
+ source
+ DataSource!
+ The data source this signal was collected from.
+
+ rawData
+ JSON!
+ Original signal payload as JSON.
+
+ publishedAt
+ DateTime!
+ —
+
+ collectedAt
+ DateTime!
+ —
+
+ url
+ String
+ —
+
+ title
+ String
+ —
+
+ description
+ String
+ —
+
+ originLocation
+ Location
+ Origin location of the signal.
+
+ destinationLocation
+ Location
+ Destination location of the signal.
+
+ generalLocation
+ Location
+ General 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.
Field Type Description
+ id
+ String!
+ —
+
+ name
+ String!
+ —
+
+ slug
+ String!
+ URL-friendly identifier, unique within the organisation.
+
+ description
+ String
+ —
+
+ organisation
+ Organisation!
+ The organisation this team belongs to.
+
+ members
+ [TeamMember!]!
+ Members of this team.
+
+ locations
+ [Location!]!
+ Locations this team is scoped to. Empty means global monitoring.
+
+ createdAt
+ DateTime!
+ —
+
+ updatedAt
+ DateTime!
+ —
+
TeamMember object Links a user to a team with a team-level role.
Field Type Description
+ id
+ String!
+ —
+
+ user
+ User!
+ —
+
+ role
+ String!
+ Team-level role: lead, analyst, or viewer.
+
+ createdAt
+ DateTime!
+ —
+
User object A registered user with role-based access.
Field Type Description
+ id
+ String!
+ —
+
+ email
+ String!
+ —
+
+ name
+ String!
+ —
+
+ emailVerified
+ Boolean!
+ —
+
+ phoneNumber
+ String
+ —
+
+ image
+ String
+ —
+
+ role
+ String!
+ User role: viewer, editor, or admin.
+
+ isActive
+ Boolean!
+ —
+
+ enableInAppNotification
+ Boolean!
+ —
+
+ enableEmailNotification
+ Boolean!
+ —
+
+ enableSMSNotification
+ Boolean!
+ —
+
+ createdAt
+ DateTime!
+ —
+
+ updatedAt
+ DateTime!
+ —
+
+ alerts
+ [UserAlert!]!
+ Alerts received by this user.
+
+ notifications
+ [Notification!]!
+ —
+
+ defaultTeam
+ Team
+ The 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.
Field Type Description
+ id
+ String!
+ —
+
+ user
+ User!
+ —
+
+ alert
+ Alert!
+ —
+
+ viewedAt
+ DateTime
+ When the user viewed this alert.
+
A user comment on a signal or event, with reply support.
Field Type Description
+ id
+ String!
+ —
+
+ user
+ User!
+ —
+
+ event
+ Event
+ —
+
+ signal
+ Signal
+ —
+
+ comment
+ String!
+ —
+
+ isCommentReply
+ Boolean!
+ Whether this comment is a reply to another comment.
+
+ repliedToCommentId
+ String
+ ID of the comment being replied to, if any.
+
+ tags
+ [CommentTag!]!
+ Users tagged in this comment.
+
+ createdAt
+ DateTime!
+ —
+
+ updatedAt
+ DateTime!
+ —
+
UserFeedback object User feedback on a signal or event — rating and optional text.
Field Type Description
+ id
+ String!
+ —
+
+ user
+ User!
+ —
+
+ event
+ Event
+ —
+
+ signal
+ Signal
+ —
+
+ rating
+ Int!
+ Rating from 1 to 5.
+
+ text
+ String
+ Optional textual feedback.
+
+ createdAt
+ DateTime!
+ —
+
+ updatedAt
+ DateTime!
+ —
+
+
+
+
+
+
+
+
+
+
+
\ 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/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/resolvers/alert.resolver.ts b/src/resolvers/alert.resolver.ts
index e462011..6254680 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,13 +16,62 @@ 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) => {
- 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 bb4850d..4eea8ed 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,11 +41,53 @@ 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 } });
+ 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/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..29c5a9f
--- /dev/null
+++ b/src/resolvers/organisation.resolver.ts
@@ -0,0 +1,207 @@
+import { GraphQLError } from "graphql";
+import type { Context } from "../context.js";
+import { requireAuth } 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);
+
+ 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({
+ 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);
+
+ try {
+ await context.prisma.organisationUsers.delete({
+ where: {
+ userId_organisationId: {
+ userId: args.userId,
+ organisationId: args.orgId,
+ },
+ },
+ });
+ return true;
+ } catch (error: unknown) {
+ if (
+ error instanceof Error &&
+ "code" in error &&
+ (error as { code: string }).code === "P2025"
+ ) {
+ return false;
+ }
+ throw error;
+ }
+ },
+ },
+
+ 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..6b94cb4 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,11 +20,50 @@ 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 } });
+ 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
new file mode 100644
index 0000000..a49ed8c
--- /dev/null
+++ b/src/resolvers/team.resolver.ts
@@ -0,0 +1,421 @@
+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,
+ );
+
+ 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 (
+ _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,
+ );
+
+ 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 (
+ _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 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" },
+ });
+ }
+
+ // 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({
+ where: { id: user.id },
+ data: { defaultTeamId: args.teamId },
+ });
+
+ return team;
+ },
+ },
+
+ 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..d2f42ab 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: OrgMemberRole): 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: 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: TeamMemberRole!): 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..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."""
- alerts(status: AlertStatus): [Alert!]!
+ """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 all signals."""
- signals: [Signal!]!
+ """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 all events."""
- events: [Event!]!
+ """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."""
@@ -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..f46f165
--- /dev/null
+++ b/src/schema/typeDefs/types/organisation.ts
@@ -0,0 +1,63 @@
+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!
+ """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!]!
+ """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!
+ """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
+ }
+`;
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."""
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
+ >;
+}
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
+ }
+}