Skip to main content

sova_activity/
record.rs

1//! Best-effort activity inserts.
2
3use crate::entity;
4use chrono::Utc;
5use sova_core::{ClientAddr, Request};
6use sova_db::{DbExt, DbHandle};
7use sea_orm::{ActiveModelTrait, Set};
8use serde_json::Value;
9
10/// One activity row to persist.
11#[derive(Debug, Clone)]
12pub struct ActivityEntry {
13    pub actor_id: Option<i64>,
14    pub subject_type: String,
15    pub subject_id: String,
16    pub event: String,
17    pub properties: Value,
18    pub ip: Option<String>,
19    pub user_agent: Option<String>,
20}
21
22/// Optional actor id attached to the request (e.g. by auth middleware).
23#[derive(Clone, Copy, Debug)]
24pub struct ActivityActor(pub i64);
25
26/// Marker in app state — installed by [`super::Activity`] plugin.
27#[derive(Clone, Debug, Default)]
28pub struct ActivityLog;
29
30impl ActivityLog {
31    /// Insert a row; logs a warning on failure and never panics the caller.
32    pub async fn record(db: &DbHandle, entry: ActivityEntry) {
33        let props = serde_json::to_string(&entry.properties).unwrap_or_else(|_| "{}".into());
34        let model = entity::ActiveModel {
35            actor_id: Set(entry.actor_id),
36            subject_type: Set(entry.subject_type),
37            subject_id: Set(entry.subject_id),
38            event: Set(entry.event),
39            properties: Set(props),
40            ip: Set(entry.ip),
41            user_agent: Set(entry.user_agent),
42            created_at: Set(Utc::now()),
43            ..Default::default()
44        };
45        if let Err(e) = model.insert(db).await {
46            tracing::warn!(error = %e, "activity_log insert failed");
47        }
48    }
49}
50
51/// Request helper for activity logging.
52pub trait ActivityExt {
53    fn activity_enabled(&self) -> bool;
54
55    #[allow(async_fn_in_trait)]
56    async fn log_activity(
57        &self,
58        event: &str,
59        subject_type: &str,
60        subject_id: impl ToString,
61        properties: Value,
62    );
63
64    #[allow(async_fn_in_trait)]
65    async fn log_activity_as(
66        &self,
67        actor_id: Option<i64>,
68        event: &str,
69        subject_type: &str,
70        subject_id: impl ToString,
71        properties: Value,
72    );
73}
74
75impl ActivityExt for Request {
76    fn activity_enabled(&self) -> bool {
77        self.try_state::<ActivityLog>().is_some()
78    }
79
80    async fn log_activity(
81        &self,
82        event: &str,
83        subject_type: &str,
84        subject_id: impl ToString,
85        properties: Value,
86    ) {
87        let actor = self.get::<ActivityActor>().map(|a| a.0);
88        self.log_activity_as(actor, event, subject_type, subject_id, properties)
89            .await;
90    }
91
92    async fn log_activity_as(
93        &self,
94        actor_id: Option<i64>,
95        event: &str,
96        subject_type: &str,
97        subject_id: impl ToString,
98        properties: Value,
99    ) {
100        if !self.activity_enabled() {
101            return;
102        }
103        let ip = self
104            .get::<ClientAddr>()
105            .map(|a| a.0.ip().to_string())
106            .or_else(|| {
107                self.header("x-forwarded-for")
108                    .and_then(|v| v.split(',').next().map(|s| s.trim().to_string()))
109            });
110        let user_agent = self.header("user-agent").map(str::to_string);
111        ActivityLog::record(
112            self.db(),
113            ActivityEntry {
114                actor_id,
115                subject_type: subject_type.into(),
116                subject_id: subject_id.to_string(),
117                event: event.into(),
118                properties,
119                ip,
120                user_agent,
121            },
122        )
123        .await;
124    }
125}