{"record":{"id":"af584a4ccbe08b62","repo":"toeverything/AFFiNE","slug":"new-workspace-owner-must-be-an-active-member","errorCode":null,"errorMessage":"New workspace owner must be an active member.","messagePattern":"New workspace owner must be an active member\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/backend/server/src/models/permission-write.ts","lineNumber":141,"sourceCode":"@Injectable()\nexport class WorkspaceMemberModel extends BaseModel {\n  @Transactional()\n  async setOwner(\n    workspaceId: string,\n    userId: string,\n    fallbackRole: WorkspaceRole\n  ) {\n    await this.db\n      .$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`permission:workspace-owner:${workspaceId}`}, 0))`;\n    const ownerCount = await this.db.workspaceMember.count({\n      where: { workspaceId, role: 'owner', state: 'active' },\n    });\n    if (ownerCount > 0) {\n      const target = await this.db.workspaceMember.findFirst({\n        where: { workspaceId, userId, state: 'active' },\n      });\n      if (!target) {\n        throw new Error('New workspace owner must be an active member.');\n      }\n    }\n\n    await this.db.workspaceMember.updateMany({\n      where: {\n        workspaceId,\n        role: 'owner',\n        userId: { not: userId },\n        state: 'active',\n      },\n      data: {\n        role: workspaceRoleToNew(fallbackRole),\n        source: 'legacy',\n      },\n    });\n\n    return await this.db.workspaceMember.upsert({\n      where: {","sourceCodeStart":123,"sourceCodeEnd":159,"githubUrl":"https://github.com/toeverything/AFFiNE/blob/26c515e050211269e911f7d9cfe162a26c83ed98/packages/backend/server/src/models/permission-write.ts#L123-L159","documentation":"Plain Error thrown by WorkspaceMemberModel.setOwner (packages/backend/server/src/models/permission-write.ts:141). setOwner takes a PostgreSQL advisory lock (pg_advisory_xact_lock on a hash of the workspace id), counts active owners, and — if at least one active owner exists — requires the target userId to be an active member of the workspace. If findFirst by (workspaceId, userId, state:'active') returns null, the guard throws. Because it is a plain Error, callers see an internal_server_error unless they validate first.","triggerScenarios":"Calling setOwner(workspaceId, userId, fallbackRole) when an active owner already exists but userId is not an active member (invited, pending, removed, or in a different workspace). The advisory lock serializes owner transfers per workspace, then the active-membership check fails.","commonSituations":"Transferring ownership to a user who was invited but never accepted; to a removed member; to a user from another workspace; race where the member is deactivated between the UI action and the call.","solutions":["Ensure the target user is an active member first: setActive or accept their invitation before setOwner.","Pre-check: const m = await db.workspaceMember.findFirst({ where: { workspaceId, userId, state: 'active' } }); throw early if missing.","Surface a clear error in the UI ('Invitee must accept and be active before ownership transfer').","For first-owner setup (no existing owner), this guard is skipped — only the active-member path triggers it."],"exampleFix":"// before\nawait memberModel.setOwner(workspaceId, newOwnerId, WorkspaceRole.Admin);\n// after\nconst active = await db.workspaceMember.findFirst({ where: { workspaceId, userId: newOwnerId, state: 'active' } });\nif (!active) throw new Error('Promote the user to active member first.');\nawait memberModel.setOwner(workspaceId, newOwnerId, WorkspaceRole.Admin);","handlingStrategy":"validation","validationCode":"const active = await db.workspaceMember.findFirst({\n  where: { workspaceId, userId, state: 'active' },\n});\nif (!active) {\n  throw new Error('Target user must be an active member before ownership transfer');\n}\nawait memberModel.setOwner(workspaceId, userId, WorkspaceRole.Admin);","typeGuard":"const isActiveMember = (m: { state: string } | null): m is { state: 'active' } =>\n  m !== null && m.state === 'active';","tryCatchPattern":"try {\n  await memberModel.setOwner(workspaceId, userId, fallback);\n} catch (e) {\n  if (e instanceof Error && /active member/i.test(e.message)) {\n    ui.warn('Invitee must be an active member first.');\n    return;\n  }\n  throw e;\n}","preventionTips":["Activate or invite-and-accept the target member before calling setOwner.","Pre-check active membership to convert the plain Error into a clear UI message.","Remember setOwner is serialized per workspace by an advisory lock."],"tags":["workspace","permission","owner","transfer"],"backgroundTag":null,"analyzedSha":"26c515e050211269e911f7d9cfe162a26c83ed98","analyzedAt":"2026-08-12T13:15:16.447Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}