systemprompt_analytics/repository/
content_analytics.rs1use anyhow::Result;
2use chrono::{DateTime, Utc};
3use sqlx::PgPool;
4use std::sync::Arc;
5use systemprompt_database::DbPool;
6
7use crate::models::cli::{ContentStatsRow, ContentTrendRow, TopContentRow};
8
9#[derive(Debug)]
10pub struct ContentAnalyticsRepository {
11 pool: Arc<PgPool>,
12}
13
14impl ContentAnalyticsRepository {
15 pub fn new(db: &DbPool) -> Result<Self> {
16 let pool = db.pool_arc()?;
17 Ok(Self { pool })
18 }
19
20 pub async fn get_top_content(
21 &self,
22 start: DateTime<Utc>,
23 end: DateTime<Utc>,
24 limit: i64,
25 ) -> Result<Vec<TopContentRow>> {
26 sqlx::query_as!(
27 TopContentRow,
28 r#"
29 WITH content_stats AS (
30 SELECT
31 ee.content_id,
32 COUNT(*)::bigint as total_views,
33 COUNT(DISTINCT ee.session_id)::bigint as unique_visitors,
34 (AVG(LEAST(ee.time_on_page_ms, 1800000)) / 1000.0)::float8 as avg_time_on_page_seconds
35 FROM engagement_events ee
36 INNER JOIN user_sessions us ON ee.session_id = us.session_id
37 WHERE ee.created_at >= $1 AND ee.created_at < $2
38 AND ee.content_id IS NOT NULL
39 AND us.is_bot = false AND us.is_behavioral_bot = false
40 GROUP BY ee.content_id
41 )
42 SELECT
43 cs.content_id as "content_id!",
44 mc.slug as "slug?",
45 mc.title as "title?",
46 mc.source_id as "source_id?",
47 cs.total_views as "total_views!",
48 cs.unique_visitors as "unique_visitors!",
49 cs.avg_time_on_page_seconds::float8 as "avg_time_on_page_seconds",
50 NULL::text as "trend_direction"
51 FROM content_stats cs
52 LEFT JOIN markdown_content mc ON cs.content_id = mc.id
53 ORDER BY cs.total_views DESC
54 LIMIT $3
55 "#,
56 start,
57 end,
58 limit
59 )
60 .fetch_all(&*self.pool)
61 .await
62 .map_err(Into::into)
63 }
64
65 pub async fn get_stats(
66 &self,
67 start: DateTime<Utc>,
68 end: DateTime<Utc>,
69 ) -> Result<ContentStatsRow> {
70 sqlx::query_as!(
71 ContentStatsRow,
72 r#"
73 SELECT
74 COUNT(*)::bigint as "total_views!",
75 COUNT(DISTINCT ee.session_id)::bigint as "unique_visitors!",
76 COALESCE(AVG(LEAST(ee.time_on_page_ms, 1800000)) / 1000.0, 0)::float8 as "avg_time_on_page_seconds",
77 COALESCE(AVG(ee.max_scroll_depth), 0)::float8 as "avg_scroll_depth",
78 COALESCE(SUM(ee.click_count), 0)::bigint as "total_clicks!"
79 FROM engagement_events ee
80 INNER JOIN user_sessions us ON ee.session_id = us.session_id
81 WHERE ee.created_at >= $1 AND ee.created_at < $2
82 AND us.is_bot = false AND us.is_behavioral_bot = false
83 "#,
84 start,
85 end
86 )
87 .fetch_one(&*self.pool)
88 .await
89 .map_err(Into::into)
90 }
91
92 pub async fn get_content_for_trends(
93 &self,
94 start: DateTime<Utc>,
95 end: DateTime<Utc>,
96 ) -> Result<Vec<ContentTrendRow>> {
97 sqlx::query_as!(
98 ContentTrendRow,
99 r#"
100 WITH date_series AS (
101 SELECT generate_series(
102 date_trunc('day', $1::timestamptz),
103 date_trunc('day', $2::timestamptz) - interval '1 day',
104 '1 day'::interval
105 ) as day
106 ),
107 daily_stats AS (
108 SELECT
109 date_trunc('day', ee.created_at) as day,
110 COUNT(*)::bigint as views,
111 COUNT(DISTINCT ee.session_id)::bigint as unique_visitors
112 FROM engagement_events ee
113 INNER JOIN user_sessions us ON ee.session_id = us.session_id
114 WHERE ee.created_at >= $1 AND ee.created_at < $2
115 AND us.is_bot = false AND us.is_behavioral_bot = false
116 GROUP BY date_trunc('day', ee.created_at)
117 )
118 SELECT
119 ds.day as "timestamp!",
120 COALESCE(s.views, 0)::bigint as "views!",
121 COALESCE(s.unique_visitors, 0)::bigint as "unique_visitors!"
122 FROM date_series ds
123 LEFT JOIN daily_stats s ON ds.day = s.day
124 ORDER BY ds.day
125 "#,
126 start,
127 end
128 )
129 .fetch_all(&*self.pool)
130 .await
131 .map_err(Into::into)
132 }
133}