// Podcast-statistikk dashboard API (oppgave 30.4) // // Leser fra podcast_download_stats-tabellen (skrevet av synops-stats CLI) // og returnerer aggregert data for admin-dashboardet: // - Nedlastinger per episode (totalt + trend) // - Topp-episoder // - Klientfordeling (Apple Podcasts, Spotify, etc.) // - Daglig tidsserie // // Ref: docs/features/podcast_statistikk.md use axum::extract::State; use axum::http::StatusCode; use axum::Json; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; use crate::auth::AdminUser; use crate::AppState; // ============================================================================= // Datatyper // ============================================================================= #[derive(Serialize, sqlx::FromRow)] pub struct EpisodeTotal { pub episode_id: Option, pub episode_title: Option, pub total_downloads: i64, pub total_unique_listeners: i64, pub first_date: Option, pub last_date: Option, pub days_with_data: i64, } #[derive(Serialize, sqlx::FromRow)] pub struct DailyDownloads { pub date: NaiveDate, pub downloads: i64, pub unique_listeners: i64, } #[derive(Serialize)] pub struct ClientBreakdown { pub client: String, pub count: i64, } #[derive(Serialize)] pub struct PodcastStatsResponse { /// Totalt over hele perioden pub total_downloads: i64, pub total_unique_listeners: i64, /// Per episode, sortert etter nedlastinger (topp først) pub episodes: Vec, /// Daglig tidsserie (alle episoder aggregert) pub daily: Vec, /// Klientfordeling (aggregert over perioden) pub clients: Vec, } #[derive(Deserialize)] pub struct PodcastStatsParams { pub days: Option, pub episode_id: Option, } #[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/podcast/stats // ============================================================================= pub async fn podcast_stats( State(state): State, _admin: AdminUser, axum::extract::Query(params): axum::extract::Query, ) -> Result, (StatusCode, Json)> { let days = params.days.unwrap_or(30).clamp(1, 365); let episodes = fetch_episode_totals(&state.db, days, params.episode_id) .await .map_err(|e| internal_error(&format!("Feil i episodeoversikt: {e}")))?; let daily = fetch_daily(&state.db, days, params.episode_id) .await .map_err(|e| internal_error(&format!("Feil i daglig oversikt: {e}")))?; let clients = fetch_clients(&state.db, days, params.episode_id) .await .map_err(|e| internal_error(&format!("Feil i klientoversikt: {e}")))?; let total_downloads = episodes.iter().map(|e| e.total_downloads).sum(); let total_unique_listeners = episodes.iter().map(|e| e.total_unique_listeners).sum(); Ok(Json(PodcastStatsResponse { total_downloads, total_unique_listeners, episodes, daily, clients, })) } // ============================================================================= // Spørringer // ============================================================================= async fn fetch_episode_totals( db: &PgPool, days: i32, episode_filter: Option, ) -> Result, sqlx::Error> { sqlx::query_as::<_, EpisodeTotal>( r#" SELECT s.episode_id, n.title AS episode_title, COALESCE(SUM(s.downloads), 0)::BIGINT AS total_downloads, COALESCE(SUM(s.unique_listeners), 0)::BIGINT AS total_unique_listeners, MIN(s.date) AS first_date, MAX(s.date) AS last_date, COUNT(DISTINCT s.date)::BIGINT AS days_with_data FROM podcast_download_stats s LEFT JOIN nodes n ON n.id = s.episode_id WHERE s.date >= (CURRENT_DATE - make_interval(days := $1)) AND ($2::UUID IS NULL OR s.episode_id = $2) GROUP BY s.episode_id, n.title ORDER BY total_downloads DESC "#, ) .bind(days) .bind(episode_filter) .fetch_all(db) .await } async fn fetch_daily( db: &PgPool, days: i32, episode_filter: Option, ) -> Result, sqlx::Error> { sqlx::query_as::<_, DailyDownloads>( r#" SELECT s.date, COALESCE(SUM(s.downloads), 0)::BIGINT AS downloads, COALESCE(SUM(s.unique_listeners), 0)::BIGINT AS unique_listeners FROM podcast_download_stats s WHERE s.date >= (CURRENT_DATE - make_interval(days := $1)) AND ($2::UUID IS NULL OR s.episode_id = $2) GROUP BY s.date ORDER BY s.date ASC "#, ) .bind(days) .bind(episode_filter) .fetch_all(db) .await } /// Aggreger klientfordeling fra JSONB clients-feltet. async fn fetch_clients( db: &PgPool, days: i32, episode_filter: Option, ) -> Result, sqlx::Error> { // clients er JSONB med { "Apple Podcasts": 18, "Spotify": 15, ... } // Vi bruker jsonb_each_text for å ekspandere og aggregere let rows: Vec<(String, i64)> = sqlx::query_as( r#" SELECT kv.key AS client, COALESCE(SUM(kv.value::BIGINT), 0)::BIGINT AS count FROM podcast_download_stats s, jsonb_each_text(s.clients) AS kv(key, value) WHERE s.date >= (CURRENT_DATE - make_interval(days := $1)) AND ($2::UUID IS NULL OR s.episode_id = $2) GROUP BY kv.key ORDER BY count DESC "#, ) .bind(days) .bind(episode_filter) .fetch_all(db) .await?; Ok(rows .into_iter() .map(|(client, count)| ClientBreakdown { client, count }) .collect()) }