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]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
67b80da695
commit
f3f08a8edd
@@ -41,3 +41,84 @@ pub async fn permissions(Authed(user): Authed) -> Json<Value> {
|
||||
"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)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user