mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
refactor(platform): rename Workspace → Team across entire org feature
Rename the 'workspace' concept to 'team' per architecture + product
alignment. Teams are groups of people sharing resources (like GitHub
Teams within Organizations). This resolves the naming collision with
the existing UserWorkspace file-storage model.
Schema:
- OrgWorkspace → Team, OrgWorkspaceMember → TeamMember,
WorkspaceInvite → TeamInvite, WorkspaceJoinPolicy → TeamJoinPolicy
- orgWorkspaceId → teamId on all tenant-bound models
- Single clean migration (old workspace migrations deleted)
Auth:
- WorkspaceAction → TeamAction
- requires_workspace_permission → requires_team_permission
- X-Workspace-Id → X-Team-Id header
- RequestContext: workspace_id → team_id, is_workspace_* → is_team_*
Backend:
- workspace_routes.py → team_routes.py (+ db, model files)
- All function/class/variable renames throughout
- API routes: /api/orgs/{id}/workspaces → /api/orgs/{id}/teams
Frontend:
- OrgWorkspaceSwitcher → OrgTeamSwitcher
- OrgWorkspaceProvider → OrgTeamProvider
- useOrgWorkspaceStore → useOrgTeamStore
- ACTIVE_WORKSPACE → ACTIVE_TEAM localStorage key
- active-workspace-id → active-team-id
NOT renamed (file-storage workspace — different concept):
- UserWorkspace, UserWorkspaceFile, workspace:// URIs
- ExecutionContext.workspace_id (file workspace)
- backend/data/workspace.py, backend/util/workspace*.py
40 files changed, 199 tests passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,16 +5,16 @@ from .dependencies import (
|
||||
get_user_id,
|
||||
requires_admin_user,
|
||||
requires_org_permission,
|
||||
requires_team_permission,
|
||||
requires_user,
|
||||
requires_workspace_permission,
|
||||
)
|
||||
from .helpers import add_auth_responses_to_openapi
|
||||
from .models import RequestContext, User
|
||||
from .permissions import (
|
||||
OrgAction,
|
||||
WorkspaceAction,
|
||||
TeamAction,
|
||||
check_org_permission,
|
||||
check_workspace_permission,
|
||||
check_team_permission,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -25,12 +25,12 @@ __all__ = [
|
||||
"get_optional_user_id",
|
||||
"get_request_context",
|
||||
"requires_org_permission",
|
||||
"requires_workspace_permission",
|
||||
"requires_team_permission",
|
||||
"add_auth_responses_to_openapi",
|
||||
"User",
|
||||
"RequestContext",
|
||||
"OrgAction",
|
||||
"WorkspaceAction",
|
||||
"TeamAction",
|
||||
"check_org_permission",
|
||||
"check_workspace_permission",
|
||||
"check_team_permission",
|
||||
]
|
||||
|
||||
@@ -13,9 +13,9 @@ from .jwt_utils import get_jwt_payload, verify_user
|
||||
from .models import RequestContext, User
|
||||
from .permissions import (
|
||||
OrgAction,
|
||||
WorkspaceAction,
|
||||
TeamAction,
|
||||
check_org_permission,
|
||||
check_workspace_permission,
|
||||
check_team_permission,
|
||||
)
|
||||
|
||||
optional_bearer = HTTPBearer(auto_error=False)
|
||||
@@ -128,7 +128,7 @@ async def get_user_id(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ORG_HEADER_NAME = "X-Org-Id"
|
||||
WORKSPACE_HEADER_NAME = "X-Workspace-Id"
|
||||
TEAM_HEADER_NAME = "X-Team-Id"
|
||||
|
||||
|
||||
async def get_request_context(
|
||||
@@ -142,8 +142,8 @@ async def get_request_context(
|
||||
1. Extract user_id from JWT (supports admin impersonation via X-Act-As-User-Id).
|
||||
2. Read X-Org-Id header; fall back to the user's personal org; fail if none.
|
||||
3. Validate that the user has an ACTIVE OrgMember row for that org.
|
||||
4. Read X-Workspace-Id header (optional). If set, validate that the
|
||||
workspace belongs to the org AND the user has an OrgWorkspaceMember
|
||||
4. Read X-Team-Id header (optional). If set, validate that the
|
||||
workspace belongs to the org AND the user has an TeamMember
|
||||
row. On failure, silently fall back to None (org-home).
|
||||
5. Populate all role flags and return a RequestContext.
|
||||
"""
|
||||
@@ -214,50 +214,50 @@ async def get_request_context(
|
||||
is_org_billing_manager = org_member.isBillingManager
|
||||
seat_status = "ACTIVE" # validated above; seat assignment checked separately
|
||||
|
||||
# --- 4. workspace_id (optional) -------------------------------------------
|
||||
workspace_id: str | None = (
|
||||
request.headers.get(WORKSPACE_HEADER_NAME, "").strip() or None
|
||||
# --- 4. team_id (optional) -------------------------------------------
|
||||
team_id: str | None = (
|
||||
request.headers.get(TEAM_HEADER_NAME, "").strip() or None
|
||||
)
|
||||
is_workspace_admin = False
|
||||
is_workspace_billing_manager = False
|
||||
is_team_admin = False
|
||||
is_team_billing_manager = False
|
||||
|
||||
if workspace_id is not None:
|
||||
if team_id is not None:
|
||||
# Validate workspace belongs to org AND user has a membership row
|
||||
ws_member = await prisma.orgworkspacemember.find_unique(
|
||||
ws_member = await prisma.teammember.find_unique(
|
||||
where={
|
||||
"workspaceId_userId": {
|
||||
"workspaceId": workspace_id,
|
||||
"teamId_userId": {
|
||||
"teamId": team_id,
|
||||
"userId": user_id,
|
||||
},
|
||||
},
|
||||
include={"Workspace": True},
|
||||
include={"Team": True},
|
||||
)
|
||||
if (
|
||||
ws_member is None
|
||||
or ws_member.Workspace is None
|
||||
or ws_member.Workspace.orgId != org_id
|
||||
or ws_member.Team is None
|
||||
or ws_member.Team.orgId != org_id
|
||||
):
|
||||
logger.debug(
|
||||
"Workspace %s not valid for user %s in org %s; falling back to org-home",
|
||||
workspace_id,
|
||||
team_id,
|
||||
user_id,
|
||||
org_id,
|
||||
)
|
||||
workspace_id = None
|
||||
team_id = None
|
||||
else:
|
||||
is_workspace_admin = ws_member.isAdmin
|
||||
is_workspace_billing_manager = ws_member.isBillingManager
|
||||
is_team_admin = ws_member.isAdmin
|
||||
is_team_billing_manager = ws_member.isBillingManager
|
||||
|
||||
# --- 5. build context -----------------------------------------------------
|
||||
return RequestContext(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
workspace_id=workspace_id,
|
||||
team_id=team_id,
|
||||
is_org_owner=is_org_owner,
|
||||
is_org_admin=is_org_admin,
|
||||
is_org_billing_manager=is_org_billing_manager,
|
||||
is_workspace_admin=is_workspace_admin,
|
||||
is_workspace_billing_manager=is_workspace_billing_manager,
|
||||
is_team_admin=is_team_admin,
|
||||
is_team_billing_manager=is_team_billing_manager,
|
||||
seat_status=seat_status,
|
||||
)
|
||||
|
||||
@@ -292,12 +292,12 @@ def requires_org_permission(
|
||||
return _dependency
|
||||
|
||||
|
||||
def requires_workspace_permission(
|
||||
*actions: WorkspaceAction,
|
||||
def requires_team_permission(
|
||||
*actions: TeamAction,
|
||||
):
|
||||
"""Factory returning a FastAPI dependency that enforces workspace-level permissions.
|
||||
|
||||
The user must be in a workspace context (workspace_id is set) and
|
||||
The user must be in a workspace context (team_id is set) and
|
||||
hold **all** listed actions.
|
||||
|
||||
Example::
|
||||
@@ -305,7 +305,7 @@ def requires_workspace_permission(
|
||||
@router.post("/workspace/{ws_id}/agents")
|
||||
async def create_agent(
|
||||
ctx: RequestContext = Security(
|
||||
requires_workspace_permission(WorkspaceAction.CREATE_AGENTS)
|
||||
requires_team_permission(TeamAction.CREATE_AGENTS)
|
||||
),
|
||||
):
|
||||
...
|
||||
@@ -314,13 +314,13 @@ def requires_workspace_permission(
|
||||
async def _dependency(
|
||||
ctx: RequestContext = fastapi.Security(get_request_context),
|
||||
) -> RequestContext:
|
||||
if ctx.workspace_id is None:
|
||||
if ctx.team_id is None:
|
||||
raise fastapi.HTTPException(
|
||||
status_code=400,
|
||||
detail="Workspace context required for this action",
|
||||
)
|
||||
for action in actions:
|
||||
if not check_workspace_permission(ctx, action):
|
||||
if not check_team_permission(ctx, action):
|
||||
raise fastapi.HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Missing workspace permission: {action.value}",
|
||||
|
||||
@@ -26,10 +26,10 @@ class User:
|
||||
class RequestContext:
|
||||
user_id: str
|
||||
org_id: str
|
||||
workspace_id: str | None # None = org-home context
|
||||
team_id: str | None # None = org-home context
|
||||
is_org_owner: bool
|
||||
is_org_admin: bool
|
||||
is_org_billing_manager: bool
|
||||
is_workspace_admin: bool
|
||||
is_workspace_billing_manager: bool
|
||||
is_team_admin: bool
|
||||
is_team_billing_manager: bool
|
||||
seat_status: str # ACTIVE, INACTIVE, PENDING, NONE
|
||||
|
||||
@@ -24,7 +24,7 @@ class OrgAction(str, Enum):
|
||||
SHARE_RESOURCES = "SHARE_RESOURCES"
|
||||
|
||||
|
||||
class WorkspaceAction(str, Enum):
|
||||
class TeamAction(str, Enum):
|
||||
MANAGE_MEMBERS = "MANAGE_MEMBERS"
|
||||
MANAGE_SETTINGS = "MANAGE_SETTINGS"
|
||||
MANAGE_CREDENTIALS = "MANAGE_CREDENTIALS"
|
||||
@@ -53,16 +53,16 @@ _ORG_PERMISSIONS: dict[OrgAction, set[str]] = {
|
||||
}
|
||||
|
||||
# Workspace permission map: action -> set of roles that are allowed.
|
||||
# "ws_member" means any workspace member with no special workspace role.
|
||||
_WORKSPACE_PERMISSIONS: dict[WorkspaceAction, set[str]] = {
|
||||
WorkspaceAction.MANAGE_MEMBERS: {"ws_admin"},
|
||||
WorkspaceAction.MANAGE_SETTINGS: {"ws_admin"},
|
||||
WorkspaceAction.MANAGE_CREDENTIALS: {"ws_admin"},
|
||||
WorkspaceAction.VIEW_SPEND: {"ws_admin", "ws_billing_manager"},
|
||||
WorkspaceAction.CREATE_AGENTS: {"ws_admin", "ws_member"},
|
||||
WorkspaceAction.USE_CREDENTIALS: {"ws_admin", "ws_member"},
|
||||
WorkspaceAction.VIEW_EXECUTIONS: {"ws_admin", "ws_member"},
|
||||
WorkspaceAction.DELETE_AGENTS: {"ws_admin"},
|
||||
# "team_member" means any workspace member with no special workspace role.
|
||||
_TEAM_PERMISSIONS: dict[TeamAction, set[str]] = {
|
||||
TeamAction.MANAGE_MEMBERS: {"team_admin"},
|
||||
TeamAction.MANAGE_SETTINGS: {"team_admin"},
|
||||
TeamAction.MANAGE_CREDENTIALS: {"team_admin"},
|
||||
TeamAction.VIEW_SPEND: {"team_admin", "team_billing_manager"},
|
||||
TeamAction.CREATE_AGENTS: {"team_admin", "team_member"},
|
||||
TeamAction.USE_CREDENTIALS: {"team_admin", "team_member"},
|
||||
TeamAction.VIEW_EXECUTIONS: {"team_admin", "team_member"},
|
||||
TeamAction.DELETE_AGENTS: {"team_admin"},
|
||||
}
|
||||
|
||||
|
||||
@@ -86,18 +86,18 @@ def _get_org_roles(ctx: RequestContext) -> set[str]:
|
||||
return roles
|
||||
|
||||
|
||||
def _get_workspace_roles(ctx: RequestContext) -> set[str]:
|
||||
def _get_team_roles(ctx: RequestContext) -> set[str]:
|
||||
"""Derive the set of workspace-level role tags from a RequestContext."""
|
||||
roles: set[str] = set()
|
||||
if ctx.is_workspace_admin:
|
||||
roles.add("ws_admin")
|
||||
if ctx.is_workspace_billing_manager:
|
||||
roles.add("ws_billing_manager")
|
||||
# Regular workspace members (not admin, not billing_manager) get ws_member.
|
||||
# WS admins also get ws_member (they can do everything a member can).
|
||||
if ctx.workspace_id is not None:
|
||||
if ctx.is_workspace_admin or (not ctx.is_workspace_billing_manager):
|
||||
roles.add("ws_member")
|
||||
if ctx.is_team_admin:
|
||||
roles.add("team_admin")
|
||||
if ctx.is_team_billing_manager:
|
||||
roles.add("team_billing_manager")
|
||||
# Regular workspace members (not admin, not billing_manager) get team_member.
|
||||
# WS admins also get team_member (they can do everything a member can).
|
||||
if ctx.team_id is not None:
|
||||
if ctx.is_team_admin or (not ctx.is_team_billing_manager):
|
||||
roles.add("team_member")
|
||||
return roles
|
||||
|
||||
|
||||
@@ -107,9 +107,9 @@ def check_org_permission(ctx: RequestContext, action: OrgAction) -> bool:
|
||||
return bool(_get_org_roles(ctx) & allowed_roles)
|
||||
|
||||
|
||||
def check_workspace_permission(ctx: RequestContext, action: WorkspaceAction) -> bool:
|
||||
def check_team_permission(ctx: RequestContext, action: TeamAction) -> bool:
|
||||
"""Return True if the RequestContext grants the given workspace-level action."""
|
||||
if ctx.workspace_id is None:
|
||||
if ctx.team_id is None:
|
||||
return False
|
||||
allowed_roles = _WORKSPACE_PERMISSIONS.get(action, set())
|
||||
return bool(_get_workspace_roles(ctx) & allowed_roles)
|
||||
allowed_roles = _TEAM_PERMISSIONS.get(action, set())
|
||||
return bool(_get_team_roles(ctx) & allowed_roles)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Exhaustive tests for org-level and workspace-level permission checks.
|
||||
|
||||
Every OrgAction x role combination and every WorkspaceAction x role
|
||||
Every OrgAction x role combination and every TeamAction x role
|
||||
combination is covered. These are pure-function tests -- no mocking,
|
||||
no database, no I/O.
|
||||
"""
|
||||
@@ -11,9 +11,9 @@ import pytest
|
||||
from autogpt_libs.auth.models import RequestContext
|
||||
from autogpt_libs.auth.permissions import (
|
||||
OrgAction,
|
||||
WorkspaceAction,
|
||||
TeamAction,
|
||||
check_org_permission,
|
||||
check_workspace_permission,
|
||||
check_team_permission,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -26,20 +26,20 @@ def _make_ctx(
|
||||
is_org_owner: bool = False,
|
||||
is_org_admin: bool = False,
|
||||
is_org_billing_manager: bool = False,
|
||||
is_workspace_admin: bool = False,
|
||||
is_workspace_billing_manager: bool = False,
|
||||
is_team_admin: bool = False,
|
||||
is_team_billing_manager: bool = False,
|
||||
seat_status: str = "ACTIVE",
|
||||
workspace_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
) -> RequestContext:
|
||||
return RequestContext(
|
||||
user_id="user-1",
|
||||
org_id="org-1",
|
||||
workspace_id=workspace_id,
|
||||
team_id=team_id,
|
||||
is_org_owner=is_org_owner,
|
||||
is_org_admin=is_org_admin,
|
||||
is_org_billing_manager=is_org_billing_manager,
|
||||
is_workspace_admin=is_workspace_admin,
|
||||
is_workspace_billing_manager=is_workspace_billing_manager,
|
||||
is_team_admin=is_team_admin,
|
||||
is_team_billing_manager=is_team_billing_manager,
|
||||
seat_status=seat_status,
|
||||
)
|
||||
|
||||
@@ -51,10 +51,10 @@ ORG_BILLING_MANAGER = _make_ctx(is_org_billing_manager=True)
|
||||
ORG_MEMBER = _make_ctx() # ACTIVE seat, no special role flags
|
||||
|
||||
|
||||
# Convenience contexts for workspace-level roles (workspace_id is set)
|
||||
WS_ADMIN = _make_ctx(workspace_id="ws-1", is_workspace_admin=True)
|
||||
WS_BILLING_MANAGER = _make_ctx(workspace_id="ws-1", is_workspace_billing_manager=True)
|
||||
WS_MEMBER = _make_ctx(workspace_id="ws-1") # regular workspace member
|
||||
# Convenience contexts for workspace-level roles (team_id is set)
|
||||
TEAM_ADMIN = _make_ctx(team_id="ws-1", is_team_admin=True)
|
||||
TEAM_BILLING_MGR = _make_ctx(team_id="ws-1", is_team_billing_manager=True)
|
||||
TEAM_MEMBER = _make_ctx(team_id="ws-1") # regular workspace member
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -178,85 +178,85 @@ class TestOrgPermissions:
|
||||
# Workspace permission matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_WS_EXPECTED: dict[WorkspaceAction, dict[str, bool]] = {
|
||||
WorkspaceAction.MANAGE_MEMBERS: {
|
||||
"ws_admin": True,
|
||||
"ws_billing_manager": False,
|
||||
"ws_member": False,
|
||||
_TEAM_EXPECTED: dict[TeamAction, dict[str, bool]] = {
|
||||
TeamAction.MANAGE_MEMBERS: {
|
||||
"team_admin": True,
|
||||
"team_billing_manager": False,
|
||||
"team_member": False,
|
||||
},
|
||||
WorkspaceAction.MANAGE_SETTINGS: {
|
||||
"ws_admin": True,
|
||||
"ws_billing_manager": False,
|
||||
"ws_member": False,
|
||||
TeamAction.MANAGE_SETTINGS: {
|
||||
"team_admin": True,
|
||||
"team_billing_manager": False,
|
||||
"team_member": False,
|
||||
},
|
||||
WorkspaceAction.MANAGE_CREDENTIALS: {
|
||||
"ws_admin": True,
|
||||
"ws_billing_manager": False,
|
||||
"ws_member": False,
|
||||
TeamAction.MANAGE_CREDENTIALS: {
|
||||
"team_admin": True,
|
||||
"team_billing_manager": False,
|
||||
"team_member": False,
|
||||
},
|
||||
WorkspaceAction.VIEW_SPEND: {
|
||||
"ws_admin": True,
|
||||
"ws_billing_manager": True,
|
||||
"ws_member": False,
|
||||
TeamAction.VIEW_SPEND: {
|
||||
"team_admin": True,
|
||||
"team_billing_manager": True,
|
||||
"team_member": False,
|
||||
},
|
||||
WorkspaceAction.CREATE_AGENTS: {
|
||||
"ws_admin": True,
|
||||
"ws_billing_manager": False,
|
||||
"ws_member": True,
|
||||
TeamAction.CREATE_AGENTS: {
|
||||
"team_admin": True,
|
||||
"team_billing_manager": False,
|
||||
"team_member": True,
|
||||
},
|
||||
WorkspaceAction.USE_CREDENTIALS: {
|
||||
"ws_admin": True,
|
||||
"ws_billing_manager": False,
|
||||
"ws_member": True,
|
||||
TeamAction.USE_CREDENTIALS: {
|
||||
"team_admin": True,
|
||||
"team_billing_manager": False,
|
||||
"team_member": True,
|
||||
},
|
||||
WorkspaceAction.VIEW_EXECUTIONS: {
|
||||
"ws_admin": True,
|
||||
"ws_billing_manager": False,
|
||||
"ws_member": True,
|
||||
TeamAction.VIEW_EXECUTIONS: {
|
||||
"team_admin": True,
|
||||
"team_billing_manager": False,
|
||||
"team_member": True,
|
||||
},
|
||||
WorkspaceAction.DELETE_AGENTS: {
|
||||
"ws_admin": True,
|
||||
"ws_billing_manager": False,
|
||||
"ws_member": False,
|
||||
TeamAction.DELETE_AGENTS: {
|
||||
"team_admin": True,
|
||||
"team_billing_manager": False,
|
||||
"team_member": False,
|
||||
},
|
||||
}
|
||||
|
||||
_WS_ROLE_CTX = {
|
||||
"ws_admin": WS_ADMIN,
|
||||
"ws_billing_manager": WS_BILLING_MANAGER,
|
||||
"ws_member": WS_MEMBER,
|
||||
_TEAM_ROLE_CTX = {
|
||||
"team_admin": TEAM_ADMIN,
|
||||
"team_billing_manager": TEAM_BILLING_MGR,
|
||||
"team_member": TEAM_MEMBER,
|
||||
}
|
||||
|
||||
|
||||
class TestWorkspacePermissions:
|
||||
class TestTeamPermissions:
|
||||
"""Exhaustive workspace action x role matrix."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"action",
|
||||
list(WorkspaceAction),
|
||||
ids=[a.value for a in WorkspaceAction],
|
||||
list(TeamAction),
|
||||
ids=[a.value for a in TeamAction],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["ws_admin", "ws_billing_manager", "ws_member"],
|
||||
["team_admin", "team_billing_manager", "team_member"],
|
||||
)
|
||||
def test_workspace_permission_matrix(self, action: WorkspaceAction, role: str):
|
||||
ctx = _WS_ROLE_CTX[role]
|
||||
expected = _WS_EXPECTED[action][role]
|
||||
result = check_workspace_permission(ctx, action)
|
||||
def test_team_permission_matrix(self, action: TeamAction, role: str):
|
||||
ctx = _TEAM_ROLE_CTX[role]
|
||||
expected = _TEAM_EXPECTED[action][role]
|
||||
result = check_team_permission(ctx, action)
|
||||
assert result is expected, (
|
||||
f"WorkspaceAction.{action.value} for role={role}: "
|
||||
f"TeamAction.{action.value} for role={role}: "
|
||||
f"expected {expected}, got {result}"
|
||||
)
|
||||
|
||||
def test_no_workspace_context_denies_all(self):
|
||||
"""Without a workspace_id, all workspace actions are denied."""
|
||||
ctx = _make_ctx(is_workspace_admin=True) # no workspace_id
|
||||
for action in WorkspaceAction:
|
||||
assert check_workspace_permission(ctx, action) is False
|
||||
def test_no_team_context_denies_all(self):
|
||||
"""Without a team_id, all workspace actions are denied."""
|
||||
ctx = _make_ctx(is_team_admin=True) # no team_id
|
||||
for action in TeamAction:
|
||||
assert check_team_permission(ctx, action) is False
|
||||
|
||||
def test_ws_billing_manager_is_not_ws_member(self):
|
||||
"""A workspace billing manager should NOT get ws_member permissions."""
|
||||
ctx = WS_BILLING_MANAGER
|
||||
assert check_workspace_permission(ctx, WorkspaceAction.CREATE_AGENTS) is False
|
||||
assert check_workspace_permission(ctx, WorkspaceAction.VIEW_SPEND) is True
|
||||
def test_team_billing_manager_is_not_team_member(self):
|
||||
"""A workspace billing manager should NOT get team_member permissions."""
|
||||
ctx = TEAM_BILLING_MGR
|
||||
assert check_team_permission(ctx, TeamAction.CREATE_AGENTS) is False
|
||||
assert check_team_permission(ctx, TeamAction.VIEW_SPEND) is True
|
||||
|
||||
@@ -842,7 +842,7 @@ async def stream_chat_post(
|
||||
context=request.context,
|
||||
file_ids=sanitized_file_ids,
|
||||
organization_id=ctx.org_id,
|
||||
org_workspace_id=ctx.workspace_id,
|
||||
team_id=ctx.team_id,
|
||||
)
|
||||
|
||||
setup_time = (time.perf_counter() - stream_start_time) * 1000
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, List
|
||||
|
||||
import autogpt_libs.auth as autogpt_auth_lib
|
||||
from fastapi import APIRouter, HTTPException, Query, Security, status
|
||||
from prisma.enums import ReviewStatus
|
||||
|
||||
from backend.copilot.constants import (
|
||||
is_copilot_synthetic_id,
|
||||
parse_node_id_from_exec_id,
|
||||
)
|
||||
from backend.data.execution import (
|
||||
ExecutionContext,
|
||||
ExecutionStatus,
|
||||
get_graph_execution_meta,
|
||||
get_node_executions,
|
||||
)
|
||||
from backend.data.graph import get_graph_settings
|
||||
from backend.data.human_review import (
|
||||
create_auto_approval_record,
|
||||
get_pending_reviews_for_execution,
|
||||
get_pending_reviews_for_user,
|
||||
get_reviews_by_node_exec_ids,
|
||||
has_pending_reviews_for_graph_exec,
|
||||
process_all_reviews_for_execution,
|
||||
)
|
||||
from backend.data.model import USER_TIMEZONE_NOT_SET
|
||||
from backend.data.user import get_user_by_id
|
||||
from backend.data.workspace import get_or_create_workspace
|
||||
from backend.executor.utils import add_graph_execution
|
||||
|
||||
from .model import PendingHumanReviewModel, ReviewRequest, ReviewResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
tags=["v2", "executions", "review"],
|
||||
dependencies=[Security(autogpt_auth_lib.requires_user)],
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_node_ids(
|
||||
node_exec_ids: list[str],
|
||||
graph_exec_id: str,
|
||||
is_copilot: bool,
|
||||
) -> dict[str, str]:
|
||||
"""Resolve node_exec_id -> node_id for auto-approval records.
|
||||
|
||||
CoPilot synthetic IDs encode node_id in the format "{node_id}:{random}".
|
||||
Graph executions look up node_id from NodeExecution records.
|
||||
"""
|
||||
if not node_exec_ids:
|
||||
return {}
|
||||
|
||||
if is_copilot:
|
||||
return {neid: parse_node_id_from_exec_id(neid) for neid in node_exec_ids}
|
||||
|
||||
node_execs = await get_node_executions(
|
||||
graph_exec_id=graph_exec_id, include_exec_data=False
|
||||
)
|
||||
node_exec_map = {ne.node_exec_id: ne.node_id for ne in node_execs}
|
||||
|
||||
result = {}
|
||||
for neid in node_exec_ids:
|
||||
if neid in node_exec_map:
|
||||
result[neid] = node_exec_map[neid]
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to resolve node_id for {neid}: Node execution not found."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/pending",
|
||||
summary="Get Pending Reviews",
|
||||
response_model=List[PendingHumanReviewModel],
|
||||
responses={
|
||||
200: {"description": "List of pending reviews"},
|
||||
500: {"description": "Server error", "content": {"application/json": {}}},
|
||||
},
|
||||
)
|
||||
async def list_pending_reviews(
|
||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
||||
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
||||
page_size: int = Query(25, ge=1, le=100, description="Number of reviews per page"),
|
||||
) -> List[PendingHumanReviewModel]:
|
||||
"""Get all pending reviews for the current user.
|
||||
|
||||
Retrieves all reviews with status "WAITING" that belong to the authenticated user.
|
||||
Results are ordered by creation time (newest first).
|
||||
|
||||
Args:
|
||||
user_id: Authenticated user ID from security dependency
|
||||
|
||||
Returns:
|
||||
List of pending review objects with status converted to typed literals
|
||||
|
||||
Raises:
|
||||
HTTPException: If authentication fails or database error occurs
|
||||
|
||||
Note:
|
||||
Reviews with invalid status values are logged as warnings but excluded
|
||||
from results rather than failing the entire request.
|
||||
"""
|
||||
|
||||
return await get_pending_reviews_for_user(user_id, page, page_size)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/execution/{graph_exec_id}",
|
||||
summary="Get Pending Reviews for Execution",
|
||||
response_model=List[PendingHumanReviewModel],
|
||||
responses={
|
||||
200: {"description": "List of pending reviews for the execution"},
|
||||
404: {"description": "Graph execution not found"},
|
||||
500: {"description": "Server error", "content": {"application/json": {}}},
|
||||
},
|
||||
)
|
||||
async def list_pending_reviews_for_execution(
|
||||
graph_exec_id: str,
|
||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
||||
) -> List[PendingHumanReviewModel]:
|
||||
"""Get all pending reviews for a specific graph execution.
|
||||
|
||||
Retrieves all reviews with status "WAITING" for the specified graph execution
|
||||
that belong to the authenticated user. Results are ordered by creation time
|
||||
(oldest first) to preserve review order within the execution.
|
||||
|
||||
Args:
|
||||
graph_exec_id: ID of the graph execution to get reviews for
|
||||
user_id: Authenticated user ID from security dependency
|
||||
|
||||
Returns:
|
||||
List of pending review objects for the specified execution
|
||||
|
||||
Raises:
|
||||
HTTPException:
|
||||
- 404: If the graph execution doesn't exist or isn't owned by this user
|
||||
- 500: If authentication fails or database error occurs
|
||||
|
||||
Note:
|
||||
Only returns reviews owned by the authenticated user for security.
|
||||
Reviews with invalid status are excluded with warning logs.
|
||||
"""
|
||||
|
||||
# Verify user owns the graph execution before returning reviews
|
||||
# (CoPilot synthetic IDs don't have graph execution records)
|
||||
if not is_copilot_synthetic_id(graph_exec_id):
|
||||
graph_exec = await get_graph_execution_meta(
|
||||
user_id=user_id, execution_id=graph_exec_id
|
||||
)
|
||||
if not graph_exec:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Graph execution #{graph_exec_id} not found",
|
||||
)
|
||||
|
||||
return await get_pending_reviews_for_execution(graph_exec_id, user_id)
|
||||
|
||||
|
||||
@router.post("/action", response_model=ReviewResponse)
|
||||
async def process_review_action(
|
||||
request: ReviewRequest,
|
||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
||||
) -> ReviewResponse:
|
||||
"""Process reviews with approve or reject actions."""
|
||||
|
||||
# Collect all node exec IDs from the request
|
||||
all_request_node_ids = {review.node_exec_id for review in request.reviews}
|
||||
|
||||
if not all_request_node_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="At least one review must be provided",
|
||||
)
|
||||
|
||||
# Batch fetch all requested reviews (regardless of status for idempotent handling)
|
||||
reviews_map = await get_reviews_by_node_exec_ids(
|
||||
list(all_request_node_ids), user_id
|
||||
)
|
||||
|
||||
# Validate all reviews were found (must exist, any status is OK for now)
|
||||
missing_ids = all_request_node_ids - set(reviews_map.keys())
|
||||
if missing_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Review(s) not found: {', '.join(missing_ids)}",
|
||||
)
|
||||
|
||||
# Validate all reviews belong to the same execution
|
||||
graph_exec_ids = {review.graph_exec_id for review in reviews_map.values()}
|
||||
if len(graph_exec_ids) > 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="All reviews in a single request must belong to the same execution.",
|
||||
)
|
||||
|
||||
graph_exec_id = next(iter(graph_exec_ids))
|
||||
is_copilot = is_copilot_synthetic_id(graph_exec_id)
|
||||
|
||||
# Validate execution status for graph executions (skip for CoPilot synthetic IDs)
|
||||
if not is_copilot:
|
||||
graph_exec_meta = await get_graph_execution_meta(
|
||||
user_id=user_id, execution_id=graph_exec_id
|
||||
)
|
||||
if not graph_exec_meta:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Graph execution #{graph_exec_id} not found",
|
||||
)
|
||||
if graph_exec_meta.status not in (
|
||||
ExecutionStatus.REVIEW,
|
||||
ExecutionStatus.INCOMPLETE,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Cannot process reviews while execution status is {graph_exec_meta.status}",
|
||||
)
|
||||
|
||||
# Build review decisions map and track which reviews requested auto-approval
|
||||
# Auto-approved reviews use original data (no modifications allowed)
|
||||
review_decisions = {}
|
||||
auto_approve_requests = {} # Map node_exec_id -> auto_approve_future flag
|
||||
|
||||
for review in request.reviews:
|
||||
review_status = (
|
||||
ReviewStatus.APPROVED if review.approved else ReviewStatus.REJECTED
|
||||
)
|
||||
# If this review requested auto-approval, don't allow data modifications
|
||||
reviewed_data = None if review.auto_approve_future else review.reviewed_data
|
||||
review_decisions[review.node_exec_id] = (
|
||||
review_status,
|
||||
reviewed_data,
|
||||
review.message,
|
||||
)
|
||||
auto_approve_requests[review.node_exec_id] = review.auto_approve_future
|
||||
|
||||
# Process all reviews
|
||||
updated_reviews = await process_all_reviews_for_execution(
|
||||
user_id=user_id,
|
||||
review_decisions=review_decisions,
|
||||
)
|
||||
|
||||
# Create auto-approval records for approved reviews that requested it
|
||||
# Deduplicate by node_id to avoid race conditions when multiple reviews
|
||||
# for the same node are processed in parallel
|
||||
async def create_auto_approval_for_node(
|
||||
node_id: str, review_result
|
||||
) -> tuple[str, bool]:
|
||||
"""
|
||||
Create auto-approval record for a node.
|
||||
Returns (node_id, success) tuple for tracking failures.
|
||||
"""
|
||||
try:
|
||||
await create_auto_approval_record(
|
||||
user_id=user_id,
|
||||
graph_exec_id=review_result.graph_exec_id,
|
||||
graph_id=review_result.graph_id,
|
||||
graph_version=review_result.graph_version,
|
||||
node_id=node_id,
|
||||
payload=review_result.payload,
|
||||
)
|
||||
return (node_id, True)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to create auto-approval record for node {node_id}",
|
||||
exc_info=e,
|
||||
)
|
||||
return (node_id, False)
|
||||
|
||||
# Collect node_exec_ids that need auto-approval and resolve their node_ids
|
||||
node_exec_ids_needing_auto_approval = [
|
||||
node_exec_id
|
||||
for node_exec_id, review_result in updated_reviews.items()
|
||||
if review_result.status == ReviewStatus.APPROVED
|
||||
and auto_approve_requests.get(node_exec_id, False)
|
||||
]
|
||||
|
||||
node_id_map = await _resolve_node_ids(
|
||||
node_exec_ids_needing_auto_approval, graph_exec_id, is_copilot
|
||||
)
|
||||
|
||||
# Deduplicate by node_id — one auto-approval per node
|
||||
nodes_needing_auto_approval: dict[str, Any] = {}
|
||||
for node_exec_id in node_exec_ids_needing_auto_approval:
|
||||
node_id = node_id_map.get(node_exec_id)
|
||||
if node_id and node_id not in nodes_needing_auto_approval:
|
||||
nodes_needing_auto_approval[node_id] = updated_reviews[node_exec_id]
|
||||
|
||||
# Execute all auto-approval creations in parallel (deduplicated by node_id)
|
||||
auto_approval_results = await asyncio.gather(
|
||||
*[
|
||||
create_auto_approval_for_node(node_id, review_result)
|
||||
for node_id, review_result in nodes_needing_auto_approval.items()
|
||||
],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# Count auto-approval failures
|
||||
auto_approval_failed_count = 0
|
||||
for result in auto_approval_results:
|
||||
if isinstance(result, Exception):
|
||||
auto_approval_failed_count += 1
|
||||
logger.error(
|
||||
f"Unexpected exception during auto-approval creation: {result}"
|
||||
)
|
||||
elif isinstance(result, tuple) and len(result) == 2 and not result[1]:
|
||||
auto_approval_failed_count += 1
|
||||
|
||||
# Count results
|
||||
approved_count = sum(
|
||||
1
|
||||
for review in updated_reviews.values()
|
||||
if review.status == ReviewStatus.APPROVED
|
||||
)
|
||||
rejected_count = sum(
|
||||
1
|
||||
for review in updated_reviews.values()
|
||||
if review.status == ReviewStatus.REJECTED
|
||||
)
|
||||
|
||||
# Resume graph execution only for real graph executions (not CoPilot)
|
||||
# CoPilot sessions are resumed by the LLM retrying run_block with review_id
|
||||
if not is_copilot and updated_reviews:
|
||||
still_has_pending = await has_pending_reviews_for_graph_exec(graph_exec_id)
|
||||
|
||||
if not still_has_pending:
|
||||
first_review = next(iter(updated_reviews.values()))
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(user_id)
|
||||
settings = await get_graph_settings(
|
||||
user_id=user_id, graph_id=first_review.graph_id
|
||||
)
|
||||
|
||||
user_timezone = (
|
||||
user.timezone if user.timezone != USER_TIMEZONE_NOT_SET else "UTC"
|
||||
)
|
||||
|
||||
workspace = await get_or_create_workspace(user_id)
|
||||
|
||||
execution_context = ExecutionContext(
|
||||
human_in_the_loop_safe_mode=settings.human_in_the_loop_safe_mode,
|
||||
sensitive_action_safe_mode=settings.sensitive_action_safe_mode,
|
||||
user_timezone=user_timezone,
|
||||
workspace_id=workspace.id,
|
||||
)
|
||||
|
||||
await add_graph_execution(
|
||||
graph_id=first_review.graph_id,
|
||||
user_id=user_id,
|
||||
graph_exec_id=graph_exec_id,
|
||||
execution_context=execution_context,
|
||||
)
|
||||
logger.info(f"Resumed execution {graph_exec_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to resume execution {graph_exec_id}: {str(e)}")
|
||||
|
||||
# Build error message if auto-approvals failed
|
||||
error_message = None
|
||||
if auto_approval_failed_count > 0:
|
||||
error_message = (
|
||||
f"{auto_approval_failed_count} auto-approval setting(s) could not be saved. "
|
||||
f"You may need to manually approve these reviews in future executions."
|
||||
)
|
||||
|
||||
return ReviewResponse(
|
||||
approved_count=approved_count,
|
||||
rejected_count=rejected_count,
|
||||
failed_count=auto_approval_failed_count,
|
||||
error=error_message,
|
||||
)
|
||||
|
||||
@@ -489,16 +489,16 @@ async def _execute_webhook_node_trigger(
|
||||
return
|
||||
logger.debug(f"Executing graph #{node.graph_id} node #{node.id}")
|
||||
try:
|
||||
from backend.api.features.orgs.db import get_user_default_org_workspace
|
||||
from backend.api.features.orgs.db import get_user_default_team
|
||||
|
||||
org_id, ws_id = await get_user_default_org_workspace(webhook.user_id)
|
||||
org_id, ws_id = await get_user_default_team(webhook.user_id)
|
||||
await add_graph_execution(
|
||||
user_id=webhook.user_id,
|
||||
graph_id=node.graph_id,
|
||||
graph_version=node.graph_version,
|
||||
nodes_input_masks={node.id: {"payload": payload}},
|
||||
organization_id=org_id,
|
||||
org_workspace_id=ws_id,
|
||||
team_id=ws_id,
|
||||
)
|
||||
except GraphNotInLibraryError as e:
|
||||
logger.warning(
|
||||
@@ -555,9 +555,9 @@ async def _execute_webhook_preset_trigger(
|
||||
logger.debug(f"Executing preset #{preset.id} for webhook #{webhook.id}")
|
||||
|
||||
try:
|
||||
from backend.api.features.orgs.db import get_user_default_org_workspace
|
||||
from backend.api.features.orgs.db import get_user_default_team
|
||||
|
||||
org_id, ws_id = await get_user_default_org_workspace(webhook.user_id)
|
||||
org_id, ws_id = await get_user_default_team(webhook.user_id)
|
||||
await add_graph_execution(
|
||||
user_id=webhook.user_id,
|
||||
graph_id=preset.graph_id,
|
||||
@@ -566,7 +566,7 @@ async def _execute_webhook_preset_trigger(
|
||||
graph_credentials_inputs=preset.credentials,
|
||||
nodes_input_masks={trigger_node.id: {**preset.inputs, "payload": payload}},
|
||||
organization_id=org_id,
|
||||
org_workspace_id=ws_id,
|
||||
team_id=ws_id,
|
||||
)
|
||||
except GraphNotInLibraryError as e:
|
||||
logger.warning(
|
||||
|
||||
@@ -19,12 +19,12 @@ logger = logging.getLogger(__name__)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_user_default_org_workspace(
|
||||
async def get_user_default_team(
|
||||
user_id: str,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Get the user's personal org ID and its default workspace ID.
|
||||
|
||||
Returns (organization_id, workspace_id). Either may be None if
|
||||
Returns (organization_id, team_id). Either may be None if
|
||||
the user has no org (e.g., migration hasn't run yet).
|
||||
"""
|
||||
member = await prisma.orgmember.find_first(
|
||||
@@ -41,7 +41,7 @@ async def get_user_default_org_workspace(
|
||||
return None, None
|
||||
|
||||
org_id = member.orgId
|
||||
workspace = await prisma.orgworkspace.find_first(
|
||||
workspace = await prisma.team.find_first(
|
||||
where={"orgId": org_id, "isDefault": True}
|
||||
)
|
||||
ws_id = workspace.id if workspace else None
|
||||
@@ -80,7 +80,7 @@ async def _create_personal_org_for_user(
|
||||
}
|
||||
)
|
||||
|
||||
workspace = await prisma.orgworkspace.create(
|
||||
workspace = await prisma.team.create(
|
||||
data={
|
||||
"name": "Default",
|
||||
"orgId": org.id,
|
||||
@@ -90,9 +90,9 @@ async def _create_personal_org_for_user(
|
||||
}
|
||||
)
|
||||
|
||||
await prisma.orgworkspacemember.create(
|
||||
await prisma.teammember.create(
|
||||
data={
|
||||
"workspaceId": workspace.id,
|
||||
"teamId": workspace.id,
|
||||
"userId": user_id,
|
||||
"isAdmin": True,
|
||||
"status": "ACTIVE",
|
||||
@@ -169,7 +169,7 @@ async def create_org(
|
||||
}
|
||||
)
|
||||
|
||||
workspace = await prisma.orgworkspace.create(
|
||||
workspace = await prisma.team.create(
|
||||
data={
|
||||
"name": "Default",
|
||||
"orgId": org.id,
|
||||
@@ -179,9 +179,9 @@ async def create_org(
|
||||
}
|
||||
)
|
||||
|
||||
await prisma.orgworkspacemember.create(
|
||||
await prisma.teammember.create(
|
||||
data={
|
||||
"workspaceId": workspace.id,
|
||||
"teamId": workspace.id,
|
||||
"userId": user_id,
|
||||
"isAdmin": True,
|
||||
"status": "ACTIVE",
|
||||
@@ -381,13 +381,13 @@ async def add_org_member(
|
||||
include={"User": True},
|
||||
)
|
||||
|
||||
default_ws = await prisma.orgworkspace.find_first(
|
||||
default_ws = await prisma.team.find_first(
|
||||
where={"orgId": org_id, "isDefault": True}
|
||||
)
|
||||
if default_ws:
|
||||
await prisma.orgworkspacemember.create(
|
||||
await prisma.teammember.create(
|
||||
data={
|
||||
"workspaceId": default_ws.id,
|
||||
"teamId": default_ws.id,
|
||||
"userId": user_id,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
@@ -470,10 +470,10 @@ async def remove_org_member(org_id: str, user_id: str, requesting_user_id: str)
|
||||
# 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})
|
||||
workspaces = await prisma.team.find_many(where={"orgId": org_id})
|
||||
for ws in workspaces:
|
||||
await prisma.orgworkspacemember.delete_many(
|
||||
where={"workspaceId": ws.id, "userId": user_id}
|
||||
await prisma.teammember.delete_many(
|
||||
where={"teamId": ws.id, "userId": user_id}
|
||||
)
|
||||
|
||||
# Remove org membership
|
||||
|
||||
@@ -55,7 +55,7 @@ async def create_invitation(
|
||||
"isBillingManager": request.is_billing_manager,
|
||||
"expiresAt": expires_at,
|
||||
"invitedByUserId": ctx.user_id,
|
||||
"workspaceIds": request.workspace_ids,
|
||||
"teamIds": request.team_ids,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -162,11 +162,11 @@ async def accept_invitation(
|
||||
pass
|
||||
|
||||
# Add to specified workspaces
|
||||
for ws_id in invitation.workspaceIds:
|
||||
for ws_id in invitation.teamIds:
|
||||
try:
|
||||
from . import workspace_db as ws_db
|
||||
from . import team_db as team_db
|
||||
|
||||
await ws_db.add_workspace_member(
|
||||
await team_db.add_team_member(
|
||||
ws_id=ws_id,
|
||||
user_id=user_id,
|
||||
org_id=invitation.orgId,
|
||||
|
||||
@@ -121,7 +121,7 @@ class CreateInvitationRequest(BaseModel):
|
||||
email: str
|
||||
is_admin: bool = False
|
||||
is_billing_manager: bool = False
|
||||
workspace_ids: list[str] = Field(default_factory=list)
|
||||
team_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class InvitationResponse(BaseModel):
|
||||
@@ -132,7 +132,7 @@ class InvitationResponse(BaseModel):
|
||||
token: str
|
||||
expires_at: datetime
|
||||
created_at: datetime
|
||||
workspace_ids: list[str]
|
||||
team_ids: list[str]
|
||||
|
||||
@staticmethod
|
||||
def from_db(inv) -> "InvitationResponse":
|
||||
@@ -144,5 +144,5 @@ class InvitationResponse(BaseModel):
|
||||
token=inv.token,
|
||||
expires_at=inv.expiresAt,
|
||||
created_at=inv.createdAt,
|
||||
workspace_ids=inv.workspaceIds,
|
||||
team_ids=inv.teamIds,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Covers org CRUD, workspace CRUD, invitations, credits, and migration edge
|
||||
cases. Tests are organized by domain and mock at the Prisma boundary so the
|
||||
actual logic in db.py / routes.py / workspace_db.py is exercised.
|
||||
actual logic in db.py / routes.py / team_db.py is exercised.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -117,7 +117,7 @@ def _make_ws_member(
|
||||
):
|
||||
m = MagicMock()
|
||||
m.id = id
|
||||
m.workspaceId = workspaceId
|
||||
m.teamId = workspaceId
|
||||
m.userId = userId
|
||||
m.isAdmin = isAdmin
|
||||
m.isBillingManager = isBillingManager
|
||||
@@ -126,36 +126,36 @@ def _make_ws_member(
|
||||
user_mock = MagicMock(email=user_email)
|
||||
user_mock.name = user_name
|
||||
m.User = user_mock
|
||||
m.Workspace = _make_workspace(id=workspaceId)
|
||||
m.Team = _make_workspace(id=workspaceId)
|
||||
return m
|
||||
|
||||
|
||||
def _owner_ctx(org_id=ORG_ID, user_id=USER_ID, workspace_id=None) -> RequestContext:
|
||||
def _owner_ctx(org_id=ORG_ID, user_id=USER_ID, team_id=None) -> RequestContext:
|
||||
return RequestContext(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
workspace_id=workspace_id,
|
||||
team_id=team_id,
|
||||
is_org_owner=True,
|
||||
is_org_admin=True,
|
||||
is_org_billing_manager=False,
|
||||
is_workspace_admin=True,
|
||||
is_workspace_billing_manager=False,
|
||||
is_team_admin=True,
|
||||
is_team_billing_manager=False,
|
||||
seat_status="ACTIVE",
|
||||
)
|
||||
|
||||
|
||||
def _member_ctx(
|
||||
org_id=ORG_ID, user_id=OTHER_USER_ID, workspace_id=None
|
||||
org_id=ORG_ID, user_id=OTHER_USER_ID, team_id=None
|
||||
) -> RequestContext:
|
||||
return RequestContext(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
workspace_id=workspace_id,
|
||||
team_id=team_id,
|
||||
is_org_owner=False,
|
||||
is_org_admin=False,
|
||||
is_org_billing_manager=False,
|
||||
is_workspace_admin=False,
|
||||
is_workspace_billing_manager=False,
|
||||
is_team_admin=False,
|
||||
is_team_billing_manager=False,
|
||||
seat_status="ACTIVE",
|
||||
)
|
||||
|
||||
@@ -175,8 +175,8 @@ class TestOrgDbCreateOrg:
|
||||
self.prisma.organizationalias.find_unique = AsyncMock(return_value=None)
|
||||
self.prisma.organization.create = AsyncMock(return_value=_make_org())
|
||||
self.prisma.orgmember.create = AsyncMock()
|
||||
self.prisma.orgworkspace.create = AsyncMock(return_value=_make_workspace())
|
||||
self.prisma.orgworkspacemember.create = AsyncMock()
|
||||
self.prisma.team.create = AsyncMock(return_value=_make_workspace())
|
||||
self.prisma.teammember.create = AsyncMock()
|
||||
self.prisma.organizationprofile.create = AsyncMock()
|
||||
self.prisma.organizationseatassignment.create = AsyncMock()
|
||||
self.prisma.orgbalance.create = AsyncMock()
|
||||
@@ -210,13 +210,13 @@ class TestOrgDbCreateOrg:
|
||||
assert member_data["userId"] == USER_ID
|
||||
|
||||
# Default workspace was created
|
||||
ws_data = self.prisma.orgworkspace.create.call_args[1]["data"]
|
||||
ws_data = self.prisma.team.create.call_args[1]["data"]
|
||||
assert ws_data["name"] == "Default"
|
||||
assert ws_data["isDefault"] is True
|
||||
assert ws_data["joinPolicy"] == "OPEN"
|
||||
|
||||
# User added to default workspace
|
||||
wsm_data = self.prisma.orgworkspacemember.create.call_args[1]["data"]
|
||||
wsm_data = self.prisma.teammember.create.call_args[1]["data"]
|
||||
assert wsm_data["isAdmin"] is True
|
||||
assert wsm_data["userId"] == USER_ID
|
||||
|
||||
@@ -402,8 +402,8 @@ class TestOrgDbConvertOrg:
|
||||
# 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.team.create = AsyncMock(return_value=_make_workspace())
|
||||
self.prisma.teammember.create = AsyncMock()
|
||||
self.prisma.organizationprofile.create = AsyncMock()
|
||||
self.prisma.organizationseatassignment.create = AsyncMock()
|
||||
self.prisma.orgbalance.create = AsyncMock()
|
||||
@@ -449,8 +449,8 @@ class TestOrgDbMembers:
|
||||
new_member = _make_member(userId=OTHER_USER_ID, user_email="bob@example.com")
|
||||
self.prisma.orgmember.create = AsyncMock(return_value=new_member)
|
||||
default_ws = _make_workspace(id="ws-default", isDefault=True)
|
||||
self.prisma.orgworkspace.find_first = AsyncMock(return_value=default_ws)
|
||||
self.prisma.orgworkspacemember.create = AsyncMock()
|
||||
self.prisma.team.find_first = AsyncMock(return_value=default_ws)
|
||||
self.prisma.teammember.create = AsyncMock()
|
||||
|
||||
result = await add_org_member(
|
||||
org_id=ORG_ID,
|
||||
@@ -464,9 +464,9 @@ class TestOrgDbMembers:
|
||||
assert result.email == "bob@example.com"
|
||||
|
||||
# Workspace member auto-created for default workspace
|
||||
self.prisma.orgworkspacemember.create.assert_called_once()
|
||||
wsm_data = self.prisma.orgworkspacemember.create.call_args[1]["data"]
|
||||
assert wsm_data["workspaceId"] == "ws-default"
|
||||
self.prisma.teammember.create.assert_called_once()
|
||||
wsm_data = self.prisma.teammember.create.call_args[1]["data"]
|
||||
assert wsm_data["teamId"] == "ws-default"
|
||||
assert wsm_data["userId"] == OTHER_USER_ID
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -475,13 +475,13 @@ class TestOrgDbMembers:
|
||||
|
||||
new_member = _make_member(userId=OTHER_USER_ID)
|
||||
self.prisma.orgmember.create = AsyncMock(return_value=new_member)
|
||||
self.prisma.orgworkspace.find_first = AsyncMock(return_value=None)
|
||||
self.prisma.orgworkspacemember.create = AsyncMock()
|
||||
self.prisma.team.find_first = AsyncMock(return_value=None)
|
||||
self.prisma.teammember.create = AsyncMock()
|
||||
|
||||
await add_org_member(org_id=ORG_ID, user_id=OTHER_USER_ID)
|
||||
|
||||
# No workspace member should have been created
|
||||
self.prisma.orgworkspacemember.create.assert_not_called()
|
||||
self.prisma.teammember.create.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_member_cascades_workspace_memberships(self):
|
||||
@@ -494,16 +494,16 @@ class TestOrgDbMembers:
|
||||
|
||||
ws1 = _make_workspace(id="ws-1")
|
||||
ws2 = _make_workspace(id="ws-2")
|
||||
self.prisma.orgworkspace.find_many = AsyncMock(return_value=[ws1, ws2])
|
||||
self.prisma.orgworkspacemember.delete_many = AsyncMock()
|
||||
self.prisma.team.find_many = AsyncMock(return_value=[ws1, ws2])
|
||||
self.prisma.teammember.delete_many = AsyncMock()
|
||||
self.prisma.orgmember.delete = AsyncMock()
|
||||
|
||||
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
|
||||
calls = self.prisma.orgworkspacemember.delete_many.call_args_list
|
||||
ws_ids = [c[1]["where"]["workspaceId"] for c in calls]
|
||||
assert self.prisma.teammember.delete_many.call_count == 2
|
||||
calls = self.prisma.teammember.delete_many.call_args_list
|
||||
ws_ids = [c[1]["where"]["teamId"] for c in calls]
|
||||
assert set(ws_ids) == {"ws-1", "ws-2"}
|
||||
|
||||
# Org membership deleted
|
||||
@@ -773,7 +773,7 @@ class TestOrgRoutes:
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 2. WORKSPACE CRUD (workspace_db.py)
|
||||
# 2. WORKSPACE CRUD (team_db.py)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@@ -781,23 +781,23 @@ class TestWorkspaceDbCreate:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_prisma(self, mocker):
|
||||
self.prisma = MagicMock()
|
||||
mocker.patch("backend.api.features.orgs.workspace_db.prisma", self.prisma)
|
||||
mocker.patch("backend.api.features.orgs.team_db.prisma", self.prisma)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_workspace_creator_becomes_admin(self):
|
||||
from backend.api.features.orgs.workspace_db import create_workspace
|
||||
async def test_create_team_creator_becomes_admin(self):
|
||||
from backend.api.features.orgs.team_db import create_team
|
||||
|
||||
ws = _make_workspace(id="ws-new", isDefault=False)
|
||||
self.prisma.orgworkspace.create = AsyncMock(return_value=ws)
|
||||
self.prisma.orgworkspacemember.create = AsyncMock()
|
||||
self.prisma.team.create = AsyncMock(return_value=ws)
|
||||
self.prisma.teammember.create = AsyncMock()
|
||||
|
||||
result = await create_workspace(org_id=ORG_ID, name="Dev", user_id=USER_ID)
|
||||
result = await create_team(org_id=ORG_ID, name="Dev", user_id=USER_ID)
|
||||
|
||||
assert result.name == "Default" # from mock
|
||||
assert result.member_count == 1
|
||||
|
||||
# Verify creator is marked as admin
|
||||
wsm_data = self.prisma.orgworkspacemember.create.call_args[1]["data"]
|
||||
wsm_data = self.prisma.teammember.create.call_args[1]["data"]
|
||||
assert wsm_data["isAdmin"] is True
|
||||
assert wsm_data["userId"] == USER_ID
|
||||
assert wsm_data["status"] == "ACTIVE"
|
||||
@@ -807,163 +807,163 @@ class TestWorkspaceDbJoinLeave:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_prisma(self, mocker):
|
||||
self.prisma = MagicMock()
|
||||
mocker.patch("backend.api.features.orgs.workspace_db.prisma", self.prisma)
|
||||
mocker.patch("backend.api.features.orgs.team_db.prisma", self.prisma)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_join_open_workspace_success(self):
|
||||
from backend.api.features.orgs.workspace_db import join_workspace
|
||||
from backend.api.features.orgs.team_db import join_team
|
||||
|
||||
open_ws = _make_workspace(joinPolicy="OPEN")
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(return_value=open_ws)
|
||||
self.prisma.team.find_unique = AsyncMock(return_value=open_ws)
|
||||
# User is an org member
|
||||
self.prisma.orgmember.find_unique = AsyncMock(
|
||||
return_value=_make_member(userId=OTHER_USER_ID)
|
||||
)
|
||||
self.prisma.orgworkspacemember.find_unique = AsyncMock(return_value=None)
|
||||
self.prisma.orgworkspacemember.create = AsyncMock()
|
||||
self.prisma.teammember.find_unique = AsyncMock(return_value=None)
|
||||
self.prisma.teammember.create = AsyncMock()
|
||||
|
||||
result = await join_workspace(WS_ID, OTHER_USER_ID, ORG_ID)
|
||||
result = await join_team(WS_ID, OTHER_USER_ID, ORG_ID)
|
||||
|
||||
assert result.id == WS_ID
|
||||
self.prisma.orgworkspacemember.create.assert_called_once()
|
||||
create_data = self.prisma.orgworkspacemember.create.call_args[1]["data"]
|
||||
self.prisma.teammember.create.assert_called_once()
|
||||
create_data = self.prisma.teammember.create.call_args[1]["data"]
|
||||
assert create_data["userId"] == OTHER_USER_ID
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_join_open_workspace_already_member_is_idempotent(self):
|
||||
from backend.api.features.orgs.workspace_db import join_workspace
|
||||
from backend.api.features.orgs.team_db import join_team
|
||||
|
||||
open_ws = _make_workspace(joinPolicy="OPEN")
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(return_value=open_ws)
|
||||
self.prisma.team.find_unique = AsyncMock(return_value=open_ws)
|
||||
# User is an org member
|
||||
self.prisma.orgmember.find_unique = AsyncMock(
|
||||
return_value=_make_member(userId=USER_ID)
|
||||
)
|
||||
self.prisma.orgworkspacemember.find_unique = AsyncMock(
|
||||
self.prisma.teammember.find_unique = AsyncMock(
|
||||
return_value=_make_ws_member()
|
||||
)
|
||||
|
||||
await join_workspace(WS_ID, USER_ID, ORG_ID)
|
||||
await join_team(WS_ID, USER_ID, ORG_ID)
|
||||
|
||||
# Should return workspace without creating a duplicate member
|
||||
self.prisma.orgworkspacemember.create.assert_not_called()
|
||||
self.prisma.teammember.create.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_join_private_workspace_raises(self):
|
||||
from backend.api.features.orgs.workspace_db import join_workspace
|
||||
from backend.api.features.orgs.team_db import join_team
|
||||
|
||||
private_ws = _make_workspace(joinPolicy="PRIVATE")
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(return_value=private_ws)
|
||||
self.prisma.team.find_unique = AsyncMock(return_value=private_ws)
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot self-join a PRIVATE"):
|
||||
await join_workspace(WS_ID, OTHER_USER_ID, ORG_ID)
|
||||
await join_team(WS_ID, OTHER_USER_ID, ORG_ID)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_join_workspace_wrong_org_raises(self):
|
||||
from backend.api.features.orgs.workspace_db import join_workspace
|
||||
async def test_join_team_wrong_org_raises(self):
|
||||
from backend.api.features.orgs.team_db import join_team
|
||||
|
||||
ws = _make_workspace(orgId="other-org")
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(return_value=ws)
|
||||
self.prisma.team.find_unique = AsyncMock(return_value=ws)
|
||||
|
||||
with pytest.raises(ValueError, match="does not belong"):
|
||||
await join_workspace(WS_ID, USER_ID, ORG_ID)
|
||||
await join_team(WS_ID, USER_ID, ORG_ID)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leave_default_workspace_raises(self):
|
||||
from backend.api.features.orgs.workspace_db import leave_workspace
|
||||
from backend.api.features.orgs.team_db import leave_team
|
||||
|
||||
default_ws = _make_workspace(isDefault=True)
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(return_value=default_ws)
|
||||
self.prisma.team.find_unique = AsyncMock(return_value=default_ws)
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot leave the default"):
|
||||
await leave_workspace(WS_ID, USER_ID)
|
||||
await leave_team(WS_ID, USER_ID)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leave_non_default_workspace_success(self):
|
||||
from backend.api.features.orgs.workspace_db import leave_workspace
|
||||
from backend.api.features.orgs.team_db import leave_team
|
||||
|
||||
ws = _make_workspace(isDefault=False)
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(return_value=ws)
|
||||
self.prisma.orgworkspacemember.delete_many = AsyncMock()
|
||||
self.prisma.team.find_unique = AsyncMock(return_value=ws)
|
||||
self.prisma.teammember.delete_many = AsyncMock()
|
||||
|
||||
await leave_workspace(WS_ID, USER_ID)
|
||||
await leave_team(WS_ID, USER_ID)
|
||||
|
||||
self.prisma.orgworkspacemember.delete_many.assert_called_once()
|
||||
self.prisma.teammember.delete_many.assert_called_once()
|
||||
|
||||
|
||||
class TestWorkspaceDbDelete:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_prisma(self, mocker):
|
||||
self.prisma = MagicMock()
|
||||
mocker.patch("backend.api.features.orgs.workspace_db.prisma", self.prisma)
|
||||
mocker.patch("backend.api.features.orgs.team_db.prisma", self.prisma)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_default_workspace_raises(self):
|
||||
from backend.api.features.orgs.workspace_db import delete_workspace
|
||||
from backend.api.features.orgs.team_db import delete_team
|
||||
|
||||
default_ws = _make_workspace(isDefault=True)
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(return_value=default_ws)
|
||||
self.prisma.team.find_unique = AsyncMock(return_value=default_ws)
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot delete the default"):
|
||||
await delete_workspace(WS_ID)
|
||||
await delete_team(WS_ID)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_non_default_workspace_success(self):
|
||||
from backend.api.features.orgs.workspace_db import delete_workspace
|
||||
from backend.api.features.orgs.team_db import delete_team
|
||||
|
||||
ws = _make_workspace(isDefault=False)
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(return_value=ws)
|
||||
self.prisma.orgworkspace.delete = AsyncMock()
|
||||
self.prisma.team.find_unique = AsyncMock(return_value=ws)
|
||||
self.prisma.team.delete = AsyncMock()
|
||||
|
||||
await delete_workspace(WS_ID)
|
||||
await delete_team(WS_ID)
|
||||
|
||||
self.prisma.orgworkspace.delete.assert_called_once_with(where={"id": WS_ID})
|
||||
self.prisma.team.delete.assert_called_once_with(where={"id": WS_ID})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_workspace_not_found_raises(self):
|
||||
from backend.api.features.orgs.workspace_db import delete_workspace
|
||||
async def test_delete_team_not_found_raises(self):
|
||||
from backend.api.features.orgs.team_db import delete_team
|
||||
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(return_value=None)
|
||||
self.prisma.team.find_unique = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
await delete_workspace("nonexistent-ws")
|
||||
await delete_team("nonexistent-ws")
|
||||
|
||||
|
||||
class TestWorkspaceDbGetWorkspace:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_prisma(self, mocker):
|
||||
self.prisma = MagicMock()
|
||||
mocker.patch("backend.api.features.orgs.workspace_db.prisma", self.prisma)
|
||||
mocker.patch("backend.api.features.orgs.team_db.prisma", self.prisma)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_workspace_wrong_org_raises(self):
|
||||
from backend.api.features.orgs.workspace_db import get_workspace
|
||||
async def test_get_team_wrong_org_raises(self):
|
||||
from backend.api.features.orgs.team_db import get_team
|
||||
|
||||
ws = _make_workspace(orgId="org-real")
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(return_value=ws)
|
||||
self.prisma.team.find_unique = AsyncMock(return_value=ws)
|
||||
|
||||
with pytest.raises(NotFoundError, match="not found in org"):
|
||||
await get_workspace(WS_ID, expected_org_id="org-wrong")
|
||||
await get_team(WS_ID, expected_org_id="org-wrong")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_workspace_correct_org_success(self):
|
||||
from backend.api.features.orgs.workspace_db import get_workspace
|
||||
async def test_get_team_correct_org_success(self):
|
||||
from backend.api.features.orgs.team_db import get_team
|
||||
|
||||
ws = _make_workspace(orgId=ORG_ID)
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(return_value=ws)
|
||||
self.prisma.team.find_unique = AsyncMock(return_value=ws)
|
||||
|
||||
result = await get_workspace(WS_ID, expected_org_id=ORG_ID)
|
||||
result = await get_team(WS_ID, expected_org_id=ORG_ID)
|
||||
assert result.id == WS_ID
|
||||
assert result.org_id == ORG_ID
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_workspace_no_org_check_success(self):
|
||||
from backend.api.features.orgs.workspace_db import get_workspace
|
||||
async def test_get_team_no_org_check_success(self):
|
||||
from backend.api.features.orgs.team_db import get_team
|
||||
|
||||
ws = _make_workspace(orgId="any-org")
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(return_value=ws)
|
||||
self.prisma.team.find_unique = AsyncMock(return_value=ws)
|
||||
|
||||
result = await get_workspace(WS_ID)
|
||||
result = await get_team(WS_ID)
|
||||
assert result.id == WS_ID
|
||||
|
||||
|
||||
@@ -971,31 +971,31 @@ class TestWorkspaceDbMembers:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_prisma(self, mocker):
|
||||
self.prisma = MagicMock()
|
||||
mocker.patch("backend.api.features.orgs.workspace_db.prisma", self.prisma)
|
||||
mocker.patch("backend.api.features.orgs.team_db.prisma", self.prisma)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_workspace_member_requires_org_membership(self):
|
||||
from backend.api.features.orgs.workspace_db import add_workspace_member
|
||||
async def test_add_team_member_requires_org_membership(self):
|
||||
from backend.api.features.orgs.team_db import add_team_member
|
||||
|
||||
# Workspace belongs to the org
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(
|
||||
self.prisma.team.find_unique = AsyncMock(
|
||||
return_value=_make_workspace(orgId=ORG_ID)
|
||||
)
|
||||
self.prisma.orgmember.find_unique = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(ValueError, match="not a member of the organization"):
|
||||
await add_workspace_member(
|
||||
await add_team_member(
|
||||
ws_id=WS_ID,
|
||||
user_id="outsider",
|
||||
org_id=ORG_ID,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_workspace_member_success(self):
|
||||
from backend.api.features.orgs.workspace_db import add_workspace_member
|
||||
async def test_add_team_member_success(self):
|
||||
from backend.api.features.orgs.team_db import add_team_member
|
||||
|
||||
# Workspace belongs to the org
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(
|
||||
self.prisma.team.find_unique = AsyncMock(
|
||||
return_value=_make_workspace(orgId=ORG_ID)
|
||||
)
|
||||
org_mem = _make_member(userId=OTHER_USER_ID)
|
||||
@@ -1005,9 +1005,9 @@ class TestWorkspaceDbMembers:
|
||||
isAdmin=True,
|
||||
user_email="bob@example.com",
|
||||
)
|
||||
self.prisma.orgworkspacemember.create = AsyncMock(return_value=ws_mem)
|
||||
self.prisma.teammember.create = AsyncMock(return_value=ws_mem)
|
||||
|
||||
result = await add_workspace_member(
|
||||
result = await add_team_member(
|
||||
ws_id=WS_ID,
|
||||
user_id=OTHER_USER_ID,
|
||||
org_id=ORG_ID,
|
||||
@@ -1018,18 +1018,18 @@ class TestWorkspaceDbMembers:
|
||||
assert result.is_admin is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_workspace_members_returns_active_only(self):
|
||||
from backend.api.features.orgs.workspace_db import list_workspace_members
|
||||
async def test_list_team_members_returns_active_only(self):
|
||||
from backend.api.features.orgs.team_db import list_team_members
|
||||
|
||||
m1 = _make_ws_member(userId="u1", user_email="a@example.com", user_name="A")
|
||||
m2 = _make_ws_member(userId="u2", user_email="b@example.com", user_name="B")
|
||||
self.prisma.orgworkspacemember.find_many = AsyncMock(return_value=[m1, m2])
|
||||
self.prisma.teammember.find_many = AsyncMock(return_value=[m1, m2])
|
||||
|
||||
result = await list_workspace_members(WS_ID)
|
||||
result = await list_team_members(WS_ID)
|
||||
|
||||
assert len(result) == 2
|
||||
# Verify the query filters for active status
|
||||
find_call = self.prisma.orgworkspacemember.find_many.call_args
|
||||
find_call = self.prisma.teammember.find_many.call_args
|
||||
assert find_call[1]["where"]["status"] == "ACTIVE"
|
||||
|
||||
|
||||
@@ -1046,9 +1046,9 @@ class TestWorkspaceRoutes:
|
||||
FastAPI Security dependency overrides.
|
||||
"""
|
||||
|
||||
def test_list_workspaces_wrong_org_returns_403(self):
|
||||
"""list_workspaces raises 403 when ctx.org_id != path org_id."""
|
||||
from backend.api.features.orgs.workspace_routes import list_workspaces
|
||||
def test_list_teams_wrong_org_returns_403(self):
|
||||
"""list_teams raises 403 when ctx.org_id != path org_id."""
|
||||
from backend.api.features.orgs.team_routes import list_teams
|
||||
|
||||
ctx = _member_ctx(org_id="org-X")
|
||||
|
||||
@@ -1056,14 +1056,14 @@ class TestWorkspaceRoutes:
|
||||
import asyncio
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(
|
||||
list_workspaces(org_id="org-Y", ctx=ctx)
|
||||
list_teams(org_id="org-Y", ctx=ctx)
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
def test_list_workspace_members_requires_org_membership(self, mocker):
|
||||
def test_list_team_members_requires_org_membership(self, mocker):
|
||||
"""list_members raises 403 when ctx.org_id != path org_id."""
|
||||
from backend.api.features.orgs.workspace_routes import list_members
|
||||
from backend.api.features.orgs.team_routes import list_members
|
||||
|
||||
ctx = _member_ctx(org_id="org-mine")
|
||||
|
||||
@@ -1076,9 +1076,9 @@ class TestWorkspaceRoutes:
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
def test_get_workspace_wrong_org_returns_403(self):
|
||||
"""get_workspace raises 403 when ctx.org_id != path org_id."""
|
||||
from backend.api.features.orgs.workspace_routes import get_workspace
|
||||
def test_get_team_wrong_org_returns_403(self):
|
||||
"""get_team raises 403 when ctx.org_id != path org_id."""
|
||||
from backend.api.features.orgs.team_routes import get_team
|
||||
|
||||
ctx = _member_ctx(org_id="org-A")
|
||||
|
||||
@@ -1086,7 +1086,7 @@ class TestWorkspaceRoutes:
|
||||
import asyncio
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(
|
||||
get_workspace(org_id="org-B", ws_id=WS_ID, ctx=ctx)
|
||||
get_team(org_id="org-B", ws_id=WS_ID, ctx=ctx)
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
@@ -1117,7 +1117,7 @@ class TestInvitationAcceptance:
|
||||
expiresAt=None,
|
||||
isAdmin=False,
|
||||
isBillingManager=False,
|
||||
workspaceIds=None,
|
||||
teamIds=None,
|
||||
):
|
||||
inv = MagicMock()
|
||||
inv.id = "inv-1"
|
||||
@@ -1131,7 +1131,7 @@ class TestInvitationAcceptance:
|
||||
inv.expiresAt = expiresAt or (datetime.now(timezone.utc) + timedelta(days=3))
|
||||
inv.createdAt = FIXED_NOW
|
||||
inv.invitedByUserId = USER_ID
|
||||
inv.workspaceIds = workspaceIds or []
|
||||
inv.teamIds = teamIds or []
|
||||
return inv
|
||||
|
||||
@pytest.fixture
|
||||
@@ -1159,7 +1159,7 @@ class TestInvitationAcceptance:
|
||||
# Mock add_org_member chain
|
||||
new_member = _make_member(userId=test_user_id)
|
||||
self.prisma.orgmember.create = AsyncMock(return_value=new_member)
|
||||
self.prisma.orgworkspace.find_first = AsyncMock(return_value=None)
|
||||
self.prisma.team.find_first = AsyncMock(return_value=None)
|
||||
self.prisma.orginvitation.update = AsyncMock()
|
||||
|
||||
resp = client.post("/invitations/tok-abc/accept")
|
||||
@@ -1294,7 +1294,7 @@ class TestInvitationListPending:
|
||||
inv.token = "tok-1"
|
||||
inv.expiresAt = datetime.now(timezone.utc) + timedelta(days=5)
|
||||
inv.createdAt = FIXED_NOW
|
||||
inv.workspaceIds = []
|
||||
inv.teamIds = []
|
||||
self.prisma.orginvitation.find_many = AsyncMock(return_value=[inv])
|
||||
|
||||
resp = client.get("/invitations/pending")
|
||||
@@ -1390,12 +1390,12 @@ class TestOrgCreditsSpend:
|
||||
org_id=ORG_ID,
|
||||
user_id=USER_ID,
|
||||
amount=10,
|
||||
workspace_id="ws-1",
|
||||
team_id="ws-1",
|
||||
metadata={"reason": "block execution"},
|
||||
)
|
||||
|
||||
tx_data = self.prisma.orgcredittransaction.create.call_args[1]["data"]
|
||||
assert tx_data["workspaceId"] == "ws-1"
|
||||
assert tx_data["teamId"] == "ws-1"
|
||||
assert tx_data["metadata"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1518,8 +1518,8 @@ class TestMigrationSlugEdgeCases:
|
||||
return_value=MagicMock(id="org-new")
|
||||
)
|
||||
self.prisma.orgmember.create = AsyncMock()
|
||||
self.prisma.orgworkspace.create = AsyncMock(return_value=MagicMock(id="ws-new"))
|
||||
self.prisma.orgworkspacemember.create = AsyncMock()
|
||||
self.prisma.team.create = AsyncMock(return_value=MagicMock(id="ws-new"))
|
||||
self.prisma.teammember.create = AsyncMock()
|
||||
self.prisma.organizationprofile.create = AsyncMock()
|
||||
self.prisma.organizationseatassignment.create = AsyncMock()
|
||||
self.prisma.query_raw = AsyncMock(return_value=[])
|
||||
@@ -1804,7 +1804,7 @@ class TestOrgCreditsTransactionHistory:
|
||||
tx.type = "USAGE"
|
||||
tx.runningBalance = 90
|
||||
tx.initiatedByUserId = USER_ID
|
||||
tx.workspaceId = None
|
||||
tx.teamId = None
|
||||
tx.metadata = None
|
||||
self.prisma.orgcredittransaction.find_many = AsyncMock(return_value=[tx])
|
||||
|
||||
@@ -1877,8 +1877,8 @@ class TestConversionSpawnsNewPersonalOrg:
|
||||
)
|
||||
self.prisma.organization.update = AsyncMock()
|
||||
self.prisma.orgmember.create = AsyncMock()
|
||||
self.prisma.orgworkspace.create = AsyncMock(return_value=_make_workspace(id="new-ws"))
|
||||
self.prisma.orgworkspacemember.create = AsyncMock()
|
||||
self.prisma.team.create = AsyncMock(return_value=_make_workspace(id="new-ws"))
|
||||
self.prisma.teammember.create = AsyncMock()
|
||||
self.prisma.organizationprofile.create = AsyncMock()
|
||||
self.prisma.organizationseatassignment.create = AsyncMock()
|
||||
self.prisma.orgbalance.create = AsyncMock()
|
||||
@@ -1926,12 +1926,12 @@ class TestConversionSpawnsNewPersonalOrg:
|
||||
|
||||
await convert_personal_org(ORG_ID, USER_ID)
|
||||
|
||||
# Organization, OrgMember, OrgWorkspace, OrgWorkspaceMember,
|
||||
# Organization, OrgMember, Team, TeamMember,
|
||||
# OrganizationProfile, OrganizationSeatAssignment, OrgBalance
|
||||
self.prisma.organization.create.assert_called_once()
|
||||
self.prisma.orgmember.create.assert_called_once()
|
||||
self.prisma.orgworkspace.create.assert_called_once()
|
||||
self.prisma.orgworkspacemember.create.assert_called_once()
|
||||
self.prisma.team.create.assert_called_once()
|
||||
self.prisma.teammember.create.assert_called_once()
|
||||
self.prisma.organizationprofile.create.assert_called_once()
|
||||
self.prisma.organizationseatassignment.create.assert_called_once()
|
||||
self.prisma.orgbalance.create.assert_called_once()
|
||||
@@ -2114,7 +2114,7 @@ class TestSelfRemovalPrevention:
|
||||
self.prisma.orgmember.find_unique = AsyncMock(return_value=member)
|
||||
# User has 1 other org membership
|
||||
self.prisma.orgmember.count = AsyncMock(return_value=1)
|
||||
self.prisma.orgworkspace.find_many = AsyncMock(return_value=[])
|
||||
self.prisma.team.find_many = AsyncMock(return_value=[])
|
||||
self.prisma.orgmember.delete = AsyncMock()
|
||||
|
||||
# Should not raise
|
||||
@@ -2147,42 +2147,42 @@ class TestDefaultWorkspaceProtection:
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, mocker):
|
||||
self.prisma = MagicMock()
|
||||
mocker.patch("backend.api.features.orgs.workspace_db.prisma", self.prisma)
|
||||
mocker.patch("backend.api.features.orgs.team_db.prisma", self.prisma)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_default_workspace_join_policy_blocked(self):
|
||||
from backend.api.features.orgs.workspace_db import update_workspace
|
||||
from backend.api.features.orgs.team_db import update_team
|
||||
|
||||
default_ws = _make_workspace(isDefault=True, joinPolicy="OPEN")
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(return_value=default_ws)
|
||||
self.prisma.team.find_unique = AsyncMock(return_value=default_ws)
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot change the default workspace"):
|
||||
await update_workspace(WS_ID, {"joinPolicy": "PRIVATE"})
|
||||
await update_team(WS_ID, {"joinPolicy": "PRIVATE"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_default_workspace_name_allowed(self):
|
||||
from backend.api.features.orgs.workspace_db import update_workspace
|
||||
from backend.api.features.orgs.team_db import update_team
|
||||
|
||||
default_ws = _make_workspace(isDefault=True)
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(return_value=default_ws)
|
||||
self.prisma.orgworkspace.update = AsyncMock()
|
||||
self.prisma.team.find_unique = AsyncMock(return_value=default_ws)
|
||||
self.prisma.team.update = AsyncMock()
|
||||
|
||||
result = await update_workspace(WS_ID, {"name": "General"})
|
||||
self.prisma.orgworkspace.update.assert_called_once()
|
||||
result = await update_team(WS_ID, {"name": "General"})
|
||||
self.prisma.team.update.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_non_default_workspace_join_policy_allowed(self):
|
||||
from backend.api.features.orgs.workspace_db import update_workspace
|
||||
from backend.api.features.orgs.team_db import update_team
|
||||
|
||||
non_default_ws = _make_workspace(isDefault=False, joinPolicy="OPEN")
|
||||
updated_ws = _make_workspace(isDefault=False, joinPolicy="PRIVATE")
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(
|
||||
self.prisma.team.find_unique = AsyncMock(
|
||||
side_effect=[non_default_ws, updated_ws]
|
||||
)
|
||||
self.prisma.orgworkspace.update = AsyncMock()
|
||||
self.prisma.team.update = AsyncMock()
|
||||
|
||||
result = await update_workspace("ws-other", {"joinPolicy": "PRIVATE"})
|
||||
self.prisma.orgworkspace.update.assert_called_once()
|
||||
result = await update_team("ws-other", {"joinPolicy": "PRIVATE"})
|
||||
self.prisma.team.update.assert_called_once()
|
||||
|
||||
|
||||
class TestLastAdminGuard:
|
||||
@@ -2191,45 +2191,45 @@ class TestLastAdminGuard:
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, mocker):
|
||||
self.prisma = MagicMock()
|
||||
mocker.patch("backend.api.features.orgs.workspace_db.prisma", self.prisma)
|
||||
mocker.patch("backend.api.features.orgs.team_db.prisma", self.prisma)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_last_workspace_admin_blocked(self):
|
||||
from backend.api.features.orgs.workspace_db import remove_workspace_member
|
||||
from backend.api.features.orgs.team_db import remove_team_member
|
||||
|
||||
admin = _make_ws_member(isAdmin=True, userId="admin-1")
|
||||
self.prisma.orgworkspacemember.find_unique = AsyncMock(return_value=admin)
|
||||
self.prisma.teammember.find_unique = AsyncMock(return_value=admin)
|
||||
# Only 1 admin exists
|
||||
self.prisma.orgworkspacemember.count = AsyncMock(return_value=1)
|
||||
self.prisma.teammember.count = AsyncMock(return_value=1)
|
||||
|
||||
with pytest.raises(ValueError, match="last workspace admin"):
|
||||
await remove_workspace_member(WS_ID, "admin-1")
|
||||
await remove_team_member(WS_ID, "admin-1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_workspace_admin_when_others_exist_allowed(self):
|
||||
from backend.api.features.orgs.workspace_db import remove_workspace_member
|
||||
from backend.api.features.orgs.team_db import remove_team_member
|
||||
|
||||
admin = _make_ws_member(isAdmin=True, userId="admin-1")
|
||||
self.prisma.orgworkspacemember.find_unique = AsyncMock(return_value=admin)
|
||||
self.prisma.teammember.find_unique = AsyncMock(return_value=admin)
|
||||
# 2 admins exist — safe to remove one
|
||||
self.prisma.orgworkspacemember.count = AsyncMock(return_value=2)
|
||||
self.prisma.orgworkspacemember.delete = AsyncMock()
|
||||
self.prisma.teammember.count = AsyncMock(return_value=2)
|
||||
self.prisma.teammember.delete = AsyncMock()
|
||||
|
||||
await remove_workspace_member(WS_ID, "admin-1")
|
||||
self.prisma.orgworkspacemember.delete.assert_called_once()
|
||||
await remove_team_member(WS_ID, "admin-1")
|
||||
self.prisma.teammember.delete.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_non_admin_workspace_member_always_allowed(self):
|
||||
from backend.api.features.orgs.workspace_db import remove_workspace_member
|
||||
from backend.api.features.orgs.team_db import remove_team_member
|
||||
|
||||
member = _make_ws_member(isAdmin=False, userId="regular-1")
|
||||
self.prisma.orgworkspacemember.find_unique = AsyncMock(return_value=member)
|
||||
self.prisma.orgworkspacemember.delete = AsyncMock()
|
||||
self.prisma.teammember.find_unique = AsyncMock(return_value=member)
|
||||
self.prisma.teammember.delete = AsyncMock()
|
||||
|
||||
await remove_workspace_member(WS_ID, "regular-1")
|
||||
self.prisma.orgworkspacemember.delete.assert_called_once()
|
||||
await remove_team_member(WS_ID, "regular-1")
|
||||
self.prisma.teammember.delete.assert_called_once()
|
||||
# Should NOT have checked admin count
|
||||
self.prisma.orgworkspacemember.count.assert_not_called()
|
||||
self.prisma.teammember.count.assert_not_called()
|
||||
|
||||
|
||||
class TestInvitationIdempotency:
|
||||
@@ -2377,7 +2377,7 @@ class TestPRReviewBugs:
|
||||
self.prisma = MagicMock()
|
||||
mocker.patch("backend.api.features.orgs.db.prisma", self.prisma)
|
||||
mocker.patch("backend.api.features.orgs.invitation_routes.prisma", self.prisma)
|
||||
mocker.patch("backend.api.features.orgs.workspace_db.prisma", self.prisma)
|
||||
mocker.patch("backend.api.features.orgs.team_db.prisma", self.prisma)
|
||||
|
||||
# --- Bug: invitation routes missing _verify_org_path ---
|
||||
|
||||
@@ -2391,7 +2391,7 @@ class TestPRReviewBugs:
|
||||
request.email = "test@test.com"
|
||||
request.is_admin = False
|
||||
request.is_billing_manager = False
|
||||
request.workspace_ids = []
|
||||
request.team_ids = []
|
||||
|
||||
with pytest.raises(fastapi.HTTPException) as exc_info:
|
||||
await create_invitation(org_id="org-B", request=request, ctx=ctx)
|
||||
@@ -2441,8 +2441,8 @@ class TestPRReviewBugs:
|
||||
self.prisma.organizationalias.find_unique = AsyncMock(return_value=None)
|
||||
self.prisma.organization.create = AsyncMock(return_value=_make_org())
|
||||
self.prisma.orgmember.create = AsyncMock()
|
||||
self.prisma.orgworkspace.create = AsyncMock(return_value=_make_workspace())
|
||||
self.prisma.orgworkspacemember.create = AsyncMock()
|
||||
self.prisma.team.create = AsyncMock(return_value=_make_workspace())
|
||||
self.prisma.teammember.create = AsyncMock()
|
||||
self.prisma.organizationprofile.create = AsyncMock()
|
||||
self.prisma.organizationseatassignment.create = AsyncMock()
|
||||
self.prisma.orgbalance.create = AsyncMock()
|
||||
@@ -2466,28 +2466,28 @@ class TestPRReviewBugs:
|
||||
with pytest.raises(ValueError, match="same user"):
|
||||
await transfer_ownership(ORG_ID, USER_ID, USER_ID)
|
||||
|
||||
# --- Bug: join_workspace doesn't verify org membership ---
|
||||
# --- Bug: join_team doesn't verify org membership ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_join_workspace_requires_org_membership(self):
|
||||
"""join_workspace should verify user is actually an org member."""
|
||||
from backend.api.features.orgs.workspace_db import join_workspace
|
||||
async def test_join_team_requires_org_membership(self):
|
||||
"""join_team should verify user is actually an org member."""
|
||||
from backend.api.features.orgs.team_db import join_team
|
||||
|
||||
ws = _make_workspace(joinPolicy="OPEN")
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(return_value=ws)
|
||||
self.prisma.orgworkspacemember.find_unique = AsyncMock(return_value=None)
|
||||
self.prisma.team.find_unique = AsyncMock(return_value=ws)
|
||||
self.prisma.teammember.find_unique = AsyncMock(return_value=None)
|
||||
# User is NOT an org member
|
||||
self.prisma.orgmember.find_unique = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(ValueError, match="not a member"):
|
||||
await join_workspace(WS_ID, "non-member-user", ORG_ID)
|
||||
await join_team(WS_ID, "non-member-user", ORG_ID)
|
||||
|
||||
# --- Bug: workspace create/delete missing org path check ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_create_route_rejects_mismatched_org(self):
|
||||
"""create_workspace route should verify ctx.org_id == path org_id."""
|
||||
from backend.api.features.orgs.workspace_routes import create_workspace
|
||||
"""create_team route should verify ctx.org_id == path org_id."""
|
||||
from backend.api.features.orgs.team_routes import create_team
|
||||
|
||||
ctx = _owner_ctx(org_id="org-A")
|
||||
request = MagicMock()
|
||||
@@ -2496,7 +2496,7 @@ class TestPRReviewBugs:
|
||||
request.join_policy = "OPEN"
|
||||
|
||||
with pytest.raises(fastapi.HTTPException) as exc_info:
|
||||
await create_workspace(org_id="org-B", request=request, ctx=ctx)
|
||||
await create_team(org_id="org-B", request=request, ctx=ctx)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@@ -2507,7 +2507,7 @@ class TestPRReviewBugsRound2:
|
||||
def setup(self, mocker):
|
||||
self.prisma = MagicMock()
|
||||
mocker.patch("backend.api.features.orgs.db.prisma", self.prisma)
|
||||
mocker.patch("backend.api.features.orgs.workspace_db.prisma", self.prisma)
|
||||
mocker.patch("backend.api.features.orgs.team_db.prisma", self.prisma)
|
||||
|
||||
# --- Bug: update_org_member bare next() raises StopIteration ---
|
||||
|
||||
@@ -2549,24 +2549,24 @@ class TestPRReviewBugsRound2:
|
||||
finally:
|
||||
backend.data.org_credit.prisma = original
|
||||
|
||||
# --- Bug: add_workspace_member doesn't verify workspace belongs to org ---
|
||||
# --- Bug: add_team_member doesn't verify workspace belongs to org ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_workspace_member_verifies_workspace_in_org(self):
|
||||
"""add_workspace_member should verify the workspace actually belongs to
|
||||
async def test_add_team_member_verifies_workspace_in_org(self):
|
||||
"""add_team_member should verify the workspace actually belongs to
|
||||
the claimed org, not just that the user is in the org."""
|
||||
from backend.api.features.orgs.workspace_db import add_workspace_member
|
||||
from backend.api.features.orgs.team_db import add_team_member
|
||||
|
||||
# User is an org member of org-A
|
||||
self.prisma.orgmember.find_unique = AsyncMock(
|
||||
return_value=_make_member(orgId="org-A")
|
||||
)
|
||||
# But the workspace belongs to org-B
|
||||
self.prisma.orgworkspace.find_unique = AsyncMock(
|
||||
self.prisma.team.find_unique = AsyncMock(
|
||||
return_value=_make_workspace(orgId="org-B")
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="does not belong"):
|
||||
await add_workspace_member(
|
||||
await add_team_member(
|
||||
ws_id=WS_ID, user_id=OTHER_USER_ID, org_id="org-A"
|
||||
)
|
||||
|
||||
@@ -5,20 +5,20 @@ import logging
|
||||
from backend.data.db import prisma
|
||||
from backend.util.exceptions import NotFoundError
|
||||
|
||||
from .workspace_model import WorkspaceMemberResponse, WorkspaceResponse
|
||||
from .team_model import TeamMemberResponse, TeamResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_workspace(
|
||||
async def create_team(
|
||||
org_id: str,
|
||||
name: str,
|
||||
user_id: str,
|
||||
description: str | None = None,
|
||||
join_policy: str = "OPEN",
|
||||
) -> WorkspaceResponse:
|
||||
) -> TeamResponse:
|
||||
"""Create a workspace and make the creator an admin."""
|
||||
ws = await prisma.orgworkspace.create(
|
||||
ws = await prisma.team.create(
|
||||
data={
|
||||
"name": name,
|
||||
"orgId": org_id,
|
||||
@@ -29,21 +29,21 @@ async def create_workspace(
|
||||
)
|
||||
|
||||
# Creator becomes admin
|
||||
await prisma.orgworkspacemember.create(
|
||||
await prisma.teammember.create(
|
||||
data={
|
||||
"workspaceId": ws.id,
|
||||
"teamId": ws.id,
|
||||
"userId": user_id,
|
||||
"isAdmin": True,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
)
|
||||
|
||||
return WorkspaceResponse.from_db(ws, member_count=1)
|
||||
return TeamResponse.from_db(ws, member_count=1)
|
||||
|
||||
|
||||
async def list_workspaces(org_id: str, user_id: str) -> list[WorkspaceResponse]:
|
||||
async def list_teams(org_id: str, user_id: str) -> list[TeamResponse]:
|
||||
"""List workspaces: all OPEN workspaces + PRIVATE ones the user belongs to."""
|
||||
workspaces = await prisma.orgworkspace.find_many(
|
||||
workspaces = await prisma.team.find_many(
|
||||
where={
|
||||
"orgId": org_id,
|
||||
"archivedAt": None,
|
||||
@@ -54,51 +54,51 @@ async def list_workspaces(org_id: str, user_id: str) -> list[WorkspaceResponse]:
|
||||
},
|
||||
order={"createdAt": "asc"},
|
||||
)
|
||||
return [WorkspaceResponse.from_db(ws) for ws in workspaces]
|
||||
return [TeamResponse.from_db(ws) for ws in workspaces]
|
||||
|
||||
|
||||
async def get_workspace(
|
||||
async def get_team(
|
||||
ws_id: str, expected_org_id: str | None = None
|
||||
) -> WorkspaceResponse:
|
||||
) -> TeamResponse:
|
||||
"""Get workspace details. Validates org ownership if expected_org_id is given."""
|
||||
ws = await prisma.orgworkspace.find_unique(where={"id": ws_id})
|
||||
ws = await prisma.team.find_unique(where={"id": ws_id})
|
||||
if ws is None:
|
||||
raise NotFoundError(f"Workspace {ws_id} not found")
|
||||
if expected_org_id and ws.orgId != expected_org_id:
|
||||
raise NotFoundError(f"Workspace {ws_id} not found in org {expected_org_id}")
|
||||
return WorkspaceResponse.from_db(ws)
|
||||
return TeamResponse.from_db(ws)
|
||||
|
||||
|
||||
async def update_workspace(ws_id: str, data: dict) -> WorkspaceResponse:
|
||||
async def update_team(ws_id: str, data: dict) -> TeamResponse:
|
||||
"""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)
|
||||
return await get_team(ws_id)
|
||||
|
||||
# Guard: default workspace joinPolicy cannot be changed
|
||||
if "joinPolicy" in update_data:
|
||||
ws = await prisma.orgworkspace.find_unique(where={"id": ws_id})
|
||||
ws = await prisma.team.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)
|
||||
await prisma.team.update(where={"id": ws_id}, data=update_data)
|
||||
return await get_team(ws_id)
|
||||
|
||||
|
||||
async def delete_workspace(ws_id: str) -> None:
|
||||
async def delete_team(ws_id: str) -> None:
|
||||
"""Delete a workspace. Cannot delete the default workspace."""
|
||||
ws = await prisma.orgworkspace.find_unique(where={"id": ws_id})
|
||||
ws = await prisma.team.find_unique(where={"id": ws_id})
|
||||
if ws is None:
|
||||
raise NotFoundError(f"Workspace {ws_id} not found")
|
||||
if ws.isDefault:
|
||||
raise ValueError("Cannot delete the default workspace")
|
||||
|
||||
await prisma.orgworkspace.delete(where={"id": ws_id})
|
||||
await prisma.team.delete(where={"id": ws_id})
|
||||
|
||||
|
||||
async def join_workspace(ws_id: str, user_id: str, org_id: str) -> WorkspaceResponse:
|
||||
async def join_team(ws_id: str, user_id: str, org_id: str) -> TeamResponse:
|
||||
"""Self-join an OPEN workspace. User must be an org member."""
|
||||
ws = await prisma.orgworkspace.find_unique(where={"id": ws_id})
|
||||
ws = await prisma.team.find_unique(where={"id": ws_id})
|
||||
if ws is None:
|
||||
raise NotFoundError(f"Workspace {ws_id} not found")
|
||||
if ws.orgId != org_id:
|
||||
@@ -114,55 +114,55 @@ async def join_workspace(ws_id: str, user_id: str, org_id: str) -> WorkspaceResp
|
||||
raise ValueError(f"User {user_id} is not a member of the organization")
|
||||
|
||||
# Check not already a member
|
||||
existing = await prisma.orgworkspacemember.find_unique(
|
||||
where={"workspaceId_userId": {"workspaceId": ws_id, "userId": user_id}}
|
||||
existing = await prisma.teammember.find_unique(
|
||||
where={"teamId_userId": {"teamId": ws_id, "userId": user_id}}
|
||||
)
|
||||
if existing:
|
||||
return WorkspaceResponse.from_db(ws)
|
||||
return TeamResponse.from_db(ws)
|
||||
|
||||
await prisma.orgworkspacemember.create(
|
||||
await prisma.teammember.create(
|
||||
data={
|
||||
"workspaceId": ws_id,
|
||||
"teamId": ws_id,
|
||||
"userId": user_id,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
)
|
||||
return WorkspaceResponse.from_db(ws)
|
||||
return TeamResponse.from_db(ws)
|
||||
|
||||
|
||||
async def leave_workspace(ws_id: str, user_id: str) -> None:
|
||||
async def leave_team(ws_id: str, user_id: str) -> None:
|
||||
"""Leave a workspace. Cannot leave the default workspace."""
|
||||
ws = await prisma.orgworkspace.find_unique(where={"id": ws_id})
|
||||
ws = await prisma.team.find_unique(where={"id": ws_id})
|
||||
if ws is None:
|
||||
raise NotFoundError(f"Workspace {ws_id} not found")
|
||||
if ws.isDefault:
|
||||
raise ValueError("Cannot leave the default workspace")
|
||||
|
||||
await prisma.orgworkspacemember.delete_many(
|
||||
where={"workspaceId": ws_id, "userId": user_id}
|
||||
await prisma.teammember.delete_many(
|
||||
where={"teamId": ws_id, "userId": user_id}
|
||||
)
|
||||
|
||||
|
||||
async def list_workspace_members(ws_id: str) -> list[WorkspaceMemberResponse]:
|
||||
async def list_team_members(ws_id: str) -> list[TeamMemberResponse]:
|
||||
"""List all active members of a workspace."""
|
||||
members = await prisma.orgworkspacemember.find_many(
|
||||
where={"workspaceId": ws_id, "status": "ACTIVE"},
|
||||
members = await prisma.teammember.find_many(
|
||||
where={"teamId": ws_id, "status": "ACTIVE"},
|
||||
include={"User": True},
|
||||
)
|
||||
return [WorkspaceMemberResponse.from_db(m) for m in members]
|
||||
return [TeamMemberResponse.from_db(m) for m in members]
|
||||
|
||||
|
||||
async def add_workspace_member(
|
||||
async def add_team_member(
|
||||
ws_id: str,
|
||||
user_id: str,
|
||||
org_id: str,
|
||||
is_admin: bool = False,
|
||||
is_billing_manager: bool = False,
|
||||
invited_by: str | None = None,
|
||||
) -> WorkspaceMemberResponse:
|
||||
) -> TeamMemberResponse:
|
||||
"""Add a member to a workspace. Must be an org member, workspace must belong to org."""
|
||||
# Verify workspace belongs to the org
|
||||
ws = await prisma.orgworkspace.find_unique(where={"id": ws_id})
|
||||
ws = await prisma.team.find_unique(where={"id": ws_id})
|
||||
if ws is None or ws.orgId != org_id:
|
||||
raise ValueError(f"Workspace {ws_id} does not belong to org {org_id}")
|
||||
|
||||
@@ -173,9 +173,9 @@ async def add_workspace_member(
|
||||
if org_member is None:
|
||||
raise ValueError(f"User {user_id} is not a member of the organization")
|
||||
|
||||
member = await prisma.orgworkspacemember.create(
|
||||
member = await prisma.teammember.create(
|
||||
data={
|
||||
"workspaceId": ws_id,
|
||||
"teamId": ws_id,
|
||||
"userId": user_id,
|
||||
"isAdmin": is_admin,
|
||||
"isBillingManager": is_billing_manager,
|
||||
@@ -184,15 +184,15 @@ async def add_workspace_member(
|
||||
},
|
||||
include={"User": True},
|
||||
)
|
||||
return WorkspaceMemberResponse.from_db(member)
|
||||
return TeamMemberResponse.from_db(member)
|
||||
|
||||
|
||||
async def update_workspace_member(
|
||||
async def update_team_member(
|
||||
ws_id: str,
|
||||
user_id: str,
|
||||
is_admin: bool | None,
|
||||
is_billing_manager: bool | None,
|
||||
) -> WorkspaceMemberResponse:
|
||||
) -> TeamMemberResponse:
|
||||
"""Update a workspace member's role flags."""
|
||||
update_data: dict = {}
|
||||
if is_admin is not None:
|
||||
@@ -201,27 +201,27 @@ async def update_workspace_member(
|
||||
update_data["isBillingManager"] = is_billing_manager
|
||||
|
||||
if update_data:
|
||||
await prisma.orgworkspacemember.update(
|
||||
where={"workspaceId_userId": {"workspaceId": ws_id, "userId": user_id}},
|
||||
await prisma.teammember.update(
|
||||
where={"teamId_userId": {"teamId": ws_id, "userId": user_id}},
|
||||
data=update_data,
|
||||
)
|
||||
|
||||
members = await list_workspace_members(ws_id)
|
||||
members = await list_team_members(ws_id)
|
||||
return next(m for m in members if m.user_id == user_id)
|
||||
|
||||
|
||||
async def remove_workspace_member(ws_id: str, user_id: str) -> None:
|
||||
async def remove_team_member(ws_id: str, user_id: str) -> None:
|
||||
"""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}}
|
||||
member = await prisma.teammember.find_unique(
|
||||
where={"teamId_userId": {"teamId": ws_id, "userId": user_id}}
|
||||
)
|
||||
if member and member.isAdmin:
|
||||
admin_count = await prisma.orgworkspacemember.count(
|
||||
where={"workspaceId": ws_id, "isAdmin": True, "status": "ACTIVE"}
|
||||
admin_count = await prisma.teammember.count(
|
||||
where={"teamId": ws_id, "isAdmin": True, "status": "ACTIVE"}
|
||||
)
|
||||
if admin_count <= 1:
|
||||
raise ValueError(
|
||||
@@ -229,6 +229,6 @@ async def remove_workspace_member(ws_id: str, user_id: str) -> None:
|
||||
"Promote another member to admin first."
|
||||
)
|
||||
|
||||
await prisma.orgworkspacemember.delete(
|
||||
where={"workspaceId_userId": {"workspaceId": ws_id, "userId": user_id}}
|
||||
await prisma.teammember.delete(
|
||||
where={"teamId_userId": {"teamId": ws_id, "userId": user_id}}
|
||||
)
|
||||
@@ -5,19 +5,19 @@ from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateWorkspaceRequest(BaseModel):
|
||||
class CreateTeamRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
join_policy: str = "OPEN" # OPEN or PRIVATE
|
||||
|
||||
|
||||
class UpdateWorkspaceRequest(BaseModel):
|
||||
class UpdateTeamRequest(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
join_policy: str | None = None # OPEN or PRIVATE
|
||||
|
||||
|
||||
class WorkspaceResponse(BaseModel):
|
||||
class TeamResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
slug: str | None
|
||||
@@ -29,8 +29,8 @@ class WorkspaceResponse(BaseModel):
|
||||
created_at: datetime
|
||||
|
||||
@staticmethod
|
||||
def from_db(ws, member_count: int = 0) -> "WorkspaceResponse":
|
||||
return WorkspaceResponse(
|
||||
def from_db(ws, member_count: int = 0) -> "TeamResponse":
|
||||
return TeamResponse(
|
||||
id=ws.id,
|
||||
name=ws.name,
|
||||
slug=ws.slug,
|
||||
@@ -43,7 +43,7 @@ class WorkspaceResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceMemberResponse(BaseModel):
|
||||
class TeamMemberResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
email: str
|
||||
@@ -53,8 +53,8 @@ class WorkspaceMemberResponse(BaseModel):
|
||||
joined_at: datetime
|
||||
|
||||
@staticmethod
|
||||
def from_db(member) -> "WorkspaceMemberResponse":
|
||||
return WorkspaceMemberResponse(
|
||||
def from_db(member) -> "TeamMemberResponse":
|
||||
return TeamMemberResponse(
|
||||
id=member.id,
|
||||
user_id=member.userId,
|
||||
email=member.User.email if member.User else "",
|
||||
@@ -65,12 +65,12 @@ class WorkspaceMemberResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class AddWorkspaceMemberRequest(BaseModel):
|
||||
class AddTeamMemberRequest(BaseModel):
|
||||
user_id: str
|
||||
is_admin: bool = False
|
||||
is_billing_manager: bool = False
|
||||
|
||||
|
||||
class UpdateWorkspaceMemberRequest(BaseModel):
|
||||
class UpdateTeamMemberRequest(BaseModel):
|
||||
is_admin: bool | None = None
|
||||
is_billing_manager: bool | None = None
|
||||
@@ -5,20 +5,20 @@ from typing import Annotated
|
||||
from autogpt_libs.auth import (
|
||||
get_request_context,
|
||||
requires_org_permission,
|
||||
requires_workspace_permission,
|
||||
requires_team_permission,
|
||||
)
|
||||
from autogpt_libs.auth.models import RequestContext
|
||||
from autogpt_libs.auth.permissions import OrgAction, WorkspaceAction
|
||||
from autogpt_libs.auth.permissions import OrgAction, TeamAction
|
||||
from fastapi import APIRouter, HTTPException, Security
|
||||
|
||||
from . import workspace_db as ws_db
|
||||
from .workspace_model import (
|
||||
AddWorkspaceMemberRequest,
|
||||
CreateWorkspaceRequest,
|
||||
UpdateWorkspaceMemberRequest,
|
||||
UpdateWorkspaceRequest,
|
||||
WorkspaceMemberResponse,
|
||||
WorkspaceResponse,
|
||||
from . import team_db as team_db
|
||||
from .team_model import (
|
||||
AddTeamMemberRequest,
|
||||
CreateTeamRequest,
|
||||
UpdateTeamMemberRequest,
|
||||
UpdateTeamRequest,
|
||||
TeamMemberResponse,
|
||||
TeamResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -29,17 +29,17 @@ router = APIRouter()
|
||||
summary="Create workspace",
|
||||
tags=["orgs", "workspaces"],
|
||||
)
|
||||
async def create_workspace(
|
||||
async def create_team(
|
||||
org_id: str,
|
||||
request: CreateWorkspaceRequest,
|
||||
request: CreateTeamRequest,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.CREATE_WORKSPACES)),
|
||||
],
|
||||
) -> WorkspaceResponse:
|
||||
) -> TeamResponse:
|
||||
if ctx.org_id != org_id:
|
||||
raise HTTPException(403, detail="Not a member of this organization")
|
||||
return await ws_db.create_workspace(
|
||||
return await team_db.create_team(
|
||||
org_id=org_id,
|
||||
name=request.name,
|
||||
user_id=ctx.user_id,
|
||||
@@ -53,13 +53,13 @@ async def create_workspace(
|
||||
summary="List workspaces",
|
||||
tags=["orgs", "workspaces"],
|
||||
)
|
||||
async def list_workspaces(
|
||||
async def list_teams(
|
||||
org_id: str,
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> list[WorkspaceResponse]:
|
||||
) -> list[TeamResponse]:
|
||||
if ctx.org_id != org_id:
|
||||
raise HTTPException(403, detail="Not a member of this organization")
|
||||
return await ws_db.list_workspaces(org_id, ctx.user_id)
|
||||
return await team_db.list_teams(org_id, ctx.user_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -67,14 +67,14 @@ async def list_workspaces(
|
||||
summary="Get workspace details",
|
||||
tags=["orgs", "workspaces"],
|
||||
)
|
||||
async def get_workspace(
|
||||
async def get_team(
|
||||
org_id: str,
|
||||
ws_id: str,
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> WorkspaceResponse:
|
||||
) -> TeamResponse:
|
||||
if ctx.org_id != org_id:
|
||||
raise HTTPException(403, detail="Not a member of this organization")
|
||||
return await ws_db.get_workspace(ws_id, expected_org_id=org_id)
|
||||
return await team_db.get_team(ws_id, expected_org_id=org_id)
|
||||
|
||||
|
||||
@router.patch(
|
||||
@@ -82,18 +82,18 @@ async def get_workspace(
|
||||
summary="Update workspace",
|
||||
tags=["orgs", "workspaces"],
|
||||
)
|
||||
async def update_workspace(
|
||||
async def update_team(
|
||||
org_id: str,
|
||||
ws_id: str,
|
||||
request: UpdateWorkspaceRequest,
|
||||
request: UpdateTeamRequest,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_workspace_permission(WorkspaceAction.MANAGE_SETTINGS)),
|
||||
Security(requires_team_permission(TeamAction.MANAGE_SETTINGS)),
|
||||
],
|
||||
) -> WorkspaceResponse:
|
||||
) -> TeamResponse:
|
||||
# Verify workspace belongs to org (ctx validates workspace membership)
|
||||
await ws_db.get_workspace(ws_id, expected_org_id=org_id)
|
||||
return await ws_db.update_workspace(
|
||||
await team_db.get_team(ws_id, expected_org_id=org_id)
|
||||
return await team_db.update_team(
|
||||
ws_id,
|
||||
{
|
||||
"name": request.name,
|
||||
@@ -109,7 +109,7 @@ async def update_workspace(
|
||||
tags=["orgs", "workspaces"],
|
||||
status_code=204,
|
||||
)
|
||||
async def delete_workspace(
|
||||
async def delete_team(
|
||||
org_id: str,
|
||||
ws_id: str,
|
||||
ctx: Annotated[
|
||||
@@ -117,8 +117,8 @@ async def delete_workspace(
|
||||
Security(requires_org_permission(OrgAction.MANAGE_WORKSPACES)),
|
||||
],
|
||||
) -> None:
|
||||
await ws_db.get_workspace(ws_id, expected_org_id=org_id)
|
||||
await ws_db.delete_workspace(ws_id)
|
||||
await team_db.get_team(ws_id, expected_org_id=org_id)
|
||||
await team_db.delete_team(ws_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -126,12 +126,12 @@ async def delete_workspace(
|
||||
summary="Self-join open workspace",
|
||||
tags=["orgs", "workspaces"],
|
||||
)
|
||||
async def join_workspace(
|
||||
async def join_team(
|
||||
org_id: str,
|
||||
ws_id: str,
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> WorkspaceResponse:
|
||||
return await ws_db.join_workspace(ws_id, ctx.user_id, org_id)
|
||||
) -> TeamResponse:
|
||||
return await team_db.join_team(ws_id, ctx.user_id, org_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -140,12 +140,12 @@ async def join_workspace(
|
||||
tags=["orgs", "workspaces"],
|
||||
status_code=204,
|
||||
)
|
||||
async def leave_workspace(
|
||||
async def leave_team(
|
||||
org_id: str,
|
||||
ws_id: str,
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> None:
|
||||
await ws_db.leave_workspace(ws_id, ctx.user_id)
|
||||
await team_db.leave_team(ws_id, ctx.user_id)
|
||||
|
||||
|
||||
# --- Members ---
|
||||
@@ -160,11 +160,11 @@ async def list_members(
|
||||
org_id: str,
|
||||
ws_id: str,
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> list[WorkspaceMemberResponse]:
|
||||
) -> list[TeamMemberResponse]:
|
||||
if ctx.org_id != org_id:
|
||||
raise HTTPException(403, detail="Not a member of this organization")
|
||||
await ws_db.get_workspace(ws_id, expected_org_id=org_id)
|
||||
return await ws_db.list_workspace_members(ws_id)
|
||||
await team_db.get_team(ws_id, expected_org_id=org_id)
|
||||
return await team_db.list_team_members(ws_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -175,13 +175,13 @@ async def list_members(
|
||||
async def add_member(
|
||||
org_id: str,
|
||||
ws_id: str,
|
||||
request: AddWorkspaceMemberRequest,
|
||||
request: AddTeamMemberRequest,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_workspace_permission(WorkspaceAction.MANAGE_MEMBERS)),
|
||||
Security(requires_team_permission(TeamAction.MANAGE_MEMBERS)),
|
||||
],
|
||||
) -> WorkspaceMemberResponse:
|
||||
return await ws_db.add_workspace_member(
|
||||
) -> TeamMemberResponse:
|
||||
return await team_db.add_team_member(
|
||||
ws_id=ws_id,
|
||||
user_id=request.user_id,
|
||||
org_id=org_id,
|
||||
@@ -200,13 +200,13 @@ async def update_member(
|
||||
org_id: str,
|
||||
ws_id: str,
|
||||
uid: str,
|
||||
request: UpdateWorkspaceMemberRequest,
|
||||
request: UpdateTeamMemberRequest,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_workspace_permission(WorkspaceAction.MANAGE_MEMBERS)),
|
||||
Security(requires_team_permission(TeamAction.MANAGE_MEMBERS)),
|
||||
],
|
||||
) -> WorkspaceMemberResponse:
|
||||
return await ws_db.update_workspace_member(
|
||||
) -> TeamMemberResponse:
|
||||
return await team_db.update_team_member(
|
||||
ws_id=ws_id,
|
||||
user_id=uid,
|
||||
is_admin=request.is_admin,
|
||||
@@ -226,7 +226,7 @@ async def remove_member(
|
||||
uid: str,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_workspace_permission(WorkspaceAction.MANAGE_MEMBERS)),
|
||||
Security(requires_team_permission(TeamAction.MANAGE_MEMBERS)),
|
||||
],
|
||||
) -> None:
|
||||
await ws_db.remove_workspace_member(ws_id, uid)
|
||||
await team_db.remove_team_member(ws_id, uid)
|
||||
@@ -860,7 +860,7 @@ async def create_new_graph(
|
||||
graph,
|
||||
user_id=user_id,
|
||||
organization_id=ctx.org_id,
|
||||
org_workspace_id=ctx.workspace_id,
|
||||
team_id=ctx.team_id,
|
||||
)
|
||||
await library_db.create_library_agent(graph, user_id)
|
||||
activated_graph = await on_graph_activate(graph, user_id=user_id)
|
||||
@@ -918,7 +918,7 @@ async def update_graph(
|
||||
graph,
|
||||
user_id=user_id,
|
||||
organization_id=ctx.org_id,
|
||||
org_workspace_id=ctx.workspace_id,
|
||||
team_id=ctx.team_id,
|
||||
)
|
||||
|
||||
if new_graph_version.is_active:
|
||||
@@ -1050,7 +1050,7 @@ async def execute_graph(
|
||||
graph_credentials_inputs=credentials_inputs,
|
||||
dry_run=dry_run,
|
||||
organization_id=ctx.org_id,
|
||||
org_workspace_id=ctx.workspace_id,
|
||||
team_id=ctx.team_id,
|
||||
)
|
||||
# Record successful graph execution
|
||||
record_graph_execution(graph_id=graph_id, status="success", user_id=user_id)
|
||||
@@ -1429,7 +1429,7 @@ async def create_graph_execution_schedule(
|
||||
input_credentials=schedule_params.credentials,
|
||||
user_timezone=user_timezone,
|
||||
organization_id=ctx.org_id,
|
||||
org_workspace_id=ctx.workspace_id,
|
||||
team_id=ctx.team_id,
|
||||
)
|
||||
|
||||
# Convert the next_run_time back to user timezone for display
|
||||
|
||||
@@ -31,13 +31,13 @@ import backend.api.features.mcp.routes as mcp_routes
|
||||
import backend.api.features.oauth
|
||||
import backend.api.features.orgs.invitation_routes
|
||||
import backend.api.features.orgs.routes as org_routes
|
||||
import backend.api.features.orgs.workspace_routes
|
||||
import backend.api.features.orgs.team_routes
|
||||
import backend.api.features.otto.routes
|
||||
import backend.api.features.postmark.postmark
|
||||
import backend.api.features.store.model
|
||||
import backend.api.features.store.routes
|
||||
import backend.api.features.v1
|
||||
import backend.api.features.workspace.routes as workspace_routes
|
||||
import backend.api.features.workspace.routes as team_routes
|
||||
import backend.data.block
|
||||
import backend.data.db
|
||||
import backend.data.graph
|
||||
@@ -357,7 +357,7 @@ app.include_router(
|
||||
prefix="/api/chat",
|
||||
)
|
||||
app.include_router(
|
||||
workspace_routes.router,
|
||||
team_routes.router,
|
||||
tags=["workspace"],
|
||||
prefix="/api/workspace",
|
||||
)
|
||||
@@ -377,7 +377,7 @@ app.include_router(
|
||||
prefix="/api/orgs",
|
||||
)
|
||||
app.include_router(
|
||||
backend.api.features.orgs.workspace_routes.router,
|
||||
backend.api.features.orgs.team_routes.router,
|
||||
tags=["v2", "orgs", "workspaces"],
|
||||
prefix="/api/orgs/{org_id}/workspaces",
|
||||
)
|
||||
@@ -450,12 +450,12 @@ class AgentServer(backend.util.service.AppProcess):
|
||||
ctx = RequestContext(
|
||||
user_id=user_id,
|
||||
org_id="test-org",
|
||||
workspace_id="test-workspace",
|
||||
team_id="test-workspace",
|
||||
is_org_owner=True,
|
||||
is_org_admin=True,
|
||||
is_org_billing_manager=False,
|
||||
is_workspace_admin=True,
|
||||
is_workspace_billing_manager=False,
|
||||
is_team_admin=True,
|
||||
is_team_billing_manager=False,
|
||||
seat_status="ACTIVE",
|
||||
)
|
||||
return await backend.api.features.v1.execute_graph(
|
||||
@@ -488,12 +488,12 @@ class AgentServer(backend.util.service.AppProcess):
|
||||
ctx = RequestContext(
|
||||
user_id=user_id,
|
||||
org_id="test-org",
|
||||
workspace_id="test-workspace",
|
||||
team_id="test-workspace",
|
||||
is_org_owner=True,
|
||||
is_org_admin=True,
|
||||
is_org_billing_manager=False,
|
||||
is_workspace_admin=True,
|
||||
is_workspace_billing_manager=False,
|
||||
is_team_admin=True,
|
||||
is_team_billing_manager=False,
|
||||
seat_status="ACTIVE",
|
||||
)
|
||||
return await backend.api.features.v1.create_new_graph(
|
||||
|
||||
@@ -43,7 +43,7 @@ async def create_chat_session(
|
||||
user_id: str,
|
||||
*,
|
||||
organization_id: str | None = None,
|
||||
org_workspace_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
metadata: ChatSessionMetadata | None = None,
|
||||
) -> ChatSessionInfo:
|
||||
"""Create a new chat session in the database."""
|
||||
@@ -55,7 +55,7 @@ async def create_chat_session(
|
||||
successfulAgentSchedules=SafeJson({}),
|
||||
# Tenancy dual-write fields
|
||||
**({"organizationId": organization_id} if organization_id else {}),
|
||||
**({"orgWorkspaceId": org_workspace_id} if org_workspace_id else {}),
|
||||
**({"teamId": team_id} if team_id else {}),
|
||||
metadata=SafeJson((metadata or ChatSessionMetadata()).model_dump()),
|
||||
)
|
||||
prisma_session = await PrismaChatSession.prisma().create(data=data)
|
||||
|
||||
@@ -159,7 +159,7 @@ class CoPilotExecutionEntry(BaseModel):
|
||||
organization_id: str | None = None
|
||||
"""Active organization for tenant-scoped execution"""
|
||||
|
||||
org_workspace_id: str | None = None
|
||||
team_id: str | None = None
|
||||
"""Active workspace for tenant-scoped execution"""
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ async def enqueue_copilot_turn(
|
||||
context: dict[str, str] | None = None,
|
||||
file_ids: list[str] | None = None,
|
||||
organization_id: str | None = None,
|
||||
org_workspace_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
) -> None:
|
||||
"""Enqueue a CoPilot task for processing by the executor service.
|
||||
|
||||
@@ -206,7 +206,7 @@ async def enqueue_copilot_turn(
|
||||
context=context,
|
||||
file_ids=file_ids,
|
||||
organization_id=organization_id,
|
||||
org_workspace_id=org_workspace_id,
|
||||
team_id=team_id,
|
||||
)
|
||||
|
||||
queue_client = await get_async_copilot_queue()
|
||||
|
||||
@@ -98,13 +98,13 @@ class ExecutionContext(BaseModel):
|
||||
root_execution_id: Optional[str] = None
|
||||
parent_execution_id: Optional[str] = None
|
||||
|
||||
# Workspace (file storage)
|
||||
# File workspace (UserWorkspace — NOT the Team concept)
|
||||
workspace_id: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
|
||||
# Org/workspace tenancy context
|
||||
# Org/team tenancy context
|
||||
organization_id: Optional[str] = None
|
||||
org_workspace_id: Optional[str] = None
|
||||
team_id: Optional[str] = None
|
||||
|
||||
|
||||
# -------------------------- Models -------------------------- #
|
||||
@@ -521,7 +521,7 @@ async def get_graph_executions(
|
||||
created_time_gte: Optional[datetime] = None,
|
||||
created_time_lte: Optional[datetime] = None,
|
||||
limit: Optional[int] = None,
|
||||
workspace_id: Optional[str] = None,
|
||||
team_id: Optional[str] = None,
|
||||
) -> list[GraphExecutionMeta]:
|
||||
"""⚠️ **Optional `user_id` check**: MUST USE check in user-facing endpoints."""
|
||||
where_filter: AgentGraphExecutionWhereInput = {
|
||||
@@ -529,9 +529,9 @@ async def get_graph_executions(
|
||||
}
|
||||
if graph_exec_id:
|
||||
where_filter["id"] = graph_exec_id
|
||||
# Prefer workspace_id scoping over user_id when available
|
||||
if workspace_id:
|
||||
where_filter["orgWorkspaceId"] = workspace_id
|
||||
# Prefer team_id scoping over user_id when available
|
||||
if team_id:
|
||||
where_filter["teamId"] = team_id
|
||||
elif user_id:
|
||||
where_filter["userId"] = user_id
|
||||
if graph_id:
|
||||
@@ -739,7 +739,7 @@ async def create_graph_execution(
|
||||
parent_graph_exec_id: Optional[str] = None,
|
||||
is_dry_run: bool = False,
|
||||
organization_id: Optional[str] = None,
|
||||
org_workspace_id: Optional[str] = None,
|
||||
team_id: Optional[str] = None,
|
||||
) -> GraphExecutionWithNodes:
|
||||
"""
|
||||
Create a new AgentGraphExecution record.
|
||||
@@ -780,7 +780,7 @@ async def create_graph_execution(
|
||||
**({"stats": Json({"is_dry_run": True})} if is_dry_run else {}),
|
||||
# Tenancy dual-write fields
|
||||
**({"organizationId": organization_id} if organization_id else {}),
|
||||
**({"orgWorkspaceId": org_workspace_id} if org_workspace_id else {}),
|
||||
**({"teamId": team_id} if team_id else {}),
|
||||
},
|
||||
include=GRAPH_EXECUTION_INCLUDE_WITH_NODES,
|
||||
)
|
||||
|
||||
@@ -1090,7 +1090,7 @@ async def get_graph(
|
||||
for_export: bool = False,
|
||||
include_subgraphs: bool = False,
|
||||
skip_access_check: bool = False,
|
||||
workspace_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
) -> GraphModel | None:
|
||||
"""
|
||||
Retrieves a graph from the DB.
|
||||
@@ -1104,16 +1104,16 @@ async def get_graph(
|
||||
graph = None
|
||||
|
||||
# Only search graph directly on owned graph (or access check is skipped)
|
||||
if skip_access_check or user_id is not None or workspace_id is not None:
|
||||
if skip_access_check or user_id is not None or team_id is not None:
|
||||
graph_where_clause: AgentGraphWhereInput = {
|
||||
"id": graph_id,
|
||||
}
|
||||
if version is not None:
|
||||
graph_where_clause["version"] = version
|
||||
# Prefer workspace_id scoping over user_id when both are available
|
||||
# Prefer team_id scoping over user_id when both are available
|
||||
if not skip_access_check:
|
||||
if workspace_id is not None:
|
||||
graph_where_clause["orgWorkspaceId"] = workspace_id
|
||||
if team_id is not None:
|
||||
graph_where_clause["teamId"] = team_id
|
||||
elif user_id is not None:
|
||||
graph_where_clause["userId"] = user_id
|
||||
|
||||
@@ -1341,11 +1341,11 @@ async def get_graph_all_versions(
|
||||
graph_id: str,
|
||||
user_id: str,
|
||||
limit: int = MAX_GRAPH_VERSIONS_FETCH,
|
||||
workspace_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
) -> list[GraphModel]:
|
||||
where_clause: AgentGraphWhereInput = {"id": graph_id}
|
||||
if workspace_id is not None:
|
||||
where_clause["orgWorkspaceId"] = workspace_id
|
||||
if team_id is not None:
|
||||
where_clause["teamId"] = team_id
|
||||
else:
|
||||
where_clause["userId"] = user_id
|
||||
|
||||
@@ -1513,7 +1513,7 @@ async def create_graph(
|
||||
user_id: str,
|
||||
*,
|
||||
organization_id: str | None = None,
|
||||
org_workspace_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
) -> GraphModel:
|
||||
async with transaction() as tx:
|
||||
await __create_graph(
|
||||
@@ -1521,7 +1521,7 @@ async def create_graph(
|
||||
graph,
|
||||
user_id,
|
||||
organization_id=organization_id,
|
||||
org_workspace_id=org_workspace_id,
|
||||
team_id=team_id,
|
||||
)
|
||||
|
||||
if created_graph := await get_graph(graph.id, graph.version, user_id=user_id):
|
||||
@@ -1536,7 +1536,7 @@ async def fork_graph(
|
||||
user_id: str,
|
||||
*,
|
||||
organization_id: str | None = None,
|
||||
org_workspace_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
) -> GraphModel:
|
||||
"""
|
||||
Forks a graph by copying it and all its nodes and links to a new graph.
|
||||
@@ -1558,7 +1558,7 @@ async def fork_graph(
|
||||
graph,
|
||||
user_id,
|
||||
organization_id=organization_id,
|
||||
org_workspace_id=org_workspace_id,
|
||||
team_id=team_id,
|
||||
)
|
||||
|
||||
return graph
|
||||
@@ -1570,7 +1570,7 @@ async def __create_graph(
|
||||
user_id: str,
|
||||
*,
|
||||
organization_id: str | None = None,
|
||||
org_workspace_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
):
|
||||
graphs = [graph] + graph.sub_graphs
|
||||
|
||||
@@ -1610,7 +1610,7 @@ async def __create_graph(
|
||||
forkedFromVersion=graph.forked_from_version,
|
||||
# Tenancy dual-write fields
|
||||
organizationId=organization_id,
|
||||
orgWorkspaceId=org_workspace_id,
|
||||
teamId=team_id,
|
||||
)
|
||||
for graph in graphs
|
||||
]
|
||||
|
||||
@@ -32,7 +32,7 @@ async def spend_org_credits(
|
||||
org_id: str,
|
||||
user_id: str,
|
||||
amount: int,
|
||||
workspace_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> int:
|
||||
"""Atomically spend credits from the org balance.
|
||||
@@ -80,8 +80,8 @@ async def spend_org_credits(
|
||||
"type": CreditTransactionType.USAGE,
|
||||
"runningBalance": new_balance,
|
||||
}
|
||||
if workspace_id:
|
||||
tx_data["workspaceId"] = workspace_id
|
||||
if team_id:
|
||||
tx_data["teamId"] = team_id
|
||||
if metadata:
|
||||
tx_data["metadata"] = SafeJson(metadata)
|
||||
|
||||
@@ -156,7 +156,7 @@ async def get_org_transaction_history(
|
||||
"type": t.type,
|
||||
"runningBalance": t.runningBalance,
|
||||
"initiatedByUserId": t.initiatedByUserId,
|
||||
"workspaceId": t.workspaceId,
|
||||
"teamId": t.teamId,
|
||||
"metadata": t.metadata,
|
||||
}
|
||||
for t in transactions
|
||||
|
||||
@@ -96,11 +96,11 @@ class TestSpendOrgCredits:
|
||||
)
|
||||
|
||||
await spend_org_credits(
|
||||
"org-1", "user-1", 200, workspace_id="ws-1", metadata={"block": "llm"}
|
||||
"org-1", "user-1", 200, team_id="ws-1", metadata={"block": "llm"}
|
||||
)
|
||||
|
||||
tx_data = mock_prisma.orgcredittransaction.create.call_args[1]["data"]
|
||||
assert tx_data["workspaceId"] == "ws-1"
|
||||
assert tx_data["teamId"] == "ws-1"
|
||||
assert tx_data["amount"] == -200
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ class TestGetOrgTransactionHistory:
|
||||
type="USAGE",
|
||||
runningBalance=900,
|
||||
initiatedByUserId="user-1",
|
||||
workspaceId="ws-1",
|
||||
teamId="ws-1",
|
||||
metadata=None,
|
||||
)
|
||||
mock_prisma.orgcredittransaction.find_many = AsyncMock(return_value=[mock_tx])
|
||||
@@ -156,7 +156,7 @@ class TestGetOrgTransactionHistory:
|
||||
result = await get_org_transaction_history("org-1", limit=10)
|
||||
assert len(result) == 1
|
||||
assert result[0]["amount"] == -100
|
||||
assert result[0]["workspaceId"] == "ws-1"
|
||||
assert result[0]["teamId"] == "ws-1"
|
||||
|
||||
|
||||
class TestSeatManagement:
|
||||
|
||||
@@ -129,8 +129,8 @@ async def create_orgs_for_existing_users() -> int:
|
||||
}
|
||||
)
|
||||
|
||||
# Create default OrgWorkspace
|
||||
workspace = await prisma.orgworkspace.create(
|
||||
# Create default Team
|
||||
workspace = await prisma.team.create(
|
||||
data={
|
||||
"name": "Default",
|
||||
"Org": {"connect": {"id": org.id}},
|
||||
@@ -140,8 +140,8 @@ async def create_orgs_for_existing_users() -> int:
|
||||
}
|
||||
)
|
||||
|
||||
# Create OrgWorkspaceMember
|
||||
await prisma.orgworkspacemember.create(
|
||||
# Create TeamMember
|
||||
await prisma.teammember.create(
|
||||
data={
|
||||
"Workspace": {"connect": {"id": workspace.id}},
|
||||
"User": {"connect": {"id": user_id}},
|
||||
@@ -239,13 +239,13 @@ async def migrate_credit_transactions() -> int:
|
||||
return result
|
||||
|
||||
|
||||
async def _assign_workspace_tenancy(table_sql: "LiteralString") -> int:
|
||||
"""Assign organizationId + orgWorkspaceId on a single table's unassigned rows."""
|
||||
async def _assign_team_tenancy(table_sql: "LiteralString") -> int:
|
||||
"""Assign organizationId + teamId on a single table's unassigned rows."""
|
||||
return await prisma.execute_raw(table_sql)
|
||||
|
||||
|
||||
async def assign_resources_to_workspaces() -> dict[str, int]:
|
||||
"""Set organizationId and orgWorkspaceId on all tenant-bound rows that lack them.
|
||||
async def assign_resources_to_teams() -> dict[str, int]:
|
||||
"""Set organizationId and teamId on all tenant-bound rows that lack them.
|
||||
|
||||
Uses the user's personal org and its default workspace.
|
||||
|
||||
@@ -253,92 +253,92 @@ async def assign_resources_to_workspaces() -> dict[str, int]:
|
||||
"""
|
||||
results: dict[str, int] = {}
|
||||
|
||||
# --- Tables needing both organizationId + orgWorkspaceId ---
|
||||
# --- Tables needing both organizationId + teamId ---
|
||||
|
||||
results["AgentGraph"] = await _assign_workspace_tenancy(
|
||||
results["AgentGraph"] = await _assign_team_tenancy(
|
||||
"""
|
||||
UPDATE "AgentGraph" t
|
||||
SET "organizationId" = o."id", "orgWorkspaceId" = w."id"
|
||||
SET "organizationId" = o."id", "teamId" = w."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
|
||||
JOIN "OrgWorkspace" w ON w."orgId" = o."id" AND w."isDefault" = true
|
||||
JOIN "Team" w ON w."orgId" = o."id" AND w."isDefault" = true
|
||||
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
results["AgentGraphExecution"] = await _assign_workspace_tenancy(
|
||||
results["AgentGraphExecution"] = await _assign_team_tenancy(
|
||||
"""
|
||||
UPDATE "AgentGraphExecution" t
|
||||
SET "organizationId" = o."id", "orgWorkspaceId" = w."id"
|
||||
SET "organizationId" = o."id", "teamId" = w."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
|
||||
JOIN "OrgWorkspace" w ON w."orgId" = o."id" AND w."isDefault" = true
|
||||
JOIN "Team" w ON w."orgId" = o."id" AND w."isDefault" = true
|
||||
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
results["ChatSession"] = await _assign_workspace_tenancy(
|
||||
results["ChatSession"] = await _assign_team_tenancy(
|
||||
"""
|
||||
UPDATE "ChatSession" t
|
||||
SET "organizationId" = o."id", "orgWorkspaceId" = w."id"
|
||||
SET "organizationId" = o."id", "teamId" = w."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
|
||||
JOIN "OrgWorkspace" w ON w."orgId" = o."id" AND w."isDefault" = true
|
||||
JOIN "Team" w ON w."orgId" = o."id" AND w."isDefault" = true
|
||||
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
results["AgentPreset"] = await _assign_workspace_tenancy(
|
||||
results["AgentPreset"] = await _assign_team_tenancy(
|
||||
"""
|
||||
UPDATE "AgentPreset" t
|
||||
SET "organizationId" = o."id", "orgWorkspaceId" = w."id"
|
||||
SET "organizationId" = o."id", "teamId" = w."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
|
||||
JOIN "OrgWorkspace" w ON w."orgId" = o."id" AND w."isDefault" = true
|
||||
JOIN "Team" w ON w."orgId" = o."id" AND w."isDefault" = true
|
||||
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
results["LibraryAgent"] = await _assign_workspace_tenancy(
|
||||
results["LibraryAgent"] = await _assign_team_tenancy(
|
||||
"""
|
||||
UPDATE "LibraryAgent" t
|
||||
SET "organizationId" = o."id", "orgWorkspaceId" = w."id"
|
||||
SET "organizationId" = o."id", "teamId" = w."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
|
||||
JOIN "OrgWorkspace" w ON w."orgId" = o."id" AND w."isDefault" = true
|
||||
JOIN "Team" w ON w."orgId" = o."id" AND w."isDefault" = true
|
||||
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
results["LibraryFolder"] = await _assign_workspace_tenancy(
|
||||
results["LibraryFolder"] = await _assign_team_tenancy(
|
||||
"""
|
||||
UPDATE "LibraryFolder" t
|
||||
SET "organizationId" = o."id", "orgWorkspaceId" = w."id"
|
||||
SET "organizationId" = o."id", "teamId" = w."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
|
||||
JOIN "OrgWorkspace" w ON w."orgId" = o."id" AND w."isDefault" = true
|
||||
JOIN "Team" w ON w."orgId" = o."id" AND w."isDefault" = true
|
||||
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
results["IntegrationWebhook"] = await _assign_workspace_tenancy(
|
||||
results["IntegrationWebhook"] = await _assign_team_tenancy(
|
||||
"""
|
||||
UPDATE "IntegrationWebhook" t
|
||||
SET "organizationId" = o."id", "orgWorkspaceId" = w."id"
|
||||
SET "organizationId" = o."id", "teamId" = w."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
|
||||
JOIN "OrgWorkspace" w ON w."orgId" = o."id" AND w."isDefault" = true
|
||||
JOIN "Team" w ON w."orgId" = o."id" AND w."isDefault" = true
|
||||
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
results["APIKey"] = await _assign_workspace_tenancy(
|
||||
results["APIKey"] = await _assign_team_tenancy(
|
||||
"""
|
||||
UPDATE "APIKey" t
|
||||
SET "organizationId" = o."id", "orgWorkspaceId" = w."id"
|
||||
SET "organizationId" = o."id", "teamId" = w."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
|
||||
JOIN "OrgWorkspace" w ON w."orgId" = o."id" AND w."isDefault" = true
|
||||
JOIN "Team" w ON w."orgId" = o."id" AND w."isDefault" = true
|
||||
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
|
||||
"""
|
||||
)
|
||||
@@ -450,7 +450,7 @@ async def run_migration() -> None:
|
||||
orgs_created = await create_orgs_for_existing_users()
|
||||
await migrate_org_balances()
|
||||
await migrate_credit_transactions()
|
||||
resource_counts = await assign_resources_to_workspaces()
|
||||
resource_counts = await assign_resources_to_teams()
|
||||
await migrate_store_listings()
|
||||
await create_store_listing_aliases()
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import pytest
|
||||
from backend.data.org_migration import (
|
||||
_resolve_unique_slug,
|
||||
_sanitize_slug,
|
||||
assign_resources_to_workspaces,
|
||||
assign_resources_to_teams,
|
||||
create_orgs_for_existing_users,
|
||||
migrate_credit_transactions,
|
||||
migrate_org_balances,
|
||||
@@ -30,8 +30,8 @@ def mock_prisma(mocker):
|
||||
mock.organizationalias.find_unique = AsyncMock(return_value=None)
|
||||
mock.organization.create = AsyncMock(return_value=MagicMock(id="org-1"))
|
||||
mock.orgmember.create = AsyncMock()
|
||||
mock.orgworkspace.create = AsyncMock(return_value=MagicMock(id="ws-1"))
|
||||
mock.orgworkspacemember.create = AsyncMock()
|
||||
mock.team.create = AsyncMock(return_value=MagicMock(id="ws-1"))
|
||||
mock.teammember.create = AsyncMock()
|
||||
mock.organizationprofile.create = AsyncMock()
|
||||
mock.organizationseatassignment.create = AsyncMock()
|
||||
mock.query_raw = AsyncMock(return_value=[])
|
||||
@@ -171,8 +171,8 @@ class TestCreateOrgsForExistingUsers:
|
||||
assert create_data["bootstrapUserId"] == "user-1"
|
||||
|
||||
# Verify workspace created
|
||||
mock_prisma.orgworkspace.create.assert_called_once()
|
||||
ws_data = mock_prisma.orgworkspace.create.call_args[1]["data"]
|
||||
mock_prisma.team.create.assert_called_once()
|
||||
ws_data = mock_prisma.team.create.call_args[1]["data"]
|
||||
assert ws_data["name"] == "Default"
|
||||
assert ws_data["isDefault"] is True
|
||||
assert ws_data["joinPolicy"] == "OPEN"
|
||||
@@ -360,8 +360,8 @@ class TestCreateOrgsForExistingUsers:
|
||||
# Verify all 6 records created
|
||||
mock_prisma.organization.create.assert_called_once()
|
||||
mock_prisma.orgmember.create.assert_called_once()
|
||||
mock_prisma.orgworkspace.create.assert_called_once()
|
||||
mock_prisma.orgworkspacemember.create.assert_called_once()
|
||||
mock_prisma.team.create.assert_called_once()
|
||||
mock_prisma.teammember.create.assert_called_once()
|
||||
mock_prisma.organizationprofile.create.assert_called_once()
|
||||
mock_prisma.organizationseatassignment.create.assert_called_once()
|
||||
|
||||
@@ -371,7 +371,7 @@ class TestCreateOrgsForExistingUsers:
|
||||
assert member_data["isAdmin"] is True
|
||||
|
||||
# Verify workspace is default+open
|
||||
ws_data = mock_prisma.orgworkspace.create.call_args[1]["data"]
|
||||
ws_data = mock_prisma.team.create.call_args[1]["data"]
|
||||
assert ws_data["isDefault"] is True
|
||||
assert ws_data["joinPolicy"] == "OPEN"
|
||||
|
||||
@@ -408,7 +408,7 @@ class TestMigrateCreditTransactions:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# assign_resources_to_workspaces
|
||||
# assign_resources_to_teams
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -416,13 +416,13 @@ class TestAssignResources:
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_all_tables(self, mock_prisma, mocker):
|
||||
mocker.patch(
|
||||
"backend.data.org_migration._assign_workspace_tenancy",
|
||||
"backend.data.org_migration._assign_team_tenancy",
|
||||
new_callable=AsyncMock,
|
||||
return_value=10,
|
||||
)
|
||||
mock_prisma.execute_raw = AsyncMock(return_value=10)
|
||||
|
||||
result = await assign_resources_to_workspaces()
|
||||
result = await assign_resources_to_teams()
|
||||
|
||||
# 8 tables with workspace + 3 tables org-only = 11 entries
|
||||
assert len(result) == 11
|
||||
@@ -435,12 +435,12 @@ class TestAssignResources:
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_updates_still_returns(self, mock_prisma, mocker):
|
||||
mocker.patch(
|
||||
"backend.data.org_migration._assign_workspace_tenancy",
|
||||
"backend.data.org_migration._assign_team_tenancy",
|
||||
new_callable=AsyncMock,
|
||||
return_value=0,
|
||||
)
|
||||
mock_prisma.execute_raw = AsyncMock(return_value=0)
|
||||
result = await assign_resources_to_workspaces()
|
||||
result = await assign_resources_to_teams()
|
||||
assert all(v == 0 for v in result.values())
|
||||
|
||||
|
||||
@@ -480,7 +480,7 @@ class TestRunMigration:
|
||||
new_callable=lambda: lambda: _track(calls, "credits", 0),
|
||||
)
|
||||
mocker.patch(
|
||||
"backend.data.org_migration.assign_resources_to_workspaces",
|
||||
"backend.data.org_migration.assign_resources_to_teams",
|
||||
new_callable=lambda: lambda: _track(
|
||||
calls, "assign_resources", {"AgentGraph": 5}
|
||||
),
|
||||
|
||||
@@ -160,7 +160,7 @@ async def _execute_graph(**kwargs):
|
||||
inputs=args.input_data,
|
||||
graph_credentials_inputs=args.input_credentials,
|
||||
organization_id=args.organization_id,
|
||||
org_workspace_id=args.org_workspace_id,
|
||||
team_id=args.team_id,
|
||||
)
|
||||
await db.increment_onboarding_runs(args.user_id)
|
||||
elapsed = asyncio.get_event_loop().time() - start_time
|
||||
@@ -393,7 +393,7 @@ class GraphExecutionJobArgs(BaseModel):
|
||||
input_data: GraphInput
|
||||
input_credentials: dict[str, CredentialsMetaInput] = Field(default_factory=dict)
|
||||
organization_id: str | None = None
|
||||
org_workspace_id: str | None = None
|
||||
team_id: str | None = None
|
||||
|
||||
|
||||
class GraphExecutionJobInfo(GraphExecutionJobArgs):
|
||||
@@ -672,7 +672,7 @@ class Scheduler(AppService):
|
||||
name: Optional[str] = None,
|
||||
user_timezone: str | None = None,
|
||||
organization_id: Optional[str] = None,
|
||||
org_workspace_id: Optional[str] = None,
|
||||
team_id: Optional[str] = None,
|
||||
) -> GraphExecutionJobInfo:
|
||||
# Validate the graph before scheduling to prevent runtime failures
|
||||
# We don't need the return value, just want the validation to run
|
||||
@@ -710,7 +710,7 @@ class Scheduler(AppService):
|
||||
input_data=input_data,
|
||||
input_credentials=input_credentials,
|
||||
organization_id=organization_id,
|
||||
org_workspace_id=org_workspace_id,
|
||||
team_id=team_id,
|
||||
)
|
||||
job = self.scheduler.add_job(
|
||||
execute_graph,
|
||||
|
||||
@@ -15,7 +15,7 @@ from backend.data import graph as graph_db
|
||||
from backend.data import human_review as human_review_db
|
||||
from backend.data import onboarding as onboarding_db
|
||||
from backend.data import user as user_db
|
||||
from backend.data import workspace as workspace_db
|
||||
from backend.data import workspace as team_db
|
||||
|
||||
# Import dynamic field utilities from centralized location
|
||||
from backend.data.block import BlockInput, BlockOutputEntry
|
||||
@@ -870,7 +870,7 @@ async def add_graph_execution(
|
||||
graph_exec_id: Optional[str] = None,
|
||||
dry_run: bool = False,
|
||||
organization_id: Optional[str] = None,
|
||||
org_workspace_id: Optional[str] = None,
|
||||
team_id: Optional[str] = None,
|
||||
) -> GraphExecutionWithNodes:
|
||||
"""
|
||||
Adds a graph execution to the queue and returns the execution entry.
|
||||
@@ -897,7 +897,7 @@ async def add_graph_execution(
|
||||
udb = user_db
|
||||
gdb = graph_db
|
||||
odb = onboarding_db
|
||||
wdb = workspace_db
|
||||
wdb = team_db
|
||||
else:
|
||||
edb = udb = gdb = odb = wdb = get_database_manager_async_client()
|
||||
|
||||
@@ -956,7 +956,7 @@ async def add_graph_execution(
|
||||
parent_graph_exec_id=parent_exec_id,
|
||||
is_dry_run=dry_run,
|
||||
organization_id=organization_id,
|
||||
org_workspace_id=org_workspace_id,
|
||||
team_id=team_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -987,7 +987,7 @@ async def add_graph_execution(
|
||||
# Execution hierarchy
|
||||
root_execution_id=graph_exec.id,
|
||||
# Workspace (enables workspace:// file resolution in blocks)
|
||||
workspace_id=workspace.id,
|
||||
team_id=workspace.id,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -23,7 +23,7 @@ _cryptor = JSONCryptor()
|
||||
async def get_scoped_credentials(
|
||||
user_id: str,
|
||||
organization_id: str,
|
||||
workspace_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
provider: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Get credentials visible to the user in the current org/workspace context.
|
||||
@@ -52,11 +52,11 @@ async def get_scoped_credentials(
|
||||
results.append(_cred_to_metadata(c, scope="USER"))
|
||||
|
||||
# 2. Workspace-scoped credentials (only if workspace is active)
|
||||
if workspace_id:
|
||||
if team_id:
|
||||
ws_where: dict = {
|
||||
"organizationId": organization_id,
|
||||
"ownerType": "WORKSPACE",
|
||||
"ownerId": workspace_id,
|
||||
"ownerId": team_id,
|
||||
"status": "active",
|
||||
}
|
||||
if provider:
|
||||
@@ -87,7 +87,7 @@ async def get_credential_by_id(
|
||||
credential_id: str,
|
||||
user_id: str,
|
||||
organization_id: str,
|
||||
workspace_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
decrypt: bool = False,
|
||||
) -> Optional[dict]:
|
||||
"""Get a specific credential by ID if the user has access.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "IntegrationCredential" DROP CONSTRAINT "IntegrationCredential_workspace_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Organization_slug_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "IntegrationCredential" ADD COLUMN "workspaceId" TEXT;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WorkspaceInvite" ADD CONSTRAINT "WorkspaceInvite_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "OrgWorkspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "IntegrationCredential" ADD CONSTRAINT "IntegrationCredential_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "OrgWorkspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WorkspaceJoinPolicy" AS ENUM ('OPEN', 'PRIVATE');
|
||||
CREATE TYPE "TeamJoinPolicy" AS ENUM ('OPEN', 'PRIVATE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ResourceVisibility" AS ENUM ('PRIVATE', 'WORKSPACE', 'ORG');
|
||||
@@ -29,42 +29,42 @@ CREATE TYPE "CredentialOwnerType" AS ENUM ('USER', 'WORKSPACE', 'ORG');
|
||||
ALTER TABLE "BuilderSearchHistory" ADD COLUMN "organizationId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ChatSession" ADD COLUMN "orgWorkspaceId" TEXT,
|
||||
ADD COLUMN "organizationId" TEXT,
|
||||
ALTER TABLE "ChatSession" ADD COLUMN "organizationId" TEXT,
|
||||
ADD COLUMN "teamId" TEXT,
|
||||
ADD COLUMN "visibility" "ResourceVisibility" NOT NULL DEFAULT 'PRIVATE';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "AgentGraph" ADD COLUMN "orgWorkspaceId" TEXT,
|
||||
ADD COLUMN "organizationId" TEXT,
|
||||
ALTER TABLE "AgentGraph" ADD COLUMN "organizationId" TEXT,
|
||||
ADD COLUMN "teamId" TEXT,
|
||||
ADD COLUMN "visibility" "ResourceVisibility" NOT NULL DEFAULT 'PRIVATE';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "AgentPreset" ADD COLUMN "orgWorkspaceId" TEXT,
|
||||
ADD COLUMN "organizationId" TEXT,
|
||||
ALTER TABLE "AgentPreset" ADD COLUMN "organizationId" TEXT,
|
||||
ADD COLUMN "teamId" TEXT,
|
||||
ADD COLUMN "visibility" "ResourceVisibility" NOT NULL DEFAULT 'PRIVATE';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LibraryAgent" ADD COLUMN "orgWorkspaceId" TEXT,
|
||||
ADD COLUMN "organizationId" TEXT,
|
||||
ALTER TABLE "LibraryAgent" ADD COLUMN "organizationId" TEXT,
|
||||
ADD COLUMN "teamId" TEXT,
|
||||
ADD COLUMN "visibility" "ResourceVisibility" NOT NULL DEFAULT 'PRIVATE';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LibraryFolder" ADD COLUMN "orgWorkspaceId" TEXT,
|
||||
ADD COLUMN "organizationId" TEXT,
|
||||
ALTER TABLE "LibraryFolder" ADD COLUMN "organizationId" TEXT,
|
||||
ADD COLUMN "teamId" TEXT,
|
||||
ADD COLUMN "visibility" "ResourceVisibility" NOT NULL DEFAULT 'PRIVATE';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "AgentGraphExecution" ADD COLUMN "orgWorkspaceId" TEXT,
|
||||
ADD COLUMN "organizationId" TEXT,
|
||||
ALTER TABLE "AgentGraphExecution" ADD COLUMN "organizationId" TEXT,
|
||||
ADD COLUMN "teamId" TEXT,
|
||||
ADD COLUMN "visibility" "ResourceVisibility" NOT NULL DEFAULT 'PRIVATE';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "PendingHumanReview" ADD COLUMN "orgWorkspaceId" TEXT,
|
||||
ADD COLUMN "organizationId" TEXT;
|
||||
ALTER TABLE "PendingHumanReview" ADD COLUMN "organizationId" TEXT,
|
||||
ADD COLUMN "teamId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "IntegrationWebhook" ADD COLUMN "orgWorkspaceId" TEXT,
|
||||
ADD COLUMN "organizationId" TEXT,
|
||||
ALTER TABLE "IntegrationWebhook" ADD COLUMN "organizationId" TEXT,
|
||||
ADD COLUMN "teamId" TEXT,
|
||||
ADD COLUMN "visibility" "ResourceVisibility" NOT NULL DEFAULT 'PRIVATE';
|
||||
|
||||
-- AlterTable
|
||||
@@ -74,15 +74,15 @@ ALTER TABLE "StoreListing" ADD COLUMN "owningOrgId" TEXT;
|
||||
ALTER TABLE "StoreListingVersion" ADD COLUMN "organizationId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "APIKey" ADD COLUMN "orgWorkspaceId" TEXT,
|
||||
ADD COLUMN "organizationId" TEXT,
|
||||
ALTER TABLE "APIKey" ADD COLUMN "organizationId" TEXT,
|
||||
ADD COLUMN "ownerType" "CredentialOwnerType",
|
||||
ADD COLUMN "workspaceIdRestriction" TEXT;
|
||||
ADD COLUMN "teamId" TEXT,
|
||||
ADD COLUMN "teamIdRestriction" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "OAuthApplication" ADD COLUMN "organizationId" TEXT,
|
||||
ADD COLUMN "ownerType" "CredentialOwnerType",
|
||||
ADD COLUMN "workspaceIdRestriction" TEXT;
|
||||
ADD COLUMN "teamIdRestriction" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Organization" (
|
||||
@@ -166,13 +166,13 @@ CREATE TABLE "OrgInvitation" (
|
||||
"acceptedAt" TIMESTAMP(3),
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"invitedByUserId" TEXT NOT NULL,
|
||||
"workspaceIds" TEXT[],
|
||||
"teamIds" TEXT[],
|
||||
|
||||
CONSTRAINT "OrgInvitation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrgWorkspace" (
|
||||
CREATE TABLE "Team" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
@@ -180,20 +180,20 @@ CREATE TABLE "OrgWorkspace" (
|
||||
"slug" TEXT,
|
||||
"description" TEXT,
|
||||
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||
"joinPolicy" "WorkspaceJoinPolicy" NOT NULL DEFAULT 'OPEN',
|
||||
"joinPolicy" "TeamJoinPolicy" NOT NULL DEFAULT 'OPEN',
|
||||
"orgId" TEXT NOT NULL,
|
||||
"archivedAt" TIMESTAMP(3),
|
||||
"createdByUserId" TEXT,
|
||||
|
||||
CONSTRAINT "OrgWorkspace_pkey" PRIMARY KEY ("id")
|
||||
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrgWorkspaceMember" (
|
||||
CREATE TABLE "TeamMember" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"workspaceId" TEXT NOT NULL,
|
||||
"teamId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isBillingManager" BOOLEAN NOT NULL DEFAULT false,
|
||||
@@ -201,14 +201,14 @@ CREATE TABLE "OrgWorkspaceMember" (
|
||||
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"invitedByUserId" TEXT,
|
||||
|
||||
CONSTRAINT "OrgWorkspaceMember_pkey" PRIMARY KEY ("id")
|
||||
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WorkspaceInvite" (
|
||||
CREATE TABLE "TeamInvite" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"workspaceId" TEXT NOT NULL,
|
||||
"teamId" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"targetUserId" TEXT,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
@@ -220,7 +220,7 @@ CREATE TABLE "WorkspaceInvite" (
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"invitedByUserId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "WorkspaceInvite_pkey" PRIMARY KEY ("id")
|
||||
CONSTRAINT "TeamInvite_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
@@ -269,7 +269,7 @@ CREATE TABLE "OrgCreditTransaction" (
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"orgId" TEXT NOT NULL,
|
||||
"initiatedByUserId" TEXT,
|
||||
"workspaceId" TEXT,
|
||||
"teamId" TEXT,
|
||||
"amount" INTEGER NOT NULL,
|
||||
"type" "CreditTransactionType" NOT NULL,
|
||||
"runningBalance" INTEGER,
|
||||
@@ -302,7 +302,7 @@ CREATE TABLE "TransferRequest" (
|
||||
CREATE TABLE "AuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT,
|
||||
"workspaceId" TEXT,
|
||||
"teamId" TEXT,
|
||||
"actorUserId" TEXT NOT NULL,
|
||||
"entityType" TEXT NOT NULL,
|
||||
"entityId" TEXT,
|
||||
@@ -321,6 +321,7 @@ CREATE TABLE "IntegrationCredential" (
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"ownerType" "CredentialOwnerType" NOT NULL,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"teamId" TEXT,
|
||||
"provider" TEXT NOT NULL,
|
||||
"credentialType" TEXT NOT NULL,
|
||||
"displayName" TEXT NOT NULL,
|
||||
@@ -339,9 +340,6 @@ CREATE TABLE "IntegrationCredential" (
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Organization_slug_key" ON "Organization"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Organization_slug_idx" ON "Organization"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OrganizationAlias_aliasSlug_key" ON "OrganizationAlias"("aliasSlug");
|
||||
|
||||
@@ -376,34 +374,34 @@ CREATE INDEX "OrgInvitation_token_idx" ON "OrgInvitation"("token");
|
||||
CREATE INDEX "OrgInvitation_orgId_idx" ON "OrgInvitation"("orgId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrgWorkspace_orgId_isDefault_idx" ON "OrgWorkspace"("orgId", "isDefault");
|
||||
CREATE INDEX "Team_orgId_isDefault_idx" ON "Team"("orgId", "isDefault");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrgWorkspace_orgId_joinPolicy_idx" ON "OrgWorkspace"("orgId", "joinPolicy");
|
||||
CREATE INDEX "Team_orgId_joinPolicy_idx" ON "Team"("orgId", "joinPolicy");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OrgWorkspace_orgId_name_key" ON "OrgWorkspace"("orgId", "name");
|
||||
CREATE UNIQUE INDEX "Team_orgId_name_key" ON "Team"("orgId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrgWorkspaceMember_userId_idx" ON "OrgWorkspaceMember"("userId");
|
||||
CREATE INDEX "TeamMember_userId_idx" ON "TeamMember"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrgWorkspaceMember_workspaceId_status_idx" ON "OrgWorkspaceMember"("workspaceId", "status");
|
||||
CREATE INDEX "TeamMember_teamId_status_idx" ON "TeamMember"("teamId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OrgWorkspaceMember_workspaceId_userId_key" ON "OrgWorkspaceMember"("workspaceId", "userId");
|
||||
CREATE UNIQUE INDEX "TeamMember_teamId_userId_key" ON "TeamMember"("teamId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WorkspaceInvite_token_key" ON "WorkspaceInvite"("token");
|
||||
CREATE UNIQUE INDEX "TeamInvite_token_key" ON "TeamInvite"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WorkspaceInvite_email_idx" ON "WorkspaceInvite"("email");
|
||||
CREATE INDEX "TeamInvite_email_idx" ON "TeamInvite"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WorkspaceInvite_token_idx" ON "WorkspaceInvite"("token");
|
||||
CREATE INDEX "TeamInvite_token_idx" ON "TeamInvite"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WorkspaceInvite_workspaceId_idx" ON "WorkspaceInvite"("workspaceId");
|
||||
CREATE INDEX "TeamInvite_teamId_idx" ON "TeamInvite"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrganizationSeatAssignment_organizationId_status_idx" ON "OrganizationSeatAssignment"("organizationId", "status");
|
||||
@@ -445,52 +443,52 @@ CREATE INDEX "IntegrationCredential_ownerId_ownerType_idx" ON "IntegrationCreden
|
||||
CREATE INDEX "IntegrationCredential_createdByUserId_idx" ON "IntegrationCredential"("createdByUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ChatSession_orgWorkspaceId_updatedAt_idx" ON "ChatSession"("orgWorkspaceId", "updatedAt");
|
||||
CREATE INDEX "ChatSession_teamId_updatedAt_idx" ON "ChatSession"("teamId", "updatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AgentGraph_orgWorkspaceId_isActive_id_version_idx" ON "AgentGraph"("orgWorkspaceId", "isActive", "id", "version");
|
||||
CREATE INDEX "AgentGraph_teamId_isActive_id_version_idx" ON "AgentGraph"("teamId", "isActive", "id", "version");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AgentPreset_orgWorkspaceId_idx" ON "AgentPreset"("orgWorkspaceId");
|
||||
CREATE INDEX "AgentPreset_teamId_idx" ON "AgentPreset"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LibraryAgent_orgWorkspaceId_idx" ON "LibraryAgent"("orgWorkspaceId");
|
||||
CREATE INDEX "LibraryAgent_teamId_idx" ON "LibraryAgent"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AgentGraphExecution_orgWorkspaceId_isDeleted_createdAt_idx" ON "AgentGraphExecution"("orgWorkspaceId", "isDeleted", "createdAt");
|
||||
CREATE INDEX "AgentGraphExecution_teamId_isDeleted_createdAt_idx" ON "AgentGraphExecution"("teamId", "isDeleted", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "StoreListing_owningOrgId_idx" ON "StoreListing"("owningOrgId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "APIKey_orgWorkspaceId_idx" ON "APIKey"("orgWorkspaceId");
|
||||
CREATE INDEX "APIKey_teamId_idx" ON "APIKey"("teamId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChatSession" ADD CONSTRAINT "ChatSession_orgWorkspaceId_fkey" FOREIGN KEY ("orgWorkspaceId") REFERENCES "OrgWorkspace"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "ChatSession" ADD CONSTRAINT "ChatSession_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentGraph" ADD CONSTRAINT "AgentGraph_orgWorkspaceId_fkey" FOREIGN KEY ("orgWorkspaceId") REFERENCES "OrgWorkspace"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "AgentGraph" ADD CONSTRAINT "AgentGraph_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentPreset" ADD CONSTRAINT "AgentPreset_orgWorkspaceId_fkey" FOREIGN KEY ("orgWorkspaceId") REFERENCES "OrgWorkspace"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "AgentPreset" ADD CONSTRAINT "AgentPreset_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LibraryAgent" ADD CONSTRAINT "LibraryAgent_orgWorkspaceId_fkey" FOREIGN KEY ("orgWorkspaceId") REFERENCES "OrgWorkspace"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "LibraryAgent" ADD CONSTRAINT "LibraryAgent_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LibraryFolder" ADD CONSTRAINT "LibraryFolder_orgWorkspaceId_fkey" FOREIGN KEY ("orgWorkspaceId") REFERENCES "OrgWorkspace"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "LibraryFolder" ADD CONSTRAINT "LibraryFolder_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentGraphExecution" ADD CONSTRAINT "AgentGraphExecution_orgWorkspaceId_fkey" FOREIGN KEY ("orgWorkspaceId") REFERENCES "OrgWorkspace"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "AgentGraphExecution" ADD CONSTRAINT "AgentGraphExecution_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "IntegrationWebhook" ADD CONSTRAINT "IntegrationWebhook_orgWorkspaceId_fkey" FOREIGN KEY ("orgWorkspaceId") REFERENCES "OrgWorkspace"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "IntegrationWebhook" ADD CONSTRAINT "IntegrationWebhook_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StoreListing" ADD CONSTRAINT "StoreListing_owningOrgId_fkey" FOREIGN KEY ("owningOrgId") REFERENCES "Organization"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "APIKey" ADD CONSTRAINT "APIKey_orgWorkspaceId_fkey" FOREIGN KEY ("orgWorkspaceId") REFERENCES "OrgWorkspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "APIKey" ADD CONSTRAINT "APIKey_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrganizationAlias" ADD CONSTRAINT "OrganizationAlias_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -508,13 +506,16 @@ ALTER TABLE "OrgMember" ADD CONSTRAINT "OrgMember_userId_fkey" FOREIGN KEY ("use
|
||||
ALTER TABLE "OrgInvitation" ADD CONSTRAINT "OrgInvitation_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrgWorkspace" ADD CONSTRAINT "OrgWorkspace_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "Team" ADD CONSTRAINT "Team_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrgWorkspaceMember" ADD CONSTRAINT "OrgWorkspaceMember_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "OrgWorkspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrgWorkspaceMember" ADD CONSTRAINT "OrgWorkspaceMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamInvite" ADD CONSTRAINT "TeamInvite_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrganizationSubscription" ADD CONSTRAINT "OrganizationSubscription_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -544,5 +545,5 @@ ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_organizationId_fkey" FOREIGN KEY
|
||||
ALTER TABLE "IntegrationCredential" ADD CONSTRAINT "IntegrationCredential_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "IntegrationCredential" ADD CONSTRAINT "IntegrationCredential_workspace_fkey" FOREIGN KEY ("ownerId") REFERENCES "OrgWorkspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "IntegrationCredential" ADD CONSTRAINT "IntegrationCredential_workspace_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -82,8 +82,8 @@ model User {
|
||||
OAuthRefreshTokens OAuthRefreshToken[]
|
||||
|
||||
// Organization & Workspace relations
|
||||
OrgMemberships OrgMember[]
|
||||
WorkspaceMemberships OrgWorkspaceMember[]
|
||||
OrgMemberships OrgMember[]
|
||||
TeamMemberships TeamMember[]
|
||||
}
|
||||
|
||||
enum SubscriptionTier {
|
||||
@@ -255,12 +255,12 @@ model ChatSession {
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
orgWorkspaceId String?
|
||||
teamId String?
|
||||
visibility ResourceVisibility @default(PRIVATE)
|
||||
OrgWorkspace OrgWorkspace? @relation(fields: [orgWorkspaceId], references: [id], onDelete: SetNull)
|
||||
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId, updatedAt])
|
||||
@@index([orgWorkspaceId, updatedAt])
|
||||
@@index([teamId, updatedAt])
|
||||
}
|
||||
|
||||
model ChatMessage {
|
||||
@@ -320,14 +320,14 @@ model AgentGraph {
|
||||
|
||||
// Tenancy columns (nullable during migration, NOT NULL after cutover)
|
||||
organizationId String?
|
||||
orgWorkspaceId String?
|
||||
teamId String?
|
||||
visibility ResourceVisibility @default(PRIVATE)
|
||||
OrgWorkspace OrgWorkspace? @relation(fields: [orgWorkspaceId], references: [id], onDelete: SetNull)
|
||||
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@id(name: "graphVersionId", [id, version])
|
||||
@@index([userId, isActive, id, version])
|
||||
@@index([forkedFromId, forkedFromVersion])
|
||||
@@index([orgWorkspaceId, isActive, id, version])
|
||||
@@index([teamId, isActive, id, version])
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
@@ -370,14 +370,14 @@ model AgentPreset {
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
orgWorkspaceId String?
|
||||
teamId String?
|
||||
visibility ResourceVisibility @default(PRIVATE)
|
||||
OrgWorkspace OrgWorkspace? @relation(fields: [orgWorkspaceId], references: [id], onDelete: SetNull)
|
||||
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId])
|
||||
@@index([agentGraphId, agentGraphVersion])
|
||||
@@index([webhookId])
|
||||
@@index([orgWorkspaceId])
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
enum NotificationType {
|
||||
@@ -458,15 +458,15 @@ model LibraryAgent {
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
orgWorkspaceId String?
|
||||
teamId String?
|
||||
visibility ResourceVisibility @default(PRIVATE)
|
||||
OrgWorkspace OrgWorkspace? @relation(fields: [orgWorkspaceId], references: [id], onDelete: SetNull)
|
||||
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([userId, agentGraphId, agentGraphVersion])
|
||||
@@index([agentGraphId, agentGraphVersion])
|
||||
@@index([creatorId])
|
||||
@@index([folderId])
|
||||
@@index([orgWorkspaceId])
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
model LibraryFolder {
|
||||
@@ -491,9 +491,9 @@ model LibraryFolder {
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
orgWorkspaceId String?
|
||||
teamId String?
|
||||
visibility ResourceVisibility @default(PRIVATE)
|
||||
OrgWorkspace OrgWorkspace? @relation(fields: [orgWorkspaceId], references: [id], onDelete: SetNull)
|
||||
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([userId, parentId, name]) // Name unique per parent per user
|
||||
}
|
||||
@@ -630,9 +630,9 @@ model AgentGraphExecution {
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
orgWorkspaceId String?
|
||||
teamId String?
|
||||
visibility ResourceVisibility @default(PRIVATE)
|
||||
OrgWorkspace OrgWorkspace? @relation(fields: [orgWorkspaceId], references: [id], onDelete: SetNull)
|
||||
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([agentGraphId, agentGraphVersion])
|
||||
@@index([userId, isDeleted, createdAt])
|
||||
@@ -640,7 +640,7 @@ model AgentGraphExecution {
|
||||
@@index([agentPresetId])
|
||||
@@index([shareToken])
|
||||
@@index([parentGraphExecutionId])
|
||||
@@index([orgWorkspaceId, isDeleted, createdAt])
|
||||
@@index([teamId, isDeleted, createdAt])
|
||||
}
|
||||
|
||||
// This model describes the execution of an AgentNode.
|
||||
@@ -735,7 +735,7 @@ model PendingHumanReview {
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
orgWorkspaceId String?
|
||||
teamId String?
|
||||
|
||||
@@unique([nodeExecId]) // One pending review per node execution
|
||||
@@index([userId, status])
|
||||
@@ -766,9 +766,9 @@ model IntegrationWebhook {
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
orgWorkspaceId String?
|
||||
teamId String?
|
||||
visibility ResourceVisibility @default(PRIVATE)
|
||||
OrgWorkspace OrgWorkspace? @relation(fields: [orgWorkspaceId], references: [id], onDelete: SetNull)
|
||||
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
|
||||
}
|
||||
|
||||
model AnalyticsDetails {
|
||||
@@ -838,7 +838,7 @@ enum CreditTransactionType {
|
||||
////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////
|
||||
|
||||
enum WorkspaceJoinPolicy {
|
||||
enum TeamJoinPolicy {
|
||||
OPEN
|
||||
PRIVATE
|
||||
}
|
||||
@@ -1322,15 +1322,15 @@ model APIKey {
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
orgWorkspaceId String?
|
||||
ownerType CredentialOwnerType?
|
||||
workspaceIdRestriction String?
|
||||
OrgWorkspace OrgWorkspace? @relation(fields: [orgWorkspaceId], references: [id], onDelete: Cascade)
|
||||
organizationId String?
|
||||
teamId String?
|
||||
ownerType CredentialOwnerType?
|
||||
teamIdRestriction String?
|
||||
Team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([head, name])
|
||||
@@index([userId, status])
|
||||
@@index([orgWorkspaceId])
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
model UserBalance {
|
||||
@@ -1385,9 +1385,9 @@ model OAuthApplication {
|
||||
RefreshTokens OAuthRefreshToken[]
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
ownerType CredentialOwnerType?
|
||||
workspaceIdRestriction String?
|
||||
organizationId String?
|
||||
ownerType CredentialOwnerType?
|
||||
teamIdRestriction String?
|
||||
|
||||
@@index([clientId])
|
||||
@@index([ownerId])
|
||||
@@ -1488,7 +1488,7 @@ model Organization {
|
||||
bootstrapUserId String?
|
||||
|
||||
Members OrgMember[]
|
||||
Workspaces OrgWorkspace[]
|
||||
Teams Team[]
|
||||
Aliases OrganizationAlias[]
|
||||
Balance OrgBalance?
|
||||
CreditTransactions OrgCreditTransaction[]
|
||||
@@ -1570,7 +1570,7 @@ model OrgInvitation {
|
||||
acceptedAt DateTime?
|
||||
revokedAt DateTime?
|
||||
invitedByUserId String
|
||||
workspaceIds String[]
|
||||
teamIds String[]
|
||||
|
||||
Org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -1579,43 +1579,43 @@ model OrgInvitation {
|
||||
@@index([orgId])
|
||||
}
|
||||
|
||||
model OrgWorkspace {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
model Team {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
name String
|
||||
slug String?
|
||||
description String?
|
||||
isDefault Boolean @default(false)
|
||||
joinPolicy WorkspaceJoinPolicy @default(OPEN)
|
||||
isDefault Boolean @default(false)
|
||||
joinPolicy TeamJoinPolicy @default(OPEN)
|
||||
orgId String
|
||||
archivedAt DateTime?
|
||||
createdByUserId String?
|
||||
|
||||
Org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
Members OrgWorkspaceMember[]
|
||||
Org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
Members TeamMember[]
|
||||
|
||||
AgentGraphs AgentGraph[]
|
||||
Executions AgentGraphExecution[]
|
||||
ChatSessions ChatSession[]
|
||||
Presets AgentPreset[]
|
||||
LibraryAgents LibraryAgent[]
|
||||
LibraryFolders LibraryFolder[]
|
||||
Webhooks IntegrationWebhook[]
|
||||
APIKeys APIKey[]
|
||||
Credentials IntegrationCredential[] @relation("WorkspaceCredentials")
|
||||
WorkspaceInvites WorkspaceInvite[]
|
||||
AgentGraphs AgentGraph[]
|
||||
Executions AgentGraphExecution[]
|
||||
ChatSessions ChatSession[]
|
||||
Presets AgentPreset[]
|
||||
LibraryAgents LibraryAgent[]
|
||||
LibraryFolders LibraryFolder[]
|
||||
Webhooks IntegrationWebhook[]
|
||||
APIKeys APIKey[]
|
||||
Credentials IntegrationCredential[] @relation("TeamCredentials")
|
||||
TeamInvites TeamInvite[]
|
||||
|
||||
@@unique([orgId, name])
|
||||
@@index([orgId, isDefault])
|
||||
@@index([orgId, joinPolicy])
|
||||
}
|
||||
|
||||
model OrgWorkspaceMember {
|
||||
model TeamMember {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
workspaceId String
|
||||
teamId String
|
||||
userId String
|
||||
isAdmin Boolean @default(false)
|
||||
isBillingManager Boolean @default(false)
|
||||
@@ -1623,18 +1623,18 @@ model OrgWorkspaceMember {
|
||||
joinedAt DateTime @default(now())
|
||||
invitedByUserId String?
|
||||
|
||||
Workspace OrgWorkspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
Team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([workspaceId, userId])
|
||||
@@unique([teamId, userId])
|
||||
@@index([userId])
|
||||
@@index([workspaceId, status])
|
||||
@@index([teamId, status])
|
||||
}
|
||||
|
||||
model WorkspaceInvite {
|
||||
model TeamInvite {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
workspaceId String
|
||||
teamId String
|
||||
email String
|
||||
targetUserId String?
|
||||
isAdmin Boolean @default(false)
|
||||
@@ -1646,11 +1646,11 @@ model WorkspaceInvite {
|
||||
revokedAt DateTime?
|
||||
invitedByUserId String
|
||||
|
||||
Workspace OrgWorkspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
Team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([email])
|
||||
@@index([token])
|
||||
@@index([workspaceId])
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
model OrganizationSubscription {
|
||||
@@ -1699,7 +1699,7 @@ model OrgCreditTransaction {
|
||||
createdAt DateTime @default(now())
|
||||
orgId String
|
||||
initiatedByUserId String?
|
||||
workspaceId String?
|
||||
teamId String?
|
||||
amount Int
|
||||
type CreditTransactionType
|
||||
runningBalance Int?
|
||||
@@ -1739,7 +1739,7 @@ model TransferRequest {
|
||||
model AuditLog {
|
||||
id String @id @default(uuid())
|
||||
organizationId String?
|
||||
workspaceId String?
|
||||
teamId String?
|
||||
actorUserId String
|
||||
entityType String
|
||||
entityId String?
|
||||
@@ -1760,8 +1760,8 @@ model IntegrationCredential {
|
||||
id String @id @default(uuid())
|
||||
organizationId String
|
||||
ownerType CredentialOwnerType
|
||||
ownerId String // userId, workspaceId, or orgId depending on ownerType
|
||||
workspaceId String? // Dedicated FK for workspace-scoped creds (set when ownerType=WORKSPACE)
|
||||
ownerId String // userId, teamId, or orgId depending on ownerType
|
||||
teamId String? // Dedicated FK for workspace-scoped creds (set when ownerType=WORKSPACE)
|
||||
provider String
|
||||
credentialType String
|
||||
displayName String
|
||||
@@ -1774,8 +1774,8 @@ model IntegrationCredential {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
Organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
Workspace OrgWorkspace? @relation("WorkspaceCredentials", fields: [workspaceId], references: [id], onDelete: Cascade, map: "IntegrationCredential_workspace_fkey")
|
||||
Organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
Workspace Team? @relation("TeamCredentials", fields: [teamId], references: [id], onDelete: Cascade, map: "IntegrationCredential_workspace_fkey")
|
||||
|
||||
@@index([organizationId, ownerType, provider])
|
||||
@@index([ownerId, ownerType])
|
||||
|
||||
@@ -116,11 +116,11 @@ class TestDataCreator:
|
||||
self._user_org_cache: Dict[str, tuple[str | None, str | None]] = {}
|
||||
|
||||
async def _get_user_org_ws(self, user_id: str) -> tuple[str | None, str | None]:
|
||||
"""Get (organization_id, org_workspace_id) for a user, with caching."""
|
||||
"""Get (organization_id, team_id) for a user, with caching."""
|
||||
if user_id not in self._user_org_cache:
|
||||
from backend.api.features.orgs.db import get_user_default_org_workspace
|
||||
from backend.api.features.orgs.db import get_user_default_team
|
||||
|
||||
org_id, ws_id = await get_user_default_org_workspace(user_id)
|
||||
org_id, ws_id = await get_user_default_team(user_id)
|
||||
self._user_org_cache[user_id] = (org_id, ws_id)
|
||||
return self._user_org_cache[user_id]
|
||||
|
||||
@@ -383,7 +383,7 @@ class TestDataCreator:
|
||||
graph,
|
||||
user["id"],
|
||||
organization_id=org_id,
|
||||
org_workspace_id=ws_id,
|
||||
team_id=ws_id,
|
||||
)
|
||||
graph_dict = created_graph.model_dump()
|
||||
# Ensure userId is included for store submissions
|
||||
|
||||
@@ -70,15 +70,15 @@ export const customMutator = async <
|
||||
);
|
||||
}
|
||||
|
||||
// Inject org/workspace context headers
|
||||
// Inject org/team context headers
|
||||
try {
|
||||
const activeOrgID = localStorage.getItem("active-org-id");
|
||||
const activeWorkspaceID = localStorage.getItem("active-workspace-id");
|
||||
const activeTeamID = localStorage.getItem("active-team-id");
|
||||
if (activeOrgID) {
|
||||
headers["X-Org-Id"] = activeOrgID;
|
||||
}
|
||||
if (activeWorkspaceID) {
|
||||
headers["X-Workspace-Id"] = activeWorkspaceID;
|
||||
if (activeTeamID) {
|
||||
headers["X-Team-Id"] = activeTeamID;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Org context: Failed to access localStorage:", error);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { BackendAPIProvider } from "@/lib/autogpt-server-api/context";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import CredentialsProvider from "@/providers/agent-credentials/credentials-provider";
|
||||
import OnboardingProvider from "@/providers/onboarding/onboarding-provider";
|
||||
import OrgWorkspaceProvider from "@/providers/org-workspace/OrgWorkspaceProvider";
|
||||
import OrgTeamProvider from "@/providers/org-team/OrgTeamProvider";
|
||||
import {
|
||||
PostHogPageViewTracker,
|
||||
PostHogProvider,
|
||||
@@ -31,7 +31,7 @@ export function Providers({ children, ...props }: ThemeProviderProps) {
|
||||
<PostHogPageViewTracker />
|
||||
</Suspense>
|
||||
<CredentialsProvider>
|
||||
<OrgWorkspaceProvider>
|
||||
<OrgTeamProvider>
|
||||
<LaunchDarklyProvider>
|
||||
<OnboardingProvider>
|
||||
<ThemeProvider forcedTheme="light" {...props}>
|
||||
@@ -39,7 +39,7 @@ export function Providers({ children, ...props }: ThemeProviderProps) {
|
||||
</ThemeProvider>
|
||||
</OnboardingProvider>
|
||||
</LaunchDarklyProvider>
|
||||
</OrgWorkspaceProvider>
|
||||
</OrgTeamProvider>
|
||||
</CredentialsProvider>
|
||||
</BackendAPIProvider>
|
||||
</PostHogProvider>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { environment } from "@/services/environment";
|
||||
import { AccountMenu } from "./components/AccountMenu/AccountMenu";
|
||||
import { FeedbackButton } from "./components/FeedbackButton";
|
||||
import { AgentActivityDropdown } from "./components/AgentActivityDropdown/AgentActivityDropdown";
|
||||
import { OrgWorkspaceSwitcher } from "./components/OrgWorkspaceSwitcher/OrgWorkspaceSwitcher";
|
||||
import { OrgTeamSwitcher } from "./components/OrgTeamSwitcher/OrgTeamSwitcher";
|
||||
import { LoginButton } from "./components/LoginButton";
|
||||
import { MobileNavBar } from "./components/MobileNavbar/MobileNavBar";
|
||||
import { NavbarLink } from "./components/NavbarLink";
|
||||
@@ -94,7 +94,7 @@ export function Navbar() {
|
||||
{isLoggedIn && !isSmallScreen ? (
|
||||
<div className="flex flex-1 items-center justify-end gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<OrgWorkspaceSwitcher />
|
||||
<OrgTeamSwitcher />
|
||||
<FeedbackButton />
|
||||
<AgentActivityDropdown />
|
||||
{profile && <Wallet key={profile.username} />}
|
||||
|
||||
@@ -7,20 +7,20 @@ import Avatar, {
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/atoms/Avatar/Avatar";
|
||||
import { useOrgWorkspaceSwitcher } from "./useOrgWorkspaceSwitcher";
|
||||
import { useOrgTeamSwitcher } from "./useOrgTeamSwitcher";
|
||||
import { CaretDown, Check, Plus, GearSix } from "@phosphor-icons/react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function OrgWorkspaceSwitcher() {
|
||||
export function OrgTeamSwitcher() {
|
||||
const {
|
||||
orgs,
|
||||
workspaces,
|
||||
teams,
|
||||
activeOrg,
|
||||
activeWorkspace,
|
||||
activeTeam,
|
||||
switchOrg,
|
||||
switchWorkspace,
|
||||
switchTeam,
|
||||
isLoaded,
|
||||
} = useOrgWorkspaceSwitcher();
|
||||
} = useOrgTeamSwitcher();
|
||||
|
||||
if (!isLoaded || orgs.length === 0) {
|
||||
return null;
|
||||
@@ -91,36 +91,36 @@ export function OrgWorkspaceSwitcher() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Workspace list (only if orgs exist) */}
|
||||
{workspaces.length > 0 && (
|
||||
{/* Team list (only if orgs exist) */}
|
||||
{teams.length > 0 && (
|
||||
<>
|
||||
<div className="border-t border-neutral-100" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="px-2 py-1 text-xs font-medium uppercase text-neutral-400">
|
||||
Workspaces
|
||||
Teams
|
||||
</span>
|
||||
{workspaces.map((ws) => (
|
||||
{teams.map((ws) => (
|
||||
<button
|
||||
key={ws.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-neutral-100"
|
||||
onClick={() => switchWorkspace(ws.id)}
|
||||
onClick={() => switchTeam(ws.id)}
|
||||
>
|
||||
<span className="flex-1 truncate text-left">{ws.name}</span>
|
||||
{ws.joinPolicy === "PRIVATE" && (
|
||||
<span className="text-xs text-neutral-400">Private</span>
|
||||
)}
|
||||
{ws.id === activeWorkspace?.id && (
|
||||
{ws.id === activeTeam?.id && (
|
||||
<Check size={14} className="text-green-600" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<Link
|
||||
href="/org/workspaces"
|
||||
href="/org/teams"
|
||||
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-neutral-500 hover:bg-neutral-100"
|
||||
>
|
||||
<GearSix size={14} />
|
||||
<span>Manage workspaces</span>
|
||||
<span>Manage teams</span>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
@@ -1,20 +1,19 @@
|
||||
import { useOrgWorkspaceStore } from "@/services/org-workspace/store";
|
||||
import { useOrgTeamStore } from "@/services/org-team/store";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
|
||||
export function useOrgWorkspaceSwitcher() {
|
||||
export function useOrgTeamSwitcher() {
|
||||
const {
|
||||
orgs,
|
||||
workspaces,
|
||||
teams,
|
||||
activeOrgID,
|
||||
activeWorkspaceID,
|
||||
activeTeamID,
|
||||
setActiveOrg,
|
||||
setActiveWorkspace,
|
||||
setActiveTeam,
|
||||
isLoaded,
|
||||
} = useOrgWorkspaceStore();
|
||||
} = useOrgTeamStore();
|
||||
|
||||
const activeOrg = orgs.find((o) => o.id === activeOrgID) || null;
|
||||
const activeWorkspace =
|
||||
workspaces.find((w) => w.id === activeWorkspaceID) || null;
|
||||
const activeTeam = teams.find((w) => w.id === activeTeamID) || null;
|
||||
|
||||
function switchOrg(orgID: string) {
|
||||
if (orgID === activeOrgID) return;
|
||||
@@ -24,21 +23,21 @@ export function useOrgWorkspaceSwitcher() {
|
||||
queryClient.clear();
|
||||
}
|
||||
|
||||
function switchWorkspace(workspaceID: string) {
|
||||
if (workspaceID === activeWorkspaceID) return;
|
||||
setActiveWorkspace(workspaceID);
|
||||
// Clear cache for workspace-scoped data
|
||||
function switchTeam(teamID: string) {
|
||||
if (teamID === activeTeamID) return;
|
||||
setActiveTeam(teamID);
|
||||
// Clear cache for team-scoped data
|
||||
const queryClient = getQueryClient();
|
||||
queryClient.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
orgs,
|
||||
workspaces,
|
||||
teams,
|
||||
activeOrg,
|
||||
activeWorkspace,
|
||||
activeTeam,
|
||||
switchOrg,
|
||||
switchWorkspace,
|
||||
switchTeam,
|
||||
isLoaded,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { useOrgWorkspaceStore } from "@/services/org-workspace/store";
|
||||
import { useOrgTeamStore } from "@/services/org-team/store";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
@@ -10,19 +10,19 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes org/workspace context on login and clears it on logout.
|
||||
* Initializes org/team context on login and clears it on logout.
|
||||
*
|
||||
* On mount (when logged in):
|
||||
* 1. Fetches the user's org list from GET /api/orgs
|
||||
* 2. If no activeOrgID is stored, sets the personal org as default
|
||||
* 3. Fetches workspaces for the active org
|
||||
* 3. Fetches teams for the active org
|
||||
*
|
||||
* On org/workspace switch: clears React Query cache to force refetch.
|
||||
* On org/team switch: clears React Query cache to force refetch.
|
||||
*/
|
||||
export default function OrgWorkspaceProvider({ children }: Props) {
|
||||
export default function OrgTeamProvider({ children }: Props) {
|
||||
const { isLoggedIn, user } = useSupabase();
|
||||
const { activeOrgID, setActiveOrg, setOrgs, setLoaded, clearContext } =
|
||||
useOrgWorkspaceStore();
|
||||
useOrgTeamStore();
|
||||
|
||||
const prevOrgID = useRef(activeOrgID);
|
||||
|
||||
@@ -10,7 +10,7 @@ interface Org {
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
interface Workspace {
|
||||
interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string | null;
|
||||
@@ -19,50 +19,50 @@ interface Workspace {
|
||||
orgId: string;
|
||||
}
|
||||
|
||||
interface OrgWorkspaceState {
|
||||
interface OrgTeamState {
|
||||
activeOrgID: string | null;
|
||||
activeWorkspaceID: string | null;
|
||||
activeTeamID: string | null;
|
||||
orgs: Org[];
|
||||
workspaces: Workspace[];
|
||||
teams: Team[];
|
||||
isLoaded: boolean;
|
||||
|
||||
setActiveOrg(orgID: string): void;
|
||||
setActiveWorkspace(workspaceID: string | null): void;
|
||||
setActiveTeam(teamID: string | null): void;
|
||||
setOrgs(orgs: Org[]): void;
|
||||
setWorkspaces(workspaces: Workspace[]): void;
|
||||
setTeams(teams: Team[]): void;
|
||||
setLoaded(loaded: boolean): void;
|
||||
clearContext(): void;
|
||||
}
|
||||
|
||||
export const useOrgWorkspaceStore = create<OrgWorkspaceState>((set) => ({
|
||||
export const useOrgTeamStore = create<OrgTeamState>((set) => ({
|
||||
activeOrgID: storage.get(Key.ACTIVE_ORG) || null,
|
||||
activeWorkspaceID: storage.get(Key.ACTIVE_WORKSPACE) || null,
|
||||
activeTeamID: storage.get(Key.ACTIVE_TEAM) || null,
|
||||
orgs: [],
|
||||
workspaces: [],
|
||||
teams: [],
|
||||
isLoaded: false,
|
||||
|
||||
setActiveOrg(orgID: string) {
|
||||
storage.set(Key.ACTIVE_ORG, orgID);
|
||||
set({ activeOrgID: orgID, activeWorkspaceID: null });
|
||||
// Clear workspace when switching org — provider will resolve default
|
||||
storage.clean(Key.ACTIVE_WORKSPACE);
|
||||
set({ activeOrgID: orgID, activeTeamID: null });
|
||||
// Clear team when switching org — provider will resolve default
|
||||
storage.clean(Key.ACTIVE_TEAM);
|
||||
},
|
||||
|
||||
setActiveWorkspace(workspaceID: string | null) {
|
||||
if (workspaceID) {
|
||||
storage.set(Key.ACTIVE_WORKSPACE, workspaceID);
|
||||
setActiveTeam(teamID: string | null) {
|
||||
if (teamID) {
|
||||
storage.set(Key.ACTIVE_TEAM, teamID);
|
||||
} else {
|
||||
storage.clean(Key.ACTIVE_WORKSPACE);
|
||||
storage.clean(Key.ACTIVE_TEAM);
|
||||
}
|
||||
set({ activeWorkspaceID: workspaceID });
|
||||
set({ activeTeamID: teamID });
|
||||
},
|
||||
|
||||
setOrgs(orgs: Org[]) {
|
||||
set({ orgs });
|
||||
},
|
||||
|
||||
setWorkspaces(workspaces: Workspace[]) {
|
||||
set({ workspaces });
|
||||
setTeams(teams: Team[]) {
|
||||
set({ teams });
|
||||
},
|
||||
|
||||
setLoaded(loaded: boolean) {
|
||||
@@ -71,12 +71,12 @@ export const useOrgWorkspaceStore = create<OrgWorkspaceState>((set) => ({
|
||||
|
||||
clearContext() {
|
||||
storage.clean(Key.ACTIVE_ORG);
|
||||
storage.clean(Key.ACTIVE_WORKSPACE);
|
||||
storage.clean(Key.ACTIVE_TEAM);
|
||||
set({
|
||||
activeOrgID: null,
|
||||
activeWorkspaceID: null,
|
||||
activeTeamID: null,
|
||||
orgs: [],
|
||||
workspaces: [],
|
||||
teams: [],
|
||||
isLoaded: false,
|
||||
});
|
||||
},
|
||||
@@ -16,7 +16,7 @@ export enum Key {
|
||||
COPILOT_NOTIFICATION_BANNER_DISMISSED = "copilot-notification-banner-dismissed",
|
||||
COPILOT_NOTIFICATION_DIALOG_DISMISSED = "copilot-notification-dialog-dismissed",
|
||||
ACTIVE_ORG = "active-org-id",
|
||||
ACTIVE_WORKSPACE = "active-workspace-id",
|
||||
ACTIVE_TEAM = "active-team-id",
|
||||
COPILOT_COMPLETED_SESSIONS = "copilot-completed-sessions",
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user