Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 11 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,17 @@
"Bash(bun add @graphql-tools/schema)",
"Bash(git add src/schema/typeDefs/query.ts src/schema/typeDefs/types/alert.ts src/schema/typeDefs/types/apiKey.ts src/schema/typeDefs/types/dataSource.ts src/schema/typeDefs/types/detection.ts src/schema/typeDefs/types/location.ts)",
"Bash(git rebase --continue)",
"Bash(git add .claude/settings.json)"
"Bash(git add .claude/settings.json)",
"Bash(npx tsc --noEmit)",
"Bash(bun run tsc -p tsconfig.build.json --noEmit)",
"Bash(ls node_modules/nodemailer/lib/*.d.ts)",
"Bash(bun test:*)",
"Bash(git add:*)",
"Bash(git checkout:*)",
"Bash(git push:*)",
"Bash(gh pr:*)",
"Bash(git stash:*)",
"Bash(bun run:*)"
]
}
}
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
scripts
*.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");
Comment on lines +7 to +14
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential slug collision during backfill.

The backfill generates slugs using LOWER(REPLACE("name", ' ', '-')). If two organisations have names that normalize to the same slug (e.g., "My Org" and "my-org"), the subsequent unique constraint creation will fail.

Consider adding a suffix to handle collisions:

🛡️ Proposed safer backfill approach
-- Backfill with collision handling using row_number
UPDATE "organisations" o
SET "slug" = subq.unique_slug
FROM (
  SELECT id, 
    CASE 
      WHEN rn = 1 THEN base_slug
      ELSE base_slug || '-' || rn::text
    END AS unique_slug
  FROM (
    SELECT id, 
      LOWER(REPLACE("name", ' ', '-')) AS base_slug,
      ROW_NUMBER() OVER (PARTITION BY LOWER(REPLACE("name", ' ', '-')) ORDER BY id) AS rn
    FROM "organisations"
  ) ranked
) subq
WHERE o.id = subq.id AND o."slug" IS NULL;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@prisma/migrations/20260319000000_add_teams_and_evolve_orgs/migration.sql`
around lines 7 - 14, The backfill UPDATE for "organisations" that sets "slug" =
LOWER(REPLACE("name", ' ', '-')) can produce duplicate slugs and will break when
creating the unique index "organisations_slug_key"; modify the backfill to
generate collision-safe unique slugs (e.g., compute a base_slug from
LOWER(REPLACE("name",' ','-')) and use ROW_NUMBER() OVER (PARTITION BY base_slug
ORDER BY id) to append a suffix like "-<n>" for duplicates) and update only rows
with slug IS NULL before altering the column to NOT NULL and creating the unique
index.


-- 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;
Comment on lines +1 to +9
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify that the first migration creates the unique index before this migration runs

echo "=== Checking first migration for CREATE UNIQUE INDEX ==="
cat prisma/migrations/20260319000000_add_teams_and_evolve_orgs/migration.sql | grep -n "CREATE UNIQUE INDEX"

echo ""
echo "=== Listing migration order by timestamp ==="
ls -1 prisma/migrations/ | sort

Repository: CLEAR-Initiative/clear-api

Length of output: 1186


🏁 Script executed:

cat prisma/migrations/20260319000000_add_teams_and_evolve_orgs/migration.sql

Repository: CLEAR-Initiative/clear-api

Length of output: 4502


Critical: Migration order causes CREATE UNIQUE INDEX to fail before deduplication runs.

The first migration (20260319000000_add_teams_and_evolve_orgs) backfills slugs with LOWER(REPLACE("name", ' ', '-')), which can create duplicates if multiple organisations share the same name or differ only in whitespace/casing. When it then attempts to create the unique index on line 14, the statement will fail due to existing duplicate values, causing the entire first migration to roll back. The deduplication migration will never execute.

Move the CREATE UNIQUE INDEX statement from the first migration to the end of this migration, or inline the deduplication logic in the first migration before the index creation.

Note: The deduplication logic itself also has an edge case where it can create new duplicates (e.g., if slugs ["my-org", "my-org", "my-org-2"] exist, the deduplicated result becomes ["my-org", "my-org-2", "my-org-2"]), though this is less critical as it would require the first migration to succeed, which is unlikely given the backfill issue.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@prisma/migrations/20260319020000_fix_duplicate_org_slugs/migration.sql`
around lines 1 - 9, The CREATE UNIQUE INDEX in migration
20260319000000_add_teams_and_evolve_orgs is being created before duplicates are
removed, so move that CREATE UNIQUE INDEX statement out of that first migration
and add it to the end of this migration (20260319020000_fix_duplicate_org_slugs)
after the UPDATE deduplication runs, or alternatively inline the deduplication
UPDATE from this migration into the first migration immediately before its
CREATE UNIQUE INDEX; also fix the deduplication UPDATE logic (referencing the
organisations table and the sub query with ROW_NUMBER() AS rn) so it never
produces a conflicting slug (e.g., when computing suffixes ensure you generate a
unique suffix per base slug by consulting existing slugs or using a max-suffix +
1 per partition rather than naively appending rn) before creating the unique
index.

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";
77 changes: 69 additions & 8 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,14 @@ model user {
sessions session[]
accounts account[]

defaultTeamId String? @map("default_team_id")
defaultTeam teams? @relation("DefaultTeam", fields: [defaultTeamId], references: [id], onDelete: SetNull)

alerts userAlerts[]
apiKeys apiKeys[]
notifications notifications[]
organisations organisationUsers[]
teamMemberships teamMembers[]
eventEscaladedByUsers eventEscaladedByUsers[]
userFeedbacks userFeedbacks[]
userComments userComments[]
Expand Down Expand Up @@ -121,24 +125,80 @@ model verification {
// ––– Organisation ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––

model organisations {
id String @id @default(cuid())
name String
id String @id @default(cuid())
name String
slug String @unique
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

users organisationUsers[]
teams teams[]
}

model organisationUsers {
id String @id @default(cuid())
userId String @map("user_id")
organisationId String @map("organisation_id")
role String @default("viewer")
id String @id @default(cuid())
userId String @map("user_id")
organisationId String @map("organisation_id")
role String @default("member") // owner | admin | member
createdAt DateTime @default(now()) @map("created_at")

user user @relation(fields: [userId], references: [id])
organisation organisations @relation(fields: [organisationId], references: [id])
user user @relation(fields: [userId], references: [id], onDelete: Cascade)
organisation organisations @relation(fields: [organisationId], references: [id], onDelete: Cascade)

@@unique([userId, organisationId])
@@map("user_to_organisation")
}

// ––– Teams –––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––

model teams {
id String @id @default(cuid())
organisationId String @map("organisation_id")
name String
slug String
description String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

organisation organisations @relation(fields: [organisationId], references: [id], onDelete: Cascade)
members teamMembers[]
locations teamLocations[]
defaultForUsers user[] @relation("DefaultTeam")

@@unique([organisationId, slug])
@@index([organisationId])
}

model teamMembers {
id String @id @default(cuid())
teamId String @map("team_id")
userId String @map("user_id")
role String @default("viewer") // lead | analyst | viewer
createdAt DateTime @default(now()) @map("created_at")

team teams @relation(fields: [teamId], references: [id], onDelete: Cascade)
user user @relation(fields: [userId], references: [id], onDelete: Cascade)

@@unique([teamId, userId])
@@index([userId])
@@map("team_members")
}

model teamLocations {
id String @id @default(cuid())
teamId String @map("team_id")
locationId String @map("location_id")
createdAt DateTime @default(now()) @map("created_at")

team teams @relation(fields: [teamId], references: [id], onDelete: Cascade)
location locations @relation(fields: [locationId], references: [id], onDelete: Cascade)

@@unique([teamId, locationId])
@@index([locationId])
@@map("team_locations")
}

// ─── Geography ───────────────────────────────────────────────────────────────

model locations {
Expand Down Expand Up @@ -167,6 +227,7 @@ model locations {
eventDestinations events[] @relation("EventDestination")
eventLocations events[] @relation("EventLocation")
userAlertSubscriptions userAlertSubscriptions[]
teamScopes teamLocations[]

@@index([level])
@@index([parentId])
Expand Down
22 changes: 22 additions & 0 deletions scripts/build-docs.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading