diff --git a/crates/db-user/src/lib.rs b/crates/db-user/src/lib.rs index 8ed6fc2c4c..25cf3c7dfb 100644 --- a/crates/db-user/src/lib.rs +++ b/crates/db-user/src/lib.rs @@ -142,7 +142,7 @@ impl std::ops::Deref for UserDatabase { } // Append only. Do not reorder. -const MIGRATIONS: [&str; 27] = [ +const MIGRATIONS: [&str; 28] = [ include_str!("./calendars_migration.sql"), include_str!("./configs_migration.sql"), include_str!("./events_migration.sql"), @@ -170,8 +170,25 @@ const MIGRATIONS: [&str; 27] = [ include_str!("./templates_migration_1.sql"), include_str!("./chat_conversations_migration.sql"), include_str!("./chat_messages_v2_migration.sql"), + include_str!("./templates_migration_2.sql"), ]; +/// Run the bundled SQL migrations against the given database. +/// +/// Applies the module's embedded migrations in order to bring the database schema up to date. +/// +/// # Returns +/// +/// `Ok(())` on success, `Err(crate::Error)` if obtaining a connection or applying migrations fails. +/// +/// # Examples +/// +/// ```no_run +/// # async fn run() -> Result<(), crate::Error> { +/// let db = /* obtain a UserDatabase */; +/// migrate(&db).await?; +/// # Ok(()) } +/// ``` pub async fn migrate(db: &UserDatabase) -> Result<(), crate::Error> { let conn = db.conn()?; hypr_db_core::migrate(&conn, MIGRATIONS.to_vec()).await?; @@ -198,4 +215,4 @@ mod tests { let user_id = uuid::Uuid::new_v4().to_string(); init::seed(&db, user_id).await.unwrap(); } -} +} \ No newline at end of file diff --git a/crates/db-user/src/templates_ops.rs b/crates/db-user/src/templates_ops.rs index 69f32d870a..b14d683c9e 100644 --- a/crates/db-user/src/templates_ops.rs +++ b/crates/db-user/src/templates_ops.rs @@ -19,6 +19,22 @@ impl UserDatabase { Ok(items) } + /// Inserts a new template or updates an existing one identified by its `id`, then returns the stored template. + /// + /// If a row with the same `id` already exists, this operation updates its `title`, `description`, `sections`, `tags`, and `context_option` and returns the resulting row; otherwise it inserts a new row and returns it. Errors are propagated for database or row-conversion failures. + /// + /// # Examples + /// + /// ```no_run + /// # use crate::{UserDatabase, Template}; + /// # async fn example(db: &UserDatabase, template: Template) -> Result<(), crate::Error> { + /// let stored = db.upsert_template(template).await?; + /// // `stored` now contains the inserted or updated Template as persisted in the database. + /// # Ok(()) + /// # } + /// ``` + /// + /// Returns the inserted or updated `Template`. pub async fn upsert_template(&self, template: Template) -> Result { let conn = self.conn()?; @@ -31,7 +47,8 @@ impl UserDatabase { description, sections, tags, - context_option + context_option, + created_at ) VALUES ( :id, :user_id, @@ -39,7 +56,8 @@ impl UserDatabase { :description, :sections, :tags, - :context_option + :context_option, + :created_at ) ON CONFLICT(id) DO UPDATE SET title = :title, description = :description, @@ -55,6 +73,7 @@ impl UserDatabase { ":sections": serde_json::to_string(&template.sections).unwrap(), ":tags": serde_json::to_string(&template.tags).unwrap(), ":context_option": template.context_option.as_deref().unwrap_or(""), + ":created_at": template.created_at, }, ) .await?; @@ -77,6 +96,50 @@ impl UserDatabase { mod tests { use crate::{tests::setup_db, Human, Template}; + /// Integration test that verifies listing templates for a user is empty, upserting a template, and then retrieving the inserted template. + /// + /// This test: + /// - Creates a test database and a Human (user). + /// - Asserts that listing templates for the user initially returns zero results. + /// - Inserts a Template using `upsert_template`. + /// - Asserts that listing templates for the user returns one result afterwards. + /// + /// # Examples + /// + /// ``` + /// # async fn run_test(db: &crate::UserDatabase) { + /// let human = db + /// .upsert_human(crate::Human { + /// full_name: Some("test".to_string()), + /// ..crate::Human::default() + /// }) + /// .await + /// .unwrap(); + /// + /// let templates = db.list_templates(&human.id).await.unwrap(); + /// assert_eq!(templates.len(), 0); + /// + /// let _template = db + /// .upsert_template(crate::Template { + /// id: uuid::Uuid::new_v4().to_string(), + /// user_id: human.id.clone(), + /// title: "test".to_string(), + /// description: "test".to_string(), + /// sections: vec![], + /// tags: vec![], + /// context_option: Some( + /// r#"{"type":"tags","selections":["Meeting","Project A"]}"#.to_string(), + /// ), + /// created_at: chrono::Utc::now() + /// .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + /// }) + /// .await + /// .unwrap(); + /// + /// let templates = db.list_templates(&human.id).await.unwrap(); + /// assert_eq!(templates.len(), 1); + /// # } + /// ``` #[tokio::test] async fn test_templates() { let db = setup_db().await; @@ -103,6 +166,8 @@ mod tests { context_option: Some( r#"{"type":"tags","selections":["Meeting","Project A"]}"#.to_string(), ), + created_at: chrono::Utc::now() + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), }) .await .unwrap(); @@ -110,4 +175,4 @@ mod tests { let templates = db.list_templates(&human.id).await.unwrap(); assert_eq!(templates.len(), 1); } -} +} \ No newline at end of file diff --git a/crates/db-user/src/templates_types.rs b/crates/db-user/src/templates_types.rs index 181d2bfda7..a95c839363 100644 --- a/crates/db-user/src/templates_types.rs +++ b/crates/db-user/src/templates_types.rs @@ -9,6 +9,7 @@ user_common_derives! { pub sections: Vec, pub tags: Vec, pub context_option: Option, + pub created_at: String, } } @@ -20,6 +21,27 @@ user_common_derives! { } impl Template { + /// Constructs a `Template` from a database row. + /// + /// Maps columns to `Template` fields as follows: + /// - column 0 → `id` (panics if missing), + /// - column 1 → `user_id` (panics if missing), + /// - column 2 → `title` (panics if missing), + /// - column 3 → `description` (panics if missing), + /// - column 4 → `sections` (expects JSON string; defaults to empty `Vec` if absent), + /// - column 5 → `tags` (expects JSON string; defaults to empty `Vec` if absent), + /// - column 6 → `context_option` (set to `None` if missing or unreadable), + /// - column 7 → `created_at` (falls back to current UTC time in RFC3339 seconds precision if unavailable). + /// + /// Note: JSON deserialization for `sections` and `tags` is unwrapped and will panic on invalid JSON. Required primitive columns (id, user_id, title, description) use `expect` and will panic if absent. + /// + /// # Examples + /// + /// ```ignore + /// // `row` must be obtained from libsql query results. + /// let template = Template::from_row(&row).expect("valid row"); + /// assert!(!template.id.is_empty()); + /// ``` pub fn from_row(row: &libsql::Row) -> Result { Ok(Self { id: row.get(0).expect("id"), @@ -35,6 +57,9 @@ impl Template { .map(|s| serde_json::from_str(s).unwrap()) .unwrap_or_default(), context_option: row.get(6).ok(), + created_at: row.get(7).unwrap_or_else(|_| { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + }), }) } -} +} \ No newline at end of file