synops/maskinrommet/src/user_usage.rs
vegard 5771d1eed6 Fullfører oppgave 15.9: Brukersynlig forbruk
Legger til to nye API-endepunkter for brukersynlig ressursforbruk:
- GET /my/usage — brukerens eget forbruk (filtrert på triggered_by)
- GET /query/node_usage — forbruk for én node (kun eier/admin)

Frontend:
- /profile — profilside med grafstatistikk og forbruksoversikt
- NodeUsage-komponent integrert i samlings-detaljsiden
- Brukernavn i header lenker nå til profilsiden

Tilgangssjekk: nodeforbruk krever created_by eller owner/admin-edge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 04:42:47 +00:00

355 lines
12 KiB
Rust

// Brukersynlig forbruk — oppgave 15.9
//
// To endepunkter:
// GET /my/usage?days=30 — innlogget brukers eget forbruk
// GET /query/node_usage?node_id=<uuid>&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<chrono::Utc>,
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<ResourceTypeSummary>,
pub daily: Vec<DailyUsage>,
pub graph: GraphStats,
}
/// Samlet respons for node-forbruk.
#[derive(Serialize)]
pub struct NodeUsageResponse {
pub node_id: Uuid,
pub node_title: Option<String>,
pub by_type: Vec<ResourceTypeSummary>,
pub daily: Vec<DailyUsage>,
}
#[derive(Deserialize)]
pub struct UsageParams {
pub days: Option<i32>,
}
#[derive(Deserialize)]
pub struct NodeUsageParams {
pub node_id: Uuid,
pub days: Option<i32>,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
}
fn err(status: StatusCode, msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(status, Json(ErrorResponse { error: msg.to_string() }))
}
// =============================================================================
// GET /my/usage — brukerens eget forbruk
// =============================================================================
pub async fn my_usage(
State(state): State<AppState>,
user: AuthUser,
axum::extract::Query(params): axum::extract::Query<UsageParams>,
) -> Result<Json<UserUsageResponse>, (StatusCode, Json<ErrorResponse>)> {
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<AppState>,
user: AuthUser,
axum::extract::Query(params): axum::extract::Query<NodeUsageParams>,
) -> Result<Json<NodeUsageResponse>, (StatusCode, Json<ErrorResponse>)> {
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<String> = 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<bool, sqlx::Error> {
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<Vec<ResourceTypeSummary>, 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<Vec<DailyUsage>, 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<GraphStats, sqlx::Error> {
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<Vec<ResourceTypeSummary>, 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<Vec<DailyUsage>, 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
}