From eecdbba9d78fe15b6dc5e40aa19183aa43820165 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Sun, 8 Mar 2026 11:04:02 -0700 Subject: [PATCH] feat(quotesdb): add reports table and POST /api/quotes/:id/report endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CREATE_REPORTS migration constant (was unused — now wired in) - Wire CREATE_REPORTS into run_migrations for both NativeRepository and D1Repository - Add create_report to QuoteRepository trait with NotFound semantics - Implement create_report in NativeRepository (two-step: existence check then insert) - Implement create_report in D1Repository (two-step: COUNT check then insert) - Add report_handler: POST /api/quotes/{id}/report, 201/400/404/500 - Register route before /{id} in router so static /report suffix wins - Add create_report to MockRepo in handler tests - Add handler tests: test_report_success, test_report_quote_not_found, test_report_reason_too_long - Add DB tests: test_create_report_success, test_create_report_not_found - Add ReportInput schema and /api/quotes/{id}/report path to openapi.yaml Co-Authored-By: Claude Sonnet 4.6 --- quotesdb/api/openapi.yaml | 54 +++++++++++++ quotesdb/src/bin/api/db/d1.rs | 47 +++++++++++ quotesdb/src/bin/api/db/migrations.rs | 14 ++++ quotesdb/src/bin/api/db/mod.rs | 9 +++ quotesdb/src/bin/api/db/native.rs | 76 +++++++++++++++++- quotesdb/src/bin/api/handlers/mod.rs | 109 +++++++++++++++++++++++++- 6 files changed, 306 insertions(+), 3 deletions(-) diff --git a/quotesdb/api/openapi.yaml b/quotesdb/api/openapi.yaml index 8f25a76..73e5637 100644 --- a/quotesdb/api/openapi.yaml +++ b/quotesdb/api/openapi.yaml @@ -242,6 +242,15 @@ components: description: Human-readable error message. example: "quote not found" + # Request body for POST /api/quotes/{id}/report (all fields optional). + ReportInput: + type: object + properties: + reason: + type: string + maxLength: 256 + description: Optional reason for reporting the quote. + # --------------------------------------------------------------------------- # Paths # --------------------------------------------------------------------------- @@ -624,6 +633,51 @@ paths: schema: $ref: "#/components/schemas/Error" + /api/quotes/{id}/report: + post: + operationId: reportQuote + summary: Report a quote for moderation + description: > + Submits a moderation report for the identified quote. The request body + and reason field are both optional — an empty body is accepted. + Reason must not exceed 256 characters. + tags: [quotes] + parameters: + - name: id + in: path + required: true + description: NanoID of the quote to report (~21 characters). + schema: + type: string + example: "V1StGXR8_Z5jdHi6B-myT" + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/ReportInput" + responses: + "201": + description: Report submitted successfully. No response body. + "400": + description: Reason exceeds 256 characters. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Quote not found. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + description: Internal server error. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + # --------------------------------------------------------------------------- # Tags (for grouping in generated docs) # --------------------------------------------------------------------------- diff --git a/quotesdb/src/bin/api/db/d1.rs b/quotesdb/src/bin/api/db/d1.rs index 29da448..f28e571 100644 --- a/quotesdb/src/bin/api/db/d1.rs +++ b/quotesdb/src/bin/api/db/d1.rs @@ -119,6 +119,7 @@ impl QuoteRepository for D1Repository { CREATE_TAG_INDEX, CREATE_AUTHOR_INDEX, CREATE_ADMIN_CONFIG, + CREATE_REPORTS, ] { self.db .exec(sql) @@ -675,4 +676,50 @@ impl QuoteRepository for D1Repository { .map(|_| ()) .map_err(|e| DbError::Internal(e.to_string())) } + + /// Create a moderation report for an existing quote. + /// + /// Checks that the quote exists via a COUNT query, then inserts a new row + /// into the `reports` table. Returns `Err(DbError::NotFound)` if no quote + /// with the given `quote_id` exists. + async fn create_report(&self, quote_id: &str, reason: Option<&str>) -> Result<(), DbError> { + // Step 1: verify the quote exists. + #[derive(serde::Deserialize)] + struct ExistsRow { + count: i64, + } + + let exists_row = self + .db + .prepare("SELECT COUNT(*) AS count FROM quotes WHERE id = ?1") + .bind(&[JsValue::from_str(quote_id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + let exists = exists_row.map(|r| r.count > 0).unwrap_or(false); + if !exists { + return Err(DbError::NotFound); + } + + // Step 2: insert the report row. + let id = generate_id(); + let reason_value = match reason { + Some(r) => JsValue::from_str(r), + None => JsValue::NULL, + }; + self.db + .prepare("INSERT INTO reports (id, quote_id, reason) VALUES (?1, ?2, ?3)") + .bind(&[ + JsValue::from_str(&id), + JsValue::from_str(quote_id), + reason_value, + ]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map(|_| ()) + .map_err(|e| DbError::Internal(e.to_string())) + } } diff --git a/quotesdb/src/bin/api/db/migrations.rs b/quotesdb/src/bin/api/db/migrations.rs index 700e56a..a76a56f 100644 --- a/quotesdb/src/bin/api/db/migrations.rs +++ b/quotesdb/src/bin/api/db/migrations.rs @@ -57,3 +57,17 @@ CREATE TABLE IF NOT EXISTS admin_config ( /// 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"; + +/// Creates the `reports` table if it does not already exist. +/// +/// Each row represents one user-submitted report against a quote. +/// `quote_id` references `quotes(id)` with `ON DELETE CASCADE` so reports +/// are removed automatically when the associated quote is deleted. +/// `reason` is optional and capped at 256 characters by application logic. +pub const CREATE_REPORTS: &str = "\ +CREATE TABLE IF NOT EXISTS reports ( + id TEXT PRIMARY KEY, + quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE, + reason TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +)"; diff --git a/quotesdb/src/bin/api/db/mod.rs b/quotesdb/src/bin/api/db/mod.rs index 86e5295..7f727be 100644 --- a/quotesdb/src/bin/api/db/mod.rs +++ b/quotesdb/src/bin/api/db/mod.rs @@ -180,4 +180,13 @@ pub trait QuoteRepository { /// Uses `INSERT OR IGNORE` so calling it multiple times is safe. /// Call once on startup to ensure the key exists. async fn seed_submissions_locked(&self) -> Result<(), DbError>; + + /// Create a moderation report for an existing quote. + /// + /// `reason` is optional and should be at most 256 chars (enforced at the + /// handler layer before this method is called). + /// + /// Returns `Err(DbError::NotFound)` if `quote_id` does not exist in the + /// `quotes` table. + async fn create_report(&self, quote_id: &str, reason: Option<&str>) -> Result<(), DbError>; } diff --git a/quotesdb/src/bin/api/db/native.rs b/quotesdb/src/bin/api/db/native.rs index f4b8401..4eeb5dc 100644 --- a/quotesdb/src/bin/api/db/native.rs +++ b/quotesdb/src/bin/api/db/native.rs @@ -70,7 +70,8 @@ impl QuoteRepository for NativeRepository { use super::migrations::*; conn.execute_batch(&format!( "{CREATE_QUOTES}; {CREATE_QUOTE_TAGS}; \ - {CREATE_TAG_INDEX}; {CREATE_AUTHOR_INDEX}; {CREATE_ADMIN_CONFIG};" + {CREATE_TAG_INDEX}; {CREATE_AUTHOR_INDEX}; {CREATE_ADMIN_CONFIG}; \ + {CREATE_REPORTS};" ))?; // ALTER TABLE does not support IF NOT EXISTS — ignore the error // when the column already exists (idempotent on re-runs). @@ -604,6 +605,50 @@ impl QuoteRepository for NativeRepository { .await .map_err(|e| DbError::Internal(e.to_string())) } + + /// Create a moderation report for an existing quote. + /// + /// Uses a two-step approach: first checks that the quote exists, then + /// inserts the report row. Returns `Err(DbError::NotFound)` if the quote + /// does not exist. + async fn create_report(&self, quote_id: &str, reason: Option<&str>) -> Result<(), DbError> { + let quote_id = quote_id.to_owned(); + let reason = reason.map(|s| s.to_owned()); + + // Step 1: verify the quote exists. + let exists = self + .conn + .call({ + let quote_id = quote_id.clone(); + move |conn| { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM quotes WHERE id = ?1", + rusqlite::params![quote_id], + |row| row.get(0), + )?; + Ok(count > 0) + } + }) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + if !exists { + return Err(DbError::NotFound); + } + + // Step 2: insert the report row. + self.conn + .call(move |conn| { + let id = quotesdb::generate_id(); + conn.execute( + "INSERT INTO reports (id, quote_id, reason) VALUES (?1, ?2, ?3)", + rusqlite::params![id, quote_id, reason], + )?; + Ok(()) + }) + .await + .map_err(|e| DbError::Internal(e.to_string())) + } } #[cfg(test)] @@ -1064,6 +1109,35 @@ mod tests { ); } + // ── create_report tests ──────────────────────────────────────────────────── + + /// `create_report` succeeds when the referenced quote exists. + #[tokio::test] + async fn test_create_report_success() { + let repo = in_memory_repo().await; + let (quote, _) = repo + .create_quote(make_input("Report me", "Author")) + .await + .unwrap(); + + let result = repo.create_report("e.id, Some("spam")).await; + assert!( + result.is_ok(), + "create_report should succeed for an existing quote; got {result:?}" + ); + } + + /// `create_report` returns `Err(DbError::NotFound)` when the quote does not exist. + #[tokio::test] + async fn test_create_report_not_found() { + let repo = in_memory_repo().await; + let result = repo.create_report("nonexistent-id", None).await; + assert!( + matches!(result, Err(DbError::NotFound)), + "create_report should return NotFound for an unknown quote; got {result:?}" + ); + } + /// `get_quote` (direct ID lookup) must return the quote even when it is hidden. #[tokio::test] async fn test_get_quote_returns_hidden_quote() { diff --git a/quotesdb/src/bin/api/handlers/mod.rs b/quotesdb/src/bin/api/handlers/mod.rs index f591b1b..733d8c6 100644 --- a/quotesdb/src/bin/api/handlers/mod.rs +++ b/quotesdb/src/bin/api/handlers/mod.rs @@ -539,6 +539,48 @@ async fn reset_auth_code( } } +/// Request body for `POST /api/quotes/:id/report`. +/// +/// All fields are optional — a report can be submitted without a reason. +#[derive(Debug, Deserialize)] +struct ReportInput { + /// Optional human-readable reason for the report. At most 256 characters. + reason: Option, +} + +/// `POST /api/quotes/:id/report` — submit a moderation report for a quote. +/// +/// The request body is a JSON object with an optional `reason` field. The body +/// itself is also optional — omitting it entirely (or sending `{}`) is valid. +/// +/// Returns `201 Created` on success, `400 Bad Request` if the reason exceeds +/// 256 characters, `404 Not Found` if no quote with the given ID exists, or +/// `500 Internal Server Error` on a database failure. +#[cfg_attr(target_arch = "wasm32", worker::send)] +async fn report_handler( + State(repo): State, + Path(id): Path, + body: Option>, +) -> Response { + let reason = body.and_then(|Json(input)| input.reason); + + // Validate reason length — enforced here before the DB call. + if reason.as_deref().map(|r| r.len()).unwrap_or(0) > 256 { + return error_response( + StatusCode::BAD_REQUEST, + "reason must be at most 256 characters", + ); + } + + match repo.create_report(&id, reason.as_deref()).await { + Ok(()) => StatusCode::CREATED.into_response(), + Err(crate::db::DbError::NotFound) => { + error_response(StatusCode::NOT_FOUND, "quote not found") + } + Err(e) => db_error_response(e), + } +} + // ── Router ──────────────────────────────────────────────────────────────────── /// Build the Axum [`Router`] with all API routes wired to their handlers. @@ -562,9 +604,10 @@ pub fn router(repo: Arc) -> Router { .route("/api/admin/lock", post(lock_submissions)) .route("/api/admin/unlock", post(unlock_submissions)) .route("/api/admin/reset-auth-code", post(reset_auth_code)) - // IMPORTANT: /random must be registered before /{id} so the static - // segment wins over the dynamic capture. + // IMPORTANT: /random and /{id}/report must be registered before /{id} + // so static segments win over the dynamic capture. .route("/api/quotes/random", get(random_handler)) + .route("/api/quotes/{id}/report", post(report_handler)) .route("/api/quotes/{id}", get(get_quote_handler)) .route("/api/quotes", get(list_handler)) .route("/api/quotes", put(create_handler)) @@ -787,6 +830,19 @@ mod tests { async fn seed_submissions_locked(&self) -> Result<(), DbError> { Ok(()) } + + async fn create_report( + &self, + quote_id: &str, + _reason: Option<&str>, + ) -> Result<(), DbError> { + let quotes = self.quotes.lock().unwrap(); + if quotes.iter().any(|(q, _)| q.id == quote_id) { + Ok(()) + } else { + Err(DbError::NotFound) + } + } } fn sample_quote() -> Quote { @@ -1363,6 +1419,55 @@ mod tests { assert_eq!(status, StatusCode::FORBIDDEN); } + // ── POST /api/quotes/:id/report handler tests ────────────────────────────── + + /// `POST /api/quotes/:id/report` with a known quote ID returns `201 Created`. + #[tokio::test] + async fn test_report_success() { + let app = router(MockRepo::with_quote(sample_quote(), "auth")); + let body = serde_json::json!({ "reason": "inappropriate content" }); + let req = Request::builder() + .method(Method::POST) + .uri("/api/quotes/abc-123/report") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::CREATED); + } + + /// `POST /api/quotes/:id/report` with an unknown quote ID returns `404 Not Found`. + #[tokio::test] + async fn test_report_quote_not_found() { + let app = router(MockRepo::empty()); + let body = serde_json::json!({}); + let req = Request::builder() + .method(Method::POST) + .uri("/api/quotes/unknown/report") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + /// `POST /api/quotes/:id/report` with a reason longer than 256 characters + /// returns `400 Bad Request`. + #[tokio::test] + async fn test_report_reason_too_long() { + let app = router(MockRepo::with_quote(sample_quote(), "auth")); + let long_reason = "x".repeat(257); + let body = serde_json::json!({ "reason": long_reason }); + let req = Request::builder() + .method(Method::POST) + .uri("/api/quotes/abc-123/report") + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, _) = send(app, req).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + /// After a successful reset, subsequent calls with the old code return `403` /// and with the new code return `200`. #[tokio::test]