mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Compare commits
1 Commits
feat/org-w
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5e2eccda7 |
@@ -25,7 +25,7 @@ STATE_FILE=~/.claude/orchestrator-state.json
|
||||
| `spawn-agent.sh SESSION PATH SPARE NEW_BRANCH OBJECTIVE [PR_NUMBER] [STEPS...]` | Create window + checkout branch + launch claude + send task. **Stdout: `SESSION:WIN` only** |
|
||||
| `recycle-agent.sh WINDOW PATH SPARE_BRANCH` | Kill window + restore spare branch |
|
||||
| `run-loop.sh` | **Mechanical babysitter** — idle restart + dialog approval + recycle on ORCHESTRATOR:DONE + supervisor health check + all-done notification |
|
||||
| `verify-complete.sh WINDOW` | Verify PR is done: checkpoints ✓ + 0 unresolved threads + CI green. Repo auto-derived from state file `.repo` or git remote. |
|
||||
| `verify-complete.sh WINDOW` | Verify PR is done: checkpoints ✓ + 0 unresolved threads + CI green + no fresh CHANGES_REQUESTED. Repo auto-derived from state file `.repo` or git remote. |
|
||||
| `notify.sh MESSAGE` | Send notification via Discord webhook (env `DISCORD_WEBHOOK_URL` or state `.discord_webhook`), macOS notification center, and stdout |
|
||||
| `capacity.sh [REPO_ROOT]` | Print available + in-use worktrees |
|
||||
| `status.sh` | Print fleet status + live pane commands |
|
||||
@@ -64,7 +64,7 @@ spare/N branch → spawn-agent.sh (--session-id UUID) → window + feat/bran
|
||||
↓
|
||||
ORCHESTRATOR:DONE
|
||||
↓
|
||||
verify-complete.sh: checkpoints ✓ + 0 threads + CI green
|
||||
verify-complete.sh: checkpoints ✓ + 0 threads + CI green + no fresh CHANGES_REQUESTED
|
||||
↓
|
||||
state → "done", notify, window KEPT OPEN
|
||||
↓
|
||||
@@ -328,7 +328,9 @@ For each agent, decide:
|
||||
|
||||
### Strict ORCHESTRATOR:DONE gate
|
||||
|
||||
`verify-complete.sh` handles the main checks automatically (checkpoints, threads, CHANGES_REQUESTED, CI green, spawned_at). Run it:
|
||||
`verify-complete.sh` handles the main checks automatically (checkpoints, threads, CI green, spawned_at, and CHANGES_REQUESTED). Run it:
|
||||
|
||||
**CHANGES_REQUESTED staleness rule**: a `CHANGES_REQUESTED` review only blocks if it was submitted *after* the latest commit. If the latest commit postdates the review, the review is considered stale (feedback already addressed) and does not block. This avoids false negatives when a bot reviewer hasn't re-reviewed after the agent's fixing commits.
|
||||
|
||||
```bash
|
||||
SKILLS_DIR=~/.claude/orchestrator/scripts
|
||||
@@ -412,6 +414,38 @@ Please verify: <specific behaviors to check>.
|
||||
|
||||
Only one `/pr-test` at a time — they share ports and DB.
|
||||
|
||||
### /pr-test result evaluation
|
||||
|
||||
**PARTIAL on any headline feature scenario is an immediate blocker.** Do not approve, do not mark done, do not let the agent output `ORCHESTRATOR:DONE`.
|
||||
|
||||
| `/pr-test` result | Action |
|
||||
|---|---|
|
||||
| All headline scenarios **PASS** | Proceed to evaluation step 2 |
|
||||
| Any headline scenario **PARTIAL** | Re-brief the agent immediately — see below |
|
||||
| Any headline scenario **FAIL** | Re-brief the agent immediately |
|
||||
|
||||
**What PARTIAL means**: the feature is only partly working. Example: the Apply button never appeared, or the AI returned no action blocks. The agent addressed part of the objective but not all of it.
|
||||
|
||||
**When any headline scenario is PARTIAL or FAIL:**
|
||||
|
||||
1. Do NOT mark the agent done or accept `ORCHESTRATOR:DONE`
|
||||
2. Re-brief the agent with the specific scenario that failed and what was missing:
|
||||
```bash
|
||||
tmux send-keys -t SESSION:WIN "PARTIAL result on /pr-test — S5 (Apply button) never appeared. The AI must output JSON action blocks for the Apply button to render. Fix this before re-running /pr-test."
|
||||
sleep 0.3
|
||||
tmux send-keys -t SESSION:WIN Enter
|
||||
```
|
||||
3. Set state back to `running`:
|
||||
```bash
|
||||
jq --arg w "SESSION:WIN" '(.agents[] | select(.window == $w)).state = "running"' \
|
||||
~/.claude/orchestrator-state.json > /tmp/orch.tmp && mv /tmp/orch.tmp ~/.claude/orchestrator-state.json
|
||||
```
|
||||
4. Wait for new `ORCHESTRATOR:DONE`, then re-run `/pr-test` from scratch
|
||||
|
||||
**Rule: only ALL-PASS qualifies for approval.** A mix of PASS + PARTIAL is a failure.
|
||||
|
||||
> **Why this matters**: PR #12699 was wrongly approved with S5 PARTIAL — the AI never output JSON action blocks so the Apply button never appeared. The fix was already in the agent's reach but slipped through because PARTIAL was not treated as blocking.
|
||||
|
||||
### 2. Do your own evaluation
|
||||
|
||||
1. **Read the PR diff and objective** — does the code actually implement what was asked? Is anything obviously missing or half-done?
|
||||
@@ -421,8 +455,9 @@ Only one `/pr-test` at a time — they share ports and DB.
|
||||
|
||||
### 3. Decide
|
||||
|
||||
- `/pr-test` passes + evaluation looks good → mark `done` in state, tell the user the PR is ready, ask if window should be closed
|
||||
- `/pr-test` fails or evaluation finds gaps → re-brief the agent with specific failures, set state back to `running`
|
||||
- `/pr-test` all scenarios PASS + evaluation looks good → mark `done` in state, tell the user the PR is ready, ask if window should be closed
|
||||
- `/pr-test` any scenario PARTIAL or FAIL → re-brief the agent with the specific failing scenario, set state back to `running` (see `/pr-test result evaluation` above)
|
||||
- Evaluation finds gaps even with all PASS → re-brief the agent with specific gaps, set state back to `running`
|
||||
|
||||
**Never mark done based purely on script output.** You hold the full objective context; the script does not.
|
||||
|
||||
@@ -441,6 +476,7 @@ Stop the fleet (`active = false`) when **all** of the following are true:
|
||||
| All agents are `done` or `escalated` | `jq '[.agents[] | select(.state | test("running\|stuck\|idle\|waiting_approval"))] | length' ~/.claude/orchestrator-state.json` == 0 |
|
||||
| All PRs have 0 unresolved review threads | GraphQL `isResolved` check per PR |
|
||||
| All PRs have green CI **on a run triggered after the agent's last push** | `gh run list --branch BRANCH --limit 1` timestamp > `spawned_at` in state |
|
||||
| No fresh CHANGES_REQUESTED (after latest commit) | `verify-complete.sh` checks this — stale pre-commit reviews are ignored |
|
||||
| No agents are `escalated` without human review | If any are escalated, surface to user first |
|
||||
|
||||
**Do NOT stop just because agents output `ORCHESTRATOR:DONE`.** That is a signal to verify, not a signal to stop.
|
||||
|
||||
@@ -115,13 +115,64 @@ if [ "$UNRESOLVED" -gt 0 ]; then
|
||||
fi
|
||||
|
||||
# --- Check 6: no CHANGES_REQUESTED (checked AFTER CI — bots post reviews after their check) ---
|
||||
CHANGES_REQUESTED=$(gh pr view "$PR_NUMBER" --repo "$REPO" \
|
||||
--json reviews --jq '[.reviews[] | select(.state == "CHANGES_REQUESTED")] | length' 2>/dev/null || echo "0")
|
||||
# A CHANGES_REQUESTED review is stale if the latest commit was pushed AFTER the review was submitted.
|
||||
# Stale reviews (pre-dating the fixing commits) should not block verification.
|
||||
#
|
||||
# Fetch commits and latestReviews in a single call and fail closed — if gh fails,
|
||||
# treat that as NOT COMPLETE rather than silently passing.
|
||||
# Use latestReviews (not reviews) so each reviewer's latest state is used — superseded
|
||||
# CHANGES_REQUESTED entries are automatically excluded when the reviewer later approved.
|
||||
# Note: we intentionally use committedDate (not PR updatedAt) because updatedAt changes on any
|
||||
# PR activity (bot comments, label changes) which would create false negatives.
|
||||
PR_REVIEW_METADATA=$(gh pr view "$PR_NUMBER" --repo "$REPO" \
|
||||
--json commits,latestReviews 2>/dev/null) || {
|
||||
echo "NOT COMPLETE: unable to fetch PR review metadata for PR #$PR_NUMBER" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ "$CHANGES_REQUESTED" -gt 0 ]; then
|
||||
REQUESTERS=$(gh pr view "$PR_NUMBER" --repo "$REPO" \
|
||||
--json reviews --jq '[.reviews[] | select(.state == "CHANGES_REQUESTED") | .author.login] | join(", ")' 2>/dev/null || echo "unknown")
|
||||
echo "NOT COMPLETE: CHANGES_REQUESTED from ${REQUESTERS} on PR #$PR_NUMBER" >&2
|
||||
LATEST_COMMIT_DATE=$(jq -r '.commits[-1].committedDate // ""' <<< "$PR_REVIEW_METADATA")
|
||||
CHANGES_REQUESTED_REVIEWS=$(jq '[.latestReviews[]? | select(.state == "CHANGES_REQUESTED")]' <<< "$PR_REVIEW_METADATA")
|
||||
|
||||
BLOCKING_CHANGES_REQUESTED=0
|
||||
BLOCKING_REQUESTERS=""
|
||||
|
||||
if [ -n "$LATEST_COMMIT_DATE" ] && [ "$(echo "$CHANGES_REQUESTED_REVIEWS" | jq length)" -gt 0 ]; then
|
||||
if date --version >/dev/null 2>&1; then
|
||||
LATEST_COMMIT_EPOCH=$(date -d "$LATEST_COMMIT_DATE" "+%s" 2>/dev/null || echo "0")
|
||||
else
|
||||
LATEST_COMMIT_EPOCH=$(TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%SZ" "$LATEST_COMMIT_DATE" "+%s" 2>/dev/null || echo "0")
|
||||
fi
|
||||
|
||||
while IFS= read -r review; do
|
||||
[ -z "$review" ] && continue
|
||||
REVIEW_DATE=$(echo "$review" | jq -r '.submittedAt // ""')
|
||||
REVIEWER=$(echo "$review" | jq -r '.author.login // "unknown"')
|
||||
if [ -z "$REVIEW_DATE" ]; then
|
||||
# No submission date — treat as fresh (conservative: blocks verification)
|
||||
BLOCKING_CHANGES_REQUESTED=$(( BLOCKING_CHANGES_REQUESTED + 1 ))
|
||||
BLOCKING_REQUESTERS="${BLOCKING_REQUESTERS:+$BLOCKING_REQUESTERS, }${REVIEWER}"
|
||||
else
|
||||
if date --version >/dev/null 2>&1; then
|
||||
REVIEW_EPOCH=$(date -d "$REVIEW_DATE" "+%s" 2>/dev/null || echo "0")
|
||||
else
|
||||
REVIEW_EPOCH=$(TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%SZ" "$REVIEW_DATE" "+%s" 2>/dev/null || echo "0")
|
||||
fi
|
||||
if [ "$REVIEW_EPOCH" -gt "$LATEST_COMMIT_EPOCH" ]; then
|
||||
# Review was submitted AFTER latest commit — still fresh, blocks verification
|
||||
BLOCKING_CHANGES_REQUESTED=$(( BLOCKING_CHANGES_REQUESTED + 1 ))
|
||||
BLOCKING_REQUESTERS="${BLOCKING_REQUESTERS:+$BLOCKING_REQUESTERS, }${REVIEWER}"
|
||||
fi
|
||||
# Review submitted BEFORE latest commit — stale, skip
|
||||
fi
|
||||
done <<< "$(echo "$CHANGES_REQUESTED_REVIEWS" | jq -c '.[]')"
|
||||
else
|
||||
# No commit date or no changes_requested — check raw count as fallback
|
||||
BLOCKING_CHANGES_REQUESTED=$(echo "$CHANGES_REQUESTED_REVIEWS" | jq length 2>/dev/null || echo "0")
|
||||
BLOCKING_REQUESTERS=$(echo "$CHANGES_REQUESTED_REVIEWS" | jq -r '[.[].author.login] | join(", ")' 2>/dev/null || echo "unknown")
|
||||
fi
|
||||
|
||||
if [ "$BLOCKING_CHANGES_REQUESTED" -gt 0 ]; then
|
||||
echo "NOT COMPLETE: CHANGES_REQUESTED (after latest commit) from ${BLOCKING_REQUESTERS} on PR #$PR_NUMBER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
from .config import verify_settings
|
||||
from .dependencies import (
|
||||
get_optional_user_id,
|
||||
get_request_context,
|
||||
get_user_id,
|
||||
requires_admin_user,
|
||||
requires_org_permission,
|
||||
requires_team_permission,
|
||||
requires_user,
|
||||
)
|
||||
from .helpers import add_auth_responses_to_openapi
|
||||
from .models import RequestContext, User
|
||||
from .permissions import (
|
||||
OrgAction,
|
||||
TeamAction,
|
||||
check_org_permission,
|
||||
check_team_permission,
|
||||
)
|
||||
from .models import User
|
||||
|
||||
__all__ = [
|
||||
"verify_settings",
|
||||
@@ -23,14 +14,6 @@ __all__ = [
|
||||
"requires_admin_user",
|
||||
"requires_user",
|
||||
"get_optional_user_id",
|
||||
"get_request_context",
|
||||
"requires_org_permission",
|
||||
"requires_team_permission",
|
||||
"add_auth_responses_to_openapi",
|
||||
"User",
|
||||
"RequestContext",
|
||||
"OrgAction",
|
||||
"TeamAction",
|
||||
"check_org_permission",
|
||||
"check_team_permission",
|
||||
]
|
||||
|
||||
@@ -10,13 +10,7 @@ import fastapi
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from .jwt_utils import get_jwt_payload, verify_user
|
||||
from .models import RequestContext, User
|
||||
from .permissions import (
|
||||
OrgAction,
|
||||
TeamAction,
|
||||
check_org_permission,
|
||||
check_team_permission,
|
||||
)
|
||||
from .models import User
|
||||
|
||||
optional_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
@@ -121,210 +115,3 @@ async def get_user_id(
|
||||
return impersonate_header
|
||||
|
||||
return user_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Org / Workspace context resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ORG_HEADER_NAME = "X-Org-Id"
|
||||
TEAM_HEADER_NAME = "X-Team-Id"
|
||||
|
||||
|
||||
async def get_request_context(
|
||||
request: fastapi.Request,
|
||||
jwt_payload: dict = fastapi.Security(get_jwt_payload),
|
||||
) -> RequestContext:
|
||||
"""
|
||||
FastAPI dependency that resolves the full org/workspace context for a request.
|
||||
|
||||
Resolution order:
|
||||
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-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.
|
||||
"""
|
||||
from backend.data.db import prisma # deferred -- only needed at runtime
|
||||
|
||||
# --- 1. user_id (reuse existing impersonation logic) ----------------------
|
||||
user_id = jwt_payload.get("sub")
|
||||
if not user_id:
|
||||
raise fastapi.HTTPException(
|
||||
status_code=401, detail="User ID not found in token"
|
||||
)
|
||||
|
||||
impersonate_header = request.headers.get(IMPERSONATION_HEADER_NAME, "").strip()
|
||||
if impersonate_header:
|
||||
authenticated_user = verify_user(jwt_payload, admin_only=False)
|
||||
if authenticated_user.role != "admin":
|
||||
raise fastapi.HTTPException(
|
||||
status_code=403,
|
||||
detail="Only admin users can impersonate other users",
|
||||
)
|
||||
logger.info(
|
||||
f"Admin impersonation: {authenticated_user.user_id} ({authenticated_user.email}) "
|
||||
f"acting as user {impersonate_header} for requesting {request.method} {request.url}"
|
||||
)
|
||||
user_id = impersonate_header
|
||||
|
||||
# --- 2. org_id ------------------------------------------------------------
|
||||
org_id = request.headers.get(ORG_HEADER_NAME, "").strip() or None
|
||||
|
||||
if org_id is None:
|
||||
# Fall back to the user's personal org (an org where the user is the
|
||||
# sole owner, typically created at sign-up).
|
||||
personal_org = await prisma.orgmember.find_first(
|
||||
where={
|
||||
"userId": user_id,
|
||||
"isOwner": True,
|
||||
"Org": {"isPersonal": True},
|
||||
},
|
||||
order={"createdAt": "asc"},
|
||||
)
|
||||
if personal_org is None:
|
||||
logger.warning(
|
||||
f"User {user_id} has no personal org — account in inconsistent state"
|
||||
)
|
||||
raise fastapi.HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"No organization context available. Your account may be in "
|
||||
"an inconsistent state — please contact support."
|
||||
),
|
||||
)
|
||||
org_id = personal_org.orgId
|
||||
|
||||
# --- 3. validate OrgMember ------------------------------------------------
|
||||
org_member = await prisma.orgmember.find_unique(
|
||||
where={
|
||||
"orgId_userId": {"orgId": org_id, "userId": user_id},
|
||||
},
|
||||
)
|
||||
if org_member is None or org_member.status != "ACTIVE":
|
||||
raise fastapi.HTTPException(
|
||||
status_code=403,
|
||||
detail="User is not an active member of this organization",
|
||||
)
|
||||
|
||||
is_org_owner = org_member.isOwner
|
||||
is_org_admin = org_member.isAdmin
|
||||
is_org_billing_manager = org_member.isBillingManager
|
||||
seat_status = "ACTIVE" # validated above; seat assignment checked separately
|
||||
|
||||
# --- 4. team_id (optional) -------------------------------------------
|
||||
team_id: str | None = (
|
||||
request.headers.get(TEAM_HEADER_NAME, "").strip() or None
|
||||
)
|
||||
is_team_admin = False
|
||||
is_team_billing_manager = False
|
||||
|
||||
if team_id is not None:
|
||||
# Validate workspace belongs to org AND user has a membership row
|
||||
ws_member = await prisma.teammember.find_unique(
|
||||
where={
|
||||
"teamId_userId": {
|
||||
"teamId": team_id,
|
||||
"userId": user_id,
|
||||
},
|
||||
},
|
||||
include={"Team": True},
|
||||
)
|
||||
if (
|
||||
ws_member is None
|
||||
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",
|
||||
team_id,
|
||||
user_id,
|
||||
org_id,
|
||||
)
|
||||
team_id = None
|
||||
else:
|
||||
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,
|
||||
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_team_admin=is_team_admin,
|
||||
is_team_billing_manager=is_team_billing_manager,
|
||||
seat_status=seat_status,
|
||||
)
|
||||
|
||||
|
||||
def requires_org_permission(
|
||||
*actions: OrgAction,
|
||||
):
|
||||
"""Factory returning a FastAPI dependency that enforces org-level permissions.
|
||||
|
||||
The request is allowed only if the user holds **all** listed actions.
|
||||
|
||||
Example::
|
||||
|
||||
@router.delete("/org/{org_id}")
|
||||
async def delete_org(
|
||||
ctx: RequestContext = Security(requires_org_permission(OrgAction.DELETE_ORG)),
|
||||
):
|
||||
...
|
||||
"""
|
||||
|
||||
async def _dependency(
|
||||
ctx: RequestContext = fastapi.Security(get_request_context),
|
||||
) -> RequestContext:
|
||||
for action in actions:
|
||||
if not check_org_permission(ctx, action):
|
||||
raise fastapi.HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Missing org permission: {action.value}",
|
||||
)
|
||||
return ctx
|
||||
|
||||
return _dependency
|
||||
|
||||
|
||||
def requires_team_permission(
|
||||
*actions: TeamAction,
|
||||
):
|
||||
"""Factory returning a FastAPI dependency that enforces workspace-level permissions.
|
||||
|
||||
The user must be in a workspace context (team_id is set) and
|
||||
hold **all** listed actions.
|
||||
|
||||
Example::
|
||||
|
||||
@router.post("/workspace/{ws_id}/agents")
|
||||
async def create_agent(
|
||||
ctx: RequestContext = Security(
|
||||
requires_team_permission(TeamAction.CREATE_AGENTS)
|
||||
),
|
||||
):
|
||||
...
|
||||
"""
|
||||
|
||||
async def _dependency(
|
||||
ctx: RequestContext = fastapi.Security(get_request_context),
|
||||
) -> RequestContext:
|
||||
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_team_permission(ctx, action):
|
||||
raise fastapi.HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Missing workspace permission: {action.value}",
|
||||
)
|
||||
return ctx
|
||||
|
||||
return _dependency
|
||||
|
||||
@@ -20,16 +20,3 @@ class User:
|
||||
phone_number=payload.get("phone", ""),
|
||||
role=payload["role"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestContext:
|
||||
user_id: str
|
||||
org_id: str
|
||||
team_id: str | None # None = org-home context
|
||||
is_org_owner: bool
|
||||
is_org_admin: bool
|
||||
is_org_billing_manager: bool
|
||||
is_team_admin: bool
|
||||
is_team_billing_manager: bool
|
||||
seat_status: str # ACTIVE, INACTIVE, PENDING, NONE
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"""
|
||||
Role-based permission checks for org-level and workspace-level actions.
|
||||
|
||||
Permission maps are pure data; check functions are pure functions operating
|
||||
on a RequestContext -- no I/O, no database access.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from .models import RequestContext
|
||||
|
||||
|
||||
class OrgAction(str, Enum):
|
||||
DELETE_ORG = "DELETE_ORG"
|
||||
RENAME_ORG = "RENAME_ORG"
|
||||
MANAGE_MEMBERS = "MANAGE_MEMBERS"
|
||||
MANAGE_WORKSPACES = "MANAGE_WORKSPACES"
|
||||
CREATE_WORKSPACES = "CREATE_WORKSPACES"
|
||||
MANAGE_BILLING = "MANAGE_BILLING"
|
||||
PUBLISH_TO_STORE = "PUBLISH_TO_STORE"
|
||||
TRANSFER_RESOURCES = "TRANSFER_RESOURCES"
|
||||
VIEW_ORG = "VIEW_ORG"
|
||||
CREATE_RESOURCES = "CREATE_RESOURCES"
|
||||
SHARE_RESOURCES = "SHARE_RESOURCES"
|
||||
|
||||
|
||||
class TeamAction(str, Enum):
|
||||
MANAGE_MEMBERS = "MANAGE_MEMBERS"
|
||||
MANAGE_SETTINGS = "MANAGE_SETTINGS"
|
||||
MANAGE_CREDENTIALS = "MANAGE_CREDENTIALS"
|
||||
VIEW_SPEND = "VIEW_SPEND"
|
||||
CREATE_AGENTS = "CREATE_AGENTS"
|
||||
USE_CREDENTIALS = "USE_CREDENTIALS"
|
||||
VIEW_EXECUTIONS = "VIEW_EXECUTIONS"
|
||||
DELETE_AGENTS = "DELETE_AGENTS"
|
||||
|
||||
|
||||
# Org permission map: action -> set of roles that are allowed.
|
||||
# Roles checked via RequestContext boolean flags.
|
||||
# "member" means any active org member (no special role flag required).
|
||||
_ORG_PERMISSIONS: dict[OrgAction, set[str]] = {
|
||||
OrgAction.DELETE_ORG: {"owner"},
|
||||
OrgAction.RENAME_ORG: {"owner", "admin"},
|
||||
OrgAction.MANAGE_MEMBERS: {"owner", "admin"},
|
||||
OrgAction.MANAGE_WORKSPACES: {"owner", "admin"},
|
||||
OrgAction.CREATE_WORKSPACES: {"owner", "admin", "billing_manager"},
|
||||
OrgAction.MANAGE_BILLING: {"owner", "billing_manager"},
|
||||
OrgAction.PUBLISH_TO_STORE: {"owner", "admin", "member"},
|
||||
OrgAction.TRANSFER_RESOURCES: {"owner", "admin"},
|
||||
OrgAction.VIEW_ORG: {"owner", "admin", "billing_manager", "member"},
|
||||
OrgAction.CREATE_RESOURCES: {"owner", "admin", "member"},
|
||||
OrgAction.SHARE_RESOURCES: {"owner", "admin", "member"},
|
||||
}
|
||||
|
||||
# Workspace permission map: action -> set of roles that are allowed.
|
||||
# "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"},
|
||||
}
|
||||
|
||||
|
||||
def _get_org_roles(ctx: RequestContext) -> set[str]:
|
||||
"""Derive the set of org-level role tags from a RequestContext."""
|
||||
roles: set[str] = set()
|
||||
if ctx.is_org_owner:
|
||||
roles.add("owner")
|
||||
if ctx.is_org_admin:
|
||||
roles.add("admin")
|
||||
if ctx.is_org_billing_manager:
|
||||
roles.add("billing_manager")
|
||||
# A plain member (no owner/admin/billing_manager flags) gets "member".
|
||||
# Owner and admin also get "member" since they can do everything a member can.
|
||||
# Billing managers do NOT get "member" — they only get finance-related actions.
|
||||
if ctx.is_org_owner or ctx.is_org_admin:
|
||||
roles.add("member")
|
||||
elif not ctx.is_org_billing_manager:
|
||||
# Plain member with no special role flags
|
||||
roles.add("member")
|
||||
return roles
|
||||
|
||||
|
||||
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_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
|
||||
|
||||
|
||||
def check_org_permission(ctx: RequestContext, action: OrgAction) -> bool:
|
||||
"""Return True if the RequestContext grants the given org-level action."""
|
||||
allowed_roles = _ORG_PERMISSIONS.get(action, set())
|
||||
return bool(_get_org_roles(ctx) & allowed_roles)
|
||||
|
||||
|
||||
def check_team_permission(ctx: RequestContext, action: TeamAction) -> bool:
|
||||
"""Return True if the RequestContext grants the given workspace-level action."""
|
||||
if ctx.team_id is None:
|
||||
return False
|
||||
allowed_roles = _TEAM_PERMISSIONS.get(action, set())
|
||||
return bool(_get_team_roles(ctx) & allowed_roles)
|
||||
@@ -1,262 +0,0 @@
|
||||
"""
|
||||
Exhaustive tests for org-level and workspace-level permission checks.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from autogpt_libs.auth.models import RequestContext
|
||||
from autogpt_libs.auth.permissions import (
|
||||
OrgAction,
|
||||
TeamAction,
|
||||
check_org_permission,
|
||||
check_team_permission,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers to build RequestContext fixtures for each role
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_ctx(
|
||||
*,
|
||||
is_org_owner: bool = False,
|
||||
is_org_admin: bool = False,
|
||||
is_org_billing_manager: bool = False,
|
||||
is_team_admin: bool = False,
|
||||
is_team_billing_manager: bool = False,
|
||||
seat_status: str = "ACTIVE",
|
||||
team_id: str | None = None,
|
||||
) -> RequestContext:
|
||||
return RequestContext(
|
||||
user_id="user-1",
|
||||
org_id="org-1",
|
||||
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_team_admin=is_team_admin,
|
||||
is_team_billing_manager=is_team_billing_manager,
|
||||
seat_status=seat_status,
|
||||
)
|
||||
|
||||
|
||||
# Convenience contexts for org-level roles
|
||||
ORG_OWNER = _make_ctx(is_org_owner=True)
|
||||
ORG_ADMIN = _make_ctx(is_org_admin=True)
|
||||
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 (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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Org permission matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Expected outcomes per (action, role). True = allowed.
|
||||
_ORG_EXPECTED: dict[OrgAction, dict[str, bool]] = {
|
||||
OrgAction.DELETE_ORG: {
|
||||
"owner": True,
|
||||
"admin": False,
|
||||
"billing_manager": False,
|
||||
"member": False,
|
||||
},
|
||||
OrgAction.RENAME_ORG: {
|
||||
"owner": True,
|
||||
"admin": True,
|
||||
"billing_manager": False,
|
||||
"member": False,
|
||||
},
|
||||
OrgAction.MANAGE_MEMBERS: {
|
||||
"owner": True,
|
||||
"admin": True,
|
||||
"billing_manager": False,
|
||||
"member": False,
|
||||
},
|
||||
OrgAction.MANAGE_WORKSPACES: {
|
||||
"owner": True,
|
||||
"admin": True,
|
||||
"billing_manager": False,
|
||||
"member": False,
|
||||
},
|
||||
OrgAction.CREATE_WORKSPACES: {
|
||||
"owner": True,
|
||||
"admin": True,
|
||||
"billing_manager": True,
|
||||
"member": False,
|
||||
},
|
||||
OrgAction.MANAGE_BILLING: {
|
||||
"owner": True,
|
||||
"admin": False,
|
||||
"billing_manager": True,
|
||||
"member": False,
|
||||
},
|
||||
OrgAction.PUBLISH_TO_STORE: {
|
||||
"owner": True,
|
||||
"admin": True,
|
||||
"billing_manager": False,
|
||||
"member": True,
|
||||
},
|
||||
OrgAction.TRANSFER_RESOURCES: {
|
||||
"owner": True,
|
||||
"admin": True,
|
||||
"billing_manager": False,
|
||||
"member": False,
|
||||
},
|
||||
OrgAction.VIEW_ORG: {
|
||||
"owner": True,
|
||||
"admin": True,
|
||||
"billing_manager": True,
|
||||
"member": True,
|
||||
},
|
||||
OrgAction.CREATE_RESOURCES: {
|
||||
"owner": True,
|
||||
"admin": True,
|
||||
"billing_manager": False,
|
||||
"member": True,
|
||||
},
|
||||
OrgAction.SHARE_RESOURCES: {
|
||||
"owner": True,
|
||||
"admin": True,
|
||||
"billing_manager": False,
|
||||
"member": True,
|
||||
},
|
||||
}
|
||||
|
||||
_ORG_ROLE_CTX = {
|
||||
"owner": ORG_OWNER,
|
||||
"admin": ORG_ADMIN,
|
||||
"billing_manager": ORG_BILLING_MANAGER,
|
||||
"member": ORG_MEMBER,
|
||||
}
|
||||
|
||||
|
||||
class TestOrgPermissions:
|
||||
"""Exhaustive org action x role matrix."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"action",
|
||||
list(OrgAction),
|
||||
ids=[a.value for a in OrgAction],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["owner", "admin", "billing_manager", "member"],
|
||||
)
|
||||
def test_org_permission_matrix(self, action: OrgAction, role: str):
|
||||
ctx = _ORG_ROLE_CTX[role]
|
||||
expected = _ORG_EXPECTED[action][role]
|
||||
result = check_org_permission(ctx, action)
|
||||
assert result is expected, (
|
||||
f"OrgAction.{action.value} for role={role}: "
|
||||
f"expected {expected}, got {result}"
|
||||
)
|
||||
|
||||
def test_member_role_always_present_regardless_of_seat(self):
|
||||
"""The 'member' role is implicit for all org members.
|
||||
Seat-gating is enforced at the endpoint level, not in permission checks."""
|
||||
ctx = _make_ctx(seat_status="INACTIVE")
|
||||
# VIEW_ORG is allowed for members regardless of seat status
|
||||
assert check_org_permission(ctx, OrgAction.VIEW_ORG) is True
|
||||
|
||||
def test_owner_with_inactive_seat_retains_all_owner_permissions(self):
|
||||
"""Owner flag is independent of seat_status."""
|
||||
ctx = _make_ctx(is_org_owner=True, seat_status="INACTIVE")
|
||||
assert check_org_permission(ctx, OrgAction.DELETE_ORG) is True
|
||||
assert check_org_permission(ctx, OrgAction.PUBLISH_TO_STORE) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workspace permission matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TEAM_EXPECTED: dict[TeamAction, dict[str, bool]] = {
|
||||
TeamAction.MANAGE_MEMBERS: {
|
||||
"team_admin": True,
|
||||
"team_billing_manager": False,
|
||||
"team_member": False,
|
||||
},
|
||||
TeamAction.MANAGE_SETTINGS: {
|
||||
"team_admin": True,
|
||||
"team_billing_manager": False,
|
||||
"team_member": False,
|
||||
},
|
||||
TeamAction.MANAGE_CREDENTIALS: {
|
||||
"team_admin": True,
|
||||
"team_billing_manager": False,
|
||||
"team_member": False,
|
||||
},
|
||||
TeamAction.VIEW_SPEND: {
|
||||
"team_admin": True,
|
||||
"team_billing_manager": True,
|
||||
"team_member": False,
|
||||
},
|
||||
TeamAction.CREATE_AGENTS: {
|
||||
"team_admin": True,
|
||||
"team_billing_manager": False,
|
||||
"team_member": True,
|
||||
},
|
||||
TeamAction.USE_CREDENTIALS: {
|
||||
"team_admin": True,
|
||||
"team_billing_manager": False,
|
||||
"team_member": True,
|
||||
},
|
||||
TeamAction.VIEW_EXECUTIONS: {
|
||||
"team_admin": True,
|
||||
"team_billing_manager": False,
|
||||
"team_member": True,
|
||||
},
|
||||
TeamAction.DELETE_AGENTS: {
|
||||
"team_admin": True,
|
||||
"team_billing_manager": False,
|
||||
"team_member": False,
|
||||
},
|
||||
}
|
||||
|
||||
_TEAM_ROLE_CTX = {
|
||||
"team_admin": TEAM_ADMIN,
|
||||
"team_billing_manager": TEAM_BILLING_MGR,
|
||||
"team_member": TEAM_MEMBER,
|
||||
}
|
||||
|
||||
|
||||
class TestTeamPermissions:
|
||||
"""Exhaustive workspace action x role matrix."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"action",
|
||||
list(TeamAction),
|
||||
ids=[a.value for a in TeamAction],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["team_admin", "team_billing_manager", "team_member"],
|
||||
)
|
||||
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"TeamAction.{action.value} for role={role}: "
|
||||
f"expected {expected}, got {result}"
|
||||
)
|
||||
|
||||
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_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
|
||||
@@ -147,18 +147,11 @@ async def execute_graph(
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
# Resolve org/team from user's default (PR14 will add key-level org context)
|
||||
from backend.api.features.orgs.db import get_user_default_team
|
||||
|
||||
org_id, team_id = await get_user_default_team(auth.user_id)
|
||||
|
||||
graph_exec = await add_graph_execution(
|
||||
graph_id=graph_id,
|
||||
user_id=auth.user_id,
|
||||
inputs=node_input,
|
||||
graph_version=graph_version,
|
||||
organization_id=org_id,
|
||||
team_id=team_id,
|
||||
)
|
||||
return {"id": graph_exec.id}
|
||||
except Exception as e:
|
||||
|
||||
@@ -723,7 +723,6 @@ async def stream_chat_post(
|
||||
session_id: str,
|
||||
request: StreamChatRequest,
|
||||
user_id: str = Security(auth.get_user_id),
|
||||
ctx: auth.RequestContext = Security(auth.get_request_context),
|
||||
):
|
||||
"""
|
||||
Stream chat responses for a session (POST with context support).
|
||||
@@ -867,8 +866,6 @@ async def stream_chat_post(
|
||||
is_user_message=request.is_user_message,
|
||||
context=request.context,
|
||||
file_ids=sanitized_file_ids,
|
||||
organization_id=ctx.org_id,
|
||||
team_id=ctx.team_id,
|
||||
mode=request.mode,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, List
|
||||
|
||||
import autogpt_libs.auth as autogpt_auth_lib
|
||||
from fastapi import APIRouter, HTTPException, Query, Security, status
|
||||
from prisma.enums import ReviewStatus
|
||||
|
||||
from backend.copilot.constants import (
|
||||
is_copilot_synthetic_id,
|
||||
parse_node_id_from_exec_id,
|
||||
)
|
||||
from backend.data.execution import (
|
||||
ExecutionContext,
|
||||
ExecutionStatus,
|
||||
get_graph_execution_meta,
|
||||
get_node_executions,
|
||||
)
|
||||
from backend.data.graph import get_graph_settings
|
||||
from backend.data.human_review import (
|
||||
create_auto_approval_record,
|
||||
get_pending_reviews_for_execution,
|
||||
get_pending_reviews_for_user,
|
||||
get_reviews_by_node_exec_ids,
|
||||
has_pending_reviews_for_graph_exec,
|
||||
process_all_reviews_for_execution,
|
||||
)
|
||||
from backend.data.model import USER_TIMEZONE_NOT_SET
|
||||
from backend.data.user import get_user_by_id
|
||||
from backend.data.workspace import get_or_create_workspace
|
||||
from backend.executor.utils import add_graph_execution
|
||||
|
||||
from .model import PendingHumanReviewModel, ReviewRequest, ReviewResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
tags=["v2", "executions", "review"],
|
||||
dependencies=[Security(autogpt_auth_lib.requires_user)],
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_node_ids(
|
||||
node_exec_ids: list[str],
|
||||
graph_exec_id: str,
|
||||
is_copilot: bool,
|
||||
) -> dict[str, str]:
|
||||
"""Resolve node_exec_id -> node_id for auto-approval records.
|
||||
|
||||
CoPilot synthetic IDs encode node_id in the format "{node_id}:{random}".
|
||||
Graph executions look up node_id from NodeExecution records.
|
||||
"""
|
||||
if not node_exec_ids:
|
||||
return {}
|
||||
|
||||
if is_copilot:
|
||||
return {neid: parse_node_id_from_exec_id(neid) for neid in node_exec_ids}
|
||||
|
||||
node_execs = await get_node_executions(
|
||||
graph_exec_id=graph_exec_id, include_exec_data=False
|
||||
)
|
||||
node_exec_map = {ne.node_exec_id: ne.node_id for ne in node_execs}
|
||||
|
||||
result = {}
|
||||
for neid in node_exec_ids:
|
||||
if neid in node_exec_map:
|
||||
result[neid] = node_exec_map[neid]
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to resolve node_id for {neid}: Node execution not found."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/pending",
|
||||
summary="Get Pending Reviews",
|
||||
response_model=List[PendingHumanReviewModel],
|
||||
responses={
|
||||
200: {"description": "List of pending reviews"},
|
||||
500: {"description": "Server error", "content": {"application/json": {}}},
|
||||
},
|
||||
)
|
||||
async def list_pending_reviews(
|
||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
||||
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
||||
page_size: int = Query(25, ge=1, le=100, description="Number of reviews per page"),
|
||||
) -> List[PendingHumanReviewModel]:
|
||||
"""Get all pending reviews for the current user.
|
||||
|
||||
Retrieves all reviews with status "WAITING" that belong to the authenticated user.
|
||||
Results are ordered by creation time (newest first).
|
||||
|
||||
Args:
|
||||
user_id: Authenticated user ID from security dependency
|
||||
|
||||
Returns:
|
||||
List of pending review objects with status converted to typed literals
|
||||
|
||||
Raises:
|
||||
HTTPException: If authentication fails or database error occurs
|
||||
|
||||
Note:
|
||||
Reviews with invalid status values are logged as warnings but excluded
|
||||
from results rather than failing the entire request.
|
||||
"""
|
||||
|
||||
return await get_pending_reviews_for_user(user_id, page, page_size)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/execution/{graph_exec_id}",
|
||||
summary="Get Pending Reviews for Execution",
|
||||
response_model=List[PendingHumanReviewModel],
|
||||
responses={
|
||||
200: {"description": "List of pending reviews for the execution"},
|
||||
404: {"description": "Graph execution not found"},
|
||||
500: {"description": "Server error", "content": {"application/json": {}}},
|
||||
},
|
||||
)
|
||||
async def list_pending_reviews_for_execution(
|
||||
graph_exec_id: str,
|
||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
||||
) -> List[PendingHumanReviewModel]:
|
||||
"""Get all pending reviews for a specific graph execution.
|
||||
|
||||
Retrieves all reviews with status "WAITING" for the specified graph execution
|
||||
that belong to the authenticated user. Results are ordered by creation time
|
||||
(oldest first) to preserve review order within the execution.
|
||||
|
||||
Args:
|
||||
graph_exec_id: ID of the graph execution to get reviews for
|
||||
user_id: Authenticated user ID from security dependency
|
||||
|
||||
Returns:
|
||||
List of pending review objects for the specified execution
|
||||
|
||||
Raises:
|
||||
HTTPException:
|
||||
- 404: If the graph execution doesn't exist or isn't owned by this user
|
||||
- 500: If authentication fails or database error occurs
|
||||
|
||||
Note:
|
||||
Only returns reviews owned by the authenticated user for security.
|
||||
Reviews with invalid status are excluded with warning logs.
|
||||
"""
|
||||
|
||||
# Verify user owns the graph execution before returning reviews
|
||||
# (CoPilot synthetic IDs don't have graph execution records)
|
||||
if not is_copilot_synthetic_id(graph_exec_id):
|
||||
graph_exec = await get_graph_execution_meta(
|
||||
user_id=user_id, execution_id=graph_exec_id
|
||||
)
|
||||
if not graph_exec:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Graph execution #{graph_exec_id} not found",
|
||||
)
|
||||
|
||||
return await get_pending_reviews_for_execution(graph_exec_id, user_id)
|
||||
|
||||
|
||||
@router.post("/action", response_model=ReviewResponse)
|
||||
async def process_review_action(
|
||||
request: ReviewRequest,
|
||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
||||
) -> ReviewResponse:
|
||||
"""Process reviews with approve or reject actions."""
|
||||
|
||||
# Collect all node exec IDs from the request
|
||||
all_request_node_ids = {review.node_exec_id for review in request.reviews}
|
||||
|
||||
if not all_request_node_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="At least one review must be provided",
|
||||
)
|
||||
|
||||
# Batch fetch all requested reviews (regardless of status for idempotent handling)
|
||||
reviews_map = await get_reviews_by_node_exec_ids(
|
||||
list(all_request_node_ids), user_id
|
||||
)
|
||||
|
||||
# Validate all reviews were found (must exist, any status is OK for now)
|
||||
missing_ids = all_request_node_ids - set(reviews_map.keys())
|
||||
if missing_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Review(s) not found: {', '.join(missing_ids)}",
|
||||
)
|
||||
|
||||
# Validate all reviews belong to the same execution
|
||||
graph_exec_ids = {review.graph_exec_id for review in reviews_map.values()}
|
||||
if len(graph_exec_ids) > 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="All reviews in a single request must belong to the same execution.",
|
||||
)
|
||||
|
||||
graph_exec_id = next(iter(graph_exec_ids))
|
||||
is_copilot = is_copilot_synthetic_id(graph_exec_id)
|
||||
|
||||
# Validate execution status for graph executions (skip for CoPilot synthetic IDs)
|
||||
if not is_copilot:
|
||||
graph_exec_meta = await get_graph_execution_meta(
|
||||
user_id=user_id, execution_id=graph_exec_id
|
||||
)
|
||||
if not graph_exec_meta:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Graph execution #{graph_exec_id} not found",
|
||||
)
|
||||
if graph_exec_meta.status not in (
|
||||
ExecutionStatus.REVIEW,
|
||||
ExecutionStatus.INCOMPLETE,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Cannot process reviews while execution status is {graph_exec_meta.status}",
|
||||
)
|
||||
|
||||
# Build review decisions map and track which reviews requested auto-approval
|
||||
# Auto-approved reviews use original data (no modifications allowed)
|
||||
review_decisions = {}
|
||||
auto_approve_requests = {} # Map node_exec_id -> auto_approve_future flag
|
||||
|
||||
for review in request.reviews:
|
||||
review_status = (
|
||||
ReviewStatus.APPROVED if review.approved else ReviewStatus.REJECTED
|
||||
)
|
||||
# If this review requested auto-approval, don't allow data modifications
|
||||
reviewed_data = None if review.auto_approve_future else review.reviewed_data
|
||||
review_decisions[review.node_exec_id] = (
|
||||
review_status,
|
||||
reviewed_data,
|
||||
review.message,
|
||||
)
|
||||
auto_approve_requests[review.node_exec_id] = review.auto_approve_future
|
||||
|
||||
# Process all reviews
|
||||
updated_reviews = await process_all_reviews_for_execution(
|
||||
user_id=user_id,
|
||||
review_decisions=review_decisions,
|
||||
)
|
||||
|
||||
# Create auto-approval records for approved reviews that requested it
|
||||
# Deduplicate by node_id to avoid race conditions when multiple reviews
|
||||
# for the same node are processed in parallel
|
||||
async def create_auto_approval_for_node(
|
||||
node_id: str, review_result
|
||||
) -> tuple[str, bool]:
|
||||
"""
|
||||
Create auto-approval record for a node.
|
||||
Returns (node_id, success) tuple for tracking failures.
|
||||
"""
|
||||
try:
|
||||
await create_auto_approval_record(
|
||||
user_id=user_id,
|
||||
graph_exec_id=review_result.graph_exec_id,
|
||||
graph_id=review_result.graph_id,
|
||||
graph_version=review_result.graph_version,
|
||||
node_id=node_id,
|
||||
payload=review_result.payload,
|
||||
)
|
||||
return (node_id, True)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to create auto-approval record for node {node_id}",
|
||||
exc_info=e,
|
||||
)
|
||||
return (node_id, False)
|
||||
|
||||
# Collect node_exec_ids that need auto-approval and resolve their node_ids
|
||||
node_exec_ids_needing_auto_approval = [
|
||||
node_exec_id
|
||||
for node_exec_id, review_result in updated_reviews.items()
|
||||
if review_result.status == ReviewStatus.APPROVED
|
||||
and auto_approve_requests.get(node_exec_id, False)
|
||||
]
|
||||
|
||||
node_id_map = await _resolve_node_ids(
|
||||
node_exec_ids_needing_auto_approval, graph_exec_id, is_copilot
|
||||
)
|
||||
|
||||
# Deduplicate by node_id — one auto-approval per node
|
||||
nodes_needing_auto_approval: dict[str, Any] = {}
|
||||
for node_exec_id in node_exec_ids_needing_auto_approval:
|
||||
node_id = node_id_map.get(node_exec_id)
|
||||
if node_id and node_id not in nodes_needing_auto_approval:
|
||||
nodes_needing_auto_approval[node_id] = updated_reviews[node_exec_id]
|
||||
|
||||
# Execute all auto-approval creations in parallel (deduplicated by node_id)
|
||||
auto_approval_results = await asyncio.gather(
|
||||
*[
|
||||
create_auto_approval_for_node(node_id, review_result)
|
||||
for node_id, review_result in nodes_needing_auto_approval.items()
|
||||
],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# Count auto-approval failures
|
||||
auto_approval_failed_count = 0
|
||||
for result in auto_approval_results:
|
||||
if isinstance(result, Exception):
|
||||
auto_approval_failed_count += 1
|
||||
logger.error(
|
||||
f"Unexpected exception during auto-approval creation: {result}"
|
||||
)
|
||||
elif isinstance(result, tuple) and len(result) == 2 and not result[1]:
|
||||
auto_approval_failed_count += 1
|
||||
|
||||
# Count results
|
||||
approved_count = sum(
|
||||
1
|
||||
for review in updated_reviews.values()
|
||||
if review.status == ReviewStatus.APPROVED
|
||||
)
|
||||
rejected_count = sum(
|
||||
1
|
||||
for review in updated_reviews.values()
|
||||
if review.status == ReviewStatus.REJECTED
|
||||
)
|
||||
|
||||
# Resume graph execution only for real graph executions (not CoPilot)
|
||||
# CoPilot sessions are resumed by the LLM retrying run_block with review_id
|
||||
if not is_copilot and updated_reviews:
|
||||
still_has_pending = await has_pending_reviews_for_graph_exec(graph_exec_id)
|
||||
|
||||
if not still_has_pending:
|
||||
first_review = next(iter(updated_reviews.values()))
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(user_id)
|
||||
settings = await get_graph_settings(
|
||||
user_id=user_id, graph_id=first_review.graph_id
|
||||
)
|
||||
|
||||
user_timezone = (
|
||||
user.timezone if user.timezone != USER_TIMEZONE_NOT_SET else "UTC"
|
||||
)
|
||||
|
||||
workspace = await get_or_create_workspace(user_id)
|
||||
|
||||
execution_context = ExecutionContext(
|
||||
human_in_the_loop_safe_mode=settings.human_in_the_loop_safe_mode,
|
||||
sensitive_action_safe_mode=settings.sensitive_action_safe_mode,
|
||||
user_timezone=user_timezone,
|
||||
workspace_id=workspace.id,
|
||||
)
|
||||
|
||||
await add_graph_execution(
|
||||
graph_id=first_review.graph_id,
|
||||
user_id=user_id,
|
||||
graph_exec_id=graph_exec_id,
|
||||
execution_context=execution_context,
|
||||
)
|
||||
logger.info(f"Resumed execution {graph_exec_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to resume execution {graph_exec_id}: {str(e)}")
|
||||
|
||||
# Build error message if auto-approvals failed
|
||||
error_message = None
|
||||
if auto_approval_failed_count > 0:
|
||||
error_message = (
|
||||
f"{auto_approval_failed_count} auto-approval setting(s) could not be saved. "
|
||||
f"You may need to manually approve these reviews in future executions."
|
||||
)
|
||||
|
||||
return ReviewResponse(
|
||||
approved_count=approved_count,
|
||||
rejected_count=rejected_count,
|
||||
failed_count=auto_approval_failed_count,
|
||||
error=error_message,
|
||||
)
|
||||
|
||||
@@ -489,16 +489,11 @@ 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_team
|
||||
|
||||
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,
|
||||
team_id=ws_id,
|
||||
)
|
||||
except GraphNotInLibraryError as e:
|
||||
logger.warning(
|
||||
@@ -555,9 +550,6 @@ 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_team
|
||||
|
||||
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,
|
||||
@@ -565,8 +557,6 @@ async def _execute_webhook_preset_trigger(
|
||||
graph_version=preset.graph_version,
|
||||
graph_credentials_inputs=preset.credentials,
|
||||
nodes_input_masks={trigger_node.id: {**preset.inputs, "payload": payload}},
|
||||
organization_id=org_id,
|
||||
team_id=ws_id,
|
||||
)
|
||||
except GraphNotInLibraryError as e:
|
||||
logger.warning(
|
||||
|
||||
@@ -24,7 +24,6 @@ async def test_get_library_agents(mocker):
|
||||
userId="test-user",
|
||||
isActive=True,
|
||||
createdAt=datetime.now(),
|
||||
visibility=prisma.enums.ResourceVisibility.PRIVATE,
|
||||
)
|
||||
]
|
||||
|
||||
@@ -50,7 +49,6 @@ async def test_get_library_agents(mocker):
|
||||
userId="other-user",
|
||||
isActive=True,
|
||||
createdAt=datetime.now(),
|
||||
visibility=prisma.enums.ResourceVisibility.PRIVATE,
|
||||
),
|
||||
)
|
||||
]
|
||||
@@ -115,7 +113,6 @@ async def test_add_agent_to_library(mocker):
|
||||
userId="creator",
|
||||
isActive=True,
|
||||
createdAt=datetime.now(),
|
||||
visibility=prisma.enums.ResourceVisibility.PRIVATE,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import datetime
|
||||
|
||||
import prisma.enums
|
||||
import prisma.models
|
||||
import pytest
|
||||
|
||||
@@ -21,7 +20,6 @@ async def test_agent_preset_from_db(test_user_id: str):
|
||||
isActive=True,
|
||||
userId=test_user_id,
|
||||
isDeleted=False,
|
||||
visibility=prisma.enums.ResourceVisibility.PRIVATE,
|
||||
InputPresets=[
|
||||
prisma.models.AgentNodeExecutionInputOutput.model_validate(
|
||||
{
|
||||
|
||||
@@ -371,9 +371,6 @@ async def delete_preset(
|
||||
async def execute_preset(
|
||||
preset_id: str,
|
||||
user_id: str = Security(autogpt_auth_lib.get_user_id),
|
||||
ctx: autogpt_auth_lib.RequestContext = Security(
|
||||
autogpt_auth_lib.get_request_context
|
||||
),
|
||||
inputs: dict[str, Any] = Body(..., embed=True, default_factory=dict),
|
||||
credential_inputs: dict[str, CredentialsMetaInput] = Body(
|
||||
..., embed=True, default_factory=dict
|
||||
@@ -412,6 +409,4 @@ async def execute_preset(
|
||||
preset_id=preset_id,
|
||||
inputs=merged_node_input,
|
||||
graph_credentials_inputs=merged_credential_inputs,
|
||||
organization_id=ctx.org_id,
|
||||
team_id=ctx.team_id,
|
||||
)
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Override session-scoped fixtures for org tests.
|
||||
|
||||
Org tests mock at the Prisma boundary and don't need the full test server
|
||||
or its graph cleanup hook.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def server():
|
||||
"""No-op — org tests don't need the full backend server."""
|
||||
yield None
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def graph_cleanup():
|
||||
"""No-op — org tests don't create real graphs."""
|
||||
yield
|
||||
@@ -1,573 +0,0 @@
|
||||
"""Database operations for organization management."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import prisma.errors
|
||||
|
||||
from backend.data.db import prisma
|
||||
from backend.data.org_migration import _resolve_unique_slug, _sanitize_slug
|
||||
from backend.util.exceptions import NotFoundError
|
||||
|
||||
from .model import OrgAliasResponse, OrgMemberResponse, OrgResponse, UpdateOrgData
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Utilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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, 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(
|
||||
where={
|
||||
"userId": user_id,
|
||||
"isOwner": True,
|
||||
"Org": {"isPersonal": True, "deletedAt": None},
|
||||
},
|
||||
)
|
||||
if member is None:
|
||||
logger.warning(
|
||||
f"User {user_id} has no personal org — account may be in inconsistent state"
|
||||
)
|
||||
return None, None
|
||||
|
||||
org_id = member.orgId
|
||||
workspace = await prisma.team.find_first(
|
||||
where={"orgId": org_id, "isDefault": True}
|
||||
)
|
||||
ws_id = workspace.id if workspace else None
|
||||
return org_id, ws_id
|
||||
|
||||
|
||||
async def _create_personal_org_for_user(
|
||||
user_id: str,
|
||||
slug_base: str,
|
||||
display_name: str,
|
||||
) -> OrgResponse:
|
||||
"""Create a new personal org with all required records.
|
||||
|
||||
Used by both initial org creation (migration) and conversion (spawning
|
||||
a new personal org when the old one becomes a team org).
|
||||
"""
|
||||
slug = await _resolve_unique_slug(slug_base)
|
||||
|
||||
org = await prisma.organization.create(
|
||||
data={
|
||||
"name": display_name,
|
||||
"slug": slug,
|
||||
"isPersonal": True,
|
||||
"bootstrapUserId": user_id,
|
||||
"settings": "{}",
|
||||
}
|
||||
)
|
||||
|
||||
await prisma.orgmember.create(
|
||||
data={
|
||||
"orgId": org.id,
|
||||
"userId": user_id,
|
||||
"isOwner": True,
|
||||
"isAdmin": True,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
)
|
||||
|
||||
workspace = await prisma.team.create(
|
||||
data={
|
||||
"name": "Default",
|
||||
"orgId": org.id,
|
||||
"isDefault": True,
|
||||
"joinPolicy": "OPEN",
|
||||
"createdByUserId": user_id,
|
||||
}
|
||||
)
|
||||
|
||||
await prisma.teammember.create(
|
||||
data={
|
||||
"teamId": workspace.id,
|
||||
"userId": user_id,
|
||||
"isAdmin": True,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
)
|
||||
|
||||
await prisma.organizationprofile.create(
|
||||
data={
|
||||
"organizationId": org.id,
|
||||
"username": slug,
|
||||
"displayName": display_name,
|
||||
}
|
||||
)
|
||||
|
||||
await prisma.organizationseatassignment.create(
|
||||
data={
|
||||
"organizationId": org.id,
|
||||
"userId": user_id,
|
||||
"seatType": "FREE",
|
||||
"status": "ACTIVE",
|
||||
"assignedByUserId": user_id,
|
||||
}
|
||||
)
|
||||
|
||||
# Create zero-balance row so credit operations don't need upsert
|
||||
await prisma.orgbalance.create(data={"orgId": org.id, "balance": 0})
|
||||
|
||||
return OrgResponse.from_db(org, member_count=1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Org CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def create_org(
|
||||
name: str,
|
||||
slug: str,
|
||||
user_id: str,
|
||||
description: str | None = None,
|
||||
) -> OrgResponse:
|
||||
"""Create a team organization and make the user the owner.
|
||||
|
||||
Raises:
|
||||
ValueError: If the slug is already taken by another org or alias.
|
||||
"""
|
||||
existing_org = await prisma.organization.find_unique(where={"slug": slug})
|
||||
if existing_org:
|
||||
raise ValueError(f"Slug '{slug}' is already in use")
|
||||
existing_alias = await prisma.organizationalias.find_unique(
|
||||
where={"aliasSlug": slug}
|
||||
)
|
||||
if existing_alias:
|
||||
raise ValueError(f"Slug '{slug}' is already in use as an alias")
|
||||
|
||||
org = await prisma.organization.create(
|
||||
data={
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"description": description,
|
||||
"isPersonal": False,
|
||||
"bootstrapUserId": user_id,
|
||||
"settings": "{}",
|
||||
}
|
||||
)
|
||||
|
||||
await prisma.orgmember.create(
|
||||
data={
|
||||
"orgId": org.id,
|
||||
"userId": user_id,
|
||||
"isOwner": True,
|
||||
"isAdmin": True,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
)
|
||||
|
||||
workspace = await prisma.team.create(
|
||||
data={
|
||||
"name": "Default",
|
||||
"orgId": org.id,
|
||||
"isDefault": True,
|
||||
"joinPolicy": "OPEN",
|
||||
"createdByUserId": user_id,
|
||||
}
|
||||
)
|
||||
|
||||
await prisma.teammember.create(
|
||||
data={
|
||||
"teamId": workspace.id,
|
||||
"userId": user_id,
|
||||
"isAdmin": True,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
)
|
||||
|
||||
await prisma.organizationprofile.create(
|
||||
data={
|
||||
"organizationId": org.id,
|
||||
"username": slug,
|
||||
"displayName": name,
|
||||
}
|
||||
)
|
||||
|
||||
await prisma.organizationseatassignment.create(
|
||||
data={
|
||||
"organizationId": org.id,
|
||||
"userId": user_id,
|
||||
"seatType": "FREE",
|
||||
"status": "ACTIVE",
|
||||
"assignedByUserId": user_id,
|
||||
}
|
||||
)
|
||||
|
||||
# Create zero-balance row so credit operations don't need upsert
|
||||
await prisma.orgbalance.create(data={"orgId": org.id, "balance": 0})
|
||||
|
||||
return OrgResponse.from_db(org, member_count=1)
|
||||
|
||||
|
||||
async def list_user_orgs(user_id: str) -> list[OrgResponse]:
|
||||
"""List all non-deleted organizations the user belongs to."""
|
||||
memberships = await prisma.orgmember.find_many(
|
||||
where={
|
||||
"userId": user_id,
|
||||
"status": "ACTIVE",
|
||||
"Org": {"deletedAt": None},
|
||||
},
|
||||
include={"Org": True},
|
||||
)
|
||||
results = []
|
||||
for m in memberships:
|
||||
org = m.Org
|
||||
if org is None:
|
||||
continue
|
||||
results.append(OrgResponse.from_db(org))
|
||||
return results
|
||||
|
||||
|
||||
async def get_org(org_id: str) -> OrgResponse:
|
||||
"""Get organization details."""
|
||||
org = await prisma.organization.find_unique(where={"id": org_id})
|
||||
if org is None or org.deletedAt is not None:
|
||||
raise NotFoundError(f"Organization {org_id} not found")
|
||||
return OrgResponse.from_db(org)
|
||||
|
||||
|
||||
async def update_org(org_id: str, data: UpdateOrgData) -> OrgResponse:
|
||||
"""Update organization fields. Creates a RENAME alias if slug changes.
|
||||
|
||||
Only accepts the structured UpdateOrgData model — no arbitrary dict keys.
|
||||
"""
|
||||
update_dict: dict = {}
|
||||
if data.name is not None:
|
||||
update_dict["name"] = data.name
|
||||
if data.description is not None:
|
||||
update_dict["description"] = data.description
|
||||
if data.avatar_url is not None:
|
||||
update_dict["avatarUrl"] = data.avatar_url
|
||||
|
||||
if data.slug is not None:
|
||||
existing = await prisma.organization.find_unique(where={"slug": data.slug})
|
||||
if existing and existing.id != org_id:
|
||||
raise ValueError(f"Slug '{data.slug}' is already in use")
|
||||
existing_alias = await prisma.organizationalias.find_unique(
|
||||
where={"aliasSlug": data.slug}
|
||||
)
|
||||
if existing_alias:
|
||||
raise ValueError(f"Slug '{data.slug}' is already in use as an alias")
|
||||
|
||||
old_org = await prisma.organization.find_unique(where={"id": org_id})
|
||||
if old_org and old_org.slug != data.slug:
|
||||
await prisma.organizationalias.create(
|
||||
data={
|
||||
"organizationId": org_id,
|
||||
"aliasSlug": old_org.slug,
|
||||
"aliasType": "RENAME",
|
||||
}
|
||||
)
|
||||
update_dict["slug"] = data.slug
|
||||
|
||||
if not update_dict:
|
||||
return await get_org(org_id)
|
||||
|
||||
await prisma.organization.update(where={"id": org_id}, data=update_dict)
|
||||
|
||||
# Sync OrganizationProfile when name or slug changes
|
||||
profile_update: dict = {}
|
||||
if data.name is not None:
|
||||
profile_update["displayName"] = data.name
|
||||
if data.slug is not None:
|
||||
profile_update["username"] = data.slug
|
||||
if profile_update:
|
||||
await prisma.organizationprofile.update(
|
||||
where={"organizationId": org_id},
|
||||
data=profile_update,
|
||||
)
|
||||
|
||||
return await get_org(org_id)
|
||||
|
||||
|
||||
async def delete_org(org_id: str) -> None:
|
||||
"""Soft-delete an organization. Cannot delete personal orgs.
|
||||
|
||||
Sets deletedAt instead of hard-deleting to preserve financial records.
|
||||
"""
|
||||
org = await prisma.organization.find_unique(where={"id": org_id})
|
||||
if org is None:
|
||||
raise NotFoundError(f"Organization {org_id} not found")
|
||||
if org.isPersonal:
|
||||
raise ValueError("Cannot delete a personal organization. Convert it first.")
|
||||
if org.deletedAt is not None:
|
||||
raise ValueError("Organization is already deleted")
|
||||
|
||||
await prisma.organization.update(
|
||||
where={"id": org_id},
|
||||
data={"deletedAt": datetime.now(timezone.utc)},
|
||||
)
|
||||
|
||||
|
||||
async def convert_personal_org(org_id: str, user_id: str) -> OrgResponse:
|
||||
"""Convert a personal org to a team org.
|
||||
|
||||
Creates a new personal org for the user so they always have one.
|
||||
Existing resources (agents, credits, store listings) stay in the
|
||||
team org — that's the point of converting.
|
||||
|
||||
If new personal org creation fails, the conversion is rolled back.
|
||||
"""
|
||||
org = await prisma.organization.find_unique(where={"id": org_id})
|
||||
if org is None:
|
||||
raise NotFoundError(f"Organization {org_id} not found")
|
||||
if not org.isPersonal:
|
||||
raise ValueError("Organization is already a team org")
|
||||
|
||||
# Step 1: Flip isPersonal on the old org
|
||||
await prisma.organization.update(
|
||||
where={"id": org_id},
|
||||
data={"isPersonal": False},
|
||||
)
|
||||
|
||||
# Step 2: Create a new personal org for the user
|
||||
try:
|
||||
slug_base = f"{_sanitize_slug(org.slug)}-personal-1"
|
||||
# Fetch user name for display
|
||||
user = await prisma.user.find_unique(where={"id": user_id})
|
||||
display_name = user.name if user and user.name else org.name
|
||||
|
||||
await _create_personal_org_for_user(
|
||||
user_id=user_id,
|
||||
slug_base=slug_base,
|
||||
display_name=display_name,
|
||||
)
|
||||
except Exception:
|
||||
# Roll back: restore isPersonal on the old org
|
||||
logger.exception(
|
||||
f"Failed to create new personal org for user {user_id} during "
|
||||
f"conversion of org {org_id} — rolling back"
|
||||
)
|
||||
await prisma.organization.update(
|
||||
where={"id": org_id},
|
||||
data={"isPersonal": True},
|
||||
)
|
||||
raise
|
||||
|
||||
return await get_org(org_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Members
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def list_org_members(org_id: str) -> list[OrgMemberResponse]:
|
||||
"""List all active members of an organization."""
|
||||
members = await prisma.orgmember.find_many(
|
||||
where={"orgId": org_id, "status": "ACTIVE"},
|
||||
include={"User": True},
|
||||
)
|
||||
return [OrgMemberResponse.from_db(m) for m in members]
|
||||
|
||||
|
||||
async def add_org_member(
|
||||
org_id: str,
|
||||
user_id: str,
|
||||
is_admin: bool = False,
|
||||
is_billing_manager: bool = False,
|
||||
invited_by: str | None = None,
|
||||
) -> OrgMemberResponse:
|
||||
"""Add a member to an organization and its default workspace."""
|
||||
member = await prisma.orgmember.create(
|
||||
data={
|
||||
"orgId": org_id,
|
||||
"userId": user_id,
|
||||
"isAdmin": is_admin,
|
||||
"isBillingManager": is_billing_manager,
|
||||
"status": "ACTIVE",
|
||||
"invitedByUserId": invited_by,
|
||||
},
|
||||
include={"User": True},
|
||||
)
|
||||
|
||||
default_ws = await prisma.team.find_first(
|
||||
where={"orgId": org_id, "isDefault": True}
|
||||
)
|
||||
if default_ws:
|
||||
await prisma.teammember.create(
|
||||
data={
|
||||
"teamId": default_ws.id,
|
||||
"userId": user_id,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
)
|
||||
|
||||
return OrgMemberResponse.from_db(member)
|
||||
|
||||
|
||||
async def update_org_member(
|
||||
org_id: str, user_id: str, is_admin: bool | None, is_billing_manager: bool | None
|
||||
) -> OrgMemberResponse:
|
||||
"""Update a member's role flags."""
|
||||
member = await prisma.orgmember.find_unique(
|
||||
where={"orgId_userId": {"orgId": org_id, "userId": user_id}}
|
||||
)
|
||||
if member is None:
|
||||
raise NotFoundError(f"Member {user_id} not found in org {org_id}")
|
||||
if member.isOwner:
|
||||
raise ValueError(
|
||||
"Cannot change the owner's role flags directly. Use transfer-ownership."
|
||||
)
|
||||
|
||||
update_data: dict = {}
|
||||
if is_admin is not None:
|
||||
update_data["isAdmin"] = is_admin
|
||||
if is_billing_manager is not None:
|
||||
update_data["isBillingManager"] = is_billing_manager
|
||||
|
||||
if update_data:
|
||||
await prisma.orgmember.update(
|
||||
where={"orgId_userId": {"orgId": org_id, "userId": user_id}},
|
||||
data=update_data,
|
||||
)
|
||||
members = await list_org_members(org_id)
|
||||
match = next((m for m in members if m.user_id == user_id), None)
|
||||
if match is None:
|
||||
raise NotFoundError(f"Member {user_id} not found in org {org_id} after update")
|
||||
return match
|
||||
|
||||
|
||||
async def remove_org_member(org_id: str, user_id: str, requesting_user_id: str) -> None:
|
||||
"""Remove a member from an organization and all its workspaces.
|
||||
|
||||
Guards:
|
||||
- Cannot remove the org owner (transfer ownership first)
|
||||
- Cannot remove yourself (use leave flow instead)
|
||||
- Cannot remove a user who has active schedules (transfer/cancel first)
|
||||
- Cannot remove a user who would become org-less (no other org memberships)
|
||||
"""
|
||||
member = await prisma.orgmember.find_unique(
|
||||
where={"orgId_userId": {"orgId": org_id, "userId": user_id}}
|
||||
)
|
||||
if member is None:
|
||||
raise NotFoundError(f"Member {user_id} not found in org {org_id}")
|
||||
if member.isOwner:
|
||||
raise ValueError("Cannot remove the org owner. Transfer ownership first.")
|
||||
if user_id == requesting_user_id:
|
||||
raise ValueError(
|
||||
"Cannot remove yourself from an organization. "
|
||||
"Ask another admin to remove you, or transfer ownership first."
|
||||
)
|
||||
|
||||
# Check if user would become org-less
|
||||
other_memberships = await prisma.orgmember.count(
|
||||
where={
|
||||
"userId": user_id,
|
||||
"status": "ACTIVE",
|
||||
"orgId": {"not": org_id},
|
||||
"Org": {"deletedAt": None},
|
||||
}
|
||||
)
|
||||
if other_memberships == 0:
|
||||
raise ValueError(
|
||||
"Cannot remove this member — they have no other organization memberships "
|
||||
"and would be locked out. They must join or create another org first."
|
||||
)
|
||||
|
||||
# Check for active schedules
|
||||
# TODO: Check APScheduler for active schedules owned by this user in this org
|
||||
# For now, this is a placeholder for the schedule transfer requirement
|
||||
|
||||
# Remove from all workspaces in this org
|
||||
workspaces = await prisma.team.find_many(where={"orgId": org_id})
|
||||
for ws in workspaces:
|
||||
await prisma.teammember.delete_many(
|
||||
where={"teamId": ws.id, "userId": user_id}
|
||||
)
|
||||
|
||||
# Remove org membership
|
||||
await prisma.orgmember.delete(
|
||||
where={"orgId_userId": {"orgId": org_id, "userId": user_id}}
|
||||
)
|
||||
|
||||
|
||||
async def transfer_ownership(
|
||||
org_id: str, current_owner_id: str, new_owner_id: str
|
||||
) -> None:
|
||||
"""Transfer org ownership atomically. Both updates happen in one statement."""
|
||||
if current_owner_id == new_owner_id:
|
||||
raise ValueError("Cannot transfer ownership to the same user")
|
||||
|
||||
current = await prisma.orgmember.find_unique(
|
||||
where={"orgId_userId": {"orgId": org_id, "userId": current_owner_id}}
|
||||
)
|
||||
if current is None or not current.isOwner:
|
||||
raise ValueError("Current user is not the org owner")
|
||||
|
||||
new = await prisma.orgmember.find_unique(
|
||||
where={"orgId_userId": {"orgId": org_id, "userId": new_owner_id}}
|
||||
)
|
||||
if new is None:
|
||||
raise NotFoundError(f"User {new_owner_id} is not a member of org {org_id}")
|
||||
|
||||
await prisma.execute_raw(
|
||||
"""
|
||||
UPDATE "OrgMember"
|
||||
SET "isOwner" = CASE
|
||||
WHEN "userId" = $1 THEN false
|
||||
WHEN "userId" = $2 THEN true
|
||||
ELSE "isOwner"
|
||||
END,
|
||||
"isAdmin" = CASE
|
||||
WHEN "userId" = $2 THEN true
|
||||
ELSE "isAdmin"
|
||||
END,
|
||||
"updatedAt" = NOW()
|
||||
WHERE "orgId" = $3 AND "userId" IN ($1, $2)
|
||||
""",
|
||||
current_owner_id,
|
||||
new_owner_id,
|
||||
org_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aliases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def list_org_aliases(org_id: str) -> list[OrgAliasResponse]:
|
||||
"""List all aliases for an organization."""
|
||||
aliases = await prisma.organizationalias.find_many(
|
||||
where={"organizationId": org_id, "removedAt": None}
|
||||
)
|
||||
return [OrgAliasResponse.from_db(a) for a in aliases]
|
||||
|
||||
|
||||
async def create_org_alias(
|
||||
org_id: str, alias_slug: str, user_id: str
|
||||
) -> OrgAliasResponse:
|
||||
"""Create a new alias for an organization."""
|
||||
existing_org = await prisma.organization.find_unique(where={"slug": alias_slug})
|
||||
if existing_org:
|
||||
raise ValueError(f"Slug '{alias_slug}' is already used by an organization")
|
||||
|
||||
existing_alias = await prisma.organizationalias.find_unique(
|
||||
where={"aliasSlug": alias_slug}
|
||||
)
|
||||
if existing_alias:
|
||||
raise ValueError(f"Slug '{alias_slug}' is already used as an alias")
|
||||
|
||||
alias = await prisma.organizationalias.create(
|
||||
data={
|
||||
"organizationId": org_id,
|
||||
"aliasSlug": alias_slug,
|
||||
"aliasType": "MANUAL",
|
||||
"createdByUserId": user_id,
|
||||
}
|
||||
)
|
||||
return OrgAliasResponse.from_db(alias)
|
||||
@@ -1,247 +0,0 @@
|
||||
"""Invitation API routes for organization membership."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from autogpt_libs.auth import get_user_id, requires_org_permission, requires_user
|
||||
from autogpt_libs.auth.models import RequestContext
|
||||
from autogpt_libs.auth.permissions import OrgAction
|
||||
from fastapi import APIRouter, HTTPException, Security
|
||||
from prisma.errors import UniqueViolationError
|
||||
|
||||
from backend.data.db import prisma
|
||||
from backend.util.exceptions import NotFoundError
|
||||
|
||||
from . import db as org_db
|
||||
from .model import CreateInvitationRequest, InvitationResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
INVITATION_TTL_DAYS = 7
|
||||
|
||||
|
||||
def _verify_org_path(ctx: RequestContext, org_id: str) -> None:
|
||||
"""Ensure the authenticated user's active org matches the path parameter."""
|
||||
if ctx.org_id != org_id:
|
||||
raise HTTPException(403, detail="Not a member of this organization")
|
||||
|
||||
|
||||
# --- Org-scoped invitation endpoints (under /api/orgs/{org_id}/invitations) ---
|
||||
|
||||
org_router = APIRouter()
|
||||
|
||||
|
||||
@org_router.post(
|
||||
"",
|
||||
summary="Create invitation",
|
||||
tags=["orgs", "invitations"],
|
||||
)
|
||||
async def create_invitation(
|
||||
org_id: str,
|
||||
request: CreateInvitationRequest,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.MANAGE_MEMBERS)),
|
||||
],
|
||||
) -> InvitationResponse:
|
||||
_verify_org_path(ctx, org_id)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=INVITATION_TTL_DAYS)
|
||||
|
||||
invitation = await prisma.orginvitation.create(
|
||||
data={
|
||||
"orgId": org_id,
|
||||
"email": request.email,
|
||||
"isAdmin": request.is_admin,
|
||||
"isBillingManager": request.is_billing_manager,
|
||||
"expiresAt": expires_at,
|
||||
"invitedByUserId": ctx.user_id,
|
||||
"teamIds": request.team_ids,
|
||||
}
|
||||
)
|
||||
|
||||
# TODO: Send email via Postmark with invitation link
|
||||
# link = f"{frontend_base_url}/org/invite/{invitation.token}"
|
||||
|
||||
return InvitationResponse.from_db(invitation)
|
||||
|
||||
|
||||
@org_router.get(
|
||||
"",
|
||||
summary="List pending invitations",
|
||||
tags=["orgs", "invitations"],
|
||||
)
|
||||
async def list_invitations(
|
||||
org_id: str,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.MANAGE_MEMBERS)),
|
||||
],
|
||||
) -> list[InvitationResponse]:
|
||||
_verify_org_path(ctx, org_id)
|
||||
invitations = await prisma.orginvitation.find_many(
|
||||
where={
|
||||
"orgId": org_id,
|
||||
"acceptedAt": None,
|
||||
"revokedAt": None,
|
||||
"expiresAt": {"gt": datetime.now(timezone.utc)},
|
||||
},
|
||||
order={"createdAt": "desc"},
|
||||
)
|
||||
return [InvitationResponse.from_db(inv) for inv in invitations]
|
||||
|
||||
|
||||
@org_router.delete(
|
||||
"/{invitation_id}",
|
||||
summary="Revoke invitation",
|
||||
tags=["orgs", "invitations"],
|
||||
status_code=204,
|
||||
)
|
||||
async def revoke_invitation(
|
||||
org_id: str,
|
||||
invitation_id: str,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.MANAGE_MEMBERS)),
|
||||
],
|
||||
) -> None:
|
||||
_verify_org_path(ctx, org_id)
|
||||
invitation = await prisma.orginvitation.find_unique(where={"id": invitation_id})
|
||||
if invitation is None or invitation.orgId != org_id:
|
||||
raise NotFoundError(f"Invitation {invitation_id} not found")
|
||||
|
||||
await prisma.orginvitation.update(
|
||||
where={"id": invitation_id},
|
||||
data={"revokedAt": datetime.now(timezone.utc)},
|
||||
)
|
||||
|
||||
|
||||
# --- Token-based endpoints (under /api/invitations) ---
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{token}/accept",
|
||||
summary="Accept invitation",
|
||||
tags=["invitations"],
|
||||
dependencies=[Security(requires_user)],
|
||||
)
|
||||
async def accept_invitation(
|
||||
token: str,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
) -> dict:
|
||||
invitation = await prisma.orginvitation.find_unique(where={"token": token})
|
||||
if invitation is None:
|
||||
raise NotFoundError("Invitation not found")
|
||||
if invitation.acceptedAt is not None:
|
||||
raise HTTPException(400, detail="Invitation already accepted")
|
||||
if invitation.revokedAt is not None:
|
||||
raise HTTPException(400, detail="Invitation has been revoked")
|
||||
if invitation.expiresAt < datetime.now(timezone.utc):
|
||||
raise HTTPException(400, detail="Invitation has expired")
|
||||
|
||||
# Verify the accepting user's email matches the invitation
|
||||
accepting_user = await prisma.user.find_unique(where={"id": user_id})
|
||||
if accepting_user is None:
|
||||
raise HTTPException(401, detail="User not found")
|
||||
if accepting_user.email.lower() != invitation.email.lower():
|
||||
raise HTTPException(
|
||||
403,
|
||||
detail="This invitation was sent to a different email address",
|
||||
)
|
||||
|
||||
# Add user to org (idempotent — handles race condition from concurrent accepts)
|
||||
try:
|
||||
await org_db.add_org_member(
|
||||
org_id=invitation.orgId,
|
||||
user_id=user_id,
|
||||
is_admin=invitation.isAdmin,
|
||||
is_billing_manager=invitation.isBillingManager,
|
||||
invited_by=invitation.invitedByUserId,
|
||||
)
|
||||
except UniqueViolationError:
|
||||
# User is already a member — treat as success (idempotent)
|
||||
pass
|
||||
|
||||
# Add to specified workspaces
|
||||
for ws_id in invitation.teamIds:
|
||||
try:
|
||||
from . import team_db as team_db
|
||||
|
||||
await team_db.add_team_member(
|
||||
ws_id=ws_id,
|
||||
user_id=user_id,
|
||||
org_id=invitation.orgId,
|
||||
invited_by=invitation.invitedByUserId,
|
||||
)
|
||||
except Exception:
|
||||
# Non-fatal -- workspace may have been deleted
|
||||
pass
|
||||
|
||||
# Mark invitation as accepted
|
||||
await prisma.orginvitation.update(
|
||||
where={"id": invitation.id},
|
||||
data={"acceptedAt": datetime.now(timezone.utc), "targetUserId": user_id},
|
||||
)
|
||||
|
||||
return {"orgId": invitation.orgId, "message": "Invitation accepted"}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{token}/decline",
|
||||
summary="Decline invitation",
|
||||
tags=["invitations"],
|
||||
dependencies=[Security(requires_user)],
|
||||
status_code=204,
|
||||
)
|
||||
async def decline_invitation(
|
||||
token: str,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
) -> None:
|
||||
invitation = await prisma.orginvitation.find_unique(where={"token": token})
|
||||
if invitation is None:
|
||||
raise NotFoundError("Invitation not found")
|
||||
|
||||
# State checks — same as accept_invitation
|
||||
if invitation.acceptedAt is not None:
|
||||
raise HTTPException(400, detail="Invitation already accepted")
|
||||
if invitation.revokedAt is not None:
|
||||
raise HTTPException(400, detail="Invitation already revoked")
|
||||
if invitation.expiresAt < datetime.now(timezone.utc):
|
||||
raise HTTPException(400, detail="Invitation has expired")
|
||||
|
||||
# Verify the declining user's email matches the invitation
|
||||
declining_user = await prisma.user.find_unique(where={"id": user_id})
|
||||
if declining_user is None:
|
||||
raise HTTPException(401, detail="User not found")
|
||||
if declining_user.email.lower() != invitation.email.lower():
|
||||
raise HTTPException(403, detail="This invitation was sent to a different email address")
|
||||
|
||||
await prisma.orginvitation.update(
|
||||
where={"id": invitation.id},
|
||||
data={"revokedAt": datetime.now(timezone.utc)},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/pending",
|
||||
summary="List pending invitations for current user",
|
||||
tags=["invitations"],
|
||||
dependencies=[Security(requires_user)],
|
||||
)
|
||||
async def list_pending_for_user(
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
) -> list[InvitationResponse]:
|
||||
# Get user's email
|
||||
user = await prisma.user.find_unique(where={"id": user_id})
|
||||
if user is None:
|
||||
return []
|
||||
|
||||
invitations = await prisma.orginvitation.find_many(
|
||||
where={
|
||||
"email": user.email,
|
||||
"acceptedAt": None,
|
||||
"revokedAt": None,
|
||||
"expiresAt": {"gt": datetime.now(timezone.utc)},
|
||||
},
|
||||
order={"createdAt": "desc"},
|
||||
)
|
||||
return [InvitationResponse.from_db(inv) for inv in invitations]
|
||||
@@ -1,148 +0,0 @@
|
||||
"""Pydantic request/response models for organization management."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateOrgRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
slug: str = Field(
|
||||
..., min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$"
|
||||
)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class UpdateOrgRequest(BaseModel):
|
||||
name: str | None = None
|
||||
slug: str | None = Field(None, pattern=r"^[a-z0-9][a-z0-9-]*$")
|
||||
description: str | None = None
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
class UpdateOrgData(BaseModel):
|
||||
"""Structured data object for update_org DB function.
|
||||
|
||||
Only these fields can be updated — no arbitrary dict keys.
|
||||
"""
|
||||
|
||||
name: str | None = None
|
||||
slug: str | None = None
|
||||
description: str | None = None
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
class OrgResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
avatar_url: str | None
|
||||
description: str | None
|
||||
is_personal: bool
|
||||
member_count: int
|
||||
created_at: datetime
|
||||
|
||||
@staticmethod
|
||||
def from_db(org, member_count: int = 0) -> "OrgResponse":
|
||||
return OrgResponse(
|
||||
id=org.id,
|
||||
name=org.name,
|
||||
slug=org.slug,
|
||||
avatar_url=org.avatarUrl,
|
||||
description=org.description,
|
||||
is_personal=org.isPersonal,
|
||||
member_count=member_count,
|
||||
created_at=org.createdAt,
|
||||
)
|
||||
|
||||
|
||||
class OrgMemberResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
email: str
|
||||
name: str | None
|
||||
is_owner: bool
|
||||
is_admin: bool
|
||||
is_billing_manager: bool
|
||||
joined_at: datetime
|
||||
|
||||
@staticmethod
|
||||
def from_db(member) -> "OrgMemberResponse":
|
||||
return OrgMemberResponse(
|
||||
id=member.id,
|
||||
user_id=member.userId,
|
||||
email=member.User.email if member.User else "",
|
||||
name=member.User.name if member.User else None,
|
||||
is_owner=member.isOwner,
|
||||
is_admin=member.isAdmin,
|
||||
is_billing_manager=member.isBillingManager,
|
||||
joined_at=member.joinedAt,
|
||||
)
|
||||
|
||||
|
||||
class AddMemberRequest(BaseModel):
|
||||
user_id: str
|
||||
is_admin: bool = False
|
||||
is_billing_manager: bool = False
|
||||
|
||||
|
||||
class UpdateMemberRequest(BaseModel):
|
||||
is_admin: bool | None = None
|
||||
is_billing_manager: bool | None = None
|
||||
|
||||
|
||||
class TransferOwnershipRequest(BaseModel):
|
||||
new_owner_id: str
|
||||
|
||||
|
||||
class OrgAliasResponse(BaseModel):
|
||||
id: str
|
||||
alias_slug: str
|
||||
alias_type: str
|
||||
created_at: datetime
|
||||
|
||||
@staticmethod
|
||||
def from_db(alias) -> "OrgAliasResponse":
|
||||
return OrgAliasResponse(
|
||||
id=alias.id,
|
||||
alias_slug=alias.aliasSlug,
|
||||
alias_type=alias.aliasType,
|
||||
created_at=alias.createdAt,
|
||||
)
|
||||
|
||||
|
||||
class CreateAliasRequest(BaseModel):
|
||||
alias_slug: str = Field(
|
||||
..., min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$"
|
||||
)
|
||||
|
||||
|
||||
class CreateInvitationRequest(BaseModel):
|
||||
email: str
|
||||
is_admin: bool = False
|
||||
is_billing_manager: bool = False
|
||||
team_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class InvitationResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
is_admin: bool
|
||||
is_billing_manager: bool
|
||||
token: str
|
||||
expires_at: datetime
|
||||
created_at: datetime
|
||||
team_ids: list[str]
|
||||
|
||||
@staticmethod
|
||||
def from_db(inv) -> "InvitationResponse":
|
||||
return InvitationResponse(
|
||||
id=inv.id,
|
||||
email=inv.email,
|
||||
is_admin=inv.isAdmin,
|
||||
is_billing_manager=inv.isBillingManager,
|
||||
token=inv.token,
|
||||
expires_at=inv.expiresAt,
|
||||
created_at=inv.createdAt,
|
||||
team_ids=inv.teamIds,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,273 +0,0 @@
|
||||
"""Organization management API routes."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from autogpt_libs.auth import (
|
||||
get_request_context,
|
||||
get_user_id,
|
||||
requires_org_permission,
|
||||
requires_user,
|
||||
)
|
||||
from autogpt_libs.auth.models import RequestContext
|
||||
from autogpt_libs.auth.permissions import OrgAction
|
||||
from fastapi import APIRouter, HTTPException, Security
|
||||
|
||||
from . import db as org_db
|
||||
from .model import (
|
||||
AddMemberRequest,
|
||||
CreateAliasRequest,
|
||||
CreateOrgRequest,
|
||||
OrgAliasResponse,
|
||||
OrgMemberResponse,
|
||||
OrgResponse,
|
||||
TransferOwnershipRequest,
|
||||
UpdateMemberRequest,
|
||||
UpdateOrgData,
|
||||
UpdateOrgRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _verify_org_path(ctx: RequestContext, org_id: str) -> None:
|
||||
"""Ensure the authenticated user's active org matches the path parameter.
|
||||
|
||||
Prevents authorization bypass where a user sends X-Org-Id for org A
|
||||
but targets org B in the URL path.
|
||||
"""
|
||||
if ctx.org_id != org_id:
|
||||
raise HTTPException(403, detail="Not a member of this organization")
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
summary="Create organization",
|
||||
tags=["orgs"],
|
||||
dependencies=[Security(requires_user)],
|
||||
)
|
||||
async def create_org(
|
||||
request: CreateOrgRequest,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
) -> OrgResponse:
|
||||
return await org_db.create_org(
|
||||
name=request.name,
|
||||
slug=request.slug,
|
||||
user_id=user_id,
|
||||
description=request.description,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
summary="List user organizations",
|
||||
tags=["orgs"],
|
||||
dependencies=[Security(requires_user)],
|
||||
)
|
||||
async def list_orgs(
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
) -> list[OrgResponse]:
|
||||
return await org_db.list_user_orgs(user_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{org_id}",
|
||||
summary="Get organization details",
|
||||
tags=["orgs"],
|
||||
)
|
||||
async def get_org(
|
||||
org_id: str,
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> OrgResponse:
|
||||
if ctx.org_id != org_id:
|
||||
raise HTTPException(403, detail="Not a member of this organization")
|
||||
return await org_db.get_org(org_id)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{org_id}",
|
||||
summary="Update organization",
|
||||
tags=["orgs"],
|
||||
)
|
||||
async def update_org(
|
||||
org_id: str,
|
||||
request: UpdateOrgRequest,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.RENAME_ORG)),
|
||||
],
|
||||
) -> OrgResponse:
|
||||
_verify_org_path(ctx, org_id)
|
||||
return await org_db.update_org(
|
||||
org_id,
|
||||
UpdateOrgData(
|
||||
name=request.name,
|
||||
slug=request.slug,
|
||||
description=request.description,
|
||||
avatar_url=request.avatar_url,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{org_id}",
|
||||
summary="Delete organization",
|
||||
tags=["orgs"],
|
||||
status_code=204,
|
||||
)
|
||||
async def delete_org(
|
||||
org_id: str,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.DELETE_ORG)),
|
||||
],
|
||||
) -> None:
|
||||
_verify_org_path(ctx, org_id)
|
||||
await org_db.delete_org(org_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{org_id}/convert",
|
||||
summary="Convert personal org to team org",
|
||||
tags=["orgs"],
|
||||
)
|
||||
async def convert_org(
|
||||
org_id: str,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.DELETE_ORG)),
|
||||
],
|
||||
) -> OrgResponse:
|
||||
_verify_org_path(ctx, org_id)
|
||||
return await org_db.convert_personal_org(org_id, ctx.user_id)
|
||||
|
||||
|
||||
# --- Members ---
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{org_id}/members",
|
||||
summary="List organization members",
|
||||
tags=["orgs"],
|
||||
)
|
||||
async def list_members(
|
||||
org_id: str,
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> list[OrgMemberResponse]:
|
||||
if ctx.org_id != org_id:
|
||||
raise HTTPException(403, detail="Not a member of this organization")
|
||||
return await org_db.list_org_members(org_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{org_id}/members",
|
||||
summary="Add member to organization",
|
||||
tags=["orgs"],
|
||||
)
|
||||
async def add_member(
|
||||
org_id: str,
|
||||
request: AddMemberRequest,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.MANAGE_MEMBERS)),
|
||||
],
|
||||
) -> OrgMemberResponse:
|
||||
_verify_org_path(ctx, org_id)
|
||||
return await org_db.add_org_member(
|
||||
org_id=org_id,
|
||||
user_id=request.user_id,
|
||||
is_admin=request.is_admin,
|
||||
is_billing_manager=request.is_billing_manager,
|
||||
invited_by=ctx.user_id,
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{org_id}/members/{uid}",
|
||||
summary="Update member role",
|
||||
tags=["orgs"],
|
||||
)
|
||||
async def update_member(
|
||||
org_id: str,
|
||||
uid: str,
|
||||
request: UpdateMemberRequest,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.MANAGE_MEMBERS)),
|
||||
],
|
||||
) -> OrgMemberResponse:
|
||||
_verify_org_path(ctx, org_id)
|
||||
return await org_db.update_org_member(
|
||||
org_id=org_id,
|
||||
user_id=uid,
|
||||
is_admin=request.is_admin,
|
||||
is_billing_manager=request.is_billing_manager,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{org_id}/members/{uid}",
|
||||
summary="Remove member from organization",
|
||||
tags=["orgs"],
|
||||
status_code=204,
|
||||
)
|
||||
async def remove_member(
|
||||
org_id: str,
|
||||
uid: str,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.MANAGE_MEMBERS)),
|
||||
],
|
||||
) -> None:
|
||||
_verify_org_path(ctx, org_id)
|
||||
await org_db.remove_org_member(org_id, uid, requesting_user_id=ctx.user_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{org_id}/transfer-ownership",
|
||||
summary="Transfer organization ownership",
|
||||
tags=["orgs"],
|
||||
)
|
||||
async def transfer_ownership(
|
||||
org_id: str,
|
||||
request: TransferOwnershipRequest,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.DELETE_ORG)),
|
||||
],
|
||||
) -> None:
|
||||
_verify_org_path(ctx, org_id)
|
||||
await org_db.transfer_ownership(org_id, ctx.user_id, request.new_owner_id)
|
||||
|
||||
|
||||
# --- Aliases ---
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{org_id}/aliases",
|
||||
summary="List organization aliases",
|
||||
tags=["orgs"],
|
||||
)
|
||||
async def list_aliases(
|
||||
org_id: str,
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> list[OrgAliasResponse]:
|
||||
if ctx.org_id != org_id:
|
||||
raise HTTPException(403, detail="Not a member of this organization")
|
||||
return await org_db.list_org_aliases(org_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{org_id}/aliases",
|
||||
summary="Create organization alias",
|
||||
tags=["orgs"],
|
||||
)
|
||||
async def create_alias(
|
||||
org_id: str,
|
||||
request: CreateAliasRequest,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.RENAME_ORG)),
|
||||
],
|
||||
) -> OrgAliasResponse:
|
||||
_verify_org_path(ctx, org_id)
|
||||
return await org_db.create_org_alias(org_id, request.alias_slug, ctx.user_id)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,234 +0,0 @@
|
||||
"""Database operations for workspace management."""
|
||||
|
||||
import logging
|
||||
|
||||
from backend.data.db import prisma
|
||||
from backend.util.exceptions import NotFoundError
|
||||
|
||||
from .team_model import TeamMemberResponse, TeamResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_team(
|
||||
org_id: str,
|
||||
name: str,
|
||||
user_id: str,
|
||||
description: str | None = None,
|
||||
join_policy: str = "OPEN",
|
||||
) -> TeamResponse:
|
||||
"""Create a workspace and make the creator an admin."""
|
||||
ws = await prisma.team.create(
|
||||
data={
|
||||
"name": name,
|
||||
"orgId": org_id,
|
||||
"description": description,
|
||||
"joinPolicy": join_policy,
|
||||
"createdByUserId": user_id,
|
||||
}
|
||||
)
|
||||
|
||||
# Creator becomes admin
|
||||
await prisma.teammember.create(
|
||||
data={
|
||||
"teamId": ws.id,
|
||||
"userId": user_id,
|
||||
"isAdmin": True,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
)
|
||||
|
||||
return TeamResponse.from_db(ws, member_count=1)
|
||||
|
||||
|
||||
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.team.find_many(
|
||||
where={
|
||||
"orgId": org_id,
|
||||
"archivedAt": None,
|
||||
"OR": [
|
||||
{"joinPolicy": "OPEN"},
|
||||
{"Members": {"some": {"userId": user_id, "status": "ACTIVE"}}},
|
||||
],
|
||||
},
|
||||
order={"createdAt": "asc"},
|
||||
)
|
||||
return [TeamResponse.from_db(ws) for ws in workspaces]
|
||||
|
||||
|
||||
async def get_team(
|
||||
ws_id: str, expected_org_id: str | None = None
|
||||
) -> TeamResponse:
|
||||
"""Get workspace details. Validates org ownership if expected_org_id is given."""
|
||||
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 TeamResponse.from_db(ws)
|
||||
|
||||
|
||||
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_team(ws_id)
|
||||
|
||||
# Guard: default workspace joinPolicy cannot be changed
|
||||
if "joinPolicy" in update_data:
|
||||
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.team.update(where={"id": ws_id}, data=update_data)
|
||||
return await get_team(ws_id)
|
||||
|
||||
|
||||
async def delete_team(ws_id: str) -> None:
|
||||
"""Delete a workspace. Cannot delete the default workspace."""
|
||||
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.team.delete(where={"id": ws_id})
|
||||
|
||||
|
||||
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.team.find_unique(where={"id": ws_id})
|
||||
if ws is None:
|
||||
raise NotFoundError(f"Workspace {ws_id} not found")
|
||||
if ws.orgId != org_id:
|
||||
raise ValueError("Workspace does not belong to this organization")
|
||||
if ws.joinPolicy != "OPEN":
|
||||
raise ValueError("Cannot self-join a PRIVATE workspace. Request an invite.")
|
||||
|
||||
# Verify user is actually an org member
|
||||
org_member = await prisma.orgmember.find_unique(
|
||||
where={"orgId_userId": {"orgId": org_id, "userId": user_id}}
|
||||
)
|
||||
if org_member is None:
|
||||
raise ValueError(f"User {user_id} is not a member of the organization")
|
||||
|
||||
# Check not already a member
|
||||
existing = await prisma.teammember.find_unique(
|
||||
where={"teamId_userId": {"teamId": ws_id, "userId": user_id}}
|
||||
)
|
||||
if existing:
|
||||
return TeamResponse.from_db(ws)
|
||||
|
||||
await prisma.teammember.create(
|
||||
data={
|
||||
"teamId": ws_id,
|
||||
"userId": user_id,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
)
|
||||
return TeamResponse.from_db(ws)
|
||||
|
||||
|
||||
async def leave_team(ws_id: str, user_id: str) -> None:
|
||||
"""Leave a workspace. Cannot leave the default workspace."""
|
||||
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.teammember.delete_many(
|
||||
where={"teamId": ws_id, "userId": user_id}
|
||||
)
|
||||
|
||||
|
||||
async def list_team_members(ws_id: str) -> list[TeamMemberResponse]:
|
||||
"""List all active members of a workspace."""
|
||||
members = await prisma.teammember.find_many(
|
||||
where={"teamId": ws_id, "status": "ACTIVE"},
|
||||
include={"User": True},
|
||||
)
|
||||
return [TeamMemberResponse.from_db(m) for m in members]
|
||||
|
||||
|
||||
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,
|
||||
) -> 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.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}")
|
||||
|
||||
# Verify user is in the org
|
||||
org_member = await prisma.orgmember.find_unique(
|
||||
where={"orgId_userId": {"orgId": org_id, "userId": user_id}}
|
||||
)
|
||||
if org_member is None:
|
||||
raise ValueError(f"User {user_id} is not a member of the organization")
|
||||
|
||||
member = await prisma.teammember.create(
|
||||
data={
|
||||
"teamId": ws_id,
|
||||
"userId": user_id,
|
||||
"isAdmin": is_admin,
|
||||
"isBillingManager": is_billing_manager,
|
||||
"status": "ACTIVE",
|
||||
"invitedByUserId": invited_by,
|
||||
},
|
||||
include={"User": True},
|
||||
)
|
||||
return TeamMemberResponse.from_db(member)
|
||||
|
||||
|
||||
async def update_team_member(
|
||||
ws_id: str,
|
||||
user_id: str,
|
||||
is_admin: bool | None,
|
||||
is_billing_manager: bool | None,
|
||||
) -> TeamMemberResponse:
|
||||
"""Update a workspace member's role flags."""
|
||||
update_data: dict = {}
|
||||
if is_admin is not None:
|
||||
update_data["isAdmin"] = is_admin
|
||||
if is_billing_manager is not None:
|
||||
update_data["isBillingManager"] = is_billing_manager
|
||||
|
||||
if update_data:
|
||||
await prisma.teammember.update(
|
||||
where={"teamId_userId": {"teamId": ws_id, "userId": user_id}},
|
||||
data=update_data,
|
||||
)
|
||||
|
||||
members = await list_team_members(ws_id)
|
||||
return next(m for m in members if m.user_id == user_id)
|
||||
|
||||
|
||||
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.teammember.find_unique(
|
||||
where={"teamId_userId": {"teamId": ws_id, "userId": user_id}}
|
||||
)
|
||||
if member and member.isAdmin:
|
||||
admin_count = await prisma.teammember.count(
|
||||
where={"teamId": ws_id, "isAdmin": True, "status": "ACTIVE"}
|
||||
)
|
||||
if admin_count <= 1:
|
||||
raise ValueError(
|
||||
"Cannot remove the last workspace admin. "
|
||||
"Promote another member to admin first."
|
||||
)
|
||||
|
||||
await prisma.teammember.delete(
|
||||
where={"teamId_userId": {"teamId": ws_id, "userId": user_id}}
|
||||
)
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Pydantic request/response models for workspace management."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateTeamRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
join_policy: str = "OPEN" # OPEN or PRIVATE
|
||||
|
||||
|
||||
class UpdateTeamRequest(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
join_policy: str | None = None # OPEN or PRIVATE
|
||||
|
||||
|
||||
class TeamResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
slug: str | None
|
||||
description: str | None
|
||||
is_default: bool
|
||||
join_policy: str
|
||||
org_id: str
|
||||
member_count: int
|
||||
created_at: datetime
|
||||
|
||||
@staticmethod
|
||||
def from_db(ws, member_count: int = 0) -> "TeamResponse":
|
||||
return TeamResponse(
|
||||
id=ws.id,
|
||||
name=ws.name,
|
||||
slug=ws.slug,
|
||||
description=ws.description,
|
||||
is_default=ws.isDefault,
|
||||
join_policy=ws.joinPolicy,
|
||||
org_id=ws.orgId,
|
||||
member_count=member_count,
|
||||
created_at=ws.createdAt,
|
||||
)
|
||||
|
||||
|
||||
class TeamMemberResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
email: str
|
||||
name: str | None
|
||||
is_admin: bool
|
||||
is_billing_manager: bool
|
||||
joined_at: datetime
|
||||
|
||||
@staticmethod
|
||||
def from_db(member) -> "TeamMemberResponse":
|
||||
return TeamMemberResponse(
|
||||
id=member.id,
|
||||
user_id=member.userId,
|
||||
email=member.User.email if member.User else "",
|
||||
name=member.User.name if member.User else None,
|
||||
is_admin=member.isAdmin,
|
||||
is_billing_manager=member.isBillingManager,
|
||||
joined_at=member.joinedAt,
|
||||
)
|
||||
|
||||
|
||||
class AddTeamMemberRequest(BaseModel):
|
||||
user_id: str
|
||||
is_admin: bool = False
|
||||
is_billing_manager: bool = False
|
||||
|
||||
|
||||
class UpdateTeamMemberRequest(BaseModel):
|
||||
is_admin: bool | None = None
|
||||
is_billing_manager: bool | None = None
|
||||
@@ -1,236 +0,0 @@
|
||||
"""Workspace management API routes (nested under /api/orgs/{org_id}/workspaces)."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from autogpt_libs.auth import (
|
||||
get_request_context,
|
||||
requires_org_permission,
|
||||
requires_team_permission,
|
||||
)
|
||||
from autogpt_libs.auth.models import RequestContext
|
||||
from autogpt_libs.auth.permissions import OrgAction, TeamAction
|
||||
from fastapi import APIRouter, HTTPException, Security
|
||||
|
||||
from . import team_db as team_db
|
||||
from .team_model import (
|
||||
AddTeamMemberRequest,
|
||||
CreateTeamRequest,
|
||||
UpdateTeamMemberRequest,
|
||||
UpdateTeamRequest,
|
||||
TeamMemberResponse,
|
||||
TeamResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
summary="Create workspace",
|
||||
tags=["orgs", "workspaces"],
|
||||
)
|
||||
async def create_team(
|
||||
org_id: str,
|
||||
request: CreateTeamRequest,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.CREATE_WORKSPACES)),
|
||||
],
|
||||
) -> TeamResponse:
|
||||
if ctx.org_id != org_id:
|
||||
raise HTTPException(403, detail="Not a member of this organization")
|
||||
return await team_db.create_team(
|
||||
org_id=org_id,
|
||||
name=request.name,
|
||||
user_id=ctx.user_id,
|
||||
description=request.description,
|
||||
join_policy=request.join_policy,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
summary="List workspaces",
|
||||
tags=["orgs", "workspaces"],
|
||||
)
|
||||
async def list_teams(
|
||||
org_id: str,
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> list[TeamResponse]:
|
||||
if ctx.org_id != org_id:
|
||||
raise HTTPException(403, detail="Not a member of this organization")
|
||||
return await team_db.list_teams(org_id, ctx.user_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ws_id}",
|
||||
summary="Get workspace details",
|
||||
tags=["orgs", "workspaces"],
|
||||
)
|
||||
async def get_team(
|
||||
org_id: str,
|
||||
ws_id: str,
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> TeamResponse:
|
||||
if ctx.org_id != org_id:
|
||||
raise HTTPException(403, detail="Not a member of this organization")
|
||||
return await team_db.get_team(ws_id, expected_org_id=org_id)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{ws_id}",
|
||||
summary="Update workspace",
|
||||
tags=["orgs", "workspaces"],
|
||||
)
|
||||
async def update_team(
|
||||
org_id: str,
|
||||
ws_id: str,
|
||||
request: UpdateTeamRequest,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_team_permission(TeamAction.MANAGE_SETTINGS)),
|
||||
],
|
||||
) -> TeamResponse:
|
||||
# Verify workspace belongs to org (ctx validates workspace membership)
|
||||
await team_db.get_team(ws_id, expected_org_id=org_id)
|
||||
return await team_db.update_team(
|
||||
ws_id,
|
||||
{
|
||||
"name": request.name,
|
||||
"description": request.description,
|
||||
"joinPolicy": request.join_policy,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{ws_id}",
|
||||
summary="Delete workspace",
|
||||
tags=["orgs", "workspaces"],
|
||||
status_code=204,
|
||||
)
|
||||
async def delete_team(
|
||||
org_id: str,
|
||||
ws_id: str,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.MANAGE_WORKSPACES)),
|
||||
],
|
||||
) -> None:
|
||||
await team_db.get_team(ws_id, expected_org_id=org_id)
|
||||
await team_db.delete_team(ws_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{ws_id}/join",
|
||||
summary="Self-join open workspace",
|
||||
tags=["orgs", "workspaces"],
|
||||
)
|
||||
async def join_team(
|
||||
org_id: str,
|
||||
ws_id: str,
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> TeamResponse:
|
||||
if ctx.org_id != org_id:
|
||||
raise HTTPException(403, detail="Not a member of this organization")
|
||||
return await team_db.join_team(ws_id, ctx.user_id, org_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{ws_id}/leave",
|
||||
summary="Leave workspace",
|
||||
tags=["orgs", "workspaces"],
|
||||
status_code=204,
|
||||
)
|
||||
async def leave_team(
|
||||
org_id: str,
|
||||
ws_id: str,
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> None:
|
||||
if ctx.org_id != org_id:
|
||||
raise HTTPException(403, detail="Not a member of this organization")
|
||||
await team_db.leave_team(ws_id, ctx.user_id)
|
||||
|
||||
|
||||
# --- Members ---
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ws_id}/members",
|
||||
summary="List workspace members",
|
||||
tags=["orgs", "workspaces"],
|
||||
)
|
||||
async def list_members(
|
||||
org_id: str,
|
||||
ws_id: str,
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> list[TeamMemberResponse]:
|
||||
if ctx.org_id != org_id:
|
||||
raise HTTPException(403, detail="Not a member of this organization")
|
||||
await team_db.get_team(ws_id, expected_org_id=org_id)
|
||||
return await team_db.list_team_members(ws_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{ws_id}/members",
|
||||
summary="Add member to workspace",
|
||||
tags=["orgs", "workspaces"],
|
||||
)
|
||||
async def add_member(
|
||||
org_id: str,
|
||||
ws_id: str,
|
||||
request: AddTeamMemberRequest,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_team_permission(TeamAction.MANAGE_MEMBERS)),
|
||||
],
|
||||
) -> TeamMemberResponse:
|
||||
return await team_db.add_team_member(
|
||||
ws_id=ws_id,
|
||||
user_id=request.user_id,
|
||||
org_id=org_id,
|
||||
is_admin=request.is_admin,
|
||||
is_billing_manager=request.is_billing_manager,
|
||||
invited_by=ctx.user_id,
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{ws_id}/members/{uid}",
|
||||
summary="Update workspace member role",
|
||||
tags=["orgs", "workspaces"],
|
||||
)
|
||||
async def update_member(
|
||||
org_id: str,
|
||||
ws_id: str,
|
||||
uid: str,
|
||||
request: UpdateTeamMemberRequest,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_team_permission(TeamAction.MANAGE_MEMBERS)),
|
||||
],
|
||||
) -> TeamMemberResponse:
|
||||
return await team_db.update_team_member(
|
||||
ws_id=ws_id,
|
||||
user_id=uid,
|
||||
is_admin=request.is_admin,
|
||||
is_billing_manager=request.is_billing_manager,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{ws_id}/members/{uid}",
|
||||
summary="Remove member from workspace",
|
||||
tags=["orgs", "workspaces"],
|
||||
status_code=204,
|
||||
)
|
||||
async def remove_member(
|
||||
org_id: str,
|
||||
ws_id: str,
|
||||
uid: str,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_team_permission(TeamAction.MANAGE_MEMBERS)),
|
||||
],
|
||||
) -> None:
|
||||
await team_db.remove_team_member(ws_id, uid)
|
||||
@@ -730,7 +730,6 @@ async def create_store_submission(
|
||||
categories: list[str] = [],
|
||||
changes_summary: str | None = "Initial Submission",
|
||||
recommended_schedule_cron: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
) -> store_model.StoreSubmission:
|
||||
"""
|
||||
Create the first (and only) store listing and thus submission as a normal user
|
||||
@@ -859,7 +858,6 @@ async def create_store_submission(
|
||||
"agentGraphId": graph_id,
|
||||
"OwningUser": {"connect": {"id": user_id}},
|
||||
"CreatorProfile": {"connect": {"userId": user_id}},
|
||||
**({"owningOrgId": organization_id} if organization_id else {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -197,7 +197,6 @@ async def test_create_store_submission(mocker):
|
||||
userId="user-id",
|
||||
createdAt=now,
|
||||
isActive=True,
|
||||
visibility=prisma.enums.ResourceVisibility.PRIVATE,
|
||||
StoreListingVersions=[],
|
||||
User=mock_user,
|
||||
)
|
||||
|
||||
@@ -384,9 +384,6 @@ async def get_submissions(
|
||||
async def create_submission(
|
||||
submission_request: store_model.StoreSubmissionRequest,
|
||||
user_id: str = Security(autogpt_libs.auth.get_user_id),
|
||||
ctx: autogpt_libs.auth.RequestContext = Security(
|
||||
autogpt_libs.auth.get_request_context
|
||||
),
|
||||
) -> store_model.StoreSubmission:
|
||||
"""Submit a new marketplace listing for review"""
|
||||
result = await store_db.create_store_submission(
|
||||
@@ -404,7 +401,6 @@ async def create_submission(
|
||||
categories=submission_request.categories,
|
||||
changes_summary=submission_request.changes_summary or "Initial Submission",
|
||||
recommended_schedule_cron=submission_request.recommended_schedule_cron,
|
||||
organization_id=ctx.org_id,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
"""Database operations for resource transfer management."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from backend.data.db import prisma
|
||||
from backend.util.exceptions import NotFoundError
|
||||
|
||||
from .model import TransferResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_VALID_RESOURCE_TYPES = {"AgentGraph", "StoreListing"}
|
||||
|
||||
|
||||
async def create_transfer(
|
||||
source_org_id: str,
|
||||
target_org_id: str,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
user_id: str,
|
||||
reason: str | None = None,
|
||||
) -> TransferResponse:
|
||||
"""Create a new transfer request from source org to target org.
|
||||
|
||||
Validates:
|
||||
- resource_type is one of the allowed types
|
||||
- source and target orgs are different
|
||||
- target org exists
|
||||
- the resource exists and belongs to the source org
|
||||
"""
|
||||
if resource_type not in _VALID_RESOURCE_TYPES:
|
||||
raise ValueError(
|
||||
f"Invalid resource_type '{resource_type}'. "
|
||||
f"Must be one of: {', '.join(sorted(_VALID_RESOURCE_TYPES))}"
|
||||
)
|
||||
|
||||
if source_org_id == target_org_id:
|
||||
raise ValueError("Source and target organizations must be different")
|
||||
|
||||
target_org = await prisma.organization.find_unique(
|
||||
where={"id": target_org_id}
|
||||
)
|
||||
if target_org is None or target_org.deletedAt is not None:
|
||||
raise NotFoundError(f"Target organization {target_org_id} not found")
|
||||
|
||||
await _validate_resource_ownership(resource_type, resource_id, source_org_id)
|
||||
|
||||
tr = await prisma.transferrequest.create(
|
||||
data={
|
||||
"resourceType": resource_type,
|
||||
"resourceId": resource_id,
|
||||
"sourceOrganizationId": source_org_id,
|
||||
"targetOrganizationId": target_org_id,
|
||||
"initiatedByUserId": user_id,
|
||||
"status": "PENDING",
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
return TransferResponse.from_db(tr)
|
||||
|
||||
|
||||
async def list_transfers(org_id: str) -> list[TransferResponse]:
|
||||
"""List all transfer requests where org is source OR target."""
|
||||
transfers = await prisma.transferrequest.find_many(
|
||||
where={
|
||||
"OR": [
|
||||
{"sourceOrganizationId": org_id},
|
||||
{"targetOrganizationId": org_id},
|
||||
]
|
||||
},
|
||||
order={"createdAt": "desc"},
|
||||
)
|
||||
return [TransferResponse.from_db(t) for t in transfers]
|
||||
|
||||
|
||||
async def approve_transfer(
|
||||
transfer_id: str,
|
||||
user_id: str,
|
||||
org_id: str,
|
||||
) -> TransferResponse:
|
||||
"""Approve a transfer from the source or target side.
|
||||
|
||||
- If user's active org is the source org, sets sourceApprovedByUserId.
|
||||
- If user's active org is the target org, sets targetApprovedByUserId.
|
||||
- Advances the status accordingly.
|
||||
"""
|
||||
tr = await prisma.transferrequest.find_unique(where={"id": transfer_id})
|
||||
if tr is None:
|
||||
raise NotFoundError(f"Transfer request {transfer_id} not found")
|
||||
|
||||
if tr.status in ("COMPLETED", "REJECTED"):
|
||||
raise ValueError(f"Cannot approve a transfer with status '{tr.status}'")
|
||||
|
||||
update_data: dict = {}
|
||||
|
||||
if org_id == tr.sourceOrganizationId:
|
||||
if tr.sourceApprovedByUserId is not None:
|
||||
raise ValueError("Source organization has already approved this transfer")
|
||||
update_data["sourceApprovedByUserId"] = user_id
|
||||
if tr.targetApprovedByUserId is not None:
|
||||
# Both sides approved — ready for execution (NOT completed yet)
|
||||
update_data["status"] = "TARGET_APPROVED"
|
||||
else:
|
||||
update_data["status"] = "SOURCE_APPROVED"
|
||||
|
||||
elif org_id == tr.targetOrganizationId:
|
||||
if tr.targetApprovedByUserId is not None:
|
||||
raise ValueError("Target organization has already approved this transfer")
|
||||
update_data["targetApprovedByUserId"] = user_id
|
||||
if tr.sourceApprovedByUserId is not None:
|
||||
# Both sides approved — ready for execution (NOT completed yet)
|
||||
update_data["status"] = "SOURCE_APPROVED"
|
||||
else:
|
||||
update_data["status"] = "TARGET_APPROVED"
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
"Your active organization is not a party to this transfer"
|
||||
)
|
||||
|
||||
updated = await prisma.transferrequest.update(
|
||||
where={"id": transfer_id},
|
||||
data=update_data,
|
||||
)
|
||||
return TransferResponse.from_db(updated)
|
||||
|
||||
|
||||
async def reject_transfer(
|
||||
transfer_id: str,
|
||||
user_id: str,
|
||||
org_id: str,
|
||||
) -> TransferResponse:
|
||||
"""Reject a pending transfer request. Caller must be in source or target org."""
|
||||
tr = await prisma.transferrequest.find_unique(where={"id": transfer_id})
|
||||
if tr is None:
|
||||
raise NotFoundError(f"Transfer request {transfer_id} not found")
|
||||
|
||||
if tr.status in ("COMPLETED", "REJECTED"):
|
||||
raise ValueError(f"Cannot reject a transfer with status '{tr.status}'")
|
||||
|
||||
if org_id not in (tr.sourceOrganizationId, tr.targetOrganizationId):
|
||||
raise ValueError(
|
||||
"Your active organization is not a party to this transfer"
|
||||
)
|
||||
|
||||
updated = await prisma.transferrequest.update(
|
||||
where={"id": transfer_id},
|
||||
data={"status": "REJECTED"},
|
||||
)
|
||||
return TransferResponse.from_db(updated)
|
||||
|
||||
|
||||
async def execute_transfer(
|
||||
transfer_id: str,
|
||||
user_id: str,
|
||||
) -> TransferResponse:
|
||||
"""Execute an approved transfer -- move the resource to the target org.
|
||||
|
||||
Requires both source and target approvals. Updates the resource's
|
||||
organization ownership and creates AuditLog entries for both orgs.
|
||||
"""
|
||||
tr = await prisma.transferrequest.find_unique(where={"id": transfer_id})
|
||||
if tr is None:
|
||||
raise NotFoundError(f"Transfer request {transfer_id} not found")
|
||||
|
||||
if tr.sourceApprovedByUserId is None or tr.targetApprovedByUserId is None:
|
||||
raise ValueError(
|
||||
"Transfer requires approval from both source and target organizations"
|
||||
)
|
||||
|
||||
if tr.status == "COMPLETED":
|
||||
raise ValueError("Transfer has already been executed")
|
||||
|
||||
if tr.status == "REJECTED":
|
||||
raise ValueError("Cannot execute a rejected transfer")
|
||||
|
||||
await _move_resource(
|
||||
resource_type=tr.resourceType,
|
||||
resource_id=tr.resourceId,
|
||||
target_org_id=tr.targetOrganizationId,
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
updated = await prisma.transferrequest.update(
|
||||
where={"id": transfer_id},
|
||||
data={"status": "COMPLETED", "completedAt": now},
|
||||
)
|
||||
|
||||
await _create_audit_logs(
|
||||
transfer=updated,
|
||||
actor_user_id=user_id,
|
||||
)
|
||||
|
||||
return TransferResponse.from_db(updated)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _validate_resource_ownership(
|
||||
resource_type: str, resource_id: str, org_id: str
|
||||
) -> None:
|
||||
"""Verify the resource exists and belongs to the given org."""
|
||||
if resource_type == "AgentGraph":
|
||||
graph = await prisma.agentgraph.find_first(
|
||||
where={"id": resource_id, "isActive": True}
|
||||
)
|
||||
if graph is None:
|
||||
raise NotFoundError(f"AgentGraph '{resource_id}' not found")
|
||||
if graph.organizationId != org_id:
|
||||
raise ValueError(
|
||||
"AgentGraph does not belong to the source organization"
|
||||
)
|
||||
|
||||
elif resource_type == "StoreListing":
|
||||
listing = await prisma.storelisting.find_unique(
|
||||
where={"id": resource_id}
|
||||
)
|
||||
if listing is None or listing.isDeleted:
|
||||
raise NotFoundError(f"StoreListing '{resource_id}' not found")
|
||||
if listing.owningOrgId != org_id:
|
||||
raise ValueError(
|
||||
"StoreListing does not belong to the source organization"
|
||||
)
|
||||
|
||||
|
||||
async def _move_resource(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
target_org_id: str,
|
||||
) -> None:
|
||||
"""Move the resource to the target organization."""
|
||||
if resource_type == "AgentGraph":
|
||||
await prisma.agentgraph.update_many(
|
||||
where={"id": resource_id, "isActive": True},
|
||||
data={"organizationId": target_org_id},
|
||||
)
|
||||
|
||||
elif resource_type == "StoreListing":
|
||||
await prisma.storelisting.update(
|
||||
where={"id": resource_id},
|
||||
data={"owningOrgId": target_org_id},
|
||||
)
|
||||
|
||||
|
||||
async def _create_audit_logs(transfer, actor_user_id: str) -> None:
|
||||
"""Create audit log entries for both source and target organizations."""
|
||||
common = {
|
||||
"actorUserId": actor_user_id,
|
||||
"entityType": "TransferRequest",
|
||||
"entityId": transfer.id,
|
||||
"action": "TRANSFER_EXECUTED",
|
||||
"afterJson": {
|
||||
"resourceType": transfer.resourceType,
|
||||
"resourceId": transfer.resourceId,
|
||||
"sourceOrganizationId": transfer.sourceOrganizationId,
|
||||
"targetOrganizationId": transfer.targetOrganizationId,
|
||||
},
|
||||
"correlationId": transfer.id,
|
||||
}
|
||||
|
||||
await prisma.auditlog.create(
|
||||
data={
|
||||
**common,
|
||||
"organizationId": transfer.sourceOrganizationId,
|
||||
"beforeJson": {"organizationId": transfer.sourceOrganizationId},
|
||||
}
|
||||
)
|
||||
|
||||
await prisma.auditlog.create(
|
||||
data={
|
||||
**common,
|
||||
"organizationId": transfer.targetOrganizationId,
|
||||
"beforeJson": {"organizationId": transfer.sourceOrganizationId},
|
||||
}
|
||||
)
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Pydantic request/response models for resource transfer management."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateTransferRequest(BaseModel):
|
||||
resource_type: str # "AgentGraph", "StoreListing"
|
||||
resource_id: str
|
||||
target_organization_id: str
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class TransferResponse(BaseModel):
|
||||
id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
source_organization_id: str
|
||||
target_organization_id: str
|
||||
initiated_by_user_id: str
|
||||
status: str
|
||||
source_approved_by_user_id: str | None
|
||||
target_approved_by_user_id: str | None
|
||||
completed_at: datetime | None
|
||||
reason: str | None
|
||||
created_at: datetime
|
||||
|
||||
@staticmethod
|
||||
def from_db(tr) -> "TransferResponse":
|
||||
return TransferResponse(
|
||||
id=tr.id,
|
||||
resource_type=tr.resourceType,
|
||||
resource_id=tr.resourceId,
|
||||
source_organization_id=tr.sourceOrganizationId,
|
||||
target_organization_id=tr.targetOrganizationId,
|
||||
initiated_by_user_id=tr.initiatedByUserId,
|
||||
status=tr.status,
|
||||
source_approved_by_user_id=tr.sourceApprovedByUserId,
|
||||
target_approved_by_user_id=tr.targetApprovedByUserId,
|
||||
completed_at=tr.completedAt,
|
||||
reason=tr.reason,
|
||||
created_at=tr.createdAt,
|
||||
)
|
||||
@@ -1,105 +0,0 @@
|
||||
"""Resource transfer API routes."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from autogpt_libs.auth import requires_org_permission
|
||||
from autogpt_libs.auth.models import RequestContext
|
||||
from autogpt_libs.auth.permissions import OrgAction
|
||||
from fastapi import APIRouter, Security
|
||||
|
||||
from . import db as transfer_db
|
||||
from .model import CreateTransferRequest, TransferResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
summary="Create transfer request",
|
||||
tags=["transfers"],
|
||||
)
|
||||
async def create_transfer(
|
||||
request: CreateTransferRequest,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.TRANSFER_RESOURCES)),
|
||||
],
|
||||
) -> TransferResponse:
|
||||
return await transfer_db.create_transfer(
|
||||
source_org_id=ctx.org_id,
|
||||
target_org_id=request.target_organization_id,
|
||||
resource_type=request.resource_type,
|
||||
resource_id=request.resource_id,
|
||||
user_id=ctx.user_id,
|
||||
reason=request.reason,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
summary="List transfers for active organization",
|
||||
tags=["transfers"],
|
||||
)
|
||||
async def list_transfers(
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.TRANSFER_RESOURCES)),
|
||||
],
|
||||
) -> list[TransferResponse]:
|
||||
return await transfer_db.list_transfers(ctx.org_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{transfer_id}/approve",
|
||||
summary="Approve transfer request",
|
||||
tags=["transfers"],
|
||||
)
|
||||
async def approve_transfer(
|
||||
transfer_id: str,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.TRANSFER_RESOURCES)),
|
||||
],
|
||||
) -> TransferResponse:
|
||||
return await transfer_db.approve_transfer(
|
||||
transfer_id=transfer_id,
|
||||
user_id=ctx.user_id,
|
||||
org_id=ctx.org_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{transfer_id}/reject",
|
||||
summary="Reject transfer request",
|
||||
tags=["transfers"],
|
||||
)
|
||||
async def reject_transfer(
|
||||
transfer_id: str,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.TRANSFER_RESOURCES)),
|
||||
],
|
||||
) -> TransferResponse:
|
||||
return await transfer_db.reject_transfer(
|
||||
transfer_id=transfer_id,
|
||||
user_id=ctx.user_id,
|
||||
org_id=ctx.org_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{transfer_id}/execute",
|
||||
summary="Execute approved transfer",
|
||||
tags=["transfers"],
|
||||
)
|
||||
async def execute_transfer(
|
||||
transfer_id: str,
|
||||
ctx: Annotated[
|
||||
RequestContext,
|
||||
Security(requires_org_permission(OrgAction.TRANSFER_RESOURCES)),
|
||||
],
|
||||
) -> TransferResponse:
|
||||
return await transfer_db.execute_transfer(
|
||||
transfer_id=transfer_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
@@ -9,9 +9,8 @@ from typing import Annotated, Any, Sequence, get_args
|
||||
|
||||
import pydantic
|
||||
import stripe
|
||||
from autogpt_libs.auth import get_request_context, get_user_id, requires_user
|
||||
from autogpt_libs.auth import get_user_id, requires_user
|
||||
from autogpt_libs.auth.jwt_utils import get_jwt_payload
|
||||
from autogpt_libs.auth.models import RequestContext
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Body,
|
||||
@@ -438,10 +437,7 @@ async def get_graph_blocks() -> Response:
|
||||
dependencies=[Security(requires_user)],
|
||||
)
|
||||
async def execute_graph_block(
|
||||
block_id: str,
|
||||
data: BlockInput,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
block_id: str, data: BlockInput, user_id: Annotated[str, Security(get_user_id)]
|
||||
) -> CompletedBlockOutput:
|
||||
obj = get_block(block_id)
|
||||
if not obj:
|
||||
@@ -487,7 +483,6 @@ async def execute_graph_block(
|
||||
)
|
||||
async def upload_file(
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
file: UploadFile = File(...),
|
||||
expiration_hours: int = 24,
|
||||
) -> UploadFileResponse:
|
||||
@@ -577,7 +572,6 @@ async def upload_file(
|
||||
)
|
||||
async def get_user_credits(
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> dict[str, int]:
|
||||
user_credit_model = await get_user_credit_model(user_id)
|
||||
return {"credits": await user_credit_model.get_credits(user_id)}
|
||||
@@ -590,9 +584,7 @@ async def get_user_credits(
|
||||
dependencies=[Security(requires_user)],
|
||||
)
|
||||
async def request_top_up(
|
||||
request: RequestTopUp,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
request: RequestTopUp, user_id: Annotated[str, Security(get_user_id)]
|
||||
):
|
||||
user_credit_model = await get_user_credit_model(user_id)
|
||||
checkout_url = await user_credit_model.top_up_intent(user_id, request.credit_amount)
|
||||
@@ -747,7 +739,6 @@ async def manage_payment_method(
|
||||
)
|
||||
async def get_credit_history(
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
transaction_time: datetime | None = None,
|
||||
transaction_type: str | None = None,
|
||||
transaction_count_limit: int = 100,
|
||||
@@ -772,7 +763,6 @@ async def get_credit_history(
|
||||
)
|
||||
async def get_refund_requests(
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> list[RefundRequest]:
|
||||
user_credit_model = await get_user_credit_model(user_id)
|
||||
return await user_credit_model.get_refund_requests(user_id)
|
||||
@@ -795,7 +785,6 @@ class DeleteGraphResponse(TypedDict):
|
||||
)
|
||||
async def list_graphs(
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> Sequence[graph_db.GraphMeta]:
|
||||
paginated_result = await graph_db.list_graphs_paginated(
|
||||
user_id=user_id,
|
||||
@@ -821,7 +810,6 @@ async def list_graphs(
|
||||
async def get_graph(
|
||||
graph_id: str,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
version: int | None = None,
|
||||
for_export: bool = False,
|
||||
) -> graph_db.GraphModel:
|
||||
@@ -844,9 +832,7 @@ async def get_graph(
|
||||
dependencies=[Security(requires_user)],
|
||||
)
|
||||
async def get_graph_all_versions(
|
||||
graph_id: str,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
graph_id: str, user_id: Annotated[str, Security(get_user_id)]
|
||||
) -> Sequence[graph_db.GraphModel]:
|
||||
graphs = await graph_db.get_graph_all_versions(graph_id, user_id=user_id)
|
||||
if not graphs:
|
||||
@@ -863,18 +849,12 @@ async def get_graph_all_versions(
|
||||
async def create_new_graph(
|
||||
create_graph: CreateGraph,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> graph_db.GraphModel:
|
||||
graph = graph_db.make_graph_model(create_graph.graph, user_id)
|
||||
graph.reassign_ids(user_id=user_id, reassign_graph_id=True)
|
||||
graph.validate_graph(for_run=False)
|
||||
|
||||
await graph_db.create_graph(
|
||||
graph,
|
||||
user_id=user_id,
|
||||
organization_id=ctx.org_id,
|
||||
team_id=ctx.team_id,
|
||||
)
|
||||
await graph_db.create_graph(graph, user_id=user_id)
|
||||
await library_db.create_library_agent(graph, user_id)
|
||||
activated_graph = await on_graph_activate(graph, user_id=user_id)
|
||||
|
||||
@@ -891,9 +871,7 @@ async def create_new_graph(
|
||||
dependencies=[Security(requires_user)],
|
||||
)
|
||||
async def delete_graph(
|
||||
graph_id: str,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
graph_id: str, user_id: Annotated[str, Security(get_user_id)]
|
||||
) -> DeleteGraphResponse:
|
||||
if active_version := await graph_db.get_graph(
|
||||
graph_id=graph_id, version=None, user_id=user_id
|
||||
@@ -913,7 +891,6 @@ async def update_graph(
|
||||
graph_id: str,
|
||||
graph: graph_db.Graph,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> graph_db.GraphModel:
|
||||
if graph.id and graph.id != graph_id:
|
||||
raise HTTPException(400, detail="Graph ID does not match ID in URI")
|
||||
@@ -929,12 +906,7 @@ async def update_graph(
|
||||
graph.reassign_ids(user_id=user_id, reassign_graph_id=False)
|
||||
graph.validate_graph(for_run=False)
|
||||
|
||||
new_graph_version = await graph_db.create_graph(
|
||||
graph,
|
||||
user_id=user_id,
|
||||
organization_id=ctx.org_id,
|
||||
team_id=ctx.team_id,
|
||||
)
|
||||
new_graph_version = await graph_db.create_graph(graph, user_id=user_id)
|
||||
|
||||
if new_graph_version.is_active:
|
||||
await library_db.update_library_agent_version_and_settings(
|
||||
@@ -967,7 +939,6 @@ async def set_graph_active_version(
|
||||
graph_id: str,
|
||||
request_body: SetGraphActiveVersion,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
):
|
||||
new_active_version = request_body.active_graph_version
|
||||
new_active_graph = await graph_db.get_graph(
|
||||
@@ -1011,7 +982,6 @@ async def update_graph_settings(
|
||||
graph_id: str,
|
||||
settings: GraphSettings,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> GraphSettings:
|
||||
"""Update graph settings for the user's library agent."""
|
||||
library_agent = await library_db.get_library_agent_by_graph_id(
|
||||
@@ -1038,7 +1008,6 @@ async def update_graph_settings(
|
||||
async def execute_graph(
|
||||
graph_id: str,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
inputs: Annotated[dict[str, Any], Body(..., embed=True, default_factory=dict)],
|
||||
credentials_inputs: Annotated[
|
||||
dict[str, CredentialsMetaInput], Body(..., embed=True, default_factory=dict)
|
||||
@@ -1066,8 +1035,6 @@ async def execute_graph(
|
||||
graph_version=graph_version,
|
||||
graph_credentials_inputs=credentials_inputs,
|
||||
dry_run=dry_run,
|
||||
organization_id=ctx.org_id,
|
||||
team_id=ctx.team_id,
|
||||
)
|
||||
# Record successful graph execution
|
||||
record_graph_execution(graph_id=graph_id, status="success", user_id=user_id)
|
||||
@@ -1153,7 +1120,6 @@ async def _stop_graph_run(
|
||||
)
|
||||
async def list_graphs_executions(
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> list[execution_db.GraphExecutionMeta]:
|
||||
paginated_result = await execution_db.get_graph_executions_paginated(
|
||||
user_id=user_id,
|
||||
@@ -1177,7 +1143,6 @@ async def list_graphs_executions(
|
||||
async def list_graph_executions(
|
||||
graph_id: str,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
||||
page_size: int = Query(
|
||||
25, ge=1, le=100, description="Number of executions per page"
|
||||
@@ -1235,7 +1200,6 @@ async def get_graph_execution(
|
||||
graph_id: str,
|
||||
graph_exec_id: str,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> execution_db.GraphExecution | execution_db.GraphExecutionWithNodes:
|
||||
result = await execution_db.get_graph_execution(
|
||||
user_id=user_id,
|
||||
@@ -1293,7 +1257,6 @@ async def hide_activity_summary_if_disabled(
|
||||
async def delete_graph_execution(
|
||||
graph_exec_id: str,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> None:
|
||||
await execution_db.delete_graph_execution(
|
||||
graph_exec_id=graph_exec_id, user_id=user_id
|
||||
@@ -1321,7 +1284,6 @@ async def enable_execution_sharing(
|
||||
graph_id: Annotated[str, Path],
|
||||
graph_exec_id: Annotated[str, Path],
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
_body: ShareRequest = Body(default=ShareRequest()),
|
||||
) -> ShareResponse:
|
||||
"""Enable sharing for a graph execution."""
|
||||
@@ -1360,7 +1322,6 @@ async def disable_execution_sharing(
|
||||
graph_id: Annotated[str, Path],
|
||||
graph_exec_id: Annotated[str, Path],
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> None:
|
||||
"""Disable sharing for a graph execution."""
|
||||
# Verify the execution belongs to the user
|
||||
@@ -1420,7 +1381,6 @@ class ScheduleCreationRequest(pydantic.BaseModel):
|
||||
)
|
||||
async def create_graph_execution_schedule(
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
graph_id: str = Path(..., description="ID of the graph to schedule"),
|
||||
schedule_params: ScheduleCreationRequest = Body(),
|
||||
) -> scheduler.GraphExecutionJobInfo:
|
||||
@@ -1451,8 +1411,6 @@ async def create_graph_execution_schedule(
|
||||
input_data=schedule_params.inputs,
|
||||
input_credentials=schedule_params.credentials,
|
||||
user_timezone=user_timezone,
|
||||
organization_id=ctx.org_id,
|
||||
team_id=ctx.team_id,
|
||||
)
|
||||
|
||||
# Convert the next_run_time back to user timezone for display
|
||||
@@ -1474,7 +1432,6 @@ async def create_graph_execution_schedule(
|
||||
)
|
||||
async def list_graph_execution_schedules(
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
graph_id: str = Path(),
|
||||
) -> list[scheduler.GraphExecutionJobInfo]:
|
||||
return await get_scheduler_client().get_execution_schedules(
|
||||
@@ -1491,7 +1448,6 @@ async def list_graph_execution_schedules(
|
||||
)
|
||||
async def list_all_graphs_execution_schedules(
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> list[scheduler.GraphExecutionJobInfo]:
|
||||
return await get_scheduler_client().get_execution_schedules(user_id=user_id)
|
||||
|
||||
@@ -1504,7 +1460,6 @@ async def list_all_graphs_execution_schedules(
|
||||
)
|
||||
async def delete_graph_execution_schedule(
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
schedule_id: str = Path(..., description="ID of the schedule to delete"),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
@@ -1529,9 +1484,7 @@ async def delete_graph_execution_schedule(
|
||||
dependencies=[Security(requires_user)],
|
||||
)
|
||||
async def create_api_key(
|
||||
request: CreateAPIKeyRequest,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
request: CreateAPIKeyRequest, user_id: Annotated[str, Security(get_user_id)]
|
||||
) -> CreateAPIKeyResponse:
|
||||
"""Create a new API key"""
|
||||
api_key_info, plain_text_key = await api_key_db.create_api_key(
|
||||
@@ -1539,7 +1492,6 @@ async def create_api_key(
|
||||
user_id=user_id,
|
||||
permissions=request.permissions,
|
||||
description=request.description,
|
||||
organization_id=ctx.org_id if ctx.org_id else None,
|
||||
)
|
||||
return CreateAPIKeyResponse(api_key=api_key_info, plain_text_key=plain_text_key)
|
||||
|
||||
@@ -1552,7 +1504,6 @@ async def create_api_key(
|
||||
)
|
||||
async def get_api_keys(
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> list[api_key_db.APIKeyInfo]:
|
||||
"""List all API keys for the user"""
|
||||
return await api_key_db.list_user_api_keys(user_id)
|
||||
@@ -1565,9 +1516,7 @@ async def get_api_keys(
|
||||
dependencies=[Security(requires_user)],
|
||||
)
|
||||
async def get_api_key(
|
||||
key_id: str,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
key_id: str, user_id: Annotated[str, Security(get_user_id)]
|
||||
) -> api_key_db.APIKeyInfo:
|
||||
"""Get a specific API key"""
|
||||
api_key = await api_key_db.get_api_key_by_id(key_id, user_id)
|
||||
@@ -1583,9 +1532,7 @@ async def get_api_key(
|
||||
dependencies=[Security(requires_user)],
|
||||
)
|
||||
async def delete_api_key(
|
||||
key_id: str,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
key_id: str, user_id: Annotated[str, Security(get_user_id)]
|
||||
) -> api_key_db.APIKeyInfo:
|
||||
"""Revoke an API key"""
|
||||
return await api_key_db.revoke_api_key(key_id, user_id)
|
||||
@@ -1598,9 +1545,7 @@ async def delete_api_key(
|
||||
dependencies=[Security(requires_user)],
|
||||
)
|
||||
async def suspend_key(
|
||||
key_id: str,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
key_id: str, user_id: Annotated[str, Security(get_user_id)]
|
||||
) -> api_key_db.APIKeyInfo:
|
||||
"""Suspend an API key"""
|
||||
return await api_key_db.suspend_api_key(key_id, user_id)
|
||||
@@ -1616,7 +1561,6 @@ async def update_permissions(
|
||||
key_id: str,
|
||||
request: UpdatePermissionsRequest,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
ctx: Annotated[RequestContext, Security(get_request_context)],
|
||||
) -> api_key_db.APIKeyInfo:
|
||||
"""Update API key permissions"""
|
||||
return await api_key_db.update_api_key_permissions(
|
||||
|
||||
@@ -29,20 +29,15 @@ import backend.api.features.library.model
|
||||
import backend.api.features.library.routes
|
||||
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.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.transfers.routes as transfer_routes
|
||||
import backend.api.features.v1
|
||||
import backend.api.features.workspace.routes as team_routes
|
||||
import backend.api.features.workspace.routes as workspace_routes
|
||||
import backend.data.block
|
||||
import backend.data.db
|
||||
import backend.data.graph
|
||||
import backend.data.org_migration
|
||||
import backend.data.user
|
||||
import backend.integrations.webhooks.utils
|
||||
import backend.util.service
|
||||
@@ -134,7 +129,6 @@ async def lifespan_context(app: fastapi.FastAPI):
|
||||
await backend.data.graph.fix_llm_provider_credentials()
|
||||
await backend.data.graph.migrate_llm_models(DEFAULT_LLM_MODEL)
|
||||
await backend.integrations.webhooks.utils.migrate_legacy_triggered_graphs()
|
||||
await backend.data.org_migration.run_migration()
|
||||
|
||||
with launch_darkly_context():
|
||||
yield
|
||||
@@ -358,7 +352,7 @@ app.include_router(
|
||||
prefix="/api/chat",
|
||||
)
|
||||
app.include_router(
|
||||
team_routes.router,
|
||||
workspace_routes.router,
|
||||
tags=["workspace"],
|
||||
prefix="/api/workspace",
|
||||
)
|
||||
@@ -372,31 +366,6 @@ app.include_router(
|
||||
tags=["oauth"],
|
||||
prefix="/api/oauth",
|
||||
)
|
||||
app.include_router(
|
||||
org_routes.router,
|
||||
tags=["v2", "orgs"],
|
||||
prefix="/api/orgs",
|
||||
)
|
||||
app.include_router(
|
||||
backend.api.features.orgs.team_routes.router,
|
||||
tags=["v2", "orgs", "workspaces"],
|
||||
prefix="/api/orgs/{org_id}/workspaces",
|
||||
)
|
||||
app.include_router(
|
||||
backend.api.features.orgs.invitation_routes.org_router,
|
||||
tags=["v2", "orgs", "invitations"],
|
||||
prefix="/api/orgs/{org_id}/invitations",
|
||||
)
|
||||
app.include_router(
|
||||
backend.api.features.orgs.invitation_routes.router,
|
||||
tags=["v2", "invitations"],
|
||||
prefix="/api/invitations",
|
||||
)
|
||||
app.include_router(
|
||||
transfer_routes.router,
|
||||
tags=["v2", "transfers"],
|
||||
prefix="/api/transfers",
|
||||
)
|
||||
|
||||
app.mount("/external-api", external_api)
|
||||
|
||||
@@ -451,22 +420,8 @@ class AgentServer(backend.util.service.AppProcess):
|
||||
graph_version: Optional[int] = None,
|
||||
node_input: Optional[dict[str, Any]] = None,
|
||||
):
|
||||
from autogpt_libs.auth.models import RequestContext
|
||||
|
||||
ctx = RequestContext(
|
||||
user_id=user_id,
|
||||
org_id="test-org",
|
||||
team_id="test-workspace",
|
||||
is_org_owner=True,
|
||||
is_org_admin=True,
|
||||
is_org_billing_manager=False,
|
||||
is_team_admin=True,
|
||||
is_team_billing_manager=False,
|
||||
seat_status="ACTIVE",
|
||||
)
|
||||
return await backend.api.features.v1.execute_graph(
|
||||
user_id=user_id,
|
||||
ctx=ctx,
|
||||
graph_id=graph_id,
|
||||
graph_version=graph_version,
|
||||
inputs=node_input or {},
|
||||
@@ -489,22 +444,7 @@ class AgentServer(backend.util.service.AppProcess):
|
||||
create_graph: backend.api.features.v1.CreateGraph,
|
||||
user_id: str,
|
||||
):
|
||||
from autogpt_libs.auth.models import RequestContext
|
||||
|
||||
ctx = RequestContext(
|
||||
user_id=user_id,
|
||||
org_id="test-org",
|
||||
team_id="test-workspace",
|
||||
is_org_owner=True,
|
||||
is_org_admin=True,
|
||||
is_org_billing_manager=False,
|
||||
is_team_admin=True,
|
||||
is_team_billing_manager=False,
|
||||
seat_status="ACTIVE",
|
||||
)
|
||||
return await backend.api.features.v1.create_new_graph(
|
||||
create_graph, user_id, ctx
|
||||
)
|
||||
return await backend.api.features.v1.create_new_graph(create_graph, user_id)
|
||||
|
||||
@staticmethod
|
||||
async def test_get_graph_run_status(graph_exec_id: str, user_id: str):
|
||||
|
||||
@@ -95,8 +95,6 @@ class AgentExecutorBlock(Block):
|
||||
update={"parent_execution_id": graph_exec_id},
|
||||
),
|
||||
dry_run=execution_context.dry_run,
|
||||
organization_id=execution_context.organization_id,
|
||||
team_id=execution_context.team_id,
|
||||
)
|
||||
|
||||
logger = execution_utils.LogMetadata(
|
||||
|
||||
@@ -162,9 +162,6 @@ async def get_chat_messages_paginated(
|
||||
async def create_chat_session(
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
organization_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
metadata: ChatSessionMetadata | None = None,
|
||||
) -> ChatSessionInfo:
|
||||
"""Create a new chat session in the database."""
|
||||
@@ -174,9 +171,6 @@ async def create_chat_session(
|
||||
credentials=SafeJson({}),
|
||||
successfulAgentRuns=SafeJson({}),
|
||||
successfulAgentSchedules=SafeJson({}),
|
||||
# Tenancy dual-write fields
|
||||
**({"organizationId": organization_id} if organization_id else {}),
|
||||
**({"teamId": team_id} if team_id else {}),
|
||||
metadata=SafeJson((metadata or ChatSessionMetadata()).model_dump()),
|
||||
)
|
||||
prisma_session = await PrismaChatSession.prisma().create(data=data)
|
||||
|
||||
@@ -157,12 +157,6 @@ class CoPilotExecutionEntry(BaseModel):
|
||||
file_ids: list[str] | None = None
|
||||
"""Workspace file IDs attached to the user's message"""
|
||||
|
||||
organization_id: str | None = None
|
||||
"""Active organization for tenant-scoped execution"""
|
||||
|
||||
team_id: str | None = None
|
||||
"""Active workspace for tenant-scoped execution"""
|
||||
|
||||
mode: CopilotMode | None = None
|
||||
"""Autopilot mode override: 'fast' or 'extended_thinking'. None = server default."""
|
||||
|
||||
@@ -185,8 +179,6 @@ async def enqueue_copilot_turn(
|
||||
is_user_message: bool = True,
|
||||
context: dict[str, str] | None = None,
|
||||
file_ids: list[str] | None = None,
|
||||
organization_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
mode: CopilotMode | None = None,
|
||||
) -> None:
|
||||
"""Enqueue a CoPilot task for processing by the executor service.
|
||||
@@ -211,8 +203,6 @@ async def enqueue_copilot_turn(
|
||||
is_user_message=is_user_message,
|
||||
context=context,
|
||||
file_ids=file_ids,
|
||||
organization_id=organization_id,
|
||||
team_id=team_id,
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
|
||||
@@ -498,19 +498,12 @@ class RunAgentTool(BaseTool):
|
||||
library_agent = await get_or_create_library_agent(graph, user_id)
|
||||
|
||||
# Execute
|
||||
# Resolve org/team context for tenancy
|
||||
from backend.api.features.orgs.db import get_user_default_team
|
||||
|
||||
org_id, team_id = await get_user_default_team(user_id)
|
||||
|
||||
execution = await execution_utils.add_graph_execution(
|
||||
graph_id=library_agent.graph_id,
|
||||
user_id=user_id,
|
||||
inputs=inputs,
|
||||
graph_credentials_inputs=graph_credentials,
|
||||
dry_run=dry_run,
|
||||
organization_id=org_id,
|
||||
team_id=team_id,
|
||||
)
|
||||
|
||||
# Track successful run (dry runs don't count against the session limit)
|
||||
|
||||
@@ -29,9 +29,6 @@ class APIKeyInfo(APIAuthorizationInfo):
|
||||
)
|
||||
status: APIKeyStatus
|
||||
description: Optional[str] = None
|
||||
organization_id: Optional[str] = None
|
||||
owner_type: Optional[str] = None
|
||||
team_id_restriction: Optional[str] = None
|
||||
|
||||
type: Literal["api_key"] = "api_key" # type: ignore
|
||||
|
||||
@@ -49,9 +46,6 @@ class APIKeyInfo(APIAuthorizationInfo):
|
||||
revoked_at=api_key.revokedAt,
|
||||
description=api_key.description,
|
||||
user_id=api_key.userId,
|
||||
organization_id=api_key.organizationId,
|
||||
owner_type=api_key.ownerType,
|
||||
team_id_restriction=api_key.teamIdRestriction,
|
||||
)
|
||||
|
||||
|
||||
@@ -80,9 +74,6 @@ async def create_api_key(
|
||||
user_id: str,
|
||||
permissions: list[APIKeyPermission],
|
||||
description: Optional[str] = None,
|
||||
organization_id: Optional[str] = None,
|
||||
owner_type: Optional[str] = None,
|
||||
team_id_restriction: Optional[str] = None,
|
||||
) -> tuple[APIKeyInfo, str]:
|
||||
"""
|
||||
Generate a new API key and store it in the database.
|
||||
@@ -90,25 +81,19 @@ async def create_api_key(
|
||||
"""
|
||||
generated_key = keysmith.generate_key()
|
||||
|
||||
create_data: dict[str, object] = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": name,
|
||||
"head": generated_key.head,
|
||||
"tail": generated_key.tail,
|
||||
"hash": generated_key.hash,
|
||||
"salt": generated_key.salt,
|
||||
"permissions": [p for p in permissions],
|
||||
"description": description,
|
||||
"userId": user_id,
|
||||
}
|
||||
if organization_id is not None:
|
||||
create_data["organizationId"] = organization_id
|
||||
if owner_type is not None:
|
||||
create_data["ownerType"] = owner_type
|
||||
if team_id_restriction is not None:
|
||||
create_data["teamIdRestriction"] = team_id_restriction
|
||||
|
||||
saved_key_obj = await PrismaAPIKey.prisma().create(data=create_data) # type: ignore
|
||||
saved_key_obj = await PrismaAPIKey.prisma().create(
|
||||
data={
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": name,
|
||||
"head": generated_key.head,
|
||||
"tail": generated_key.tail,
|
||||
"hash": generated_key.hash,
|
||||
"salt": generated_key.salt,
|
||||
"permissions": [p for p in permissions],
|
||||
"description": description,
|
||||
"userId": user_id,
|
||||
}
|
||||
)
|
||||
|
||||
return APIKeyInfo.from_db(saved_key_obj), generated_key.key
|
||||
|
||||
|
||||
@@ -98,14 +98,10 @@ class ExecutionContext(BaseModel):
|
||||
root_execution_id: Optional[str] = None
|
||||
parent_execution_id: Optional[str] = None
|
||||
|
||||
# File workspace (UserWorkspace — NOT the Team concept)
|
||||
# Workspace
|
||||
workspace_id: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
|
||||
# Org/team tenancy context
|
||||
organization_id: Optional[str] = None
|
||||
team_id: Optional[str] = None
|
||||
|
||||
|
||||
# -------------------------- Models -------------------------- #
|
||||
|
||||
@@ -521,7 +517,6 @@ async def get_graph_executions(
|
||||
created_time_gte: Optional[datetime] = None,
|
||||
created_time_lte: Optional[datetime] = None,
|
||||
limit: Optional[int] = None,
|
||||
team_id: Optional[str] = None,
|
||||
) -> list[GraphExecutionMeta]:
|
||||
"""⚠️ **Optional `user_id` check**: MUST USE check in user-facing endpoints."""
|
||||
where_filter: AgentGraphExecutionWhereInput = {
|
||||
@@ -529,10 +524,7 @@ async def get_graph_executions(
|
||||
}
|
||||
if graph_exec_id:
|
||||
where_filter["id"] = graph_exec_id
|
||||
# Prefer team_id scoping over user_id when available
|
||||
if team_id:
|
||||
where_filter["teamId"] = team_id
|
||||
elif user_id:
|
||||
if user_id:
|
||||
where_filter["userId"] = user_id
|
||||
if graph_id:
|
||||
where_filter["agentGraphId"] = graph_id
|
||||
@@ -738,8 +730,6 @@ async def create_graph_execution(
|
||||
nodes_input_masks: Optional[NodesInputMasks] = None,
|
||||
parent_graph_exec_id: Optional[str] = None,
|
||||
is_dry_run: bool = False,
|
||||
organization_id: Optional[str] = None,
|
||||
team_id: Optional[str] = None,
|
||||
) -> GraphExecutionWithNodes:
|
||||
"""
|
||||
Create a new AgentGraphExecution record.
|
||||
@@ -778,9 +768,6 @@ async def create_graph_execution(
|
||||
"agentPresetId": preset_id,
|
||||
"parentGraphExecutionId": parent_graph_exec_id,
|
||||
**({"stats": Json({"is_dry_run": True})} if is_dry_run else {}),
|
||||
# Tenancy dual-write fields
|
||||
**({"organizationId": organization_id} if organization_id else {}),
|
||||
**({"teamId": team_id} if team_id else {}),
|
||||
},
|
||||
include=GRAPH_EXECUTION_INCLUDE_WITH_NODES,
|
||||
)
|
||||
|
||||
@@ -1090,7 +1090,6 @@ async def get_graph(
|
||||
for_export: bool = False,
|
||||
include_subgraphs: bool = False,
|
||||
skip_access_check: bool = False,
|
||||
team_id: str | None = None,
|
||||
) -> GraphModel | None:
|
||||
"""
|
||||
Retrieves a graph from the DB.
|
||||
@@ -1104,18 +1103,14 @@ 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 team_id is not None:
|
||||
if skip_access_check or user_id is not None:
|
||||
graph_where_clause: AgentGraphWhereInput = {
|
||||
"id": graph_id,
|
||||
}
|
||||
if version is not None:
|
||||
graph_where_clause["version"] = version
|
||||
# Prefer team_id scoping over user_id when both are available
|
||||
if not skip_access_check:
|
||||
if team_id is not None:
|
||||
graph_where_clause["teamId"] = team_id
|
||||
elif user_id is not None:
|
||||
graph_where_clause["userId"] = user_id
|
||||
if not skip_access_check and user_id is not None:
|
||||
graph_where_clause["userId"] = user_id
|
||||
|
||||
graph = await AgentGraph.prisma().find_first(
|
||||
where=graph_where_clause,
|
||||
@@ -1338,19 +1333,10 @@ async def set_graph_active_version(graph_id: str, version: int, user_id: str) ->
|
||||
|
||||
|
||||
async def get_graph_all_versions(
|
||||
graph_id: str,
|
||||
user_id: str,
|
||||
limit: int = MAX_GRAPH_VERSIONS_FETCH,
|
||||
team_id: str | None = None,
|
||||
graph_id: str, user_id: str, limit: int = MAX_GRAPH_VERSIONS_FETCH
|
||||
) -> list[GraphModel]:
|
||||
where_clause: AgentGraphWhereInput = {"id": graph_id}
|
||||
if team_id is not None:
|
||||
where_clause["teamId"] = team_id
|
||||
else:
|
||||
where_clause["userId"] = user_id
|
||||
|
||||
graph_versions = await AgentGraph.prisma().find_many(
|
||||
where=where_clause,
|
||||
where={"id": graph_id, "userId": user_id},
|
||||
order={"version": "desc"},
|
||||
include=AGENT_GRAPH_INCLUDE,
|
||||
take=limit,
|
||||
@@ -1508,21 +1494,9 @@ async def is_graph_published_in_marketplace(graph_id: str, graph_version: int) -
|
||||
return marketplace_listing is not None
|
||||
|
||||
|
||||
async def create_graph(
|
||||
graph: Graph,
|
||||
user_id: str,
|
||||
*,
|
||||
organization_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
) -> GraphModel:
|
||||
async def create_graph(graph: Graph, user_id: str) -> GraphModel:
|
||||
async with transaction() as tx:
|
||||
await __create_graph(
|
||||
tx,
|
||||
graph,
|
||||
user_id,
|
||||
organization_id=organization_id,
|
||||
team_id=team_id,
|
||||
)
|
||||
await __create_graph(tx, graph, user_id)
|
||||
|
||||
if created_graph := await get_graph(graph.id, graph.version, user_id=user_id):
|
||||
return created_graph
|
||||
@@ -1530,14 +1504,7 @@ async def create_graph(
|
||||
raise ValueError(f"Created graph {graph.id} v{graph.version} is not in DB")
|
||||
|
||||
|
||||
async def fork_graph(
|
||||
graph_id: str,
|
||||
graph_version: int,
|
||||
user_id: str,
|
||||
*,
|
||||
organization_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
) -> GraphModel:
|
||||
async def fork_graph(graph_id: str, graph_version: int, user_id: str) -> GraphModel:
|
||||
"""
|
||||
Forks a graph by copying it and all its nodes and links to a new graph.
|
||||
"""
|
||||
@@ -1553,25 +1520,12 @@ async def fork_graph(
|
||||
graph.validate_graph(for_run=False)
|
||||
|
||||
async with transaction() as tx:
|
||||
await __create_graph(
|
||||
tx,
|
||||
graph,
|
||||
user_id,
|
||||
organization_id=organization_id,
|
||||
team_id=team_id,
|
||||
)
|
||||
await __create_graph(tx, graph, user_id)
|
||||
|
||||
return graph
|
||||
|
||||
|
||||
async def __create_graph(
|
||||
tx,
|
||||
graph: Graph,
|
||||
user_id: str,
|
||||
*,
|
||||
organization_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
):
|
||||
async def __create_graph(tx, graph: Graph, user_id: str):
|
||||
graphs = [graph] + graph.sub_graphs
|
||||
|
||||
# Auto-increment version for any graph entry (parent or sub-graph) whose
|
||||
@@ -1608,9 +1562,6 @@ async def __create_graph(
|
||||
userId=user_id,
|
||||
forkedFromId=graph.forked_from_id,
|
||||
forkedFromVersion=graph.forked_from_version,
|
||||
# Tenancy dual-write fields
|
||||
organizationId=organization_id,
|
||||
teamId=team_id,
|
||||
)
|
||||
for graph in graphs
|
||||
]
|
||||
|
||||
@@ -170,8 +170,6 @@ async def get_or_create_human_review(
|
||||
input_data: SafeJsonData,
|
||||
message: str,
|
||||
editable: bool,
|
||||
organization_id: str | None = None,
|
||||
team_id: str | None = None,
|
||||
) -> Optional[ReviewResult]:
|
||||
"""
|
||||
Get existing review or create a new pending review entry.
|
||||
@@ -208,8 +206,6 @@ async def get_or_create_human_review(
|
||||
"instructions": message,
|
||||
"editable": editable,
|
||||
"status": ReviewStatus.WAITING,
|
||||
**({"organizationId": organization_id} if organization_id else {}),
|
||||
**({"teamId": team_id} if team_id else {}),
|
||||
},
|
||||
"update": {}, # Do nothing on update - keep existing review as is
|
||||
},
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
"""Org-level credit operations.
|
||||
|
||||
Mirrors the UserCreditBase pattern but operates on OrgBalance and
|
||||
OrgCreditTransaction tables instead of UserBalance and CreditTransaction.
|
||||
|
||||
All balance mutations use atomic SQL to prevent race conditions.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from prisma.enums import CreditTransactionType
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.data.db import prisma
|
||||
from backend.util.exceptions import InsufficientBalanceError
|
||||
from backend.util.json import SafeJson
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _BalanceResult(BaseModel):
|
||||
balance: int
|
||||
|
||||
|
||||
async def get_org_credits(org_id: str) -> int:
|
||||
"""Get the current credit balance for an organization."""
|
||||
balance = await prisma.orgbalance.find_unique(where={"orgId": org_id})
|
||||
return balance.balance if balance else 0
|
||||
|
||||
|
||||
async def spend_org_credits(
|
||||
org_id: str,
|
||||
user_id: str,
|
||||
amount: int,
|
||||
team_id: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> int:
|
||||
"""Atomically spend credits from the org balance.
|
||||
|
||||
Uses a single UPDATE ... WHERE balance >= $amount to prevent race
|
||||
conditions. If the UPDATE affects 0 rows, the balance is insufficient.
|
||||
|
||||
Returns:
|
||||
The remaining balance.
|
||||
|
||||
Raises:
|
||||
InsufficientBalanceError: If the org doesn't have enough credits.
|
||||
"""
|
||||
if amount <= 0:
|
||||
raise ValueError("Spend amount must be positive")
|
||||
|
||||
# Atomic deduct — only succeeds if balance >= amount
|
||||
rows_affected = await prisma.execute_raw(
|
||||
"""
|
||||
UPDATE "OrgBalance"
|
||||
SET "balance" = "balance" - $1, "updatedAt" = NOW()
|
||||
WHERE "orgId" = $2 AND "balance" >= $1
|
||||
""",
|
||||
amount,
|
||||
org_id,
|
||||
)
|
||||
|
||||
if rows_affected == 0:
|
||||
current = await get_org_credits(org_id)
|
||||
raise InsufficientBalanceError(
|
||||
f"Organization has {current} credits but needs {amount}",
|
||||
user_id=user_id,
|
||||
balance=current,
|
||||
amount=amount,
|
||||
)
|
||||
|
||||
# Read the new balance for the transaction record
|
||||
new_balance = await get_org_credits(org_id)
|
||||
|
||||
# Record the transaction
|
||||
tx_data: dict = {
|
||||
"orgId": org_id,
|
||||
"initiatedByUserId": user_id,
|
||||
"amount": -amount,
|
||||
"type": CreditTransactionType.USAGE,
|
||||
"runningBalance": new_balance,
|
||||
}
|
||||
if team_id:
|
||||
tx_data["teamId"] = team_id
|
||||
if metadata:
|
||||
tx_data["metadata"] = SafeJson(metadata)
|
||||
|
||||
await prisma.orgcredittransaction.create(data=tx_data)
|
||||
|
||||
return new_balance
|
||||
|
||||
|
||||
async def top_up_org_credits(
|
||||
org_id: str,
|
||||
amount: int,
|
||||
user_id: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> int:
|
||||
"""Atomically add credits to the org balance.
|
||||
|
||||
Creates the OrgBalance row if it doesn't exist (upsert pattern via raw SQL).
|
||||
|
||||
Returns the new balance.
|
||||
"""
|
||||
if amount <= 0:
|
||||
raise ValueError("Top-up amount must be positive")
|
||||
|
||||
# Atomic upsert — INSERT or UPDATE in one statement
|
||||
await prisma.execute_raw(
|
||||
"""
|
||||
INSERT INTO "OrgBalance" ("orgId", "balance", "updatedAt")
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT ("orgId")
|
||||
DO UPDATE SET "balance" = "OrgBalance"."balance" + $2, "updatedAt" = NOW()
|
||||
""",
|
||||
org_id,
|
||||
amount,
|
||||
)
|
||||
|
||||
new_balance = await get_org_credits(org_id)
|
||||
|
||||
# Record the transaction
|
||||
tx_data: dict = {
|
||||
"orgId": org_id,
|
||||
"amount": amount,
|
||||
"type": CreditTransactionType.TOP_UP,
|
||||
"runningBalance": new_balance,
|
||||
}
|
||||
if user_id:
|
||||
tx_data["initiatedByUserId"] = user_id
|
||||
if metadata:
|
||||
tx_data["metadata"] = SafeJson(metadata)
|
||||
|
||||
await prisma.orgcredittransaction.create(data=tx_data)
|
||||
|
||||
return new_balance
|
||||
|
||||
|
||||
async def get_org_transaction_history(
|
||||
org_id: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""Get credit transaction history for an organization."""
|
||||
transactions = await prisma.orgcredittransaction.find_many(
|
||||
where={"orgId": org_id, "isActive": True},
|
||||
order={"createdAt": "desc"},
|
||||
take=limit,
|
||||
skip=offset,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"transactionKey": t.transactionKey,
|
||||
"createdAt": t.createdAt,
|
||||
"amount": t.amount,
|
||||
"type": t.type,
|
||||
"runningBalance": t.runningBalance,
|
||||
"initiatedByUserId": t.initiatedByUserId,
|
||||
"teamId": t.teamId,
|
||||
"metadata": t.metadata,
|
||||
}
|
||||
for t in transactions
|
||||
]
|
||||
|
||||
|
||||
async def get_seat_info(org_id: str) -> dict:
|
||||
"""Get seat utilization for an organization."""
|
||||
seats = await prisma.organizationseatassignment.find_many(
|
||||
where={"organizationId": org_id}
|
||||
)
|
||||
total = len(seats)
|
||||
active = sum(1 for s in seats if s.status == "ACTIVE")
|
||||
return {
|
||||
"total": total,
|
||||
"active": active,
|
||||
"inactive": total - active,
|
||||
"seats": [
|
||||
{
|
||||
"userId": s.userId,
|
||||
"seatType": s.seatType,
|
||||
"status": s.status,
|
||||
"createdAt": s.createdAt,
|
||||
}
|
||||
for s in seats
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def assign_seat(
|
||||
org_id: str,
|
||||
user_id: str,
|
||||
seat_type: str = "FREE",
|
||||
assigned_by: str | None = None,
|
||||
) -> dict:
|
||||
"""Assign a seat to a user in the organization."""
|
||||
seat = await prisma.organizationseatassignment.upsert(
|
||||
where={
|
||||
"organizationId_userId": {
|
||||
"organizationId": org_id,
|
||||
"userId": user_id,
|
||||
}
|
||||
},
|
||||
data={
|
||||
"create": {
|
||||
"organizationId": org_id,
|
||||
"userId": user_id,
|
||||
"seatType": seat_type,
|
||||
"status": "ACTIVE",
|
||||
"assignedByUserId": assigned_by,
|
||||
},
|
||||
"update": {
|
||||
"seatType": seat_type,
|
||||
"status": "ACTIVE",
|
||||
"assignedByUserId": assigned_by,
|
||||
},
|
||||
},
|
||||
)
|
||||
return {
|
||||
"userId": seat.userId,
|
||||
"seatType": seat.seatType,
|
||||
"status": seat.status,
|
||||
}
|
||||
|
||||
|
||||
async def unassign_seat(org_id: str, user_id: str) -> None:
|
||||
"""Deactivate a user's seat assignment."""
|
||||
await prisma.organizationseatassignment.update(
|
||||
where={
|
||||
"organizationId_userId": {
|
||||
"organizationId": org_id,
|
||||
"userId": user_id,
|
||||
}
|
||||
},
|
||||
data={"status": "INACTIVE"},
|
||||
)
|
||||
@@ -1,215 +0,0 @@
|
||||
"""Tests for org-level credit operations.
|
||||
|
||||
Tests the atomic spend/top-up logic, edge cases, and error paths.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.data.org_credit import (
|
||||
assign_seat,
|
||||
get_org_credits,
|
||||
get_org_transaction_history,
|
||||
get_seat_info,
|
||||
spend_org_credits,
|
||||
top_up_org_credits,
|
||||
unassign_seat,
|
||||
)
|
||||
from backend.util.exceptions import InsufficientBalanceError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_prisma(mocker):
|
||||
mock = MagicMock()
|
||||
mock.orgbalance.find_unique = AsyncMock(return_value=MagicMock(balance=1000))
|
||||
mock.execute_raw = AsyncMock(return_value=1) # 1 row affected = success
|
||||
mock.orgcredittransaction.create = AsyncMock()
|
||||
mock.orgcredittransaction.find_many = AsyncMock(return_value=[])
|
||||
mock.organizationseatassignment.find_many = AsyncMock(return_value=[])
|
||||
mock.organizationseatassignment.upsert = AsyncMock(
|
||||
return_value=MagicMock(userId="u1", seatType="FREE", status="ACTIVE")
|
||||
)
|
||||
mock.organizationseatassignment.update = AsyncMock()
|
||||
mocker.patch("backend.data.org_credit.prisma", mock)
|
||||
return mock
|
||||
|
||||
|
||||
class TestGetOrgCredits:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_balance(self, mock_prisma):
|
||||
result = await get_org_credits("org-1")
|
||||
assert result == 1000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_zero_when_no_balance_row(self, mock_prisma):
|
||||
mock_prisma.orgbalance.find_unique = AsyncMock(return_value=None)
|
||||
result = await get_org_credits("org-missing")
|
||||
assert result == 0
|
||||
|
||||
|
||||
class TestSpendOrgCredits:
|
||||
@pytest.mark.asyncio
|
||||
async def test_spend_success_returns_remaining(self, mock_prisma):
|
||||
# execute_raw returns 1 (row affected) = success
|
||||
mock_prisma.execute_raw = AsyncMock(return_value=1)
|
||||
# After spend, balance reads 900
|
||||
mock_prisma.orgbalance.find_unique = AsyncMock(
|
||||
return_value=MagicMock(balance=900)
|
||||
)
|
||||
|
||||
result = await spend_org_credits("org-1", "user-1", 100)
|
||||
assert result == 900
|
||||
mock_prisma.orgcredittransaction.create.assert_called_once()
|
||||
# Verify transaction data is correct
|
||||
tx_data = mock_prisma.orgcredittransaction.create.call_args[1]["data"]
|
||||
assert tx_data["orgId"] == "org-1"
|
||||
assert tx_data["initiatedByUserId"] == "user-1"
|
||||
assert tx_data["amount"] == -100
|
||||
assert tx_data["runningBalance"] == 900
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spend_insufficient_balance_raises(self, mock_prisma):
|
||||
# execute_raw returns 0 = no row matched (insufficient balance)
|
||||
mock_prisma.execute_raw = AsyncMock(return_value=0)
|
||||
mock_prisma.orgbalance.find_unique = AsyncMock(
|
||||
return_value=MagicMock(balance=50)
|
||||
)
|
||||
|
||||
with pytest.raises(InsufficientBalanceError) as exc_info:
|
||||
await spend_org_credits("org-1", "user-1", 100)
|
||||
|
||||
assert exc_info.value.balance == 50
|
||||
assert exc_info.value.amount == 100
|
||||
# Transaction should NOT be created on failure
|
||||
mock_prisma.orgcredittransaction.create.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spend_zero_amount_raises(self):
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
await spend_org_credits("org-1", "user-1", 0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spend_negative_amount_raises(self):
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
await spend_org_credits("org-1", "user-1", -5)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spend_records_workspace_attribution(self, mock_prisma):
|
||||
mock_prisma.execute_raw = AsyncMock(return_value=1)
|
||||
mock_prisma.orgbalance.find_unique = AsyncMock(
|
||||
return_value=MagicMock(balance=800)
|
||||
)
|
||||
|
||||
await spend_org_credits(
|
||||
"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["teamId"] == "ws-1"
|
||||
assert tx_data["amount"] == -200
|
||||
|
||||
|
||||
class TestTopUpOrgCredits:
|
||||
@pytest.mark.asyncio
|
||||
async def test_top_up_success(self, mock_prisma):
|
||||
mock_prisma.orgbalance.find_unique = AsyncMock(
|
||||
return_value=MagicMock(balance=1500)
|
||||
)
|
||||
|
||||
result = await top_up_org_credits("org-1", 500, user_id="user-1")
|
||||
assert result == 1500
|
||||
mock_prisma.execute_raw.assert_called_once() # Atomic upsert
|
||||
# Verify transaction data
|
||||
mock_prisma.orgcredittransaction.create.assert_called_once()
|
||||
tx_data = mock_prisma.orgcredittransaction.create.call_args[1]["data"]
|
||||
assert tx_data["orgId"] == "org-1"
|
||||
assert tx_data["amount"] == 500
|
||||
assert tx_data["initiatedByUserId"] == "user-1"
|
||||
mock_prisma.orgcredittransaction.create.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_top_up_zero_raises(self):
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
await top_up_org_credits("org-1", 0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_top_up_negative_raises(self):
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
await top_up_org_credits("org-1", -10)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_top_up_no_user_id_omits_from_transaction(self, mock_prisma):
|
||||
mock_prisma.orgbalance.find_unique = AsyncMock(
|
||||
return_value=MagicMock(balance=500)
|
||||
)
|
||||
|
||||
await top_up_org_credits("org-1", 500)
|
||||
|
||||
tx_data = mock_prisma.orgcredittransaction.create.call_args[1]["data"]
|
||||
assert "initiatedByUserId" not in tx_data
|
||||
|
||||
|
||||
class TestGetOrgTransactionHistory:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_transactions(self, mock_prisma):
|
||||
mock_tx = MagicMock(
|
||||
transactionKey="tx-1",
|
||||
createdAt="2026-01-01",
|
||||
amount=-100,
|
||||
type="USAGE",
|
||||
runningBalance=900,
|
||||
initiatedByUserId="user-1",
|
||||
teamId="ws-1",
|
||||
metadata=None,
|
||||
)
|
||||
mock_prisma.orgcredittransaction.find_many = AsyncMock(return_value=[mock_tx])
|
||||
|
||||
result = await get_org_transaction_history("org-1", limit=10)
|
||||
assert len(result) == 1
|
||||
assert result[0]["amount"] == -100
|
||||
assert result[0]["teamId"] == "ws-1"
|
||||
|
||||
|
||||
class TestSeatManagement:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_seat_info(self, mock_prisma):
|
||||
mock_prisma.organizationseatassignment.find_many = AsyncMock(
|
||||
return_value=[
|
||||
MagicMock(
|
||||
userId="u1", seatType="PAID", status="ACTIVE", createdAt="now"
|
||||
),
|
||||
MagicMock(
|
||||
userId="u2", seatType="FREE", status="INACTIVE", createdAt="now"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
result = await get_seat_info("org-1")
|
||||
assert result["total"] == 2
|
||||
assert result["active"] == 1
|
||||
assert result["inactive"] == 1
|
||||
# Verify the query filtered by org
|
||||
call_kwargs = mock_prisma.organizationseatassignment.find_many.call_args[1]
|
||||
assert call_kwargs["where"]["organizationId"] == "org-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_seat(self, mock_prisma):
|
||||
mock_prisma.organizationseatassignment.upsert = AsyncMock(
|
||||
return_value=MagicMock(userId="user-1", seatType="PAID", status="ACTIVE")
|
||||
)
|
||||
result = await assign_seat("org-1", "user-1", seat_type="PAID")
|
||||
assert result["seatType"] == "PAID"
|
||||
mock_prisma.organizationseatassignment.upsert.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unassign_seat(self, mock_prisma):
|
||||
await unassign_seat("org-1", "user-1")
|
||||
mock_prisma.organizationseatassignment.update.assert_called_once()
|
||||
call_kwargs = mock_prisma.organizationseatassignment.update.call_args[1]
|
||||
# Verify correct record targeted
|
||||
where = call_kwargs["where"]["organizationId_userId"]
|
||||
assert where["organizationId"] == "org-1"
|
||||
assert where["userId"] == "user-1"
|
||||
# Verify status set to INACTIVE
|
||||
assert call_kwargs["data"]["status"] == "INACTIVE"
|
||||
@@ -1,463 +0,0 @@
|
||||
"""
|
||||
Data migration: Bootstrap personal organizations for existing users.
|
||||
|
||||
Creates one Organization per user, with owner membership, default workspace,
|
||||
org profile, seat assignment, and org balance. Assigns all tenant-bound
|
||||
resources to the user's default workspace. Idempotent — safe to run repeatedly.
|
||||
|
||||
Run automatically during server startup via rest_api.py lifespan.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import LiteralString
|
||||
|
||||
from backend.data.db import prisma
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _sanitize_slug(raw: str) -> str:
|
||||
"""Convert a string to a URL-safe slug: lowercase, alphanumeric + hyphens."""
|
||||
slug = re.sub(r"[^a-z0-9-]", "-", raw.lower().strip())
|
||||
slug = re.sub(r"-+", "-", slug).strip("-")
|
||||
return slug or "user"
|
||||
|
||||
|
||||
async def _resolve_unique_slug(desired: str) -> str:
|
||||
"""Return *desired* if no Organization uses it yet, else append a numeric suffix."""
|
||||
existing = await prisma.organization.find_unique(where={"slug": desired})
|
||||
if existing is None:
|
||||
# Also check aliases
|
||||
alias = await prisma.organizationalias.find_unique(where={"aliasSlug": desired})
|
||||
if alias is None:
|
||||
return desired
|
||||
|
||||
# Collision — find the next available numeric suffix
|
||||
for i in range(1, 10_000):
|
||||
candidate = f"{desired}-{i}"
|
||||
org = await prisma.organization.find_unique(where={"slug": candidate})
|
||||
alias = await prisma.organizationalias.find_unique(
|
||||
where={"aliasSlug": candidate}
|
||||
)
|
||||
if org is None and alias is None:
|
||||
return candidate
|
||||
|
||||
raise RuntimeError(
|
||||
f"Could not resolve a unique slug for '{desired}' after 10000 attempts"
|
||||
)
|
||||
|
||||
|
||||
async def create_orgs_for_existing_users() -> int:
|
||||
"""Create a personal Organization for every user that lacks one.
|
||||
|
||||
Returns the number of orgs created.
|
||||
"""
|
||||
# Find users who are NOT yet an owner of any personal org
|
||||
users_without_org = await prisma.query_raw(
|
||||
"""
|
||||
SELECT u."id", u."email", u."name", u."stripeCustomerId", u."topUpConfig",
|
||||
p."username" AS profile_username, p."name" AS profile_name,
|
||||
p."description" AS profile_description,
|
||||
p."avatarUrl" AS profile_avatar_url,
|
||||
p."links" AS profile_links
|
||||
FROM "User" u
|
||||
LEFT JOIN "Profile" p ON p."userId" = u."id"
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId"
|
||||
WHERE om."userId" = u."id" AND om."isOwner" = true AND o."isPersonal" = true
|
||||
)
|
||||
""",
|
||||
)
|
||||
|
||||
if not users_without_org:
|
||||
logger.info("Org migration: all users already have personal orgs")
|
||||
return 0
|
||||
|
||||
logger.info(
|
||||
f"Org migration: creating personal orgs for {len(users_without_org)} users"
|
||||
)
|
||||
|
||||
created = 0
|
||||
for row in users_without_org:
|
||||
user_id: str = row["id"]
|
||||
email: str = row["email"]
|
||||
profile_username: str | None = row.get("profile_username")
|
||||
profile_name: str | None = row.get("profile_name")
|
||||
user_name: str | None = row.get("name")
|
||||
|
||||
# Determine slug: Profile.username → sanitized User.name → email local part → user-{id[:8]}
|
||||
if profile_username:
|
||||
desired_slug = _sanitize_slug(profile_username)
|
||||
elif user_name:
|
||||
desired_slug = _sanitize_slug(user_name)
|
||||
else:
|
||||
local_part = email.split("@")[0] if email else ""
|
||||
desired_slug = (
|
||||
_sanitize_slug(local_part) if local_part else f"user-{user_id[:8]}"
|
||||
)
|
||||
|
||||
slug = await _resolve_unique_slug(desired_slug)
|
||||
|
||||
display_name = profile_name or user_name or email.split("@")[0]
|
||||
|
||||
# Create Organization — only include optional JSON fields when non-None
|
||||
org_data: dict = {
|
||||
"name": display_name,
|
||||
"slug": slug,
|
||||
"isPersonal": True,
|
||||
"bootstrapUserId": user_id,
|
||||
"settings": "{}",
|
||||
}
|
||||
if row.get("stripeCustomerId"):
|
||||
org_data["stripeCustomerId"] = row["stripeCustomerId"]
|
||||
if row.get("topUpConfig"):
|
||||
org_data["topUpConfig"] = row["topUpConfig"]
|
||||
|
||||
org = await prisma.organization.create(data=org_data)
|
||||
|
||||
# Create OrgMember (owner)
|
||||
await prisma.orgmember.create(
|
||||
data={
|
||||
"Org": {"connect": {"id": org.id}},
|
||||
"User": {"connect": {"id": user_id}},
|
||||
"isOwner": True,
|
||||
"isAdmin": True,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
)
|
||||
|
||||
# Create default Team
|
||||
workspace = await prisma.team.create(
|
||||
data={
|
||||
"name": "Default",
|
||||
"Org": {"connect": {"id": org.id}},
|
||||
"isDefault": True,
|
||||
"joinPolicy": "OPEN",
|
||||
"createdByUserId": user_id,
|
||||
}
|
||||
)
|
||||
|
||||
# Create TeamMember
|
||||
await prisma.teammember.create(
|
||||
data={
|
||||
"Workspace": {"connect": {"id": workspace.id}},
|
||||
"User": {"connect": {"id": user_id}},
|
||||
"isAdmin": True,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
)
|
||||
|
||||
# Create OrganizationProfile (from user's Profile if exists)
|
||||
profile_data: dict = {
|
||||
"Organization": {"connect": {"id": org.id}},
|
||||
"username": slug,
|
||||
"displayName": display_name,
|
||||
}
|
||||
if row.get("profile_avatar_url"):
|
||||
profile_data["avatarUrl"] = row["profile_avatar_url"]
|
||||
if row.get("profile_description"):
|
||||
profile_data["bio"] = row["profile_description"]
|
||||
if row.get("profile_links"):
|
||||
profile_data["socialLinks"] = row["profile_links"]
|
||||
|
||||
await prisma.organizationprofile.create(data=profile_data)
|
||||
|
||||
# Create seat assignment (FREE seat for personal org)
|
||||
await prisma.organizationseatassignment.create(
|
||||
data={
|
||||
"organizationId": org.id,
|
||||
"userId": user_id,
|
||||
"seatType": "FREE",
|
||||
"status": "ACTIVE",
|
||||
"assignedByUserId": user_id,
|
||||
}
|
||||
)
|
||||
|
||||
# Log if slug diverged from desired (collision resolution)
|
||||
if slug != desired_slug:
|
||||
logger.info(
|
||||
f"Org migration: slug collision for user {user_id} — "
|
||||
f"desired '{desired_slug}', assigned '{slug}'"
|
||||
)
|
||||
|
||||
created += 1
|
||||
|
||||
logger.info(f"Org migration: created {created} personal orgs")
|
||||
return created
|
||||
|
||||
|
||||
async def migrate_org_balances() -> int:
|
||||
"""Copy UserBalance rows into OrgBalance for personal orgs that lack one.
|
||||
|
||||
Returns the number of balances migrated.
|
||||
"""
|
||||
result = await prisma.execute_raw(
|
||||
"""
|
||||
INSERT INTO "OrgBalance" ("orgId", "balance", "updatedAt")
|
||||
SELECT o."id", ub."balance", ub."updatedAt"
|
||||
FROM "UserBalance" ub
|
||||
JOIN "OrgMember" om ON om."userId" = ub."userId" AND om."isOwner" = true
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "OrgBalance" ob WHERE ob."orgId" = o."id"
|
||||
)
|
||||
"""
|
||||
)
|
||||
logger.info(f"Org migration: migrated {result} org balances")
|
||||
return result
|
||||
|
||||
|
||||
async def migrate_credit_transactions() -> int:
|
||||
"""Copy CreditTransaction rows into OrgCreditTransaction for personal orgs.
|
||||
|
||||
Only copies transactions that haven't been migrated yet (by checking for
|
||||
matching transactionKey + orgId).
|
||||
|
||||
Returns the number of transactions migrated.
|
||||
"""
|
||||
result = await prisma.execute_raw(
|
||||
"""
|
||||
INSERT INTO "OrgCreditTransaction"
|
||||
("transactionKey", "createdAt", "orgId", "initiatedByUserId",
|
||||
"amount", "type", "runningBalance", "isActive", "metadata")
|
||||
SELECT
|
||||
ct."transactionKey", ct."createdAt", o."id", ct."userId",
|
||||
ct."amount", ct."type", ct."runningBalance", ct."isActive", ct."metadata"
|
||||
FROM "CreditTransaction" ct
|
||||
JOIN "OrgMember" om ON om."userId" = ct."userId" AND om."isOwner" = true
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "OrgCreditTransaction" oct
|
||||
WHERE oct."transactionKey" = ct."transactionKey" AND oct."orgId" = o."id"
|
||||
)
|
||||
"""
|
||||
)
|
||||
logger.info(f"Org migration: migrated {result} credit transactions")
|
||||
return result
|
||||
|
||||
|
||||
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_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.
|
||||
|
||||
Returns a dict of table_name -> rows_updated.
|
||||
"""
|
||||
results: dict[str, int] = {}
|
||||
|
||||
# --- Tables needing both organizationId + teamId ---
|
||||
|
||||
results["AgentGraph"] = await _assign_team_tenancy(
|
||||
"""
|
||||
UPDATE "AgentGraph" t
|
||||
SET "organizationId" = o."id", "teamId" = w."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = 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_team_tenancy(
|
||||
"""
|
||||
UPDATE "AgentGraphExecution" t
|
||||
SET "organizationId" = o."id", "teamId" = w."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = 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_team_tenancy(
|
||||
"""
|
||||
UPDATE "ChatSession" t
|
||||
SET "organizationId" = o."id", "teamId" = w."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = 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_team_tenancy(
|
||||
"""
|
||||
UPDATE "AgentPreset" t
|
||||
SET "organizationId" = o."id", "teamId" = w."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = 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_team_tenancy(
|
||||
"""
|
||||
UPDATE "LibraryAgent" t
|
||||
SET "organizationId" = o."id", "teamId" = w."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = 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_team_tenancy(
|
||||
"""
|
||||
UPDATE "LibraryFolder" t
|
||||
SET "organizationId" = o."id", "teamId" = w."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = 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_team_tenancy(
|
||||
"""
|
||||
UPDATE "IntegrationWebhook" t
|
||||
SET "organizationId" = o."id", "teamId" = w."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = 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_team_tenancy(
|
||||
"""
|
||||
UPDATE "APIKey" t
|
||||
SET "organizationId" = o."id", "teamId" = w."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = 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
|
||||
"""
|
||||
)
|
||||
|
||||
# --- Tables needing only organizationId ---
|
||||
|
||||
results["BuilderSearchHistory"] = await prisma.execute_raw(
|
||||
"""
|
||||
UPDATE "BuilderSearchHistory" t
|
||||
SET "organizationId" = o."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
|
||||
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
results["PendingHumanReview"] = await prisma.execute_raw(
|
||||
"""
|
||||
UPDATE "PendingHumanReview" t
|
||||
SET "organizationId" = o."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
|
||||
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
results["StoreListingVersion"] = await prisma.execute_raw(
|
||||
"""
|
||||
UPDATE "StoreListingVersion" slv
|
||||
SET "organizationId" = o."id"
|
||||
FROM "StoreListingVersion" v
|
||||
JOIN "StoreListing" sl ON sl."id" = v."storeListingId"
|
||||
JOIN "OrgMember" om ON om."userId" = sl."owningUserId" AND om."isOwner" = true
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
|
||||
WHERE slv."id" = v."id" AND slv."organizationId" IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
for table_name, count in results.items():
|
||||
if count > 0:
|
||||
logger.info(f"Org migration: assigned {count} {table_name} rows")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def migrate_store_listings() -> int:
|
||||
"""Set owningOrgId on StoreListings that lack it.
|
||||
|
||||
Returns the number of listings migrated.
|
||||
"""
|
||||
result = await prisma.execute_raw(
|
||||
"""
|
||||
UPDATE "StoreListing" sl
|
||||
SET "owningOrgId" = o."id"
|
||||
FROM "OrgMember" om
|
||||
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
|
||||
WHERE sl."owningUserId" = om."userId"
|
||||
AND om."isOwner" = true
|
||||
AND sl."owningOrgId" IS NULL
|
||||
"""
|
||||
)
|
||||
if result > 0:
|
||||
logger.info(f"Org migration: assigned {result} store listings to orgs")
|
||||
return result
|
||||
|
||||
|
||||
async def create_store_listing_aliases() -> int:
|
||||
"""Create OrganizationAlias entries for published store listings.
|
||||
|
||||
This ensures that marketplace URLs using the org slug continue to work.
|
||||
Only creates aliases for listings whose org slug matches the user's Profile
|
||||
username (which it should for personal orgs created from Profile.username).
|
||||
|
||||
Returns the number of aliases created.
|
||||
"""
|
||||
result = await prisma.execute_raw(
|
||||
"""
|
||||
INSERT INTO "OrganizationAlias"
|
||||
("id", "organizationId", "aliasSlug", "aliasType", "createdByUserId", "isRemovable")
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
o."id",
|
||||
p."username",
|
||||
'MIGRATION',
|
||||
o."bootstrapUserId",
|
||||
false
|
||||
FROM "StoreListing" sl
|
||||
JOIN "Organization" o ON o."id" = sl."owningOrgId"
|
||||
JOIN "Profile" p ON p."userId" = sl."owningUserId"
|
||||
WHERE sl."owningOrgId" IS NOT NULL
|
||||
AND sl."hasApprovedVersion" = true
|
||||
AND o."slug" != p."username"
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "OrganizationAlias" oa
|
||||
WHERE oa."aliasSlug" = p."username"
|
||||
)
|
||||
"""
|
||||
)
|
||||
if result > 0:
|
||||
logger.info(f"Org migration: created {result} store listing aliases")
|
||||
return result
|
||||
|
||||
|
||||
async def run_migration() -> None:
|
||||
"""Orchestrate the full org bootstrap migration. Idempotent."""
|
||||
start = time.monotonic()
|
||||
logger.info("Org migration: starting personal org bootstrap")
|
||||
|
||||
orgs_created = await create_orgs_for_existing_users()
|
||||
await migrate_org_balances()
|
||||
await migrate_credit_transactions()
|
||||
resource_counts = await assign_resources_to_teams()
|
||||
await migrate_store_listings()
|
||||
await create_store_listing_aliases()
|
||||
|
||||
total_resources = sum(resource_counts.values())
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
logger.info(
|
||||
f"Org migration: complete in {elapsed:.2f}s — "
|
||||
f"{orgs_created} orgs created, {total_resources} resources assigned"
|
||||
)
|
||||
@@ -1,511 +0,0 @@
|
||||
"""Tests for the personal org bootstrap migration.
|
||||
|
||||
Tests the migration logic including slug resolution, idempotency,
|
||||
and correct data mapping. Uses mocks for Prisma DB calls since the
|
||||
test infrastructure does not provide a live database connection.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.data.org_migration import (
|
||||
_resolve_unique_slug,
|
||||
_sanitize_slug,
|
||||
assign_resources_to_teams,
|
||||
create_orgs_for_existing_users,
|
||||
migrate_credit_transactions,
|
||||
migrate_org_balances,
|
||||
migrate_store_listings,
|
||||
run_migration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_prisma(mocker):
|
||||
"""Replace the prisma client in org_migration with a full mock."""
|
||||
mock = MagicMock()
|
||||
# Default: all find_unique calls return None (no collisions)
|
||||
mock.organization.find_unique = AsyncMock(return_value=None)
|
||||
mock.organizationalias.find_unique = AsyncMock(return_value=None)
|
||||
mock.organization.create = AsyncMock(return_value=MagicMock(id="org-1"))
|
||||
mock.orgmember.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=[])
|
||||
mock.execute_raw = AsyncMock(return_value=0)
|
||||
mocker.patch("backend.data.org_migration.prisma", mock)
|
||||
return mock
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sanitize_slug
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSanitizeSlug:
|
||||
def test_lowercase_and_hyphens(self):
|
||||
assert _sanitize_slug("Hello World") == "hello-world"
|
||||
|
||||
def test_strips_special_chars(self):
|
||||
assert _sanitize_slug("user@name!#$%") == "user-name"
|
||||
|
||||
def test_collapses_multiple_hyphens(self):
|
||||
assert _sanitize_slug("a---b") == "a-b"
|
||||
|
||||
def test_strips_leading_trailing_hyphens(self):
|
||||
assert _sanitize_slug("-hello-") == "hello"
|
||||
|
||||
def test_empty_string_returns_user(self):
|
||||
assert _sanitize_slug("") == "user"
|
||||
|
||||
def test_only_special_chars_returns_user(self):
|
||||
assert _sanitize_slug("@#$%") == "user"
|
||||
|
||||
def test_numeric_slug(self):
|
||||
assert _sanitize_slug("12345") == "12345"
|
||||
|
||||
def test_preserves_hyphens(self):
|
||||
assert _sanitize_slug("my-cool-agent") == "my-cool-agent"
|
||||
|
||||
def test_unicode_stripped(self):
|
||||
assert _sanitize_slug("caf\u00e9-latt\u00e9") == "caf-latt"
|
||||
|
||||
def test_whitespace_only(self):
|
||||
assert _sanitize_slug(" ") == "user"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_unique_slug
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveUniqueSlug:
|
||||
@pytest.mark.asyncio
|
||||
async def test_slug_available_returns_as_is(self, mock_prisma):
|
||||
result = await _resolve_unique_slug("my-org")
|
||||
assert result == "my-org"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slug_taken_by_org_gets_suffix(self, mock_prisma):
|
||||
async def org_find(where):
|
||||
slug = where.get("slug", "")
|
||||
if slug == "taken":
|
||||
return MagicMock(id="existing-org")
|
||||
return None
|
||||
|
||||
mock_prisma.organization.find_unique = AsyncMock(side_effect=org_find)
|
||||
|
||||
result = await _resolve_unique_slug("taken")
|
||||
assert result == "taken-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slug_taken_by_alias_gets_suffix(self, mock_prisma):
|
||||
async def alias_find(where):
|
||||
slug = where.get("aliasSlug", "")
|
||||
if slug == "aliased":
|
||||
return MagicMock()
|
||||
return None
|
||||
|
||||
mock_prisma.organizationalias.find_unique = AsyncMock(side_effect=alias_find)
|
||||
|
||||
result = await _resolve_unique_slug("aliased")
|
||||
assert result == "aliased-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_collisions_increments(self, mock_prisma):
|
||||
async def org_find(where):
|
||||
slug = where.get("slug", "")
|
||||
if slug in ("x", "x-1", "x-2"):
|
||||
return MagicMock(id="existing")
|
||||
return None
|
||||
|
||||
mock_prisma.organization.find_unique = AsyncMock(side_effect=org_find)
|
||||
|
||||
result = await _resolve_unique_slug("x")
|
||||
assert result == "x-3"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_orgs_for_existing_users
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateOrgsForExistingUsers:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_users_without_org_is_noop(self, mock_prisma):
|
||||
result = await create_orgs_for_existing_users()
|
||||
assert result == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_with_profile_gets_profile_username_slug(self, mock_prisma):
|
||||
mock_prisma.query_raw = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": "user-1",
|
||||
"email": "alice@example.com",
|
||||
"name": "Alice",
|
||||
"stripeCustomerId": "cus_123",
|
||||
"topUpConfig": None,
|
||||
"profile_username": "alice",
|
||||
"profile_name": "Alice Smith",
|
||||
"profile_description": "A developer",
|
||||
"profile_avatar_url": "https://example.com/avatar.png",
|
||||
"profile_links": ["https://github.com/alice"],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
result = await create_orgs_for_existing_users()
|
||||
assert result == 1
|
||||
|
||||
# Verify org was created with profile-derived slug
|
||||
mock_prisma.organization.create.assert_called_once()
|
||||
create_data = mock_prisma.organization.create.call_args[1]["data"]
|
||||
assert create_data["slug"] == "alice"
|
||||
assert create_data["name"] == "Alice Smith"
|
||||
assert create_data["isPersonal"] is True
|
||||
assert create_data["stripeCustomerId"] == "cus_123"
|
||||
assert create_data["bootstrapUserId"] == "user-1"
|
||||
|
||||
# Verify workspace created
|
||||
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"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_without_profile_uses_email_slug(self, mock_prisma):
|
||||
mock_prisma.query_raw = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": "user-2",
|
||||
"email": "bob@company.org",
|
||||
"name": None,
|
||||
"stripeCustomerId": None,
|
||||
"topUpConfig": None,
|
||||
"profile_username": None,
|
||||
"profile_name": None,
|
||||
"profile_description": None,
|
||||
"profile_avatar_url": None,
|
||||
"profile_links": None,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
result = await create_orgs_for_existing_users()
|
||||
assert result == 1
|
||||
|
||||
create_data = mock_prisma.organization.create.call_args[1]["data"]
|
||||
assert create_data["slug"] == "bob"
|
||||
assert create_data["name"] == "bob"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_with_name_no_profile_uses_name_slug(self, mock_prisma):
|
||||
mock_prisma.query_raw = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": "user-3",
|
||||
"email": "charlie@example.com",
|
||||
"name": "Charlie Brown",
|
||||
"stripeCustomerId": None,
|
||||
"topUpConfig": None,
|
||||
"profile_username": None,
|
||||
"profile_name": None,
|
||||
"profile_description": None,
|
||||
"profile_avatar_url": None,
|
||||
"profile_links": None,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
result = await create_orgs_for_existing_users()
|
||||
assert result == 1
|
||||
|
||||
create_data = mock_prisma.organization.create.call_args[1]["data"]
|
||||
assert create_data["slug"] == "charlie-brown"
|
||||
assert create_data["name"] == "Charlie Brown"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_with_empty_email_uses_id_slug(self, mock_prisma):
|
||||
mock_prisma.query_raw = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": "abcdef12-3456-7890-abcd-ef1234567890",
|
||||
"email": "",
|
||||
"name": None,
|
||||
"stripeCustomerId": None,
|
||||
"topUpConfig": None,
|
||||
"profile_username": None,
|
||||
"profile_name": None,
|
||||
"profile_description": None,
|
||||
"profile_avatar_url": None,
|
||||
"profile_links": None,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
result = await create_orgs_for_existing_users()
|
||||
assert result == 1
|
||||
|
||||
create_data = mock_prisma.organization.create.call_args[1]["data"]
|
||||
assert create_data["slug"] == "user-abcdef12"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stripe_customer_id_included_when_present(self, mock_prisma):
|
||||
mock_prisma.query_raw = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": "user-stripe",
|
||||
"email": "stripe@test.com",
|
||||
"name": "Stripe User",
|
||||
"stripeCustomerId": "cus_abc123",
|
||||
"topUpConfig": '{"amount": 1000}',
|
||||
"profile_username": "stripeuser",
|
||||
"profile_name": "Stripe User",
|
||||
"profile_description": None,
|
||||
"profile_avatar_url": None,
|
||||
"profile_links": None,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
result = await create_orgs_for_existing_users()
|
||||
assert result == 1
|
||||
|
||||
create_data = mock_prisma.organization.create.call_args[1]["data"]
|
||||
assert create_data["stripeCustomerId"] == "cus_abc123"
|
||||
assert create_data["topUpConfig"] == '{"amount": 1000}'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stripe_fields_omitted_when_none(self, mock_prisma):
|
||||
mock_prisma.query_raw = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": "user-no-stripe",
|
||||
"email": "nostripe@test.com",
|
||||
"name": None,
|
||||
"stripeCustomerId": None,
|
||||
"topUpConfig": None,
|
||||
"profile_username": "nostripe",
|
||||
"profile_name": None,
|
||||
"profile_description": None,
|
||||
"profile_avatar_url": None,
|
||||
"profile_links": None,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
result = await create_orgs_for_existing_users()
|
||||
assert result == 1
|
||||
|
||||
create_data = mock_prisma.organization.create.call_args[1]["data"]
|
||||
assert "stripeCustomerId" not in create_data
|
||||
assert "topUpConfig" not in create_data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_org_profile_omits_none_optional_fields(self, mock_prisma):
|
||||
"""Profile creation should not pass None for optional JSON fields."""
|
||||
mock_prisma.query_raw = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": "user-minimal",
|
||||
"email": "minimal@test.com",
|
||||
"name": "Min",
|
||||
"stripeCustomerId": None,
|
||||
"topUpConfig": None,
|
||||
"profile_username": "minimal",
|
||||
"profile_name": "Min",
|
||||
"profile_description": None,
|
||||
"profile_avatar_url": None,
|
||||
"profile_links": None,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
await create_orgs_for_existing_users()
|
||||
|
||||
profile_data = mock_prisma.organizationprofile.create.call_args[1]["data"]
|
||||
assert "avatarUrl" not in profile_data
|
||||
assert "bio" not in profile_data
|
||||
assert "socialLinks" not in profile_data
|
||||
assert profile_data["username"] == "minimal"
|
||||
assert profile_data["displayName"] == "Min"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_all_required_records(self, mock_prisma):
|
||||
"""Verify the full set of records created per user."""
|
||||
mock_prisma.query_raw = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": "user-full",
|
||||
"email": "full@test.com",
|
||||
"name": "Full User",
|
||||
"stripeCustomerId": None,
|
||||
"topUpConfig": None,
|
||||
"profile_username": "fulluser",
|
||||
"profile_name": "Full User",
|
||||
"profile_description": "Bio here",
|
||||
"profile_avatar_url": "https://example.com/avatar.png",
|
||||
"profile_links": ["https://github.com/fulluser"],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
await create_orgs_for_existing_users()
|
||||
|
||||
# Verify all 6 records created
|
||||
mock_prisma.organization.create.assert_called_once()
|
||||
mock_prisma.orgmember.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()
|
||||
|
||||
# Verify OrgMember is owner+admin
|
||||
member_data = mock_prisma.orgmember.create.call_args[1]["data"]
|
||||
assert member_data["isOwner"] is True
|
||||
assert member_data["isAdmin"] is True
|
||||
|
||||
# Verify workspace is default+open
|
||||
ws_data = mock_prisma.team.create.call_args[1]["data"]
|
||||
assert ws_data["isDefault"] is True
|
||||
assert ws_data["joinPolicy"] == "OPEN"
|
||||
|
||||
# Verify seat is FREE+ACTIVE
|
||||
seat_data = mock_prisma.organizationseatassignment.create.call_args[1]["data"]
|
||||
assert seat_data["seatType"] == "FREE"
|
||||
assert seat_data["status"] == "ACTIVE"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# migrate_org_balances
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMigrateOrgBalances:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_count(self, mock_prisma):
|
||||
mock_prisma.execute_raw = AsyncMock(return_value=5)
|
||||
result = await migrate_org_balances()
|
||||
assert result == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# migrate_credit_transactions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMigrateCreditTransactions:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_count(self, mock_prisma):
|
||||
mock_prisma.execute_raw = AsyncMock(return_value=42)
|
||||
result = await migrate_credit_transactions()
|
||||
assert result == 42
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# assign_resources_to_teams
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAssignResources:
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_all_tables(self, mock_prisma, mocker):
|
||||
mocker.patch(
|
||||
"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_teams()
|
||||
|
||||
# 8 tables with workspace + 3 tables org-only = 11 entries
|
||||
assert len(result) == 11
|
||||
assert result["AgentGraph"] == 10
|
||||
assert result["ChatSession"] == 10
|
||||
assert result["BuilderSearchHistory"] == 10
|
||||
assert result["PendingHumanReview"] == 10
|
||||
assert result["StoreListingVersion"] == 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_updates_still_returns(self, mock_prisma, mocker):
|
||||
mocker.patch(
|
||||
"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_teams()
|
||||
assert all(v == 0 for v in result.values())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# migrate_store_listings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMigrateStoreListings:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_count(self, mock_prisma):
|
||||
mock_prisma.execute_raw = AsyncMock(return_value=3)
|
||||
result = await migrate_store_listings()
|
||||
assert result == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_migration (orchestrator)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRunMigration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_calls_all_steps_in_order(self, mocker):
|
||||
calls: list[str] = []
|
||||
|
||||
mocker.patch(
|
||||
"backend.data.org_migration.create_orgs_for_existing_users",
|
||||
new_callable=lambda: lambda: _track(calls, "create_orgs", 1),
|
||||
)
|
||||
mocker.patch(
|
||||
"backend.data.org_migration.migrate_org_balances",
|
||||
new_callable=lambda: lambda: _track(calls, "balances", 0),
|
||||
)
|
||||
mocker.patch(
|
||||
"backend.data.org_migration.migrate_credit_transactions",
|
||||
new_callable=lambda: lambda: _track(calls, "credits", 0),
|
||||
)
|
||||
mocker.patch(
|
||||
"backend.data.org_migration.assign_resources_to_teams",
|
||||
new_callable=lambda: lambda: _track(
|
||||
calls, "assign_resources", {"AgentGraph": 5}
|
||||
),
|
||||
)
|
||||
mocker.patch(
|
||||
"backend.data.org_migration.migrate_store_listings",
|
||||
new_callable=lambda: lambda: _track(calls, "store_listings", 0),
|
||||
)
|
||||
mocker.patch(
|
||||
"backend.data.org_migration.create_store_listing_aliases",
|
||||
new_callable=lambda: lambda: _track(calls, "aliases", 0),
|
||||
)
|
||||
|
||||
await run_migration()
|
||||
|
||||
assert calls == [
|
||||
"create_orgs",
|
||||
"balances",
|
||||
"credits",
|
||||
"assign_resources",
|
||||
"store_listings",
|
||||
"aliases",
|
||||
]
|
||||
|
||||
|
||||
async def _track(calls: list[str], name: str, result):
|
||||
calls.append(name)
|
||||
return result
|
||||
@@ -159,8 +159,6 @@ async def _execute_graph(**kwargs):
|
||||
graph_version=args.graph_version,
|
||||
inputs=args.input_data,
|
||||
graph_credentials_inputs=args.input_credentials,
|
||||
organization_id=args.organization_id,
|
||||
team_id=args.team_id,
|
||||
)
|
||||
await db.increment_onboarding_runs(args.user_id)
|
||||
elapsed = asyncio.get_event_loop().time() - start_time
|
||||
@@ -392,8 +390,6 @@ class GraphExecutionJobArgs(BaseModel):
|
||||
cron: str
|
||||
input_data: GraphInput
|
||||
input_credentials: dict[str, CredentialsMetaInput] = Field(default_factory=dict)
|
||||
organization_id: str | None = None
|
||||
team_id: str | None = None
|
||||
|
||||
|
||||
class GraphExecutionJobInfo(GraphExecutionJobArgs):
|
||||
@@ -671,8 +667,6 @@ class Scheduler(AppService):
|
||||
input_credentials: dict[str, CredentialsMetaInput],
|
||||
name: Optional[str] = None,
|
||||
user_timezone: str | None = None,
|
||||
organization_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
|
||||
@@ -709,8 +703,6 @@ class Scheduler(AppService):
|
||||
cron=cron,
|
||||
input_data=input_data,
|
||||
input_credentials=input_credentials,
|
||||
organization_id=organization_id,
|
||||
team_id=team_id,
|
||||
)
|
||||
job = self.scheduler.add_job(
|
||||
execute_graph,
|
||||
|
||||
@@ -15,7 +15,7 @@ from backend.data import graph as graph_db
|
||||
from backend.data import human_review as human_review_db
|
||||
from backend.data import onboarding as onboarding_db
|
||||
from backend.data import user as user_db
|
||||
from backend.data import workspace as team_db
|
||||
from backend.data import workspace as workspace_db
|
||||
|
||||
# Import dynamic field utilities from centralized location
|
||||
from backend.data.block import BlockInput, BlockOutputEntry
|
||||
@@ -869,8 +869,6 @@ async def add_graph_execution(
|
||||
execution_context: Optional[ExecutionContext] = None,
|
||||
graph_exec_id: Optional[str] = None,
|
||||
dry_run: bool = False,
|
||||
organization_id: Optional[str] = None,
|
||||
team_id: Optional[str] = None,
|
||||
) -> GraphExecutionWithNodes:
|
||||
"""
|
||||
Adds a graph execution to the queue and returns the execution entry.
|
||||
@@ -897,7 +895,7 @@ async def add_graph_execution(
|
||||
udb = user_db
|
||||
gdb = graph_db
|
||||
odb = onboarding_db
|
||||
wdb = team_db
|
||||
wdb = workspace_db
|
||||
else:
|
||||
edb = udb = gdb = odb = wdb = get_database_manager_async_client()
|
||||
|
||||
@@ -955,8 +953,6 @@ async def add_graph_execution(
|
||||
preset_id=preset_id,
|
||||
parent_graph_exec_id=parent_exec_id,
|
||||
is_dry_run=dry_run,
|
||||
organization_id=organization_id,
|
||||
team_id=team_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -987,7 +983,7 @@ async def add_graph_execution(
|
||||
# Execution hierarchy
|
||||
root_execution_id=graph_exec.id,
|
||||
# Workspace (enables workspace:// file resolution in blocks)
|
||||
team_id=workspace.id,
|
||||
workspace_id=workspace.id,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
"""Scoped credential store using the IntegrationCredential table.
|
||||
|
||||
Provides the new credential resolution path (USER → WORKSPACE → ORG)
|
||||
using the IntegrationCredential table introduced in PR1. During the
|
||||
dual-read transition period, callers should try this store first and
|
||||
fall back to the legacy IntegrationCredentialsStore.
|
||||
|
||||
This store is used alongside the existing credentials_store.py which
|
||||
reads from the User.integrations encrypted blob.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from backend.data.db import prisma
|
||||
from backend.util.encryption import JSONCryptor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_cryptor = JSONCryptor()
|
||||
|
||||
|
||||
async def get_scoped_credentials(
|
||||
user_id: str,
|
||||
organization_id: str,
|
||||
team_id: str | None = None,
|
||||
provider: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Get credentials visible to the user in the current org/workspace context.
|
||||
|
||||
Resolution order (per plan 3D):
|
||||
1. USER credentials created by this user in this org
|
||||
2. WORKSPACE credentials for the active workspace (if workspace is set)
|
||||
3. ORG credentials for the active org
|
||||
|
||||
Returns a list of credential metadata dicts (not decrypted payloads).
|
||||
"""
|
||||
results: list[dict] = []
|
||||
|
||||
# 1. User-scoped credentials
|
||||
user_where: dict = {
|
||||
"organizationId": organization_id,
|
||||
"ownerType": "USER",
|
||||
"ownerId": user_id,
|
||||
"status": "active",
|
||||
}
|
||||
if provider:
|
||||
user_where["provider"] = provider
|
||||
|
||||
user_creds = await prisma.integrationcredential.find_many(where=user_where)
|
||||
for c in user_creds:
|
||||
results.append(_cred_to_metadata(c, scope="USER"))
|
||||
|
||||
# 2. Workspace-scoped credentials (only if workspace is active)
|
||||
if team_id:
|
||||
ws_where: dict = {
|
||||
"organizationId": organization_id,
|
||||
"ownerType": "TEAM",
|
||||
"ownerId": team_id,
|
||||
"status": "active",
|
||||
}
|
||||
if provider:
|
||||
ws_where["provider"] = provider
|
||||
|
||||
ws_creds = await prisma.integrationcredential.find_many(where=ws_where)
|
||||
for c in ws_creds:
|
||||
results.append(_cred_to_metadata(c, scope="TEAM"))
|
||||
|
||||
# 3. Org-scoped credentials
|
||||
org_where: dict = {
|
||||
"organizationId": organization_id,
|
||||
"ownerType": "ORG",
|
||||
"ownerId": organization_id,
|
||||
"status": "active",
|
||||
}
|
||||
if provider:
|
||||
org_where["provider"] = provider
|
||||
|
||||
org_creds = await prisma.integrationcredential.find_many(where=org_where)
|
||||
for c in org_creds:
|
||||
results.append(_cred_to_metadata(c, scope="ORG"))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def get_credential_by_id(
|
||||
credential_id: str,
|
||||
user_id: str,
|
||||
organization_id: str,
|
||||
team_id: str | None = None,
|
||||
decrypt: bool = False,
|
||||
) -> Optional[dict]:
|
||||
"""Get a specific credential by ID if the user has access.
|
||||
|
||||
Access rules:
|
||||
- USER creds: only the creating user can access
|
||||
- WORKSPACE creds: any workspace member can access (verified by caller)
|
||||
- ORG creds: any org member can access (verified by caller)
|
||||
"""
|
||||
cred = await prisma.integrationcredential.find_unique(where={"id": credential_id})
|
||||
if cred is None or cred.organizationId != organization_id:
|
||||
return None
|
||||
|
||||
# Access check
|
||||
if cred.ownerType == "USER" and cred.createdByUserId != user_id:
|
||||
return None
|
||||
|
||||
result = _cred_to_metadata(cred, scope=cred.ownerType)
|
||||
if decrypt:
|
||||
result["payload"] = _cryptor.decrypt(cred.encryptedPayload)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def create_credential(
|
||||
organization_id: str,
|
||||
owner_type: str, # USER, WORKSPACE, ORG
|
||||
owner_id: str, # userId, workspaceId, or orgId
|
||||
provider: str,
|
||||
credential_type: str,
|
||||
display_name: str,
|
||||
payload: dict,
|
||||
user_id: str,
|
||||
expires_at=None,
|
||||
metadata: dict | None = None,
|
||||
) -> dict:
|
||||
"""Create a new scoped credential."""
|
||||
encrypted = _cryptor.encrypt(payload)
|
||||
|
||||
cred = await prisma.integrationcredential.create(
|
||||
data={
|
||||
"organizationId": organization_id,
|
||||
"ownerType": owner_type,
|
||||
"ownerId": owner_id,
|
||||
"provider": provider,
|
||||
"credentialType": credential_type,
|
||||
"displayName": display_name,
|
||||
"encryptedPayload": encrypted,
|
||||
"createdByUserId": user_id,
|
||||
"expiresAt": expires_at,
|
||||
"metadata": metadata,
|
||||
}
|
||||
)
|
||||
|
||||
return _cred_to_metadata(cred, scope=owner_type)
|
||||
|
||||
|
||||
async def delete_credential(
|
||||
credential_id: str, user_id: str, organization_id: str
|
||||
) -> None:
|
||||
"""Soft-delete a credential by setting status to 'revoked'."""
|
||||
cred = await prisma.integrationcredential.find_unique(where={"id": credential_id})
|
||||
if cred is None or cred.organizationId != organization_id:
|
||||
raise ValueError(f"Credential {credential_id} not found")
|
||||
|
||||
# Only the creator or an admin can delete (admin check done at route level)
|
||||
await prisma.integrationcredential.update(
|
||||
where={"id": credential_id},
|
||||
data={"status": "revoked"},
|
||||
)
|
||||
|
||||
|
||||
def _cred_to_metadata(cred, scope: str) -> dict:
|
||||
"""Convert a Prisma IntegrationCredential to a metadata dict."""
|
||||
return {
|
||||
"id": cred.id,
|
||||
"provider": cred.provider,
|
||||
"credentialType": cred.credentialType,
|
||||
"displayName": cred.displayName,
|
||||
"scope": scope,
|
||||
"createdByUserId": cred.createdByUserId,
|
||||
"lastUsedAt": cred.lastUsedAt,
|
||||
"expiresAt": cred.expiresAt,
|
||||
"createdAt": cred.createdAt,
|
||||
}
|
||||
@@ -1,553 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TeamJoinPolicy" AS ENUM ('OPEN', 'PRIVATE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ResourceVisibility" AS ENUM ('PRIVATE', 'TEAM', 'ORG');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CredentialScope" AS ENUM ('USER', 'TEAM', 'ORG');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "OrgAliasType" AS ENUM ('MIGRATION', 'RENAME', 'MANUAL');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "OrgMemberStatus" AS ENUM ('INVITED', 'ACTIVE', 'SUSPENDED', 'REMOVED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SeatType" AS ENUM ('FREE', 'PAID');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SeatStatus" AS ENUM ('ACTIVE', 'INACTIVE', 'PENDING');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TransferStatus" AS ENUM ('PENDING', 'SOURCE_APPROVED', 'TARGET_APPROVED', 'COMPLETED', 'REJECTED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CredentialOwnerType" AS ENUM ('USER', 'TEAM', 'ORG');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "BuilderSearchHistory" ADD COLUMN "organizationId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
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 "organizationId" TEXT,
|
||||
ADD COLUMN "teamId" TEXT,
|
||||
ADD COLUMN "visibility" "ResourceVisibility" NOT NULL DEFAULT 'PRIVATE';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "AgentPreset" ADD COLUMN "organizationId" TEXT,
|
||||
ADD COLUMN "teamId" TEXT,
|
||||
ADD COLUMN "visibility" "ResourceVisibility" NOT NULL DEFAULT 'PRIVATE';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "UserNotificationBatch" ADD COLUMN "organizationId" TEXT,
|
||||
ADD COLUMN "teamId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
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 "organizationId" TEXT,
|
||||
ADD COLUMN "teamId" TEXT,
|
||||
ADD COLUMN "visibility" "ResourceVisibility" NOT NULL DEFAULT 'PRIVATE';
|
||||
|
||||
-- AlterTable
|
||||
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 "organizationId" TEXT,
|
||||
ADD COLUMN "teamId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "IntegrationWebhook" ADD COLUMN "organizationId" TEXT,
|
||||
ADD COLUMN "teamId" TEXT,
|
||||
ADD COLUMN "visibility" "ResourceVisibility" NOT NULL DEFAULT 'PRIVATE';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "StoreListing" ADD COLUMN "owningOrgId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "StoreListingVersion" ADD COLUMN "organizationId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "APIKey" ADD COLUMN "organizationId" TEXT,
|
||||
ADD COLUMN "ownerType" "CredentialOwnerType",
|
||||
ADD COLUMN "teamId" TEXT,
|
||||
ADD COLUMN "teamIdRestriction" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "OAuthApplication" ADD COLUMN "organizationId" TEXT,
|
||||
ADD COLUMN "ownerType" "CredentialOwnerType",
|
||||
ADD COLUMN "teamIdRestriction" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Organization" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"avatarUrl" TEXT,
|
||||
"description" TEXT,
|
||||
"isPersonal" BOOLEAN NOT NULL DEFAULT false,
|
||||
"settings" JSONB NOT NULL DEFAULT '{}',
|
||||
"stripeCustomerId" TEXT,
|
||||
"stripeSubscriptionId" TEXT,
|
||||
"topUpConfig" JSONB,
|
||||
"archivedAt" TIMESTAMP(3),
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
"bootstrapUserId" TEXT,
|
||||
|
||||
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrganizationAlias" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"aliasSlug" TEXT NOT NULL,
|
||||
"aliasType" "OrgAliasType" NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdByUserId" TEXT,
|
||||
"removedAt" TIMESTAMP(3),
|
||||
"removedByUserId" TEXT,
|
||||
"isRemovable" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "OrganizationAlias_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrganizationProfile" (
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"displayName" TEXT,
|
||||
"avatarUrl" TEXT,
|
||||
"bio" TEXT,
|
||||
"socialLinks" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "OrganizationProfile_pkey" PRIMARY KEY ("organizationId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrgMember" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"orgId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"isOwner" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isBillingManager" BOOLEAN NOT NULL DEFAULT false,
|
||||
"status" "OrgMemberStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"invitedByUserId" TEXT,
|
||||
|
||||
CONSTRAINT "OrgMember_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrgInvitation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"orgId" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"targetUserId" TEXT,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isBillingManager" BOOLEAN NOT NULL DEFAULT false,
|
||||
"token" TEXT NOT NULL,
|
||||
"tokenHash" TEXT,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"acceptedAt" TIMESTAMP(3),
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"invitedByUserId" TEXT NOT NULL,
|
||||
"teamIds" TEXT[],
|
||||
|
||||
CONSTRAINT "OrgInvitation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Team" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT,
|
||||
"description" TEXT,
|
||||
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||
"joinPolicy" "TeamJoinPolicy" NOT NULL DEFAULT 'OPEN',
|
||||
"orgId" TEXT NOT NULL,
|
||||
"archivedAt" TIMESTAMP(3),
|
||||
"createdByUserId" TEXT,
|
||||
|
||||
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamMember" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"teamId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isBillingManager" BOOLEAN NOT NULL DEFAULT false,
|
||||
"status" "OrgMemberStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"invitedByUserId" TEXT,
|
||||
|
||||
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamInvite" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"teamId" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"targetUserId" TEXT,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isBillingManager" BOOLEAN NOT NULL DEFAULT false,
|
||||
"token" TEXT NOT NULL,
|
||||
"tokenHash" TEXT,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"acceptedAt" TIMESTAMP(3),
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"invitedByUserId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "TeamInvite_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrganizationSubscription" (
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"planCode" TEXT,
|
||||
"planTier" TEXT,
|
||||
"stripeCustomerId" TEXT,
|
||||
"stripeSubscriptionId" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"renewalAt" TIMESTAMP(3),
|
||||
"cancelAt" TIMESTAMP(3),
|
||||
"entitlements" JSONB NOT NULL DEFAULT '{}',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "OrganizationSubscription_pkey" PRIMARY KEY ("organizationId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrganizationSeatAssignment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"seatType" "SeatType" NOT NULL DEFAULT 'FREE',
|
||||
"status" "SeatStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"assignedByUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "OrganizationSeatAssignment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrgBalance" (
|
||||
"orgId" TEXT NOT NULL,
|
||||
"balance" INTEGER NOT NULL DEFAULT 0,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "OrgBalance_pkey" PRIMARY KEY ("orgId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrgCreditTransaction" (
|
||||
"transactionKey" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"orgId" TEXT NOT NULL,
|
||||
"initiatedByUserId" TEXT,
|
||||
"teamId" TEXT,
|
||||
"amount" INTEGER NOT NULL,
|
||||
"type" "CreditTransactionType" NOT NULL,
|
||||
"runningBalance" INTEGER,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"metadata" JSONB,
|
||||
|
||||
CONSTRAINT "OrgCreditTransaction_pkey" PRIMARY KEY ("transactionKey","orgId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TransferRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"resourceType" TEXT NOT NULL,
|
||||
"resourceId" TEXT NOT NULL,
|
||||
"sourceOrganizationId" TEXT NOT NULL,
|
||||
"targetOrganizationId" TEXT NOT NULL,
|
||||
"initiatedByUserId" TEXT NOT NULL,
|
||||
"status" "TransferStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"sourceApprovedByUserId" TEXT,
|
||||
"targetApprovedByUserId" TEXT,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"reason" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TransferRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT,
|
||||
"teamId" TEXT,
|
||||
"actorUserId" TEXT NOT NULL,
|
||||
"entityType" TEXT NOT NULL,
|
||||
"entityId" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"beforeJson" JSONB,
|
||||
"afterJson" JSONB,
|
||||
"correlationId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "IntegrationCredential" (
|
||||
"id" TEXT NOT NULL,
|
||||
"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,
|
||||
"encryptedPayload" TEXT NOT NULL,
|
||||
"createdByUserId" TEXT NOT NULL,
|
||||
"lastUsedAt" TIMESTAMP(3),
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"metadata" JSONB,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "IntegrationCredential_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Organization_slug_key" ON "Organization"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OrganizationAlias_aliasSlug_key" ON "OrganizationAlias"("aliasSlug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrganizationAlias_aliasSlug_idx" ON "OrganizationAlias"("aliasSlug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrganizationAlias_organizationId_idx" ON "OrganizationAlias"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OrganizationProfile_username_key" ON "OrganizationProfile"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrgMember_userId_idx" ON "OrgMember"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrgMember_orgId_status_idx" ON "OrgMember"("orgId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OrgMember_orgId_userId_key" ON "OrgMember"("orgId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OrgInvitation_token_key" ON "OrgInvitation"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrgInvitation_email_idx" ON "OrgInvitation"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrgInvitation_token_idx" ON "OrgInvitation"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrgInvitation_orgId_idx" ON "OrgInvitation"("orgId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Team_orgId_isDefault_idx" ON "Team"("orgId", "isDefault");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Team_orgId_joinPolicy_idx" ON "Team"("orgId", "joinPolicy");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Team_orgId_name_key" ON "Team"("orgId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamMember_userId_idx" ON "TeamMember"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamMember_teamId_status_idx" ON "TeamMember"("teamId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TeamMember_teamId_userId_key" ON "TeamMember"("teamId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TeamInvite_token_key" ON "TeamInvite"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamInvite_email_idx" ON "TeamInvite"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamInvite_token_idx" ON "TeamInvite"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamInvite_teamId_idx" ON "TeamInvite"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrganizationSeatAssignment_organizationId_status_idx" ON "OrganizationSeatAssignment"("organizationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OrganizationSeatAssignment_organizationId_userId_key" ON "OrganizationSeatAssignment"("organizationId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrgCreditTransaction_orgId_createdAt_idx" ON "OrgCreditTransaction"("orgId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrgCreditTransaction_initiatedByUserId_idx" ON "OrgCreditTransaction"("initiatedByUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TransferRequest_sourceOrganizationId_idx" ON "TransferRequest"("sourceOrganizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TransferRequest_targetOrganizationId_idx" ON "TransferRequest"("targetOrganizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TransferRequest_status_idx" ON "TransferRequest"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_organizationId_createdAt_idx" ON "AuditLog"("organizationId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_actorUserId_createdAt_idx" ON "AuditLog"("actorUserId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_entityType_entityId_idx" ON "AuditLog"("entityType", "entityId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "IntegrationCredential_organizationId_ownerType_provider_idx" ON "IntegrationCredential"("organizationId", "ownerType", "provider");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "IntegrationCredential_ownerId_ownerType_idx" ON "IntegrationCredential"("ownerId", "ownerType");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "IntegrationCredential_createdByUserId_idx" ON "IntegrationCredential"("createdByUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ChatSession_teamId_updatedAt_idx" ON "ChatSession"("teamId", "updatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AgentGraph_teamId_isActive_id_version_idx" ON "AgentGraph"("teamId", "isActive", "id", "version");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AgentPreset_teamId_idx" ON "AgentPreset"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LibraryAgent_teamId_idx" ON "LibraryAgent"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
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_teamId_idx" ON "APIKey"("teamId");
|
||||
|
||||
-- AddForeignKey
|
||||
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_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
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_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
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_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
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_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;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrganizationProfile" ADD CONSTRAINT "OrganizationProfile_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrgMember" ADD CONSTRAINT "OrgMember_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrgMember" ADD CONSTRAINT "OrgMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrgInvitation" ADD CONSTRAINT "OrgInvitation_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Team" ADD CONSTRAINT "Team_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
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;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrganizationSeatAssignment" ADD CONSTRAINT "OrganizationSeatAssignment_organizationId_userId_fkey" FOREIGN KEY ("organizationId", "userId") REFERENCES "OrgMember"("orgId", "userId") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrganizationSeatAssignment" ADD CONSTRAINT "OrganizationSeatAssignment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrgBalance" ADD CONSTRAINT "OrgBalance_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OrgCreditTransaction" ADD CONSTRAINT "OrgCreditTransaction_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE NO ACTION ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TransferRequest" ADD CONSTRAINT "TransferRequest_sourceOrganizationId_fkey" FOREIGN KEY ("sourceOrganizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TransferRequest" ADD CONSTRAINT "TransferRequest_targetOrganizationId_fkey" FOREIGN KEY ("targetOrganizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
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 ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -80,10 +80,6 @@ model User {
|
||||
OAuthAuthorizationCodes OAuthAuthorizationCode[]
|
||||
OAuthAccessTokens OAuthAccessToken[]
|
||||
OAuthRefreshTokens OAuthRefreshToken[]
|
||||
|
||||
// Organization & Workspace relations
|
||||
OrgMemberships OrgMember[]
|
||||
TeamMemberships TeamMember[]
|
||||
}
|
||||
|
||||
enum SubscriptionTier {
|
||||
@@ -217,9 +213,6 @@ model BuilderSearchHistory {
|
||||
|
||||
userId String
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
@@ -253,14 +246,7 @@ model ChatSession {
|
||||
|
||||
Messages ChatMessage[]
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
teamId String?
|
||||
visibility ResourceVisibility @default(PRIVATE)
|
||||
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId, updatedAt])
|
||||
@@index([teamId, updatedAt])
|
||||
}
|
||||
|
||||
model ChatMessage {
|
||||
@@ -318,16 +304,9 @@ model AgentGraph {
|
||||
LibraryAgents LibraryAgent[]
|
||||
StoreListingVersions StoreListingVersion[]
|
||||
|
||||
// Tenancy columns (nullable during migration, NOT NULL after cutover)
|
||||
organizationId String?
|
||||
teamId String?
|
||||
visibility ResourceVisibility @default(PRIVATE)
|
||||
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@id(name: "graphVersionId", [id, version])
|
||||
@@index([userId, isActive, id, version])
|
||||
@@index([forkedFromId, forkedFromVersion])
|
||||
@@index([teamId, isActive, id, version])
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
@@ -368,16 +347,9 @@ model AgentPreset {
|
||||
|
||||
isDeleted Boolean @default(false)
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
teamId String?
|
||||
visibility ResourceVisibility @default(PRIVATE)
|
||||
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId])
|
||||
@@index([agentGraphId, agentGraphVersion])
|
||||
@@index([webhookId])
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
enum NotificationType {
|
||||
@@ -421,10 +393,6 @@ model UserNotificationBatch {
|
||||
|
||||
Notifications NotificationEvent[]
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
teamId String?
|
||||
|
||||
// Each user can only have one batch of a notification type at a time
|
||||
@@unique([userId, type])
|
||||
}
|
||||
@@ -460,17 +428,10 @@ model LibraryAgent {
|
||||
|
||||
settings Json @default("{}")
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
teamId String?
|
||||
visibility ResourceVisibility @default(PRIVATE)
|
||||
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([userId, agentGraphId, agentGraphVersion])
|
||||
@@index([agentGraphId, agentGraphVersion])
|
||||
@@index([creatorId])
|
||||
@@index([folderId])
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
model LibraryFolder {
|
||||
@@ -493,12 +454,6 @@ model LibraryFolder {
|
||||
|
||||
LibraryAgents LibraryAgent[]
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
teamId String?
|
||||
visibility ResourceVisibility @default(PRIVATE)
|
||||
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([userId, parentId, name]) // Name unique per parent per user
|
||||
}
|
||||
|
||||
@@ -632,19 +587,12 @@ model AgentGraphExecution {
|
||||
shareToken String? @unique
|
||||
sharedAt DateTime?
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
teamId String?
|
||||
visibility ResourceVisibility @default(PRIVATE)
|
||||
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([agentGraphId, agentGraphVersion])
|
||||
@@index([userId, isDeleted, createdAt])
|
||||
@@index([createdAt])
|
||||
@@index([agentPresetId])
|
||||
@@index([shareToken])
|
||||
@@index([parentGraphExecutionId])
|
||||
@@index([teamId, isDeleted, createdAt])
|
||||
}
|
||||
|
||||
// This model describes the execution of an AgentNode.
|
||||
@@ -737,10 +685,6 @@ model PendingHumanReview {
|
||||
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
teamId String?
|
||||
|
||||
@@unique([nodeExecId]) // One pending review per node execution
|
||||
@@index([userId, status])
|
||||
@@index([graphExecId, status])
|
||||
@@ -767,12 +711,6 @@ model IntegrationWebhook {
|
||||
|
||||
AgentNodes AgentNode[]
|
||||
AgentPresets AgentPreset[]
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
teamId String?
|
||||
visibility ResourceVisibility @default(PRIVATE)
|
||||
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
|
||||
}
|
||||
|
||||
model AnalyticsDetails {
|
||||
@@ -836,68 +774,6 @@ enum CreditTransactionType {
|
||||
CARD_CHECK
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////
|
||||
////////// ORGANIZATION ORGANIZATION & WORKSPACE ENUMS TEAM ENUMS /////////////
|
||||
////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////
|
||||
|
||||
enum TeamJoinPolicy {
|
||||
OPEN
|
||||
PRIVATE
|
||||
}
|
||||
|
||||
enum ResourceVisibility {
|
||||
PRIVATE
|
||||
TEAM
|
||||
ORG
|
||||
}
|
||||
|
||||
enum CredentialScope {
|
||||
USER
|
||||
TEAM
|
||||
ORG
|
||||
}
|
||||
|
||||
enum OrgAliasType {
|
||||
MIGRATION
|
||||
RENAME
|
||||
MANUAL
|
||||
}
|
||||
|
||||
enum OrgMemberStatus {
|
||||
INVITED
|
||||
ACTIVE
|
||||
SUSPENDED
|
||||
REMOVED
|
||||
}
|
||||
|
||||
enum SeatType {
|
||||
FREE
|
||||
PAID
|
||||
}
|
||||
|
||||
enum SeatStatus {
|
||||
ACTIVE
|
||||
INACTIVE
|
||||
PENDING
|
||||
}
|
||||
|
||||
enum TransferStatus {
|
||||
PENDING
|
||||
SOURCE_APPROVED
|
||||
TARGET_APPROVED
|
||||
COMPLETED
|
||||
REJECTED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum CredentialOwnerType {
|
||||
USER
|
||||
TEAM
|
||||
ORG
|
||||
}
|
||||
|
||||
model CreditTransaction {
|
||||
transactionKey String @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
@@ -1137,7 +1013,7 @@ model StoreListing {
|
||||
ActiveVersion StoreListingVersion? @relation("ActiveVersion", fields: [activeVersionId], references: [id])
|
||||
|
||||
// The agent link here is only so we can do lookup on agentId
|
||||
agentGraphId String @unique
|
||||
agentGraphId String @unique
|
||||
|
||||
owningUserId String
|
||||
OwningUser User @relation(fields: [owningUserId], references: [id])
|
||||
@@ -1146,14 +1022,9 @@ model StoreListing {
|
||||
// Relations
|
||||
Versions StoreListingVersion[] @relation("ListingVersions")
|
||||
|
||||
// Tenancy — owningOrgId will replace owningUserId as canonical owner after cutover
|
||||
owningOrgId String?
|
||||
OwningOrg Organization? @relation("OrgStoreListings", fields: [owningOrgId], references: [id])
|
||||
|
||||
@@unique([owningUserId, slug])
|
||||
// Used in the view query
|
||||
@@index([isDeleted, hasApprovedVersion])
|
||||
@@index([owningOrgId])
|
||||
}
|
||||
|
||||
model StoreListingVersion {
|
||||
@@ -1214,9 +1085,6 @@ model StoreListingVersion {
|
||||
// Note: Embeddings now stored in UnifiedContentEmbedding table
|
||||
// Use contentType=STORE_AGENT and contentId=storeListingVersionId
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
|
||||
@@unique([storeListingId, version])
|
||||
@@index([storeListingId, submissionStatus, isAvailable])
|
||||
@@index([submissionStatus])
|
||||
@@ -1325,16 +1193,8 @@ model APIKey {
|
||||
userId String
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
teamId String?
|
||||
ownerType CredentialOwnerType?
|
||||
teamIdRestriction String?
|
||||
Team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([head, name])
|
||||
@@index([userId, status])
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
model UserBalance {
|
||||
@@ -1388,11 +1248,6 @@ model OAuthApplication {
|
||||
AccessTokens OAuthAccessToken[]
|
||||
RefreshTokens OAuthRefreshToken[]
|
||||
|
||||
// Tenancy
|
||||
organizationId String?
|
||||
ownerType CredentialOwnerType?
|
||||
teamIdRestriction String?
|
||||
|
||||
@@index([clientId])
|
||||
@@index([ownerId])
|
||||
}
|
||||
@@ -1467,321 +1322,3 @@ model OAuthRefreshToken {
|
||||
@@index([userId, applicationId])
|
||||
@@index([expiresAt]) // For cleanup
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////
|
||||
//////// ORGANIZATION ORGANIZATION & WORKSPACE MODELS TEAM MODELS //////////////
|
||||
////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////
|
||||
|
||||
model Organization {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
name String
|
||||
slug String @unique
|
||||
avatarUrl String?
|
||||
description String?
|
||||
isPersonal Boolean @default(false)
|
||||
settings Json @default("{}")
|
||||
stripeCustomerId String?
|
||||
stripeSubscriptionId String?
|
||||
topUpConfig Json?
|
||||
archivedAt DateTime?
|
||||
deletedAt DateTime?
|
||||
bootstrapUserId String?
|
||||
|
||||
Members OrgMember[]
|
||||
Teams Team[]
|
||||
Aliases OrganizationAlias[]
|
||||
Balance OrgBalance?
|
||||
CreditTransactions OrgCreditTransaction[]
|
||||
Invitations OrgInvitation[]
|
||||
StoreListings StoreListing[] @relation("OrgStoreListings")
|
||||
Credentials IntegrationCredential[]
|
||||
Profile OrganizationProfile?
|
||||
Subscription OrganizationSubscription?
|
||||
SeatAssignments OrganizationSeatAssignment[]
|
||||
TransfersFrom TransferRequest[] @relation("TransferSource")
|
||||
TransfersTo TransferRequest[] @relation("TransferTarget")
|
||||
AuditLogs AuditLog[]
|
||||
// slug already has @unique which creates an index — no separate @@index needed
|
||||
}
|
||||
|
||||
model OrganizationAlias {
|
||||
id String @id @default(uuid())
|
||||
organizationId String
|
||||
aliasSlug String @unique
|
||||
aliasType OrgAliasType
|
||||
createdAt DateTime @default(now())
|
||||
createdByUserId String?
|
||||
removedAt DateTime?
|
||||
removedByUserId String?
|
||||
isRemovable Boolean @default(true)
|
||||
|
||||
Organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([aliasSlug])
|
||||
@@index([organizationId])
|
||||
}
|
||||
|
||||
model OrganizationProfile {
|
||||
organizationId String @id
|
||||
username String @unique
|
||||
displayName String?
|
||||
avatarUrl String?
|
||||
bio String?
|
||||
socialLinks Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
Organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model OrgMember {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
orgId String
|
||||
userId String
|
||||
isOwner Boolean @default(false)
|
||||
isAdmin Boolean @default(false)
|
||||
isBillingManager Boolean @default(false)
|
||||
status OrgMemberStatus @default(ACTIVE)
|
||||
joinedAt DateTime @default(now())
|
||||
invitedByUserId String?
|
||||
|
||||
Org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
SeatAssignment OrganizationSeatAssignment? @relation
|
||||
|
||||
@@unique([orgId, userId])
|
||||
@@index([userId])
|
||||
@@index([orgId, status])
|
||||
}
|
||||
|
||||
model OrgInvitation {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
orgId String
|
||||
email String
|
||||
targetUserId String?
|
||||
isAdmin Boolean @default(false)
|
||||
isBillingManager Boolean @default(false)
|
||||
token String @unique @default(uuid())
|
||||
tokenHash String?
|
||||
expiresAt DateTime
|
||||
acceptedAt DateTime?
|
||||
revokedAt DateTime?
|
||||
invitedByUserId String
|
||||
teamIds String[]
|
||||
|
||||
Org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([email])
|
||||
@@index([token])
|
||||
@@index([orgId])
|
||||
}
|
||||
|
||||
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 TeamJoinPolicy @default(OPEN)
|
||||
orgId String
|
||||
archivedAt DateTime?
|
||||
createdByUserId String?
|
||||
|
||||
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("TeamCredentials")
|
||||
TeamInvites TeamInvite[]
|
||||
|
||||
@@unique([orgId, name])
|
||||
@@index([orgId, isDefault])
|
||||
@@index([orgId, joinPolicy])
|
||||
}
|
||||
|
||||
model TeamMember {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
teamId String
|
||||
userId String
|
||||
isAdmin Boolean @default(false)
|
||||
isBillingManager Boolean @default(false)
|
||||
status OrgMemberStatus @default(ACTIVE)
|
||||
joinedAt DateTime @default(now())
|
||||
invitedByUserId String?
|
||||
|
||||
Team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamId, userId])
|
||||
@@index([userId])
|
||||
@@index([teamId, status])
|
||||
}
|
||||
|
||||
model TeamInvite {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
teamId String
|
||||
email String
|
||||
targetUserId String?
|
||||
isAdmin Boolean @default(false)
|
||||
isBillingManager Boolean @default(false)
|
||||
token String @unique @default(uuid())
|
||||
tokenHash String?
|
||||
expiresAt DateTime
|
||||
acceptedAt DateTime?
|
||||
revokedAt DateTime?
|
||||
invitedByUserId String
|
||||
|
||||
Team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([email])
|
||||
@@index([token])
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
model OrganizationSubscription {
|
||||
organizationId String @id
|
||||
planCode String?
|
||||
planTier String?
|
||||
stripeCustomerId String?
|
||||
stripeSubscriptionId String?
|
||||
status String @default("active")
|
||||
renewalAt DateTime?
|
||||
cancelAt DateTime?
|
||||
entitlements Json @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
Organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model OrganizationSeatAssignment {
|
||||
id String @id @default(uuid())
|
||||
organizationId String
|
||||
userId String
|
||||
seatType SeatType @default(FREE)
|
||||
status SeatStatus @default(ACTIVE)
|
||||
assignedByUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
OrgMember OrgMember? @relation(fields: [organizationId, userId], references: [orgId, userId])
|
||||
Organization Organization @relation(fields: [organizationId], references: [id])
|
||||
|
||||
@@unique([organizationId, userId])
|
||||
@@index([organizationId, status])
|
||||
}
|
||||
|
||||
model OrgBalance {
|
||||
orgId String @id
|
||||
balance Int @default(0)
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
Org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model OrgCreditTransaction {
|
||||
transactionKey String @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
orgId String
|
||||
initiatedByUserId String?
|
||||
teamId String?
|
||||
amount Int
|
||||
type CreditTransactionType
|
||||
runningBalance Int?
|
||||
isActive Boolean @default(true)
|
||||
metadata Json?
|
||||
|
||||
Org Organization @relation(fields: [orgId], references: [id], onDelete: NoAction)
|
||||
|
||||
@@id(name: "orgCreditTransactionIdentifier", [transactionKey, orgId])
|
||||
@@index([orgId, createdAt])
|
||||
@@index([initiatedByUserId])
|
||||
}
|
||||
|
||||
model TransferRequest {
|
||||
id String @id @default(uuid())
|
||||
resourceType String
|
||||
resourceId String
|
||||
sourceOrganizationId String
|
||||
targetOrganizationId String
|
||||
initiatedByUserId String
|
||||
status TransferStatus @default(PENDING)
|
||||
sourceApprovedByUserId String?
|
||||
targetApprovedByUserId String?
|
||||
completedAt DateTime?
|
||||
reason String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
SourceOrg Organization @relation("TransferSource", fields: [sourceOrganizationId], references: [id])
|
||||
TargetOrg Organization @relation("TransferTarget", fields: [targetOrganizationId], references: [id])
|
||||
|
||||
@@index([sourceOrganizationId])
|
||||
@@index([targetOrganizationId])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(uuid())
|
||||
organizationId String?
|
||||
teamId String?
|
||||
actorUserId String
|
||||
entityType String
|
||||
entityId String?
|
||||
action String
|
||||
beforeJson Json?
|
||||
afterJson Json?
|
||||
correlationId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
Organization Organization? @relation(fields: [organizationId], references: [id])
|
||||
|
||||
@@index([organizationId, createdAt])
|
||||
@@index([actorUserId, createdAt])
|
||||
@@index([entityType, entityId])
|
||||
}
|
||||
|
||||
model IntegrationCredential {
|
||||
id String @id @default(uuid())
|
||||
organizationId String
|
||||
ownerType CredentialOwnerType
|
||||
ownerId String // userId, teamId, or orgId depending on ownerType
|
||||
teamId String? // Dedicated FK for workspace-scoped creds (set when ownerType=TEAM)
|
||||
provider String
|
||||
credentialType String
|
||||
displayName String
|
||||
encryptedPayload String
|
||||
createdByUserId String
|
||||
lastUsedAt DateTime?
|
||||
status String @default("active")
|
||||
metadata Json?
|
||||
expiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
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])
|
||||
@@index([createdByUserId])
|
||||
}
|
||||
|
||||
@@ -112,17 +112,6 @@ class TestDataCreator:
|
||||
self.api_keys: List[Dict[str, Any]] = []
|
||||
self.presets: List[Dict[str, Any]] = []
|
||||
self.profiles: List[Dict[str, Any]] = []
|
||||
# Org/workspace context per user (populated after migration runs)
|
||||
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, 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_team
|
||||
|
||||
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]
|
||||
|
||||
async def create_test_users(self) -> List[Dict[str, Any]]:
|
||||
"""Create test users using Supabase client."""
|
||||
@@ -377,14 +366,8 @@ class TestDataCreator:
|
||||
)
|
||||
|
||||
try:
|
||||
# Use the API function to create graph with org context
|
||||
org_id, ws_id = await self._get_user_org_ws(user["id"])
|
||||
created_graph = await create_graph(
|
||||
graph,
|
||||
user["id"],
|
||||
organization_id=org_id,
|
||||
team_id=ws_id,
|
||||
)
|
||||
# Use the API function to create graph
|
||||
created_graph = await create_graph(graph, user["id"])
|
||||
graph_dict = created_graph.model_dump()
|
||||
# Ensure userId is included for store submissions
|
||||
graph_dict["userId"] = user["id"]
|
||||
|
||||
@@ -69,20 +69,6 @@ export const customMutator = async <
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// Inject org/team context headers
|
||||
try {
|
||||
const activeOrgID = localStorage.getItem("active-org-id");
|
||||
const activeTeamID = localStorage.getItem("active-team-id");
|
||||
if (activeOrgID) {
|
||||
headers["X-Org-Id"] = activeOrgID;
|
||||
}
|
||||
if (activeTeamID) {
|
||||
headers["X-Team-Id"] = activeTeamID;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Org context: Failed to access localStorage:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const isFormData = data instanceof FormData;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ 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 OrgTeamProvider from "@/providers/org-team/OrgTeamProvider";
|
||||
import {
|
||||
PostHogPageViewTracker,
|
||||
PostHogProvider,
|
||||
@@ -31,15 +30,13 @@ export function Providers({ children, ...props }: ThemeProviderProps) {
|
||||
<PostHogPageViewTracker />
|
||||
</Suspense>
|
||||
<CredentialsProvider>
|
||||
<OrgTeamProvider>
|
||||
<LaunchDarklyProvider>
|
||||
<OnboardingProvider>
|
||||
<ThemeProvider forcedTheme="light" {...props}>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</OnboardingProvider>
|
||||
</LaunchDarklyProvider>
|
||||
</OrgTeamProvider>
|
||||
<LaunchDarklyProvider>
|
||||
<OnboardingProvider>
|
||||
<ThemeProvider forcedTheme="light" {...props}>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</OnboardingProvider>
|
||||
</LaunchDarklyProvider>
|
||||
</CredentialsProvider>
|
||||
</BackendAPIProvider>
|
||||
</PostHogProvider>
|
||||
|
||||
@@ -13,7 +13,6 @@ import { environment } from "@/services/environment";
|
||||
import { AccountMenu } from "./components/AccountMenu/AccountMenu";
|
||||
import { FeedbackButton } from "./components/FeedbackButton";
|
||||
import { AgentActivityDropdown } from "./components/AgentActivityDropdown/AgentActivityDropdown";
|
||||
import { OrgTeamSwitcher } from "./components/OrgTeamSwitcher/OrgTeamSwitcher";
|
||||
import { LoginButton } from "./components/LoginButton";
|
||||
import { MobileNavBar } from "./components/MobileNavbar/MobileNavBar";
|
||||
import { NavbarLink } from "./components/NavbarLink";
|
||||
@@ -94,7 +93,6 @@ export function Navbar() {
|
||||
{isLoggedIn && !isSmallScreen ? (
|
||||
<div className="flex flex-1 items-center justify-end gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<OrgTeamSwitcher />
|
||||
<FeedbackButton />
|
||||
<AgentActivityDropdown />
|
||||
{profile && <Wallet key={profile.username} />}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import Avatar, {
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/atoms/Avatar/Avatar";
|
||||
import { useOrgTeamSwitcher } from "./useOrgTeamSwitcher";
|
||||
import { CaretDown, Check, Plus, GearSix } from "@phosphor-icons/react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function OrgTeamSwitcher() {
|
||||
const {
|
||||
orgs,
|
||||
teams,
|
||||
activeOrg,
|
||||
activeTeam,
|
||||
switchOrg,
|
||||
switchTeam,
|
||||
isLoaded,
|
||||
} = useOrgTeamSwitcher();
|
||||
|
||||
if (!isLoaded || orgs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-1.5 rounded-lg bg-white/60 px-2.5 py-1.5 text-sm font-medium text-neutral-700 hover:bg-white/80"
|
||||
aria-label="Switch organization"
|
||||
data-testid="org-switcher-trigger"
|
||||
>
|
||||
<Avatar className="h-5 w-5">
|
||||
<AvatarImage
|
||||
src={activeOrg?.avatarUrl ?? ""}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<AvatarFallback className="text-xs" aria-hidden="true">
|
||||
{activeOrg?.name?.charAt(0) || "O"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="max-w-[8rem] truncate">{activeOrg?.name}</span>
|
||||
<CaretDown size={12} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className="flex w-64 flex-col gap-2 rounded-xl bg-white p-2 shadow-lg"
|
||||
align="end"
|
||||
data-testid="org-switcher-popover"
|
||||
>
|
||||
{/* Org list */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="px-2 py-1 text-xs font-medium uppercase text-neutral-400">
|
||||
Organizations
|
||||
</span>
|
||||
{orgs.map((org) => (
|
||||
<button
|
||||
key={org.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={() => switchOrg(org.id)}
|
||||
>
|
||||
<Avatar className="h-5 w-5">
|
||||
<AvatarImage src={org.avatarUrl ?? ""} alt="" />
|
||||
<AvatarFallback className="text-xs">
|
||||
{org.name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 truncate text-left">{org.name}</span>
|
||||
{org.isPersonal && (
|
||||
<span className="text-xs text-neutral-400">Personal</span>
|
||||
)}
|
||||
{org.id === activeOrg?.id && (
|
||||
<Check size={14} className="text-green-600" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<Link
|
||||
href="/org/settings"
|
||||
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-neutral-500 hover:bg-neutral-100"
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>Create organization</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
Teams
|
||||
</span>
|
||||
{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={() => 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 === activeTeam?.id && (
|
||||
<Check size={14} className="text-green-600" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<Link
|
||||
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 teams</span>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useOrgTeamStore } from "@/services/org-team/store";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
|
||||
export function useOrgTeamSwitcher() {
|
||||
const {
|
||||
orgs,
|
||||
teams,
|
||||
activeOrgID,
|
||||
activeTeamID,
|
||||
setActiveOrg,
|
||||
setActiveTeam,
|
||||
isLoaded,
|
||||
} = useOrgTeamStore();
|
||||
|
||||
const activeOrg = orgs.find((o) => o.id === activeOrgID) || null;
|
||||
const activeTeam = teams.find((w) => w.id === activeTeamID) || null;
|
||||
|
||||
function switchOrg(orgID: string) {
|
||||
if (orgID === activeOrgID) return;
|
||||
setActiveOrg(orgID);
|
||||
// Clear cache to force refetch with new org context
|
||||
const queryClient = getQueryClient();
|
||||
queryClient.clear();
|
||||
}
|
||||
|
||||
function switchTeam(teamID: string) {
|
||||
if (teamID === activeTeamID) return;
|
||||
setActiveTeam(teamID);
|
||||
// Clear cache for team-scoped data
|
||||
const queryClient = getQueryClient();
|
||||
queryClient.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
orgs,
|
||||
teams,
|
||||
activeOrg,
|
||||
activeTeam,
|
||||
switchOrg,
|
||||
switchTeam,
|
||||
isLoaded,
|
||||
};
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { useOrgTeamStore } from "@/services/org-team/store";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 teams for the active org
|
||||
*
|
||||
* On org/team switch: clears React Query cache to force refetch.
|
||||
*/
|
||||
export default function OrgTeamProvider({ children }: Props) {
|
||||
const { isLoggedIn, user } = useSupabase();
|
||||
const { activeOrgID, setActiveOrg, setOrgs, setLoaded, clearContext } =
|
||||
useOrgTeamStore();
|
||||
|
||||
const prevOrgID = useRef(activeOrgID);
|
||||
|
||||
// Fetch orgs when logged in
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || !user) {
|
||||
clearContext();
|
||||
return;
|
||||
}
|
||||
|
||||
async function loadOrgs() {
|
||||
try {
|
||||
const res = await fetch("/api/proxy/api/orgs", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const orgs = data.data || data;
|
||||
setOrgs(orgs);
|
||||
|
||||
// If no active org, set the personal org as default
|
||||
if (!activeOrgID && orgs.length > 0) {
|
||||
const personal = orgs.find(
|
||||
(o: { isPersonal: boolean }) => o.isPersonal,
|
||||
);
|
||||
if (personal) {
|
||||
setActiveOrg(personal.id);
|
||||
} else {
|
||||
setActiveOrg(orgs[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
setLoaded(true);
|
||||
} catch {
|
||||
setLoaded(true);
|
||||
}
|
||||
}
|
||||
|
||||
loadOrgs();
|
||||
}, [isLoggedIn, user]);
|
||||
|
||||
// Clear React Query cache when org switches
|
||||
useEffect(() => {
|
||||
if (prevOrgID.current !== activeOrgID && prevOrgID.current !== null) {
|
||||
const queryClient = getQueryClient();
|
||||
queryClient.clear();
|
||||
}
|
||||
prevOrgID.current = activeOrgID;
|
||||
}, [activeOrgID]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Key, storage } from "@/services/storage/local-storage";
|
||||
import { create } from "zustand";
|
||||
|
||||
interface Org {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
avatarUrl: string | null;
|
||||
isPersonal: boolean;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string | null;
|
||||
isDefault: boolean;
|
||||
joinPolicy: string;
|
||||
orgId: string;
|
||||
}
|
||||
|
||||
interface OrgTeamState {
|
||||
activeOrgID: string | null;
|
||||
activeTeamID: string | null;
|
||||
orgs: Org[];
|
||||
teams: Team[];
|
||||
isLoaded: boolean;
|
||||
|
||||
setActiveOrg(orgID: string): void;
|
||||
setActiveTeam(teamID: string | null): void;
|
||||
setOrgs(orgs: Org[]): void;
|
||||
setTeams(teams: Team[]): void;
|
||||
setLoaded(loaded: boolean): void;
|
||||
clearContext(): void;
|
||||
}
|
||||
|
||||
export const useOrgTeamStore = create<OrgTeamState>((set) => ({
|
||||
activeOrgID: storage.get(Key.ACTIVE_ORG) || null,
|
||||
activeTeamID: storage.get(Key.ACTIVE_TEAM) || null,
|
||||
orgs: [],
|
||||
teams: [],
|
||||
isLoaded: false,
|
||||
|
||||
setActiveOrg(orgID: string) {
|
||||
storage.set(Key.ACTIVE_ORG, orgID);
|
||||
set({ activeOrgID: orgID, activeTeamID: null });
|
||||
// Clear team when switching org — provider will resolve default
|
||||
storage.clean(Key.ACTIVE_TEAM);
|
||||
},
|
||||
|
||||
setActiveTeam(teamID: string | null) {
|
||||
if (teamID) {
|
||||
storage.set(Key.ACTIVE_TEAM, teamID);
|
||||
} else {
|
||||
storage.clean(Key.ACTIVE_TEAM);
|
||||
}
|
||||
set({ activeTeamID: teamID });
|
||||
},
|
||||
|
||||
setOrgs(orgs: Org[]) {
|
||||
set({ orgs });
|
||||
},
|
||||
|
||||
setTeams(teams: Team[]) {
|
||||
set({ teams });
|
||||
},
|
||||
|
||||
setLoaded(loaded: boolean) {
|
||||
set({ isLoaded: loaded });
|
||||
},
|
||||
|
||||
clearContext() {
|
||||
storage.clean(Key.ACTIVE_ORG);
|
||||
storage.clean(Key.ACTIVE_TEAM);
|
||||
set({
|
||||
activeOrgID: null,
|
||||
activeTeamID: null,
|
||||
orgs: [],
|
||||
teams: [],
|
||||
isLoaded: false,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -15,8 +15,6 @@ export enum Key {
|
||||
COPILOT_NOTIFICATIONS_ENABLED = "copilot-notifications-enabled",
|
||||
COPILOT_NOTIFICATION_BANNER_DISMISSED = "copilot-notification-banner-dismissed",
|
||||
COPILOT_NOTIFICATION_DIALOG_DISMISSED = "copilot-notification-dialog-dismissed",
|
||||
ACTIVE_ORG = "active-org-id",
|
||||
ACTIVE_TEAM = "active-team-id",
|
||||
COPILOT_ARTIFACT_PANEL_WIDTH = "copilot-artifact-panel-width",
|
||||
COPILOT_MODE = "copilot-mode",
|
||||
COPILOT_COMPLETED_SESSIONS = "copilot-completed-sessions",
|
||||
|
||||
Reference in New Issue
Block a user