diff --git a/packages/db/migrations/20260126100000_AddWorkspaceRoles.sql b/packages/db/migrations/20260126100000_AddWorkspaceRoles.sql new file mode 100644 index 00000000..cedf39aa --- /dev/null +++ b/packages/db/migrations/20260126100000_AddWorkspaceRoles.sql @@ -0,0 +1,151 @@ +-- Create workspace_roles table +CREATE TABLE IF NOT EXISTS "workspace_roles" ( + "id" bigserial PRIMARY KEY NOT NULL, + "workspaceId" bigint NOT NULL, + "name" varchar(64) NOT NULL, + "description" varchar(255), + "hierarchyLevel" integer NOT NULL, + "isSystem" boolean DEFAULT false NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp +); +--> statement-breakpoint +ALTER TABLE "workspace_roles" ENABLE ROW LEVEL SECURITY; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "unique_role_per_workspace" ON "workspace_roles" USING btree ("workspaceId","name"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "workspace_roles_workspace_idx" ON "workspace_roles" USING btree ("workspaceId"); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "workspace_roles" ADD CONSTRAINT "workspace_roles_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint + +-- Create workspace_role_permissions table +CREATE TABLE IF NOT EXISTS "workspace_role_permissions" ( + "id" bigserial PRIMARY KEY NOT NULL, + "workspaceRoleId" bigint NOT NULL, + "permission" varchar(64) NOT NULL, + "granted" boolean DEFAULT true NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "workspace_role_permissions" ENABLE ROW LEVEL SECURITY; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "unique_role_permission" ON "workspace_role_permissions" USING btree ("workspaceRoleId","permission"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "role_permissions_role_idx" ON "workspace_role_permissions" USING btree ("workspaceRoleId"); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "workspace_role_permissions" ADD CONSTRAINT "workspace_role_permissions_workspaceRoleId_workspace_roles_id_fk" FOREIGN KEY ("workspaceRoleId") REFERENCES "public"."workspace_roles"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint + +-- Add roleId column to workspace_members (nullable for migration) +ALTER TABLE "workspace_members" ADD COLUMN IF NOT EXISTS "roleId" bigint; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_roleId_workspace_roles_id_fk" FOREIGN KEY ("roleId") REFERENCES "public"."workspace_roles"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint + +-- Seed system roles for each existing workspace +INSERT INTO "workspace_roles" ("workspaceId", "name", "description", "hierarchyLevel", "isSystem", "createdAt") +SELECT w.id, 'admin', 'Full access to all workspace features', 100, true, NOW() +FROM "workspace" w +WHERE NOT EXISTS ( + SELECT 1 FROM "workspace_roles" wr + WHERE wr."workspaceId" = w.id AND wr."name" = 'admin' +); +--> statement-breakpoint +INSERT INTO "workspace_roles" ("workspaceId", "name", "description", "hierarchyLevel", "isSystem", "createdAt") +SELECT w.id, 'member', 'Standard member with create and edit permissions', 50, true, NOW() +FROM "workspace" w +WHERE NOT EXISTS ( + SELECT 1 FROM "workspace_roles" wr + WHERE wr."workspaceId" = w.id AND wr."name" = 'member' +); +--> statement-breakpoint +INSERT INTO "workspace_roles" ("workspaceId", "name", "description", "hierarchyLevel", "isSystem", "createdAt") +SELECT w.id, 'guest', 'View-only access', 10, true, NOW() +FROM "workspace" w +WHERE NOT EXISTS ( + SELECT 1 FROM "workspace_roles" wr + WHERE wr."workspaceId" = w.id AND wr."name" = 'guest' +); +--> statement-breakpoint + +-- Seed admin role permissions (all permissions) +INSERT INTO "workspace_role_permissions" ("workspaceRoleId", "permission", "granted", "createdAt") +SELECT wr.id, p.permission, true, NOW() +FROM "workspace_roles" wr +CROSS JOIN ( + VALUES + ('workspace:view'), ('workspace:edit'), ('workspace:delete'), ('workspace:manage'), + ('board:view'), ('board:create'), ('board:edit'), ('board:delete'), + ('list:view'), ('list:create'), ('list:edit'), ('list:delete'), + ('card:view'), ('card:create'), ('card:edit'), ('card:delete'), + ('comment:view'), ('comment:create'), ('comment:edit'), ('comment:delete'), + ('member:view'), ('member:invite'), ('member:edit'), ('member:remove') +) AS p(permission) +WHERE wr."name" = 'admin' AND wr."isSystem" = true +AND NOT EXISTS ( + SELECT 1 FROM "workspace_role_permissions" wrp + WHERE wrp."workspaceRoleId" = wr.id AND wrp."permission" = p.permission +); +--> statement-breakpoint + +-- Seed member role permissions +INSERT INTO "workspace_role_permissions" ("workspaceRoleId", "permission", "granted", "createdAt") +SELECT wr.id, p.permission, true, NOW() +FROM "workspace_roles" wr +CROSS JOIN ( + VALUES + ('workspace:view'), + ('board:view'), ('board:create'), + ('list:view'), ('list:create'), ('list:edit'), + ('card:view'), ('card:create'), ('card:edit'), ('card:delete'), + ('comment:view'), ('comment:create'), ('comment:edit'), ('comment:delete'), + ('member:view') +) AS p(permission) +WHERE wr."name" = 'member' AND wr."isSystem" = true +AND NOT EXISTS ( + SELECT 1 FROM "workspace_role_permissions" wrp + WHERE wrp."workspaceRoleId" = wr.id AND wrp."permission" = p.permission +); +--> statement-breakpoint + +-- Seed guest role permissions (view only) +INSERT INTO "workspace_role_permissions" ("workspaceRoleId", "permission", "granted", "createdAt") +SELECT wr.id, p.permission, true, NOW() +FROM "workspace_roles" wr +CROSS JOIN ( + VALUES + ('workspace:view'), + ('board:view'), + ('list:view'), + ('card:view'), + ('comment:view'), + ('member:view') +) AS p(permission) +WHERE wr."name" = 'guest' AND wr."isSystem" = true +AND NOT EXISTS ( + SELECT 1 FROM "workspace_role_permissions" wrp + WHERE wrp."workspaceRoleId" = wr.id AND wrp."permission" = p.permission +); +--> statement-breakpoint + +-- Migrate existing workspace_members to use roleId +UPDATE "workspace_members" wm +SET "roleId" = wr.id +FROM "workspace_roles" wr +WHERE wm."workspaceId" = wr."workspaceId" + AND wm."role"::text = wr."name" + AND wm."roleId" IS NULL; + diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index bf963f29..0162cf55 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -12,3 +12,4 @@ export * from "./integrations"; export * from "./workspaces"; export * from "./subscriptions"; export * from "./workspaceInviteLinks"; +export * from "./permissions"; diff --git a/packages/db/src/schema/permissions.ts b/packages/db/src/schema/permissions.ts new file mode 100644 index 00000000..6394a809 --- /dev/null +++ b/packages/db/src/schema/permissions.ts @@ -0,0 +1,97 @@ +import { relations } from "drizzle-orm"; +import { + bigint, + bigserial, + boolean, + index, + integer, + pgTable, + timestamp, + uniqueIndex, + varchar, +} from "drizzle-orm/pg-core"; + +import { workspaces } from "./workspaces"; + +export const workspaceRoles = pgTable( + "workspace_roles", + { + id: bigserial("id", { mode: "number" }).primaryKey(), + workspaceId: bigint("workspaceId", { mode: "number" }) + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + name: varchar("name", { length: 64 }).notNull(), + description: varchar("description", { length: 255 }), + hierarchyLevel: integer("hierarchyLevel").notNull(), + isSystem: boolean("isSystem").notNull().default(false), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt"), + }, + (table) => [ + uniqueIndex("unique_role_per_workspace").on(table.workspaceId, table.name), + index("workspace_roles_workspace_idx").on(table.workspaceId), + ], +).enableRLS(); + +export const workspaceRolesRelations = relations( + workspaceRoles, + ({ one, many }) => ({ + workspace: one(workspaces, { + fields: [workspaceRoles.workspaceId], + references: [workspaces.id], + relationName: "workspaceRoles", + }), + permissions: many(workspaceRolePermissions), + }), +); + + +export const workspaceRolePermissions = pgTable( + "workspace_role_permissions", + { + id: bigserial("id", { mode: "number" }).primaryKey(), + workspaceRoleId: bigint("workspaceRoleId", { mode: "number" }) + .notNull() + .references(() => workspaceRoles.id, { onDelete: "cascade" }), + permission: varchar("permission", { length: 64 }).notNull(), + granted: boolean("granted").notNull().default(true), + createdAt: timestamp("createdAt").defaultNow().notNull(), + }, + (table) => [ + uniqueIndex("unique_role_permission").on( + table.workspaceRoleId, + table.permission, + ), + index("role_permissions_role_idx").on(table.workspaceRoleId), + ], +).enableRLS(); + +export const workspaceRolePermissionsRelations = relations( + workspaceRolePermissions, + ({ one }) => ({ + role: one(workspaceRoles, { + fields: [workspaceRolePermissions.workspaceRoleId], + references: [workspaceRoles.id], + relationName: "rolePermissions", + }), + }), +); + +export const workspaceMemberPermissions = pgTable( + "workspace_member_permissions", + { + id: bigserial("id", { mode: "number" }).primaryKey(), + workspaceMemberId: bigint("workspaceMemberId", { mode: "number" }).notNull(), + permission: varchar("permission", { length: 64 }).notNull(), + granted: boolean("granted").notNull().default(true), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt"), + }, + (table) => [ + uniqueIndex("unique_member_permission").on( + table.workspaceMemberId, + table.permission, + ), + index("permission_member_idx").on(table.workspaceMemberId), + ], +).enableRLS(); diff --git a/packages/db/src/schema/workspaces.ts b/packages/db/src/schema/workspaces.ts index 4fccc8b1..415d3e71 100644 --- a/packages/db/src/schema/workspaces.ts +++ b/packages/db/src/schema/workspaces.ts @@ -12,6 +12,7 @@ import { } from "drizzle-orm/pg-core"; import { boards } from "./boards"; +import { workspaceMemberPermissions, workspaceRoles } from "./permissions"; import { subscription } from "./subscriptions"; import { users } from "./users"; @@ -69,6 +70,7 @@ export const workspaceRelations = relations(workspaces, ({ one, many }) => ({ members: many(workspaceMembers), boards: many(boards), subscriptions: many(subscription), + roles: many(workspaceRoles), })); export const workspaceMembers = pgTable("workspace_members", { @@ -86,13 +88,18 @@ export const workspaceMembers = pgTable("workspace_members", { deletedBy: uuid("deletedBy").references(() => users.id, { onDelete: "set null", }), + // Legacy role enum role: memberRoleEnum("role").notNull(), + roleId: bigint("roleId", { mode: "number" }).references( + () => workspaceRoles.id, + { onDelete: "restrict" }, + ), status: memberStatusEnum("status").default("invited").notNull(), }).enableRLS(); export const workspaceMembersRelations = relations( workspaceMembers, - ({ one }) => ({ + ({ one, many }) => ({ user: one(users, { fields: [workspaceMembers.userId], references: [users.id], @@ -103,6 +110,23 @@ export const workspaceMembersRelations = relations( references: [workspaces.id], relationName: "workspaceMembersWorkspace", }), + workspaceRole: one(workspaceRoles, { + fields: [workspaceMembers.roleId], + references: [workspaceRoles.id], + relationName: "workspaceMemberRole", + }), + permissions: many(workspaceMemberPermissions), + }), +); + +export const workspaceMemberPermissionsRelations = relations( + workspaceMemberPermissions, + ({ one }) => ({ + member: one(workspaceMembers, { + fields: [workspaceMemberPermissions.workspaceMemberId], + references: [workspaceMembers.id], + relationName: "memberPermissions", + }), }), );