From c59efdc373c07c48acec60854076abdfdbcc53ea Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Sat, 7 Mar 2026 22:03:57 -0800 Subject: [PATCH] feat(quotesdb): add hidden flag to quotes - Add `hidden: bool` to the `Quote` struct and `hidden: Option` to `UpdateQuoteInput` in `src/lib.rs` - Add `ALTER_QUOTES_ADD_HIDDEN` migration constant in `db/migrations.rs` - Apply the ALTER TABLE migration in `NativeRepository::run_migrations` and `D1Repository::run_migrations` with try/ignore for idempotency - Exclude hidden quotes from `list_quotes` (WHERE hidden = 0) and `get_random_quote` in both native and D1 implementations - Update all SELECT queries to include the `hidden` column - Handle `hidden` field in `update_quote` SET clause for both implementations - Update `MockRepo` and `sample_quote` in handler tests to include `hidden` Co-Authored-By: Claude Sonnet 4.6 --- quotesdb/src/bin/api/db/d1.rs | 43 +++++++++++++-------- quotesdb/src/bin/api/db/migrations.rs | 10 +++++ quotesdb/src/bin/api/db/native.rs | 54 ++++++++++++++++----------- quotesdb/src/bin/api/handlers/mod.rs | 5 +++ quotesdb/src/lib.rs | 14 +++++++ 5 files changed, 89 insertions(+), 37 deletions(-) diff --git a/quotesdb/src/bin/api/db/d1.rs b/quotesdb/src/bin/api/db/d1.rs index 16fb233..29da448 100644 --- a/quotesdb/src/bin/api/db/d1.rs +++ b/quotesdb/src/bin/api/db/d1.rs @@ -20,6 +20,8 @@ struct QuoteRow { author: String, source: Option, date: Option, + /// Stored as an integer (0 = visible, 1 = hidden); converted to bool on deserialization. + hidden: i64, created_at: String, updated_at: String, } @@ -33,6 +35,7 @@ impl QuoteRow { author: self.author, source: self.source, date: self.date, + hidden: self.hidden != 0, created_at: self.created_at, updated_at: self.updated_at, tags, @@ -102,11 +105,12 @@ impl D1Repository { #[async_trait::async_trait(?Send)] impl QuoteRepository for D1Repository { - /// Run the five DDL migration statements from [`super::migrations`]. + /// Run all DDL migration statements from [`super::migrations`]. /// - /// Covers `quotes`, `quote_tags`, tag index, author index, and - /// `admin_config`. Safe to call multiple times — all statements use - /// `IF NOT EXISTS`. + /// Covers `quotes`, `quote_tags`, tag index, author index, `admin_config`, + /// and the `hidden` column ALTER. Safe to call multiple times — CREATE + /// statements use `IF NOT EXISTS`. The ALTER TABLE error (column already + /// exists) is intentionally ignored for idempotency. async fn run_migrations(&self) -> Result<(), DbError> { use super::migrations::*; for sql in &[ @@ -121,6 +125,9 @@ impl QuoteRepository for D1Repository { .await .map_err(|e| DbError::Internal(e.to_string()))?; } + // ALTER TABLE does not support IF NOT EXISTS — ignore the error when + // the column already exists (idempotent on re-runs). + let _ = self.db.exec(ALTER_QUOTES_ADD_HIDDEN).await; Ok(()) } @@ -144,7 +151,8 @@ impl QuoteRepository for D1Repository { let page = page.max(1); // ── Build WHERE clause with positional params ────────────────────── - let mut conditions: Vec = Vec::new(); + // Always exclude hidden quotes from listing endpoints. + let mut conditions: Vec = vec!["q.hidden = 0".to_owned()]; let mut binds: Vec = Vec::new(); let mut param_idx: u32 = 1; @@ -175,11 +183,7 @@ impl QuoteRepository for D1Repository { param_idx += 1; } - let where_clause = if conditions.is_empty() { - String::new() - } else { - format!("WHERE {}", conditions.join(" AND ")) - }; + let where_clause = format!("WHERE {}", conditions.join(" AND ")); // ── Count total matching rows ────────────────────────────────────── let count_sql = format!("SELECT COUNT(*) as count FROM quotes q {where_clause}"); @@ -200,7 +204,7 @@ impl QuoteRepository for D1Repository { // ── Fetch the page of quotes ─────────────────────────────────────── let list_sql = format!( "SELECT q.id, q.text, q.author, q.source, q.date, \ - q.created_at, q.updated_at \ + q.hidden, q.created_at, q.updated_at \ FROM quotes q {where_clause} \ ORDER BY q.created_at DESC \ LIMIT ?{param_idx} OFFSET ?{}", @@ -244,7 +248,7 @@ impl QuoteRepository for D1Repository { let row = self .db .prepare( - "SELECT id, text, author, source, date, created_at, updated_at \ + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ FROM quotes WHERE id = ?1", ) .bind(&[JsValue::from_str(id)]) @@ -269,8 +273,8 @@ impl QuoteRepository for D1Repository { let row = self .db .prepare( - "SELECT id, text, author, source, date, created_at, updated_at \ - FROM quotes ORDER BY RANDOM() LIMIT 1", + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ + FROM quotes WHERE hidden = 0 ORDER BY RANDOM() LIMIT 1", ) .first::(None) .await @@ -343,7 +347,7 @@ impl QuoteRepository for D1Repository { let row = self .db .prepare( - "SELECT id, text, author, source, date, created_at, updated_at \ + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ FROM quotes WHERE id = ?1", ) .bind(&[JsValue::from_str(&id)]) @@ -426,6 +430,13 @@ impl QuoteRepository for D1Repository { ); param_idx += 1; + // hidden is only updated when explicitly provided + if let Some(h) = input.hidden { + sets.push(format!("hidden = ?{param_idx}")); + binds.push(JsValue::from_f64(if h { 1.0 } else { 0.0 })); + param_idx += 1; + } + sets.push("updated_at = datetime('now')".to_string()); let update_sql = format!( @@ -476,7 +487,7 @@ impl QuoteRepository for D1Repository { let row = self .db .prepare( - "SELECT id, text, author, source, date, created_at, updated_at \ + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ FROM quotes WHERE id = ?1", ) .bind(&[JsValue::from_str(id)]) diff --git a/quotesdb/src/bin/api/db/migrations.rs b/quotesdb/src/bin/api/db/migrations.rs index 4111c76..700e56a 100644 --- a/quotesdb/src/bin/api/db/migrations.rs +++ b/quotesdb/src/bin/api/db/migrations.rs @@ -47,3 +47,13 @@ CREATE TABLE IF NOT EXISTS admin_config ( key TEXT PRIMARY KEY, value TEXT NOT NULL )"; + +/// Adds the `hidden` column to the `quotes` table. +/// +/// This is a schema migration for existing databases. The column defaults to +/// `0` (not hidden) so all pre-existing quotes remain publicly visible. +/// +/// SQLite does not support `ADD COLUMN IF NOT EXISTS`, so callers must +/// ignore the error when the column already exists (e.g., on repeated startup). +pub const ALTER_QUOTES_ADD_HIDDEN: &str = "\ +ALTER TABLE quotes ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0"; diff --git a/quotesdb/src/bin/api/db/native.rs b/quotesdb/src/bin/api/db/native.rs index 573d6f0..95f6e00 100644 --- a/quotesdb/src/bin/api/db/native.rs +++ b/quotesdb/src/bin/api/db/native.rs @@ -39,28 +39,31 @@ fn fetch_tags_for_quote( Ok(tags) } -/// Map rusqlite columns (id, text, author, source, date, created_at, updated_at) +/// Map rusqlite columns (id, text, author, source, date, hidden, created_at, updated_at) /// plus a pre-fetched tags vec into a [`Quote`]. fn row_to_quote(row: &rusqlite::Row<'_>, tags: Vec) -> Result { + let hidden_int: i64 = row.get(5)?; Ok(Quote { id: row.get(0)?, text: row.get(1)?, author: row.get(2)?, source: row.get(3)?, date: row.get(4)?, - created_at: row.get(5)?, - updated_at: row.get(6)?, + hidden: hidden_int != 0, + created_at: row.get(6)?, + updated_at: row.get(7)?, tags, }) } #[async_trait::async_trait] impl QuoteRepository for NativeRepository { - /// Run the five DDL migration statements from [`super::migrations`]. + /// Run all DDL migration statements from [`super::migrations`]. /// - /// Covers `quotes`, `quote_tags`, tag index, author index, and - /// `admin_config`. Safe to call multiple times — all statements use - /// `IF NOT EXISTS`. + /// Covers `quotes`, `quote_tags`, tag index, author index, `admin_config`, + /// and the `hidden` column ALTER. Safe to call multiple times — CREATE + /// statements use `IF NOT EXISTS`, and the ALTER TABLE error (column + /// already exists) is intentionally ignored. async fn run_migrations(&self) -> Result<(), DbError> { self.conn .call(|conn| { @@ -69,6 +72,9 @@ impl QuoteRepository for NativeRepository { "{CREATE_QUOTES}; {CREATE_QUOTE_TAGS}; \ {CREATE_TAG_INDEX}; {CREATE_AUTHOR_INDEX}; {CREATE_ADMIN_CONFIG};" ))?; + // ALTER TABLE does not support IF NOT EXISTS — ignore the error + // when the column already exists (idempotent on re-runs). + let _ = conn.execute(ALTER_QUOTES_ADD_HIDDEN, []); Ok(()) }) .await @@ -102,7 +108,8 @@ impl QuoteRepository for NativeRepository { const PAGE_SIZE: i64 = 10; // ── Build WHERE clause ──────────────────────────────────── - let mut conditions: Vec = Vec::new(); + // Always exclude hidden quotes from listing endpoints. + let mut conditions: Vec = vec!["q.hidden = 0".to_owned()]; if author.is_some() { conditions.push("q.author = ? COLLATE NOCASE".to_owned()); } @@ -120,11 +127,7 @@ impl QuoteRepository for NativeRepository { if date_before.is_some() { conditions.push("q.date <= ?".to_owned()); } - let where_clause = if conditions.is_empty() { - String::new() - } else { - format!("WHERE {}", conditions.join(" AND ")) - }; + let where_clause = format!("WHERE {}", conditions.join(" AND ")); // Collect bound params in order for both queries let mut params: Vec> = Vec::new(); @@ -158,7 +161,7 @@ impl QuoteRepository for NativeRepository { // ── Fetch the page of quotes ────────────────────────────── let list_sql = format!( "SELECT q.id, q.text, q.author, q.source, q.date, \ - q.created_at, q.updated_at \ + q.hidden, q.created_at, q.updated_at \ FROM quotes q {where_clause} \ ORDER BY q.created_at DESC \ LIMIT ? OFFSET ?" @@ -189,14 +192,16 @@ impl QuoteRepository for NativeRepository { .query_map( rusqlite::params_from_iter(param_refs2.iter().copied()), |row| { + let hidden_int: i64 = row.get(5)?; Ok(Quote { id: row.get(0)?, text: row.get(1)?, author: row.get(2)?, source: row.get(3)?, date: row.get(4)?, - created_at: row.get(5)?, - updated_at: row.get(6)?, + hidden: hidden_int != 0, + created_at: row.get(6)?, + updated_at: row.get(7)?, tags: vec![], }) }, @@ -231,7 +236,7 @@ impl QuoteRepository for NativeRepository { self.conn .call(move |conn| { let mut stmt = conn.prepare( - "SELECT id, text, author, source, date, created_at, updated_at \ + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ FROM quotes WHERE id = ?", )?; let mut rows = stmt.query([&id as &str])?; @@ -254,8 +259,8 @@ impl QuoteRepository for NativeRepository { self.conn .call(|conn| { let mut stmt = conn.prepare( - "SELECT id, text, author, source, date, created_at, updated_at \ - FROM quotes ORDER BY RANDOM() LIMIT 1", + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ + FROM quotes WHERE hidden = 0 ORDER BY RANDOM() LIMIT 1", )?; let mut rows = stmt.query([])?; match rows.next()? { @@ -307,7 +312,7 @@ impl QuoteRepository for NativeRepository { // Read back the inserted row to obtain server-generated timestamps let mut stmt = conn.prepare( - "SELECT id, text, author, source, date, created_at, updated_at \ + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ FROM quotes WHERE id = ?", )?; let mut rows = stmt.query([&id2 as &str])?; @@ -377,6 +382,9 @@ impl QuoteRepository for NativeRepository { } sets.push("source = ?".to_owned()); sets.push("date = ?".to_owned()); + if input.hidden.is_some() { + sets.push("hidden = ?".to_owned()); + } sets.push("updated_at = datetime('now')".to_owned()); let sql = format!("UPDATE quotes SET {} WHERE id = ?", sets.join(", ")); @@ -392,6 +400,9 @@ impl QuoteRepository for NativeRepository { // source and date may be null (None clears the field) params.push(Box::new(input.source.clone())); params.push(Box::new(input.date.clone())); + if let Some(h) = input.hidden { + params.push(Box::new(if h { 1i64 } else { 0i64 })); + } params.push(Box::new(id.clone())); let param_refs: Vec<&dyn rusqlite::types::ToSql> = @@ -412,7 +423,7 @@ impl QuoteRepository for NativeRepository { // Read back the updated quote let mut stmt = conn.prepare( - "SELECT id, text, author, source, date, created_at, updated_at \ + "SELECT id, text, author, source, date, hidden, created_at, updated_at \ FROM quotes WHERE id = ?", )?; let mut rows = stmt.query([&id as &str])?; @@ -815,6 +826,7 @@ mod tests { source: None, date: None, tags: Some(vec!["new".to_owned()]), + hidden: None, }, &auth, ) diff --git a/quotesdb/src/bin/api/handlers/mod.rs b/quotesdb/src/bin/api/handlers/mod.rs index 33f37e8..f591b1b 100644 --- a/quotesdb/src/bin/api/handlers/mod.rs +++ b/quotesdb/src/bin/api/handlers/mod.rs @@ -685,6 +685,7 @@ mod tests { source: input.source, date: input.date, tags: input.tags, + hidden: false, created_at: "2024-01-01T00:00:00".to_owned(), updated_at: "2024-01-01T00:00:00".to_owned(), }; @@ -720,6 +721,9 @@ mod tests { if let Some(tags) = input.tags { q.tags = tags; } + if let Some(h) = input.hidden { + q.hidden = h; + } Ok(q.clone()) } } @@ -793,6 +797,7 @@ mod tests { source: None, date: None, tags: vec![], + hidden: false, created_at: "2024-01-01T00:00:00".to_owned(), updated_at: "2024-01-01T00:00:00".to_owned(), } diff --git a/quotesdb/src/lib.rs b/quotesdb/src/lib.rs index c9257ff..f846d4f 100644 --- a/quotesdb/src/lib.rs +++ b/quotesdb/src/lib.rs @@ -92,10 +92,12 @@ const WORDS: &[&str] = &[ /// source: None, /// date: None, /// tags: vec![], +/// hidden: false, /// created_at: "2024-01-01T00:00:00Z".to_string(), /// updated_at: "2024-01-01T00:00:00Z".to_string(), /// }; /// assert_eq!(q.id, "abc123"); +/// assert!(!q.hidden); /// ``` #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Quote { @@ -111,6 +113,13 @@ pub struct Quote { pub date: Option, /// Zero or more tags for categorisation. pub tags: Vec, + /// Whether the quote is hidden from listing endpoints. + /// + /// Hidden quotes are excluded from `GET /api/quotes` and + /// `GET /api/quotes/random` but remain accessible via + /// `GET /api/quotes/:id`. Toggling this field requires a valid + /// `X-Auth-Code` header. + pub hidden: bool, /// ISO 8601 creation timestamp. pub created_at: String, /// ISO 8601 last-update timestamp. @@ -171,8 +180,10 @@ pub struct CreateQuoteInput { /// source: None, /// date: None, /// tags: None, +/// hidden: Some(true), /// }; /// assert_eq!(input.text.unwrap(), "Updated text"); +/// assert_eq!(input.hidden, Some(true)); /// ``` #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct UpdateQuoteInput { @@ -188,6 +199,9 @@ pub struct UpdateQuoteInput { pub date: Option, /// Replacement tags. If provided, replaces the entire tag set. pub tags: Option>, + /// Toggle the hidden flag. Requires valid `X-Auth-Code` header regardless + /// of direction (hide or unhide). + pub hidden: Option, } // ── Public functions ──────────────────────────────────────────────────────────