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>
main
Elijah Voigt 3 months ago
parent 2272a258f6
commit 1c0d1eb37f

@ -20,6 +20,8 @@ struct QuoteRow {
author: String,
source: Option<String>,
date: Option<String>,
/// 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<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 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::<QuoteRow>(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)])

@ -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";

@ -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<String>) -> Result<Quote, rusqlite::Error> {
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<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() {
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<Box<dyn rusqlite::types::ToSql>> = 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,
)

@ -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(),
}

@ -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<String>,
/// Zero or more tags for categorisation.
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.
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<String>,
/// Replacement tags. If provided, replaces the entire tag set.
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 ──────────────────────────────────────────────────────────

Loading…
Cancel
Save