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}