diff --git a/Cargo.lock b/Cargo.lock index 70fc279..242247c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -453,7 +453,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bzod" -version = "0.1.0" +version = "0.4.0" dependencies = [ "argon2", "askama", diff --git a/Cargo.toml b/Cargo.toml index fe221f1..848689b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bzod" -version = "0.1.0" +version = "0.4.0" edition = "2021" [dependencies] diff --git a/src/db/analytics.rs b/src/db/analytics.rs index 5721520..3e295d9 100644 --- a/src/db/analytics.rs +++ b/src/db/analytics.rs @@ -544,7 +544,7 @@ pub fn get_monthly_clicks_trend( let mut stmt = conn.prepare( "SELECT year_month, SUM(metric_value) FROM monthly_summaries WHERE target_type = ?1 AND target_id = ?2 AND metric_type = 'clicks' - GROUP BY year_month ORDER BY year_month ASC LIMIT ?3;" + GROUP BY year_month ORDER BY year_month ASC LIMIT ?3;", )?; let rows = stmt.query_map(params![target_type, target_id, limit_months], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) @@ -559,7 +559,7 @@ pub fn get_monthly_clicks_trend( let mut stmt = conn.prepare( "SELECT strftime('%Y-%m', timestamp) as m, COUNT(*) FROM visits WHERE target_type = ?1 AND target_id = ?2 - GROUP BY m ORDER BY m ASC LIMIT ?3;" + GROUP BY m ORDER BY m ASC LIMIT ?3;", )?; let rows = stmt.query_map(params![target_type, target_id, limit_months], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) @@ -572,7 +572,9 @@ pub fn get_monthly_clicks_trend( Ok(res) } -pub fn get_visits_schema_columns(conn: &Connection) -> rusqlite::Result> { +pub fn get_visits_schema_columns( + conn: &Connection, +) -> rusqlite::Result> { let mut columns = std::collections::HashSet::new(); let mut stmt = conn.prepare("PRAGMA table_info(visits);")?; let mut rows = stmt.query([])?; @@ -607,7 +609,10 @@ pub fn get_target_visits_paginated( if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") { let next_day = parsed_date + chrono::Duration::days(1); sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1)); - params.push(Box::new(format!("{}T00:00:00Z", next_day.format("%Y-%m-%d")))); + params.push(Box::new(format!( + "{}T00:00:00Z", + next_day.format("%Y-%m-%d") + ))); } } @@ -665,7 +670,10 @@ pub fn get_target_visits_all_in_memory( if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") { let next_day = parsed_date + chrono::Duration::days(1); sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1)); - params.push(Box::new(format!("{}T00:00:00Z", next_day.format("%Y-%m-%d")))); + params.push(Box::new(format!( + "{}T00:00:00Z", + next_day.format("%Y-%m-%d") + ))); } } @@ -706,7 +714,8 @@ pub fn get_target_visit_total_filtered( return get_target_visit_count(conn, target_type, target_id); } - let mut sql = "SELECT COUNT(*) FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string(); + let mut sql = + "SELECT COUNT(*) FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string(); let mut params: Vec> = vec![ Box::new(target_type.to_string()), Box::new(target_id.to_string()), @@ -721,12 +730,17 @@ pub fn get_target_visit_total_filtered( if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") { let next_day = parsed_date + chrono::Duration::days(1); sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1)); - params.push(Box::new(format!("{}T00:00:00Z", next_day.format("%Y-%m-%d")))); + params.push(Box::new(format!( + "{}T00:00:00Z", + next_day.format("%Y-%m-%d") + ))); } } let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - conn.query_row(&sql, rusqlite::params_from_iter(param_refs), |row| row.get(0)) + conn.query_row(&sql, rusqlite::params_from_iter(param_refs), |row| { + row.get(0) + }) } #[cfg(test)] diff --git a/src/templates/analytics.rs b/src/templates/analytics.rs index 70d735a..ed8a900 100644 --- a/src/templates/analytics.rs +++ b/src/templates/analytics.rs @@ -50,7 +50,6 @@ impl UrlAnalyticsTemplate { } } - impl IntoResponse for UrlAnalyticsTemplate { fn into_response(self) -> Response { match self.render() { @@ -93,7 +92,6 @@ impl PageAnalyticsTemplate { } } - impl IntoResponse for PageAnalyticsTemplate { fn into_response(self) -> Response { match self.render() { diff --git a/src/templates/pages.rs b/src/templates/pages.rs index 9863a52..9df89b3 100644 --- a/src/templates/pages.rs +++ b/src/templates/pages.rs @@ -23,7 +23,6 @@ impl PagesTemplate { } } - impl IntoResponse for PagesTemplate { fn into_response(self) -> Response { match self.render() { diff --git a/src/templates/urls.rs b/src/templates/urls.rs index 71b58d4..9a6ef00 100644 --- a/src/templates/urls.rs +++ b/src/templates/urls.rs @@ -25,7 +25,6 @@ impl UrlsTemplate { } } - impl IntoResponse for UrlsTemplate { fn into_response(self) -> Response { match self.render() { diff --git a/src/web/admin.rs b/src/web/admin.rs index c6c39ec..4d3a03e 100644 --- a/src/web/admin.rs +++ b/src/web/admin.rs @@ -62,14 +62,15 @@ use crate::auth::{ }; use crate::charts::{generate_bar_chart, generate_line_chart}; use crate::db::analytics::{ - get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, get_metric_rankings_raw, - get_total_clicks, get_target_unique_visitors, get_monthly_clicks_trend, - get_visits_schema_columns, get_target_visits_paginated, get_target_visits_all_in_memory, - get_target_visit_total_filtered, parse_ua, clean_referrer, + clean_referrer, get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, + get_metric_rankings_raw, get_monthly_clicks_trend, get_target_unique_visitors, + get_target_visit_total_filtered, get_target_visits_all_in_memory, get_target_visits_paginated, + get_total_clicks, get_visits_schema_columns, parse_ua, }; use crate::db::content::{ - create_landing_page, delete_landing_page, delete_url, get_landing_page_count, get_url_counts, - list_landing_pages, list_urls, get_url_count_by_tag, get_url_by_id, get_landing_page_by_id, + create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id, + get_landing_page_count, get_url_by_id, get_url_count_by_tag, get_url_counts, + list_landing_pages, list_urls, }; use crate::models::User; use crate::state::AppState; @@ -79,7 +80,6 @@ const PAGE_SIZE: usize = 25; const ANALYTICS_PAGE_SIZE: usize = 50; const MAX_JSON_EXPORT_ROWS: usize = 50_000; - // Helper: Verify session and return user or redirect to login async fn require_auth(state: &AppState, jar: &CookieJar) -> Result<(User, String), Redirect> { let conn = state.admin_db.lock().unwrap(); @@ -383,11 +383,17 @@ pub async fn urls_get( let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); let total_pages = std::cmp::max(1, calculated_total_pages); let requested_page = query.page.unwrap_or(1); - let current_page = if requested_page == 0 { 1 } else { requested_page }.clamp(1, total_pages); + let current_page = if requested_page == 0 { + 1 + } else { + requested_page + } + .clamp(1, total_pages); let offset = (current_page - 1) * PAGE_SIZE; - let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref()).unwrap_or_default(); - + let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref()) + .unwrap_or_default(); + let start_page = current_page.saturating_sub(3).max(1); let end_page = std::cmp::min(total_pages, current_page + 3); let visible_pages: Vec = (start_page..=end_page).collect(); @@ -423,7 +429,6 @@ pub async fn urls_get( template.into_response() } - #[derive(Deserialize)] pub struct CreateUrlForm { pub destination: String, @@ -661,11 +666,16 @@ pub async fn pages_get( let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); let total_pages = std::cmp::max(1, calculated_total_pages); let requested_page = query.page.unwrap_or(1); - let current_page = if requested_page == 0 { 1 } else { requested_page }.clamp(1, total_pages); + let current_page = if requested_page == 0 { + 1 + } else { + requested_page + } + .clamp(1, total_pages); let offset = (current_page - 1) * PAGE_SIZE; let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default(); - + let start_page = current_page.saturating_sub(3).max(1); let end_page = std::cmp::min(total_pages, current_page + 3); let visible_pages: Vec = (start_page..=end_page).collect(); @@ -1592,23 +1602,22 @@ pub struct AnalyticsQuery { pub date_to: Option, } -fn validate_date_filters(date_from: Option<&str>, date_to: Option<&str>) -> Result<(Option, Option), StatusCode> { +fn validate_date_filters( + date_from: Option<&str>, + date_to: Option<&str>, +) -> Result<(Option, Option), StatusCode> { let from_parsed = match date_from { - Some(df) if !df.is_empty() => { - match chrono::NaiveDate::parse_from_str(df, "%Y-%m-%d") { - Ok(d) => Some(d), - Err(_) => return Err(StatusCode::BAD_REQUEST), - } - } + Some(df) if !df.is_empty() => match chrono::NaiveDate::parse_from_str(df, "%Y-%m-%d") { + Ok(d) => Some(d), + Err(_) => return Err(StatusCode::BAD_REQUEST), + }, _ => None, }; let to_parsed = match date_to { - Some(dt) if !dt.is_empty() => { - match chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") { - Ok(d) => Some(d), - Err(_) => return Err(StatusCode::BAD_REQUEST), - } - } + Some(dt) if !dt.is_empty() => match chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") { + Ok(d) => Some(d), + Err(_) => return Err(StatusCode::BAD_REQUEST), + }, _ => None, }; if let (Some(f), Some(t)) = (from_parsed, to_parsed) { @@ -1623,10 +1632,8 @@ fn validate_date_filters(date_from: Option<&str>, date_to: Option<&str>) -> Resu } fn escape_csv_field(field: &str) -> String { - let needs_escaping = field.contains(',') - || field.contains('"') - || field.contains('\n') - || field.contains('\r'); + let needs_escaping = + field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r'); if needs_escaping { let escaped = field.replace('"', "\"\""); format!("\"{}\"", escaped) @@ -1657,20 +1664,22 @@ async fn perform_csv_export( date_from: Option, date_to: Option, ) -> Response { - let (clean_date_from, clean_date_to) = match validate_date_filters( - date_from.as_deref(), - date_to.as_deref(), - ) { - Ok(res) => res, - Err(status) => return status.into_response(), - }; + let (clean_date_from, clean_date_to) = + match validate_date_filters(date_from.as_deref(), date_to.as_deref()) { + Ok(res) => res, + Err(status) => return status.into_response(), + }; let target_exists = { let conn = state.content_db.lock().unwrap(); if target_type == "url" { - get_url_by_id(&conn, &id).map(|u| u.is_some()).unwrap_or(false) + get_url_by_id(&conn, &id) + .map(|u| u.is_some()) + .unwrap_or(false) } else { - get_landing_page_by_id(&conn, &id).map(|p| p.is_some()).unwrap_or(false) + get_landing_page_by_id(&conn, &id) + .map(|p| p.is_some()) + .unwrap_or(false) } }; if !target_exists { @@ -1685,7 +1694,8 @@ async fn perform_csv_export( &id, clean_date_from.as_deref(), clean_date_to.as_deref(), - ).unwrap_or(0) + ) + .unwrap_or(0) }; let (has_utm_source, has_utm_campaign) = { @@ -1694,13 +1704,14 @@ async fn perform_csv_export( (cols.contains("utm_source"), cols.contains("utm_campaign")) }; - let (tx, rx) = tokio::sync::mpsc::channel::>(32); + let (tx, rx) = + tokio::sync::mpsc::channel::>(32); let analytics_db = state.analytics_db.clone(); let target_id = id.clone(); tokio::task::spawn_blocking(move || { let conn = analytics_db.lock().unwrap(); - + let mut header = "Timestamp,IP Address,Country,Referrer,Browser,User-Agent".to_string(); if has_utm_source { header.push_str(",UTM Source"); @@ -1709,8 +1720,11 @@ async fn perform_csv_export( header.push_str(",UTM Campaign"); } header.push('\n'); - - if tx.blocking_send(Ok(axum::body::Bytes::from(header))).is_err() { + + if tx + .blocking_send(Ok(axum::body::Bytes::from(header))) + .is_err() + { return; } @@ -1724,11 +1738,12 @@ async fn perform_csv_export( "timestamp, ip_address, country, referer, user_agent" }; - let mut sql = format!("SELECT {} FROM visits WHERE target_type = ?1 AND target_id = ?2", select_fields); - let mut params: Vec> = vec![ - Box::new(target_type.to_string()), - Box::new(target_id), - ]; + let mut sql = format!( + "SELECT {} FROM visits WHERE target_type = ?1 AND target_id = ?2", + select_fields + ); + let mut params: Vec> = + vec![Box::new(target_type.to_string()), Box::new(target_id)]; if let Some(df) = clean_date_from.as_deref() { sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1)); @@ -1739,7 +1754,10 @@ async fn perform_csv_export( if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") { let next_day = parsed_date + chrono::Duration::days(1); sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1)); - params.push(Box::new(format!("{}T00:00:00Z", next_day.format("%Y-%m-%d")))); + params.push(Box::new(format!( + "{}T00:00:00Z", + next_day.format("%Y-%m-%d") + ))); } } @@ -1767,7 +1785,11 @@ async fn perform_csv_export( let (browser, _, _) = parse_ua(&user_agent); let referrer = clean_referrer(&referer); - let country_display = if country.is_empty() { "Unknown".to_string() } else { country }; + let country_display = if country.is_empty() { + "Unknown".to_string() + } else { + country + }; let mut line = format!( "{},{},{},{},{},{}", @@ -1817,11 +1839,15 @@ async fn perform_csv_export( StatusCode::OK, [ ("Content-Type", "text/csv"), - ("Content-Disposition", &format!("attachment; filename=\"{}\"", filename)), + ( + "Content-Disposition", + &format!("attachment; filename=\"{}\"", filename), + ), ("X-BZOD-Export-Records", &count.to_string()), ], axum::body::Body::from_stream(stream), - ).into_response() + ) + .into_response() } async fn perform_json_export( @@ -1831,20 +1857,22 @@ async fn perform_json_export( date_from: Option, date_to: Option, ) -> Response { - let (clean_date_from, clean_date_to) = match validate_date_filters( - date_from.as_deref(), - date_to.as_deref(), - ) { - Ok(res) => res, - Err(status) => return status.into_response(), - }; + let (clean_date_from, clean_date_to) = + match validate_date_filters(date_from.as_deref(), date_to.as_deref()) { + Ok(res) => res, + Err(status) => return status.into_response(), + }; let target_exists = { let conn = state.content_db.lock().unwrap(); if target_type == "url" { - get_url_by_id(&conn, &id).map(|u| u.is_some()).unwrap_or(false) + get_url_by_id(&conn, &id) + .map(|u| u.is_some()) + .unwrap_or(false) } else { - get_landing_page_by_id(&conn, &id).map(|p| p.is_some()).unwrap_or(false) + get_landing_page_by_id(&conn, &id) + .map(|p| p.is_some()) + .unwrap_or(false) } }; if !target_exists { @@ -1859,7 +1887,8 @@ async fn perform_json_export( &id, clean_date_from.as_deref(), clean_date_to.as_deref(), - ).unwrap_or(0) + ) + .unwrap_or(0) }; if count > MAX_JSON_EXPORT_ROWS as i64 { @@ -1895,7 +1924,11 @@ async fn perform_json_export( .map(|r| { let (browser, _, _) = parse_ua(&r.user_agent); let referrer = clean_referrer(&r.referer); - let country_display = if r.country.is_empty() { "Unknown".to_string() } else { r.country }; + let country_display = if r.country.is_empty() { + "Unknown".to_string() + } else { + r.country + }; JsonExportRow { timestamp: r.timestamp, ip_address: r.ip_address, @@ -1909,7 +1942,9 @@ async fn perform_json_export( let body_str = match serde_json::to_string(&export_rows) { Ok(s) => s, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response(), + Err(_) => { + return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response() + } }; let filename = if target_type == "url" { @@ -1922,11 +1957,15 @@ async fn perform_json_export( StatusCode::OK, [ ("Content-Type", "application/json"), - ("Content-Disposition", &format!("attachment; filename=\"{}\"", filename)), + ( + "Content-Disposition", + &format!("attachment; filename=\"{}\"", filename), + ), ("X-BZOD-Export-Records", &count.to_string()), ], body_str, - ).into_response() + ) + .into_response() } // GET /admin/analytics/url/:id @@ -1956,16 +1995,18 @@ pub async fn url_analytics_get( let has_utm_source = schema_cols.contains("utm_source"); let has_utm_campaign = schema_cols.contains("utm_campaign"); if has_utm_source || has_utm_campaign { - return (StatusCode::INTERNAL_SERVER_ERROR, "UTM mapping verification failed").into_response(); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "UTM mapping verification failed", + ) + .into_response(); } - let (clean_date_from, clean_date_to) = match validate_date_filters( - query.date_from.as_deref(), - query.date_to.as_deref(), - ) { - Ok(res) => res, - Err(status) => return status.into_response(), - }; + let (clean_date_from, clean_date_to) = + match validate_date_filters(query.date_from.as_deref(), query.date_to.as_deref()) { + Ok(res) => res, + Err(status) => return status.into_response(), + }; let total_clicks = get_target_visit_total_filtered( &conn, @@ -1973,7 +2014,8 @@ pub async fn url_analytics_get( &id, clean_date_from.as_deref(), clean_date_to.as_deref(), - ).unwrap_or(0); + ) + .unwrap_or(0); let unique_visitors = get_target_unique_visitors(&conn, "url", &id).unwrap_or(0); let qr_scans = crate::db::qr::get_qr_scan_count(&conn, &id).unwrap_or(0); @@ -2016,7 +2058,12 @@ pub async fn url_analytics_get( let calculated_total_pages = (total_clicks as usize).div_ceil(ANALYTICS_PAGE_SIZE); let total_pages = std::cmp::max(1, calculated_total_pages); let requested_page = query.analytics_page.unwrap_or(1); - let current_page = if requested_page == 0 { 1 } else { requested_page }.clamp(1, total_pages); + let current_page = if requested_page == 0 { + 1 + } else { + requested_page + } + .clamp(1, total_pages); let offset = (current_page - 1) * ANALYTICS_PAGE_SIZE; let visits_raw = get_target_visits_paginated( @@ -2027,7 +2074,8 @@ pub async fn url_analytics_get( offset as i64, clean_date_from.as_deref(), clean_date_to.as_deref(), - ).unwrap_or_default(); + ) + .unwrap_or_default(); let visits: Vec = visits_raw .into_iter() @@ -2040,7 +2088,11 @@ pub async fn url_analytics_get( sr, timestamp: r.timestamp, ip_address: r.ip_address, - country: if r.country.is_empty() { "Unknown".to_string() } else { r.country }, + country: if r.country.is_empty() { + "Unknown".to_string() + } else { + r.country + }, referrer, browser, user_agent: r.user_agent, @@ -2055,7 +2107,11 @@ pub async fn url_analytics_get( let visible_pages: Vec = (start_page..=end_page).collect(); let page_start = if total_clicks == 0 { 0 } else { offset + 1 }; - let page_end = if total_clicks == 0 { 0 } else { std::cmp::min(total_clicks as usize, offset + ANALYTICS_PAGE_SIZE) }; + let page_end = if total_clicks == 0 { + 0 + } else { + std::cmp::min(total_clicks as usize, offset + ANALYTICS_PAGE_SIZE) + }; let template = crate::templates::UrlAnalyticsTemplate { admin_username: user.username, @@ -2110,16 +2166,18 @@ pub async fn page_analytics_get( let has_utm_source = schema_cols.contains("utm_source"); let has_utm_campaign = schema_cols.contains("utm_campaign"); if has_utm_source || has_utm_campaign { - return (StatusCode::INTERNAL_SERVER_ERROR, "UTM mapping verification failed").into_response(); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "UTM mapping verification failed", + ) + .into_response(); } - let (clean_date_from, clean_date_to) = match validate_date_filters( - query.date_from.as_deref(), - query.date_to.as_deref(), - ) { - Ok(res) => res, - Err(status) => return status.into_response(), - }; + let (clean_date_from, clean_date_to) = + match validate_date_filters(query.date_from.as_deref(), query.date_to.as_deref()) { + Ok(res) => res, + Err(status) => return status.into_response(), + }; let total_views = get_target_visit_total_filtered( &conn, @@ -2127,7 +2185,8 @@ pub async fn page_analytics_get( &id, clean_date_from.as_deref(), clean_date_to.as_deref(), - ).unwrap_or(0); + ) + .unwrap_or(0); let unique_visitors = get_target_unique_visitors(&conn, "page", &id).unwrap_or(0); @@ -2163,7 +2222,12 @@ pub async fn page_analytics_get( let calculated_total_pages = (total_views as usize).div_ceil(ANALYTICS_PAGE_SIZE); let total_pages = std::cmp::max(1, calculated_total_pages); let requested_page = query.analytics_page.unwrap_or(1); - let current_page = if requested_page == 0 { 1 } else { requested_page }.clamp(1, total_pages); + let current_page = if requested_page == 0 { + 1 + } else { + requested_page + } + .clamp(1, total_pages); let offset = (current_page - 1) * ANALYTICS_PAGE_SIZE; let visits_raw = get_target_visits_paginated( @@ -2174,7 +2238,8 @@ pub async fn page_analytics_get( offset as i64, clean_date_from.as_deref(), clean_date_to.as_deref(), - ).unwrap_or_default(); + ) + .unwrap_or_default(); let visits: Vec = visits_raw .into_iter() @@ -2187,7 +2252,11 @@ pub async fn page_analytics_get( sr, timestamp: r.timestamp, ip_address: r.ip_address, - country: if r.country.is_empty() { "Unknown".to_string() } else { r.country }, + country: if r.country.is_empty() { + "Unknown".to_string() + } else { + r.country + }, referrer, browser, user_agent: r.user_agent, @@ -2202,7 +2271,11 @@ pub async fn page_analytics_get( let visible_pages: Vec = (start_page..=end_page).collect(); let page_start = if total_views == 0 { 0 } else { offset + 1 }; - let page_end = if total_views == 0 { 0 } else { std::cmp::min(total_views as usize, offset + ANALYTICS_PAGE_SIZE) }; + let page_end = if total_views == 0 { + 0 + } else { + std::cmp::min(total_views as usize, offset + ANALYTICS_PAGE_SIZE) + }; let template = crate::templates::PageAnalyticsTemplate { admin_username: user.username, @@ -2274,4 +2347,3 @@ pub async fn page_analytics_json_export( } perform_json_export(state, "page", id, query.date_from, query.date_to).await } - diff --git a/src/web/routes.rs b/src/web/routes.rs index d9c2fa8..4b0b4aa 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -38,11 +38,23 @@ pub fn create_router(state: AppState) -> Router { .route("/admin/pages/create", post(admin::pages_create)) .route("/admin/pages/delete/:id", post(admin::pages_delete)) .route("/admin/analytics/url/:id", get(admin::url_analytics_get)) - .route("/admin/analytics/url/:id/export/csv", get(admin::url_analytics_csv_export)) - .route("/admin/analytics/url/:id/export/json", get(admin::url_analytics_json_export)) + .route( + "/admin/analytics/url/:id/export/csv", + get(admin::url_analytics_csv_export), + ) + .route( + "/admin/analytics/url/:id/export/json", + get(admin::url_analytics_json_export), + ) .route("/admin/analytics/page/:id", get(admin::page_analytics_get)) - .route("/admin/analytics/page/:id/export/csv", get(admin::page_analytics_csv_export)) - .route("/admin/analytics/page/:id/export/json", get(admin::page_analytics_json_export)) + .route( + "/admin/analytics/page/:id/export/csv", + get(admin::page_analytics_csv_export), + ) + .route( + "/admin/analytics/page/:id/export/json", + get(admin::page_analytics_json_export), + ) .route("/admin/settings", get(admin::settings_get)) .route( "/admin/settings/password", diff --git a/tests/advanced_analytics_tests.rs b/tests/advanced_analytics_tests.rs index 5e0e6de..191fc56 100644 --- a/tests/advanced_analytics_tests.rs +++ b/tests/advanced_analytics_tests.rs @@ -1,11 +1,10 @@ -use rusqlite::Connection; -use bzod::db::migrations::{run_migrations, ANALYTICS_MIGRATIONS}; use bzod::db::analytics::{ - get_visits_schema_columns, get_target_visits_paginated, - get_target_visits_all_in_memory, get_target_visit_total_filtered, - parse_ua, clean_referrer, insert_visits_batch + clean_referrer, get_target_visit_total_filtered, get_target_visits_all_in_memory, + get_target_visits_paginated, get_visits_schema_columns, insert_visits_batch, parse_ua, }; +use bzod::db::migrations::{run_migrations, ANALYTICS_MIGRATIONS}; use bzod::models::VisitRecord; +use rusqlite::Connection; fn setup_analytics_db() -> Connection { let mut conn = Connection::open_in_memory().unwrap(); @@ -121,25 +120,47 @@ fn test_advanced_analytics_date_filtering() { insert_visits_batch(&mut conn, &records).unwrap(); // 1. Filter with date_from = "2026-06-15" (inclusive) -> should get v2 and v3 - let count1 = get_target_visit_total_filtered(&conn, "url", target_uuid, Some("2026-06-15"), None).unwrap(); + let count1 = + get_target_visit_total_filtered(&conn, "url", target_uuid, Some("2026-06-15"), None) + .unwrap(); assert_eq!(count1, 2); - let results1 = get_target_visits_all_in_memory(&conn, "url", target_uuid, Some("2026-06-15"), None).unwrap(); + let results1 = + get_target_visits_all_in_memory(&conn, "url", target_uuid, Some("2026-06-15"), None) + .unwrap(); assert_eq!(results1.len(), 2); assert_eq!(results1[0].id, "v3"); // DESC sorting assert_eq!(results1[1].id, "v2"); // 2. Filter with date_to = "2026-06-15" (inclusive) -> should get v1 and v2 - let count2 = get_target_visit_total_filtered(&conn, "url", target_uuid, None, Some("2026-06-15")).unwrap(); + let count2 = + get_target_visit_total_filtered(&conn, "url", target_uuid, None, Some("2026-06-15")) + .unwrap(); assert_eq!(count2, 2); - let results2 = get_target_visits_all_in_memory(&conn, "url", target_uuid, None, Some("2026-06-15")).unwrap(); + let results2 = + get_target_visits_all_in_memory(&conn, "url", target_uuid, None, Some("2026-06-15")) + .unwrap(); assert_eq!(results2.len(), 2); assert_eq!(results2[0].id, "v2"); assert_eq!(results2[1].id, "v1"); // 3. Filter with both date_from and date_to = "2026-06-15" - let count3 = get_target_visit_total_filtered(&conn, "url", target_uuid, Some("2026-06-15"), Some("2026-06-15")).unwrap(); + let count3 = get_target_visit_total_filtered( + &conn, + "url", + target_uuid, + Some("2026-06-15"), + Some("2026-06-15"), + ) + .unwrap(); assert_eq!(count3, 1); - let results3 = get_target_visits_all_in_memory(&conn, "url", target_uuid, Some("2026-06-15"), Some("2026-06-15")).unwrap(); + let results3 = get_target_visits_all_in_memory( + &conn, + "url", + target_uuid, + Some("2026-06-15"), + Some("2026-06-15"), + ) + .unwrap(); assert_eq!(results3.len(), 1); assert_eq!(results3[0].id, "v2"); } diff --git a/tests/analytics_tests.rs b/tests/analytics_tests.rs index f0733a7..1f8ef41 100644 --- a/tests/analytics_tests.rs +++ b/tests/analytics_tests.rs @@ -1,12 +1,12 @@ -use rusqlite::Connection; -use chrono::Utc; -use bzod::db::migrations::{run_migrations, CONTENT_MIGRATIONS, ANALYTICS_MIGRATIONS}; -use bzod::db::content::{create_url_extended, get_url_count_by_tag, list_urls, get_url_counts}; use bzod::db::analytics::{ - get_target_visit_count, get_target_unique_visitors, get_monthly_clicks_trend, + get_monthly_clicks_trend, get_target_unique_visitors, get_target_visit_count, insert_visits_batch, }; +use bzod::db::content::{create_url_extended, get_url_count_by_tag, get_url_counts, list_urls}; +use bzod::db::migrations::{run_migrations, ANALYTICS_MIGRATIONS, CONTENT_MIGRATIONS}; use bzod::models::VisitRecord; +use chrono::Utc; +use rusqlite::Connection; fn setup_content_db() -> Connection { let mut conn = Connection::open_in_memory().unwrap(); @@ -98,8 +98,14 @@ fn test_target_analytics_queries() { let target_uuid = "target-uuid-123456"; // 1. Visit Count and Unique Visitors check on empty db - assert_eq!(get_target_visit_count(&conn, "url", target_uuid).unwrap(), 0); - assert_eq!(get_target_unique_visitors(&conn, "url", target_uuid).unwrap(), 0); + assert_eq!( + get_target_visit_count(&conn, "url", target_uuid).unwrap(), + 0 + ); + assert_eq!( + get_target_unique_visitors(&conn, "url", target_uuid).unwrap(), + 0 + ); // 2. Insert some visit records let now = Utc::now(); @@ -145,8 +151,14 @@ fn test_target_analytics_queries() { insert_visits_batch(&mut conn, &records).unwrap(); // 3. Verify counts - assert_eq!(get_target_visit_count(&conn, "url", target_uuid).unwrap(), 3); - assert_eq!(get_target_unique_visitors(&conn, "url", target_uuid).unwrap(), 2); + assert_eq!( + get_target_visit_count(&conn, "url", target_uuid).unwrap(), + 3 + ); + assert_eq!( + get_target_unique_visitors(&conn, "url", target_uuid).unwrap(), + 2 + ); // 4. Verify monthly clicks trend fallback let monthly_trend = get_monthly_clicks_trend(&conn, "url", target_uuid, 12).unwrap();