feat(domain): add audit value types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// What kind of change an audit entry records.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuditAction {
|
||||
Created,
|
||||
Updated,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
impl AuditAction {
|
||||
/// The database/text representation.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
AuditAction::Created => "created",
|
||||
AuditAction::Updated => "updated",
|
||||
AuditAction::Deleted => "deleted",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse from the database/text representation.
|
||||
pub fn from_db(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"created" => Some(AuditAction::Created),
|
||||
"updated" => Some(AuditAction::Updated),
|
||||
"deleted" => Some(AuditAction::Deleted),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Who performed the change.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "kind", content = "id")]
|
||||
pub enum AuditActor {
|
||||
/// A specific user, referenced by id (a `UserId` newtype arrives with auth).
|
||||
User(Uuid),
|
||||
/// The system itself (migrations, automated processes).
|
||||
System,
|
||||
}
|
||||
|
||||
/// One field's before/after values within a change.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FieldChange {
|
||||
/// Field name (catalogue field key or column name).
|
||||
pub field: String,
|
||||
/// Value before the change (None when newly set).
|
||||
pub before: Option<Value>,
|
||||
/// Value after the change (None when cleared).
|
||||
pub after: Option<Value>,
|
||||
}
|
||||
|
||||
/// An audit event to be recorded.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct NewAuditEvent {
|
||||
pub actor: AuditActor,
|
||||
pub action: AuditAction,
|
||||
pub entity_type: String,
|
||||
pub entity_id: Uuid,
|
||||
pub changes: Vec<FieldChange>,
|
||||
}
|
||||
|
||||
/// A recorded audit entry, read back from the log.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AuditEntry {
|
||||
/// Monotonic sequence number (insertion order).
|
||||
pub seq: i64,
|
||||
/// When it was recorded.
|
||||
pub at: OffsetDateTime,
|
||||
pub actor: AuditActor,
|
||||
pub action: AuditAction,
|
||||
pub entity_type: String,
|
||||
pub entity_id: Uuid,
|
||||
pub changes: Vec<FieldChange>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn action_round_trips_via_db_string() {
|
||||
for a in [
|
||||
AuditAction::Created,
|
||||
AuditAction::Updated,
|
||||
AuditAction::Deleted,
|
||||
] {
|
||||
assert_eq!(AuditAction::from_db(a.as_str()), Some(a));
|
||||
}
|
||||
assert_eq!(AuditAction::from_db("bogus"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_change_serde_round_trip() {
|
||||
let fc = FieldChange {
|
||||
field: "name".into(),
|
||||
before: Some(json!("Vase")),
|
||||
after: Some(json!("Roman Vase")),
|
||||
};
|
||||
let v = serde_json::to_value(&fc).unwrap();
|
||||
assert_eq!(v["field"], "name");
|
||||
assert_eq!(v["before"], "Vase");
|
||||
assert_eq!(v["after"], "Roman Vase");
|
||||
let back: FieldChange = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, fc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actor_is_adjacently_tagged() {
|
||||
let v = serde_json::to_value(AuditActor::User(Uuid::nil())).unwrap();
|
||||
assert_eq!(v["kind"], "user");
|
||||
let v2 = serde_json::to_value(AuditActor::System).unwrap();
|
||||
assert_eq!(v2["kind"], "system");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user