mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
GATEWAY_USER_TOKENS never went to production — replaced entirely by DB-backed user management via /api/admin/users and /api/tokens. Removed: - UserTokenConfig struct and GATEWAY_USER_TOKENS env var parsing - user_tokens field from GatewayConfig - GatewayChannel::new_multi_auth() constructor - Env-var user migration block in main.rs (~90 lines) - multi_tenant auto-detection from GATEWAY_USER_TOKENS (now runtime via db.has_any_users() in app.rs) Review fixes (zmanian): - User ID generation: UUID instead of display-name derivation (#1) - Invitation accept moved to public router (no auth needed) (#3) - libSQL get_invitation_by_hash aligned with postgres: filters status='pending' AND expires_at > now (#4) - UUID parse: returns DatabaseError::Serialization instead of unwrap_or_default (#7) - PostgreSQL SELECT * replaced with explicit column lists (#8) - Sort order aligned (both backends use DESC) (#6) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
167 lines
6.2 KiB
Rust
167 lines
6.2 KiB
Rust
use crate::config::helpers::{optional_env, parse_bool_env, parse_option_env, parse_optional_env};
|
|
use crate::error::ConfigError;
|
|
use crate::settings::Settings;
|
|
|
|
/// Heartbeat configuration.
|
|
#[derive(Debug, Clone)]
|
|
pub struct HeartbeatConfig {
|
|
/// Whether heartbeat is enabled.
|
|
pub enabled: bool,
|
|
/// Interval between heartbeat checks in seconds (used when fire_at is not set).
|
|
pub interval_secs: u64,
|
|
/// Channel to notify on heartbeat findings.
|
|
pub notify_channel: Option<String>,
|
|
/// User ID to notify on heartbeat findings.
|
|
pub notify_user: Option<String>,
|
|
/// Fixed time-of-day to fire (HH:MM, 24h). When set, interval_secs is ignored.
|
|
pub fire_at: Option<chrono::NaiveTime>,
|
|
/// Hour (0-23) when quiet hours start.
|
|
pub quiet_hours_start: Option<u32>,
|
|
/// Hour (0-23) when quiet hours end.
|
|
pub quiet_hours_end: Option<u32>,
|
|
/// Timezone for fire_at and quiet hours evaluation (IANA name).
|
|
pub timezone: Option<String>,
|
|
/// When true, cycle through all users with routines. Set explicitly via
|
|
/// HEARTBEAT_MULTI_TENANT or detected at runtime after DB initialization.
|
|
pub multi_tenant: bool,
|
|
}
|
|
|
|
impl Default for HeartbeatConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
interval_secs: 1800, // 30 minutes
|
|
notify_channel: None,
|
|
notify_user: None,
|
|
fire_at: None,
|
|
quiet_hours_start: None,
|
|
quiet_hours_end: None,
|
|
timezone: None,
|
|
multi_tenant: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl HeartbeatConfig {
|
|
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
|
let fire_at_str =
|
|
optional_env("HEARTBEAT_FIRE_AT")?.or_else(|| settings.heartbeat.fire_at.clone());
|
|
let fire_at = fire_at_str
|
|
.map(|s| {
|
|
chrono::NaiveTime::parse_from_str(&s, "%H:%M").map_err(|e| {
|
|
ConfigError::InvalidValue {
|
|
key: "HEARTBEAT_FIRE_AT".to_string(),
|
|
message: format!("must be HH:MM (24h), e.g. '14:00': {e}"),
|
|
}
|
|
})
|
|
})
|
|
.transpose()?;
|
|
|
|
Ok(Self {
|
|
enabled: parse_bool_env("HEARTBEAT_ENABLED", settings.heartbeat.enabled)?,
|
|
interval_secs: parse_optional_env(
|
|
"HEARTBEAT_INTERVAL_SECS",
|
|
settings.heartbeat.interval_secs,
|
|
)?,
|
|
notify_channel: optional_env("HEARTBEAT_NOTIFY_CHANNEL")?
|
|
.or_else(|| settings.heartbeat.notify_channel.clone()),
|
|
notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?
|
|
.or_else(|| settings.heartbeat.notify_user.clone()),
|
|
fire_at,
|
|
quiet_hours_start: parse_option_env::<u32>("HEARTBEAT_QUIET_START")?
|
|
.or(settings.heartbeat.quiet_hours_start)
|
|
.map(|h| {
|
|
if h > 23 {
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "HEARTBEAT_QUIET_START".into(),
|
|
message: "must be 0-23".into(),
|
|
});
|
|
}
|
|
Ok(h)
|
|
})
|
|
.transpose()?,
|
|
quiet_hours_end: parse_option_env::<u32>("HEARTBEAT_QUIET_END")?
|
|
.or(settings.heartbeat.quiet_hours_end)
|
|
.map(|h| {
|
|
if h > 23 {
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "HEARTBEAT_QUIET_END".into(),
|
|
message: "must be 0-23".into(),
|
|
});
|
|
}
|
|
Ok(h)
|
|
})
|
|
.transpose()?,
|
|
timezone: {
|
|
let tz = optional_env("HEARTBEAT_TIMEZONE")?
|
|
.or_else(|| settings.heartbeat.timezone.clone());
|
|
if let Some(ref tz_str) = tz
|
|
&& crate::timezone::parse_timezone(tz_str).is_none()
|
|
{
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "HEARTBEAT_TIMEZONE".into(),
|
|
message: format!("invalid IANA timezone: '{tz_str}'"),
|
|
});
|
|
}
|
|
tz
|
|
},
|
|
multi_tenant: parse_bool_env("HEARTBEAT_MULTI_TENANT", false)?,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_quiet_hours_settings_fallback() {
|
|
// When env vars are not set, settings values should be used
|
|
let mut settings = Settings::default();
|
|
settings.heartbeat.quiet_hours_start = Some(22);
|
|
settings.heartbeat.quiet_hours_end = Some(6);
|
|
|
|
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
|
|
assert_eq!(config.quiet_hours_start, Some(22));
|
|
assert_eq!(config.quiet_hours_end, Some(6));
|
|
}
|
|
|
|
#[test]
|
|
fn test_quiet_hours_rejects_invalid_hour() {
|
|
let mut settings = Settings::default();
|
|
settings.heartbeat.quiet_hours_start = Some(24);
|
|
|
|
let result = HeartbeatConfig::resolve(&settings);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_quiet_hours_accepts_boundary_values() {
|
|
let mut settings = Settings::default();
|
|
settings.heartbeat.quiet_hours_start = Some(0);
|
|
settings.heartbeat.quiet_hours_end = Some(23);
|
|
|
|
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
|
|
assert_eq!(config.quiet_hours_start, Some(0));
|
|
assert_eq!(config.quiet_hours_end, Some(23));
|
|
}
|
|
|
|
#[test]
|
|
fn test_heartbeat_timezone_rejects_invalid() {
|
|
let mut settings = Settings::default();
|
|
settings.heartbeat.timezone = Some("Fake/Zone".to_string());
|
|
|
|
let result = HeartbeatConfig::resolve(&settings);
|
|
assert!(result.is_err(), "invalid IANA timezone should be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn test_heartbeat_timezone_accepts_valid() {
|
|
let mut settings = Settings::default();
|
|
settings.heartbeat.timezone = Some("America/New_York".to_string());
|
|
|
|
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
|
|
assert_eq!(config.timezone.as_deref(), Some("America/New_York"));
|
|
}
|
|
}
|