//! Persistence for orgs — a baseline topology whose nodes are whole companies //! (the top rung of the deploy ladder). `orgs` holds the topology graph; //! `org_companies` is the durable node→company binding. Mirrors `companies` //! one tier up; the recursive executor runs each bound company's sub-topology, //! which in turn runs its teams, which run their claws. use cm_domain::WorkspaceId; use serde_json::Value; use sqlx::PgPool; use time::OffsetDateTime; use uuid::Uuid; use crate::DbError; /// An org row summary (list view). pub struct OrgSummary { pub id: Uuid, pub name: String, pub kind: String, pub status: String, pub created_at: OffsetDateTime, } /// A full org (graph + metadata). pub struct Org { pub id: Uuid, pub name: String, pub kind: String, pub graph: Value, pub status: String, pub created_at: OffsetDateTime, } /// A node→company binding within an org. pub struct OrgCompany { pub node_id: String, pub company_id: Uuid, pub role: String, } /// Insert an org (the topology graph). Members are added separately. pub async fn insert_org( pool: &PgPool, id: Uuid, workspace_id: WorkspaceId, name: &str, kind: &str, graph: &Value, ) -> Result<(), DbError> { sqlx::query!( "INSERT INTO orgs (id, workspace_id, name, kind, graph) VALUES ($1, $2, $3, $4, $5)", id, workspace_id.as_uuid(), name, kind, graph, ) .execute(pool) .await?; Ok(()) } /// Bind a company to a topology node within an org. pub async fn add_company( pool: &PgPool, org_id: Uuid, node_id: &str, company_id: Uuid, role: &str, ) -> Result<(), DbError> { sqlx::query!( "INSERT INTO org_companies (org_id, node_id, company_id, role) VALUES ($1, $2, $3, $4)", org_id, node_id, company_id, role, ) .execute(pool) .await?; Ok(()) } /// The most recent orgs for a workspace, newest first. pub async fn list_for_workspace( pool: &PgPool, workspace_id: WorkspaceId, limit: i64, ) -> Result, DbError> { let rows = sqlx::query!( "SELECT id, name, kind, status, created_at FROM orgs WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2", workspace_id.as_uuid(), limit, ) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|r| OrgSummary { id: r.id, name: r.name, kind: r.kind, status: r.status, created_at: r.created_at, }) .collect()) } /// A single org, workspace-scoped. pub async fn get(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result { let row = sqlx::query!( "SELECT id, name, kind, graph, status, created_at FROM orgs WHERE id = $1 AND workspace_id = $2", id, workspace_id.as_uuid(), ) .fetch_optional(pool) .await? .ok_or(DbError::NotFound)?; Ok(Org { id: row.id, name: row.name, kind: row.kind, graph: row.graph, status: row.status, created_at: row.created_at, }) } /// The node→company bindings for an org. pub async fn companies_for_org(pool: &PgPool, org_id: Uuid) -> Result, DbError> { let rows = sqlx::query!( "SELECT node_id, company_id, role FROM org_companies WHERE org_id = $1 ORDER BY node_id", org_id, ) .fetch_all(pool) .await?; Ok(rows .into_iter() .map(|r| OrgCompany { node_id: r.node_id, company_id: r.company_id, role: r.role, }) .collect()) } /// Delete an org. Structural only: the `org_companies` links are removed /// (cascade) so the companies survive, just ungrouped from this org. /// Rename an org in-place. The name is the only mutable identity field. /// Returns NotFound when the id isn't visible in the caller's workspace. pub async fn rename_org( pool: &PgPool, id: Uuid, workspace_id: WorkspaceId, name: &str, ) -> Result<(), DbError> { let res = sqlx::query("UPDATE orgs SET name = $3 WHERE id = $1 AND workspace_id = $2") .bind(id) .bind(workspace_id.as_uuid()) .bind(name) .execute(pool) .await?; if res.rows_affected() == 0 { return Err(DbError::NotFound); } Ok(()) } pub async fn delete_org(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<(), DbError> { sqlx::query("DELETE FROM org_companies WHERE org_id = $1") .bind(id) .execute(pool) .await?; let res = sqlx::query("DELETE FROM orgs WHERE id = $1 AND workspace_id = $2") .bind(id) .bind(workspace_id.as_uuid()) .execute(pool) .await?; if res.rows_affected() == 0 { return Err(DbError::NotFound); } Ok(()) } /// Distinct company ids bound to an org via `org_companies`. The /// cascade-reap path drills through here to collect teams → agents. pub async fn companies_of_org(pool: &PgPool, org_id: Uuid) -> Result, DbError> { let rows = sqlx::query!( "SELECT DISTINCT company_id FROM org_companies WHERE org_id = $1", org_id, ) .fetch_all(pool) .await?; Ok(rows.into_iter().map(|r| r.company_id).collect()) }