feat(domain): add audit value types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 07:40:02 +02:00
parent d3f5e73dad
commit 0447284d43
4 changed files with 125 additions and 0 deletions
+1
View File
@@ -13,6 +13,7 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres",
uuid = { version = "1", features = ["v4", "serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
time = { version = "0.3.44", features = ["serde"] }
clap = { version = "4", features = ["derive", "env"] }
utoipa = { version = "5", features = ["uuid"] }
anyhow = "1"
+2
View File
@@ -7,3 +7,5 @@ rust-version.workspace = true
[dependencies]
uuid.workspace = true
serde.workspace = true
serde_json.workspace = true
time.workspace = true
+120
View File
@@ -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");
}
}
+2
View File
@@ -1,5 +1,7 @@
//! Core domain types and invariants. No I/O dependencies.
mod audit;
mod id;
pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent};
pub use id::OrgId;