Skip to main content

platform_core/
admin_actions.rs

1use crate::context::{ActorContext, RequestContext};
2use crate::db::DbPool;
3use crate::error::{AppError, AppResult, ErrorCode};
4use chrono::{DateTime, Duration, Utc};
5use serde_json::{Value, json};
6
7#[derive(Debug, Clone)]
8pub struct AdminActionStoryRecord {
9    pub module_name: String,
10    pub action_name: String,
11    pub label: String,
12    pub capability: String,
13    pub input: Value,
14    pub result: Option<Value>,
15    pub success: bool,
16    pub error_code: Option<String>,
17    pub error_message: Option<String>,
18    pub started_at: DateTime<Utc>,
19    pub duration_ms: i64,
20}
21
22pub async fn insert_admin_action_story_event(
23    pool: &DbPool,
24    request_ctx: &RequestContext,
25    record: AdminActionStoryRecord,
26) -> AppResult<String> {
27    let id = admin_action_story_event_id(request_ctx);
28    let completed_at = record.started_at + Duration::milliseconds(record.duration_ms.max(0));
29    let status = if record.success {
30        "completed"
31    } else {
32        "failed"
33    };
34
35    sqlx::query(
36        r#"
37        insert into platform.story_events (
38            id,
39            source_type,
40            source_id,
41            node_type,
42            name,
43            status,
44            service,
45            correlation_id,
46            causation_id,
47            started_at,
48            completed_at,
49            duration_ms,
50            error,
51            metadata,
52            trace_id,
53            span_id,
54            updated_at
55        )
56        values ($1, 'admin_action', $2, 'admin_action', $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $9)
57        on conflict (source_type, source_id) do update
58        set
59            name = excluded.name,
60            status = excluded.status,
61            service = excluded.service,
62            correlation_id = excluded.correlation_id,
63            causation_id = excluded.causation_id,
64            started_at = excluded.started_at,
65            completed_at = excluded.completed_at,
66            duration_ms = excluded.duration_ms,
67            error = excluded.error,
68            metadata = excluded.metadata,
69            trace_id = excluded.trace_id,
70            span_id = excluded.span_id,
71            updated_at = excluded.updated_at
72        "#,
73    )
74    .bind(&id)
75    .bind(&request_ctx.request_id.0)
76    .bind(&record.label)
77    .bind(status)
78    .bind(&record.module_name)
79    .bind(&request_ctx.correlation_id.0)
80    .bind(&request_ctx.causation_id)
81    .bind(record.started_at)
82    .bind(completed_at)
83    .bind(record.duration_ms)
84    .bind(record.error_message.clone())
85    .bind(admin_action_metadata(request_ctx, &record))
86    .bind(&request_ctx.trace.trace_id)
87    .bind(&request_ctx.trace.span_id)
88    .execute(pool)
89    .await
90    .map_err(map_admin_action_error)?;
91
92    Ok(id)
93}
94
95pub fn admin_action_story_event_id(request_ctx: &RequestContext) -> String {
96    format!("adminaction_{}", request_ctx.request_id.0)
97}
98
99fn admin_action_metadata(request_ctx: &RequestContext, record: &AdminActionStoryRecord) -> Value {
100    json!({
101        "module_name": &record.module_name,
102        "action_name": &record.action_name,
103        "label": &record.label,
104        "capability": &record.capability,
105        "duration_ms": record.duration_ms,
106        "request_id": request_ctx.request_id.0,
107        "trace_id": request_ctx.trace.trace_id,
108        "span_id": request_ctx.trace.span_id,
109        "actor_kind": actor_kind(&request_ctx.actor),
110        "success": record.success,
111        "error_code": &record.error_code,
112        "error_message": &record.error_message,
113        "input_summary": value_summary(&record.input),
114        "result_summary": record.result.as_ref().map(value_summary),
115    })
116}
117
118fn value_summary(value: &Value) -> String {
119    match value {
120        Value::Null => "null".to_owned(),
121        Value::Bool(value) => value.to_string(),
122        Value::Number(value) => value.to_string(),
123        Value::String(value) => truncate_summary(value),
124        Value::Array(items) => format!("{} items", items.len()),
125        Value::Object(entries) if entries.is_empty() => "{}".to_owned(),
126        Value::Object(entries) => truncate_summary(
127            &entries
128                .iter()
129                .take(4)
130                .map(|(key, value)| format!("{key}: {}", scalar_summary(value)))
131                .collect::<Vec<_>>()
132                .join(" / "),
133        ),
134    }
135}
136
137fn scalar_summary(value: &Value) -> String {
138    match value {
139        Value::Null => "null".to_owned(),
140        Value::Bool(value) => value.to_string(),
141        Value::Number(value) => value.to_string(),
142        Value::String(value) => value.clone(),
143        Value::Array(items) => format!("{} items", items.len()),
144        Value::Object(_) => "{...}".to_owned(),
145    }
146}
147
148fn truncate_summary(value: &str) -> String {
149    const LIMIT: usize = 160;
150    if value.chars().count() <= LIMIT {
151        return value.to_owned();
152    }
153    format!(
154        "{}...",
155        value
156            .chars()
157            .take(LIMIT.saturating_sub(3))
158            .collect::<String>()
159    )
160}
161
162fn actor_kind(actor: &ActorContext) -> &'static str {
163    match actor {
164        ActorContext::Anonymous => "anonymous",
165        ActorContext::User { .. } => "user",
166        ActorContext::Service { .. } => "service",
167        ActorContext::System => "system",
168    }
169}
170
171fn map_admin_action_error(source: sqlx::Error) -> AppError {
172    AppError::new(ErrorCode::Internal, "Admin action story operation failed").with_source(source)
173}