diff --git a/packages/db/src/repository/member.repo.ts b/packages/db/src/repository/member.repo.ts index da081718..8098ed13 100644 --- a/packages/db/src/repository/member.repo.ts +++ b/packages/db/src/repository/member.repo.ts @@ -55,6 +55,12 @@ export const getByPublicId = async (db: dbClient, publicId: string) => { }); }; +export const getById = async (db: dbClient, memberId: number) => { + return db.query.workspaceMembers.findFirst({ + where: eq(workspaceMembers.id, memberId), + }); +}; + export const getByEmailAndStatus = async ( db: dbClient, email: string, @@ -133,3 +139,28 @@ export const pauseAllMembers = async (db: dbClient, workspaceId: number) => { ), ); }; + +export const updateRole = async ( + db: dbClient, + args: { + memberId: number; + role: MemberRole; + roleId: number | null; + }, +) => { + const [result] = await db + .update(workspaceMembers) + .set({ + role: args.role, + roleId: args.roleId, + updatedAt: new Date(), + }) + .where(eq(workspaceMembers.id, args.memberId)) + .returning({ + id: workspaceMembers.id, + publicId: workspaceMembers.publicId, + role: workspaceMembers.role, + }); + + return result; +}; diff --git a/packages/db/src/repository/permission.repo.ts b/packages/db/src/repository/permission.repo.ts new file mode 100644 index 00000000..a98fbdc2 --- /dev/null +++ b/packages/db/src/repository/permission.repo.ts @@ -0,0 +1,332 @@ +import { and, eq, isNull } from "drizzle-orm"; + +import type { dbClient } from "@kan/db/client"; +import { + workspaceMemberPermissions, + workspaceMembers, + workspaceRolePermissions, + workspaceRoles, +} from "@kan/db/schema"; +import type { Permission, Role } from "@kan/shared"; +import { generateUID, getDefaultPermissions } from "@kan/shared"; + +/** + * Get permissions by role ID + */ +export const getPermissionsByRoleId = async ( + db: dbClient, + roleId: number, +): Promise => { + const permissions = await db + .select({ permission: workspaceRolePermissions.permission }) + .from(workspaceRolePermissions) + .where( + and( + eq(workspaceRolePermissions.workspaceRoleId, roleId), + eq(workspaceRolePermissions.granted, true), + ), + ); + + return permissions.map((p) => p.permission as Permission); +}; + +/** + * Get role by workspace ID and name + */ +export const getRoleByWorkspaceIdAndName = async ( + db: dbClient, + workspaceId: number, + name: string, +) => { + const [role] = await db + .select() + .from(workspaceRoles) + .where( + and( + eq(workspaceRoles.workspaceId, workspaceId), + eq(workspaceRoles.name, name), + ), + ) + .limit(1); + + return role; +}; + +/** + * Get all custom permission overrides for a workspace member + */ +export const getMemberPermissionOverrides = async ( + db: dbClient, + workspaceMemberId: number, +) => { + return db + .select() + .from(workspaceMemberPermissions) + .where(eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId)); +}; + +/** + * Get effective permissions for a workspace member + * Combines role template (from DB) with custom overrides + */ +export const getMemberEffectivePermissions = async ( + db: dbClient, + workspaceMemberId: number, + roleId: number | null, + roleName: string, +): Promise => { + let roleDefaults: Set; + + // Try to get role permissions from database first + if (roleId) { + const dbPermissions = await getPermissionsByRoleId(db, roleId); + roleDefaults = new Set(dbPermissions); + } else { + // Fallback to code-based defaults if roleId not set + const codeDefaults = getDefaultPermissions(roleName as Role); + roleDefaults = new Set([...codeDefaults]); + } + + // Get custom overrides + const overrides = await getMemberPermissionOverrides(db, workspaceMemberId); + + // Apply overrides + for (const override of overrides) { + if (override.granted) { + roleDefaults.add(override.permission as Permission); + } else { + roleDefaults.delete(override.permission as Permission); + } + } + + return Array.from(roleDefaults); +}; + +/** + * Check if a member has a specific permission + */ +export const memberHasPermission = async ( + db: dbClient, + workspaceMemberId: number, + roleId: number | null, + roleName: string, + permission: Permission, +): Promise => { + let hasRoleDefault: boolean; + + // Check role permission from database first + if (roleId) { + const dbPermissions = await getPermissionsByRoleId(db, roleId); + hasRoleDefault = dbPermissions.includes(permission); + } else { + // Fallback to code-based defaults + const codeDefaults = getDefaultPermissions(roleName as Role); + hasRoleDefault = codeDefaults.includes(permission); + } + + // Check for override + const [override] = await db + .select() + .from(workspaceMemberPermissions) + .where( + and( + eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId), + eq(workspaceMemberPermissions.permission, permission), + ), + ) + .limit(1); + + // Override takes precedence + if (override) { + return override.granted; + } + + return hasRoleDefault; +}; + +/** + * Grant a permission to a member + */ +export const grantPermission = async ( + db: dbClient, + workspaceMemberId: number, + permission: Permission, +) => { + const [result] = await db + .insert(workspaceMemberPermissions) + .values({ + workspaceMemberId, + permission, + granted: true, + }) + .onConflictDoUpdate({ + target: [ + workspaceMemberPermissions.workspaceMemberId, + workspaceMemberPermissions.permission, + ], + set: { + granted: true, + updatedAt: new Date(), + }, + }) + .returning(); + + return result; +}; + +/** + * Revoke a permission from a member + */ +export const revokePermission = async ( + db: dbClient, + workspaceMemberId: number, + permission: Permission, +) => { + const [result] = await db + .insert(workspaceMemberPermissions) + .values({ + workspaceMemberId, + permission, + granted: false, + }) + .onConflictDoUpdate({ + target: [ + workspaceMemberPermissions.workspaceMemberId, + workspaceMemberPermissions.permission, + ], + set: { + granted: false, + updatedAt: new Date(), + }, + }) + .returning(); + + return result; +}; + +/** + * Get member with their role by userId and workspaceId + */ +export const getMemberWithRole = async ( + db: dbClient, + userId: string, + workspaceId: number, +) => { + const [member] = await db + .select({ + id: workspaceMembers.id, + publicId: workspaceMembers.publicId, + role: workspaceMembers.role, + roleId: workspaceMembers.roleId, + }) + .from(workspaceMembers) + .where( + and( + eq(workspaceMembers.userId, userId), + eq(workspaceMembers.workspaceId, workspaceId), + isNull(workspaceMembers.deletedAt), + ), + ) + .limit(1); + + return member; +}; + +/** + * Check if user has permission in workspace + */ +export const userHasPermissionInWorkspace = async ( + db: dbClient, + userId: string, + workspaceId: number, + permission: Permission, +): Promise => { + const member = await getMemberWithRole(db, userId, workspaceId); + + if (!member) { + return false; + } + + return memberHasPermission( + db, + member.id, + member.roleId, + member.role, + permission, + ); +}; + +/** + * Get all permissions for a user in a workspace + */ +export const getUserPermissionsInWorkspace = async ( + db: dbClient, + userId: string, + workspaceId: number, +): Promise<{ + permissions: Permission[]; + role: string; + roleId: number | null; +} | null> => { + const member = await getMemberWithRole(db, userId, workspaceId); + + if (!member) { + return null; + } + + const permissions = await getMemberEffectivePermissions( + db, + member.id, + member.roleId, + member.role, + ); + + return { + permissions, + role: member.role, + roleId: member.roleId, + }; +}; + +/** + * Create a role for a workspace + */ +export const createRole = async ( + db: dbClient, + args: { + workspaceId: number; + name: string; + description: string; + hierarchyLevel: number; + isSystem: boolean; + permissions: Permission[]; + }, +) => { + const [role] = await db + .insert(workspaceRoles) + .values({ + publicId: generateUID(), + workspaceId: args.workspaceId, + name: args.name, + description: args.description, + hierarchyLevel: args.hierarchyLevel, + isSystem: args.isSystem, + }) + .returning({ + id: workspaceRoles.id, + name: workspaceRoles.name, + publicId: workspaceRoles.publicId, + }); + + if (role && args.permissions.length > 0) { + await db.insert(workspaceRolePermissions).values( + args.permissions.map((perm) => ({ + workspaceRoleId: role.id, + permission: perm, + granted: true, + })), + ); + } + + return role; +}; diff --git a/packages/db/src/repository/workspace.repo.ts b/packages/db/src/repository/workspace.repo.ts index 1ebd531e..cca502f2 100644 --- a/packages/db/src/repository/workspace.repo.ts +++ b/packages/db/src/repository/workspace.repo.ts @@ -18,7 +18,33 @@ import { workspaceMembers, workspaces, } from "@kan/db/schema"; -import { generateUID } from "@kan/shared/utils"; +import type { Permission, Role } from "@kan/shared"; +import { generateUID, getDefaultPermissions } from "@kan/shared"; + +import * as permissionRepo from "./permission.repo"; + +// System role definitions +const SYSTEM_ROLES: { + name: Role; + description: string; + hierarchyLevel: number; +}[] = [ + { + name: "admin", + description: "Full access to all workspace features", + hierarchyLevel: 100, + }, + { + name: "member", + description: "Standard member with create and edit permissions", + hierarchyLevel: 50, + }, + { + name: "guest", + description: "View-only access", + hierarchyLevel: 10, + }, +]; export const getCount = async (db: dbClient) => { const result = await db @@ -57,6 +83,22 @@ export const create = async ( }); if (workspace) { + // Create system roles for the workspace + let adminRoleId: number | null = null; + for (const roleData of SYSTEM_ROLES) { + const role = await permissionRepo.createRole(db, { + workspaceId: workspace.id, + name: roleData.name, + description: roleData.description, + hierarchyLevel: roleData.hierarchyLevel, + isSystem: true, + permissions: [...getDefaultPermissions(roleData.name)] as Permission[], + }); + if (roleData.name === "admin" && role) { + adminRoleId = role.id; + } + } + await db.insert(workspaceMembers).values({ publicId: generateUID(), userId: workspaceInput.createdBy, @@ -64,6 +106,7 @@ export const create = async ( workspaceId: workspace.id, createdBy: workspaceInput.createdBy, role: "admin", + roleId: adminRoleId, status: "active", }); }