1095 lines
36 KiB
Rust
1095 lines
36 KiB
Rust
use super::*;
|
|
|
|
// GET /admin/analytics/url/:id
|
|
pub async fn url_analytics_get(
|
|
State(state): State<AppState>,
|
|
jar: CookieJar,
|
|
Path(id): Path<String>,
|
|
Query(query): Query<AnalyticsQuery>,
|
|
) -> Response {
|
|
let (user, _) = match require_auth(&state, &jar).await {
|
|
Ok(u) => u,
|
|
Err(redir) => return redir.into_response(),
|
|
};
|
|
|
|
let (_slug, owner_tid) = {
|
|
let conn = state.db.global_urls.lock().unwrap();
|
|
match conn.query_row(
|
|
"SELECT slug, owner_tenant_id FROM global_urls WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
|
rusqlite::params![id],
|
|
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
|
) {
|
|
Ok((s, t)) => (s, t),
|
|
Err(_) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
|
}
|
|
};
|
|
let tid = match owner_tid.parse::<crate::identity::TenantId>() {
|
|
Ok(t) => t,
|
|
Err(_) => {
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid tenant identity").into_response()
|
|
}
|
|
};
|
|
let user_dbs = match state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
|
Ok(d) => d,
|
|
Err(_) => {
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"Failed to open tenant database",
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let url = {
|
|
let conn = user_dbs.content.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 = user_dbs.analytics.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<crate::templates::VisitorLogEntry> = 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<usize> = (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,
|
|
is_admin: true,
|
|
};
|
|
|
|
template.into_response()
|
|
}
|
|
|
|
// GET /admin/analytics/page/:id
|
|
pub async fn page_analytics_get(
|
|
State(state): State<AppState>,
|
|
jar: CookieJar,
|
|
Path(id): Path<String>,
|
|
Query(query): Query<AnalyticsQuery>,
|
|
) -> Response {
|
|
let (user, _) = match require_auth(&state, &jar).await {
|
|
Ok(u) => u,
|
|
Err(redir) => return redir.into_response(),
|
|
};
|
|
|
|
let (_slug, owner_tid) = {
|
|
let conn = state.db.global_landing_pages.lock().unwrap();
|
|
match conn.query_row(
|
|
"SELECT slug, owner_tenant_id FROM global_landing_pages WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
|
rusqlite::params![id],
|
|
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
|
) {
|
|
Ok((s, t)) => (s, t),
|
|
Err(_) => return (StatusCode::NOT_FOUND, "Landing page not found").into_response(),
|
|
}
|
|
};
|
|
let tid = match owner_tid.parse::<crate::identity::TenantId>() {
|
|
Ok(t) => t,
|
|
Err(_) => {
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid tenant identity").into_response()
|
|
}
|
|
};
|
|
let user_dbs = match state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
|
Ok(d) => d,
|
|
Err(_) => {
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"Failed to open tenant database",
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let page = {
|
|
let conn = user_dbs.content.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 = user_dbs.analytics.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<crate::templates::VisitorLogEntry> = 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<usize> = (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,
|
|
is_admin: true,
|
|
};
|
|
|
|
template.into_response()
|
|
}
|
|
|
|
pub async fn url_analytics_csv_export(
|
|
State(state): State<AppState>,
|
|
jar: CookieJar,
|
|
Path(id): Path<String>,
|
|
Query(query): Query<AnalyticsQuery>,
|
|
) -> 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<AppState>,
|
|
jar: CookieJar,
|
|
Path(id): Path<String>,
|
|
Query(query): Query<AnalyticsQuery>,
|
|
) -> 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<AppState>,
|
|
jar: CookieJar,
|
|
Path(id): Path<String>,
|
|
Query(query): Query<AnalyticsQuery>,
|
|
) -> 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<AppState>,
|
|
jar: CookieJar,
|
|
Path(id): Path<String>,
|
|
Query(query): Query<AnalyticsQuery>,
|
|
) -> 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
|
|
}
|
|
|
|
// GET /analytics
|
|
pub async fn user_analytics_get(
|
|
State(state): State<AppState>,
|
|
jar: CookieJar,
|
|
Query(params): Query<HashMap<String, String>>,
|
|
) -> Response {
|
|
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
|
Ok(u) => u,
|
|
Err(redir) => return redir.into_response(),
|
|
};
|
|
|
|
let user_dbs = match state.get_user_dbs(user.id) {
|
|
Ok(dbs) => dbs,
|
|
Err(_) => return Redirect::to("/user/dashboard?error=Database error").into_response(),
|
|
};
|
|
|
|
let conn = user_dbs.analytics.lock().unwrap();
|
|
|
|
let total_clicks = conn
|
|
.query_row("SELECT COUNT(*) FROM visits;", [], |row| {
|
|
row.get::<_, i64>(0)
|
|
})
|
|
.unwrap_or(0);
|
|
let unique_visitors = conn
|
|
.query_row(
|
|
"SELECT COUNT(DISTINCT ip_address) FROM visits;",
|
|
[],
|
|
|row| row.get::<_, i64>(0),
|
|
)
|
|
.unwrap_or(0);
|
|
let direct_clicks = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM visits WHERE referer = '' OR referer = 'direct';",
|
|
[],
|
|
|row| row.get::<_, i64>(0),
|
|
)
|
|
.unwrap_or(0);
|
|
let referred_clicks = total_clicks - direct_clicks;
|
|
|
|
let referrers_chart = "<div>No referrer channels logged yet</div>".to_string();
|
|
let browsers_chart = "<div>No browser traffic logged yet</div>".to_string();
|
|
|
|
let visits = {
|
|
let mut stmt = conn.prepare("SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code FROM visits ORDER BY timestamp DESC LIMIT 50;").unwrap();
|
|
let rows = stmt
|
|
.query_map([], |row| {
|
|
Ok(crate::models::VisitRecord {
|
|
id: row.get(0)?,
|
|
target_type: row.get(1)?,
|
|
target_id: row.get(2)?,
|
|
timestamp: row.get(3)?,
|
|
ip_address: row.get(4)?,
|
|
user_agent: row.get(5)?,
|
|
referer: row.get(6)?,
|
|
accept_language: row.get(7)?,
|
|
country: row.get(8)?,
|
|
status_code: row.get(9)?,
|
|
owner_tenant_id: user.tenant_id,
|
|
owner_user_id: Some(user.id),
|
|
})
|
|
})
|
|
.unwrap();
|
|
rows.filter_map(|r| r.ok()).collect()
|
|
};
|
|
|
|
let csrf_token = generate_csrf_token(&session_id);
|
|
|
|
let template = crate::templates::UserAnalyticsTemplate {
|
|
admin_username: user.username.clone(),
|
|
username: user.username,
|
|
total_clicks,
|
|
unique_visitors,
|
|
direct_clicks,
|
|
referred_clicks,
|
|
referrers_chart,
|
|
browsers_chart,
|
|
visits,
|
|
csrf_token,
|
|
success: params.get("success").cloned(),
|
|
error: params.get("error").cloned(),
|
|
};
|
|
|
|
template.into_response()
|
|
}
|
|
|
|
pub async fn user_url_analytics_get(
|
|
State(state): State<AppState>,
|
|
jar: CookieJar,
|
|
Path(id): Path<String>,
|
|
Query(query): Query<AnalyticsQuery>,
|
|
) -> Response {
|
|
let (user, _session_id) = match require_user_auth(&state, &jar).await {
|
|
Ok(u) => u,
|
|
Err(redir) => return redir.into_response(),
|
|
};
|
|
|
|
// Ownership check
|
|
let owner_tid_str = {
|
|
let conn = state.db.global_urls.lock().unwrap();
|
|
match conn.query_row(
|
|
"SELECT owner_tenant_id FROM global_urls WHERE target_id = ?1",
|
|
[&id],
|
|
|row| row.get::<_, String>(0),
|
|
) {
|
|
Ok(val) => val,
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
|
return Redirect::to("/user/urls?error=Link not found").into_response();
|
|
}
|
|
Err(_) => return Redirect::to("/user/urls?error=Database error").into_response(),
|
|
}
|
|
};
|
|
|
|
let user_tid = match user.tenant_id {
|
|
Some(t) => t.to_string(),
|
|
None => return StatusCode::FORBIDDEN.into_response(),
|
|
};
|
|
|
|
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
|
return StatusCode::FORBIDDEN.into_response();
|
|
}
|
|
|
|
let user_dbs = match state.get_user_dbs(user.id) {
|
|
Ok(dbs) => dbs,
|
|
Err(_) => return Redirect::to("/user/urls?error=Database error").into_response(),
|
|
};
|
|
|
|
let url = {
|
|
let conn = user_dbs.content.lock().unwrap();
|
|
match crate::db::content::get_url_by_id(&conn, &id) {
|
|
Ok(Some(u)) => u,
|
|
Ok(None) => return Redirect::to("/user/urls?error=Link not found").into_response(),
|
|
Err(_) => return Redirect::to("/user/urls?error=Database error").into_response(),
|
|
}
|
|
};
|
|
|
|
let conn = user_dbs.analytics.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<crate::templates::VisitorLogEntry> = 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<usize> = (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,
|
|
is_admin: false,
|
|
};
|
|
|
|
template.into_response()
|
|
}
|
|
|
|
pub async fn user_page_analytics_get(
|
|
State(state): State<AppState>,
|
|
jar: CookieJar,
|
|
Path(id): Path<String>,
|
|
Query(query): Query<AnalyticsQuery>,
|
|
) -> Response {
|
|
let (user, _session_id) = match require_user_auth(&state, &jar).await {
|
|
Ok(u) => u,
|
|
Err(redir) => return redir.into_response(),
|
|
};
|
|
|
|
// Ownership check
|
|
let owner_tid_str = {
|
|
let conn = state.db.global_landing_pages.lock().unwrap();
|
|
match conn.query_row(
|
|
"SELECT owner_tenant_id FROM global_landing_pages WHERE target_id = ?1",
|
|
[&id],
|
|
|row| row.get::<_, String>(0),
|
|
) {
|
|
Ok(val) => val,
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
|
return Redirect::to("/user/pages?error=Landing page not found").into_response();
|
|
}
|
|
Err(_) => return Redirect::to("/user/pages?error=Database error").into_response(),
|
|
}
|
|
};
|
|
|
|
let user_tid = match user.tenant_id {
|
|
Some(t) => t.to_string(),
|
|
None => return StatusCode::FORBIDDEN.into_response(),
|
|
};
|
|
|
|
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
|
return StatusCode::FORBIDDEN.into_response();
|
|
}
|
|
|
|
let user_dbs = match state.get_user_dbs(user.id) {
|
|
Ok(dbs) => dbs,
|
|
Err(_) => return Redirect::to("/user/pages?error=Database error").into_response(),
|
|
};
|
|
|
|
let page = {
|
|
let conn = user_dbs.content.lock().unwrap();
|
|
match crate::db::content::get_landing_page_by_id(&conn, &id) {
|
|
Ok(Some(p)) => p,
|
|
Ok(None) => {
|
|
return Redirect::to("/user/pages?error=Landing page not found").into_response()
|
|
}
|
|
Err(_) => return Redirect::to("/user/pages?error=Database error").into_response(),
|
|
}
|
|
};
|
|
|
|
let conn = user_dbs.analytics.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<crate::templates::VisitorLogEntry> = 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<usize> = (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,
|
|
is_admin: false,
|
|
};
|
|
|
|
template.into_response()
|
|
}
|
|
|
|
pub async fn user_url_analytics_csv_export(
|
|
State(state): State<AppState>,
|
|
jar: CookieJar,
|
|
Path(id): Path<String>,
|
|
Query(query): Query<AnalyticsQuery>,
|
|
) -> Response {
|
|
let (user, _session_id) = match require_user_auth(&state, &jar).await {
|
|
Ok(u) => u,
|
|
Err(redir) => return redir.into_response(),
|
|
};
|
|
|
|
// Ownership check
|
|
let owner_tid_str = {
|
|
let conn = state.db.global_urls.lock().unwrap();
|
|
match conn.query_row(
|
|
"SELECT owner_tenant_id FROM global_urls WHERE target_id = ?1",
|
|
[&id],
|
|
|row| row.get::<_, String>(0),
|
|
) {
|
|
Ok(val) => val,
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
|
return StatusCode::NOT_FOUND.into_response();
|
|
}
|
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
}
|
|
};
|
|
|
|
let user_tid = match user.tenant_id {
|
|
Some(t) => t.to_string(),
|
|
None => return StatusCode::FORBIDDEN.into_response(),
|
|
};
|
|
|
|
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
|
return StatusCode::FORBIDDEN.into_response();
|
|
}
|
|
|
|
let user_dbs = match state.get_user_dbs(user.id) {
|
|
Ok(dbs) => dbs,
|
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
};
|
|
|
|
perform_user_csv_export(user_dbs, "url", id, query.date_from, query.date_to).await
|
|
}
|
|
|
|
pub async fn user_url_analytics_json_export(
|
|
State(state): State<AppState>,
|
|
jar: CookieJar,
|
|
Path(id): Path<String>,
|
|
Query(query): Query<AnalyticsQuery>,
|
|
) -> Response {
|
|
let (user, _session_id) = match require_user_auth(&state, &jar).await {
|
|
Ok(u) => u,
|
|
Err(redir) => return redir.into_response(),
|
|
};
|
|
|
|
// Ownership check
|
|
let owner_tid_str = {
|
|
let conn = state.db.global_urls.lock().unwrap();
|
|
match conn.query_row(
|
|
"SELECT owner_tenant_id FROM global_urls WHERE target_id = ?1",
|
|
[&id],
|
|
|row| row.get::<_, String>(0),
|
|
) {
|
|
Ok(val) => val,
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
|
return StatusCode::NOT_FOUND.into_response();
|
|
}
|
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
}
|
|
};
|
|
|
|
let user_tid = match user.tenant_id {
|
|
Some(t) => t.to_string(),
|
|
None => return StatusCode::FORBIDDEN.into_response(),
|
|
};
|
|
|
|
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
|
return StatusCode::FORBIDDEN.into_response();
|
|
}
|
|
|
|
let user_dbs = match state.get_user_dbs(user.id) {
|
|
Ok(dbs) => dbs,
|
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
};
|
|
|
|
perform_user_json_export(user_dbs, "url", id, query.date_from, query.date_to).await
|
|
}
|
|
|
|
pub async fn user_page_analytics_csv_export(
|
|
State(state): State<AppState>,
|
|
jar: CookieJar,
|
|
Path(id): Path<String>,
|
|
Query(query): Query<AnalyticsQuery>,
|
|
) -> Response {
|
|
let (user, _session_id) = match require_user_auth(&state, &jar).await {
|
|
Ok(u) => u,
|
|
Err(redir) => return redir.into_response(),
|
|
};
|
|
|
|
// Ownership check
|
|
let owner_tid_str = {
|
|
let conn = state.db.global_landing_pages.lock().unwrap();
|
|
match conn.query_row(
|
|
"SELECT owner_tenant_id FROM global_landing_pages WHERE target_id = ?1",
|
|
[&id],
|
|
|row| row.get::<_, String>(0),
|
|
) {
|
|
Ok(val) => val,
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
|
return StatusCode::NOT_FOUND.into_response();
|
|
}
|
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
}
|
|
};
|
|
|
|
let user_tid = match user.tenant_id {
|
|
Some(t) => t.to_string(),
|
|
None => return StatusCode::FORBIDDEN.into_response(),
|
|
};
|
|
|
|
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
|
return StatusCode::FORBIDDEN.into_response();
|
|
}
|
|
|
|
let user_dbs = match state.get_user_dbs(user.id) {
|
|
Ok(dbs) => dbs,
|
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
};
|
|
|
|
perform_user_csv_export(user_dbs, "page", id, query.date_from, query.date_to).await
|
|
}
|
|
|
|
pub async fn user_page_analytics_json_export(
|
|
State(state): State<AppState>,
|
|
jar: CookieJar,
|
|
Path(id): Path<String>,
|
|
Query(query): Query<AnalyticsQuery>,
|
|
) -> Response {
|
|
let (user, _session_id) = match require_user_auth(&state, &jar).await {
|
|
Ok(u) => u,
|
|
Err(redir) => return redir.into_response(),
|
|
};
|
|
|
|
// Ownership check
|
|
let owner_tid_str = {
|
|
let conn = state.db.global_landing_pages.lock().unwrap();
|
|
match conn.query_row(
|
|
"SELECT owner_tenant_id FROM global_landing_pages WHERE target_id = ?1",
|
|
[&id],
|
|
|row| row.get::<_, String>(0),
|
|
) {
|
|
Ok(val) => val,
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
|
return StatusCode::NOT_FOUND.into_response();
|
|
}
|
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
}
|
|
};
|
|
|
|
let user_tid = match user.tenant_id {
|
|
Some(t) => t.to_string(),
|
|
None => return StatusCode::FORBIDDEN.into_response(),
|
|
};
|
|
|
|
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
|
return StatusCode::FORBIDDEN.into_response();
|
|
}
|
|
|
|
let user_dbs = match state.get_user_dbs(user.id) {
|
|
Ok(dbs) => dbs,
|
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
};
|
|
|
|
perform_user_json_export(user_dbs, "page", id, query.date_from, query.date_to).await
|
|
}
|