botserver/src/basic/keywords/human_approval.rs
Rodrigo Rodriguez (Pragmatismo) 5165131b06 Add implementation plan and multi-agent features
This commit introduces comprehensive documentation and implementation
for multi-agent orchestration capabilities:

- Add IMPLEMENTATION-PLAN.md with 4-phase roadmap
- Add Kubernetes deployment manifests (deployment.yaml, hpa.yaml)
- Add database migrations for multi-agent tables (6.1.1, 6.1.2)
- Implement A2A protocol for agent-to-agent communication
- Implement user memory keywords for cross-session persistence
- Implement model routing for dynamic L
2025-11-30 19:18:23 -03:00

1011 lines
31 KiB
Rust

//! Human-in-the-Loop Approvals
//!
//! This module provides human approval workflows that pause script execution
//! until a human approves, rejects, or modifies a pending action. It enables:
//!
//! - Multi-channel approval requests (email, SMS, Teams, mobile push)
//! - Timeout handling with default actions
//! - Approval chains for multi-level authorization
//! - Audit logging of all approval decisions
//!
//! ## BASIC Keywords
//!
//! ```basic
//! ' Request approval via email
//! HEAR approval ON EMAIL "manager@company.com"
//!
//! ' Request approval via mobile push
//! HEAR approval ON MOBILE "+1-555-0100"
//!
//! ' Request approval via Teams channel
//! HEAR approval ON TEAMS "approvals-channel"
//!
//! ' With timeout and default action
//! HEAR approval ON EMAIL "manager@company.com" TIMEOUT 3600 DEFAULT "auto-approve"
//!
//! ' Multi-level approval chain
//! APPROVAL CHAIN "expense-approval"
//! LEVEL 1 ON EMAIL "supervisor@company.com" TIMEOUT 3600
//! LEVEL 2 ON EMAIL "director@company.com" TIMEOUT 7200 IF amount > 10000
//! LEVEL 3 ON EMAIL "vp@company.com" TIMEOUT 14400 IF amount > 50000
//! END APPROVAL CHAIN
//!
//! ' Request approval with context
//! SET APPROVAL CONTEXT "action", "expense_report"
//! SET APPROVAL CONTEXT "amount", 15000
//! SET APPROVAL CONTEXT "description", "Q4 Marketing Campaign"
//! result = REQUEST APPROVAL "expense-approval"
//!
//! ' Check approval status
//! status = GET APPROVAL STATUS requestId
//! ```
//!
//! ## Config.csv Properties
//!
//! ```csv
//! name,value
//! approval-enabled,true
//! approval-default-timeout,3600
//! approval-reminder-interval,1800
//! approval-max-reminders,3
//! approval-audit-enabled,true
//! approval-webhook-url,https://webhook.example.com/approvals
//! ```
use chrono::{DateTime, Duration, Utc};
use rhai::{Array, Dynamic, Engine, Map};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tracing::{debug, error, info, warn};
use uuid::Uuid;
/// Approval request structure
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalRequest {
/// Unique request identifier
pub id: Uuid,
/// Bot ID that initiated the request
pub bot_id: Uuid,
/// Session ID where the request originated
pub session_id: Uuid,
/// User ID who triggered the approval
pub initiated_by: Uuid,
/// Type of approval being requested
pub approval_type: String,
/// Current status
pub status: ApprovalStatus,
/// Channel for sending the request
pub channel: ApprovalChannel,
/// Recipient identifier (email, phone, channel name)
pub recipient: String,
/// Context data for the approval
pub context: serde_json::Value,
/// Message shown to approver
pub message: String,
/// Timeout in seconds
pub timeout_seconds: u64,
/// Default action if timeout
pub default_action: Option<ApprovalDecision>,
/// Current level in approval chain (1-indexed)
pub current_level: u32,
/// Total levels in approval chain
pub total_levels: u32,
/// When the request was created
pub created_at: DateTime<Utc>,
/// When the request expires
pub expires_at: DateTime<Utc>,
/// When reminders were sent
pub reminders_sent: Vec<DateTime<Utc>>,
/// The final decision
pub decision: Option<ApprovalDecision>,
/// Who made the decision
pub decided_by: Option<String>,
/// When the decision was made
pub decided_at: Option<DateTime<Utc>>,
/// Comments from the approver
pub comments: Option<String>,
}
/// Approval status
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ApprovalStatus {
/// Waiting for approver response
Pending,
/// Approved by approver
Approved,
/// Rejected by approver
Rejected,
/// Timed out, using default action
TimedOut,
/// Cancelled by requester
Cancelled,
/// Escalated to next level
Escalated,
/// Error occurred
Error,
}
impl Default for ApprovalStatus {
fn default() -> Self {
ApprovalStatus::Pending
}
}
/// Approval decision
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ApprovalDecision {
Approve,
Reject,
Escalate,
Defer,
RequestInfo,
}
/// Channel for sending approval requests
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ApprovalChannel {
Email,
Sms,
Mobile,
Teams,
Slack,
Webhook,
InApp,
}
impl Default for ApprovalChannel {
fn default() -> Self {
ApprovalChannel::Email
}
}
impl std::fmt::Display for ApprovalChannel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ApprovalChannel::Email => write!(f, "email"),
ApprovalChannel::Sms => write!(f, "sms"),
ApprovalChannel::Mobile => write!(f, "mobile"),
ApprovalChannel::Teams => write!(f, "teams"),
ApprovalChannel::Slack => write!(f, "slack"),
ApprovalChannel::Webhook => write!(f, "webhook"),
ApprovalChannel::InApp => write!(f, "in_app"),
}
}
}
/// Approval chain definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalChain {
/// Chain name/identifier
pub name: String,
/// Bot ID this chain belongs to
pub bot_id: Uuid,
/// Levels in the chain
pub levels: Vec<ApprovalLevel>,
/// Whether to stop on first rejection
pub stop_on_reject: bool,
/// Whether all levels must approve
pub require_all: bool,
/// Description of the chain
pub description: Option<String>,
/// When the chain was created
pub created_at: DateTime<Utc>,
}
/// Single level in an approval chain
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalLevel {
/// Level number (1-indexed)
pub level: u32,
/// Channel to use
pub channel: ApprovalChannel,
/// Recipient identifier
pub recipient: String,
/// Timeout for this level
pub timeout_seconds: u64,
/// Condition for this level (evaluated at runtime)
pub condition: Option<String>,
/// Whether this level can be skipped
pub skippable: bool,
/// Approvers at this level (for group approvals)
pub approvers: Vec<String>,
/// Required number of approvals (for group)
pub required_approvals: u32,
}
/// Approval audit log entry
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalAuditEntry {
/// Audit entry ID
pub id: Uuid,
/// Related approval request ID
pub request_id: Uuid,
/// Action that occurred
pub action: AuditAction,
/// Who performed the action
pub actor: String,
/// Details of the action
pub details: serde_json::Value,
/// When the action occurred
pub timestamp: DateTime<Utc>,
/// IP address if available
pub ip_address: Option<String>,
/// User agent if available
pub user_agent: Option<String>,
}
/// Actions tracked in audit log
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AuditAction {
RequestCreated,
NotificationSent,
ReminderSent,
Approved,
Rejected,
Escalated,
TimedOut,
Cancelled,
CommentAdded,
ContextUpdated,
}
/// Configuration for approval system
#[derive(Debug, Clone)]
pub struct ApprovalConfig {
/// Whether approvals are enabled
pub enabled: bool,
/// Default timeout in seconds
pub default_timeout: u64,
/// Interval between reminders
pub reminder_interval: u64,
/// Maximum reminders to send
pub max_reminders: u32,
/// Whether to audit all actions
pub audit_enabled: bool,
/// Webhook URL for external notifications
pub webhook_url: Option<String>,
/// Email template for approval requests
pub email_template: Option<String>,
/// Base URL for approval links
pub approval_base_url: Option<String>,
}
impl Default for ApprovalConfig {
fn default() -> Self {
ApprovalConfig {
enabled: true,
default_timeout: 3600,
reminder_interval: 1800,
max_reminders: 3,
audit_enabled: true,
webhook_url: None,
email_template: None,
approval_base_url: None,
}
}
}
/// Approval Manager
pub struct ApprovalManager {
config: ApprovalConfig,
}
impl ApprovalManager {
/// Create a new approval manager
pub fn new(config: ApprovalConfig) -> Self {
ApprovalManager { config }
}
/// Create from config map
pub fn from_config(config_map: &HashMap<String, String>) -> Self {
let config = ApprovalConfig {
enabled: config_map
.get("approval-enabled")
.map(|v| v == "true")
.unwrap_or(true),
default_timeout: config_map
.get("approval-default-timeout")
.and_then(|v| v.parse().ok())
.unwrap_or(3600),
reminder_interval: config_map
.get("approval-reminder-interval")
.and_then(|v| v.parse().ok())
.unwrap_or(1800),
max_reminders: config_map
.get("approval-max-reminders")
.and_then(|v| v.parse().ok())
.unwrap_or(3),
audit_enabled: config_map
.get("approval-audit-enabled")
.map(|v| v == "true")
.unwrap_or(true),
webhook_url: config_map.get("approval-webhook-url").cloned(),
email_template: config_map.get("approval-email-template").cloned(),
approval_base_url: config_map.get("approval-base-url").cloned(),
};
ApprovalManager::new(config)
}
/// Create a new approval request
pub fn create_request(
&self,
bot_id: Uuid,
session_id: Uuid,
initiated_by: Uuid,
approval_type: &str,
channel: ApprovalChannel,
recipient: &str,
context: serde_json::Value,
message: &str,
timeout_seconds: Option<u64>,
default_action: Option<ApprovalDecision>,
) -> ApprovalRequest {
let timeout = timeout_seconds.unwrap_or(self.config.default_timeout);
let now = Utc::now();
ApprovalRequest {
id: Uuid::new_v4(),
bot_id,
session_id,
initiated_by,
approval_type: approval_type.to_string(),
status: ApprovalStatus::Pending,
channel,
recipient: recipient.to_string(),
context,
message: message.to_string(),
timeout_seconds: timeout,
default_action,
current_level: 1,
total_levels: 1,
created_at: now,
expires_at: now + Duration::seconds(timeout as i64),
reminders_sent: Vec::new(),
decision: None,
decided_by: None,
decided_at: None,
comments: None,
}
}
/// Check if a request has expired
pub fn is_expired(&self, request: &ApprovalRequest) -> bool {
Utc::now() > request.expires_at
}
/// Check if a reminder should be sent
pub fn should_send_reminder(&self, request: &ApprovalRequest) -> bool {
if request.status != ApprovalStatus::Pending {
return false;
}
if request.reminders_sent.len() >= self.config.max_reminders as usize {
return false;
}
let last_notification = request
.reminders_sent
.last()
.copied()
.unwrap_or(request.created_at);
let since_last = Utc::now() - last_notification;
since_last.num_seconds() >= self.config.reminder_interval as i64
}
/// Generate approval URL
pub fn generate_approval_url(&self, request_id: Uuid, action: &str, token: &str) -> String {
let base_url = self
.config
.approval_base_url
.as_deref()
.unwrap_or("https://bot.example.com/approve");
format!(
"{}/{}?action={}&token={}",
base_url, request_id, action, token
)
}
/// Generate email content for approval request
pub fn generate_email_content(&self, request: &ApprovalRequest, token: &str) -> EmailContent {
let approve_url = self.generate_approval_url(request.id, "approve", token);
let reject_url = self.generate_approval_url(request.id, "reject", token);
let subject = format!(
"Approval Required: {} ({})",
request.approval_type, request.id
);
let body = format!(
r#"
An approval is requested for:
Type: {}
Message: {}
Context:
{}
This request will expire at: {}
To approve, click: {}
To reject, click: {}
If you have questions, reply to this email.
"#,
request.approval_type,
request.message,
serde_json::to_string_pretty(&request.context).unwrap_or_default(),
request.expires_at.format("%Y-%m-%d %H:%M:%S UTC"),
approve_url,
reject_url
);
EmailContent {
subject,
body,
html_body: None,
}
}
/// Process a decision
pub fn process_decision(
&self,
request: &mut ApprovalRequest,
decision: ApprovalDecision,
decided_by: &str,
comments: Option<String>,
) {
request.decision = Some(decision.clone());
request.decided_by = Some(decided_by.to_string());
request.decided_at = Some(Utc::now());
request.comments = comments;
request.status = match decision {
ApprovalDecision::Approve => ApprovalStatus::Approved,
ApprovalDecision::Reject => ApprovalStatus::Rejected,
ApprovalDecision::Escalate => ApprovalStatus::Escalated,
ApprovalDecision::Defer | ApprovalDecision::RequestInfo => ApprovalStatus::Pending,
};
}
/// Handle timeout
pub fn handle_timeout(&self, request: &mut ApprovalRequest) {
if let Some(default_action) = &request.default_action {
request.decision = Some(default_action.clone());
request.decided_by = Some("system:timeout".to_string());
request.decided_at = Some(Utc::now());
request.status = match default_action {
ApprovalDecision::Approve => ApprovalStatus::Approved,
ApprovalDecision::Reject => ApprovalStatus::Rejected,
_ => ApprovalStatus::TimedOut,
};
} else {
request.status = ApprovalStatus::TimedOut;
}
}
/// Evaluate approval chain condition
pub fn evaluate_condition(
&self,
condition: &str,
context: &serde_json::Value,
) -> Result<bool, String> {
// Simple condition evaluation
// Format: "field operator value" e.g., "amount > 10000"
let parts: Vec<&str> = condition.split_whitespace().collect();
if parts.len() != 3 {
return Err(format!("Invalid condition format: {}", condition));
}
let field = parts[0];
let operator = parts[1];
let value_str = parts[2];
let field_value = context
.get(field)
.and_then(|v| v.as_f64())
.ok_or_else(|| format!("Field not found or not numeric: {}", field))?;
let compare_value: f64 = value_str
.parse()
.map_err(|_| format!("Invalid comparison value: {}", value_str))?;
let result = match operator {
">" => field_value > compare_value,
">=" => field_value >= compare_value,
"<" => field_value < compare_value,
"<=" => field_value <= compare_value,
"==" | "=" => (field_value - compare_value).abs() < f64::EPSILON,
"!=" => (field_value - compare_value).abs() >= f64::EPSILON,
_ => return Err(format!("Unknown operator: {}", operator)),
};
Ok(result)
}
}
/// Email content structure
#[derive(Debug, Clone)]
pub struct EmailContent {
pub subject: String,
pub body: String,
pub html_body: Option<String>,
}
/// Convert ApprovalRequest to Rhai Dynamic
impl ApprovalRequest {
pub fn to_dynamic(&self) -> Dynamic {
let mut map = Map::new();
map.insert("id".into(), self.id.to_string().into());
map.insert("bot_id".into(), self.bot_id.to_string().into());
map.insert("session_id".into(), self.session_id.to_string().into());
map.insert("initiated_by".into(), self.initiated_by.to_string().into());
map.insert("approval_type".into(), self.approval_type.clone().into());
map.insert(
"status".into(),
format!("{:?}", self.status).to_lowercase().into(),
);
map.insert("channel".into(), self.channel.to_string().into());
map.insert("recipient".into(), self.recipient.clone().into());
map.insert("context".into(), json_to_dynamic(&self.context));
map.insert("message".into(), self.message.clone().into());
map.insert(
"timeout_seconds".into(),
(self.timeout_seconds as i64).into(),
);
map.insert("current_level".into(), (self.current_level as i64).into());
map.insert("total_levels".into(), (self.total_levels as i64).into());
map.insert("created_at".into(), self.created_at.to_rfc3339().into());
map.insert("expires_at".into(), self.expires_at.to_rfc3339().into());
if let Some(ref decision) = self.decision {
map.insert(
"decision".into(),
format!("{:?}", decision).to_lowercase().into(),
);
}
if let Some(ref decided_by) = self.decided_by {
map.insert("decided_by".into(), decided_by.clone().into());
}
if let Some(ref decided_at) = self.decided_at {
map.insert("decided_at".into(), decided_at.to_rfc3339().into());
}
if let Some(ref comments) = self.comments {
map.insert("comments".into(), comments.clone().into());
}
Dynamic::from(map)
}
}
/// Convert JSON value to Rhai Dynamic
fn json_to_dynamic(value: &serde_json::Value) -> Dynamic {
match value {
serde_json::Value::Null => Dynamic::UNIT,
serde_json::Value::Bool(b) => Dynamic::from(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Dynamic::from(i)
} else if let Some(f) = n.as_f64() {
Dynamic::from(f)
} else {
Dynamic::UNIT
}
}
serde_json::Value::String(s) => Dynamic::from(s.clone()),
serde_json::Value::Array(arr) => {
let array: Array = arr.iter().map(json_to_dynamic).collect();
Dynamic::from(array)
}
serde_json::Value::Object(obj) => {
let mut map = Map::new();
for (k, v) in obj {
map.insert(k.clone().into(), json_to_dynamic(v));
}
Dynamic::from(map)
}
}
}
/// Register approval keywords with Rhai engine
pub fn register_approval_keywords(engine: &mut Engine) {
// Helper functions for working with approvals in scripts
engine.register_fn("approval_is_approved", |request: Map| -> bool {
request
.get("status")
.and_then(|v| v.clone().try_cast::<String>())
.map(|s| s == "approved")
.unwrap_or(false)
});
engine.register_fn("approval_is_rejected", |request: Map| -> bool {
request
.get("status")
.and_then(|v| v.clone().try_cast::<String>())
.map(|s| s == "rejected")
.unwrap_or(false)
});
engine.register_fn("approval_is_pending", |request: Map| -> bool {
request
.get("status")
.and_then(|v| v.clone().try_cast::<String>())
.map(|s| s == "pending")
.unwrap_or(false)
});
engine.register_fn("approval_is_timed_out", |request: Map| -> bool {
request
.get("status")
.and_then(|v| v.clone().try_cast::<String>())
.map(|s| s == "timedout")
.unwrap_or(false)
});
engine.register_fn("approval_decision", |request: Map| -> String {
request
.get("decision")
.and_then(|v| v.clone().try_cast::<String>())
.unwrap_or_else(|| "pending".to_string())
});
engine.register_fn("approval_decided_by", |request: Map| -> String {
request
.get("decided_by")
.and_then(|v| v.clone().try_cast::<String>())
.unwrap_or_default()
});
engine.register_fn("approval_comments", |request: Map| -> String {
request
.get("comments")
.and_then(|v| v.clone().try_cast::<String>())
.unwrap_or_default()
});
info!("Approval keywords registered");
}
/// SQL for creating approval tables
pub const APPROVAL_SCHEMA: &str = r#"
-- Approval requests
CREATE TABLE IF NOT EXISTS approval_requests (
id UUID PRIMARY KEY,
bot_id UUID NOT NULL,
session_id UUID NOT NULL,
initiated_by UUID NOT NULL,
approval_type VARCHAR(100) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
channel VARCHAR(50) NOT NULL,
recipient VARCHAR(500) NOT NULL,
context JSONB NOT NULL DEFAULT '{}',
message TEXT NOT NULL,
timeout_seconds INTEGER NOT NULL DEFAULT 3600,
default_action VARCHAR(50),
current_level INTEGER NOT NULL DEFAULT 1,
total_levels INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
reminders_sent JSONB NOT NULL DEFAULT '[]',
decision VARCHAR(50),
decided_by VARCHAR(500),
decided_at TIMESTAMP WITH TIME ZONE,
comments TEXT
);
-- Approval chains
CREATE TABLE IF NOT EXISTS approval_chains (
id UUID PRIMARY KEY,
name VARCHAR(200) NOT NULL,
bot_id UUID NOT NULL,
levels JSONB NOT NULL DEFAULT '[]',
stop_on_reject BOOLEAN NOT NULL DEFAULT true,
require_all BOOLEAN NOT NULL DEFAULT false,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE(bot_id, name)
);
-- Approval audit log
CREATE TABLE IF NOT EXISTS approval_audit_log (
id UUID PRIMARY KEY,
request_id UUID NOT NULL REFERENCES approval_requests(id) ON DELETE CASCADE,
action VARCHAR(50) NOT NULL,
actor VARCHAR(500) NOT NULL,
details JSONB NOT NULL DEFAULT '{}',
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
ip_address VARCHAR(50),
user_agent TEXT
);
-- Approval tokens (for secure links)
CREATE TABLE IF NOT EXISTS approval_tokens (
id UUID PRIMARY KEY,
request_id UUID NOT NULL REFERENCES approval_requests(id) ON DELETE CASCADE,
token VARCHAR(100) NOT NULL UNIQUE,
action VARCHAR(50) NOT NULL,
used BOOLEAN NOT NULL DEFAULT false,
used_at TIMESTAMP WITH TIME ZONE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_approval_requests_bot_id ON approval_requests(bot_id);
CREATE INDEX IF NOT EXISTS idx_approval_requests_session_id ON approval_requests(session_id);
CREATE INDEX IF NOT EXISTS idx_approval_requests_status ON approval_requests(status);
CREATE INDEX IF NOT EXISTS idx_approval_requests_expires_at ON approval_requests(expires_at);
CREATE INDEX IF NOT EXISTS idx_approval_requests_pending ON approval_requests(status, expires_at)
WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_approval_audit_request_id ON approval_audit_log(request_id);
CREATE INDEX IF NOT EXISTS idx_approval_audit_timestamp ON approval_audit_log(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_approval_tokens_token ON approval_tokens(token);
CREATE INDEX IF NOT EXISTS idx_approval_tokens_request_id ON approval_tokens(request_id);
"#;
/// SQL for approval operations
pub mod sql {
pub const INSERT_REQUEST: &str = r#"
INSERT INTO approval_requests (
id, bot_id, session_id, initiated_by, approval_type, status,
channel, recipient, context, message, timeout_seconds,
default_action, current_level, total_levels, created_at,
expires_at, reminders_sent
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17
)
"#;
pub const UPDATE_REQUEST: &str = r#"
UPDATE approval_requests
SET status = $2,
decision = $3,
decided_by = $4,
decided_at = $5,
comments = $6
WHERE id = $1
"#;
pub const GET_REQUEST: &str = r#"
SELECT * FROM approval_requests WHERE id = $1
"#;
pub const GET_PENDING_REQUESTS: &str = r#"
SELECT * FROM approval_requests
WHERE status = 'pending'
AND expires_at > NOW()
ORDER BY created_at ASC
"#;
pub const GET_EXPIRED_REQUESTS: &str = r#"
SELECT * FROM approval_requests
WHERE status = 'pending'
AND expires_at <= NOW()
"#;
pub const GET_REQUESTS_BY_SESSION: &str = r#"
SELECT * FROM approval_requests
WHERE session_id = $1
ORDER BY created_at DESC
"#;
pub const UPDATE_REMINDERS: &str = r#"
UPDATE approval_requests
SET reminders_sent = reminders_sent || $2::jsonb
WHERE id = $1
"#;
pub const INSERT_AUDIT: &str = r#"
INSERT INTO approval_audit_log (
id, request_id, action, actor, details, timestamp, ip_address, user_agent
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8
)
"#;
pub const GET_AUDIT_LOG: &str = r#"
SELECT * FROM approval_audit_log
WHERE request_id = $1
ORDER BY timestamp ASC
"#;
pub const INSERT_TOKEN: &str = r#"
INSERT INTO approval_tokens (
id, request_id, token, action, expires_at, created_at
) VALUES (
$1, $2, $3, $4, $5, $6
)
"#;
pub const GET_TOKEN: &str = r#"
SELECT * FROM approval_tokens
WHERE token = $1 AND used = false AND expires_at > NOW()
"#;
pub const USE_TOKEN: &str = r#"
UPDATE approval_tokens
SET used = true, used_at = NOW()
WHERE token = $1
"#;
pub const INSERT_CHAIN: &str = r#"
INSERT INTO approval_chains (
id, name, bot_id, levels, stop_on_reject, require_all, description, created_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8
)
ON CONFLICT (bot_id, name)
DO UPDATE SET
levels = $4,
stop_on_reject = $5,
require_all = $6,
description = $7
"#;
pub const GET_CHAIN: &str = r#"
SELECT * FROM approval_chains
WHERE bot_id = $1 AND name = $2
"#;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = ApprovalConfig::default();
assert!(config.enabled);
assert_eq!(config.default_timeout, 3600);
assert_eq!(config.max_reminders, 3);
}
#[test]
fn test_create_request() {
let manager = ApprovalManager::new(ApprovalConfig::default());
let request = manager.create_request(
Uuid::new_v4(),
Uuid::new_v4(),
Uuid::new_v4(),
"expense_approval",
ApprovalChannel::Email,
"manager@example.com",
serde_json::json!({"amount": 1000}),
"Please approve expense",
None,
None,
);
assert_eq!(request.status, ApprovalStatus::Pending);
assert_eq!(request.approval_type, "expense_approval");
assert!(request.expires_at > Utc::now());
}
#[test]
fn test_is_expired() {
let manager = ApprovalManager::new(ApprovalConfig::default());
let mut request = manager.create_request(
Uuid::new_v4(),
Uuid::new_v4(),
Uuid::new_v4(),
"test",
ApprovalChannel::Email,
"test@example.com",
serde_json::json!({}),
"Test",
Some(1), // 1 second timeout
None,
);
assert!(!manager.is_expired(&request));
// Manually set expired time
request.expires_at = Utc::now() - Duration::seconds(10);
assert!(manager.is_expired(&request));
}
#[test]
fn test_process_decision() {
let manager = ApprovalManager::new(ApprovalConfig::default());
let mut request = manager.create_request(
Uuid::new_v4(),
Uuid::new_v4(),
Uuid::new_v4(),
"test",
ApprovalChannel::Email,
"test@example.com",
serde_json::json!({}),
"Test",
None,
None,
);
manager.process_decision(
&mut request,
ApprovalDecision::Approve,
"manager@example.com",
Some("Looks good!".to_string()),
);
assert_eq!(request.status, ApprovalStatus::Approved);
assert_eq!(request.decision, Some(ApprovalDecision::Approve));
assert_eq!(request.decided_by, Some("manager@example.com".to_string()));
assert_eq!(request.comments, Some("Looks good!".to_string()));
}
#[test]
fn test_evaluate_condition() {
let manager = ApprovalManager::new(ApprovalConfig::default());
let context = serde_json::json!({
"amount": 15000,
"priority": 2
});
assert!(manager
.evaluate_condition("amount > 10000", &context)
.unwrap());
assert!(!manager
.evaluate_condition("amount > 20000", &context)
.unwrap());
assert!(manager
.evaluate_condition("priority == 2", &context)
.unwrap());
}
#[test]
fn test_handle_timeout_with_default() {
let manager = ApprovalManager::new(ApprovalConfig::default());
let mut request = manager.create_request(
Uuid::new_v4(),
Uuid::new_v4(),
Uuid::new_v4(),
"test",
ApprovalChannel::Email,
"test@example.com",
serde_json::json!({}),
"Test",
None,
Some(ApprovalDecision::Approve),
);
manager.handle_timeout(&mut request);
assert_eq!(request.status, ApprovalStatus::Approved);
assert_eq!(request.decision, Some(ApprovalDecision::Approve));
assert_eq!(request.decided_by, Some("system:timeout".to_string()));
}
#[test]
fn test_request_to_dynamic() {
let manager = ApprovalManager::new(ApprovalConfig::default());
let request = manager.create_request(
Uuid::new_v4(),
Uuid::new_v4(),
Uuid::new_v4(),
"test",
ApprovalChannel::Email,
"test@example.com",
serde_json::json!({"key": "value"}),
"Test message",
None,
None,
);
let dynamic = request.to_dynamic();
assert!(dynamic.is::<Map>());
}
}