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