Skip to content

Commit c9a4ca0

Browse files
committed
feat(db): add statistics queries
1 parent fe0527e commit c9a4ca0

2 files changed

Lines changed: 386 additions & 0 deletions

File tree

rustmail/src/db/operations/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub mod messages;
66
pub mod reminders;
77
pub mod scheduled;
88
pub mod snippets;
9+
pub mod statistics;
910
pub mod threads;
1011

1112
pub use api_keys::*;
@@ -16,4 +17,5 @@ pub use messages::*;
1617
pub use reminders::*;
1718
pub use scheduled::*;
1819
pub use snippets::*;
20+
pub use statistics::*;
1921
pub use threads::*;
Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
use serde::Serialize;
2+
use sqlx::{FromRow, SqlitePool};
3+
4+
#[derive(Debug, Clone, Serialize)]
5+
pub struct StatisticsOverview {
6+
pub open_tickets: i64,
7+
pub total_closed: i64,
8+
pub closed_today: i64,
9+
pub closed_this_week: i64,
10+
pub closed_this_month: i64,
11+
pub avg_response_time_seconds: Option<i64>,
12+
pub avg_resolution_time_seconds: Option<i64>,
13+
}
14+
15+
#[derive(Debug, Clone, Serialize, FromRow)]
16+
pub struct DailyActivity {
17+
pub date: String,
18+
pub created: i64,
19+
pub closed: i64,
20+
}
21+
22+
#[derive(Debug, Clone, Serialize)]
23+
pub struct CategoryStats {
24+
pub name: String,
25+
pub count: i64,
26+
pub percentage: f64,
27+
}
28+
29+
#[derive(Debug, Clone, Serialize, FromRow)]
30+
struct CategoryRow {
31+
name: String,
32+
cnt: i64,
33+
}
34+
35+
#[derive(Debug, Clone, Serialize)]
36+
pub struct StaffMember {
37+
pub user_id: String,
38+
pub username: String,
39+
pub messages_count: i64,
40+
pub tickets_closed: i64,
41+
pub avg_response_time_seconds: Option<i64>,
42+
}
43+
44+
#[derive(Debug, Clone, Serialize, FromRow)]
45+
struct StaffRow {
46+
user_id: i64,
47+
username: String,
48+
messages_count: i64,
49+
tickets_closed: i64,
50+
}
51+
52+
#[derive(Debug, Clone, Serialize)]
53+
pub struct TopPerformer {
54+
pub user_id: String,
55+
pub username: String,
56+
pub value: i64,
57+
}
58+
59+
#[derive(Debug, Clone, Serialize, FromRow)]
60+
struct FastestResponderRow {
61+
user_id: i64,
62+
username: String,
63+
avg_time: i64,
64+
}
65+
66+
#[derive(Debug, Clone, Serialize, FromRow)]
67+
struct MostMessagesRow {
68+
user_id: i64,
69+
username: String,
70+
cnt: i64,
71+
}
72+
73+
#[derive(Debug, Clone, Serialize, FromRow)]
74+
struct MostTicketsRow {
75+
user_id: String,
76+
username: String,
77+
cnt: i64,
78+
}
79+
80+
#[derive(Debug, Clone, Serialize)]
81+
pub struct TopPerformers {
82+
pub fastest_responder: Option<TopPerformer>,
83+
pub most_messages: Option<TopPerformer>,
84+
pub most_tickets_closed: Option<TopPerformer>,
85+
}
86+
87+
#[derive(Debug, Clone, Serialize)]
88+
pub struct Statistics {
89+
pub overview: StatisticsOverview,
90+
pub activity: Vec<DailyActivity>,
91+
pub categories: Vec<CategoryStats>,
92+
pub staff_leaderboard: Vec<StaffMember>,
93+
pub top_performers: TopPerformers,
94+
}
95+
96+
pub async fn get_statistics(pool: &SqlitePool, days: i64) -> Result<Statistics, sqlx::Error> {
97+
let overview = get_overview(pool).await?;
98+
let activity = get_daily_activity(pool, days).await?;
99+
let categories = get_category_stats(pool).await?;
100+
let staff_leaderboard = get_staff_leaderboard(pool, days).await?;
101+
let top_performers = get_top_performers(pool).await?;
102+
103+
Ok(Statistics {
104+
overview,
105+
activity,
106+
categories,
107+
staff_leaderboard,
108+
top_performers,
109+
})
110+
}
111+
112+
async fn get_overview(pool: &SqlitePool) -> Result<StatisticsOverview, sqlx::Error> {
113+
let open_tickets: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM threads WHERE status = 1")
114+
.fetch_one(pool)
115+
.await?;
116+
117+
let total_closed: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM threads WHERE status = 0")
118+
.fetch_one(pool)
119+
.await?;
120+
121+
let closed_today: i64 = sqlx::query_scalar(
122+
"SELECT COUNT(*) FROM threads WHERE status = 0 AND closed_at >= strftime('%s', 'now', 'start of day')"
123+
)
124+
.fetch_one(pool)
125+
.await?;
126+
127+
let closed_this_week: i64 = sqlx::query_scalar(
128+
"SELECT COUNT(*) FROM threads WHERE status = 0 AND closed_at >= strftime('%s', 'now', '-7 days')"
129+
)
130+
.fetch_one(pool)
131+
.await?;
132+
133+
let closed_this_month: i64 = sqlx::query_scalar(
134+
"SELECT COUNT(*) FROM threads WHERE status = 0 AND closed_at >= strftime('%s', 'now', 'start of month')"
135+
)
136+
.fetch_one(pool)
137+
.await?;
138+
139+
let avg_response_time: Option<i64> = sqlx::query_scalar(
140+
r#"
141+
SELECT CAST(AVG(first_response_time) AS INTEGER)
142+
FROM (
143+
SELECT strftime('%s', MIN(m.created_at)) - strftime('%s', t.created_at) AS first_response_time
144+
FROM threads t
145+
JOIN thread_messages m ON m.thread_id = t.id
146+
WHERE m.message_number IS NOT NULL
147+
AND t.status = 0
148+
GROUP BY t.id
149+
HAVING first_response_time > 0
150+
)
151+
"#
152+
)
153+
.fetch_optional(pool)
154+
.await?
155+
.flatten();
156+
157+
let avg_resolution_time: Option<i64> = sqlx::query_scalar(
158+
"SELECT CAST(AVG(closed_at - strftime('%s', created_at)) AS INTEGER) FROM threads WHERE status = 0 AND closed_at IS NOT NULL"
159+
)
160+
.fetch_optional(pool)
161+
.await?
162+
.flatten();
163+
164+
Ok(StatisticsOverview {
165+
open_tickets,
166+
total_closed,
167+
closed_today,
168+
closed_this_week,
169+
closed_this_month,
170+
avg_response_time_seconds: avg_response_time,
171+
avg_resolution_time_seconds: avg_resolution_time,
172+
})
173+
}
174+
175+
async fn get_daily_activity(
176+
pool: &SqlitePool,
177+
days: i64,
178+
) -> Result<Vec<DailyActivity>, sqlx::Error> {
179+
use chrono::{Duration, Utc};
180+
use std::collections::HashMap;
181+
182+
let today = Utc::now().date_naive();
183+
let start_date = today - Duration::days(days - 1);
184+
let start_str = start_date.format("%Y-%m-%d").to_string();
185+
186+
#[derive(sqlx::FromRow)]
187+
struct CountRow {
188+
day: String,
189+
cnt: i64,
190+
}
191+
192+
let created_rows: Vec<CountRow> = sqlx::query_as(
193+
"SELECT date(created_at) as day, COUNT(*) as cnt FROM threads WHERE date(created_at) >= ? GROUP BY day"
194+
)
195+
.bind(&start_str)
196+
.fetch_all(pool)
197+
.await?;
198+
199+
let closed_rows: Vec<CountRow> = sqlx::query_as(
200+
"SELECT date(closed_at, 'unixepoch') as day, COUNT(*) as cnt FROM threads WHERE status = 0 AND closed_at IS NOT NULL AND date(closed_at, 'unixepoch') >= ? GROUP BY day"
201+
)
202+
.bind(&start_str)
203+
.fetch_all(pool)
204+
.await?;
205+
206+
let created_map: HashMap<String, i64> =
207+
created_rows.into_iter().map(|r| (r.day, r.cnt)).collect();
208+
let closed_map: HashMap<String, i64> =
209+
closed_rows.into_iter().map(|r| (r.day, r.cnt)).collect();
210+
211+
let mut results = Vec::new();
212+
for i in 0..days {
213+
let date = start_date + Duration::days(i);
214+
let date_str = date.format("%Y-%m-%d").to_string();
215+
results.push(DailyActivity {
216+
date: date_str.clone(),
217+
created: *created_map.get(&date_str).unwrap_or(&0),
218+
closed: *closed_map.get(&date_str).unwrap_or(&0),
219+
});
220+
}
221+
222+
Ok(results)
223+
}
224+
225+
async fn get_category_stats(pool: &SqlitePool) -> Result<Vec<CategoryStats>, sqlx::Error> {
226+
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM threads WHERE status = 0")
227+
.fetch_one(pool)
228+
.await?;
229+
230+
if total == 0 {
231+
return Ok(vec![]);
232+
}
233+
234+
let rows: Vec<CategoryRow> = sqlx::query_as(
235+
r#"
236+
SELECT
237+
COALESCE(category_name, 'Uncategorized') as name,
238+
COUNT(*) as cnt
239+
FROM threads
240+
WHERE status = 0
241+
GROUP BY category_name
242+
ORDER BY cnt DESC
243+
LIMIT 10
244+
"#,
245+
)
246+
.fetch_all(pool)
247+
.await?;
248+
249+
Ok(rows
250+
.into_iter()
251+
.map(|r| CategoryStats {
252+
name: r.name,
253+
count: r.cnt,
254+
percentage: (r.cnt as f64 / total as f64) * 100.0,
255+
})
256+
.collect())
257+
}
258+
259+
async fn get_staff_leaderboard(
260+
pool: &SqlitePool,
261+
days: i64,
262+
) -> Result<Vec<StaffMember>, sqlx::Error> {
263+
let rows: Vec<StaffRow> = sqlx::query_as(
264+
r#"
265+
SELECT
266+
m.user_id as user_id,
267+
m.user_name as username,
268+
COUNT(*) as messages_count,
269+
COALESCE(closed.tickets_closed, 0) as tickets_closed
270+
FROM thread_messages m
271+
JOIN threads t ON m.thread_id = t.id
272+
LEFT JOIN (
273+
SELECT closed_by, COUNT(*) as tickets_closed
274+
FROM threads
275+
WHERE status = 0
276+
AND closed_at >= strftime('%s', 'now', '-' || ? || ' days')
277+
GROUP BY closed_by
278+
) closed ON CAST(m.user_id AS TEXT) = closed.closed_by
279+
WHERE m.message_number IS NOT NULL
280+
AND m.created_at >= strftime('%s', 'now', '-' || ? || ' days')
281+
GROUP BY m.user_id, m.user_name
282+
ORDER BY messages_count DESC
283+
LIMIT 20
284+
"#,
285+
)
286+
.bind(days)
287+
.bind(days)
288+
.fetch_all(pool)
289+
.await?;
290+
291+
Ok(rows
292+
.into_iter()
293+
.map(|r| StaffMember {
294+
user_id: r.user_id.to_string(),
295+
username: r.username,
296+
messages_count: r.messages_count,
297+
tickets_closed: r.tickets_closed,
298+
avg_response_time_seconds: None,
299+
})
300+
.collect())
301+
}
302+
303+
async fn get_top_performers(pool: &SqlitePool) -> Result<TopPerformers, sqlx::Error> {
304+
let fastest: Option<FastestResponderRow> = sqlx::query_as(
305+
r#"
306+
SELECT
307+
m.user_id as user_id,
308+
m.user_name as username,
309+
CAST(AVG(response_time) AS INTEGER) as avg_time
310+
FROM (
311+
SELECT
312+
m.user_id,
313+
m.user_name,
314+
strftime('%s', MIN(m.created_at)) - strftime('%s', t.created_at) AS response_time
315+
FROM thread_messages m
316+
JOIN threads t ON m.thread_id = t.id
317+
WHERE m.message_number IS NOT NULL
318+
GROUP BY m.thread_id, m.user_id, m.user_name
319+
HAVING response_time > 0
320+
) m
321+
GROUP BY m.user_id, m.user_name
322+
HAVING COUNT(*) >= 5
323+
ORDER BY avg_time ASC
324+
LIMIT 1
325+
"#,
326+
)
327+
.fetch_optional(pool)
328+
.await?;
329+
330+
let most_messages: Option<MostMessagesRow> = sqlx::query_as(
331+
r#"
332+
SELECT
333+
user_id as user_id,
334+
user_name as username,
335+
COUNT(*) as cnt
336+
FROM thread_messages
337+
WHERE message_number IS NOT NULL
338+
GROUP BY user_id, user_name
339+
ORDER BY cnt DESC
340+
LIMIT 1
341+
"#,
342+
)
343+
.fetch_optional(pool)
344+
.await?;
345+
346+
let most_tickets: Option<MostTicketsRow> = sqlx::query_as(
347+
r#"
348+
SELECT
349+
t.closed_by as user_id,
350+
COALESCE(m.user_name, t.closed_by) as username,
351+
COUNT(*) as cnt
352+
FROM threads t
353+
LEFT JOIN (
354+
SELECT DISTINCT CAST(user_id AS TEXT) as user_id_str, user_name
355+
FROM thread_messages
356+
WHERE message_number IS NOT NULL
357+
) m ON m.user_id_str = t.closed_by
358+
WHERE t.status = 0 AND t.closed_by IS NOT NULL
359+
GROUP BY t.closed_by
360+
ORDER BY cnt DESC
361+
LIMIT 1
362+
"#,
363+
)
364+
.fetch_optional(pool)
365+
.await?;
366+
367+
Ok(TopPerformers {
368+
fastest_responder: fastest.map(|r| TopPerformer {
369+
user_id: r.user_id.to_string(),
370+
username: r.username,
371+
value: r.avg_time,
372+
}),
373+
most_messages: most_messages.map(|r| TopPerformer {
374+
user_id: r.user_id.to_string(),
375+
username: r.username,
376+
value: r.cnt,
377+
}),
378+
most_tickets_closed: most_tickets.map(|r| TopPerformer {
379+
user_id: r.user_id.clone(),
380+
username: r.username,
381+
value: r.cnt,
382+
}),
383+
})
384+
}

0 commit comments

Comments
 (0)