Files
clawmates/crates/cm-api/src/routes/team.rs
T
Omar SobhandClaude Fable 5 f3f08a8edd R4 backend: team org-chart + leaderboard, Stripe credits, workspace apps
- GET /api/team/orgchart — each member grouped with the claws they manage
  (agents.managed_by); GET /api/team/leaderboard — every claw ranked by
  its real usage_events rollup (credits/tokens/runs, zeros included).
  Both tested against real Postgres.
- Stripe Buy-credits (the Slack/Clerk integration pattern): [billing]
  config (stripe keys + price + webhook secret + credits_per_pack);
  POST /api/credits/checkout opens a real Checkout Session; POST
  /api/billing/stripe verifies Stripe's t=,v1= HMAC (constant-time) and
  grants one credit lot, idempotent on the session id; GET
  /api/billing/config gates the button (honest degradation when unset).
  Offline tests: signed grant + replay no-double-grant + forged-sig 400 +
  config flag. Live checkout creation deferred to a CM_LIVE_STRIPE test.
- /apps global page support: clawId now optional on connect + directory;
  absent => workspace-wide connection (app_connections.agent_id NULL) via
  new connections::list_for_workspace.
- ApiError gains a From<sqlx::Error> so inline queries use ? cleanly.

cm-api 10 test files incl. team_tabs (2) + stripe_billing (3); clippy clean.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 19:58:09 -05:00

125 lines
3.9 KiB
Rust

use axum::extract::State;
use axum::Json;
use cm_domain::{Agent, User};
use serde_json::{json, Value};
use crate::{ApiError, AppState, Authed};
/// Members table for the Team page (§8.3).
pub async fn members(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<User>>, ApiError> {
let members = cm_db::repo::users::list_by_workspace(&state.pool, user.workspace_id).await?;
Ok(Json(members))
}
/// The left-rail agent roster (§4).
pub async fn claws(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<Agent>>, ApiError> {
let roster = cm_db::repo::agents::roster(&state.pool, user.workspace_id).await?;
Ok(Json(roster))
}
/// Available credit balance for the Credits page (§8.4).
pub async fn credits(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Value>, ApiError> {
let available = cm_db::repo::credits::balance(&state.pool, user.workspace_id).await?;
Ok(Json(json!({ "available": available })))
}
/// What the caller may do, derived from their role (§16 RBAC).
pub async fn permissions(Authed(user): Authed) -> Json<Value> {
let owner = user.role.is_owner();
Json(json!({
"role": user.role,
"can_manage_team": owner,
"can_manage_billing": owner,
}))
}
/// Org chart for the Team page (R4): each member with the claws they
/// manage (`agents.managed_by`). Only members who manage ≥1 claw appear.
pub async fn orgchart(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Value>, ApiError> {
let rows = sqlx::query!(
r#"SELECT u.id AS user_id, u.display_name, u.role, u.email,
a.id AS claw_id, a.name AS claw_name, a.job_title, a.accent
FROM users u
JOIN agents a ON a.managed_by = u.id
WHERE u.workspace_id = $1
ORDER BY u.display_name, a.name"#,
user.workspace_id.as_uuid(),
)
.fetch_all(&state.pool)
.await?;
let mut chart: Vec<Value> = Vec::new();
for row in rows {
let claw = json!({
"id": row.claw_id,
"name": row.claw_name,
"job_title": row.job_title,
"accent": row.accent,
});
match chart
.iter_mut()
.find(|node| node["user"]["id"] == json!(row.user_id))
{
Some(node) => node["claws"].as_array_mut().unwrap().push(claw),
None => chart.push(json!({
"user": {
"id": row.user_id,
"display_name": row.display_name,
"role": row.role,
"email": row.email,
},
"claws": [claw],
})),
}
}
Ok(Json(Value::Array(chart)))
}
/// Leaderboard for the Team page (R4): every claw ranked by its rolled-up
/// `usage_events` (credits desc), zeros included.
pub async fn leaderboard(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Value>, ApiError> {
let rows = sqlx::query!(
r#"SELECT a.id, a.name, a.accent,
COALESCE(SUM(u.credits), 0)::BIGINT AS "credits!",
COALESCE(SUM(u.tokens_in + u.tokens_out), 0)::BIGINT AS "tokens!",
COUNT(u.id)::BIGINT AS "runs!"
FROM agents a
LEFT JOIN usage_events u ON u.agent_id = a.id
WHERE a.workspace_id = $1
GROUP BY a.id, a.name, a.accent
ORDER BY "credits!" DESC, "tokens!" DESC, a.name"#,
user.workspace_id.as_uuid(),
)
.fetch_all(&state.pool)
.await?;
let board: Vec<Value> = rows
.into_iter()
.map(|r| {
json!({
"id": r.id,
"name": r.name,
"accent": r.accent,
"credits": r.credits,
"tokens": r.tokens,
"runs": r.runs,
})
})
.collect();
Ok(Json(Value::Array(board)))
}