1use std::sync::Arc;
9
10use crate::Result;
11use sqlx::PgPool;
12use systemprompt_database::DbPool;
13use systemprompt_identifiers::{ContentId, EngagementEventId, SessionId, UserId};
14
15use crate::models::{CreateEngagementEventInput, EngagementEvent};
16
17#[derive(Clone, Debug)]
18pub struct EngagementRepository {
19 pool: Arc<PgPool>,
20 write_pool: Arc<PgPool>,
21}
22
23impl EngagementRepository {
24 pub fn new(db: &DbPool) -> Result<Self> {
25 let pool = db.pool_arc()?;
26 let write_pool = db.write_pool_arc()?;
27 Ok(Self { pool, write_pool })
28 }
29
30 pub async fn create_engagement(
31 &self,
32 session_id: &SessionId,
33 user_id: &UserId,
34 content_id: Option<&ContentId>,
35 input: &CreateEngagementEventInput,
36 ) -> Result<EngagementEventId> {
37 let id = EngagementEventId::generate();
38
39 sqlx::query!(
40 r#"
41 INSERT INTO engagement_events (
42 id, session_id, user_id, page_url, content_id, event_type,
43 time_on_page_ms, max_scroll_depth, click_count,
44 time_to_first_interaction_ms, time_to_first_scroll_ms,
45 scroll_velocity_avg, scroll_direction_changes,
46 mouse_move_distance_px, keyboard_events, copy_events,
47 focus_time_ms, blur_count, tab_switches, visible_time_ms, hidden_time_ms,
48 is_rage_click, is_dead_click, reading_pattern, event_data
49 )
50 VALUES (
51 $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
52 $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25
53 )
54 "#,
55 id.as_str(),
56 session_id.as_str(),
57 user_id.as_str(),
58 input.page_url,
59 content_id.map(ContentId::as_str),
60 input.event_type.as_str(),
61 input.time_on_page_ms,
62 input.max_scroll_depth,
63 input.click_count,
64 input.optional_metrics.time_to_first_interaction_ms,
65 input.optional_metrics.time_to_first_scroll_ms,
66 input.optional_metrics.scroll_velocity_avg,
67 input.optional_metrics.scroll_direction_changes,
68 input.optional_metrics.mouse_move_distance_px,
69 input.optional_metrics.keyboard_events,
70 input.optional_metrics.copy_events,
71 input.optional_metrics.focus_time_ms.unwrap_or(0),
72 input.optional_metrics.blur_count.unwrap_or(0),
73 input.optional_metrics.tab_switches.unwrap_or(0),
74 input.optional_metrics.visible_time_ms.unwrap_or(0),
75 input.optional_metrics.hidden_time_ms.unwrap_or(0),
76 input.optional_metrics.is_rage_click,
77 input.optional_metrics.is_dead_click,
78 input.optional_metrics.reading_pattern,
79 input.event_data.clone()
80 )
81 .execute(&*self.write_pool)
82 .await?;
83
84 Ok(id)
85 }
86
87 pub async fn find_by_id(&self, id: &EngagementEventId) -> Result<Option<EngagementEvent>> {
88 let event = sqlx::query_as!(
89 EngagementEvent,
90 r#"
91 SELECT
92 id as "id: EngagementEventId", session_id, user_id, page_url,
93 content_id as "content_id: ContentId",
94 event_type,
95 time_on_page_ms, time_to_first_interaction_ms, time_to_first_scroll_ms,
96 max_scroll_depth, scroll_velocity_avg, scroll_direction_changes,
97 click_count, mouse_move_distance_px, keyboard_events, copy_events,
98 focus_time_ms as "focus_time_ms!",
99 blur_count as "blur_count!",
100 tab_switches as "tab_switches!",
101 visible_time_ms as "visible_time_ms!",
102 hidden_time_ms as "hidden_time_ms!",
103 is_rage_click, is_dead_click, reading_pattern,
104 created_at, updated_at
105 FROM engagement_events
106 WHERE id = $1
107 "#,
108 id.as_str()
109 )
110 .fetch_optional(&*self.pool)
111 .await?;
112
113 Ok(event)
114 }
115
116 pub async fn list_by_session(&self, session_id: &SessionId) -> Result<Vec<EngagementEvent>> {
117 let events = sqlx::query_as!(
118 EngagementEvent,
119 r#"
120 SELECT
121 id as "id: EngagementEventId", session_id, user_id, page_url,
122 content_id as "content_id: ContentId",
123 event_type,
124 time_on_page_ms as "time_on_page_ms!", time_to_first_interaction_ms, time_to_first_scroll_ms,
125 max_scroll_depth as "max_scroll_depth!", scroll_velocity_avg, scroll_direction_changes,
126 click_count as "click_count!", mouse_move_distance_px, keyboard_events, copy_events,
127 focus_time_ms as "focus_time_ms!",
128 blur_count as "blur_count!",
129 tab_switches as "tab_switches!",
130 visible_time_ms as "visible_time_ms!",
131 hidden_time_ms as "hidden_time_ms!",
132 is_rage_click, is_dead_click, reading_pattern,
133 created_at, updated_at
134 FROM engagement_events
135 WHERE session_id = $1
136 ORDER BY created_at ASC
137 "#,
138 session_id.as_str()
139 )
140 .fetch_all(&*self.pool)
141 .await?;
142
143 Ok(events)
144 }
145
146 pub async fn list_by_user(&self, user_id: &UserId, limit: i64) -> Result<Vec<EngagementEvent>> {
147 let events = sqlx::query_as!(
148 EngagementEvent,
149 r#"
150 SELECT
151 id as "id: EngagementEventId", session_id, user_id, page_url,
152 content_id as "content_id: ContentId",
153 event_type,
154 time_on_page_ms as "time_on_page_ms!", time_to_first_interaction_ms, time_to_first_scroll_ms,
155 max_scroll_depth as "max_scroll_depth!", scroll_velocity_avg, scroll_direction_changes,
156 click_count as "click_count!", mouse_move_distance_px, keyboard_events, copy_events,
157 focus_time_ms as "focus_time_ms!",
158 blur_count as "blur_count!",
159 tab_switches as "tab_switches!",
160 visible_time_ms as "visible_time_ms!",
161 hidden_time_ms as "hidden_time_ms!",
162 is_rage_click, is_dead_click, reading_pattern,
163 created_at, updated_at
164 FROM engagement_events
165 WHERE user_id = $1
166 ORDER BY created_at DESC
167 LIMIT $2
168 "#,
169 user_id.as_str(),
170 limit
171 )
172 .fetch_all(&*self.pool)
173 .await?;
174
175 Ok(events)
176 }
177
178 pub async fn get_session_engagement_summary(
179 &self,
180 session_id: &SessionId,
181 ) -> Result<Option<SessionEngagementSummary>> {
182 let summary = sqlx::query_as!(
183 SessionEngagementSummary,
184 r#"
185 SELECT
186 session_id,
187 COUNT(*)::BIGINT as page_count,
188 SUM(time_on_page_ms)::BIGINT as total_time_on_page_ms,
189 AVG(max_scroll_depth)::REAL as avg_scroll_depth,
190 MAX(max_scroll_depth) as max_scroll_depth,
191 SUM(click_count)::BIGINT as total_clicks,
192 COUNT(*) FILTER (WHERE is_rage_click = true)::BIGINT as rage_click_pages,
193 MIN(created_at) as first_engagement,
194 MAX(created_at) as last_engagement
195 FROM engagement_events
196 WHERE session_id = $1
197 GROUP BY session_id
198 "#,
199 session_id.as_str()
200 )
201 .fetch_optional(&*self.pool)
202 .await?;
203
204 Ok(summary)
205 }
206}
207
208#[derive(Debug, Clone, sqlx::FromRow)]
209pub struct SessionEngagementSummary {
210 pub session_id: SessionId,
211 pub page_count: Option<i64>,
212 pub total_time_on_page_ms: Option<i64>,
213 pub avg_scroll_depth: Option<f32>,
214 pub max_scroll_depth: Option<i32>,
215 pub total_clicks: Option<i64>,
216 pub rage_click_pages: Option<i64>,
217 pub first_engagement: Option<chrono::DateTime<chrono::Utc>>,
218 pub last_engagement: Option<chrono::DateTime<chrono::Utc>>,
219}