structure polish: post-reify nav + ensure-chain + TeamWizard auto-parent
Two small quality-of-life fixes on top of the reify commit:
Post-reify navigation
OrphanMigrationDialog already returned team_id in its result;
Dashboard now pushes /?team=<team_id> before router.refresh() so
the user lands on the freshly-materialized team and sees exactly
where their agents just moved. Previously they had to hunt for it
in the newly-rebuilt sidebar.
Wizard auto-materialize (POST /api/structure/ensure-chain)
cm-db: ensure_chain(pool, ws, fallback_org, fallback_company) —
fast path returns coordinates of the first org+company already
bound in this workspace (workspace's oldest org, oldest company
under it). Slow path inserts a new org+company with the
fallback names ("My Workspace" / "General") + binds them via
org_companies. Returns { org_id, company_id, created }. Small
txn — leaves the workspace consistent whether it was already
wired or not.
cm-api: POST /api/structure/ensure-chain accepts optional
fallback_org_name and fallback_company_name in the body (trimmed,
else default). Returns the ids.
CreateTeamRequest gains an optional attach_to_company_id. When
set, after build_team() completes, we look up the company
(workspace ownership check enforced by companies::get), count
its existing teams for a stable n_i node id, and insert a
company_teams binding — so the team lands under the parent
atomically instead of a follow-up round-trip.
TeamWizard now calls ensure-chain before POST /api/teams and
passes the returned company_id in attach_to_company_id. Both
calls are best-effort — if ensure-chain fails (network etc.)
we still try to create the team, and the migration dialog stays
available as the fallback UX. Wizard flow now: fresh workspace's
first team is fully wired from the moment it appears in the
tree — no synthetic "My Workspace" scaffolding ever gets
rendered around it.
The Team/Company create paths not touched here (create_team_from_claws,
company create, org create, MasterPlannerModal scaffold) still
work as before — they just won't auto-parent yet. Later commits
can wire them the same way.
This commit is contained in:
+28
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT oc.org_id AS org_id, oc.company_id AS company_id\n FROM org_companies oc\n JOIN orgs o ON o.id = oc.org_id\n WHERE o.workspace_id = $1\n ORDER BY o.created_at\n LIMIT 1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "org_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "company_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "4cccf6d1f49e67d752fdd54dfab52c56a80381374b03dc7c4c7cd4d86e976080"
|
||||||
|
}
|
||||||
@@ -460,6 +460,10 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/structure/orphan-counts",
|
"/api/structure/orphan-counts",
|
||||||
get(routes::structure::orphan_counts),
|
get(routes::structure::orphan_counts),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/structure/ensure-chain",
|
||||||
|
post(routes::structure::ensure_chain),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/structure/reify-orphans",
|
"/api/structure/reify-orphans",
|
||||||
post(routes::structure::reify_orphans),
|
post(routes::structure::reify_orphans),
|
||||||
|
|||||||
@@ -210,6 +210,58 @@ pub async fn orphan_counts(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `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<String>,
|
||||||
|
/// Placeholder name used only if we have to CREATE the company.
|
||||||
|
#[serde(default)]
|
||||||
|
pub fallback_company_name: Option<String>,
|
||||||
|
}
|
||||||
|
#[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<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Json(body): Json<EnsureChainRequest>,
|
||||||
|
) -> Result<Json<EnsuredChainOut>, 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
|
/// `POST /api/structure/reify-orphans` — create a real org+company+team
|
||||||
/// chain with the provided names and re-parent every orphan into it, all
|
/// 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
|
/// in one transaction. Returns the freshly-created ids so the client can
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ pub struct CreateTeamRequest {
|
|||||||
/// TopologyKind (snake_case), e.g. "hierarchical", "pipeline".
|
/// TopologyKind (snake_case), e.g. "hierarchical", "pipeline".
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub members: Vec<TeamMemberInput>,
|
pub members: Vec<TeamMemberInput>,
|
||||||
|
/// Optional parent company — when provided, the freshly-created team
|
||||||
|
/// gets bound to it via `company_teams` so it never lands orphaned.
|
||||||
|
/// Wizards typically fetch this from `POST /api/structure/ensure-chain`
|
||||||
|
/// so the workspace always has a valid parent before team creation.
|
||||||
|
#[serde(default)]
|
||||||
|
pub attach_to_company_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -159,6 +165,18 @@ pub async fn create_team(
|
|||||||
&body.members,
|
&body.members,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
// Auto-parent the new team when the wizard fetched a company via
|
||||||
|
// `POST /api/structure/ensure-chain`. Ownership check + node_id
|
||||||
|
// allocation happen inline so the team never lands orphaned mid-turn.
|
||||||
|
if let Some(company_id) = body.attach_to_company_id {
|
||||||
|
let company = cm_db::repo::companies::get(&state.pool, company_id, user.workspace_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::NotFound)?;
|
||||||
|
let existing = cm_db::repo::companies::teams_for_company(&state.pool, company.id).await?;
|
||||||
|
let node_id = format!("n{}", existing.len());
|
||||||
|
cm_db::repo::companies::add_team(&state.pool, company.id, &node_id, team_id, "team")
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
Json(TeamCreated {
|
Json(TeamCreated {
|
||||||
|
|||||||
@@ -117,6 +117,97 @@ pub async fn count_orphans(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Result of [`ensure_chain`]: the ids of the org + company the caller
|
||||||
|
/// should attach new teams under. `created` is `true` if either row was
|
||||||
|
/// freshly inserted by this call, `false` when we reused what already
|
||||||
|
/// existed. Wizards use this to guarantee a workspace has a parent chain
|
||||||
|
/// before creating a team, without asking the user for names — the two
|
||||||
|
/// created rows use the `fallback_*` names and can be renamed inline.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct EnsuredChain {
|
||||||
|
pub org_id: Uuid,
|
||||||
|
pub company_id: Uuid,
|
||||||
|
pub created: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure the workspace has at least one org + one company; return
|
||||||
|
/// coordinates for the first available. Idempotent: reuses existing rows
|
||||||
|
/// when present, creates placeholder rows otherwise. Safe to call before
|
||||||
|
/// every `POST /api/teams` so a fresh workspace's first team lands under
|
||||||
|
/// a real parent chain instead of falling into synthesized scaffolding.
|
||||||
|
pub async fn ensure_chain(
|
||||||
|
pool: &PgPool,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
fallback_org_name: &str,
|
||||||
|
fallback_company_name: &str,
|
||||||
|
) -> Result<EnsuredChain, DbError> {
|
||||||
|
// Fast path: workspace already has an org with a company bound. Use
|
||||||
|
// the first one we find — the wizard doesn't need to be clever about
|
||||||
|
// which parent to pick; the user can rename or reparent later.
|
||||||
|
if let Some(row) = sqlx::query!(
|
||||||
|
"SELECT oc.org_id AS org_id, oc.company_id AS company_id
|
||||||
|
FROM org_companies oc
|
||||||
|
JOIN orgs o ON o.id = oc.org_id
|
||||||
|
WHERE o.workspace_id = $1
|
||||||
|
ORDER BY o.created_at
|
||||||
|
LIMIT 1",
|
||||||
|
workspace_id.as_uuid(),
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(EnsuredChain {
|
||||||
|
org_id: row.org_id,
|
||||||
|
company_id: row.company_id,
|
||||||
|
created: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow path: materialize both. Reuses the reify shape but skips the
|
||||||
|
// team + orphan moves — the caller is about to create its own team.
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let now = OffsetDateTime::now_utc();
|
||||||
|
let org_id = Uuid::now_v7();
|
||||||
|
let company_id = Uuid::now_v7();
|
||||||
|
let empty_graph = serde_json::json!({"kind":"flat","nodes":[],"edges":[]});
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO orgs (id, workspace_id, name, kind, graph, status, created_at)
|
||||||
|
VALUES ($1, $2, $3, 'flat', $4, 'active', $5)",
|
||||||
|
org_id,
|
||||||
|
workspace_id.as_uuid(),
|
||||||
|
fallback_org_name,
|
||||||
|
empty_graph,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO companies (id, workspace_id, name, kind, graph, status, created_at)
|
||||||
|
VALUES ($1, $2, $3, 'flat', $4, 'active', $5)",
|
||||||
|
company_id,
|
||||||
|
workspace_id.as_uuid(),
|
||||||
|
fallback_company_name,
|
||||||
|
empty_graph,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO org_companies (org_id, node_id, company_id, role)
|
||||||
|
VALUES ($1, 'n0', $2, 'company')",
|
||||||
|
org_id,
|
||||||
|
company_id,
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(EnsuredChain {
|
||||||
|
org_id,
|
||||||
|
company_id,
|
||||||
|
created: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of a successful reify — ids of the freshly-created holder rows
|
/// Result of a successful reify — ids of the freshly-created holder rows
|
||||||
/// so the frontend can select the new org/company/team after refresh.
|
/// so the frontend can select the new org/company/team after refresh.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
|||||||
@@ -961,8 +961,12 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
{orphanDialogOpen ? (
|
{orphanDialogOpen ? (
|
||||||
<OrphanMigrationDialog
|
<OrphanMigrationDialog
|
||||||
onClose={() => setOrphanDialogOpen(false)}
|
onClose={() => setOrphanDialogOpen(false)}
|
||||||
onReified={() => {
|
onReified={(result) => {
|
||||||
setOrphanDialogOpen(false);
|
setOrphanDialogOpen(false);
|
||||||
|
// Land the user right on the freshly-materialized team so they
|
||||||
|
// see where their agents just moved. router.refresh() reloads
|
||||||
|
// the workspace tree so the sidebar reflects the new chain.
|
||||||
|
router.push(`/?team=${encodeURIComponent(result.team_id)}`);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -112,10 +112,32 @@ export function TeamWizard() {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
// Guarantee the workspace has a parent org+company so the new team
|
||||||
|
// never lands orphaned. Idempotent server-side — reuses whatever's
|
||||||
|
// there. Non-fatal on error: we still try to create the team and let
|
||||||
|
// it fall through to the migration dialog if needed.
|
||||||
|
let attachToCompanyId: string | undefined;
|
||||||
|
try {
|
||||||
|
const ec = await fetch("/api/structure/ensure-chain", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: "{}",
|
||||||
|
});
|
||||||
|
if (ec.ok) {
|
||||||
|
const data = (await ec.json()) as { company_id: string };
|
||||||
|
attachToCompanyId = data.company_id;
|
||||||
|
}
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
|
||||||
const res = await fetch("/api/teams", {
|
const res = await fetch("/api/teams", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name, kind, members }),
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
kind,
|
||||||
|
members,
|
||||||
|
...(attachToCompanyId ? { attach_to_company_id: attachToCompanyId } : {}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (res.status !== 201) throw new Error(`Create failed (${res.status})`);
|
if (res.status !== 201) throw new Error(`Create failed (${res.status})`);
|
||||||
await res.json();
|
await res.json();
|
||||||
|
|||||||
Reference in New Issue
Block a user