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:
Nicholas Tindle
2026-04-04 17:58:53 +02:00
parent 87c3ab6464
commit fbb6b5cfac
40 changed files with 773 additions and 1163 deletions

View File

@@ -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",
]

View File

@@ -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}",

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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(

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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"
)

View File

@@ -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}}
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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(

View File

@@ -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)

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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
]

View File

@@ -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

View File

@@ -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:

View File

@@ -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()

View File

@@ -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}
),

View File

@@ -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,

View File

@@ -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:

View File

@@ -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.

View File

@@ -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;

View File

@@ -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;

View File

@@ -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])

View File

@@ -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

View File

@@ -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);

View File

@@ -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>

View File

@@ -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} />}

View File

@@ -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>
</>

View File

@@ -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,
};
}

View File

@@ -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);

View File

@@ -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,
});
},

View File

@@ -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",
}