shared/models/
user_activity.rs

1use crate::{State, models::InsertQueryBuilder, prelude::*, storage::StorageUrlRetriever};
2use compact_str::ToCompactString;
3use garde::Validate;
4use serde::{Deserialize, Serialize};
5use sqlx::{Row, postgres::PgRow};
6use std::{
7    collections::BTreeMap,
8    sync::{Arc, LazyLock},
9};
10use utoipa::ToSchema;
11
12pub type GetUserActivityLogger = crate::extract::ConsumingExtension<UserActivityLogger>;
13
14#[derive(Clone)]
15pub struct UserActivityLogger {
16    pub state: State,
17    pub user_uuid: uuid::Uuid,
18    pub impersonator_uuid: Option<uuid::Uuid>,
19    pub api_key_uuid: Option<uuid::Uuid>,
20    pub ip: std::net::IpAddr,
21}
22
23impl UserActivityLogger {
24    pub async fn log(&self, event: impl Into<compact_str::CompactString>, data: serde_json::Value) {
25        let options = CreateUserActivityOptions {
26            user_uuid: self.user_uuid,
27            impersonator_uuid: self.impersonator_uuid,
28            api_key_uuid: self.api_key_uuid,
29            event: event.into(),
30            ip: Some(self.ip.into()),
31            data,
32            created: None,
33        };
34        if let Err(err) = UserActivity::create(&self.state, options).await {
35            tracing::warn!(
36                user = %self.user_uuid,
37                "failed to log user activity: {:#?}",
38                err
39            );
40        }
41    }
42}
43
44#[derive(Serialize, Deserialize)]
45pub struct UserActivity {
46    pub impersonator: Option<Fetchable<super::user::User>>,
47    pub api_key: Option<Fetchable<super::user_api_key::UserApiKey>>,
48
49    pub event: compact_str::CompactString,
50    pub ip: Option<sqlx::types::ipnetwork::IpNetwork>,
51    pub data: serde_json::Value,
52
53    pub created: chrono::NaiveDateTime,
54}
55
56impl BaseModel for UserActivity {
57    const NAME: &'static str = "user_activity";
58
59    #[inline]
60    fn columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
61        let prefix = prefix.unwrap_or_default();
62
63        BTreeMap::from([
64            (
65                "user_activities.impersonator_uuid",
66                compact_str::format_compact!("{prefix}impersonator_uuid"),
67            ),
68            (
69                "user_activities.api_key_uuid",
70                compact_str::format_compact!("{prefix}api_key_uuid"),
71            ),
72            (
73                "user_activities.event",
74                compact_str::format_compact!("{prefix}event"),
75            ),
76            (
77                "user_activities.ip",
78                compact_str::format_compact!("{prefix}ip"),
79            ),
80            (
81                "user_activities.data",
82                compact_str::format_compact!("{prefix}data"),
83            ),
84            (
85                "user_activities.created",
86                compact_str::format_compact!("{prefix}created"),
87            ),
88        ])
89    }
90
91    #[inline]
92    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
93        let prefix = prefix.unwrap_or_default();
94
95        Ok(Self {
96            impersonator: super::user::User::get_fetchable_from_row(
97                row,
98                compact_str::format_compact!("{prefix}impersonator_uuid"),
99            ),
100            api_key: super::user_api_key::UserApiKey::get_fetchable_from_row(
101                row,
102                compact_str::format_compact!("{prefix}api_key_uuid"),
103            ),
104            event: row.try_get(compact_str::format_compact!("{prefix}event").as_str())?,
105            ip: row.try_get(compact_str::format_compact!("{prefix}ip").as_str())?,
106            data: row.try_get(compact_str::format_compact!("{prefix}data").as_str())?,
107            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
108        })
109    }
110}
111
112impl UserActivity {
113    pub async fn by_user_uuid_with_pagination(
114        database: &crate::database::Database,
115        user_uuid: uuid::Uuid,
116        page: i64,
117        per_page: i64,
118        search: Option<&str>,
119    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
120        let offset = (page - 1) * per_page;
121
122        let rows = sqlx::query(&format!(
123            r#"
124            SELECT {}, COUNT(*) OVER() AS total_count
125            FROM user_activities
126            WHERE user_activities.user_uuid = $1 AND ($2 IS NULL OR user_activities.event ILIKE '%' || $2 || '%')
127            ORDER BY user_activities.created DESC
128            LIMIT $3 OFFSET $4
129            "#,
130            Self::columns_sql(None)
131        ))
132        .bind(user_uuid)
133        .bind(search)
134        .bind(per_page)
135        .bind(offset)
136        .fetch_all(database.read())
137        .await?;
138
139        Ok(super::Pagination {
140            total: rows
141                .first()
142                .map_or(Ok(0), |row| row.try_get("total_count"))?,
143            per_page,
144            page,
145            data: rows
146                .into_iter()
147                .map(|row| Self::map(None, &row))
148                .try_collect_vec()?,
149        })
150    }
151
152    pub async fn delete_older_than(
153        database: &crate::database::Database,
154        cutoff: chrono::DateTime<chrono::Utc>,
155    ) -> Result<u64, crate::database::DatabaseError> {
156        let result = sqlx::query(
157            r#"
158            DELETE FROM user_activities
159            WHERE created < $1
160            "#,
161        )
162        .bind(cutoff.naive_utc())
163        .execute(database.write())
164        .await?;
165
166        Ok(result.rows_affected())
167    }
168
169    #[inline]
170    pub async fn into_api_object(
171        self,
172        database: &crate::database::Database,
173        storage_url_retriever: &StorageUrlRetriever<'_>,
174    ) -> Result<ApiUserActivity, anyhow::Error> {
175        Ok(ApiUserActivity {
176            impersonator: if let Some(impersonator) = self.impersonator {
177                Some(
178                    impersonator
179                        .fetch_cached(database)
180                        .await?
181                        .into_api_object(storage_url_retriever),
182                )
183            } else {
184                None
185            },
186            event: self.event,
187            ip: self.ip.map(|ip| ip.ip().to_compact_string()),
188            data: self.data,
189            is_api: self.api_key.is_some(),
190            created: self.created.and_utc(),
191        })
192    }
193}
194
195#[derive(ToSchema, Deserialize, Validate)]
196pub struct CreateUserActivityOptions {
197    #[garde(skip)]
198    pub user_uuid: uuid::Uuid,
199    #[garde(skip)]
200    pub impersonator_uuid: Option<uuid::Uuid>,
201    #[garde(skip)]
202    pub api_key_uuid: Option<uuid::Uuid>,
203    #[garde(length(chars, min = 1, max = 255))]
204    #[schema(min_length = 1, max_length = 255)]
205    pub event: compact_str::CompactString,
206    #[garde(skip)]
207    #[schema(value_type = Option<String>)]
208    pub ip: Option<sqlx::types::ipnetwork::IpNetwork>,
209    #[garde(skip)]
210    pub data: serde_json::Value,
211    #[garde(skip)]
212    pub created: Option<chrono::NaiveDateTime>,
213}
214
215#[async_trait::async_trait]
216impl CreatableModel for UserActivity {
217    type CreateOptions<'a> = CreateUserActivityOptions;
218    type CreateResult = ();
219
220    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
221        static CREATE_LISTENERS: LazyLock<CreateListenerList<UserActivity>> =
222            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
223
224        &CREATE_LISTENERS
225    }
226
227    async fn create(
228        state: &crate::State,
229        mut options: Self::CreateOptions<'_>,
230    ) -> Result<Self::CreateResult, crate::database::DatabaseError> {
231        options.validate()?;
232
233        let mut transaction = state.database.write().begin().await?;
234
235        let mut query_builder = InsertQueryBuilder::new("user_activities");
236
237        Self::run_create_handlers(&mut options, &mut query_builder, state, &mut transaction)
238            .await?;
239
240        query_builder
241            .set("user_uuid", options.user_uuid)
242            .set("impersonator_uuid", options.impersonator_uuid)
243            .set("api_key_uuid", options.api_key_uuid)
244            .set("event", &options.event)
245            .set("ip", options.ip)
246            .set("data", options.data);
247
248        if let Some(created) = options.created {
249            query_builder.set("created", created);
250        }
251
252        query_builder.execute(&mut *transaction).await?;
253
254        transaction.commit().await?;
255
256        Ok(())
257    }
258}
259
260#[derive(ToSchema, Serialize)]
261#[schema(title = "UserActivity")]
262pub struct ApiUserActivity {
263    pub impersonator: Option<super::user::ApiUser>,
264
265    pub event: compact_str::CompactString,
266    pub ip: Option<compact_str::CompactString>,
267    pub data: serde_json::Value,
268
269    pub is_api: bool,
270
271    pub created: chrono::DateTime<chrono::Utc>,
272}