Add admin API for counting users with a given amount of activity

Max Brunsfeld created

Change summary

crates/collab/src/api.rs | 28 ++++++++++++++
crates/collab/src/db.rs  | 78 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 104 insertions(+), 2 deletions(-)

Detailed changes

crates/collab/src/api.rs 🔗

@@ -17,7 +17,7 @@ use axum::{
 use axum_extra::response::ErasedJson;
 use serde::{Deserialize, Serialize};
 use serde_json::json;
-use std::sync::Arc;
+use std::{sync::Arc, time::Duration};
 use time::OffsetDateTime;
 use tower::ServiceBuilder;
 use tracing::instrument;
@@ -43,6 +43,7 @@ pub fn routes(rpc_server: &Arc<rpc::Server>, state: Arc<AppState>) -> Router<Bod
             "/user_activity/timeline/:user_id",
             get(get_user_activity_timeline),
         )
+        .route("/user_activity/counts", get(get_active_user_counts))
         .route("/project_metadata", get(get_project_metadata))
         .layer(
             ServiceBuilder::new()
@@ -298,6 +299,31 @@ async fn get_user_activity_timeline(
     Ok(ErasedJson::pretty(summary))
 }
 
+#[derive(Serialize)]
+struct ActiveUserSet {
+    active_time_in_minutes: u64,
+    user_count: usize,
+}
+
+async fn get_active_user_counts(
+    Query(params): Query<TimePeriodParams>,
+    Extension(app): Extension<Arc<AppState>>,
+) -> Result<ErasedJson> {
+    let durations_in_minutes = [10, 60, 4 * 60, 8 * 60];
+
+    let mut user_sets = Vec::with_capacity(durations_in_minutes.len());
+    for duration in durations_in_minutes {
+        user_sets.push(ActiveUserSet {
+            active_time_in_minutes: duration,
+            user_count: app
+                .db
+                .get_active_user_count(params.start..params.end, Duration::from_secs(duration * 60))
+                .await?,
+        })
+    }
+    Ok(ErasedJson::pretty(user_sets))
+}
+
 #[derive(Deserialize)]
 struct GetProjectMetadataParams {
     project_id: u64,

crates/collab/src/db.rs 🔗

@@ -69,6 +69,14 @@ pub trait Db: Send + Sync {
         active_projects: &[(UserId, ProjectId)],
     ) -> Result<()>;
 
+    /// Get the number of users who have been active in the given
+    /// time period for at least the given time duration.
+    async fn get_active_user_count(
+        &self,
+        time_period: Range<OffsetDateTime>,
+        min_duration: Duration,
+    ) -> Result<usize>;
+
     /// Get the users that have been most active during the given time period,
     /// along with the amount of time they have been active in each project.
     async fn get_top_users_activity_summary(
@@ -593,6 +601,40 @@ impl Db for PostgresDb {
         Ok(())
     }
 
+    async fn get_active_user_count(
+        &self,
+        time_period: Range<OffsetDateTime>,
+        min_duration: Duration,
+    ) -> Result<usize> {
+        let query = "
+            WITH
+                project_durations AS (
+                    SELECT user_id, project_id, SUM(duration_millis) AS project_duration
+                    FROM project_activity_periods
+                    WHERE $1 < ended_at AND ended_at <= $2
+                    GROUP BY user_id, project_id
+                ),
+                user_durations AS (
+                    SELECT user_id, SUM(project_duration) as total_duration
+                    FROM project_durations
+                    GROUP BY user_id
+                    ORDER BY total_duration DESC
+                    LIMIT $3
+                )
+            SELECT count(user_durations.user_id)
+            FROM user_durations
+            WHERE user_durations.total_duration >= $3
+        ";
+
+        let count: i64 = sqlx::query_scalar(query)
+            .bind(time_period.start)
+            .bind(time_period.end)
+            .bind(min_duration.as_millis() as i64)
+            .fetch_one(&self.pool)
+            .await?;
+        Ok(count as usize)
+    }
+
     async fn get_top_users_activity_summary(
         &self,
         time_period: Range<OffsetDateTime>,
@@ -1544,7 +1586,7 @@ pub mod tests {
     }
 
     #[tokio::test(flavor = "multi_thread")]
-    async fn test_project_activity() {
+    async fn test_user_activity() {
         let test_db = TestDb::postgres().await;
         let db = test_db.db();
 
@@ -1641,6 +1683,32 @@ pub mod tests {
                 },
             ]
         );
+
+        assert_eq!(
+            db.get_active_user_count(t0..t6, Duration::from_secs(56))
+                .await
+                .unwrap(),
+            0
+        );
+        assert_eq!(
+            db.get_active_user_count(t0..t6, Duration::from_secs(54))
+                .await
+                .unwrap(),
+            1
+        );
+        assert_eq!(
+            db.get_active_user_count(t0..t6, Duration::from_secs(30))
+                .await
+                .unwrap(),
+            2
+        );
+        assert_eq!(
+            db.get_active_user_count(t0..t6, Duration::from_secs(10))
+                .await
+                .unwrap(),
+            3
+        );
+
         assert_eq!(
             db.get_user_activity_timeline(t3..t6, user_1).await.unwrap(),
             &[
@@ -2477,6 +2545,14 @@ pub mod tests {
             unimplemented!()
         }
 
+        async fn get_active_user_count(
+            &self,
+            _time_period: Range<OffsetDateTime>,
+            _min_duration: Duration,
+        ) -> Result<usize> {
+            unimplemented!()
+        }
+
         async fn get_top_users_activity_summary(
             &self,
             _time_period: Range<OffsetDateTime>,