Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# CHANGELOG

## 1.6.3 - October 6, 2026

Safety release for Google Play's user-generated content policy.

### Added

- **Report a listing or a user** from the ⋮ menu on book details and in chat. Pick a reason (spam, scam, inappropriate, harassment, prohibited item, other) and add optional details. Up to 20 reports a day per user; reporting the same thing twice is ignored.
- **Block a user** from the same menus. Their books disappear from home and search, the conversation is hidden from your chats, and neither of you can message the other (enforced in the database). Blocked users are listed under **Profile → Blocked users**, where you can unblock them.
- **Admin → Reports** tab to review open reports: dismiss, or remove the reported listing. Served by new Rust endpoints `GET /admin/reports`, `POST /admin/reports/{id}/dismiss` and `POST /admin/reports/{id}/remove-listing`.

### Database

- `20261019000000_reports_and_blocks.sql`: `content_reports` and `user_blocks` tables, the daily report limit and the message block. Run it **before** deploying the Rust service.

## 1.6.2 - October 6, 2026

### Changed
Expand Down
6 changes: 5 additions & 1 deletion bookbridge-rust-core/src/routes/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ async fn delete_personal_rows(
tx: &mut Transaction<'_, Postgres>,
user_id: Uuid,
) -> Result<(), AppError> {
const STATEMENTS: [&str; 11] = [
const STATEMENTS: [&str; 14] = [
// Payer phone numbers, keyed by the user's payment references.
"DELETE FROM payment_payers WHERE payment_reference IN ( \
SELECT payment_reference FROM transactions WHERE buyer_id = $1 \
Expand All @@ -133,6 +133,10 @@ async fn delete_personal_rows(
// Star ratings stay so other sellers keep their reputation.
"UPDATE reviews SET comment = NULL WHERE reviewer_id = $1 AND comment IS NOT NULL",
"UPDATE feedback SET user_id = NULL WHERE user_id = $1",
"DELETE FROM user_blocks WHERE blocker_id = $1 OR blocked_id = $1",
// Reports stay for moderation history, without the deleted user.
"UPDATE content_reports SET reporter_id = NULL WHERE reporter_id = $1",
"UPDATE content_reports SET reported_user_id = NULL WHERE reported_user_id = $1",
"DELETE FROM profiles_private WHERE id = $1",
];
for sql in STATEMENTS {
Expand Down
1 change: 1 addition & 0 deletions bookbridge-rust-core/src/routes/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ pub fn admin_routes() -> Router<AppState> {
post(dismiss_unmatched_handler),
)
.merge(crate::routes::id_verification::id_verification_routes())
.merge(crate::routes::reports::report_routes())
}

pub async fn me_handler(_admin: AdminUser) -> Json<AdminMeResponse> {
Expand Down
1 change: 1 addition & 0 deletions bookbridge-rust-core/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ pub mod health;
pub mod id_verification;
pub mod payments;
pub mod push;
pub mod reports;
pub mod subscriptions;
pub mod webhook;
237 changes: 237 additions & 0 deletions bookbridge-rust-core/src/routes/reports.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
//! Admin review of user reports (Google Play user-generated content policy).
//!
//! Users file reports from the app straight into `content_reports` (RLS
//! insert only). Admins list open reports here and either dismiss them or
//! take the reported listing off the market. Each decision is recorded in
//! `admin_actions` and closes every open report about the same listing.

use axum::{
extract::{Path, State},
routing::{get, post},
Json, Router,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{PgConnection, Row};
use uuid::Uuid;

use crate::error::AppError;
use crate::routes::admin::validate_note;
use crate::user_auth::AdminUser;
use crate::AppState;

const LIST_LIMIT: i64 = 200;

#[derive(Deserialize)]
pub struct ReportDecisionRequest {
pub note: String,
}

#[derive(Serialize)]
pub struct ReportDecisionResponse {
pub ok: bool,
pub status: &'static str,
/// Open reports closed by this decision, including this one.
pub closed_reports: i64,
}

#[derive(Serialize)]
pub struct ReportSummary {
pub id: Uuid,
pub reason: String,
pub details: Option<String>,
pub created_at: DateTime<Utc>,
pub reporter_name: Option<String>,
pub listing_id: Option<Uuid>,
pub listing_title: Option<String>,
pub listing_status: Option<String>,
pub reported_user_id: Option<Uuid>,
pub reported_user_name: Option<String>,
}

#[derive(Serialize)]
pub struct ReportList {
pub reports: Vec<ReportSummary>,
}

pub fn report_routes() -> Router<AppState> {
Router::new()
.route("/admin/reports", get(list_handler))
.route("/admin/reports/:report_id/dismiss", post(dismiss_handler))
.route(
"/admin/reports/:report_id/remove-listing",
post(remove_listing_handler),
)
}

pub async fn list_handler(
State(state): State<AppState>,
_admin: AdminUser,
) -> Result<Json<ReportList>, AppError> {
let rows = sqlx::query(
"SELECT r.id, r.reason, r.details, r.created_at, r.listing_id, r.reported_user_id, \
rp.full_name AS reporter_name, l.title AS listing_title, \
l.status AS listing_status, \
COALESCE(ru.full_name, ls.full_name) AS reported_user_name, \
COALESCE(r.reported_user_id, l.seller_id) AS target_user_id \
FROM content_reports r \
LEFT JOIN profiles rp ON rp.id = r.reporter_id \
LEFT JOIN listings l ON l.id = r.listing_id \
LEFT JOIN profiles ru ON ru.id = r.reported_user_id \
LEFT JOIN profiles ls ON ls.id = l.seller_id \
WHERE r.status = 'open' \
ORDER BY r.created_at ASC \
LIMIT $1",
)
.bind(LIST_LIMIT)
.fetch_all(&state.pool)
.await?;

let reports = rows
.into_iter()
.map(|row| ReportSummary {
id: row.get("id"),
reason: row.get("reason"),
details: row.get("details"),
created_at: row.get("created_at"),
reporter_name: row.get("reporter_name"),
listing_id: row.get("listing_id"),
listing_title: row.get("listing_title"),
listing_status: row.get("listing_status"),
reported_user_id: row.get("target_user_id"),
reported_user_name: row.get("reported_user_name"),
})
.collect();

Ok(Json(ReportList { reports }))
}

pub async fn dismiss_handler(
State(state): State<AppState>,
AdminUser(admin_id): AdminUser,
Path(report_id): Path<Uuid>,
Json(request): Json<ReportDecisionRequest>,
) -> Result<Json<ReportDecisionResponse>, AppError> {
let note = validate_note(&request.note)?;
let mut tx = state.pool.begin().await?;
lock_open_report(&mut tx, report_id).await?;

let closed = close_reports(&mut tx, "id = $1", report_id, "dismissed", admin_id).await?;
record_report_action(&mut tx, admin_id, "dismiss_report", report_id, &note).await?;
tx.commit().await?;

tracing::info!(%admin_id, %report_id, "Report dismissed");
Ok(Json(ReportDecisionResponse {
ok: true,
status: "dismissed",
closed_reports: closed,
}))
}

/// Takes the reported listing off the market (status `removed`; a sold
/// listing stays `sold`) and closes every open report about it.
pub async fn remove_listing_handler(
State(state): State<AppState>,
AdminUser(admin_id): AdminUser,
Path(report_id): Path<Uuid>,
Json(request): Json<ReportDecisionRequest>,
) -> Result<Json<ReportDecisionResponse>, AppError> {
let note = validate_note(&request.note)?;
let mut tx = state.pool.begin().await?;
let listing_id = lock_open_report(&mut tx, report_id)
.await?
.ok_or_else(|| AppError::BadRequest("This report is not about a listing".to_string()))?;

let updated = sqlx::query(
"UPDATE listings SET \
status = CASE WHEN status = 'sold' THEN 'sold' ELSE 'removed' END, \
is_boosted = false, boost_expires_at = NULL \
WHERE id = $1",
)
.bind(listing_id)
.execute(&mut *tx)
.await?;
if updated.rows_affected() == 0 {
return Err(AppError::NotFound(format!(
"Listing {listing_id} not found"
)));
}

let closed =
close_reports(&mut tx, "listing_id = $1", listing_id, "actioned", admin_id).await?;
record_report_action(
&mut tx,
admin_id,
"remove_reported_listing",
report_id,
&note,
)
.await?;
tx.commit().await?;

tracing::info!(%admin_id, %report_id, %listing_id, "Reported listing removed");
Ok(Json(ReportDecisionResponse {
ok: true,
status: "actioned",
closed_reports: closed,
}))
}

/// Locks an open report and returns its listing id, if any.
async fn lock_open_report(
conn: &mut PgConnection,
report_id: Uuid,
) -> Result<Option<Uuid>, AppError> {
let row =
sqlx::query("SELECT status, listing_id FROM content_reports WHERE id = $1 FOR UPDATE")
.bind(report_id)
.fetch_optional(&mut *conn)
.await?
.ok_or_else(|| AppError::NotFound(format!("Report {report_id} not found")))?;
let status: String = row.get("status");
if status != "open" {
return Err(AppError::Conflict(format!(
"This report is '{status}', not open"
)));
}
Ok(row.get("listing_id"))
}

async fn close_reports(
conn: &mut PgConnection,
filter: &'static str,
id: Uuid,
status: &'static str,
admin_id: Uuid,
) -> Result<i64, AppError> {
let sql = format!(
"UPDATE content_reports SET status = $2, reviewed_by = $3, reviewed_at = now() \
WHERE status = 'open' AND {filter}"
);
let result = sqlx::query(&sql)
.bind(id)
.bind(status)
.bind(admin_id)
.execute(&mut *conn)
.await?;
Ok(result.rows_affected() as i64)
}

async fn record_report_action(
conn: &mut PgConnection,
admin_id: Uuid,
action: &'static str,
report_id: Uuid,
note: &str,
) -> Result<(), AppError> {
sqlx::query(
"INSERT INTO admin_actions (admin_id, action, report_id, note) VALUES ($1, $2, $3, $4)",
)
.bind(admin_id)
.bind(action)
.bind(report_id)
.bind(note)
.execute(&mut *conn)
.await?;
Ok(())
}
44 changes: 44 additions & 0 deletions bookbridge-rust-core/tests/account_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,28 @@ async fn world() -> Option<World> {
.await
.unwrap();

// Blocks both ways (after the messages, which a block would refuse) and
// reports in both directions.
exec(
&pool,
"INSERT INTO user_blocks (blocker_id, blocked_id) VALUES ($1, $2), ($2, $1)",
&[user, other],
)
.await;
exec(
&pool,
"INSERT INTO content_reports (reporter_id, listing_id, reason) VALUES ($1, $2, 'spam')",
&[user, other_listing],
)
.await;
exec(
&pool,
"INSERT INTO content_reports (reporter_id, reported_user_id, reason) \
VALUES ($1, $2, 'harassment')",
&[other, user],
)
.await;

let storage = Storage::default();
{
let mut s = storage.lock().unwrap();
Expand Down Expand Up @@ -534,6 +556,8 @@ async fn deletion_anonymises_the_account_and_keeps_order_history() {
"SELECT count(*) FROM wishlists WHERE user_id = $1",
"SELECT count(*) FROM notifications WHERE user_id = $1",
"SELECT count(*) FROM feedback WHERE user_id = $1",
"SELECT count(*) FROM user_blocks WHERE blocker_id = $1 OR blocked_id = $1",
"SELECT count(*) FROM content_reports WHERE reporter_id = $1 OR reported_user_id = $1",
"SELECT count(*) FROM payment_payers pp JOIN transactions t \
ON t.payment_reference = pp.payment_reference WHERE t.buyer_id = $1",
] {
Expand Down Expand Up @@ -600,6 +624,26 @@ async fn deletion_anonymises_the_account_and_keeps_order_history() {
.await
.unwrap();
assert_eq!(review, (5, None));
// Reports kept for moderation history, without the deleted user.
assert_eq!(
count(
&w.pool,
"SELECT count(*) FROM content_reports WHERE listing_id = $1 AND reporter_id IS NULL",
w.other_listing
)
.await,
1
);
assert_eq!(
count(
&w.pool,
"SELECT count(*) FROM content_reports WHERE reporter_id = $1 \
AND reported_user_id IS NULL",
w.other
)
.await,
1
);

// Login scrubbed and banned.
let u = sqlx::query(
Expand Down
Loading
Loading