1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use tandem_types::TenantContext;
4use tokio::fs::{self, OpenOptions};
5use tokio::io::AsyncWriteExt;
6use uuid::Uuid;
7
8use crate::{now_ms, AppState};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum AuditDurability {
13 BestEffort,
14 DurableRequired,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ProtectedAuditEnvelope {
19 pub event_id: String,
20 pub durability: AuditDurability,
21 pub event_type: String,
22 #[serde(default)]
23 pub tenant_context: TenantContext,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub actor: Option<String>,
26 pub payload: Value,
27 pub created_at_ms: u64,
28}
29
30pub async fn append_protected_audit_event(
31 state: &AppState,
32 event_type: impl Into<String>,
33 tenant_context: &TenantContext,
34 actor: Option<String>,
35 payload: Value,
36) -> anyhow::Result<()> {
37 let path = state.protected_audit_path.clone();
38 if let Some(parent) = path.parent() {
39 fs::create_dir_all(parent).await?;
40 }
41 let row = ProtectedAuditEnvelope {
42 event_id: Uuid::new_v4().to_string(),
43 durability: AuditDurability::DurableRequired,
44 event_type: event_type.into(),
45 tenant_context: tenant_context.clone(),
46 actor,
47 payload,
48 created_at_ms: now_ms(),
49 };
50 let mut file = OpenOptions::new()
51 .create(true)
52 .append(true)
53 .open(&path)
54 .await?;
55 file.write_all(serde_json::to_string(&row)?.as_bytes())
56 .await?;
57 file.write_all(b"\n").await?;
58 file.flush().await?;
59 Ok(())
60}