diff --git a/.claude/settings.json b/.claude/settings.json index 7165672..811bcc7 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -51,7 +51,22 @@ "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:*)", + "Bash(git fetch:*)", + "Bash(kill 59234)" + ], + "additionalDirectories": [ + "/Users/james/code/clear-mvp" ] } } diff --git a/.dockerignore b/.dockerignore index cba8fdd..4c62e0a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,11 @@ node_modules dist .git .github +.beads +.claude infra + *.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/migrations/20260323165357_add_ancestor_ids/migration.sql b/prisma/migrations/20260323165357_add_ancestor_ids/migration.sql new file mode 100644 index 0000000..fb42dfd --- /dev/null +++ b/prisma/migrations/20260323165357_add_ancestor_ids/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "locations" ADD COLUMN "ancestor_ids" TEXT[]; + +-- CreateIndex +CREATE INDEX "locations_ancestor_ids_idx" ON "locations" USING GIN ("ancestor_ids" array_ops); diff --git a/prisma/migrations/20260324131311_add_invitations/migration.sql b/prisma/migrations/20260324131311_add_invitations/migration.sql new file mode 100644 index 0000000..0deb4ee --- /dev/null +++ b/prisma/migrations/20260324131311_add_invitations/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "invitations" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "organisation_id" TEXT NOT NULL, + "team_id" TEXT, + "role" TEXT NOT NULL DEFAULT 'member', + "team_role" TEXT, + "token" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "accepted_at" TIMESTAMP(3), + "invited_by_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "invitations_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "invitations_token_key" ON "invitations"("token"); + +-- CreateIndex +CREATE INDEX "invitations_email_idx" ON "invitations"("email"); + +-- CreateIndex +CREATE INDEX "invitations_organisation_id_idx" ON "invitations"("organisation_id"); + +-- AddForeignKey +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_organisation_id_fkey" FOREIGN KEY ("organisation_id") REFERENCES "organisations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "teams"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_invited_by_id_fkey" FOREIGN KEY ("invited_by_id") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index be3e7aa..40e11c6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,14 +68,19 @@ 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[] commentTags commentTags[] + invitationsSent invitations[] @relation("InvitedBy") } model session { @@ -121,35 +126,118 @@ model verification { // ––– Organisation –––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– model organisations { - id String @id @default(cuid()) - name String - users organisationUsers[] + 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[] + invitations invitations[] } 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") + invitations invitations[] + + @@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") +} + +// ─── Invitations ───────────────────────────────────────────────────────────── + +model invitations { + id String @id @default(cuid()) + email String + organisationId String @map("organisation_id") + teamId String? @map("team_id") + role String @default("member") // org role: owner | admin | member + teamRole String? @map("team_role") // team role: lead | analyst | viewer + token String @unique + expiresAt DateTime @map("expires_at") + acceptedAt DateTime? @map("accepted_at") + invitedById String @map("invited_by_id") + createdAt DateTime @default(now()) @map("created_at") + + organisation organisations @relation(fields: [organisationId], references: [id], onDelete: Cascade) + team teams? @relation(fields: [teamId], references: [id], onDelete: SetNull) + invitedBy user @relation("InvitedBy", fields: [invitedById], references: [id]) + + @@index([email]) + @@index([organisationId]) + @@map("invitations") +} + // ─── Geography ─────────────────────────────────────────────────────────────── model locations { - id String @id @default(cuid()) - geoId Int? @map("geonames_id") - osmId BigInt? @map("osm_id") - pCode String? @map("p_code") @db.VarChar(50) - geometry Unsupported("geometry(Geometry, 4326)") // use CHECK (GeometryType(geometry) IN ('POINT', 'MULTIPOLYGON')) - name String - level Int - parentId String? @map("parent_id") + id String @id @default(cuid()) + geoId Int? @map("geonames_id") + osmId BigInt? @map("osm_id") + pCode String? @map("p_code") @db.VarChar(50) + geometry Unsupported("geometry(Geometry, 4326)") // use CHECK (GeometryType(geometry) IN ('POINT', 'MULTIPOLYGON')) + name String + level Int + parentId String? @map("parent_id") + ancestorIds String[] @map("ancestor_ids") parent locations? @relation("LocationHierarchy", fields: [parentId], references: [id]) children locations[] @relation("LocationHierarchy") @@ -167,9 +255,11 @@ model locations { eventDestinations events[] @relation("EventDestination") eventLocations events[] @relation("EventLocation") userAlertSubscriptions userAlertSubscriptions[] + teamScopes teamLocations[] @@index([level]) @@index([parentId]) + @@index([ancestorIds(ops: ArrayOps)], type: Gin) } // ─── Data Mining ───────────────────────────────────────────────────────────── diff --git a/prisma/seed.ts b/prisma/seed.ts index 9f05497..6ffd77a 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,8 +1,311 @@ import { randomUUID } from "node:crypto"; import "dotenv/config"; -import { Prisma } from "../src/generated/prisma/client.js"; import { prisma } from "../src/lib/prisma.js"; import { auth } from "../src/lib/auth.js"; +import { env } from "../src/utils/env.js"; + +// ─── Location Seeding (can be run independently) ──────────────────────────── + +// ─── Sudan Location Data ───────────────────────────────────────────────────── + +interface StateData { + key: string; + name: string; + bbox: string; // MULTIPOLYGON WKT + districts: { key: string; name: string; lng: number; lat: number }[]; +} + +const SUDAN_STATES: StateData[] = [ + { + key: "khartoum", name: "Khartoum", + bbox: "MULTIPOLYGON(((31.7 15.19, 34.38 15.19, 34.38 16.63, 31.7 16.63, 31.7 15.19)))", + districts: [ + { key: "khartoumCity", name: "Khartoum City", lng: 32.56, lat: 15.59 }, + { key: "omdurman", name: "Omdurman", lng: 32.48, lat: 15.64 }, + { key: "bahri", name: "Bahri (Khartoum North)", lng: 32.55, lat: 15.65 }, + { key: "jabelAwlia", name: "Jabel Awlia", lng: 32.47, lat: 15.22 }, + { key: "sharqAlNeel", name: "Sharq Al Neel", lng: 32.67, lat: 15.68 }, + { key: "umbadda", name: "Umbadda", lng: 32.38, lat: 15.70 }, + { key: "karrari", name: "Karrari", lng: 32.41, lat: 15.72 }, + ], + }, + { + key: "northDarfur", name: "North Darfur", + bbox: "MULTIPOLYGON(((23.0 13.0, 27.5 13.0, 27.5 20.0, 23.0 20.0, 23.0 13.0)))", + districts: [ + { key: "elFasher", name: "El Fasher", lng: 25.35, lat: 13.63 }, + { key: "kutum", name: "Kutum", lng: 24.67, lat: 14.20 }, + { key: "kebkabiya", name: "Kebkabiya", lng: 24.32, lat: 13.99 }, + { key: "mellit", name: "Mellit", lng: 25.48, lat: 14.78 }, + { key: "umKeddada", name: "Um Keddada", lng: 26.29, lat: 13.60 }, + { key: "tawila", name: "Tawila", lng: 24.85, lat: 13.36 }, + { key: "elLait", name: "El Lait", lng: 25.85, lat: 18.00 }, + { key: "sarafOmra", name: "Saraf Omra", lng: 24.14, lat: 13.11 }, + ], + }, + { + key: "southDarfur", name: "South Darfur", + bbox: "MULTIPOLYGON(((23.5 8.65, 27.5 8.65, 27.5 13.12, 23.5 13.12, 23.5 8.65)))", + districts: [ + { key: "nyala", name: "Nyala", lng: 24.88, lat: 12.05 }, + { key: "edDaein", name: "Ed Daein", lng: 26.13, lat: 11.46 }, + { key: "kass", name: "Kass", lng: 24.26, lat: 12.50 }, + { key: "buram", name: "Buram", lng: 25.72, lat: 9.96 }, + { key: "tullus", name: "Tullus", lng: 26.65, lat: 10.58 }, + { key: "reheidAlBirdi", name: "Reheid Al Birdi", lng: 25.90, lat: 12.30 }, + { key: "marshing", name: "Marshing", lng: 24.42, lat: 12.98 }, + { key: "adila", name: "Adila", lng: 27.18, lat: 11.52 }, + ], + }, + { + key: "westDarfur", name: "West Darfur", + bbox: "MULTIPOLYGON(((21.8 11.0, 23.5 11.0, 23.5 14.0, 21.8 14.0, 21.8 11.0)))", + districts: [ + { key: "elGeneina", name: "El Geneina", lng: 22.45, lat: 13.45 }, + { key: "kulbus", name: "Kulbus", lng: 22.22, lat: 13.88 }, + { key: "habila", name: "Habila (West Darfur)", lng: 22.90, lat: 12.90 }, + { key: "beida", name: "Beida", lng: 22.35, lat: 13.00 }, + { key: "sirba", name: "Sirba", lng: 22.62, lat: 13.22 }, + { key: "jabelMoon", name: "Jabel Moon", lng: 22.15, lat: 13.60 }, + ], + }, + { + key: "centralDarfur", name: "Central Darfur", + bbox: "MULTIPOLYGON(((23.0 11.5, 25.5 11.5, 25.5 14.0, 23.0 14.0, 23.0 11.5)))", + districts: [ + { key: "zalingei", name: "Zalingei", lng: 23.47, lat: 12.91 }, + { key: "nertiti", name: "Nertiti", lng: 24.26, lat: 12.93 }, + { key: "azum", name: "Azum", lng: 23.62, lat: 13.22 }, + { key: "wadiSalih", name: "Wadi Salih", lng: 23.75, lat: 12.15 }, + { key: "mukjar", name: "Mukjar", lng: 23.89, lat: 12.32 }, + { key: "umDukhun", name: "Um Dukhun", lng: 23.40, lat: 11.76 }, + ], + }, + { + key: "eastDarfur", name: "East Darfur", + bbox: "MULTIPOLYGON(((25.5 9.5, 28.0 9.5, 28.0 13.5, 25.5 13.5, 25.5 9.5)))", + districts: [ + { key: "edDaeinEast", name: "Ed Daein (East Darfur)", lng: 26.13, lat: 11.46 }, + { key: "abuKarinka", name: "Abu Karinka", lng: 26.27, lat: 11.90 }, + { key: "assalaya", name: "Assalaya", lng: 25.69, lat: 11.35 }, + { key: "elFirdous", name: "El Firdous", lng: 26.52, lat: 10.35 }, + { key: "sheiria", name: "Sheiria", lng: 27.15, lat: 11.43 }, + { key: "yassin", name: "Yassin", lng: 25.88, lat: 12.10 }, + ], + }, + { + key: "northKordofan", name: "North Kordofan", + bbox: "MULTIPOLYGON(((27.5 12.0, 32.5 12.0, 32.5 16.0, 27.5 16.0, 27.5 12.0)))", + districts: [ + { key: "elObeid", name: "El Obeid", lng: 30.22, lat: 13.18 }, + { key: "umRawaba", name: "Um Rawaba", lng: 31.22, lat: 12.90 }, + { key: "enNahud", name: "En Nahud", lng: 28.43, lat: 12.69 }, + { key: "sheikan", name: "Sheikan", lng: 30.30, lat: 13.30 }, + { key: "bara", name: "Bara", lng: 30.37, lat: 13.70 }, + { key: "sodari", name: "Sodari", lng: 30.55, lat: 14.45 }, + { key: "umDam", name: "Um Dam", lng: 31.45, lat: 13.51 }, + { key: "jabrat", name: "Jabrat El Sheikh", lng: 29.33, lat: 12.95 }, + ], + }, + { + key: "southKordofan", name: "South Kordofan", + bbox: "MULTIPOLYGON(((28.5 9.5, 32.0 9.5, 32.0 12.5, 28.5 12.5, 28.5 9.5)))", + districts: [ + { key: "kadugli", name: "Kadugli", lng: 29.72, lat: 11.01 }, + { key: "dilling", name: "Dilling", lng: 29.66, lat: 12.06 }, + { key: "abuJubaiyha", name: "Abu Jubaiyha", lng: 31.22, lat: 11.60 }, + { key: "rashad", name: "Rashad", lng: 31.05, lat: 11.85 }, + { key: "talodi", name: "Talodi", lng: 30.38, lat: 10.63 }, + { key: "kaduqli", name: "Heiban", lng: 30.12, lat: 11.40 }, + { key: "lagawa", name: "Lagawa", lng: 28.92, lat: 11.35 }, + ], + }, + { + key: "westKordofan", name: "West Kordofan", + bbox: "MULTIPOLYGON(((27.0 10.0, 30.0 10.0, 30.0 13.0, 27.0 13.0, 27.0 10.0)))", + districts: [ + { key: "elFula", name: "El Fula", lng: 28.35, lat: 11.73 }, + { key: "muglad", name: "Muglad", lng: 27.73, lat: 11.04 }, + { key: "abyei", name: "Abyei", lng: 28.44, lat: 9.59 }, + { key: "babanusa", name: "Babanusa", lng: 27.80, lat: 11.33 }, + { key: "ghubaysh", name: "Ghubaysh", lng: 28.60, lat: 12.30 }, + ], + }, + { + key: "blueNile", name: "Blue Nile", + bbox: "MULTIPOLYGON(((33.0 9.5, 35.5 9.5, 35.5 12.5, 33.0 12.5, 33.0 9.5)))", + districts: [ + { key: "edDamazin", name: "Ed Damazin", lng: 34.36, lat: 11.79 }, + { key: "roseires", name: "Roseires", lng: 34.38, lat: 11.85 }, + { key: "kurmuk", name: "Kurmuk", lng: 34.28, lat: 10.55 }, + { key: "bau", name: "Bau", lng: 34.07, lat: 10.96 }, + { key: "geissan", name: "Geissan", lng: 34.42, lat: 10.99 }, + { key: "tadamon", name: "Tadamon", lng: 33.85, lat: 11.28 }, + ], + }, + { + key: "whiteNile", name: "White Nile", + bbox: "MULTIPOLYGON(((31.5 12.0, 33.5 12.0, 33.5 14.5, 31.5 14.5, 31.5 12.0)))", + districts: [ + { key: "rabak", name: "Rabak", lng: 32.74, lat: 13.18 }, + { key: "kosti", name: "Kosti", lng: 32.66, lat: 13.16 }, + { key: "dueim", name: "Ed Dueim", lng: 32.30, lat: 14.00 }, + { key: "tendalti", name: "Tendalti", lng: 32.60, lat: 13.46 }, + { key: "jabalein", name: "Jabalein", lng: 32.95, lat: 12.59 }, + { key: "guli", name: "Guli", lng: 32.25, lat: 12.65 }, + ], + }, + { + key: "sennar", name: "Sennar", + bbox: "MULTIPOLYGON(((32.5 12.0, 35.5 12.0, 35.5 14.0, 32.5 14.0, 32.5 12.0)))", + districts: [ + { key: "sennarCity", name: "Sennar City", lng: 33.60, lat: 13.55 }, + { key: "singa", name: "Singa", lng: 33.93, lat: 13.15 }, + { key: "dinder", name: "Dinder", lng: 35.00, lat: 12.50 }, + { key: "abuHugar", name: "Abu Hugar", lng: 33.33, lat: 13.09 }, + { key: "easternSennar", name: "Eastern Sennar", lng: 34.30, lat: 13.25 }, + { key: "suruj", name: "Suruj", lng: 33.66, lat: 12.70 }, + ], + }, + { + key: "gezira", name: "Gezira", + bbox: "MULTIPOLYGON(((32.5 13.5, 34.5 13.5, 34.5 15.5, 32.5 15.5, 32.5 13.5)))", + districts: [ + { key: "wadMedani", name: "Wad Medani", lng: 33.52, lat: 14.40 }, + { key: "managil", name: "Managil", lng: 32.99, lat: 14.25 }, + { key: "hasaheisa", name: "Hasaheisa", lng: 33.30, lat: 14.75 }, + { key: "kamlin", name: "Kamlin", lng: 32.70, lat: 15.30 }, + { key: "elMesallamiya", name: "El Mesallamiya", lng: 33.60, lat: 14.63 }, + { key: "southGezira", name: "South Gezira", lng: 33.18, lat: 13.90 }, + { key: "umAlQura", name: "Um Al Qura", lng: 33.02, lat: 14.55 }, + ], + }, + { + key: "kassala", name: "Kassala", + bbox: "MULTIPOLYGON(((35.0 14.5, 37.0 14.5, 37.0 17.0, 35.0 17.0, 35.0 14.5)))", + districts: [ + { key: "kassalaCity", name: "Kassala City", lng: 36.40, lat: 15.45 }, + { key: "halfa", name: "New Halfa", lng: 35.60, lat: 15.32 }, + { key: "aroma", name: "Aroma", lng: 36.14, lat: 15.82 }, + { key: "khashm", name: "Khashm El Girba", lng: 35.88, lat: 14.90 }, + { key: "wagerHamid", name: "Wagar", lng: 36.25, lat: 15.56 }, + { key: "telkok", name: "Telkok", lng: 36.50, lat: 15.00 }, + ], + }, + { + key: "redSea", name: "Red Sea", + bbox: "MULTIPOLYGON(((35.5 17.5, 38.6 17.5, 38.6 22.0, 35.5 22.0, 35.5 17.5)))", + districts: [ + { key: "portSudan", name: "Port Sudan", lng: 37.22, lat: 19.62 }, + { key: "suakin", name: "Suakin", lng: 37.33, lat: 19.11 }, + { key: "tokar", name: "Tokar", lng: 37.73, lat: 18.43 }, + { key: "halayib", name: "Halayib", lng: 36.65, lat: 22.19 }, + { key: "sinkat", name: "Sinkat", lng: 36.72, lat: 19.78 }, + { key: "haya", name: "Haya", lng: 36.38, lat: 18.33 }, + ], + }, + { + key: "riverNile", name: "River Nile", + bbox: "MULTIPOLYGON(((31.5 16.5, 35.0 16.5, 35.0 20.0, 31.5 20.0, 31.5 16.5)))", + districts: [ + { key: "atbara", name: "Atbara", lng: 33.98, lat: 17.70 }, + { key: "edDamer", name: "Ed Damer", lng: 33.95, lat: 17.59 }, + { key: "shendi", name: "Shendi", lng: 33.43, lat: 16.68 }, + { key: "berber", name: "Berber", lng: 33.98, lat: 18.02 }, + { key: "abuHamed", name: "Abu Hamed", lng: 33.32, lat: 19.53 }, + { key: "meroe", name: "Meroe", lng: 33.75, lat: 16.94 }, + ], + }, + { + key: "northern", name: "Northern", + bbox: "MULTIPOLYGON(((24.0 18.0, 33.0 18.0, 33.0 22.0, 24.0 22.0, 24.0 18.0)))", + districts: [ + { key: "dongola", name: "Dongola", lng: 30.48, lat: 19.17 }, + { key: "merowe", name: "Merowe", lng: 31.82, lat: 18.49 }, + { key: "wadi", name: "Wadi Halfa", lng: 31.35, lat: 21.80 }, + { key: "delgo", name: "Delgo", lng: 30.45, lat: 20.46 }, + { key: "elGolid", name: "El Golid", lng: 30.12, lat: 18.88 }, + { key: "elDebba", name: "El Debba", lng: 30.95, lat: 18.06 }, + ], + }, + { + key: "gedaref", name: "Gedaref", + bbox: "MULTIPOLYGON(((34.0 12.5, 37.0 12.5, 37.0 15.5, 34.0 15.5, 34.0 12.5)))", + districts: [ + { key: "gedarefCity", name: "Gedaref City", lng: 35.40, lat: 14.03 }, + { key: "elFashaga", name: "El Fashaga", lng: 36.19, lat: 13.28 }, + { key: "elFao", name: "El Fao", lng: 34.47, lat: 13.97 }, + { key: "galabat", name: "Galabat", lng: 36.14, lat: 12.92 }, + { key: "rahad", name: "Eastern Rahad", lng: 35.10, lat: 13.55 }, + { key: "butana", name: "Butana", lng: 34.60, lat: 14.80 }, + { key: "gureisha", name: "Gureisha", lng: 35.90, lat: 13.85 }, + ], + }, +]; + +// ─── Seed Locations ────────────────────────────────────────────────────────── + +/** Seeded location IDs keyed by their data key (e.g. "khartoum", "elFasher") */ +export type LocationMap = Record; + +export async function seedLocations(): Promise { + console.log("Seeding locations..."); + + // Clear existing locations + await prisma.$executeRaw`DELETE FROM "locations"`; + + // Track ancestor chains for each location + const locationAncestors = new Map(); + const result: LocationMap = {}; + + async function insertLocation( + key: string, + name: string, + level: number, + wkt: string, + parentId: string | null = null, + ) { + const id = randomUUID(); + const ancestorIds = parentId + ? [parentId, ...(locationAncestors.get(parentId) ?? [])] + : []; + locationAncestors.set(id, ancestorIds); + + await prisma.$executeRaw` + INSERT INTO "locations" ("id", "name", "level", "parent_id", "ancestor_ids", "geometry") + VALUES (${id}, ${name}, ${level}, ${parentId}, ${ancestorIds}, ST_GeomFromText(${wkt}, 4326)) + `; + result[key] = { id }; + return { id }; + } + + // Level 0: Country (bounding box covering all of Sudan ~8.5°N to 22°N, 21.8°E to 38.6°E) + const sudan = await insertLocation("sudan", "Sudan", 0, "MULTIPOLYGON(((21.8 8.5, 38.6 8.5, 38.6 22.0, 21.8 22.0, 21.8 8.5)))"); + + // Level 1: States (sequential to ensure parent exists before children reference it) + for (const state of SUDAN_STATES) { + const stateResult = await insertLocation(state.key, state.name, 1, state.bbox, sudan.id); + + // Level 2: Districts within each state + for (const district of state.districts) { + await insertLocation( + district.key, + district.name, + 2, + `POINT(${district.lng} ${district.lat})`, + stateResult.id, + ); + } + } + + const stateCount = SUDAN_STATES.length; + const districtCount = SUDAN_STATES.reduce((sum, s) => sum + s.districts.length, 0); + console.log(`Created ${1 + stateCount + districtCount} locations (1 country, ${stateCount} states, ${districtCount} districts) with geographic data`); + + return result; +} + +// ─── Full Seed ─────────────────────────────────────────────────────────────── async function seed() { console.log("Seeding database...\n"); @@ -23,7 +326,7 @@ async function seed() { await prisma.featureFlags.deleteMany(); await prisma.disasterTypes.deleteMany(); await prisma.dataSources.deleteMany(); - await prisma.$executeRaw`DELETE FROM "locations"`; + await prisma.invitations.deleteMany(); await prisma.organisationUsers.deleteMany(); await prisma.organisations.deleteMany(); console.log("Cleared existing data (users, sessions, accounts, and API keys preserved)."); @@ -36,105 +339,48 @@ async function seed() { return signup.user; } - const admin = await getOrCreateUser("Admin User", "admin@clear.dev", "password123"); + const admin = await getOrCreateUser("Admin User", env.ADMIN_EMAIL, env.ADMIN_PASSWORD); + // Ensure admin has global admin role and verified email + await prisma.user.update({ + where: { id: admin.id }, + data: { role: "admin", emailVerified: true }, + }); + const analyst = await getOrCreateUser("Analyst User", "analyst@clear.dev", "password123"); + await prisma.user.update({ where: { id: analyst.id }, data: { emailVerified: true } }); + const viewer = await getOrCreateUser("Viewer User", "viewer@clear.dev", "password123"); + await prisma.user.update({ where: { id: viewer.id }, data: { emailVerified: true } }); - // ─── Organisation & Roles ───────────────────────────────────────────────── + // ─── Organisation ──────────────────────────────────────────────────────── + // Global admin (admin@clear.dev) does NOT need org membership — global role is sufficient. + // Analyst is added as the org owner for demo purposes. const org = await prisma.organisations.create({ - data: { name: "CLEAR Platform" }, + data: { name: "CLEAR Platform", slug: "clear-platform" }, }); - await Promise.all([ - prisma.organisationUsers.create({ - data: { userId: admin.id, organisationId: org.id, role: "admin" }, - }), - prisma.organisationUsers.create({ - data: { userId: analyst.id, organisationId: org.id, role: "analyst" }, - }), - prisma.organisationUsers.create({ - data: { userId: viewer.id, organisationId: org.id, role: "viewer" }, - }), - ]); + await prisma.organisationUsers.create({ + data: { userId: analyst.id, organisationId: org.id, role: "owner" }, + }); console.log( `Created 3 users: admin (${admin.id}), analyst (${analyst.id}), viewer (${viewer.id})`, ); - - // ─── Locations (Sudan hierarchy: Country → State → Locality) ───────────── - async function insertLocation( - id: string, - name: string, - level: number, - wkt: string, - parentId: string | null = null, - ) { - await prisma.$executeRaw` - INSERT INTO "locations" ("id", "name", "level", "parent_id", "geometry") - VALUES (${id}, ${name}, ${level}, ${parentId}, ST_GeomFromText(${wkt}, 4326)) - `; - return { id }; - } - - // Level 0: Country - const sudanId = randomUUID(); - const sudan = await insertLocation(sudanId, "Sudan", 0, "POINT(30.0 15.5)"); - - // Level 1: States - const khartoumId = randomUUID(); - const northDarfurId = randomUUID(); - const southDarfurId = randomUUID(); - const northKordofanId = randomUUID(); - - const [khartoum, northDarfur, southDarfur, _northKordofan] = await Promise.all([ - insertLocation( - khartoumId, - "Khartoum", - 1, - "MULTIPOLYGON(((31.7 15.19, 34.38 15.19, 34.38 16.63, 31.7 16.63, 31.7 15.19)))", - sudan.id, - ), - insertLocation( - northDarfurId, - "North Darfur", - 1, - "MULTIPOLYGON(((23.0 13.0, 27.5 13.0, 27.5 20.0, 23.0 20.0, 23.0 13.0)))", - sudan.id, - ), - insertLocation( - southDarfurId, - "South Darfur", - 1, - "MULTIPOLYGON(((23.5 8.65, 27.5 8.65, 27.5 13.12, 23.5 13.12, 23.5 8.65)))", - sudan.id, - ), - insertLocation( - northKordofanId, - "North Kordofan", - 1, - "MULTIPOLYGON(((27.5 12.0, 32.5 12.0, 32.5 16.0, 27.5 16.0, 27.5 12.0)))", - sudan.id, - ), - ]); - - // Level 2: Localities - const khartoumCityId = randomUUID(); - const omdurmanId = randomUUID(); - const elFasherId = randomUUID(); - const kutumId = randomUUID(); - const nyalaId = randomUUID(); - const elDaeinId = randomUUID(); - - const [khartoumCity, omdurman, elFasher, kutum, nyala, elDaein] = await Promise.all([ - insertLocation(khartoumCityId, "Khartoum City", 2, "POINT(32.56 15.59)", khartoum.id), - insertLocation(omdurmanId, "Omdurman", 2, "POINT(32.48 15.64)", khartoum.id), - insertLocation(elFasherId, "El Fasher", 2, "POINT(25.35 13.63)", northDarfur.id), - insertLocation(kutumId, "Kutum", 2, "POINT(24.67 14.20)", northDarfur.id), - insertLocation(nyalaId, "Nyala", 2, "POINT(24.88 12.05)", southDarfur.id), - insertLocation(elDaeinId, "Ed Daein", 2, "POINT(26.13 11.46)", southDarfur.id), - ]); - - console.log("Created 11 locations (1 country, 4 states, 6 localities) with geographic data"); + console.log(" admin@clear.dev is global admin (no org membership needed)"); + console.log(" analyst@clear.dev is org owner of 'CLEAR Platform'"); + console.log(" viewer@clear.dev has no org membership (invite-only)"); + + const loc = await seedLocations(); + + // Convenience aliases for signal/event seed data + const khartoum = loc.khartoum!; + const northDarfur = loc.northDarfur!; + const southDarfur = loc.southDarfur!; + const khartoumCity = loc.khartoumCity!; + const omdurman = loc.omdurman!; + const elFasher = loc.elFasher!; + const kutum = loc.kutum!; + const nyala = loc.nyala!; // ─── Data Sources ────────────────────────────────────────────────────────── const [dataminr, acled, gdacs, dtm] = await Promise.all([ @@ -598,9 +844,26 @@ async function seed() { console.log(" viewer@clear.dev / password123 (role: viewer)"); } -seed() - .catch((e) => { - console.error("Seed failed:", e); - process.exit(1); - }) - .finally(() => prisma.$disconnect()); +// ─── CLI Entry Point ───────────────────────────────────────────────────────── +// Usage: +// bun run prisma/seed.ts # Full seed (all tables) +// bun run prisma/seed.ts --locations # Seed only locations + +const args = process.argv.slice(2); + +if (args.includes("--locations")) { + seedLocations() + .then(() => console.log("Location seed complete.")) + .catch((e) => { + console.error("Location seed failed:", e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); +} else { + seed() + .catch((e) => { + console.error("Seed failed:", e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); +} diff --git a/scripts/backfill-locations.ts b/scripts/backfill-locations.ts new file mode 100644 index 0000000..7b769a1 --- /dev/null +++ b/scripts/backfill-locations.ts @@ -0,0 +1,235 @@ +/** + * Backfill location_id on existing signals and events. + * + * Signals: + * - With lat/lng in raw_data → creates level-4 point location (child of resolved district) + * - Without lat/lng → text-matches title/description against known location names + * + * Events: + * - Single signal point → reuses that signal's location + * - Multiple signal points → creates level-4 convex hull region + * - No signal points → text-matches event title/description + * + * Usage: + * bunx tsx scripts/backfill-locations.ts + * bunx tsx scripts/backfill-locations.ts --dry-run # Preview without writing + */ + +import "dotenv/config"; +import { prisma } from "../src/lib/prisma.js"; +import { createPointLocation, createRegionFromPoints } from "../src/utils/geo-resolve.js"; + +const dryRun = process.argv.includes("--dry-run"); + +interface RawDataWithLocation { + estimatedEventLocation?: { + coordinates?: number[]; + name?: string; + }; +} + +/** Try to find the most granular location whose name appears in the text. */ +async function findLocationByTextMatch(text: string): Promise<{ id: string; name: string; level: number } | null> { + const locations = await prisma.locations.findMany({ + select: { id: true, name: true, level: true }, + orderBy: { level: "desc" }, // most granular first (district > state > country) + }); + for (const loc of locations) { + if (text.includes(loc.name.toLowerCase())) { + return loc; + } + } + return null; +} + +async function backfillSignals() { + console.log("=== Backfilling signal locations ==="); + + const signals = await prisma.signals.findMany({ + where: { locationId: null, originId: null, destinationId: null }, + select: { id: true, rawData: true, title: true, description: true }, + }); + + console.log(`Found ${signals.length} signals with no location`); + + let resolved = 0; + let failed = 0; + + for (const signal of signals) { + const raw = signal.rawData as RawDataWithLocation | null; + const coords = raw?.estimatedEventLocation?.coordinates; + + if (coords && coords.length >= 2) { + // Has coordinates — create a level-4 point location + const lat = coords[0]!; + const lng = coords[1]!; + const locName = raw?.estimatedEventLocation?.name ?? signal.title ?? undefined; + + if (!dryRun) { + const pointLoc = await createPointLocation(prisma, lat, lng, locName); + await prisma.signals.update({ + where: { id: signal.id }, + data: { locationId: pointLoc.id }, + }); + resolved++; + console.log(` ${signal.title ?? signal.id} → ${pointLoc.name} (level 4, point) [${lat}, ${lng}]`); + } else { + resolved++; + console.log(` [DRY] ${signal.title ?? signal.id} → would create point at (${lat}, ${lng})`); + } + } else { + // No coordinates — text match fallback + const textToSearch = `${signal.title ?? ""} ${signal.description ?? ""}`.toLowerCase(); + const matchedLoc = await findLocationByTextMatch(textToSearch); + if (matchedLoc) { + if (!dryRun) { + await prisma.signals.update({ + where: { id: signal.id }, + data: { locationId: matchedLoc.id }, + }); + } + resolved++; + console.log(` ${dryRun ? "[DRY] " : ""}${signal.title ?? signal.id} → ${matchedLoc.name} (level ${matchedLoc.level}) [text match]`); + } else { + const locName = raw?.estimatedEventLocation?.name ?? "no location data"; + failed++; + console.log(` SKIP: ${signal.title ?? signal.id} — ${locName} (no coordinates, no text match)`); + } + } + } + + console.log(`Signals: ${resolved} resolved, ${failed} unresolved out of ${signals.length}`); +} + +async function backfillEvents() { + console.log("\n=== Backfilling event locations ==="); + + const events = await prisma.events.findMany({ + where: { locationId: null, originId: null, destinationId: null }, + select: { + id: true, + title: true, + description: true, + signalEvents: { + select: { + signal: { + select: { locationId: true, originId: true, destinationId: true }, + }, + }, + }, + }, + }); + + console.log(`Found ${events.length} events with no location`); + + let resolved = 0; + let failed = 0; + + for (const event of events) { + // Collect all unique location IDs from linked signals + const locIds = new Set(); + for (const se of event.signalEvents) { + if (se.signal.locationId) locIds.add(se.signal.locationId); + if (se.signal.originId) locIds.add(se.signal.originId); + if (se.signal.destinationId) locIds.add(se.signal.destinationId); + } + + if (locIds.size === 0) { + // No signal locations — try text match on event title/description + const textToSearch = `${event.title ?? ""} ${event.description ?? ""}`.toLowerCase(); + const matchedLoc = await findLocationByTextMatch(textToSearch); + if (matchedLoc) { + if (!dryRun) { + await prisma.events.update({ + where: { id: event.id }, + data: { locationId: matchedLoc.id }, + }); + } + resolved++; + console.log(` ${dryRun ? "[DRY] " : ""}${event.title ?? event.id} → ${matchedLoc.name} (level ${matchedLoc.level}) [text match]`); + } else { + failed++; + console.log(` SKIP: ${event.title ?? event.id} — no signal locations, no text match`); + } + continue; + } + + if (locIds.size === 1) { + // Single location — reuse directly + const locId = [...locIds][0]!; + if (!dryRun) { + await prisma.events.update({ + where: { id: event.id }, + data: { locationId: locId }, + }); + } + resolved++; + console.log(` ${dryRun ? "[DRY] " : ""}${event.title ?? event.id} → reusing signal location ${locId}`); + continue; + } + + // Multiple locations — fetch point geometries and create a convex hull region + const locPoints = await prisma.$queryRaw>` + SELECT ST_Y("geometry"::geometry) as lat, ST_X("geometry"::geometry) as lng + FROM "locations" + WHERE id = ANY(${[...locIds]}::text[]) + AND "geometry" IS NOT NULL + AND ST_GeometryType("geometry"::geometry) = 'ST_Point' + `; + + if (locPoints.length === 0) { + // Signal locations exist but have no point geometry — use first location ID + const locId = [...locIds][0]!; + if (!dryRun) { + await prisma.events.update({ + where: { id: event.id }, + data: { locationId: locId }, + }); + } + resolved++; + console.log(` ${dryRun ? "[DRY] " : ""}${event.title ?? event.id} → fallback to first signal location ${locId}`); + } else if (locPoints.length === 1) { + const locId = [...locIds][0]!; + if (!dryRun) { + await prisma.events.update({ + where: { id: event.id }, + data: { locationId: locId }, + }); + } + resolved++; + console.log(` ${dryRun ? "[DRY] " : ""}${event.title ?? event.id} → single point location`); + } else { + // Multiple points — create convex hull region + if (!dryRun) { + const region = await createRegionFromPoints(prisma, locPoints, event.title ?? undefined); + await prisma.events.update({ + where: { id: event.id }, + data: { locationId: region.id }, + }); + resolved++; + console.log(` ${event.title ?? event.id} → region "${region.name}" (${locPoints.length} points)`); + } else { + resolved++; + console.log(` [DRY] ${event.title ?? event.id} → would create region from ${locPoints.length} points`); + } + } + } + + console.log(`Events: ${resolved} resolved, ${failed} unresolved out of ${events.length}`); +} + +async function main() { + if (dryRun) console.log("*** DRY RUN — no changes will be written ***\n"); + + await backfillSignals(); + await backfillEvents(); + + console.log("\nDone."); +} + +main() + .catch((e) => { + console.error("Backfill failed:", e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/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 + + + + + +
+ + + + +
+
+

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

