Skip to main content

shared/models/
user_activity.rs

1use crate::{State, models::InsertQueryBuilder, prelude::*};
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    extension_data: super::ModelExtensionData,
56}
57
58impl BaseModel for UserActivity {
59    const NAME: &'static str = "user_activity";
60
61    fn get_extension_list() -> &'static super::ModelExtensionList {
62        static EXTENSIONS: LazyLock<super::ModelExtensionList> =
63            LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
64
65        &EXTENSIONS
66    }
67
68    fn get_extension_data(&self) -> &super::ModelExtensionData {
69        &self.extension_data
70    }
71
72    #[inline]
73    fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
74        let prefix = prefix.unwrap_or_default();
75
76        BTreeMap::from([
77            (
78                "user_activities.impersonator_uuid",
79                compact_str::format_compact!("{prefix}impersonator_uuid"),
80            ),
81            (
82                "user_activities.api_key_uuid",
83                compact_str::format_compact!("{prefix}api_key_uuid"),
84            ),
85            (
86                "user_activities.event",
87                compact_str::format_compact!("{prefix}event"),
88            ),
89            (
90                "user_activities.ip",
91                compact_str::format_compact!("{prefix}ip"),
92            ),
93            (
94                "user_activities.data",
95                compact_str::format_compact!("{prefix}data"),
96            ),
97            (
98                "user_activities.created",
99                compact_str::format_compact!("{prefix}created"),
100            ),
101        ])
102    }
103
104    #[inline]
105    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
106        let prefix = prefix.unwrap_or_default();
107
108        Ok(Self {
109            impersonator: super::user::User::get_fetchable_from_row(
110                row,
111                compact_str::format_compact!("{prefix}impersonator_uuid"),
112            ),
113            api_key: super::user_api_key::UserApiKey::get_fetchable_from_row(
114                row,
115                compact_str::format_compact!("{prefix}api_key_uuid"),
116            ),
117            event: row.try_get(compact_str::format_compact!("{prefix}event").as_str())?,
118            ip: row.try_get(compact_str::format_compact!("{prefix}ip").as_str())?,
119            data: row.try_get(compact_str::format_compact!("{prefix}data").as_str())?,
120            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
121            extension_data: Self::map_extensions(prefix, row)?,
122        })
123    }
124}
125
126impl UserActivity {
127    pub async fn by_user_uuid_with_pagination(
128        database: &crate::database::Database,
129        user_uuid: uuid::Uuid,
130        page: i64,
131        per_page: i64,
132        search: Option<&str>,
133    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
134        super::PaginatedQueryBuilder::new(
135            Self::columns_sql(None),
136            r#"
137            FROM user_activities
138            WHERE user_activities.user_uuid = $1 AND ($2 IS NULL OR user_activities.event ILIKE '%' || $2 || '%')
139            "#,
140            "user_activities.created DESC",
141        )
142        .bind(user_uuid)
143        .bind(search)
144        .fetch(database, page, per_page)
145        .await
146    }
147
148    pub async fn delete_older_than(
149        database: &crate::database::Database,
150        cutoff: chrono::DateTime<chrono::Utc>,
151    ) -> Result<u64, crate::database::DatabaseError> {
152        let result = sqlx::query(
153            r#"
154            DELETE FROM user_activities
155            WHERE user_activities.created < $1
156            "#,
157        )
158        .bind(cutoff.naive_utc())
159        .execute(database.write())
160        .await?;
161
162        Ok(result.rows_affected())
163    }
164
165    pub async fn retain_latest_logs_per_user(
166        database: &crate::database::Database,
167        keep_count: i64,
168    ) -> Result<u64, crate::database::DatabaseError> {
169        let result = sqlx::query(
170            r#"
171            DELETE FROM user_activities
172            WHERE ctid IN (
173                SELECT ctid 
174                FROM (
175                    SELECT
176                        ctid, 
177                        ROW_NUMBER() OVER (PARTITION BY user_activities.user_uuid ORDER BY user_activities.created DESC) rn
178                    FROM user_activities
179                ) sub
180                WHERE sub.rn > $1
181            )
182            "#,
183        )
184        .bind(keep_count)
185        .execute(database.write())
186        .await?;
187
188        Ok(result.rows_affected())
189    }
190}
191
192#[async_trait::async_trait]
193impl IntoApiObject for UserActivity {
194    type ApiObject = ApiUserActivity;
195    type ExtraArgs<'a> = &'a crate::storage::StorageUrlRetriever<'a>;
196
197    async fn into_api_object<'a>(
198        self,
199        state: &crate::State,
200        storage_url_retriever: Self::ExtraArgs<'a>,
201    ) -> Result<Self::ApiObject, crate::database::DatabaseError> {
202        let api_object = ApiUserActivity::init_hooks(&self, state).await?;
203
204        let impersonator = if let Some(impersonator) = self.impersonator {
205            Some(
206                impersonator
207                    .fetch_cached(&state.database)
208                    .await?
209                    .into_api_object(state, storage_url_retriever)
210                    .await?,
211            )
212        } else {
213            None
214        };
215
216        let api_object = finish_extendible!(
217            ApiUserActivity {
218                impersonator,
219                event: self.event,
220                ip: self.ip.map(|ip| ip.ip().to_compact_string()),
221                data: self.data,
222                is_api: self.api_key.is_some(),
223                created: self.created.and_utc(),
224            },
225            api_object,
226            state
227        )?;
228
229        Ok(api_object)
230    }
231}
232
233#[derive(ToSchema, Deserialize, Validate)]
234pub struct CreateUserActivityOptions {
235    #[garde(skip)]
236    pub user_uuid: uuid::Uuid,
237    #[garde(skip)]
238    pub impersonator_uuid: Option<uuid::Uuid>,
239    #[garde(skip)]
240    pub api_key_uuid: Option<uuid::Uuid>,
241    #[garde(length(chars, min = 1, max = 255))]
242    #[schema(min_length = 1, max_length = 255)]
243    pub event: compact_str::CompactString,
244    #[garde(skip)]
245    #[schema(value_type = Option<String>)]
246    pub ip: Option<sqlx::types::ipnetwork::IpNetwork>,
247    #[garde(skip)]
248    pub data: serde_json::Value,
249    #[garde(skip)]
250    pub created: Option<chrono::NaiveDateTime>,
251}
252
253#[async_trait::async_trait]
254impl CreatableModel for UserActivity {
255    type CreateOptions<'a> = CreateUserActivityOptions;
256    type CreateResult = ();
257
258    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
259        static CREATE_LISTENERS: LazyLock<CreateListenerList<UserActivity>> =
260            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
261
262        &CREATE_LISTENERS
263    }
264
265    async fn create_with_transaction(
266        state: &crate::State,
267        mut options: Self::CreateOptions<'_>,
268        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
269    ) -> Result<Self::CreateResult, crate::database::DatabaseError> {
270        options.validate()?;
271
272        let mut query_builder = InsertQueryBuilder::new("user_activities");
273
274        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
275
276        query_builder
277            .set("user_uuid", options.user_uuid)
278            .set("impersonator_uuid", options.impersonator_uuid)
279            .set("api_key_uuid", options.api_key_uuid)
280            .set("event", &options.event)
281            .set("ip", options.ip)
282            .set("data", &options.data);
283
284        if let Some(created) = options.created {
285            query_builder.set("created", created);
286        }
287
288        query_builder.execute(&mut **transaction).await?;
289
290        let mut result = ();
291
292        Self::run_after_create_handlers(&mut result, &options, state, transaction).await?;
293
294        Ok(result)
295    }
296}
297
298#[schema_extension_derive::extendible]
299#[init_args(UserActivity, crate::State)]
300#[hook_args(crate::State)]
301#[derive(ToSchema, Serialize)]
302#[schema(title = "UserActivity")]
303pub struct ApiUserActivity {
304    pub impersonator: Option<super::user::ApiUser>,
305
306    pub event: compact_str::CompactString,
307    pub ip: Option<compact_str::CompactString>,
308    pub data: serde_json::Value,
309
310    pub is_api: bool,
311
312    pub created: chrono::DateTime<chrono::Utc>,
313}