-
Notifications
You must be signed in to change notification settings - Fork 1
feat: multi-tenancy with orgs, teams, and location scoping #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
94c82ef
8a228b2
7d023e9
2e58f56
531dfc1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,11 @@ node_modules | |
| dist | ||
| .git | ||
| .github | ||
| .beads | ||
| .claude | ||
| infra | ||
| scripts | ||
| *.md | ||
| *.test.ts | ||
| .env | ||
| .env.* | ||
| 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; | ||
|
Comment on lines
+1
to
+9
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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/ | sortRepository: CLEAR-Initiative/clear-api Length of output: 1186 🏁 Script executed: cat prisma/migrations/20260319000000_add_teams_and_evolve_orgs/migration.sqlRepository: CLEAR-Initiative/clear-api Length of output: 4502 Critical: Migration order causes The first migration ( Move the Note: The deduplication logic itself also has an edge case where it can create new duplicates (e.g., if slugs 🤖 Prompt for AI Agents |
||
| 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,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); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
🤖 Prompt for AI Agents