+ + + + + + + + + + + + +
FeatureDescription
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.
+
+ +
+

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. Requires authentication. Admins may omit teamId to list all; non-admins must provide a teamId for a team they belong to.
status: AlertStatusteamId: String
alertAlertLook 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
signalSignalLook 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
eventEventLook 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.
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!
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!
+ +

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 an alert from an event, notifying subscribers.
input: CreateAlertInput!
updateAlertAlert!Update an existing alert.
id: String!input: UpdateAlertInput!
deleteAlertBoolean!Delete an alert.
id: String!
createSignalSignal!Create a signal from a data source.
input: CreateSignalInput!
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.
createOrganisationOrganisation!Create a new organisation. The creator becomes the owner.
input: CreateOrganisationInput!
updateOrganisationOrganisation!Update an existing organisation. Requires org owner or admin.
id: String!input: UpdateOrganisationInput!
addOrgMemberOrgMember!Add a member to an organisation.
orgId: String!userId: String!role: OrgMemberRole
removeOrgMemberBoolean!Remove a member from an organisation.
orgId: String!userId: String!
createTeamTeam!Create a new team within an organisation. Requires org admin or owner.
input: CreateTeamInput!
updateTeamTeam!Update an existing team.
id: String!input: UpdateTeamInput!
deleteTeamBoolean!Delete a team.
id: String!
addTeamMemberTeamMember!Add a member to a team.
teamId: String!userId: String!role: TeamMemberRole
removeTeamMemberBoolean!Remove a member from a team.
teamId: String!userId: String!
updateTeamMemberRoleTeamMember!Update a team member's role.
teamId: String!userId: String!role: TeamMemberRole!
setTeamLocationsTeam!Set the locations a team is scoped to. Replaces all existing locations.
teamId: String!locationIds: [String!]!
setDefaultTeamTeam!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 (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
eventIdString!The event ID to create an alert from.
statusAlertStatus

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

CreateEventInput input

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
signalIds[String!]!
titleString
descriptionString
descriptionSignalsJSON
validFromString!
validToString!
firstSignalCreatedAtString!
lastSignalCreatedAtString!
originIdString
destinationIdString
locationIdString
types[String!]!
populationAffectedString
rankFloat!

CreateLocationInput input

+ + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
geoIdInt
osmIdString
pCodeString
nameString!
levelInt!
parentIdString

CreateNotificationInput input

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

CreateOrganisationInput input

Fields for creating a new organisation.

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

CreateSignalInput input

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
sourceIdString!
rawDataJSON!
publishedAtString!
collectedAtString
urlString
titleString
descriptionString
originIdString
destinationIdString
locationIdString

CreateTeamInput input

+ + + + + + + + + + + + + + + +
FieldTypeDescription
organisationIdString!
nameString!
slugString!
descriptionString

UpdateAlertInput input

+ + + +
FieldTypeDescription
statusAlertStatus

UpdateDataSourceInput input

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

UpdateEventInput input

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
signalIds[String!]
titleString
descriptionString
descriptionSignalsJSON
validFromString
validToString
firstSignalCreatedAtString
lastSignalCreatedAtString
originIdString
destinationIdString
locationIdString
types[String!]
populationAffectedString
rankFloat

UpdateLocationInput input

+ + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
geoIdInt
osmIdString
pCodeString
nameString
levelInt
parentIdString

UpdateOrganisationInput input

Fields for updating an existing organisation.

+ + + + + + + + + + + +
FieldTypeDescription
nameStringNew display name.
slugStringNew URL-friendly identifier.
isActiveBooleanSet active/inactive status.

UpdateProfileInput input

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

UpdateTeamInput input

+ + + + + + + + + + + +
FieldTypeDescription
nameString
slugString
descriptionString

Alert object

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

+ + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
eventEvent!The event this alert was created from.
statusAlertStatus!
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.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
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!

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.

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

DataSource object

An external data source that feeds signals 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!
signals[Signal!]!Signals collected from this data source.

DisasterType object

A disaster classification with GLIDE number.

+ + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
disasterTypeString!
disasterClassString!
glideNumberString!

Event object

An event grouping related signals into a coherent situation.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
titleString
descriptionString
descriptionSignalsJSONLLM-generated signal descriptions as JSON.
validFromDateTime!
validToDateTime!
firstSignalCreatedAtDateTime!
lastSignalCreatedAtDateTime!
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!
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
idString!
userUser!
eventEvent!
isSituationBoolean!Whether this has been escalated to a situation.
validFromDateTime!
validToDateTime!

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!
geoIdIntGeoNames identifier.
osmIdStringOpenStreetMap identifier.
pCodeStringP-Code identifier.
nameString!
levelInt!Hierarchy level: 0 = country, 1 = state/province, 2 = city, etc.
geometryGeoJSONGeometry as GeoJSON (Point or MultiPolygon).
parentLocationParent location in the hierarchy.
children[Location!]!Child locations one level below.

Notification object

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

Organisation object

An organisation that owns teams and has members.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
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!
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.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
emailString!
nameString!
emailVerifiedBoolean!
phoneNumberString
imageString
roleString!User role: viewer, editor, or admin.
isActiveBoolean!
enableInAppNotificationBoolean!
enableEmailNotificationBoolean!
enableSMSNotificationBoolean!
createdAtDateTime!
updatedAtDateTime!
alerts[UserAlert!]!Alerts received by this user.
notifications[Notification!]!
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.

+ + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
userUser!
alertAlert!
viewedAtDateTimeWhen the user viewed this alert.

UserComment object

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

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
userUser!
eventEvent
signalSignal
commentString!
isCommentReplyBoolean!Whether this comment is a reply to another comment.
repliedToCommentIdStringID 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.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
idString!
userUser!
eventEvent
signalSignal
ratingInt!Rating from 1 to 5.
textStringOptional textual feedback.
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/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..d0b0546 100644 --- a/src/resolvers/alert.resolver.ts +++ b/src/resolvers/alert.resolver.ts @@ -1,7 +1,13 @@ 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 { getLocationIdsWithDescendants } from "../utils/geo-resolve.js"; +import { buildEventLocationFilterForTeam } from "../utils/location-scope.js"; +import { env } from "../utils/env.js"; +import { getEmailProvider } from "../services/messaging/registry.js"; +import { alertNotification } from "../services/messaging/templates.js"; interface CreateAlertInput { eventId: string; @@ -14,13 +20,82 @@ 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 } }); + alertsByLocation: async ( + _parent: unknown, + args: { locationId: string; status?: AlertStatus }, + context: Context, + ) => { + requireAuth(context); + const locationIds = await getLocationIdsWithDescendants(context.prisma, args.locationId); + return context.prisma.alerts.findMany({ + where: { + ...(args.status ? { status: args.status } : {}), + event: { + OR: [ + { originId: { in: locationIds } }, + { destinationId: { in: locationIds } }, + { locationId: { in: locationIds } }, + ], + }, + }, + }); + }, + alert: async (_parent: unknown, args: { id: string }, context: Context) => { + const user = requireAuth(context); + const alert = await context.prisma.alerts.findUnique({ + 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: { @@ -50,7 +125,7 @@ export const alertResolvers = { }, }); - // Find subscribers matching the event's types and locations + // Fan out notifications to immediate subscribers const eventLocationIds = [ event.originId, event.destinationId, @@ -58,17 +133,34 @@ export const alertResolvers = { ].filter((id): id is string => id !== null); if (eventLocationIds.length > 0 && event.types.length > 0) { + // Expand locations to include ancestors so country-level subscriptions + // match district-level alerts + const allLocationIds = new Set(eventLocationIds); + const locations = await context.prisma.locations.findMany({ + where: { id: { in: eventLocationIds } }, + select: { ancestorIds: true }, + }); + for (const loc of locations) { + for (const aid of loc.ancestorIds) allLocationIds.add(aid); + } + const subscriptions = await context.prisma.userAlertSubscriptions.findMany({ where: { active: true, + frequency: "immediately", alertType: { in: event.types }, - locationId: { in: eventLocationIds }, + locationId: { in: [...allLocationIds] }, }, + select: { userId: true }, }); - // Create userAlerts entries for each unique subscriber const uniqueUserIds = [...new Set(subscriptions.map((s) => s.userId))]; + if (uniqueUserIds.length > 0) { + const title = event.title ?? event.types[0] ?? "Alert"; + const alertUrl = `${env.FRONTEND_URL}/alerts/${alert.id}`; + + // 1. Populate userAlerts join table await context.prisma.userAlerts.createMany({ data: uniqueUserIds.map((userId) => ({ userId, @@ -76,6 +168,44 @@ export const alertResolvers = { })), skipDuplicates: true, }); + + // 2. Create in-app notifications + await context.prisma.notifications.createMany({ + data: uniqueUserIds.map((userId) => ({ + userId, + message: `New alert: ${title}`, + notificationType: "alert", + actionUrl: `/alerts/${alert.id}`, + actionText: "View Alert", + })), + }); + + // 3. Send email notifications (fire-and-forget) + const emailUsers = await context.prisma.user.findMany({ + where: { id: { in: uniqueUserIds }, emailNotification: true }, + select: { name: true, email: true }, + }); + + if (emailUsers.length > 0) { + void (async () => { + try { + const emailProvider = await getEmailProvider(); + await emailProvider.sendBulk( + emailUsers.map((u) => { + const content = alertNotification(u.name, title, event.description, alertUrl); + return { + to: u.email, + subject: content.subject, + textBody: content.textBody, + htmlBody: content.htmlBody, + }; + }), + ); + } catch (err) { + console.error("[createAlert] Failed to send alert emails:", err); + } + })(); + } } } diff --git a/src/resolvers/auth.resolver.ts b/src/resolvers/auth.resolver.ts index e054e02..cf5f215 100644 --- a/src/resolvers/auth.resolver.ts +++ b/src/resolvers/auth.resolver.ts @@ -120,5 +120,111 @@ export const authResolvers = { return true; }, + + requestPasswordReset: async ( + _parent: unknown, + args: { email: string }, + context: Context, + ) => { + // Always return true to prevent email enumeration + const user = await context.prisma.user.findUnique({ + where: { email: args.email }, + }); + + if (!user) return true; + + // Throttle check + const identifier = `password-reset:${args.email}`; + const recent = await context.prisma.verification.findFirst({ + where: { identifier }, + orderBy: { createdAt: "desc" }, + }); + + if (recent?.createdAt && Date.now() - recent.createdAt.getTime() < THROTTLE_MS) { + // Silently succeed — don't reveal throttle to client + return true; + } + + // Clean up old tokens + await context.prisma.verification.deleteMany({ where: { identifier } }); + + // Create reset token (1 hour expiry) + const token = randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + + await context.prisma.verification.create({ + data: { identifier, value: token, expiresAt }, + }); + + // Send password reset email + const resetUrl = `${env.FRONTEND_URL}/auth/reset-password?token=${token}`; + const emailContent = templates.passwordReset(user.name, resetUrl); + + try { + const provider = await getEmailProvider(); + await provider.send({ + to: args.email, + subject: emailContent.subject, + textBody: emailContent.textBody, + htmlBody: emailContent.htmlBody, + }); + } catch (error) { + console.error("[AUTH] Failed to send password reset email:", error instanceof Error ? error.message : error); + // Don't throw — silently fail to prevent info leakage + } + + return true; + }, + + resetPassword: async ( + _parent: unknown, + args: { token: string; newPassword: string }, + context: Context, + ) => { + if (args.newPassword.length < 8) { + throw new GraphQLError("Password must be at least 8 characters", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + const verification = await context.prisma.verification.findFirst({ + where: { + value: args.token, + identifier: { startsWith: "password-reset:" }, + expiresAt: { gt: new Date() }, + }, + }); + + if (!verification) { + throw new GraphQLError("Invalid or expired reset token", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + const email = verification.identifier.replace("password-reset:", ""); + const user = await context.prisma.user.findUnique({ where: { email } }); + + if (!user) { + throw new GraphQLError("User not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + // Hash new password using Better Auth's built-in hasher + const { hashPassword } = await import("better-auth/crypto"); + const hashedPassword = await hashPassword(args.newPassword); + + await context.prisma.account.updateMany({ + where: { userId: user.id, providerId: "credential" }, + data: { password: hashedPassword }, + }); + + // Clean up verification token + await context.prisma.verification.delete({ + where: { id: verification.id }, + }); + + return true; + }, }, }; diff --git a/src/resolvers/event.resolver.ts b/src/resolvers/event.resolver.ts index bb4850d..2f9c92f 100644 --- a/src/resolvers/event.resolver.ts +++ b/src/resolvers/event.resolver.ts @@ -1,7 +1,10 @@ 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 { createPointLocation, createRegionFromPoints, getLocationIdsWithDescendants } from "../utils/geo-resolve.js"; +import { buildEventLocationFilterForTeam } from "../utils/location-scope.js"; interface CreateEventInput { title?: string; @@ -18,6 +21,8 @@ interface CreateEventInput { populationAffected?: string; rank: number; signalIds: string[]; + lat?: number; + lng?: number; } interface UpdateEventInput { @@ -39,11 +44,66 @@ 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 } }); + eventsByLocation: async (_parent: unknown, args: { locationId: string }, context: Context) => { + requireAuth(context); + const locationIds = await getLocationIdsWithDescendants(context.prisma, args.locationId); + return context.prisma.events.findMany({ + where: { + OR: [ + { originId: { in: locationIds } }, + { destinationId: { in: locationIds } }, + { locationId: { in: locationIds } }, + ], + }, + }); + }, + event: async (_parent: unknown, args: { id: string }, context: Context) => { + const user = requireAuth(context); + const event = await context.prisma.events.findUnique({ where: { id: args.id } }); + 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: { @@ -55,6 +115,59 @@ export const eventResolvers = { requireRole(context, ["admin", "analyst"]); const { input } = args; + // Resolve location for the event + let locationId = input.locationId; + let originId = input.originId; + let destinationId = input.destinationId; + + if (!locationId && !originId && !destinationId) { + if (input.lat != null && input.lng != null) { + // Single lat/lng provided — create a point location + const pointLoc = await createPointLocation( + context.prisma, input.lat, input.lng, input.title ?? undefined, + ); + locationId = pointLoc.id; + } else if (input.signalIds.length > 0) { + // No explicit location — gather point geometries from linked signals + const signalLocations = await context.prisma.signals.findMany({ + where: { id: { in: input.signalIds } }, + select: { locationId: true, originId: true, destinationId: true }, + }); + + // Collect unique location IDs from signals + const locIds = new Set(); + for (const sl of signalLocations) { + if (sl.locationId) locIds.add(sl.locationId); + if (sl.originId) locIds.add(sl.originId); + if (sl.destinationId) locIds.add(sl.destinationId); + } + + if (locIds.size > 0) { + // Fetch point geometries for these locations + const locPoints = await context.prisma.$queryRaw< + Array<{ lat: number; lng: number }> + >` + SELECT ST_Y("geometry"::geometry) as lat, ST_X("geometry"::geometry) as lng + FROM "locations" + WHERE id = ANY(${[...locIds]}::text[]) + AND "geometry" IS NOT NULL + AND ST_GeometryType("geometry"::geometry) = 'ST_Point' + `; + + if (locPoints.length === 1) { + // Single point — reuse the signal's location directly + locationId = [...locIds][0]!; + } else if (locPoints.length > 1) { + // Multiple points — create a convex hull region + const region = await createRegionFromPoints( + context.prisma, locPoints, input.title ?? undefined, + ); + locationId = region.id; + } + } + } + } + const event = await context.prisma.events.create({ data: { title: input.title, @@ -66,9 +179,9 @@ export const eventResolvers = { validTo: new Date(input.validTo), firstSignalCreatedAt: new Date(input.firstSignalCreatedAt), lastSignalCreatedAt: new Date(input.lastSignalCreatedAt), - originId: input.originId, - destinationId: input.destinationId, - locationId: input.locationId, + originId, + destinationId, + locationId, types: input.types, populationAffected: input.populationAffected ? BigInt(input.populationAffected) diff --git a/src/resolvers/feedback.resolver.ts b/src/resolvers/feedback.resolver.ts new file mode 100644 index 0000000..5fd0cc6 --- /dev/null +++ b/src/resolvers/feedback.resolver.ts @@ -0,0 +1,266 @@ +import { GraphQLError } from "graphql"; +import type { Context } from "../context.js"; +import { requireAuth } from "../utils/auth-guard.js"; + +// ─── Interfaces ────────────────────────────────────────────────────────────── + +interface AddFeedbackInput { + eventId?: string; + signalId?: string; + rating: number; + text?: string; +} + +interface AddCommentInput { + eventId?: string; + signalId?: string; + comment: string; + tagUserIds?: string[]; +} + +interface ReplyToCommentInput { + repliedToCommentId: string; + comment: string; + tagUserIds?: string[]; +} + +// ─── Resolver ──────────────────────────────────────────────────────────────── + +export const feedbackResolvers = { + Mutation: { + addFeedback: async ( + _parent: unknown, + args: { input: AddFeedbackInput }, + context: Context, + ) => { + const user = requireAuth(context); + const { eventId, signalId, rating, text } = args.input; + + if (!eventId && !signalId) { + throw new GraphQLError("Provide either eventId or signalId", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + if (eventId && signalId) { + throw new GraphQLError("Provide only one of eventId or signalId, not both", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + if (rating < 1 || rating > 5) { + throw new GraphQLError("Rating must be between 1 and 5", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + return context.prisma.userFeedbacks.create({ + data: { + userId: user.id, + eventId: eventId ?? null, + signalId: signalId ?? null, + rating, + text: text ?? null, + }, + }); + }, + + deleteFeedback: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + const user = requireAuth(context); + + const feedback = await context.prisma.userFeedbacks.findUnique({ + where: { id: args.id }, + }); + if (!feedback) { + throw new GraphQLError("Feedback not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + if (feedback.userId !== user.id && user.role !== "admin") { + throw new GraphQLError("You can only delete your own feedback", { + extensions: { code: "FORBIDDEN" }, + }); + } + + await context.prisma.userFeedbacks.delete({ where: { id: args.id } }); + return true; + }, + + addComment: async ( + _parent: unknown, + args: { input: AddCommentInput }, + context: Context, + ) => { + const user = requireAuth(context); + const { eventId, signalId, comment, tagUserIds } = args.input; + + if (!eventId && !signalId) { + throw new GraphQLError("Provide either eventId or signalId", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + if (eventId && signalId) { + throw new GraphQLError("Provide only one of eventId or signalId, not both", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + const created = await context.prisma.userComments.create({ + data: { + userId: user.id, + eventId: eventId ?? null, + signalId: signalId ?? null, + comment, + isCommentReply: false, + }, + }); + + if (tagUserIds?.length) { + await context.prisma.commentTags.createMany({ + data: tagUserIds.map((userId) => ({ + userId, + commentId: created.id, + })), + skipDuplicates: true, + }); + } + + return created; + }, + + replyToComment: async ( + _parent: unknown, + args: { input: ReplyToCommentInput }, + context: Context, + ) => { + const user = requireAuth(context); + const { repliedToCommentId, comment, tagUserIds } = args.input; + + const parentComment = await context.prisma.userComments.findUnique({ + where: { id: repliedToCommentId }, + }); + if (!parentComment) { + throw new GraphQLError("Comment not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + const created = await context.prisma.userComments.create({ + data: { + userId: user.id, + eventId: parentComment.eventId, + signalId: parentComment.signalId, + comment, + isCommentReply: true, + repliedToCommentId, + }, + }); + + if (tagUserIds?.length) { + await context.prisma.commentTags.createMany({ + data: tagUserIds.map((userId) => ({ + userId, + commentId: created.id, + })), + skipDuplicates: true, + }); + } + + return created; + }, + + deleteComment: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + const user = requireAuth(context); + + const comment = await context.prisma.userComments.findUnique({ + where: { id: args.id }, + }); + if (!comment) { + throw new GraphQLError("Comment not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + if (comment.userId !== user.id && user.role !== "admin") { + throw new GraphQLError("You can only delete your own comments", { + extensions: { code: "FORBIDDEN" }, + }); + } + + await context.prisma.userComments.delete({ where: { id: args.id } }); + return true; + }, + + tagUsersInComment: async ( + _parent: unknown, + args: { commentId: string; userIds: string[] }, + context: Context, + ) => { + requireAuth(context); + + const comment = await context.prisma.userComments.findUnique({ + where: { id: args.commentId }, + }); + if (!comment) { + throw new GraphQLError("Comment not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + await context.prisma.commentTags.createMany({ + data: args.userIds.map((userId) => ({ + userId, + commentId: args.commentId, + })), + skipDuplicates: true, + }); + + return comment; + }, + }, + + UserFeedback: { + user: (parent: { userId: string }, _args: unknown, { prisma }: Context) => { + return prisma.user.findUnique({ where: { id: parent.userId } }); + }, + event: (parent: { eventId: string | null }, _args: unknown, { prisma }: Context) => { + if (!parent.eventId) return null; + return prisma.events.findUnique({ where: { id: parent.eventId } }); + }, + signal: (parent: { signalId: string | null }, _args: unknown, { prisma }: Context) => { + if (!parent.signalId) return null; + return prisma.signals.findUnique({ where: { id: parent.signalId } }); + }, + }, + + UserComment: { + user: (parent: { userId: string }, _args: unknown, { prisma }: Context) => { + return prisma.user.findUnique({ where: { id: parent.userId } }); + }, + event: (parent: { eventId: string | null }, _args: unknown, { prisma }: Context) => { + if (!parent.eventId) return null; + return prisma.events.findUnique({ where: { id: parent.eventId } }); + }, + signal: (parent: { signalId: string | null }, _args: unknown, { prisma }: Context) => { + if (!parent.signalId) return null; + return prisma.signals.findUnique({ where: { id: parent.signalId } }); + }, + tags: (parent: { id: string }, _args: unknown, { prisma }: Context) => { + return prisma.commentTags.findMany({ where: { commentId: parent.id } }); + }, + }, + + CommentTag: { + user: (parent: { userId: string }, _args: unknown, { prisma }: Context) => { + return prisma.user.findUnique({ where: { id: parent.userId } }); + }, + comment: (parent: { commentId: string }, _args: unknown, { prisma }: Context) => { + return prisma.userComments.findUnique({ where: { id: parent.commentId } }); + }, + }, +}; diff --git a/src/resolvers/index.ts b/src/resolvers/index.ts index bed57c5..181aa0c 100644 --- a/src/resolvers/index.ts +++ b/src/resolvers/index.ts @@ -11,6 +11,11 @@ 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"; +import { feedbackResolvers } from "./feedback.resolver.js"; +import { invitationResolvers } from "./invitation.resolver.js"; +import { subscriptionResolvers } from "./subscription.resolver.js"; export const resolvers: IResolvers[] = [ scalarResolvers, @@ -25,4 +30,9 @@ export const resolvers: IResolvers[] = [ featureFlagResolvers, apiKeyResolvers, disasterTypeResolvers, + organisationResolvers, + teamResolvers, + feedbackResolvers, + invitationResolvers, + subscriptionResolvers, ]; diff --git a/src/resolvers/invitation.resolver.ts b/src/resolvers/invitation.resolver.ts new file mode 100644 index 0000000..2407e8d --- /dev/null +++ b/src/resolvers/invitation.resolver.ts @@ -0,0 +1,463 @@ +import { randomBytes } from "node:crypto"; +import { GraphQLError } from "graphql"; +import type { Context } from "../context.js"; +import { requireAuth } from "../utils/auth-guard.js"; +import { env } from "../utils/env.js"; +import { getEmailProvider } from "../services/messaging/registry.js"; +import { + organisationInvite, + teamInviteNotification, +} from "../services/messaging/templates.js"; +import { auth } from "../lib/auth.js"; + +// ─── Interfaces ────────────────────────────────────────────────────────────── + +interface InviteUserInput { + email: string; + organisationId: string; + teamId?: string; + role?: string; + teamRole?: string; +} + +interface AcceptInviteInput { + token: string; + name: string; + password: string; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function generateToken(): string { + return randomBytes(32).toString("hex"); +} + +function invitationStatus(invite: { + acceptedAt: Date | null; + expiresAt: Date; +}): "pending" | "accepted" | "expired" { + if (invite.acceptedAt) return "accepted"; + if (invite.expiresAt < new Date()) return "expired"; + return "pending"; +} + +async function requireOrgAdmin(context: Context, organisationId: string) { + const user = requireAuth(context); + + // Global admins bypass org-level checks + if (user.role === "admin") return user; + + const membership = await context.prisma.organisationUsers.findUnique({ + where: { + userId_organisationId: { userId: user.id, organisationId }, + }, + }); + + if (!membership || !["owner", "admin"].includes(membership.role)) { + throw new GraphQLError("Requires organisation admin or owner role", { + extensions: { code: "FORBIDDEN" }, + }); + } + + return user; +} + +// ─── Resolver ──────────────────────────────────────────────────────────────── + +export const invitationResolvers = { + Query: { + pendingInvites: async ( + _parent: unknown, + args: { organisationId: string }, + context: Context, + ) => { + await requireOrgAdmin(context, args.organisationId); + return context.prisma.invitations.findMany({ + where: { + organisationId: args.organisationId, + acceptedAt: null, + expiresAt: { gt: new Date() }, + }, + orderBy: { createdAt: "desc" }, + }); + }, + + invitationByToken: async ( + _parent: unknown, + args: { token: string }, + context: Context, + ) => { + const invite = await context.prisma.invitations.findUnique({ + where: { token: args.token }, + include: { + organisation: { select: { name: true } }, + team: { select: { name: true } }, + }, + }); + + if (!invite) return null; + + return { + id: invite.id, + email: invite.email, + organisationName: invite.organisation.name, + teamName: invite.team?.name ?? null, + role: invite.role, + teamRole: invite.teamRole, + expiresAt: invite.expiresAt, + status: invitationStatus(invite), + }; + }, + }, + + Mutation: { + inviteUser: async ( + _parent: unknown, + args: { input: InviteUserInput }, + context: Context, + ) => { + const inviter = await requireOrgAdmin(context, args.input.organisationId); + const { email, organisationId, teamId, role = "member", teamRole = "viewer" } = args.input; + + // Validate org exists + const org = await context.prisma.organisations.findUnique({ + where: { id: organisationId }, + }); + if (!org) { + throw new GraphQLError("Organisation not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + // Validate team if provided + let teamName: string | undefined; + if (teamId) { + const team = await context.prisma.teams.findUnique({ + where: { id: teamId }, + }); + if (!team || team.organisationId !== organisationId) { + throw new GraphQLError("Team not found in this organisation", { + extensions: { code: "NOT_FOUND" }, + }); + } + teamName = team.name; + } + + // Check if the user is already an org member + const existingUser = await context.prisma.user.findUnique({ + where: { email }, + }); + + if (existingUser) { + const existingMembership = await context.prisma.organisationUsers.findUnique({ + where: { + userId_organisationId: { userId: existingUser.id, organisationId }, + }, + }); + + if (existingMembership) { + // Already an org member — if teamId provided, add directly to team + if (teamId) { + const existingTeamMember = await context.prisma.teamMembers.findUnique({ + where: { teamId_userId: { teamId, userId: existingUser.id } }, + }); + + if (existingTeamMember) { + throw new GraphQLError("User is already a member of this team", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + // Add directly to team (no invite needed) + await context.prisma.teamMembers.create({ + data: { teamId, userId: existingUser.id, role: teamRole }, + }); + + // Send notification email + const emailProvider = await getEmailProvider(); + const content = teamInviteNotification( + inviter.name, + org.name, + teamName!, + teamRole, + `${env.FRONTEND_URL}/dashboard`, + ); + await emailProvider.send({ + to: email, + subject: content.subject, + textBody: content.textBody, + htmlBody: content.htmlBody, + }); + + // Return a synthetic invitation record for consistency + return context.prisma.invitations.create({ + data: { + email, + organisationId, + teamId, + role, + teamRole, + token: generateToken(), + expiresAt: new Date(), + acceptedAt: new Date(), + invitedById: inviter.id, + }, + }); + } + + throw new GraphQLError("User is already a member of this organisation", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + } + + // Check for existing pending invite + const existingInvite = await context.prisma.invitations.findFirst({ + where: { + email, + organisationId, + acceptedAt: null, + expiresAt: { gt: new Date() }, + }, + }); + + if (existingInvite) { + throw new GraphQLError("A pending invitation already exists for this email. Use resendInvite to resend.", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + // Create invitation + const token = generateToken(); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + + const invitation = await context.prisma.invitations.create({ + data: { + email, + organisationId, + teamId: teamId ?? null, + role, + teamRole: teamId ? teamRole : null, + token, + expiresAt, + invitedById: inviter.id, + }, + }); + + // Send invite email + const inviteUrl = `${env.FRONTEND_URL}/accept-invite?token=${token}`; + const emailProvider = await getEmailProvider(); + const content = organisationInvite( + inviter.name, + org.name, + role, + inviteUrl, + teamName, + ); + await emailProvider.send({ + to: email, + subject: content.subject, + textBody: content.textBody, + htmlBody: content.htmlBody, + }); + + return invitation; + }, + + acceptInvite: async ( + _parent: unknown, + args: { input: AcceptInviteInput }, + context: Context, + ) => { + const { token, name, password } = args.input; + + const invitation = await context.prisma.invitations.findUnique({ + where: { token }, + }); + + if (!invitation) { + throw new GraphQLError("Invalid invitation token", { + extensions: { code: "NOT_FOUND" }, + }); + } + + if (invitation.acceptedAt) { + throw new GraphQLError("Invitation has already been accepted", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + if (invitation.expiresAt < new Date()) { + throw new GraphQLError("Invitation has expired", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + // Check if user with this email already exists + let existingUser = await context.prisma.user.findUnique({ + where: { email: invitation.email }, + }); + + if (!existingUser) { + // Create new user via Better Auth + const signup = await auth.api.signUpEmail({ + body: { name, email: invitation.email, password }, + }); + // Mark email as verified (admin vouched for it) + await context.prisma.user.update({ + where: { id: signup.user.id }, + data: { emailVerified: true }, + }); + existingUser = await context.prisma.user.findUniqueOrThrow({ + where: { id: signup.user.id }, + }); + } + + // Add to organisation + const existingOrgMembership = await context.prisma.organisationUsers.findUnique({ + where: { + userId_organisationId: { + userId: existingUser.id, + organisationId: invitation.organisationId, + }, + }, + }); + + if (!existingOrgMembership) { + await context.prisma.organisationUsers.create({ + data: { + userId: existingUser.id, + organisationId: invitation.organisationId, + role: invitation.role, + }, + }); + } + + // Add to team if specified + if (invitation.teamId) { + const existingTeamMember = await context.prisma.teamMembers.findUnique({ + where: { + teamId_userId: { + teamId: invitation.teamId, + userId: existingUser.id, + }, + }, + }); + + if (!existingTeamMember) { + await context.prisma.teamMembers.create({ + data: { + teamId: invitation.teamId, + userId: existingUser.id, + role: invitation.teamRole ?? "viewer", + }, + }); + } + } + + // Mark invitation as accepted + await context.prisma.invitations.update({ + where: { id: invitation.id }, + data: { acceptedAt: new Date() }, + }); + + return true; + }, + + cancelInvite: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + const invitation = await context.prisma.invitations.findUnique({ + where: { id: args.id }, + }); + + if (!invitation) { + throw new GraphQLError("Invitation not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + await requireOrgAdmin(context, invitation.organisationId); + + await context.prisma.invitations.delete({ + where: { id: args.id }, + }); + + return true; + }, + + resendInvite: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + const invitation = await context.prisma.invitations.findUnique({ + where: { id: args.id }, + include: { + organisation: { select: { name: true } }, + team: { select: { name: true } }, + }, + }); + + if (!invitation) { + throw new GraphQLError("Invitation not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + const inviter = await requireOrgAdmin(context, invitation.organisationId); + + if (invitation.acceptedAt) { + throw new GraphQLError("Cannot resend an accepted invitation", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + // Reset token and expiry + const newToken = generateToken(); + const newExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + const updated = await context.prisma.invitations.update({ + where: { id: args.id }, + data: { token: newToken, expiresAt: newExpiry }, + }); + + // Resend email + const inviteUrl = `${env.FRONTEND_URL}/accept-invite?token=${newToken}`; + const emailProvider = await getEmailProvider(); + const content = organisationInvite( + inviter.name, + invitation.organisation.name, + invitation.role, + inviteUrl, + invitation.team?.name, + ); + await emailProvider.send({ + to: invitation.email, + subject: content.subject, + textBody: content.textBody, + htmlBody: content.htmlBody, + }); + + return updated; + }, + }, + + Invitation: { + organisation: (parent: { organisationId: string }, _args: unknown, { prisma }: Context) => { + return prisma.organisations.findUnique({ where: { id: parent.organisationId } }); + }, + team: (parent: { teamId: string | null }, _args: unknown, { prisma }: Context) => { + if (!parent.teamId) return null; + return prisma.teams.findUnique({ where: { id: parent.teamId } }); + }, + invitedBy: (parent: { invitedById: string }, _args: unknown, { prisma }: Context) => { + return prisma.user.findUnique({ where: { id: parent.invitedById } }); + }, + status: (parent: { acceptedAt: Date | null; expiresAt: Date }) => { + return invitationStatus(parent); + }, + }, +}; diff --git a/src/resolvers/location.resolver.ts b/src/resolvers/location.resolver.ts index edb07a6..39a0522 100644 --- a/src/resolvers/location.resolver.ts +++ b/src/resolvers/location.resolver.ts @@ -3,6 +3,7 @@ import { GraphQLError } from "graphql"; import type { Context } from "../context.js"; import { Prisma, type PrismaClient } from "../generated/prisma/client.js"; import { requireRole } from "../utils/auth-guard.js"; +import { computeAncestorIds } from "../utils/geo-resolve.js"; interface CreateLocationInput { geoId?: number; @@ -80,9 +81,12 @@ export const locationResolvers = { const pCode = input.pCode ?? null; const parentId = input.parentId ?? null; + // Compute ancestor IDs from the parent chain + const ancestorIds = await computeAncestorIds(context.prisma, parentId); + await context.prisma.$executeRaw` - INSERT INTO "locations" ("id", "geonames_id", "osm_id", "p_code", "name", "level", "parent_id", "geometry") - VALUES (${id}, ${geoId}, ${osmId}, ${pCode}, ${input.name}, ${input.level}, ${parentId}, ST_GeomFromText('POINT(0 0)', 4326)) + INSERT INTO "locations" ("id", "geonames_id", "osm_id", "p_code", "name", "level", "parent_id", "ancestor_ids", "geometry") + VALUES (${id}, ${geoId}, ${osmId}, ${pCode}, ${input.name}, ${input.level}, ${parentId}, ${ancestorIds}, ST_GeomFromText('POINT(0 0)', 4326)) `; return context.prisma.locations.findUniqueOrThrow({ where: { id } }); @@ -171,5 +175,15 @@ export const locationResolvers = { if (!geo?.geometry_geojson) return null; return JSON.parse(geo.geometry_geojson) as unknown; }, + ancestorIds: (parent: { ancestorIds: string[] }) => { + return parent.ancestorIds ?? []; + }, + ancestors: (parent: { ancestorIds: string[] }, _args: unknown, { prisma }: Context) => { + if (!parent.ancestorIds?.length) return []; + return prisma.locations.findMany({ + where: { id: { in: parent.ancestorIds } }, + orderBy: { level: "asc" }, + }); + }, }, }; diff --git a/src/resolvers/notification.resolver.ts b/src/resolvers/notification.resolver.ts index d76ec62..0db883c 100644 --- a/src/resolvers/notification.resolver.ts +++ b/src/resolvers/notification.resolver.ts @@ -1,7 +1,10 @@ import { GraphQLError } from "graphql"; import type { Context } from "../context.js"; -import type { NotificationStatus } from "../generated/prisma/client.js"; +import type { NotificationStatus, PrismaClient } from "../generated/prisma/client.js"; import { requireAuth, requireRole } from "../utils/auth-guard.js"; +import { env } from "../utils/env.js"; +import { getEmailProvider } from "../services/messaging/registry.js"; +import { alertNotification, alertDigest } from "../services/messaging/templates.js"; interface CreateNotificationInput { userId: string; @@ -11,13 +14,64 @@ interface CreateNotificationInput { actionText?: string; } +interface CreateBulkNotificationsInput { + userIds: string[]; + message: string; + notificationType: string; + actionUrl?: string; + actionText?: string; +} + +interface AlertNotifyInput { + alertId: string; +} + +interface AlertDigestInput { + alertIds: string[]; + frequency: "daily" | "weekly" | "monthly"; +} + +/** + * Find all subscriber user IDs for a given alert based on its event's + * types and locations, filtered by frequency. + */ +async function findSubscribers( + prisma: PrismaClient, + eventTypes: string[], + locationIds: string[], + frequency: "immediately" | "daily" | "weekly" | "monthly", +): Promise { + if (eventTypes.length === 0 || locationIds.length === 0) return []; + + // Expand locations to include ancestors (subscriptions at country level + // should match alerts at district level) + const allLocationIds = new Set(locationIds); + const locations = await prisma.locations.findMany({ + where: { id: { in: locationIds } }, + select: { ancestorIds: true }, + }); + for (const loc of locations) { + for (const ancestorId of loc.ancestorIds) { + allLocationIds.add(ancestorId); + } + } + + const subscriptions = await prisma.userAlertSubscriptions.findMany({ + where: { + active: true, + frequency, + alertType: { in: eventTypes }, + locationId: { in: [...allLocationIds] }, + }, + select: { userId: true }, + }); + + return [...new Set(subscriptions.map((s) => s.userId))]; +} + export const notificationResolvers = { Query: { - notifications: ( - _parent: unknown, - args: { status?: NotificationStatus }, - context: Context, - ) => { + notifications: (_parent: unknown, args: { status?: NotificationStatus }, context: Context) => { const user = requireAuth(context); return context.prisma.notifications.findMany({ where: { @@ -53,12 +107,272 @@ export const notificationResolvers = { }, }); }, + createBulkNotifications: async ( + _parent: unknown, + args: { input: CreateBulkNotificationsInput }, + context: Context, + ) => { + requireRole(context, ["admin", "analyst"]); + const { input } = args; + + const result = await context.prisma.notifications.createMany({ + data: input.userIds.map((userId) => ({ + userId, + message: input.message, + notificationType: input.notificationType, + actionUrl: input.actionUrl, + actionText: input.actionText, + })), + }); + + return result.count; + }, + notifyAlertSubscribers: async ( + _parent: unknown, + args: { input: AlertNotifyInput }, + context: Context, + ) => { + requireRole(context, ["admin", "analyst"]); + + const alert = await context.prisma.alerts.findUnique({ + where: { id: args.input.alertId }, + include: { event: true }, + }); + if (!alert) { + throw new GraphQLError("Alert not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + const event = alert.event; + const eventLocationIds = [event.originId, event.destinationId, event.locationId].filter( + (id): id is string => id !== null, + ); + + const userIds = await findSubscribers( + context.prisma, + event.types, + eventLocationIds, + "immediately", + ); + + if (userIds.length === 0) return 0; + + const title = event.title ?? event.types[0] ?? "Alert"; + const alertUrl = `${env.FRONTEND_URL}/alerts/${alert.id}`; + + // 1. Populate userAlerts join table + await context.prisma.userAlerts.createMany({ + data: userIds.map((userId) => ({ + userId, + alertId: alert.id, + })), + skipDuplicates: true, + }); + + // 2. Create in-app notifications + const result = await context.prisma.notifications.createMany({ + data: userIds.map((userId) => ({ + userId, + message: `New alert: ${title}`, + notificationType: "alert", + actionUrl: `/alerts/${alert.id}`, + actionText: "View Alert", + })), + }); + + // 3. Send email notifications to users who have email notifications enabled + const emailUsers = await context.prisma.user.findMany({ + where: { id: { in: userIds }, emailNotification: true }, + select: { id: true, name: true, email: true }, + }); + + if (emailUsers.length > 0) { + const emailProvider = await getEmailProvider(); + const emails = emailUsers.map((u) => { + const content = alertNotification(u.name, title, event.description, alertUrl); + return { + to: u.email, + subject: content.subject, + textBody: content.textBody, + htmlBody: content.htmlBody, + }; + }); + + // Fire-and-forget — don't block the response on email delivery + void emailProvider.sendBulk(emails).catch((err) => { + console.error("[NOTIFY] Failed to send alert emails:", err); + }); + } - deleteNotification: async ( + return result.count; + }, + notifyAlertDigest: async ( _parent: unknown, - args: { id: string }, + args: { input: AlertDigestInput }, context: Context, ) => { + requireRole(context, ["admin", "analyst"]); + const { alertIds, frequency } = args.input; + + if (!["daily", "weekly", "monthly"].includes(frequency)) { + throw new GraphQLError("Frequency must be daily, weekly, or monthly", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + const alerts = await context.prisma.alerts.findMany({ + where: { id: { in: alertIds } }, + include: { event: true }, + }); + + if (alerts.length === 0) return 0; + + // Pre-compute the expanded location set (including ancestors) for each alert + const alertLocationSets: Map> = new Map(); + const allTypes = new Set(); + const allExpandedLocationIds = new Set(); + + for (const alert of alerts) { + for (const t of alert.event.types) allTypes.add(t); + + const directIds = [ + alert.event.originId, + alert.event.destinationId, + alert.event.locationId, + ].filter((id): id is string => id !== null); + + const expanded = new Set(directIds); + const locations = await context.prisma.locations.findMany({ + where: { id: { in: directIds } }, + select: { ancestorIds: true }, + }); + for (const loc of locations) { + for (const aid of loc.ancestorIds) expanded.add(aid); + } + alertLocationSets.set(alert.id, expanded); + for (const lid of expanded) allExpandedLocationIds.add(lid); + } + + if (allTypes.size === 0 || allExpandedLocationIds.size === 0) return 0; + + // Fetch only subscriptions that match ANY of the alert types AND locations + const subscriptions = await context.prisma.userAlertSubscriptions.findMany({ + where: { + active: true, + frequency, + alertType: { in: [...allTypes] }, + locationId: { in: [...allExpandedLocationIds] }, + }, + select: { userId: true, alertType: true, locationId: true }, + }); + + if (subscriptions.length === 0) return 0; + + // For each user, find which alerts match their subscriptions + // userAlertMap: userId → Set + const userAlertMap = new Map>(); + + for (const sub of subscriptions) { + for (const alert of alerts) { + const typesMatch = alert.event.types.includes(sub.alertType); + const locationSet = alertLocationSets.get(alert.id); + const locationMatch = locationSet?.has(sub.locationId) ?? false; + + if (typesMatch && locationMatch) { + let set = userAlertMap.get(sub.userId); + if (!set) { + set = new Set(); + userAlertMap.set(sub.userId, set); + } + set.add(alert.id); + } + } + } + + if (userAlertMap.size === 0) return 0; + + const frequencyLabel = frequency.charAt(0).toUpperCase() + frequency.slice(1); + const dashboardUrl = `${env.FRONTEND_URL}/detection`; + + // 1. Populate userAlerts join table for each user's matched alerts + const userAlertData: Array<{ userId: string; alertId: string }> = []; + for (const [userId, matchedAlertIds] of userAlertMap) { + for (const alertId of matchedAlertIds) { + userAlertData.push({ userId, alertId }); + } + } + await context.prisma.userAlerts.createMany({ + data: userAlertData, + skipDuplicates: true, + }); + + // 2. Create in-app notifications per user + const notificationData: Array<{ + userId: string; + message: string; + notificationType: string; + actionUrl: string; + actionText: string; + }> = []; + + for (const [userId, matchedAlertIds] of userAlertMap) { + const count = matchedAlertIds.size; + const titles = alerts + .filter((a) => matchedAlertIds.has(a.id)) + .map((a) => a.event.title ?? a.event.types[0] ?? "Alert") + .slice(0, 3); + const preview = titles.join(", ") + (count > 3 ? ` +${count - 3} more` : ""); + + notificationData.push({ + userId, + message: `${frequencyLabel} digest (${count}): ${preview}`, + notificationType: "alert_digest", + actionUrl: "/detection", + actionText: "View Alerts", + }); + } + + const result = await context.prisma.notifications.createMany({ + data: notificationData, + }); + + // 3. Send digest emails to users who have email notifications enabled + const allUserIds = [...userAlertMap.keys()]; + const emailUsers = await context.prisma.user.findMany({ + where: { id: { in: allUserIds }, emailNotification: true }, + select: { id: true, name: true, email: true }, + }); + + if (emailUsers.length > 0) { + const emailProvider = await getEmailProvider(); + const emails = emailUsers.map((u) => { + const matchedIds = userAlertMap.get(u.id)!; + const userAlerts = alerts + .filter((a) => matchedIds.has(a.id)) + .map((a) => ({ + title: a.event.title ?? a.event.types[0] ?? "Alert", + description: a.event.description, + url: `${env.FRONTEND_URL}/alerts/${a.id}`, + })); + + const content = alertDigest(u.name, frequency, userAlerts, dashboardUrl); + return { + to: u.email, + subject: content.subject, + textBody: content.textBody, + htmlBody: content.htmlBody, + }; + }); + + void emailProvider.sendBulk(emails).catch((err) => { + console.error("[NOTIFY] Failed to send digest emails:", err); + }); + } + + return result.count; + }, + deleteNotification: async (_parent: unknown, args: { id: string }, context: Context) => { const user = requireAuth(context); const notification = await context.prisma.notifications.findUnique({ @@ -74,12 +388,7 @@ export const notificationResolvers = { await context.prisma.notifications.delete({ where: { id: args.id } }); return true; }, - - markNotificationRead: async ( - _parent: unknown, - args: { id: string }, - context: Context, - ) => { + markNotificationRead: async (_parent: unknown, args: { id: string }, context: Context) => { const user = requireAuth(context); const notification = await context.prisma.notifications.findUnique({ @@ -97,11 +406,7 @@ export const notificationResolvers = { data: { status: "READ" }, }); }, - markAllNotificationsRead: async ( - _parent: unknown, - _args: unknown, - context: Context, - ) => { + markAllNotificationsRead: async (_parent: unknown, _args: unknown, context: Context) => { const user = requireAuth(context); await context.prisma.notifications.updateMany({ diff --git a/src/resolvers/organisation.resolver.ts b/src/resolvers/organisation.resolver.ts new file mode 100644 index 0000000..45a120f --- /dev/null +++ b/src/resolvers/organisation.resolver.ts @@ -0,0 +1,244 @@ +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); + + // Global admins see all organisations + if (user.role === "admin") { + return context.prisma.organisations.findMany({ + orderBy: { createdAt: "desc" }, + }); + } + + const memberships = await context.prisma.organisationUsers.findMany({ + where: { userId: user.id }, + select: { organisationId: true }, + }); + 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, + ) => { + // Only global admins can create organisations + requireRole(context, ["admin"]); + 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" }, + }); + } + + // Global admin creates the org but is NOT added as a member — + // they control everything via their global role. + return context.prisma.organisations.create({ + data: { name, slug }, + }); + }, + + deleteOrganisation: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + // Only global admins can delete organisations + requireRole(context, ["admin"]); + + const org = await context.prisma.organisations.findUnique({ + where: { id: args.id }, + }); + if (!org) { + throw new GraphQLError("Organisation not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + // Cascade is handled by Prisma schema (onDelete: Cascade on teams, members, invitations) + await context.prisma.organisations.delete({ where: { id: args.id } }); + return true; + }, + + updateOrganisation: async ( + _parent: unknown, + args: { id: string; input: UpdateOrganisationInput }, + 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); + + let targetUserId = args.userId; + if (args.userId.includes("@")) { + const found = await context.prisma.user.findFirst({ + where: { email: args.userId }, + }); + if (!found) { + throw new GraphQLError("No user found with that email address", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + targetUserId = found.id; + } + + return context.prisma.organisationUsers.create({ + data: { + userId: targetUserId, + 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..d21cd7a 100644 --- a/src/resolvers/signal.resolver.ts +++ b/src/resolvers/signal.resolver.ts @@ -1,7 +1,10 @@ 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 { createPointLocation, getLocationIdsWithDescendants } from "../utils/geo-resolve.js"; +import { buildLocationFilterForTeam } from "../utils/location-scope.js"; interface CreateSignalInput { sourceId: string; @@ -14,15 +17,69 @@ interface CreateSignalInput { originId?: string; destinationId?: string; locationId?: string; + lat?: number; + lng?: number; } 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 }); + }, + signalsByLocation: async (_parent: unknown, args: { locationId: string }, context: Context) => { + requireAuth(context); + const locationIds = await getLocationIdsWithDescendants(context.prisma, args.locationId); + return context.prisma.signals.findMany({ + where: { + OR: [ + { originId: { in: locationIds } }, + { destinationId: { in: locationIds } }, + { locationId: { in: locationIds } }, + ], + }, + }); }, - signal: (_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: { @@ -43,6 +100,18 @@ export const signalResolvers = { }); } + // Resolve lat/lng to a level-4 point location if no explicit locationId is provided + let locationId = input.locationId; + if (!locationId && input.lat != null && input.lng != null) { + const pointLoc = await createPointLocation( + context.prisma, + input.lat, + input.lng, + input.title ?? undefined, + ); + locationId = pointLoc.id; + } + return context.prisma.signals.create({ data: { sourceId: input.sourceId, @@ -54,7 +123,7 @@ export const signalResolvers = { description: input.description, originId: input.originId, destinationId: input.destinationId, - locationId: input.locationId, + locationId, }, }); }, diff --git a/src/resolvers/subscription.resolver.ts b/src/resolvers/subscription.resolver.ts new file mode 100644 index 0000000..2001a8a --- /dev/null +++ b/src/resolvers/subscription.resolver.ts @@ -0,0 +1,153 @@ +import { GraphQLError } from "graphql"; +import type { Context } from "../context.js"; +import type { Channel, Frequency } from "../generated/prisma/client.js"; +import { requireAuth, requireRole } from "../utils/auth-guard.js"; + +interface SubscribeToAlertsInput { + locationId: string; + alertType: string; + channel: Channel; + frequency: Frequency; +} + +interface UpdateAlertSubscriptionInput { + channel?: Channel; + frequency?: Frequency; + active?: boolean; +} + +export const subscriptionResolvers = { + Query: { + myAlertSubscriptions: async (_parent: unknown, _args: unknown, context: Context) => { + const user = requireAuth(context); + return context.prisma.userAlertSubscriptions.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: "desc" }, + }); + }, + + alertSubscriptionsByLocation: async ( + _parent: unknown, + args: { locationId: string }, + context: Context, + ) => { + requireRole(context, ["admin"]); + return context.prisma.userAlertSubscriptions.findMany({ + where: { locationId: args.locationId }, + orderBy: { createdAt: "desc" }, + }); + }, + }, + + Mutation: { + subscribeToAlerts: async ( + _parent: unknown, + args: { input: SubscribeToAlertsInput }, + context: Context, + ) => { + const user = requireAuth(context); + const { input } = args; + + // Verify location exists + const location = await context.prisma.locations.findUnique({ + where: { id: input.locationId }, + }); + if (!location) { + throw new GraphQLError("Location not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + + // Check for duplicate subscription + const existing = await context.prisma.userAlertSubscriptions.findFirst({ + where: { + userId: user.id, + locationId: input.locationId, + alertType: input.alertType, + channel: input.channel, + }, + }); + if (existing) { + throw new GraphQLError("You already have a subscription for this type, location, and channel", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + return context.prisma.userAlertSubscriptions.create({ + data: { + userId: user.id, + locationId: input.locationId, + alertType: input.alertType, + channel: input.channel, + frequency: input.frequency, + }, + }); + }, + + updateAlertSubscription: async ( + _parent: unknown, + args: { id: string; input: UpdateAlertSubscriptionInput }, + context: Context, + ) => { + const user = requireAuth(context); + const { id, input } = args; + + const subscription = await context.prisma.userAlertSubscriptions.findUnique({ + where: { id }, + }); + if (!subscription) { + throw new GraphQLError("Subscription not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + if (subscription.userId !== user.id && user.role !== "admin") { + throw new GraphQLError("Not authorized to update this subscription", { + extensions: { code: "FORBIDDEN" }, + }); + } + + return context.prisma.userAlertSubscriptions.update({ + where: { id }, + data: { + channel: input.channel ?? undefined, + frequency: input.frequency ?? undefined, + active: input.active ?? undefined, + }, + }); + }, + + unsubscribeFromAlerts: async ( + _parent: unknown, + args: { id: string }, + context: Context, + ) => { + const user = requireAuth(context); + + const subscription = await context.prisma.userAlertSubscriptions.findUnique({ + where: { id: args.id }, + }); + if (!subscription) { + throw new GraphQLError("Subscription not found", { + extensions: { code: "NOT_FOUND" }, + }); + } + if (subscription.userId !== user.id && user.role !== "admin") { + throw new GraphQLError("Not authorized to delete this subscription", { + extensions: { code: "FORBIDDEN" }, + }); + } + + await context.prisma.userAlertSubscriptions.delete({ where: { id: args.id } }); + return true; + }, + }, + + AlertSubscription: { + user: (parent: { userId: string }, _args: unknown, { prisma }: Context) => { + return prisma.user.findUnique({ where: { id: parent.userId } }); + }, + location: (parent: { locationId: string }, _args: unknown, { prisma }: Context) => { + return prisma.locations.findUnique({ where: { id: parent.locationId } }); + }, + }, +}; diff --git a/src/resolvers/team.resolver.ts b/src/resolvers/team.resolver.ts new file mode 100644 index 0000000..904efc5 --- /dev/null +++ b/src/resolvers/team.resolver.ts @@ -0,0 +1,427 @@ +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, + members: { + create: { + userId: user.id, + role: "lead", + }, + }, + }, + }); + }, + + 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..c9427fb 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -13,6 +13,9 @@ 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"; +import { invitationTypeDef } from "./typeDefs/types/invitation.js"; export const typeDefs = [ scalarTypeDef, @@ -30,4 +33,7 @@ export const typeDefs = [ apiKeyTypeDef, feedbackTypeDef, disasterTypeTypeDef, + organisationTypeDef, + teamTypeDef, + invitationTypeDef, ]; diff --git a/src/schema/typeDefs/mutation.ts b/src/schema/typeDefs/mutation.ts index b704c1f..757a662 100644 --- a/src/schema/typeDefs/mutation.ts +++ b/src/schema/typeDefs/mutation.ts @@ -71,6 +71,15 @@ export const mutationTypeDef = gql` """Create a notification for a user.""" createNotification(input: CreateNotificationInput!): Notification! + """Create notifications for multiple users at once. Returns the count of notifications created.""" + createBulkNotifications(input: CreateBulkNotificationsInput!): Int! + + """Notify all subscribers of a single alert (immediate frequency). Matches on event types and locations.""" + notifyAlertSubscribers(input: AlertNotifyInput!): Int! + + """Send a digest notification for multiple alerts to subscribers of the given frequency (daily/weekly/monthly).""" + notifyAlertDigest(input: AlertDigestInput!): Int! + """Delete a notification.""" deleteNotification(id: String!): Boolean! @@ -79,10 +88,131 @@ export const mutationTypeDef = gql` """Mark all notifications as read for the authenticated user.""" markAllNotificationsRead: Boolean! + + # ─── Feedback ────────────────────────────────────────────────────────────── + """Add feedback (rating + optional text) to a signal or event.""" + addFeedback(input: AddFeedbackInput!): UserFeedback! + + """Delete your own feedback.""" + deleteFeedback(id: String!): Boolean! + + # ─── Comments ───────────────────────────────────────────────────────────── + """Add a comment to a signal or event.""" + addComment(input: AddCommentInput!): UserComment! + + """Reply to an existing comment.""" + replyToComment(input: ReplyToCommentInput!): UserComment! + + """Delete your own comment.""" + deleteComment(id: String!): Boolean! + + """Tag users in a comment.""" + tagUsersInComment(commentId: String!, userIds: [String!]!): UserComment! + + # ─── Organisations ───────────────────────────────────────────────────────── + """Create a new organisation. The creator becomes the owner.""" + createOrganisation(input: CreateOrganisationInput!): Organisation! + + """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! + + """Delete an organisation and all its teams, members, and invitations. Requires global admin.""" + deleteOrganisation(id: String!): Boolean! + + # ─── Teams ───────────────────────────────────────────────────────────────── + """Create a new team within an organisation. Requires org admin or owner.""" + createTeam(input: CreateTeamInput!): Team! + + """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! + + # ─── Invitations ────────────────────────────────────────────────────────── + """Invite a user to an organisation (and optionally a team). Sends invite email.""" + inviteUser(input: InviteUserInput!): Invitation! + + """Accept an invitation. Creates user account if new, adds to org and team.""" + acceptInvite(input: AcceptInviteInput!): Boolean! + + """Cancel a pending invitation.""" + cancelInvite(id: String!): Boolean! + + """Resend an invitation email (resets expiry to 7 days).""" + resendInvite(id: String!): Invitation! + + # ─── Password Reset ────────────────────────────────────────────────────── + """Request a password reset email (public, always returns true).""" + requestPasswordReset(email: String!): Boolean! + + """Reset password using a token from the reset email.""" + resetPassword(token: String!, newPassword: String!): Boolean! + + # ─── Alert Subscriptions ────────────────────────────────────────────────── + """Subscribe to alerts for a specific type and location.""" + subscribeToAlerts(input: SubscribeToAlertsInput!): AlertSubscription! + + """Update an existing alert subscription (channel, frequency, active).""" + updateAlertSubscription(id: String!, input: UpdateAlertSubscriptionInput!): AlertSubscription! + + """Unsubscribe — deletes the subscription.""" + unsubscribeFromAlerts(id: String!): Boolean! } # ─── Input Types ─────────────────────────────────────────────────────────── + input SubscribeToAlertsInput { + locationId: String! + """Disaster/event type (glideNumber from disaster_types, e.g. 'fl', 'eq').""" + alertType: String! + channel: Channel! + frequency: Frequency! + } + + input UpdateAlertSubscriptionInput { + channel: Channel + frequency: Frequency + active: Boolean + } + + input InviteUserInput { + email: String! + organisationId: String! + teamId: String + """Organisation role: owner, admin, member (default: member).""" + role: String + """Team role: lead, analyst, viewer (default: viewer). Only used if teamId is provided.""" + teamRole: String + } + + input AcceptInviteInput { + token: String! + name: String! + password: String! + } + input UpdateProfileInput { name: String phoneNumber: String @@ -113,6 +243,10 @@ export const mutationTypeDef = gql` originId: String destinationId: String locationId: String + """Latitude for automatic geo-resolution (resolves to nearest location in hierarchy).""" + lat: Float + """Longitude for automatic geo-resolution.""" + lng: Float } input CreateEventInput { @@ -130,6 +264,10 @@ export const mutationTypeDef = gql` types: [String!]! populationAffected: String rank: Float! + """Latitude for automatic geo-resolution (resolves to nearest location in hierarchy).""" + lat: Float + """Longitude for automatic geo-resolution.""" + lng: Float } input UpdateEventInput { @@ -190,4 +328,52 @@ export const mutationTypeDef = gql` actionUrl: String actionText: String } + + input CreateBulkNotificationsInput { + """List of user IDs to notify.""" + userIds: [String!]! + message: String! + notificationType: String! + actionUrl: String + actionText: String + } + + input AlertNotifyInput { + """Alert ID to notify subscribers about (uses immediate frequency).""" + alertId: String! + } + + input AlertDigestInput { + """List of alert IDs to include in the digest.""" + alertIds: [String!]! + """Frequency: daily, weekly, or monthly.""" + frequency: String! + } + + input AddFeedbackInput { + """Provide exactly one of eventId or signalId.""" + eventId: String + signalId: String + """Rating from 1 to 5.""" + rating: Int! + """Optional textual feedback.""" + text: String + } + + input AddCommentInput { + """Provide exactly one of eventId or signalId.""" + eventId: String + signalId: String + comment: String! + """User IDs to tag in the comment.""" + tagUserIds: [String!] + } + + input ReplyToCommentInput { + """ID of the comment to reply to.""" + repliedToCommentId: String! + comment: String! + """User IDs to tag in the reply.""" + tagUserIds: [String!] + } `; diff --git a/src/schema/typeDefs/query.ts b/src/schema/typeDefs/query.ts index 2c822c4..83c0652 100644 --- a/src/schema/typeDefs/query.ts +++ b/src/schema/typeDefs/query.ts @@ -11,24 +11,33 @@ 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 signals by location. Returns all signals whose origin, destination, or general location is within the given location (including descendants).""" + signalsByLocation(locationId: String!): [Signal!]! - """Look up an event by ID.""" + """List events. Requires authentication. Admins may omit teamId to list all; non-admins must provide a teamId for a team they belong to.""" + events(teamId: String): [Event!]! + + """Look up an event by ID. Requires authentication. Non-admins can only access events within their team scope.""" event(id: String!): Event + """List events by location. Returns all events whose origin, destination, or general location is within the given location (including descendants).""" + eventsByLocation(locationId: String!): [Event!]! + + """List alerts by location. Returns all alerts whose event's location is within the given location (including descendants).""" + alertsByLocation(locationId: String!, status: AlertStatus): [Alert!]! + """List all data sources.""" dataSources: [DataSource!]! @@ -61,5 +70,32 @@ 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 + + # ─── Invitations ────────────────────────────────────────────────────────── + """List pending invitations for an organisation. Requires org admin.""" + pendingInvites(organisationId: String!): [Invitation!]! + + """Look up an invitation by token (public — used on accept-invite page).""" + invitationByToken(token: String!): InvitationInfo + + # ─── Alert Subscriptions ──────────────────────────────────────────────── + """List the authenticated user's alert subscriptions.""" + myAlertSubscriptions: [AlertSubscription!]! + + """List all alert subscriptions for a location (admin only).""" + alertSubscriptionsByLocation(locationId: String!): [AlertSubscription!]! } `; diff --git a/src/schema/typeDefs/types/alert.ts b/src/schema/typeDefs/types/alert.ts index 4e52972..379d33b 100644 --- a/src/schema/typeDefs/types/alert.ts +++ b/src/schema/typeDefs/types/alert.ts @@ -37,4 +37,33 @@ export const alertTypeDef = gql` validFrom: DateTime! validTo: DateTime! } + + """Notification channel for alert subscriptions.""" + enum Channel { + email + sms + } + + """How often a user receives alert notifications.""" + enum Frequency { + immediately + daily + weekly + monthly + } + + """A user's subscription to alerts of a specific type at a specific location.""" + type AlertSubscription { + id: String! + userId: String! + user: User! + location: Location! + """Disaster/event type to subscribe to (e.g. 'fl' for flood, 'eq' for earthquake).""" + alertType: String! + active: Boolean! + channel: Channel! + frequency: Frequency! + createdAt: DateTime! + updatedAt: DateTime! + } `; diff --git a/src/schema/typeDefs/types/invitation.ts b/src/schema/typeDefs/types/invitation.ts new file mode 100644 index 0000000..36beee1 --- /dev/null +++ b/src/schema/typeDefs/types/invitation.ts @@ -0,0 +1,40 @@ +import { gql } from "graphql-tag"; + +export const invitationTypeDef = gql` + """Status of an invitation.""" + enum InvitationStatus { + pending + accepted + expired + } + + """An invitation to join an organisation (and optionally a team).""" + type Invitation { + id: String! + email: String! + organisation: Organisation! + team: Team + """Organisation role assigned on acceptance.""" + role: String! + """Team role assigned on acceptance (if team specified).""" + teamRole: String + expiresAt: DateTime! + acceptedAt: DateTime + invitedBy: User! + createdAt: DateTime! + """Computed from acceptedAt and expiresAt.""" + status: InvitationStatus! + } + + """Public invitation info returned by token lookup (limited fields).""" + type InvitationInfo { + id: String! + email: String! + organisationName: String! + teamName: String + role: String! + teamRole: String + expiresAt: DateTime! + status: InvitationStatus! + } +`; diff --git a/src/schema/typeDefs/types/location.ts b/src/schema/typeDefs/types/location.ts index c48fa73..4535f1f 100644 --- a/src/schema/typeDefs/types/location.ts +++ b/src/schema/typeDefs/types/location.ts @@ -19,5 +19,9 @@ export const locationTypeDef = gql` parent: Location """Child locations one level below.""" children: [Location!]! + """IDs of all ancestor locations (parent, grandparent, etc.).""" + ancestorIds: [String!]! + """All ancestor locations (parent, grandparent, etc.).""" + ancestors: [Location!]! } `; diff --git a/src/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/services/messaging/templates.ts b/src/services/messaging/templates.ts index 215d418..cdbb004 100644 --- a/src/services/messaging/templates.ts +++ b/src/services/messaging/templates.ts @@ -5,12 +5,53 @@ * Returns { subject, textBody, htmlBody } for each template type. */ -interface EmailContent { +export interface EmailContent { subject: string; textBody: string; htmlBody: string; } +/** Reusable HTML email wrapper */ +function wrapHtml(heading: string, body: string): string { + return ` + + + + + + + + + + +
+ + + + + + + + + + +
+

CLEAR Platform

+

${heading}

+
${body}
+

© CLEAR — Crisis Landscape Early Assessment and Response

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

+ You've been invited! +

+

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

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

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

+

+ ${inviteUrl} +

+
+

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

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

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

+

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

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

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

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

+ Hi ${displayName}, +

+

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

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

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

+

+ ${resetUrl} +

+
+

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

`, + ), + }; +} + +/** + * Immediate alert notification — single alert sent to a subscriber. + */ +export function alertNotification( + userName: string, + alertTitle: string, + alertDescription: string | null, + alertUrl: string, +): EmailContent { + const displayName = userName || "there"; + + return { + subject: `Alert: ${alertTitle} — CLEAR Platform`, + + textBody: `Hi ${displayName}, + +A new alert has been published that matches your subscriptions: + +${alertTitle} +${alertDescription ?? ""} + +View the alert: ${alertUrl} + +— The CLEAR Platform Team`, + + htmlBody: wrapHtml( + "New Alert", + `

+ Hi ${displayName}, +

+

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

+
+

${alertTitle}

+ ${alertDescription ? `

${alertDescription}

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

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

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

+ Hi ${displayName}, +

+

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

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

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

`, + ), + }; +} 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/env.ts b/src/utils/env.ts index 7941b3a..eb5f59f 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -20,6 +20,10 @@ const envSchema = z.object({ SMTP_FROM: z.string().default("noreply@clear-platform.org"), POSTMARK_SERVER_TOKEN: z.string().optional(), POSTMARK_SENDER_EMAIL: z.string().optional(), + + // Global admin seed (env overrides seed defaults) + ADMIN_EMAIL: z.string().email().default("admin@clear.dev"), + ADMIN_PASSWORD: z.string().min(8).default("password123"), }); const parsed = envSchema.parse(process.env); diff --git a/src/utils/geo-resolve.ts b/src/utils/geo-resolve.ts new file mode 100644 index 0000000..78a602e --- /dev/null +++ b/src/utils/geo-resolve.ts @@ -0,0 +1,212 @@ +import { randomUUID } from "node:crypto"; +import type { PrismaClient } from "../generated/prisma/client.js"; + +interface ResolvedLocation { + id: string; + name: string; + level: number; +} + +/** + * Resolve a lat/lng point to the most granular existing location in the hierarchy. + * Returns the best match (district > state > country) without creating new entries. + */ +export async function resolveLatLngToLocation( + prisma: PrismaClient, + lat: number, + lng: number, +): Promise { + // Phase 1: Find polygon that contains the point (state/country level) + const containRows = await prisma.$queryRaw` + SELECT id, name, level + FROM "locations" + WHERE "geometry" IS NOT NULL + AND ST_Contains("geometry"::geometry, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)) + ORDER BY level DESC + LIMIT 1 + `; + if (containRows.length > 0) return containRows[0]!; + + // Phase 2: Find nearest point location within 50km (district level) + const nearbyRows = await prisma.$queryRaw` + SELECT id, name, level + FROM "locations" + WHERE "geometry" IS NOT NULL + AND ST_DWithin("geometry", ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, 50000) + ORDER BY level DESC, ST_Distance("geometry", ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography) ASC + LIMIT 1 + `; + return nearbyRows[0] ?? null; +} + +/** + * Create a level-4 point location for an exact lat/lng, parented to the + * nearest resolved district/state. If an existing level-4 point is within + * 500m, reuse it instead of creating a duplicate. + * + * @param name Human-readable name (e.g., Dataminr location name or generated) + * @returns The created or reused location row + */ +export async function createPointLocation( + prisma: PrismaClient, + lat: number, + lng: number, + name?: string, +): Promise { + // Check for an existing level-4 point within 500m to avoid duplicates + const existing = await prisma.$queryRaw` + SELECT id, name, level + FROM "locations" + WHERE level = 4 + AND "geometry" IS NOT NULL + AND ST_DWithin("geometry", ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, 500) + ORDER BY ST_Distance("geometry", ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography) ASC + LIMIT 1 + `; + if (existing.length > 0) return existing[0]!; + + // Resolve parent location (most granular existing: district > state > country) + const parent = await resolveLatLngToLocation(prisma, lat, lng); + const parentId = parent?.id ?? null; + + // Compute ancestor IDs + const ancestorIds = parentId ? await computeAncestorIds(prisma, parentId) : []; + + const id = randomUUID(); + const locationName = name ?? `Point ${lat.toFixed(4)}, ${lng.toFixed(4)}`; + + await prisma.$executeRaw` + INSERT INTO "locations" ("id", "name", "level", "parent_id", "ancestor_ids", "geometry") + VALUES (${id}, ${locationName}, 4, ${parentId}, ${ancestorIds}, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)) + `; + + console.log(`[createPointLocation] Created "${locationName}" (level 4) → parent: ${parent?.name ?? "none"}`); + + return { id, name: locationName, level: 4 }; +} + +/** + * Create a level-4 region location from multiple signal points. + * Uses ST_ConvexHull to build a polygon around the points, or a single point + * if there's only one. Parented to the most common parent among the points. + */ +export async function createRegionFromPoints( + prisma: PrismaClient, + points: Array<{ lat: number; lng: number }>, + name?: string, +): Promise { + if (points.length === 0) { + throw new Error("Cannot create region from zero points"); + } + + // Single point — delegate to createPointLocation + if (points.length === 1) { + return createPointLocation(prisma, points[0]!.lat, points[0]!.lng, name); + } + + // Build a convex hull from the points + const pointsWkt = points.map((p) => `${p.lng} ${p.lat}`).join(","); + const multipointWkt = "MULTIPOINT(" + pointsWkt + ")"; + + // Find the best parent by resolving the centroid + const avgLat = points.reduce((s, p) => s + p.lat, 0) / points.length; + const avgLng = points.reduce((s, p) => s + p.lng, 0) / points.length; + const parent = await resolveLatLngToLocation(prisma, avgLat, avgLng); + const parentId = parent?.id ?? null; + + const ancestorIds = parentId ? await computeAncestorIds(prisma, parentId) : []; + + const id = randomUUID(); + const regionName = name ?? `Region ${avgLat.toFixed(2)}, ${avgLng.toFixed(2)}`; + + // Find the nearest state-level (level 1) polygon to clip the region against + // Walk up ancestors to find a state, or use the parent directly if it's a state + let clipLocationId: string | null = null; + if (parent && parent.level <= 1) { + clipLocationId = parent.id; + } else if (parentId) { + // Parent is a district (level 2+), find the state ancestor + for (const aid of ancestorIds) { + const ancestor = await prisma.locations.findUnique({ + where: { id: aid }, + select: { id: true, level: true }, + }); + if (ancestor && ancestor.level === 1) { + clipLocationId = ancestor.id; + break; + } + } + } + + if (clipLocationId) { + // Clip the convex hull to the state boundary using ST_Intersection + await prisma.$executeRaw` + INSERT INTO "locations" ("id", "name", "level", "parent_id", "ancestor_ids", "geometry") + SELECT + ${id}, ${regionName}, 4, ${parentId}, ${ancestorIds}::text[], + ST_Intersection( + ST_ConvexHull(ST_GeomFromText(${multipointWkt}, 4326)), + "geometry"::geometry + ) + FROM "locations" + WHERE id = ${clipLocationId} + AND "geometry" IS NOT NULL + `; + } else { + // No state boundary to clip against — use raw convex hull + await prisma.$executeRaw` + INSERT INTO "locations" ("id", "name", "level", "parent_id", "ancestor_ids", "geometry") + VALUES ( + ${id}, ${regionName}, 4, ${parentId}, ${ancestorIds}, + ST_ConvexHull(ST_GeomFromText(${multipointWkt}, 4326)) + ) + `; + } + + console.log(`[createRegionFromPoints] Created "${regionName}" (level 4, ${points.length} points, clipped=${!!clipLocationId}) → parent: ${parent?.name ?? "none"}`); + + return { id, name: regionName, level: 4 }; +} + +/** + * Get all location IDs that are within the given location + * (the location itself + all descendants), using the ancestorIds array. + * Much faster than the recursive CTE approach. + */ +export async function getLocationIdsWithDescendants( + prisma: PrismaClient, + locationId: string, +): Promise { + // Find all locations where ancestorIds contains the target, plus the target itself + const rows = await prisma.$queryRaw<{ id: string }[]>` + SELECT id FROM "locations" + WHERE id = ${locationId} + OR ${locationId} = ANY("ancestor_ids") + `; + return rows.map((r) => r.id); +} + +/** + * Compute the ancestor IDs for a location by walking up the parent chain. + * Returns an array ordered from direct parent to root. + */ +export async function computeAncestorIds( + prisma: PrismaClient, + parentId: string | null, +): Promise { + if (!parentId) return []; + + const ancestors: string[] = []; + let currentId: string | null = parentId; + + while (currentId) { + ancestors.push(currentId); + const parent: { parentId: string | null } | null = await prisma.locations.findUnique({ + where: { id: currentId }, + select: { parentId: true }, + }); + currentId = parent?.parentId ?? null; + } + + return ancestors; +} diff --git a/src/utils/location-scope.ts b/src/utils/location-scope.ts new file mode 100644 index 0000000..df20365 --- /dev/null +++ b/src/utils/location-scope.ts @@ -0,0 +1,53 @@ +import type { PrismaClient } from "../generated/prisma/client.js"; +import type { Prisma } from "../generated/prisma/client.js"; +import { getLocationIdsWithDescendants } from "./geo-resolve.js"; + +/** + * Build a Prisma where clause that filters signals by a team's location scope. + * Looks up the team's locations, expands the hierarchy using ancestorIds, + * 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; + + // Expand each scope location to include all descendants + const allIds = new Set(); + for (const locId of locationIds) { + const expanded = await getLocationIdsWithDescendants(prisma, locId); + for (const id of expanded) allIds.add(id); + } + + const expandedIds = [...allIds]; + + return { + OR: [ + { 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 + } +}