synops/maskinrommet/src/users_admin.rs
vegard 63b188641e Implementer brukeradministrasjon i admin (/admin/users)
- Backend: users_admin.rs med liste, toggle aktiv/deaktiv, AI-budsjett
- Frontend: brukeroversikt med roller, budsjett, siste aktivitet, filter
- API: fetchUsersOverview, toggleUser, updateUserBudget
2026-03-19 19:19:10 +00:00

317 lines
9.9 KiB
Rust

// 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<ErrorResponse>) {
(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<String>,
pub visibility: String,
pub created_at: DateTime<Utc>,
pub metadata: serde_json::Value,
pub is_active: bool,
pub roles: Vec<UserRole>,
pub ai_budget: Option<AiBudget>,
pub ai_usage_this_month: f64,
pub last_activity: Option<DateTime<Utc>>,
}
#[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<UserInfo>,
pub total_count: i64,
}
pub async fn users_overview(
State(state): State<AppState>,
_admin: AdminUser,
) -> Result<Json<UsersOverviewResponse>, (StatusCode, Json<ErrorResponse>)> {
// Hent alle person-noder med auth-identitet
#[derive(sqlx::FromRow)]
struct UserRow {
id: Uuid,
title: Option<String>,
visibility: String,
metadata: serde_json::Value,
created_at: DateTime<Utc>,
email: Option<String>,
}
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<String>,
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::<AiBudget>(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<DateTime<Utc>> = 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<AppState>,
_admin: AdminUser,
Json(req): Json<ToggleUserRequest>,
) -> Result<Json<ToggleUserResponse>, (StatusCode, Json<ErrorResponse>)> {
// Sjekk at noden er en person
let node_kind: Option<String> = 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<f64>,
}
#[derive(Serialize)]
pub struct UpdateBudgetResponse {
pub node_id: Uuid,
pub ai_budget: Option<AiBudget>,
}
pub async fn update_budget(
State(state): State<AppState>,
_admin: AdminUser,
Json(req): Json<UpdateBudgetRequest>,
) -> Result<Json<UpdateBudgetResponse>, (StatusCode, Json<ErrorResponse>)> {
// Sjekk at noden er en person
let node_kind: Option<String> = 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,
}))
}
}
}