//! HTTP handlers for the `user_ai_prefs` settings surface. //! //! `GET /prefs` — the current row (or the empty shape when none exists). //! `PUT /prefs` — partial update: an omitted field is unchanged, an explicit //! null clears, a value sets. `display_tz` is validated against chrono-tz //! (the same check as `camera_tz::display_zone_for`); an invalid zone name //! → 400 and nothing is written. //! `GET /timezones` — the IANA zone list from chrono-tz, for picker UIs //! (unauthenticated; static data). //! //! Scope: existing columns only (voice, library, display_tz) — no migration. //! The legacy `tz_offset_minutes` column is preserved verbatim on every //! write. `POST /reels`'s `capture_prefs` stays as the incidental writer; //! both writers hit the same single row, so they're idempotent. Owner //! defaults (model/backend/persona/reels) are a separate follow-on ticket //! that adds the columns. use actix_web::{HttpResponse, web}; use chrono_tz::Tz; use serde::{Deserialize, Serialize}; use serde_json::json; use std::str::FromStr; use std::time::{SystemTime, UNIX_EPOCH}; use crate::data::Claims; use crate::database::models::{UpsertUserAiPrefs, UserAiPrefs}; use crate::state::AppState; // --------------------------------------------------------------------------- // Wire shapes — snake_case, matching the `display_tz` contract surface. // --------------------------------------------------------------------------- #[derive(Serialize, Deserialize, Debug)] pub struct PrefsResponse { pub voice: Option, pub library: Option, pub display_tz: Option, pub updated_at: Option, } /// One partial-update field. `Absent` = key omitted (unchanged), `Null` = /// explicit JSON null (clear), `Val(v)` = set. serde's derive can't keep /// explicit null and a missing key apart on `Option>` (both come /// out as outer `None`), so this carries a hand-rolled visitor. #[derive(Clone, Debug, Default)] pub enum TriState { #[default] Absent, Null, Val(String), } impl<'de> Deserialize<'de> for TriState { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { struct Visitor; impl<'de> serde::de::Visitor<'de> for Visitor { type Value = TriState; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("a string or null") } // serde_json routes an explicit JSON null to visit_unit/visit_none; // a missing key never calls deserialize — `#[serde(default)]` // supplies Absent instead. fn visit_unit(self) -> Result { Ok(TriState::Null) } fn visit_none(self) -> Result { Ok(TriState::Null) } fn visit_str(self, v: &str) -> Result { Ok(TriState::Val(v.to_string())) } fn visit_string(self, v: String) -> Result { Ok(TriState::Val(v)) } } deserializer.deserialize_any(Visitor) } } /// Partial update: an omitted field is unchanged, an explicit null clears, /// a value sets. #[derive(Deserialize, Debug, Default)] pub struct UpdatePrefsRequest { #[serde(default)] pub voice: TriState, #[serde(default)] pub library: TriState, #[serde(default)] pub display_tz: TriState, } #[derive(Serialize, Debug)] pub struct TimezonesResponse { pub timezones: Vec, } // --------------------------------------------------------------------------- // Pure seams // --------------------------------------------------------------------------- /// The same zone-name check as `camera_tz::display_zone_for` (trim, then /// chrono-tz parse). Used to reject writes with an unparseable zone before /// any DB touch. fn is_valid_display_tz(name: &str) -> bool { Tz::from_str(name.trim()).is_ok() } /// The full set of zone names `PUT /prefs` will accept — `chrono_tz::TZ_VARIANTS` /// is exactly the domain of `Tz::from_str`, so the picker list and the /// validation check can never drift apart. pub fn all_timezones() -> Vec { chrono_tz::TZ_VARIANTS .iter() .map(|tz| tz.to_string()) .collect() } /// Merge a partial update onto the existing row. Returns /// `(voice, library, display_tz, changed)`. An omitted field keeps the /// stored value (or `None` when there is no row); a present field wins. fn tri_value( field: &TriState, existing: Option<&UserAiPrefs>, keep: impl FnOnce(&UserAiPrefs) -> Option, ) -> Option { match field { TriState::Absent => existing.and_then(keep), TriState::Null => None, TriState::Val(v) => Some(v.clone()), } } fn merge_patch( existing: Option<&UserAiPrefs>, patch: &UpdatePrefsRequest, ) -> (Option, Option, Option, bool) { let voice = tri_value(&patch.voice, existing, |e| e.voice.clone()); let library = tri_value(&patch.library, existing, |e| e.library.clone()); let display_tz = tri_value(&patch.display_tz, existing, |e| e.display_tz.clone()); let changed = !matches!(patch.voice, TriState::Absent) || !matches!(patch.library, TriState::Absent) || !matches!(patch.display_tz, TriState::Absent); (voice, library, display_tz, changed) } fn row_to_response(row: Option<&UserAiPrefs>) -> PrefsResponse { match row { Some(row) => PrefsResponse { voice: row.voice.clone(), library: row.library.clone(), display_tz: row.display_tz.clone(), updated_at: Some(row.updated_at), }, None => PrefsResponse { voice: None, library: None, display_tz: None, updated_at: None, }, } } // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- pub async fn get_prefs(_claims: Claims, app_state: web::Data) -> HttpResponse { let mut dao = match app_state.user_ai_prefs_dao.lock() { Ok(dao) => dao, Err(_) => { return HttpResponse::InternalServerError().json(json!({ "error": "prefs store unavailable" })); } }; match dao.get_prefs(&opentelemetry::Context::new()) { Ok(row) => HttpResponse::Ok().json(row_to_response(row.as_ref())), Err(e) => HttpResponse::InternalServerError().json(json!({ "error": format!("failed to read prefs: {e}") })), } } pub async fn put_prefs( _claims: Claims, body: web::Json, app_state: web::Data, ) -> HttpResponse { let patch = body.0; // Validate before any write: an invalid zone name → 400, nothing written. if let TriState::Val(name) = &patch.display_tz && !is_valid_display_tz(name) { return HttpResponse::BadRequest().json(json!({ "error": format!("invalid display_tz: {name}") })); } let mut dao = match app_state.user_ai_prefs_dao.lock() { Ok(dao) => dao, Err(_) => { return HttpResponse::InternalServerError().json(json!({ "error": "prefs store unavailable" })); } }; let ctx = opentelemetry::Context::new(); let existing = match dao.get_prefs(&ctx) { Ok(row) => row, Err(e) => { return HttpResponse::InternalServerError().json(json!({ "error": format!("failed to read prefs: {e}") })); } }; let (voice, library, display_tz, changed) = merge_patch(existing.as_ref(), &patch); if !changed { // Empty body: no-op, return the current row without bumping updated_at. return HttpResponse::Ok().json(row_to_response(existing.as_ref())); } let now = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("Time went backwards") .as_secs() as i64; let resp = PrefsResponse { voice: voice.clone(), library: library.clone(), display_tz: display_tz.clone(), updated_at: Some(now), }; let upsert = UpsertUserAiPrefs { voice, // Frozen legacy column: preserved verbatim, never written by this // surface. tz_offset_minutes: existing.as_ref().and_then(|e| e.tz_offset_minutes), library, display_tz, updated_at: now, }; match dao.upsert_prefs(&ctx, &upsert) { Ok(()) => HttpResponse::Ok().json(resp), Err(e) => HttpResponse::InternalServerError().json(json!({ "error": format!("failed to write prefs: {e}") })), } } pub async fn list_timezones() -> HttpResponse { HttpResponse::Ok().json(TimezonesResponse { timezones: all_timezones(), }) } #[cfg(test)] mod tests { use super::*; use actix_web::App; use actix_web::test::{TestRequest, call_service, init_service, read_body_json}; use std::sync::{Arc, Mutex}; use crate::database::{SqliteUserAiPrefsDao, UserAiPrefsDao}; /// AppState whose prefs DAO shares one in-memory connection, plus a /// second DAO handle on the same connection for seeding. fn prefs_state() -> (web::Data, SqliteUserAiPrefsDao) { let conn = Arc::new(Mutex::new(crate::database::test::in_memory_db_connection())); let mut state = AppState::test_state(); let state_dao: Arc>> = Arc::new(Mutex::new(Box::new( SqliteUserAiPrefsDao::from_connection(conn.clone()), ))); state.user_ai_prefs_dao = state_dao; let seed_dao = SqliteUserAiPrefsDao::from_connection(conn); (web::Data::new(state), seed_dao) } fn make_token() -> String { let claims = Claims::valid_user(String::from("1")); jsonwebtoken::encode( &jsonwebtoken::Header::default(), &claims, &jsonwebtoken::EncodingKey::from_secret(b"test_key"), ) .unwrap() } fn auth() -> (&'static str, String) { ("Authorization", format!("Bearer {}", make_token())) } fn seed(dao: &mut SqliteUserAiPrefsDao, row: UpsertUserAiPrefs) { dao.upsert_prefs(&opentelemetry::Context::new(), &row) .expect("seed prefs"); } fn seeded_row( voice: Option<&str>, library: Option<&str>, display_tz: Option<&str>, ) -> UpsertUserAiPrefs { UpsertUserAiPrefs { voice: voice.map(str::to_string), tz_offset_minutes: Some(-240), library: library.map(str::to_string), updated_at: 1_700_000_000, display_tz: display_tz.map(str::to_string), } } // --- GET /prefs --------------------------------------------------------- #[actix_rt::test] async fn get_prefs_requires_auth() { let (state, _seed) = prefs_state(); let app = init_service( App::new() .service( web::resource("/prefs") .route(web::get().to(get_prefs)) .route(web::put().to(put_prefs)), ) .service(web::resource("/timezones").route(web::get().to(list_timezones))) .app_data(state.clone()), ) .await; let req = TestRequest::get().uri("/prefs").to_request(); let resp = call_service(&app, req).await; assert_eq!(resp.status(), 401); } #[actix_rt::test] async fn get_prefs_returns_empty_shape_when_no_row() { let (state, _seed) = prefs_state(); let app = init_service( App::new() .service( web::resource("/prefs") .route(web::get().to(get_prefs)) .route(web::put().to(put_prefs)), ) .service(web::resource("/timezones").route(web::get().to(list_timezones))) .app_data(state.clone()), ) .await; let (header, value) = auth(); let req = TestRequest::get() .uri("/prefs") .insert_header((header, value)) .to_request(); let resp = call_service(&app, req).await; assert_eq!(resp.status(), 200); let body: serde_json::Value = read_body_json(resp).await; assert_eq!( body, serde_json::json!({ "voice": null, "library": null, "display_tz": null, "updated_at": null }) ); } #[actix_rt::test] async fn get_prefs_returns_stored_row() { let (state, mut seed_dao) = prefs_state(); seed( &mut seed_dao, seeded_row(Some("grandma"), Some("2"), Some("America/New_York")), ); let app = init_service( App::new() .service( web::resource("/prefs") .route(web::get().to(get_prefs)) .route(web::put().to(put_prefs)), ) .service(web::resource("/timezones").route(web::get().to(list_timezones))) .app_data(state.clone()), ) .await; let (header, value) = auth(); let req = TestRequest::get() .uri("/prefs") .insert_header((header, value)) .to_request(); let resp = call_service(&app, req).await; let body: serde_json::Value = read_body_json(resp).await; assert_eq!(body["voice"], "grandma"); assert_eq!(body["library"], "2"); assert_eq!(body["display_tz"], "America/New_York"); assert_eq!(body["updated_at"], 1_700_000_000); } // --- PUT /prefs --------------------------------------------------------- #[actix_rt::test] async fn put_prefs_requires_auth() { let (state, _seed) = prefs_state(); let app = init_service( App::new() .service( web::resource("/prefs") .route(web::get().to(get_prefs)) .route(web::put().to(put_prefs)), ) .service(web::resource("/timezones").route(web::get().to(list_timezones))) .app_data(state.clone()), ) .await; let req = TestRequest::put() .uri("/prefs") .set_json(serde_json::json!({"voice": "dad"})) .to_request(); let resp = call_service(&app, req).await; assert_eq!(resp.status(), 401); } #[actix_rt::test] async fn put_prefs_partial_update_leaves_omitted_fields_untouched() { let (state, mut seed_dao) = prefs_state(); seed( &mut seed_dao, seeded_row(Some("grandma"), Some("1"), Some("America/New_York")), ); let app = init_service( App::new() .service( web::resource("/prefs") .route(web::get().to(get_prefs)) .route(web::put().to(put_prefs)), ) .service(web::resource("/timezones").route(web::get().to(list_timezones))) .app_data(state.clone()), ) .await; let (header, value) = auth(); let req = TestRequest::put() .uri("/prefs") .insert_header((header, value)) .set_json(serde_json::json!({"voice": "dad"})) .to_request(); let resp = call_service(&app, req).await; assert_eq!(resp.status(), 200); let mut dao = state.user_ai_prefs_dao.lock().unwrap(); let row = dao .get_prefs(&opentelemetry::Context::new()) .unwrap() .unwrap(); assert_eq!(row.voice.as_deref(), Some("dad")); assert_eq!(row.library.as_deref(), Some("1")); assert_eq!(row.display_tz.as_deref(), Some("America/New_York")); // The frozen legacy column survives the write. assert_eq!(row.tz_offset_minutes, Some(-240)); assert!(row.updated_at > 1_700_000_000); } #[actix_rt::test] async fn put_prefs_explicit_null_clears_field() { let (state, mut seed_dao) = prefs_state(); seed( &mut seed_dao, seeded_row(Some("grandma"), Some("1"), Some("America/New_York")), ); let app = init_service( App::new() .service( web::resource("/prefs") .route(web::get().to(get_prefs)) .route(web::put().to(put_prefs)), ) .service(web::resource("/timezones").route(web::get().to(list_timezones))) .app_data(state.clone()), ) .await; let (header, value) = auth(); let req = TestRequest::put() .uri("/prefs") .insert_header((header, value)) .set_json(serde_json::json!({"voice": null})) .to_request(); let resp = call_service(&app, req).await; assert_eq!(resp.status(), 200); let mut dao = state.user_ai_prefs_dao.lock().unwrap(); let row = dao .get_prefs(&opentelemetry::Context::new()) .unwrap() .unwrap(); assert!(row.voice.is_none()); assert_eq!(row.library.as_deref(), Some("1")); assert_eq!(row.display_tz.as_deref(), Some("America/New_York")); } #[actix_rt::test] async fn put_prefs_inserts_row_when_none_exists() { let (state, _seed) = prefs_state(); let app = init_service( App::new() .service( web::resource("/prefs") .route(web::get().to(get_prefs)) .route(web::put().to(put_prefs)), ) .service(web::resource("/timezones").route(web::get().to(list_timezones))) .app_data(state.clone()), ) .await; let (header, value) = auth(); let req = TestRequest::put() .uri("/prefs") .insert_header((header, value)) .set_json(serde_json::json!({"library": "1"})) .to_request(); let resp = call_service(&app, req).await; assert_eq!(resp.status(), 200); let mut dao = state.user_ai_prefs_dao.lock().unwrap(); let row = dao .get_prefs(&opentelemetry::Context::new()) .unwrap() .unwrap(); assert_eq!(row.library.as_deref(), Some("1")); assert!(row.voice.is_none()); assert!(row.display_tz.is_none()); } #[actix_rt::test] async fn put_prefs_invalid_display_tz_returns_400_and_writes_nothing() { let (state, mut seed_dao) = prefs_state(); seed( &mut seed_dao, seeded_row(Some("grandma"), Some("1"), Some("America/New_York")), ); let app = init_service( App::new() .service( web::resource("/prefs") .route(web::get().to(get_prefs)) .route(web::put().to(put_prefs)), ) .service(web::resource("/timezones").route(web::get().to(list_timezones))) .app_data(state.clone()), ) .await; let (header, value) = auth(); let req = TestRequest::put() .uri("/prefs") .insert_header((header, value)) .set_json(serde_json::json!({"voice": "dad", "display_tz": "Not/A_Zone"})) .to_request(); let resp = call_service(&app, req).await; assert_eq!(resp.status(), 400); // Nothing written — not even the valid field in the same body. let mut dao = state.user_ai_prefs_dao.lock().unwrap(); let row = dao .get_prefs(&opentelemetry::Context::new()) .unwrap() .unwrap(); assert_eq!(row.voice.as_deref(), Some("grandma")); assert_eq!(row.display_tz.as_deref(), Some("America/New_York")); assert_eq!(row.updated_at, 1_700_000_000); } #[actix_rt::test] async fn put_prefs_valid_display_tz_persists() { let (state, _seed) = prefs_state(); let app = init_service( App::new() .service( web::resource("/prefs") .route(web::get().to(get_prefs)) .route(web::put().to(put_prefs)), ) .service(web::resource("/timezones").route(web::get().to(list_timezones))) .app_data(state.clone()), ) .await; let (header, value) = auth(); let req = TestRequest::put() .uri("/prefs") .insert_header((header, value)) .set_json(serde_json::json!({"display_tz": "Europe/Berlin"})) .to_request(); let resp = call_service(&app, req).await; assert_eq!(resp.status(), 200); let mut dao = state.user_ai_prefs_dao.lock().unwrap(); let row = dao .get_prefs(&opentelemetry::Context::new()) .unwrap() .unwrap(); assert_eq!(row.display_tz.as_deref(), Some("Europe/Berlin")); } #[actix_rt::test] async fn put_prefs_empty_body_is_noop() { let (state, _seed) = prefs_state(); let app = init_service( App::new() .service( web::resource("/prefs") .route(web::get().to(get_prefs)) .route(web::put().to(put_prefs)), ) .service(web::resource("/timezones").route(web::get().to(list_timezones))) .app_data(state.clone()), ) .await; let (header, value) = auth(); let req = TestRequest::put() .uri("/prefs") .insert_header((header, value)) .set_json(serde_json::json!({})) .to_request(); let resp = call_service(&app, req).await; assert_eq!(resp.status(), 200); let body: serde_json::Value = read_body_json(resp).await; assert_eq!(body["updated_at"], serde_json::Value::Null); let mut dao = state.user_ai_prefs_dao.lock().unwrap(); assert!( dao.get_prefs(&opentelemetry::Context::new()) .unwrap() .is_none() ); } // --- GET /timezones ----------------------------------------------------- #[actix_rt::test] async fn list_timezones_is_unauthenticated_and_includes_iana_zones() { let (state, _seed) = prefs_state(); let app = init_service( App::new() .service( web::resource("/prefs") .route(web::get().to(get_prefs)) .route(web::put().to(put_prefs)), ) .service(web::resource("/timezones").route(web::get().to(list_timezones))) .app_data(state.clone()), ) .await; let req = TestRequest::get().uri("/timezones").to_request(); let resp = call_service(&app, req).await; assert_eq!(resp.status(), 200); let body: serde_json::Value = read_body_json(resp).await; let zones: Vec<&str> = body["timezones"] .as_array() .expect("timezones is an array") .iter() .map(|v| v.as_str().expect("zone is a string")) .collect(); assert!( zones.contains(&"America/New_York"), "IANA list must include America/New_York" ); assert!(zones.len() > 400, "IANA list looks truncated"); } #[test] fn all_timezones_is_exactly_the_from_str_domain() { // The picker list and the PUT /prefs validation must never drift: // every listed name parses, and the list is the full variant set. let zones = all_timezones(); assert_eq!(zones.len(), chrono_tz::TZ_VARIANTS.len()); for z in &zones { assert!(Tz::from_str(z).is_ok(), "listed zone must parse: {z}"); } assert!(zones.contains(&"America/New_York".to_string())); } // --- merge_patch (pure) -------------------------------------------------- #[test] fn merge_patch_omitted_keeps_existing() { let row = UserAiPrefs { id: 1, voice: Some("a".into()), tz_offset_minutes: None, library: Some("b".into()), updated_at: 0, display_tz: Some("c".into()), }; let (voice, library, display_tz, changed) = merge_patch(Some(&row), &UpdatePrefsRequest::default()); assert_eq!(voice.as_deref(), Some("a")); assert_eq!(library.as_deref(), Some("b")); assert_eq!(display_tz.as_deref(), Some("c")); assert!(!changed); } #[test] fn merge_patch_present_field_wins_including_null() { let row = UserAiPrefs { id: 1, voice: Some("a".into()), tz_offset_minutes: None, library: None, updated_at: 0, display_tz: None, }; let patch = UpdatePrefsRequest { voice: TriState::Null, // clear library: TriState::Val(String::from("x")), // set display_tz: TriState::Absent, }; let (voice, library, display_tz, changed) = merge_patch(Some(&row), &patch); assert!(voice.is_none()); assert_eq!(library.as_deref(), Some("x")); assert!(display_tz.is_none()); assert!(changed); } }