- Backend: users_admin.rs med liste, toggle aktiv/deaktiv, AI-budsjett - Frontend: brukeroversikt med roller, budsjett, siste aktivitet, filter - API: fetchUsersOverview, toggleUser, updateUserBudget
317 lines
9.9 KiB
Rust
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,
|
|
}))
|
|
}
|
|
}
|
|
}
|