chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissions, workflow, workspace as workspaceTable } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { PermissionType } from '@sim/platform-authz/workspace'
|
||||
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, count, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
|
||||
const logger = createLogger('WorkspaceUtils')
|
||||
|
||||
export interface WorkspaceBillingSettings {
|
||||
billedAccountUserId: string | null
|
||||
allowPersonalApiKeys: boolean
|
||||
}
|
||||
|
||||
export type WorkspaceScope = 'active' | 'archived' | 'all'
|
||||
|
||||
export async function getWorkspaceBillingSettings(
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceBillingSettings | null> {
|
||||
if (!workspaceId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
billedAccountUserId: workspaceTable.billedAccountUserId,
|
||||
allowPersonalApiKeys: workspaceTable.allowPersonalApiKeys,
|
||||
})
|
||||
.from(workspaceTable)
|
||||
.where(and(eq(workspaceTable.id, workspaceId), isNull(workspaceTable.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
billedAccountUserId: rows[0].billedAccountUserId ?? null,
|
||||
allowPersonalApiKeys: rows[0].allowPersonalApiKeys ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorkspaceBilledAccountUserId(workspaceId: string): Promise<string | null> {
|
||||
const settings = await getWorkspaceBillingSettings(workspaceId)
|
||||
return settings?.billedAccountUserId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspaces the user administers purely through organization owner/admin role,
|
||||
* with no explicit permission row required. Empty when the user is not an org
|
||||
* owner/admin. Implements the workspace-permission inheritance model.
|
||||
*/
|
||||
export async function getOrgAdminWorkspaceRows(
|
||||
userId: string,
|
||||
scope: WorkspaceScope = 'active'
|
||||
): Promise<Array<typeof workspaceTable.$inferSelect>> {
|
||||
const [membership] = await db
|
||||
.select({ organizationId: member.organizationId, role: member.role })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!membership || !isOrgAdminRole(membership.role)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const orgFilter = eq(workspaceTable.organizationId, membership.organizationId)
|
||||
const where =
|
||||
scope === 'all'
|
||||
? orgFilter
|
||||
: scope === 'archived'
|
||||
? and(orgFilter, sql`${workspaceTable.archivedAt} IS NOT NULL`)
|
||||
: and(orgFilter, isNull(workspaceTable.archivedAt))
|
||||
|
||||
return db.select().from(workspaceTable).where(where).orderBy(desc(workspaceTable.createdAt))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every workspace a user can access: explicit permission grants plus workspaces
|
||||
* derived from organization owner/admin role. Deduped with explicit rows first.
|
||||
*/
|
||||
export async function listAccessibleWorkspaceRowsForUser(
|
||||
userId: string,
|
||||
scope: WorkspaceScope = 'active'
|
||||
): Promise<
|
||||
Array<{ workspace: typeof workspaceTable.$inferSelect; permissionType: PermissionType }>
|
||||
> {
|
||||
const explicit = await db
|
||||
.select({ workspace: workspaceTable, permissionType: permissions.permissionType })
|
||||
.from(permissions)
|
||||
.innerJoin(workspaceTable, eq(permissions.entityId, workspaceTable.id))
|
||||
.where(
|
||||
scope === 'all'
|
||||
? and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'))
|
||||
: scope === 'archived'
|
||||
? and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
sql`${workspaceTable.archivedAt} IS NOT NULL`
|
||||
)
|
||||
: and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
isNull(workspaceTable.archivedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(workspaceTable.createdAt))
|
||||
|
||||
const orgRows = await getOrgAdminWorkspaceRows(userId, scope)
|
||||
if (orgRows.length === 0) {
|
||||
return explicit
|
||||
}
|
||||
|
||||
const orgWorkspaceIds = new Set(orgRows.map((ws) => ws.id))
|
||||
const seen = new Set(explicit.map((row) => row.workspace.id))
|
||||
|
||||
const elevatedExplicit = explicit.map((row) =>
|
||||
orgWorkspaceIds.has(row.workspace.id) ? { ...row, permissionType: 'admin' as const } : row
|
||||
)
|
||||
const derived = orgRows
|
||||
.filter((ws) => !seen.has(ws.id))
|
||||
.map((ws) => ({ workspace: ws, permissionType: 'admin' as const }))
|
||||
|
||||
return [...elevatedExplicit, ...derived]
|
||||
}
|
||||
|
||||
export async function listUserWorkspaces(userId: string, scope: WorkspaceScope = 'active') {
|
||||
const rows = await listAccessibleWorkspaceRowsForUser(userId, scope)
|
||||
|
||||
return rows.map(({ workspace: ws, permissionType }) => ({
|
||||
workspaceId: ws.id,
|
||||
workspaceName: ws.name,
|
||||
role: ws.ownerId === userId ? 'owner' : permissionType,
|
||||
}))
|
||||
}
|
||||
|
||||
export interface ReassignBilledAccountResult {
|
||||
reassigned: Array<{ workspaceId: string; newBilledAccountUserId: string }>
|
||||
unresolved: string[]
|
||||
}
|
||||
|
||||
export interface ReassignWorkflowOwnershipResult {
|
||||
reassigned: Array<{ workspaceId: string; newWorkflowUserId: string; workflowCount: number }>
|
||||
unresolved: string[]
|
||||
}
|
||||
|
||||
export const WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR =
|
||||
'Cannot remove the workspace billing account. Please reassign billing first.'
|
||||
|
||||
export class WorkspaceBillingAccountRemovalError extends Error {
|
||||
constructor() {
|
||||
super(WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR)
|
||||
this.name = 'WorkspaceBillingAccountRemovalError'
|
||||
}
|
||||
}
|
||||
|
||||
export async function transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({
|
||||
tx,
|
||||
workspaceId,
|
||||
departingUserId,
|
||||
}: {
|
||||
tx: DbOrTx
|
||||
workspaceId: string
|
||||
departingUserId: string
|
||||
}): Promise<boolean> {
|
||||
const [workspaceRow] = await tx
|
||||
.select({
|
||||
ownerId: workspaceTable.ownerId,
|
||||
billedAccountUserId: workspaceTable.billedAccountUserId,
|
||||
})
|
||||
.from(workspaceTable)
|
||||
.where(eq(workspaceTable.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceRow || workspaceRow.ownerId !== departingUserId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const newOwnerId = workspaceRow.billedAccountUserId
|
||||
if (!newOwnerId || newOwnerId === departingUserId) {
|
||||
throw new WorkspaceBillingAccountRemovalError()
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(workspaceTable)
|
||||
.set({ ownerId: newOwnerId, updatedAt: new Date() })
|
||||
.where(eq(workspaceTable.id, workspaceId))
|
||||
|
||||
const [existingNewOwnerPermission] = await tx
|
||||
.select({ id: permissions.id })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, newOwnerId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingNewOwnerPermission) {
|
||||
await tx
|
||||
.update(permissions)
|
||||
.set({ permissionType: 'admin', updatedAt: new Date() })
|
||||
.where(eq(permissions.id, existingNewOwnerPermission.id))
|
||||
return true
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
await tx.insert(permissions).values({
|
||||
id: generateId(),
|
||||
userId: newOwnerId,
|
||||
entityType: 'workspace',
|
||||
entityId: workspaceId,
|
||||
permissionType: 'admin',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassigns workflows owned by a user who is about to lose access to one or more workspaces.
|
||||
*
|
||||
* Workflow execution, webhook provider config, and environment variables intentionally resolve
|
||||
* through `workflow.userId`, so that identity must remain an active workspace identity. The
|
||||
* replacement is the workspace billed account: the same stable identity used for server-side
|
||||
* billing/permission actor resolution, and one the member-removal routes protect from removal.
|
||||
*/
|
||||
export async function reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
|
||||
tx,
|
||||
workspaceIds,
|
||||
departingUserId,
|
||||
}: {
|
||||
tx: DbOrTx
|
||||
workspaceIds: string[]
|
||||
departingUserId: string
|
||||
}): Promise<ReassignWorkflowOwnershipResult> {
|
||||
const uniqueWorkspaceIds = Array.from(new Set(workspaceIds.filter(Boolean)))
|
||||
if (uniqueWorkspaceIds.length === 0) {
|
||||
return { reassigned: [], unresolved: [] }
|
||||
}
|
||||
|
||||
const workspaceRows = await tx
|
||||
.select({
|
||||
id: workspaceTable.id,
|
||||
billedAccountUserId: workspaceTable.billedAccountUserId,
|
||||
})
|
||||
.from(workspaceTable)
|
||||
.where(inArray(workspaceTable.id, uniqueWorkspaceIds))
|
||||
|
||||
const reassigned: ReassignWorkflowOwnershipResult['reassigned'] = []
|
||||
const unresolved: string[] = []
|
||||
const reassignmentWorkspaceIds: string[] = []
|
||||
const workflowCounts = await tx
|
||||
.select({
|
||||
workspaceId: workflow.workspaceId,
|
||||
workflowCount: count(workflow.id),
|
||||
})
|
||||
.from(workflow)
|
||||
.where(
|
||||
and(eq(workflow.userId, departingUserId), inArray(workflow.workspaceId, uniqueWorkspaceIds))
|
||||
)
|
||||
.groupBy(workflow.workspaceId)
|
||||
|
||||
const workflowCountsByWorkspaceId = new Map<string, number>()
|
||||
for (const { workspaceId, workflowCount } of workflowCounts) {
|
||||
if (!workspaceId || workflowCount === 0) continue
|
||||
workflowCountsByWorkspaceId.set(workspaceId, workflowCount)
|
||||
}
|
||||
|
||||
for (const ws of workspaceRows) {
|
||||
const workflowCount = workflowCountsByWorkspaceId.get(ws.id) ?? 0
|
||||
if (workflowCount === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const replacementUserId =
|
||||
ws.billedAccountUserId !== departingUserId ? ws.billedAccountUserId : null
|
||||
|
||||
if (!replacementUserId) {
|
||||
unresolved.push(ws.id)
|
||||
continue
|
||||
}
|
||||
|
||||
reassignmentWorkspaceIds.push(ws.id)
|
||||
}
|
||||
|
||||
if (reassignmentWorkspaceIds.length > 0) {
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({
|
||||
userId: sql<string>`(
|
||||
select ${workspaceTable.billedAccountUserId}
|
||||
from ${workspaceTable}
|
||||
where ${workspaceTable.id} = ${workflow.workspaceId}
|
||||
)`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(workflow.userId, departingUserId),
|
||||
inArray(workflow.workspaceId, reassignmentWorkspaceIds)
|
||||
)
|
||||
)
|
||||
|
||||
const billedAccountByWorkspaceId = new Map(
|
||||
workspaceRows.map((ws) => [ws.id, ws.billedAccountUserId])
|
||||
)
|
||||
for (const workspaceId of reassignmentWorkspaceIds) {
|
||||
const workflowCount = workflowCountsByWorkspaceId.get(workspaceId) ?? 0
|
||||
const newWorkflowUserId = billedAccountByWorkspaceId.get(workspaceId)
|
||||
if (!newWorkflowUserId) continue
|
||||
reassigned.push({
|
||||
workspaceId,
|
||||
newWorkflowUserId,
|
||||
workflowCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (reassigned.length > 0 || unresolved.length > 0) {
|
||||
logger.info('Reassigned workflow ownership for removed workspace member', {
|
||||
departingUserId,
|
||||
reassigned,
|
||||
unresolved,
|
||||
})
|
||||
}
|
||||
|
||||
return { reassigned, unresolved }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassigns `billedAccountUserId` on every workspace that points to `departingUserId` to
|
||||
* another eligible user, so the user can be deleted without violating the `workspace.billed_account_user_id`
|
||||
* foreign key (`ON DELETE NO ACTION`).
|
||||
*
|
||||
* Preference order for the replacement:
|
||||
* 1. The workspace owner (if different from the departing user)
|
||||
* 2. Any existing workspace admin
|
||||
*
|
||||
* Returns the list of workspaces that could not be reassigned (no owner + no admin). Callers should
|
||||
* block user deletion when `unresolved.length > 0` so we never leave an orphaned billing reference.
|
||||
*/
|
||||
export async function reassignBilledAccountForUser(
|
||||
departingUserId: string
|
||||
): Promise<ReassignBilledAccountResult> {
|
||||
const billedWorkspaces = await db
|
||||
.select({
|
||||
id: workspaceTable.id,
|
||||
ownerId: workspaceTable.ownerId,
|
||||
})
|
||||
.from(workspaceTable)
|
||||
.where(eq(workspaceTable.billedAccountUserId, departingUserId))
|
||||
|
||||
if (billedWorkspaces.length === 0) {
|
||||
return { reassigned: [], unresolved: [] }
|
||||
}
|
||||
|
||||
const reassigned: ReassignBilledAccountResult['reassigned'] = []
|
||||
const unresolved: string[] = []
|
||||
|
||||
for (const ws of billedWorkspaces) {
|
||||
let replacement: string | null = ws.ownerId !== departingUserId ? ws.ownerId : null
|
||||
|
||||
if (!replacement) {
|
||||
const [admin] = await db
|
||||
.select({ userId: permissions.userId })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, ws.id),
|
||||
eq(permissions.permissionType, 'admin'),
|
||||
ne(permissions.userId, departingUserId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
replacement = admin?.userId ?? null
|
||||
}
|
||||
|
||||
if (!replacement) {
|
||||
unresolved.push(ws.id)
|
||||
continue
|
||||
}
|
||||
|
||||
await db
|
||||
.update(workspaceTable)
|
||||
.set({ billedAccountUserId: replacement, updatedAt: new Date() })
|
||||
.where(eq(workspaceTable.id, ws.id))
|
||||
|
||||
reassigned.push({ workspaceId: ws.id, newBilledAccountUserId: replacement })
|
||||
}
|
||||
|
||||
if (reassigned.length > 0) {
|
||||
logger.info('Reassigned workspace billed account for departing user', {
|
||||
departingUserId,
|
||||
reassignedCount: reassigned.length,
|
||||
unresolvedCount: unresolved.length,
|
||||
})
|
||||
}
|
||||
|
||||
return { reassigned, unresolved }
|
||||
}
|
||||
|
||||
export interface ReassignOwnedWorkspacesResult {
|
||||
reassigned: Array<{ workspaceId: string; newOwnerId: string }>
|
||||
unresolved: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassigns `ownerId` on every workspace owned by `departingUserId` to another
|
||||
* eligible user, so the user can be deleted without the `workspace.owner_id`
|
||||
* `ON DELETE CASCADE` silently deleting their workspaces.
|
||||
*
|
||||
* Preference order for the replacement:
|
||||
* 1. The workspace billed account (if different from the departing user)
|
||||
* 2. Any other workspace admin
|
||||
*
|
||||
* Returns workspaces that could not be reassigned (no distinct billed account and
|
||||
* no other admin). Callers MUST block user deletion when `unresolved.length > 0`
|
||||
* so the cascade can never nuke a workspace.
|
||||
*/
|
||||
export async function reassignOwnedWorkspacesForUser(
|
||||
departingUserId: string
|
||||
): Promise<ReassignOwnedWorkspacesResult> {
|
||||
const ownedWorkspaces = await db
|
||||
.select({
|
||||
id: workspaceTable.id,
|
||||
billedAccountUserId: workspaceTable.billedAccountUserId,
|
||||
})
|
||||
.from(workspaceTable)
|
||||
.where(eq(workspaceTable.ownerId, departingUserId))
|
||||
|
||||
if (ownedWorkspaces.length === 0) {
|
||||
return { reassigned: [], unresolved: [] }
|
||||
}
|
||||
|
||||
const reassigned: ReassignOwnedWorkspacesResult['reassigned'] = []
|
||||
const unresolved: string[] = []
|
||||
|
||||
for (const ws of ownedWorkspaces) {
|
||||
let replacement: string | null =
|
||||
ws.billedAccountUserId !== departingUserId ? ws.billedAccountUserId : null
|
||||
|
||||
if (!replacement) {
|
||||
const [admin] = await db
|
||||
.select({ userId: permissions.userId })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, ws.id),
|
||||
eq(permissions.permissionType, 'admin'),
|
||||
ne(permissions.userId, departingUserId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
replacement = admin?.userId ?? null
|
||||
}
|
||||
|
||||
if (!replacement) {
|
||||
unresolved.push(ws.id)
|
||||
continue
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
await db
|
||||
.update(workspaceTable)
|
||||
.set({ ownerId: replacement, updatedAt: now })
|
||||
.where(eq(workspaceTable.id, ws.id))
|
||||
|
||||
// Owners are admins — guarantee the new owner holds an admin permission row.
|
||||
await db
|
||||
.insert(permissions)
|
||||
.values({
|
||||
id: generateId(),
|
||||
userId: replacement,
|
||||
entityType: 'workspace',
|
||||
entityId: ws.id,
|
||||
permissionType: 'admin',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [permissions.userId, permissions.entityType, permissions.entityId],
|
||||
set: { permissionType: 'admin', updatedAt: now },
|
||||
})
|
||||
|
||||
reassigned.push({ workspaceId: ws.id, newOwnerId: replacement })
|
||||
}
|
||||
|
||||
if (reassigned.length > 0) {
|
||||
logger.info('Reassigned workspace ownership for departing user', {
|
||||
departingUserId,
|
||||
reassignedCount: reassigned.length,
|
||||
unresolvedCount: unresolved.length,
|
||||
})
|
||||
}
|
||||
|
||||
return { reassigned, unresolved }
|
||||
}
|
||||
Reference in New Issue
Block a user