aster_forge_db/
audit_log.rs

1//! Database-backed audit log table and base store.
2//!
3//! Aster products share the same audit log storage shape: an actor id, stable
4//! action wire value, target entity metadata, optional detail JSON, request
5//! metadata, and creation timestamp. Products still own typed action enums,
6//! detail schemas, presentation, retention policy, and authorization. This
7//! module keeps the common `SeaORM` table contract, index builders, and simple
8//! write/count/delete helpers in one place.
9
10use chrono::{DateTime, Utc};
11use sea_orm::entity::prelude::*;
12use sea_orm::sea_query::{
13    Alias, ColumnDef, Index, IndexCreateStatement, Table, TableCreateStatement, TableDropStatement,
14};
15use sea_orm::{
16    ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, DatabaseBackend, DatabaseConnection,
17    EntityTrait, ExprTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set,
18    sea_query::Expr,
19};
20
21/// Audit log table name.
22pub const AUDIT_LOGS_TABLE: &str = "audit_logs";
23/// Stable row id column.
24pub const AUDIT_LOG_ID_COLUMN: &str = "id";
25/// Actor user id column. System events use `0`.
26pub const AUDIT_LOG_USER_ID_COLUMN: &str = "user_id";
27/// Stable action wire value column.
28pub const AUDIT_LOG_ACTION_COLUMN: &str = "action";
29/// Target entity type column.
30pub const AUDIT_LOG_ENTITY_TYPE_COLUMN: &str = "entity_type";
31/// Optional target entity id column.
32pub const AUDIT_LOG_ENTITY_ID_COLUMN: &str = "entity_id";
33/// Optional target entity display name column.
34pub const AUDIT_LOG_ENTITY_NAME_COLUMN: &str = "entity_name";
35/// Optional product-owned JSON detail column.
36pub const AUDIT_LOG_DETAILS_COLUMN: &str = "details";
37/// Optional client IP column.
38pub const AUDIT_LOG_IP_ADDRESS_COLUMN: &str = "ip_address";
39/// Optional user-agent column.
40pub const AUDIT_LOG_USER_AGENT_COLUMN: &str = "user_agent";
41/// Row creation timestamp column.
42pub const AUDIT_LOG_CREATED_AT_COLUMN: &str = "created_at";
43
44/// Index name for created-at scans.
45pub const AUDIT_LOG_CREATED_AT_INDEX: &str = "idx_audit_logs_created_at";
46/// Index name for action filtering.
47pub const AUDIT_LOG_ACTION_INDEX: &str = "idx_audit_logs_action";
48/// Index name for user filtering.
49pub const AUDIT_LOG_USER_ID_INDEX: &str = "idx_audit_logs_user_id";
50/// Index name for action/time/user activity aggregation.
51pub const AUDIT_LOG_ACTION_CREATED_USER_INDEX: &str = "idx_audit_logs_action_created_user";
52/// Index name for cursor scans by created-at/id.
53pub const AUDIT_LOG_CREATED_ID_INDEX: &str = "idx_audit_logs_created_id";
54/// Index name for user cursor scans.
55pub const AUDIT_LOG_USER_CREATED_ID_INDEX: &str = "idx_audit_logs_user_created_id";
56/// Index name for action cursor scans.
57pub const AUDIT_LOG_ACTION_CREATED_ID_INDEX: &str = "idx_audit_logs_action_created_id";
58/// Index name for entity-type cursor scans.
59pub const AUDIT_LOG_ENTITY_TYPE_CREATED_ID_INDEX: &str = "idx_audit_logs_entity_type_created_id";
60
61const AUDIT_LOG_ACTION_MAX_LEN: u32 = 64;
62const AUDIT_LOG_ACTION_MAX_BYTES: usize = 64;
63const AUDIT_LOG_ENTITY_TYPE_MAX_LEN: u32 = 64;
64const AUDIT_LOG_ENTITY_TYPE_MAX_BYTES: usize = 64;
65const AUDIT_LOG_ENTITY_NAME_MAX_BYTES: usize = 255;
66const AUDIT_LOG_IP_ADDRESS_MAX_BYTES: usize = 128;
67const AUDIT_LOG_USER_AGENT_MAX_BYTES: usize = 512;
68
69/// Builds the shared `audit_logs` table creation statement.
70#[must_use]
71pub fn create_audit_logs_table(backend: DatabaseBackend) -> TableCreateStatement {
72    Table::create()
73        .table(audit_logs_table())
74        .if_not_exists()
75        .col(
76            ColumnDef::new(audit_log_id())
77                .big_integer()
78                .not_null()
79                .auto_increment()
80                .primary_key(),
81        )
82        .col(
83            ColumnDef::new(audit_log_user_id())
84                .big_integer()
85                .not_null()
86                .default(0),
87        )
88        .col(
89            ColumnDef::new(audit_log_action())
90                .string_len(AUDIT_LOG_ACTION_MAX_LEN)
91                .not_null(),
92        )
93        .col(
94            ColumnDef::new(audit_log_entity_type())
95                .string_len(AUDIT_LOG_ENTITY_TYPE_MAX_LEN)
96                .not_null(),
97        )
98        .col(ColumnDef::new(audit_log_entity_id()).big_integer().null())
99        .col(
100            ColumnDef::new(audit_log_entity_name())
101                .string_len(255)
102                .null(),
103        )
104        .col(ColumnDef::new(audit_log_details()).text().null())
105        .col(
106            ColumnDef::new(audit_log_ip_address())
107                .string_len(128)
108                .null(),
109        )
110        .col(
111            ColumnDef::new(audit_log_user_agent())
112                .string_len(512)
113                .null(),
114        )
115        .col(utc_datetime_column(backend, audit_log_created_at()).not_null())
116        .to_owned()
117}
118
119/// Builds the shared `audit_logs` table drop statement.
120#[must_use]
121pub fn drop_audit_logs_table() -> TableDropStatement {
122    Table::drop()
123        .table(audit_logs_table())
124        .if_exists()
125        .to_owned()
126}
127
128/// Builds the created-at index.
129#[must_use]
130pub fn create_audit_logs_created_at_index() -> IndexCreateStatement {
131    Index::create()
132        .name(AUDIT_LOG_CREATED_AT_INDEX)
133        .table(audit_logs_table())
134        .col(audit_log_created_at())
135        .if_not_exists()
136        .to_owned()
137}
138
139/// Builds the action index.
140#[must_use]
141pub fn create_audit_logs_action_index() -> IndexCreateStatement {
142    Index::create()
143        .name(AUDIT_LOG_ACTION_INDEX)
144        .table(audit_logs_table())
145        .col(audit_log_action())
146        .if_not_exists()
147        .to_owned()
148}
149
150/// Builds the user id index.
151#[must_use]
152pub fn create_audit_logs_user_id_index() -> IndexCreateStatement {
153    Index::create()
154        .name(AUDIT_LOG_USER_ID_INDEX)
155        .table(audit_logs_table())
156        .col(audit_log_user_id())
157        .if_not_exists()
158        .to_owned()
159}
160
161/// Builds the action/created/user activity index.
162#[must_use]
163pub fn create_audit_logs_action_created_user_index() -> IndexCreateStatement {
164    Index::create()
165        .name(AUDIT_LOG_ACTION_CREATED_USER_INDEX)
166        .table(audit_logs_table())
167        .col(audit_log_action())
168        .col(audit_log_created_at())
169        .col(audit_log_user_id())
170        .if_not_exists()
171        .to_owned()
172}
173
174/// Builds the created-at/id cursor index.
175#[must_use]
176pub fn create_audit_logs_created_id_index() -> IndexCreateStatement {
177    Index::create()
178        .name(AUDIT_LOG_CREATED_ID_INDEX)
179        .table(audit_logs_table())
180        .col(audit_log_created_at())
181        .col(audit_log_id())
182        .if_not_exists()
183        .to_owned()
184}
185
186/// Builds the user/created-at/id cursor index.
187#[must_use]
188pub fn create_audit_logs_user_created_id_index() -> IndexCreateStatement {
189    Index::create()
190        .name(AUDIT_LOG_USER_CREATED_ID_INDEX)
191        .table(audit_logs_table())
192        .col(audit_log_user_id())
193        .col(audit_log_created_at())
194        .col(audit_log_id())
195        .if_not_exists()
196        .to_owned()
197}
198
199/// Builds the action/created-at/id cursor index.
200#[must_use]
201pub fn create_audit_logs_action_created_id_index() -> IndexCreateStatement {
202    Index::create()
203        .name(AUDIT_LOG_ACTION_CREATED_ID_INDEX)
204        .table(audit_logs_table())
205        .col(audit_log_action())
206        .col(audit_log_created_at())
207        .col(audit_log_id())
208        .if_not_exists()
209        .to_owned()
210}
211
212/// Builds the entity-type/created-at/id cursor index.
213#[must_use]
214pub fn create_audit_logs_entity_type_created_id_index() -> IndexCreateStatement {
215    Index::create()
216        .name(AUDIT_LOG_ENTITY_TYPE_CREATED_ID_INDEX)
217        .table(audit_logs_table())
218        .col(audit_log_entity_type())
219        .col(audit_log_created_at())
220        .col(audit_log_id())
221        .if_not_exists()
222        .to_owned()
223}
224
225/// Returns the base index builders used by the current shared schema.
226#[must_use]
227pub fn create_audit_logs_base_indexes() -> [IndexCreateStatement; 3] {
228    [
229        create_audit_logs_created_at_index(),
230        create_audit_logs_action_index(),
231        create_audit_logs_user_id_index(),
232    ]
233}
234
235/// Returns the optional activity/query index builders used by admin views.
236#[must_use]
237pub fn create_audit_logs_query_indexes() -> [IndexCreateStatement; 5] {
238    [
239        create_audit_logs_action_created_user_index(),
240        create_audit_logs_created_id_index(),
241        create_audit_logs_user_created_id_index(),
242        create_audit_logs_action_created_id_index(),
243        create_audit_logs_entity_type_created_id_index(),
244    ]
245}
246
247fn audit_logs_table() -> Alias {
248    Alias::new(AUDIT_LOGS_TABLE)
249}
250
251fn audit_log_id() -> Alias {
252    Alias::new(AUDIT_LOG_ID_COLUMN)
253}
254
255fn audit_log_user_id() -> Alias {
256    Alias::new(AUDIT_LOG_USER_ID_COLUMN)
257}
258
259fn audit_log_action() -> Alias {
260    Alias::new(AUDIT_LOG_ACTION_COLUMN)
261}
262
263fn audit_log_entity_type() -> Alias {
264    Alias::new(AUDIT_LOG_ENTITY_TYPE_COLUMN)
265}
266
267fn audit_log_entity_id() -> Alias {
268    Alias::new(AUDIT_LOG_ENTITY_ID_COLUMN)
269}
270
271fn audit_log_entity_name() -> Alias {
272    Alias::new(AUDIT_LOG_ENTITY_NAME_COLUMN)
273}
274
275fn audit_log_details() -> Alias {
276    Alias::new(AUDIT_LOG_DETAILS_COLUMN)
277}
278
279fn audit_log_ip_address() -> Alias {
280    Alias::new(AUDIT_LOG_IP_ADDRESS_COLUMN)
281}
282
283fn audit_log_user_agent() -> Alias {
284    Alias::new(AUDIT_LOG_USER_AGENT_COLUMN)
285}
286
287fn audit_log_created_at() -> Alias {
288    Alias::new(AUDIT_LOG_CREATED_AT_COLUMN)
289}
290
291fn utc_datetime_column(backend: DatabaseBackend, column: Alias) -> ColumnDef {
292    let mut definition = ColumnDef::new(column);
293    match backend {
294        DatabaseBackend::MySql => {
295            definition.custom(Alias::new("datetime(6)"));
296        }
297        _ => {
298            definition.timestamp_with_time_zone();
299        }
300    }
301    definition
302}
303
304/// `SeaORM` model for `audit_logs` with product-neutral string action values.
305#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
306#[sea_orm(table_name = "audit_logs")]
307pub struct Model {
308    /// Stable row id.
309    #[sea_orm(primary_key)]
310    pub id: i64,
311    /// Actor user id. System events use `0`.
312    pub user_id: i64,
313    /// Stable action wire value.
314    pub action: String,
315    /// Target entity type.
316    pub entity_type: String,
317    /// Optional target entity id.
318    pub entity_id: Option<i64>,
319    /// Optional target entity display name.
320    pub entity_name: Option<String>,
321    /// Optional product-owned JSON detail payload.
322    pub details: Option<String>,
323    /// Optional client IP.
324    pub ip_address: Option<String>,
325    /// Optional user-agent.
326    pub user_agent: Option<String>,
327    /// Row creation timestamp.
328    pub created_at: DateTimeUtc,
329}
330
331/// Audit log relations.
332#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
333pub enum Relation {}
334
335impl ActiveModelBehavior for ActiveModel {}
336
337/// Product-neutral request to insert one audit log row.
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub struct AuditLogCreate {
340    /// Actor user id. System events use `0`.
341    pub user_id: i64,
342    /// Stable action wire value.
343    pub action: String,
344    /// Target entity type.
345    pub entity_type: String,
346    /// Optional target entity id.
347    pub entity_id: Option<i64>,
348    /// Optional target entity display name.
349    pub entity_name: Option<String>,
350    /// Optional product-owned JSON detail payload.
351    pub details: Option<String>,
352    /// Optional client IP.
353    pub ip_address: Option<String>,
354    /// Optional user-agent.
355    pub user_agent: Option<String>,
356    /// Row creation timestamp.
357    pub created_at: DateTime<Utc>,
358}
359
360/// Product-neutral cursor query for audit logs sorted by `(created_at, id)` descending.
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub struct AuditLogQuery<'a> {
363    /// Optional actor user id filter.
364    pub user_id: Option<i64>,
365    /// Optional action wire value filter.
366    pub action: Option<&'a str>,
367    /// Optional entity type wire value filter.
368    pub entity_type: Option<&'a str>,
369    /// Optional entity id filter.
370    pub entity_id: Option<i64>,
371    /// Optional inclusive lower created-at bound.
372    pub after: Option<DateTime<Utc>>,
373    /// Optional inclusive upper created-at bound.
374    pub before: Option<DateTime<Utc>>,
375    /// Requested page size. Clamped to `1..=200`.
376    pub limit: u64,
377    /// Cursor from the previous page, encoded as `(created_at, id)`.
378    pub cursor: Option<(DateTime<Utc>, i64)>,
379}
380
381/// Result of an audit log cursor query.
382#[derive(Debug, Clone, PartialEq)]
383pub struct AuditLogCursorSlice {
384    /// Current page items.
385    pub items: Vec<Model>,
386    /// Total rows matching the filters before cursor slicing.
387    pub total: u64,
388    /// Whether another page exists after this slice.
389    pub has_more: bool,
390}
391
392impl AuditLogCursorSlice {
393    fn from_overfetch(mut items: Vec<Model>, total: u64, limit: u64) -> crate::Result<Self> {
394        let item_count = u64::try_from(items.len()).map_err(|_| {
395            crate::DbError::non_retryable("audit log cursor item count is too large")
396        })?;
397        let has_more = item_count > limit;
398        if has_more {
399            let target_len = usize::try_from(limit).map_err(|_| {
400                crate::DbError::non_retryable("audit log cursor limit is too large")
401            })?;
402            items.truncate(target_len);
403        }
404        Ok(Self {
405            items,
406            total,
407            has_more,
408        })
409    }
410}
411
412impl AuditLogCreate {
413    /// Converts the request into a validated `SeaORM` active model.
414    ///
415    /// # Errors
416    ///
417    /// Returns an error when the database operation fails.
418    pub fn into_active_model(self) -> crate::Result<ActiveModel> {
419        validate_create(&self)?;
420        Ok(ActiveModel {
421            id: sea_orm::ActiveValue::default(),
422            user_id: Set(self.user_id),
423            action: Set(self.action),
424            entity_type: Set(self.entity_type),
425            entity_id: Set(self.entity_id),
426            entity_name: Set(self.entity_name),
427            details: Set(self.details),
428            ip_address: Set(self.ip_address),
429            user_agent: Set(self.user_agent),
430            created_at: Set(self.created_at),
431        })
432    }
433}
434
435/// SeaORM-backed audit log store.
436#[derive(Debug, Clone)]
437pub struct AuditLogDbStore {
438    db: DatabaseConnection,
439}
440
441impl AuditLogDbStore {
442    /// Creates an audit log store from a `SeaORM` database connection.
443    #[must_use]
444    pub const fn new(db: DatabaseConnection) -> Self {
445        Self { db }
446    }
447
448    /// Inserts one audit log row.
449    ///
450    /// # Errors
451    ///
452    /// Returns an error when the database operation fails.
453    pub async fn create(&self, request: AuditLogCreate) -> crate::Result<Model> {
454        create_audit_log_row(&self.db, request).await
455    }
456
457    /// Inserts multiple already-built audit log active models.
458    ///
459    /// # Errors
460    ///
461    /// Returns an error when the database operation fails.
462    pub async fn create_many(&self, models: Vec<ActiveModel>) -> crate::Result<()> {
463        create_audit_log_rows(&self.db, models).await
464    }
465
466    /// Inserts multiple audit log create requests.
467    ///
468    /// # Errors
469    ///
470    /// Returns an error when the database operation fails.
471    pub async fn create_many_requests(&self, requests: Vec<AuditLogCreate>) -> crate::Result<()> {
472        create_audit_log_requests(&self.db, requests).await
473    }
474
475    /// Counts rows created in `[start, end)`.
476    ///
477    /// # Errors
478    ///
479    /// Returns an error when the database operation fails.
480    pub async fn count_created_between(
481        &self,
482        start: DateTime<Utc>,
483        end: DateTime<Utc>,
484    ) -> crate::Result<u64> {
485        count_audit_logs_created_between(&self.db, start, end).await
486    }
487
488    /// Counts rows for any of the supplied action wire values in `[start, end)`.
489    ///
490    /// # Errors
491    ///
492    /// Returns an error when the database operation fails.
493    pub async fn count_created_between_with_actions(
494        &self,
495        start: DateTime<Utc>,
496        end: DateTime<Utc>,
497        actions: &[&str],
498    ) -> crate::Result<u64> {
499        count_audit_logs_created_between_with_actions(&self.db, start, end, actions).await
500    }
501
502    /// Deletes rows created before the supplied cutoff.
503    ///
504    /// # Errors
505    ///
506    /// Returns an error when the database operation fails.
507    pub async fn delete_before(&self, before: DateTime<Utc>) -> crate::Result<u64> {
508        delete_audit_logs_before(&self.db, before).await
509    }
510
511    /// Finds audit logs with shared cursor filtering.
512    ///
513    /// # Errors
514    ///
515    /// Returns an error when the database operation fails.
516    pub async fn find_with_filters_cursor(
517        &self,
518        query: AuditLogQuery<'_>,
519    ) -> crate::Result<AuditLogCursorSlice> {
520        find_audit_logs_with_filters_cursor(&self.db, query).await
521    }
522
523    /// Counts distinct positive user ids for any supplied action wire value in `[start, end)`.
524    ///
525    /// # Errors
526    ///
527    /// Returns an error when the database operation fails.
528    pub async fn count_distinct_users_created_between_with_actions(
529        &self,
530        start: DateTime<Utc>,
531        end: DateTime<Utc>,
532        actions: &[&str],
533    ) -> crate::Result<u64> {
534        count_distinct_audit_log_users_created_between_with_actions(&self.db, start, end, actions)
535            .await
536    }
537}
538
539/// Inserts one audit log row using any `SeaORM` connection or transaction.
540///
541/// # Errors
542///
543/// Returns an error when the database operation fails.
544pub async fn create_audit_log_row<C>(db: &C, request: AuditLogCreate) -> crate::Result<Model>
545where
546    C: ConnectionTrait,
547{
548    request
549        .into_active_model()?
550        .insert(db)
551        .await
552        .map_err(crate::DbError::from)
553}
554
555/// Inserts many validated audit log create requests.
556///
557/// # Errors
558///
559/// Returns an error when the database operation fails.
560pub async fn create_audit_log_requests<C>(
561    db: &C,
562    requests: Vec<AuditLogCreate>,
563) -> crate::Result<()>
564where
565    C: ConnectionTrait,
566{
567    if requests.is_empty() {
568        return Ok(());
569    }
570    let models = requests
571        .into_iter()
572        .map(AuditLogCreate::into_active_model)
573        .collect::<crate::Result<Vec<_>>>()?;
574    create_audit_log_rows(db, models).await
575}
576
577/// Inserts many audit log active models.
578///
579/// # Errors
580///
581/// Returns an error when the database operation fails.
582pub async fn create_audit_log_rows<C>(db: &C, models: Vec<ActiveModel>) -> crate::Result<()>
583where
584    C: ConnectionTrait,
585{
586    if models.is_empty() {
587        return Ok(());
588    }
589    Entity::insert_many(models)
590        .exec(db)
591        .await
592        .map_err(crate::DbError::from)?;
593    Ok(())
594}
595
596/// Counts audit log rows created in `[start, end)`.
597///
598/// # Errors
599///
600/// Returns an error when the database operation fails.
601pub async fn count_audit_logs_created_between<C>(
602    db: &C,
603    start: DateTime<Utc>,
604    end: DateTime<Utc>,
605) -> crate::Result<u64>
606where
607    C: ConnectionTrait,
608{
609    Entity::find()
610        .filter(Column::CreatedAt.gte(start))
611        .filter(Column::CreatedAt.lt(end))
612        .count(db)
613        .await
614        .map_err(crate::DbError::from)
615}
616
617/// Counts audit log rows for any of the supplied action wire values in `[start, end)`.
618///
619/// # Errors
620///
621/// Returns an error when the database operation fails.
622pub async fn count_audit_logs_created_between_with_actions<C>(
623    db: &C,
624    start: DateTime<Utc>,
625    end: DateTime<Utc>,
626    actions: &[&str],
627) -> crate::Result<u64>
628where
629    C: ConnectionTrait,
630{
631    if actions.is_empty() {
632        return Ok(0);
633    }
634    Entity::find()
635        .filter(Column::CreatedAt.gte(start))
636        .filter(Column::CreatedAt.lt(end))
637        .filter(Column::Action.is_in(actions.iter().copied()))
638        .count(db)
639        .await
640        .map_err(crate::DbError::from)
641}
642
643/// Counts distinct positive user ids for any supplied action wire value in `[start, end)`.
644///
645/// # Errors
646///
647/// Returns an error when the database operation fails.
648pub async fn count_distinct_audit_log_users_created_between_with_actions<C>(
649    db: &C,
650    start: DateTime<Utc>,
651    end: DateTime<Utc>,
652    actions: &[&str],
653) -> crate::Result<u64>
654where
655    C: ConnectionTrait,
656{
657    if actions.is_empty() {
658        return Ok(0);
659    }
660    let count = Entity::find()
661        .select_only()
662        .column_as(
663            Expr::col(Column::UserId).count_distinct(),
664            "distinct_user_count",
665        )
666        .filter(Column::CreatedAt.gte(start))
667        .filter(Column::CreatedAt.lt(end))
668        .filter(Column::Action.is_in(actions.iter().copied()))
669        .filter(Column::UserId.gt(0))
670        .into_tuple::<i64>()
671        .one(db)
672        .await
673        .map_err(crate::DbError::from)?
674        .unwrap_or(0);
675
676    u64::try_from(count)
677        .map_err(|_| crate::DbError::non_retryable("distinct audit log user count is negative"))
678}
679
680/// Finds audit logs with shared cursor filtering.
681///
682/// # Errors
683///
684/// Returns an error when the database operation fails.
685pub async fn find_audit_logs_with_filters_cursor<C>(
686    db: &C,
687    query: AuditLogQuery<'_>,
688) -> crate::Result<AuditLogCursorSlice>
689where
690    C: ConnectionTrait,
691{
692    let mut statement = Entity::find();
693    let limit = query.limit.clamp(1, 200);
694
695    if let Some(user_id) = query.user_id {
696        statement = statement.filter(Column::UserId.eq(user_id));
697    }
698    if let Some(action) = query.action {
699        statement = statement.filter(Column::Action.eq(action));
700    }
701    if let Some(entity_type) = query.entity_type {
702        statement = statement.filter(Column::EntityType.eq(entity_type));
703    }
704    if let Some(entity_id) = query.entity_id {
705        statement = statement.filter(Column::EntityId.eq(entity_id));
706    }
707    if let Some(after) = query.after {
708        statement = statement.filter(Column::CreatedAt.gte(after));
709    }
710    if let Some(before) = query.before {
711        statement = statement.filter(Column::CreatedAt.lte(before));
712    }
713
714    let total = statement
715        .clone()
716        .count(db)
717        .await
718        .map_err(crate::DbError::from)?;
719    if let Some((created_at, id)) = query.cursor {
720        statement = statement.filter(
721            Condition::any().add(Column::CreatedAt.lt(created_at)).add(
722                Condition::all()
723                    .add(Column::CreatedAt.eq(created_at))
724                    .add(Column::Id.lt(id)),
725            ),
726        );
727    }
728
729    let items = statement
730        .order_by_desc(Column::CreatedAt)
731        .order_by_desc(Column::Id)
732        .limit(limit.saturating_add(1))
733        .all(db)
734        .await
735        .map_err(crate::DbError::from)?;
736    AuditLogCursorSlice::from_overfetch(items, total, limit)
737}
738
739/// Deletes audit log rows created before the supplied cutoff.
740///
741/// # Errors
742///
743/// Returns an error when the database operation fails.
744pub async fn delete_audit_logs_before<C>(db: &C, before: DateTime<Utc>) -> crate::Result<u64>
745where
746    C: ConnectionTrait,
747{
748    let result = Entity::delete_many()
749        .filter(Column::CreatedAt.lt(before))
750        .exec(db)
751        .await
752        .map_err(crate::DbError::from)?;
753    Ok(result.rows_affected)
754}
755
756fn validate_create(request: &AuditLogCreate) -> crate::Result<()> {
757    if request.user_id < 0 {
758        return Err(crate::DbError::non_retryable(
759            "audit log user id must be non-negative",
760        ));
761    }
762    validate_non_empty("audit log action", &request.action)?;
763    validate_max_len(
764        "audit log action",
765        &request.action,
766        AUDIT_LOG_ACTION_MAX_BYTES,
767    )?;
768    validate_non_empty("audit log entity type", &request.entity_type)?;
769    validate_max_len(
770        "audit log entity type",
771        &request.entity_type,
772        AUDIT_LOG_ENTITY_TYPE_MAX_BYTES,
773    )?;
774    if let Some(value) = &request.entity_name {
775        validate_max_len(
776            "audit log entity name",
777            value,
778            AUDIT_LOG_ENTITY_NAME_MAX_BYTES,
779        )?;
780    }
781    if let Some(value) = &request.ip_address {
782        validate_max_len(
783            "audit log ip address",
784            value,
785            AUDIT_LOG_IP_ADDRESS_MAX_BYTES,
786        )?;
787    }
788    if let Some(value) = &request.user_agent {
789        validate_max_len(
790            "audit log user agent",
791            value,
792            AUDIT_LOG_USER_AGENT_MAX_BYTES,
793        )?;
794    }
795    Ok(())
796}
797
798fn validate_non_empty(name: &str, value: &str) -> crate::Result<()> {
799    if value.trim().is_empty() {
800        return Err(crate::DbError::non_retryable(format!(
801            "{name} cannot be empty"
802        )));
803    }
804    Ok(())
805}
806
807fn validate_max_len(name: &str, value: &str, max_len: usize) -> crate::Result<()> {
808    if value.len() > max_len {
809        return Err(crate::DbError::non_retryable(format!(
810            "{name} must be at most {max_len} bytes",
811        )));
812    }
813    Ok(())
814}
815
816#[cfg(test)]
817mod tests {
818    use chrono::{Duration, Utc};
819    use sea_orm::sea_query::SqliteQueryBuilder;
820    use sea_orm::{ConnectionTrait, DbBackend, EntityTrait, Set};
821
822    use super::{
823        AUDIT_LOG_ACTION_CREATED_USER_INDEX, AUDIT_LOG_CREATED_ID_INDEX,
824        AUDIT_LOG_ENTITY_TYPE_CREATED_ID_INDEX, AUDIT_LOG_USER_CREATED_ID_INDEX, ActiveModel,
825        AuditLogCreate, AuditLogDbStore, AuditLogQuery, Entity, create_audit_log_rows,
826        create_audit_logs_action_created_user_index, create_audit_logs_base_indexes,
827        create_audit_logs_created_id_index, create_audit_logs_entity_type_created_id_index,
828        create_audit_logs_query_indexes, create_audit_logs_table,
829        create_audit_logs_user_created_id_index,
830    };
831    use crate::DatabaseErrorKind;
832
833    async fn sqlite_store() -> AuditLogDbStore {
834        let db = sea_orm::Database::connect("sqlite::memory:")
835            .await
836            .expect("audit log test database should connect");
837        db.execute(&create_audit_logs_table(DbBackend::Sqlite))
838            .await
839            .expect("audit logs table builder should execute");
840        for index in create_audit_logs_base_indexes() {
841            db.execute(&index)
842                .await
843                .expect("audit logs base index builder should execute");
844        }
845        for index in create_audit_logs_query_indexes() {
846            db.execute(&index)
847                .await
848                .expect("audit logs query index builder should execute");
849        }
850        AuditLogDbStore::new(db)
851    }
852
853    fn create_request(created_at: chrono::DateTime<Utc>, action: &str) -> AuditLogCreate {
854        AuditLogCreate {
855            user_id: 42,
856            action: action.to_string(),
857            entity_type: "system".to_string(),
858            entity_id: Some(7),
859            entity_name: Some("server".to_string()),
860            details: Some(r#"{"ok":true}"#.to_string()),
861            ip_address: Some("127.0.0.1".to_string()),
862            user_agent: Some("test".to_string()),
863            created_at,
864        }
865    }
866
867    #[tokio::test]
868    async fn create_audit_log_rows_preserves_driver_error_classification() {
869        let db = sea_orm::Database::connect("sqlite::memory:")
870            .await
871            .expect("audit log test database should connect");
872        db.execute(&create_audit_logs_table(DbBackend::Sqlite))
873            .await
874            .expect("audit logs table builder should execute");
875
876        let duplicate_row = || {
877            let mut model = create_request(Utc::now(), "audit.test")
878                .into_active_model()
879                .expect("create request should convert into an active model");
880            model.id = Set(1);
881            model
882        };
883
884        create_audit_log_rows(&db, vec![duplicate_row()])
885            .await
886            .expect("first insert should succeed");
887        let error = create_audit_log_rows(&db, vec![duplicate_row()])
888            .await
889            .expect_err("re-inserting the same id must violate the primary key");
890
891        // The helper must keep the driver-native classification (unique violation)
892        // instead of flattening the error into an unclassified operation failure.
893        assert_eq!(
894            error.database_error_kind(),
895            Some(DatabaseErrorKind::UniqueConstraint)
896        );
897    }
898
899    #[test]
900    fn create_audit_logs_table_uses_stable_shape() {
901        let sql = create_audit_logs_table(DbBackend::Sqlite).to_string(SqliteQueryBuilder);
902        assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"audit_logs\""));
903        assert!(sql.contains("\"user_id\""));
904        assert!(sql.contains("DEFAULT"));
905        assert!(sql.contains("\"action\""));
906        assert!(sql.contains("\"entity_type\""));
907        assert!(sql.contains("\"ip_address\""));
908
909        assert!(
910            create_audit_logs_action_created_user_index()
911                .to_string(SqliteQueryBuilder)
912                .contains(AUDIT_LOG_ACTION_CREATED_USER_INDEX)
913        );
914        assert!(
915            create_audit_logs_created_id_index()
916                .to_string(SqliteQueryBuilder)
917                .contains(AUDIT_LOG_CREATED_ID_INDEX)
918        );
919        assert!(
920            create_audit_logs_user_created_id_index()
921                .to_string(SqliteQueryBuilder)
922                .contains(AUDIT_LOG_USER_CREATED_ID_INDEX)
923        );
924        assert!(
925            create_audit_logs_entity_type_created_id_index()
926                .to_string(SqliteQueryBuilder)
927                .contains(AUDIT_LOG_ENTITY_TYPE_CREATED_ID_INDEX)
928        );
929    }
930
931    #[tokio::test]
932    async fn audit_log_store_creates_counts_and_deletes_rows() {
933        let store = sqlite_store().await;
934        let now = Utc::now();
935
936        let created = store
937            .create(create_request(now, "server_shutdown"))
938            .await
939            .expect("audit log row should insert");
940        assert_eq!(created.user_id, 42);
941        assert_eq!(created.action, "server_shutdown");
942
943        let count = store
944            .count_created_between(now - Duration::seconds(1), now + Duration::seconds(1))
945            .await
946            .expect("audit log count should succeed");
947        assert_eq!(count, 1);
948
949        let action_count = store
950            .count_created_between_with_actions(
951                now - Duration::seconds(1),
952                now + Duration::seconds(1),
953                &["server_shutdown"],
954            )
955            .await
956            .expect("audit log action count should succeed");
957        assert_eq!(action_count, 1);
958
959        let deleted = store
960            .delete_before(now + Duration::seconds(1))
961            .await
962            .expect("audit log delete should succeed");
963        assert_eq!(deleted, 1);
964    }
965
966    #[tokio::test]
967    async fn audit_log_store_filters_cursor_pages_and_counts_distinct_users() {
968        let store = sqlite_store().await;
969        let base = Utc::now();
970
971        store
972            .create_many_requests(vec![
973                AuditLogCreate {
974                    user_id: 1,
975                    created_at: base - Duration::seconds(3),
976                    ..create_request(base - Duration::seconds(3), "user_login")
977                },
978                AuditLogCreate {
979                    user_id: 1,
980                    created_at: base - Duration::seconds(2),
981                    ..create_request(base - Duration::seconds(2), "user_login")
982                },
983                AuditLogCreate {
984                    user_id: 2,
985                    entity_type: "profile".to_string(),
986                    created_at: base - Duration::seconds(1),
987                    ..create_request(base - Duration::seconds(1), "profile_update")
988                },
989                AuditLogCreate {
990                    user_id: 0,
991                    created_at: base,
992                    ..create_request(base, "user_login")
993                },
994            ])
995            .await
996            .expect("audit query fixtures should insert");
997
998        let first_page = store
999            .find_with_filters_cursor(AuditLogQuery {
1000                user_id: None,
1001                action: Some("user_login"),
1002                entity_type: None,
1003                entity_id: None,
1004                after: Some(base - Duration::seconds(10)),
1005                before: Some(base + Duration::seconds(1)),
1006                limit: 2,
1007                cursor: None,
1008            })
1009            .await
1010            .expect("first audit log page should query");
1011        assert_eq!(first_page.total, 3);
1012        assert!(first_page.has_more);
1013        assert_eq!(first_page.items.len(), 2);
1014        assert_eq!(first_page.items[0].user_id, 0);
1015
1016        let cursor = first_page
1017            .items
1018            .last()
1019            .map(|item| (item.created_at, item.id))
1020            .expect("first page should have a cursor item");
1021        let second_page = store
1022            .find_with_filters_cursor(AuditLogQuery {
1023                user_id: None,
1024                action: Some("user_login"),
1025                entity_type: None,
1026                entity_id: None,
1027                after: Some(base - Duration::seconds(10)),
1028                before: Some(base + Duration::seconds(1)),
1029                limit: 2,
1030                cursor: Some(cursor),
1031            })
1032            .await
1033            .expect("second audit log page should query");
1034        assert!(!second_page.has_more);
1035        assert_eq!(second_page.items.len(), 1);
1036
1037        let distinct = store
1038            .count_distinct_users_created_between_with_actions(
1039                base - Duration::seconds(10),
1040                base + Duration::seconds(1),
1041                &["user_login", "profile_update"],
1042            )
1043            .await
1044            .expect("distinct user count should query");
1045        assert_eq!(distinct, 2);
1046    }
1047
1048    #[tokio::test]
1049    async fn audit_log_store_rejects_invalid_create_values() {
1050        let store = sqlite_store().await;
1051        let error = store
1052            .create(AuditLogCreate {
1053                action: String::new(),
1054                ..create_request(Utc::now(), "server_shutdown")
1055            })
1056            .await
1057            .expect_err("empty action should be rejected");
1058        assert!(error.to_string().contains("audit log action"));
1059    }
1060
1061    #[tokio::test]
1062    async fn audit_log_create_many_accepts_empty_and_inserts_batch() {
1063        let store = sqlite_store().await;
1064        store
1065            .create_many(Vec::new())
1066            .await
1067            .expect("empty batch should be accepted");
1068
1069        let now = Utc::now();
1070        store
1071            .create_many(vec![
1072                ActiveModel {
1073                    id: sea_orm::ActiveValue::default(),
1074                    user_id: Set(1),
1075                    action: Set("a".to_string()),
1076                    entity_type: Set("system".to_string()),
1077                    entity_id: Set(None),
1078                    entity_name: Set(None),
1079                    details: Set(None),
1080                    ip_address: Set(None),
1081                    user_agent: Set(None),
1082                    created_at: Set(now),
1083                },
1084                ActiveModel {
1085                    id: sea_orm::ActiveValue::default(),
1086                    user_id: Set(2),
1087                    action: Set("b".to_string()),
1088                    entity_type: Set("system".to_string()),
1089                    entity_id: Set(None),
1090                    entity_name: Set(None),
1091                    details: Set(None),
1092                    ip_address: Set(None),
1093                    user_agent: Set(None),
1094                    created_at: Set(now),
1095                },
1096            ])
1097            .await
1098            .expect("batch insert should succeed");
1099
1100        let db = store.db.clone();
1101        let rows = Entity::find()
1102            .all(&db)
1103            .await
1104            .expect("audit log rows should query");
1105        assert_eq!(rows.len(), 2);
1106    }
1107
1108    #[tokio::test]
1109    async fn audit_log_create_many_requests_validates_and_inserts_batch() {
1110        let store = sqlite_store().await;
1111        let now = Utc::now();
1112
1113        store
1114            .create_many_requests(vec![
1115                create_request(now, "server_start"),
1116                create_request(now, "server_shutdown"),
1117            ])
1118            .await
1119            .expect("request batch insert should succeed");
1120
1121        let count = store
1122            .count_created_between(now - Duration::seconds(1), now + Duration::seconds(1))
1123            .await
1124            .expect("audit log count should succeed");
1125        assert_eq!(count, 2);
1126
1127        let error = store
1128            .create_many_requests(vec![AuditLogCreate {
1129                action: " ".to_string(),
1130                ..create_request(now, "server_shutdown")
1131            }])
1132            .await
1133            .expect_err("invalid request batch should be rejected");
1134        assert!(error.to_string().contains("audit log action"));
1135    }
1136
1137    #[tokio::test]
1138    async fn audit_logs_builders_execute_on_sqlite_connection() {
1139        let db = sea_orm::Database::connect("sqlite::memory:")
1140            .await
1141            .expect("audit log builder test database should connect");
1142        db.execute(&create_audit_logs_table(DbBackend::Sqlite))
1143            .await
1144            .expect("audit logs table builder should execute");
1145        db.execute(&create_audit_logs_created_id_index())
1146            .await
1147            .expect("audit log index builder should execute");
1148    }
1149}