From 84c48fa5c1751ef857a7224443ee3510eecf5454 Mon Sep 17 00:00:00 2001 From: Sunil Thakares Date: Wed, 17 Jun 2026 15:35:59 +0530 Subject: [PATCH] Add analytics drill-down, visitor logs, exports and pagination --- Cargo.lock | 13 + Cargo.toml | 2 + src/db/analytics.rs | 221 +++++++++ src/db/content.rs | 13 + src/templates/analytics.rs | 108 +++++ src/templates/mod.rs | 2 + src/templates/pages.rs | 10 + src/templates/urls.rs | 10 + src/web/admin.rs | 749 +++++++++++++++++++++++++++++- src/web/routes.rs | 6 + templates/page_analytics.html | 223 +++++++++ templates/pages.html | 35 +- templates/url_analytics.html | 252 ++++++++++ templates/urls.html | 35 +- tests/advanced_analytics_tests.rs | 156 +++++++ tests/analytics_tests.rs | 155 +++++++ 16 files changed, 1982 insertions(+), 8 deletions(-) create mode 100644 src/templates/analytics.rs create mode 100644 templates/page_analytics.html create mode 100644 templates/url_analytics.html create mode 100644 tests/advanced_analytics_tests.rs create mode 100644 tests/analytics_tests.rs diff --git a/Cargo.lock b/Cargo.lock index a2d89c9..70fc279 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -463,6 +463,7 @@ dependencies = [ "clap", "dotenvy", "flate2", + "futures-util", "hex", "image", "qrcode", @@ -851,6 +852,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-task" version = "0.3.32" @@ -864,6 +876,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "slab", diff --git a/Cargo.toml b/Cargo.toml index e079815..fe221f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,5 @@ toml = "0.8" qrcode = "0.14" image = "0.25" zip = { version = "2.1", default-features = false, features = ["deflate"] } +futures-util = "0.3" + diff --git a/src/db/analytics.rs b/src/db/analytics.rs index c119bf3..5721520 100644 --- a/src/db/analytics.rs +++ b/src/db/analytics.rs @@ -508,6 +508,227 @@ pub fn get_metric_rankings_raw( Ok(res) } +/// Returns the raw visit counts for a specific target ID. +pub fn get_target_visit_count( + conn: &Connection, + target_type: &str, + target_id: &str, +) -> rusqlite::Result { + conn.query_row( + "SELECT COUNT(*) FROM visits WHERE target_type = ?1 AND target_id = ?2;", + params![target_type, target_id], + |row| row.get(0), + ) +} + +/// Returns the distinct IP count (Unique Visitors) for a specific target ID. +pub fn get_target_unique_visitors( + conn: &Connection, + target_type: &str, + target_id: &str, +) -> rusqlite::Result { + conn.query_row( + "SELECT COUNT(DISTINCT ip_address) FROM visits WHERE target_type = ?1 AND target_id = ?2;", + params![target_type, target_id], + |row| row.get(0), + ) +} + +/// Fetches the monthly clicks trend for a specific target ID, falling back to a raw visits query if monthly_summaries are empty. +pub fn get_monthly_clicks_trend( + conn: &Connection, + target_type: &str, + target_id: &str, + limit_months: i64, +) -> rusqlite::Result> { + 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;" + )?; + let rows = stmt.query_map(params![target_type, target_id, limit_months], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })?; + let mut res = Vec::new(); + for r in rows { + res.push(r?); + } + + if res.is_empty() { + // Fallback to raw visits + 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;" + )?; + let rows = stmt.query_map(params![target_type, target_id, limit_months], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })?; + for r in rows { + res.push(r?); + } + } + + Ok(res) +} + +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([])?; + while let Some(row) = rows.next()? { + let name: String = row.get("name")?; + columns.insert(name); + } + Ok(columns) +} + +pub fn get_target_visits_paginated( + conn: &Connection, + target_type: &str, + target_id: &str, + limit: i64, + offset: i64, + date_from: Option<&str>, + date_to: Option<&str>, +) -> rusqlite::Result> { + let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code 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()), + ]; + + if let Some(df) = date_from { + sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1)); + params.push(Box::new(format!("{}T00:00:00Z", df))); + } + + if let Some(dt) = date_to { + 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")))); + } + } + + sql.push_str(" ORDER BY timestamp DESC, id DESC LIMIT ?"); + sql.push_str(&(params.len() + 1).to_string()); + params.push(Box::new(limit)); + + sql.push_str(" OFFSET ?"); + sql.push_str(&(params.len() + 1).to_string()); + params.push(Box::new(offset)); + + let mut stmt = conn.prepare(&sql)?; + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let rows = stmt.query_map(rusqlite::params_from_iter(param_refs), |row| { + Ok(VisitRecord { + id: row.get("id")?, + target_type: row.get("target_type")?, + target_id: row.get("target_id")?, + timestamp: row.get("timestamp")?, + ip_address: row.get("ip_address")?, + user_agent: row.get("user_agent")?, + referer: row.get("referer")?, + accept_language: row.get("accept_language")?, + country: row.get("country")?, + status_code: row.get("status_code")?, + }) + })?; + + let mut visits = Vec::new(); + for r in rows { + visits.push(r?); + } + Ok(visits) +} + +pub fn get_target_visits_all_in_memory( + conn: &Connection, + target_type: &str, + target_id: &str, + date_from: Option<&str>, + date_to: Option<&str>, +) -> rusqlite::Result> { + let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code 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()), + ]; + + if let Some(df) = date_from { + sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1)); + params.push(Box::new(format!("{}T00:00:00Z", df))); + } + + if let Some(dt) = date_to { + 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")))); + } + } + + sql.push_str(" ORDER BY timestamp DESC, id DESC"); + + let mut stmt = conn.prepare(&sql)?; + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let rows = stmt.query_map(rusqlite::params_from_iter(param_refs), |row| { + Ok(VisitRecord { + id: row.get("id")?, + target_type: row.get("target_type")?, + target_id: row.get("target_id")?, + timestamp: row.get("timestamp")?, + ip_address: row.get("ip_address")?, + user_agent: row.get("user_agent")?, + referer: row.get("referer")?, + accept_language: row.get("accept_language")?, + country: row.get("country")?, + status_code: row.get("status_code")?, + }) + })?; + + let mut visits = Vec::new(); + for r in rows { + visits.push(r?); + } + Ok(visits) +} + +pub fn get_target_visit_total_filtered( + conn: &Connection, + target_type: &str, + target_id: &str, + date_from: Option<&str>, + date_to: Option<&str>, +) -> rusqlite::Result { + if date_from.is_none() && date_to.is_none() { + 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 params: Vec> = vec![ + Box::new(target_type.to_string()), + Box::new(target_id.to_string()), + ]; + + if let Some(df) = date_from { + sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1)); + params.push(Box::new(format!("{}T00:00:00Z", df))); + } + + if let Some(dt) = date_to { + 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")))); + } + } + + 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)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/db/content.rs b/src/db/content.rs index 747f237..c63be2e 100644 --- a/src/db/content.rs +++ b/src/db/content.rs @@ -47,6 +47,19 @@ pub fn get_tags_for_url(conn: &Connection, url_id: &str) -> rusqlite::Result rusqlite::Result { + let tag_name = tag.trim().to_lowercase(); + conn.query_row( + "SELECT COUNT(*) FROM urls u + JOIN url_tags ut ON u.id = ut.url_id + JOIN tags t ON ut.tag_id = t.id + WHERE t.name = ?1;", + params![tag_name], + |row| row.get(0), + ) +} + /// The full column list used in all URL SELECT queries. const URL_COLUMNS: &str = "id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, last_status, last_latency_ms, max_access_count, access_count"; diff --git a/src/templates/analytics.rs b/src/templates/analytics.rs new file mode 100644 index 0000000..70d735a --- /dev/null +++ b/src/templates/analytics.rs @@ -0,0 +1,108 @@ +use crate::models::{LandingPage, Url}; +use askama::Template; +use axum::{ + http::StatusCode, + response::{Html, IntoResponse, Response}, +}; + +#[derive(Clone, Debug)] +pub struct VisitorLogEntry { + pub sr: usize, + pub timestamp: String, + pub ip_address: String, + pub country: String, + pub referrer: String, + pub browser: String, + pub user_agent: String, + pub utm_source: String, + pub utm_campaign: String, +} + +#[derive(Template)] +#[template(path = "url_analytics.html")] +pub struct UrlAnalyticsTemplate { + pub admin_username: String, + pub url: Url, + pub total_clicks: i64, + pub unique_visitors: i64, + pub qr_scans: i64, + pub direct_clicks: i64, + pub traffic_chart: String, + pub monthly_chart: String, + pub countries_chart: String, + pub referrers_chart: String, + pub browsers_chart: String, + // Paginated visitor logs + pub visits: Vec, + pub current_page: usize, + pub total_pages: usize, + pub visible_pages: Vec, + pub total_records: i64, + pub page_start: usize, + pub page_end: usize, + pub date_from: Option, + pub date_to: Option, +} + +impl UrlAnalyticsTemplate { + pub fn is_current(&self, page: &usize) -> bool { + *page == self.current_page + } +} + + +impl IntoResponse for UrlAnalyticsTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} + +#[derive(Template)] +#[template(path = "page_analytics.html")] +pub struct PageAnalyticsTemplate { + pub admin_username: String, + pub page: LandingPage, + pub total_views: i64, + pub unique_visitors: i64, + pub traffic_chart: String, + pub monthly_chart: String, + pub countries_chart: String, + pub referrers_chart: String, + // Paginated visitor logs + pub visits: Vec, + pub current_page: usize, + pub total_pages: usize, + pub visible_pages: Vec, + pub total_records: i64, + pub page_start: usize, + pub page_end: usize, + pub date_from: Option, + pub date_to: Option, +} + +impl PageAnalyticsTemplate { + pub fn is_current(&self, page: &usize) -> bool { + *page == self.current_page + } +} + + +impl IntoResponse for PageAnalyticsTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Render error: {}", e), + ) + .into_response(), + } + } +} diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 050cc2c..46761db 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -1,9 +1,11 @@ +pub mod analytics; pub mod dashboard; pub mod pages; pub mod settings; pub mod stats; pub mod urls; +pub use analytics::{PageAnalyticsTemplate, UrlAnalyticsTemplate, VisitorLogEntry}; pub use dashboard::DashboardTemplate; pub use pages::PagesTemplate; pub use settings::SettingsTemplate; diff --git a/src/templates/pages.rs b/src/templates/pages.rs index 8d297fe..9863a52 100644 --- a/src/templates/pages.rs +++ b/src/templates/pages.rs @@ -12,8 +12,18 @@ pub struct PagesTemplate { pub pages: Vec, pub csrf_token: String, pub error: Option, + pub current_page: usize, + pub total_pages: usize, + pub visible_pages: Vec, } +impl PagesTemplate { + pub fn is_current(&self, page: &usize) -> bool { + *page == self.current_page + } +} + + 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 be4445a..71b58d4 100644 --- a/src/templates/urls.rs +++ b/src/templates/urls.rs @@ -14,8 +14,18 @@ pub struct UrlsTemplate { pub error: Option, pub tag_filter: Option, pub base_url: String, + pub current_page: usize, + pub total_pages: usize, + pub visible_pages: Vec, } +impl UrlsTemplate { + pub fn is_current(&self, page: &usize) -> bool { + *page == self.current_page + } +} + + 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 3f2d84e..c6c39ec 100644 --- a/src/web/admin.rs +++ b/src/web/admin.rs @@ -63,16 +63,23 @@ 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_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, }; use crate::db::content::{ create_landing_page, delete_landing_page, delete_url, get_landing_page_count, get_url_counts, - list_landing_pages, list_urls, + list_landing_pages, list_urls, get_url_count_by_tag, get_url_by_id, get_landing_page_by_id, }; use crate::models::User; use crate::state::AppState; use crate::utils::{get_client_ip, get_db_file_info, get_memory_usage}; +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(); @@ -353,6 +360,7 @@ pub async fn dashboard_get(State(state): State, jar: CookieJar) -> Res pub struct UrlsQuery { pub tag: Option, pub error: Option, + pub page: Option, } pub async fn urls_get( @@ -365,9 +373,26 @@ pub async fn urls_get( Err(redir) => return redir.into_response(), }; - let urls = { + let (urls, total_pages, page, visible_pages) = { let conn = state.content_db.lock().unwrap(); - list_urls(&conn, 100, 0, query.tag.as_deref()).unwrap_or_default() + let total_records = if let Some(tag_str) = query.tag.as_deref() { + get_url_count_by_tag(&conn, tag_str).unwrap_or(0) + } else { + get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0) + }; + 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 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 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(); + + (urls, total_pages, current_page, visible_pages) }; let csrf_token = generate_csrf_token(&session_id); @@ -390,11 +415,15 @@ pub async fn urls_get( error: query.error, tag_filter: query.tag, base_url, + current_page: page, + total_pages, + visible_pages, }; template.into_response() } + #[derive(Deserialize)] pub struct CreateUrlForm { pub destination: String, @@ -613,6 +642,7 @@ pub async fn urls_delete( #[derive(Deserialize)] pub struct PagesQuery { pub error: Option, + pub page: Option, } pub async fn pages_get( @@ -625,9 +655,22 @@ pub async fn pages_get( Err(redir) => return redir.into_response(), }; - let pages = { + let (pages, total_pages, page, visible_pages) = { let conn = state.content_db.lock().unwrap(); - list_landing_pages(&conn, 100, 0).unwrap_or_default() + let total_records = get_landing_page_count(&conn).unwrap_or(0); + 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 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(); + + (pages, total_pages, current_page, visible_pages) }; let csrf_token = generate_csrf_token(&session_id); @@ -637,6 +680,9 @@ pub async fn pages_get( pages, csrf_token, error: query.error, + current_page: page, + total_pages, + visible_pages, }; template.into_response() @@ -1538,3 +1584,694 @@ pub async fn restore_backup_post( } } } + +#[derive(Deserialize)] +pub struct AnalyticsQuery { + pub analytics_page: Option, + pub date_from: Option, + pub date_to: Option, +} + +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), + } + } + _ => 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), + } + } + _ => None, + }; + if let (Some(f), Some(t)) = (from_parsed, to_parsed) { + if f > t { + return Err(StatusCode::BAD_REQUEST); + } + } + Ok(( + from_parsed.map(|d| d.format("%Y-%m-%d").to_string()), + to_parsed.map(|d| d.format("%Y-%m-%d").to_string()), + )) +} + +fn escape_csv_field(field: &str) -> String { + let needs_escaping = field.contains(',') + || field.contains('"') + || field.contains('\n') + || field.contains('\r'); + if needs_escaping { + let escaped = field.replace('"', "\"\""); + format!("\"{}\"", escaped) + } else { + field.to_string() + } +} + +struct DbExportStream { + receiver: tokio::sync::mpsc::Receiver>, +} + +impl futures_util::stream::Stream for DbExportStream { + type Item = Result; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.receiver.poll_recv(cx) + } +} + +async fn perform_csv_export( + state: AppState, + target_type: &'static str, + id: String, + 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 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) + } else { + get_landing_page_by_id(&conn, &id).map(|p| p.is_some()).unwrap_or(false) + } + }; + if !target_exists { + return (StatusCode::NOT_FOUND, "Target not found").into_response(); + } + + let count = { + let conn = state.analytics_db.lock().unwrap(); + get_target_visit_total_filtered( + &conn, + target_type, + &id, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ).unwrap_or(0) + }; + + let (has_utm_source, has_utm_campaign) = { + let conn = state.analytics_db.lock().unwrap(); + let cols = get_visits_schema_columns(&conn).unwrap_or_default(); + (cols.contains("utm_source"), cols.contains("utm_campaign")) + }; + + 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"); + } + if has_utm_campaign { + header.push_str(",UTM Campaign"); + } + header.push('\n'); + + if tx.blocking_send(Ok(axum::body::Bytes::from(header))).is_err() { + return; + } + + let select_fields = if has_utm_source && has_utm_campaign { + "timestamp, ip_address, country, referer, user_agent, utm_source, utm_campaign" + } else if has_utm_source { + "timestamp, ip_address, country, referer, user_agent, utm_source" + } else if has_utm_campaign { + "timestamp, ip_address, country, referer, user_agent, utm_campaign" + } else { + "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), + ]; + + if let Some(df) = clean_date_from.as_deref() { + sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1)); + params.push(Box::new(format!("{}T00:00:00Z", df))); + } + + if let Some(dt) = clean_date_to.as_deref() { + 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")))); + } + } + + sql.push_str(" ORDER BY timestamp DESC, id DESC"); + + let mut stmt = match conn.prepare(&sql) { + Ok(s) => s, + Err(_) => return, + }; + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let mut rows = match stmt.query(rusqlite::params_from_iter(param_refs)) { + Ok(r) => r, + Err(_) => return, + }; + + let mut csv_buffer = String::new(); + + while let Ok(Some(row)) = rows.next() { + let timestamp: String = row.get(0).unwrap_or_default(); + let ip_address: String = row.get(1).unwrap_or_default(); + let country: String = row.get(2).unwrap_or_default(); + let referer: String = row.get(3).unwrap_or_default(); + let user_agent: String = row.get(4).unwrap_or_default(); + + let (browser, _, _) = parse_ua(&user_agent); + let referrer = clean_referrer(&referer); + let country_display = if country.is_empty() { "Unknown".to_string() } else { country }; + + let mut line = format!( + "{},{},{},{},{},{}", + escape_csv_field(×tamp), + escape_csv_field(&ip_address), + escape_csv_field(&country_display), + escape_csv_field(&referrer), + escape_csv_field(&browser), + escape_csv_field(&user_agent) + ); + + let mut col_idx = 5; + if has_utm_source { + let utm_src: String = row.get(col_idx).unwrap_or_default(); + line.push_str(&format!(",{}", escape_csv_field(&utm_src))); + col_idx += 1; + } + if has_utm_campaign { + let utm_camp: String = row.get(col_idx).unwrap_or_default(); + line.push_str(&format!(",{}", escape_csv_field(&utm_camp))); + } + line.push('\n'); + + csv_buffer.push_str(&line); + if csv_buffer.len() >= 8192 { + let bytes = axum::body::Bytes::from(csv_buffer); + if tx.blocking_send(Ok(bytes)).is_err() { + return; + } + csv_buffer = String::new(); + } + } + + if !csv_buffer.is_empty() { + let _ = tx.blocking_send(Ok(axum::body::Bytes::from(csv_buffer))); + } + }); + + let stream = DbExportStream { receiver: rx }; + let filename = if target_type == "url" { + format!("url_{}_analytics.csv", id) + } else { + format!("page_{}_analytics.csv", id) + }; + + ( + StatusCode::OK, + [ + ("Content-Type", "text/csv"), + ("Content-Disposition", &format!("attachment; filename=\"{}\"", filename)), + ("X-BZOD-Export-Records", &count.to_string()), + ], + axum::body::Body::from_stream(stream), + ).into_response() +} + +async fn perform_json_export( + state: AppState, + target_type: &'static str, + id: String, + 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 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) + } else { + get_landing_page_by_id(&conn, &id).map(|p| p.is_some()).unwrap_or(false) + } + }; + if !target_exists { + return (StatusCode::NOT_FOUND, "Target not found").into_response(); + } + + let count = { + let conn = state.analytics_db.lock().unwrap(); + get_target_visit_total_filtered( + &conn, + target_type, + &id, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ).unwrap_or(0) + }; + + if count > MAX_JSON_EXPORT_ROWS as i64 { + return StatusCode::PAYLOAD_TOO_LARGE.into_response(); + } + + let visits_raw = { + let conn = state.analytics_db.lock().unwrap(); + match get_target_visits_all_in_memory( + &conn, + target_type, + &id, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ) { + Ok(v) => v, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + } + }; + + #[derive(serde::Serialize)] + struct JsonExportRow { + timestamp: String, + ip_address: String, + country: String, + referrer: String, + browser: String, + user_agent: String, + } + + let export_rows: Vec = visits_raw + .into_iter() + .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 }; + JsonExportRow { + timestamp: r.timestamp, + ip_address: r.ip_address, + country: country_display, + referrer, + browser, + user_agent: r.user_agent, + } + }) + .collect(); + + let body_str = match serde_json::to_string(&export_rows) { + Ok(s) => s, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response(), + }; + + let filename = if target_type == "url" { + format!("url_{}_analytics.json", id) + } else { + format!("page_{}_analytics.json", id) + }; + + ( + StatusCode::OK, + [ + ("Content-Type", "application/json"), + ("Content-Disposition", &format!("attachment; filename=\"{}\"", filename)), + ("X-BZOD-Export-Records", &count.to_string()), + ], + body_str, + ).into_response() +} + +// GET /admin/analytics/url/:id +pub async fn url_analytics_get( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(query): Query, +) -> Response { + let (user, _) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let url = { + let conn = state.content_db.lock().unwrap(); + match get_url_by_id(&conn, &id) { + Ok(Some(u)) => u, + Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + } + }; + + let conn = state.analytics_db.lock().unwrap(); + + let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default(); + 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(); + } + + 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, + "url", + &id, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ).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); + let direct_clicks = (total_clicks - qr_scans).max(0); + + let clicks_data = get_clicks_trend(&conn, "url", &id, 30) + .or_else(|_| get_clicks_trend_raw(&conn, "url", &id, 30)) + .unwrap_or_default(); + let mut trend_map = std::collections::BTreeMap::new(); + for i in (0..30).rev() { + let date_str = (Utc::now() - chrono::Duration::days(i)) + .format("%Y-%m-%d") + .to_string(); + trend_map.insert(date_str, 0i64); + } + for (d, c) in clicks_data { + trend_map.insert(d, c); + } + let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); + let traffic_chart = generate_line_chart(&formatted_trend); + + let monthly_data = get_monthly_clicks_trend(&conn, "url", &id, 12).unwrap_or_default(); + let monthly_chart = generate_line_chart(&monthly_data); + + let countries_data = get_metric_rankings(&conn, "url", &id, "country", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "country", 5)) + .unwrap_or_default(); + let countries_chart = generate_bar_chart(&countries_data); + + let referrers_data = get_metric_rankings(&conn, "url", &id, "referrer", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "referrer", 5)) + .unwrap_or_default(); + let referrers_chart = generate_bar_chart(&referrers_data); + + let browsers_data = get_metric_rankings(&conn, "url", &id, "browser", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "browser", 5)) + .unwrap_or_default(); + let browsers_chart = generate_bar_chart(&browsers_data); + + 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 offset = (current_page - 1) * ANALYTICS_PAGE_SIZE; + + let visits_raw = get_target_visits_paginated( + &conn, + "url", + &id, + ANALYTICS_PAGE_SIZE as i64, + offset as i64, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ).unwrap_or_default(); + + let visits: Vec = visits_raw + .into_iter() + .enumerate() + .map(|(idx, r)| { + let (browser, _, _) = parse_ua(&r.user_agent); + let referrer = clean_referrer(&r.referer); + let sr = offset + idx + 1; + crate::templates::VisitorLogEntry { + sr, + timestamp: r.timestamp, + ip_address: r.ip_address, + country: if r.country.is_empty() { "Unknown".to_string() } else { r.country }, + referrer, + browser, + user_agent: r.user_agent, + utm_source: "-".to_string(), + utm_campaign: "-".to_string(), + } + }) + .collect(); + + 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(); + + 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 template = crate::templates::UrlAnalyticsTemplate { + admin_username: user.username, + url, + total_clicks, + unique_visitors, + qr_scans, + direct_clicks, + traffic_chart, + monthly_chart, + countries_chart, + referrers_chart, + browsers_chart, + visits, + current_page, + total_pages, + visible_pages, + total_records: total_clicks, + page_start, + page_end, + date_from: clean_date_from, + date_to: clean_date_to, + }; + + template.into_response() +} + +// GET /admin/analytics/page/:id +pub async fn page_analytics_get( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(query): Query, +) -> Response { + let (user, _) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let page = { + let conn = state.content_db.lock().unwrap(); + match get_landing_page_by_id(&conn, &id) { + Ok(Some(p)) => p, + Ok(None) => return (StatusCode::NOT_FOUND, "Landing page not found").into_response(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + } + }; + + let conn = state.analytics_db.lock().unwrap(); + + let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default(); + 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(); + } + + 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, + "page", + &id, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ).unwrap_or(0); + + let unique_visitors = get_target_unique_visitors(&conn, "page", &id).unwrap_or(0); + + let clicks_data = get_clicks_trend(&conn, "page", &id, 30) + .or_else(|_| get_clicks_trend_raw(&conn, "page", &id, 30)) + .unwrap_or_default(); + let mut trend_map = std::collections::BTreeMap::new(); + for i in (0..30).rev() { + let date_str = (Utc::now() - chrono::Duration::days(i)) + .format("%Y-%m-%d") + .to_string(); + trend_map.insert(date_str, 0i64); + } + for (d, c) in clicks_data { + trend_map.insert(d, c); + } + let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); + let traffic_chart = generate_line_chart(&formatted_trend); + + let monthly_data = get_monthly_clicks_trend(&conn, "page", &id, 12).unwrap_or_default(); + let monthly_chart = generate_line_chart(&monthly_data); + + let countries_data = get_metric_rankings(&conn, "page", &id, "country", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "page", &id, "country", 5)) + .unwrap_or_default(); + let countries_chart = generate_bar_chart(&countries_data); + + let referrers_data = get_metric_rankings(&conn, "page", &id, "referrer", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "page", &id, "referrer", 5)) + .unwrap_or_default(); + let referrers_chart = generate_bar_chart(&referrers_data); + + 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 offset = (current_page - 1) * ANALYTICS_PAGE_SIZE; + + let visits_raw = get_target_visits_paginated( + &conn, + "page", + &id, + ANALYTICS_PAGE_SIZE as i64, + offset as i64, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ).unwrap_or_default(); + + let visits: Vec = visits_raw + .into_iter() + .enumerate() + .map(|(idx, r)| { + let (browser, _, _) = parse_ua(&r.user_agent); + let referrer = clean_referrer(&r.referer); + let sr = offset + idx + 1; + crate::templates::VisitorLogEntry { + sr, + timestamp: r.timestamp, + ip_address: r.ip_address, + country: if r.country.is_empty() { "Unknown".to_string() } else { r.country }, + referrer, + browser, + user_agent: r.user_agent, + utm_source: "-".to_string(), + utm_campaign: "-".to_string(), + } + }) + .collect(); + + 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(); + + 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 template = crate::templates::PageAnalyticsTemplate { + admin_username: user.username, + page, + total_views, + unique_visitors, + traffic_chart, + monthly_chart, + countries_chart, + referrers_chart, + visits, + current_page, + total_pages, + visible_pages, + total_records: total_views, + page_start, + page_end, + date_from: clean_date_from, + date_to: clean_date_to, + }; + + template.into_response() +} + +pub async fn url_analytics_csv_export( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(query): Query, +) -> Response { + if require_auth(&state, &jar).await.is_err() { + return StatusCode::UNAUTHORIZED.into_response(); + } + perform_csv_export(state, "url", id, query.date_from, query.date_to).await +} + +pub async fn url_analytics_json_export( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(query): Query, +) -> Response { + if require_auth(&state, &jar).await.is_err() { + return StatusCode::UNAUTHORIZED.into_response(); + } + perform_json_export(state, "url", id, query.date_from, query.date_to).await +} + +pub async fn page_analytics_csv_export( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(query): Query, +) -> Response { + if require_auth(&state, &jar).await.is_err() { + return StatusCode::UNAUTHORIZED.into_response(); + } + perform_csv_export(state, "page", id, query.date_from, query.date_to).await +} + +pub async fn page_analytics_json_export( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(query): Query, +) -> Response { + if require_auth(&state, &jar).await.is_err() { + return StatusCode::UNAUTHORIZED.into_response(); + } + 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 aa9bb70..d9c2fa8 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -37,6 +37,12 @@ pub fn create_router(state: AppState) -> Router { .route("/admin/pages", get(admin::pages_get)) .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/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/settings", get(admin::settings_get)) .route( "/admin/settings/password", diff --git a/templates/page_analytics.html b/templates/page_analytics.html new file mode 100644 index 0000000..3e7ad6c --- /dev/null +++ b/templates/page_analytics.html @@ -0,0 +1,223 @@ +{% extends "layout.html" %} + +{% block title %}Landing Page Analytics - {{ page.slug }} - BZOD{% endblock %} + +{% block active_pages %}active{% endblock %} + +{% block header_title %}Page Analytics: /p/{{ page.code }}{% endblock %} + +{% block header_actions %} + +{% endblock %} + +{% block content %} + +
+
+
+ + +
+
+ + +
+ + Clear +
+
+ + +
+

+ + Page Details +

+
+
+ Short Path + + /p/{{ page.code }} + +
+ +
+ Title + {{ page.title }} +
+
+ Publish State + {{ page.state }} +
+
+ Created Date + {{ page.created_at[0..10] }} {{ page.created_at[11..16] }} +
+
+
+ + +
+
+ Total Views + {{ total_views }} +
+
+ Unique Visitors + {{ unique_visitors }} +
+
+ + +
+
+

+ + Daily Page Views (Last 30 Days) +

+
+ {{ traffic_chart|safe }} +
+
+
+

+ + Monthly Page Views +

+
+ {{ monthly_chart|safe }} +
+
+
+ + +
+ +
+

+ + Geographic Analysis (Top Countries) +

+
+ {{ countries_chart|safe }} +
+
+ + +
+

+ + Referrer Channels (Top Referrers) +

+
+ {{ referrers_chart|safe }} +
+
+
+ + +
+
+

+ + Visitor Activity Log +

+
+ +
+ + + + + + + + + + + + + + + + {% if visits.is_empty() %} + + + + {% else %} + {% for entry in visits %} + + + + + + + + + + + + {% endfor %} + {% endif %} + +
SrTimestampIP AddressCountryReferrerBrowserUser-AgentUTM SourceUTM Campaign
+ No visitor activity available. +
{{ entry.sr }}{{ entry.timestamp }}{{ entry.ip_address }} + + {{ entry.country }} + + {{ entry.referrer }}{{ entry.browser }} + {{ entry.user_agent }} + {{ entry.utm_source }}{{ entry.utm_campaign }}
+
+ + +
+
+ Showing {{ page_start }}-{{ page_end }} of {{ total_records }} visits +
+ + +
+
+{% endblock %} diff --git a/templates/pages.html b/templates/pages.html index 906d45d..ae95a21 100644 --- a/templates/pages.html +++ b/templates/pages.html @@ -83,6 +83,7 @@ Short Path SEO Preview Path Status + Analytics Created Action @@ -90,7 +91,7 @@ {% if pages.is_empty() %} - + No landing pages registered. Create one to get started! @@ -120,6 +121,11 @@ {{ page.state }} + + + 📊 Analytics + + {{ page.created_at[0..10] }} @@ -137,6 +143,33 @@ + + + {% endblock %} diff --git a/templates/url_analytics.html b/templates/url_analytics.html new file mode 100644 index 0000000..2a8e8a4 --- /dev/null +++ b/templates/url_analytics.html @@ -0,0 +1,252 @@ +{% extends "layout.html" %} + +{% block title %}URL Analytics - {{ url.code }} - BZOD{% endblock %} + +{% block active_urls %}active{% endblock %} + +{% block header_title %}URL Analytics: /{{ url.code }}{% endblock %} + +{% block header_actions %} + +{% endblock %} + +{% block content %} + +
+
+
+ + +
+
+ + +
+ + Clear +
+
+ + +
+

+ + Link Details +

+
+
+ Destination URL + + {{ url.destination }} + +
+
+ Title + {{ url.title.as_deref().unwrap_or("No Title") }} +
+
+ Created Date + {{ url.created_at[0..10] }} {{ url.created_at[11..16] }} +
+
+ Tags +
+ {% if url.tags.is_empty() %} + No tags + {% else %} + {% for t in url.tags %} + {{ t }} + {% endfor %} + {% endif %} +
+
+
+ {% if let Some(desc) = url.description.as_deref() %} + {% if !desc.is_empty() %} +
+ Description +

{{ desc }}

+
+ {% endif %} + {% endif %} +
+ + +
+
+ Total Clicks + {{ total_clicks }} +
+
+ Unique Visitors + {{ unique_visitors }} +
+
+ QR Code scans + {{ qr_scans }} +
+
+ Direct Clicks + {{ direct_clicks }} +
+
+ + +
+
+

+ + Daily Click Traffic (Last 30 Days) +

+
+ {{ traffic_chart|safe }} +
+
+
+

+ + Monthly Click Traffic +

+
+ {{ monthly_chart|safe }} +
+
+
+ + +
+ +
+

+ + Geographic Analysis (Top Countries) +

+
+ {{ countries_chart|safe }} +
+
+ + +
+

+ + Referrer Channels (Top Referrers) +

+
+ {{ referrers_chart|safe }} +
+
+ + +
+

+ + Browser breakdown (Top Browsers) +

+
+ {{ browsers_chart|safe }} +
+
+
+ + +
+
+

+ + Visitor Activity Log +

+
+ +
+ + + + + + + + + + + + + + + + {% if visits.is_empty() %} + + + + {% else %} + {% for entry in visits %} + + + + + + + + + + + + {% endfor %} + {% endif %} + +
SrTimestampIP AddressCountryReferrerBrowserUser-AgentUTM SourceUTM Campaign
+ No visitor activity available. +
{{ entry.sr }}{{ entry.timestamp }}{{ entry.ip_address }} + + {{ entry.country }} + + {{ entry.referrer }}{{ entry.browser }} + {{ entry.user_agent }} + {{ entry.utm_source }}{{ entry.utm_campaign }}
+
+ + +
+
+ Showing {{ page_start }}-{{ page_end }} of {{ total_records }} visits +
+ + +
+
+{% endblock %} diff --git a/templates/urls.html b/templates/urls.html index e967db8..8c783a6 100644 --- a/templates/urls.html +++ b/templates/urls.html @@ -121,6 +121,7 @@ QR Code Health Tags + Analytics Created Action @@ -128,7 +129,7 @@ {% if urls.is_empty() %} - + No shortened URLs registered. Create one to get started! @@ -213,6 +214,11 @@ {% endfor %} + + + 📊 Analytics + + {{ url.created_at[0..10] }} @@ -230,6 +236,33 @@ + + + {% endblock %} diff --git a/tests/advanced_analytics_tests.rs b/tests/advanced_analytics_tests.rs new file mode 100644 index 0000000..5e0e6de --- /dev/null +++ b/tests/advanced_analytics_tests.rs @@ -0,0 +1,156 @@ +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 +}; +use bzod::models::VisitRecord; + +fn setup_analytics_db() -> Connection { + let mut conn = Connection::open_in_memory().unwrap(); + run_migrations(&mut conn, "analytics", ANALYTICS_MIGRATIONS, None).unwrap(); + conn +} + +#[test] +fn test_advanced_analytics_schema_columns() { + let conn = setup_analytics_db(); + let columns = get_visits_schema_columns(&conn).unwrap(); + assert!(columns.contains("id")); + assert!(columns.contains("target_type")); + assert!(columns.contains("target_id")); + assert!(columns.contains("timestamp")); + assert!(columns.contains("ip_address")); + assert!(columns.contains("user_agent")); + assert!(columns.contains("referer")); + assert!(columns.contains("country")); + assert!(columns.contains("status_code")); + // Verify UTM columns don't exist by default + assert!(!columns.contains("utm_source")); + assert!(!columns.contains("utm_campaign")); +} + +#[test] +fn test_advanced_analytics_pagination_and_sorting() { + let mut conn = setup_analytics_db(); + let target_uuid = "target-123"; + + // Insert 55 visits to test pagination bounds (ANALYTICS_PAGE_SIZE = 50) + let mut records = Vec::new(); + for i in 1..=55 { + // Stagger timestamps so they are ordered deterministically + let time_str = format!("2026-06-17T12:00:{:02}Z", i); + records.push(VisitRecord { + id: format!("visit-{}", i), + target_type: "url".to_string(), + target_id: target_uuid.to_string(), + timestamp: time_str, + ip_address: "1.1.1.1".to_string(), + user_agent: "Mozilla".to_string(), + referer: "direct".to_string(), + accept_language: "en".to_string(), + country: "US".to_string(), + status_code: 200, + }); + } + insert_visits_batch(&mut conn, &records).unwrap(); + + // Verify total count + let total = get_target_visit_total_filtered(&conn, "url", target_uuid, None, None).unwrap(); + assert_eq!(total, 55); + + // Page 1 (limit 50, offset 0) + let page1 = get_target_visits_paginated(&conn, "url", target_uuid, 50, 0, None, None).unwrap(); + assert_eq!(page1.len(), 50); + // Ordered DESC: first record returned should be the latest (visit-55) + assert_eq!(page1[0].id, "visit-55"); + assert_eq!(page1[49].id, "visit-6"); + + // Page 2 (limit 50, offset 50) + let page2 = get_target_visits_paginated(&conn, "url", target_uuid, 50, 50, None, None).unwrap(); + assert_eq!(page2.len(), 5); + assert_eq!(page2[0].id, "visit-5"); + assert_eq!(page2[4].id, "visit-1"); +} + +#[test] +fn test_advanced_analytics_date_filtering() { + let mut conn = setup_analytics_db(); + let target_uuid = "target-456"; + + // Insert records across different days + let records = vec![ + VisitRecord { + id: "v1".to_string(), + target_type: "url".to_string(), + target_id: target_uuid.to_string(), + timestamp: "2026-06-01T10:00:00Z".to_string(), + ip_address: "1.1.1.1".to_string(), + user_agent: "Mozilla".to_string(), + referer: "direct".to_string(), + accept_language: "en".to_string(), + country: "US".to_string(), + status_code: 200, + }, + VisitRecord { + id: "v2".to_string(), + target_type: "url".to_string(), + target_id: target_uuid.to_string(), + timestamp: "2026-06-15T10:00:00Z".to_string(), + ip_address: "1.1.1.1".to_string(), + user_agent: "Mozilla".to_string(), + referer: "direct".to_string(), + accept_language: "en".to_string(), + country: "US".to_string(), + status_code: 200, + }, + VisitRecord { + id: "v3".to_string(), + target_type: "url".to_string(), + target_id: target_uuid.to_string(), + timestamp: "2026-06-30T10:00:00Z".to_string(), + ip_address: "1.1.1.1".to_string(), + user_agent: "Mozilla".to_string(), + referer: "direct".to_string(), + accept_language: "en".to_string(), + country: "US".to_string(), + status_code: 200, + }, + ]; + 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(); + assert_eq!(count1, 2); + 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(); + assert_eq!(count2, 2); + 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(); + 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(); + assert_eq!(results3.len(), 1); + assert_eq!(results3[0].id, "v2"); +} + +#[test] +fn test_parse_ua_and_referrer_helpers() { + let (browser, os, device) = parse_ua("Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/605.1.15"); + assert_eq!(browser, "Safari"); + assert_eq!(os, "iOS"); + assert_eq!(device, "Mobile"); + + let referrer = clean_referrer("https://news.ycombinator.com/item?id=12345"); + assert_eq!(referrer, "news.ycombinator.com"); +} diff --git a/tests/analytics_tests.rs b/tests/analytics_tests.rs new file mode 100644 index 0000000..f0733a7 --- /dev/null +++ b/tests/analytics_tests.rs @@ -0,0 +1,155 @@ +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, + insert_visits_batch, +}; +use bzod::models::VisitRecord; + +fn setup_content_db() -> Connection { + let mut conn = Connection::open_in_memory().unwrap(); + run_migrations(&mut conn, "content", CONTENT_MIGRATIONS, None).unwrap(); + conn +} + +fn setup_analytics_db() -> Connection { + let mut conn = Connection::open_in_memory().unwrap(); + run_migrations(&mut conn, "analytics", ANALYTICS_MIGRATIONS, None).unwrap(); + conn +} + +#[test] +fn test_get_url_count_by_tag() { + let conn = setup_content_db(); + + // Verify tag count on empty db + let count = get_url_count_by_tag(&conn, "promo").unwrap(); + assert_eq!(count, 0); + + // Create a URL and associate tags + create_url_extended( + &conn, + "code1", + "https://example.com/1", + None, + None, + &["promo".to_string(), "tech".to_string()], + None, + None, + None, + ) + .unwrap(); + + create_url_extended( + &conn, + "code2", + "https://example.com/2", + None, + None, + &["promo".to_string()], + None, + None, + None, + ) + .unwrap(); + + assert_eq!(get_url_count_by_tag(&conn, "promo").unwrap(), 2); + assert_eq!(get_url_count_by_tag(&conn, "tech").unwrap(), 1); + assert_eq!(get_url_count_by_tag(&conn, "other").unwrap(), 0); +} + +#[test] +fn test_pagination_limits_and_offset() { + let conn = setup_content_db(); + + // Create 27 URLs to test page pagination limits of PAGE_SIZE = 25 + for i in 0..27 { + create_url_extended( + &conn, + &format!("cd{:03}", i), + &format!("https://example.com/{}", i), + None, + None, + &[], + None, + None, + None, + ) + .unwrap(); + } + + let (total, _, _) = get_url_counts(&conn).unwrap(); + assert_eq!(total, 27); + + // Page 1 (limit 25, offset 0) + let page1_urls = list_urls(&conn, 25, 0, None).unwrap(); + assert_eq!(page1_urls.len(), 25); + + // Page 2 (limit 25, offset 25) + let page2_urls = list_urls(&conn, 25, 25, None).unwrap(); + assert_eq!(page2_urls.len(), 2); +} + +#[test] +fn test_target_analytics_queries() { + let mut conn = setup_analytics_db(); + 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); + + // 2. Insert some visit records + let now = Utc::now(); + let records = vec![ + VisitRecord { + id: "visit-1".to_string(), + target_type: "url".to_string(), + target_id: target_uuid.to_string(), + timestamp: now.to_rfc3339(), + ip_address: "1.1.1.1".to_string(), + user_agent: "Mozilla".to_string(), + referer: "direct".to_string(), + accept_language: "en".to_string(), + country: "US".to_string(), + status_code: 200, + }, + VisitRecord { + id: "visit-2".to_string(), + target_type: "url".to_string(), + target_id: target_uuid.to_string(), + timestamp: now.to_rfc3339(), + ip_address: "1.1.1.1".to_string(), // same visitor + user_agent: "Mozilla".to_string(), + referer: "direct".to_string(), + accept_language: "en".to_string(), + country: "US".to_string(), + status_code: 200, + }, + VisitRecord { + id: "visit-3".to_string(), + target_type: "url".to_string(), + target_id: target_uuid.to_string(), + timestamp: now.to_rfc3339(), + ip_address: "2.2.2.2".to_string(), // different visitor + user_agent: "Mozilla".to_string(), + referer: "direct".to_string(), + accept_language: "en".to_string(), + country: "US".to_string(), + status_code: 200, + }, + ]; + + 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); + + // 4. Verify monthly clicks trend fallback + let monthly_trend = get_monthly_clicks_trend(&conn, "url", target_uuid, 12).unwrap(); + assert_eq!(monthly_trend.len(), 1); + assert_eq!(monthly_trend[0].1, 3); // 3 clicks total +}