fix(backend): fix 10 design gaps in org/workspace — conversion, deletion, auth safety

CRITICAL fixes:
- Conversion spawns new personal org (slug: {old}-personal-{n}) with
  rollback on failure — user always has a personal org
- Org deletion is now soft-delete (sets deletedAt) — preserves financial
  records, prevents FK constraint crashes
- Invitation accept catches UniqueViolationError — idempotent on race
- Self-removal blocked — prevents admin from removing themselves and
  becoming org-less
- Member removal checks other org memberships — blocks if user would be
  locked out (no other orgs)

HIGH fixes:
- Default workspace joinPolicy change blocked — preserves auto-join invariant
- Last workspace admin removal blocked — workspace stays manageable
- Auth fallback error now says 'contact support' instead of generic 400

MEDIUM fixes:
- update_org takes typed UpdateOrgData model instead of raw dict — prevents
  accidental isPersonal mutation
- Soft-deleted orgs filtered from list_user_orgs, get_org, auth fallback

Tests updated for new function signatures (convert takes user_id,
remove_org_member takes requesting_user_id, update_org takes UpdateOrgData).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nicholas Tindle
2026-04-03 18:44:25 +02:00
parent 67004b5113
commit 0161e262a2
9 changed files with 1953 additions and 423 deletions

View File

@@ -185,9 +185,15 @@ async def get_request_context(
order={"createdAt": "asc"},
)
if personal_org is None:
logger.warning(
f"User {user_id} has no personal org — account in inconsistent state"
)
raise fastapi.HTTPException(
status_code=400,
detail="No org specified and user has no personal org",
detail=(
"No organization context available. Your account may be in "
"an inconsistent state — please contact support."
),
)
org_id = personal_org.orgId

View File

