feat: add per-user retention settings to users.toml

New [user.retention] TOML sub-block with auto_close_days and
auto_delete_days. Both default to 0, which disables the respective
sweep — a conservative default that preserves current behaviour for
existing deployments and lets admins test auto-close in isolation.

Only parsing + the test User literals are touched here; the actual
lazy sweep consuming these values lands in the next commit.
This commit is contained in:
2026-04-21 10:48:24 +02:00
parent 8173ff2f26
commit e5a62d8f10
14 changed files with 90 additions and 0 deletions
+71
View File
@@ -12,6 +12,20 @@ pub struct WhisperUserSettings {
pub initial_prompt: Option<String>,
}
/// Per-user retention settings from users.toml.
///
/// `auto_close_days = 0` disables the auto-close sweep (a case is never
/// closed for age alone). `auto_delete_days = 0` disables the auto-purge
/// sweep (closed cases stay forever until manual purge) — important for
/// admin / dev setups where data must survive across restarts.
#[derive(Debug, Default, Clone, Deserialize)]
pub struct RetentionUserSettings {
#[serde(default)]
pub auto_close_days: u32,
#[serde(default)]
pub auto_delete_days: u32,
}
/// A single user entry from users.toml.
#[derive(Debug, Deserialize)]
pub struct User {
@@ -21,6 +35,8 @@ pub struct User {
pub role: String,
#[serde(default)]
pub whisper: WhisperUserSettings,
#[serde(default)]
pub retention: RetentionUserSettings,
}
/// Wrapper for deserializing the [[user]] array from TOML.
@@ -258,6 +274,7 @@ mod tests {
web_password: String::new(),
role: "doctor".into(),
whisper: WhisperUserSettings::default(),
retention: RetentionUserSettings::default(),
}
}
@@ -277,6 +294,60 @@ mod tests {
assert!(u.whisper.initial_prompt.is_none());
}
#[test]
fn parses_user_without_retention_block_defaults_to_zero() {
let toml_str = r#"
[[user]]
slug = "a"
api_key = "k1"
web_password = ""
role = "doctor"
"#;
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
let r = &parsed.user[0].retention;
// Both zero means "feature disabled", matching the pre-migration
// status quo — no case is ever auto-closed or auto-purged.
assert_eq!(r.auto_close_days, 0);
assert_eq!(r.auto_delete_days, 0);
}
#[test]
fn parses_user_with_retention_block() {
let toml_str = r#"
[[user]]
slug = "a"
api_key = "k1"
web_password = ""
role = "doctor"
[user.retention]
auto_close_days = 7
auto_delete_days = 30
"#;
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
let r = &parsed.user[0].retention;
assert_eq!(r.auto_close_days, 7);
assert_eq!(r.auto_delete_days, 30);
}
#[test]
fn parses_user_with_partial_retention_block() {
let toml_str = r#"
[[user]]
slug = "a"
api_key = "k1"
web_password = ""
role = "doctor"
[user.retention]
auto_close_days = 5
"#;
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
let r = &parsed.user[0].retention;
assert_eq!(r.auto_close_days, 5);
// Missing field defaults to 0 = disabled — lets admins test auto-close
// in isolation without losing data to auto-purge.
assert_eq!(r.auto_delete_days, 0);
}
#[test]
fn parses_user_with_full_whisper_block() {
let toml_str = r#"
+1
View File
@@ -31,6 +31,7 @@ fn make_user(slug: &str) -> User {
web_password: bcrypt::hash("s", 4).unwrap(),
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
}
}
+1
View File
@@ -17,6 +17,7 @@ fn test_config() -> Arc<Config> {
web_password: "unused".into(),
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
}],
api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]),
..Config::test_default()
+1
View File
@@ -23,6 +23,7 @@ fn make_user(slug: &str) -> User {
web_password: bcrypt::hash("s", 4).unwrap(),
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
}
}
+1
View File
@@ -23,6 +23,7 @@ fn make_user(slug: &str, password_plain: &str) -> User {
web_password: bcrypt::hash(password_plain, 4).unwrap(),
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
}
}
+1
View File
@@ -23,6 +23,7 @@ fn make_user(slug: &str, password_plain: &str) -> User {
web_password: bcrypt::hash(password_plain, 4).unwrap(),
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
}
}
+1
View File
@@ -14,6 +14,7 @@ fn make_user(slug: &str, password_plain: &str) -> User {
web_password: bcrypt::hash(password_plain, 4).unwrap(),
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
}
}
+1
View File
@@ -22,6 +22,7 @@ fn make_user() -> User {
web_password: bcrypt::hash("unused", 4).unwrap(),
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
}
}
+1
View File
@@ -28,6 +28,7 @@ fn test_config() -> (Arc<Config>, PathBuf) {
web_password: "unused".into(),
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
}],
api_keys: HashMap::from([(TEST_KEY.into(), TEST_SLUG.into())]),
..Config::test_default()
+1
View File
@@ -37,6 +37,7 @@ fn make_user(slug: &str) -> User {
web_password: bcrypt::hash("s", 4).unwrap(),
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
}
}
+1
View File
@@ -265,6 +265,7 @@ fn test_config_with_whisper(whisper_url: String) -> Arc<Config> {
web_password: "unused".into(),
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
}],
whisper_url,
whisper_timeout_seconds: 5,
+1
View File
@@ -22,6 +22,7 @@ fn test_config() -> Arc<Config> {
web_password: "unused".into(),
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
}],
api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]),
..Config::test_default()
+1
View File
@@ -21,6 +21,7 @@ fn test_config() -> Arc<Config> {
web_password: "unused".into(),
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
}],
api_keys: HashMap::from([("test-key".into(), "dr_test".into())]),
..Config::test_default()
+7
View File
@@ -8,6 +8,13 @@ slug = "dr_mueller"
api_key = "change-me-to-a-secure-key"
web_password = "$2b$12$..."
role = "doctor"
# Optional per-user retention. Both fields default to 0 (feature
# disabled). auto_close_days = 7 + auto_delete_days = 30 means cases
# without new recordings auto-close after 7 days and are irreversibly
# removed 30 days later. Changes require a server restart.
[user.retention]
auto_close_days = 7
auto_delete_days = 30
[[user]]
slug = "dr_schmidt"