wizards: ensure-chain preflight in AddToTeam + AddToCompany (W1)
TeamWizard already ran ensure-chain before /api/teams; the two "AddTo"
modals didn't, so teams/companies created from them landed as
structural orphans (no company/org parent). Same treatment now applied
to both modals + their backend endpoints.
Backend — two symmetric `attach_to_*_id` fields (mirrors what
create_team already exposes):
- ComposeTeamRequest gains `attach_to_company_id`. After team insert,
create_team_from_claws binds it via companies::add_team with a fresh
`n{count}` node id.
- CreateCompanyRequest gains `attach_to_org_id`. After company insert,
create_company binds it via orgs::add_company the same way.
Both bindings are optional — plain POSTs from tools/tests still work.
Ownership is re-checked via `<parent>::get(pool, id, workspace_id)` so
the endpoints can't be tricked into parenting into another workspace.
Frontend — both modals now:
1. POST /api/structure/ensure-chain (empty body → server picks
"My Workspace" / "General" fallbacks when nothing exists yet).
2. Include the returned parent id in the create request.
AddToOrgModal untouched — orgs are top-level, no parent needed.
MasterPlannerModal untouched — it posts to /webhooks, doesn't create
structural rows.
Follow-up already queued in the original list: same treatment for the
Company/Org "wizard"-flavored surfaces (as opposed to the compose
modals). Currently those don't exist as distinct wizards.
This commit is contained in:
@@ -28,6 +28,12 @@ pub struct CreateCompanyRequest {
|
|||||||
/// TopologyKind (snake_case), e.g. "hierarchical", "pipeline".
|
/// TopologyKind (snake_case), e.g. "hierarchical", "pipeline".
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub members: Vec<CompanyMemberInput>,
|
pub members: Vec<CompanyMemberInput>,
|
||||||
|
/// Optional parent org id. When set, the new company is bound under
|
||||||
|
/// this org via `orgs::add_company` inside the same handler so the
|
||||||
|
/// company never lands orphaned. Wizards fetch this via
|
||||||
|
/// `POST /api/structure/ensure-chain`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub attach_to_org_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -104,6 +110,19 @@ pub async fn create_company(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-parent under the caller's ensured org, mirroring create_team's
|
||||||
|
// attach_to_company_id pattern. Ownership-checked; skipped when the
|
||||||
|
// caller didn't run ensure-chain.
|
||||||
|
if let Some(org_id) = body.attach_to_org_id {
|
||||||
|
let org = cm_db::repo::orgs::get(&state.pool, org_id, user.workspace_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::NotFound)?;
|
||||||
|
let existing = cm_db::repo::orgs::companies_for_org(&state.pool, org.id).await?;
|
||||||
|
let node_id = format!("n{}", existing.len());
|
||||||
|
cm_db::repo::orgs::add_company(&state.pool, org.id, &node_id, company_id, "company")
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
Json(CompanyCreated {
|
Json(CompanyCreated {
|
||||||
|
|||||||
@@ -193,6 +193,12 @@ pub struct ComposeTeamRequest {
|
|||||||
pub kind: String,
|
pub kind: String,
|
||||||
/// Existing claws (agents) to group into the new team.
|
/// Existing claws (agents) to group into the new team.
|
||||||
pub claw_ids: Vec<Uuid>,
|
pub claw_ids: Vec<Uuid>,
|
||||||
|
/// Optional parent company id. When set, the new team is bound under
|
||||||
|
/// this company via `companies::add_team` inside the same handler so
|
||||||
|
/// the team never lands orphaned. Mirrors `create_team`'s field —
|
||||||
|
/// wizards fetch this via `POST /api/structure/ensure-chain`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub attach_to_company_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/teams/from-claws` — create a team from EXISTING claws (no new
|
/// `POST /api/teams/from-claws` — create a team from EXISTING claws (no new
|
||||||
@@ -251,6 +257,18 @@ pub async fn create_team_from_claws(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same auto-parent path as create_team — if the caller preflighted
|
||||||
|
// ensure-chain to get a company_id, bind the new team under it here.
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -40,10 +40,26 @@ export function AddToCompanyModal({ teams, onClose }: { teams: PickableTeam[]; o
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
// Preflight: make sure this workspace has a real org for the new
|
||||||
|
// company to live under. ensure-chain returns org_id + company_id;
|
||||||
|
// we only need the org here — the new company IS the company.
|
||||||
|
const chainRes = await fetch("/api/structure/ensure-chain", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
if (!chainRes.ok) { setError("Could not prepare workspace"); setBusy(false); return; }
|
||||||
|
const { org_id } = (await chainRes.json()) as { org_id: string };
|
||||||
|
|
||||||
const res = await fetch("/api/companies", {
|
const res = await fetch("/api/companies", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name: name.trim(), kind: "hub_spoke", members: [...picked].map((team_id) => ({ team_id, role: "team" })) }),
|
body: JSON.stringify({
|
||||||
|
name: name.trim(),
|
||||||
|
kind: "hub_spoke",
|
||||||
|
members: [...picked].map((team_id) => ({ team_id, role: "team" })),
|
||||||
|
attach_to_org_id: org_id,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (res.status !== 201) { setError(`Could not create company (${res.status})`); setBusy(false); return; }
|
if (res.status !== 201) { setError(`Could not create company (${res.status})`); setBusy(false); return; }
|
||||||
const { company_id } = (await res.json()) as { company_id: string };
|
const { company_id } = (await res.json()) as { company_id: string };
|
||||||
|
|||||||
@@ -40,10 +40,27 @@ export function AddToTeamModal({ claws, onClose }: { claws: Agent[]; onClose: ()
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
// Preflight: make sure this workspace has a real company for the
|
||||||
|
// new team to live under. First-team-ever gets a "My Workspace" /
|
||||||
|
// "General" scaffold created for it; anything already there is
|
||||||
|
// reused. Matches TeamWizard's flow.
|
||||||
|
const chainRes = await fetch("/api/structure/ensure-chain", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
if (!chainRes.ok) { setError("Could not prepare workspace"); setBusy(false); return; }
|
||||||
|
const { company_id } = (await chainRes.json()) as { company_id: string };
|
||||||
|
|
||||||
const res = await fetch("/api/teams/from-claws", {
|
const res = await fetch("/api/teams/from-claws", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name: name.trim(), kind: "hub_spoke", claw_ids: [...picked] }),
|
body: JSON.stringify({
|
||||||
|
name: name.trim(),
|
||||||
|
kind: "hub_spoke",
|
||||||
|
claw_ids: [...picked],
|
||||||
|
attach_to_company_id: company_id,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (res.status !== 201) { setError(`Could not create team (${res.status})`); setBusy(false); return; }
|
if (res.status !== 201) { setError(`Could not create team (${res.status})`); setBusy(false); return; }
|
||||||
const { team_id } = (await res.json()) as { team_id: string };
|
const { team_id } = (await res.json()) as { team_id: string };
|
||||||
|
|||||||
Reference in New Issue
Block a user