Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
94c82ef
Refactor docs to pre-built HTML with build script
positonic Mar 19, 2026
8a228b2
Add multi-tenancy schema: organisations, teams, location scoping
positonic Mar 19, 2026
7d023e9
Add auth guards and location scope utilities for team-based filtering
positonic Mar 19, 2026
2e58f56
Add org/team GraphQL API and team-scoped query filtering
positonic Mar 19, 2026
531dfc1
feat: enhance access control for alerts, events, signals, and organis…
positonic Mar 19, 2026
4fc030b
Merge pull request #5 from CLEAR-Initiative/feat-multi-tenancy
positonic Mar 19, 2026
9f14112
remove scripts from dockerignaore
Prajjawalk Mar 20, 2026
ab2b491
feat: auto-add creator as team lead on team creation and update settings
positonic Mar 21, 2026
3516e9e
feat: add command to kill specific process in settings
positonic Mar 21, 2026
57bfa8c
feat: enhance addOrgMember mutation to support user lookup by email
positonic Mar 21, 2026
fbcf668
Add: ancestorIds to filter datapoints by regions
Prajjawalk Mar 23, 2026
f84e207
Merge pull request #6 from CLEAR-Initiative/feat/multi-country-support
Prajjawalk Mar 23, 2026
213fa29
Add: org & team invitation handlers
Prajjawalk Mar 24, 2026
c99567c
Merge pull request #7 from CLEAR-Initiative/feat/invite-based-onboarding
Prajjawalk Mar 24, 2026
fb79c75
Add: feedback and comment resolvers
Prajjawalk Mar 25, 2026
a94218e
Add: notification mutations
Prajjawalk Mar 25, 2026
9f9d5ca
update location resolvers
Prajjawalk Mar 26, 2026
b7368f3
Add: alert subscription
Prajjawalk Mar 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
}
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ node_modules
dist
.git
.github
.beads
.claude
infra

*.md
*.test.ts
.env
.env.*
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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";
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- RenameForeignKey
ALTER TABLE "user" RENAME CONSTRAINT "user_active_team_id_fkey" TO "user_default_team_id_fkey";
Original file line number Diff line number Diff line change
@@ -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);
34 changes: 34 additions & 0 deletions prisma/migrations/20260324131311_add_invitations/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
124 changes: 107 additions & 17 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand All @@ -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 ─────────────────────────────────────────────────────────────
Expand Down
Loading
Loading