use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalSourceKind {
AutomationV2,
Coder,
Workflow,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalDecision {
Approve,
Rework,
Cancel,
}
impl ApprovalDecision {
pub fn as_str(&self) -> &'static str {
match self {
ApprovalDecision::Approve => "approve",
ApprovalDecision::Rework => "rework",
ApprovalDecision::Cancel => "cancel",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalRequest {
pub request_id: String,
pub source: ApprovalSourceKind,
pub tenant: ApprovalTenantRef,
pub run_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action_kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action_preview_markdown: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub surface_payload: Option<serde_json::Value>,
pub requested_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at_ms: Option<u64>,
#[serde(default)]
pub decisions: Vec<ApprovalDecision>,
#[serde(default)]
pub rework_targets: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decided_by: Option<ApprovalActorRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decided_at_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decision: Option<ApprovalDecision>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rework_feedback: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ApprovalTenantRef {
pub org_id: String,
pub workspace_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalActorRef {
pub surface: String,
pub surface_user_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalDecisionInput {
pub decision: ApprovalDecision,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor: Option<ApprovalActorRef>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ApprovalListFilter {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub org_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<ApprovalSourceKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn approval_decision_serializes_lowercase() {
assert_eq!(
serde_json::to_string(&ApprovalDecision::Approve).unwrap(),
"\"approve\""
);
assert_eq!(
serde_json::to_string(&ApprovalDecision::Rework).unwrap(),
"\"rework\""
);
assert_eq!(
serde_json::to_string(&ApprovalDecision::Cancel).unwrap(),
"\"cancel\""
);
}
#[test]
fn approval_source_serializes_snake_case() {
assert_eq!(
serde_json::to_string(&ApprovalSourceKind::AutomationV2).unwrap(),
"\"automation_v2\""
);
assert_eq!(
serde_json::to_string(&ApprovalSourceKind::Coder).unwrap(),
"\"coder\""
);
assert_eq!(
serde_json::to_string(&ApprovalSourceKind::Workflow).unwrap(),
"\"workflow\""
);
}
#[test]
fn approval_request_round_trips_minimal() {
let request = ApprovalRequest {
request_id: "automation_v2:run-1:node-2".to_string(),
source: ApprovalSourceKind::AutomationV2,
tenant: ApprovalTenantRef {
org_id: "local-default-org".to_string(),
workspace_id: "local-default-workspace".to_string(),
user_id: None,
},
run_id: "run-1".to_string(),
node_id: Some("node-2".to_string()),
workflow_name: Some("sales-research-outreach".to_string()),
action_kind: Some("send_email".to_string()),
action_preview_markdown: Some("Will email **alice@example.com**".to_string()),
surface_payload: Some(serde_json::json!({ "automation_v2_run_id": "run-1" })),
requested_at_ms: 1_700_000_000_000,
expires_at_ms: None,
decisions: vec![
ApprovalDecision::Approve,
ApprovalDecision::Rework,
ApprovalDecision::Cancel,
],
rework_targets: vec!["draft-stage".to_string()],
instructions: Some("Verify recipient is in the approved ICP.".to_string()),
decided_by: None,
decided_at_ms: None,
decision: None,
rework_feedback: None,
};
let json = serde_json::to_string(&request).unwrap();
let parsed: ApprovalRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.request_id, request.request_id);
assert_eq!(parsed.source, ApprovalSourceKind::AutomationV2);
assert_eq!(parsed.decisions.len(), 3);
assert!(parsed.decided_at_ms.is_none());
}
#[test]
fn approval_decision_input_accepts_optional_reason() {
let input: ApprovalDecisionInput = serde_json::from_str(
r#"{"decision":"rework","reason":"please tighten the ICP filter"}"#,
)
.unwrap();
assert_eq!(input.decision, ApprovalDecision::Rework);
assert_eq!(
input.reason.as_deref(),
Some("please tighten the ICP filter")
);
}
#[test]
fn approval_list_filter_defaults_to_empty() {
let filter = ApprovalListFilter::default();
assert!(filter.org_id.is_none());
assert!(filter.source.is_none());
assert!(filter.limit.is_none());
}
}