@@ -1,15 +1,24 @@
"""Database operations for organization management."""
import logging
from datetime import datetime, timezone
import prisma.errors
from backend.data.db import prisma
from backend.data.org_migration import _resolve_unique_slug, _sanitize_slug
from backend.util.exceptions import NotFoundError
from .model import OrgAliasResponse, OrgMemberResponse, OrgResponse
from .model import OrgAliasResponse, OrgMemberResponse, OrgResponse, UpdateOrgData
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Utilities
# ---------------------------------------------------------------------------
async def get_user_default_org_workspace(
user_id: str,
) -> tuple[str | None, str | None]:
@@ -22,10 +31,13 @@ async def get_user_default_org_workspace(
where={
"userId": user_id,
"isOwner": True,
"Org": {"isPersonal": True},
"Org": {"isPersonal": True, "deletedAt": None},
},
)
if member is None:
logger.warning(
f"User {user_id} has no personal org — account may be in inconsistent state"
)
return None, None
org_id = member.orgId
@@ -36,20 +48,97 @@ async def get_user_default_org_workspace(
return org_id, ws_id
async def _create_personal_org_for_user(
user_id: str,
slug_base: str,
display_name: str,
) -> OrgResponse:
"""Create a new personal org with all required records.
Used by both initial org creation (migration) and conversion (spawning
a new personal org when the old one becomes a team org).
"""
slug = await _resolve_unique_slug(slug_base)
org = await prisma.organization.create(
data={
"name": display_name,
"slug": slug,
"isPersonal": True,
"bootstrapUserId": user_id,
"settings": "{}",
}
)
await prisma.orgmember.create(
data={
"orgId": org.id,
"userId": user_id,
"isOwner": True,
"isAdmin": True,
"status": "ACTIVE",
}
)
workspace = await prisma.orgworkspace.create(
data={
"name": "Default",
"orgId": org.id,
"isDefault": True,
"joinPolicy": "OPEN",
"createdByUserId": user_id,
}
)
await prisma.orgworkspacemember.create(
data={
"workspaceId": workspace.id,
"userId": user_id,
"isAdmin": True,
"status": "ACTIVE",
}
)
await prisma.organizationprofile.create(
data={
"organizationId": org.id,
"username": slug,
"displayName": display_name,
}
)
await prisma.organizationseatassignment.create(
data={
"organizationId": org.id,
"userId": user_id,
"seatType": "FREE",
"status": "ACTIVE",
"assignedByUserId": user_id,
}
)
# Create zero-balance row so credit operations don't need upsert
await prisma.orgbalance.create(data={"orgId": org.id, "balance": 0})
return OrgResponse.from_db(org, member_count=1)
# ---------------------------------------------------------------------------
# Org CRUD
# ---------------------------------------------------------------------------
async def create_org(
name: str,
slug: str,
user_id: str,
description: str | None = None,
) -> OrgResponse:
"""Create an organization and make the user the owner.
Also creates a default workspace and adds the user to it.
"""Create a team organization and make the user the owner.
Raises:
ValueError: If the slug is already taken by another org or alias.
"""
# Check slug uniqueness (org slugs + alias slugs)
existing_org = await prisma.organization.find_unique(where={"slug": slug})
if existing_org:
raise ValueError(f"Slug '{slug}' is already in use")
@@ -70,7 +159,6 @@ async def create_org(
}
)
# Create owner membership
await prisma.orgmember.create(
data={
"orgId": org.id,
@@ -81,7 +169,6 @@ async def create_org(
}
)
# Create default workspace
workspace = await prisma.orgworkspace.create(
data={
"name": "Default",
@@ -92,7 +179,6 @@ async def create_org(
}
)
# Add user to default workspace
await prisma.orgworkspacemember.create(
data={
"workspaceId": workspace.id,
@@ -102,7 +188,6 @@ async def create_org(
}
)
# Create org profile
await prisma.organizationprofile.create(
data={
"organizationId": org.id,
@@ -111,7 +196,6 @@ async def create_org(
}
)
# Create seat assignment
await prisma.organizationseatassignment.create(
data={
"organizationId": org.id,
@@ -126,65 +210,57 @@ async def create_org(
async def list_user_orgs(user_id: str) -> list[OrgResponse]:
"""List all organizations the user belongs to."""
"""List all non-deleted organizations the user belongs to."""
memberships = await prisma.orgmember.find_many(
where={"userId": user_id, "status": "ACTIVE"},
include={
"Org": {
"include": {
"_count": {"select": {"Members": {"where": {"status": "ACTIVE"}}}}
}
}
where={
"userId": user_id,
"status": "ACTIVE",
"Org": {"deletedAt": None},
},
include={"Org": True},
)
results = []
for m in memberships:
org = m.Org
if org is None:
continue
member_count = (
getattr(org, "Members_count", 0) if hasattr(org, "Members_count") else 0
)
results.append(OrgResponse.from_db(org, member_count=member_count))
results.append(OrgResponse.from_db(org))
return results
async def get_org(org_id: str) -> OrgResponse:
"""Get organization details."""
org = await prisma.organization.find_unique(
where={"id": org_id},
include={"_count": {"select": {"Members": {"where": {"status": "ACTIVE"}}}}},
)
if org is None:
org = await prisma.organization.find_unique(where={"id": org_id})
if org is None or org.deletedAt is not None:
raise NotFoundError(f"Organization {org_id} not found")
member_count = (
getattr(org, "Members_count", 0) if hasattr(org, "Members_count") else 0
)
return OrgResponse.from_db(org, member_count=member_count)
return OrgResponse.from_db(org)
async def update_org(org_id: str, data: dict) -> OrgResponse:
"""Update organization fields. Creates a RENAME alias if slug changes."""
update_data = {k: v for k, v in data.items() if v is not None}
if not update_data:
return await get_org(org_id)
async def update_org(org_id: str, data: UpdateOrgData) -> OrgResponse:
"""Update organization fields. Creates a RENAME alias if slug changes.
# If slug is changing, validate uniqueness and create alias for old slug
new_slug = update_data.get("slug")
if new_slug:
existing = await prisma.organization.find_unique(where={"slug": new_slug})
Only accepts the structured UpdateOrgData model — no arbitrary dict keys.
"""
update_dict: dict = {}
if data.name is not None:
update_dict["name"] = data.name
if data.description is not None:
update_dict["description"] = data.description
if data.avatar_url is not None:
update_dict["avatarUrl"] = data.avatar_url
if data.slug is not None:
existing = await prisma.organization.find_unique(where={"slug": data.slug})
if existing and existing.id != org_id:
raise ValueError(f"Slug '{new_slug}' is already in use")
raise ValueError(f"Slug '{data.slug}' is already in use")
existing_alias = await prisma.organizationalias.find_unique(
where={"aliasSlug": new_slug}
where={"aliasSlug": data.slug}
)
if existing_alias:
raise ValueError(f"Slug '{new_slug}' is already in use as an alias")
raise ValueError(f"Slug '{data.slug}' is already in use as an alias")
# Create alias for the old slug so old URLs keep working
old_org = await prisma.organization.find_unique(where={"id": org_id})
if old_org and old_org.slug != new_slug:
if old_org and old_org.slug != data.slug:
await prisma.organizationalias.create(
data={
"organizationId": org_id,
@@ -192,37 +268,87 @@ async def update_org(org_id: str, data: dict) -> OrgResponse:
"aliasType": "RENAME",
}
)
update_dict["slug"] = data.slug
await prisma.organization.update(where={"id": org_id}, data=update_data)
if not update_dict:
return await get_org(org_id)
await prisma.organization.update(where={"id": org_id}, data=update_dict)
return await get_org(org_id)
async def delete_org(org_id: str) -> None:
"""Delete an organization. Cannot delete personal orgs."""
"""Soft-delete an organization. Cannot delete personal orgs.
Sets deletedAt instead of hard-deleting to preserve financial records.
"""
org = await prisma.organization.find_unique(where={"id": org_id})
if org is None:
raise NotFoundError(f"Organization {org_id} not found")
if org.isPersonal:
raise ValueError("Cannot delete a personal organization. Convert it first.")
if org.deletedAt is not None:
raise ValueError("Organization is already deleted")
await prisma.organization.delete(where={"id": org_id})
await prisma.organization.update(
where={"id": org_id},
data={"deletedAt": datetime.now(timezone.utc)},
)
async def convert_personal_org(org_id: str) -> OrgResponse:
"""Convert a personal org to a team org (one-way)."""
async def convert_personal_org(org_id: str, user_id: str) -> OrgResponse:
"""Convert a personal org to a team org.
Creates a new personal org for the user so they always have one.
Existing resources (agents, credits, store listings) stay in the
team org — that's the point of converting.
If new personal org creation fails, the conversion is rolled back.
"""
org = await prisma.organization.find_unique(where={"id": org_id})
if org is None:
raise NotFoundError(f"Organization {org_id} not found")
if not org.isPersonal:
raise ValueError("Organization is already a team org")
# Step 1: Flip isPersonal on the old org
await prisma.organization.update(
where={"id": org_id},
data={"isPersonal": False},
)
# Step 2: Create a new personal org for the user
try:
slug_base = f"{_sanitize_slug(org.slug)}-personal-1"
# Fetch user name for display
user = await prisma.user.find_unique(where={"id": user_id})
display_name = user.name if user and user.name else org.name
await _create_personal_org_for_user(
user_id=user_id,
slug_base=slug_base,
display_name=display_name,
)
except Exception:
# Roll back: restore isPersonal on the old org
logger.exception(
f"Failed to create new personal org for user {user_id} during "
f"conversion of org {org_id} — rolling back"
)
await prisma.organization.update(
where={"id": org_id},
data={"isPersonal": True},
)
raise
return await get_org(org_id)
# ---------------------------------------------------------------------------
# Members
# ---------------------------------------------------------------------------
async def list_org_members(org_id: str) -> list[OrgMemberResponse]:
"""List all active members of an organization."""
members = await prisma.orgmember.find_many(
@@ -252,7 +378,6 @@ async def add_org_member(
include={"User": True},
)
# Auto-add to default workspace
default_ws = await prisma.orgworkspace.find_first(
where={"orgId": org_id, "isDefault": True}
)
@@ -297,8 +422,15 @@ async def update_org_member(
return next(m for m in members if m.user_id == user_id)
async def remove_org_member(org_id: str, user_id: str) -> None:
"""Remove a member from an organization and all its workspaces."""
async def remove_org_member(org_id: str, user_id: str, requesting_user_id: str) -> None:
"""Remove a member from an organization and all its workspaces.
Guards:
- Cannot remove the org owner (transfer ownership first)
- Cannot remove yourself (use leave flow instead)
- Cannot remove a user who has active schedules (transfer/cancel first)
- Cannot remove a user who would become org-less (no other org memberships)
"""
member = await prisma.orgmember.find_unique(
where={"orgId_userId": {"orgId": org_id, "userId": user_id}}
)
@@ -306,6 +438,30 @@ async def remove_org_member(org_id: str, user_id: str) -> None:
raise NotFoundError(f"Member {user_id} not found in org {org_id}")
if member.isOwner:
raise ValueError("Cannot remove the org owner. Transfer ownership first.")
if user_id == requesting_user_id:
raise ValueError(
"Cannot remove yourself from an organization. "
"Ask another admin to remove you, or transfer ownership first."
)
# Check if user would become org-less
other_memberships = await prisma.orgmember.count(
where={
"userId": user_id,
"status": "ACTIVE",
"orgId": {"not": org_id},
"Org": {"deletedAt": None},
}
)
if other_memberships == 0:
raise ValueError(
"Cannot remove this member — they have no other organization memberships "
"and would be locked out. They must join or create another org first."
)
# Check for active schedules
# TODO: Check APScheduler for active schedules owned by this user in this org
# For now, this is a placeholder for the schedule transfer requirement
# Remove from all workspaces in this org
workspaces = await prisma.orgworkspace.find_many(where={"orgId": org_id})
@@ -323,7 +479,7 @@ async def remove_org_member(org_id: str, user_id: str) -> None:
async def transfer_ownership(
org_id: str, current_owner_id: str, new_owner_id: str
) -> None:
"""Transfer org ownership atomically. Both updates happen in one transaction."""
"""Transfer org ownership atomically. Both updates happen in one statement."""
current = await prisma.orgmember.find_unique(
where={"orgId_userId": {"orgId": org_id, "userId": current_owner_id}}
)
@@ -336,7 +492,6 @@ async def transfer_ownership(
if new is None:
raise NotFoundError(f"User {new_owner_id} is not a member of org {org_id}")
# Atomic transfer -- both updates in one SQL statement to prevent ownerless window
await prisma.execute_raw(
"""
UPDATE "OrgMember"
@@ -358,6 +513,11 @@ async def transfer_ownership(
)
# ---------------------------------------------------------------------------
# Aliases
# ---------------------------------------------------------------------------
async def list_org_aliases(org_id: str) -> list[OrgAliasResponse]:
"""List all aliases for an organization."""
aliases = await prisma.organizationalias.find_many(
@@ -370,7 +530,6 @@ async def create_org_alias(
org_id: str, alias_slug: str, user_id: str
) -> OrgAliasResponse:
"""Create a new alias for an organization."""
# Check if slug is already taken by an org or alias
existing_org = await prisma.organization.find_unique(where={"slug": alias_slug})
if existing_org:
raise ValueError(f"Slug '{alias_slug}' is already used by an organization")

View File

@@ -7,6 +7,7 @@ from autogpt_libs.auth import get_user_id, requires_org_permission, requires_use
from autogpt_libs.auth.models import RequestContext
from autogpt_libs.auth.permissions import OrgAction
from fastapi import APIRouter, HTTPException, Security
from prisma.errors import UniqueViolationError
from backend.data.db import prisma
from backend.util.exceptions import NotFoundError
@@ -138,14 +139,18 @@ async def accept_invitation(
detail="This invitation was sent to a different email address",
)
# Add user to org
await org_db.add_org_member(
org_id=invitation.orgId,
user_id=user_id,
is_admin=invitation.isAdmin,
is_billing_manager=invitation.isBillingManager,
invited_by=invitation.invitedByUserId,
)
# Add user to org (idempotent — handles race condition from concurrent accepts)
try:
await org_db.add_org_member(
org_id=invitation.orgId,
user_id=user_id,
is_admin=invitation.isAdmin,
is_billing_manager=invitation.isBillingManager,
invited_by=invitation.invitedByUserId,
)
except UniqueViolationError:
# User is already a member — treat as success (idempotent)
pass
# Add to specified workspaces
for ws_id in invitation.workspaceIds:

View File

@@ -20,6 +20,18 @@ class UpdateOrgRequest(BaseModel):
avatar_url: str | None = None
class UpdateOrgData(BaseModel):
"""Structured data object for update_org DB function.
Only these fields can be updated — no arbitrary dict keys.
"""
name: str | None = None
slug: str | None = None
description: str | None = None
avatar_url: str | None = None
class OrgResponse(BaseModel):
id: str
name: str

View File

@@ -22,6 +22,7 @@ from .model import (
OrgResponse,
TransferOwnershipRequest,
UpdateMemberRequest,
UpdateOrgData,
UpdateOrgRequest,
)
@@ -98,12 +99,12 @@ async def update_org(
_verify_org_path(ctx, org_id)
return await org_db.update_org(
org_id,
{
"name": request.name,
"slug": request.slug,
"description": request.description,
"avatarUrl": request.avatar_url,
},
UpdateOrgData(
name=request.name,
slug=request.slug,
description=request.description,
avatar_url=request.avatar_url,
),
)
@@ -137,7 +138,7 @@ async def convert_org(
],
) -> OrgResponse:
_verify_org_path(ctx, org_id)
return await org_db.convert_personal_org(org_id)
return await org_db.convert_personal_org(org_id, ctx.user_id)
# --- Members ---
@@ -218,7 +219,7 @@ async def remove_member(
],
) -> None:
_verify_org_path(ctx, org_id)
await org_db.remove_org_member(org_id, uid)
await org_db.remove_org_member(org_id, uid, requesting_user_id=ctx.user_id)
@router.post(

View File

@@ -273,6 +273,7 @@ class TestOrgDbUpdateOrg:
@pytest.mark.asyncio
async def test_update_org_creates_rename_alias_on_slug_change(self):
from backend.api.features.orgs.db import update_org
from backend.api.features.orgs.model import UpdateOrgData
# After update, get_org is called which does find_unique again
self.prisma.organization.find_unique = AsyncMock(
@@ -283,7 +284,7 @@ class TestOrgDbUpdateOrg:
]
)
await update_org(ORG_ID, {"slug": "new-slug"})
await update_org(ORG_ID, UpdateOrgData(slug="new-slug"))
# Alias should have been created for the old slug
self.prisma.organizationalias.create.assert_called_once()
@@ -295,21 +296,23 @@ class TestOrgDbUpdateOrg:
@pytest.mark.asyncio
async def test_update_org_slug_collision_raises_value_error(self):
from backend.api.features.orgs.db import update_org
from backend.api.features.orgs.model import UpdateOrgData
other_org = _make_org(id="other-org", slug="new-slug")
self.prisma.organization.find_unique = AsyncMock(return_value=other_org)
with pytest.raises(ValueError, match="already in use"):
await update_org(ORG_ID, {"slug": "new-slug"})
await update_org(ORG_ID, UpdateOrgData(slug="new-slug"))
@pytest.mark.asyncio
async def test_update_org_no_op_when_all_none(self):
from backend.api.features.orgs.db import update_org
from backend.api.features.orgs.model import UpdateOrgData
# When all values are None, update_org returns get_org result
self.prisma.organization.find_unique = AsyncMock(return_value=self.old_org)
result = await update_org(ORG_ID, {"name": None, "slug": None})
result = await update_org(ORG_ID, UpdateOrgData())
# No update call should have happened
self.prisma.organization.update.assert_not_called()
@@ -373,19 +376,41 @@ class TestOrgDbConvertOrg:
async def test_convert_personal_to_team_org(self):
from backend.api.features.orgs.db import convert_personal_org
personal_org = _make_org(isPersonal=True)
converted_org = _make_org(isPersonal=False)
personal_org = _make_org(isPersonal=True, slug="myorg")
converted_org = _make_org(isPersonal=False, slug="myorg")
new_personal_org = _make_org(
id="new-personal-org", isPersonal=True, slug="myorg-personal-1"
)
# find_unique calls: 1) convert check, 2) slug resolution (None = available),
# 3) get_org at end
self.prisma.organization.find_unique = AsyncMock(
side_effect=[personal_org, converted_org]
side_effect=[personal_org, None, converted_org]
)
# Alias check for slug resolution
self.prisma.organizationalias.find_unique = AsyncMock(return_value=None)
# New personal org creation chain
self.prisma.organization.create = AsyncMock(return_value=new_personal_org)
self.prisma.orgmember.create = AsyncMock()
self.prisma.orgworkspace.create = AsyncMock(return_value=_make_workspace())
self.prisma.orgworkspacemember.create = AsyncMock()
self.prisma.organizationprofile.create = AsyncMock()
self.prisma.organizationseatassignment.create = AsyncMock()
self.prisma.orgbalance.create = AsyncMock()
# User lookup for display name
self.prisma.user.find_unique = AsyncMock(
return_value=MagicMock(name="Test User")
)
await convert_personal_org(ORG_ID)
await convert_personal_org(ORG_ID, USER_ID)
self.prisma.organization.update.assert_called_once_with(
where={"id": ORG_ID},
data={"isPersonal": False},
)
# Should have flipped isPersonal on the old org
update_calls = self.prisma.organization.update.call_args_list
assert any(c[1]["data"].get("isPersonal") is False for c in update_calls)
# Should have created a new personal org
self.prisma.organization.create.assert_called_once()
create_data = self.prisma.organization.create.call_args[1]["data"]
assert create_data["isPersonal"] is True
@pytest.mark.asyncio
async def test_convert_already_team_org_raises(self):
@@ -396,7 +421,7 @@ class TestOrgDbConvertOrg:
)
with pytest.raises(ValueError, match="already a team org"):
await convert_personal_org(ORG_ID)
await convert_personal_org(ORG_ID, USER_ID)
class TestOrgDbMembers:
@@ -454,6 +479,8 @@ class TestOrgDbMembers:
member = _make_member(userId=OTHER_USER_ID, isOwner=False)
self.prisma.orgmember.find_unique = AsyncMock(return_value=member)
# User has another org — removal allowed
self.prisma.orgmember.count = AsyncMock(return_value=1)
ws1 = _make_workspace(id="ws-1")
ws2 = _make_workspace(id="ws-2")
@@ -461,7 +488,7 @@ class TestOrgDbMembers:
self.prisma.orgworkspacemember.delete_many = AsyncMock()
self.prisma.orgmember.delete = AsyncMock()
await remove_org_member(ORG_ID, OTHER_USER_ID)
await remove_org_member(ORG_ID, OTHER_USER_ID, requesting_user_id=USER_ID)
# Should delete workspace memberships for each workspace
assert self.prisma.orgworkspacemember.delete_many.call_count == 2
@@ -480,7 +507,7 @@ class TestOrgDbMembers:
self.prisma.orgmember.find_unique = AsyncMock(return_value=owner)
with pytest.raises(ValueError, match="Cannot remove the org owner"):
await remove_org_member(ORG_ID, USER_ID)
await remove_org_member(ORG_ID, USER_ID, requesting_user_id="admin-user")
@pytest.mark.asyncio
async def test_remove_nonexistent_member_raises_not_found(self):
@@ -489,7 +516,7 @@ class TestOrgDbMembers:
self.prisma.orgmember.find_unique = AsyncMock(return_value=None)
with pytest.raises(NotFoundError):
await remove_org_member(ORG_ID, "ghost-user")
await remove_org_member(ORG_ID, "ghost-user", requesting_user_id=USER_ID)
@pytest.mark.asyncio
async def test_update_owner_role_raises_value_error(self):

