// Admin-API for brukeradministrasjon (oppgave 064) // // Viser person-noder med roller, AI-budsjett, siste aktivitet. // Støtter deaktivering/aktivering og oppdatering av AI-budsjett. use axum::{extract::State, http::StatusCode, Json}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::auth::AdminUser; use crate::AppState; #[derive(Serialize)] pub struct ErrorResponse { pub error: String, } fn internal_error(msg: &str) -> (StatusCode, Json) { (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: msg.to_string() })) } // ============================================================================= // GET /admin/users — brukeroversikt // ============================================================================= #[derive(Serialize)] pub struct UserInfo { pub node_id: Uuid, pub title: String, pub email: Option, pub visibility: String, pub created_at: DateTime, pub metadata: serde_json::Value, pub is_active: bool, pub roles: Vec, pub ai_budget: Option, pub ai_usage_this_month: f64, pub last_activity: Option>, } #[derive(Serialize)] pub struct UserRole { pub collection_id: Uuid, pub collection_title: String, pub role: String, } #[derive(Serialize, Deserialize, Clone)] pub struct AiBudget { pub monthly_limit_usd: f64, } #[derive(Serialize)] pub struct UsersOverviewResponse { pub users: Vec, pub total_count: i64, } pub async fn users_overview( State(state): State, _admin: AdminUser, ) -> Result, (StatusCode, Json)> { // Hent alle person-noder med auth-identitet #[derive(sqlx::FromRow)] struct UserRow { id: Uuid, title: Option, visibility: String, metadata: serde_json::Value, created_at: DateTime, email: Option, } let user_rows = sqlx::query_as::<_, UserRow>( r#"SELECT n.id, n.title, n.visibility::text as visibility, n.metadata, n.created_at, ai.email FROM nodes n LEFT JOIN auth_identities ai ON ai.node_id = n.id WHERE n.node_kind = 'person' ORDER BY n.title NULLS LAST, n.created_at"#, ) .fetch_all(&state.db) .await .map_err(|e| internal_error(&format!("Feil ved henting av brukere: {e}")))?; let total_count = user_rows.len() as i64; let mut users = Vec::new(); for row in user_rows { // Roller: edges fra person → collection med type owner/admin/member_of #[derive(sqlx::FromRow)] struct RoleRow { collection_id: Uuid, collection_title: Option, edge_type: String, } let roles = sqlx::query_as::<_, RoleRow>( r#"SELECT e.target_id as collection_id, n.title as collection_title, e.edge_type FROM edges e JOIN nodes n ON n.id = e.target_id AND n.node_kind = 'collection' WHERE e.source_id = $1 AND e.edge_type IN ('owner', 'admin', 'member_of') ORDER BY e.edge_type, n.title"#, ) .bind(row.id) .fetch_all(&state.db) .await .map_err(|e| internal_error(&format!("DB-feil (roller): {e}")))?; // AI-budsjett fra metadata let ai_budget = row.metadata .get("ai_budget") .and_then(|b| serde_json::from_value::(b.clone()).ok()); // AI-forbruk denne måneden let (total_prompt, total_completion): (i64, i64) = sqlx::query_as::<_, (i64, i64)>( r#"SELECT COALESCE(SUM(prompt_tokens)::BIGINT, 0), COALESCE(SUM(completion_tokens)::BIGINT, 0) FROM ai_usage_log WHERE requested_by = $1 AND created_at >= date_trunc('month', now())"#, ) .bind(row.id) .fetch_one(&state.db) .await .map_err(|e| internal_error(&format!("DB-feil (forbruk): {e}")))?; let ai_usage_this_month = crate::ai_budget::estimate_cost_usd(total_prompt, total_completion); // Siste aktivitet: nyeste created_at fra nodes opprettet av bruker let last_activity: Option> = sqlx::query_scalar( r#"SELECT MAX(created_at) FROM nodes WHERE created_by = $1"#, ) .bind(row.id) .fetch_one(&state.db) .await .map_err(|e| internal_error(&format!("DB-feil (aktivitet): {e}")))?; // is_active: person med visibility != 'hidden' anses som aktiv let is_active = row.visibility != "hidden"; users.push(UserInfo { node_id: row.id, title: row.title.unwrap_or_else(|| "Uten navn".to_string()), email: row.email, visibility: row.visibility, created_at: row.created_at, metadata: row.metadata, is_active, roles: roles.into_iter().map(|r| UserRole { collection_id: r.collection_id, collection_title: r.collection_title.unwrap_or_else(|| "Ukjent".to_string()), role: r.edge_type, }).collect(), ai_budget, ai_usage_this_month, last_activity, }); } Ok(Json(UsersOverviewResponse { users, total_count })) } // ============================================================================= // POST /admin/users/toggle — aktiver/deaktiver bruker // ============================================================================= #[derive(Deserialize)] pub struct ToggleUserRequest { pub node_id: Uuid, } #[derive(Serialize)] pub struct ToggleUserResponse { pub node_id: Uuid, pub is_active: bool, } pub async fn toggle_user( State(state): State, _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // Sjekk at noden er en person let node_kind: Option = sqlx::query_scalar( "SELECT node_kind FROM nodes WHERE id = $1", ) .bind(req.node_id) .fetch_optional(&state.db) .await .map_err(|e| internal_error(&format!("DB-feil: {e}")))?; match node_kind.as_deref() { Some("person") => {} Some(_) => return Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Noden er ikke en person".to_string(), }))), None => return Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Bruker finnes ikke".to_string(), }))), } // Toggle: hidden ↔ discoverable let current_visibility: String = sqlx::query_scalar( "SELECT visibility::text FROM nodes WHERE id = $1", ) .bind(req.node_id) .fetch_one(&state.db) .await .map_err(|e| internal_error(&format!("DB-feil: {e}")))?; let new_visibility = if current_visibility == "hidden" { "discoverable" } else { "hidden" }; sqlx::query( "UPDATE nodes SET visibility = $1::visibility WHERE id = $2", ) .bind(new_visibility) .bind(req.node_id) .execute(&state.db) .await .map_err(|e| internal_error(&format!("DB-feil: {e}")))?; let is_active = new_visibility != "hidden"; tracing::info!(user_node_id = %req.node_id, is_active, "Bruker-status endret"); Ok(Json(ToggleUserResponse { node_id: req.node_id, is_active, })) } // ============================================================================= // POST /admin/users/budget — oppdater AI-budsjett // ============================================================================= #[derive(Deserialize)] pub struct UpdateBudgetRequest { pub node_id: Uuid, pub monthly_limit_usd: Option, } #[derive(Serialize)] pub struct UpdateBudgetResponse { pub node_id: Uuid, pub ai_budget: Option, } pub async fn update_budget( State(state): State, _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // Sjekk at noden er en person let node_kind: Option = sqlx::query_scalar( "SELECT node_kind FROM nodes WHERE id = $1", ) .bind(req.node_id) .fetch_optional(&state.db) .await .map_err(|e| internal_error(&format!("DB-feil: {e}")))?; if node_kind.as_deref() != Some("person") { return Err((StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Noden er ikke en person".to_string(), }))); } match req.monthly_limit_usd { Some(limit) => { // Sett budsjett sqlx::query( r#"UPDATE nodes SET metadata = jsonb_set( COALESCE(metadata, '{}'::jsonb), '{ai_budget}', $1::jsonb ) WHERE id = $2"#, ) .bind(serde_json::json!({ "monthly_limit_usd": limit })) .bind(req.node_id) .execute(&state.db) .await .map_err(|e| internal_error(&format!("DB-feil: {e}")))?; tracing::info!(user_node_id = %req.node_id, limit, "AI-budsjett oppdatert"); Ok(Json(UpdateBudgetResponse { node_id: req.node_id, ai_budget: Some(AiBudget { monthly_limit_usd: limit }), })) } None => { // Fjern budsjett sqlx::query( "UPDATE nodes SET metadata = metadata - 'ai_budget' WHERE id = $1", ) .bind(req.node_id) .execute(&state.db) .await .map_err(|e| internal_error(&format!("DB-feil: {e}")))?; tracing::info!(user_node_id = %req.node_id, "AI-budsjett fjernet"); Ok(Json(UpdateBudgetResponse { node_id: req.node_id, ai_budget: None, })) } } }