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}