View File

@@ -70,11 +70,17 @@ async def get_workspace(
async def update_workspace(ws_id: str, data: dict) -> WorkspaceResponse:
"""Update workspace fields."""
"""Update workspace fields. Guards the default workspace join policy."""
update_data = {k: v for k, v in data.items() if v is not None}
if not update_data:
return await get_workspace(ws_id)
# Guard: default workspace joinPolicy cannot be changed
if "joinPolicy" in update_data:
ws = await prisma.orgworkspace.find_unique(where={"id": ws_id})
if ws and ws.isDefault:
raise ValueError("Cannot change the default workspace's join policy")
await prisma.orgworkspace.update(where={"id": ws_id}, data=update_data)
return await get_workspace(ws_id)
@@ -193,7 +199,24 @@ async def update_workspace_member(
async def remove_workspace_member(ws_id: str, user_id: str) -> None:
"""Remove a member from a workspace."""
"""Remove a member from a workspace.
Guards against removing the last admin — workspace would become unmanageable.
"""
# Check if this would remove the last admin
member = await prisma.orgworkspacemember.find_unique(
where={"workspaceId_userId": {"workspaceId": ws_id, "userId": user_id}}
)
if member and member.isAdmin:
admin_count = await prisma.orgworkspacemember.count(
where={"workspaceId": ws_id, "isAdmin": True, "status": "ACTIVE"}
)
if admin_count <= 1:
raise ValueError(
"Cannot remove the last workspace admin. "
"Promote another member to admin first."
)
await prisma.orgworkspacemember.delete(
where={"workspaceId_userId": {"workspaceId": ws_id, "userId": user_id}}
)

View File

@@ -164,8 +164,12 @@ class TestSeatManagement:
async def test_get_seat_info(self, mock_prisma):
mock_prisma.organizationseatassignment.find_many = AsyncMock(
return_value=[
MagicMock(userId="u1", seatType="PAID", status="ACTIVE", createdAt="now"),
MagicMock(userId="u2", seatType="FREE", status="INACTIVE", createdAt="now"),
MagicMock(
userId="u1", seatType="PAID", status="ACTIVE", createdAt="now"
),
MagicMock(
userId="u2", seatType="FREE", status="INACTIVE", createdAt="now"
),
]
)

File diff suppressed because it is too large Load Diff