feat(quotesdb): add reports table and POST /api/quotes/:id/report endpoint

- 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 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent 14cc879743
commit eecdbba9d7

@ -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)
# ---------------------------------------------------------------------------

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

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

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

@ -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(&quote.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() {

@ -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<String>,
}
/// `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<Repo>,
Path(id): Path<String>,
body: Option<Json<ReportInput>>,
) -> 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<dyn QuoteRepository + Send + Sync>) -> 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]

Loading…
Cancel
Save