feat(quotesdb): add hidden flag to quotes

- Add `hidden: bool` to the `Quote` struct and `hidden: Option<bool>` 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 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent 549accded0
commit c59efdc373

@ -20,6 +20,8 @@ struct QuoteRow {
author: String, author: String,
source: Option<String>, source: Option<String>,
date: Option<String>, date: Option<String>,
/// Stored as an integer (0 = visible, 1 = hidden); converted to bool on deserialization.
hidden: i64,
created_at: String, created_at: String,
updated_at: String, updated_at: String,
} }
@ -33,6 +35,7 @@ impl QuoteRow {
author: self.author, author: self.author,
source: self.source, source: self.source,
date: self.date, date: self.date,
hidden: self.hidden != 0,
created_at: self.created_at, created_at: self.created_at,
updated_at: self.updated_at, updated_at: self.updated_at,
tags, tags,
@ -102,11 +105,12 @@ impl D1Repository {
#[async_trait::async_trait(?Send)] #[async_trait::async_trait(?Send)]
impl QuoteRepository for D1Repository { 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 /// Covers `quotes`, `quote_tags`, tag index, author index, `admin_config`,
/// `admin_config`. Safe to call multiple times — all statements use /// and the `hidden` column ALTER. Safe to call multiple times — CREATE
/// `IF NOT EXISTS`. /// statements use `IF NOT EXISTS`. The ALTER TABLE error (column already
/// exists) is intentionally ignored for idempotency.
async fn run_migrations(&self) -> Result<(), DbError> { async fn run_migrations(&self) -> Result<(), DbError> {
use super::migrations::*; use super::migrations::*;
for sql in &[ for sql in &[
@ -121,6 +125,9 @@ impl QuoteRepository for D1Repository {
.await .await
.map_err(|e| DbError::Internal(e.to_string()))?; .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(()) Ok(())
} }
@ -144,7 +151,8 @@ impl QuoteRepository for D1Repository {
let page = page.max(1); let page = page.max(1);
// ── Build WHERE clause with positional params ────────────────────── // ── Build WHERE clause with positional params ──────────────────────
let mut conditions: Vec<String> = Vec::new(); // Always exclude hidden quotes from listing endpoints.
let mut conditions: Vec<String> = vec!["q.hidden = 0".to_owned()];
let mut binds: Vec<JsValue> = Vec::new(); let mut binds: Vec<JsValue> = Vec::new();
let mut param_idx: u32 = 1; let mut param_idx: u32 = 1;
@ -175,11 +183,7 @@ impl QuoteRepository for D1Repository {
param_idx += 1; param_idx += 1;
} }
let where_clause = if conditions.is_empty() { let where_clause = format!("WHERE {}", conditions.join(" AND "));
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
// ── Count total matching rows ────────────────────────────────────── // ── Count total matching rows ──────────────────────────────────────
let count_sql = format!("SELECT COUNT(*) as count FROM quotes q {where_clause}"); 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 ─────────────────────────────────────── // ── Fetch the page of quotes ───────────────────────────────────────
let list_sql = format!( let list_sql = format!(
"SELECT q.id, q.text, q.author, q.source, q.date, \ "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} \ FROM quotes q {where_clause} \
ORDER BY q.created_at DESC \ ORDER BY q.created_at DESC \
LIMIT ?{param_idx} OFFSET ?{}", LIMIT ?{param_idx} OFFSET ?{}",
@ -244,7 +248,7 @@ impl QuoteRepository for D1Repository {
let row = self let row = self
.db .db
.prepare( .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", FROM quotes WHERE id = ?1",
) )
.bind(&[JsValue::from_str(id)]) .bind(&[JsValue::from_str(id)])
@ -269,8 +273,8 @@ impl QuoteRepository for D1Repository {
let row = self let row = self
.db .db
.prepare( .prepare(
"SELECT id, text, author, source, date, created_at, updated_at \ "SELECT id, text, author, source, date, hidden, created_at, updated_at \
FROM quotes ORDER BY RANDOM() LIMIT 1", FROM quotes WHERE hidden = 0 ORDER BY RANDOM() LIMIT 1",
) )
.first::<QuoteRow>(None) .first::<QuoteRow>(None)
.await .await
@ -343,7 +347,7 @@ impl QuoteRepository for D1Repository {
let row = self let row = self
.db .db
.prepare( .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", FROM quotes WHERE id = ?1",
) )
.bind(&[JsValue::from_str(&id)]) .bind(&[JsValue::from_str(&id)])
@ -426,6 +430,13 @@ impl QuoteRepository for D1Repository {
); );
param_idx += 1; 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()); sets.push("updated_at = datetime('now')".to_string());
let update_sql = format!( let update_sql = format!(
@ -476,7 +487,7 @@ impl QuoteRepository for D1Repository {
let row = self let row = self
.db .db
.prepare( .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", FROM quotes WHERE id = ?1",
) )
.bind(&[JsValue::from_str(id)]) .bind(&[JsValue::from_str(id)])

@ -47,3 +47,13 @@ CREATE TABLE IF NOT EXISTS admin_config (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL 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";

@ -39,28 +39,31 @@ fn fetch_tags_for_quote(
Ok(tags) 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`]. /// plus a pre-fetched tags vec into a [`Quote`].
fn row_to_quote(row: &rusqlite::Row<'_>, tags: Vec<String>) -> Result<Quote, rusqlite::Error> { fn row_to_quote(row: &rusqlite::Row<'_>, tags: Vec<String>) -> Result<Quote, rusqlite::Error> {
let hidden_int: i64 = row.get(5)?;
Ok(Quote { Ok(Quote {
id: row.get(0)?, id: row.get(0)?,
text: row.get(1)?, text: row.get(1)?,
author: row.get(2)?, author: row.get(2)?,
source: row.get(3)?, source: row.get(3)?,
date: row.get(4)?, date: row.get(4)?,
created_at: row.get(5)?, hidden: hidden_int != 0,
updated_at: row.get(6)?, created_at: row.get(6)?,
updated_at: row.get(7)?,
tags, tags,
}) })
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl QuoteRepository for NativeRepository { 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 /// Covers `quotes`, `quote_tags`, tag index, author index, `admin_config`,
/// `admin_config`. Safe to call multiple times — all statements use /// and the `hidden` column ALTER. Safe to call multiple times — CREATE
/// `IF NOT EXISTS`. /// statements use `IF NOT EXISTS`, and the ALTER TABLE error (column
/// already exists) is intentionally ignored.
async fn run_migrations(&self) -> Result<(), DbError> { async fn run_migrations(&self) -> Result<(), DbError> {
self.conn self.conn
.call(|conn| { .call(|conn| {
@ -69,6 +72,9 @@ impl QuoteRepository for NativeRepository {
"{CREATE_QUOTES}; {CREATE_QUOTE_TAGS}; \ "{CREATE_QUOTES}; {CREATE_QUOTE_TAGS}; \
{CREATE_TAG_INDEX}; {CREATE_AUTHOR_INDEX}; {CREATE_ADMIN_CONFIG};" {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(()) Ok(())
}) })
.await .await
@ -102,7 +108,8 @@ impl QuoteRepository for NativeRepository {
const PAGE_SIZE: i64 = 10; const PAGE_SIZE: i64 = 10;
// ── Build WHERE clause ──────────────────────────────────── // ── Build WHERE clause ────────────────────────────────────
let mut conditions: Vec<String> = Vec::new(); // Always exclude hidden quotes from listing endpoints.
let mut conditions: Vec<String> = vec!["q.hidden = 0".to_owned()];
if author.is_some() { if author.is_some() {
conditions.push("q.author = ? COLLATE NOCASE".to_owned()); conditions.push("q.author = ? COLLATE NOCASE".to_owned());
} }
@ -120,11 +127,7 @@ impl QuoteRepository for NativeRepository {
if date_before.is_some() { if date_before.is_some() {
conditions.push("q.date <= ?".to_owned()); conditions.push("q.date <= ?".to_owned());
} }
let where_clause = if conditions.is_empty() { let where_clause = format!("WHERE {}", conditions.join(" AND "));
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
// Collect bound params in order for both queries // Collect bound params in order for both queries
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new(); let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
@ -158,7 +161,7 @@ impl QuoteRepository for NativeRepository {
// ── Fetch the page of quotes ────────────────────────────── // ── Fetch the page of quotes ──────────────────────────────
let list_sql = format!( let list_sql = format!(
"SELECT q.id, q.text, q.author, q.source, q.date, \ "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} \ FROM quotes q {where_clause} \
ORDER BY q.created_at DESC \ ORDER BY q.created_at DESC \
LIMIT ? OFFSET ?" LIMIT ? OFFSET ?"
@ -189,14 +192,16 @@ impl QuoteRepository for NativeRepository {
.query_map( .query_map(
rusqlite::params_from_iter(param_refs2.iter().copied()), rusqlite::params_from_iter(param_refs2.iter().copied()),
|row| { |row| {
let hidden_int: i64 = row.get(5)?;
Ok(Quote { Ok(Quote {
id: row.get(0)?, id: row.get(0)?,
text: row.get(1)?, text: row.get(1)?,
author: row.get(2)?, author: row.get(2)?,
source: row.get(3)?, source: row.get(3)?,
date: row.get(4)?, date: row.get(4)?,
created_at: row.get(5)?, hidden: hidden_int != 0,
updated_at: row.get(6)?, created_at: row.get(6)?,
updated_at: row.get(7)?,
tags: vec![], tags: vec![],
}) })
}, },
@ -231,7 +236,7 @@ impl QuoteRepository for NativeRepository {
self.conn self.conn
.call(move |conn| { .call(move |conn| {
let mut stmt = conn.prepare( 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 = ?", FROM quotes WHERE id = ?",
)?; )?;
let mut rows = stmt.query([&id as &str])?; let mut rows = stmt.query([&id as &str])?;
@ -254,8 +259,8 @@ impl QuoteRepository for NativeRepository {
self.conn self.conn
.call(|conn| { .call(|conn| {
let mut stmt = conn.prepare( 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 ORDER BY RANDOM() LIMIT 1", FROM quotes WHERE hidden = 0 ORDER BY RANDOM() LIMIT 1",
)?; )?;
let mut rows = stmt.query([])?; let mut rows = stmt.query([])?;
match rows.next()? { match rows.next()? {
@ -307,7 +312,7 @@ impl QuoteRepository for NativeRepository {
// Read back the inserted row to obtain server-generated timestamps // Read back the inserted row to obtain server-generated timestamps
let mut stmt = conn.prepare( 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 = ?", FROM quotes WHERE id = ?",
)?; )?;
let mut rows = stmt.query([&id2 as &str])?; let mut rows = stmt.query([&id2 as &str])?;
@ -377,6 +382,9 @@ impl QuoteRepository for NativeRepository {
} }
sets.push("source = ?".to_owned()); sets.push("source = ?".to_owned());
sets.push("date = ?".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()); sets.push("updated_at = datetime('now')".to_owned());
let sql = format!("UPDATE quotes SET {} WHERE id = ?", sets.join(", ")); 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) // source and date may be null (None clears the field)
params.push(Box::new(input.source.clone())); params.push(Box::new(input.source.clone()));
params.push(Box::new(input.date.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())); params.push(Box::new(id.clone()));
let param_refs: Vec<&dyn rusqlite::types::ToSql> = let param_refs: Vec<&dyn rusqlite::types::ToSql> =
@ -412,7 +423,7 @@ impl QuoteRepository for NativeRepository {
// Read back the updated quote // Read back the updated quote
let mut stmt = conn.prepare( 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 = ?", FROM quotes WHERE id = ?",
)?; )?;
let mut rows = stmt.query([&id as &str])?; let mut rows = stmt.query([&id as &str])?;
@ -815,6 +826,7 @@ mod tests {
source: None, source: None,
date: None, date: None,
tags: Some(vec!["new".to_owned()]), tags: Some(vec!["new".to_owned()]),
hidden: None,
}, },
&auth, &auth,
) )

@ -685,6 +685,7 @@ mod tests {
source: input.source, source: input.source,
date: input.date, date: input.date,
tags: input.tags, tags: input.tags,
hidden: false,
created_at: "2024-01-01T00:00:00".to_owned(), created_at: "2024-01-01T00:00:00".to_owned(),
updated_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 { if let Some(tags) = input.tags {
q.tags = tags; q.tags = tags;
} }
if let Some(h) = input.hidden {
q.hidden = h;
}
Ok(q.clone()) Ok(q.clone())
} }
} }
@ -793,6 +797,7 @@ mod tests {
source: None, source: None,
date: None, date: None,
tags: vec![], tags: vec![],
hidden: false,
created_at: "2024-01-01T00:00:00".to_owned(), created_at: "2024-01-01T00:00:00".to_owned(),
updated_at: "2024-01-01T00:00:00".to_owned(), updated_at: "2024-01-01T00:00:00".to_owned(),
} }

@ -92,10 +92,12 @@ const WORDS: &[&str] = &[
/// source: None, /// source: None,
/// date: None, /// date: None,
/// tags: vec![], /// tags: vec![],
/// hidden: false,
/// created_at: "2024-01-01T00:00:00Z".to_string(), /// created_at: "2024-01-01T00:00:00Z".to_string(),
/// updated_at: "2024-01-01T00:00:00Z".to_string(), /// updated_at: "2024-01-01T00:00:00Z".to_string(),
/// }; /// };
/// assert_eq!(q.id, "abc123"); /// assert_eq!(q.id, "abc123");
/// assert!(!q.hidden);
/// ``` /// ```
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Quote { pub struct Quote {
@ -111,6 +113,13 @@ pub struct Quote {
pub date: Option<String>, pub date: Option<String>,
/// Zero or more tags for categorisation. /// Zero or more tags for categorisation.
pub tags: Vec<String>, pub tags: Vec<String>,
/// 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. /// ISO 8601 creation timestamp.
pub created_at: String, pub created_at: String,
/// ISO 8601 last-update timestamp. /// ISO 8601 last-update timestamp.
@ -171,8 +180,10 @@ pub struct CreateQuoteInput {
/// source: None, /// source: None,
/// date: None, /// date: None,
/// tags: None, /// tags: None,
/// hidden: Some(true),
/// }; /// };
/// assert_eq!(input.text.unwrap(), "Updated text"); /// assert_eq!(input.text.unwrap(), "Updated text");
/// assert_eq!(input.hidden, Some(true));
/// ``` /// ```
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateQuoteInput { pub struct UpdateQuoteInput {
@ -188,6 +199,9 @@ pub struct UpdateQuoteInput {
pub date: Option<String>, pub date: Option<String>,
/// Replacement tags. If provided, replaces the entire tag set. /// Replacement tags. If provided, replaces the entire tag set.
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
/// Toggle the hidden flag. Requires valid `X-Auth-Code` header regardless
/// of direction (hide or unhide).
pub hidden: Option<bool>,
} }
// ── Public functions ────────────────────────────────────────────────────────── // ── Public functions ──────────────────────────────────────────────────────────

Loading…
Cancel
Save