1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
//! Activity log domain models
//!
//! An activity log is an append-only history of changes to a subject record —
//! a sales order, fulfillment order, shipment, or any other entity. Each entry
//! captures what changed (`action`), a human-readable `summary`, the `actor`
//! responsible, and arbitrary structured `metadata`.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use stateset_primitives::ActivityLogId;
use strum::{Display, EnumString};
use uuid::Uuid;
/// The kind of actor that produced an activity log entry.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum ActorKind {
/// A human user.
#[default]
User,
/// An automated system process.
System,
/// An external integration / API caller.
Integration,
/// An autonomous agent (A2A commerce).
Agent,
}
/// A single append-only activity log entry for a subject record.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivityLogEntry {
/// Unique entry ID.
pub id: ActivityLogId,
/// Subject record type (e.g. `sales_order`, `fulfillment_order`, `shipment`).
pub subject_type: String,
/// Subject record ID.
pub subject_id: Uuid,
/// Machine action key (e.g. `status_changed`, `field_edited`, `created`).
pub action: String,
/// Human-readable summary of the change.
pub summary: String,
/// What kind of actor produced this entry.
pub actor_kind: ActorKind,
/// Optional actor identifier (user id, integration name, agent id).
pub actor: Option<String>,
/// Arbitrary structured metadata (e.g. before/after values).
pub metadata: serde_json::Value,
/// When the entry was recorded.
pub created_at: DateTime<Utc>,
}
impl ActivityLogEntry {
/// A display label for the actor, falling back to the actor kind.
#[must_use]
pub fn actor_label(&self) -> String {
match &self.actor {
Some(a) if !a.is_empty() => a.clone(),
_ => self.actor_kind.to_string(),
}
}
}
/// Input for recording an activity log entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordActivity {
/// Subject record type.
pub subject_type: String,
/// Subject record ID.
pub subject_id: Uuid,
/// Machine action key.
pub action: String,
/// Human-readable summary.
pub summary: String,
/// Actor kind (defaults to `System`).
#[serde(default)]
pub actor_kind: ActorKind,
/// Optional actor identifier.
pub actor: Option<String>,
/// Structured metadata.
#[serde(default)]
pub metadata: serde_json::Value,
}
/// Filter for listing activity log entries.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ActivityLogFilter {
/// Filter by subject type.
pub subject_type: Option<String>,
/// Filter by subject ID.
pub subject_id: Option<Uuid>,
/// Filter by action key.
pub action: Option<String>,
/// Filter by actor kind.
pub actor_kind: Option<ActorKind>,
/// Maximum results.
pub limit: Option<u32>,
/// Offset for pagination.
pub offset: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
fn make(actor_kind: ActorKind, actor: Option<&str>) -> ActivityLogEntry {
ActivityLogEntry {
id: ActivityLogId::new(),
subject_type: "sales_order".into(),
subject_id: Uuid::nil(),
action: "status_changed".into(),
summary: "Status changed from pending to shipped".into(),
actor_kind,
actor: actor.map(String::from),
metadata: serde_json::json!({"from": "pending", "to": "shipped"}),
created_at: Utc::now(),
}
}
#[test]
fn actor_label_prefers_actor() {
assert_eq!(make(ActorKind::User, Some("alice@x.test")).actor_label(), "alice@x.test");
}
#[test]
fn actor_label_falls_back_to_kind() {
assert_eq!(make(ActorKind::System, None).actor_label(), "system");
assert_eq!(make(ActorKind::Agent, Some("")).actor_label(), "agent");
}
#[test]
fn actor_kind_roundtrip() {
for k in [ActorKind::User, ActorKind::System, ActorKind::Integration, ActorKind::Agent] {
assert_eq!(k.to_string().parse::<ActorKind>().unwrap(), k);
}
}
#[test]
fn actor_kind_default_is_user() {
assert_eq!(ActorKind::default(), ActorKind::User);
}
}