// Brukersynlig forbruk — oppgave 15.9 // // To endepunkter: // GET /my/usage?days=30 — innlogget brukers eget forbruk // GET /query/node_usage?node_id=&days=30 — forbruk for en spesifikk node (kun eier) // // Bygger på resource_usage_log (oppgave 15.7) med filtrering på // triggered_by (bruker) og target_node_id (node). // // Ref: docs/features/ressursforbruk.md use axum::extract::State; use axum::http::StatusCode; use axum::Json; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; use crate::auth::AuthUser; use crate::AppState; // ============================================================================= // Datatyper // ============================================================================= /// Aggregert forbruk per ressurstype for én bruker eller node. #[derive(Serialize, sqlx::FromRow)] pub struct ResourceTypeSummary { pub resource_type: String, pub event_count: i64, pub total_value: f64, pub secondary_value: f64, } /// Daglig tidsserie for bruker/node. #[derive(Serialize, sqlx::FromRow)] pub struct DailyUsage { pub day: chrono::DateTime, pub resource_type: String, pub event_count: i64, pub total_value: f64, } /// Grafstatistikk: noder og edges opprettet. #[derive(Serialize)] pub struct GraphStats { pub nodes_created: i64, pub edges_created: i64, } /// Samlet respons for brukerforbruk. #[derive(Serialize)] pub struct UserUsageResponse { pub by_type: Vec, pub daily: Vec, pub graph: GraphStats, } /// Samlet respons for node-forbruk. #[derive(Serialize)] pub struct NodeUsageResponse { pub node_id: Uuid, pub node_title: Option, pub by_type: Vec, pub daily: Vec, } #[derive(Deserialize)] pub struct UsageParams { pub days: Option, } #[derive(Deserialize)] pub struct NodeUsageParams { pub node_id: Uuid, pub days: Option, } #[derive(Serialize)] pub struct ErrorResponse { pub error: String, } fn err(status: StatusCode, msg: &str) -> (StatusCode, Json) { (status, Json(ErrorResponse { error: msg.to_string() })) } // ============================================================================= // GET /my/usage — brukerens eget forbruk // ============================================================================= pub async fn my_usage( State(state): State, user: AuthUser, axum::extract::Query(params): axum::extract::Query, ) -> Result, (StatusCode, Json)> { let days = params.days.unwrap_or(30).clamp(1, 365); let user_id = user.node_id; let by_type = fetch_user_by_type(&state.db, user_id, days) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?; let daily = fetch_user_daily(&state.db, user_id, days) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?; let graph = fetch_graph_stats_for_user(&state.db, user_id) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?; Ok(Json(UserUsageResponse { by_type, daily, graph })) } // ============================================================================= // GET /query/node_usage — forbruk for en spesifikk node // ============================================================================= pub async fn node_usage( State(state): State, user: AuthUser, axum::extract::Query(params): axum::extract::Query, ) -> Result, (StatusCode, Json)> { let days = params.days.unwrap_or(30).clamp(1, 365); let node_id = params.node_id; // Sjekk at brukeren eier noden (created_by) eller har owner/admin-edge let is_owner = check_node_access(&state.db, node_id, user.node_id) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?; if !is_owner { return Err(err(StatusCode::FORBIDDEN, "Du har ikke tilgang til denne nodens forbruk")); } let node_title: Option = sqlx::query_scalar("SELECT title FROM nodes WHERE id = $1") .bind(node_id) .fetch_optional(&state.db) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))? .flatten(); let by_type = fetch_node_by_type(&state.db, node_id, days) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?; let daily = fetch_node_daily(&state.db, node_id, days) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?; Ok(Json(NodeUsageResponse { node_id, node_title, by_type, daily })) } // ============================================================================= // Tilgangssjekk // ============================================================================= /// Bruker har tilgang til nodeforbruk hvis: /// 1. De opprettet noden (created_by), eller /// 2. De har en owner- eller admin-edge til noden async fn check_node_access(db: &PgPool, node_id: Uuid, user_id: Uuid) -> Result { let row: Option<(bool,)> = sqlx::query_as( r#" SELECT EXISTS( SELECT 1 FROM nodes WHERE id = $1 AND created_by = $2 UNION ALL SELECT 1 FROM edges WHERE target_node_id = $1 AND source_node_id = $2 AND edge_type IN ('owner', 'admin') ) AS ok "#, ) .bind(node_id) .bind(user_id) .fetch_optional(db) .await?; Ok(row.map(|r| r.0).unwrap_or(false)) } // ============================================================================= // Spørringer — brukerforbruk // ============================================================================= async fn fetch_user_by_type( db: &PgPool, user_id: Uuid, days: i32, ) -> Result, sqlx::Error> { sqlx::query_as::<_, ResourceTypeSummary>( r#" SELECT r.resource_type, COUNT(*)::BIGINT AS event_count, COALESCE(SUM( CASE r.resource_type WHEN 'ai' THEN (r.detail->>'tokens_in')::FLOAT8 WHEN 'whisper' THEN (r.detail->>'duration_seconds')::FLOAT8 WHEN 'tts' THEN (r.detail->>'characters')::FLOAT8 WHEN 'cas' THEN (r.detail->>'size_bytes')::FLOAT8 WHEN 'bandwidth' THEN (r.detail->>'size_bytes')::FLOAT8 WHEN 'livekit' THEN (r.detail->>'participant_minutes')::FLOAT8 ELSE 0 END ), 0) AS total_value, COALESCE(SUM( CASE r.resource_type WHEN 'ai' THEN (r.detail->>'tokens_out')::FLOAT8 ELSE 0 END ), 0) AS secondary_value FROM resource_usage_log r WHERE r.triggered_by = $1 AND r.created_at >= now() - make_interval(days := $2) GROUP BY r.resource_type ORDER BY total_value DESC "#, ) .bind(user_id) .bind(days) .fetch_all(db) .await } async fn fetch_user_daily( db: &PgPool, user_id: Uuid, days: i32, ) -> Result, sqlx::Error> { sqlx::query_as::<_, DailyUsage>( r#" SELECT date_trunc('day', r.created_at) AS day, r.resource_type, COUNT(*)::BIGINT AS event_count, COALESCE(SUM( CASE r.resource_type WHEN 'ai' THEN (r.detail->>'tokens_in')::FLOAT8 WHEN 'whisper' THEN (r.detail->>'duration_seconds')::FLOAT8 WHEN 'tts' THEN (r.detail->>'characters')::FLOAT8 WHEN 'cas' THEN (r.detail->>'size_bytes')::FLOAT8 WHEN 'bandwidth' THEN (r.detail->>'size_bytes')::FLOAT8 WHEN 'livekit' THEN (r.detail->>'participant_minutes')::FLOAT8 ELSE 0 END ), 0) AS total_value FROM resource_usage_log r WHERE r.triggered_by = $1 AND r.created_at >= now() - make_interval(days := $2) GROUP BY date_trunc('day', r.created_at), r.resource_type ORDER BY day DESC, resource_type "#, ) .bind(user_id) .bind(days) .fetch_all(db) .await } async fn fetch_graph_stats_for_user(db: &PgPool, user_id: Uuid) -> Result { let nodes: (i64,) = sqlx::query_as("SELECT COUNT(*)::BIGINT FROM nodes WHERE created_by = $1") .bind(user_id) .fetch_one(db) .await?; let edges: (i64,) = sqlx::query_as( "SELECT COUNT(*)::BIGINT FROM edges WHERE created_by = $1", ) .bind(user_id) .fetch_one(db) .await?; Ok(GraphStats { nodes_created: nodes.0, edges_created: edges.0, }) } // ============================================================================= // Spørringer — nodeforbruk // ============================================================================= async fn fetch_node_by_type( db: &PgPool, node_id: Uuid, days: i32, ) -> Result, sqlx::Error> { sqlx::query_as::<_, ResourceTypeSummary>( r#" SELECT r.resource_type, COUNT(*)::BIGINT AS event_count, COALESCE(SUM( CASE r.resource_type WHEN 'ai' THEN (r.detail->>'tokens_in')::FLOAT8 WHEN 'whisper' THEN (r.detail->>'duration_seconds')::FLOAT8 WHEN 'tts' THEN (r.detail->>'characters')::FLOAT8 WHEN 'cas' THEN (r.detail->>'size_bytes')::FLOAT8 WHEN 'bandwidth' THEN (r.detail->>'size_bytes')::FLOAT8 WHEN 'livekit' THEN (r.detail->>'participant_minutes')::FLOAT8 ELSE 0 END ), 0) AS total_value, COALESCE(SUM( CASE r.resource_type WHEN 'ai' THEN (r.detail->>'tokens_out')::FLOAT8 ELSE 0 END ), 0) AS secondary_value FROM resource_usage_log r WHERE r.target_node_id = $1 AND r.created_at >= now() - make_interval(days := $2) GROUP BY r.resource_type ORDER BY total_value DESC "#, ) .bind(node_id) .bind(days) .fetch_all(db) .await } async fn fetch_node_daily( db: &PgPool, node_id: Uuid, days: i32, ) -> Result, sqlx::Error> { sqlx::query_as::<_, DailyUsage>( r#" SELECT date_trunc('day', r.created_at) AS day, r.resource_type, COUNT(*)::BIGINT AS event_count, COALESCE(SUM( CASE r.resource_type WHEN 'ai' THEN (r.detail->>'tokens_in')::FLOAT8 WHEN 'whisper' THEN (r.detail->>'duration_seconds')::FLOAT8 WHEN 'tts' THEN (r.detail->>'characters')::FLOAT8 WHEN 'cas' THEN (r.detail->>'size_bytes')::FLOAT8 WHEN 'bandwidth' THEN (r.detail->>'size_bytes')::FLOAT8 WHEN 'livekit' THEN (r.detail->>'participant_minutes')::FLOAT8 ELSE 0 END ), 0) AS total_value FROM resource_usage_log r WHERE r.target_node_id = $1 AND r.created_at >= now() - make_interval(days := $2) GROUP BY date_trunc('day', r.created_at), r.resource_type ORDER BY day DESC, resource_type "#, ) .bind(node_id) .bind(days) .fetch_all(db) .await }