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}