//! Unified structure endpoint for the recursive zoom canvas. //! //! `GET /api/structure/{level}/{id}` returns one level of the //! `org ▸ company ▸ team ▸ claw` hierarchy in a single polymorphic shape: the //! level's own topology graph plus its children (the tier below) with their //! drill targets. The canvas fetches depth-1 lazily — drilling into a child //! issues another request for that child's level — so a large org never serializes //! its whole subtree at once. use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::Json; use cm_domain::AgentId; use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; use crate::{ApiError, AppState, Authed}; /// One child node (the tier below) with where it drills to. #[derive(Serialize)] pub struct StructureChild { /// The graph node id this child binds to (matches a node in `graph`). pub node_id: String, pub role: String, /// The child's level: "company" | "team" | "claw". pub child_level: String, /// The child's id — the drill target (`/{child_level}s/{child_id}` or, for /// a claw, its chat at `/claws/{child_id}`). pub child_id: String, pub child_name: String, } /// One level of the hierarchy: its graph + the children to drill into. #[derive(Serialize)] pub struct StructureNode { /// "org" | "company" | "team" | "claw". pub level: String, pub id: String, pub name: String, /// Topology kind at this level (None for a claw leaf). pub kind: Option, /// This level's TopologyGraph (None for a claw leaf). pub graph: Option, pub children: Vec, } /// Workspace-wide hierarchy counts for the breadcrumb + status bar. #[derive(Serialize)] pub struct StructureStats { pub org_count: usize, pub company_count: usize, pub team_count: usize, pub claw_count: usize, pub running_now: i64, } /// `GET /api/structure/stats` — counts across the whole workspace hierarchy. pub async fn stats( State(state): State, Authed(user): Authed, ) -> Result, ApiError> { let ws = user.workspace_id; let orgs = cm_db::repo::orgs::list_for_workspace(&state.pool, ws, 1000).await?; let companies = cm_db::repo::companies::list_for_workspace(&state.pool, ws, 1000).await?; let teams = cm_db::repo::teams::list_for_workspace(&state.pool, ws, 1000).await?; let claws = cm_db::repo::agents::roster(&state.pool, ws).await?; let running_now = cm_db::repo::topology_runs::count_active(&state.pool, ws).await?; Ok(Json(StructureStats { org_count: orgs.len(), company_count: companies.len(), team_count: teams.len(), claw_count: claws.len(), running_now, })) } /// `GET /api/structure/{level}/{id}` — one level of the recursive hierarchy. pub async fn node( State(state): State, Authed(user): Authed, Path((level, id)): Path<(String, Uuid)>, ) -> Result, ApiError> { let ws = user.workspace_id; match level.as_str() { "org" => { let org = cm_db::repo::orgs::get(&state.pool, id, ws).await?; let bindings = cm_db::repo::orgs::companies_for_org(&state.pool, id).await?; let mut children = Vec::with_capacity(bindings.len()); for b in bindings { let name = cm_db::repo::companies::get(&state.pool, b.company_id, ws) .await .map(|c| c.name) .unwrap_or_default(); children.push(StructureChild { node_id: b.node_id, role: b.role, child_level: "company".into(), child_id: b.company_id.to_string(), child_name: name, }); } Ok(Json(StructureNode { level: "org".into(), id: org.id.to_string(), name: org.name, kind: Some(org.kind), graph: Some(org.graph), children, })) } "company" => { let company = cm_db::repo::companies::get(&state.pool, id, ws).await?; let bindings = cm_db::repo::companies::teams_for_company(&state.pool, id).await?; let mut children = Vec::with_capacity(bindings.len()); for b in bindings { let name = cm_db::repo::teams::get_team(&state.pool, b.team_id, ws) .await .map(|t| t.name) .unwrap_or_default(); children.push(StructureChild { node_id: b.node_id, role: b.role, child_level: "team".into(), child_id: b.team_id.to_string(), child_name: name, }); } Ok(Json(StructureNode { level: "company".into(), id: company.id.to_string(), name: company.name, kind: Some(company.kind), graph: Some(company.graph), children, })) } "team" => { let team = cm_db::repo::teams::get_team(&state.pool, id, ws).await?; let members = cm_db::repo::teams::members_for_team(&state.pool, id).await?; let mut children = Vec::with_capacity(members.len()); for m in members { let name = cm_db::repo::agents::get(&state.pool, AgentId::from(m.claw_id)) .await .ok() .filter(|a| a.workspace_id == ws) .map(|a| a.name) .unwrap_or_default(); children.push(StructureChild { node_id: m.node_id, role: m.role, child_level: "claw".into(), child_id: m.claw_id.to_string(), child_name: name, }); } Ok(Json(StructureNode { level: "team".into(), id: team.id.to_string(), name: team.name, kind: Some(team.kind), graph: Some(team.graph), children, })) } "claw" => { let agent = cm_db::repo::agents::get(&state.pool, AgentId::from(id)).await?; if agent.workspace_id != ws { return Err(ApiError::NotFound); } Ok(Json(StructureNode { level: "claw".into(), id: agent.id.to_string(), name: agent.name, kind: None, graph: None, children: vec![], })) } _ => Err(ApiError::BadRequest), } } // ── Orphan discovery + reification ────────────────────────────────────── // // The dashboard synthesizes "My Workspace / Direct / Ungrouped" containers // whenever the workspace has entities that aren't fully wired into the // org → company → team chain. These endpoints replace those synthetics // with real DB rows so the sidebar and the world viz stop showing // placeholder scaffolding. /// `GET /api/structure/orphan-counts` — how many agents/teams/companies /// would be scaffolded under a synthetic container. Zero on all three /// means the workspace is fully wired. #[derive(Serialize)] pub struct OrphanCountsOut { pub agents: i64, pub teams: i64, pub companies: i64, } pub async fn orphan_counts( State(state): State, Authed(user): Authed, ) -> Result, ApiError> { let c = cm_db::repo::structure_reify::count_orphans(&state.pool, user.workspace_id).await?; Ok(Json(OrphanCountsOut { agents: c.agents, teams: c.teams, companies: c.companies, })) } /// `POST /api/structure/ensure-chain` — guarantee the workspace has an /// org + company chain and return coordinates for it. Idempotent: reuses /// existing rows when present, creates placeholder ones otherwise. /// Wizards call this before creating a team so first-team-in-a-fresh- /// workspace lands under real parents instead of synthesized scaffolding. #[derive(Deserialize, Default)] pub struct EnsureChainRequest { /// Placeholder name used only if we have to CREATE the org. #[serde(default)] pub fallback_org_name: Option, /// Placeholder name used only if we have to CREATE the company. #[serde(default)] pub fallback_company_name: Option, } #[derive(Serialize)] pub struct EnsuredChainOut { pub org_id: String, pub company_id: String, /// `true` when either row was freshly created by this call. pub created: bool, } pub async fn ensure_chain( State(state): State, Authed(user): Authed, Json(body): Json, ) -> Result, ApiError> { let org_name = body .fallback_org_name .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .unwrap_or("My Workspace"); let company_name = body .fallback_company_name .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .unwrap_or("General"); let r = cm_db::repo::structure_reify::ensure_chain( &state.pool, user.workspace_id, org_name, company_name, ) .await?; Ok(Json(EnsuredChainOut { org_id: r.org_id.to_string(), company_id: r.company_id.to_string(), created: r.created, })) } /// `POST /api/structure/reify-orphans` — create a real org+company+team /// chain with the provided names and re-parent every orphan into it, all /// in one transaction. Returns the freshly-created ids so the client can /// select them after refresh. #[derive(Deserialize)] pub struct ReifyRequest { pub org_name: String, pub company_name: String, pub team_name: String, } #[derive(Serialize)] pub struct ReifiedOut { pub org_id: String, pub company_id: String, pub team_id: String, pub moved_agents: i64, pub moved_teams: i64, pub moved_companies: i64, } pub async fn reify_orphans( State(state): State, Authed(user): Authed, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let org = body.org_name.trim(); let company = body.company_name.trim(); let team = body.team_name.trim(); if org.is_empty() || company.is_empty() || team.is_empty() { return Err(ApiError::BadRequest); } let r = cm_db::repo::structure_reify::reify_orphans( &state.pool, user.workspace_id, org, company, team, ) .await?; Ok(( StatusCode::CREATED, Json(ReifiedOut { org_id: r.org_id.to_string(), company_id: r.company_id.to_string(), team_id: r.team_id.to_string(), moved_agents: r.moved_agents, moved_teams: r.moved_teams, moved_companies: r.moved_companies, }), )) }